@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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +384 -2
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +102 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +60 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +149 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/dev/api.d.ts +94 -0
- package/dist/src/dev/api.d.ts.map +1 -0
- package/dist/src/dev/api.js +425 -0
- package/dist/src/dev/api.js.map +1 -0
- package/dist/src/dev/docs.d.ts +93 -0
- package/dist/src/dev/docs.d.ts.map +1 -0
- package/dist/src/dev/docs.js +275 -0
- package/dist/src/dev/docs.js.map +1 -0
- package/dist/src/dev/prompts.d.ts +11 -0
- package/dist/src/dev/prompts.d.ts.map +1 -0
- package/dist/src/dev/prompts.js +141 -0
- package/dist/src/dev/prompts.js.map +1 -0
- package/dist/src/dev/scaffold.d.ts +55 -0
- package/dist/src/dev/scaffold.d.ts.map +1 -0
- package/dist/src/dev/scaffold.js +213 -0
- package/dist/src/dev/scaffold.js.map +1 -0
- package/dist/src/dev/tools.d.ts +170 -0
- package/dist/src/dev/tools.d.ts.map +1 -0
- package/dist/src/dev/tools.js +258 -0
- package/dist/src/dev/tools.js.map +1 -0
- package/dist/src/env-file.d.ts +9 -0
- package/dist/src/env-file.d.ts.map +1 -0
- package/dist/src/env-file.js +54 -0
- package/dist/src/env-file.js.map +1 -0
- package/dist/src/errors.d.ts +22 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +115 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/filters.d.ts +13 -0
- package/dist/src/filters.d.ts.map +1 -0
- package/dist/src/filters.js +16 -0
- package/dist/src/filters.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/remote.d.ts +64 -0
- package/dist/src/remote.d.ts.map +1 -0
- package/dist/src/remote.js +242 -0
- package/dist/src/remote.js.map +1 -0
- package/dist/src/server.d.ts +22 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +156 -0
- package/dist/src/server.js.map +1 -0
- package/docs/llms-full.txt +1740 -0
- 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".
|