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
@@ -52,6 +52,29 @@ describe("mcp-registry Dockerfile", () => {
52
52
  expect(npmBuilt.Dockerfile).not.toContain("corepack enable");
53
53
  const py = files(project({ tool: { ...project().tool, binding: "python" } }));
54
54
  expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
55
+ expect(py.Dockerfile).toContain("COPY . .\nRUN uv sync --no-dev");
56
+ expect(py.Dockerfile).not.toContain("COPY pyproject.toml ./");
57
+ expect(py.Dockerfile).toContain('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');
58
+ expect(py.Dockerfile).toContain("COPY --from=build /app/runtime/ ./");
59
+ expect(py.Dockerfile).not.toContain("COPY --from=build /app .");
60
+ const pythonWeb = files(project({
61
+ tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
62
+ }));
63
+ expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
64
+ expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
65
+ });
66
+ it("ships an author-extensible Docker ignore region without excluding source metadata", () => {
67
+ const ignore = surface.plan(project()).find((file) => file.path === ".dockerignore");
68
+ if (ignore?.kind !== "region")
69
+ throw new Error("expected a Docker ignore region");
70
+ expect(ignore.template).toContain("# tf:dockerignore\n# /tf:dockerignore");
71
+ expect(ignore.regions[0]?.content).toContain(".git/");
72
+ expect(ignore.regions[0]?.content).toContain(".env.*");
73
+ expect(ignore.regions[0]?.content).toContain("!.env.example");
74
+ expect(ignore.regions[0]?.content).toContain("**/node_modules/");
75
+ expect(ignore.regions[0]?.content).toContain("**/.venv/");
76
+ expect(ignore.regions[0]?.content).not.toContain("README");
77
+ expect(ignore.regions[0]?.content).not.toContain("web/");
55
78
  });
56
79
  });
