@dex-ai/coding-agent 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/dex.ts +402 -0
  2. package/package.json +45 -0
  3. package/src/__tests__/command-validation.test.ts +205 -0
  4. package/src/__tests__/history.test.ts +183 -0
  5. package/src/cli-extension.ts +153 -0
  6. package/src/commands/extension-loader.ts +399 -0
  7. package/src/commands/extension.ts +924 -0
  8. package/src/commands/update.ts +419 -0
  9. package/src/env.d.ts +5 -0
  10. package/src/extensions/cli-tui-components/ActivityPanel.vue +24 -0
  11. package/src/extensions/cli-tui-components/ActivityPanel.vue.compiled.ts +96 -0
  12. package/src/extensions/cli-tui-components/App.vue +127 -0
  13. package/src/extensions/cli-tui-components/App.vue.compiled.ts +374 -0
  14. package/src/extensions/cli-tui-components/ApprovalPrompt.vue +30 -0
  15. package/src/extensions/cli-tui-components/ApprovalPrompt.vue.compiled.ts +72 -0
  16. package/src/extensions/cli-tui-components/AskPanel.vue +228 -0
  17. package/src/extensions/cli-tui-components/AskPanel.vue.compiled.ts +419 -0
  18. package/src/extensions/cli-tui-components/CommandPalette.vue +19 -0
  19. package/src/extensions/cli-tui-components/CommandPalette.vue.compiled.ts +65 -0
  20. package/src/extensions/cli-tui-components/ConfirmModal.vue +29 -0
  21. package/src/extensions/cli-tui-components/ConfirmModal.vue.compiled.ts +72 -0
  22. package/src/extensions/cli-tui-components/DiffView.vue +139 -0
  23. package/src/extensions/cli-tui-components/DiffView.vue.compiled.ts +274 -0
  24. package/src/extensions/cli-tui-components/FormModal.vue +58 -0
  25. package/src/extensions/cli-tui-components/FormModal.vue.compiled.ts +156 -0
  26. package/src/extensions/cli-tui-components/Header.vue +13 -0
  27. package/src/extensions/cli-tui-components/Header.vue.compiled.ts +42 -0
  28. package/src/extensions/cli-tui-components/InputArea.vue +202 -0
  29. package/src/extensions/cli-tui-components/InputArea.vue.compiled.ts +243 -0
  30. package/src/extensions/cli-tui-components/InteractivePanel.vue +32 -0
  31. package/src/extensions/cli-tui-components/InteractivePanel.vue.compiled.ts +103 -0
  32. package/src/extensions/cli-tui-components/ListModal.vue +58 -0
  33. package/src/extensions/cli-tui-components/ListModal.vue.compiled.ts +130 -0
  34. package/src/extensions/cli-tui-components/MarkdownContent.ts +54 -0
  35. package/src/extensions/cli-tui-components/Messages.vue +68 -0
  36. package/src/extensions/cli-tui-components/Messages.vue.compiled.ts +253 -0
  37. package/src/extensions/cli-tui-components/Modal.vue +56 -0
  38. package/src/extensions/cli-tui-components/Modal.vue.compiled.ts +61 -0
  39. package/src/extensions/cli-tui-components/SettingsPanel.vue +178 -0
  40. package/src/extensions/cli-tui-components/SettingsPanel.vue.compiled.ts +359 -0
  41. package/src/extensions/cli-tui-components/Spinner.vue +19 -0
  42. package/src/extensions/cli-tui-components/Spinner.vue.compiled.ts +42 -0
  43. package/src/extensions/cli-tui-components/StatusBar.vue +45 -0
  44. package/src/extensions/cli-tui-components/StatusBar.vue.compiled.ts +106 -0
  45. package/src/extensions/cli-tui-components/SteeringPreview.vue +11 -0
  46. package/src/extensions/cli-tui-components/SteeringPreview.vue.compiled.ts +38 -0
  47. package/src/extensions/cli-tui-components/ThinkingBlock.vue +40 -0
  48. package/src/extensions/cli-tui-components/ThinkingBlock.vue.compiled.ts +82 -0
  49. package/src/extensions/cli-tui-components/ToolCall.vue +114 -0
  50. package/src/extensions/cli-tui-components/ToolCall.vue.compiled.ts +319 -0
  51. package/src/extensions/cli-tui-components/UserMessage.vue +40 -0
  52. package/src/extensions/cli-tui-components/UserMessage.vue.compiled.ts +148 -0
  53. package/src/extensions/cli-tui-components/ask-panel-controller.ts +573 -0
  54. package/src/extensions/cli-tui-components/settings-panel-controller.ts +958 -0
  55. package/src/extensions/cli-tui.ts +2349 -0
  56. package/src/extensions/debug.ts +46 -0
  57. package/src/extensions/headless.ts +55 -0
  58. package/src/extensions/modal-system.ts +719 -0
  59. package/src/host.ts +505 -0
  60. package/src/index.ts +9 -0
  61. package/src/input/history.ts +233 -0
  62. package/src/input/index.ts +6 -0
  63. package/src/panels/dynamic-panel.ts +5 -0
  64. package/src/panels/index.ts +43 -0
  65. package/src/panels/state.ts +73 -0
  66. package/src/panels/types.ts +79 -0
  67. package/src/panels/widget.ts +25 -0
  68. package/src/provider-registry.ts +44 -0
  69. package/src/stderr-capture.ts +248 -0
  70. package/src/types.ts +20 -0
