@vellumai/assistant 0.6.1 → 0.6.2

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 (115) hide show
  1. package/docker-entrypoint.sh +12 -2
  2. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  3. package/openapi.yaml +1 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  6. package/src/__tests__/checker.test.ts +104 -170
  7. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  8. package/src/__tests__/context-overflow-approval.test.ts +5 -5
  9. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  10. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  11. package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
  12. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  13. package/src/__tests__/inline-command-runner.test.ts +7 -5
  14. package/src/__tests__/log-export-workspace.test.ts +190 -0
  15. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  16. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  17. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  18. package/src/__tests__/onboarding-template-contract.test.ts +5 -4
  19. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  20. package/src/__tests__/require-fresh-approval.test.ts +0 -2
  21. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  22. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  23. package/src/__tests__/terminal-tools.test.ts +2 -5
  24. package/src/__tests__/test-preload.ts +14 -0
  25. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  27. package/src/__tests__/tool-executor.test.ts +0 -1
  28. package/src/__tests__/transport-hints-queue.test.ts +77 -0
  29. package/src/__tests__/trust-store.test.ts +4 -4
  30. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  31. package/src/__tests__/workspace-policy.test.ts +2 -7
  32. package/src/agent/loop.ts +0 -29
  33. package/src/channels/types.ts +5 -0
  34. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  35. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  36. package/src/cli/commands/default-action.ts +68 -1
  37. package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
  38. package/src/cli/commands/oauth/connect.ts +11 -0
  39. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  40. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  41. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  42. package/src/cli/program.ts +9 -2
  43. package/src/config/assistant-feature-flags.ts +59 -55
  44. package/src/config/bundled-skills/app-builder/SKILL.md +87 -4
  45. package/src/config/bundled-skills/gmail/SKILL.md +11 -6
  46. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  47. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  48. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  49. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  50. package/src/config/feature-flag-registry.json +2 -2
  51. package/src/config/schemas/services.ts +8 -0
  52. package/src/credential-execution/approval-bridge.ts +0 -1
  53. package/src/credential-execution/managed-catalog.ts +3 -7
  54. package/src/daemon/config-watcher.ts +6 -2
  55. package/src/daemon/context-overflow-approval.ts +0 -1
  56. package/src/daemon/conversation-agent-loop.ts +33 -12
  57. package/src/daemon/conversation-attachments.ts +0 -1
  58. package/src/daemon/conversation-messaging.ts +3 -0
  59. package/src/daemon/conversation-process.ts +18 -2
  60. package/src/daemon/conversation-queue-manager.ts +8 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +64 -7
  62. package/src/daemon/conversation-surfaces.ts +65 -0
  63. package/src/daemon/conversation-tool-setup.ts +0 -3
  64. package/src/daemon/conversation.ts +3 -5
  65. package/src/daemon/handlers/conversations.ts +2 -1
  66. package/src/daemon/handlers/shared.ts +7 -0
  67. package/src/daemon/lifecycle.ts +21 -1
  68. package/src/daemon/message-types/conversations.ts +4 -0
  69. package/src/daemon/message-types/messages.ts +0 -1
  70. package/src/daemon/message-types/notifications.ts +12 -0
  71. package/src/daemon/message-types/settings.ts +12 -0
  72. package/src/daemon/server.ts +21 -24
  73. package/src/daemon/transport-hints.ts +33 -0
  74. package/src/index.ts +1 -1
  75. package/src/memory/conversation-crud.ts +15 -10
  76. package/src/memory/conversation-directories.ts +39 -0
  77. package/src/memory/conversation-group-migration.ts +65 -5
  78. package/src/memory/embedding-local.ts +1 -1
  79. package/src/memory/graph/capability-seed.ts +3 -5
  80. package/src/memory/group-crud.ts +25 -9
  81. package/src/messaging/provider.ts +1 -1
  82. package/src/notifications/broadcaster.ts +6 -0
  83. package/src/notifications/conversation-pairing.ts +12 -4
  84. package/src/notifications/emit-signal.ts +14 -0
  85. package/src/notifications/signal.ts +11 -0
  86. package/src/oauth/platform-connection.test.ts +2 -2
  87. package/src/oauth/seed-providers.ts +1 -0
  88. package/src/permissions/checker.ts +3 -3
  89. package/src/permissions/defaults.ts +7 -8
  90. package/src/permissions/prompter.ts +0 -2
  91. package/src/platform/client.ts +1 -1
  92. package/src/prompts/templates/BOOTSTRAP.md +14 -5
  93. package/src/prompts/templates/SOUL.md +11 -11
  94. package/src/runtime/assistant-event-hub.ts +22 -0
  95. package/src/runtime/auth/token-service.ts +8 -0
  96. package/src/runtime/routes/conversation-analysis-routes.ts +18 -6
  97. package/src/runtime/routes/conversation-routes.ts +9 -3
  98. package/src/runtime/routes/group-routes.ts +22 -8
  99. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  100. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  101. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  102. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  103. package/src/runtime/routes/log-export-routes.ts +18 -3
  104. package/src/skills/inline-command-runner.ts +12 -14
  105. package/src/tools/permission-checker.ts +0 -18
  106. package/src/tools/secret-detection-handler.ts +0 -1
  107. package/src/tools/skills/sandbox-runner.ts +3 -6
  108. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  109. package/src/tools/terminal/sandbox.ts +4 -1
  110. package/src/tools/terminal/shell.ts +3 -5
  111. package/src/tools/types.ts +0 -3
  112. package/src/watcher/provider-types.ts +1 -1
  113. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  114. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  115. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,168 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { seedPkbAutoinjectMigration } from "../workspace/migrations/030-seed-pkb-autoinject.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ let workspaceDir: string;
