@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/Dockerfile +7 -7
  2. package/openapi.yaml +151 -53
  3. package/package.json +5 -4
  4. package/scripts/bundled-plugin-packages.ts +154 -0
  5. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  6. package/scripts/test.ts +15 -13
  7. package/src/__tests__/managed-store.test.ts +121 -0
  8. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  9. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  10. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  11. package/src/calls/voice-control-protocol.ts +13 -4
  12. package/src/calls/voice-session-bridge.ts +27 -6
  13. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  14. package/src/cli/commands/plugins.ts +50 -18
  15. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  16. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  17. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  18. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  19. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  20. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  21. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  22. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  23. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  24. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  25. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  26. package/src/cli/lib/diff-plugin.ts +1 -1
  27. package/src/cli/lib/inspect-plugin.ts +80 -12
  28. package/src/cli/lib/install-from-github.ts +133 -76
  29. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  30. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  31. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  32. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  33. package/src/cli/lib/plugin-details.ts +12 -0
  34. package/src/cli/lib/plugin-marketplace.ts +121 -21
  35. package/src/cli/lib/plugin-pin-history.ts +5 -2
  36. package/src/cli/lib/search-plugins.ts +58 -16
  37. package/src/cli/lib/upgrade-plugin.ts +48 -3
  38. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  39. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  40. package/src/daemon/conversation-tool-setup.ts +4 -22
  41. package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
  42. package/src/live-voice/__tests__/live-voice-vad.test.ts +658 -3
  43. package/src/live-voice/__tests__/protocol.test.ts +48 -0
  44. package/src/live-voice/__tests__/session-controls.test.ts +43 -0
  45. package/src/live-voice/live-voice-session.ts +784 -64
  46. package/src/live-voice/protocol.ts +20 -0
  47. package/src/live-voice/session-controls.ts +61 -3
  48. package/src/monitoring/plugin-auto-update.ts +6 -0
  49. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +15 -0
  50. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  51. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  52. package/src/runtime/routes/plugins-routes.ts +69 -49
  53. package/src/skills/managed-store.ts +153 -31
  54. package/src/tools/skills/find-similar-skills.test.ts +214 -2
  55. package/src/tools/skills/find-similar-skills.ts +62 -9
  56. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  57. package/src/tools/skills/scaffold-managed.ts +16 -13
@@ -171,6 +171,94 @@ describe("scaffold_managed_skill tool", () => {
171
171
  });
172
172
  });
173
173
 
