toolfactory 0.1.1 → 0.1.2
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 +8 -3
- package/dist/bindings/python.js +1 -1
- package/dist/model.d.ts +1 -0
- package/dist/model.js +9 -1
- package/dist/project/gate.js +34 -20
- package/dist/project/history.d.ts +1 -0
- package/dist/surfaces/agents.js +6 -0
- package/dist/surfaces/codex.js +1 -1
- package/dist/surfaces/codex.test.js +10 -0
- package/dist/surfaces/distribution.test.js +118 -4
- package/dist/surfaces/dsh.js +4 -1
- package/dist/surfaces/dsh.test.js +3 -3
- package/dist/surfaces/hermes-native.js +5 -3
- package/dist/surfaces/hermes-native.test.js +21 -0
- package/dist/surfaces/mcp-registry.js +16 -2
- package/dist/surfaces/mcp-registry.test.js +5 -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/pypi.js +27 -3
- package/dist/surfaces/readme.js +10 -7
- package/dist/surfaces/registry.js +3 -3
- package/dist/surfaces/workflows.js +13 -6
- package/dist/surfaces/workflows.test.js +70 -3
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/bindings/python.ts +1 -1
- package/src/model.ts +11 -1
- package/src/project/gate.ts +35 -20
- package/src/surfaces/agents.ts +7 -0
- package/src/surfaces/codex.test.ts +10 -0
- package/src/surfaces/codex.ts +4 -3
- package/src/surfaces/distribution.test.ts +139 -4
- package/src/surfaces/dsh.test.ts +3 -3
- package/src/surfaces/dsh.ts +4 -1
- package/src/surfaces/hermes-native.test.ts +22 -0
- package/src/surfaces/hermes-native.ts +5 -2
- package/src/surfaces/mcp-registry.test.ts +8 -0
- package/src/surfaces/mcp-registry.ts +20 -2
- 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/pypi.ts +27 -3
- package/src/surfaces/readme.ts +11 -6
- package/src/surfaces/registry.ts +3 -3
- package/src/surfaces/workflows.test.ts +87 -3
- package/src/surfaces/workflows.ts +13 -6
- 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-Spapkkvh.js} +1 -1
- package/web/dist/index.html +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { NODE_ENGINES } from "../bindings/typescript.js";
|
|
3
|
-
import { MCPB_PIN, surface as mcpb } from "./mcpb.js";
|
|
3
|
+
import { MCPB_PIN, surface as mcpb, UV_MANIFEST_VERSION } from "./mcpb.js";
|
|
4
4
|
const echo = {
|
|
5
5
|
name: "echo",
|
|
6
6
|
description: "Echoes.",
|
|
@@ -90,8 +90,36 @@ describe("mcpb", () => {
|
|
|
90
90
|
// No privacy_policies unless tool.json names them (see the module docs).
|
|
91
91
|
expect(manifestFile.content).not.toContain("privacy_policies");
|
|
92
92
|
});
|
|
93
|
-
it("
|
|
94
|
-
|
|
93
|
+
it("uses the official v0.4 uv bundle contract for Python, with its canonical PyPI surface", () => {
|
|
94
|
+
const pythonProject = {
|
|
95
|
+
...project,
|
|
96
|
+
tool: { ...project.tool, binding: "python", surfaces: ["mcp", "pypi", "mcpb"] },
|
|
97
|
+
};
|
|
98
|
+
const [file] = mcpb.plan(pythonProject);
|
|
99
|
+
if (file?.kind !== "file")
|
|
100
|
+
throw new Error("expected one whole file");
|
|
101
|
+
expect(JSON.parse(file.content)).toMatchObject({
|
|
102
|
+
manifest_version: UV_MANIFEST_VERSION,
|
|
103
|
+
server: {
|
|
104
|
+
type: "uv",
|
|
105
|
+
entry_point: "src/hello_tool/toolfactory/mcp.py",
|
|
106
|
+
mcp_config: {
|
|
107
|
+
command: "uv",
|
|
108
|
+
args: [
|
|
109
|
+
"run",
|
|
110
|
+
"--directory",
|
|
111
|
+
"${__dirname}",
|
|
112
|
+
"python",
|
|
113
|
+
"-m",
|
|
114
|
+
"hello_tool.toolfactory.mcp",
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
compatibility: { runtimes: { python: ">=3.11" } },
|
|
119
|
+
});
|
|
120
|
+
expect(() => mcpb.plan({ ...pythonProject, tool: { ...pythonProject.tool, surfaces: ["mcp", "mcpb"] } })).toThrow(/requires the "pypi" surface/);
|
|
121
|
+
});
|
|
122
|
+
it("declares the kernel's own Node floor and validates with the pinned official CLI", () => {
|
|
95
123
|
expect(mcpb.validate?.(project)).toEqual([
|
|
96
124
|
{
|
|
97
125
|
label: "mcpb validate",
|
package/dist/surfaces/npm.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { githubSlug } from "../hosts/github.js";
|
|
6
6
|
import { registryName } from "./mcp-registry.js";
|
|
7
|
-
import { compact, has, npmName } from "./shared.js";
|
|
7
|
+
import { compact, has, npmName, pypiName } from "./shared.js";
|
|
8
8
|
import { WEB_DIR } from "./web.js";
|
|
9
9
|
/**
|
|
10
10
|
* npm's object form. A bare URL string is only valid as one of npm's shorthands, so `publint`
|
|
@@ -17,14 +17,30 @@ function repositoryField(repository) {
|
|
|
17
17
|
const slug = githubSlug(repository);
|
|
18
18
|
return { type: "git", url: slug ? `git+https://github.com/${slug}.git` : repository };
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A deliberately thin cross-registry bridge: npm supplies discovery and a familiar `npx` entry,
|
|
22
|
+
* while uvx resolves the canonical Python distribution. `uvx` owns Python acquisition, caching,
|
|
23
|
+
* and the package install; the npm package never downloads an interpreter itself.
|
|
24
|
+
*/
|
|
25
|
+
function pythonLauncher(project) {
|
|
26
|
+
const { identity } = project;
|
|
27
|
+
return `#!/usr/bin/env node
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
|
|
30
|
+
const child = spawnSync("uvx", ["--from", ${JSON.stringify(`${pypiName(project)}==${identity.version}`)}, ${JSON.stringify(identity.name)}, ...process.argv.slice(2)], {
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
});
|
|
33
|
+
if (child.error) throw child.error;
|
|
34
|
+
process.exit(child.status ?? 1);
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
20
37
|
export const surface = {
|
|
21
38
|
id: "npm",
|
|
22
39
|
plan(project) {
|
|
23
|
-
if (project.tool.binding !== "typescript") {
|
|
24
|
-
throw new Error('Surface "npm" requires the typescript binding.');
|
|
25
|
-
}
|
|
26
40
|
const { identity } = project;
|
|
27
41
|
const authored = project.tool.identity === "package.json";
|
|
42
|
+
const python = project.tool.binding === "python";
|
|
43
|
+
const launcher = `bin/${identity.name}.mjs`;
|
|
28
44
|
const patch = compact({
|
|
29
45
|
name: authored ? undefined : npmName(project),
|
|
30
46
|
version: authored ? undefined : identity.version,
|
|
@@ -35,21 +51,36 @@ export const surface = {
|
|
|
35
51
|
keywords: authored ? undefined : identity.keywords,
|
|
36
52
|
type: "module",
|
|
37
53
|
bin: has(project, "cli") || has(project, "mcp")
|
|
38
|
-
? {
|
|
54
|
+
? {
|
|
55
|
+
[identity.name]: python ? `./${launcher}` : "./dist/toolfactory/cli.js",
|
|
56
|
+
}
|
|
39
57
|
: undefined,
|
|
40
58
|
// `web/dist` only with the `web` surface: the kernel serves the built page from inside
|
|
41
59
|
// the installed package, so `npx <tool> mcp --http --open` works without a checkout.
|
|
42
|
-
files:
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
files: python
|
|
61
|
+
? ["bin", "README.md", "LICENSE"]
|
|
62
|
+
: [
|
|
63
|
+
"dist",
|
|
64
|
+
"src",
|
|
65
|
+
"schemas",
|
|
66
|
+
...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
],
|
|
50
70
|
mcpName: has(project, "mcp-registry") ? registryName(project) : undefined,
|
|
51
71
|
});
|
|
52
|
-
return [
|
|
72
|
+
return [
|
|
73
|
+
...(python && (has(project, "cli") || has(project, "mcp"))
|
|
74
|
+
? [{ kind: "file", path: launcher, content: pythonLauncher(project) }]
|
|
75
|
+
: []),
|
|
76
|
+
{
|
|
77
|
+
kind: "merge",
|
|
78
|
+
path: "package.json",
|
|
79
|
+
format: "json",
|
|
80
|
+
patch,
|
|
81
|
+
owned: ["bin", "files"],
|
|
82
|
+
},
|
|
83
|
+
];
|
|
53
84
|
},
|
|
54
85
|
validate(project) {
|
|
55
86
|
return [
|
package/dist/surfaces/pypi.js
CHANGED
|
@@ -26,19 +26,43 @@ export const surface = {
|
|
|
26
26
|
? { [project.identity.name]: cliEntryPoint(project) }
|
|
27
27
|
: undefined,
|
|
28
28
|
}),
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// Hatch ignores a missing `only-include` path, unlike `force-include`, which makes
|
|
30
|
+
// `uv sync` work in a fresh checkout before the optional page is built. At package time,
|
|
31
|
+
// the gate builds `web/dist` first and Hatch maps it into the installed package.
|
|
31
32
|
...(has(project, "web")
|
|
32
33
|
? {
|
|
33
34
|
tool: {
|
|
34
35
|
hatch: {
|
|
35
36
|
build: {
|
|
37
|
+
// Hatch's normal file selection honors .gitignore; `web/dist` is ignored in
|
|
38
|
+
// every generated project, so artifacts is the standard explicit opt-in for
|
|
39
|
+
// the release build's compiled page.
|
|
40
|
+
artifacts: [`${WEB_DIR}/dist/**`],
|
|
36
41
|
targets: {
|
|
37
42
|
wheel: {
|
|
38
|
-
"
|
|
43
|
+
"only-include": [`src/${pythonPackage(project)}`, `${WEB_DIR}/dist`],
|
|
44
|
+
sources: {
|
|
45
|
+
src: "",
|
|
39
46
|
[`${WEB_DIR}/dist`]: `${pythonPackage(project)}/${WEB_DIR}`,
|
|
40
47
|
},
|
|
41
48
|
},
|
|
49
|
+
// This is the source MCPB stages, so include every declared project metadata
|
|
50
|
+
// file alongside the package and built page. `only-include` ignores absent
|
|
51
|
+
// legal files without making fresh checkouts fail.
|
|
52
|
+
sdist: {
|
|
53
|
+
"only-include": [
|
|
54
|
+
"pyproject.toml",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"LICENSE.md",
|
|
58
|
+
"LICENSE.txt",
|
|
59
|
+
"NOTICE",
|
|
60
|
+
"NOTICE.md",
|
|
61
|
+
"NOTICE.txt",
|
|
62
|
+
`src/${pythonPackage(project)}`,
|
|
63
|
+
`${WEB_DIR}/dist`,
|
|
64
|
+
],
|
|
65
|
+
},
|
|
42
66
|
},
|
|
43
67
|
},
|
|
44
68
|
},
|
package/dist/surfaces/readme.js
CHANGED
|
@@ -114,16 +114,15 @@ function installLines(project) {
|
|
|
114
114
|
}
|
|
115
115
|
if (has(project, "browser-extension")) {
|
|
116
116
|
// Three channels, in the order a reader needs them: build-and-load from a checkout, the
|
|
117
|
-
// release assets (
|
|
118
|
-
//
|
|
117
|
+
// release assets (the signed Firefox XPI depends on configured signing credentials), and
|
|
118
|
+
// store listings. Pairing is the last step of each.
|
|
119
119
|
const zips = ["chrome", "firefox", "edge"].map((browser) => `\`${zipName(project, browser)}\``);
|
|
120
120
|
lines.push([
|
|
121
121
|
`- **Browser extension** — from a checkout: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`,
|
|
122
122
|
` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\``,
|
|
123
123
|
` (Firefox: \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\`). Each GitHub Release attaches the`,
|
|
124
|
-
` store uploads ${zips.join(", ")},
|
|
125
|
-
"
|
|
126
|
-
" extensions; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
|
|
124
|
+
` store uploads ${zips.join(", ")}. When Firefox signing credentials are configured, it also attaches a`,
|
|
125
|
+
" Mozilla-signed `.xpi`; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
|
|
127
126
|
` submit step has each store's credentials. Then pair it: \`${mcpCommand(project)} --http --pair\``,
|
|
128
127
|
" prints the `<url>#<token>` the extension's options page accepts.",
|
|
129
128
|
].join("\n"));
|
|
@@ -133,8 +132,12 @@ function installLines(project) {
|
|
|
133
132
|
// tool already is. The same listener answers `/mcp`, so the page needs nothing else running.
|
|
134
133
|
lines.push(`- **Web app** — \`${mcpCommand(project)} --http --open\` serves the operations page beside the`, " MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and", " returns the URL.");
|
|
135
134
|
}
|
|
136
|
-
if (has(project, "npm"))
|
|
137
|
-
|
|
135
|
+
if (has(project, "npm")) {
|
|
136
|
+
const pythonBridge = project.tool.binding === "python"
|
|
137
|
+
? `; requires \`uv\`, and \`${identity.name}\` delegates to \`uvx --from ${pypiName(project)}==${identity.version} ${identity.name}\``
|
|
138
|
+
: "";
|
|
139
|
+
lines.push(`- **npm package** — \`npm install ${npmName(project)}\`${pythonBridge}`);
|
|
140
|
+
}
|
|
138
141
|
if (has(project, "pypi"))
|
|
139
142
|
lines.push(`- **PyPI package** — \`uv add ${pypiName(project)}\``);
|
|
140
143
|
return lines;
|
|
@@ -36,9 +36,9 @@ const registry = {
|
|
|
36
36
|
cli,
|
|
37
37
|
mcp,
|
|
38
38
|
"mcp-registry": mcpRegistry,
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
mcpb: { ...mcpb, requires: ["mcp"
|
|
39
|
+
// TypeScript bundles stage their npm tarball; Python `uv` bundles stage the PyPI project's
|
|
40
|
+
// source. The projector enforces that binding-specific distribution dependency.
|
|
41
|
+
mcpb: { ...mcpb, requires: ["mcp"] },
|
|
42
42
|
npm,
|
|
43
43
|
skill,
|
|
44
44
|
"hermes-native": hermesNative,
|
|
@@ -408,7 +408,9 @@ function releaseDocument(project) {
|
|
|
408
408
|
};
|
|
409
409
|
const priorLegs = [];
|
|
410
410
|
if (npmSelected) {
|
|
411
|
-
|
|
411
|
+
// npm treats a slash-containing bare operand as a Git spec; make the downloaded archive a
|
|
412
|
+
// filesystem path explicitly.
|
|
413
|
+
const tarball = `./${RELEASE_ARTIFACT}/${npmTarball(project)}`;
|
|
412
414
|
jobs["publish-npm"] = compact({
|
|
413
415
|
needs: ["gate", "package"],
|
|
414
416
|
if: gated("npm"),
|
|
@@ -442,18 +444,20 @@ function releaseDocument(project) {
|
|
|
442
444
|
}
|
|
443
445
|
if (pypiSelected) {
|
|
444
446
|
jobs["publish-pypi"] = compact({
|
|
445
|
-
needs: "gate",
|
|
447
|
+
needs: ["gate", "package"],
|
|
446
448
|
if: gated("pypi"),
|
|
447
449
|
"runs-on": "ubuntu-latest",
|
|
448
450
|
environment: "pypi",
|
|
449
451
|
permissions: { "id-token": "write" },
|
|
450
452
|
steps: [
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
453
|
+
{
|
|
454
|
+
uses: "actions/download-artifact@v8",
|
|
455
|
+
with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
|
|
456
|
+
},
|
|
454
457
|
{
|
|
455
458
|
name: "Publish package distributions to PyPI",
|
|
456
459
|
uses: "pypa/gh-action-pypi-publish@release/v1",
|
|
460
|
+
with: { "packages-dir": `${RELEASE_ARTIFACT}/pypi/` },
|
|
457
461
|
},
|
|
458
462
|
],
|
|
459
463
|
});
|
|
@@ -616,7 +620,10 @@ function releaseDocument(project) {
|
|
|
616
620
|
// A tag the dispatch is cutting does not exist yet: the Release creates it here.
|
|
617
621
|
target_commitish: RELEASE_SHA,
|
|
618
622
|
generate_release_notes: true,
|
|
619
|
-
|
|
623
|
+
// PyPI distributions live one level below the other release assets.
|
|
624
|
+
files: has(project, "pypi")
|
|
625
|
+
? `${RELEASE_ARTIFACT}/*\n${RELEASE_ARTIFACT}/pypi/*`
|
|
626
|
+
: `${RELEASE_ARTIFACT}/*`,
|
|
620
627
|
fail_on_unmatched_files: true,
|
|
621
628
|
},
|
|
622
629
|
},
|
|
@@ -120,6 +120,73 @@ describe("workflows", () => {
|
|
|
120
120
|
});
|
|
121
121
|
expect(steps.some((s) => s.run === "uv run --with pytest pytest -q")).toBe(true);
|
|
122
122
|
});
|
|
123
|
+
it("installs a native Python core into Hermes only in validation jobs", () => {
|
|
124
|
+
const python = project(["cli", "mcp", "hermes-native"], {
|
|
125
|
+
tool: {
|
|
126
|
+
...project(["cli"]).tool,
|
|
127
|
+
binding: "python",
|
|
128
|
+
surfaces: ["cli", "mcp", "hermes-native"],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const files = emitted(python);
|
|
132
|
+
const ci = yamlParse(files[".github/workflows/ci.yml"]);
|
|
133
|
+
const release = yamlParse(files[".github/workflows/release.yml"]);
|
|
134
|
+
const command = 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .';
|
|
135
|
+
const ciRuns = ci.jobs.test.steps.map((step) => step.run);
|
|
136
|
+
const gateRuns = release.jobs.gate.steps.map((step) => step.run);
|
|
137
|
+
const packageRuns = release.jobs.package.steps.map((step) => step.run);
|
|
138
|
+
const ciValidate = ciRuns.findIndex((run) => run?.endsWith(" validate"));
|
|
139
|
+
const gateValidate = gateRuns.findIndex((run) => run?.endsWith(" validate"));
|
|
140
|
+
expect(ciRuns.indexOf(command)).toBeGreaterThan(ciRuns.indexOf("uv sync"));
|
|
141
|
+
expect(ciRuns.indexOf(command)).toBeLessThan(ciValidate);
|
|
142
|
+
expect(gateRuns.indexOf(command)).toBeGreaterThan(gateRuns.indexOf("uv sync"));
|
|
143
|
+
expect(gateRuns.indexOf(command)).toBeLessThan(gateValidate);
|
|
144
|
+
expect(packageRuns).not.toContain(command);
|
|
145
|
+
const withoutHermes = project(["cli", "mcp"], {
|
|
146
|
+
tool: { ...project(["cli"]).tool, binding: "python", surfaces: ["cli", "mcp"] },
|
|
147
|
+
});
|
|
148
|
+
const typescriptHermes = project(["cli", "mcp", "hermes-native"]);
|
|
149
|
+
expect(emitted(withoutHermes)[".github/workflows/ci.yml"]).not.toContain(command);
|
|
150
|
+
expect(emitted(typescriptHermes)[".github/workflows/ci.yml"]).not.toContain(command);
|
|
151
|
+
});
|
|
152
|
+
it("publishes the package job's prebuilt Python distributions", () => {
|
|
153
|
+
const python = project(["pypi", "web"], {
|
|
154
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
|
|
155
|
+
});
|
|
156
|
+
const release = yamlParse(emitted(python)[".github/workflows/release.yml"] ?? "");
|
|
157
|
+
const publish = release.jobs["publish-pypi"];
|
|
158
|
+
expect(publish.needs).toEqual(["gate", "package"]);
|
|
159
|
+
expect(publish.steps).toEqual([
|
|
160
|
+
{
|
|
161
|
+
uses: "actions/download-artifact@v8",
|
|
162
|
+
with: { name: "release-assets", path: "release-assets" },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "Publish package distributions to PyPI",
|
|
166
|
+
uses: "pypa/gh-action-pypi-publish@release/v1",
|
|
167
|
+
with: { "packages-dir": "release-assets/pypi/" },
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
expect(release.jobs.release.steps.at(-1)?.with.files).toBe("release-assets/*\nrelease-assets/pypi/*");
|
|
171
|
+
expect(packageSteps(python).find((step) => step.name === "web build")).toBeDefined();
|
|
172
|
+
expect(packageSteps(python).find((step) => step.name === "python distributions")?.run).toBe("uv build --out-dir dist/release/pypi");
|
|
173
|
+
const mcpbPython = project(["pypi", "mcp", "mcpb", "web"], {
|
|
174
|
+
tool: {
|
|
175
|
+
...project([]).tool,
|
|
176
|
+
binding: "python",
|
|
177
|
+
surfaces: ["pypi", "mcp", "mcpb", "web"],
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const mcpb = packageSteps(mcpbPython).find((step) => step.name === "MCPB bundle")?.run;
|
|
181
|
+
expect(packageSteps(mcpbPython).map((step) => step.name)).toEqual(expect.arrayContaining(["python distributions", "MCPB bundle"]));
|
|
182
|
+
expect(packageSteps(mcpbPython)
|
|
183
|
+
.map((step) => step.name)
|
|
184
|
+
.indexOf("python distributions")).toBeLessThan(packageSteps(mcpbPython)
|
|
185
|
+
.map((step) => step.name)
|
|
186
|
+
.indexOf("MCPB bundle"));
|
|
187
|
+
expect(mcpb).toContain("tar -xzf dist/release/pypi/*.tar.gz -C dist/mcpb --strip-components=1");
|
|
188
|
+
expect(mcpb).not.toContain("cp -R src dist/mcpb/src");
|
|
189
|
+
});
|
|
123
190
|
it("release.yml runs the legs in the forced order npm -> oci -> mcp-registry -> clawhub, each gated on what the gate decided", () => {
|
|
124
191
|
const files = emitted(project(["npm", "mcp-registry", "clawhub", "openclaw-native", "skill", "claude", "web", "mcp"], {
|
|
125
192
|
identity: {
|
|
@@ -265,8 +332,8 @@ describe("workflows", () => {
|
|
|
265
332
|
// configured publisher selects OIDC; otherwise the stored token is a fallback. A re-dispatch
|
|
266
333
|
// after adding either credential must not attempt to republish an immutable version.
|
|
267
334
|
expect(release.jobs["publish-npm"].steps.some((s) => s.run?.includes("npm view hello@0.1.0 version") &&
|
|
268
|
-
s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish release-assets/hello-0.1.0.tgz --access public') &&
|
|
269
|
-
s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
|
|
335
|
+
s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ./release-assets/hello-0.1.0.tgz --access public') &&
|
|
336
|
+
s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ./release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
|
|
270
337
|
// The oci leg pushes the very image server.json's oci entry names.
|
|
271
338
|
const meta = release.jobs["publish-oci"].steps.find((s) => s.id === "meta");
|
|
272
339
|
expect(meta?.with?.images).toBe("ghcr.io/acme/hello");
|
|
@@ -491,7 +558,7 @@ describe("gate", () => {
|
|
|
491
558
|
]);
|
|
492
559
|
const runs = packageSteps(target).map((step) => step.run);
|
|
493
560
|
expect(runs).toContain("npm pack --pack-destination dist/release");
|
|
494
|
-
expect(runs).toContain("uv build --out-dir dist/release");
|
|
561
|
+
expect(runs).toContain("uv build --out-dir dist/release/pypi");
|
|
495
562
|
expect(runs.some((run) => run.includes("npm pack ./hosts/openclaw"))).toBe(true);
|
|
496
563
|
expect(runs).toContain('set -- skills .claude-plugin; for path in LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then set -- "$@" "$path"; fi; done; zip -qr dist/release/hello-plugin.zip "$@"');
|
|
497
564
|
const webBuild = runs.findIndex((run) => run.includes("npm -C web run build"));
|
package/dist/toolfactory/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ const operations = [...authored, web];
|
|
|
11
11
|
const program = new Command()
|
|
12
12
|
.name("toolfactory")
|
|
13
13
|
.description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
|
|
14
|
-
.version("0.1.
|
|
14
|
+
.version("0.1.2");
|
|
15
15
|
for (const op of operations) {
|
|
16
16
|
if (!serves(op, "cli"))
|
|
17
17
|
continue;
|
package/dist/toolfactory/mcp.js
CHANGED
|
@@ -30,7 +30,7 @@ const operations = [...authored, web];
|
|
|
30
30
|
const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
|
|
31
31
|
/** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
|
|
32
32
|
function createServer() {
|
|
33
|
-
const server = new McpServer({ name: "toolfactory", version: "0.1.
|
|
33
|
+
const server = new McpServer({ name: "toolfactory", version: "0.1.2" });
|
|
34
34
|
for (const op of operations) {
|
|
35
35
|
if (!introspecting && !serves(op, "mcp"))
|
|
36
36
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolfactory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/bindings/python.ts
CHANGED
|
@@ -236,7 +236,7 @@ def env_route(token: "str | None") -> Route:
|
|
|
236
236
|
return `
|
|
237
237
|
|
|
238
238
|
def web_dist() -> "Path | None":
|
|
239
|
-
"""The
|
|
239
|
+
"""The wheel maps \`${WEB_DIR}/dist\` here at package time; a checkout has it in place."""
|
|
240
240
|
package = Path(__file__).resolve().parent.parent
|
|
241
241
|
for candidate in (package / "${WEB_DIR}", package.parent.parent / "${WEB_DIR}" / "dist"):
|
|
242
242
|
if candidate.is_dir():
|
package/src/model.ts
CHANGED
|
@@ -129,7 +129,17 @@ export const ToolConfigSchema = z
|
|
|
129
129
|
.default({ examples: {} })
|
|
130
130
|
.describe("Example arguments per operation, used by the generated surface-smoke tests."),
|
|
131
131
|
npm: z.object({ scope: z.string().optional() }).optional(),
|
|
132
|
-
codex: z
|
|
132
|
+
codex: z
|
|
133
|
+
.object({
|
|
134
|
+
interface: jsonObject.optional(),
|
|
135
|
+
mcp: z
|
|
136
|
+
.boolean()
|
|
137
|
+
.default(true)
|
|
138
|
+
.describe(
|
|
139
|
+
"Whether the Codex plugin manifest eagerly configures the generated MCP server.",
|
|
140
|
+
),
|
|
141
|
+
})
|
|
142
|
+
.optional(),
|
|
133
143
|
browserExtension: z
|
|
134
144
|
.object({
|
|
135
145
|
endpoint: z
|
package/src/project/gate.ts
CHANGED
|
@@ -121,11 +121,18 @@ export function gateSteps(project: Project): GateStep[] {
|
|
|
121
121
|
const pm = commands(project);
|
|
122
122
|
const cli = toolfactoryCli(project);
|
|
123
123
|
const openclaw = has(project, "openclaw-native");
|
|
124
|
-
const steps: GateStep[] = [
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const steps: GateStep[] = [...bootstrapSteps(project)];
|
|
125
|
+
if (!typescript && has(project, "hermes-native")) {
|
|
126
|
+
// Hermes' launcher clears PYTHONPATH and runs its own venv. Its plugin dependency
|
|
127
|
+
// declaration is advisory, so Doctor can import a native Python core only after the
|
|
128
|
+
// project is installed into that interpreter. Run after bootstrap's `uv sync` on CI.
|
|
129
|
+
steps.push({
|
|
130
|
+
name: "Install core into Hermes",
|
|
131
|
+
run: 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .',
|
|
132
|
+
when: "ci",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
steps.push({ name: "toolfactory check", run: `${cli} check` }, outputsStep(project));
|
|
129
136
|
if (has(project, "claude")) {
|
|
130
137
|
steps.push({
|
|
131
138
|
name: "Install Claude Code CLI",
|
|
@@ -232,27 +239,35 @@ export function packageSteps(project: Project): GateStep[] {
|
|
|
232
239
|
if (has(project, "npm")) {
|
|
233
240
|
steps.push({ name: "npm tarball", run: `npm pack --pack-destination ${RELEASE_DIR}` });
|
|
234
241
|
}
|
|
242
|
+
if (has(project, "pypi")) {
|
|
243
|
+
// Keep the wheel and sdist apart from the release's npm tarballs, plugin bundles and web
|
|
244
|
+
// archive: PyPA's publisher accepts only Python distribution files. The MCPB stage below
|
|
245
|
+
// unpacks this same canonical sdist rather than reproducing its source-file selection.
|
|
246
|
+
steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}/pypi` });
|
|
247
|
+
}
|
|
235
248
|
if (has(project, "mcpb")) {
|
|
236
|
-
// A bundle root is the published package with its production dependencies installed into
|
|
237
|
-
// it, so it is built from the tarball `npm pack` just wrote rather than from the checkout:
|
|
238
|
-
// whatever ships to npm is exactly what ships to Claude Desktop. `--ignore-scripts` because
|
|
239
|
-
// nothing in a bundle root may run a build; the tarball already carries `dist/`.
|
|
240
249
|
const stage = "dist/mcpb";
|
|
241
|
-
const tarball = npmTarball(project);
|
|
242
250
|
steps.push({
|
|
243
251
|
name: "MCPB bundle",
|
|
244
|
-
run:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
252
|
+
run:
|
|
253
|
+
project.tool.binding === "python"
|
|
254
|
+
? [
|
|
255
|
+
`rm -rf ${stage} && mkdir -p ${stage}`,
|
|
256
|
+
`tar -xzf ${RELEASE_DIR}/pypi/*.tar.gz -C ${stage} --strip-components=1`,
|
|
257
|
+
`cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
|
|
258
|
+
`npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
|
|
259
|
+
].join(" && ")
|
|
260
|
+
: [
|
|
261
|
+
// The Node bundle root is the published package with production dependencies
|
|
262
|
+
// installed, so whatever ships to npm is exactly what ships to Claude Desktop.
|
|
263
|
+
`rm -rf ${stage} && mkdir -p ${stage}`,
|
|
264
|
+
`tar -xzf ${RELEASE_DIR}/${npmTarball(project)} -C ${stage} --strip-components=1`,
|
|
265
|
+
`npm --prefix ${stage} install --omit=dev --ignore-scripts`,
|
|
266
|
+
`cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
|
|
267
|
+
`npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
|
|
268
|
+
].join(" && "),
|
|
251
269
|
});
|
|
252
270
|
}
|
|
253
|
-
if (has(project, "pypi")) {
|
|
254
|
-
steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}` });
|
|
255
|
-
}
|
|
256
271
|
if (has(project, "openclaw-native")) {
|
|
257
272
|
// ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
|
|
258
273
|
// to arrive already built (`package_artifact_name`).
|
package/src/surfaces/agents.ts
CHANGED
|
@@ -89,6 +89,13 @@ export const RELOAD: Reload[] = [
|
|
|
89
89
|
"`hermes skills trust` once in this repo, then a new conversation: the resolved skill directories are stable for a conversation.",
|
|
90
90
|
line: "Run `hermes skills trust` once in this repo, then start a new Hermes conversation — every invocation re-scans plugins, skills and AGENTS.md. `/reload-mcp` only affects an already-open session; `hermes gateway restart` is only for the messaging gateway.",
|
|
91
91
|
},
|
|
92
|
+
{
|
|
93
|
+
harness: "GSD",
|
|
94
|
+
env: [],
|
|
95
|
+
mcp: "`python3 .agents/sync.py --all` renders the shared `.mcp.json` even on a GSD-only machine. Refresh with `mcp_servers(refresh=true)`; start a new session after changing a connected server. Approve `mcp_discover` interactively once before unattended stdio use.",
|
|
96
|
+
instructions:
|
|
97
|
+
"Reads `AGENTS.md` and `.agents/skills/` natively; `/reload` refreshes resources. `--bare` omits them.",
|
|
98
|
+
},
|
|
92
99
|
{
|
|
93
100
|
harness: "Gemini CLI",
|
|
94
101
|
env: [],
|
|
@@ -68,4 +68,14 @@ describe("codex", () => {
|
|
|
68
68
|
expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
|
|
69
69
|
expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it("can omit the eager MCP server while retaining the plugin skill", () => {
|
|
73
|
+
const target = project(["skill", "codex", "mcp"]);
|
|
74
|
+
target.tool.codex = { mcp: false };
|
|
75
|
+
const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
|
|
76
|
+
if (manifest?.kind !== "file") throw new Error("expected a manifest");
|
|
77
|
+
const parsed = JSON.parse(manifest.content);
|
|
78
|
+
expect(parsed.skills).toBe("./skills/");
|
|
79
|
+
expect(parsed).not.toHaveProperty("mcpServers");
|
|
80
|
+
});
|
|
71
81
|
});
|
package/src/surfaces/codex.ts
CHANGED
|
@@ -43,9 +43,10 @@ export const surface: Surface = {
|
|
|
43
43
|
license: identity.license,
|
|
44
44
|
keywords: identity.keywords,
|
|
45
45
|
skills: has(project, "skill") ? "./skills/" : undefined,
|
|
46
|
-
mcpServers:
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
mcpServers:
|
|
47
|
+
has(project, "mcp") && project.tool.codex?.mcp !== false
|
|
48
|
+
? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
|
|
49
|
+
: undefined,
|
|
49
50
|
interface: codexInterface,
|
|
50
51
|
});
|
|
51
52
|
// `source: "local"` + `path: "."` is the marketplace root itself: one repository, one
|