@qodalis/mcp-cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +384 -2
  4. package/dist/src/cli.d.ts +3 -0
  5. package/dist/src/cli.d.ts.map +1 -0
  6. package/dist/src/cli.js +102 -0
  7. package/dist/src/cli.js.map +1 -0
  8. package/dist/src/config.d.ts +60 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/config.js +149 -0
  11. package/dist/src/config.js.map +1 -0
  12. package/dist/src/dev/api.d.ts +94 -0
  13. package/dist/src/dev/api.d.ts.map +1 -0
  14. package/dist/src/dev/api.js +425 -0
  15. package/dist/src/dev/api.js.map +1 -0
  16. package/dist/src/dev/docs.d.ts +93 -0
  17. package/dist/src/dev/docs.d.ts.map +1 -0
  18. package/dist/src/dev/docs.js +275 -0
  19. package/dist/src/dev/docs.js.map +1 -0
  20. package/dist/src/dev/prompts.d.ts +11 -0
  21. package/dist/src/dev/prompts.d.ts.map +1 -0
  22. package/dist/src/dev/prompts.js +141 -0
  23. package/dist/src/dev/prompts.js.map +1 -0
  24. package/dist/src/dev/scaffold.d.ts +55 -0
  25. package/dist/src/dev/scaffold.d.ts.map +1 -0
  26. package/dist/src/dev/scaffold.js +213 -0
  27. package/dist/src/dev/scaffold.js.map +1 -0
  28. package/dist/src/dev/tools.d.ts +170 -0
  29. package/dist/src/dev/tools.d.ts.map +1 -0
  30. package/dist/src/dev/tools.js +258 -0
  31. package/dist/src/dev/tools.js.map +1 -0
  32. package/dist/src/env-file.d.ts +9 -0
  33. package/dist/src/env-file.d.ts.map +1 -0
  34. package/dist/src/env-file.js +54 -0
  35. package/dist/src/env-file.js.map +1 -0
  36. package/dist/src/errors.d.ts +22 -0
  37. package/dist/src/errors.d.ts.map +1 -0
  38. package/dist/src/errors.js +115 -0
  39. package/dist/src/errors.js.map +1 -0
  40. package/dist/src/filters.d.ts +13 -0
  41. package/dist/src/filters.d.ts.map +1 -0
  42. package/dist/src/filters.js +16 -0
  43. package/dist/src/filters.js.map +1 -0
  44. package/dist/src/index.d.ts +12 -0
  45. package/dist/src/index.d.ts.map +1 -0
  46. package/dist/src/index.js +12 -0
  47. package/dist/src/index.js.map +1 -0
  48. package/dist/src/remote.d.ts +64 -0
  49. package/dist/src/remote.d.ts.map +1 -0
  50. package/dist/src/remote.js +242 -0
  51. package/dist/src/remote.js.map +1 -0
  52. package/dist/src/server.d.ts +22 -0
  53. package/dist/src/server.d.ts.map +1 -0
  54. package/dist/src/server.js +156 -0
  55. package/dist/src/server.js.map +1 -0
  56. package/docs/llms-full.txt +1740 -0
  57. package/package.json +70 -5
