toolfactory 0.1.1 → 0.2.0

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 (110) hide show
  1. package/README.md +31 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/commands.d.ts +1 -1
  4. package/dist/distribution/native.d.ts +50 -0
  5. package/dist/distribution/native.js +414 -0
  6. package/dist/distribution/native.test.d.ts +1 -0
  7. package/dist/distribution/native.test.js +127 -0
  8. package/dist/distribution/system.d.ts +57 -0
  9. package/dist/distribution/system.js +637 -0
  10. package/dist/distribution/system.test.d.ts +1 -0
  11. package/dist/distribution/system.test.js +116 -0
  12. package/dist/e2e.test.js +7 -1
  13. package/dist/hosts/openclaw.js +6 -2
  14. package/dist/hosts/vscode.d.ts +1 -0
  15. package/dist/hosts/vscode.js +67 -0
  16. package/dist/model.d.ts +63 -2
  17. package/dist/model.js +18 -1
  18. package/dist/ops.d.ts +52 -0
  19. package/dist/project/apply.d.ts +1 -1
  20. package/dist/project/apply.js +194 -17
  21. package/dist/project/apply.test.js +103 -0
  22. package/dist/project/gate.d.ts +17 -2
  23. package/dist/project/gate.js +102 -23
  24. package/dist/project/history.d.ts +30 -1
  25. package/dist/project/lock.d.ts +5 -0
  26. package/dist/project/lock.js +9 -0
  27. package/dist/surfaces/agent-plugins.js +5 -4
  28. package/dist/surfaces/agents.js +27 -3
  29. package/dist/surfaces/agents.test.js +12 -0
  30. package/dist/surfaces/claude.js +8 -3
  31. package/dist/surfaces/codex.js +9 -4
  32. package/dist/surfaces/codex.test.js +16 -6
  33. package/dist/surfaces/cursor.js +2 -2
  34. package/dist/surfaces/distribution.test.js +121 -7
  35. package/dist/surfaces/dsh.js +29 -7
  36. package/dist/surfaces/dsh.test.js +11 -6
  37. package/dist/surfaces/gemini.js +2 -2
  38. package/dist/surfaces/gemini.test.js +4 -4
  39. package/dist/surfaces/hermes-native.js +43 -12
  40. package/dist/surfaces/hermes-native.test.js +53 -11
  41. package/dist/surfaces/mcp-registry.js +59 -7
  42. package/dist/surfaces/mcp-registry.test.js +23 -0
  43. package/dist/surfaces/mcpb.d.ts +1 -0
  44. package/dist/surfaces/mcpb.js +36 -23
  45. package/dist/surfaces/mcpb.test.js +31 -3
  46. package/dist/surfaces/npm.js +45 -14
  47. package/dist/surfaces/openclaw-native.js +10 -8
  48. package/dist/surfaces/openclaw-native.test.js +30 -0
  49. package/dist/surfaces/pypi.js +27 -3
  50. package/dist/surfaces/readme.d.ts +16 -0
  51. package/dist/surfaces/readme.js +17 -7
  52. package/dist/surfaces/registry.js +9 -3
  53. package/dist/surfaces/vscode-extension.d.ts +14 -0
  54. package/dist/surfaces/vscode-extension.js +312 -0
  55. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  56. package/dist/surfaces/vscode-extension.test.js +81 -0
  57. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  58. package/dist/surfaces/vscode-scaffold.js +43 -0
  59. package/dist/surfaces/workflows.js +57 -11
  60. package/dist/surfaces/workflows.test.js +71 -3
  61. package/dist/toolfactory/cli.js +1 -1
  62. package/dist/toolfactory/mcp.js +1 -1
  63. package/package.json +2 -1
  64. package/src/bindings/python.ts +1 -1
  65. package/src/distribution/native.test.ts +161 -0
  66. package/src/distribution/native.ts +460 -0
  67. package/src/distribution/system.test.ts +136 -0
  68. package/src/distribution/system.ts +688 -0
  69. package/src/e2e.test.ts +7 -1
  70. package/src/hosts/openclaw.ts +4 -2
  71. package/src/hosts/vscode.ts +79 -0
  72. package/src/model.ts +26 -2
  73. package/src/project/apply.test.ts +120 -0
  74. package/src/project/apply.ts +257 -15
  75. package/src/project/gate.ts +131 -24
  76. package/src/project/lock.ts +12 -0
  77. package/src/surfaces/agent-plugins.ts +5 -4
  78. package/src/surfaces/agents.test.ts +16 -0
  79. package/src/surfaces/agents.ts +38 -3
  80. package/src/surfaces/claude.ts +7 -3
  81. package/src/surfaces/codex.test.ts +15 -5
  82. package/src/surfaces/codex.ts +12 -6
  83. package/src/surfaces/cursor.ts +2 -10
  84. package/src/surfaces/distribution.test.ts +141 -6
  85. package/src/surfaces/dsh.test.ts +11 -5
  86. package/src/surfaces/dsh.ts +30 -7
  87. package/src/surfaces/gemini.test.ts +3 -3
  88. package/src/surfaces/gemini.ts +1 -2
  89. package/src/surfaces/hermes-native.test.ts +61 -10
  90. package/src/surfaces/hermes-native.ts +46 -13
  91. package/src/surfaces/mcp-registry.test.ts +28 -0
  92. package/src/surfaces/mcp-registry.ts +66 -8
  93. package/src/surfaces/mcpb.test.ts +32 -5
  94. package/src/surfaces/mcpb.ts +44 -23
  95. package/src/surfaces/npm.ts +47 -15
  96. package/src/surfaces/openclaw-native.test.ts +37 -0
  97. package/src/surfaces/openclaw-native.ts +49 -47
  98. package/src/surfaces/pypi.ts +27 -3
  99. package/src/surfaces/readme.ts +21 -6
  100. package/src/surfaces/registry.ts +9 -3
  101. package/src/surfaces/vscode-extension.test.ts +89 -0
  102. package/src/surfaces/vscode-extension.ts +345 -0
  103. package/src/surfaces/vscode-scaffold.ts +55 -0
  104. package/src/surfaces/workflows.test.ts +86 -3
  105. package/src/surfaces/workflows.ts +57 -11
  106. package/src/toolfactory/cli.ts +1 -1
  107. package/src/toolfactory/mcp.ts +1 -1
  108. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  109. package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
  110. package/web/dist/index.html +2 -2
