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
@@ -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,
@@ -18,6 +18,7 @@ import {
18
18
  pypiName,
19
19
  requiredConfig,
20
20
  } from "./shared.js";
21
+ import { WEB_DIR } from "./web.js";
21
22
 
22
23
  export const SERVER_SCHEMA_ID =
23
24
  "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
@@ -87,6 +88,44 @@ const DOCKER_PACKAGE_MANAGERS: Record<
87
88
  },
88
89
  };
89
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
+
90
129
  /**
91
130
  * The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
92
131
  * multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
@@ -94,20 +133,38 @@ const DOCKER_PACKAGE_MANAGERS: Record<
94
133
  */
95
134
  export function dockerfileTemplate(project: Project, label: string): string {
96
135
  if (project.tool.binding === "python") {
136
+ const web = has(project, "web");
97
137
  return `# syntax=docker/dockerfile:1
98
- FROM ${UV_IMAGE} AS build
138
+ ${
139
+ web
140
+ ? `FROM ${NODE_IMAGE} AS web
99
141
  WORKDIR /app
100
- COPY pyproject.toml ./
101
- COPY src ./src
102
- RUN uv sync --no-dev
142
+ COPY web/package.json web/package-lock.json ./web/
143
+ RUN npm -C web ci
144
+ COPY web ./web
145
+ RUN npm -C web run build
146
+
147
+ `
148
+ : ""
149
+ }FROM ${UV_IMAGE} AS build
150
+ WORKDIR /app
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
103
156
 
104
157
  FROM ${PYTHON_IMAGE}
105
158
  LABEL io.modelcontextprotocol.server.name="${label}"
106
159
  WORKDIR /app
107
160
  COPY --from=build /app/.venv ./.venv
108
- COPY --from=build /app/src ./src
109
- COPY --from=build /app/pyproject.toml ./
110
- ENV PATH="/app/.venv/bin:$PATH"
161
+ COPY --from=build /app/runtime/ ./
162
+ ${
163
+ web
164
+ ? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
165
+ `
166
+ : ""
167
+ }ENV PATH="/app/.venv/bin:$PATH"
111
168
  ENTRYPOINT ${JSON.stringify(dockerEntrypoint(project))}
112
169
  `;
113
170
  }
@@ -185,6 +242,7 @@ export const surface: Surface = {
185
242
  return [
186
243
  { kind: "file", path: "server.json", content: json(server) },
187
244
  { kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
245
+ dockerignore(),
188
246
  ];
189
247
  },
190
248
  };
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { NODE_ENGINES } from "../bindings/typescript.js";
3
3
  import type { Operation, Project } from "../model.js";
4
- import { MCPB_PIN, surface as mcpb } from "./mcpb.js";
4
+ import { MCPB_PIN, surface as mcpb, UV_MANIFEST_VERSION } from "./mcpb.js";
5
5
 
6
6
  const echo: Operation = {
7
7
  name: "echo",
@@ -95,11 +95,38 @@ describe("mcpb", () => {
95
95
  expect(manifestFile.content).not.toContain("privacy_policies");
96
96
  });
97
97
 
98
- it("declares the kernel's own Node floor, and refuses a binding whose package is not the bundle", () => {
99
- expect(() => mcpb.plan({ ...project, tool: { ...project.tool, binding: "python" } })).toThrow(
100
- /requires the typescript binding/,
101
- );
98
+ it("uses the official v0.4 uv bundle contract for Python, with its canonical PyPI surface", () => {
99
+ const pythonProject: Project = {
100
+ ...project,
101
+ tool: { ...project.tool, binding: "python", surfaces: ["mcp", "pypi", "mcpb"] },
102
+ };
103
+ const [file] = mcpb.plan(pythonProject);
104
+ if (file?.kind !== "file") throw new Error("expected one whole file");
105
+ expect(JSON.parse(file.content)).toMatchObject({
106
+ manifest_version: UV_MANIFEST_VERSION,
107
+ server: {
108
+ type: "uv",
109
+ entry_point: "src/hello_tool/toolfactory/mcp.py",
110
+ mcp_config: {
111
+ command: "uv",
112
+ args: [
113
+ "run",
114
+ "--directory",
115
+ "${__dirname}",
116
+ "python",
117
+ "-m",
118
+ "hello_tool.toolfactory.mcp",
119
+ ],
120
+ },
121
+ },
122
+ compatibility: { runtimes: { python: ">=3.11" } },
123
+ });
124
+ expect(() =>
125
+ mcpb.plan({ ...pythonProject, tool: { ...pythonProject.tool, surfaces: ["mcp", "mcpb"] } }),
126
+ ).toThrow(/requires the "pypi" surface/);
127
+ });
102
128
 
129
+ it("declares the kernel's own Node floor and validates with the pinned official CLI", () => {
103
130
  expect(mcpb.validate?.(project)).toEqual([
104
131
  {
105
132
  label: "mcpb validate",
@@ -3,22 +3,30 @@
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
20
  import type { Project, Surface } from "../model.js";
21
- import { compact, configProperties, envName, isSensitive, json, mcpVerdict } from "./shared.js";
21
+ import {
22
+ compact,
23
+ configProperties,
24
+ envName,
25
+ has,
26
+ isSensitive,
27
+ json,
28
+ mcpVerdict,
29
+ } from "./shared.js";
22
30
 
23
31
  export const HOST_DIR = "hosts/mcpb";
24
32
  export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
@@ -31,6 +39,7 @@ export const MCPB_PIN = "2.1.2";
31
39
 
32
40
  /** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
33
41
  export const MANIFEST_VERSION = "0.2";
42
+ export const UV_MANIFEST_VERSION = "0.4";
34
43
 
35
44
  /**
36
45
  * The kernel's built `mcp` entry inside the bundle root — the same file the `mcp-registry`
@@ -55,12 +64,10 @@ const USER_CONFIG_TYPES = new Set(["string", "number", "boolean"]);
55
64
  export const surface: Surface = {
56
65
  id: "mcpb",
57
66
  plan(project: Project) {
58
- if (project.tool.binding !== "typescript") {
59
- // A python bundle would have to vendor an interpreter's site-packages into the bundle root
60
- // (Claude Desktop ships Node, not Python), which `npm pack` plus `npm install` gives the
61
- // TypeScript binding for free and `uv` gives no equivalent of.
67
+ const python = project.tool.binding === "python";
68
+ if (python && !has(project, "pypi")) {
62
69
  throw new Error(
63
- 'Surface "mcpb" requires the typescript binding: a bundle root is the npm tarball with its production dependencies installed into it.',
70
+ 'Surface "mcpb" with the python binding requires the "pypi" surface, the canonical runtime distribution.',
64
71
  );
65
72
  }
66
73
  const { identity } = project;
@@ -82,7 +89,7 @@ export const surface: Surface = {
82
89
  Object.keys(properties).map((key) => [envName(key), `\${user_config.${key}}`]),
83
90
  );
84
91
  const manifest = compact({
85
- manifest_version: MANIFEST_VERSION,
92
+ manifest_version: python ? UV_MANIFEST_VERSION : MANIFEST_VERSION,
86
93
  name: identity.name,
87
94
  display_name: identity.name,
88
95
  version: identity.version,
@@ -95,15 +102,29 @@ export const surface: Surface = {
95
102
  privacy_policies: project.tool.mcpb?.privacyPolicies,
96
103
  keywords: identity.keywords,
97
104
  server: {
98
- type: "node",
99
- entry_point: entryPoint(),
105
+ type: python ? "uv" : "node",
106
+ entry_point: python ? `${pythonKernelDir(project)}/mcp.py` : entryPoint(),
100
107
  // `${__dirname}` is the installed bundle's own directory: an install is an unzip
101
- // somewhere else, so nothing in the launch may be repository-relative.
102
- mcp_config: compact({
103
- command: "node",
104
- args: [`\${__dirname}/${entryPoint()}`],
105
- env: Object.keys(env).length ? env : undefined,
106
- }),
108
+ // somewhere else, so nothing in the launch may be repository-relative. For `uv`, this
109
+ // delegates dependency resolution and Python provisioning to the host's supported runtime.
110
+ mcp_config: python
111
+ ? compact({
112
+ command: "uv",
113
+ args: [
114
+ "run",
115
+ "--directory",
116
+ "${__dirname}",
117
+ "python",
118
+ "-m",
119
+ `${pythonPackage(project)}.toolfactory.mcp`,
120
+ ],
121
+ env: Object.keys(env).length ? env : undefined,
122
+ })
123
+ : compact({
124
+ command: "node",
125
+ args: [`\${__dirname}/${entryPoint()}`],
126
+ env: Object.keys(env).length ? env : undefined,
127
+ }),
107
128
  },
108
129
  // Exactly the operations the kernel's own `tools/list` serves (§4.3): the manifest is what
109
130
  // the install UI shows before the server ever runs, so it must not promise more.
@@ -111,7 +132,7 @@ export const surface: Surface = {
111
132
  .filter((operation) => mcpVerdict(operation).kind !== "excluded")
112
133
  .map((operation) => compact({ name: operation.name, description: operation.description })),
113
134
  user_config: userConfig.length ? Object.fromEntries(userConfig) : undefined,
114
- compatibility: { runtimes: { node: NODE_ENGINES } },
135
+ compatibility: { runtimes: python ? { python: ">=3.11" } : { node: NODE_ENGINES } },
115
136
  });
116
137
  return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
117
138
  },
@@ -4,9 +4,9 @@
4
4
  */
5
5
 
6
6
  import { githubSlug } from "../hosts/github.js";
7
- import type { Surface } from "../model.js";
7
+ import type { Project, Surface } from "../model.js";
8
8
  import { registryName } from "./mcp-registry.js";
9
- import { compact, has, npmName } from "./shared.js";
9
+ import { compact, has, npmName, pypiName } from "./shared.js";
10
10
  import { WEB_DIR } from "./web.js";
11
11
 
12
12
  /**
@@ -22,14 +22,31 @@ function repositoryField(
22
22
  return { type: "git", url: slug ? `git+https://github.com/${slug}.git` : repository };
23
23
  }
