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,8 @@
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
+ import { nativePackageSteps, nativeRegistryRows, nativeToolchainSteps, } from "../distribution/native.js";
13
+ import { systemPackageSteps, systemRegistryRows, } from "../distribution/system.js";
12
14
  import { githubSlug } from "../hosts/github.js";
13
15
  import { githubOwner, projectName } from "../identity/name.js";
14
16
  import { isInstructionOnly } from "../model.js";
@@ -17,6 +19,7 @@ import { HOST_DIR as DSH_HOST_DIR } from "../surfaces/dsh.js";
17
19
  import { MANIFEST_PATH as MCPB_MANIFEST_PATH, MCPB_PIN } from "../surfaces/mcpb.js";
18
20
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "../surfaces/openclaw-native.js";
19
21
  import { configProperties, envName, has, isSensitive, npmName, pypiName, } from "../surfaces/shared.js";
22
+ import { extensionName, OVSX_VERSION, publisher, VSCE_VERSION, HOST_DIR as VSCODE_HOST_DIR, vsixName, } from "../surfaces/vscode-extension.js";
20
23
  /** Where `toolfactory package` and the release `package` job leave every asset. */
21
24
  export const RELEASE_DIR = "dist/release";
22
25
  /** The web app's build, shared by the release tarball and the Pages job (which sets `PAGES_BASE`). */