@@ -9,6 +9,18 @@
9
9
  * A step here is a command and nothing else. GitHub Actions `uses:` steps are runner provisioning
10
10
  * (checkout, toolchains, caches) and stay in `src/surfaces/workflows.ts`, which wraps this list.
11
11
  */
12
+
13
+ import {
14
+ type NativePackageId,
15
+ nativePackageSteps,
16
+ nativeRegistryRows,
17
+ nativeToolchainSteps,
18
+ } from "../distribution/native.js";
19
+ import {
20
+ type SystemPackageId,
21
+ systemPackageSteps,
22
+ systemRegistryRows,
23
+ } from "../distribution/system.js";
12
24
  import { githubSlug } from "../hosts/github.js";
13
25
  import { githubOwner, projectName } from "../identity/name.js";
14
26
  import { isInstructionOnly, type PackageManager, type Project, type SurfaceId } from "../model.js";
@@ -28,6 +40,14 @@ import {
28
40
  npmName,
29
41
  pypiName,
30
42
  } from "../surfaces/shared.js";
43
+ import {
44
+ extensionName,
45
+ OVSX_VERSION,
46
+ publisher,
47
+ VSCE_VERSION,
48
+ HOST_DIR as VSCODE_HOST_DIR,
49
+ vsixName,
50
+ } from "../surfaces/vscode-extension.js";
31
51
 
