@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
@@ -0,0 +1,169 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+
6
+ import type { FetchLike } from "../fetch-like.js";
7
+ import { installPlugin, readInstallMeta } from "../install-from-github.js";
8
+ import type { PluginCatalog } from "../search-plugins.js";
9
+ import { upgradePlugin } from "../upgrade-plugin.js";
10
+
11
+ const roots: string[] = [];
12
+
13
+ afterEach(() => {
14
+ for (const root of roots.splice(0)) {
15
+ rmSync(root, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ function packageMaterializer(calls: string[]) {
20
+ return (path: string, version: string, destination: string): number => {
21
+ calls.push(`${path}@${version}`);
22
+ mkdirSync(destination, { recursive: true });
23
+ writeFileSync(
24
+ join(destination, "plugin.json"),
25
+ JSON.stringify({
26
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
27
+ name: "fathom",
28
+ version,
29
+ }),
30
+ );
31
+ writeFileSync(
32
+ join(destination, "mcp.json"),
33
+ JSON.stringify({
34
+ $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
35
+ mcpServers: {
36
+ fathom: {
37
+ type: "streamable-http",
38
+ url: "https://api.fathom.ai/mcp",
39
+ },
40
+ },
41
+ }),
42
+ );
43
+ return 2;
44
+ };
45
+ }
46
+
47
+ function marketplaceFetch(version: string): FetchLike {
48
+ return (async () =>
49
+ new Response(
50
+ JSON.stringify({
51
+ name: "vellum-assistant",
52
+ plugins: [
53
+ {
54
+ name: "fathom",
55
+ source: {
56
+ source: "local",
57
+ path: "plugins/mcp-catalog/fathom",
58
+ version,
59
+ },
60
+ },
61
+ ],
62
+ }),
63
+ { status: 200 },
64
+ )) as FetchLike;
65
+ }
66
+
67
+ function localCatalog(version: string): PluginCatalog {
68
+ return {
69
+ ref: "bundled",
70
+ matches: [
71
+ {
72
+ name: "fathom",
73
+ path: `local:plugins/mcp-catalog/fathom@${version}`,
74
+ category: null,
75
+ source: {
76
+ kind: "local",
77
+ path: "plugins/mcp-catalog/fathom",
78
+ version,
79
+ },
80
+ },
81
+ ],
82
+ };
83
+ }
84
+
85
+ describe("bundled plugin upgrades", () => {
86
+ test("ignores a newer live manifest version the current binary cannot materialize", async () => {
87
+ const root = mkdtempSync(join(tmpdir(), "local-plugin-upgrade-"));
88
+ roots.push(root);
89
+ const pluginsDir = join(root, "plugins");
90
+ const calls: string[] = [];
91
+ const materializeLocalPackage = packageMaterializer(calls);
92
+
93
+ await installPlugin(
94
+ {
95
+ name: "fathom",
96
+ trustedSource: {
97
+ kind: "local",
98
+ path: "plugins/mcp-catalog/fathom",
99
+ version: "1.0.0",
100
+ },
101
+ },
102
+ {
103
+ fetch: marketplaceFetch("1.0.0"),
104
+ workspacePluginsDir: pluginsDir,
105
+ materializeLocalPackage,
106
+ },
107
+ );
108
+
109
+ const result = await upgradePlugin(
110
+ { name: "fathom" },
111
+ {
112
+ fetch: marketplaceFetch("9.0.0"),
113
+ workspacePluginsDir: pluginsDir,
114
+ materializeLocalPackage,
115
+ localCatalog: localCatalog("1.0.0"),
116
+ beforeSwap: async () => {},
117
+ },
118
+ );
119
+
120
+ expect(result.outcome).toBe("already-up-to-date");
121
+ expect(calls).toEqual(["plugins/mcp-catalog/fathom@1.0.0"]);
122
+ });
123
+
124
+ test("materializes the exact inspected path and version", async () => {
125
+ const root = mkdtempSync(join(tmpdir(), "local-plugin-upgrade-"));
126
+ roots.push(root);
127
+ const pluginsDir = join(root, "plugins");
128
+ const calls: string[] = [];
129
+ const materializeLocalPackage = packageMaterializer(calls);
130
+
131
+ await installPlugin(
132
+ {
133
+ name: "fathom",
134
+ trustedSource: {
135
+ kind: "local",
136
+ path: "plugins/mcp-catalog/fathom",
137
+ version: "1.0.0",
138
+ },
139
+ },
140
+ {
141
+ fetch: marketplaceFetch("1.0.0"),
142
+ workspacePluginsDir: pluginsDir,
143
+ materializeLocalPackage,
144
+ },
145
+ );
146
+
147
+ const result = await upgradePlugin(
148
+ { name: "fathom" },
149
+ {
150
+ fetch: marketplaceFetch("1.1.0"),
151
+ workspacePluginsDir: pluginsDir,
152
+ materializeLocalPackage,
153
+ localCatalog: localCatalog("1.1.0"),
154
+ beforeSwap: async () => {},
155
+ },
156
+ );
157
+
158
+ expect(result.outcome).toBe("upgraded");
159
+ expect(calls).toEqual([
160
+ "plugins/mcp-catalog/fathom@1.0.0",
161
+ "plugins/mcp-catalog/fathom@1.1.0",
162
+ ]);
163
+ expect(readInstallMeta(result.target)?.source).toEqual({
164
+ kind: "local",
165
+ path: "plugins/mcp-catalog/fathom",
166
+ version: "1.1.0",
167
+ });
168
+ });
169
+ });
@@ -20,10 +20,12 @@ import type { FetchLike } from "../fetch-like.js";
20
20
  import {
21
21
  getPluginCatalog,
22
22
  invalidatePluginCatalogCache,
23
+ mergePlatformCatalogWithBundledLocals,
23
24
  PLUGIN_CATALOG_CACHE_TTL_MS,
24
25
  } from "../plugin-catalog-cache.js";
25
26
  import { readBundledPluginCatalog } from "../plugin-catalog-local.js";
26
27
  import type { SearchPluginsDeps } from "../search-plugins.js";
28
+ import type { PluginCatalog } from "../search-plugins.js";
27
29
 
28
30
  // A non-zero base time. Bun treats `setSystemTime(new Date(0))` as "reset to
29
31
  // the real clock", so the fake clock must start from a positive epoch.
@@ -166,3 +168,58 @@ describe("getPluginCatalog", () => {
166
168
  expect(result.ref).toBe("main");
167
169
  });
168
170
  });
171
+
172
+ describe("mergePlatformCatalogWithBundledLocals", () => {
173
+ test("adds local packages while keeping a platform row on name collision", () => {
174
+ const platform: PluginCatalog = {
175
+ ref: "main",
176
+ matches: [
177
+ {
178
+ name: "fathom",
179
+ path: "github:provider/fathom@pin",
180
+ category: null,
181
+ source: {
182
+ kind: "github",
183
+ repo: "provider/fathom",
184
+ ref: "0".repeat(40),
185
+ },
186
+ },
187
+ ],
188
+ };
189
+ const bundled: PluginCatalog = {
190
+ ref: "bundled",
191
+ matches: [
192
+ {
193
+ name: "fathom",
194
+ path: "local:plugins/mcp-catalog/fathom@1.0.0",
195
+ category: null,
196
+ source: {
197
+ kind: "local",
198
+ path: "plugins/mcp-catalog/fathom",
199
+ version: "1.0.0",
200
+ },
201
+ },
202
+ {
203
+ name: "notion",
204
+ path: "local:plugins/mcp-catalog/notion@1.0.0",
205
+ category: null,
206
+ source: {
207
+ kind: "local",
208
+ path: "plugins/mcp-catalog/notion",
209
+ version: "1.0.0",
210
+ },
211
+ },
212
+ ],
213
+ };
214
+
215
+ const merged = mergePlatformCatalogWithBundledLocals(platform, bundled);
216
+
217
+ expect(merged.matches.map((match) => match.name)).toEqual([
218
+ "fathom",
219
+ "notion",
220
+ ]);
221
+ expect(merged.matches[0]?.source.kind).toBe("github");
222
+ expect(merged.matches[1]?.source.kind).toBe("local");
223
+ expect(merged.ref).toBe("main");
224
+ });
225
+ });
@@ -41,6 +41,15 @@ describe("fetchPluginCatalogFromPlatform", () => {
41
41
  homepage: "https://example.com",
42
42
  license: "MIT",
43
43
  icon: "🧠",
44
+ integration: {
45
+ kind: "mcp",
46
+ displayName: "Memory Graph",
47
+ documentationUrl: "https://example.com/docs",
48
+ verifiedAt: "2026-09-10",
49
+ verification: "documentation-only",
50
+ setup: { mode: "oauth", instructions: "Sign in." },
51
+ logo: "memory-graph.png",
52
+ },
44
53
  // dropped keys
45
54
  id: "abc",
46
55
  display_name: "Memory Graph",
@@ -69,6 +78,11 @@ describe("fetchPluginCatalogFromPlatform", () => {
69
78
  category: "productivity",
70
79
  homepage: "https://example.com",
71
80
  license: "MIT",
81
+ integration: {
82
+ kind: "mcp",
83
+ displayName: "Memory Graph",
84
+ logo: "memory-graph.png",
85
+ },
72
86
  source: {
73
87
  kind: "github",
74
88
  repo: "acme/memory-graph",
@@ -26,7 +26,9 @@ const FULL_SHA = "63a91ecadbf4c4719a4602a5abb00883f9966034";
26
26
 
27
27
  /** A synthetic catalog match with an overridable source. */
28
28
  function match(
29
- source: Partial<PluginSearchMatch["source"]> = {},
29
+ source: Partial<
30
+ Extract<PluginSearchMatch["source"], { kind: "github" }>
31
+ > = {},
30
32
  ): PluginSearchMatch {
31
33
  return {
32
34
  name: "example",
@@ -37,12 +39,32 @@ function match(
37
39
  }
38
40
 
39
41
  describe("resolveSourceFromMatch", () => {
42
+ test("returns an exact bundled package path and version", () => {
43
+ expect(
44
+ resolveSourceFromMatch({
45
+ name: "fathom",
46
+ path: "local:plugins/mcp-catalog/fathom@1.0.0",
47
+ category: null,
48
+ source: {
49
+ kind: "local",
50
+ path: "plugins/mcp-catalog/fathom",
51
+ version: "1.0.0",
52
+ },
53
+ }),
54
+ ).toEqual({
55
+ kind: "local",
56
+ path: "plugins/mcp-catalog/fathom",
57
+ version: "1.0.0",
58
+ });
59
+ });
60
+
40
61
  test("returns owner/repo/path/ref for a full-SHA match", () => {
41
62
  expect(
42
63
  resolveSourceFromMatch(
43
64
  match({ repo: "acme/widget", path: "pkg/plugin" }),
44
65
  ),
45
66
  ).toEqual({
67
+ kind: "github",
46
68
  owner: "acme",
47
69
  repo: "widget",
48
70
  path: "pkg/plugin",
@@ -52,6 +74,7 @@ describe("resolveSourceFromMatch", () => {
52
74
 
53
75
  test('defaults path to "" when the source declares none', () => {
54
76
  expect(resolveSourceFromMatch(match())).toEqual({
77
+ kind: "github",
55
78
  owner: "acme",
56
79
  repo: "example",
57
80
  path: "",
@@ -66,6 +89,7 @@ describe("resolveSourceFromMatch", () => {
66
89
  // A repo-root entry (`path: ""` or absent) is valid — the clean-path gate
67
90
  // only applies to a non-empty path, so it must not be rejected.
68
91
  expect(resolveSourceFromMatch(match({ path }))).toEqual({
92
+ kind: "github",
69
93
  owner: "acme",
70
94
  repo: "example",
71
95
  path: "",
@@ -94,6 +118,7 @@ describe("resolveSourceFromMatch", () => {
94
118
 
95
119
  test("resolves a clean nested path", () => {
96
120
  expect(resolveSourceFromMatch(match({ path: "packages/plugin" }))).toEqual({
121
+ kind: "github",
97
122
  owner: "acme",
98
123
  repo: "example",
99
124
  path: "packages/plugin",
@@ -157,6 +182,7 @@ describe("catalog-backed resolvers (bundled, offline)", () => {
157
182
  expect(
158
183
  await resolvePluginSourceFromCatalog("caveman", rejectingDeps),
159
184
  ).toEqual({
185
+ kind: "github",
160
186
  owner: "JuliusBrussee",
161
187
  repo: "caveman",
162
188
  path: "",
@@ -108,12 +108,19 @@ function splitOnce(s: string, sep: string): [string, string] {
108
108
  }
109
109
 
110
110
  /** The bundled catalog entry for {@link name} — the source under test. */
111
- function bundledMatch(name: string): PluginSearchMatch {
111
+ function bundledMatch(name: string): PluginSearchMatch & {
112
+ source: Extract<PluginSearchMatch["source"], { kind: "github" }>;
113
+ } {
112
114
  const match = readBundledPluginCatalog().matches.find((m) => m.name === name);
113
115
  if (!match) {
114
116
  throw new Error(`bundled catalog has no entry for "${name}"`);
115
117
  }
116
- return match;
118
+ if (match.source.kind !== "github") {
119
+ throw new Error(`bundled catalog entry "${name}" is not GitHub-backed`);
120
+ }
121
+ return match as PluginSearchMatch & {
122
+ source: Extract<PluginSearchMatch["source"], { kind: "github" }>;
123
+ };
117
124
  }
118
125
 
119
126
  const PNG_SIGNATURE = Buffer.from([
@@ -81,6 +81,24 @@ describe("fetchMarketplaceEntries", () => {
81
81
  expect(entries[1]!.source.path).toBe("packages/nested");
82
82
  });
83
83
 
84
+ test("skips an entry with an unknown future source kind", async () => {
85
+ const fetch = manifestFetch({
86
+ ...VALID_MANIFEST,
87
+ plugins: [
88
+ VALID_MANIFEST.plugins[0],
89
+ {
90
+ name: "future-plugin",
91
+ source: { source: "registry", package: "future-plugin" },
92
+ },
93
+ VALID_MANIFEST.plugins[1],
94
+ ],
95
+ });
96
+
97
+ const entries = await fetchMarketplaceEntries({ fetch }, { ref: "main" });
98
+
99
+ expect(entries.map((entry) => entry.name)).toEqual(["caveman", "nested"]);
100
+ });
101
+
84
102
  test("forwards the ref to the GitHub Contents API", async () => {
85
103
  // GIVEN a fixture that records the ref query parameter
86
104
  let seenRef: string | undefined;
@@ -334,6 +352,7 @@ describe("resolveMarketplaceSource", () => {
334
352
 
335
353
  // THEN the owner/repo split out and path defaults to repo root
336
354
  expect(resolved).toEqual({
355
+ kind: "github",
337
356
  owner: "JuliusBrussee",
338
357
  repo: "caveman",
339
358
  path: "",
@@ -348,6 +367,7 @@ describe("resolveMarketplaceSource", () => {
348
367
 
349
368
  // THEN the sub-path is preserved
350
369
  expect(resolved).toEqual({
370
+ kind: "github",
351
371
  owner: "acme",
352
372
  repo: "monorepo",
353
373
  path: "packages/nested",
@@ -22,12 +22,19 @@ import {
22
22
  import { Command } from "commander";
23
23
 
24
24
  import type { InstallPluginOptions } from "../install-from-github.js";
25
+ import type { PluginMatchSource } from "../search-plugins.js";
25
26
 
26
27
  const installPluginCalls: InstallPluginOptions[] = [];
27
28
  const platformInstallCalls: Array<{ name: string; force?: boolean }> = [];
28
29
 
29
30
  const realGithub = await import("../install-from-github.js");
30
31
  const realPlatform = await import("../install-from-platform.js");
32
+ const realCatalogCache = await import("../plugin-catalog-cache.js");
33
+ let platformCatalogSource: PluginMatchSource = {
34
+ kind: "github",
35
+ repo: "JuliusBrussee/caveman",
36
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
37
+ };
31
38
 
32
39
  mock.module("../install-from-github.js", () => ({
33
40
  ...realGithub,
@@ -59,6 +66,21 @@ mock.module("../install-from-platform.js", () => ({
59
66
  },
60
67
  }));
61
68
 
69
+ mock.module("../plugin-catalog-cache.js", () => ({
70
+ ...realCatalogCache,
71
+ getPluginCatalog: async () => ({
72
+ ref: "main",
73
+ matches: [
74
+ {
75
+ name: BUNDLED_PLUGIN,
76
+ path: "fixture",
77
+ category: null,
78
+ source: platformCatalogSource,
79
+ },
80
+ ],
81
+ }),
82
+ }));
83
+
62
84
  const { registerPluginsCommand } = await import("../../commands/plugins.js");
63
85
 
64
86
  /** A plugin known to live in the bundled marketplace manifest. */
@@ -90,6 +112,11 @@ describe("plugins install by name — disable-platform mode", () => {
90
112
  installPluginCalls.length = 0;
91
113
  platformInstallCalls.length = 0;
92
114
  process.exitCode = undefined;
115
+ platformCatalogSource = {
116
+ kind: "github",
117
+ repo: "JuliusBrussee/caveman",
118
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
119
+ };
93
120
  spyOn(console, "log").mockImplementation(() => {});
94
121
  spyOn(console, "error").mockImplementation(() => {});
95
122
  });
@@ -156,4 +183,20 @@ describe("plugins install by name — disable-platform mode", () => {
156
183
  expect(platformInstallCalls[0]!.name).toBe(BUNDLED_PLUGIN);
157
184
  expect(process.exitCode).not.toBe(1);
158
185
  });
186
+
187
+ test("installs a platform-visible local package without the platform tar endpoint", async () => {
188
+ delete process.env.VELLUM_DISABLE_PLATFORM;
189
+ delete process.env.IS_PLATFORM;
190
+ platformCatalogSource = {
191
+ kind: "local",
192
+ path: "plugins/mcp-catalog/fathom",
193
+ version: "1.0.0",
194
+ };
195
+
196
+ await runInstall(BUNDLED_PLUGIN);
197
+
198
+ expect(platformInstallCalls).toHaveLength(0);
199
+ expect(installPluginCalls).toHaveLength(1);
200
+ expect(installPluginCalls[0]?.trustedSource).toEqual(platformCatalogSource);
201
+ });
159
202
  });
@@ -151,6 +151,37 @@ describe("assertValidSearchPattern", () => {
151
151
  });
152
152
 
153
153
  describe("marketplaceMatch", () => {
154
+ test("projects a bundled MCP package and its integration metadata", () => {
155
+ const match = marketplaceMatch({
156
+ name: "fathom",
157
+ source: {
158
+ source: "local",
159
+ path: "plugins/mcp-catalog/fathom",
160
+ version: "1.0.0",
161
+ },
162
+ integration: {
163
+ kind: "mcp",
164
+ displayName: "Fathom",
165
+ documentationUrl: "https://developers.fathom.ai/mcp-docs",
166
+ verifiedAt: "2026-09-10",
167
+ verification: "documentation-only",
168
+ setup: { mode: "oauth", instructions: "Sign in to Fathom." },
169
+ logo: "fathom.png",
170
+ },
171
+ });
172
+
173
+ expect(match).toMatchObject({
174
+ name: "fathom",
175
+ path: "local:plugins/mcp-catalog/fathom@1.0.0",
176
+ integration: { kind: "mcp", logo: "fathom.png" },
177
+ source: {
178
+ kind: "local",
179
+ path: "plugins/mcp-catalog/fathom",
180
+ version: "1.0.0",
181
+ },
182
+ });
183
+ });
184
+
154
185
  test("projects a root entry onto a github source with a display locator", () => {
155
186
  expect(
156
187
  marketplaceMatch(
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": 1,
3
+ "packages": {}
4
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Materialize reviewed local marketplace packages from the generated bundle.
3
+ * Package keys come from plugins/marketplace.json and never resolve against a
4
+ * caller-controlled filesystem root.
5
+ */
6
+
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
10
+ import bundledPackages from "./bundled-plugin-packages.json" with { type: "json" };
11
+
12
+ interface BundledPackageFile {
13
+ readonly path: string;
14
+ readonly contentBase64: string;
15
+ }
16
+
17
+ interface BundledPackage {
18
+ readonly version: string;
19
+ readonly files: readonly BundledPackageFile[];
20
+ }
21
+
22
+ const packages = bundledPackages.packages as Record<string, BundledPackage>;
23
+
24
+ export class BundledPluginPackageError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "BundledPluginPackageError";
28
+ }
29
+ }
30
+
31
+ /** Return the embedded package when its path and version match the catalog. */
32
+ export function getBundledPluginPackage(
33
+ path: string,
34
+ version: string,
35
+ ): BundledPackage | null {
36
+ const pluginPackage = packages[path];
37
+ return pluginPackage?.version === version ? pluginPackage : null;
38
+ }
39
+
40
+ /** Read one UTF-8 file from an exact embedded package. */
41
+ export function readBundledPluginFile(
42
+ path: string,
43
+ version: string,
44
+ filePath: string,
45
+ ): string | null {
46
+ const file = getBundledPluginPackage(path, version)?.files.find(
47
+ (candidate) => candidate.path === filePath,
48
+ );
49
+ return file
50
+ ? Buffer.from(file.contentBase64, "base64").toString("utf8")
51
+ : null;
52
+ }
53
+
54
+ /** Copy one exact embedded package into an existing staging directory. */
55
+ export function materializeBundledPluginPackage(
56
+ path: string,
57
+ version: string,
58
+ destination: string,
59
+ ): number {
60
+ const pluginPackage = getBundledPluginPackage(path, version);
61
+ if (!pluginPackage) {
62
+ throw new BundledPluginPackageError(
63
+ `Bundled plugin package ${path} @ ${version} is unavailable`,
64
+ );
65
+ }
66
+
67
+ for (const file of pluginPackage.files) {
68
+ if (
69
+ file.path.length === 0 ||
70
+ file.path.startsWith("/") ||
71
+ file.path.includes("\\") ||
72
+ file.path
73
+ .split("/")
74
+ .some(
75
+ (segment) => segment === "." || segment === ".." || segment === "",
76
+ )
77
+ ) {
78
+ throw new BundledPluginPackageError(
79
+ `Bundled plugin package ${path} contains an unsafe file path`,
80
+ );
81
+ }
82
+ const target = join(destination, ...file.path.split("/"));
83
+ mkdirSync(dirname(target), { recursive: true });
84
+ writeFileSync(target, Buffer.from(file.contentBase64, "base64"));
85
+ }
86
+ return pluginPackage.files.length;
87
+ }
@@ -290,7 +290,7 @@ export async function diffPlugin(
290
290
 
291
291
  const meta = readInstallMeta(target);
292
292
  const commit = meta?.commit ?? null;
293
- if (!meta || !commit) {
293
+ if (!meta || meta.source.kind !== "github" || !commit) {
294
294
  throw new PluginDiffUnavailableError(
295
295
  name,
296
296
  `no install commit was recorded (an older or manually-copied install); reinstall with 'assistant plugins install ${name} --force' to record provenance`,