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,8 +1,12 @@
|
|
|
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 { buildPlan } from "../project/plan.js";
|
|
3
7
|
import { surface as claude } from "./claude.js";
|
|
4
8
|
import { surface as npm } from "./npm.js";
|
|
5
|
-
import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
|
|
9
|
+
import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
|
|
6
10
|
import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
|
|
7
11
|
const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
|
|
8
12
|
function project(surfaces, overrides = {}) {
|
|
@@ -47,14 +51,123 @@ describe("npm", () => {
|
|
|
47
51
|
});
|
|
48
52
|
expect(npm.validate?.(project(["npm"])).map((command) => command.args.join(" "))).toContain("--yes publint --strict");
|
|
49
53
|
});
|
|
54
|
+
it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
|
|
55
|
+
const python = project(["npm", "pypi", "cli", "mcp"], {
|
|
56
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
|
|
57
|
+
});
|
|
58
|
+
const files = npm.plan(python);
|
|
59
|
+
const launcher = files.find((file) => file.path === "bin/hello.mjs");
|
|
60
|
+
const packageJson = files.find((file) => file.path === "package.json");
|
|
61
|
+
if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
|
|
62
|
+
throw new Error("expected a launcher and package metadata");
|
|
63
|
+
}
|
|
64
|
+
expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
|
|
65
|
+
expect(launcher.content).toContain("...process.argv.slice(2)");
|
|
66
|
+
expect(packageJson.patch).toMatchObject({
|
|
67
|
+
name: "hello",
|
|
68
|
+
version: "0.1.0",
|
|
69
|
+
bin: { hello: "./bin/hello.mjs" },
|
|
70
|
+
files: ["bin", "README.md", "LICENSE"],
|
|
71
|
+
});
|
|
72
|
+
expect(region(python)).toContain("requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`");
|
|
73
|
+
});
|
|
74
|
+
it("ships the built web page in Python's prebuilt wheel before release publication", () => {
|
|
75
|
+
const python = project(["pypi", "web"], {
|
|
76
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
|
|
77
|
+
});
|
|
78
|
+
const [file] = pypi.plan(python);
|
|
79
|
+
if (file?.kind !== "merge")
|
|
80
|
+
throw new Error("expected Python package metadata");
|
|
81
|
+
expect(file.patch).toMatchObject({
|
|
82
|
+
tool: {
|
|
83
|
+
hatch: {
|
|
84
|
+
build: {
|
|
85
|
+
artifacts: ["web/dist/**"],
|
|
86
|
+
targets: {
|
|
87
|
+
wheel: {
|
|
88
|
+
"only-include": ["src/hello", "web/dist"],
|
|
89
|
+
sources: { src: "", "web/dist": "hello/web" },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(JSON.stringify(file.patch)).not.toContain("force-include");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
|
|
100
|
+
describe.skipIf(!uv)("Python release source", () => {
|
|
101
|
+
it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
|
|
102
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
|
|
103
|
+
mkdirSync(join(root, "src", "hello"), { recursive: true });
|
|
104
|
+
mkdirSync(join(root, "web", "dist"), { recursive: true });
|
|
105
|
+
writeFileSync(join(root, "pyproject.toml"), `[project]
|
|
106
|
+
name = "hello"
|
|
107
|
+
version = "0.1.0"
|
|
108
|
+
readme = "README.md"
|
|
109
|
+
|
|
110
|
+
[build-system]
|
|
111
|
+
requires = ["hatchling"]
|
|
112
|
+
build-backend = "hatchling.build"
|
|
113
|
+
|
|
114
|
+
[tool.hatch.build]
|
|
115
|
+
artifacts = ["web/dist/**"]
|
|
116
|
+
|
|
117
|
+
[tool.hatch.build.targets.wheel]
|
|
118
|
+
packages = ["src/hello"]
|
|
119
|
+
only-include = ["src/hello", "web/dist"]
|
|
120
|
+
|
|
121
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
122
|
+
src = ""
|
|
123
|
+
"web/dist" = "hello/web"
|
|
124
|
+
|
|
125
|
+
[tool.hatch.build.targets.sdist]
|
|
126
|
+
only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
|
|
127
|
+
`);
|
|
128
|
+
writeFileSync(join(root, "README.md"), "# hello\n");
|
|
129
|
+
writeFileSync(join(root, "LICENSE"), "MIT\n");
|
|
130
|
+
writeFileSync(join(root, ".gitignore"), "dist/\n");
|
|
131
|
+
writeFileSync(join(root, "src", "hello", "__init__.py"), "");
|
|
132
|
+
mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
|
|
133
|
+
writeFileSync(join(root, "web", "dist", "index.html"), '<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n');
|
|
134
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
|
|
135
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
|
|
136
|
+
const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
|
|
137
|
+
cwd: root,
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
});
|
|
140
|
+
expect(build.status, build.stderr).toBe(0);
|
|
141
|
+
const wheel = spawnSync("unzip", ["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")], { encoding: "utf8" });
|
|
142
|
+
expect(wheel.status, wheel.stderr).toBe(0);
|
|
143
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.css");
|
|
144
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.js");
|
|
145
|
+
const stage = join(root, "dist", "mcpb");
|
|
146
|
+
mkdirSync(stage, { recursive: true });
|
|
147
|
+
const extract = spawnSync("tar", [
|
|
148
|
+
"-xzf",
|
|
149
|
+
join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
|
|
150
|
+
"-C",
|
|
151
|
+
stage,
|
|
152
|
+
"--strip-components=1",
|
|
153
|
+
], { encoding: "utf8" });
|
|
154
|
+
expect(extract.status, extract.stderr).toBe(0);
|
|
155
|
+
const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
|
|
156
|
+
expect(sync.status, sync.stderr).toBe(0);
|
|
157
|
+
expect(existsSync(join(stage, "README.md"))).toBe(true);
|
|
158
|
+
expect(existsSync(join(stage, "LICENSE"))).toBe(true);
|
|
159
|
+
expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
|
|
160
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
|
|
161
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
|
|
162
|
+
});
|
|
50
163
|
});
|
|
51
164
|
describe("claude", () => {
|
|
52
165
|
it("makes the repository its own single-plugin marketplace", () => {
|
|
53
166
|
const files = claude.plan(project(["claude", "mcp"]));
|
|
54
167
|
const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
|
|
55
|
-
if (file?.kind !== "
|
|
56
|
-
throw new Error("expected a
|
|
57
|
-
expect(
|
|
168
|
+
if (file?.kind !== "merge")
|
|
169
|
+
throw new Error("expected a merge file");
|
|
170
|
+
expect(file.patch).toMatchObject({
|
|
58
171
|
name: "hello",
|
|
59
172
|
owner: { name: "GoatInAHat" },
|
|
60
173
|
plugins: [{ name: "hello", source: "./", description: "Say hello" }],
|
|
@@ -91,12 +204,13 @@ describe("readme", () => {
|
|
|
91
204
|
expect(body).toContain("`codex plugin add hello@hello`");
|
|
92
205
|
expect(body).toContain("`gemini extensions install https://github.com/GoatInAHat/Hello-Tool`");
|
|
93
206
|
expect(body).toContain("download `hello.mcpb` from the GitHub Release and double-click to install");
|
|
94
|
-
// The extension's three channels — unpacked from a checkout,
|
|
95
|
-
// signed
|
|
207
|
+
// The extension's three channels — unpacked from a checkout, release assets (including an
|
|
208
|
+
// optional signed XPI), and store listings — and the pairing step that ends each.
|
|
96
209
|
expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
|
|
97
210
|
expect(body).toContain("web-ext run");
|
|
98
211
|
expect(body).toContain("`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`");
|
|
99
|
-
expect(body).toContain("Mozilla-signed `.xpi`");
|
|
212
|
+
expect(body).toContain("When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`");
|
|
213
|
+
expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
|
|
100
214
|
expect(body).toContain("`npx -y hello mcp --http --pair`");
|
|
101
215
|
});
|
|
102
216
|
it("names the one command that opens the web app, and only when that surface is selected", () => {
|
package/dist/surfaces/dsh.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { Scalar, stringify as yamlStringify } from "yaml";
|
|
22
22
|
import { getBinding } from "../bindings/index.js";
|
|
23
23
|
import { projectName } from "../identity/name.js";
|
|
24
|
-
import { compact, configProperties, envName,
|
|
24
|
+
import { compact, configProperties, envName, kernelLaunch, mcpVerdict } from "./shared.js";
|
|
25
25
|
/**
|
|
26
26
|
* The `@deepseek-ai/dsh` release `validate()` boots. Pinned, not `@alpha`: npm's `latest` tag
|
|
27
27
|
* lags the alpha line by weeks, so an unpinned install is neither current nor reproducible.
|
|
@@ -37,6 +37,8 @@ export const PATCH_FILE = "cordis.patch.yml";
|
|
|
37
37
|
* passes to `dsh --patch` to drive the working tree.
|
|
38
38
|
*/
|
|
39
39
|
export const LOCAL_PATCH_FILE = "cordis.local.patch.yml";
|
|
40
|
+
const PATCH_BEGIN = "# tf:mcp";
|
|
41
|
+
const PATCH_END = "# /tf:mcp";
|
|
40
42
|
/** DSH's first-party MCP bridge; the only plugin the bundle names. */
|
|
41
43
|
const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
|
|
42
44
|
/** `headless` is one-shot and ships an app bundle; a profile without one blocks forever. */
|
|
@@ -64,7 +66,10 @@ function patch(project, launch) {
|
|
|
64
66
|
const serverName = projectName.dshServer(project.identity.name);
|
|
65
67
|
const env = Object.fromEntries(Object.keys(configProperties(project)).map((key) => [
|
|
66
68
|
envName(key),
|
|
67
|
-
|
|
69
|
+
// Schemastery's dict rejects the empty object produced when every `!!js`
|
|
70
|
+
// value resolves to undefined. Keep every declared key string-valued;
|
|
71
|
+
// an absent optional credential remains the empty string for the child.
|
|
72
|
+
jsExpression(`process.env.${envName(key)} ?? ""`),
|
|
68
73
|
]));
|
|
69
74
|
const row = {
|
|
70
75
|
id: rowId(project),
|
|
@@ -79,6 +84,9 @@ function patch(project, launch) {
|
|
|
79
84
|
};
|
|
80
85
|
return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
|
|
81
86
|
}
|
|
87
|
+
function patchTemplate() {
|
|
88
|
+
return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
|
|
89
|
+
}
|
|
82
90
|
/** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
|
|
83
91
|
export function toolName(project, operation) {
|
|
84
92
|
return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
|
|
@@ -105,20 +113,34 @@ export const surface = {
|
|
|
105
113
|
dsh: { bundle: { patch: `./${PATCH_FILE}` } },
|
|
106
114
|
});
|
|
107
115
|
return [
|
|
108
|
-
{ kind: "
|
|
116
|
+
{ kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
|
|
109
117
|
{
|
|
110
|
-
kind: "
|
|
118
|
+
kind: "region",
|
|
111
119
|
path: `${HOST_DIR}/${PATCH_FILE}`,
|
|
112
120
|
// `cwd` is deliberately absent: an installed bundle has no path to point at, and the
|
|
113
121
|
// published launch resolves through the registry from wherever DSH is running.
|
|
114
|
-
|
|
122
|
+
regions: [
|
|
123
|
+
{
|
|
124
|
+
begin: PATCH_BEGIN,
|
|
125
|
+
end: PATCH_END,
|
|
126
|
+
content: patch(project, kernelLaunch(project, ".")),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
template: patchTemplate(),
|
|
115
130
|
},
|
|
116
131
|
{
|
|
117
|
-
kind: "
|
|
132
|
+
kind: "region",
|
|
118
133
|
path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
|
|
119
134
|
// The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
|
|
120
135
|
// against the directory DSH itself was started in.
|
|
121
|
-
|
|
136
|
+
regions: [
|
|
137
|
+
{
|
|
138
|
+
begin: PATCH_BEGIN,
|
|
139
|
+
end: PATCH_END,
|
|
140
|
+
content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
template: patchTemplate(),
|
|
122
144
|
},
|
|
123
145
|
];
|
|
124
146
|
},
|
|
@@ -31,9 +31,14 @@ const project = {
|
|
|
31
31
|
packageManager: "npm",
|
|
32
32
|
};
|
|
33
33
|
const files = Object.fromEntries(dsh.plan(project).map((file) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
return [
|
|
35
|
+
file.path,
|
|
36
|
+
file.kind === "file"
|
|
37
|
+
? file.content
|
|
38
|
+
: file.kind === "merge"
|
|
39
|
+
? JSON.stringify(file.patch)
|
|
40
|
+
: file.regions.map((region) => region.content).join(""),
|
|
41
|
+
];
|
|
37
42
|
}));
|
|
38
43
|
describe("dsh", () => {
|
|
39
44
|
it("is two shipped files and no code: the bundle manifest and one mcp-client insert row", () => {
|
|
@@ -61,8 +66,8 @@ describe("dsh", () => {
|
|
|
61
66
|
command: "npx",
|
|
62
67
|
args: ["-y", "hello.tool@0.1.0", "mcp"],
|
|
63
68
|
env: {
|
|
64
|
-
APIKEY:
|
|
65
|
-
REGION:
|
|
69
|
+
APIKEY: 'process.env.APIKEY ?? ""',
|
|
70
|
+
REGION: 'process.env.REGION ?? ""',
|
|
66
71
|
},
|
|
67
72
|
failOnStartupError: true,
|
|
68
73
|
},
|
|
@@ -72,7 +77,7 @@ describe("dsh", () => {
|
|
|
72
77
|
]);
|
|
73
78
|
// DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
|
|
74
79
|
// restated as the `!!js` expression DSH evaluates when it loads the patch.
|
|
75
|
-
expect(patch).toContain(
|
|
80
|
+
expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
|
|
76
81
|
});
|
|
77
82
|
it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
|
|
78
83
|
// The local row is the same shape against this working tree, and stays out of `files`.
|
package/dist/surfaces/gemini.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { projectName } from "../identity/name.js";
|
|
21
21
|
import { AGENTS_PATH } from "./agents.js";
|
|
22
|
-
import { compact, configProperties, envName, isSensitive,
|
|
22
|
+
import { compact, configProperties, envName, isSensitive, kernelLaunch, mcpVerdict, } from "./shared.js";
|
|
23
23
|
export const MANIFEST_PATH = "gemini-extension.json";
|
|
24
24
|
/**
|
|
25
25
|
* The `@google/gemini-cli` release `validate()` runs. Pinned so the gate is reproducible;
|
|
@@ -48,7 +48,7 @@ export const surface = {
|
|
|
48
48
|
contextFileName: AGENTS_PATH,
|
|
49
49
|
settings: settings.length ? settings : undefined,
|
|
50
50
|
});
|
|
51
|
-
return [{ kind: "
|
|
51
|
+
return [{ kind: "merge", path: MANIFEST_PATH, format: "json", patch: manifest }];
|
|
52
52
|
},
|
|
53
53
|
validate(project) {
|
|
54
54
|
// Gemini's own manifest validator: it parses the manifest, enforces the name and version
|
|
@@ -30,9 +30,9 @@ const project = {
|
|
|
30
30
|
packageManager: "npm",
|
|
31
31
|
};
|
|
32
32
|
const [manifestFile, ...rest] = gemini.plan(project);
|
|
33
|
-
if (manifestFile?.kind !== "
|
|
34
|
-
throw new Error("expected one
|
|
35
|
-
const manifest =
|
|
33
|
+
if (manifestFile?.kind !== "merge")
|
|
34
|
+
throw new Error("expected one merge file");
|
|
35
|
+
const manifest = manifestFile.patch;
|
|
36
36
|
describe("gemini", () => {
|
|
37
37
|
it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
|
|
38
38
|
expect(rest).toEqual([]);
|
|
@@ -54,7 +54,7 @@ describe("gemini", () => {
|
|
|
54
54
|
],
|
|
55
55
|
});
|
|
56
56
|
// `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
|
|
57
|
-
expect(manifestFile.
|
|
57
|
+
expect(JSON.stringify(manifestFile.patch)).not.toContain('"skills"');
|
|
58
58
|
});
|
|
59
59
|
it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
|
|
60
60
|
expect(gemini.validate?.(project)).toEqual([
|
|
@@ -25,13 +25,11 @@
|
|
|
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 } from "../model.js";
|
|
33
31
|
import { includedOperations } from "../report/coverage.js";
|
|
34
|
-
import { compact, configProperties, dataDirEnvName, envName, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
|
|
32
|
+
import { compact, configProperties, dataDirEnvName, envName, has, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
|
|
35
33
|
export const HOST_DIR = "hosts/hermes";
|
|
36
34
|
/**
|
|
37
35
|
* `hermes_cli/plugins.py` `SUPPORTED_MANIFEST_VERSION` is 2 and the loader parses every field
|
|
@@ -57,6 +55,8 @@ export function hermesInstall() {
|
|
|
57
55
|
return `curl -fsSL ${HERMES_PIN.installer} | bash -s -- --commit ${HERMES_PIN.commit} --skip-setup --skip-browser --skip-computer-use --non-interactive --no-skills`;
|
|
58
56
|
}
|
|
59
57
|
const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
|
|
58
|
+
const INIT_BEGIN = "# tf:init";
|
|
59
|
+
const INIT_END = "# /tf:init";
|
|
60
60
|
/**
|
|
61
61
|
* The plugin package. It is a sibling of the author's own package rather than part of it: a
|
|
62
62
|
* Hermes directory plugin *is* a Python package, and the two must be importable side by side.
|
|
@@ -243,7 +243,7 @@ function manifest(project, operations) {
|
|
|
243
243
|
optional_env: optional.length ? optional : undefined,
|
|
244
244
|
python_dependencies: core ? [core] : undefined,
|
|
245
245
|
});
|
|
246
|
-
return
|
|
246
|
+
return document;
|
|
247
247
|
}
|
|
248
248
|
function pyproject(project) {
|
|
249
249
|
const core = pinnedCore(project) ?? pypiName(project);
|
|
@@ -270,7 +270,7 @@ function pyproject(project) {
|
|
|
270
270
|
: undefined,
|
|
271
271
|
}),
|
|
272
272
|
});
|
|
273
|
-
return
|
|
273
|
+
return document;
|
|
274
274
|
}
|
|
275
275
|
/** isort order: plain `import` before `from`, alphabetical within each. */
|
|
276
276
|
function sortImports(lines) {
|
|
@@ -287,14 +287,16 @@ function importBlock(core) {
|
|
|
287
287
|
/** Python core: the handler calls the author's own operation in-process. */
|
|
288
288
|
function pythonCore(project) {
|
|
289
289
|
const pkg = projectName.pythonPackage(project.identity.name);
|
|
290
|
+
const web = has(project, "web");
|
|
290
291
|
return {
|
|
291
292
|
stdlib: ["import asyncio", "import inspect"],
|
|
292
293
|
external: [
|
|
293
294
|
"from pydantic import BaseModel",
|
|
294
|
-
`from ${pkg}.ops import OPERATIONS`,
|
|
295
|
+
`from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
|
|
295
296
|
`from ${pkg}.toolfactory.config import context`,
|
|
297
|
+
...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
|
|
296
298
|
],
|
|
297
|
-
body:
|
|
299
|
+
body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
|
|
298
300
|
"""Fail loudly if the core package and this plugin disagree on the operations."""
|
|
299
301
|
for operation in OPERATIONS:
|
|
300
302
|
if operation.name == name:
|
|
@@ -355,7 +357,7 @@ def _handler(name: str) -> Callable[..., str]:
|
|
|
355
357
|
return call`,
|
|
356
358
|
};
|
|
357
359
|
}
|
|
358
|
-
function
|
|
360
|
+
function initRegion(project, operations) {
|
|
359
361
|
const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
|
|
360
362
|
const names = envDeclarations(project).required.map((entry) => entry.name);
|
|
361
363
|
return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
|
|
@@ -383,7 +385,7 @@ def _ready() -> bool:
|
|
|
383
385
|
${core.body}
|
|
384
386
|
|
|
385
387
|
|
|
386
|
-
def
|
|
388
|
+
def _register_generated(ctx: Any) -> None:
|
|
387
389
|
# Hermes' own per-plugin data directory, through the variable every host uses.
|
|
388
390
|
os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
|
|
389
391
|
for schema in TOOLS:
|
|
@@ -396,6 +398,20 @@ def register(ctx: Any) -> None:
|
|
|
396
398
|
requires_env=list(REQUIRES_ENV),
|
|
397
399
|
description=schema["description"],
|
|
398
400
|
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def register(ctx: Any) -> None:
|
|
404
|
+
_register_generated(ctx)
|
|
405
|
+
register_native(ctx)
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
function initTemplate() {
|
|
409
|
+
return `${INIT_BEGIN}${INIT_END}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Yours: use the full PluginContext here. This hook runs after Toolfactory's generated tools.
|
|
413
|
+
def register_native(ctx: Any) -> None:
|
|
414
|
+
pass
|
|
399
415
|
`;
|
|
400
416
|
}
|
|
401
417
|
function readme(project, operations) {
|
|
@@ -472,9 +488,24 @@ export const surface = {
|
|
|
472
488
|
const operations = includedOperations(project, surface);
|
|
473
489
|
const dir = pluginDir(project);
|
|
474
490
|
return [
|
|
475
|
-
{
|
|
476
|
-
|
|
477
|
-
|
|
491
|
+
{
|
|
492
|
+
kind: "merge",
|
|
493
|
+
path: `${dir}/plugin.yaml`,
|
|
494
|
+
format: "yaml",
|
|
495
|
+
patch: manifest(project, operations),
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
kind: "region",
|
|
499
|
+
path: `${dir}/__init__.py`,
|
|
500
|
+
regions: [{ begin: INIT_BEGIN, end: INIT_END, content: initRegion(project, operations) }],
|
|
501
|
+
template: initTemplate(),
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
kind: "merge",
|
|
505
|
+
path: `${HOST_DIR}/pyproject.toml`,
|
|
506
|
+
format: "toml",
|
|
507
|
+
patch: pyproject(project),
|
|
508
|
+
},
|
|
478
509
|
{ kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
|
|
479
510
|
{
|
|
480
511
|
kind: "file",
|
|
@@ -1,10 +1,11 @@
|
|
|
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
|
+
import { apply } from "../project/apply.js";
|
|
8
9
|
import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
|
|
9
10
|
const echo = {
|
|
10
11
|
name: "echo",
|
|
@@ -19,6 +20,12 @@ const echo = {
|
|
|
19
20
|
requires: [],
|
|
20
21
|
};
|
|
21
22
|
const shoot = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
|
|
23
|
+
const web = {
|
|
24
|
+
name: "web",
|
|
25
|
+
description: "Open the generated operations page.",
|
|
26
|
+
inputSchema: { type: "object" },
|
|
27
|
+
requires: [],
|
|
28
|
+
};
|
|
22
29
|
function project(overrides = {}) {
|
|
23
30
|
return {
|
|
24
31
|
root: "/repo",
|
|
@@ -50,10 +57,35 @@ function project(overrides = {}) {
|
|
|
50
57
|
};
|
|
51
58
|
}
|
|
52
59
|
function emitted(target) {
|
|
53
|
-
return Object.fromEntries(surface
|
|
60
|
+
return Object.fromEntries(surface
|
|
61
|
+
.plan(target)
|
|
62
|
+
.map((file) => [
|
|
63
|
+
file.path,
|
|
64
|
+
file.kind === "file"
|
|
65
|
+
? file.content
|
|
66
|
+
: file.kind === "merge"
|
|
67
|
+
? file.format === "yaml"
|
|
68
|
+
? stringifyYaml(file.patch)
|
|
69
|
+
: file.format === "toml"
|
|
70
|
+
? stringifyToml(file.patch)
|
|
71
|
+
: JSON.stringify(file.patch)
|
|
72
|
+
: file.regions.map((region) => region.content).join(""),
|
|
73
|
+
]));
|
|
54
74
|
}
|
|
55
75
|
const python = project({ tool: { ...project().tool, binding: "python" } });
|
|
56
76
|
describe("hermes-native", () => {
|
|
77
|
+
it("keeps native registrations in the author hook while refreshing generated registrations", () => {
|
|
78
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-region-"));
|
|
79
|
+
const target = { ...project(), root };
|
|
80
|
+
apply(root, surface.plan(target), "0.1.0");
|
|
81
|
+
const path = join(root, pluginDir(target), "__init__.py");
|
|
82
|
+
writeFileSync(path, readFileSync(path, "utf8").replace(" pass\n", ' ctx.register_tool(name="native", toolset="native", schema={}, handler=lambda _: "{}")\n'));
|
|
83
|
+
apply(root, surface.plan({ ...target, operations: [echo] }), "0.1.0");
|
|
84
|
+
const text = readFileSync(path, "utf8");
|
|
85
|
+
expect(text).toContain('name="native"');
|
|
86
|
+
expect(text).toContain("def _register_generated(ctx: Any)");
|
|
87
|
+
expect(text).toContain("_register_generated(ctx)\n register_native(ctx)");
|
|
88
|
+
});
|
|
57
89
|
it("projects the manifest Hermes parses and the credentials it prompts for", () => {
|
|
58
90
|
const files = emitted(project());
|
|
59
91
|
const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "");
|
|
@@ -104,6 +136,21 @@ describe("hermes-native", () => {
|
|
|
104
136
|
expect(pyproject.project.dependencies).toEqual(["hello>=1.2.0,<2"]);
|
|
105
137
|
expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
|
|
106
138
|
});
|
|
139
|
+
it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
|
|
140
|
+
const target = project({
|
|
141
|
+
tool: {
|
|
142
|
+
...project().tool,
|
|
143
|
+
binding: "python",
|
|
144
|
+
surfaces: ["hermes-native", "cli", "mcp", "web"],
|
|
145
|
+
},
|
|
146
|
+
operations: [echo, web],
|
|
147
|
+
});
|
|
148
|
+
const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
|
|
149
|
+
expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
|
|
150
|
+
expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
|
|
151
|
+
expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
|
|
152
|
+
expect(native).toContain('handler=_handler(schema["name"])');
|
|
153
|
+
});
|
|
107
154
|
it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
|
|
108
155
|
const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
|
|
109
156
|
expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
|
|
@@ -136,12 +183,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
|
|
|
136
183
|
it("loads and registers the generated plugin", () => {
|
|
137
184
|
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
|
|
138
185
|
try {
|
|
139
|
-
|
|
140
|
-
if (file.kind !== "file")
|
|
141
|
-
continue;
|
|
142
|
-
mkdirSync(dirname(join(root, file.path)), { recursive: true });
|
|
143
|
-
writeFileSync(join(root, file.path), file.content);
|
|
144
|
-
}
|
|
186
|
+
apply(root, surface.plan(project()), "0.1.0");
|
|
145
187
|
const commands = surface.validate?.({ ...project(), root }) ?? [];
|
|
146
188
|
const command = commands.find((c) => c.label === "hermes plugins doctor");
|
|
147
189
|
if (!command)
|
|
@@ -7,6 +7,7 @@ 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
9
|
import { compact, configProperties, envName, has, isSensitive, json, npmName, pypiName, requiredConfig, } from "./shared.js";
|
|
10
|
+
import { WEB_DIR } from "./web.js";
|
|
10
11
|
export const SERVER_SCHEMA_ID = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
|
|
11
12
|
/** `owner` from the identity file's GitHub repository, or the error every registry name needs it for. */
|
|
12
13
|
function requireGithubOwner(identity) {
|
|
@@ -62,6 +63,42 @@ const DOCKER_PACKAGE_MANAGERS = {
|
|
|
62
63
|
prune: "pnpm prune --prod",
|
|
63
64
|
},
|
|
64
65
|
};
|
|
66
|
+
const DOCKERIGNORE_BEGIN = "# tf:dockerignore";
|
|
67
|
+
const DOCKERIGNORE_END = "# /tf:dockerignore";
|
|
68
|
+
/** Build context hygiene is generated, while the rest of `.dockerignore` stays author-owned. */
|
|
69
|
+
function dockerignore() {
|
|
70
|
+
return {
|
|
71
|
+
kind: "region",
|
|
72
|
+
path: ".dockerignore",
|
|
73
|
+
regions: [
|
|
74
|
+
{
|
|
75
|
+
begin: DOCKERIGNORE_BEGIN,
|
|
76
|
+
end: DOCKERIGNORE_END,
|
|
77
|
+
content: `
|
|
78
|
+
.git/
|
|
79
|
+
.env
|
|
80
|
+
.env.*
|
|
81
|
+
!.env.example
|
|
82
|
+
node_modules/
|
|
83
|
+
**/node_modules/
|
|
84
|
+
.venv/
|
|
85
|
+
**/.venv/
|
|
86
|
+
dist/
|
|
87
|
+
coverage/
|
|
88
|
+
test-results/
|
|
89
|
+
playwright-report/
|
|
90
|
+
.pytest_cache/
|
|
91
|
+
.mypy_cache/
|
|
92
|
+
.ruff_cache/
|
|
93
|
+
__pycache__/
|
|
94
|
+
**/__pycache__/
|
|
95
|
+
.vscode-test/
|
|
96
|
+
`,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
template: `${DOCKERIGNORE_BEGIN}\n${DOCKERIGNORE_END}\n\n# Add local build inputs to exclude below.\n`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
65
102
|
/**
|
|
66
103
|
* The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
|
|
67
104
|
* multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
|
|
@@ -69,20 +106,34 @@ const DOCKER_PACKAGE_MANAGERS = {
|
|
|
69
106
|
*/
|
|
70
107
|
export function dockerfileTemplate(project, label) {
|
|
71
108
|
if (project.tool.binding === "python") {
|
|
109
|
+
const web = has(project, "web");
|
|
72
110
|
return `# syntax=docker/dockerfile:1
|
|
73
|
-
|
|
111
|
+
${web
|
|
112
|
+
? `FROM ${NODE_IMAGE} AS web
|
|
113
|
+
WORKDIR /app
|
|
114
|
+
COPY web/package.json web/package-lock.json ./web/
|
|
115
|
+
RUN npm -C web ci
|
|
116
|
+
COPY web ./web
|
|
117
|
+
RUN npm -C web run build
|
|
118
|
+
|
|
119
|
+
`
|
|
120
|
+
: ""}FROM ${UV_IMAGE} AS build
|
|
74
121
|
WORKDIR /app
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
122
|
+
# pyproject metadata can name README, license, and other package assets; copy the project without
|
|
123
|
+
# assuming optional metadata files exist.
|
|
124
|
+
COPY . .
|
|
125
|
+
${web ? "COPY --from=web /app/web/dist ./web/dist\n" : ""}RUN uv sync --no-dev
|
|
126
|
+
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
|
|
78
127
|
|
|
79
128
|
FROM ${PYTHON_IMAGE}
|
|
80
129
|
LABEL io.modelcontextprotocol.server.name="${label}"
|
|
81
130
|
WORKDIR /app
|
|
82
131
|
COPY --from=build /app/.venv ./.venv
|
|
83
|
-
COPY --from=build /app/
|
|
84
|
-
|
|
85
|
-
|
|
132
|
+
COPY --from=build /app/runtime/ ./
|
|
133
|
+
${web
|
|
134
|
+
? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
|
|
135
|
+
`
|
|
136
|
+
: ""}ENV PATH="/app/.venv/bin:$PATH"
|
|
86
137
|
ENTRYPOINT ${JSON.stringify(dockerEntrypoint(project))}
|
|
87
138
|
`;
|
|
88
139
|
}
|
|
@@ -155,6 +206,7 @@ export const surface = {
|
|
|
155
206
|
return [
|
|
156
207
|
{ kind: "file", path: "server.json", content: json(server) },
|
|
157
208
|
{ kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
|
|
209
|
+
dockerignore(),
|
|
158
210
|
];
|
|
159
211
|
},
|
|
160
212
|
};
|