32
52
  export interface GateStep {
33
53
  /** Step label: the CI step name, and the banner the shell script echoes. */
@@ -121,11 +141,18 @@ export function gateSteps(project: Project): GateStep[] {
121
141
  const pm = commands(project);
122
142
  const cli = toolfactoryCli(project);
123
143
  const openclaw = has(project, "openclaw-native");
124
- const steps: GateStep[] = [
125
- ...bootstrapSteps(project),
126
- { name: "toolfactory check", run: `${cli} check` },
127
- outputsStep(project),
128
- ];
144
+ const steps: GateStep[] = [...bootstrapSteps(project)];
145
+ if (!typescript && has(project, "hermes-native")) {
146
+ // Hermes' launcher clears PYTHONPATH and runs its own venv. Its plugin dependency
147
+ // declaration is advisory, so Doctor can import a native Python core only after the
148
+ // project is installed into that interpreter. Run after bootstrap's `uv sync` on CI.
149
+ steps.push({
150
+ name: "Install core into Hermes",
151
+ run: 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .',
152
+ when: "ci",
153
+ });
154
+ }
155
+ steps.push({ name: "toolfactory check", run: `${cli} check` }, outputsStep(project));
129
156
  if (has(project, "claude")) {
130
157
  steps.push({
131
158
  name: "Install Claude Code CLI",
@@ -133,7 +160,11 @@ export function gateSteps(project: Project): GateStep[] {
133
160
  when: "ci",
134
161
  });
135
162
  }
136
- if (has(project, "web") || has(project, "browser-extension")) {
163
+ if (
164
+ has(project, "web") ||
165
+ has(project, "browser-extension") ||
166
+ has(project, "vscode-extension")
167
+ ) {
137
168
  // The web smoke and the browser extension's own Playwright smoke both drive Chromium;
138
169
  // their own npm installs fetch the browser, the runner needs its system libraries.
139
170
  steps.push({
@@ -142,6 +173,7 @@ export function gateSteps(project: Project): GateStep[] {
142
173
  when: "ci",
143
174
  });
144
175
  }
176
+ steps.push(...nativeToolchainSteps(project));
145
177
  steps.push({
146
178
  name: "toolfactory validate",
147
179
  run: `${cli} validate`,
@@ -153,6 +185,7 @@ export function gateSteps(project: Project): GateStep[] {
153
185
  name: "author tests",
154
186
  run: typescript ? pm.test : "uv run --with pytest pytest -q",
155
187
  });
188
+ if (typescript) steps.push({ name: "author integration tests", run: pm.run("test:integration") });
156
189
  if (openclaw) {
157
190
  // T3, credential-free: one real OpenClaw agent turn against a scripted model. The suite only
158
191
  // exists when `tool.json` `tests.examples` names an operation the plugin carries, which is
@@ -213,13 +246,16 @@ const LEGAL_BUNDLE_PATHS = [
213
246
  * the release workflow's `package` job, which uploads the directory as one artifact. Publishing
214
247
  * to a registry stays a CI concern; producing the artifacts never is.
215
248
  */
216
- export function packageSteps(project: Project): GateStep[] {
249
+ export function packageSteps(project: Project, options: { system?: boolean } = {}): GateStep[] {
217
250
  const cli = toolfactoryCli(project);
218
251
  const name = project.identity.name;
219
252
  const steps: GateStep[] = [
220
253
  { name: "release directory", run: `rm -rf ${RELEASE_DIR} && mkdir -p ${RELEASE_DIR}` },
221
254
  ...bootstrapSteps(project),
222
255
  outputsStep(project),
256
+ ...nativeToolchainSteps(project),
257
+ ...nativePackageSteps(project),
258
+ ...systemPackageSteps(project, { include: options.system !== false }),
223
259
  ];
224
260
  if (has(project, "web")) {
225
261
  steps.push({
@@ -232,27 +268,35 @@ export function packageSteps(project: Project): GateStep[] {
232
268
  if (has(project, "npm")) {
233
269
  steps.push({ name: "npm tarball", run: `npm pack --pack-destination ${RELEASE_DIR}` });
234
270
  }
271
+ if (has(project, "pypi")) {
272
+ // Keep the wheel and sdist apart from the release's npm tarballs, plugin bundles and web
273
+ // archive: PyPA's publisher accepts only Python distribution files. The MCPB stage below
274
+ // unpacks this same canonical sdist rather than reproducing its source-file selection.
275
+ steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}/pypi` });
276
+ }
235
277
  if (has(project, "mcpb")) {
236
- // A bundle root is the published package with its production dependencies installed into
237
- // it, so it is built from the tarball `npm pack` just wrote rather than from the checkout:
238
- // whatever ships to npm is exactly what ships to Claude Desktop. `--ignore-scripts` because
239
- // nothing in a bundle root may run a build; the tarball already carries `dist/`.
240
278
  const stage = "dist/mcpb";
241
- const tarball = npmTarball(project);
242
279
  steps.push({
243
280
  name: "MCPB bundle",
244
- run: [
245
- `rm -rf ${stage} && mkdir -p ${stage}`,
246
- `tar -xzf ${RELEASE_DIR}/${tarball} -C ${stage} --strip-components=1`,
247
- `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
248
- `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
249
- `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
250
- ].join(" && "),
281
+ run:
282
+ project.tool.binding === "python"
283
+ ? [
284
+ `rm -rf ${stage} && mkdir -p ${stage}`,
285
+ `tar -xzf ${RELEASE_DIR}/pypi/*.tar.gz -C ${stage} --strip-components=1`,
286
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
287
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
288
+ ].join(" && ")
289
+ : [
290
+ // The Node bundle root is the published package with production dependencies
291
+ // installed, so whatever ships to npm is exactly what ships to Claude Desktop.
292
+ `rm -rf ${stage} && mkdir -p ${stage}`,
293
+ `tar -xzf ${RELEASE_DIR}/${npmTarball(project)} -C ${stage} --strip-components=1`,
294
+ `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
295
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
296
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
297
+ ].join(" && "),
251
298
  });
252
299
  }
253
- if (has(project, "pypi")) {
254
- steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}` });
255
- }
256
300
  if (has(project, "openclaw-native")) {
257
301
  // ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
258
302
  // to arrive already built (`package_artifact_name`).
@@ -314,6 +358,12 @@ export function packageSteps(project: Project): GateStep[] {
314
358
  });
315
359
  }
316
360
  // COVERAGE.md is tracked; coverage.json is a build output that need not be, so recompute it.
361
+ if (has(project, "vscode-extension")) {
362
+ steps.push({
363
+ name: "VS Code extension VSIX",
364
+ run: `npm --prefix ${VSCODE_HOST_DIR} install && npm --prefix ${VSCODE_HOST_DIR} run vsix && cp ${VSCODE_HOST_DIR}/extension.vsix ${RELEASE_DIR}/${vsixName(project)}`,
365
+ });
366
+ }
317
367
  steps.push({
318
368
  name: "coverage report",
319
369
  run: `cp COVERAGE.md ${RELEASE_DIR}/ && ${cli} coverage > ${RELEASE_DIR}/coverage.json`,
@@ -340,6 +390,8 @@ export function openclawTarball(project: Project): string {
340
390
  export interface Registry {
341
391
  /** Stable id: the release job suffix, the `secrets status` row, the unpublish step name. */
342
392
  id:
393
+ | NativePackageId
394
+ | SystemPackageId
343
395
  | "npm"
344
396
  | "pypi"
345
397
  | "mcp-registry"
@@ -350,7 +402,9 @@ export interface Registry {
350
402
  | "chrome"
351
403
  | "firefox"
352
404
  | "edge"
353
- | "safari";
405
+ | "safari"
406
+ | "vscode-marketplace"
407
+ | "open-vsx";
354
408
  /** The surfaces whose selection publishes here; all must be selected. */
355
409
  surfaces: SurfaceId[];
356
410
  /** Environment names the publish leg consumes (repository scope); [] for OIDC/GITHUB_TOKEN legs. */
@@ -432,6 +486,22 @@ const ASC_SECRETS = ["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_PRIVATE_KEY"];
432
486
  export const RELEASE_SECRET_NAMES = [
433
487
  "NPM_TOKEN",
434
488
  "CLAWHUB_TOKEN",
489
+ "VSCE_PAT",
490
+ "OVSX_PAT",
491
+ "CARGO_REGISTRY_TOKEN",
492
+ "NUGET_API_KEY",
493
+ "MAVEN_CENTRAL_USERNAME",
494
+ "MAVEN_CENTRAL_PASSWORD",
495
+ "MAVEN_GPG_PRIVATE_KEY",
496
+ "MAVEN_GPG_PASSPHRASE",
497
+ "RUBYGEMS_API_KEY",
498
+ "HOMEBREW_TAP_TOKEN",
499
+ "SCOOP_BUCKET_TOKEN",
500
+ "CHOCOLATEY_API_KEY",
501
+ "APT_GPG_PRIVATE_KEY",
502
+ "APT_GPG_KEY_ID",
503
+ "COPR_LOGIN",
504
+ "COPR_TOKEN",
435
505
  ...CHROME_SECRETS,
436
506
  ...FIREFOX_SECRETS,
437
507
  ...EDGE_SECRETS,
@@ -459,6 +529,29 @@ export function registries(project: Project): Registry[] {
459
529
  const wxtSubmit = `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt submit --dry-run`;
460
530
 
461
531
  const rows: Registry[] = [
532
+ ...nativeRegistryRows(project),
533
+ ...systemRegistryRows(project),
534
+ {
535
+ id: "vscode-marketplace",
536
+ surfaces: ["vscode-extension"],
537
+ secrets: ["VSCE_PAT"],
538
+ url: "https://marketplace.visualstudio.com/manage",
539
+ probe: `npx --yes @vscode/vsce@${VSCE_VERSION} verify-pat ${publisher(project)}`,
540
+ exists: `npx --yes @vscode/vsce@${VSCE_VERSION} show ${publisher(project)}.${extensionName(project)} --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.exit(JSON.parse(s).versions.some(v=>v.version==="${version}")?0:1))'`,
541
+ gate: allSet(["VSCE_PAT"]),
542
+ retractUrl: "https://marketplace.visualstudio.com/manage",
543
+ retractHard: `npx --yes @vscode/vsce@${VSCE_VERSION} unpublish ${publisher(project)}.${extensionName(project)} --force`,
544
+ },
545
+ {
546
+ id: "open-vsx",
547
+ surfaces: ["vscode-extension"],
548
+ secrets: ["OVSX_PAT"],
549
+ url: "https://open-vsx.org/user-settings/tokens",
550
+ probe: `npx --yes ovsx@${OVSX_VERSION} verify-pat ${publisher(project)}`,
551
+ exists: `curl -fsS -o /dev/null https://open-vsx.org/api/${publisher(project)}/${extensionName(project)}/${version}`,
552
+ gate: allSet(["OVSX_PAT"]),
553
+ retractUrl: "https://open-vsx.org/user-settings/extensions",
554
+ },
462
555
  {
463
556
  id: "npm",
464
557
  surfaces: ["npm"],
@@ -612,7 +705,9 @@ export function registries(project: Project): Registry[] {
612
705
  const selected = rows.filter(
613
706
  (row) =>
614
707
  row.surfaces.every((surface) => has(project, surface)) &&
615
- (row.id !== "safari" || project.tool.browserExtension?.safari === true),
708
+ (row.id !== "safari" || project.tool.browserExtension?.safari === true) &&
709
+ (row.id !== "vscode-marketplace" || project.tool.vscode?.marketplace !== false) &&
710
+ (row.id !== "open-vsx" || project.tool.vscode?.openvsx !== false),
616
711
  );
617
712
  const mcpRegistry = selected.find((row) => row.id === "mcp-registry");
618
713
  if (mcpRegistry) {
@@ -623,6 +718,14 @@ export function registries(project: Project): Registry[] {
623
718
  ? packages.map((row) => `[ "$${gateVariable(row)}" = true ]`).join(" && ")
624
719
  : "true";
625
720
  }
721
+ const runtime = selected.find(
722
+ (row) => row.id === (project.tool.binding === "python" ? "pypi" : "npm"),
723
+ );
724
+ for (const row of selected.filter(
725
+ (row) => row.id === "vscode-marketplace" || row.id === "open-vsx",
726
+ )) {
727
+ if (runtime) row.gate = `(${row.gate}) && { ${runtime.exists} || { ${runtime.gate}; }; }`;
728
+ }
626
729
  return selected;
627
730
  }
628
731
 
@@ -649,6 +752,10 @@ export function manualSteps(project: Project): string[] {
649
752
  const has_ = (id: Registry["id"]) => rows.some((row) => row.id === id);
650
753
  const slug = githubSlug(project.identity.repository);
651
754
  const steps: string[] = [];
755
+ if (has_("vscode-marketplace") || has_("open-vsx"))
756
+ steps.push(
757
+ "VS Code: register the publisher in Visual Studio Marketplace; create the Open VSX namespace and accept its publisher agreement before setting VSCE_PAT / OVSX_PAT. tool.vscode.publisher selects that identity.",
758
+ );
652
759
  if (has_("oci")) {
653
760
  steps.push(
654
761
  `ghcr.io: the first push publishes a private image whatever the repository's visibility, and GitHub has no visibility API — flip it once at ${rows.find((row) => row.id === "oci")?.url}.`,
@@ -23,6 +23,18 @@ export const LockSchema = z.object({
23
23
  * patch — or deselecting the surface that added it — uninstalls it instead of stranding it.
24
24
  */
25
25
  keys: z.array(z.string()).optional(),
26
+ /** A keyed array's inverse: the entries Toolfactory owns at each dotted path. */
27
+ keyedArrays: z
28
+ .record(
29
+ z.string(),
30
+ z.object({
31
+ id: z.string(),
32
+ values: z.array(z.string()),
33
+ /** The last projected entry shape lets a later plan remove only stale generated leaves. */
34
+ entries: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
35
+ }),
36
+ )
37
+ .optional(),
26
38
  /**
27
39
  * A region file's inverse, exactly as `keys` is a merge file's: the marker pairs its
28
40
  * projector wrote. Regions the current plan no longer writes are emptied before the current
@@ -4,7 +4,7 @@
4
4
  * Validation is Ajv against the cached official schemas; the spec forbids fetching at load.
5
5
  */
6
6
  import type { Project, Surface } from "../model.js";
7
- import { json, kernelLaunch, mcpVerdict } from "./shared.js";
7
+ import { kernelLaunch, mcpVerdict } from "./shared.js";
8
8
 
9
9
  export const MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
10
10
  export const PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
@@ -24,12 +24,13 @@ export const surface: Surface = {
24
24
  const launch = kernelLaunch(project, "${PLUGIN_ROOT}");
25
25
  return [
26
26
  {
27
- kind: "file",
27
+ kind: "merge",
28
28
  path: "mcp.json",
29
- content: json({
29
+ format: "json",
30
+ patch: {
30
31
  $schema: MCP_SCHEMA_ID,
31
32
  mcpServers: { [project.identity.name]: { type: "stdio", ...launch } },
32
- }),
33
+ },
33
34
  },
34
35
  ];
35
36
  },
@@ -129,6 +129,22 @@ describe("agents", () => {
129
129
  expect(setup).toContain("npm install --no-audit --no-fund");
130
130
  });
131
131
 
132
+ it("installs uv before Python projects run the vendored setup workflow", () => {
133
+ const workflow = planned(
134
+ project(["cli"], { binding: "python" }),
135
+ ".github/workflows/agent-config.yml",
136
+ );
137
+ if (workflow.kind !== "file") throw new Error("expected an agent-config workflow file");
138
+ const install = "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0";
139
+ expect(workflow.content).toContain(install);
140
+ expect(workflow.content.indexOf(install)).toBeLessThan(
141
+ workflow.content.indexOf("run: bash .agents/setup --all"),
142
+ );
143
+ const typescript = planned(project(["cli"]), ".github/workflows/agent-config.yml");
144
+ if (typescript.kind !== "file") throw new Error("expected an agent-config workflow file");
145
+ expect(typescript.content).toBe(TEMPLATE_FILES[".github/workflows/agent-config.yml"]);
146
+ });
147
+
132
148
  it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
133
149
  const bare = region(project(["cli"]), IGNORE_PATH);
134
150
  const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
@@ -89,6 +89,13 @@ export const RELOAD: Reload[] = [
89
89
  "`hermes skills trust` once in this repo, then a new conversation: the resolved skill directories are stable for a conversation.",
90
90
  line: "Run `hermes skills trust` once in this repo, then start a new Hermes conversation — every invocation re-scans plugins, skills and AGENTS.md. `/reload-mcp` only affects an already-open session; `hermes gateway restart` is only for the messaging gateway.",
91
91
  },
92
+ {
93
+ harness: "GSD",
94
+ env: [],
95
+ mcp: "`python3 .agents/sync.py --all` renders the shared `.mcp.json` even on a GSD-only machine. Refresh with `mcp_servers(refresh=true)`; start a new session after changing a connected server. Approve `mcp_discover` interactively once before unattended stdio use.",
96
+ instructions:
97
+ "Reads `AGENTS.md` and `.agents/skills/` natively; `/reload` refreshes resources. `--bare` omits them.",
98
+ },
92
99
  {
93
100
  harness: "Gemini CLI",
94
101
  env: [],
@@ -191,7 +198,8 @@ function layoutSection(project: Project): string[] {
191
198
  has(project, "openclaw-native") ||
192
199
  has(project, "hermes-native") ||
193
200
  has(project, "dsh") ||
194
- has(project, "browser-extension")
201
+ has(project, "browser-extension") ||
202
+ has(project, "vscode-extension")
195
203
  ) {
196
204
  lines.push(
197
205
  "- `hosts/<id>/` — the host-native escape hatch for a selected host; nothing else creates it.",
@@ -274,6 +282,15 @@ function installSection(project: Project): string[] {
274
282
  " KEY/PASSWORD/SECRET/TOKEN names before spawning an MCP server.",
275
283
  );
276
284
  }
285
+ if (has(project, "vscode-extension")) {
286
+ lines.push(
287
+ "- **VS Code extension**: `npm --prefix hosts/vscode install`, then open `hosts/vscode` in VS Code",
288
+ " and press F5. Development and `npm --prefix hosts/vscode test` use the checkout's kernel.",
289
+ " `npm --prefix hosts/vscode run vsix` packages it; install with `code --install-extension hosts/vscode/extension.vsix`.",
290
+ " Use Developer: Reload Window after rebuilding an installed extension. Native activation, contributions",
291
+ " and every VS Code API are available in your own files; include your license in the extension directory.",
292
+ );
293
+ }
277
294
  if (has(project, "browser-extension")) {
278
295
  const { command, args } = getBinding(project.tool.binding).cliCommand(project);
279
296
  lines.push(
@@ -292,7 +309,8 @@ function installSection(project: Project): string[] {
292
309
  !openclaw &&
293
310
  !hermes &&
294
311
  !has(project, "dsh") &&
295
- !has(project, "browser-extension")
312
+ !has(project, "browser-extension") &&
313
+ !has(project, "vscode-extension")
296
314
  ) {
297
315
  lines.push(
298
316
  "_No host-native surface (`openclaw-native`, `hermes-native`, `dsh`, `browser-extension`) or",
@@ -681,6 +699,22 @@ function devcontainer(project: Project): string {
681
699
  return `${JSON.stringify({ ...document, features }, null, 2)}\n`;
682
700
  }
683
701
 
702
+ /** The vendor-owned workflow gains the Python prerequisite it cannot know. */
703
+ function agentConfigWorkflow(project: Project): string {
704
+ const path = ".github/workflows/agent-config.yml";
705
+ const workflow = TEMPLATE_FILES[path] ?? "";
706
+ if (isInstructionOnly(project.tool) || project.tool.binding !== "python") return workflow;
707
+ const setup = " - name: Run the shared setup entry point\n";
708
+ if (!workflow.includes(setup))
709
+ throw new Error(`${path} no longer has the setup step; re-vendor before adding uv.`);
710
+ return workflow.replace(
711
+ setup,
712
+ " - name: Install uv\n" +
713
+ " uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0\n\n" +
714
+ setup,
715
+ );
716
+ }
717
+
684
718
  /** Whole-file carriers: no per-project content, `adopt` is the escape hatch. */
685
719
  function vendored(project: Project): PlannedFile[] {
686
720
  const skip = new Set([SETUP_PATH, IGNORE_PATH, ".devcontainer/devcontainer.json"]);
@@ -691,7 +725,8 @@ function vendored(project: Project): PlannedFile[] {
691
725
  ([path, content]): PlannedFile => ({
692
726
  kind: "file",
693
727
  path,
694
- content,
728
+ content:
729
+ path === ".github/workflows/agent-config.yml" ? agentConfigWorkflow(project) : content,
695
730
  ...(path.endsWith("sync.py") ? { mode: 0o755 } : {}),
696
731
  }),
697
732
  ),
@@ -12,7 +12,6 @@ import {
12
12
  envName,
13
13
  has,
14
14
  isSensitive,
15
- json,
16
15
  kernelLaunch,
17
16
  mcpVerdict,
18
17
  requiredConfig,
@@ -79,8 +78,13 @@ export const surface: Surface = {
79
78
  plugins: [compact({ name: identity.name, source: "./", description: identity.description })],
80
79
  });
81
80
  return [
82
- { kind: "file", path: ".claude-plugin/plugin.json", content: json(manifest) },
83
- { kind: "file", path: ".claude-plugin/marketplace.json", content: json(marketplace) },
81
+ { kind: "merge", path: ".claude-plugin/plugin.json", format: "json", patch: manifest },
82
+ {
83
+ kind: "merge",
84
+ path: ".claude-plugin/marketplace.json",
85
+ format: "json",
86
+ patch: marketplace,
87
+ },
84
88
  ];
85
89
  },
86
90
  validate(project) {
@@ -33,8 +33,8 @@ describe("codex", () => {
33
33
  it("makes the repository its own single-plugin marketplace, and validates with the real codex CLI", () => {
34
34
  const files = codex.plan(project(["codex", "mcp"]));
35
35
  const marketplace = files.find((entry) => entry.path === ".agents/plugins/marketplace.json");
36
- if (marketplace?.kind !== "file") throw new Error("expected a whole file");
37
- expect(JSON.parse(marketplace.content)).toMatchObject({
36
+ if (marketplace?.kind !== "merge") throw new Error("expected a merge manifest");
37
+ expect(marketplace.patch).toMatchObject({
38
38
  name: "hello",
39
39
  interface: { displayName: "Hello" },
40
40
  plugins: [
@@ -64,8 +64,18 @@ describe("codex", () => {
64
64
  const target = project(["skill", "codex"]);
65
65
  target.tool.runtime = "none";
66
66
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
67
- if (manifest?.kind !== "file") throw new Error("expected a manifest");
68
- expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
69
- expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
67
+ if (manifest?.kind !== "merge") throw new Error("expected a manifest");
68
+ expect(manifest.patch).toMatchObject({ skills: "./skills/" });
69
+ expect(manifest.patch).not.toHaveProperty("mcpServers");
70
+ });
71
+
72
+ it("can omit the eager MCP server while retaining the plugin skill", () => {
73
+ const target = project(["skill", "codex", "mcp"]);
74
+ target.tool.codex = { mcp: false };
75
+ const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
76
+ if (manifest?.kind !== "merge") throw new Error("expected a manifest");
77
+ const parsed = manifest.patch;
78
+ expect(parsed.skills).toBe("./skills/");
79
+ expect(parsed).not.toHaveProperty("mcpServers");
70
80
  });
71
81
  });
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { projectName } from "../identity/name.js";
12
12
  import type { Surface } from "../model.js";
13
- import { compact, has, json, kernelLaunch, mcpVerdict } from "./shared.js";
13
+ import { compact, has, kernelLaunch, mcpVerdict } from "./shared.js";
14
14
 
15
15
  /** The Codex CLI (`@openai/codex`) `validate()` is proven against — pin for a deterministic gate. */
16
16
  export const CODEX_PIN = "0.152.1";
@@ -43,9 +43,10 @@ export const surface: Surface = {
43
43
  license: identity.license,
44
44
  keywords: identity.keywords,
45
45
  skills: has(project, "skill") ? "./skills/" : undefined,
46
- mcpServers: has(project, "mcp")
47
- ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
48
- : undefined,
46
+ mcpServers:
47
+ has(project, "mcp") && project.tool.codex?.mcp !== false
48
+ ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
49
+ : undefined,
49
50
  interface: codexInterface,
50
51
  });
51
52
  // `source: "local"` + `path: "."` is the marketplace root itself: one repository, one
@@ -64,8 +65,13 @@ export const surface: Surface = {
64
65
  ],
65
66
  });
66
67
  return [
67
- { kind: "file", path: ".codex-plugin/plugin.json", content: json(manifest) },
68
- { kind: "file", path: ".agents/plugins/marketplace.json", content: json(marketplace) },
68
+ { kind: "merge", path: ".codex-plugin/plugin.json", format: "json", patch: manifest },
69
+ {
70
+ kind: "merge",
71
+ path: ".agents/plugins/marketplace.json",
72
+ format: "json",
73
+ patch: marketplace,
74
+ },
69
75
  ];
70
76
  },
71
77
  validate(project) {
@@ -4,15 +4,7 @@
4
4
  */
5
5
  import { projectName } from "../identity/name.js";
6
6
  import type { Surface } from "../model.js";
7
- import {
8
- compact,
9
- configProperties,
10
- envName,
11
- has,
12
- json,
13
- mcpVerdict,
14
- requiredConfig,
15
- } from "./shared.js";
7
+ import { compact, configProperties, envName, has, mcpVerdict, requiredConfig } from "./shared.js";
16
8
 
17
9
  export const surface: Surface = {
18
10
  id: "cursor",
@@ -51,7 +43,7 @@ export const surface: Surface = {
51
43
  mcpServers: has(project, "agent-plugins") ? "./mcp.json" : undefined,
52
44
  variables,
53
45
  });
54
- return [{ kind: "file", path: ".cursor-plugin/plugin.json", content: json(manifest) }];
46
+ return [{ kind: "merge", path: ".cursor-plugin/plugin.json", format: "json", patch: manifest }];
55
47
  },
56
48
  verdict: (operation) => {
57
49
  const verdict = mcpVerdict(operation);