π«π· Version franΓ§aise
CLI TypeScript Commands
User-facing reference for the
Orkeon.Cli.Commands.Scriptingsubsystem. For the architecture spec and design rationale, see the maintainers' design archive (featurecli-ts-commands, SPEC).
What it is
A way to add interactive REPL commands to an Orkeon CLI runner by dropping
*.cmd.ts files into a folder β no .NET recompile, no host restart beyond
the runner itself. Each script declares one or more commands via the global
defineCommand({...}) helper; the loader picks them up at startup, validates
them, and exposes them alongside the built-in help/exit/clear.
Commands can also dispatch work to live agents β synchronously (block and return the reply) or asynchronously (fire a ticket, react when the agent responds). See Dispatching commands to agents.
Quick start
mkdir -p ./commands
cat > ./commands/hello.cmd.ts <<'EOF'
defineCommand({
name: "hello",
description: "Say hello to the world (or to someone in particular).",
args: {
who: { type: "string", default: "world" },
},
async handler(args, ctx) {
ctx.log.info(`Hello, ${args.who}!`);
return ctx.continue();
},
});
EOF
dotnet run --project src/apps/Orkeon.ConsoleApp -- \
--runner=scripted-commands \
--commands-dir ./commands
At the prompt:
scripted> help # 'hello' shows up
scripted> hello # β Hello, world!
scripted> hello --who=Ada # β Hello, Ada!
scripted> help-cmd hello # signature with typed args
scripted> exit
CLI flags
| Flag | Effect |
|---|---|
--runner=scripted-commands |
Boot directly into the scripted-commands REPL instead of the main menu. |
--commands-dir <path> |
Add a directory to scan (repeatable; mounted as /cli-commands[-N] in the VFS). |
--no-script-commands |
Disable discovery entirely. Registry is empty. |
--strict-commands |
Equivalent to FailFastOnInvalidScript=true + ContinueOnConflict=false (CI). |
--settings <path> |
Explicit appsettings path (repeatable β later files override earlier ones). |
--mount <phys:virt:rights> |
Add a VFS mount (repeatable) β e.g. to make a commands directory reachable. |
--crews-dir <path> |
Add a crew-resolution directory (repeatable) β what makes script-host / runCrewAsync("name") resolvable. |
appsettings.json
Same options under Orkeon:Cli:ScriptCommands:
{
"Orkeon": {
"Cli": {
"ScriptCommands": {
"Enabled": true,
"Directories": ["/cli-commands"],
"FailFastOnInvalidScript": false,
"EsbuildTranspile": true,
"MaxScripts": 50,
"ContinueOnConflict": true,
"FallbackCommandName": "assistant",
"Limits": {
"MemoryLimitBytes": 67108864,
"RecursionLimit": 100,
"ExecutionTimeout": "00:05:00"
}
}
}
}
}
Directories are virtual paths resolved through IFileSystemService β set up
a Orkeon:FileSystem:Mounts entry if the directory isn't already mounted.
FallbackCommandName (default assistant) routes any REPL line not prefixed
with / to that scripted command β the switch that turns the REPL into a
conversational agent.
defineCommand reference
defineCommand({
name: "deploy", // ^[a-z][a-z0-9-]*$, must not collide with help/exit/clear
aliases: ["d", "ship"], // optional, must not duplicate `name`
description: "Deploy a crew.", // single line, β€ 200 chars
args: {
target: { type: "string", required: true, choices: ["dev", "prod"] },
crew: { type: "string", required: true },
dryRun: { type: "boolean", default: false },
},
async handler(args, ctx) {
ctx.log.info(`Deploying ${args.crew} to ${args.target}`);
return ctx.continue();
},
});
When args is omitted, the handler receives { raw: string[] } instead β the
signature (args, ctx) => ... stays stable across typed/untyped commands.
Supported arg types
type |
Notes |
|---|---|
"string" |
Optional choices: readonly string[], optional default. |
"number" |
Optional min, max, default. |
"boolean" |
Bare --flag β true. Optional default. |
"string[]" |
Greedy: positional consumes the tail, flag form consumes until next --key. |
Validation errors surface as Error: ... printed by the runner; the handler is
not invoked.
ctx reference (the runtime context)
interface CommandRuntimeContext {
readonly command: { readonly name: string; readonly rawInput: string };
readonly log: {
debug(msg: string, data?: object): void;
info(msg: string, data?: object): void;
warn(msg: string, data?: object): void;
error(msg: string, data?: object): void;
};
write(text: string): void;
writeLine(text: string): void;
clear(): void;
prompt(spec: PromptSpec): Promise<string | boolean>;
progress(spec: { total?: number; label: string }): ProgressHandle;
table<T extends object>(rows: readonly T[], columns?: readonly (keyof T)[]): void;
readonly signal: CommandSignal; // CancellationToken β see "Cancellation"
readonly services: ServiceLocator; // whitelisted
continue(message?: string): CommandActionResult;
exit(farewell?: string): CommandActionResult;
}
The full .d.ts is shipped as an embedded resource inside
Orkeon.Cli.Commands.Scripting.dll (Phase 5 will publish it to disk automatically; for
now copy src/cli/Orkeon.Cli.Commands.Scripting/Typings/orkeon-cli.d.ts next to your
scripts for IDE autocompletion).
Prompts
const target = await ctx.prompt({ type: "select", message: "Target?", choices: ["dev","prod"] });
const ok = await ctx.prompt({ type: "confirm", message: "Proceed?", default: false });
const name = await ctx.prompt({ type: "text", message: "Name?" });
const pwd = await ctx.prompt({ type: "password", message: "Password:" });
Cancellation
ctx.signal is the raw .NET CancellationToken exposed by Jint. Properties
are PascalCase because of reflection:
while (!ctx.signal.IsCancellationRequested) {
// long-running work
}
ctx.signal.ThrowIfCancellationRequested();
The TypeScript alias CommandSignal declared in .d.ts is a documentation
convenience; the runtime members are PascalCase.
Services
const fs = ctx.services.get<IFileSystemService>("fs");
const cfg = ctx.services.get<IConfiguration>("configuration");
const tools = ctx.services.get<IBaseTool[]>("tools");
The host whitelist drives what's reachable. Default keys: fs, configuration,
tools, plus optional llm, logger, commands (the dispatch faΓ§ade β see
below) and script-host (crew launching from a command β see
scripting). Hosts add their own by passing a
Action<ScriptServiceWhitelist> to AddScriptCommands.
Dispatching commands to agents
A command can address a live agent by name and let its response decide when
the command is finished. This is the commands faΓ§ade, reached via
ctx.services.get("commands"). Under the hood it rides the existing
IAgentChannel: the faΓ§ade resolves the agent name β AgentId, correlates the
request/response host-side, and tracks every dispatch in a queryable registry.
Key rule: the command finishes when the agent responds, not when the handler returns. Routing is point-to-point β one command targets exactly one agent, which is its sole finisher (no fan-out, no join).
The agent side β onCommand
An agent declares that it answers dispatched commands with onCommand (in the
crew DSL β see scripting.md). The value it returns is the
response that terminates the command:
const echo = agentBuilder()
.name("echo").role("Echo").goal("Echo a payload back")
.onCommand("run", (env) => env.payload.toUpperCase()) // answers intent "run"
.onCommand((env) => ({ success: true, payload: "ack" })) // catch-all (any intent)
.build();
The handler receives an envelope { intent, payload, from, correlationId }
and returns either a payload string or { success?, payload?, error? }. The
agent must be activated on the dispatch bus (AgentCommandRegistrar) so a
.cmd.ts command can reach it by name. Handler JS runs under the agent's engine
lock, so it is safe even when a background async dispatch invokes it.
Synchronous dispatch β request
A normal defineCommand that awaits the agent. request blocks until the agent
responds and returns the CommandResponse (so await on it resolves to the
value β a sync command is meant to block):
defineCommand({
name: "ask",
description: "Ask the 'echo' agent and wait.",
args: { text: { type: "string", required: true } },
handler(args, ctx) {
const res = ctx.services.get("commands").request("echo", "run", args.text);
return res.success ? ctx.continue("β " + res.payload)
: ctx.continue("agent error: " + res.error);
},
});
request(agent, intent, payload) returns { agent, intent, success, payload, error? }.
Asynchronous dispatch β defineAsyncCommand
Use defineAsyncCommand when the work should detach: dispatch fires and returns
the prompt immediately; the optional completed is replayed later when the agent
responds.
defineAsyncCommand({
name: "ask-bg",
description: "Ask the 'echo' agent in the background.",
maxConcurrent: 3, // admission quota (see below)
args: { text: { type: "string", required: true } },
dispatch(args, ctx) { // does NOT block
const ticket = ctx.services.get("commands").post("echo", "run", args.text);
ctx.log.info("launched (ticket " + ticket + ")");
return { ticket };
},
completed(result, ctx) { // replayed at the next pump
ctx.writeLine("β " + result.agent + ": " + result.payload);
},
});
post(agent, intent, payload)returns a ticket (string) right away and runs the request on a background task.completed(result, ctx)is not called from the background thread (Jint is single-threaded). It is replayed on the engine thread at the next "pump" β typically the next command invocation on the same script. A terse host line is also printed immediately at completion so you see something without waiting.- For a value you want to read on demand, poll with
result --ticket=β¦(below).
Admission quota β maxConcurrent
Declared on defineAsyncCommand, it bounds the number of in-flight instances of
that command. Omitted β unbounded (β). Acquisition is non-blocking: when the
quota is full a new invocation is rejected immediately (the dispatch never
runs) with a Rejected: quota of N instance(s) of '<cmd>' reached. message. The
slot is released when the agent responds. maxConcurrent only has a real effect
for async commands β a sync command already holds the engine and is serialised.
Introspecting in-flight commands
The dispatch registry is exposed two ways. From a script:
const facade = ctx.services.get("commands");
facade.list({ state: "running" }); // CommandInstanceView[]
facade.get("t3"); // one view, or undefined
facade.cancel("t3"); // request cancellation; returns boolean
And as built-in commands at the REPL (fast, stay responsive while async work runs in the background):
| Command | Effect |
|---|---|
ps [--state=β¦] |
List instances. State: running (default), done, failed, cancelled, rejected, all. |
inspect --ticket=<t> |
Full detail of one instance (state, agent, elapsed, result, progress). |
result --ticket=<t> |
Print the result payload (poll). Reports "still running" if not done. |
cancel --ticket=<t> |
Request cancellation of an in-flight ticket. |
A CommandInstanceView carries: ticket, name, kind (sync/async),
targetAgent, intent, correlationId, state, startedAt, completedAt?,
elapsedMs, result?, error?, progress?. Terminal entries are retained for a
while (bounded) so result/inspect can read a recent ticket, then evicted.
Wiring (host side)
AddScriptCommands registers the dispatch substrate as a singleton (its own
in-memory channel β the CLI dispatch bus β plus the name directory and instance
registry) and adds commands to the default whitelist. Agents are connected with
AgentCommandRegistrar.Register(agent, engine, engineLock, service.Channel, service.Directory),
which registers the channel handler and the nameβid mapping.
End-to-end example
examples/cli-ts-commands/ ships dispatch.cmd.ts (the ask / ask-bg
commands) and echo-agent.ork.ts (the onCommand agent).
scripted> ask hello # β HELLO (sync, blocks)
scripted> ask-bg hello # launched (ticket t1) (async, returns now)
scripted> ps # t1 askbg echo running β¦
scripted> result --ticket=t1 # [t1] HELLO
TypeScript support matrix (esbuild β Jint)
| Feature | Support |
|---|---|
interface, type, enum (non-const) |
β |
import/export (relative paths) |
β |
async/await, Promise, Promise.all |
β |
| Destructuring, spread, defaults, rest | β |
| Classes, getters/setters, inheritance | β |
| Template literals | β |
Map/Set/WeakMap/WeakSet |
β |
JSON.parse/JSON.stringify |
β |
| Regex (no lookbehind) | β |
npm modules (fs, path, node:*) |
β Use ctx.services.get("fs"). |
fetch, setTimeout, setInterval |
β/β οΈ |
Decorators (@experimental) |
β esbuild stops at stage-3. |
tsconfig paths/aliases |
β οΈ relative imports only. |
Constraints (worth knowing)
- Discovery at startup only. Edit a script, restart the runner. No hot reload (deliberate β spec Β§14).
- One engine per script, kept warm. Invocations of the same command share Jint state; invocations across commands are isolated.
- Jint is not thread-safe. The runner serialises invocations; a
SemaphoreSlimguards each engine defensively. - Sandbox limits (CLI profile): 64 MB memory, 100 max recursion, 5 min
execution. Override via
Orkeon:Cli:ScriptCommands:Limits. - Conflict policy: first script wins by ordinal path order; the second is
logged at Warning. Toggle
ContinueOnConflict=falseto fail fast. - VFS only. Scripts read disk through
ctx.services.get("fs")β no directSystem.IO.
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
hello doesn't appear in help |
Script outside the configured Directories, or evaluation error logged Error |
Check logs from Orkeon.Cli.Commands.Scripting.ScriptCommandLoader. |
esbuild not found on startup |
Tool not installed | npm i -g esbuild, or copy into tools/scripting-esbuild/. |
defineCommand is not defined |
Script evaluated before bindings (bug) | File an issue with the script path. |
| Prompt doesn't render in the REPL pane | Adapter not Terminal.Gui or prefix heuristic missed | Ensure the script writes prompts with a > suffix. |
Error: --target value 'staging' is not in choices [dev, prod] |
Typo or stale choices |
Use help-cmd <name> to see the live signature. |