toolfactory 0.1.2 → 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 (98) hide show
  1. package/README.md +24 -1
  2. package/dist/commands.d.ts +1 -1
  3. package/dist/distribution/native.d.ts +50 -0
  4. package/dist/distribution/native.js +414 -0
  5. package/dist/distribution/native.test.d.ts +1 -0
  6. package/dist/distribution/native.test.js +127 -0
  7. package/dist/distribution/system.d.ts +57 -0
  8. package/dist/distribution/system.js +637 -0
  9. package/dist/distribution/system.test.d.ts +1 -0
  10. package/dist/distribution/system.test.js +116 -0
  11. package/dist/e2e.test.js +7 -1
  12. package/dist/hosts/openclaw.js +6 -2
  13. package/dist/hosts/vscode.d.ts +1 -0
  14. package/dist/hosts/vscode.js +67 -0
  15. package/dist/model.d.ts +62 -2
  16. package/dist/model.js +9 -0
  17. package/dist/ops.d.ts +52 -0
  18. package/dist/project/apply.d.ts +1 -1
  19. package/dist/project/apply.js +194 -17
  20. package/dist/project/apply.test.js +103 -0
  21. package/dist/project/gate.d.ts +17 -2
  22. package/dist/project/gate.js +68 -3
  23. package/dist/project/history.d.ts +29 -1
  24. package/dist/project/lock.d.ts +5 -0
  25. package/dist/project/lock.js +9 -0
  26. package/dist/surfaces/agent-plugins.js +5 -4
  27. package/dist/surfaces/agents.js +21 -3
  28. package/dist/surfaces/agents.test.js +12 -0
  29. package/dist/surfaces/claude.js +8 -3
  30. package/dist/surfaces/codex.js +8 -3
  31. package/dist/surfaces/codex.test.js +8 -8
  32. package/dist/surfaces/cursor.js +2 -2
  33. package/dist/surfaces/distribution.test.js +3 -3
  34. package/dist/surfaces/dsh.js +25 -6
  35. package/dist/surfaces/dsh.test.js +8 -3
  36. package/dist/surfaces/gemini.js +2 -2
  37. package/dist/surfaces/gemini.test.js +4 -4
  38. package/dist/surfaces/hermes-native.js +38 -9
  39. package/dist/surfaces/hermes-native.test.js +32 -11
  40. package/dist/surfaces/mcp-registry.js +43 -5
  41. package/dist/surfaces/mcp-registry.test.js +18 -0
  42. package/dist/surfaces/openclaw-native.js +10 -8
  43. package/dist/surfaces/openclaw-native.test.js +30 -0
  44. package/dist/surfaces/readme.d.ts +16 -0
  45. package/dist/surfaces/readme.js +7 -0
  46. package/dist/surfaces/registry.js +6 -0
  47. package/dist/surfaces/vscode-extension.d.ts +14 -0
  48. package/dist/surfaces/vscode-extension.js +312 -0
  49. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  50. package/dist/surfaces/vscode-extension.test.js +81 -0
  51. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  52. package/dist/surfaces/vscode-scaffold.js +43 -0
  53. package/dist/surfaces/workflows.js +49 -10
  54. package/dist/surfaces/workflows.test.js +3 -2
  55. package/dist/toolfactory/cli.js +1 -1
  56. package/dist/toolfactory/mcp.js +1 -1
  57. package/package.json +2 -1
  58. package/src/distribution/native.test.ts +161 -0
  59. package/src/distribution/native.ts +460 -0
  60. package/src/distribution/system.test.ts +136 -0
  61. package/src/distribution/system.ts +688 -0
  62. package/src/e2e.test.ts +7 -1
  63. package/src/hosts/openclaw.ts +4 -2
  64. package/src/hosts/vscode.ts +79 -0
  65. package/src/model.ts +15 -1
  66. package/src/project/apply.test.ts +120 -0
  67. package/src/project/apply.ts +257 -15
  68. package/src/project/gate.ts +96 -4
  69. package/src/project/lock.ts +12 -0
  70. package/src/surfaces/agent-plugins.ts +5 -4
  71. package/src/surfaces/agents.test.ts +16 -0
  72. package/src/surfaces/agents.ts +31 -3
  73. package/src/surfaces/claude.ts +7 -3
  74. package/src/surfaces/codex.test.ts +7 -7
  75. package/src/surfaces/codex.ts +8 -3
  76. package/src/surfaces/cursor.ts +2 -10
  77. package/src/surfaces/distribution.test.ts +2 -2
  78. package/src/surfaces/dsh.test.ts +8 -2
  79. package/src/surfaces/dsh.ts +26 -6
  80. package/src/surfaces/gemini.test.ts +3 -3
  81. package/src/surfaces/gemini.ts +1 -2
  82. package/src/surfaces/hermes-native.test.ts +39 -10
  83. package/src/surfaces/hermes-native.ts +41 -11
  84. package/src/surfaces/mcp-registry.test.ts +20 -0
  85. package/src/surfaces/mcp-registry.ts +46 -6
  86. package/src/surfaces/openclaw-native.test.ts +37 -0
  87. package/src/surfaces/openclaw-native.ts +49 -47
  88. package/src/surfaces/readme.ts +10 -0
  89. package/src/surfaces/registry.ts +6 -0
  90. package/src/surfaces/vscode-extension.test.ts +89 -0
  91. package/src/surfaces/vscode-extension.ts +345 -0
  92. package/src/surfaces/vscode-scaffold.ts +55 -0
  93. package/src/surfaces/workflows.test.ts +3 -4
  94. package/src/surfaces/workflows.ts +49 -10
  95. package/src/toolfactory/cli.ts +1 -1
  96. package/src/toolfactory/mcp.ts +1 -1
  97. package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
  98. package/web/dist/index.html +1 -1
