toolfactory 0.1.2 → 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 +24 -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 +62 -2
- package/dist/model.js +9 -0
- 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 +68 -3
- package/dist/project/history.d.ts +29 -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 +21 -3
- package/dist/surfaces/agents.test.js +12 -0
- package/dist/surfaces/claude.js +8 -3
- package/dist/surfaces/codex.js +8 -3
- package/dist/surfaces/codex.test.js +8 -8
- package/dist/surfaces/cursor.js +2 -2
- package/dist/surfaces/distribution.test.js +3 -3
- package/dist/surfaces/dsh.js +25 -6
- package/dist/surfaces/dsh.test.js +8 -3
- package/dist/surfaces/gemini.js +2 -2
- package/dist/surfaces/gemini.test.js +4 -4
- package/dist/surfaces/hermes-native.js +38 -9
- package/dist/surfaces/hermes-native.test.js +32 -11
- package/dist/surfaces/mcp-registry.js +43 -5
- package/dist/surfaces/mcp-registry.test.js +18 -0
- package/dist/surfaces/openclaw-native.js +10 -8
- package/dist/surfaces/openclaw-native.test.js +30 -0
- package/dist/surfaces/readme.d.ts +16 -0
- package/dist/surfaces/readme.js +7 -0
- package/dist/surfaces/registry.js +6 -0
- 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 +49 -10
- package/dist/surfaces/workflows.test.js +3 -2
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +2 -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 +15 -1
- package/src/project/apply.test.ts +120 -0
- package/src/project/apply.ts +257 -15
- package/src/project/gate.ts +96 -4
- 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 +31 -3
- package/src/surfaces/claude.ts +7 -3
- package/src/surfaces/codex.test.ts +7 -7
- package/src/surfaces/codex.ts +8 -3
- package/src/surfaces/cursor.ts +2 -10
- package/src/surfaces/distribution.test.ts +2 -2
- package/src/surfaces/dsh.test.ts +8 -2
- package/src/surfaces/dsh.ts +26 -6
- package/src/surfaces/gemini.test.ts +3 -3
- package/src/surfaces/gemini.ts +1 -2
- package/src/surfaces/hermes-native.test.ts +39 -10
- package/src/surfaces/hermes-native.ts +41 -11
- package/src/surfaces/mcp-registry.test.ts +20 -0
- package/src/surfaces/mcp-registry.ts +46 -6
- package/src/surfaces/openclaw-native.test.ts +37 -0
- package/src/surfaces/openclaw-native.ts +49 -47
- package/src/surfaces/readme.ts +10 -0
- package/src/surfaces/registry.ts +6 -0
- 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 +3 -4
- package/src/surfaces/workflows.ts +49 -10
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
- package/web/dist/index.html +1 -1
package/dist/project/lock.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare const LockSchema: z.ZodObject<{
|
|
|
16
16
|
manual: "manual";
|
|
17
17
|
}>;
|
|
18
18
|
keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
19
|
+
keyedArrays: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
values: z.ZodArray<z.ZodString>;
|
|
22
|
+
entries: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
23
|
+
}, z.core.$strip>>>;
|
|
19
24
|
regions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
20
25
|
begin: z.ZodString;
|
|
21
26
|
end: z.ZodString;
|
package/dist/project/lock.js
CHANGED
|
@@ -17,6 +17,15 @@ export const LockSchema = z.object({
|
|
|
17
17
|
* patch — or deselecting the surface that added it — uninstalls it instead of stranding it.
|
|
18
18
|
*/
|
|
19
19
|
keys: z.array(z.string()).optional(),
|
|
20
|
+
/** A keyed array's inverse: the entries Toolfactory owns at each dotted path. */
|
|
21
|
+
keyedArrays: z
|
|
22
|
+
.record(z.string(), z.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
values: z.array(z.string()),
|
|
25
|
+
/** The last projected entry shape lets a later plan remove only stale generated leaves. */
|
|
26
|
+
entries: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
|
27
|
+
}))
|
|
28
|
+
.optional(),
|
|
20
29
|
/**
|
|
21
30
|
* A region file's inverse, exactly as `keys` is a merge file's: the marker pairs its
|
|
22
31
|
* projector wrote. Regions the current plan no longer writes are emptied before the current
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { kernelLaunch, mcpVerdict } from "./shared.js";
|
|
2
2
|
export const MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
|
|
3
3
|
export const PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
4
4
|
export function assertBundleIdentity(project) {
|
|
@@ -13,12 +13,13 @@ export const surface = {
|
|
|
13
13
|
const launch = kernelLaunch(project, "${PLUGIN_ROOT}");
|
|
14
14
|
return [
|
|
15
15
|
{
|
|
16
|
-
kind: "
|
|
16
|
+
kind: "merge",
|
|
17
17
|
path: "mcp.json",
|
|
18
|
-
|
|
18
|
+
format: "json",
|
|
19
|
+
patch: {
|
|
19
20
|
$schema: MCP_SCHEMA_ID,
|
|
20
21
|
mcpServers: { [project.identity.name]: { type: "stdio", ...launch } },
|
|
21
|
-
}
|
|
22
|
+
},
|
|
22
23
|
},
|
|
23
24
|
];
|
|
24
25
|
},
|
package/dist/surfaces/agents.js
CHANGED
|
@@ -160,7 +160,8 @@ function layoutSection(project) {
|
|
|
160
160
|
if (has(project, "openclaw-native") ||
|
|
161
161
|
has(project, "hermes-native") ||
|
|
162
162
|
has(project, "dsh") ||
|
|
163
|
-
has(project, "browser-extension")
|
|
163
|
+
has(project, "browser-extension") ||
|
|
164
|
+
has(project, "vscode-extension")) {
|
|
164
165
|
lines.push("- `hosts/<id>/` — the host-native escape hatch for a selected host; nothing else creates it.");
|
|
165
166
|
}
|
|
166
167
|
if (has(project, "web"))
|
|
@@ -216,6 +217,9 @@ function installSection(project) {
|
|
|
216
217
|
if (has(project, "dsh")) {
|
|
217
218
|
lines.push(`- **DSH** (experimental): \`dsh plugin --profile ${DSH_PROFILE} add ./${DSH_HOST_DIR}\` installs the`, " bundle (`dsh plugin` forwards to pnpm, so pnpm must be on `PATH`); the operations reach the", ` model as \`${dshToolName(project, "<operation>")}\`, and \`dsh --profile ${DSH_PROFILE} --dump-config\` shows`, ` the composed \`id: ${dshRowId(project)}\` row. Bundle patches are read once per boot and never watched,`, ` so restart DSH after editing \`${DSH_HOST_DIR}/${PATCH_FILE}\` or reinstalling. Boot with`, ` \`--patch ${DSH_HOST_DIR}/${LOCAL_PATCH_FILE}\` and no bundle installed to drive this checkout`, " instead. Every config variable is restated in the row's `env:` because DSH scrubs", " KEY/PASSWORD/SECRET/TOKEN names before spawning an MCP server.");
|
|
218
219
|
}
|
|
220
|
+
if (has(project, "vscode-extension")) {
|
|
221
|
+
lines.push("- **VS Code extension**: `npm --prefix hosts/vscode install`, then open `hosts/vscode` in VS Code", " and press F5. Development and `npm --prefix hosts/vscode test` use the checkout's kernel.", " `npm --prefix hosts/vscode run vsix` packages it; install with `code --install-extension hosts/vscode/extension.vsix`.", " Use Developer: Reload Window after rebuilding an installed extension. Native activation, contributions", " and every VS Code API are available in your own files; include your license in the extension directory.");
|
|
222
|
+
}
|
|
219
223
|
if (has(project, "browser-extension")) {
|
|
220
224
|
const { command, args } = getBinding(project.tool.binding).cliCommand(project);
|
|
221
225
|
lines.push(`- **Browser extension**: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`, ` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\`;`, ` \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\` does the same for Firefox. Pair it with this`, ` checkout's kernel: \`${[command, ...args].join(" ")} mcp --http --pair\` mints a token under the data`, ` directory and prints the \`<url>#<token>\` the extension's options page accepts (with no token minted`, ` and no \`${envName(project.identity.name)}_MCP_TOKEN\` in the environment the endpoint stays open on loopback). The content`, " script's selectors are yours to maintain: it is authored escape-hatch code against a page that", " changes on its own schedule, and nothing here can version-proof it.");
|
|
@@ -224,7 +228,8 @@ function installSection(project) {
|
|
|
224
228
|
!openclaw &&
|
|
225
229
|
!hermes &&
|
|
226
230
|
!has(project, "dsh") &&
|
|
227
|
-
!has(project, "browser-extension")
|
|
231
|
+
!has(project, "browser-extension") &&
|
|
232
|
+
!has(project, "vscode-extension")) {
|
|
228
233
|
lines.push("_No host-native surface (`openclaw-native`, `hermes-native`, `dsh`, `browser-extension`) or", "`gemini` extension is selected; nothing installs into a running host._");
|
|
229
234
|
}
|
|
230
235
|
lines.push("", "Codex and Cursor read this file as is; Claude Code and Gemini read `CLAUDE.md` / `GEMINI.md`,", "each of which `.agents/sync.py` renders as the one line `@AGENTS.md`.", "");
|
|
@@ -570,6 +575,19 @@ function devcontainer(project) {
|
|
|
570
575
|
: document.features;
|
|
571
576
|
return `${JSON.stringify({ ...document, features }, null, 2)}\n`;
|
|
572
577
|
}
|
|
578
|
+
/** The vendor-owned workflow gains the Python prerequisite it cannot know. */
|
|
579
|
+
function agentConfigWorkflow(project) {
|
|
580
|
+
const path = ".github/workflows/agent-config.yml";
|
|
581
|
+
const workflow = TEMPLATE_FILES[path] ?? "";
|
|
582
|
+
if (isInstructionOnly(project.tool) || project.tool.binding !== "python")
|
|
583
|
+
return workflow;
|
|
584
|
+
const setup = " - name: Run the shared setup entry point\n";
|
|
585
|
+
if (!workflow.includes(setup))
|
|
586
|
+
throw new Error(`${path} no longer has the setup step; re-vendor before adding uv.`);
|
|
587
|
+
return workflow.replace(setup, " - name: Install uv\n" +
|
|
588
|
+
" uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0\n\n" +
|
|
589
|
+
setup);
|
|
590
|
+
}
|
|
573
591
|
/** Whole-file carriers: no per-project content, `adopt` is the escape hatch. */
|
|
574
592
|
function vendored(project) {
|
|
575
593
|
const skip = new Set([SETUP_PATH, IGNORE_PATH, ".devcontainer/devcontainer.json"]);
|
|
@@ -579,7 +597,7 @@ function vendored(project) {
|
|
|
579
597
|
.map(([path, content]) => ({
|
|
580
598
|
kind: "file",
|
|
581
599
|
path,
|
|
582
|
-
content,
|
|
600
|
+
content: path === ".github/workflows/agent-config.yml" ? agentConfigWorkflow(project) : content,
|
|
583
601
|
...(path.endsWith("sync.py") ? { mode: 0o755 } : {}),
|
|
584
602
|
})),
|
|
585
603
|
{ kind: "file", path: ".devcontainer/devcontainer.json", content: devcontainer(project) },
|
|
@@ -108,6 +108,18 @@ describe("agents", () => {
|
|
|
108
108
|
expect(setup).toContain("npx toolfactory check >/dev/null");
|
|
109
109
|
expect(setup).toContain("npm install --no-audit --no-fund");
|
|
110
110
|
});
|
|
111
|
+
it("installs uv before Python projects run the vendored setup workflow", () => {
|
|
112
|
+
const workflow = planned(project(["cli"], { binding: "python" }), ".github/workflows/agent-config.yml");
|
|
113
|
+
if (workflow.kind !== "file")
|
|
114
|
+
throw new Error("expected an agent-config workflow file");
|
|
115
|
+
const install = "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0";
|
|
116
|
+
expect(workflow.content).toContain(install);
|
|
117
|
+
expect(workflow.content.indexOf(install)).toBeLessThan(workflow.content.indexOf("run: bash .agents/setup --all"));
|
|
118
|
+
const typescript = planned(project(["cli"]), ".github/workflows/agent-config.yml");
|
|
119
|
+
if (typescript.kind !== "file")
|
|
120
|
+
throw new Error("expected an agent-config workflow file");
|
|
121
|
+
expect(typescript.content).toBe(TEMPLATE_FILES[".github/workflows/agent-config.yml"]);
|
|
122
|
+
});
|
|
111
123
|
it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
|
|
112
124
|
const bare = region(project(["cli"]), IGNORE_PATH);
|
|
113
125
|
const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
|
package/dist/surfaces/claude.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `claude plugin marketplace add <owner>/<repo>` (and Copilot CLI's identical command) installs.
|
|
6
6
|
*/
|
|
7
7
|
import { githubSlug } from "../hosts/github.js";
|
|
8
|
-
import { compact, configProperties, envName, has, isSensitive,
|
|
8
|
+
import { compact, configProperties, envName, has, isSensitive, kernelLaunch, mcpVerdict, requiredConfig, } from "./shared.js";
|
|
9
9
|
export const surface = {
|
|
10
10
|
id: "claude",
|
|
11
11
|
plan(project) {
|
|
@@ -61,8 +61,13 @@ export const surface = {
|
|
|
61
61
|
plugins: [compact({ name: identity.name, source: "./", description: identity.description })],
|
|
62
62
|
});
|
|
63
63
|
return [
|
|
64
|
-
{ kind: "
|
|
65
|
-
{
|
|
64
|
+
{ kind: "merge", path: ".claude-plugin/plugin.json", format: "json", patch: manifest },
|
|
65
|
+
{
|
|
66
|
+
kind: "merge",
|
|
67
|
+
path: ".claude-plugin/marketplace.json",
|
|
68
|
+
format: "json",
|
|
69
|
+
patch: marketplace,
|
|
70
|
+
},
|
|
66
71
|
];
|
|
67
72
|
},
|
|
68
73
|
validate(project) {
|
package/dist/surfaces/codex.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* this surface does not reach.
|
|
9
9
|
*/
|
|
10
10
|
import { projectName } from "../identity/name.js";
|
|
11
|
-
import { compact, has,
|
|
11
|
+
import { compact, has, kernelLaunch, mcpVerdict } from "./shared.js";
|
|
12
12
|
/** The Codex CLI (`@openai/codex`) `validate()` is proven against — pin for a deterministic gate. */
|
|
13
13
|
export const CODEX_PIN = "0.152.1";
|
|
14
14
|
export const surface = {
|
|
@@ -60,8 +60,13 @@ export const surface = {
|
|
|
60
60
|
],
|
|
61
61
|
});
|
|
62
62
|
return [
|
|
63
|
-
{ kind: "
|
|
64
|
-
{
|
|
63
|
+
{ kind: "merge", path: ".codex-plugin/plugin.json", format: "json", patch: manifest },
|
|
64
|
+
{
|
|
65
|
+
kind: "merge",
|
|
66
|
+
path: ".agents/plugins/marketplace.json",
|
|
67
|
+
format: "json",
|
|
68
|
+
patch: marketplace,
|
|
69
|
+
},
|
|
65
70
|
];
|
|
66
71
|
},
|
|
67
72
|
validate(project) {
|
|
@@ -29,9 +29,9 @@ describe("codex", () => {
|
|
|
29
29
|
it("makes the repository its own single-plugin marketplace, and validates with the real codex CLI", () => {
|
|
30
30
|
const files = codex.plan(project(["codex", "mcp"]));
|
|
31
31
|
const marketplace = files.find((entry) => entry.path === ".agents/plugins/marketplace.json");
|
|
32
|
-
if (marketplace?.kind !== "
|
|
33
|
-
throw new Error("expected a
|
|
34
|
-
expect(
|
|
32
|
+
if (marketplace?.kind !== "merge")
|
|
33
|
+
throw new Error("expected a merge manifest");
|
|
34
|
+
expect(marketplace.patch).toMatchObject({
|
|
35
35
|
name: "hello",
|
|
36
36
|
interface: { displayName: "Hello" },
|
|
37
37
|
plugins: [
|
|
@@ -59,18 +59,18 @@ describe("codex", () => {
|
|
|
59
59
|
const target = project(["skill", "codex"]);
|
|
60
60
|
target.tool.runtime = "none";
|
|
61
61
|
const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
|
|
62
|
-
if (manifest?.kind !== "
|
|
62
|
+
if (manifest?.kind !== "merge")
|
|
63
63
|
throw new Error("expected a manifest");
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
64
|
+
expect(manifest.patch).toMatchObject({ skills: "./skills/" });
|
|
65
|
+
expect(manifest.patch).not.toHaveProperty("mcpServers");
|
|
66
66
|
});
|
|
67
67
|
it("can omit the eager MCP server while retaining the plugin skill", () => {
|
|
68
68
|
const target = project(["skill", "codex", "mcp"]);
|
|
69
69
|
target.tool.codex = { mcp: false };
|
|
70
70
|
const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
|
|
71
|
-
if (manifest?.kind !== "
|
|
71
|
+
if (manifest?.kind !== "merge")
|
|
72
72
|
throw new Error("expected a manifest");
|
|
73
|
-
const parsed =
|
|
73
|
+
const parsed = manifest.patch;
|
|
74
74
|
expect(parsed.skills).toBe("./skills/");
|
|
75
75
|
expect(parsed).not.toHaveProperty("mcpServers");
|
|
76
76
|
});
|
package/dist/surfaces/cursor.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and the bundle's `mcp.json` is shared. Loader unverified, like Codex.
|
|
4
4
|
*/
|
|
5
5
|
import { projectName } from "../identity/name.js";
|
|
6
|
-
import { compact, configProperties, envName, has,
|
|
6
|
+
import { compact, configProperties, envName, has, mcpVerdict, requiredConfig } from "./shared.js";
|
|
7
7
|
export const surface = {
|
|
8
8
|
id: "cursor",
|
|
9
9
|
plan(project) {
|
|
@@ -39,7 +39,7 @@ export const surface = {
|
|
|
39
39
|
mcpServers: has(project, "agent-plugins") ? "./mcp.json" : undefined,
|
|
40
40
|
variables,
|
|
41
41
|
});
|
|
42
|
-
return [{ kind: "
|
|
42
|
+
return [{ kind: "merge", path: ".cursor-plugin/plugin.json", format: "json", patch: manifest }];
|
|
43
43
|
},
|
|
44
44
|
verdict: (operation) => {
|
|
45
45
|
const verdict = mcpVerdict(operation);
|
|
@@ -165,9 +165,9 @@ describe("claude", () => {
|
|
|
165
165
|
it("makes the repository its own single-plugin marketplace", () => {
|
|
166
166
|
const files = claude.plan(project(["claude", "mcp"]));
|
|
167
167
|
const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
|
|
168
|
-
if (file?.kind !== "
|
|
169
|
-
throw new Error("expected a
|
|
170
|
-
expect(
|
|
168
|
+
if (file?.kind !== "merge")
|
|
169
|
+
throw new Error("expected a merge file");
|
|
170
|
+
expect(file.patch).toMatchObject({
|
|
171
171
|
name: "hello",
|
|
172
172
|
owner: { name: "GoatInAHat" },
|
|
173
173
|
plugins: [{ name: "hello", source: "./", description: "Say hello" }],
|
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. */
|
|
@@ -82,6 +84,9 @@ function patch(project, launch) {
|
|
|
82
84
|
};
|
|
83
85
|
return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
|
|
84
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
|
+
}
|
|
85
90
|
/** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
|
|
86
91
|
export function toolName(project, operation) {
|
|
87
92
|
return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
|
|
@@ -108,20 +113,34 @@ export const surface = {
|
|
|
108
113
|
dsh: { bundle: { patch: `./${PATCH_FILE}` } },
|
|
109
114
|
});
|
|
110
115
|
return [
|
|
111
|
-
{ kind: "
|
|
116
|
+
{ kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
|
|
112
117
|
{
|
|
113
|
-
kind: "
|
|
118
|
+
kind: "region",
|
|
114
119
|
path: `${HOST_DIR}/${PATCH_FILE}`,
|
|
115
120
|
// `cwd` is deliberately absent: an installed bundle has no path to point at, and the
|
|
116
121
|
// published launch resolves through the registry from wherever DSH is running.
|
|
117
|
-
|
|
122
|
+
regions: [
|
|
123
|
+
{
|
|
124
|
+
begin: PATCH_BEGIN,
|
|
125
|
+
end: PATCH_END,
|
|
126
|
+
content: patch(project, kernelLaunch(project, ".")),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
template: patchTemplate(),
|
|
118
130
|
},
|
|
119
131
|
{
|
|
120
|
-
kind: "
|
|
132
|
+
kind: "region",
|
|
121
133
|
path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
|
|
122
134
|
// The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
|
|
123
135
|
// against the directory DSH itself was started in.
|
|
124
|
-
|
|
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(),
|
|
125
144
|
},
|
|
126
145
|
];
|
|
127
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", () => {
|
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,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 } from "../model.js";
|
|
@@ -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) {
|
|
@@ -357,7 +357,7 @@ def _handler(name: str) -> Callable[..., str]:
|
|
|
357
357
|
return call`,
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
|
-
function
|
|
360
|
+
function initRegion(project, operations) {
|
|
361
361
|
const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
|
|
362
362
|
const names = envDeclarations(project).required.map((entry) => entry.name);
|
|
363
363
|
return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
|
|
@@ -385,7 +385,7 @@ def _ready() -> bool:
|
|
|
385
385
|
${core.body}
|
|
386
386
|
|
|
387
387
|
|
|
388
|
-
def
|
|
388
|
+
def _register_generated(ctx: Any) -> None:
|
|
389
389
|
# Hermes' own per-plugin data directory, through the variable every host uses.
|
|
390
390
|
os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
|
|
391
391
|
for schema in TOOLS:
|
|
@@ -398,6 +398,20 @@ def register(ctx: Any) -> None:
|
|
|
398
398
|
requires_env=list(REQUIRES_ENV),
|
|
399
399
|
description=schema["description"],
|
|
400
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
|
|
401
415
|
`;
|
|
402
416
|
}
|
|
403
417
|
function readme(project, operations) {
|
|
@@ -474,9 +488,24 @@ export const surface = {
|
|
|
474
488
|
const operations = includedOperations(project, surface);
|
|
475
489
|
const dir = pluginDir(project);
|
|
476
490
|
return [
|
|
477
|
-
{
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
},
|
|
480
509
|
{ kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
|
|
481
510
|
{
|
|
482
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",
|
|
@@ -56,10 +57,35 @@ function project(overrides = {}) {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
function emitted(target) {
|
|
59
|
-
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
|
+
]));
|
|
60
74
|
}
|
|
61
75
|
const python = project({ tool: { ...project().tool, binding: "python" } });
|
|
62
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
|
+
});
|
|
63
89
|
it("projects the manifest Hermes parses and the credentials it prompts for", () => {
|
|
64
90
|
const files = emitted(project());
|
|
65
91
|
const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "");
|
|
@@ -157,12 +183,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
|
|
|
157
183
|
it("loads and registers the generated plugin", () => {
|
|
158
184
|
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
|
|
159
185
|
try {
|
|
160
|
-
|
|
161
|
-
if (file.kind !== "file")
|
|
162
|
-
continue;
|
|
163
|
-
mkdirSync(dirname(join(root, file.path)), { recursive: true });
|
|
164
|
-
writeFileSync(join(root, file.path), file.content);
|
|
165
|
-
}
|
|
186
|
+
apply(root, surface.plan(project()), "0.1.0");
|
|
166
187
|
const commands = surface.validate?.({ ...project(), root }) ?? [];
|
|
167
188
|
const command = commands.find((c) => c.label === "hermes plugins doctor");
|
|
168
189
|
if (!command)
|
|
@@ -63,6 +63,42 @@ const DOCKER_PACKAGE_MANAGERS = {
|
|
|
63
63
|
prune: "pnpm prune --prod",
|
|
64
64
|
},
|
|
65
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
|
+
}
|
|
66
102
|
/**
|
|
67
103
|
* The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
|
|
68
104
|
* multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
|
|
@@ -83,16 +119,17 @@ RUN npm -C web run build
|
|
|
83
119
|
`
|
|
84
120
|
: ""}FROM ${UV_IMAGE} AS build
|
|
85
121
|
WORKDIR /app
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
127
|
|
|
90
128
|
FROM ${PYTHON_IMAGE}
|
|
91
129
|
LABEL io.modelcontextprotocol.server.name="${label}"
|
|
92
130
|
WORKDIR /app
|
|
93
131
|
COPY --from=build /app/.venv ./.venv
|
|
94
|
-
COPY --from=build /app/
|
|
95
|
-
COPY --from=build /app/pyproject.toml ./
|
|
132
|
+
COPY --from=build /app/runtime/ ./
|
|
96
133
|
${web
|
|
97
134
|
? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
|
|
98
135
|
`
|
|
@@ -169,6 +206,7 @@ export const surface = {
|
|
|
169
206
|
return [
|
|
170
207
|
{ kind: "file", path: "server.json", content: json(server) },
|
|
171
208
|
{ kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
|
|
209
|
+
dockerignore(),
|
|
172
210
|
];
|
|
173
211
|
},
|
|
174
212
|
};
|