19
+ let pkbDir: string;
20
+
21
+ function freshWorkspace(): void {
22
+ workspaceDir = join(
23
+ tmpdir(),
24
+ `vellum-migration-030-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
25
+ );
26
+ pkbDir = join(workspaceDir, "pkb");
27
+ mkdirSync(pkbDir, { recursive: true });
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Setup / Teardown
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const dirs: string[] = [];
35
+
36
+ beforeEach(() => {
37
+ freshWorkspace();
38
+ dirs.push(workspaceDir);
39
+ });
40
+
41
+ afterEach(() => {
42
+ for (const dir of dirs.splice(0)) {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe("030-seed-pkb-autoinject migration", () => {
52
+ test("has correct migration id", () => {
53
+ expect(seedPkbAutoinjectMigration.id).toBe("030-seed-pkb-autoinject");
54
+ });
55
+
56
+ // ─── run() ──────────────────────────────────────────────────────────────
57
+
58
+ test("creates _autoinject.md with default content", () => {
59
+ seedPkbAutoinjectMigration.run(workspaceDir);
60
+
61
+ const filePath = join(pkbDir, "_autoinject.md");
62
+ expect(existsSync(filePath)).toBe(true);
63
+
64
+ const content = readFileSync(filePath, "utf-8");
65
+ expect(content).toContain("INDEX.md");
66
+ expect(content).toContain("essentials.md");
67
+ expect(content).toContain("threads.md");
68
+ expect(content).toContain("buffer.md");
69
+ });
70
+
71
+ test("no-op when pkb/ does not exist", () => {
72
+ rmSync(pkbDir, { recursive: true, force: true });
73
+ seedPkbAutoinjectMigration.run(workspaceDir);
74
+ expect(existsSync(join(pkbDir, "_autoinject.md"))).toBe(false);
75
+ });
76
+
77
+ test("idempotent — does not overwrite existing _autoinject.md", () => {
78
+ const customContent = "INDEX.md\ncustom-topic.md\n";
79
+ writeFileSync(join(pkbDir, "_autoinject.md"), customContent, "utf-8");
80
+
81
+ seedPkbAutoinjectMigration.run(workspaceDir);
82
+
83
+ const content = readFileSync(join(pkbDir, "_autoinject.md"), "utf-8");
84
+ expect(content).toBe(customContent);
85
+ });
86
+
87
+ test("appends _autoinject.md entry to INDEX.md", () => {
88
+ const indexContent =
89
+ "# Knowledge Base\n\n## Always Loaded\n" +
90
+ "- essentials.md — Core facts\n" +
91
+ "- threads.md — Active threads\n" +
92
+ "- buffer.md — Inbox\n\n" +
93
+ "## Topics\n";
94
+ writeFileSync(join(pkbDir, "INDEX.md"), indexContent, "utf-8");
95
+
96
+ seedPkbAutoinjectMigration.run(workspaceDir);
97
+
98
+ const updated = readFileSync(join(pkbDir, "INDEX.md"), "utf-8");
99
+ expect(updated).toContain("_autoinject.md");
100
+ // Should appear after buffer.md
101
+ const bufferIdx = updated.indexOf("buffer.md");
102
+ const autoinjectIdx = updated.indexOf("_autoinject.md");
103
+ expect(autoinjectIdx).toBeGreaterThan(bufferIdx);
104
+ });
105
+
106
+ test("does not duplicate _autoinject.md entry in INDEX.md", () => {
107
+ const indexContent =
108
+ "# Knowledge Base\n\n## Always Loaded\n" +
109
+ "- buffer.md — Inbox\n" +
110
+ "- _autoinject.md — Controls autoinjection\n\n" +
111
+ "## Topics\n";
112
+ writeFileSync(join(pkbDir, "INDEX.md"), indexContent, "utf-8");
113
+
114
+ seedPkbAutoinjectMigration.run(workspaceDir);
115
+
116
+ const updated = readFileSync(join(pkbDir, "INDEX.md"), "utf-8");
117
+ const matches = updated.match(/_autoinject\.md/g);
118
+ expect(matches?.length).toBe(1);
119
+ });
120
+
121
+ test("handles missing INDEX.md gracefully", () => {
122
+ // No INDEX.md — should still create _autoinject.md without error
123
+ seedPkbAutoinjectMigration.run(workspaceDir);
124
+ expect(existsSync(join(pkbDir, "_autoinject.md"))).toBe(true);
125
+ });
126
+
127
+ // ─── down() ─────────────────────────────────────────────────────────────
128
+
129
+ describe("down()", () => {
130
+ test("removes _autoinject.md when content matches template", () => {
131
+ seedPkbAutoinjectMigration.run(workspaceDir);
132
+ expect(existsSync(join(pkbDir, "_autoinject.md"))).toBe(true);
133
+
134
+ seedPkbAutoinjectMigration.down(workspaceDir);
135
+ expect(existsSync(join(pkbDir, "_autoinject.md"))).toBe(false);
136
+ });
137
+
138
+ test("preserves _autoinject.md when user has customized it", () => {
139
+ const customContent = "INDEX.md\nmy-custom-file.md\n";
140
+ writeFileSync(join(pkbDir, "_autoinject.md"), customContent, "utf-8");
141
+
142
+ seedPkbAutoinjectMigration.down(workspaceDir);
143
+
144
+ expect(existsSync(join(pkbDir, "_autoinject.md"))).toBe(true);
145
+ expect(readFileSync(join(pkbDir, "_autoinject.md"), "utf-8")).toBe(
146
+ customContent,
147
+ );
148
+ });
149
+
150
+ test("no-op when _autoinject.md does not exist", () => {
151
+ seedPkbAutoinjectMigration.down(workspaceDir);
152
+ // Should not throw
153
+ });
154
+
155
+ test("no-op when pkb/ does not exist", () => {
156
+ rmSync(pkbDir, { recursive: true, force: true });
157
+ seedPkbAutoinjectMigration.down(workspaceDir);
158
+ // Should not throw
159
+ });
160
+
161
+ test("idempotent — calling down() twice is safe", () => {
162
+ seedPkbAutoinjectMigration.run(workspaceDir);
163
+ seedPkbAutoinjectMigration.down(workspaceDir);
164
+ seedPkbAutoinjectMigration.down(workspaceDir);
165
+ // Should not throw
166
+ });
167
+ });
168
+ });
@@ -198,7 +198,7 @@ describe("isWorkspaceScopedInvocation", () => {
198
198
  // ── Bash ───────────────────────────────────────────────────────────
199
199
 
200
200
  describe("bash", () => {
201
- test("returns true (sandbox handles isolation)", () => {
201
+ test("returns true (container handles isolation)", () => {
202
202
  expect(
203
203
  isWorkspaceScopedInvocation(
204
204
  "bash",
@@ -255,12 +255,7 @@ describe("isWorkspaceScopedInvocation", () => {
255
255
  // ── Always-scoped safe tools ───────────────────────────────────────
256
256
 
257
257
  describe("always-scoped tools", () => {
258
- const safeTools = [
259
- "skill_load",
260
- "recall",
261
- "ui_update",
262
- "ui_dismiss",
263
- ];
258
+ const safeTools = ["skill_load", "recall", "ui_update", "ui_dismiss"];
264
259
 
265
260
  for (const tool of safeTools) {
266
261
  test(`${tool} is workspace-scoped`, () => {
package/src/agent/loop.ts CHANGED
@@ -201,7 +201,6 @@ export class AgentLoop {
201
201
  ): Promise<Message[]> {
202
202
  const history = [...messages];
203
203
  let toolUseTurns = 0;
204
- let nudgedForEmptyResponse = false;
205
204
  let consecutiveErrorTurns = 0;
206
205
  let lastLlmCallTime = 0;
207
206
  const rlog = requestId ? log.child({ requestId }) : log;
@@ -403,35 +402,7 @@ export class AgentLoop {
403
402
  block.type === "tool_use",
404
403
  );
405
404
 
406
- // Check if the assistant turn contained any visible text (used for
407
- // the empty-response nudge).
408
- const hasTextBlock = response.content.some(
409
- (block) => block.type === "text" && block.text.trim().length > 0,
410
- );
411
-
412
405
  if (toolUseBlocks.length === 0 || !this.toolExecutor) {
413
- // Check if the LLM returned no text after tool results — nudge it to respond
414
- const lastUserMsg =
415
- history.length >= 2 ? history[history.length - 2] : undefined;
416
- const lastWasToolResult =
417
- lastUserMsg?.role === "user" &&
418
- lastUserMsg.content.some((block) => block.type === "tool_result");
419
-
420
- if (!hasTextBlock && lastWasToolResult && !nudgedForEmptyResponse) {
421
- nudgedForEmptyResponse = true;
422
- history.push({
423
- role: "user",
424
- content: [
425
- {
426
- type: "text",
427
- text: "<system_notice>You executed tools but didn't tell the user what happened. Provide a brief, conversational summary of the results.</system_notice>",
428
- },
429
- ],
430
- });
431
- continue;
432
- }
433
-
434
- // No tool calls or no executor — done
435
406
  break;
436
407
  }
437
408
 
@@ -90,6 +90,11 @@ export function isInteractiveInterface(id: InterfaceId): boolean {
90
90
  return INTERACTIVE_INTERFACES.has(id);
91
91
  }
92
92
 
93
+ /** Whether the interface supports host proxies (bash, file, computer-use). */
94
+ export function supportsHostProxy(id: InterfaceId): boolean {
95
+ return id === "macos";
96
+ }
97
+
93
98
  export interface TurnInterfaceContext {
94
99
  userMessageInterface: InterfaceId;
95
100
  assistantMessageInterface: InterfaceId;
@@ -1,17 +1,32 @@
1
+ export interface AssistantCommandResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ }
5
+
1
6
  /**
2
7
  * CLI test utility — run an assistant CLI command via the real program,
3
- * capturing stdout.
8
+ * capturing stdout and stderr.
9
+ *
10
+ * Returns both stdout and stderr. For backward compatibility, the function
11
+ * is also callable with just a string return (use `runAssistantCommand`).
4
12
  */
5
- export async function runAssistantCommand(...args: string[]): Promise<string> {
13
+ export async function runAssistantCommandFull(
14
+ ...args: string[]
15
+ ): Promise<AssistantCommandResult> {
6
16
  const { buildCliProgram } = await import("../program.js");
7
- const program = buildCliProgram();
17
+ const program = await buildCliProgram();
8
18
  program.exitOverride();
9
- program.configureOutput({ writeErr: () => {}, writeOut: () => {} });
10
19
 
11
- const chunks: string[] = [];
20
+ const stderrChunks: string[] = [];
21
+ program.configureOutput({
22
+ writeErr: (str: string) => stderrChunks.push(str),
23
+ writeOut: () => {},
24
+ });
25
+
26
+ const stdoutChunks: string[] = [];
12
27
  const originalWrite = process.stdout.write;
13
28
  process.stdout.write = ((chunk: string | Uint8Array) => {
14
- chunks.push(
29
+ stdoutChunks.push(
15
30
  typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
16
31
  );
17
32
  return true;
@@ -25,5 +40,17 @@ export async function runAssistantCommand(...args: string[]): Promise<string> {
25
40
  process.stdout.write = originalWrite;
26
41
  }
27
42
 
28
- return chunks.join("");
43
+ return {
44
+ stdout: stdoutChunks.join(""),
45
+ stderr: stderrChunks.join(""),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * CLI test utility — run an assistant CLI command via the real program,
51
+ * capturing stdout (backward-compatible wrapper).
52
+ */
53
+ export async function runAssistantCommand(...args: string[]): Promise<string> {
54
+ const result = await runAssistantCommandFull(...args);
55
+ return result.stdout;
29
56
  }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { runAssistantCommandFull } from "./run-assistant-command.js";
4
+
5
+ describe("unknown command handling", () => {
6
+ it("reports an error for an unknown subcommand", async () => {
7
+ const { stderr } = await runAssistantCommandFull("invalid");
8
+
9
+ expect(stderr).toContain("unknown command 'invalid'");
10
+ expect(stderr).toContain("Run 'assistant --help'");
11
+ });
12
+
13
+ it("reports an error for an unknown subcommand with extra arguments", async () => {
14
+ const { stderr } = await runAssistantCommandFull("invalid", "something");
15
+
16
+ expect(stderr).toContain("unknown command 'invalid'");
17
+ expect(stderr).toContain("Run 'assistant --help'");
18
+ });
19
+
20
+ it("suggests a similar command when the input is close", async () => {
21
+ const { stderr } = await runAssistantCommandFull("confg");
22
+
23
+ expect(stderr).toContain("unknown command 'confg'");
24
+ expect(stderr).toContain("Did you mean 'config'");
25
+ });
26
+
27
+ it("does not suggest a command when the input is too far off", async () => {
28
+ const { stderr } = await runAssistantCommandFull("xyzzy");
29
+
30
+ expect(stderr).toContain("unknown command 'xyzzy'");
31
+ expect(stderr).not.toContain("Did you mean");
32
+ });
33
+ });
@@ -5,10 +5,77 @@ import { shouldAutoStartDaemon } from "../../daemon/connection-policy.js";
5
5
  import { ensureDaemonRunning } from "../../daemon/lifecycle.js";
6
6
 
7
7
  export function registerDefaultAction(program: Command): void {
8
- program.action(async () => {
8
+ program.action(async (_options: unknown, cmd: Command) => {
9
+ // Commander routes unknown subcommands to the root action as positional
10
+ // args instead of raising an error. Detect this case and fail with a
11
+ // helpful message so users don't silently get the interactive CLI when
12
+ // they mistype a command name.
13
+ if (cmd.args.length > 0) {
14
+ const unknown = cmd.args[0];
15
+ const available = cmd.commands.map((c) => c.name());
16
+ const suggestion = findClosestCommand(unknown, available);
17
+ const lines = [`unknown command '${unknown}'`];
18
+ if (suggestion) {
19
+ lines.push(`(Did you mean '${suggestion}'?)`);
20
+ }
21
+ lines.push(`Run 'assistant --help' to see a list of available commands.`);
22
+ cmd.error(lines.join("\n"), {
23
+ code: "commander.unknownCommand",
24
+ exitCode: 1,
25
+ });
26
+ return;
27
+ }
28
+
9
29
  if (shouldAutoStartDaemon()) {
10
30
  await ensureDaemonRunning();
11
31
  }
12
32
  await startCli();
13
33
  });
14
34
  }
35
+
36
+ /**
37
+ * Find the closest matching command name using Levenshtein distance.
38
+ * Returns the best match if the distance is ≤ 40% of the longer string's
39
+ * length, otherwise returns undefined.
40
+ */
41
+ function findClosestCommand(
42
+ input: string,
43
+ candidates: string[],
44
+ ): string | undefined {
45
+ let best: string | undefined;
46
+ let bestDist = Infinity;
47
+
48
+ for (const name of candidates) {
49
+ const dist = levenshtein(input.toLowerCase(), name.toLowerCase());
50
+ if (dist < bestDist) {
51
+ bestDist = dist;
52
+ best = name;
53
+ }
54
+ }
55
+
56
+ // Only suggest if the edit distance is at most 40% of the longer string
57
+ const maxLen = Math.max(input.length, best?.length ?? 0);
58
+ if (best && bestDist <= Math.ceil(maxLen * 0.4)) {
59
+ return best;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function levenshtein(a: string, b: string): number {
65
+ const m = a.length;
66
+ const n = b.length;
67
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
68
+ Array(n + 1).fill(0),
69
+ );
70
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
71
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
72
+ for (let i = 1; i <= m; i++) {
73
+ for (let j = 1; j <= n; j++) {
74
+ dp[i][j] =
75
+ a[i - 1] === b[j - 1]
76
+ ? dp[i - 1][j - 1]
77
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
78
+ }
79
+ }
80
+ return dp[m][n];
81
+ }
@@ -668,6 +668,33 @@ describe("assistant oauth connect", () => {
668
668
  expect(parsed.error).toContain("apps upsert");
669
669
  });
670
670
 
671
+ // -------------------------------------------------------------------------
672
+ // Manual-token providers (slack_channel, telegram)
673
+ // -------------------------------------------------------------------------
674
+
675
+ test("manual-token provider returns error directing to credentials command", async () => {
676
+ mockGetProvider = () => ({
677
+ providerKey: "slack_channel",
678
+ authUrl: "urn:manual-token",
679
+ tokenUrl: "urn:manual-token",
680
+ managedServiceConfigKey: null,
681
+ });
682
+ mockIsManagedMode = () => false;
683
+
684
+ const { exitCode, stdout } = await runCommand([
685
+ "connect",
686
+ "slack_channel",
687
+ "--json",
688
+ ]);
689
+ expect(exitCode).toBe(1);
690
+ const parsed = JSON.parse(stdout);
691
+ expect(parsed.ok).toBe(false);
692
+ expect(parsed.error).toContain("manual token configuration");
693
+ expect(parsed.error).toContain("assistant credentials set");
694
+ expect(parsed.error).toContain("--service");
695
+ expect(parsed.error).toContain("--field");
696
+ });
697
+
671
698
  // -------------------------------------------------------------------------
672
699
  // Orchestrator error propagation
673
700
  // -------------------------------------------------------------------------
@@ -336,6 +336,17 @@ Examples:
336
336
  // BYO PATH
337
337
  // =============================================================
338
338
 
339
+ // Manual-token providers (slack_channel, telegram) don't use
340
+ // OAuth2 browser flows — credentials are configured via
341
+ // `assistant credentials` or chat setup instead.
342
+ if (providerRow.authUrl === "urn:manual-token") {
343
+ writeError(
344
+ `"${provider}" uses manual token configuration, not an OAuth browser flow. ` +
345
+ `Set the token with: assistant credentials set <token_value> --service ${provider} --field <field_name>`,
346
+ );
347
+ return;
348
+ }
349
+
339
350
  // a. Resolve client credentials from the DB
340
351
  const dbApp = opts.clientId
341
352
  ? getAppByProviderAndClientId(provider, opts.clientId)
@@ -109,7 +109,7 @@ async function runCommand(
109
109
  process.exitCode = 0;
110
110
 
111
111
  try {
112
- const program = buildCliProgram();
112
+ const program = await buildCliProgram();
113
113
  program.exitOverride();
114
114
  program.configureOutput({
115
115
  writeErr: () => {},
@@ -130,7 +130,7 @@ async function runCommand(
130
130
  process.exitCode = 0;
131
131
 
132
132
  try {
133
- const program = buildCliProgram();
133
+ const program = await buildCliProgram();
134
134
  program.exitOverride();
135
135
  program.configureOutput({
136
136
  writeErr: () => {},
@@ -110,7 +110,7 @@ async function runCommand(
110
110
  process.exitCode = 0;
111
111
 
112
112
  try {
113
- const program = buildCliProgram();
113
+ const program = await buildCliProgram();
114
114
  program.exitOverride();
115
115
  program.configureOutput({
116
116
  writeErr: () => {},
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
 
3
+ import { initFeatureFlagOverrides } from "../config/assistant-feature-flags.js";
3
4
  import { getConfig } from "../config/loader.js";
4
5
  import { isEmailEnabled } from "../email/feature-gate.js";
5
6
  import { registerHooksCommand } from "../hooks/cli.js";
@@ -33,13 +34,19 @@ import { registerSkillsCommand } from "./commands/skills.js";
33
34
  import { registerTrustCommand } from "./commands/trust.js";
34
35
  import { registerUsageCommand } from "./commands/usage.js";
35
36
 
36
- export function buildCliProgram(): Command {
37
+ /**
38
+ * Build the CLI program tree. Pre-populates the feature flag cache from
39
+ * the gateway so flag-gated commands are registered correctly.
40
+ */
41
+ export async function buildCliProgram(): Promise<Command> {
42
+ await initFeatureFlagOverrides();
37
43
  const program = new Command();
38
44
 
39
45
  program
40
46
  .name("assistant")
41
47
  .description("Local AI assistant")
42
- .version(APP_VERSION);
48
+ .version(APP_VERSION)
49
+ .allowExcessArguments(true);
43
50
 
44
51
  program.addHelpText(
45
52
  "after",