@@ -88,11 +91,18 @@ export function gateSteps(project) {
88
91
  const pm = commands(project);
89
92
  const cli = toolfactoryCli(project);
90
93
  const openclaw = has(project, "openclaw-native");
91
- const steps = [
92
- ...bootstrapSteps(project),
93
- { name: "toolfactory check", run: `${cli} check` },
94
- outputsStep(project),
95
- ];
94
+ const steps = [...bootstrapSteps(project)];
95
+ if (!typescript && has(project, "hermes-native")) {
96
+ // Hermes' launcher clears PYTHONPATH and runs its own venv. Its plugin dependency
97
+ // declaration is advisory, so Doctor can import a native Python core only after the
98
+ // project is installed into that interpreter. Run after bootstrap's `uv sync` on CI.
99
+ steps.push({
100
+ name: "Install core into Hermes",
101
+ run: 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .',
102
+ when: "ci",
103
+ });
104
+ }
105
+ steps.push({ name: "toolfactory check", run: `${cli} check` }, outputsStep(project));
96
106
  if (has(project, "claude")) {
97
107
  steps.push({
98
108
  name: "Install Claude Code CLI",
@@ -100,7 +110,9 @@ export function gateSteps(project) {
100
110
  when: "ci",
101
111
  });
102
112
  }
103
- if (has(project, "web") || has(project, "browser-extension")) {
113
+ if (has(project, "web") ||
114
+ has(project, "browser-extension") ||
115
+ has(project, "vscode-extension")) {
104
116
  // The web smoke and the browser extension's own Playwright smoke both drive Chromium;
105
117
  // their own npm installs fetch the browser, the runner needs its system libraries.
106
118
  steps.push({
@@ -109,6 +121,7 @@ export function gateSteps(project) {
109
121
  when: "ci",
110
122
  });
111
123
  }
124
+ steps.push(...nativeToolchainSteps(project));
112
125
  steps.push({
113
126
  name: "toolfactory validate",
114
127
  run: `${cli} validate`,
@@ -121,6 +134,8 @@ export function gateSteps(project) {
121
134
  name: "author tests",
122
135
  run: typescript ? pm.test : "uv run --with pytest pytest -q",
123
136
  });
137
+ if (typescript)
138
+ steps.push({ name: "author integration tests", run: pm.run("test:integration") });
124
139
  if (openclaw) {
125
140
  // T3, credential-free: one real OpenClaw agent turn against a scripted model. The suite only
126
141
  // exists when `tool.json` `tests.examples` names an operation the plugin carries, which is
@@ -183,13 +198,16 @@ const LEGAL_BUNDLE_PATHS = [
183
198
  * the release workflow's `package` job, which uploads the directory as one artifact. Publishing
184
199
  * to a registry stays a CI concern; producing the artifacts never is.
185
200
  */
186
- export function packageSteps(project) {
201
+ export function packageSteps(project, options = {}) {
187
202
  const cli = toolfactoryCli(project);
188
203
  const name = project.identity.name;
189
204
  const steps = [
190
205
  { name: "release directory", run: `rm -rf ${RELEASE_DIR} && mkdir -p ${RELEASE_DIR}` },
191
206
  ...bootstrapSteps(project),
192
207
  outputsStep(project),
208
+ ...nativeToolchainSteps(project),
209
+ ...nativePackageSteps(project),
210
+ ...systemPackageSteps(project, { include: options.system !== false }),
193
211
  ];
194
212
  if (has(project, "web")) {
195
213
  steps.push({
@@ -202,27 +220,34 @@ export function packageSteps(project) {
202
220
  if (has(project, "npm")) {
203
221
  steps.push({ name: "npm tarball", run: `npm pack --pack-destination ${RELEASE_DIR}` });
204
222
  }
223
+ if (has(project, "pypi")) {
224
+ // Keep the wheel and sdist apart from the release's npm tarballs, plugin bundles and web
225
+ // archive: PyPA's publisher accepts only Python distribution files. The MCPB stage below
226
+ // unpacks this same canonical sdist rather than reproducing its source-file selection.
227
+ steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}/pypi` });
228
+ }
205
229
  if (has(project, "mcpb")) {
206
- // A bundle root is the published package with its production dependencies installed into
207
- // it, so it is built from the tarball `npm pack` just wrote rather than from the checkout:
208
- // whatever ships to npm is exactly what ships to Claude Desktop. `--ignore-scripts` because
209
- // nothing in a bundle root may run a build; the tarball already carries `dist/`.
210
230
  const stage = "dist/mcpb";
211
- const tarball = npmTarball(project);
212
231
  steps.push({
213
232
  name: "MCPB bundle",
214
- run: [
215
- `rm -rf ${stage} && mkdir -p ${stage}`,
216
- `tar -xzf ${RELEASE_DIR}/${tarball} -C ${stage} --strip-components=1`,
217
- `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
218
- `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
219
- `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
220
- ].join(" && "),
233
+ run: project.tool.binding === "python"
234
+ ? [
235
+ `rm -rf ${stage} && mkdir -p ${stage}`,
236
+ `tar -xzf ${RELEASE_DIR}/pypi/*.tar.gz -C ${stage} --strip-components=1`,
237
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
238
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
239
+ ].join(" && ")
240
+ : [
241
+ // The Node bundle root is the published package with production dependencies
242
+ // installed, so whatever ships to npm is exactly what ships to Claude Desktop.
243
+ `rm -rf ${stage} && mkdir -p ${stage}`,
244
+ `tar -xzf ${RELEASE_DIR}/${npmTarball(project)} -C ${stage} --strip-components=1`,
245
+ `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
246
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
247
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
248
+ ].join(" && "),
221
249
  });
222
250
  }
223
- if (has(project, "pypi")) {
224
- steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}` });
225
- }
226
251
  if (has(project, "openclaw-native")) {
227
252
  // ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
228
253
  // to arrive already built (`package_artifact_name`).
@@ -281,6 +306,12 @@ export function packageSteps(project) {
281
306
  });
282
307
  }
283
308
  // COVERAGE.md is tracked; coverage.json is a build output that need not be, so recompute it.
309
+ if (has(project, "vscode-extension")) {
310
+ steps.push({
311
+ name: "VS Code extension VSIX",
312
+ run: `npm --prefix ${VSCODE_HOST_DIR} install && npm --prefix ${VSCODE_HOST_DIR} run vsix && cp ${VSCODE_HOST_DIR}/extension.vsix ${RELEASE_DIR}/${vsixName(project)}`,
313
+ });
314
+ }
284
315
  steps.push({
285
316
  name: "coverage report",
286
317
  run: `cp COVERAGE.md ${RELEASE_DIR}/ && ${cli} coverage > ${RELEASE_DIR}/coverage.json`,
@@ -341,6 +372,22 @@ const ASC_SECRETS = ["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_PRIVATE_KEY"];
341
372
  export const RELEASE_SECRET_NAMES = [
342
373
  "NPM_TOKEN",
343
374
  "CLAWHUB_TOKEN",
375
+ "VSCE_PAT",
376
+ "OVSX_PAT",
377
+ "CARGO_REGISTRY_TOKEN",
378
+ "NUGET_API_KEY",
379
+ "MAVEN_CENTRAL_USERNAME",
380
+ "MAVEN_CENTRAL_PASSWORD",
381
+ "MAVEN_GPG_PRIVATE_KEY",
382
+ "MAVEN_GPG_PASSPHRASE",
383
+ "RUBYGEMS_API_KEY",
384
+ "HOMEBREW_TAP_TOKEN",
385
+ "SCOOP_BUCKET_TOKEN",
386
+ "CHOCOLATEY_API_KEY",
387
+ "APT_GPG_PRIVATE_KEY",
388
+ "APT_GPG_KEY_ID",
389
+ "COPR_LOGIN",
390
+ "COPR_TOKEN",
344
391
  ...CHROME_SECRETS,
345
392
  ...FIREFOX_SECRETS,
346
393
  ...EDGE_SECRETS,
@@ -365,6 +412,29 @@ export function registries(project) {
365
412
  const browserZip = (browser) => `${BROWSER_HOST_DIR}/.output/${zipName(project, browser)}`;
366
413
  const wxtSubmit = `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt submit --dry-run`;
367
414
  const rows = [
415
+ ...nativeRegistryRows(project),
416
+ ...systemRegistryRows(project),
417
+ {
418
+ id: "vscode-marketplace",
419
+ surfaces: ["vscode-extension"],
420
+ secrets: ["VSCE_PAT"],
421
+ url: "https://marketplace.visualstudio.com/manage",
422
+ probe: `npx --yes @vscode/vsce@${VSCE_VERSION} verify-pat ${publisher(project)}`,
423
+ 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))'`,
424
+ gate: allSet(["VSCE_PAT"]),
425
+ retractUrl: "https://marketplace.visualstudio.com/manage",
426
+ retractHard: `npx --yes @vscode/vsce@${VSCE_VERSION} unpublish ${publisher(project)}.${extensionName(project)} --force`,
427
+ },
428
+ {
429
+ id: "open-vsx",
430
+ surfaces: ["vscode-extension"],
431
+ secrets: ["OVSX_PAT"],
432
+ url: "https://open-vsx.org/user-settings/tokens",
433
+ probe: `npx --yes ovsx@${OVSX_VERSION} verify-pat ${publisher(project)}`,
434
+ exists: `curl -fsS -o /dev/null https://open-vsx.org/api/${publisher(project)}/${extensionName(project)}/${version}`,
435
+ gate: allSet(["OVSX_PAT"]),
436
+ retractUrl: "https://open-vsx.org/user-settings/extensions",
437
+ },
368
438
  {
369
439
  id: "npm",
370
440
  surfaces: ["npm"],
@@ -513,7 +583,9 @@ export function registries(project) {
513
583
  },
514
584
  ];
515
585
  const selected = rows.filter((row) => row.surfaces.every((surface) => has(project, surface)) &&
516
- (row.id !== "safari" || project.tool.browserExtension?.safari === true));
586
+ (row.id !== "safari" || project.tool.browserExtension?.safari === true) &&
587
+ (row.id !== "vscode-marketplace" || project.tool.vscode?.marketplace !== false) &&
588
+ (row.id !== "open-vsx" || project.tool.vscode?.openvsx !== false));
517
589
  const mcpRegistry = selected.find((row) => row.id === "mcp-registry");
518
590
  if (mcpRegistry) {
519
591
  const packages = selected.filter((row) => row.id === "npm" || row.id === "pypi" || row.id === "oci");
@@ -521,6 +593,11 @@ export function registries(project) {
521
593
  ? packages.map((row) => `[ "$${gateVariable(row)}" = true ]`).join(" && ")
522
594
  : "true";
523
595
  }
596
+ const runtime = selected.find((row) => row.id === (project.tool.binding === "python" ? "pypi" : "npm"));
597
+ for (const row of selected.filter((row) => row.id === "vscode-marketplace" || row.id === "open-vsx")) {
598
+ if (runtime)
599
+ row.gate = `(${row.gate}) && { ${runtime.exists} || { ${runtime.gate}; }; }`;
600
+ }
524
601
  return selected;
525
602
  }
526
603
  /** The shell variable the release gate holds a row's presence in; the job output takes the same name. */
@@ -544,6 +621,8 @@ export function manualSteps(project) {
544
621
  const has_ = (id) => rows.some((row) => row.id === id);
545
622
  const slug = githubSlug(project.identity.repository);
546
623
  const steps = [];
624
+ if (has_("vscode-marketplace") || has_("open-vsx"))
625
+ steps.push("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.");
547
626
  if (has_("oci")) {
548
627
  steps.push(`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}.`);
549
628
  }
@@ -10,7 +10,7 @@ export declare function toolAtRef(root: string, ref: string): {
10
10
  schemaVersion: 1;
11
11
  identity: string;
12
12
  binding: "typescript" | "python";
13
- surfaces: ("skill" | "agent-plugins" | "claude" | "codex" | "cursor" | "mcp" | "mcp-registry" | "cli" | "npm" | "pypi" | "openclaw-native" | "hermes-native" | "clawhub" | "web" | "dsh" | "workflows" | "agents" | "readme" | "gemini" | "mcpb" | "browser-extension")[];
13
+ surfaces: ("homebrew" | "winget" | "scoop" | "chocolatey" | "apt" | "rpm" | "skill" | "agent-plugins" | "claude" | "codex" | "cursor" | "mcp" | "mcp-registry" | "cli" | "npm" | "pypi" | "openclaw-native" | "hermes-native" | "clawhub" | "web" | "dsh" | "workflows" | "agents" | "readme" | "gemini" | "mcpb" | "browser-extension" | "vscode-extension" | "cargo" | "nuget" | "maven-central" | "rubygems" | "packagist" | "go-module")[];
14
14
  bundle: {
15
15
  runtime: "package" | "bundled";
16
16
  };
@@ -28,6 +28,7 @@ export declare function toolAtRef(root: string, ref: string): {
28
28
  scope?: string | undefined;
29
29
  } | undefined;
30
30
  codex?: {
31
+ mcp: boolean;
31
32
  interface?: Record<string, unknown> | undefined;
32
33
  } | undefined;
33
34
  browserExtension?: {
@@ -45,6 +46,34 @@ export declare function toolAtRef(root: string, ref: string): {
45
46
  hermes?: {
46
47
  toolset?: string | undefined;
47
48
  } | undefined;
49
+ vscode?: {
50
+ marketplace: boolean;
51
+ openvsx: boolean;
52
+ publisher?: string | undefined;
53
+ } | undefined;
54
+ nativePackages?: {
55
+ id: "cargo" | "nuget" | "maven-central" | "rubygems" | "packagist" | "go-module";
56
+ path: string;
57
+ identity: string;
58
+ name: string;
59
+ versionCommand?: string | undefined;
60
+ }[] | undefined;
61
+ systemPackages?: {
62
+ id: "homebrew" | "winget" | "scoop" | "chocolatey" | "apt" | "rpm";
63
+ path: string;
64
+ identity: string;
65
+ name: string;
66
+ versionCommand: string;
67
+ buildDependencies: string[];
68
+ buildCommand?: string | undefined;
69
+ asset?: string | undefined;
70
+ tap?: string | undefined;
71
+ bucket?: string | undefined;
72
+ bucketName?: string | undefined;
73
+ catalogPath?: string | undefined;
74
+ ppa?: string | undefined;
75
+ coprProject?: string | undefined;
76
+ }[] | undefined;
48
77
  openclaw?: {
49
78
  registers?: {
50
79
  api: string;
@@ -16,6 +16,11 @@ export declare const LockSchema: z.ZodObject<{
16
16
  manual: "manual";
17
17
  }>;
18
18
  keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
+ keyedArrays: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
20
+ id: z.ZodString;
21
+ values: z.ZodArray<z.ZodString>;
22
+ entries: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
23
+ }, z.core.$strip>>>;
19
24
  regions: z.ZodOptional<z.ZodArray<z.ZodObject<{
20
25
  begin: z.ZodString;
21
26
  end: z.ZodString;
@@ -17,6 +17,15 @@ export const LockSchema = z.object({
17
17
  * patch — or deselecting the surface that added it — uninstalls it instead of stranding it.
18
18
  */
19
19
  keys: z.array(z.string()).optional(),
20
+ /** A keyed array's inverse: the entries Toolfactory owns at each dotted path. */
21
+ keyedArrays: z
22
+ .record(z.string(), z.object({
23
+ id: z.string(),
24
+ values: z.array(z.string()),
25
+ /** The last projected entry shape lets a later plan remove only stale generated leaves. */
26
+ entries: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
27
+ }))
28
+ .optional(),
20
29
  /**
21
30
  * A region file's inverse, exactly as `keys` is a merge file's: the marker pairs its
22
31
  * projector wrote. Regions the current plan no longer writes are emptied before the current
@@ -1,4 +1,4 @@
1
- import { json, kernelLaunch, mcpVerdict } from "./shared.js";
1
+ import { kernelLaunch, mcpVerdict } from "./shared.js";
2
2
  export const MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
3
3
  export const PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
4
4
  export function assertBundleIdentity(project) {
@@ -13,12 +13,13 @@ export const surface = {
13
13
  const launch = kernelLaunch(project, "${PLUGIN_ROOT}");
14
14
  return [
15
15
  {
16
- kind: "file",
16
+ kind: "merge",
17
17
  path: "mcp.json",
18
- content: json({
18
+ format: "json",
19
+ patch: {
19
20
  $schema: MCP_SCHEMA_ID,
20
21
  mcpServers: { [project.identity.name]: { type: "stdio", ...launch } },
21
- }),
22
+ },
22
23
  },
23
24
  ];
24
25
  },
@@ -58,6 +58,12 @@ export const RELOAD = [
58
58
  instructions: "`hermes skills trust` once in this repo, then a new conversation: the resolved skill directories are stable for a conversation.",
59
59
  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.",
60
60
  },
61
+ {
62
+ harness: "GSD",
63
+ env: [],
64
+ 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.",
65
+ instructions: "Reads `AGENTS.md` and `.agents/skills/` natively; `/reload` refreshes resources. `--bare` omits them.",
66
+ },
61
67
  {
62
68
  harness: "Gemini CLI",
63
69
  env: [],
@@ -154,7 +160,8 @@ function layoutSection(project) {
154
160
  if (has(project, "openclaw-native") ||
155
161
  has(project, "hermes-native") ||
156
162
  has(project, "dsh") ||
157
- has(project, "browser-extension")) {
163
+ has(project, "browser-extension") ||
164
+ has(project, "vscode-extension")) {
158
165
  lines.push("- `hosts/<id>/` — the host-native escape hatch for a selected host; nothing else creates it.");
159
166
  }
160
167
  if (has(project, "web"))
@@ -210,6 +217,9 @@ function installSection(project) {
210
217
  if (has(project, "dsh")) {
211
218
  lines.push(`- **DSH** (experimental): \`dsh plugin --profile ${DSH_PROFILE} add ./${DSH_HOST_DIR}\` installs the`, " bundle (`dsh plugin` forwards to pnpm, so pnpm must be on `PATH`); the operations reach the", ` model as \`${dshToolName(project, "<operation>")}\`, and \`dsh --profile ${DSH_PROFILE} --dump-config\` shows`, ` the composed \`id: ${dshRowId(project)}\` row. Bundle patches are read once per boot and never watched,`, ` so restart DSH after editing \`${DSH_HOST_DIR}/${PATCH_FILE}\` or reinstalling. Boot with`, ` \`--patch ${DSH_HOST_DIR}/${LOCAL_PATCH_FILE}\` and no bundle installed to drive this checkout`, " instead. Every config variable is restated in the row's `env:` because DSH scrubs", " KEY/PASSWORD/SECRET/TOKEN names before spawning an MCP server.");
212
219
  }
220
+ if (has(project, "vscode-extension")) {
221
+ lines.push("- **VS Code extension**: `npm --prefix hosts/vscode install`, then open `hosts/vscode` in VS Code", " and press F5. Development and `npm --prefix hosts/vscode test` use the checkout's kernel.", " `npm --prefix hosts/vscode run vsix` packages it; install with `code --install-extension hosts/vscode/extension.vsix`.", " Use Developer: Reload Window after rebuilding an installed extension. Native activation, contributions", " and every VS Code API are available in your own files; include your license in the extension directory.");
222
+ }
213
223
  if (has(project, "browser-extension")) {
214
224
  const { command, args } = getBinding(project.tool.binding).cliCommand(project);
215
225
  lines.push(`- **Browser extension**: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`, ` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\`;`, ` \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\` does the same for Firefox. Pair it with this`, ` checkout's kernel: \`${[command, ...args].join(" ")} mcp --http --pair\` mints a token under the data`, ` directory and prints the \`<url>#<token>\` the extension's options page accepts (with no token minted`, ` and no \`${envName(project.identity.name)}_MCP_TOKEN\` in the environment the endpoint stays open on loopback). The content`, " script's selectors are yours to maintain: it is authored escape-hatch code against a page that", " changes on its own schedule, and nothing here can version-proof it.");
@@ -218,7 +228,8 @@ function installSection(project) {
218
228
  !openclaw &&
219
229
  !hermes &&
220
230
  !has(project, "dsh") &&
221
- !has(project, "browser-extension")) {
231
+ !has(project, "browser-extension") &&
232
+ !has(project, "vscode-extension")) {
222
233
  lines.push("_No host-native surface (`openclaw-native`, `hermes-native`, `dsh`, `browser-extension`) or", "`gemini` extension is selected; nothing installs into a running host._");
223
234
  }
224
235
  lines.push("", "Codex and Cursor read this file as is; Claude Code and Gemini read `CLAUDE.md` / `GEMINI.md`,", "each of which `.agents/sync.py` renders as the one line `@AGENTS.md`.", "");
@@ -564,6 +575,19 @@ function devcontainer(project) {
564
575
  : document.features;
565
576
  return `${JSON.stringify({ ...document, features }, null, 2)}\n`;
566
577
  }
578
+ /** The vendor-owned workflow gains the Python prerequisite it cannot know. */
579
+ function agentConfigWorkflow(project) {
580
+ const path = ".github/workflows/agent-config.yml";
581
+ const workflow = TEMPLATE_FILES[path] ?? "";
582
+ if (isInstructionOnly(project.tool) || project.tool.binding !== "python")
583
+ return workflow;
584
+ const setup = " - name: Run the shared setup entry point\n";
585
+ if (!workflow.includes(setup))
586
+ throw new Error(`${path} no longer has the setup step; re-vendor before adding uv.`);
587
+ return workflow.replace(setup, " - name: Install uv\n" +
588
+ " uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0\n\n" +
589
+ setup);
590
+ }
567
591
  /** Whole-file carriers: no per-project content, `adopt` is the escape hatch. */
568
592
  function vendored(project) {
569
593
  const skip = new Set([SETUP_PATH, IGNORE_PATH, ".devcontainer/devcontainer.json"]);
@@ -573,7 +597,7 @@ function vendored(project) {
573
597
  .map(([path, content]) => ({
574
598
  kind: "file",
575
599
  path,
576
- content,
600
+ content: path === ".github/workflows/agent-config.yml" ? agentConfigWorkflow(project) : content,
577
601
  ...(path.endsWith("sync.py") ? { mode: 0o755 } : {}),
578
602
  })),
579
603
  { kind: "file", path: ".devcontainer/devcontainer.json", content: devcontainer(project) },
@@ -108,6 +108,18 @@ describe("agents", () => {
108
108
  expect(setup).toContain("npx toolfactory check >/dev/null");
109
109
  expect(setup).toContain("npm install --no-audit --no-fund");
110
110
  });
111
+ it("installs uv before Python projects run the vendored setup workflow", () => {
112
+ const workflow = planned(project(["cli"], { binding: "python" }), ".github/workflows/agent-config.yml");
113
+ if (workflow.kind !== "file")
114
+ throw new Error("expected an agent-config workflow file");
115
+ const install = "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0";
116
+ expect(workflow.content).toContain(install);
117
+ expect(workflow.content.indexOf(install)).toBeLessThan(workflow.content.indexOf("run: bash .agents/setup --all"));
118
+ const typescript = planned(project(["cli"]), ".github/workflows/agent-config.yml");
119
+ if (typescript.kind !== "file")
120
+ throw new Error("expected an agent-config workflow file");
121
+ expect(typescript.content).toBe(TEMPLATE_FILES[".github/workflows/agent-config.yml"]);
122
+ });
111
123
  it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
112
124
  const bare = region(project(["cli"]), IGNORE_PATH);
113
125
  const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
@@ -5,7 +5,7 @@
5
5
  * `claude plugin marketplace add <owner>/<repo>` (and Copilot CLI's identical command) installs.
6
6
  */
7
7
  import { githubSlug } from "../hosts/github.js";
8
- import { compact, configProperties, envName, has, isSensitive, json, kernelLaunch, mcpVerdict, requiredConfig, } from "./shared.js";
8
+ import { compact, configProperties, envName, has, isSensitive, kernelLaunch, mcpVerdict, requiredConfig, } from "./shared.js";
9
9
  export const surface = {
10
10
  id: "claude",
11
11
  plan(project) {
@@ -61,8 +61,13 @@ export const surface = {
61
61
  plugins: [compact({ name: identity.name, source: "./", description: identity.description })],
62
62
  });
63
63
  return [
64
- { kind: "file", path: ".claude-plugin/plugin.json", content: json(manifest) },
65
- { kind: "file", path: ".claude-plugin/marketplace.json", content: json(marketplace) },
64
+ { kind: "merge", path: ".claude-plugin/plugin.json", format: "json", patch: manifest },
65
+ {
66
+ kind: "merge",
67
+ path: ".claude-plugin/marketplace.json",
68
+ format: "json",
69
+ patch: marketplace,
70
+ },
66
71
  ];
67
72
  },
68
73
  validate(project) {
@@ -8,7 +8,7 @@
8
8
  * this surface does not reach.
9
9
  */
10
10
  import { projectName } from "../identity/name.js";
11
- import { compact, has, json, kernelLaunch, mcpVerdict } from "./shared.js";
11
+ import { compact, has, kernelLaunch, mcpVerdict } from "./shared.js";
12
12
  /** The Codex CLI (`@openai/codex`) `validate()` is proven against — pin for a deterministic gate. */
13
13
  export const CODEX_PIN = "0.152.1";
14
14
  export const surface = {
@@ -39,7 +39,7 @@ export const surface = {
39
39
  license: identity.license,
40
40
  keywords: identity.keywords,
41
41
  skills: has(project, "skill") ? "./skills/" : undefined,
42
- mcpServers: has(project, "mcp")
42
+ mcpServers: has(project, "mcp") && project.tool.codex?.mcp !== false
43
43
  ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
44
44
  : undefined,
45
45
  interface: codexInterface,
@@ -60,8 +60,13 @@ export const surface = {
60
60
  ],
61
61
  });
62
62
  return [
63
- { kind: "file", path: ".codex-plugin/plugin.json", content: json(manifest) },
64
- { kind: "file", path: ".agents/plugins/marketplace.json", content: json(marketplace) },
63
+ { kind: "merge", path: ".codex-plugin/plugin.json", format: "json", patch: manifest },
64
+ {
65
+ kind: "merge",
66
+ path: ".agents/plugins/marketplace.json",
67
+ format: "json",
68
+ patch: marketplace,
69
+ },
65
70
  ];
66
71
  },
67
72
  validate(project) {
@@ -29,9 +29,9 @@ describe("codex", () => {
29
29
  it("makes the repository its own single-plugin marketplace, and validates with the real codex CLI", () => {
30
30
  const files = codex.plan(project(["codex", "mcp"]));
31
31
  const marketplace = files.find((entry) => entry.path === ".agents/plugins/marketplace.json");
32
- if (marketplace?.kind !== "file")
33
- throw new Error("expected a whole file");
34
- expect(JSON.parse(marketplace.content)).toMatchObject({
32
+ if (marketplace?.kind !== "merge")
33
+ throw new Error("expected a merge manifest");
34
+ expect(marketplace.patch).toMatchObject({
35
35
  name: "hello",
36
36
  interface: { displayName: "Hello" },
37
37
  plugins: [
@@ -59,9 +59,19 @@ describe("codex", () => {
59
59
  const target = project(["skill", "codex"]);
60
60
  target.tool.runtime = "none";
61
61
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
62
- if (manifest?.kind !== "file")
62
+ if (manifest?.kind !== "merge")
63
63
  throw new Error("expected a manifest");
64
- expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
65
- expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
64
+ expect(manifest.patch).toMatchObject({ skills: "./skills/" });
65
+ expect(manifest.patch).not.toHaveProperty("mcpServers");
66
+ });
67
+ it("can omit the eager MCP server while retaining the plugin skill", () => {
68
+ const target = project(["skill", "codex", "mcp"]);
69
+ target.tool.codex = { mcp: false };
70
+ const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
71
+ if (manifest?.kind !== "merge")
72
+ throw new Error("expected a manifest");
73
+ const parsed = manifest.patch;
74
+ expect(parsed.skills).toBe("./skills/");
75
+ expect(parsed).not.toHaveProperty("mcpServers");
66
76
  });
67
77
  });
@@ -3,7 +3,7 @@
3
3
  * and the bundle's `mcp.json` is shared. Loader unverified, like Codex.
4
4
  */
5
5
  import { projectName } from "../identity/name.js";
6
- import { compact, configProperties, envName, has, json, mcpVerdict, requiredConfig, } from "./shared.js";
6
+ import { compact, configProperties, envName, has, mcpVerdict, requiredConfig } from "./shared.js";
7
7
  export const surface = {
8
8
  id: "cursor",
9
9
  plan(project) {
@@ -39,7 +39,7 @@ export const surface = {
39
39
  mcpServers: has(project, "agent-plugins") ? "./mcp.json" : undefined,
40
40
  variables,
41
41
  });
42
- return [{ kind: "file", path: ".cursor-plugin/plugin.json", content: json(manifest) }];
42
+ return [{ kind: "merge", path: ".cursor-plugin/plugin.json", format: "json", patch: manifest }];
43
43
  },
44
44
  verdict: (operation) => {
45
45
  const verdict = mcpVerdict(operation);