@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
package/Dockerfile CHANGED
@@ -130,13 +130,13 @@ RUN bun run scripts/smoke-container-workspace-dependencies.ts
130
130
  # The repo-root .dockerignore decides which skills (and files) ship.
131
131
  COPY skills /app/skills
132
132
 
133
- # Materialize the git-ignored offline plugin manifest from its canonical source.
134
- # `assistant/src/cli/lib/plugin-catalog-local.ts` statically imports this JSON
135
- # (loaded even when platform features are enabled), so it must exist in the image
136
- # the build installs with --ignore-scripts, so the postinstall sync that
137
- # regenerates it in dev never runs here. Mirrors how CI materializes the
138
- # feature-flag registry before building.
139
- COPY plugins/marketplace.json /app/assistant/src/cli/lib/bundled-marketplace.json
133
+ # Materialize the git-ignored offline marketplace and reviewed local packages.
134
+ # The build installs with --ignore-scripts, so the postinstall sync that creates
135
+ # these static imports in development never runs here.
136
+ COPY plugins /app/plugins
137
+ COPY meta/sync-bundled-copies.ts /app/meta/
138
+ RUN cd /app && bun run meta/sync-bundled-copies.ts --plugins-only && \
139
+ bun run assistant/scripts/generate-bundled-plugin-packages.ts
140
140
 
141
141
  # Install assistant CLI launcher backed by the bundled assistant package
142
142
  RUN printf '#!/usr/bin/env sh\nexec bun run /app/assistant/src/index.ts "$@"\n' > /usr/local/bin/assistant && \
package/openapi.yaml CHANGED
@@ -26622,26 +26622,44 @@ paths:
26622
26622
  description: Resolved version (installed copy first, then repo `package.json`).
26623
26623
  source:
26624
26624
  anyOf:
26625
- - type: object
26626
- properties:
26627
- kind:
26628
- type: string
26629
- const: github
26630
- repo:
26631
- type: string
26632
- description: "`owner/repo` of the external plugin repository."
26633
- path:
26634
- description: Directory within the repo, when the plugin is not at the root.
26635
- type: string
26636
- ref:
26637
- type: string
26638
- description: Pinned git ref the plugin is fetched from.
26639
- required:
26640
- - kind
26641
- - repo
26642
- - ref
26643
- additionalProperties: false
26644
- description: "Origin of the match: a whitelisted external plugin repository."
26625
+ - oneOf:
26626
+ - type: object
26627
+ properties:
26628
+ kind:
26629
+ type: string
26630
+ const: github
26631
+ repo:
26632
+ type: string
26633
+ description: "`owner/repo` of the external plugin repository."
26634
+ path:
26635
+ description: Directory within the repo, when the plugin is not at the root.
26636
+ type: string
26637
+ ref:
26638
+ type: string
26639
+ description: Pinned git ref the plugin is fetched from.
26640
+ required:
26641
+ - kind
26642
+ - repo
26643
+ - ref
26644
+ additionalProperties: false
26645
+ - type: object
26646
+ properties:
26647
+ kind:
26648
+ type: string
26649
+ const: local
26650
+ path:
26651
+ type: string
26652
+ description: Package key in the assistant bundle.
26653
+ version:
26654
+ type: string
26655
+ description: Bundled package version.
26656
+ required:
26657
+ - kind
26658
+ - path
26659
+ - version
26660
+ additionalProperties: false
26661
+ description: Reviewed source of the marketplace entry.
26662
+ type: object
26645
26663
  - type: "null"
26646
26664
  description: Pinned origin from the marketplace entry, or null when an installed copy has no catalog entry.
26647
26665
  readme:
@@ -26996,28 +27014,42 @@ paths:
26996
27014
  description: ISO-8601 install timestamp from the sidecar; null when absent.
26997
27015
  source:
26998
27016
  anyOf:
26999
- - type: object
27000
- properties:
27001
- kind:
27002
- type: string
27003
- description: Source kind. Only `github` is written today.
27004
- owner:
27005
- type: string
27006
- repo:
27007
- type: string
27008
- path:
27009
- description: Repo-relative directory holding the plugin root; absent = repo root.
27010
- type: string
27011
- ref:
27012
- type: string
27013
- description: Ref the install resolved through (the pinned commit SHA for marketplace installs).
27014
- required:
27015
- - kind
27016
- - owner
27017
- - repo
27018
- - ref
27019
- additionalProperties: false
27017
+ - oneOf:
27018
+ - type: object
27019
+ properties:
27020
+ kind:
27021
+ type: string
27022
+ const: github
27023
+ owner:
27024
+ type: string
27025
+ repo:
27026
+ type: string
27027
+ path:
27028
+ type: string
27029
+ ref:
27030
+ type: string
27031
+ required:
27032
+ - kind
27033
+ - owner
27034
+ - repo
27035
+ - ref
27036
+ additionalProperties: false
27037
+ - type: object
27038
+ properties:
27039
+ kind:
27040
+ type: string
27041
+ const: local
27042
+ path:
27043
+ type: string
27044
+ version:
27045
+ type: string
27046
+ required:
27047
+ - kind
27048
+ - path
27049
+ - version
27050
+ additionalProperties: false
27020
27051
  description: Source coordinates recorded in the install-time provenance sidecar.
27052
+ type: object
27021
27053
  - type: "null"
27022
27054
  description: Source recorded at install time; null when no sidecar exists.
27023
27055
  localChanges:
@@ -27076,6 +27108,9 @@ paths:
27076
27108
  anyOf:
27077
27109
  - type: object
27078
27110
  properties:
27111
+ kind:
27112
+ type: string
27113
+ const: local
27079
27114
  repo:
27080
27115
  type: string
27081
27116
  description: "`owner/repo` of the external plugin repository."
@@ -27111,6 +27146,9 @@ paths:
27111
27146
  marketplaceRef:
27112
27147
  type: string
27113
27148
  description: Ref of the canonical repo the marketplace manifest was read from.
27149
+ version:
27150
+ description: Bundled package version.
27151
+ type: string
27114
27152
  required:
27115
27153
  - repo
27116
27154
  - path
@@ -27547,7 +27585,7 @@ paths:
27547
27585
  description: Install name. Matches `assistant plugins install <name>`.
27548
27586
  path:
27549
27587
  type: string
27550
- description: "Human-readable origin: a `github:owner/repo@ref` locator for the external plugin."
27588
+ description: Human-readable catalog origin locator.
27551
27589
  description:
27552
27590
  description: Short description, when known (external entries only today).
27553
27591
  type: string
@@ -27561,27 +27599,87 @@ paths:
27561
27599
  - type: string
27562
27600
  - type: "null"
27563
27601
  description: Marketplace category slug (Skills taxonomy); null when the entry declares none.
27564
- source:
27602
+ integration:
27565
27603
  type: object
27566
27604
  properties:
27567
27605
  kind:
27568
27606
  type: string
27569
- const: github
27570
- repo:
27607
+ const: mcp
27608
+ displayName:
27609
+ type: string
27610
+ documentationUrl:
27571
27611
  type: string
27572
- description: "`owner/repo` of the external plugin repository."
27573
- path:
27574
- description: Directory within the repo, when the plugin is not at the root.
27612
+ verifiedAt:
27613
+ type: string
27614
+ verification:
27615
+ type: string
27616
+ const: documentation-only
27617
+ setup:
27618
+ type: object
27619
+ properties:
27620
+ mode:
27621
+ type: string
27622
+ enum:
27623
+ - oauth
27624
+ - manual
27625
+ instructions:
27626
+ type: string
27627
+ required:
27628
+ - mode
27629
+ - instructions
27630
+ additionalProperties: false
27631
+ logo:
27575
27632
  type: string
27576
- ref:
27633
+ oauthProvider:
27577
27634
  type: string
27578
- description: Pinned git ref the plugin is fetched from.
27579
27635
  required:
27580
27636
  - kind
27581
- - repo
27582
- - ref
27637
+ - displayName
27638
+ - documentationUrl
27639
+ - verifiedAt
27640
+ - verification
27641
+ - setup
27642
+ - logo
27583
27643
  additionalProperties: false