24
24
 
25
+ /**
26
+ * A deliberately thin cross-registry bridge: npm supplies discovery and a familiar `npx` entry,
27
+ * while uvx resolves the canonical Python distribution. `uvx` owns Python acquisition, caching,
28
+ * and the package install; the npm package never downloads an interpreter itself.
29
+ */
30
+ function pythonLauncher(project: Project): string {
31
+ const { identity } = project;
32
+ return `#!/usr/bin/env node
33
+ import { spawnSync } from "node:child_process";
34
+
35
+ const child = spawnSync("uvx", ["--from", ${JSON.stringify(`${pypiName(project)}==${identity.version}`)}, ${JSON.stringify(identity.name)}, ...process.argv.slice(2)], {
36
+ stdio: "inherit",
37
+ });
38
+ if (child.error) throw child.error;
39
+ process.exit(child.status ?? 1);
40
+ `;
41
+ }
42
+
25
43
  export const surface: Surface = {
26
44
  id: "npm",
27
45
  plan(project) {
28
- if (project.tool.binding !== "typescript") {
29
- throw new Error('Surface "npm" requires the typescript binding.');
30
- }
31
46
  const { identity } = project;
32
47
  const authored = project.tool.identity === "package.json";
48
+ const python = project.tool.binding === "python";
49
+ const launcher = `bin/${identity.name}.mjs`;
33
50
  const patch = compact({
34
51
  name: authored ? undefined : npmName(project),
35
52
  version: authored ? undefined : identity.version,
@@ -41,21 +58,36 @@ export const surface: Surface = {
41
58
  type: "module",
42
59
  bin:
43
60
  has(project, "cli") || has(project, "mcp")
44
- ? { [identity.name]: "./dist/toolfactory/cli.js" }
61
+ ? {
62
+ [identity.name]: python ? `./${launcher}` : "./dist/toolfactory/cli.js",
63
+ }
45
64
  : undefined,
46
65
  // `web/dist` only with the `web` surface: the kernel serves the built page from inside
47
66
  // the installed package, so `npx <tool> mcp --http --open` works without a checkout.
48
- files: [
49
- "dist",
50
- "src",
51
- "schemas",
52
- ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
53
- "README.md",
54
- "LICENSE",
55
- ],
67
+ files: python
68
+ ? ["bin", "README.md", "LICENSE"]
69
+ : [
70
+ "dist",
71
+ "src",
72
+ "schemas",
73
+ ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
74
+ "README.md",
75
+ "LICENSE",
76
+ ],
56
77
  mcpName: has(project, "mcp-registry") ? registryName(project) : undefined,
57
78
  });
58
- return [{ kind: "merge", path: "package.json", format: "json", patch, owned: ["bin"] }];
79
+ return [
80
+ ...(python && (has(project, "cli") || has(project, "mcp"))
81
+ ? [{ kind: "file" as const, path: launcher, content: pythonLauncher(project) }]
82
+ : []),
83
+ {
84
+ kind: "merge" as const,
85
+ path: "package.json",
86
+ format: "json" as const,
87
+ patch,
88
+ owned: ["bin", "files"],
89
+ },
90
+ ];
59
91
  },
60
92
  validate(project) {
61
93
  return [
@@ -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",
@@ -29,19 +29,43 @@ export const surface: Surface = {
29
29
  ? { [project.identity.name]: cliEntryPoint(project) }
30
30
  : undefined,
31
31
  }),
32
- // The kernel serves the built page from inside its own package, so the wheel has to
33
- // carry it: `packages` only copies the package tree, `force-include` maps anything else.
32
+ // Hatch ignores a missing `only-include` path, unlike `force-include`, which makes
33
+ // `uv sync` work in a fresh checkout before the optional page is built. At package time,
34
+ // the gate builds `web/dist` first and Hatch maps it into the installed package.
34
35
  ...(has(project, "web")
35
36
  ? {
36
37
  tool: {
37
38
  hatch: {
38
39
  build: {
40
+ // Hatch's normal file selection honors .gitignore; `web/dist` is ignored in
41
+ // every generated project, so artifacts is the standard explicit opt-in for
42
+ // the release build's compiled page.
43
+ artifacts: [`${WEB_DIR}/dist/**`],
39
44
  targets: {
40
45
  wheel: {
41
- "force-include": {
46
+ "only-include": [`src/${pythonPackage(project)}`, `${WEB_DIR}/dist`],
47
+ sources: {
48
+ src: "",
42
49
  [`${WEB_DIR}/dist`]: `${pythonPackage(project)}/${WEB_DIR}`,
43
50
  },
44
51
  },
52
+ // This is the source MCPB stages, so include every declared project metadata
53
+ // file alongside the package and built page. `only-include` ignores absent
54
+ // legal files without making fresh checkouts fail.
55
+ sdist: {
56
+ "only-include": [
57
+ "pyproject.toml",
58
+ "README.md",
59
+ "LICENSE",
60
+ "LICENSE.md",
61
+ "LICENSE.txt",
62
+ "NOTICE",
63
+ "NOTICE.md",
64
+ "NOTICE.txt",
65
+ `src/${pythonPackage(project)}`,
66
+ `${WEB_DIR}/dist`,
67
+ ],
68
+ },
45
69
  },
46
70
  },
47
71
  },