@@ -25,8 +25,6 @@
25
25
  * support. The fake-ctx test gets the same behavioural coverage with no Hermes import at all.
26
26
  */
27
27
  import { join } from "node:path";
28
- import { stringify as toml } from "smol-toml";
29
- import { stringify as yaml } from "yaml";
30
28
  import { KERNEL_DIR } from "../bindings/typescript.js";
31
29
  import { projectName } from "../identity/name.js";
32
30
  import { isPortable, type Operation, type Project, type Surface, type Verdict } from "../model.js";
@@ -72,6 +70,8 @@ export function hermesInstall(): string {
72
70
  }
73
71
 
74
72
  const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
73
+ const INIT_BEGIN = "# tf:init";
74
+ const INIT_END = "# /tf:init";
75
75
 
76
76
  /**
77
77
  * The plugin package. It is a sibling of the author's own package rather than part of it: a
@@ -251,7 +251,7 @@ def test_register_adds_every_declared_tool() -> None:
251
251
  `;
252
252
  }
253
253
 
254
- function manifest(project: Project, operations: Operation[]): string {
254
+ function manifest(project: Project, operations: Operation[]): Record<string, unknown> {
255
255
  const { identity } = project;
256
256
  const { required, optional } = envDeclarations(project);
257
257
  const shim = project.tool.binding !== "python";
@@ -278,10 +278,10 @@ function manifest(project: Project, operations: Operation[]): string {
278
278
  optional_env: optional.length ? optional : undefined,
279
279
  python_dependencies: core ? [core] : undefined,
280
280
  });
281
- return `${HEADER}${yaml(document)}`;
281
+ return document as Record<string, unknown>;
282
282
  }
283
283
 
284
- function pyproject(project: Project): string {
284
+ function pyproject(project: Project): Record<string, unknown> {
285
285
  const core = pinnedCore(project) ?? pypiName(project);
286
286
  const python = project.tool.binding === "python";
287
287
  const document = compact({
@@ -306,7 +306,7 @@ function pyproject(project: Project): string {
306
306
  : undefined,
307
307
  }),
308
308
  });
309
- return `${toml(document)}\n`;
309
+ return document as Record<string, unknown>;
310
310
  }
311
311
 
312
312
  /** isort order: plain `import` before `from`, alphabetical within each. */
@@ -406,7 +406,7 @@ def _handler(name: str) -> Callable[..., str]:
406
406
  };