27584
- description: "Origin of the match: a whitelisted external plugin repository."
27644
+ source:
27645
+ oneOf:
27646
+ - type: object
27647
+ properties:
27648
+ kind:
27649
+ type: string
27650
+ const: github
27651
+ repo:
27652
+ type: string
27653
+ description: "`owner/repo` of the external plugin repository."
27654
+ path:
27655
+ description: Directory within the repo, when the plugin is not at the root.
27656
+ type: string
27657
+ ref:
27658
+ type: string
27659
+ description: Pinned git ref the plugin is fetched from.
27660
+ required:
27661
+ - kind
27662
+ - repo
27663
+ - ref
27664
+ additionalProperties: false
27665
+ - type: object
27666
+ properties:
27667
+ kind:
27668
+ type: string
27669
+ const: local
27670
+ path:
27671
+ type: string
27672
+ description: Package key in the assistant bundle.
27673
+ version:
27674
+ type: string
27675
+ description: Bundled package version.
27676
+ required:
27677
+ - kind
27678
+ - path
27679
+ - version
27680
+ additionalProperties: false
27681
+ description: Reviewed source of the marketplace entry.
27682
+ type: object
27585
27683
  required:
27586
27684
  - name
27587
27685
  - path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-staging.4",
3
+ "version": "0.12.2-staging.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,16 +24,17 @@
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
25
  "lint:circular": "bun run scripts/check-circular-deps.ts",
26
26
  "lint:unused:production": "knip --production --include exports",
27
- "pretypecheck": "cd .. && bun run meta/sync-bundled-copies.ts",
27
+ "sync:bundled-catalog": "cd .. && bun run meta/sync-bundled-copies.ts && bun run assistant/scripts/generate-bundled-plugin-packages.ts",
28
+ "pretypecheck": "bun run sync:bundled-catalog",
28
29
  "typecheck": "bunx tsc --noEmit",
29
- "pretypecheck:fast": "cd .. && bun run meta/sync-bundled-copies.ts",
30
+ "pretypecheck:fast": "bun run sync:bundled-catalog",
30
31
  "typecheck:fast": "bunx tsgo --noEmit",
31
32
  "test": "bun run scripts/test.ts",
32
33
  "test:coverage": "COVERAGE=true bun run scripts/test.ts",
33
34
  "test:stable": "EXCLUDE_EXPERIMENTAL=true bun run scripts/test.ts",
34
35
  "test:bench": "find src -type f -name '*.benchmark.test.ts' -print0 | xargs -0 -P 1 -I {} bun test {}",
35
36
  "test:filesystem-tools": "bash scripts/test-filesystem-tools.sh",
36
- "postinstall": "cd .. && (git config core.hooksPath || git config core.hooksPath .githooks 2>/dev/null || true) && ([ -f meta/sync-bundled-copies.ts ] && bun run meta/sync-bundled-copies.ts 2>/dev/null || true)"
37
+ "postinstall": "cd .. && (git config core.hooksPath || git config core.hooksPath .githooks 2>/dev/null || true) && ([ -f meta/sync-bundled-copies.ts ] && (bun run meta/sync-bundled-copies.ts && bun run assistant/scripts/generate-bundled-plugin-packages.ts) 2>/dev/null || true)"
37
38
  },
