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.
- package/README.md +31 -3
- package/dist/bindings/python.js +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/distribution/native.d.ts +50 -0
- package/dist/distribution/native.js +414 -0
- package/dist/distribution/native.test.d.ts +1 -0
- package/dist/distribution/native.test.js +127 -0
- package/dist/distribution/system.d.ts +57 -0
- package/dist/distribution/system.js +637 -0
- package/dist/distribution/system.test.d.ts +1 -0
- package/dist/distribution/system.test.js +116 -0
- package/dist/e2e.test.js +7 -1
- package/dist/hosts/openclaw.js +6 -2
- package/dist/hosts/vscode.d.ts +1 -0
- package/dist/hosts/vscode.js +67 -0
- package/dist/model.d.ts +63 -2
- package/dist/model.js +18 -1
- package/dist/ops.d.ts +52 -0
- package/dist/project/apply.d.ts +1 -1
- package/dist/project/apply.js +194 -17
- package/dist/project/apply.test.js +103 -0
- package/dist/project/gate.d.ts +17 -2
- package/dist/project/gate.js +102 -23
- package/dist/project/history.d.ts +30 -1
- package/dist/project/lock.d.ts +5 -0
- package/dist/project/lock.js +9 -0
- package/dist/surfaces/agent-plugins.js +5 -4
- package/dist/surfaces/agents.js +27 -3
- package/dist/surfaces/agents.test.js +12 -0
- package/dist/surfaces/claude.js +8 -3
- package/dist/surfaces/codex.js +9 -4
- package/dist/surfaces/codex.test.js +16 -6
- package/dist/surfaces/cursor.js +2 -2
- package/dist/surfaces/distribution.test.js +121 -7
- package/dist/surfaces/dsh.js +29 -7
- package/dist/surfaces/dsh.test.js +11 -6
- package/dist/surfaces/gemini.js +2 -2
- package/dist/surfaces/gemini.test.js +4 -4
- package/dist/surfaces/hermes-native.js +43 -12
- package/dist/surfaces/hermes-native.test.js +53 -11
- package/dist/surfaces/mcp-registry.js +59 -7
- package/dist/surfaces/mcp-registry.test.js +23 -0
- package/dist/surfaces/mcpb.d.ts +1 -0
- package/dist/surfaces/mcpb.js +36 -23
- package/dist/surfaces/mcpb.test.js +31 -3
- package/dist/surfaces/npm.js +45 -14
- package/dist/surfaces/openclaw-native.js +10 -8
- package/dist/surfaces/openclaw-native.test.js +30 -0
- package/dist/surfaces/pypi.js +27 -3
- package/dist/surfaces/readme.d.ts +16 -0
- package/dist/surfaces/readme.js +17 -7
- package/dist/surfaces/registry.js +9 -3
- package/dist/surfaces/vscode-extension.d.ts +14 -0
- package/dist/surfaces/vscode-extension.js +312 -0
- package/dist/surfaces/vscode-extension.test.d.ts +1 -0
- package/dist/surfaces/vscode-extension.test.js +81 -0
- package/dist/surfaces/vscode-scaffold.d.ts +43 -0
- package/dist/surfaces/vscode-scaffold.js +43 -0
- package/dist/surfaces/workflows.js +57 -11
- package/dist/surfaces/workflows.test.js +71 -3
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +2 -1
- package/src/bindings/python.ts +1 -1
- package/src/distribution/native.test.ts +161 -0
- package/src/distribution/native.ts +460 -0
- package/src/distribution/system.test.ts +136 -0
- package/src/distribution/system.ts +688 -0
- package/src/e2e.test.ts +7 -1
- package/src/hosts/openclaw.ts +4 -2
- package/src/hosts/vscode.ts +79 -0
- package/src/model.ts +26 -2
- package/src/project/apply.test.ts +120 -0
- package/src/project/apply.ts +257 -15
- package/src/project/gate.ts +131 -24
- package/src/project/lock.ts +12 -0
- package/src/surfaces/agent-plugins.ts +5 -4
- package/src/surfaces/agents.test.ts +16 -0
- package/src/surfaces/agents.ts +38 -3
- package/src/surfaces/claude.ts +7 -3
- package/src/surfaces/codex.test.ts +15 -5
- package/src/surfaces/codex.ts +12 -6
- package/src/surfaces/cursor.ts +2 -10
- package/src/surfaces/distribution.test.ts +141 -6
- package/src/surfaces/dsh.test.ts +11 -5
- package/src/surfaces/dsh.ts +30 -7
- package/src/surfaces/gemini.test.ts +3 -3
- package/src/surfaces/gemini.ts +1 -2
- package/src/surfaces/hermes-native.test.ts +61 -10
- package/src/surfaces/hermes-native.ts +46 -13
- package/src/surfaces/mcp-registry.test.ts +28 -0
- package/src/surfaces/mcp-registry.ts +66 -8
- package/src/surfaces/mcpb.test.ts +32 -5
- package/src/surfaces/mcpb.ts +44 -23
- package/src/surfaces/npm.ts +47 -15
- package/src/surfaces/openclaw-native.test.ts +37 -0
- package/src/surfaces/openclaw-native.ts +49 -47
- package/src/surfaces/pypi.ts +27 -3
- package/src/surfaces/readme.ts +21 -6
- package/src/surfaces/registry.ts +9 -3
- package/src/surfaces/vscode-extension.test.ts +89 -0
- package/src/surfaces/vscode-extension.ts +345 -0
- package/src/surfaces/vscode-scaffold.ts +55 -0
- package/src/surfaces/workflows.test.ts +86 -3
- package/src/surfaces/workflows.ts +57 -11
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
- package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
- package/web/dist/index.html +2 -2
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
1
5
|
import { describe, expect, it } from "vitest";
|
|
2
6
|
import type { Operation, Project, SurfaceId } from "../model.js";
|
|
3
7
|
import { buildPlan } from "../project/plan.js";
|
|
4
8
|
import { surface as claude } from "./claude.js";
|
|
5
9
|
import { surface as npm } from "./npm.js";
|
|
6
|
-
import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
|
|
10
|
+
import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
|
|
7
11
|
import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
|
|
8
12
|
|
|
9
13
|
const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
|
|
@@ -52,14 +56,142 @@ describe("npm", () => {
|
|
|
52
56
|
"--yes publint --strict",
|
|
53
57
|
);
|
|
54
58
|
});
|
|
59
|
+
|
|
60
|
+
it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
|
|
61
|
+
const python = project(["npm", "pypi", "cli", "mcp"], {
|
|
62
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
|
|
63
|
+
});
|
|
64
|
+
const files = npm.plan(python);
|
|
65
|
+
const launcher = files.find((file) => file.path === "bin/hello.mjs");
|
|
66
|
+
const packageJson = files.find((file) => file.path === "package.json");
|
|
67
|
+
if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
|
|
68
|
+
throw new Error("expected a launcher and package metadata");
|
|
69
|
+
}
|
|
70
|
+
expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
|
|
71
|
+
expect(launcher.content).toContain("...process.argv.slice(2)");
|
|
72
|
+
expect(packageJson.patch).toMatchObject({
|
|
73
|
+
name: "hello",
|
|
74
|
+
version: "0.1.0",
|
|
75
|
+
bin: { hello: "./bin/hello.mjs" },
|
|
76
|
+
files: ["bin", "README.md", "LICENSE"],
|
|
77
|
+
});
|
|
78
|
+
expect(region(python)).toContain(
|
|
79
|
+
"requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ships the built web page in Python's prebuilt wheel before release publication", () => {
|
|
84
|
+
const python = project(["pypi", "web"], {
|
|
85
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
|
|
86
|
+
});
|
|
87
|
+
const [file] = pypi.plan(python);
|
|
88
|
+
if (file?.kind !== "merge") throw new Error("expected Python package metadata");
|
|
89
|
+
expect(file.patch).toMatchObject({
|
|
90
|
+
tool: {
|
|
91
|
+
hatch: {
|
|
92
|
+
build: {
|
|
93
|
+
artifacts: ["web/dist/**"],
|
|
94
|
+
targets: {
|
|
95
|
+
wheel: {
|
|
96
|
+
"only-include": ["src/hello", "web/dist"],
|
|
97
|
+
sources: { src: "", "web/dist": "hello/web" },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(JSON.stringify(file.patch)).not.toContain("force-include");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
|
|
109
|
+
|
|
110
|
+
describe.skipIf(!uv)("Python release source", () => {
|
|
111
|
+
it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
|
|
112
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
|
|
113
|
+
mkdirSync(join(root, "src", "hello"), { recursive: true });
|
|
114
|
+
mkdirSync(join(root, "web", "dist"), { recursive: true });
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(root, "pyproject.toml"),
|
|
117
|
+
`[project]
|
|
118
|
+
name = "hello"
|
|
119
|
+
version = "0.1.0"
|
|
120
|
+
readme = "README.md"
|
|
121
|
+
|
|
122
|
+
[build-system]
|
|
123
|
+
requires = ["hatchling"]
|
|
124
|
+
build-backend = "hatchling.build"
|
|
125
|
+
|
|
126
|
+
[tool.hatch.build]
|
|
127
|
+
artifacts = ["web/dist/**"]
|
|
128
|
+
|
|
129
|
+
[tool.hatch.build.targets.wheel]
|
|
130
|
+
packages = ["src/hello"]
|
|
131
|
+
only-include = ["src/hello", "web/dist"]
|
|
132
|
+
|
|
133
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
134
|
+
src = ""
|
|
135
|
+
"web/dist" = "hello/web"
|
|
136
|
+
|
|
137
|
+
[tool.hatch.build.targets.sdist]
|
|
138
|
+
only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
|
|
139
|
+
`,
|
|
140
|
+
);
|
|
141
|
+
writeFileSync(join(root, "README.md"), "# hello\n");
|
|
142
|
+
writeFileSync(join(root, "LICENSE"), "MIT\n");
|
|
143
|
+
writeFileSync(join(root, ".gitignore"), "dist/\n");
|
|
144
|
+
writeFileSync(join(root, "src", "hello", "__init__.py"), "");
|
|
145
|
+
mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
|
|
146
|
+
writeFileSync(
|
|
147
|
+
join(root, "web", "dist", "index.html"),
|
|
148
|
+
'<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n',
|
|
149
|
+
);
|
|
150
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
|
|
151
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
|
|
152
|
+
const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
});
|
|
156
|
+
expect(build.status, build.stderr).toBe(0);
|
|
157
|
+
const wheel = spawnSync(
|
|
158
|
+
"unzip",
|
|
159
|
+
["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")],
|
|
160
|
+
{ encoding: "utf8" },
|
|
161
|
+
);
|
|
162
|
+
expect(wheel.status, wheel.stderr).toBe(0);
|
|
163
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.css");
|
|
164
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.js");
|
|
165
|
+
const stage = join(root, "dist", "mcpb");
|
|
166
|
+
mkdirSync(stage, { recursive: true });
|
|
167
|
+
const extract = spawnSync(
|
|
168
|
+
"tar",
|
|
169
|
+
[
|
|
170
|
+
"-xzf",
|
|
171
|
+
join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
|
|
172
|
+
"-C",
|
|
173
|
+
stage,
|
|
174
|
+
"--strip-components=1",
|
|
175
|
+
],
|
|
176
|
+
{ encoding: "utf8" },
|
|
177
|
+
);
|
|
178
|
+
expect(extract.status, extract.stderr).toBe(0);
|
|
179
|
+
const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
|
|
180
|
+
expect(sync.status, sync.stderr).toBe(0);
|
|
181
|
+
expect(existsSync(join(stage, "README.md"))).toBe(true);
|
|
182
|
+
expect(existsSync(join(stage, "LICENSE"))).toBe(true);
|
|
183
|
+
expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
|
|
184
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
|
|
185
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
|
|
186
|
+
});
|
|
55
187
|
});
|
|
56
188
|
|
|
57
189
|
describe("claude", () => {
|
|
58
190
|
it("makes the repository its own single-plugin marketplace", () => {
|
|
59
191
|
const files = claude.plan(project(["claude", "mcp"]));
|
|
60
192
|
const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
|
|
61
|
-
if (file?.kind !== "
|
|
62
|
-
expect(
|
|
193
|
+
if (file?.kind !== "merge") throw new Error("expected a merge file");
|
|
194
|
+
expect(file.patch).toMatchObject({
|
|
63
195
|
name: "hello",
|
|
64
196
|
owner: { name: "GoatInAHat" },
|
|
65
197
|
plugins: [{ name: "hello", source: "./", description: "Say hello" }],
|
|
@@ -103,14 +235,17 @@ describe("readme", () => {
|
|
|
103
235
|
expect(body).toContain(
|
|
104
236
|
"download `hello.mcpb` from the GitHub Release and double-click to install",
|
|
105
237
|
);
|
|
106
|
-
// The extension's three channels — unpacked from a checkout,
|
|
107
|
-
// signed
|
|
238
|
+
// The extension's three channels — unpacked from a checkout, release assets (including an
|
|
239
|
+
// optional signed XPI), and store listings — and the pairing step that ends each.
|
|
108
240
|
expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
|
|
109
241
|
expect(body).toContain("web-ext run");
|
|
110
242
|
expect(body).toContain(
|
|
111
243
|
"`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`",
|
|
112
244
|
);
|
|
113
|
-
expect(body).toContain(
|
|
245
|
+
expect(body).toContain(
|
|
246
|
+
"When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`",
|
|
247
|
+
);
|
|
248
|
+
expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
|
|
114
249
|
expect(body).toContain("`npx -y hello mcp --http --pair`");
|
|
115
250
|
});
|
|
116
251
|
|
package/src/surfaces/dsh.test.ts
CHANGED
|
@@ -36,8 +36,14 @@ const project: Project = {
|
|
|
36
36
|
|
|
37
37
|
const files = Object.fromEntries(
|
|
38
38
|
dsh.plan(project).map((file) => {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
return [
|
|
40
|
+
file.path,
|
|
41
|
+
file.kind === "file"
|
|
42
|
+
? file.content
|
|
43
|
+
: file.kind === "merge"
|
|
44
|
+
? JSON.stringify(file.patch)
|
|
45
|
+
: file.regions.map((region) => region.content).join(""),
|
|
46
|
+
];
|
|
41
47
|
}),
|
|
42
48
|
);
|
|
43
49
|
|
|
@@ -68,8 +74,8 @@ describe("dsh", () => {
|
|
|
68
74
|
command: "npx",
|
|
69
75
|
args: ["-y", "hello.tool@0.1.0", "mcp"],
|
|
70
76
|
env: {
|
|
71
|
-
APIKEY:
|
|
72
|
-
REGION:
|
|
77
|
+
APIKEY: 'process.env.APIKEY ?? ""',
|
|
78
|
+
REGION: 'process.env.REGION ?? ""',
|
|
73
79
|
},
|
|
74
80
|
failOnStartupError: true,
|
|
75
81
|
},
|
|
@@ -79,7 +85,7 @@ describe("dsh", () => {
|
|
|
79
85
|
]);
|
|
80
86
|
// DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
|
|
81
87
|
// restated as the `!!js` expression DSH evaluates when it loads the patch.
|
|
82
|
-
expect(patch).toContain(
|
|
88
|
+
expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
|
|
83
89
|
});
|
|
84
90
|
|
|
85
91
|
it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
|
package/src/surfaces/dsh.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { Scalar, stringify as yamlStringify } from "yaml";
|
|
|
22
22
|
import { getBinding } from "../bindings/index.js";
|
|
23
23
|
import { projectName } from "../identity/name.js";
|
|
24
24
|
import type { Project, Surface } from "../model.js";
|
|
25
|
-
import { compact, configProperties, envName,
|
|
25
|
+
import { compact, configProperties, envName, kernelLaunch, mcpVerdict } from "./shared.js";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* The `@deepseek-ai/dsh` release `validate()` boots. Pinned, not `@alpha`: npm's `latest` tag
|
|
@@ -40,6 +40,8 @@ export const PATCH_FILE = "cordis.patch.yml";
|
|
|
40
40
|
* passes to `dsh --patch` to drive the working tree.
|
|
41
41
|
*/
|
|
42
42
|
export const LOCAL_PATCH_FILE = "cordis.local.patch.yml";
|
|
43
|
+
const PATCH_BEGIN = "# tf:mcp";
|
|
44
|
+
const PATCH_END = "# /tf:mcp";
|
|
43
45
|
|
|
44
46
|
/** DSH's first-party MCP bridge; the only plugin the bundle names. */
|
|
45
47
|
const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
|
|
@@ -71,7 +73,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
|
|
|
71
73
|
const env = Object.fromEntries(
|
|
72
74
|
Object.keys(configProperties(project)).map((key) => [
|
|
73
75
|
envName(key),
|
|
74
|
-
|
|
76
|
+
// Schemastery's dict rejects the empty object produced when every `!!js`
|
|
77
|
+
// value resolves to undefined. Keep every declared key string-valued;
|
|
78
|
+
// an absent optional credential remains the empty string for the child.
|
|
79
|
+
jsExpression(`process.env.${envName(key)} ?? ""`),
|
|
75
80
|
]),
|
|
76
81
|
);
|
|
77
82
|
const row = {
|
|
@@ -88,6 +93,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
|
|
|
88
93
|
return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
function patchTemplate(): string {
|
|
97
|
+
return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
/** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
|
|
92
101
|
export function toolName(project: Project, operation: string): string {
|
|
93
102
|
return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
|
|
@@ -117,20 +126,34 @@ export const surface: Surface = {
|
|
|
117
126
|
dsh: { bundle: { patch: `./${PATCH_FILE}` } },
|
|
118
127
|
});
|
|
119
128
|
return [
|
|
120
|
-
{ kind: "
|
|
129
|
+
{ kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
|
|
121
130
|
{
|
|
122
|
-
kind: "
|
|
131
|
+
kind: "region",
|
|
123
132
|
path: `${HOST_DIR}/${PATCH_FILE}`,
|
|
124
133
|
// `cwd` is deliberately absent: an installed bundle has no path to point at, and the
|
|
125
134
|
// published launch resolves through the registry from wherever DSH is running.
|
|
126
|
-
|
|
135
|
+
regions: [
|
|
136
|
+
{
|
|
137
|
+
begin: PATCH_BEGIN,
|
|
138
|
+
end: PATCH_END,
|
|
139
|
+
content: patch(project, kernelLaunch(project, ".")),
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
template: patchTemplate(),
|
|
127
143
|
},
|
|
128
144
|
{
|
|
129
|
-
kind: "
|
|
145
|
+
kind: "region",
|
|
130
146
|
path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
|
|
131
147
|
// The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
|
|
132
148
|
// against the directory DSH itself was started in.
|
|
133
|
-
|
|
149
|
+
regions: [
|
|
150
|
+
{
|
|
151
|
+
begin: PATCH_BEGIN,
|
|
152
|
+
end: PATCH_END,
|
|
153
|
+
content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
template: patchTemplate(),
|
|
134
157
|
},
|
|
135
158
|
];
|
|
136
159
|
},
|
|
@@ -34,8 +34,8 @@ const project: Project = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const [manifestFile, ...rest] = gemini.plan(project);
|
|
37
|
-
if (manifestFile?.kind !== "
|
|
38
|
-
const manifest =
|
|
37
|
+
if (manifestFile?.kind !== "merge") throw new Error("expected one merge file");
|
|
38
|
+
const manifest = manifestFile.patch as Record<string, unknown>;
|
|
39
39
|
|
|
40
40
|
describe("gemini", () => {
|
|
41
41
|
it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
|
|
@@ -58,7 +58,7 @@ describe("gemini", () => {
|
|
|
58
58
|
],
|
|
59
59
|
});
|
|
60
60
|
// `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
|
|
61
|
-
expect(manifestFile.
|
|
61
|
+
expect(JSON.stringify(manifestFile.patch)).not.toContain('"skills"');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
|
package/src/surfaces/gemini.ts
CHANGED
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
configProperties,
|
|
27
27
|
envName,
|
|
28
28
|
isSensitive,
|
|
29
|
-
json,
|
|
30
29
|
kernelLaunch,
|
|
31
30
|
mcpVerdict,
|
|
32
31
|
} from "./shared.js";
|
|
@@ -64,7 +63,7 @@ export const surface: Surface = {
|
|
|
64
63
|
contextFileName: AGENTS_PATH,
|
|
65
64
|
settings: settings.length ? settings : undefined,
|
|
66
65
|
});
|
|
67
|
-
return [{ kind: "
|
|
66
|
+
return [{ kind: "merge", path: MANIFEST_PATH, format: "json", patch: manifest }];
|
|
68
67
|
},
|
|
69
68
|
validate(project) {
|
|
70
69
|
// Gemini's own manifest validator: it parses the manifest, enforces the name and version
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
6
6
|
import { describe, expect, it } from "vitest";
|
|
7
|
-
import { parse as parseYaml } from "yaml";
|
|
7
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
8
8
|
import type { Operation, Project } from "../model.js";
|
|
9
|
+
import { apply } from "../project/apply.js";
|
|
9
10
|
import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
|
|
10
11
|
|
|
11
12
|
const echo: Operation = {
|
|
@@ -21,6 +22,12 @@ const echo: Operation = {
|
|
|
21
22
|
requires: [],
|
|
22
23
|
};
|
|
23
24
|
const shoot: Operation = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
|
|
25
|
+
const web: Operation = {
|
|
26
|
+
name: "web",
|
|
27
|
+
description: "Open the generated operations page.",
|
|
28
|
+
inputSchema: { type: "object" },
|
|
29
|
+
requires: [],
|
|
30
|
+
};
|
|
24
31
|
|
|
25
32
|
function project(overrides: Partial<Project> = {}): Project {
|
|
26
33
|
return {
|
|
@@ -55,13 +62,45 @@ function project(overrides: Partial<Project> = {}): Project {
|
|
|
55
62
|
|
|
56
63
|
function emitted(target: Project): Record<string, string> {
|
|
57
64
|
return Object.fromEntries(
|
|
58
|
-
surface
|
|
65
|
+
surface
|
|
66
|
+
.plan(target)
|
|
67
|
+
.map((file) => [
|
|
68
|
+
file.path,
|
|
69
|
+
file.kind === "file"
|
|
70
|
+
? file.content
|
|
71
|
+
: file.kind === "merge"
|
|
72
|
+
? file.format === "yaml"
|
|
73
|
+
? stringifyYaml(file.patch)
|
|
74
|
+
: file.format === "toml"
|
|
75
|
+
? stringifyToml(file.patch)
|
|
76
|
+
: JSON.stringify(file.patch)
|
|
77
|
+
: file.regions.map((region) => region.content).join(""),
|
|
78
|
+
]),
|
|
59
79
|
);
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
const python = project({ tool: { ...project().tool, binding: "python" } });
|
|
63
83
|
|
|
64
84
|
describe("hermes-native", () => {
|
|
85
|
+
it("keeps native registrations in the author hook while refreshing generated registrations", () => {
|
|
86
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-region-"));
|
|
87
|
+
const target = { ...project(), root };
|
|
88
|
+
apply(root, surface.plan(target), "0.1.0");
|
|
89
|
+
const path = join(root, pluginDir(target), "__init__.py");
|
|
90
|
+
writeFileSync(
|
|
91
|
+
path,
|
|
92
|
+
readFileSync(path, "utf8").replace(
|
|
93
|
+
" pass\n",
|
|
94
|
+
' ctx.register_tool(name="native", toolset="native", schema={}, handler=lambda _: "{}")\n',
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
apply(root, surface.plan({ ...target, operations: [echo] }), "0.1.0");
|
|
98
|
+
const text = readFileSync(path, "utf8");
|
|
99
|
+
expect(text).toContain('name="native"');
|
|
100
|
+
expect(text).toContain("def _register_generated(ctx: Any)");
|
|
101
|
+
expect(text).toContain("_register_generated(ctx)\n register_native(ctx)");
|
|
102
|
+
});
|
|
103
|
+
|
|
65
104
|
it("projects the manifest Hermes parses and the credentials it prompts for", () => {
|
|
66
105
|
const files = emitted(project());
|
|
67
106
|
const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "") as Record<
|
|
@@ -128,6 +167,22 @@ describe("hermes-native", () => {
|
|
|
128
167
|
expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
|
|
129
168
|
});
|
|
130
169
|
|
|
170
|
+
it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
|
|
171
|
+
const target = project({
|
|
172
|
+
tool: {
|
|
173
|
+
...project().tool,
|
|
174
|
+
binding: "python",
|
|
175
|
+
surfaces: ["hermes-native", "cli", "mcp", "web"],
|
|
176
|
+
},
|
|
177
|
+
operations: [echo, web],
|
|
178
|
+
});
|
|
179
|
+
const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
|
|
180
|
+
expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
|
|
181
|
+
expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
|
|
182
|
+
expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
|
|
183
|
+
expect(native).toContain('handler=_handler(schema["name"])');
|
|
184
|
+
});
|
|
185
|
+
|
|
131
186
|
it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
|
|
132
187
|
const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
|
|
133
188
|
expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
|
|
@@ -162,11 +217,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
|
|
|
162
217
|
it("loads and registers the generated plugin", () => {
|
|
163
218
|
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
|
|
164
219
|
try {
|
|
165
|
-
|
|
166
|
-
if (file.kind !== "file") continue;
|
|
167
|
-
mkdirSync(dirname(join(root, file.path)), { recursive: true });
|
|
168
|
-
writeFileSync(join(root, file.path), file.content);
|
|
169
|
-
}
|
|
220
|
+
apply(root, surface.plan(project()), "0.1.0");
|
|
170
221
|
const commands = surface.validate?.({ ...project(), root }) ?? [];
|
|
171
222
|
const command = commands.find((c) => c.label === "hermes plugins doctor");
|
|
172
223
|
if (!command) throw new Error("the hermes surface no longer declares a doctor validator");
|
|
@@ -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";
|
|
@@ -36,6 +34,7 @@ import {
|
|
|
36
34
|
configProperties,
|
|
37
35
|
dataDirEnvName,
|
|
38
36
|
envName,
|
|
37
|
+
has,
|
|
39
38
|
isSensitive,
|
|
40
39
|
pypiName,
|
|
41
40
|
requiredConfig,
|
|
@@ -71,6 +70,8 @@ export function hermesInstall(): string {
|
|
|
71
70
|
}
|
|
72
71
|
|
|
73
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";
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* The plugin package. It is a sibling of the author's own package rather than part of it: a
|
|
@@ -250,7 +251,7 @@ def test_register_adds_every_declared_tool() -> None:
|
|
|
250
251
|
`;
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
function manifest(project: Project, operations: Operation[]): string {
|
|
254
|
+
function manifest(project: Project, operations: Operation[]): Record<string, unknown> {
|
|
254
255
|
const { identity } = project;
|
|
255
256
|
const { required, optional } = envDeclarations(project);
|
|
256
257
|
const shim = project.tool.binding !== "python";
|
|
@@ -277,10 +278,10 @@ function manifest(project: Project, operations: Operation[]): string {
|
|
|
277
278
|
optional_env: optional.length ? optional : undefined,
|
|
278
279
|
python_dependencies: core ? [core] : undefined,
|
|
279
280
|
});
|
|
280
|
-
return
|
|
281
|
+
return document as Record<string, unknown>;
|
|
281
282
|
}
|
|
282
283
|
|
|
283
|
-
function pyproject(project: Project): string {
|
|
284
|
+
function pyproject(project: Project): Record<string, unknown> {
|
|
284
285
|
const core = pinnedCore(project) ?? pypiName(project);
|
|
285
286
|
const python = project.tool.binding === "python";
|
|
286
287
|
const document = compact({
|
|
@@ -305,7 +306,7 @@ function pyproject(project: Project): string {
|
|
|
305
306
|
: undefined,
|
|
306
307
|
}),
|
|
307
308
|
});
|
|
308
|
-
return
|
|
309
|
+
return document as Record<string, unknown>;
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
/** isort order: plain `import` before `from`, alphabetical within each. */
|
|
@@ -333,14 +334,16 @@ interface PluginCore {
|
|
|
333
334
|
/** Python core: the handler calls the author's own operation in-process. */
|
|
334
335
|
function pythonCore(project: Project): PluginCore {
|
|
335
336
|
const pkg = projectName.pythonPackage(project.identity.name);
|
|
337
|
+
const web = has(project, "web");
|
|
336
338
|
return {
|
|
337
339
|
stdlib: ["import asyncio", "import inspect"],
|
|
338
340
|
external: [
|
|
339
341
|
"from pydantic import BaseModel",
|
|
340
|
-
`from ${pkg}.ops import OPERATIONS`,
|
|
342
|
+
`from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
|
|
341
343
|
`from ${pkg}.toolfactory.config import context`,
|
|
344
|
+
...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
|
|
342
345
|
],
|
|
343
|
-
body:
|
|
346
|
+
body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
|
|
344
347
|
"""Fail loudly if the core package and this plugin disagree on the operations."""
|
|
345
348
|
for operation in OPERATIONS:
|
|
346
349
|
if operation.name == name:
|
|
@@ -403,7 +406,7 @@ def _handler(name: str) -> Callable[..., str]:
|
|
|
403
406
|
};
|
|
404
407
|
}
|
|
405
408
|
|
|
406
|
-
function
|
|
409
|
+
function initRegion(project: Project, operations: Operation[]): string {
|
|
407
410
|
const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
|
|
408
411
|
const names = envDeclarations(project).required.map((entry) => entry.name);
|
|
409
412
|
return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
|
|
@@ -431,7 +434,7 @@ def _ready() -> bool:
|
|
|
431
434
|
${core.body}
|
|
432
435
|
|
|
433
436
|
|
|
434
|
-
def
|
|
437
|
+
def _register_generated(ctx: Any) -> None:
|
|
435
438
|
# Hermes' own per-plugin data directory, through the variable every host uses.
|
|
436
439
|
os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
|
|
437
440
|
for schema in TOOLS:
|
|
@@ -444,6 +447,21 @@ def register(ctx: Any) -> None:
|
|
|
444
447
|
requires_env=list(REQUIRES_ENV),
|
|
445
448
|
description=schema["description"],
|
|
446
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
|
|
447
465
|
`;
|
|
448
466
|
}
|
|
449
467
|
|
|
@@ -549,9 +567,24 @@ export const surface: Surface = {
|
|
|
549
567
|
const operations = includedOperations(project, surface);
|
|
550
568
|
const dir = pluginDir(project);
|
|
551
569
|
return [
|
|
552
|
-
{
|
|
553
|
-
|
|
554
|
-
|
|
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
|
+
},
|
|
555
588
|
{ kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
|
|
556
589
|
{
|
|
557
590
|
kind: "file",
|
|
@@ -65,6 +65,34 @@ 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 .");
|
|
75
|
+
|
|
76
|
+
const pythonWeb = files(
|
|
77
|
+
project({
|
|
78
|
+
tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
|
|
82
|
+
expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
|
|
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/");
|
|
68
96
|
});
|
|
69
97
|
});
|
|
70
98
|
|