407
407
  }
408
408
 
409
- function initPy(project: Project, operations: Operation[]): string {
409
+ function initRegion(project: Project, operations: Operation[]): string {
410
410
  const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
411
411
  const names = envDeclarations(project).required.map((entry) => entry.name);
412
412
  return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
@@ -434,7 +434,7 @@ def _ready() -> bool:
434
434
  ${core.body}
435
435
 
436
436
 
437
- def register(ctx: Any) -> None:
437
+ def _register_generated(ctx: Any) -> None:
438
438
  # Hermes' own per-plugin data directory, through the variable every host uses.
439
439
  os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
440
440
  for schema in TOOLS:
@@ -447,6 +447,21 @@ def register(ctx: Any) -> None:
447
447
  requires_env=list(REQUIRES_ENV),
448
448
  description=schema["description"],
449
449
  )
450
+
451
+
452
+ def register(ctx: Any) -> None:
453
+ _register_generated(ctx)
454
+ register_native(ctx)
455
+ `;
456
+ }
457
+
458
+ function initTemplate(): string {
459
+ return `${INIT_BEGIN}${INIT_END}
460
+
461
+
462
+ # Yours: use the full PluginContext here. This hook runs after Toolfactory's generated tools.
463
+ def register_native(ctx: Any) -> None:
464
+ pass
450
465
  `;
451
466
  }
452
467
 
@@ -552,9 +567,24 @@ export const surface: Surface = {
552
567
  const operations = includedOperations(project, surface);
553
568
  const dir = pluginDir(project);
554
569
  return [
555
- { kind: "file", path: `${dir}/plugin.yaml`, content: manifest(project, operations) },
556
- { kind: "file", path: `${dir}/__init__.py`, content: initPy(project, operations) },
557
- { kind: "file", path: `${HOST_DIR}/pyproject.toml`, content: pyproject(project) },
570
+ {
571
+ kind: "merge",
572
+ path: `${dir}/plugin.yaml`,
573
+ format: "yaml",
574
+ patch: manifest(project, operations),
575
+ },
576
+ {
577
+ kind: "region",
578
+ path: `${dir}/__init__.py`,
579
+ regions: [{ begin: INIT_BEGIN, end: INIT_END, content: initRegion(project, operations) }],
580
+ template: initTemplate(),
581
+ },
582
+ {
583
+ kind: "merge",
584
+ path: `${HOST_DIR}/pyproject.toml`,
585
+ format: "toml",
586
+ patch: pyproject(project),
587
+ },
558
588
  { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
559
589
  {
560
590
  kind: "file",
@@ -65,6 +65,13 @@ describe("mcp-registry Dockerfile", () => {
65
65
 
66
66
  const py = files(project({ tool: { ...project().tool, binding: "python" } }));
67
67
  expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
68
+ expect(py.Dockerfile).toContain("COPY . .\nRUN uv sync --no-dev");
69
+ expect(py.Dockerfile).not.toContain("COPY pyproject.toml ./");
70
+ expect(py.Dockerfile).toContain(
71
+ 'for path in README README.md README.rst LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then cp "$path" /app/runtime/; fi; done',
72
+ );
73
+ expect(py.Dockerfile).toContain("COPY --from=build /app/runtime/ ./");
74
+ expect(py.Dockerfile).not.toContain("COPY --from=build /app .");
68
75
 
69
76
  const pythonWeb = files(
70
77
  project({
@@ -74,6 +81,19 @@ describe("mcp-registry Dockerfile", () => {
74
81
  expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
75
82
  expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
76
83
  });
84
+
85
+ it("ships an author-extensible Docker ignore region without excluding source metadata", () => {
86
+ const ignore = surface.plan(project()).find((file) => file.path === ".dockerignore");
87
+ if (ignore?.kind !== "region") throw new Error("expected a Docker ignore region");
88
+ expect(ignore.template).toContain("# tf:dockerignore\n# /tf:dockerignore");
89
+ expect(ignore.regions[0]?.content).toContain(".git/");
90
+ expect(ignore.regions[0]?.content).toContain(".env.*");
91
+ expect(ignore.regions[0]?.content).toContain("!.env.example");
92
+ expect(ignore.regions[0]?.content).toContain("**/node_modules/");
93
+ expect(ignore.regions[0]?.content).toContain("**/.venv/");
94
+ expect(ignore.regions[0]?.content).not.toContain("README");
95
+ expect(ignore.regions[0]?.content).not.toContain("web/");
96
+ });
77
97
  });
78
98
 
79
99
  describe("mcp-registry server.json oci package", () => {
@@ -6,7 +6,7 @@
6
6
  import { pythonPackage } from "../bindings/python.js";
7
7
  import { KERNEL_DIR } from "../bindings/typescript.js";
8
8
  import { githubOwner, projectName } from "../identity/name.js";
9
- import type { Project, Surface } from "../model.js";
9
+ import type { PlannedFile, Project, Surface } from "../model.js";
10
10
  import {
11
11
  compact,
12
12
  configProperties,
@@ -88,6 +88,44 @@ const DOCKER_PACKAGE_MANAGERS: Record<
88
88
  },
89
89
  };
90
90
 
91
+ const DOCKERIGNORE_BEGIN = "# tf:dockerignore";
92
+ const DOCKERIGNORE_END = "# /tf:dockerignore";
93
+
94
+ /** Build context hygiene is generated, while the rest of `.dockerignore` stays author-owned. */
95
+ function dockerignore(): PlannedFile {
96
+ return {
97
+ kind: "region",
98
+ path: ".dockerignore",
99
+ regions: [
100
+ {
101
+ begin: DOCKERIGNORE_BEGIN,
102
+ end: DOCKERIGNORE_END,
103
+ content: `
104
+ .git/
105
+ .env
106
+ .env.*
107
+ !.env.example
108
+ node_modules/
109
+ **/node_modules/
110
+ .venv/
111
+ **/.venv/
112
+ dist/
113
+ coverage/
114
+ test-results/
115
+ playwright-report/
116
+ .pytest_cache/
117
+ .mypy_cache/
118
+ .ruff_cache/
119
+ __pycache__/
120
+ **/__pycache__/
121
+ .vscode-test/
122
+ `,
123
+ },
124
+ ],
125
+ template: `${DOCKERIGNORE_BEGIN}\n${DOCKERIGNORE_END}\n\n# Add local build inputs to exclude below.\n`,
126
+ };
127
+ }
128
+
91
129
  /**
92
130
  * The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
93
131
  * multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
@@ -110,16 +148,17 @@ RUN npm -C web run build
110
148
  : ""
111
149
  }FROM ${UV_IMAGE} AS build
112
150
  WORKDIR /app
113
- COPY pyproject.toml ./
114
- COPY src ./src
115
- RUN uv sync --no-dev
151
+ # pyproject metadata can name README, license, and other package assets; copy the project without
152
+ # assuming optional metadata files exist.
153
+ COPY . .
154
+ ${web ? "COPY --from=web /app/web/dist ./web/dist\n" : ""}RUN uv sync --no-dev
155
+ RUN mkdir /app/runtime && cp pyproject.toml /app/runtime/ && cp -R src /app/runtime/src && for path in README README.md README.rst LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then cp "$path" /app/runtime/; fi; done
116
156
 
117
157
  FROM ${PYTHON_IMAGE}
118
158
  LABEL io.modelcontextprotocol.server.name="${label}"
119
159
  WORKDIR /app
120
160
  COPY --from=build /app/.venv ./.venv
121
- COPY --from=build /app/src ./src
122
- COPY --from=build /app/pyproject.toml ./
161
+ COPY --from=build /app/runtime/ ./
123
162
  ${
124
163
  web
125
164
  ? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
@@ -203,6 +242,7 @@ export const surface: Surface = {
203
242
  return [
204
243
  { kind: "file", path: "server.json", content: json(server) },
205
244
  { kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
245
+ dockerignore(),
206
246
  ];
207
247
  },
208
248
  };
@@ -1,7 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { describe, expect, it } from "vitest";
3
6
  import { scaffoldDrift } from "../hosts/openclaw.js";
4
7
  import type { Operation, Project } from "../model.js";
8
+ import { apply } from "../project/apply.js";
5
9
  import { computeCoverage, renderCoverageMarkdown } from "../report/coverage.js";
6
10
  import { HOST_DIR, OPENCLAW_ADDITIONS, OPENCLAW_SCAFFOLD, surface } from "./openclaw-native.js";
7
11
 
@@ -82,6 +86,39 @@ function emitted(target: Project): Record<string, string> {
82
86
  }
83
87
 
84
88
  describe("openclaw-native", () => {
89
+ it("keeps hand-authored package and manifest extensions through regeneration", () => {
90
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-openclaw-"));
91
+ const target = { ...project(), root };
92
+ apply(root, surface.plan(target), "0.1.0");
93
+ const packagePath = join(root, HOST_DIR, "package.json");
94
+ const manifestPath = join(root, HOST_DIR, "openclaw.plugin.json");
95
+ writeFileSync(
96
+ packagePath,
97
+ JSON.stringify({
98
+ ...JSON.parse(readFileSync(packagePath, "utf8")),
99
+ scripts: { custom: "node custom.mjs" },
100
+ exports: { "./native": "./src/native.ts" },
101
+ }),
102
+ );
103
+ writeFileSync(
104
+ manifestPath,
105
+ JSON.stringify({
106
+ ...JSON.parse(readFileSync(manifestPath, "utf8")),
107
+ contracts: { customProviders: ["native"] },
108
+ nativeField: true,
109
+ }),
110
+ );
111
+ apply(root, surface.plan(target), "0.1.0");
112
+ expect(JSON.parse(readFileSync(packagePath, "utf8"))).toMatchObject({
113
+ scripts: { custom: "node custom.mjs" },
114
+ exports: { "./native": "./src/native.ts" },
115
+ });
116
+ expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
117
+ contracts: { customProviders: ["native"] },
118
+ nativeField: true,
119
+ });
120
+ });
121
+
85
122
  it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
86
123
  const files = emitted(project());
87
124
  const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
@@ -238,7 +238,11 @@ function pluginInspector(project: Project, operations: Operation[]): Record<stri
238
238
  };
239
239
  }
240
240
 
241
- function packageJson(project: Project, operations: Operation[], kase?: E2eCase): string {
241
+ function packageJson(
242
+ project: Project,
243
+ operations: Operation[],
244
+ kase?: E2eCase,
245
+ ): Record<string, unknown> {
242
246
  // The core is imported in-process only when an operation reaches this surface.
243
247
  const core =
244
248
  project.tool.binding === "typescript" && operations.length
@@ -246,32 +250,30 @@ function packageJson(project: Project, operations: Operation[], kase?: E2eCase):
246
250
  : undefined;
247
251
  const extra = project.tool.openclaw;
248
252
  const e2e = kase ? { "test:e2e": OPENCLAW_ADDITIONS.e2eScript } : undefined;
249
- return json(
250
- compact({
251
- name: projectName.openclawPackage(project.identity.name),
252
- version: project.identity.version ?? "0.0.0",
253
- description: pluginDescription(project),
254
- type: "module",
255
- private: OPENCLAW_SCAFFOLD.private,
256
- scripts: { ...OPENCLAW_SCAFFOLD.scripts, ...e2e },
257
- files: OPENCLAW_SCAFFOLD.files,
258
- peerDependencies: { openclaw: pluginApi(project), ...extra?.peerDependencies },
259
- dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
260
- devDependencies: {
261
- ...OPENCLAW_SCAFFOLD.devDependencies,
262
- ...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
263
- ...extra?.devDependencies,
264
- },
265
- openclaw: {
266
- extensions: [OPENCLAW_SCAFFOLD.entry],
267
- compat: { pluginApi: pluginApi(project) },
268
- build: { openclawVersion: OPENCLAW_SCAFFOLD.openclawVersion },
269
- },
270
- pluginInspector: expectedRegistrations(project, operations).length
271
- ? pluginInspector(project, operations)
272
- : undefined,
273
- }),
274
- );
253
+ return compact({
254
+ name: projectName.openclawPackage(project.identity.name),
255
+ version: project.identity.version ?? "0.0.0",
256
+ description: pluginDescription(project),
257
+ type: "module",
258
+ private: OPENCLAW_SCAFFOLD.private,
259
+ scripts: { ...OPENCLAW_SCAFFOLD.scripts, ...e2e },
260
+ files: OPENCLAW_SCAFFOLD.files,
261
+ peerDependencies: { openclaw: pluginApi(project), ...extra?.peerDependencies },
262
+ dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
263
+ devDependencies: {
264
+ ...OPENCLAW_SCAFFOLD.devDependencies,
265
+ ...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
266
+ ...extra?.devDependencies,
267
+ },
268
+ openclaw: {
269
+ extensions: [OPENCLAW_SCAFFOLD.entry],
270
+ compat: { pluginApi: pluginApi(project) },
271
+ build: { openclawVersion: OPENCLAW_SCAFFOLD.openclawVersion },
272
+ },
273
+ pluginInspector: expectedRegistrations(project, operations).length
274
+ ? pluginInspector(project, operations)
275
+ : undefined,
276
+ }) as Record<string, unknown>;
275
277
  }
276
278
 
277
279
  /**
@@ -281,7 +283,7 @@ function packageJson(project: Project, operations: Operation[], kase?: E2eCase):
281
283
  * config UI learns which keys to mask, from the same `x-toolfactory.sensitive` declaration every
282
284
  * other surface reads.
283
285
  */
