返回 CodeWhale
commands.ts
根目录 / crates / tui / extension-host / src / shims / commands.ts
1 /**
2 * The `commands` service: slash commands a plugin contributes.
3 *
4 * Two audiences share one `register`:
5 *
6 * - **Codewhale plugins** write
7 * `ctx.commands.register({ name, description, argumentHint?, handler })`.
8 * - **DSH plugins** (`@deepseek-ai/dsh-commands`) write
9 * `ctx.commands.register({ name, description, input?: { hint }, handler })`
10 * and return `{ kind: 'success' | 'error', text }`. That surface is
11 * accepted as is.
12 *
13 * `register` returns an idempotent disposer, like `tools.register`; a
14 * registration is an effect of the calling plugin's fiber, so unloading the
15 * plugin removes it. The core admits or refuses each one (`registry/register`
16 * with `kind: 'command'`): a name that collides with a built-in command or
17 * another plugin's command is refused and activation fails with the reason.
18 *
19 * A handler is called only when the *user* runs the command, and it can only
20 * return an answer; it cannot call the model, a tool or approval. Results:
21 *
22 * - a string, or `{ kind: 'success', text? }`: shown to the user;
23 * - `{ kind: 'error', text }`: shown as a failure (a thrown error is too);
24 * - `{ kind: 'submit', prompt, text? }` (Codewhale only): `prompt` becomes the
25 * user's next message and runs through the normal turn, tool approval
26 * included; `text` is shown beside it.
27 *
28 * The invocation carries `args` (what follows the name, trimmed), `rawInput`
29 * (DSH's spelling: the text after the name including its leading separator,
30 * so `' hello'`), `commandId`, `signal` (aborted when the core cancels the
31 * call), an always-empty `attachments`, and the read-only strings `workspace`
32 * (where the user ran the command) and `dataDir` (the plugin's own writable
33 * directory). Not provided: DSH's `agent`
34 * (the host has no agent or session handle), attachments (`input.attachments`
35 * is refused), `recordInput`/`definitionId` (accepted, ignored: the core logs
36 * nothing about a command), `list`/`find`/`execute`, and `sourceEventSeq`
37 * (accepted in a result, ignored).
38 */
39 import { Service } from '@deepseek-ai/cordis'
40 import type { CommandResultWire } from '../protocol.ts'
41 import type { OwnedEntry, OwnerBase } from './owned.ts'
42
43 /** DSH's command grammar. The core enforces the same one. */
44 const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
45 const NO_ATTACHMENTS: readonly never[] = Object.freeze([])
46
47 /** A registration, from this host's side. */
48 export interface LocalCommand<O extends OwnerBase = OwnerBase> extends OwnedEntry<O> {
49 definition: NormalizedCommand
50 }
51
52 export interface NormalizedCommand {
53 name: string
54 description: string
55 argumentHint?: string
56 handler: (invocation: CommandInvocation) => unknown
57 }
58
59 export interface CommandInvocation {
60 readonly commandId: string
61 /** What follows the command name, trimmed. */
62 readonly args: string
63 /** DSH spelling: what follows the name, with its leading separator. */
64 readonly rawInput: string
65 readonly attachments: readonly never[]
66 readonly signal: AbortSignal
67 /** The workspace the user ran the command in (absent if the core did not say). */
68 readonly workspace?: string
69 /** This plugin's own writable directory. */
70 readonly dataDir?: string
71 readonly sessionId?: string
72 readonly agentId?: string
73 readonly originTurnId?: string
74 }
75
76 /** Reject an invalid definition before it reaches the core, with a message that names the problem. */
77 export function normalizeCommand(definition: any): NormalizedCommand {
78 if (!definition || typeof definition !== 'object') throw new TypeError('command definition must be an object')
79 const { name } = definition
80 if (typeof name !== 'string' || !COMMAND_NAME.test(name)) {
81 throw new TypeError(`command name ${JSON.stringify(name)} must match ${String(COMMAND_NAME)}`)
82 }
83 if (typeof definition.description !== 'string' || definition.description.trim().length === 0) {
84 throw new TypeError(`command "${name}" needs a non-empty description`)
85 }
86 if (typeof definition.handler !== 'function') throw new TypeError(`command "${name}" handler must be a function`)
87 let hint: unknown = definition.argumentHint
88 const input: unknown = definition.input
89 if (input !== undefined) {
90 if (typeof input !== 'object' || input === null || typeof (input as any).hint !== 'string') {
91 throw new TypeError(`command "${name}" input hint must be a string`)
92 }
93 if ((input as any).attachments === true) {
94 throw new TypeError(`command "${name}": attachments are not supported by the Codewhale extension host`)
95 }
96 hint ??= (input as any).hint
97 }
98 if (hint !== undefined && (typeof hint !== 'string' || hint.trim().length === 0)) {
99 throw new TypeError(`command "${name}" argument hint must be a non-empty string`)
100 }
101 return {
102 name,
103 description: definition.description,
104 ...(hint === undefined ? {} : { argumentHint: hint as string }),
105 handler: definition.handler,
106 }
107 }
108
109 /** What `registry/register` carries for a command. */
110 export function commandSpec(command: NormalizedCommand) {
111 return {
112 name: command.name,
113 description: command.description,
114 ...(command.argumentHint === undefined ? {} : { argument_hint: command.argumentHint }),
115 }
116 }
117
118 export function makeInvocation(
119 args: string,
120 commandId: string,
121 signal: AbortSignal,
122 context: { workspace?: string; dataDir?: string; sessionId?: string; agentId?: string; originTurnId?: string } = {},
123 ): CommandInvocation {
124 return Object.freeze({
125 commandId,
126 args,
127 rawInput: args === '' ? '' : ` ${args}`,
128 attachments: NO_ATTACHMENTS,
129 signal,
130 ...context,
131 })
132 }
133
134 /** Validate and detach whatever a handler returned. */
135 export function normalizeResult(command: string, value: unknown): CommandResultWire {
136 if (value === undefined || value === null) return { kind: 'success' }
137 if (typeof value === 'string') return { kind: 'success', text: value }
138 if (typeof value !== 'object') throw new TypeError(`command "${command}" handler must return a result object or a string`)
139 const result = value as { kind?: unknown; text?: unknown; prompt?: unknown }
140 const text = result.text
141 if (text !== undefined && typeof text !== 'string') {
142 throw new TypeError(`command "${command}" result text must be a string when supplied`)
143 }
144 switch (result.kind) {
145 case 'success':
146 return text === undefined ? { kind: 'success' } : { kind: 'success', text }
147 case 'error':
148 if (typeof text !== 'string' || text.trim().length === 0) {
149 throw new TypeError(`command "${command}" error text must be a non-empty string`)
150 }
151 return { kind: 'error', text }
152 case 'submit':
153 if (typeof result.prompt !== 'string' || result.prompt.trim().length === 0) {
154 throw new TypeError(`command "${command}" submit prompt must be a non-empty string`)
155 }
156 return text === undefined ? { kind: 'submit', prompt: result.prompt } : { kind: 'submit', prompt: result.prompt, text }
157 default:
158 throw new TypeError(`command "${command}" returned unknown result kind ${JSON.stringify(result.kind)}`)
159 }
160 }
161
162 /** The host's side of the shim: how a `register` call becomes a core registration. */
163 export interface CommandsHost<O extends OwnerBase> {
164 ownerOf(ctx: any): O | undefined
165 addCommand(owner: O, command: NormalizedCommand): () => void
166 }
167
168 /**
169 * Build the `commands` service class for `host`. The class is frozen so one
170 * plugin cannot rewrite `register` for the others (the owner token is not a
171 * boundary between plugins that share the process; design §4.4).
172 */
173 export function defineCommandsService<O extends OwnerBase>(host: CommandsHost<O>) {
174 class CommandsShim extends Service {
175 constructor(ctx: any) {
176 super(ctx, 'commands')
177 }
178
179 /** `ctx.commands.register(definition)`: returns an idempotent disposer. */
180 register(definition: unknown): () => void {
181 const ctx: any = this.ctx
182 const owner = host.ownerOf(ctx)
183 if (!owner) throw new Error('commands.register called outside an extension owner')
184 const command = normalizeCommand(definition)
185 return ctx.effect(() => host.addCommand(owner, command), `commands.register(${JSON.stringify(command.name)})`)
186 }
187 }
188 Object.freeze(CommandsShim.prototype)
189 return CommandsShim
190 }
191
191 lines TYPESCRIPT