package/bin/dex.ts ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * dex — coding agent CLI entry point.
4
+ */
5
+
6
+ import { createCLIHost, runCLI } from "../src/host";
7
+ import { headlessExtension } from "../src/extensions/headless";
8
+ import { cliTuiExtension } from "../src/extensions/cli-tui";
9
+ import { debugExtension } from "../src/extensions/debug";
10
+ import { loadGlobalSettings, loadAgentState } from "@dex-ai/coding-agent-sdk";
11
+ import { createStderrCapture, createStdoutGuard } from "../src/stderr-capture";
12
+ import { openaiExtension } from "@dex-ai/openai";
13
+ import { anthropicExtension } from "@dex-ai/anthropic";
14
+ import type { CLIConfig } from "../src/types";
15
+ import type { CLIExtension } from "../src/cli-extension";
16
+ import type { Extension } from "@dex-ai/sdk";
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Parse args */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ function parseArgs(args: string[]): CLIConfig {
23
+ const settings = loadGlobalSettings();
24
+ const state = loadAgentState();
25
+ let mode: "tui" | "headless" = "tui";
26
+ let provider =
27
+ process.env.DEX_PROVIDER ??
28
+ settings.defaultProvider ??
29
+ state.lastProvider ??
30
+ "";
31
+ let model =
32
+ process.env.DEX_MODEL ?? settings.defaultModel ?? state.lastModel ?? "";
33
+ let sessionId: string | undefined;
34
+ let debug = false;
35
+ const positional: string[] = [];
36
+
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i]!;
39
+ if (arg === "--headless") mode = "headless";
40
+ else if (arg === "--debug") debug = true;
41
+ else if (arg === "--provider" && args[i + 1]) {
42
+ provider = args[++i]!;
43
+ } else if (arg === "--model" && args[i + 1]) {
44
+ model = args[++i]!;
45
+ } else if (arg === "--session" && args[i + 1]) {
46
+ sessionId = args[++i]!;
47
+ } else if (!arg.startsWith("-")) positional.push(arg);
48
+ }
49
+
50
+ return {
51
+ mode,
52
+ provider,
53
+ model,
54
+ cwd: process.cwd(),
55
+ initialPrompt: positional.length > 0 ? positional.join(" ") : undefined,
56
+ ...(sessionId !== undefined ? { sessionId } : {}),
57
+ ...(debug ? { debug: true } : {}),
58
+ };
59
+ }
60
+
61
+ /* ------------------------------------------------------------------ */
62
+ /* Load custom provider extensions from ~/.dex/agent/extensions/ */
63
+ /* ------------------------------------------------------------------ */
64
+
65
+ import { existsSync, readdirSync, statSync } from "fs";
66
+ import { join } from "path";
67
+ import { homedir } from "os";
68
+ import { loadInstalledExtensions } from "../src/commands/extension-loader";
69
+
70
+ const AGENT_EXTENSIONS_DIR = join(homedir(), ".dex", "agent", "extensions");
71
+
72
+ async function loadCustomProviders(): Promise<{
73
+ providers: Map<string, Extension>;
74
+ extensions: Extension[];
75
+ }> {
76
+ const providers = new Map<string, Extension>();
77
+ const extensions: Extension[] = [];
78
+
79
+ // 1. Load legacy ~/.dex/agent/extensions/ (bundled provider extensions)
80
+ if (existsSync(AGENT_EXTENSIONS_DIR)) {
81
+ const entries = readdirSync(AGENT_EXTENSIONS_DIR);
82
+ for (const entry of entries) {
83
+ const extPath = join(AGENT_EXTENSIONS_DIR, entry);
84
+ if (!statSync(extPath).isDirectory()) continue;
85
+
86
+ // Look for index.js (bundled, works from compiled binary) or index.ts
87
+ const indexJs = join(extPath, "index.js");
88
+ const indexTs = join(extPath, "index.ts");
89
+ const entryFile = existsSync(indexJs)
90
+ ? indexJs
91
+ : existsSync(indexTs)
92
+ ? indexTs
93
+ : null;
94
+
95
+ if (!entryFile) continue;
96
+
97
+ try {
98
+ const mod = await import(entryFile);
99
+ let ext: Extension | null = null;
100
+
101
+ // Resolve the extension object
102
+ if (typeof mod.provider === "function") {
103
+ ext = mod.provider();
104
+ } else if (typeof mod.default === "function") {
105
+ ext = mod.default();
106
+ } else if (
107
+ mod.default &&
108
+ typeof mod.default === "object" &&
109
+ mod.default.name
110
+ ) {
111
+ ext = mod.default;
112
+ }
113
+
114
+ if (!ext) continue;
115
+
116
+ // Classify: if it provides models, it's a provider; otherwise general extension
117
+ if ((ext as any).models && (ext as any).models.length > 0) {
118
+ providers.set(entry, ext);
119
+ } else {
120
+ extensions.push(ext);
121
+ }
122
+ } catch (err) {
123
+ console.error(
124
+ `Failed to load extension "${entry}":`,
125
+ err instanceof Error ? err.message : err,
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ // 2. Load installed extensions from registry (isolated per-extension directories)
132
+ const cwd = process.cwd();
133
+ const installed = await loadInstalledExtensions(cwd);
134
+
135
+ for (const ext of installed.extensions) {
136
+ extensions.push(ext);
137
+ }
138
+ for (const [name, ext] of installed.providers) {
139
+ providers.set(name, ext);
140
+ }
141
+
142
+ return { providers, extensions };
143
+ }
144
+
145
+ import { createProviderFromConfig } from "../src/provider-registry";
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ /* Resolve provider */
149
+ /* ------------------------------------------------------------------ */
150
+
151
+ async function resolveProvider(
152
+ name: string,
153
+ model: string,
154
+ customProviders: Map<string, Extension>,
155
+ ): Promise<Extension> {
156
+ // Check custom providers first (file-based extensions)
157
+ if (customProviders.has(name)) {
158
+ return customProviders.get(name)!;
159
+ }
160
+
161
+ // Check settings.json providers (case-insensitive lookup)
162
+ const settings = loadGlobalSettings();
163
+ const providerEntry = Object.entries(settings.providers ?? {}).find(
164
+ ([key]) => key.toLowerCase() === name.toLowerCase(),
165
+ );
166
+ if (providerEntry) {
167
+ const [, providerConfig] = providerEntry;
168
+ return createProviderFromConfig(name, providerConfig);
169
+ }
170
+
171
+ // Built-in fallbacks
172
+ switch (name) {
173
+ case "openai":
174
+ try {
175
+ return openaiExtension({
176
+ name: "openai",
177
+ models: [model],
178
+ modelsPath: null,
179
+ });
180
+ } catch {
181
+ // No API key configured — return stub, user can configure later
182
+ return { name: "openai", models: [] };
183
+ }
184
+ case "anthropic":
185
+ try {
186
+ return anthropicExtension({
187
+ name: "anthropic",
188
+ models: [model],
189
+ modelsPath: null,
190
+ });
191
+ } catch {
192
+ return { name: "anthropic", models: [] };
193
+ }
194
+ default:
195
+ // Return a stub provider so the agent can still start up.
196
+ // The user will see a model resolution error when they try to generate
197
+ // and can fix it with /provider or /model commands.
198
+ return {
199
+ name,
200
+ models: [],
201
+ };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Resolve all additional provider extensions (excluding the primary one).
207
+ * These are loaded so the user can switch models at runtime via /model.
208
+ */
209
+ async function resolveAdditionalProviders(
210
+ primaryName: string,
211
+ customProviders: Map<string, Extension>,
212
+ ): Promise<Extension[]> {
213
+ const additional: Extension[] = [];
214
+ const settings = loadGlobalSettings();
215
+ const primaryLower = primaryName.toLowerCase();
216
+
217
+ // Custom file-based providers
218
+ for (const [name, ext] of customProviders) {
219
+ if (name.toLowerCase() === primaryLower) continue;
220
+ additional.push(ext);
221
+ }
222
+
223
+ // Settings.json providers
224
+ const settingsProviderNames = new Set<string>();
225
+ for (const [name, providerConfig] of Object.entries(
226
+ settings.providers ?? {},
227
+ )) {
228
+ if (name.toLowerCase() === primaryLower) continue;
229
+ if (customProviders.has(name)) continue; // already added
230
+ settingsProviderNames.add(
231
+ (providerConfig as any).type?.toLowerCase() ?? name.toLowerCase(),
232
+ );
233
+ try {
234
+ additional.push(createProviderFromConfig(name, providerConfig));
235
+ } catch {
236
+ // Skip providers that fail to load
237
+ }
238
+ }
239
+
240
+ // Built-in fallbacks (only if not already the primary and not in settings)
241
+ if (
242
+ primaryLower !== "openai" &&
243
+ !customProviders.has("openai") &&
244
+ !settingsProviderNames.has("openai")
245
+ ) {
246
+ try {
247
+ additional.push(openaiExtension({ name: "openai", modelsPath: null }));
248
+ } catch {
249
+ /* skip if no API key */
250
+ }
251
+ }
252
+ if (
253
+ primaryLower !== "anthropic" &&
254
+ !customProviders.has("anthropic") &&
255
+ !settingsProviderNames.has("anthropic")
256
+ ) {
257
+ try {
258
+ additional.push(
259
+ anthropicExtension({ name: "anthropic", modelsPath: null }),
260
+ );
261
+ } catch {
262
+ /* skip if no API key */
263
+ }
264
+ }
265
+
266
+ return additional;
267
+ }
268
+
269
+ /* ------------------------------------------------------------------ */
270
+ /* Main */
271
+ /* ------------------------------------------------------------------ */
272
+
273
+ // Intercept 'dex extension' subcommand — runs and exits without starting the agent
274
+ if (process.argv[2] === "extension" || process.argv[2] === "ext") {
275
+ const { runExtensionCommand } = await import("../src/commands/extension");
276
+ runExtensionCommand(process.argv.slice(3));
277
+ process.exit(0);
278
+ }
279
+
280
+ // Intercept 'dex update' subcommand — updates dex + extensions and exits
281
+ if (process.argv[2] === "update" || process.argv[2] === "upgrade") {
282
+ const { runUpdateCommand } = await import("../src/commands/update");
283
+ runUpdateCommand();
284
+ process.exit(0);
285
+ }
286
+
287
+ const config = parseArgs(process.argv.slice(2));
288
+
289
+ // Load custom extensions (providers + general)
290
+ const { providers: customProviders, extensions: userExtensions } =
291
+ await loadCustomProviders();
292
+
293
+ // Shared state that persists across hot-reloads
294
+ let capture: ReturnType<typeof createStderrCapture> | null = null;
295
+ let debugExt: ReturnType<typeof debugExtension> | null = null;
296
+
297
+ /** Cache-bust counter for hot-reloading ESM modules. */
298
+ let reloadCounter = 0;
299
+
300
+ /** Workspace root — all dex-ai-* repos live here. */
301
+ const WORKSPACE_ROOT = join(import.meta.dir, "../../..");
302
+
303
+ /**
304
+ * Invalidate Bun's module cache for all workspace source files.
305
+ * This ensures that changes to any dex-ai-* repo (vue-tui, sdk, extensions, etc.)
306
+ * are picked up on hot-reload. Only clears workspace source, not node_modules.
307
+ */
308
+ function invalidateExtensionCache(): void {
309
+ for (const key of Object.keys(require.cache)) {
310
+ // Clear workspace sources but skip node_modules (3rd party deps don't change)
311
+ if (key.startsWith(WORKSPACE_ROOT) && !key.includes("/node_modules/")) {
312
+ delete require.cache[key];
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Factory that creates fresh CLI extension instances.
319
+ * On reload, re-imports extension modules from disk to pick up source changes.
320
+ */
321
+ async function createCliExtensions(): Promise<CLIExtension[]> {
322
+ const v = ++reloadCounter;
323
+
324
+ // Invalidate module cache for extension source files (including .vue components)
325
+ invalidateExtensionCache();
326
+
327
+ // Dynamic imports with cache-busting query param to bypass Bun's ESM cache
328
+ const [tuiMod, debugMod] = await Promise.all([
329
+ config.mode !== "headless"
330
+ ? import("../src/extensions/cli-tui" + `?v=${v}`)
331
+ : null,
332
+ config.mode !== "headless"
333
+ ? import("../src/extensions/debug" + `?v=${v}`)
334
+ : null,
335
+ ]);
336
+
337
+ const exts: CLIExtension[] = [];
338
+
339
+ if (config.mode === "headless") {
340
+ const headlessMod = await import("../src/extensions/headless" + `?v=${v}`);
341
+ exts.push(headlessMod.headlessExtension());
342
+ } else {
343
+ // Debug extension — re-create with fresh module but same capture
344
+ const debugEnabled = config.debug || loadGlobalSettings().debug === true;
345
+ if (debugEnabled && debugMod) {
346
+ debugExt = debugMod.debugExtension({ capture: capture! });
347
+ exts.push(debugExt!);
348
+ }
349
+
350
+ exts.push(
351
+ tuiMod!.cliTuiExtension({
352
+ capture: capture!,
353
+ reloadVersion: v,
354
+ }),
355
+ );
356
+ }
357
+
358
+ return exts;
359
+ }
360
+
361
+ // Initial extension creation (no hot-reload needed for first boot)
362
+ const cliExtensions: CLIExtension[] = [];
363
+
364
+ if (config.mode === "headless") {
365
+ cliExtensions.push(headlessExtension());
366
+ } else {
367
+ // Install stderr capture immediately — prevents background output from corrupting TUI.
368
+ // Must happen as early as possible before any async work that may write to stderr.
369
+ capture = createStderrCapture();
370
+ capture.install();
371
+
372
+ // Install stdout guard — redirects stray stdout writes (console.log, SDK debug output)
373
+ // to stderr (which is captured). TUI renderer writes pass through (contain ESC sequences).
374
+ const stdoutGuard = createStdoutGuard();
375
+ stdoutGuard.install();
376
+
377
+ // Debug extension — enabled via --debug flag or debug: true in ~/.dex/settings.json
378
+ const debugEnabled = config.debug || loadGlobalSettings().debug === true;
379
+ debugExt = debugEnabled ? debugExtension({ capture }) : null;
380
+ if (debugExt) cliExtensions.push(debugExt);
381
+
382
+ cliExtensions.push(cliTuiExtension({ capture }));
383
+ }
384
+
385
+ const providerExt = await resolveProvider(
386
+ config.provider,
387
+ config.model,
388
+ customProviders,
389
+ );
390
+ const additionalProviders = await resolveAdditionalProviders(
391
+ config.provider,
392
+ customProviders,
393
+ );
394
+ const host = await createCLIHost(
395
+ config,
396
+ cliExtensions,
397
+ providerExt,
398
+ createCliExtensions,
399
+ additionalProviders,
400
+ userExtensions,
401
+ );
402
+ await runCLI(host);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@dex-ai/coding-agent",
3
+ "version": "0.1.92",
4
+ "description": "Coding agent CLI — event-driven terminal host with TUI, headless, and extensible modes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dex": "./bin/dex.ts"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin"
18
+ ],
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit",
21
+ "dev": "bun run bin/dex.ts",
22
+ "build": "bash scripts/build.sh",
23
+ "deploy": "bash scripts/deploy.sh"
24
+ },
25
+ "dependencies": {
26
+ "@dex-ai/anthropic": "^0.1.3",
27
+ "@dex-ai/coding-agent-sdk": "^0.1.21",
28
+ "@dex-ai/google": "^0.1.2",
29
+ "@dex-ai/ollama": "^0.1.2",
30
+ "@dex-ai/openai": "^0.1.7",
31
+ "@dex-ai/openrouter": "^0.1.5",
32
+ "@dex-ai/context": "^0.7.12",
33
+ "@dex-ai/sdk": "^0.1.29",
34
+ "@dex-ai/vue-tui": "^0.1.9",
35
+ "@dex-ai/vue-tui-markdown": "^0.1.3",
36
+ "@vue/reactivity": "^3.5.0",
37
+ "@vue/runtime-core": "^3.5.0",
38
+ "@vue/shared": "^3.5.0"
39
+ },
40
+ "sideEffects": false,
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "registry": "https://registry.npmjs.org/"
44
+ }
45
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Unit tests for command validation logic extracted from cli-tui.ts.
3
+ *
4
+ * Tests the validateCommand function behavior:
5
+ * - Recognized commands (exact match, with args, with subcommands)
6
+ * - Unrecognized commands
7
+ * - Empty / alone
8
+ * - Partial matches are NOT validated (they should auto-complete, not error)
9
+ */
10
+
11
+ import { describe, it, expect } from "bun:test";
12
+
13
+ // ─────────────────────────────────────────────────────────────
14
+ // Extracted validation logic (mirrors cli-tui.ts)
15
+ // ─────────────────────────────────────────────────────────────
16
+
17
+ const availableCommands = [
18
+ { name: "help", description: "Show available commands" },
19
+ { name: "new", description: "Start a new conversation" },
20
+ { name: "resume", description: "Resume a previous session" },
21
+ { name: "model", description: "Switch the active model" },
22
+ { name: "thinking", description: "Set thinking/reasoning level" },
23
+ { name: "provider", description: "Manage API providers" },
24
+ { name: "extension", description: "Manage extensions" },
25
+ { name: "reload", description: "Reload configuration" },
26
+ { name: "exit", description: "Exit the application" },
27
+ { name: "quit", description: "Quit the application" },
28
+ ];
29
+
30
+ const subcommands: Record<
31
+ string,
32
+ Array<{ name: string; description: string }>
33
+ > = {
34
+ provider: [{ name: "provider add", description: "Add a new provider" }],
35
+ };
36
+
37
+ function validateCommand(input: string): { valid: boolean; reason?: string } {
38
+ const trimmed = input.trim();
39
+ if (!trimmed.startsWith("/")) {
40
+ return { valid: true };
41
+ }
42
+
43
+ const afterSlash = trimmed.slice(1);
44
+ const spaceIdx = afterSlash.indexOf(" ");
45
+ const commandName = spaceIdx > 0 ? afterSlash.slice(0, spaceIdx) : afterSlash;
46
+
47
+ if (!commandName) {
48
+ return {
49
+ valid: false,
50
+ reason:
51
+ "Type a command name after /. Press /help for available commands.",
52
+ };
53
+ }
54
+
55
+ const known = availableCommands.find((c) => c.name === commandName);
56
+ if (!known) {
57
+ return {
58
+ valid: false,
59
+ reason: `Unknown command: /${commandName}. Type /help for available commands.`,
60
+ };
61
+ }
62
+
63
+ if (spaceIdx > 0) {
64
+ const remainder = afterSlash.slice(spaceIdx + 1).trim();
65
+ if (remainder) {
66
+ const subs = subcommands[commandName];
67
+ if (subs) {
68
+ const subName = remainder.split(" ")[0]!;
69
+ const fullSub = `${commandName} ${subName}`;
70
+ const knownSub = subs.find((s) => s.name === fullSub);
71
+ if (!knownSub) {
72
+ const available = subs.map((s) => `/${s.name}`).join(", ");
73
+ return {
74
+ valid: false,
75
+ reason: `Unknown subcommand: /${commandName} ${subName}. Available: ${available}`,
76
+ };
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return { valid: true };
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────
86
+ // Tests
87
+ // ─────────────────────────────────────────────────────────────
88
+
89
+ describe("validateCommand", () => {
90
+ describe("non-command input", () => {
91
+ it("should accept regular messages (no / prefix)", () => {
92
+ expect(validateCommand("hello world")).toEqual({ valid: true });
93
+ expect(validateCommand("fix this bug")).toEqual({ valid: true });
94
+ expect(validateCommand("")).toEqual({ valid: true });
95
+ });
96
+ });
97
+
98
+ describe("exact valid commands", () => {
99
+ it("should accept all known commands", () => {
100
+ for (const cmd of availableCommands) {
101
+ expect(validateCommand(`/${cmd.name}`)).toEqual({ valid: true });
102
+ }
103
+ });
104
+
105
+ it("should accept commands with trailing whitespace", () => {
106
+ expect(validateCommand("/help ")).toEqual({ valid: true });
107
+ expect(validateCommand("/model ")).toEqual({ valid: true });
108
+ });
109
+ });
110
+
111
+ describe("valid subcommands", () => {
112
+ it("should accept /provider add", () => {
113
+ expect(validateCommand("/provider add")).toEqual({ valid: true });
114
+ });
115
+
116
+ it("should accept /provider add with extra args", () => {
117
+ expect(validateCommand("/provider add extra-args")).toEqual({
118
+ valid: true,
119
+ });
120
+ });
121
+ });
122
+
123
+ describe("commands without subcommand restrictions", () => {
124
+ it("should accept /model with any args (no subcommands defined)", () => {
125
+ expect(validateCommand("/model gpt-4")).toEqual({ valid: true });
126
+ expect(validateCommand("/model custom-args here")).toEqual({
127
+ valid: true,
128
+ });
129
+ });
130
+
131
+ it("should accept /resume with any args", () => {
132
+ expect(validateCommand("/resume session-123")).toEqual({ valid: true });
133
+ });
134
+ });
135
+
136
+ describe("empty or incomplete commands", () => {
137
+ it("should reject just /", () => {
138
+ const result = validateCommand("/");
139
+ expect(result.valid).toBe(false);
140
+ expect(result.reason).toContain("Type a command name");
141
+ });
142
+
143
+ it("should reject / with only whitespace", () => {
144
+ const result = validateCommand("/ ");
145
+ expect(result.valid).toBe(false);
146
+ expect(result.reason).toContain("Type a command name");
147
+ });
148
+ });
149
+
150
+ describe("unrecognized commands", () => {
151
+ it("should reject unknown command names", () => {
152
+ const result = validateCommand("/foo");
153
+ expect(result.valid).toBe(false);
154
+ expect(result.reason).toContain("Unknown command: /foo");
155
+ expect(result.reason).toContain("/help");
156
+ });
157
+
158
+ it("should reject partial unknown commands", () => {
159
+ const result = validateCommand("/prov");
160
+ expect(result.valid).toBe(false);
161
+ expect(result.reason).toContain("Unknown command: /prov");
162
+ });
163
+
164
+ it("should reject unknown commands with args", () => {
165
+ const result = validateCommand("/bar baz qux");
166
+ expect(result.valid).toBe(false);
167
+ expect(result.reason).toContain("Unknown command: /bar");
168
+ });
169
+ });
170
+
171
+ describe("invalid subcommands", () => {
172
+ it("should reject unknown subcommands for /provider", () => {
173
+ const result = validateCommand("/provider remove");
174
+ expect(result.valid).toBe(false);
175
+ expect(result.reason).toContain("Unknown subcommand: /provider remove");
176
+ expect(result.reason).toContain("/provider add");
177
+ });
178
+
179
+ it("should reject unknown subcommands with extra args", () => {
180
+ const result = validateCommand("/provider delete my-provider");
181
+ expect(result.valid).toBe(false);
182
+ expect(result.reason).toContain("Unknown subcommand: /provider delete");
183
+ });
184
+ });
185
+
186
+ describe("guard against sending invalid commands to LLM", () => {
187
+ it("should NOT be valid for typos that could reach the LLM", () => {
188
+ // These are the cases we're trying to catch
189
+ const typoCases = [
190
+ "/prov",
191
+ "/hellp",
192
+ "/mdoel",
193
+ "/exitt",
194
+ "/modle",
195
+ "/proivder",
196
+ ];
197
+
198
+ for (const typo of typoCases) {
199
+ const result = validateCommand(typo);
200
+ expect(result.valid).toBe(false);
201
+ expect(result.reason).toBeDefined();
202
+ }
203
+ });
204
+ });
205
+ });