284
- function pluginManifest(project: Project, operations: Operation[]): string {
286
+ function pluginManifest(project: Project, operations: Operation[]): Record<string, unknown> {
285
287
  const sensitive = Object.entries(configProperties(project))
286
288
  .filter(([, property]) => isSensitive(property))
287
289
  .map(([key]) => [key, { sensitive: true }]);
@@ -289,21 +291,19 @@ function pluginManifest(project: Project, operations: Operation[]): string {
289
291
  registration.contract,
290
292
  registration.ids,
291
293
  ]);
292
- return json(
293
- compact({
294
- id: project.identity.name,
295
- name: displayName(project.identity.name),
296
- description: pluginDescription(project),
297
- version: project.identity.version ?? "0.0.0",
298
- configSchema: configSchema(project),
299
- uiHints: sensitive.length ? Object.fromEntries(sensitive) : undefined,
300
- activation: activation(project),
301
- contracts: {
302
- tools: operations.map((operation) => operation.name),
303
- ...Object.fromEntries(declared),
304
- },
305
- }),
306
- );
294
+ return compact({
295
+ id: project.identity.name,
296
+ name: displayName(project.identity.name),
297
+ description: pluginDescription(project),
298
+ version: project.identity.version ?? "0.0.0",
299
+ configSchema: configSchema(project),
300
+ uiHints: sensitive.length ? Object.fromEntries(sensitive) : undefined,
301
+ activation: activation(project),
302
+ contracts: {
303
+ tools: operations.map((operation) => operation.name),
304
+ ...Object.fromEntries(declared),
305
+ },
306
+ }) as Record<string, unknown>;
307
307
  }