38
39
  "dependencies": {
39
40
  "@agentclientprotocol/sdk": "0.25.0",
@@ -0,0 +1,154 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+
4
+ import {
5
+ AGENT_PLUGINS_MCP_SCHEMA_URL,
6
+ SpecMcpDocumentSchema,
7
+ SpecMcpServerSchema,
8
+ } from "../src/mcp/spec-schema.js";
9
+ import {
10
+ readPluginManifest,
11
+ STANDARD_PLUGIN_MANIFEST,
12
+ } from "../src/util/plugin-manifest.js";
13
+
14
+ export interface BundledPackageFile {
15
+ path: string;
16
+ contentBase64: string;
17
+ }
18
+
19
+ export interface BundledPackage {
20
+ version: string;
21
+ files: BundledPackageFile[];
22
+ }
23
+
24
+ /** Validate and embed every local source in the canonical marketplace. */
25
+ export function buildBundledPluginPackages(
26
+ repoRoot: string,
27
+ ): Record<string, BundledPackage> {
28
+ const marketplacePath = join(repoRoot, "plugins", "marketplace.json");
29
+ const manifest = JSON.parse(readFileSync(marketplacePath, "utf8")) as {
30
+ plugins?: unknown;
31
+ };
32
+ if (!Array.isArray(manifest.plugins)) {
33
+ throw new Error("plugins/marketplace.json is missing its plugins array");
34
+ }
35
+
36
+ const pluginsRoot = realpathSync(join(repoRoot, "plugins"));
37
+ const packages: Record<string, BundledPackage> = {};
38
+ for (const rawEntry of manifest.plugins) {
39
+ if (typeof rawEntry !== "object" || rawEntry === null) {
40
+ continue;
41
+ }
42
+ const entry = rawEntry as Record<string, unknown>;
43
+ const rawSource = entry.source;
44
+ if (typeof rawSource !== "object" || rawSource === null) {
45
+ continue;
46
+ }
47
+ const source = rawSource as Record<string, unknown>;
48
+ if (source.source !== "local") {
49
+ continue;
50
+ }
51
+ if (
52
+ typeof entry.name !== "string" ||
53
+ typeof source.path !== "string" ||
54
+ typeof source.version !== "string"
55
+ ) {
56
+ throw new Error(
57
+ "local marketplace entries require name, path, and version",
58
+ );
59
+ }
60
+
61
+ const pathSegments = source.path.split("/");
62
+ if (
63
+ !source.path.startsWith("plugins/") ||
64
+ source.path.includes("\\") ||
65
+ pathSegments.some(
66
+ (segment) => segment === "" || segment === "." || segment === "..",
67
+ )
68
+ ) {
69
+ throw new Error(
70
+ `local marketplace package path is not canonical: ${source.path}`,
71
+ );
72
+ }
73
+ let packageRoot = resolve(repoRoot);
74
+ for (const segment of pathSegments) {
75
+ packageRoot = join(packageRoot, segment);
76
+ if (lstatSync(packageRoot).isSymbolicLink()) {
77
+ throw new Error(
78
+ `local marketplace package path may not contain symlinks: ${source.path}`,
79
+ );
80
+ }
81
+ }
82
+ const packageRealpath = realpathSync(packageRoot);
83
+ const relativeToPlugins = relative(pluginsRoot, packageRealpath);
84
+ if (
85
+ relativeToPlugins === "" ||
86
+ relativeToPlugins === ".." ||
87
+ relativeToPlugins.startsWith(`..${sep}`)
88
+ ) {
89
+ throw new Error(
90
+ `local marketplace package escapes plugins/: ${source.path}`,
91
+ );
92
+ }
93
+
94
+ const pluginManifest = readPluginManifest(packageRealpath);
95
+ if (
96
+ pluginManifest.source !== STANDARD_PLUGIN_MANIFEST ||
97
+ pluginManifest.name !== entry.name ||
98
+ pluginManifest.version !== source.version
99
+ ) {
100
+ throw new Error(
101
+ `local marketplace package ${source.path} must match entry name and version`,
102
+ );
103
+ }
104
+ const rawMcp = JSON.parse(
105
+ readFileSync(join(packageRealpath, "mcp.json"), "utf8"),
106
+ ) as unknown;
107
+ const mcp = SpecMcpDocumentSchema.parse(rawMcp);
108
+ if (mcp.$schema !== AGENT_PLUGINS_MCP_SCHEMA_URL) {
109
+ throw new Error(
110
+ `local marketplace package ${source.path} must use the Agent Plugins MCP schema`,
111
+ );
112
+ }
113
+ if (Object.keys(mcp.mcpServers).length === 0) {
114
+ throw new Error(
115
+ `local marketplace package ${source.path} must declare an MCP server`,
116
+ );
117
+ }
118
+ for (const server of Object.values(mcp.mcpServers)) {
119
+ SpecMcpServerSchema.parse(server);
120
+ }
121
+
122
+ const files: BundledPackageFile[] = [];
123
+ const visit = (directory: string): void => {
124
+ for (const child of readdirSync(directory, { withFileTypes: true })) {
125
+ const childPath = join(directory, child.name);
126
+ if (child.isSymbolicLink() || lstatSync(childPath).isSymbolicLink()) {
127
+ throw new Error(
128
+ `local marketplace packages may not contain symlinks: ${source.path}/${child.name}`,
129
+ );
130
+ }
131
+ if (child.isDirectory()) {
132
+ visit(childPath);
133
+ continue;
134
+ }
135
+ if (!child.isFile()) {
136
+ throw new Error(
137
+ `local marketplace package contains an unsupported entry: ${childPath}`,
138
+ );
139
+ }
140
+ files.push({
141
+ path: relative(packageRealpath, childPath).split(sep).join("/"),
142
+ contentBase64: readFileSync(childPath).toString("base64"),
143
+ });
144
+ }
145
+ };
146
+ visit(packageRealpath);
147
+ files.sort((a, b) => a.path.localeCompare(b.path));
148
+ if (packages[source.path]) {
149
+ throw new Error(`duplicate local marketplace package: ${source.path}`);
150
+ }
151
+ packages[source.path] = { version: source.version, files };
152
+ }
153
+ return packages;
154
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+
6
+ import { buildBundledPluginPackages } from "./bundled-plugin-packages.js";
7
+
8
+ const repoRoot = resolve(import.meta.dir, "../..");
9
+ const output = {
10
+ version: 1,
11
+ packages: buildBundledPluginPackages(repoRoot),
12
+ };
13
+
14
+ if (process.argv.includes("--check")) {
15
+ console.log("✓ Local marketplace packages are valid");
16
+ } else {
17
+ writeFileSync(
18
+ join(
19
+ repoRoot,
20
+ "assistant",
21
+ "src",
22
+ "cli",
23
+ "lib",
24
+ "bundled-plugin-packages.json",
25
+ ),
26
+ `${JSON.stringify(output, null, 2)}\n`,
27
+ );
28
+ console.log("✓ Generated bundled local plugin packages");
29
+ }
package/scripts/test.ts CHANGED
@@ -62,26 +62,28 @@ const EXPERIMENTAL_FILES = new Set([
62
62
  const KNOWN_BROKEN_FILES = new Set<string>([]);
63
63
 
64
64
  // ---------------------------------------------------------------------------
65
- // Feature-flag registry sync
65
+ // Bundled catalog sync
66
66
  // ---------------------------------------------------------------------------
67
67
 
68
- // Ensure the bundled feature-flag-registry.json exists before running tests.
69
- // The canonical copy lives at meta/feature-flags/feature-flag-registry.json and
70
- // is synced into assistant/src/ and gateway/src/. CI runs this as a dedicated
71
- // step; locally postinstall handles it — but when node_modules is symlinked
72
- // (e.g. worktrees) postinstall never fires, so the bundled copy can be missing
73
- // and feature-flag-registry-bundled.test.ts fails. Running the sync here is
74
- // idempotent and cheap (two file copies).
75
- function syncFeatureFlagRegistry(): void {
68
+ // Ensure generated catalog inputs exist before tests. Locally, postinstall may
69
+ // not run in worktrees that share node_modules, so the static JSON imports can
70
+ // otherwise be missing.
71
+ function syncBundledCatalog(): void {
76
72
  const syncScript = join("..", "meta", "sync-bundled-copies.ts");
77
73
  if (!existsSync(syncScript)) {
78
74
  return;
79
75
  }
80
- Bun.spawnSync(["bun", "run", "meta/sync-bundled-copies.ts"], {
81
- cwd: join(process.cwd(), ".."),
76
+ const result = Bun.spawnSync(["bun", "run", "sync:bundled-catalog"], {
77
+ cwd: process.cwd(),
82
78
  stdout: "ignore",
83
- stderr: "ignore",
79
+ stderr: "pipe",
80
+ windowsHide: true,
84
81
  });
82
+ if (result.exitCode !== 0) {
83
+ throw new Error(
84
+ `Failed to generate bundled catalog inputs: ${result.stderr.toString()}`,
85
+ );
86
+ }
85
87
  }
86
88
 
87
89
  // ---------------------------------------------------------------------------
@@ -514,7 +516,7 @@ async function buildMigratedFixture(outWorkspaceDir: string): Promise<void> {
514
516
  // ---------------------------------------------------------------------------
515
517
 
516
518
  async function main(): Promise<void> {
517
- syncFeatureFlagRegistry();
519
+ syncBundledCatalog();
518
520
 
519
521
  let testFiles = collectTestFiles();
520
522
  if (testFiles.length === 0) {
@@ -332,6 +332,127 @@ describe("createManagedSkill", () => {
332
332
  expect(content).toContain("Overwritten");
333
333
  });
334
334
 
335
+ test("an overwrite keeps the frontmatter the call omits and replaces the body", () => {
336
+ createManagedSkill({
337
+ id: "kept",
338
+ name: "Kept",
339
+ description: "V1",
340
+ bodyMarkdown: "Old body.",
341
+ emoji: "📊",
342
+ includes: ["csv-basics"],
343
+ activationHints: ["user asks for the weekly report"],
344
+ avoidWhen: ["the report is monthly"],
345
+ category: "productivity",
346
+ });
347
+
348
+ const result = createManagedSkill({
349
+ id: "kept",
350
+ name: "Kept",
351
+ description: "V2",
352
+ bodyMarkdown: "New body.",
353
+ overwrite: true,
354
+ });
355
+ expect(result.created).toBe(true);
356
+
357
+ const catalog = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]);
358
+ const skill = catalog.find((s) => s.id === "kept")!;
359
+ expect(skill.description).toBe("V2");
360
+ expect(skill.emoji).toBe("📊");
361
+ expect(skill.includes).toEqual(["csv-basics"]);
362
+ expect(skill.activationHints).toEqual(["user asks for the weekly report"]);
363
+ expect(skill.avoidWhen).toEqual(["the report is monthly"]);
364
+ expect(skill.category).toBe("productivity");
365
+ expect(readFileSync(result.path, "utf-8")).toContain("New body.");
366
+ expect(readFileSync(result.path, "utf-8")).not.toContain("Old body.");
367
+ });
368
+
369
+ test("an overwrite carries through frontmatter the tool does not own", () => {
370
+ const dir = join(TEST_DIR, "skills", "hand-edited");
371
+ mkdirSync(dir, { recursive: true });
372
+ writeFileSync(
373
+ join(dir, "SKILL.md"),
374
+ [
375
+ "---",
376
+ 'name: "Hand Edited"',
377
+ 'description: "Has fields the tool never writes"',
378
+ "metadata:",
379
+ " custom: kept",
380
+ " vellum:",
381
+ ' display-name: "Hand Edited Skill"',
382
+ " platforms:",
383
+ " - macos",
384
+ " always-candidate: true",
385
+ ' emoji: "📊"',
386
+ "---",
387
+ "",
388
+ "Old body.",
389
+ "",
390
+ ].join("\n"),
391
+ );
392
+
393
+ const result = createManagedSkill({
394
+ id: "hand-edited",
395
+ name: "Hand Edited",
396
+ description: "V2",
397
+ bodyMarkdown: "New body.",
398
+ overwrite: true,
399
+ category: "productivity",
400
+ });
401
+ expect(result.created).toBe(true);
402
+
403
+ const fm = parseYaml(
404
+ readFileSync(result.path, "utf-8").match(/^---\n([\s\S]*?)\n---/)![1],
405
+ );
406
+ expect(fm.metadata.custom).toBe("kept");
407
+ expect(fm.metadata.vellum["display-name"]).toBe("Hand Edited Skill");
408
+ expect(fm.metadata.vellum.platforms).toEqual(["macos"]);
409
+ expect(fm.metadata.vellum["always-candidate"]).toBe(true);
410
+ expect(fm.metadata.vellum.emoji).toBe("📊");
411
+ expect(fm.metadata.vellum.category).toBe("productivity");
412
+ const skill = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]).find(
413
+ (s) => s.id === "hand-edited",
414
+ )!;
415
+ expect(skill.displayName).toBe("Hand Edited Skill");
416
+ expect(skill.platforms).toEqual(["macos"]);
417
+ expect(skill.alwaysCandidate).toBe(true);
418
+ });
419
+
420
+ test("an overwrite replaces a field it passes and clears one passed empty", () => {
421
+ createManagedSkill({
422
+ id: "patched",
423
+ name: "Patched",
424
+ description: "V1",
425
+ bodyMarkdown: "Body.",
426
+ emoji: "📊",
427
+ includes: ["csv-basics"],
428
+ activationHints: ["old hint"],
429
+ avoidWhen: ["the report is monthly"],
430
+ category: "productivity",
431
+ });
432
+
433
+ createManagedSkill({
434
+ id: "patched",
435
+ name: "Patched",
436
+ description: "V2",
437
+ bodyMarkdown: "Body.",
438
+ overwrite: true,
439
+ emoji: "🚀",
440
+ activationHints: ["new hint"],
441
+ avoidWhen: [],
442
+ category: "",
443
+ });
444
+
445
+ const catalog = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]);
446
+ const skill = catalog.find((s) => s.id === "patched")!;
447
+ expect(skill.emoji).toBe("🚀");
448
+ expect(skill.activationHints).toEqual(["new hint"]);
449
+ // Omitted: kept.
450
+ expect(skill.includes).toEqual(["csv-basics"]);
451
+ // Passed empty: cleared.
452
+ expect(skill.avoidWhen).toBeUndefined();
453
+ expect(skill.category).toBeUndefined();
454
+ });
455
+
335
456
  test("writes category to frontmatter and round-trips through catalog load", () => {
336
457
  createManagedSkill({
337
458
  id: "categorized-skill",