@@ -0,0 +1,1740 @@
1
+ # Qodalis CLI plugin development
2
+
3
+ > Reference for writing Qodalis CLI plugins: browser (web) plugins that run in the terminal UI, and server plugins for the Node.js, .NET and Python CLI servers. Every topic is self-contained. Version 2.0 (web packages `2.0.0-rc.1`). Source: https://github.com/qodalis-solutions/web-cli. Docs: https://cli.qodalis.com.
4
+
5
+ ## Overview
6
+
7
+ ### Web plugins vs server plugins
8
+
9
+ A Qodalis CLI command is either a web plugin running in the browser or a server plugin running in a CLI server; the user types both in the same terminal.
10
+
11
+ - **Web plugin**: a TypeScript package exporting an `ICliModule` (from `@qodalis/cli-core`) whose `processors` implement `ICliCommandProcessor`. It runs in the page, writes to the xterm.js terminal through `context.writer`, can prompt (`context.reader`), keep state, call HTTP APIs, go full screen, run background services. Use it for anything that can run in the browser: tools, formatters, games, UIs over a REST API, interactive prompts.
12
+ - **Server plugin**: a processor/module for a Qodalis CLI server (`@qodalis/cli-server-abstractions` for Node, `Qodalis.Cli.Abstractions` for .NET, `qodalis-cli-server-abstractions` for Python). It runs on the server, receives the parsed command over HTTP and returns text or structured output (`text`, `table`, `list`, `json`, `key-value`). Use it when the command needs server resources: the filesystem, databases, secrets, OS processes, private networks. Server commands are also exposed as MCP tools when the server enables MCP.
13
+ - The browser terminal discovers server commands at startup (`GET /api/v1/qcli/commands`) and registers a proxy for each one as `<server>:<command>` (e.g. `node:weather`) plus the bare name (`weather`) when no local command owns it.
14
+ - Rule of thumb: no secrets and no server-only resources -> web plugin; otherwise server plugin. Both can coexist (a web command can call a server through `context.http`).
15
+
16
+ ### Packages
17
+
18
+ The npm, NuGet and PyPI packages a plugin author uses, by role.
19
+
20
+ | Package | Role |
21
+ |---|---|
22
+ | `@qodalis/cli-core` | Web plugin API: `ICliModule`, `ICliCommandProcessor`, `ICliExecutionContext`, `CliProcessCommand`, `API_VERSION`, `DefaultLibraryAuthor`, `bootCliModule`, colors, tokens. The only runtime dependency of a web plugin. |
23
+ | `@qodalis/cli` | Framework-agnostic engine: `CliEngine`, `CliTestHarness` (for tests). |
24
+ | `@qodalis/angular-cli` | Angular wrapper: `CliModule`, `<cli>`, `<cli-panel>`, `resolveCliModuleProvider`. |
25
+ | `@qodalis/react-cli` | React wrapper: `Cli`, `CliPanel`, `CliProvider`, `CliConfigProvider`. |
26
+ | `@qodalis/vue-cli` | Vue 3 wrapper: `Cli`, `CliPanel`, `CliConfigProvider`. |
27
+ | `@qodalis/create-cli-plugin` | Scaffolder (`npx @qodalis/create-cli-plugin`). |
28
+ | `@qodalis/cli-<name>` | Published web plugins (`@qodalis/cli-guid`, `@qodalis/cli-todo`, ...). Each exports `<name>Module`. |
29
+ | `@qodalis/cli-server-abstractions` (npm) | Node server plugin API. Server: `@qodalis/cli-server-node`. |
30
+ | `Qodalis.Cli.Abstractions` (NuGet) | .NET server plugin API. Server: `Qodalis.Cli`. |
31
+ | `qodalis-cli-server-abstractions` (PyPI, import `qodalis_cli_server_abstractions`) | Python server plugin API. Server: `qodalis-cli-server` (import `qodalis_cli`). |
32
+ | `@qodalis/mcp-cli` | MCP server for AI assistants: docs search, API lookup, plugin scaffolding, and (with `QCLI_URL`) driving a CLI server. |
33
+
34
+ ## Web plugins
35
+
36
+ ### Scaffolding a web plugin with create-cli-plugin
37
+
38
+ `@qodalis/create-cli-plugin` generates a ready-to-build web plugin, either standalone (its own npm project) or inside the web-cli monorepo.
39
+
40
+ ```bash
41
+ # Standalone, interactive
42
+ npx @qodalis/create-cli-plugin
43
+
44
+ # Standalone, non-interactive (all flags optional except --name)
45
+ npx @qodalis/create-cli-plugin --name weather --description "Weather forecasts" --processor-name Weather
46
+
47
+ # Inside the web-cli monorepo (builds the scaffolder first)
48
+ pnpm run create-plugin -- --name weather --description "Weather forecasts" --processor-name Weather
49
+ ```
50
+
51
+ - Flags: `-n, --name <name>` (lowercase; becomes the command and `@qodalis/cli-<name>`), `-d, --description <description>` (default `CLI extension for <name>`), `-p, --processor-name <name>` (PascalCase; default: PascalCase of the name; the class is `Cli<ProcessorName>CommandProcessor`). Without `--name` it prompts.
52
+ - Monorepo mode is detected by walking up to a directory with a `pnpm-workspace.yaml` mentioning `packages/plugins`; the plugin is created in `packages/plugins/<name>/` with `project.json`, `tsconfig.spec.json`, a `tsconfig.base.json` path alias `@qodalis/cli-<name>` -> `./dist/<name>` and an `angular.json` Karma test entry. Build with `pnpm nx build <name>`, test with `pnpm nx test <name>`.
53
+ - Standalone mode creates `./qodalis-cli-<name>/`, runs `pnpm install` (or `npm install`), and also writes `.gitignore`, `pnpm-workspace.yaml` and `AGENTS.md`. Build with `npx tsup` (or `npm run build`), publish with `npm publish --access public`.
54
+
55
+ Generated layout (both modes):
56
+
57
+ ```text
58
+ src/
59
+ public-api.ts # exports the processor, version and `<name>Module: ICliModule`
60
+ cli-entrypoint.ts # UMD entry: bootCliModule(module) for `pkg add`
61
+ lib/
62
+ index.ts # barrel
63
+ version.ts # LIBRARY_VERSION, API_VERSION = 2
64
+ processors/cli-<name>-command-processor.ts
65
+ tests/index.spec.ts # Jasmine-style describe/it/expect
66
+ tsup.config.ts # CJS + ESM + d.ts, plus IIFE bundle in dist/umd
67
+ package.json # name @qodalis/cli-<name>, dependency @qodalis/cli-core
68
+ ```
69
+
70
+ ### ICliModule
71
+
72
+ `ICliModule` (from `@qodalis/cli-core`) is the unit a web plugin exports: it bundles command processors, services, guards, permissions and translations, plus lifecycle hooks.
73
+
74
+ ```typescript
75
+ import { API_VERSION, ICliModule } from '@qodalis/cli-core';
76
+
77
+ export const weatherModule: ICliModule = {
78
+ apiVersion: API_VERSION, // required; modules below the runtime's API_VERSION (2) are rejected at boot
79
+ name: '@qodalis/cli-weather', // required; unique id
80
+ version: '1.0.0',
81
+ description: 'Weather forecasts',
82
+ dependencies: [], // module names booted before this one
83
+ priority: 0, // lower boots first (default 0)
84
+ processors: [new CliWeatherCommandProcessor()],
85
+ services: [], // CliProvider[] registered in the shared container
86
+ guards: [], // ICliCommandGuard[] run before every command
87
+ permissions: [], // CliPermissionDefinition[] ({ name: 'weather:read', description? })
88
+ translations: { es: { 'cli.weather.description': 'Previsión del tiempo' } },
89
+ async onInit(context) {}, // after services are registered, before processors initialize
90
+ async onSetup(context) { return true; }, // first-run setup; true marks it done (persisted)
91
+ async onAfterBoot(context) {}, // all modules booted, terminal interactive
92
+ async onDestroy(context) {}, // teardown
93
+ };
94
+ ```
95
+
96
+ - `config?: Record<string, any>` and `configure?(config): ICliModule` support configurable modules; implement `ICliConfigurableModule<TConfig>` and return a copy: `configure(config) { return { ...this, config }; }`. Hosts then pass `weatherModule.configure({ units: 'metric' })`.
97
+ - Only `apiVersion` and `name` are required. Use the `API_VERSION` constant (core exports `API_VERSION = 2`; scaffolded plugins keep their own copy in `src/lib/version.ts`).
98
+ - Removed in 2.0.0-rc.1: `ICliUmdModule`, `bootUmdModule`, `registerProcessor(s)`, `resolveCommandProcessorProvider`, `CliCommandProcessor_TOKEN` and the `processors` input/prop. Always wrap processors in a module.
99
+
100
+ ### ICliCommandProcessor
101
+
102
+ `ICliCommandProcessor` is the interface every web command implements; only `command` and `processCommand` are required.
103
+
104
+ ```typescript
105
+ import {
106
+ CliProcessCommand,
107
+ ICliCommandProcessor,
108
+ ICliExecutionContext,
109
+ } from '@qodalis/cli-core';
110
+
111
+ export class CliHelloCommandProcessor implements ICliCommandProcessor {
112
+ command = 'hello';
113
+ aliases = ['hi'];
114
+ description = 'Greets someone';
115
+ author = { name: 'Jane Doe', email: 'jane@example.com' };
116
+ version = '1.0.0';
117
+ acceptsRawInput = true; // text after the command -> command.value / command.positionals
118
+ metadata = { icon: '👋', module: '@acme/cli-hello' };
119
+ parameters = [
120
+ { name: 'shout', aliases: ['s'], description: 'Upper-case the greeting', required: false, type: 'boolean' },
121
+ ];
122
+
123
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
124
+ const name = command.positionals?.[0] ?? 'world';
125
+ const text = `Hello, ${name}!`;
126
+ context.writer.writeln(command.args['shout'] ? text.toUpperCase() : text);
127
+ }
128
+ }
129
+ ```
130
+
131
+ All members:
132
+
133
+ | Member | Type | Purpose |
134
+ |---|---|---|
135
+ | `command` | `string` | Command word (matched case-insensitively). |
136
+ | `aliases?` | `string[]` | Alternative names. |
137
+ | `description?` | `string` | Shown by `help`; translated via key `cli.<command>.description`. |
138
+ | `author?` | `ICliCommandAuthor` | `{ name, email }`. `DefaultLibraryAuthor` is the Qodalis author; use your own for third-party plugins. |
139
+ | `version?` | `string` | Default `'1.0.0'`. |
140
+ | `acceptsRawInput?` | `boolean` | Capture text after the command as `command.value` instead of treating it as sub-commands. |
141
+ | `valueRequired?` | `boolean` | Like `acceptsRawInput`, and the engine errors (`Value required`) unless a positional value, piped data or named args are given; required-parameter checks are skipped when positional input exists. |
142
+ | `metadata?` | `CliProcessorMetadata` | `sealed`, `hidden`, `requireServer`, `module`, `icon`, `requiredCoreVersion`, `requiredCliVersion`, `requireAuth`, `requiredGroups`, `requiredPermissions`, `literalInput` (see the metadata topic). |
143
+ | `processors?` | `ICliCommandChildProcessor[]` | Sub-commands (`parent` is set by the engine). |
144
+ | `parameters?` | `ICliCommandParameterDescriptor[]` | Flags (`--name value`, `--name=value`, `-n`). |
145
+ | `hooks?` | `ICliProcessorHook[]` | `{ when: 'before' \| 'after', execute(context): Promise<void> }`. |
146
+ | `stateConfiguration?` | `CliStateConfiguration` | Per-processor persisted state (root processors only). |
147
+ | `configurationOptions?` | `ICliConfigurationOption[]` | Settings shown by the `configure` command. |
148
+ | `extendsProcessor?` | `boolean` | Wrap an existing processor with the same `command` instead of replacing it. |
149
+ | `originalProcessor?` | `ICliCommandProcessor` | Set by the registry when `extendsProcessor` is true; delegate with `this.originalProcessor.processCommand(command, context)`. |
150
+ | `processCommand(command, context)` | `Promise<void>` | Runs the command. |
151
+ | `writeDescription?(context)` | `void` | Custom long help text. |
152
+ | `validateBeforeExecution?(command, context)` | `{ valid, message? }` | Reject a command before it runs; `message` is printed as an error. |
153
+ | `initialize?(context)` | `Promise<void>` | Called once at boot (with the processor's own state store). |
154
+ | `onData?(data, context)` | `Promise<void>` | Raw terminal input while this processor is the context processor (full-screen apps). |
155
+ | `onResize?(cols, rows, context)` | `void` | Terminal resized while in full-screen mode. |
156
+ | `onDispose?(context)` | `void` | Full-screen mode ended, terminal disconnected or component destroyed. |
157
+
158
+ Execution order for one command: parse -> find processor -> global parameter handlers (`--help`, ...) -> extract `value`/`positionals` -> command guards -> `valueRequired` check -> required-parameter and `validator` checks -> `validateBeforeExecution` -> `before` hooks -> `processCommand` -> `after` hooks.
159
+
160
+ ### CliProcessCommand and argument parsing
161
+
162
+ `CliProcessCommand` is the parsed command passed to `processCommand`: words, flags, positional values and piped data.
163
+
164
+ ```typescript
165
+ type CliProcessCommand = {
166
+ command: string; // command text without flags, e.g. 'todo add'
167
+ chainCommands: string[]; // command words, e.g. ['todo', 'add']
168
+ rawCommand: string; // the line as typed
169
+ value?: string; // text after the command/sub-command words (flags removed, quotes kept); only with acceptsRawInput/valueRequired
170
+ positionals?: string[]; // shell-split arguments: quotes removed, escapes applied, flags excluded
171
+ args: Record<string, any>; // flags, keyed by parameter name AND each alias
172
+ data?: any; // piped input from the previous command (`a | b`)
173
+ };
174
+ ```
175
+
176
+ - Flag forms: `--name=value` (always works), `--name value` / `-n 5` (space form: only when the parameter is declared, its `type` is not `'boolean'`, **and** the processor accepts positional input via `acceptsRawInput` or `valueRequired`; otherwise the value word is taken as a sub-command and the command is not found), `-n5`, `-abc` (= `-a -b -c` when each letter is a declared boolean). A flag without value is `true`.
177
+ - Values that look numeric become numbers, `'true'`/`'false'` become booleans. For declared parameters: `type: 'boolean'` maps `true/'true'/'1'/'yes'/'y'/1` to `true`, anything else to `false`; `type: 'array'` collects repeated flags (`--tag a --tag b` -> `['a', 'b']`). Other types keep the parsed value; convert yourself (`Number(command.args['count'])`).
178
+ - Undeclared flags still land in `args` under their typed name.
179
+ - Read a flag that has aliases with `getParameterValue(descriptor, command.args)` (exported from `@qodalis/cli-core`), or just `command.args['name']` (aliases are mirrored for declared parameters).
180
+ - `defaultValue` is shown by `help` only; it is not injected into `args`. Apply it yourself: `const units = command.args['units'] ?? 'metric';`.
181
+ - Prefer `command.positionals` to `command.value` (quotes handled, `"my file.txt"` is one argument).
182
+
183
+ ### Parameters
184
+
185
+ `ICliCommandParameterDescriptor` declares a flag: name, aliases, type, required, default (help only) and an optional validator.
186
+
187
+ ```typescript
188
+ import { ICliCommandParameterDescriptor } from '@qodalis/cli-core';
189
+
190
+ const parameters: ICliCommandParameterDescriptor[] = [
191
+ {
192
+ name: 'count',
193
+ aliases: ['c'],
194
+ description: 'How many items to generate',
195
+ required: false,
196
+ type: 'number', // 'string' | 'number' | 'boolean' | 'array' | 'object' | string
197
+ defaultValue: 1, // shown in help; not applied automatically
198
+ validator: (value) =>
199
+ Number(value) > 0 && Number(value) <= 100
200
+ ? { valid: true }
201
+ : { valid: false, message: 'count must be between 1 and 100' },
202
+ },
203
+ { name: 'json', description: 'Print JSON', required: false, type: 'boolean' },
204
+ ];
205
+ ```
206
+
207
+ - Set `acceptsRawInput = true` on a processor whose parameters take values if users should be able to type `--count 5`; without it only `--count=5` works.
208
+ - `required: true` parameters are checked only in named-argument mode (no positional value and no piped data); the error is `Missing required parameters: --name`.
209
+ - `validator` runs when the flag is present; failures print `Invalid parameters:` with each message and the command does not run.
210
+ - Every command also gets the global `--help` handling; a processor declaring a parameter with the same name or alias takes it over.
211
+
212
+ ### Sub-commands
213
+
214
+ Sub-commands are nested processors in `processors`; the engine walks the command words down the tree and runs the deepest match.
215
+
216
+ ```typescript
217
+ import {
218
+ CliProcessCommand,
219
+ ICliCommandProcessor,
220
+ ICliExecutionContext,
221
+ } from '@qodalis/cli-core';
222
+
223
+ class NotesAddProcessor implements ICliCommandProcessor {
224
+ command = 'add';
225
+ description = 'Add a note';
226
+ valueRequired = true; // `notes add buy milk` -> value 'buy milk'
227
+
228
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext) {
229
+ const text = command.positionals!.join(' '); // quotes removed; command.value keeps them
230
+ const notes: string[] = context.state.getState()['notes'] ?? [];
231
+ context.state.updateState({ notes: [...notes, text] });
232
+ await context.state.persist();
233
+ context.writer.writeSuccess('Note added');
234
+ }
235
+ }
236
+
237
+ class NotesListProcessor implements ICliCommandProcessor {
238
+ command = 'list';
239
+ aliases = ['ls'];
240
+ description = 'List notes';
241
+
242
+ async processCommand(_: CliProcessCommand, context: ICliExecutionContext) {
243
+ const notes: string[] = context.state.getState()['notes'] ?? [];
244
+ context.writer.writeList(notes, { ordered: true });
245
+ }
246
+ }
247
+
248
+ export class CliNotesCommandProcessor implements ICliCommandProcessor {
249
+ command = 'notes';
250
+ description = 'Keep short notes';
251
+ processors = [new NotesAddProcessor(), new NotesListProcessor()];
252
+ stateConfiguration = { initialState: { notes: [] } }; // state is owned by the root processor
253
+
254
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext) {
255
+ await context.executor.showHelp(command, context); // `notes` alone prints help
256
+ }
257
+ }
258
+ ```
259
+
260
+ - Resolution: match the first word against root `command`/`aliases`, then each following word against the current processor's `processors`. If a word matches no child, the command is not found, unless the current processor has no children and `acceptsRawInput`/`valueRequired` is set (then the rest is its value).
261
+ - Children share the root processor's state store (`context.state`).
262
+ - Translation keys for sub-commands include the path: `cli.notes.add.description`.
263
+
264
+ ### Processor metadata, access control and permissions
265
+
266
+ `metadata` (`CliProcessorMetadata`) flags how the engine treats a processor: sealing, visibility, server requirement, version requirements and access control.
267
+
268
+ ```typescript
269
+ metadata = {
270
+ icon: '🌦', // string or CliIcon, shown in help
271
+ module: '@qodalis/cli-weather', // owning module name
272
+ hidden: false, // true: not listed by `help`, still runnable
273
+ sealed: false, // true: cannot be replaced/removed (can still be extended)
274
+ requireServer: false, // the command needs a connected server
275
+ requiredCoreVersion: '2.0.0', // skip the processor at boot if @qodalis/cli-core is older
276
+ requiredCliVersion: '2.0.0', // same for @qodalis/cli
277
+ literalInput: false, // true: pass the whole line verbatim (no pipes, ;, &&, $VAR expansion)
278
+ requireAuth: true, // a signed-in user is required
279
+ requiredGroups: ['admin'], // user must be in one of these groups (implies requireAuth)
280
+ requiredPermissions: ['weather:write'], // user must hold all (implies requireAuth)
281
+ };
282
+ ```
283
+
284
+ - `requireAuth`, `requiredGroups` and `requiredPermissions` are enforced by core guards only when an auth module registers an `ICliAuthorizationService` (e.g. `@qodalis/cli-users`); without one they are informational.
285
+ - Declare the permission names your module uses in `ICliModule.permissions` (`{ name: 'weather:write', description: 'Change weather settings' }`) so admins can list and grant them.
286
+ - Inside a command, check ad hoc with the authorization service: `context.services.get(ICliAuthorizationService_TOKEN)?.can('weather:write')`.
287
+
288
+ ### Command guards
289
+
290
+ An `ICliCommandGuard` runs before every command (across all modules) and can block it; contribute guards through `ICliModule.guards`.
291
+
292
+ ```typescript
293
+ import { ICliCommandGuard, ICliModule, API_VERSION } from '@qodalis/cli-core';
294
+
295
+ const businessHoursGuard: ICliCommandGuard = {
296
+ name: 'business-hours',
297
+ priority: 10, // lower runs first
298
+ appliesTo: (processor) => processor.command === 'deploy',
299
+ canActivate: (command, processor, context) => {
300
+ const hour = new Date().getHours();
301
+ return hour >= 9 && hour < 17 ? true : 'deploy is only allowed 09:00-17:00';
302
+ },
303
+ };
304
+
305
+ export const deployModule: ICliModule = {
306
+ apiVersion: API_VERSION,
307
+ name: '@acme/cli-deploy',
308
+ processors: [/* ... */],
309
+ guards: [businessHoursGuard],
310
+ };
311
+ ```
312
+
313
+ - `canActivate` returns `CliCommandGuardResult`: `true`/`undefined` allow; `false` blocks with a default message; a `string` blocks with that message; or `{ allowed: boolean, message?: string }`. May be async.
314
+ - A guard that throws blocks the command (fail closed). Blocked commands exit with code -1 and the message printed as an error.
315
+
316
+ ### ICliExecutionContext
317
+
318
+ `ICliExecutionContext` is the second argument of `processCommand`: the command's access to the terminal, input, state, services, HTTP and process control.
319
+
320
+ | Member | Use |
321
+ |---|---|
322
+ | `writer: ICliTerminalWriter` | Output (`writeln`, `writeSuccess`, `writeError`, tables, JSON, ...). |
323
+ | `reader: ICliInputReader` | Interactive prompts (`readLine`, `readConfirm`, `readSelect`, ...). |
324
+ | `progressBar: ICliPercentageProgressBar` | `show(text?)`, `update(percent, { type?: 'replace' \| 'increment' })`, `setText`, `complete()`, `hide()`. |
325
+ | `spinner?: ICliSpinner` | `show(text?)`, `setText(text)`, `hide()`. |
326
+ | `textAnimator?: ICliTextAnimator` | `showText(text, { speed?, removeAfterTyping? })` typewriter effect. |
327
+ | `notifier: ICliNotifier` | Status line in the panel header: `info`, `success`, `warn`, `error`, `clear`; cleared when the command ends. |
328
+ | `state: ICliStateStore` | The processor's state store (see state topic). |
329
+ | `clipboard: ICliClipboard` | `write(text)`, `read()`. |
330
+ | `http: ICliHttpClient` | HTTP client wired to the command's abort signal. |
331
+ | `services: ICliServiceProvider` | DI container (`get`, `getRequired`, `getAll`, `has`, `set`). |
332
+ | `translator: ICliTranslationService` | `t(key, defaultValue, params?)`, `getLocale()`. |
333
+ | `executor: ICliCommandExecutorService` | `showHelp(command, context)`, `executeCommand(line, context)`. |
334
+ | `process: ICliExecutionProcess` | `exit(code?, { silent? })` ends the command (it throws to unwind unless `silent`), `output(data)` sets pipe data. |
335
+ | `signal?: AbortSignal` / `onAbort: Subject<void>` | Ctrl+C / kill; check for cooperative cancellation. |
336
+ | `backgroundServices: ICliBackgroundServiceRegistry` | Register/start daemons and jobs. |
337
+ | `events?: ICliEventBus` | Terminal event bus. |
338
+ | `logger: ICliLogger` | `log`, `info`, `warn`, `error`, `debug`. |
339
+ | `options?: CliOptions` | Host options (servers, logLevel, ...). |
340
+ | `userSession?: ICliUserSession` | Current user session, when an auth module is present. |
341
+ | `terminal: Terminal` | The raw xterm.js terminal (`cols`, `rows`, `write`). |
342
+ | `enterFullScreenMode(processor, { showCursor? })` / `exitFullScreenMode()` | Alternate screen; input routed to `processor.onData`. |
343
+ | `createInterval(cb, ms)` / `createTimeout(cb, ms)` | Managed timers, cleared on full-screen exit and destroy. |
344
+ | `setContextProcessor(processor, silent?, fullScreen?)` | Route subsequent input to a processor. |
345
+ | `submitCommand(line)` | Run a line as if typed (history + prompt). |
346
+ | `promptPathProvider?` | Set to `() => string \| null` to change the path shown in the prompt. |
347
+ | `showPrompt`, `currentLine`, `setCurrentLine`, `cursorPosition`, `clearLine`, `clearCurrentLine`, `refreshCurrentLine`, `promptLength` | Low-level line editing. |
348
+ | `isBooted?` | `true` once boot finished. |
349
+
350
+ ```typescript
351
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
352
+ context.spinner?.show('Loading');
353
+ try {
354
+ const items = await context.http.get<{ name: string }[]>('https://api.example.com/items');
355
+ context.spinner?.hide();
356
+ context.writer.writeObjectsAsTable(items);
357
+ context.process.output(items); // available to the next command in a pipe
358
+ } catch (e) {
359
+ context.spinner?.hide();
360
+ context.writer.writeError(`Request failed: ${e}`);
361
+ context.process.exit(1);
362
+ }
363
+ }
364
+ ```
365
+
366
+ ### Writer API
367
+
368
+ `context.writer` (`ICliTerminalWriter`) writes text, colors, tables, lists, JSON, boxes and links to the terminal.
369
+
370
+ ```typescript
371
+ import { CliForegroundColor } from '@qodalis/cli-core';
372
+
373
+ const w = context.writer;
374
+ w.write('no newline');
375
+ w.writeln('line'); // writeln() alone prints an empty line
376
+ w.writeSuccess('Done');
377
+ w.writeInfo('FYI');
378
+ w.writeWarning('Careful');
379
+ w.writeError('Failed'); // stderr in tests
380
+ w.writeln(w.wrapInColor('cyan text', CliForegroundColor.Cyan));
381
+ w.wrapInBackgroundColor('text', CliBackgroundColor.Blue);
382
+ w.writeJson({ a: 1 }); // pretty, colorized
383
+ w.writeTable(['Name', 'Size'], [['a.txt', '1 KB']]);
384
+ w.writeObjectsAsTable([{ name: 'a', size: 1 }]);
385
+ w.writeList(['one', 'two'], { ordered: true, prefix: '-', color: CliForegroundColor.Green });
386
+ w.writeKeyValue({ Status: 'ok', Version: '1.0' }, { separator: ':', keyColor: CliForegroundColor.Yellow });
387
+ w.writeColumns(['a', 'b', 'c', 'd'], { columns: 2, padding: 2 });
388
+ w.writeDivider({ char: '-', length: 40, color: CliForegroundColor.White });
389
+ w.writeBox(['Line 1', 'Line 2'], { title: 'Notice', borderColor: CliForegroundColor.Cyan, padding: 1 });
390
+ w.writeLink('Docs', 'https://cli.qodalis.com');
391
+ w.writeIndented('indented', 2); // 2 spaces per level
392
+ w.writeToFile('report.txt', 'content'); // browser download
393
+ ```
394
+
395
+ - Colors: `CliForegroundColor` / `CliBackgroundColor` enums (`Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, `White`, `Reset`).
396
+ - Use `\r\n` or `writeln` for new lines; xterm needs carriage returns with raw `write`.
397
+
398
+ ### Input reader
399
+
400
+ `context.reader` (`ICliInputReader`) shows interactive prompts; every method resolves `null` when the user aborts (Ctrl+C or Escape).
401
+
402
+ ```typescript
403
+ const name = await context.reader.readLine('Name: ', {
404
+ default: 'guest',
405
+ placeholder: 'your name',
406
+ validate: (v) => (v.trim() ? null : 'Name is required'), // string = error, null = ok
407
+ });
408
+ if (name === null) return; // aborted
409
+
410
+ const password = await context.reader.readPassword('Password: ');
411
+ const ok = await context.reader.readConfirm('Continue?', true); // boolean | null
412
+ const env = await context.reader.readSelect('Environment:', [
413
+ { label: 'Production', value: 'prod', description: 'live', group: 'Remote' },
414
+ { label: 'Staging', value: 'stage', group: 'Remote' },
415
+ { label: 'Local', value: 'local', disabled: true },
416
+ ], { default: 'stage', searchable: true, onChange: (v) => {} });
417
+ const size = await context.reader.readSelectInline('Size:', [
418
+ { label: 'S', value: 's' }, { label: 'M', value: 'm' },
419
+ ]);
420
+ const tags = await context.reader.readMultiSelect('Tags:', [
421
+ { label: 'bug', value: 'bug', checked: true }, { label: 'feature', value: 'feature' },
422
+ ], { searchable: true }); // string[] | null
423
+ const count = await context.reader.readNumber('Count:', { min: 1, max: 10, default: 3 });
424
+ const date = await context.reader.readDate('Due date:', { format: 'YYYY-MM-DD', min: '2026-01-01' });
425
+ const files = await context.reader.readFile('Pick a file', { multiple: false }); // CliFileResult[] | null
426
+ ```
427
+
428
+ ### State store and persistence
429
+
430
+ Each root processor gets its own state store (`context.state`) configured by `stateConfiguration`; sub-commands share their root's store.
431
+
432
+ ```typescript
433
+ export class CliCounterCommandProcessor implements ICliCommandProcessor {
434
+ command = 'counter';
435
+ stateConfiguration = {
436
+ initialState: { count: 0 },
437
+ storeName: 'counter', // default: the command name
438
+ autoPersist: true, // save after every change (else call persist())
439
+ scope: 'global' as const, // 'global' (browser, all tabs) | 'user' (per signed-in user) | 'session' (this terminal, memory)
440
+ version: 1,
441
+ migrate: (state, fromVersion) => state,
442
+ syncAcrossTabs: true,
443
+ };
444
+
445
+ async processCommand(_: CliProcessCommand, context: ICliExecutionContext) {
446
+ const { count } = context.state.getState<{ count: number }>();
447
+ context.state.updateState({ count: count + 1 });
448
+ // or: await context.state.update((s) => ({ count: s['count'] + 1 }));
449
+ context.writer.writeln(`count = ${count + 1}`);
450
+ }
451
+ }
452
+ ```
453
+
454
+ - `ICliStateStore`: `getState<T>()`, `updateState(partial)`, `update(fn)`, `select(selector)` (Observable), `subscribe(cb)`, `reset()`, `persist()`, `initialize()`, `reload?()`.
455
+ - `configurationOptions` values (set with `configure`) are read with `getPluginConfigValue(context, '<command>', '<key>', defaultValue)` from `@qodalis/cli-core`.
456
+
457
+ ### Configuration options
458
+
459
+ `configurationOptions` exposes processor settings in the built-in `configure` command (interactive menu and `configure get/set`).
460
+
461
+ ```typescript
462
+ import { getPluginConfigValue, ICliConfigurationOption } from '@qodalis/cli-core';
463
+
464
+ configurationOptions: ICliConfigurationOption[] = [
465
+ { key: 'units', label: 'Units', description: 'Temperature units', type: 'select', defaultValue: 'metric',
466
+ options: [{ label: 'Metric', value: 'metric' }, { label: 'Imperial', value: 'imperial' }] },
467
+ { key: 'maxDays', label: 'Max days', description: 'Forecast length', type: 'number', defaultValue: 3,
468
+ validator: (v) => (v >= 1 && v <= 7 ? { valid: true } : { valid: false, message: '1-7' }) },
469
+ ];
470
+
471
+ // in processCommand
472
+ const units = getPluginConfigValue(context, 'weather', 'units', 'metric');
473
+ ```
474
+
475
+ - Option `type`: `'string' | 'number' | 'boolean' | 'select'`; `category` overrides the grouping (default: the processor's command name, which is also the category passed to `getPluginConfigValue`).
476
+
477
+ ### Services and dependency injection
478
+
479
+ Modules register services with `services: CliProvider[]`; commands resolve them from `context.services` by token.
480
+
481
+ ```typescript
482
+ import { API_VERSION, createCliToken, ICliModule } from '@qodalis/cli-core';
483
+
484
+ export interface IWeatherApi { current(city: string): Promise<string>; }
485
+ export const IWeatherApi_TOKEN = createCliToken<IWeatherApi>('weather-api'); // typed string token
486
+
487
+ class WeatherApi implements IWeatherApi {
488
+ constructor(private readonly baseUrl: string) {}
489
+ async current(city: string) { return `${city}: sunny`; }
490
+ }
491
+
492
+ export const weatherModule: ICliModule = {
493
+ apiVersion: API_VERSION,
494
+ name: '@acme/cli-weather',
495
+ services: [
496
+ { provide: 'weather-base-url', useValue: 'https://api.example.com' },
497
+ { provide: IWeatherApi_TOKEN, useClass: WeatherApi, deps: ['weather-base-url'] },
498
+ // { provide: TOKEN, useFactory: (url: string) => new WeatherApi(url), deps: ['weather-base-url'] },
499
+ // { provide: TOKEN, useValue: x, multi: true } // multi-provider: read with getAll
500
+ ],
501
+ processors: [/* ... */],
502
+ };
503
+
504
+ // in processCommand
505
+ const api = context.services.getRequired(IWeatherApi_TOKEN); // throws if missing; get() returns undefined
506
+ ```
507
+
508
+ - `CliProvider` = `{ provide, sealed?, transient?, multi? }` plus one of `useValue`, `useClass` (+ `deps`), `useFactory` (+ `deps`). `useClass`/`useFactory` are created lazily on first `get`, so `deps` may be registered later.
509
+ - `sealed: true` prevents overriding the token; `transient: true` creates a new instance per `get`.
510
+ - Well-known tokens are exported from `@qodalis/cli-core` (e.g. `ICliAuthorizationService_TOKEN`, `ICliCommandGuard_TOKEN`, `ICliServerAuthTokenProvider_TOKEN`, `ICliTranslationService_TOKEN`).
511
+
512
+ ### Translations (i18n)
513
+
514
+ Plugins ship translations in `ICliModule.translations` and translate at runtime with `context.translator.t(key, englishDefault, params?)`.
515
+
516
+ ```typescript
517
+ export const weatherModule: ICliModule = {
518
+ apiVersion: API_VERSION,
519
+ name: '@acme/cli-weather',
520
+ processors: [new CliWeatherCommandProcessor()],
521
+ translations: {
522
+ es: { 'cli.weather.description': 'Mostrar el tiempo', 'cli.weather.now': 'Ahora en {city}' },
523
+ fr: { 'cli.weather.description': 'Afficher la météo', 'cli.weather.now': 'Maintenant à {city}' },
524
+ },
525
+ };
526
+
527
+ // in processCommand: English default inline, {param} interpolation
528
+ context.writer.writeln(context.translator.t('cli.weather.now', 'Now in {city}', { city }));
529
+ ```
530
+
531
+ - `help` translates descriptions automatically with key `cli.<command path>.description` (e.g. `cli.weather.forecast.description`).
532
+ - Locale codes used by the official language packs: `de`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `ro`, `ru`, `zh`. Missing keys fall back to the default string.
533
+
534
+ ### HTTP client
535
+
536
+ `context.http` (`ICliHttpClient`) is a fetch wrapper that parses JSON, throws `CliHttpError` on non-2xx and aborts on Ctrl+C.
537
+
538
+ ```typescript
539
+ import { CliHttpError } from '@qodalis/cli-core';
540
+
541
+ try {
542
+ const user = await context.http.get<{ name: string }>('https://api.example.com/me', { timeout: 5000 });
543
+ await context.http.post('https://api.example.com/items', { name: 'x' }, { headers: { 'X-Key': 'k' } });
544
+ } catch (e) {
545
+ if (e instanceof CliHttpError) context.writer.writeError(`${e.status} ${e.statusText}`);
546
+ else throw e;
547
+ }
548
+ ```
549
+
550
+ - Methods: `fetch(url, init?)` (raw `Response`), `get`, `post(url, body?)`, `put`, `patch`, `delete`, `head` (returns `Response`), `options`. Options extend `RequestInit` (minus `method`/`body`) with `timeout` (ms).
551
+
552
+ ### Background services and jobs
553
+
554
+ `context.backgroundServices` runs daemons (long-lived) and jobs (one-shot) outside the command, on the main thread or in a Web Worker.
555
+
556
+ ```typescript
557
+ export const heartbeatModule: ICliModule = {
558
+ apiVersion: API_VERSION,
559
+ name: '@acme/cli-heartbeat',
560
+ async onAfterBoot(context) {
561
+ context.backgroundServices.register({
562
+ name: 'heartbeat',
563
+ description: 'Logs every 10s',
564
+ type: 'daemon', // 'daemon' | 'job'
565
+ async onStart(ctx) {
566
+ ctx.createInterval(() => ctx.log('beat'), 10000);
567
+ ctx.emit({ source: 'heartbeat', type: 'started' });
568
+ },
569
+ async onStop(ctx) { ctx.log('stopped'); },
570
+ onError(error, ctx) { ctx.log(error.message, 'error'); },
571
+ });
572
+ await context.backgroundServices.start('heartbeat');
573
+ },
574
+ };
575
+
576
+ // one-shot job from a command
577
+ await context.backgroundServices.startJob('export', async (ctx) => {
578
+ ctx.log('exporting...');
579
+ if (ctx.signal.aborted) return;
580
+ });
581
+ ```
582
+
583
+ - `ICliServiceContext` (`ctx`): `signal`, `emit(event)`, `log(message, level?)` (`'info' | 'warn' | 'error'`), `createInterval`, `createTimeout`, `state`, `services`.
584
+ - Registry: `register`, `start(name)`, `stop(name)`, `restart(name)`, `startJob(name, task, { workerCompatible? })`, `getStatus(name)`, `list()`, `getLogs(name, limit?)`, `on(handler, { source?, type? })` (returns unsubscribe), `destroyAll()`.
585
+ - Worker mode: set `workerCompatible: true` and `workerFactory: () => new Worker(...)`; `onStart` is the main-thread fallback. Users manage services with the built-in `services` command.
586
+
587
+ ### Full-screen mode and raw input
588
+
589
+ A processor can take over the terminal (games, editors, pagers) with `enterFullScreenMode`, receiving every keystroke in `onData`.
590
+
591
+ ```typescript
592
+ export class CliClockCommandProcessor implements ICliCommandProcessor {
593
+ command = 'clock';
594
+ description = 'Full-screen clock (q to quit)';
595
+ private context?: ICliExecutionContext;
596
+
597
+ async processCommand(_: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
598
+ this.context = context;
599
+ context.enterFullScreenMode(this); // alternate screen, cursor hidden
600
+ this.render();
601
+ context.createInterval(() => this.render(), 1000); // cleared automatically on exit
602
+ }
603
+
604
+ async onData(data: string, context: ICliExecutionContext): Promise<void> {
605
+ if (data === 'q' || data === '\x1b') context.exitFullScreenMode(); // also restores the prompt
606
+ }
607
+
608
+ onResize(cols: number, rows: number): void { this.render(); }
609
+
610
+ onDispose(): void { this.context = undefined; }
611
+
612
+ private render(): void {
613
+ const t = this.context?.terminal;
614
+ if (!t) return;
615
+ t.write('\x1b[H\x1b[2J'); // home + clear
616
+ t.write(new Date().toLocaleTimeString());
617
+ }
618
+ }
619
+ ```
620
+
621
+ - `onData` receives raw sequences: arrows `\x1b[A`/`\x1b[B`/`\x1b[C`/`\x1b[D`, Enter `\r`, Backspace `\x7f`, Escape `\x1b`, Ctrl+C `\x03` (passed through in full-screen mode).
622
+ - Pass `{ showCursor: true }` for REPL-style UIs.
623
+
624
+ ### Extending an existing command
625
+
626
+ Set `extendsProcessor = true` to wrap a processor registered with the same `command` and delegate to `originalProcessor`.
627
+
628
+ ```typescript
629
+ export class EchoTimestampProcessor implements ICliCommandProcessor {
630
+ command = 'echo';
631
+ extendsProcessor = true;
632
+ acceptsRawInput = true;
633
+ originalProcessor?: ICliCommandProcessor; // set by the registry
634
+
635
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext) {
636
+ context.writer.write(`[${new Date().toISOString()}] `);
637
+ await this.originalProcessor?.processCommand(command, context);
638
+ }
639
+ }
640
+ ```
641
+
642
+ - Without `extendsProcessor`, registering a processor with an existing command replaces it (unless the existing one has `metadata.sealed`).
643
+
644
+ ### Registering a module in a host app
645
+
646
+ Hosts pass plugin modules to the terminal: Angular DI or `[modules]`, React/Vue `modules` prop, or `CliEngine.registerModule` in plain TypeScript.
647
+
648
+ ```typescript
649
+ // Angular (NgModule)
650
+ import { CliModule, resolveCliModuleProvider } from '@qodalis/angular-cli';
651
+ import { guidModule } from '@qodalis/cli-guid';
652
+
653
+ @NgModule({
654
+ imports: [BrowserModule, CliModule],
655
+ providers: [resolveCliModuleProvider(guidModule), resolveCliModuleProvider(weatherModule)],
656
+ })
657
+ export class AppModule {}
658
+ // template: <cli></cli> or <cli-panel></cli-panel>; or pass [modules]="modules" on either
659
+ ```
660
+
661
+ ```tsx
662
+ // React
663
+ import { Cli, CliConfigProvider } from '@qodalis/react-cli';
664
+
665
+ <Cli modules={[guidModule, weatherModule]} />
666
+ // or share config: <CliConfigProvider modules={modules} options={options}><Cli /></CliConfigProvider>
667
+ ```
668
+
669
+ ```html
670
+ <!-- Vue 3 -->
671
+ <script setup lang="ts">
672
+ import { Cli } from '@qodalis/vue-cli';
673
+ const modules = [guidModule, weatherModule];
674
+ </script>
675
+ <template>
676
+ <Cli :modules="modules" />
677
+ </template>
678
+ ```
679
+
680
+ ```typescript
681
+ // Vanilla TypeScript
682
+ import '@xterm/xterm/css/xterm.css';
683
+ import { CliEngine } from '@qodalis/cli';
684
+ import { CliLogLevel } from '@qodalis/cli-core';
685
+
686
+ const engine = new CliEngine(document.getElementById('terminal')!, { logLevel: CliLogLevel.INFO });
687
+ engine.registerModule(weatherModule); // or registerModules([...]); before start()
688
+ await engine.start();
689
+ ```
690
+
691
+ - `<CliPanel>` (React/Vue) and `<cli-panel>` (Angular) accept the same `modules`.
692
+ - Configured modules: pass `weatherModule.configure({ ... })` instead of `weatherModule`.
693
+
694
+ ### Runtime installation with pkg add (UMD)
695
+
696
+ Published plugins can be installed in a running terminal with `pkg add @qodalis/cli-<name>`, which loads the plugin's IIFE bundle and boots its module.
697
+
698
+ ```bash
699
+ pkg add @qodalis/cli-weather # alias: packages add
700
+ pkg ls
701
+ pkg remove @qodalis/cli-weather
702
+ ```
703
+
704
+ ```typescript
705
+ // src/cli-entrypoint.ts (bundled to dist/umd/index.global.js by tsup, format 'iife')
706
+ import { bootCliModule, ICliModule } from '@qodalis/cli-core';
707
+ import { CliWeatherCommandProcessor } from './lib/processors/cli-weather-command-processor';
708
+ import { API_VERSION } from './lib/version';
709
+
710
+ const module: ICliModule = {
711
+ apiVersion: API_VERSION,
712
+ name: '@qodalis/cli-weather',
713
+ processors: [new CliWeatherCommandProcessor()],
714
+ };
715
+
716
+ bootCliModule(module);
717
+ ```
718
+
719
+ - `package.json` fields `umd` and `unpkg` point at the IIFE bundle; packages are fetched from unpkg/jsdelivr by default (`CliOptions.packageSources` adds sources).
720
+ - `bootCliModule(module)` registers the module with the running terminal's module registry; it does nothing outside a browser terminal.
721
+ - UMD bundles must not use Angular decorators; use plain classes.
722
+
723
+ ### Testing with CliTestHarness
724
+
725
+ `CliTestHarness` (from `@qodalis/cli`) runs commands through the real parser and executor without xterm or DOM, capturing output.
726
+
727
+ ```typescript
728
+ import { CliTestHarness } from '@qodalis/cli';
729
+ import { CliNotesCommandProcessor } from '../lib/processors/cli-notes-command-processor';
730
+ import { CliGreetCommandProcessor } from '../lib/processors/cli-greet-command-processor';
731
+
732
+ describe('notes', () => {
733
+ let harness: CliTestHarness;
734
+
735
+ beforeEach(() => {
736
+ harness = new CliTestHarness();
737
+ harness.registerProcessor(new CliNotesCommandProcessor());
738
+ });
739
+
740
+ it('adds and lists notes', async () => {
741
+ await harness.execute('notes add "buy milk"');
742
+ const result = await harness.execute('notes list');
743
+ expect(result.lists[0]).toEqual(['buy milk']);
744
+ expect(result.exitCode).toBe(0);
745
+ });
746
+
747
+ it('answers prompts', async () => {
748
+ harness.registerProcessor(new CliGreetCommandProcessor()); // calls readLine then readConfirm
749
+ harness.setReaderResponses('Ada', true); // consumed in order by readLine, readConfirm, ...
750
+ const result = await harness.execute('greet');
751
+ expect(result.output).toContain('Ada');
752
+ });
753
+ });
754
+ ```
755
+
756
+ - Result (`CliTestResult`): `stdout: string[]`, `stderr: string[]` (writeError/writeWarning), `output` (stdout joined by `\n`), `exitCode`, `data` (from `process.output`), `tables: { headers, rows }[]`, `lists: string[][]`.
757
+ - Harness API: `registerProcessor(p)`, `registerProcessors([...])`, `registerService(token, value)`, `initializeProcessors()`, `setReaderResponses(...values)`, `setUserSession(session)`, `setUserSessionProvider(fn)`, `execute(line)`, plus `registry`, `executor`, `services`, `translator`.
758
+ - Pipes work: `harness.execute('echo hello | grep hello')`. State is in memory per harness.
759
+ - In the monorepo, tests are Jasmine on Karma (`pnpm nx test <name>`, ChromeHeadless). Test files live in `src/tests/*.spec.ts`.
760
+
761
+ ### Building and publishing a web plugin
762
+
763
+ Web plugins build with tsup into CJS, ESM, type declarations and an IIFE bundle for runtime loading, then publish to npm.
764
+
765
+ ```bash
766
+ # standalone plugin
767
+ npx tsup # or npm run build -> dist/public-api.{js,mjs,d.ts}, dist/umd/index.global.js
768
+ npm publish --access public # files: ["dist"]
769
+
770
+ # monorepo plugin
771
+ pnpm nx build <name> # -> dist/<name>/
772
+ pnpm nx test <name>
773
+ pnpm nx lint <name>
774
+ ```
775
+
776
+ - Mark `@qodalis/cli-core` as external in the library build (the scaffolded `tsup.config.ts` does); the IIFE build bundles everything (`noExternal: [/.*/]`).
777
+ - `@qodalis/cli-core` is a dependency; standalone plugins pin `^<scaffolder version>` (all `@qodalis/*` web packages share one version).
778
+
779
+ ### Conventions for web plugins
780
+
781
+ Naming, structure and API-version rules that official and third-party web plugins follow.
782
+
783
+ - Package `@qodalis/cli-<name>` for official plugins (any scope for third-party); module export `<camelName>Module`; processor class `Cli<Name>CommandProcessor` in `src/lib/processors/cli-<name>-command-processor.ts`; module name = package name.
784
+ - Always set `apiVersion: API_VERSION` (2). Plugins depend only on `@qodalis/cli-core` (never on Angular/React/Vue).
785
+ - Translation keys: `cli.<command path>.<key>`; always pass the English default to `t()`.
786
+ - Use `context.writer` for output (never `console.log`), `context.http` for requests (abort on Ctrl+C), `context.reader` for prompts, `createInterval`/`createTimeout` for timers in full-screen apps.
787
+ - Check `null` from every reader method (user aborted). Exit with `context.process.exit(1)` on failure so pipes and `&&` stop (it throws to end the command; code after it does not run).
788
+ - TypeScript style in the monorepo: 4-space indent, single quotes, semicolons.
789
+
790
+ ## Server plugins
791
+
792
+ ### How server commands reach the browser terminal
793
+
794
+ A server plugin registers command processors in a Qodalis CLI server (Node, .NET or Python); the browser terminal discovers them and proxies execution over HTTP.
795
+
796
+ - The host app lists servers in `CliOptions.servers`: `{ name: 'node', url: 'http://localhost:8047' }` (optional `headers`, `timeout`, `enabled`).
797
+ - At startup the terminal calls `GET /api/qcli/versions` and `GET /api/v1/qcli/commands`, then registers each root command as `<server>:<command>` (e.g. `node:weather`) and as the bare name (`weather`) unless a local web command already owns it. If several servers have the command, the bare name uses the default server (`server default <name>`) or asks.
798
+ - Running a command sends `POST /api/v1/qcli/execute` with `{ command, chainCommands, rawCommand, value, positionals, args, data }`; the server resolves the processor and returns `{ exitCode, outputs: [...] }`. `POST /api/v1/qcli/execute/stream` returns Server-Sent Events (`output`, `done`, `error`).
799
+ - Output types rendered by the browser: `text` (optional `style`: `success | error | info | warning`), `table` (`headers`, `rows`), `list` (`items`, `ordered?`), `json` (`value`), `key-value` (`entries: { key, value }[]`).
800
+ - In the browser, a server command that has sub-commands accepts only its sub-command words; a leaf command accepts positional text (`weather current Paris`).
801
+ - Exceptions thrown by a processor become exit code 1 with `Error executing command: <message>` (style `error`). Unknown commands return `Unknown command: <name>`.
802
+ - Servers: Node `@qodalis/cli-server-node` (default port 8047 in the demos), .NET `Qodalis.Cli` (8046), Python `qodalis-cli-server` (8048).
803
+
804
+ ### Node: minimal server processor
805
+
806
+ A Node server command extends `CliCommandProcessor` from `@qodalis/cli-server-abstractions` and returns its output text from `handleAsync`.
807
+
808
+ ```typescript
809
+ import {
810
+ CliCommandProcessor,
811
+ CliProcessCommand,
812
+ getPositionalText,
813
+ } from '@qodalis/cli-server-abstractions';
814
+
815
+ export class CliGreetCommandProcessor extends CliCommandProcessor {
816
+ command = 'greet';
817
+ description = 'Greets someone';
818
+ acceptsValue = true; // optional positional value (valueRequired = true makes it mandatory)
819
+
820
+ async handleAsync(command: CliProcessCommand, signal?: AbortSignal): Promise<string> {
821
+ const name = getPositionalText(command) || 'world';
822
+ return `Hello, ${name}!`;
823
+ }
824
+ }
825
+ ```
826
+
827
+ ```typescript
828
+ import { createCliServer } from '@qodalis/cli-server-node';
829
+
830
+ const { app } = createCliServer({
831
+ configure: (builder) => builder.addProcessor(new CliGreetCommandProcessor()),
832
+ });
833
+ app.listen(8047);
834
+ ```
835
+
836
+ - `ICliCommandProcessor` members: `command`, `description`, `author` (default `DefaultLibraryAuthor`), `version` (default `'1.0.0'`), `allowUnlistedCommands?`, `valueRequired?`, `acceptsValue?`, `processors?`, `parameters?`, `requiredPermissions?`, `handleAsync(command, signal?) => Promise<string>`, `handleStructuredAsync?(command, signal?) => Promise<CliStructuredResponse>` (takes precedence when present).
837
+ - `CliProcessCommand`: `command`, `chainCommands: string[]`, `rawCommand`, `value?` (as typed), `positionals?: string[]` (shell-split; sent by current browsers), `args: Record<string, any>`, `data?`. Helpers: `getPositionals(command)` (falls back to splitting `value`), `getPositionalText(command)`, `splitShellWords`, `joinShellWords`, `quoteShellWord`.
838
+ - `signal` is aborted when the HTTP client disconnects.
839
+ - Packages: `npm install @qodalis/cli-server-abstractions` for plugins; `@qodalis/cli-server-node` for the host.
840
+
841
+ ### Node: parameters, sub-commands and modules
842
+
843
+ Node server commands declare flags with `CliCommandParameterDescriptor`, nest sub-commands in `processors`, and are grouped into a `CliModule` registered with `builder.addModule`.
844
+
845
+ ```typescript
846
+ import {
847
+ CliCommandParameterDescriptor,
848
+ CliCommandProcessor,
849
+ CliModule,
850
+ CliProcessCommand,
851
+ getPositionalText,
852
+ ICliCommandProcessor,
853
+ } from '@qodalis/cli-server-abstractions';
854
+
855
+ // constructor order: (name, description, required = false, type = 'string', aliases?, defaultValue?)
856
+ const locationParam = new CliCommandParameterDescriptor(
857
+ 'location', 'City name', false, 'string', ['-l'], 'London',
858
+ );
859
+
860
+ function location(command: CliProcessCommand): string {
861
+ return command.args?.location ? String(command.args.location) : getPositionalText(command) || 'London';
862
+ }
863
+
864
+ class WeatherCurrentProcessor extends CliCommandProcessor {
865
+ command = 'current';
866
+ description = 'Current conditions';
867
+ parameters = [locationParam];
868
+ acceptsValue = true;
869
+ async handleAsync(command: CliProcessCommand): Promise<string> {
870
+ return `Sunny in ${location(command)}`;
871
+ }
872
+ }
873
+
874
+ class CliWeatherCommandProcessor extends CliCommandProcessor {
875
+ command = 'weather';
876
+ description = 'Weather information';
877
+ parameters = [locationParam];
878
+ processors: ICliCommandProcessor[] = [new WeatherCurrentProcessor()];
879
+ async handleAsync(command: CliProcessCommand): Promise<string> {
880
+ return `Sunny in ${location(command)}`;
881
+ }
882
+ }
883
+
884
+ export class WeatherModule extends CliModule {
885
+ name = 'weather';
886
+ version = '1.0.0';
887
+ description = 'Weather commands';
888
+ processors: ICliCommandProcessor[] = [new CliWeatherCommandProcessor()];
889
+ }
890
+
891
+ // host: createCliServer({ configure: (builder) => builder.addModule(new WeatherModule()) });
892
+ ```
893
+
894
+ - Dispatch: root by `command` (case-insensitive), then each `chainCommands` word against `processors`. If a word matches no child, the current processor handles it only when `allowUnlistedCommands` is true; otherwise `Unknown command`. Only the resolved processor runs.
895
+ - Registering a second processor with the same command replaces the first.
896
+ - `CliBuilder` (all chainable): `addProcessor(p)`, `addModule(m)`, `addMcpToolProvider(provider)`, `setFileStorageProvider(...)`, `addDataExplorerProvider(...)`, `addFileSystem(options)`.
897
+ - `createCliServer(options)` options: `basePath` (default `'/api/qcli'`), `cors`, `allowedOrigins`, `enableShell`, `shell`, `trustProxy`, `auth`, `mcp` (`boolean | CliMcpOptions`), `configure(builder)`. Returns `{ app, registry, builder, executor, eventSocketManager, logSocketManager, mountPlugin, setAuth, mcp }`.
898
+
899
+ ### Node: structured and streaming output
900
+
901
+ Node processors return tables, lists, JSON and key-value output from `handleStructuredAsync`, or stream outputs over SSE with `handleStreamAsync`.
902
+
903
+ ```typescript
904
+ import {
905
+ CliCommandProcessor,
906
+ CliProcessCommand,
907
+ CliStructuredOutput,
908
+ CliStructuredResponse,
909
+ ICliStreamCommandProcessor,
910
+ } from '@qodalis/cli-server-abstractions';
911
+
912
+ export class CliUsersCommandProcessor extends CliCommandProcessor implements ICliStreamCommandProcessor {
913
+ command = 'users';
914
+ description = 'Lists users';
915
+
916
+ async handleAsync(): Promise<string> {
917
+ return 'use the structured output';
918
+ }
919
+
920
+ async handleStructuredAsync(command: CliProcessCommand): Promise<CliStructuredResponse> {
921
+ return {
922
+ exitCode: 0,
923
+ outputs: [
924
+ { type: 'text', value: '2 users', style: 'info' },
925
+ { type: 'table', headers: ['Name', 'Role'], rows: [['ada', 'admin'], ['bob', 'user']] },
926
+ { type: 'key-value', entries: [{ key: 'Total', value: '2' }] },
927
+ ],
928
+ };
929
+ }
930
+
931
+ async handleStreamAsync(
932
+ command: CliProcessCommand,
933
+ emit: (output: CliStructuredOutput) => void,
934
+ signal?: AbortSignal,
935
+ ): Promise<number> {
936
+ for (const name of ['ada', 'bob']) {
937
+ if (signal?.aborted) return 1;
938
+ emit({ type: 'text', value: name });
939
+ }
940
+ return 0; // exit code
941
+ }
942
+ }
943
+ ```
944
+
945
+ - `CliStructuredOutput` = `text` (`value`, `style?`) | `table` (`headers`, `rows`) | `list` (`items`, `ordered?`) | `json` (`value`) | `key-value` (`entries`). Import these types from `@qodalis/cli-server-abstractions` (the server package re-exports them as `CliServerOutput` / `CliServerResponse`).
946
+ - `CliResponseBuilder` (from `@qodalis/cli-server-node`): `writeText(text, style?)`, `writeTable(headers, rows)`, `writeList(items, ordered?)`, `writeJson(value)`, `writeKeyValue(record)`, `setExitCode(code)`, `build()`.
947
+ - `/execute/stream` prefers `handleStreamAsync`, then `handleStructuredAsync` (each output emitted), then `handleAsync`.
948
+
949
+ ### Node: permissions and MCP tools
950
+
951
+ Node processors declare `requiredPermissions`; every command automatically becomes an MCP tool when MCP is on, and plugins can add custom tools with an `McpToolProvider`.
952
+
953
+ ```typescript
954
+ import { createCliServer, mcpError, mcpObjectSchema, McpToolProvider } from '@qodalis/cli-server-node';
955
+
956
+ class CliDeployCommandProcessor extends CliCommandProcessor {
957
+ command = 'deploy';
958
+ description = 'Deploys the app';
959
+ requiredPermissions = ['deploy:run']; // plus commands:execute; enforced when the server is protected
960
+ async handleAsync(): Promise<string> { return 'deployed'; }
961
+ }
962
+
963
+ const statusTools: McpToolProvider = {
964
+ name: 'status',
965
+ getTools: () => [
966
+ {
967
+ name: 'status_ping', // ^[a-z0-9_-]{1,64}$
968
+ description: 'Ping a host',
969
+ inputSchema: mcpObjectSchema({ host: { type: 'string', description: 'Host name' } }, ['host']),
970
+ requiredPermissions: [],
971
+ annotations: { readOnlyHint: true },
972
+ call: async (args, context) =>
973
+ args.host ? { text: `pong ${args.host}`, isError: false } : mcpError('host required'),
974
+ },
975
+ ],
976
+ };
977
+
978
+ createCliServer({
979
+ mcp: { instructions: 'Deployment server tools' }, // or mcp: true; endpoint POST /api/qcli/mcp
980
+ configure: (builder) => builder.addProcessor(new CliDeployCommandProcessor()).addMcpToolProvider(statusTools),
981
+ });
982
+ ```
983
+
984
+ - Permissions are enforced only when an auth provider protects the server (the users plugin): `/execute` requires `commands:execute` plus every `requiredPermissions` along the root-to-sub-command chain.
985
+ - Command tools: a processor becomes a tool when it has no sub-processors, declares parameters or takes a value; name = command path joined with `_` (`weather_current`); input schema = parameters (+ `value`).
986
+ - `CliMcpOptions`: `enabled?`, `tools?` (allow-list, trailing `*` = prefix), `exclude?`, `token?` (static bearer, only without an auth provider), `instructions?`.
987
+ - `McpTool.call(args, context)` gets pre-validated args and `{ auth?, signal, request }`; return `McpToolResult` `{ text, structured?, isError }` (helpers `mcpError(message)`, `mcpJson(value, text?)`). Also `server.mcp.addProvider(provider)` after creation.
988
+
989
+ ### Node: testing server processors
990
+
991
+ Node server processors are tested with Vitest, by calling `handleAsync` directly or through the HTTP API with Supertest.
992
+
993
+ ```typescript
994
+ import { describe, it, expect } from 'vitest';
995
+ import request from 'supertest';
996
+ import { createCliServer } from '@qodalis/cli-server-node';
997
+ import { CliGreetCommandProcessor } from '../greet';
998
+
999
+ describe('greet', () => {
1000
+ it('greets directly', async () => {
1001
+ const result = await new CliGreetCommandProcessor().handleAsync({
1002
+ command: 'greet', chainCommands: [], rawCommand: 'greet Ada', value: 'Ada', args: {},
1003
+ });
1004
+ expect(result).toBe('Hello, Ada!');
1005
+ });
1006
+
1007
+ it('greets over HTTP', async () => {
1008
+ const { app } = createCliServer({ configure: (b) => b.addProcessor(new CliGreetCommandProcessor()) });
1009
+ const res = await request(app)
1010
+ .post('/api/v1/qcli/execute')
1011
+ .send({ command: 'greet', chainCommands: [], rawCommand: 'greet Ada', value: 'Ada', args: {} })
1012
+ .expect(200);
1013
+ expect(res.body.exitCode).toBe(0);
1014
+ expect(res.body.outputs[0]).toEqual({ type: 'text', value: 'Hello, Ada!' });
1015
+ });
1016
+ });
1017
+ ```
1018
+
1019
+ ### .NET: minimal server processor
1020
+
1021
+ A .NET server command derives from `Qodalis.Cli.CliCommandProcessor` (interface `ICliCommandProcessor` in `Qodalis.Cli.Abstractions`) and returns text from `HandleAsync`.
1022
+
1023
+ ```csharp
1024
+ using Qodalis.Cli;
1025
+ using Qodalis.Cli.Abstractions;
1026
+ using Qodalis.Cli.Abstractions.Helpers;
1027
+
1028
+ public class CliGreetCommandProcessor : CliCommandProcessor
1029
+ {
1030
+ public override string Command { get; set; } = "greet";
1031
+ public override string Description { get; set; } = "Greets someone";
1032
+ public override bool? AcceptsRawInput { get; set; } = true;
1033
+
1034
+ public override Task<string> HandleAsync(CliProcessCommand command, CancellationToken cancellationToken = default)
1035
+ {
1036
+ var name = CliValueHelpers.GetPositionalText(command);
1037
+ return Task.FromResult($"Hello, {(string.IsNullOrWhiteSpace(name) ? "world" : name)}!");
1038
+ }
1039
+ }
1040
+ ```
1041
+
1042
+ ```csharp
1043
+ // Program.cs
1044
+ builder.Services
1045
+ .AddControllers()
1046
+ .AddCli(cli =>
1047
+ {
1048
+ cli.AddProcessor<CliGreetCommandProcessor>(); // built by DI (constructor injection)
1049
+ });
1050
+
1051
+ var app = builder.Build();
1052
+ app.UseWebSockets();
1053
+ app.MapControllers();
1054
+ app.UseCli(); // after UseWebSockets()
1055
+ app.Run();
1056
+ ```
1057
+
1058
+ - `ICliCommandProcessor`: `Command`, `Description`, `Author` (default `DefaultLibraryAuthor.Instance`), `AllowUnlistedCommands`, `ValueRequired`, `AcceptsRawInput`, `Version` (default `"1.0.0"`), `Processors`, `Parameters`, `RequiredPermissions`, `HandleAsync(command, cancellationToken)`, default-implemented `HandleStructuredAsync(command, cancellationToken)` (return `null` to fall back to `HandleAsync`).
1059
+ - `CliProcessCommand`: `Command`, `Data`, `ChainCommands`, `RawCommand`, `Value`, `Positionals` (may be `null` from old clients), `Args: Dictionary<string, object>`. Helpers in `CliValueHelpers`: `GetPositionals`, `GetPositionalText`, `SplitShellWords`, `JoinShellWords`, `QuoteShellWord`.
1060
+ - The base classes `CliCommandProcessor` and `CliModule` live in the `Qodalis.Cli` package (namespace `Qodalis.Cli`); reference it, not only `Qodalis.Cli.Abstractions`.
1061
+ - The cancellation token is the HTTP request's `RequestAborted`.
1062
+
1063
+ ### .NET: parameters, sub-commands and modules
1064
+
1065
+ .NET server commands declare flags with `CliCommandParameterDescriptor` object initializers, nest sub-commands in `Processors`, and group them in a `CliModule` registered with `cli.AddModule`.
1066
+
1067
+ ```csharp
1068
+ using Qodalis.Cli;
1069
+ using Qodalis.Cli.Abstractions;
1070
+ using Qodalis.Cli.Abstractions.Helpers;
1071
+
1072
+ public class CliWeatherCommandProcessor : CliCommandProcessor
1073
+ {
1074
+ public override string Command { get; set; } = "weather";
1075
+ public override string Description { get; set; } = "Weather information";
1076
+ public override bool? AcceptsRawInput { get; set; } = true;
1077
+
1078
+ public override IEnumerable<ICliCommandParameterDescriptor>? Parameters { get; set; } =
1079
+ [
1080
+ new CliCommandParameterDescriptor
1081
+ {
1082
+ Name = "location", // required member
1083
+ Description = "City name", // required member
1084
+ Aliases = ["-l"],
1085
+ Type = CommandParameterType.String, // String | Number | Boolean | Array | Object
1086
+ DefaultValue = "London",
1087
+ },
1088
+ ];
1089
+
1090
+ public override IEnumerable<ICliCommandProcessor>? Processors { get; set; } = [new WeatherCurrentProcessor()];
1091
+
1092
+ public override Task<string> HandleAsync(CliProcessCommand command, CancellationToken cancellationToken = default)
1093
+ => Task.FromResult($"Sunny in {Location(command)}");
1094
+
1095
+ internal static string Location(CliProcessCommand command) =>
1096
+ command.Args.TryGetValue("location", out var loc) ? loc.ToString()!
1097
+ : CliValueHelpers.GetPositionalText(command) is { Length: > 0 } text ? text : "London";
1098
+ }
1099
+
1100
+ internal class WeatherCurrentProcessor : CliCommandProcessor
1101
+ {
1102
+ public override string Command { get; set; } = "current";
1103
+ public override string Description { get; set; } = "Current conditions";
1104
+ public override bool? AcceptsRawInput { get; set; } = true;
1105
+
1106
+ public override Task<string> HandleAsync(CliProcessCommand command, CancellationToken cancellationToken = default)
1107
+ => Task.FromResult($"Sunny in {CliWeatherCommandProcessor.Location(command)}");
1108
+ }
1109
+
1110
+ public class WeatherModule : CliModule
1111
+ {
1112
+ public override string Name => "weather";
1113
+ public override string Version => "1.0.0";
1114
+ public override string Description => "Weather commands";
1115
+ public override IEnumerable<ICliCommandProcessor> Processors { get; } = [new CliWeatherCommandProcessor()];
1116
+ }
1117
+
1118
+ // Program.cs: .AddCli(cli => cli.AddModule(new WeatherModule()));
1119
+ ```
1120
+
1121
+ - Dispatch: root by `Command` (case-insensitive), then each chain word against `Processors`; at the first word that matches no child the deepest matched processor handles the command (so `weather Paris` runs `weather`).
1122
+ - The last registration of a command name wins.
1123
+ - `CliBuilder` (in `AddCli(cli => ...)`): `AddProcessor<T>()` (DI-built), `AddProcessor(instance)`, `AddProcessorsFromAssembly(assembly)`, `AddModule(module)` (module-created instances, no DI), `AddMcp(o => ...)`, `EnableShell(...)`, `SetAllowedOrigins(...)`, `AddFileSystem(...)`, `AddDataExplorerProvider(...)`, `AddApplicationPart(assembly)`, `Services` (the `IServiceCollection`). Plugins add their own `CliBuilder` extension methods (e.g. `cli.AddJob<T>(...)`).
1124
+ - Packages: `dotnet add package Qodalis.Cli` (brings `Qodalis.Cli.Abstractions`); targets .NET 10.
1125
+
1126
+ ### .NET: structured and streaming output
1127
+
1128
+ .NET processors return structured output by explicitly implementing `ICliCommandProcessor.HandleStructuredAsync` with `CliResponseBuilder`, and stream with `ICliStreamCommandProcessor`.
1129
+
1130
+ ```csharp
1131
+ using Qodalis.Cli;
1132
+ using Qodalis.Cli.Abstractions;
1133
+ using Qodalis.Cli.Models;
1134
+ using Qodalis.Cli.Services;
1135
+
1136
+ public class CliUsersCommandProcessor : CliCommandProcessor, ICliStreamCommandProcessor
1137
+ {
1138
+ public override string Command { get; set; } = "users";
1139
+ public override string Description { get; set; } = "Lists users";
1140
+
1141
+ public override Task<string> HandleAsync(CliProcessCommand command, CancellationToken ct = default)
1142
+ => Task.FromResult(string.Empty);
1143
+
1144
+ // The base class does not declare HandleStructuredAsync: implement the interface member explicitly
1145
+ Task<ICliStructuredResponse?> ICliCommandProcessor.HandleStructuredAsync(CliProcessCommand command, CancellationToken ct)
1146
+ {
1147
+ var builder = new CliResponseBuilder();
1148
+ builder.WriteText("2 users", "info");
1149
+ builder.WriteTable(["Name", "Role"], [["ada", "admin"], ["bob", "user"]]);
1150
+ builder.WriteKeyValue(new Dictionary<string, string> { ["Total"] = "2" });
1151
+ return Task.FromResult<ICliStructuredResponse?>(builder.Build());
1152
+ }
1153
+
1154
+ public async Task<int> HandleStreamAsync(CliProcessCommand command, Func<object, Task> emit, CancellationToken ct = default)
1155
+ {
1156
+ foreach (var name in new[] { "ada", "bob" })
1157
+ {
1158
+ ct.ThrowIfCancellationRequested();
1159
+ await emit(new TextOutput { Value = name });
1160
+ }
1161
+ return 0; // exit code
1162
+ }
1163
+ }
1164
+ ```
1165
+
1166
+ - `ICliResponseBuilder`: `WriteText(text, style?)`, `WriteTable(headers, rows)`, `WriteList(items, ordered = false)`, `WriteJson(value)`, `WriteKeyValue(dictionary)`, `SetExitCode(code)`, `Build()` -> `CliServerResponse { ExitCode, Outputs }`.
1167
+ - Output classes (`Qodalis.Cli.Models`): `TextOutput { Value, Style }`, `TableOutput { Headers, Rows }`, `ListOutput { Items, Ordered }`, `JsonOutput { Value }`, `KeyValueOutput { Entries: KeyValueEntry[] }`.
1168
+ - The executor uses the structured result only if it is a `CliServerResponse`; an empty `HandleAsync` string produces no output.
1169
+
1170
+ ### .NET: permissions and MCP tools
1171
+
1172
+ .NET processors declare `RequiredPermissions`; with `cli.AddMcp()` every command is an MCP tool, and plugins add tools by registering an `IMcpToolProvider`.
1173
+
1174
+ ```csharp
1175
+ using System.Text.Json.Nodes;
1176
+ using Qodalis.Cli.Mcp;
1177
+
1178
+ public class CliDeployCommandProcessor : CliCommandProcessor
1179
+ {
1180
+ public override string Command { get; set; } = "deploy";
1181
+ public override string Description { get; set; } = "Deploys the app";
1182
+ public override IEnumerable<string>? RequiredPermissions { get; set; } = ["deploy:run"];
1183
+ public override Task<string> HandleAsync(CliProcessCommand command, CancellationToken ct = default)
1184
+ => Task.FromResult("deployed");
1185
+ }
1186
+
1187
+ internal sealed class StatusMcpToolProvider : IMcpToolProvider
1188
+ {
1189
+ public Task<IReadOnlyList<McpToolDefinition>> GetToolsAsync(McpToolContext context, CancellationToken cancellationToken) =>
1190
+ Task.FromResult<IReadOnlyList<McpToolDefinition>>(
1191
+ [
1192
+ new McpToolDefinition
1193
+ {
1194
+ Name = "status_ping",
1195
+ Description = "Ping a host",
1196
+ InputSchema = McpSchema.Object(new JsonObject { ["host"] = McpSchema.Property("string", "Host name") }, "host"),
1197
+ Annotations = McpToolAnnotations.ReadOnly,
1198
+ CallAsync = (args, ctx, ct) => Task.FromResult(McpToolResult.Ok($"pong {McpSchema.GetString(args, "host")}")),
1199
+ },
1200
+ ]);
1201
+ }
1202
+
1203
+ // Program.cs
1204
+ .AddCli(cli =>
1205
+ {
1206
+ cli.AddProcessor<CliDeployCommandProcessor>();
1207
+ cli.Services.AddMcpToolProvider<StatusMcpToolProvider>();
1208
+ cli.AddMcp(o => o.Instructions = "Deployment server tools"); // POST /api/qcli/mcp
1209
+ });
1210
+ ```
1211
+
1212
+ - Permissions are checked only when the server requires authentication (users plugin): `commands:execute` plus the `RequiredPermissions` of every processor from the root to the resolved sub-command (401 without a principal, 403 when missing).
1213
+ - `McpOptions`: `Enabled`, `Tools` (allow-list, trailing `*`), `Exclude`, `Token`, `Instructions`. Command tools: name = command path joined with `_`; schema from `Parameters` (+ `value`).
1214
+ - `McpToolResult.Ok(text, structured?)` / `McpToolResult.Error(text)`; `McpToolContext` has `Principal`, `Services`, `HttpContext`. Plugin tools win name collisions over command tools.
1215
+
1216
+ ### .NET: testing server processors
1217
+
1218
+ .NET server processors are tested with xUnit by building a `CliCommandRegistry` and `CliCommandExecutorService` directly.
1219
+
1220
+ ```csharp
1221
+ using Microsoft.Extensions.Logging.Abstractions;
1222
+ using Qodalis.Cli.Abstractions;
1223
+ using Qodalis.Cli.Models;
1224
+ using Qodalis.Cli.Services;
1225
+
1226
+ public class GreetTests
1227
+ {
1228
+ [Fact]
1229
+ public async Task Greets()
1230
+ {
1231
+ var registry = new CliCommandRegistry(NullLogger<CliCommandRegistry>.Instance);
1232
+ var executor = new CliCommandExecutorService(registry, NullLogger<CliCommandExecutorService>.Instance, Array.Empty<ICliProcessorFilter>());
1233
+ registry.Register(new CliGreetCommandProcessor());
1234
+
1235
+ var response = await executor.ExecuteAsync(new CliProcessCommand { Command = "greet", Value = "Ada" });
1236
+
1237
+ Assert.Equal(0, response.ExitCode);
1238
+ Assert.Equal("Hello, Ada!", Assert.IsType<TextOutput>(response.Outputs[0]).Value);
1239
+ }
1240
+ }
1241
+ ```
1242
+
1243
+ - Or call `processor.HandleAsync(new CliProcessCommand { ... })` directly. Integration tests use `Microsoft.AspNetCore.Mvc.Testing` / `TestServer`.
1244
+
1245
+ ### Python: minimal server processor
1246
+
1247
+ A Python server command subclasses `CliCommandProcessor` from `qodalis_cli_server_abstractions` and returns text from `async handle_async(command, cancellation_event=None)`.
1248
+
1249
+ ```python
1250
+ import asyncio
1251
+
1252
+ from qodalis_cli_server_abstractions import (
1253
+ CliCommandProcessor,
1254
+ CliProcessCommand,
1255
+ get_positional_text,
1256
+ )
1257
+
1258
+
1259
+ class CliGreetCommandProcessor(CliCommandProcessor):
1260
+ @property
1261
+ def command(self) -> str:
1262
+ return "greet"
1263
+
1264
+ @property
1265
+ def description(self) -> str:
1266
+ return "Greets someone"
1267
+
1268
+ @property
1269
+ def value_required(self) -> bool | None:
1270
+ return False # False = optional positional value, True = required, None = no value
1271
+
1272
+ async def handle_async(
1273
+ self,
1274
+ command: CliProcessCommand,
1275
+ cancellation_event: asyncio.Event | None = None,
1276
+ ) -> str:
1277
+ return f"Hello, {get_positional_text(command) or 'world'}!"
1278
+ ```
1279
+
1280
+ ```python
1281
+ import uvicorn
1282
+ from qodalis_cli import CliServerOptions, create_cli_server
1283
+
1284
+ result = create_cli_server(
1285
+ CliServerOptions(configure=lambda builder: builder.add_processor(CliGreetCommandProcessor()))
1286
+ )
1287
+ uvicorn.run(result.app, host="0.0.0.0", port=8048)
1288
+ ```
1289
+
1290
+ - `ICliCommandProcessor` properties: `command` and `description` (abstract), `author` (default `DEFAULT_LIBRARY_AUTHOR`), `version` (`"1.0.0"`), `allow_unlisted_commands`, `value_required`, `processors`, `parameters`, `required_permissions`; methods `handle_async(command, cancellation_event=None) -> str` (abstract) and `handle_structured_async(command, cancellation_event=None)`.
1291
+ - `CliProcessCommand` dataclass: `command`, `raw_command`, `value`, `args: dict`, `chain_commands: list[str]`, `data`, `positionals: list[str] | None`. Helpers: `get_positionals`, `get_positional_text`, `split_shell_words`, `join_shell_words`.
1292
+ - Packages: `pip install qodalis-cli-server-abstractions` (plugins), `qodalis-cli-server` (host, import `qodalis_cli`). Python >= 3.10.
1293
+ - Poll `cancellation_event.is_set()` in long loops when it is given; `/execute` passes `None`.
1294
+
1295
+ ### Python: parameters, sub-commands and modules
1296
+
1297
+ Python server commands declare flags with `CliCommandParameterDescriptor`, nest sub-commands in `processors`, and are grouped into a `CliModule` registered with `builder.add_module`.
1298
+
1299
+ ```python
1300
+ import asyncio
1301
+
1302
+ from qodalis_cli_server_abstractions import (
1303
+ CliCommandParameterDescriptor,
1304
+ CliCommandProcessor,
1305
+ CliModule,
1306
+ CliProcessCommand,
1307
+ ICliCommandParameterDescriptor,
1308
+ ICliCommandProcessor,
1309
+ get_positional_text,
1310
+ )
1311
+
1312
+ _location = CliCommandParameterDescriptor(
1313
+ name="location", description="City name", required=False,
1314
+ type="string", aliases=["-l"], default_value="London",
1315
+ )
1316
+
1317
+
1318
+ def _get_location(command: CliProcessCommand) -> str:
1319
+ return str(command.args.get("location") or get_positional_text(command) or "London")
1320
+
1321
+
1322
+ class _WeatherCurrentProcessor(CliCommandProcessor):
1323
+ @property
1324
+ def command(self) -> str:
1325
+ return "current"
1326
+
1327
+ @property
1328
+ def description(self) -> str:
1329
+ return "Current conditions"
1330
+
1331
+ @property
1332
+ def parameters(self) -> list[ICliCommandParameterDescriptor]:
1333
+ return [_location]
1334
+
1335
+ async def handle_async(self, command: CliProcessCommand, cancellation_event: asyncio.Event | None = None) -> str:
1336
+ return f"Sunny in {_get_location(command)}"
1337
+
1338
+
1339
+ class _CliWeatherCommandProcessor(CliCommandProcessor):
1340
+ @property
1341
+ def command(self) -> str:
1342
+ return "weather"
1343
+
1344
+ @property
1345
+ def description(self) -> str:
1346
+ return "Weather information"
1347
+
1348
+ @property
1349
+ def parameters(self) -> list[ICliCommandParameterDescriptor]:
1350
+ return [_location]
1351
+
1352
+ @property
1353
+ def processors(self) -> list[ICliCommandProcessor]:
1354
+ return [_WeatherCurrentProcessor()]
1355
+
1356
+ async def handle_async(self, command: CliProcessCommand, cancellation_event: asyncio.Event | None = None) -> str:
1357
+ return f"Sunny in {_get_location(command)}"
1358
+
1359
+
1360
+ class WeatherModule(CliModule):
1361
+ @property
1362
+ def name(self) -> str:
1363
+ return "weather"
1364
+
1365
+ @property
1366
+ def version(self) -> str:
1367
+ return "1.0.0"
1368
+
1369
+ @property
1370
+ def description(self) -> str:
1371
+ return "Weather commands"
1372
+
1373
+ @property
1374
+ def processors(self) -> list[ICliCommandProcessor]:
1375
+ return [_CliWeatherCommandProcessor()]
1376
+
1377
+
1378
+ # host: create_cli_server(CliServerOptions(configure=lambda b: b.add_module(WeatherModule())))
1379
+ ```
1380
+
1381
+ - `CliCommandParameterDescriptor(name, description, required=False, type="string", aliases=None, default_value=None)`.
1382
+ - Dispatch: root by `command.lower()`, then each `chain_commands` word against `processors`; if a word matches no child, the current processor handles it only when `allow_unlisted_commands` is truthy, else `Unknown command`. Only the resolved processor runs.
1383
+ - `CliBuilder` (chainable): `add_processor`, `add_module`, `set_file_storage_provider`, `add_data_explorer_provider`, `add_filesystem` (legacy). Registering the same command twice replaces the first.
1384
+ - `CliServerOptions`: `base_path` (`"/api/qcli"`), `cors`, `cors_origins`, `allowed_origins`, `enable_shell`, `shell`, `mcp` (`True`, dict or `CliMcpOptions`), `configure`. `create_cli_server` returns `CliServerResult` with `app`, `registry`, `builder`, `executor`, `mcp`, `mount_plugin(plugin)`, ...
1385
+
1386
+ ### Python: structured and streaming output
1387
+
1388
+ Python processors return structured output by overriding `handle_structured_async` with `CliResponseBuilder`, and stream by also implementing `ICliStreamCommandProcessor`.
1389
+
1390
+ ```python
1391
+ import asyncio
1392
+ from typing import Any, Callable
1393
+
1394
+ from qodalis_cli import CliResponseBuilder
1395
+ from qodalis_cli_server_abstractions import (
1396
+ CliCommandProcessor,
1397
+ CliProcessCommand,
1398
+ ICliStreamCommandProcessor,
1399
+ )
1400
+
1401
+
1402
+ class CliUsersCommandProcessor(CliCommandProcessor, ICliStreamCommandProcessor):
1403
+ @property
1404
+ def command(self) -> str:
1405
+ return "users"
1406
+
1407
+ @property
1408
+ def description(self) -> str:
1409
+ return "Lists users"
1410
+
1411
+ async def handle_async(self, command: CliProcessCommand, cancellation_event: asyncio.Event | None = None) -> str:
1412
+ return "ada, bob"
1413
+
1414
+ async def handle_structured_async(
1415
+ self, command: CliProcessCommand, cancellation_event: asyncio.Event | None = None
1416
+ ) -> Any:
1417
+ builder = CliResponseBuilder()
1418
+ builder.write_text("2 users", "info")
1419
+ builder.write_table(["Name", "Role"], [["ada", "admin"], ["bob", "user"]])
1420
+ builder.write_key_value({"Total": "2"})
1421
+ return builder.build()
1422
+
1423
+ async def handle_stream_async(
1424
+ self,
1425
+ command: CliProcessCommand,
1426
+ emit: Callable[[dict[str, Any]], None],
1427
+ cancellation_event: asyncio.Event | None = None,
1428
+ ) -> int:
1429
+ for name in ("ada", "bob"):
1430
+ emit({"type": "text", "value": name})
1431
+ return 0 # exit code
1432
+ ```
1433
+
1434
+ - Keep the `cancellation_event` parameter in every override: the executor always passes two arguments.
1435
+ - Return `self._STRUCTURED_NOT_IMPLEMENTED` from `handle_structured_async` to fall back to `handle_async`.
1436
+ - `CliResponseBuilder`: `write_text(text, style=None)`, `write_table(headers, rows)`, `write_list(items, ordered=False)`, `write_json(value)`, `write_key_value(dict)`, `set_exit_code(code)`, `build()` -> `CliServerResponse`. Output models (`qodalis_cli.models`): `CliServerTextOutput`, `CliServerTableOutput`, `CliServerListOutput`, `CliServerJsonOutput`, `CliServerKeyValueOutput`.
1437
+
1438
+ ### Python: permissions and MCP tools
1439
+
1440
+ Python processors declare `required_permissions`; with `mcp=True` every command is an MCP tool, and plugins add tools by registering an `IMcpToolProvider` on `result.mcp`.
1441
+
1442
+ ```python
1443
+ from qodalis_cli import CliMcpOptions, CliServerOptions, McpTool, McpToolResult, create_cli_server
1444
+ from qodalis_cli.mcp import object_schema
1445
+
1446
+
1447
+ class StatusToolProvider:
1448
+ name = "status"
1449
+
1450
+ async def get_tools(self, context):
1451
+ async def ping(args, ctx):
1452
+ return McpToolResult(text=f"pong {args['host']}")
1453
+
1454
+ return [
1455
+ McpTool(
1456
+ name="status_ping",
1457
+ description="Ping a host",
1458
+ input_schema=object_schema({"host": {"type": "string"}}, ["host"]),
1459
+ call=ping,
1460
+ annotations={"readOnlyHint": True},
1461
+ )
1462
+ ]
1463
+
1464
+
1465
+ result = create_cli_server(
1466
+ CliServerOptions(configure=configure, mcp=CliMcpOptions(instructions="Deployment server tools"))
1467
+ )
1468
+ result.mcp.add_provider(StatusToolProvider()) # endpoint POST /api/qcli/mcp
1469
+ ```
1470
+
1471
+ - Declare permissions on a processor with `@property def required_permissions(self) -> list[str] | None: return ["deploy:run"]`; enforced (403) only when the users plugin protects the server, together with `commands:execute`, along the whole command chain.
1472
+ - `CliMcpOptions(enabled=True, tools=None, exclude=None, token=None, instructions=None)`. Command tools: name = command path joined with `_`; schema from `parameters` (+ `value` when `value_required` is not `None`).
1473
+ - `McpToolResult(text, structured=None, is_error=False)`, `McpToolResult.error(message)`; a plugin object with `on_mount(self, server)` can call `server.mcp.add_provider(...)` when mounted with `result.mount_plugin(plugin)`.
1474
+
1475
+ ### Python: testing server processors
1476
+
1477
+ Python server processors are tested with pytest (`asyncio_mode = "auto"`), calling `handle_async` directly or through `CliCommandExecutorService`.
1478
+
1479
+ ```python
1480
+ from qodalis_cli import CliCommandExecutorService, CliCommandRegistry
1481
+ from qodalis_cli_server_abstractions import CliProcessCommand
1482
+
1483
+
1484
+ async def test_greet_direct():
1485
+ command = CliProcessCommand(command="greet", raw_command="greet Ada", value="Ada")
1486
+ assert await CliGreetCommandProcessor().handle_async(command) == "Hello, Ada!"
1487
+
1488
+
1489
+ async def test_greet_executor():
1490
+ registry = CliCommandRegistry()
1491
+ registry.register(CliGreetCommandProcessor())
1492
+ result = await CliCommandExecutorService(registry).execute_async(CliProcessCommand(command="greet", value="Ada"))
1493
+ assert result.exit_code == 0
1494
+ assert result.outputs[0].value == "Hello, Ada!"
1495
+ ```
1496
+
1497
+ - HTTP tests: `from fastapi.testclient import TestClient`; `with TestClient(create_cli_server(CliServerOptions(configure=...)).app) as client: client.post("/api/v1/qcli/execute", json={...})`.
1498
+
1499
+ ## Recipes
1500
+
1501
+ ### Recipe: a complete web plugin end to end
1502
+
1503
+ Build, test and register a `dice` web plugin (`dice roll --sides 20 --count 2`) from scaffold to host app.
1504
+
1505
+ ```bash
1506
+ npx @qodalis/create-cli-plugin --name dice --description "Roll dice" --processor-name Dice
1507
+ cd qodalis-cli-dice
1508
+ ```
1509
+
1510
+ ```typescript
1511
+ // src/lib/processors/cli-dice-command-processor.ts
1512
+ import {
1513
+ CliProcessCommand,
1514
+ ICliCommandProcessor,
1515
+ ICliExecutionContext,
1516
+ } from '@qodalis/cli-core';
1517
+ import { LIBRARY_VERSION } from '../version';
1518
+
1519
+ class DiceRollProcessor implements ICliCommandProcessor {
1520
+ command = 'roll';
1521
+ description = 'Roll one or more dice';
1522
+ acceptsRawInput = true; // lets users type `--sides 20` as well as `--sides=20`
1523
+ parameters = [
1524
+ { name: 'sides', aliases: ['s'], description: 'Sides per die', required: false, type: 'number', defaultValue: 6,
1525
+ validator: (v: any) => (Number(v) >= 2 ? { valid: true } : { valid: false, message: 'at least 2 sides' }) },
1526
+ { name: 'count', aliases: ['c'], description: 'Number of dice', required: false, type: 'number', defaultValue: 1 },
1527
+ ];
1528
+
1529
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
1530
+ const sides = Number(command.args['sides'] ?? 6); // defaultValue is not applied automatically
1531
+ const count = Number(command.args['count'] ?? 1);
1532
+ const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
1533
+ context.writer.writeln(`${rolls.join(' ')} (total ${rolls.reduce((a, b) => a + b, 0)})`);
1534
+ context.process.output(rolls); // pipe-able
1535
+ }
1536
+ }
1537
+
1538
+ export class CliDiceCommandProcessor implements ICliCommandProcessor {
1539
+ command = 'dice';
1540
+ description = 'Roll dice';
1541
+ author = { name: 'Jane Doe', email: 'jane@example.com' };
1542
+ version = LIBRARY_VERSION;
1543
+ processors = [new DiceRollProcessor()];
1544
+ metadata = { icon: '🎲' };
1545
+
1546
+ async processCommand(command: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
1547
+ await context.executor.showHelp(command, context);
1548
+ }
1549
+ }
1550
+ ```
1551
+
1552
+ ```typescript
1553
+ // src/public-api.ts (generated; module export)
1554
+ import { ICliModule } from '@qodalis/cli-core';
1555
+ import { CliDiceCommandProcessor } from './lib/processors/cli-dice-command-processor';
1556
+ import { API_VERSION } from './lib/version';
1557
+
1558
+ export * from './lib/processors/cli-dice-command-processor';
1559
+ export * from './lib/version';
1560
+
1561
+ export const diceModule: ICliModule = {
1562
+ apiVersion: API_VERSION,
1563
+ name: '@qodalis/cli-dice',
1564
+ processors: [new CliDiceCommandProcessor()],
1565
+ translations: { es: { 'cli.dice.description': 'Tirar dados' } },
1566
+ };
1567
+ ```
1568
+
1569
+ ```typescript
1570
+ // src/tests/index.spec.ts
1571
+ import { CliTestHarness } from '@qodalis/cli';
1572
+ import { CliDiceCommandProcessor } from '../lib/processors/cli-dice-command-processor';
1573
+
1574
+ describe('dice', () => {
1575
+ it('rolls the requested number of dice', async () => {
1576
+ const harness = new CliTestHarness();
1577
+ harness.registerProcessor(new CliDiceCommandProcessor());
1578
+ const result = await harness.execute('dice roll --sides 6 --count 3');
1579
+ expect(result.data.length).toBe(3);
1580
+ expect(result.data.every((n: number) => n >= 1 && n <= 6)).toBe(true);
1581
+ });
1582
+
1583
+ it('rejects one-sided dice', async () => {
1584
+ const harness = new CliTestHarness();
1585
+ harness.registerProcessor(new CliDiceCommandProcessor());
1586
+ const result = await harness.execute('dice roll --sides 1');
1587
+ expect(result.output).toContain('at least 2 sides');
1588
+ });
1589
+ });
1590
+ ```
1591
+
1592
+ ```bash
1593
+ npx tsup # dist/public-api.{js,mjs,d.ts} + dist/umd/index.global.js
1594
+ npm publish --access public # then in any Qodalis terminal: pkg add @qodalis/cli-dice
1595
+ ```
1596
+
1597
+ ```tsx
1598
+ // Host app (React); Angular: providers: [resolveCliModuleProvider(diceModule)]
1599
+ import { Cli } from '@qodalis/react-cli';
1600
+ import { diceModule } from '@qodalis/cli-dice';
1601
+
1602
+ export const App = () => <Cli modules={[diceModule]} />;
1603
+ ```
1604
+
1605
+ - `CliTestHarness` comes from `@qodalis/cli` (add it as a dev dependency together with a test runner in a standalone project; the monorepo already runs Jasmine/Karma).
1606
+ - The validator error is printed by the engine (`Invalid parameters:` + message), so the command never runs with bad input.
1607
+
1608
+ ### Recipe: a server command used from the browser terminal
1609
+
1610
+ Add a `sysinfo` command to a Node CLI server and run it from a web terminal configured with that server.
1611
+
1612
+ ```typescript
1613
+ // server.ts (Node, Express): npm install @qodalis/cli-server-node @qodalis/cli-server-abstractions
1614
+ import os from 'node:os';
1615
+ import {
1616
+ CliCommandProcessor,
1617
+ CliProcessCommand,
1618
+ CliStructuredResponse,
1619
+ } from '@qodalis/cli-server-abstractions';
1620
+ import { createCliServer } from '@qodalis/cli-server-node';
1621
+
1622
+ class CliSysinfoCommandProcessor extends CliCommandProcessor {
1623
+ command = 'sysinfo';
1624
+ description = 'Server host information';
1625
+
1626
+ async handleAsync(): Promise<string> {
1627
+ return `${os.hostname()} (${os.platform()})`;
1628
+ }
1629
+
1630
+ async handleStructuredAsync(_: CliProcessCommand): Promise<CliStructuredResponse> {
1631
+ return {
1632
+ exitCode: 0,
1633
+ outputs: [
1634
+ {
1635
+ type: 'key-value',
1636
+ entries: [
1637
+ { key: 'Host', value: os.hostname() },
1638
+ { key: 'Platform', value: `${os.platform()} ${os.release()}` },
1639
+ { key: 'CPUs', value: String(os.cpus().length) },
1640
+ { key: 'Uptime', value: `${Math.round(os.uptime() / 60)} min` },
1641
+ ],
1642
+ },
1643
+ ],
1644
+ };
1645
+ }
1646
+ }
1647
+
1648
+ const { app } = createCliServer({
1649
+ allowedOrigins: ['http://localhost:4200'], // the web app's origin (CORS + WebSocket upgrades)
1650
+ configure: (builder) => builder.addProcessor(new CliSysinfoCommandProcessor()),
1651
+ });
1652
+ app.listen(8047, () => console.log('CLI server on http://localhost:8047'));
1653
+ ```
1654
+
1655
+ ```typescript
1656
+ // Web app: tell the terminal about the server (CliOptions.servers)
1657
+ import { CliOptions } from '@qodalis/cli-core';
1658
+
1659
+ export const options: CliOptions = {
1660
+ servers: [{ name: 'node', url: 'http://localhost:8047' }],
1661
+ };
1662
+ // Angular: <cli [options]="options"></cli> React: <Cli options={options} /> Vue: <Cli :options="options" />
1663
+ ```
1664
+
1665
+ ```bash
1666
+ # in the browser terminal
1667
+ sysinfo # bare name (no local command named sysinfo)
1668
+ node:sysinfo # explicit server
1669
+ server ls # connected servers
1670
+ ```
1671
+
1672
+ - The same command in .NET: `cli.AddProcessor<CliSysinfoCommandProcessor>()` with a `CliCommandProcessor` subclass; in Python: `builder.add_processor(...)` with a `CliCommandProcessor` subclass (see the server plugin topics).
1673
+ - Enable `mcp: true` in `createCliServer` to expose `sysinfo` as an MCP tool too.
1674
+
1675
+ ### Recipe: a web plugin with an interactive prompt
1676
+
1677
+ A `signup` web command that asks for a name, a plan, extra options and confirmation, handling Ctrl+C at every step.
1678
+
1679
+ ```typescript
1680
+ import {
1681
+ CliProcessCommand,
1682
+ ICliCommandProcessor,
1683
+ ICliExecutionContext,
1684
+ } from '@qodalis/cli-core';
1685
+
1686
+ export class CliSignupCommandProcessor implements ICliCommandProcessor {
1687
+ command = 'signup';
1688
+ description = 'Create an account interactively';
1689
+
1690
+ async processCommand(_: CliProcessCommand, context: ICliExecutionContext): Promise<void> {
1691
+ const { reader, writer } = context;
1692
+
1693
+ const email = await reader.readLine('Email: ', {
1694
+ placeholder: 'you@example.com',
1695
+ validate: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Enter a valid email'),
1696
+ });
1697
+ if (email === null) return writer.writeWarning('Cancelled');
1698
+
1699
+ const plan = await reader.readSelect('Plan:', [
1700
+ { label: 'Free', value: 'free', description: '1 project' },
1701
+ { label: 'Pro', value: 'pro', description: 'unlimited projects' },
1702
+ ], { default: 'free' });
1703
+ if (plan === null) return writer.writeWarning('Cancelled');
1704
+
1705
+ const extras = await reader.readMultiSelect('Extras:', [
1706
+ { label: 'Newsletter', value: 'newsletter', checked: true },
1707
+ { label: 'Beta features', value: 'beta' },
1708
+ ]);
1709
+ if (extras === null) return writer.writeWarning('Cancelled');
1710
+
1711
+ writer.writeKeyValue({ Email: email, Plan: plan, Extras: extras.join(', ') || '-' });
1712
+ const confirmed = await reader.readConfirm('Create the account?', true);
1713
+ if (!confirmed) return writer.writeWarning('Not created');
1714
+
1715
+ context.spinner?.show('Creating account');
1716
+ try {
1717
+ await context.http.post('https://api.example.com/accounts', { email, plan, extras });
1718
+ context.spinner?.hide();
1719
+ writer.writeSuccess(`Account created for ${email}`);
1720
+ } catch (e) {
1721
+ context.spinner?.hide();
1722
+ writer.writeError(`Failed: ${e}`);
1723
+ context.process.exit(1);
1724
+ }
1725
+ }
1726
+ }
1727
+ ```
1728
+
1729
+ ```typescript
1730
+ // test: queue answers in prompt order
1731
+ const harness = new CliTestHarness();
1732
+ harness.registerProcessor(new CliSignupCommandProcessor());
1733
+ harness.setReaderResponses('ada@example.com', 'pro', ['beta'], false);
1734
+ const result = await harness.execute('signup');
1735
+ expect(result.output).toContain('ada@example.com');
1736
+ expect(result.stderr.join('\n')).toContain('Not created'); // writeWarning goes to stderr
1737
+ ```
1738
+
1739
+ - Every `reader` method resolves `null` on Ctrl+C/Escape: check it before using the value.
1740
+ - `readConfirm` resolves `boolean | null`; `!confirmed` treats abort as "no".