308
308
 
309
309
  function executeBody(project: Project, operation: Operation): string {
@@ -933,9 +933,10 @@ export const surface: Surface = {
933
933
  const kase = e2eCase(project, operations);
934
934
  return [
935
935
  {
936
- kind: "file",
936
+ kind: "merge",
937
937
  path: `${HOST_DIR}/package.json`,
938
- content: packageJson(project, operations, kase),
938
+ format: "json",
939
+ patch: packageJson(project, operations, kase),
939
940
  },
940
941
  {
941
942
  kind: "file",
@@ -955,9 +956,10 @@ export const surface: Surface = {
955
956
  content: OPENCLAW_SCAFFOLD.vitestConfig,
956
957
  },
957
958
  {
958
- kind: "file",
959
+ kind: "merge",
959
960
  path: `${HOST_DIR}/openclaw.plugin.json`,
960
- content: pluginManifest(project, operations),
961
+ format: "json",
962
+ patch: pluginManifest(project, operations),
961
963
  },
962
964
  {
963
965
  kind: "region",
@@ -14,6 +14,9 @@
14
14
  * `cursor.com/en/install-mcp`), both computed from the exact same `{command, args, env}` launch
15
15
  * the plain-text line renders — a badge can never point somewhere the text line does not.
16
16
  */
17
+
18
+ import { nativeInstallLines } from "../distribution/native.js";
19
+ import { systemInstallLines } from "../distribution/system.js";
17
20
  import { githubSlug } from "../hosts/github.js";
18
21
  import { projectName } from "../identity/name.js";
19
22
  import type { Project, Surface } from "../model.js";
@@ -22,6 +25,7 @@ import { HOST_DIR as DSH_HOST_DIR, dshTarball } from "./dsh.js";
22
25
  import { pluginDir as hermesPluginDir } from "./hermes-native.js";
23
26
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "./openclaw-native.js";
24
27
  import { envName, has, npmName, pypiName, requiredConfig } from "./shared.js";
28
+ import { vsixName } from "./vscode-extension.js";
25
29
 
26
30
  export const README_PATH = "README.md";
27
31
  export const INSTALL_BEGIN = "<!-- tf:install -->";
@@ -83,6 +87,12 @@ function installLines(project: Project): string[] {
83
87
  const { identity } = project;
84
88
  const slug = githubSlug(identity.repository);
85
89
  const lines: string[] = [];
90
+ lines.push(...nativeInstallLines(project).map((line) => `- **Native package** — ${line}`));
91
+ lines.push(...systemInstallLines(project).map((line) => `- **System package** — ${line}`));
92
+ if (has(project, "vscode-extension"))
93
+ lines.push(
94
+ `- **VS Code extension** — download \`${vsixName(project)}\` from the release, then \`code --install-extension ${vsixName(project)}\``,
95
+ );
86
96
  if (has(project, "skill") && slug) {
87
97
  lines.push(`- **Agent Skill** — \`npx skills add ${slug}\``);
88
98
  }
@@ -1,3 +1,5 @@
1
+ import { nativePackageSurfaces } from "../distribution/native.js";
2
+ import { systemPackageSurfaces } from "../distribution/system.js";
1
3
  import type { Surface, SurfaceId } from "../model.js";
2
4
  import { surface as agentPlugins } from "./agent-plugins.js";
3
5
  import { surface as agents } from "./agents.js";
@@ -17,10 +19,13 @@ import { surface as openclawNative } from "./openclaw-native.js";
17
19
  import { surface as pypi } from "./pypi.js";
18
20
  import { surface as readme } from "./readme.js";
19
21
  import { surface as skill } from "./skill.js";
22
+ import { surface as vscodeExtension } from "./vscode-extension.js";
20
23
  import { surface as web } from "./web.js";
21
24
  import { surface as workflows } from "./workflows.js";
22
25
 
23
26
  const registry: Partial<Record<SurfaceId, Surface>> = {
27
+ ...Object.fromEntries(nativePackageSurfaces.map((surface) => [surface.id, surface])),
28
+ ...Object.fromEntries(systemPackageSurfaces.map((surface) => [surface.id, surface])),
24
29
  "agent-plugins": agentPlugins,
25
30
  agents,
26
31
  "browser-extension": { ...browserExtension, requires: ["mcp"] },
@@ -48,6 +53,7 @@ const registry: Partial<Record<SurfaceId, Surface>> = {
48
53
  pypi,
49
54
  readme,
50
55
  web: { ...web, requires: ["mcp"] },
56
+ "vscode-extension": { ...vscodeExtension, requires: ["mcp", "cli"] },
51
57
  workflows,
52
58
  };
53
59
 
@@ -0,0 +1,89 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { type Project, ToolConfigSchema } from "../model.js";
6
+ import { apply, check } from "../project/apply.js";
7
+ import { packageSteps, registries } from "../project/gate.js";
8
+ import { surface } from "./vscode-extension.js";
9
+
10
+ const roots: string[] = [];
11
+ afterEach(() => {
12
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
13
+ });
14
+
15
+ function fixture(): Project {
16
+ const root = mkdtempSync(join(tmpdir(), "tf-vscode-"));
17
+ roots.push(root);
18
+ return {
19
+ root,
20
+ tool: ToolConfigSchema.parse({
21
+ schemaVersion: 1,
22
+ identity: "plugin.json",
23
+ binding: "typescript",
24
+ surfaces: ["mcp", "cli", "npm", "vscode-extension"],
25
+ vscode: { publisher: "octo" },
26
+ }),
27
+ identity: { name: "hello.tool", version: "1.0.0", repository: "https://github.com/octo/hello" },
28
+ identityExtra: {},
29
+ operations: [
30
+ { name: "echo", inputSchema: { type: "object" }, requires: [] },
31
+ { name: "native", inputSchema: { type: "object" }, requires: ["model"] },
32
+ ],
33
+ toolfactoryVersion: "0.1.2",
34
+ };
35
+ }
36
+
37
+ it("keeps native contributions, activation, build settings and command details across operation changes", () => {
38
+ const project = fixture();
39
+ apply(project.root, surface.plan(project), "0.1.2");
40
+ const path = join(project.root, "hosts/vscode/package.json");
41
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
42
+ pkg.contributes.commands[0].icon = "$(beaker)";
43
+ pkg.contributes.commands.push({ command: "hello.tool.custom", title: "Custom native command" });
44
+ pkg.contributes.views = { explorer: [{ id: "native-view", name: "Native view" }] };
45
+ pkg.scripts.custom = "node custom.js";
46
+ writeFileSync(path, JSON.stringify(pkg));
47
+ const entry = join(project.root, "hosts/vscode/src/extension.ts");
48
+ writeFileSync(entry, `${readFileSync(entry, "utf8")}\nexport const authored = true;\n`);
49
+ expect(check(project.root, surface.plan(project), "0.1.2")).toEqual([]);
50
+ const echo = project.operations[0];
51
+ if (!echo) throw new Error("fixture must contain echo");
52
+ echo.title = "New title";
53
+ apply(project.root, surface.plan(project), "0.1.2");
54
+ const rebuilt = JSON.parse(readFileSync(path, "utf8"));
55
+ expect(rebuilt.contributes.commands).toContainEqual({
56
+ command: "hello-tool.echo",
57
+ title: "New title",
58
+ category: "Hello Tool",
59
+ icon: "$(beaker)",
60
+ });
61
+ expect(rebuilt.contributes.commands).toContainEqual({
62
+ command: "hello.tool.custom",
63
+ title: "Custom native command",
64
+ });
65
+ expect(rebuilt.contributes.commands).not.toContainEqual(
66
+ expect.objectContaining({ command: "hello-tool.native" }),
67
+ );
68
+ expect(rebuilt.contributes.views).toEqual(pkg.contributes.views);
69
+ expect(rebuilt.scripts.custom).toBe("node custom.js");
70
+ expect(readFileSync(entry, "utf8")).toContain("export const authored = true;");
71
+ expect(check(project.root, surface.plan(project), "0.1.2")).toEqual([]);
72
+ });
73
+
74
+ it("packages a VSIX independently of registry credentials and permits each registry to be deselected", () => {
75
+ const project = fixture();
76
+ expect(packageSteps(project).some((step) => step.run.includes("hello-tool-1.0.0.vsix"))).toBe(
77
+ true,
78
+ );
79
+ expect(registries(project).map((row) => row.id)).toEqual(
80
+ expect.arrayContaining(["vscode-marketplace", "open-vsx"]),
81
+ );
82
+ project.tool.vscode = { publisher: "octo", marketplace: false, openvsx: true };
83
+ expect(registries(project).map((row) => row.id)).not.toContain("vscode-marketplace");
84
+ project.tool.binding = "python";
85
+ expect(() => surface.plan(project)).toThrow("requires pypi");
86
+ project.tool.surfaces = ["mcp", "cli", "pypi", "vscode-extension"];
87
+ const runtime = surface.plan(project).find((file) => file.path.endsWith("/generated.ts"));
88
+ expect(runtime?.kind === "file" && runtime.content).toContain('"command":"uvx"');
89
+ });