57
80
  describe("mcp-registry server.json oci package", () => {
@@ -8,4 +8,5 @@ export declare const MANIFEST_PATH = "hosts/mcpb/manifest.json";
8
8
  export declare const MCPB_PIN = "2.1.2";
9
9
  /** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
10
10
  export declare const MANIFEST_VERSION = "0.2";
11
+ export declare const UV_MANIFEST_VERSION = "0.4";
11
12
  export declare const surface: Surface;
@@ -3,21 +3,21 @@
3
3
  * Anthropic's Connectors Directory takes a *local* server through.
4
4
  *
5
5
  * A bundle is a zip whose root is the server plus its dependencies plus a `manifest.json`. That
6
- * root is a staging tree, not the repository: the payload is exactly the npm tarball with
7
- * production dependencies installed into it, which `src/project/gate.ts` builds under
8
- * `dist/mcpb/` at package time. So this surface emits one tracked file — the manifest — and the
9
- * packaging step copies it in; nothing here duplicates what `npm pack` already decides ships.
6
+ * root is a staging tree, not the repository: TypeScript stages the npm tarball with production
7
+ * dependencies, while Python stages its `pyproject.toml` and source for host-managed uv. This
8
+ * surface emits one tracked file — the manifest — and the packaging step copies it in.
10
9
  *
11
- * `manifest_version` is `0.2`, which is what `mcpb init` itself writes for a new bundle (0.3 and
12
- * 0.4 exist, and add icon variants, localization and the `uv` server type — none of which this
13
- * projection uses) and the floor Anthropic's directory submission requires.
10
+ * TypeScript uses `manifest_version` `0.2`, the floor Anthropic's directory submission requires.
11
+ * Python uses v0.4's official `uv` server type, which keeps the bundle portable without shipping
12
+ * an interpreter or a virtual environment.
14
13
  *
15
14
  * `privacy_policies` (the URLs of the privacy policies of the services a bundle talks to, which
16
15
  * Anthropic's directory submission requires) comes from `tool.json` `mcpb.privacyPolicies`; a
17
16
  * homepage is not one, so nothing is invented when the key is absent.
18
17
  */
18
+ import { kernelDir as pythonKernelDir, pythonPackage } from "../bindings/python.js";
19
19
  import { KERNEL_DIR, NODE_ENGINES } from "../bindings/typescript.js";
20
- import { compact, configProperties, envName, isSensitive, json, mcpVerdict } from "./shared.js";
20
+ import { compact, configProperties, envName, has, isSensitive, json, mcpVerdict, } from "./shared.js";
21
21
  export const HOST_DIR = "hosts/mcpb";
22
22
  export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
23
23
  /**
@@ -27,6 +27,7 @@ export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
27
27
  export const MCPB_PIN = "2.1.2";
28
28
  /** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
29
29
  export const MANIFEST_VERSION = "0.2";
30
+ export const UV_MANIFEST_VERSION = "0.4";
30
31
  /**
31
32
  * The kernel's built `mcp` entry inside the bundle root — the same file the `mcp-registry`
32
33
  * surface's Dockerfile runs, because both start from the published package's `dist/`.
@@ -47,11 +48,9 @@ const USER_CONFIG_TYPES = new Set(["string", "number", "boolean"]);
47
48
  export const surface = {
48
49
  id: "mcpb",
49
50
  plan(project) {
50
- if (project.tool.binding !== "typescript") {
51
- // A python bundle would have to vendor an interpreter's site-packages into the bundle root
52
- // (Claude Desktop ships Node, not Python), which `npm pack` plus `npm install` gives the
53
- // TypeScript binding for free and `uv` gives no equivalent of.
54
- throw new Error('Surface "mcpb" requires the typescript binding: a bundle root is the npm tarball with its production dependencies installed into it.');
51
+ const python = project.tool.binding === "python";
52
+ if (python && !has(project, "pypi")) {
53
+ throw new Error('Surface "mcpb" with the python binding requires the "pypi" surface, the canonical runtime distribution.');
55
54
  }
56
55
  const { identity } = project;
57
56
  const properties = configProperties(project);
@@ -70,7 +69,7 @@ export const surface = {
70
69
  // kernel reads its configuration from the same environment names it does everywhere else.
71
70
  const env = Object.fromEntries(Object.keys(properties).map((key) => [envName(key), `\${user_config.${key}}`]));
72
71
  const manifest = compact({
73
- manifest_version: MANIFEST_VERSION,
72
+ manifest_version: python ? UV_MANIFEST_VERSION : MANIFEST_VERSION,
74
73
  name: identity.name,
75
74
  display_name: identity.name,
76
75
  version: identity.version,
@@ -83,15 +82,29 @@ export const surface = {
83
82
  privacy_policies: project.tool.mcpb?.privacyPolicies,
84
83
  keywords: identity.keywords,
85
84
  server: {
86
- type: "node",
87
- entry_point: entryPoint(),
85
+ type: python ? "uv" : "node",
86
+ entry_point: python ? `${pythonKernelDir(project)}/mcp.py` : entryPoint(),
88
87
  // `${__dirname}` is the installed bundle's own directory: an install is an unzip
89
- // somewhere else, so nothing in the launch may be repository-relative.
90
- mcp_config: compact({
91
- command: "node",
92
- args: [`\${__dirname}/${entryPoint()}`],
93
- env: Object.keys(env).length ? env : undefined,
94
- }),
88
+ // somewhere else, so nothing in the launch may be repository-relative. For `uv`, this
89
+ // delegates dependency resolution and Python provisioning to the host's supported runtime.
90
+ mcp_config: python
91
+ ? compact({
92
+ command: "uv",
93
+ args: [
94
+ "run",
95
+ "--directory",
96
+ "${__dirname}",
97
+ "python",
98
+ "-m",
99
+ `${pythonPackage(project)}.toolfactory.mcp`,
100
+ ],
101
+ env: Object.keys(env).length ? env : undefined,
102
+ })
103
+ : compact({
104
+ command: "node",
105
+ args: [`\${__dirname}/${entryPoint()}`],
106
+ env: Object.keys(env).length ? env : undefined,
107
+ }),
95
108
  },
96
109
  // Exactly the operations the kernel's own `tools/list` serves (§4.3): the manifest is what
97
110
  // the install UI shows before the server ever runs, so it must not promise more.
@@ -99,7 +112,7 @@ export const surface = {
99
112
  .filter((operation) => mcpVerdict(operation).kind !== "excluded")
100
113
  .map((operation) => compact({ name: operation.name, description: operation.description })),
101
114
  user_config: userConfig.length ? Object.fromEntries(userConfig) : undefined,
102
- compatibility: { runtimes: { node: NODE_ENGINES } },
115
+ compatibility: { runtimes: python ? { python: ">=3.11" } : { node: NODE_ENGINES } },
103
116
  });
104
117
  return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
105
118
  },
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { NODE_ENGINES } from "../bindings/typescript.js";
3
- import { MCPB_PIN, surface as mcpb } from "./mcpb.js";
3
+ import { MCPB_PIN, surface as mcpb, UV_MANIFEST_VERSION } from "./mcpb.js";
4
4
  const echo = {
5
5
  name: "echo",
6
6
  description: "Echoes.",
@@ -90,8 +90,36 @@ describe("mcpb", () => {
90
90
  // No privacy_policies unless tool.json names them (see the module docs).
91
91
  expect(manifestFile.content).not.toContain("privacy_policies");
92
92
  });
93
- it("declares the kernel's own Node floor, and refuses a binding whose package is not the bundle", () => {
94
- expect(() => mcpb.plan({ ...project, tool: { ...project.tool, binding: "python" } })).toThrow(/requires the typescript binding/);
93
+ it("uses the official v0.4 uv bundle contract for Python, with its canonical PyPI surface", () => {
94
+ const pythonProject = {
95
+ ...project,
96
+ tool: { ...project.tool, binding: "python", surfaces: ["mcp", "pypi", "mcpb"] },
97
+ };
98
+ const [file] = mcpb.plan(pythonProject);
99
+ if (file?.kind !== "file")
100
+ throw new Error("expected one whole file");
101
+ expect(JSON.parse(file.content)).toMatchObject({
102
+ manifest_version: UV_MANIFEST_VERSION,
103
+ server: {
104
+ type: "uv",
105
+ entry_point: "src/hello_tool/toolfactory/mcp.py",
106
+ mcp_config: {
107
+ command: "uv",
108
+ args: [
109
+ "run",
110
+ "--directory",
111
+ "${__dirname}",
112
+ "python",
113
+ "-m",
114
+ "hello_tool.toolfactory.mcp",
115
+ ],
116
+ },
117
+ },
118
+ compatibility: { runtimes: { python: ">=3.11" } },
119
+ });
120
+ expect(() => mcpb.plan({ ...pythonProject, tool: { ...pythonProject.tool, surfaces: ["mcp", "mcpb"] } })).toThrow(/requires the "pypi" surface/);
121
+ });
122
+ it("declares the kernel's own Node floor and validates with the pinned official CLI", () => {
95
123
  expect(mcpb.validate?.(project)).toEqual([
96
124
  {
97
125
  label: "mcpb validate",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { githubSlug } from "../hosts/github.js";
6
6
  import { registryName } from "./mcp-registry.js";
7
- import { compact, has, npmName } from "./shared.js";
7
+ import { compact, has, npmName, pypiName } from "./shared.js";
8
8
  import { WEB_DIR } from "./web.js";
9
9
  /**
10
10
  * npm's object form. A bare URL string is only valid as one of npm's shorthands, so `publint`
@@ -17,14 +17,30 @@ function repositoryField(repository) {
17
17
  const slug = githubSlug(repository);
18
18
  return { type: "git", url: slug ? `git+https://github.com/${slug}.git` : repository };
19
19
  }
20
+ /**
21
+ * A deliberately thin cross-registry bridge: npm supplies discovery and a familiar `npx` entry,
22
+ * while uvx resolves the canonical Python distribution. `uvx` owns Python acquisition, caching,
23
+ * and the package install; the npm package never downloads an interpreter itself.
24
+ */
25
+ function pythonLauncher(project) {
26
+ const { identity } = project;
27
+ return `#!/usr/bin/env node
28
+ import { spawnSync } from "node:child_process";
29
+
30
+ const child = spawnSync("uvx", ["--from", ${JSON.stringify(`${pypiName(project)}==${identity.version}`)}, ${JSON.stringify(identity.name)}, ...process.argv.slice(2)], {
31
+ stdio: "inherit",
32
+ });
33
+ if (child.error) throw child.error;
34
+ process.exit(child.status ?? 1);
35
+ `;
36
+ }
20
37
  export const surface = {
21
38
  id: "npm",
22
39
  plan(project) {
23
- if (project.tool.binding !== "typescript") {
24
- throw new Error('Surface "npm" requires the typescript binding.');
25
- }
26
40
  const { identity } = project;
27
41
  const authored = project.tool.identity === "package.json";
42
+ const python = project.tool.binding === "python";
43
+ const launcher = `bin/${identity.name}.mjs`;
28
44
  const patch = compact({
29
45
  name: authored ? undefined : npmName(project),
30
46
  version: authored ? undefined : identity.version,
@@ -35,21 +51,36 @@ export const surface = {
35
51
  keywords: authored ? undefined : identity.keywords,
36
52
  type: "module",
37
53
  bin: has(project, "cli") || has(project, "mcp")
38
- ? { [identity.name]: "./dist/toolfactory/cli.js" }
54
+ ? {
55
+ [identity.name]: python ? `./${launcher}` : "./dist/toolfactory/cli.js",
56
+ }
39
57
  : undefined,
40
58
  // `web/dist` only with the `web` surface: the kernel serves the built page from inside
41
59
  // the installed package, so `npx <tool> mcp --http --open` works without a checkout.
42
- files: [
43
- "dist",
44
- "src",
45
- "schemas",
46
- ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
47
- "README.md",
48
- "LICENSE",
49
- ],
60
+ files: python
61
+ ? ["bin", "README.md", "LICENSE"]
62
+ : [
63
+ "dist",
64
+ "src",
65
+ "schemas",
66
+ ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
67
+ "README.md",
68
+ "LICENSE",
69
+ ],
50
70
  mcpName: has(project, "mcp-registry") ? registryName(project) : undefined,
51
71
  });
52
- return [{ kind: "merge", path: "package.json", format: "json", patch, owned: ["bin"] }];
72
+ return [
73
+ ...(python && (has(project, "cli") || has(project, "mcp"))
74
+ ? [{ kind: "file", path: launcher, content: pythonLauncher(project) }]
75
+ : []),
76
+ {
77
+ kind: "merge",
78
+ path: "package.json",
79
+ format: "json",
80
+ patch,
81
+ owned: ["bin", "files"],
82
+ },
83
+ ];
53
84
  },
54
85
  validate(project) {
55
86
  return [
@@ -216,7 +216,7 @@ function packageJson(project, operations, kase) {
216
216
  : undefined;
217
217
  const extra = project.tool.openclaw;
218
218
  const e2e = kase ? { "test:e2e": OPENCLAW_ADDITIONS.e2eScript } : undefined;
219
- return json(compact({
219
+ return compact({
220
220
  name: projectName.openclawPackage(project.identity.name),
221
221
  version: project.identity.version ?? "0.0.0",
222
222
  description: pluginDescription(project),
@@ -239,7 +239,7 @@ function packageJson(project, operations, kase) {
239
239
  pluginInspector: expectedRegistrations(project, operations).length
240
240
  ? pluginInspector(project, operations)
241
241
  : undefined,
242
- }));
242
+ });
243
243
  }
244
244
  /**
245
245
  * `openclaw plugins build` regenerates this file; emitting it keeps `--check` green from a clean
@@ -256,7 +256,7 @@ function pluginManifest(project, operations) {
256
256
  registration.contract,
257
257
  registration.ids,
258
258
  ]);
259
- return json(compact({
259
+ return compact({
260
260
  id: project.identity.name,
261
261
  name: displayName(project.identity.name),
262
262
  description: pluginDescription(project),
@@ -268,7 +268,7 @@ function pluginManifest(project, operations) {
268
268
  tools: operations.map((operation) => operation.name),
269
269
  ...Object.fromEntries(declared),
270
270
  },
271
- }));
271
+ });
272
272
  }
273
273
  function executeBody(project, operation) {
274
274
  if (project.tool.binding === "typescript") {
@@ -819,9 +819,10 @@ export const surface = {
819
819
  const kase = e2eCase(project, operations);
820
820
  return [
821
821
  {
822
- kind: "file",
822
+ kind: "merge",
823
823
  path: `${HOST_DIR}/package.json`,
824
- content: packageJson(project, operations, kase),
824
+ format: "json",
825
+ patch: packageJson(project, operations, kase),
825
826
  },
826
827
  {
827
828
  kind: "file",
@@ -841,9 +842,10 @@ export const surface = {
841
842
  content: OPENCLAW_SCAFFOLD.vitestConfig,
842
843
  },
843
844
  {
844
- kind: "file",
845
+ kind: "merge",
845
846
  path: `${HOST_DIR}/openclaw.plugin.json`,
846
- content: pluginManifest(project, operations),
847
+ format: "json",
848
+ patch: pluginManifest(project, operations),
847
849
  },
848
850
  {
849
851
  kind: "region",
@@ -1,6 +1,10 @@
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";
7
+ import { apply } from "../project/apply.js";
4
8
  import { computeCoverage, renderCoverageMarkdown } from "../report/coverage.js";
5
9
  import { HOST_DIR, OPENCLAW_ADDITIONS, OPENCLAW_SCAFFOLD, surface } from "./openclaw-native.js";
6
10
  const echo = {
@@ -74,6 +78,32 @@ function emitted(target) {
74
78
  ]));
75
79
  }
76
80
  describe("openclaw-native", () => {
81
+ it("keeps hand-authored package and manifest extensions through regeneration", () => {
82
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-openclaw-"));
83
+ const target = { ...project(), root };
84
+ apply(root, surface.plan(target), "0.1.0");
85
+ const packagePath = join(root, HOST_DIR, "package.json");
86
+ const manifestPath = join(root, HOST_DIR, "openclaw.plugin.json");
87
+ writeFileSync(packagePath, JSON.stringify({
88
+ ...JSON.parse(readFileSync(packagePath, "utf8")),
89
+ scripts: { custom: "node custom.mjs" },
90
+ exports: { "./native": "./src/native.ts" },
91
+ }));
92
+ writeFileSync(manifestPath, JSON.stringify({
93
+ ...JSON.parse(readFileSync(manifestPath, "utf8")),
94
+ contracts: { customProviders: ["native"] },
95
+ nativeField: true,
96
+ }));
97
+ apply(root, surface.plan(target), "0.1.0");
98
+ expect(JSON.parse(readFileSync(packagePath, "utf8"))).toMatchObject({
99
+ scripts: { custom: "node custom.mjs" },
100
+ exports: { "./native": "./src/native.ts" },
101
+ });
102
+ expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
103
+ contracts: { customProviders: ["native"] },
104
+ nativeField: true,
105
+ });
106
+ });
77
107
  it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
78
108
  const files = emitted(project());
79
109
  const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
@@ -26,19 +26,43 @@ export const surface = {
26
26
  ? { [project.identity.name]: cliEntryPoint(project) }
27
27
  : undefined,
28
28
  }),
29
- // The kernel serves the built page from inside its own package, so the wheel has to
30
- // carry it: `packages` only copies the package tree, `force-include` maps anything else.
29
+ // Hatch ignores a missing `only-include` path, unlike `force-include`, which makes
30
+ // `uv sync` work in a fresh checkout before the optional page is built. At package time,
31
+ // the gate builds `web/dist` first and Hatch maps it into the installed package.
31
32
  ...(has(project, "web")
32
33
  ? {
33
34
  tool: {
34
35
  hatch: {
35
36
  build: {
37
+ // Hatch's normal file selection honors .gitignore; `web/dist` is ignored in
38
+ // every generated project, so artifacts is the standard explicit opt-in for
39
+ // the release build's compiled page.
40
+ artifacts: [`${WEB_DIR}/dist/**`],
36
41
  targets: {
37
42
  wheel: {
38
- "force-include": {
43
+ "only-include": [`src/${pythonPackage(project)}`, `${WEB_DIR}/dist`],
44
+ sources: {
45
+ src: "",
39
46
  [`${WEB_DIR}/dist`]: `${pythonPackage(project)}/${WEB_DIR}`,
40
47
  },
41
48
  },
49
+ // This is the source MCPB stages, so include every declared project metadata
50
+ // file alongside the package and built page. `only-include` ignores absent
51
+ // legal files without making fresh checkouts fail.
52
+ sdist: {
53
+ "only-include": [
54
+ "pyproject.toml",
55
+ "README.md",
56
+ "LICENSE",
57
+ "LICENSE.md",
58
+ "LICENSE.txt",
59
+ "NOTICE",
60
+ "NOTICE.md",
61
+ "NOTICE.txt",
62
+ `src/${pythonPackage(project)}`,
63
+ `${WEB_DIR}/dist`,
64
+ ],
65
+ },
42
66
  },
43
67
  },
44
68
  },
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Surface "readme": the `<!-- tf:install -->` region of `README.md`, always generated (like
3
+ * `workflows` and `agents`; never in `tool.json.surfaces`). One install line per selected
4
+ * distribution surface, in the exact shape that surface's own installer accepts, so the
5
+ * humans-and-agents-facing half of distribution cannot drift from the artifacts. The prose
6
+ * around the region — what the tool is, how to use it — is the author's, forever.
7
+ *
8
+ * Every line works from a plain checkout; the ones that need a GitHub repository (`npx skills
9
+ * add`, the Agent Skill badge, the marketplace and Hermes clone URLs) appear only once the
10
+ * identity file carries one, so a tool built without GitHub still gets an accurate section.
11
+ *
12
+ * When `mcp` is selected alongside a package registry, the MCP line also carries the two
13
+ * first-party one-click badges (VS Code's `vscode.dev/redirect/mcp/install`, Cursor's
14
+ * `cursor.com/en/install-mcp`), both computed from the exact same `{command, args, env}` launch
15
+ * the plain-text line renders — a badge can never point somewhere the text line does not.
16
+ */
1
17
  import type { Project, Surface } from "../model.js";
2
18
  export declare const README_PATH = "README.md";
3
19
  export declare const INSTALL_BEGIN = "<!-- tf:install -->";
@@ -14,6 +14,8 @@
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
+ import { nativeInstallLines } from "../distribution/native.js";
18
+ import { systemInstallLines } from "../distribution/system.js";
17
19
  import { githubSlug } from "../hosts/github.js";
18
20
  import { projectName } from "../identity/name.js";
19
21
  import { HOST_DIR as BROWSER_HOST_DIR, zipName } from "./browser-extension.js";
@@ -21,6 +23,7 @@ import { HOST_DIR as DSH_HOST_DIR, dshTarball } from "./dsh.js";
21
23
  import { pluginDir as hermesPluginDir } from "./hermes-native.js";
22
24
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "./openclaw-native.js";
23
25
  import { envName, has, npmName, pypiName, requiredConfig } from "./shared.js";
26
+ import { vsixName } from "./vscode-extension.js";
24
27
  export const README_PATH = "README.md";
25
28
  export const INSTALL_BEGIN = "<!-- tf:install -->";
26
29
  export const INSTALL_END = "<!-- /tf:install -->";
@@ -70,6 +73,10 @@ function installLines(project) {
70
73
  const { identity } = project;
71
74
  const slug = githubSlug(identity.repository);
72
75
  const lines = [];
76
+ lines.push(...nativeInstallLines(project).map((line) => `- **Native package** — ${line}`));
77
+ lines.push(...systemInstallLines(project).map((line) => `- **System package** — ${line}`));
78
+ if (has(project, "vscode-extension"))
79
+ lines.push(`- **VS Code extension** — download \`${vsixName(project)}\` from the release, then \`code --install-extension ${vsixName(project)}\``);
73
80
  if (has(project, "skill") && slug) {
74
81
  lines.push(`- **Agent Skill** — \`npx skills add ${slug}\``);
75
82
  }
@@ -114,16 +121,15 @@ function installLines(project) {
114
121
  }
115
122
  if (has(project, "browser-extension")) {
116
123
  // Three channels, in the order a reader needs them: build-and-load from a checkout, the
117
- // release assets (only Mozilla's signed xpi is a real self-hosted install — Chrome drops
118
- // side-loaded unpacked extensions), the store listings. Pairing is the last step of each.
124
+ // release assets (the signed Firefox XPI depends on configured signing credentials), and
125
+ // store listings. Pairing is the last step of each.
119
126
  const zips = ["chrome", "firefox", "edge"].map((browser) => `\`${zipName(project, browser)}\``);
120
127
  lines.push([
121
128
  `- **Browser extension** — from a checkout: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`,
122
129
  ` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\``,
123
130
  ` (Firefox: \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\`). Each GitHub Release attaches the`,
124
- ` store uploads ${zips.join(", ")}, and the Mozilla-signed \`.xpi\`,`,
125
- " which is the only download-and-install channel now that Chrome no longer keeps side-loaded unpacked",
126
- " extensions; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
131
+ ` store uploads ${zips.join(", ")}. When Firefox signing credentials are configured, it also attaches a`,
132
+ " Mozilla-signed `.xpi`; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
127
133
  ` submit step has each store's credentials. Then pair it: \`${mcpCommand(project)} --http --pair\``,
128
134
  " prints the `<url>#<token>` the extension's options page accepts.",
129
135
  ].join("\n"));
@@ -133,8 +139,12 @@ function installLines(project) {
133
139
  // tool already is. The same listener answers `/mcp`, so the page needs nothing else running.
134
140
  lines.push(`- **Web app** — \`${mcpCommand(project)} --http --open\` serves the operations page beside the`, " MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and", " returns the URL.");
135
141
  }
136
- if (has(project, "npm"))
137
- lines.push(`- **npm package** — \`npm install ${npmName(project)}\``);
142
+ if (has(project, "npm")) {
143
+ const pythonBridge = project.tool.binding === "python"
144
+ ? `; requires \`uv\`, and \`${identity.name}\` delegates to \`uvx --from ${pypiName(project)}==${identity.version} ${identity.name}\``
145
+ : "";
146
+ lines.push(`- **npm package** — \`npm install ${npmName(project)}\`${pythonBridge}`);
147
+ }
138
148
  if (has(project, "pypi"))
139
149
  lines.push(`- **PyPI package** — \`uv add ${pypiName(project)}\``);
140
150
  return lines;
@@ -1,3 +1,5 @@
1
+ import { nativePackageSurfaces } from "../distribution/native.js";
2
+ import { systemPackageSurfaces } from "../distribution/system.js";
1
3
  import { surface as agentPlugins } from "./agent-plugins.js";
2
4
  import { surface as agents } from "./agents.js";
3
5
  import { surface as browserExtension } from "./browser-extension.js";
@@ -16,9 +18,12 @@ import { surface as openclawNative } from "./openclaw-native.js";
16
18
  import { surface as pypi } from "./pypi.js";
17
19
  import { surface as readme } from "./readme.js";
18
20
  import { surface as skill } from "./skill.js";
21
+ import { surface as vscodeExtension } from "./vscode-extension.js";
19
22
  import { surface as web } from "./web.js";
20
23
  import { surface as workflows } from "./workflows.js";
21
24
  const registry = {
25
+ ...Object.fromEntries(nativePackageSurfaces.map((surface) => [surface.id, surface])),
26
+ ...Object.fromEntries(systemPackageSurfaces.map((surface) => [surface.id, surface])),
22
27
  "agent-plugins": agentPlugins,
23
28
  agents,
24
29
  "browser-extension": { ...browserExtension, requires: ["mcp"] },
@@ -36,9 +41,9 @@ const registry = {
36
41
  cli,
37
42
  mcp,
38
43
  "mcp-registry": mcpRegistry,
39
- // The bundle root is the npm tarball with its production dependencies installed into it, and
40
- // its one row launches the kernel MCP server: without either surface there is nothing to pack.
41
- mcpb: { ...mcpb, requires: ["mcp", "npm"] },
44
+ // TypeScript bundles stage their npm tarball; Python `uv` bundles stage the PyPI project's
45
+ // source. The projector enforces that binding-specific distribution dependency.
46
+ mcpb: { ...mcpb, requires: ["mcp"] },
42
47
  npm,
43
48
  skill,
44
49
  "hermes-native": hermesNative,
@@ -46,6 +51,7 @@ const registry = {
46
51
  pypi,
47
52
  readme,
48
53
  web: { ...web, requires: ["mcp"] },
54
+ "vscode-extension": { ...vscodeExtension, requires: ["mcp", "cli"] },
49
55
  workflows,
50
56
  };
51
57
  export function registerSurface(surface) {
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { type Project, type Surface } from "../model.js";
3
+ export declare const HOST_DIR = "hosts/vscode";
4
+ export declare const vscodeConfigSchema: z.ZodObject<{
5
+ publisher: z.ZodOptional<z.ZodString>;
6
+ marketplace: z.ZodDefault<z.ZodBoolean>;
7
+ openvsx: z.ZodDefault<z.ZodBoolean>;
8
+ }, z.core.$strip>;
9
+ export declare const VSCE_VERSION = "3.9.2";
10
+ export declare const OVSX_VERSION = "1.1.1";
11
+ export declare function extensionName(project: Project): string;
12
+ export declare function publisher(project: Project): string;
13
+ export declare function vsixName(project: Project): string;
14
+ export declare const surface: Surface;