174
+ test("an overwrite through the tool keeps frontmatter it does not restate", async () => {
175
+ await executeScaffoldManagedSkill(
176
+ {
177
+ skill_id: "edited",
178
+ name: "Edited",
179
+ description: "V1",
180
+ body_markdown: "Old body.",
181
+ activation_hints: HINTS,
182
+ emoji: "📊",
183
+ category: "productivity",
184
+ includes: ["csv-basics"],
185
+ avoid_when: ["the report is monthly"],
186
+ },
187
+ makeContext(),
188
+ );
189
+
190
+ // The usual "change step 3" edit: body and hints, nothing else restated.
191
+ const result = await executeScaffoldManagedSkill(
192
+ {
193
+ skill_id: "edited",
194
+ name: "Edited",
195
+ description: "V2",
196
+ body_markdown: "New body.",
197
+ activation_hints: HINTS,
198
+ overwrite: true,
199
+ },
200
+ makeContext(),
201
+ );
202
+ expect(result.isError).toBe(false);
203
+
204
+ const skill = loadSkillCatalog().find((s) => s.id === "edited")!;
205
+ expect(skill.emoji).toBe("📊");
206
+ expect(skill.category).toBe("productivity");
207
+ expect(skill.includes).toEqual(["csv-basics"]);
208
+ expect(skill.avoidWhen).toEqual(["the report is monthly"]);
209
+ expect(skill.description).toBe("V2");
210
+ });
211
+
212
+ test("an overwrite through the tool clears a field passed empty, and hints stay required", async () => {
213
+ await executeScaffoldManagedSkill(
214
+ {
215
+ skill_id: "cleared",
216
+ name: "Cleared",
217
+ description: "V1",
218
+ body_markdown: "Body.",
219
+ activation_hints: HINTS,
220
+ category: "productivity",
221
+ avoid_when: ["the report is monthly"],
222
+ },
223
+ makeContext(),
224
+ );
225
+
226
+ const noHints = await executeScaffoldManagedSkill(
227
+ {
228
+ skill_id: "cleared",
229
+ name: "Cleared",
230
+ description: "V2",
231
+ body_markdown: "Body.",
232
+ activation_hints: [],
233
+ overwrite: true,
234
+ },
235
+ makeContext(),
236
+ );
237
+ // An explicit empty list clears other fields, but hints must track the
238
+ // body, so an overwrite still has to state them.
239
+ expect(noHints.isError).toBe(true);
240
+ expect(noHints.content).toContain("activation_hints is required");
241
+
242
+ const result = await executeScaffoldManagedSkill(
243
+ {
244
+ skill_id: "cleared",
245
+ name: "Cleared",
246
+ description: "V2",
247
+ body_markdown: "Body.",
248
+ activation_hints: HINTS,
249
+ overwrite: true,
250
+ category: " ",
251
+ avoid_when: [],
252
+ },
253
+ makeContext(),
254
+ );
255
+ expect(result.isError).toBe(false);
256
+
257
+ const skill = loadSkillCatalog().find((s) => s.id === "cleared")!;
258
+ expect(skill.category).toBeUndefined();
259
+ expect(skill.avoidWhen).toBeUndefined();
260
+ });
261
+
174
262
  test("the registered tool's schema rejects an omitted activation_hints before the executor runs", () => {
175
263
  // A call through the registered tool is validated against TOOLS.json
176
264
  // first (skill-tool-factory), so the requirement has to live in the
@@ -9,6 +9,7 @@ import {
9
9
  parseTerminalSessionControl,
10
10
  type SessionControlRequest,
11
11
  stripInternalSpeechMarkers,
12
+ TASK_STOP_MARKER,
12
13
  terminalControlMarkerLength,
13
14
  } from "../voice-control-protocol.js";
14
15
 
@@ -52,13 +53,26 @@ describe("minimize-room marker", () => {
52
53
 
53
54
  describe("isIncompleteControlMarkerTail", () => {
54
55
  test("strict prefixes of any marker are incomplete", () => {
55
- for (const tail of ["[", "[-", "[-1", "[END_CAL", "[ASK_GUARDIAN_APPRO"]) {
56
+ for (const tail of [
57
+ "[",
58
+ "[-",
59
+ "[-1",
60
+ "[END_CAL",
61
+ "[TASK:ST",
62
+ "[ASK_GUARDIAN_APPRO",
63
+ ]) {
56
64
  expect(isIncompleteControlMarkerTail(tail)).toBe(true);
57
65
  }
58
66
  });
59
67
 
60
68
  test("complete literal markers are not held", () => {
61
- for (const tail of ["[-1]", "[END_CALL]", "[0] answer", "[-1] look here"]) {
69
+ for (const tail of [
70
+ "[-1]",
71
+ "[END_CALL]",
72
+ TASK_STOP_MARKER,
73
+ "[0] answer",
74
+ "[-1] look here",
75
+ ]) {
62
76
  expect(isIncompleteControlMarkerTail(tail)).toBe(false);
63
77
  }
64
78
  });
@@ -148,6 +162,12 @@ describe("session control markers", () => {
148
162
  );
149
163
  });
150
164
 
165
+ test("strips the task-stop marker so it is never spoken", () => {
166
+ expect(stripInternalSpeechMarkers(`Okay. ${TASK_STOP_MARKER}`).trim()).toBe(
167
+ "Okay.",
168
+ );
169
+ });
170
+
151
171
  test("holds a streaming timed mute until its bracket arrives", () => {
152
172
  expect(isIncompleteControlMarkerTail("[MU")).toBe(true);
153
173
  expect(isIncompleteControlMarkerTail("[MUTE:3")).toBe(true);
@@ -162,6 +182,7 @@ describe("session control markers", () => {
162
182
 
163
183
  test.each([
164
184
  ["Okay, talk soon. [END_CALL]", { action: "end" }],
185
+ ["Okay, stopping. [TASK:STOP]", { action: "task_stop" }],
165
186
  ["Muted. [MUTE]", { action: "mute" }],
166
187
  ["Taking a look. [LOOK:SCREEN]", { action: "look_screen" }],
167
188
  ["Show me. [LOOK:CAMERA]", { action: "look_camera" }],
@@ -200,6 +221,7 @@ describe("session control markers", () => {
200
221
  test("measures the terminal marker the transcript pass strips", () => {
201
222
  expect(terminalControlMarkerLength("Done [-1]")).toBe(4);
202
223
  expect(terminalControlMarkerLength("Bye [END_CALL] ")).toBe(10);
224
+ expect(terminalControlMarkerLength("Stopping [TASK:STOP]")).toBe(11);
203
225
  expect(terminalControlMarkerLength("Muted [MUTE:30]")).toBe(9);
204
226
  expect(terminalControlMarkerLength("Okay [UPDATES:FEWER]")).toBe(15);
205
227
  expect(terminalControlMarkerLength("The array [-1] sorts")).toBe(0);
@@ -842,6 +842,17 @@ describe("startVoiceTurn triage-and-escalate control prompt", () => {
842
842
  expect(installed()).toContain(escalatedContinuationRule());
843
843
  });
844
844
 
845
+ test("a direct escalated turn keeps the caller-supplied resume prompt verbatim", async () => {
846
+ const installed = captureInstalledPrompt();
847
+ await startVoiceTurn({
848
+ ...makeTurnOptions(),
849
+ voiceControlPrompt: LIVE_VOICE_PROMPT,
850
+ routingLeg: "escalated",
851
+ directEscalated: true,
852
+ });
853
+ expect(installed()).toBe(LIVE_VOICE_PROMPT);
854
+ });
855
+
845
856
  test("the auto-built phone prompt carries the front-door rule anchored to the caller's words", async () => {
846
857
  const installed = captureInstalledPrompt();
847
858
  await startVoiceTurn({
@@ -1966,7 +1977,7 @@ describe("startVoiceTurn tool-event forwarding", () => {
1966
1977
  fakeConversation = fake.conversation;
1967
1978
  }
1968
1979
 
1969
- test("tool_use_start delivers the tool name, toolUseId, and input", async () => {
1980
+ test("tool_use_start delivers the tool name, input, and active allowlist", async () => {
1970
1981
  makeEventEmittingConversation([
1971
1982
  {
1972
1983
  type: "tool_use_start",
@@ -1975,6 +1986,11 @@ describe("startVoiceTurn tool-event forwarding", () => {
1975
1986
  toolUseId: "toolu-1",
1976
1987
  },
1977
1988
  ]);
1989
+ (
1990
+ fakeConversation as typeof fakeConversation & {
1991
+ allowedToolNames?: Set<string>;
1992
+ }
1993
+ ).allowedToolNames = new Set(["web_search"]);
1978
1994
 
1979
1995
  const starts: Array<{ toolName: string; detail?: unknown }> = [];
1980
1996
  await startVoiceTurn({
@@ -1988,7 +2004,11 @@ describe("startVoiceTurn tool-event forwarding", () => {
1988
2004
  expect(starts).toEqual([
1989
2005
  {
1990
2006
  toolName: "web_search",
1991
- detail: { toolUseId: "toolu-1", input: { query: "weather" } },
2007
+ detail: {
2008
+ toolUseId: "toolu-1",
2009
+ input: { query: "weather" },
2010
+ allowedToolNames: new Set(["web_search"]),
2011
+ },
1992
2012
  },
1993
2013
  ]);
1994
2014
  });
@@ -13,6 +13,7 @@ export const CALL_OPENING_MARKER = "[CALL_OPENING]";
13
13
  export const CALL_OPENING_ACK_MARKER = "[CALL_OPENING_ACK]";
14
14
  export const CALL_VERIFICATION_COMPLETE_MARKER = "[CALL_VERIFICATION_COMPLETE]";
15
15
  export const END_CALL_MARKER = "[END_CALL]";
16
+ export const TASK_STOP_MARKER = "[TASK:STOP]";
16
17
 
17
18
  /**
18
19
  * Verdict tokens for the fast "front-door" model (triage-and-escalate voice
@@ -89,6 +90,7 @@ const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
89
90
  const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
90
91
  const CALL_OPENING_ACK_MARKER_REGEX = /\[CALL_OPENING_ACK\]/g;
91
92
  const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
93
+ const TASK_STOP_MARKER_REGEX = /\[TASK:STOP\]/g;
92
94
  const HOLD_VERDICT_TOKEN_REGEX = /\[0\]/g;
93
95
  const ESCALATE_VERDICT_TOKEN_REGEX = /\[1\]/g;
94
96
  const MINIMIZE_ROOM_MARKER_REGEX = /\[-1\]/g;
@@ -217,6 +219,7 @@ export function stripInternalSpeechMarkers(text: string): string {
217
219
  .replace(CALL_OPENING_MARKER_REGEX, "")
218
220
  .replace(CALL_OPENING_ACK_MARKER_REGEX, "")
219
221
  .replace(END_CALL_MARKER_REGEX, "")
222
+ .replace(TASK_STOP_MARKER_REGEX, "")
220
223
  .replace(HOLD_VERDICT_TOKEN_REGEX, "")
221
224
  .replace(ESCALATE_VERDICT_TOKEN_REGEX, "")
222
225
  .replace(MINIMIZE_ROOM_MARKER_REGEX, "")
@@ -245,6 +248,7 @@ const CONTROL_MARKER_STRINGS = [
245
248
  "[CALL_OPENING]",
246
249
  "[CALL_OPENING_ACK]",
247
250
  "[END_CALL]",
251
+ TASK_STOP_MARKER,
248
252
  "[0]",
249
253
  "[1]",
250
254
  "[-1]",
@@ -354,12 +358,14 @@ export function createControlMarkerHoldback(
354
358
 
355
359
  /**
356
360
  * A session control a live-voice reply asked for with a terminal marker:
357
- * `end` from {@link END_CALL_MARKER}, `mute` from {@link MUTE_MARKER} or its
358
- * timed form, `updates` from the progress-cadence markers, `look_screen` and
359
- * `look_camera` and `look_stop` from the look markers.
361
+ * `end` from {@link END_CALL_MARKER}, `task_stop` from
362
+ * {@link TASK_STOP_MARKER}, `mute` from {@link MUTE_MARKER} or its timed form,
363
+ * `updates` from the progress-cadence markers, and the look actions from the
364
+ * look markers.
360
365
  */
361
366
  export type SessionControlRequest =
362
367
  | { readonly action: "end" }
368
+ | { readonly action: "task_stop" }
363
369
  | { readonly action: "mute"; readonly durationMs?: number }
364
370
  | { readonly action: "updates"; readonly cadence: "fewer" | "normal" }
365
371
  | { readonly action: "look_screen" }
@@ -367,7 +373,7 @@ export type SessionControlRequest =
367
373
  | { readonly action: "look_stop" };
368
374
 
369
375
  const TERMINAL_SESSION_CONTROL_REGEX =
370
- /(\[END_CALL\]|\[UPDATES:(FEWER|NORMAL)\]|\[LOOK:(SCREEN|CAMERA|STOP)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
376
+ /(\[END_CALL\]|\[TASK:STOP\]|\[UPDATES:(FEWER|NORMAL)\]|\[LOOK:(SCREEN|CAMERA|STOP)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
371
377
 
372
378
  /**
373
379
  * The session control a reply ends with, or null.
@@ -392,6 +398,9 @@ export function parseTerminalSessionControl(
392
398
  if (match[1] === END_CALL_MARKER) {
393
399
  return { action: "end" };
394
400
  }
401
+ if (match[1] === TASK_STOP_MARKER) {
402
+ return { action: "task_stop" };
403
+ }
395
404
  if (match[2] !== undefined) {
396
405
  return {
397
406
  action: "updates",
@@ -200,7 +200,10 @@ function frontDoorRuleWithDigest(
200
200
  function routingLegRuleFor(
201
201
  opts: Pick<
202
202
  VoiceTurnOptions,
203
- "routingLeg" | "unifiedVerdict" | "spokenEscalationBridge"
203
+ | "routingLeg"
204
+ | "unifiedVerdict"
205
+ | "spokenEscalationBridge"
206
+ | "directEscalated"
204
207
  >,
205
208
  callerUtterance: string,
206
209
  ): string | null {
@@ -211,7 +214,9 @@ function routingLegRuleFor(
211
214
  callerUtterance,
212
215
  );
213
216
  case "escalated":
214
- return escalatedContinuationRule(opts.spokenEscalationBridge);
217
+ return opts.directEscalated === true
218
+ ? null
219
+ : escalatedContinuationRule(opts.spokenEscalationBridge);
215
220
  default:
216
221
  return null;
217
222
  }
@@ -358,6 +363,7 @@ export interface VoiceRunEventSink {
358
363
  toolName: string,
359
364
  input: Record<string, unknown>,
360
365
  toolUseId?: string,
366
+ allowedToolNames?: ReadonlySet<string>,
361
367
  ): void;
362
368
  onToolResult(event: VoiceToolResultEvent): void;
363
369
  }
@@ -372,7 +378,11 @@ export interface VoiceTurnCallbacks {
372
378
  /** Fired when the agent run starts a definitive tool use this turn. */
373
379
  tool_use_start?: (
374
380
  toolName: string,
375
- detail?: { toolUseId?: string; input?: Record<string, unknown> },
381
+ detail?: {
382
+ toolUseId?: string;
383
+ input?: Record<string, unknown>;
384
+ allowedToolNames?: ReadonlySet<string>;
385
+ },
376
386
  ) => void;
377
387
  /** Fired when a tool invocation finishes. */
378
388
  tool_result?: (event: VoiceToolResultEvent) => void;
@@ -533,6 +543,8 @@ export interface VoiceTurnOptions {
533
543
  * Only meaningful with `routingLeg: "escalated"`.
534
544
  */
535
545
  spokenEscalationBridge?: string;
546
+ /** Run the strong leg directly, without claiming a holding phrase was spoken. */
547
+ directEscalated?: boolean;
536
548
  /**
537
549
  * Marks this turn's `content` as an internal instruction rather than user
538
550
  * speech: it persists `hidden` so `/messages` filters it after a reload,
@@ -921,9 +933,13 @@ export async function startVoiceTurn(
921
933
  onError: (message) => {
922
934
  opts.onError?.(message);
923
935
  },
924
- onToolUse: (toolName, input, toolUseId) => {
936
+ onToolUse: (toolName, input, toolUseId, allowedToolNames) => {
925
937
  log.debug({ toolName, input }, "Voice turn tool_use event");
926
- opts.callbacks?.tool_use_start?.(toolName, { toolUseId, input });
938
+ opts.callbacks?.tool_use_start?.(toolName, {
939
+ toolUseId,
940
+ input,
941
+ ...(allowedToolNames !== undefined ? { allowedToolNames } : {}),
942
+ });
927
943
  },
928
944
  onToolResult: (event) => {
929
945
  opts.callbacks?.tool_result?.(event);
@@ -2081,7 +2097,12 @@ export async function startVoiceTurn(
2081
2097
  } else if (msg.type === "conversation_error") {
2082
2098
  eventSink.onError(msg.userMessage);
2083
2099
  } else if (msg.type === "tool_use_start") {
2084
- eventSink.onToolUse(msg.toolName, msg.input, msg.toolUseId);
2100
+ eventSink.onToolUse(
2101
+ msg.toolName,
2102
+ msg.input,
2103
+ msg.toolUseId,
2104
+ conversation.allowedToolNames,
2105
+ );
2085
2106
  } else if (msg.type === "tool_result") {
2086
2107
  eventSink.onToolResult({
2087
2108
  toolName: msg.toolName,
@@ -37,6 +37,7 @@ import type {
37
37
  InstallPluginOptions,
38
38
  InstallPluginResult,
39
39
  } from "../../lib/install-from-github.js";
40
+ import type { PluginSearchMatch } from "../../lib/search-plugins.js";
40
41
  import type {
41
42
  PluginUpgradeResult,
42
43
  UpgradePluginDeps,
@@ -63,6 +64,7 @@ let stageFixture: ((stagingDir: string) => void) | null = null;
63
64
  let installPluginCalls: InstallPluginOptions[] = [];
64
65
  let platformInstallCalls: Array<{ name: string; force?: boolean }> = [];
65
66
  let upgradePluginCalls: UpgradePluginOptions[] = [];
67
+ let catalogMatches: PluginSearchMatch[] = [];
66
68
 
67
69
  /**
68
70
  * Queued daemon IPC responses. The default (empty queue) is a transport
@@ -159,6 +161,10 @@ mock.module("../../lib/install-from-platform.js", () => ({
159
161
  },
160
162
  }));
161
163
 
164
+ mock.module("../../lib/plugin-catalog-cache.js", () => ({
165
+ getPluginCatalog: async () => ({ ref: "main", matches: catalogMatches }),
166
+ }));
167
+
162
168
  mock.module("../../lib/inspect-plugin.js", () => ({
163
169
  ...realInspect,
164
170
  inspectPlugin: async () => {
@@ -257,6 +263,7 @@ beforeEach(() => {
257
263
  installPluginCalls = [];
258
264
  platformInstallCalls = [];
259
265
  upgradePluginCalls = [];
266
+ catalogMatches = [];
260
267
  ipcResults = [];
261
268
  inspectResult = null;
262
269
  installTarget = null;
@@ -433,6 +440,32 @@ describe("plugins install - declared-schedules consent", () => {
433
440
  expect(r.stdout).toContain('Plugin "caveman" declares 2 schedules:');
434
441
  expect(r.exitCode).toBe(0);
435
442
  });
443
+
444
+ test("a local catalog source bypasses the platform installer", async () => {
445
+ catalogMatches = [
446
+ {
447
+ name: "fathom",
448
+ path: "local:plugins/mcp-catalog/fathom@1.0.0",
449
+ category: "productivity",
450
+ source: {
451
+ kind: "local",
452
+ path: "plugins/mcp-catalog/fathom",
453
+ version: "1.0.0",
454
+ },
455
+ },
456
+ ];
457
+
458
+ const r = await runCommand(["plugins", "install", "fathom"]);
459
+
460
+ expect(platformInstallCalls).toHaveLength(0);
461
+ expect(installPluginCalls).toHaveLength(1);
462
+ expect(installPluginCalls[0]?.trustedSource).toEqual({
463
+ kind: "local",
464
+ path: "plugins/mcp-catalog/fathom",
465
+ version: "1.0.0",
466
+ });
467
+ expect(r.exitCode).toBe(0);
468
+ });
436
469
  });
437
470
 
438
471
  describe("plugins upgrade - declared-schedules consent (local fallback)", () => {
@@ -701,6 +734,39 @@ describe("plugins inspect - schedules surface", () => {
701
734
  expect(r.stdout).toContain("weekly RRULE:FREQ=WEEKLY;BYDAY=MO (execute)");
702
735
  expect(r.exitCode).toBe(0);
703
736
  });
737
+
738
+ test("renders a bundled package location without a GitHub URL", async () => {
739
+ inspectResult = {
740
+ ...inspectionWithSurfaces({
741
+ skills: [],
742
+ hooks: [],
743
+ tools: [],
744
+ schedules: [],
745
+ }),
746
+ installed: false,
747
+ status: "not-installed",
748
+ local: null,
749
+ remote: {
750
+ kind: "local",
751
+ repo: "",
752
+ path: "plugins/mcp-catalog/fathom",
753
+ commit: "1.0.0",
754
+ committedAt: null,
755
+ description: null,
756
+ homepage: null,
757
+ license: null,
758
+ category: "productivity",
759
+ marketplaceRef: "main",
760
+ version: "1.0.0",
761
+ },
762
+ };
763
+
764
+ const r = await runCommand(["plugins", "inspect", "fathom"]);
765
+
766
+ expect(r.stdout).toContain("bundled:plugins/mcp-catalog/fathom");
767
+ expect(r.stdout).not.toContain("https://github.com//");
768
+ expect(r.exitCode).toBe(0);
769
+ });
704
770
  });
705
771
 
706
772
  describe("plugins install - setup skill hint", () => {
@@ -235,11 +235,29 @@ export function registerPluginsCommand(program: Command): void {
235
235
  // pinned commit. With platform features disabled (air-gapped /
236
236
  // self-hosted) there is no platform to call, so resolve the pin
237
237
  // from the bundled catalog and install through the GitHub path.
238
- if (libs.catalogLocal.arePlatformFeaturesEnabled()) {
239
- result = await libs.installPlatform.installPluginViaPlatform(
240
- { name: nameOrUrl, force: opts.force },
241
- { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
242
- );
238
+ const platformEnabled =
239
+ libs.catalogLocal.arePlatformFeaturesEnabled();
240
+ if (platformEnabled) {
241
+ const match = (
242
+ await libs.catalogCache.getPluginCatalog(DEFAULT_PLUGIN_REF, {
243
+ fetch: globalThis.fetch.bind(globalThis),
244
+ })
245
+ ).matches.find((candidate) => candidate.name === nameOrUrl);
246
+ if (match?.source.kind === "local") {
247
+ result = await libs.installGitHub.installPlugin(
248
+ {
249
+ name: nameOrUrl,
250
+ force: opts.force,
251
+ trustedSource: match.source,
252
+ },
253
+ { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
254
+ );
255
+ } else {
256
+ result = await libs.installPlatform.installPluginViaPlatform(
257
+ { name: nameOrUrl, force: opts.force },
258
+ { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
259
+ );
260
+ }
243
261
  } else {
244
262
  const source =
245
263
  libs.catalogLocal.resolveBundledPluginSource(nameOrUrl);
@@ -250,19 +268,30 @@ export function registerPluginsCommand(program: Command): void {
250
268
  process.exitCode = 1;
251
269
  return;
252
270
  }
253
- result = await libs.installGitHub.installPlugin(
254
- {
255
- name: nameOrUrl,
256
- force: opts.force,
257
- trustedSource: {
258
- owner: source.owner,
259
- repo: source.repo,
260
- rootPath: source.path,
261
- ref: source.ref,
271
+ if (source.kind === "local") {
272
+ result = await libs.installGitHub.installPlugin(
273
+ {
274
+ name: nameOrUrl,
275
+ force: opts.force,
276
+ trustedSource: source,
262
277
  },
263
- },
264
- { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
265
- );
278
+ { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
279
+ );
280
+ } else {
281
+ result = await libs.installGitHub.installPlugin(
282
+ {
283
+ name: nameOrUrl,
284
+ force: opts.force,
285
+ trustedSource: {
286
+ owner: source.owner,
287
+ repo: source.repo,
288
+ rootPath: source.path,
289
+ ref: source.ref,
290
+ },
291
+ },
292
+ { fetch: globalThis.fetch.bind(globalThis), confirmStaged },
293
+ );
294
+ }
266
295
  }
267
296
  } else {
268
297
  const installOpts = direct
@@ -1390,8 +1419,11 @@ function driftLine(changes: FingerprintComparison | null): string {
1390
1419
  return parts.join(", ");
1391
1420
  }
1392
1421
 
1393
- /** Build the GitHub web URL for a remote pin's location (repo, or repo subtree). */
1422
+ /** Build the display location for a bundled package or GitHub pin. */
1394
1423
  function remoteLocation(remote: PluginRemoteInfo): string {
1424
+ if (remote.kind === "local") {
1425
+ return `bundled:${remote.path}`;
1426
+ }
1395
1427
  const base = `https://github.com/${remote.repo}`;
1396
1428
  return remote.path ? `${base}/tree/${remote.commit}/${remote.path}` : base;
1397
1429
  }
@@ -253,6 +253,73 @@ describe("installPlugin — install lifecycle", () => {
253
253
  rmSync(ws, { recursive: true, force: true });
254
254
  });
255
255
 
256
+ test("installs a bundled standard package and discovers its MCP server", async () => {
257
+ const result = await installPlugin(
258
+ {
259
+ name: "fathom",
260
+ trustedSource: {
261
+ kind: "local",
262
+ path: "plugins/mcp-catalog/fathom",
263
+ version: "1.0.0",
264
+ },
265
+ },
266
+ {
267
+ fetch: (async () => {
268
+ throw new Error("local package install must not fetch");
269
+ }) as FetchLike,
270
+ runGit: unusedGitRunner,
271
+ workspacePluginsDir: pluginsDir,
272
+ materializeLocalPackage: (path, version, destination) => {
273
+ expect(path).toBe("plugins/mcp-catalog/fathom");
274
+ expect(version).toBe("1.0.0");
275
+ writeFileSync(
276
+ join(destination, "plugin.json"),
277
+ JSON.stringify({
278
+ $schema:
279
+ "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
280
+ name: "fathom",
281
+ version: "1.0.0",
282
+ }),
283
+ );
284
+ writeFileSync(
285
+ join(destination, "mcp.json"),
286
+ JSON.stringify({
287
+ $schema:
288
+ "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
289
+ mcpServers: {
290
+ fathom: {
291
+ type: "streamable-http",
292
+ url: "https://api.fathom.ai/mcp",
293
+ },
294
+ },
295
+ }),
296
+ );
297
+ return 2;
298
+ },
299
+ },
300
+ );
301
+
302
+ expect(result).toMatchObject({
303
+ name: "fathom",
304
+ fileCount: 2,
305
+ ref: "1.0.0",
306
+ commit: null,
307
+ });
308
+ expect(readInstallMeta(result.target)?.source).toEqual({
309
+ kind: "local",
310
+ path: "plugins/mcp-catalog/fathom",
311
+ version: "1.0.0",
312
+ });
313
+ expect(
314
+ readPluginMcpServers({ workspacePluginsDir: pluginsDir }).servers,
315
+ ).toEqual([
316
+ expect.objectContaining({
317
+ pluginName: "fathom",
318
+ serverKey: "fathom",
319
+ }),
320
+ ]);
321
+ });
322
+
256
323
  test("refuses to overwrite an existing install without --force", async () => {
257
324
  // GIVEN a plugin already installed at <pluginsDir>/caveman
258
325
  const target = join(pluginsDir, "caveman");