toolfactory 0.1.1
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 +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH (DeepSeek Harness), experimental: a zero-code Cordis bundle that attaches the tool's own
|
|
3
|
+
* MCP server through DSH's first-party `@deepseek-ai/dsh-mcp-client`.
|
|
4
|
+
*
|
|
5
|
+
* `hosts/dsh/` is two files and no code — a `package.json` whose only load-bearing key is
|
|
6
|
+
* `dsh.bundle.patch` (that one expression is DSH's whole acceptance test for a bundle) and the
|
|
7
|
+
* Cordis patch it names, one `insert` row. Nothing else is emitted, on purpose: a bundle that
|
|
8
|
+
* shipped a Cordis plugin would have to track the `ToolDefinition`/`inject` API — the fastest
|
|
9
|
+
* moving part of an alpha harness — and would put its package name and version into the
|
|
10
|
+
* `dsh_plugin_packages` inventory DSH sends to the model endpoint on every request. The row keys
|
|
11
|
+
* this file writes are byte-identical across every published DSH version; the parts that did
|
|
12
|
+
* move (profile templates, `patchReload`) are DSH-owned state written by `dsh plugin`.
|
|
13
|
+
*
|
|
14
|
+
* The bundle declares no dependency: `@deepseek-ai/dsh-mcp-client` is a direct dependency of
|
|
15
|
+
* `@deepseek-ai/dsh` itself, symlinked into `$DSH_HOME/profiles/node_modules/` as every
|
|
16
|
+
* profile's resolution fallback, so declaring it would only invite a version mismatch on a
|
|
17
|
+
* package the user cannot independently satisfy.
|
|
18
|
+
*
|
|
19
|
+
* Every DSH-specific string lives here and nowhere else.
|
|
20
|
+
*/
|
|
21
|
+
import { Scalar, stringify as yamlStringify } from "yaml";
|
|
22
|
+
import { getBinding } from "../bindings/index.js";
|
|
23
|
+
import { projectName } from "../identity/name.js";
|
|
24
|
+
import type { Project, Surface } from "../model.js";
|
|
25
|
+
import { compact, configProperties, envName, json, kernelLaunch, mcpVerdict } from "./shared.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `@deepseek-ai/dsh` release `validate()` boots. Pinned, not `@alpha`: npm's `latest` tag
|
|
29
|
+
* lags the alpha line by weeks, so an unpinned install is neither current nor reproducible.
|
|
30
|
+
* Renovate bumps this constant (`renovate.json` `customManagers`).
|
|
31
|
+
*/
|
|
32
|
+
export const DSH_PIN = "0.1.2-alpha.5";
|
|
33
|
+
|
|
34
|
+
export const HOST_DIR = "hosts/dsh";
|
|
35
|
+
/** The patch file name `dsh.bundle.patch` points at; DSH's own convention for a Cordis overlay. */
|
|
36
|
+
export const PATCH_FILE = "cordis.patch.yml";
|
|
37
|
+
/**
|
|
38
|
+
* The same row against this checkout instead of the published package. Kept out of `files`, so
|
|
39
|
+
* `npm pack ./hosts/dsh` never ships it: it is what `validate()` boots and what a developer
|
|
40
|
+
* passes to `dsh --patch` to drive the working tree.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCAL_PATCH_FILE = "cordis.local.patch.yml";
|
|
43
|
+
|
|
44
|
+
/** DSH's first-party MCP bridge; the only plugin the bundle names. */
|
|
45
|
+
const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
|
|
46
|
+
/** `headless` is one-shot and ships an app bundle; a profile without one blocks forever. */
|
|
47
|
+
export const PROFILE = "headless";
|
|
48
|
+
/**
|
|
49
|
+
* The pass signature of the keyless boot probe. Reaching it proves, in order: the patch parsed,
|
|
50
|
+
* the row is a valid loader entry, the mcp-client resolved, its config passed the plugin schema,
|
|
51
|
+
* the MCP server spawned, and `initialize` + `tools/list` succeeded — `failOnStartupError` makes
|
|
52
|
+
* every one of those a hard failure before the model is ever consulted. The run then dies on the
|
|
53
|
+
* DeepSeek credential nobody has in CI, which is the exit this validator asserts.
|
|
54
|
+
*/
|
|
55
|
+
const MISSING_CREDENTIAL = "MISSING_CREDENTIAL: llm-deepseek";
|
|
56
|
+
|
|
57
|
+
/** `!!js <expr>` — the `tag:yaml.org,2002:js` scalar DSH evaluates when it loads a patch. */
|
|
58
|
+
function jsExpression(source: string): Scalar {
|
|
59
|
+
const scalar = new Scalar(source);
|
|
60
|
+
scalar.tag = "!!js";
|
|
61
|
+
return scalar;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One `insert` row for the mcp-client. `env` restates every config variable as a `!!js` lookup
|
|
66
|
+
* because DSH scrubs any parent name matching /KEY|PASSWORD|SECRET|TOKEN/i (and every `DSH_*`)
|
|
67
|
+
* before spawning an MCP server: an unrestated credential simply never arrives.
|
|
68
|
+
*/
|
|
69
|
+
function patch(project: Project, launch: { command: string; args: string[] }): string {
|
|
70
|
+
const serverName = projectName.dshServer(project.identity.name);
|
|
71
|
+
const env = Object.fromEntries(
|
|
72
|
+
Object.keys(configProperties(project)).map((key) => [
|
|
73
|
+
envName(key),
|
|
74
|
+
jsExpression(`process.env.${envName(key)}`),
|
|
75
|
+
]),
|
|
76
|
+
);
|
|
77
|
+
const row = {
|
|
78
|
+
id: rowId(project),
|
|
79
|
+
name: MCP_CLIENT,
|
|
80
|
+
config: compact({
|
|
81
|
+
serverName,
|
|
82
|
+
transport: "stdio",
|
|
83
|
+
...launch,
|
|
84
|
+
env: Object.keys(env).length ? env : undefined,
|
|
85
|
+
failOnStartupError: true,
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
|
|
92
|
+
export function toolName(project: Project, operation: string): string {
|
|
93
|
+
return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The row id and, with it, the `# == <bundle>` layer `--dump-config` prints. */
|
|
97
|
+
export function rowId(project: Project): string {
|
|
98
|
+
return `mcp-${projectName.dshServer(project.identity.name)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The tarball `npm pack ./hosts/dsh` writes into the release assets, by name. */
|
|
102
|
+
export function dshTarball(project: Project): string {
|
|
103
|
+
const name = projectName.dshPackage(project.identity.name);
|
|
104
|
+
return `${name}-${project.identity.version ?? "0.0.0"}.tgz`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const surface: Surface = {
|
|
108
|
+
id: "dsh",
|
|
109
|
+
plan(project) {
|
|
110
|
+
const { identity } = project;
|
|
111
|
+
const manifest = compact({
|
|
112
|
+
name: projectName.dshPackage(identity.name),
|
|
113
|
+
version: identity.version,
|
|
114
|
+
description: `DSH bundle for ${identity.name}: attaches its MCP server through ${MCP_CLIENT}.`,
|
|
115
|
+
license: identity.license,
|
|
116
|
+
files: [PATCH_FILE],
|
|
117
|
+
dsh: { bundle: { patch: `./${PATCH_FILE}` } },
|
|
118
|
+
});
|
|
119
|
+
return [
|
|
120
|
+
{ kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
|
|
121
|
+
{
|
|
122
|
+
kind: "file",
|
|
123
|
+
path: `${HOST_DIR}/${PATCH_FILE}`,
|
|
124
|
+
// `cwd` is deliberately absent: an installed bundle has no path to point at, and the
|
|
125
|
+
// published launch resolves through the registry from wherever DSH is running.
|
|
126
|
+
content: patch(project, kernelLaunch(project, ".")),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
kind: "file",
|
|
130
|
+
path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
|
|
131
|
+
// The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
|
|
132
|
+
// against the directory DSH itself was started in.
|
|
133
|
+
content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
validate(project) {
|
|
138
|
+
// A fresh DSH_HOME per run: an id already present from an installed bundle would collide
|
|
139
|
+
// (`duplicate loader entry id`), and the profile is initialised on first use anyway.
|
|
140
|
+
const script = [
|
|
141
|
+
'DSH_HOME="$(mktemp -d)"; export DSH_HOME DSH_TELEMETRY_DISABLED=1',
|
|
142
|
+
`out="$(env -u DEEPSEEK_API_KEY -u DEEPSEEK_BASE_URL npx -y @deepseek-ai/dsh@${DSH_PIN} --profile ${PROFILE} --patch ${HOST_DIR}/${LOCAL_PATCH_FILE} 'toolfactory dsh surface probe' 2>&1)"`,
|
|
143
|
+
'rm -rf "$DSH_HOME"',
|
|
144
|
+
'printf "%s\\n" "$out"',
|
|
145
|
+
`printf "%s" "$out" | grep -qF '${MISSING_CREDENTIAL}'`,
|
|
146
|
+
].join("\n");
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
label: "dsh headless boot (keyless)",
|
|
150
|
+
command: "sh",
|
|
151
|
+
args: ["-c", script],
|
|
152
|
+
cwd: project.root,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
},
|
|
156
|
+
// The tools reach the model as `mcp__<serverName>__<operation>`: DSH's MCP client carries tool
|
|
157
|
+
// calls and nothing else, so the rule is the `mcp` surface's own.
|
|
158
|
+
verdict: mcpVerdict,
|
|
159
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surfaces that write nothing of their own and exist for their coverage row and their
|
|
3
|
+
* effect on other surfaces:
|
|
4
|
+
*
|
|
5
|
+
* - `clawhub` publishes the OpenClaw-native package; its only artifact is the release leg
|
|
6
|
+
* `workflows` emits when it is selected together with `openclaw-native`.
|
|
7
|
+
*/
|
|
8
|
+
import type { Surface } from "../model.js";
|
|
9
|
+
|
|
10
|
+
export const clawhub: Surface = {
|
|
11
|
+
id: "clawhub",
|
|
12
|
+
plan: () => [],
|
|
13
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Operation, Project } from "../model.js";
|
|
3
|
+
import { GEMINI_PIN, surface as gemini } from "./gemini.js";
|
|
4
|
+
|
|
5
|
+
const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
|
|
6
|
+
const browse: Operation = {
|
|
7
|
+
name: "browse",
|
|
8
|
+
inputSchema: { type: "object" },
|
|
9
|
+
requires: ["browser"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const project: Project = {
|
|
13
|
+
root: "/repo",
|
|
14
|
+
tool: {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
identity: "plugin.json",
|
|
17
|
+
binding: "typescript",
|
|
18
|
+
surfaces: ["mcp", "npm", "skill", "gemini"],
|
|
19
|
+
bundle: { runtime: "package" },
|
|
20
|
+
tests: { examples: {} },
|
|
21
|
+
config: {
|
|
22
|
+
properties: {
|
|
23
|
+
apiKey: { type: "string", title: "API key", "x-toolfactory": { sensitive: true } },
|
|
24
|
+
region: { type: "string", description: "Service region." },
|
|
25
|
+
},
|
|
26
|
+
required: ["apiKey"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
identity: { name: "hello.tool", version: "0.1.0", description: "Says hello.", license: "MIT" },
|
|
30
|
+
identityExtra: {},
|
|
31
|
+
operations: [echo, browse],
|
|
32
|
+
toolfactoryVersion: "0.1.0",
|
|
33
|
+
packageManager: "npm",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const [manifestFile, ...rest] = gemini.plan(project);
|
|
37
|
+
if (manifestFile?.kind !== "file") throw new Error("expected one whole file");
|
|
38
|
+
const manifest = JSON.parse(manifestFile.content) as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
describe("gemini", () => {
|
|
41
|
+
it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
|
|
42
|
+
expect(rest).toEqual([]);
|
|
43
|
+
expect(manifestFile.path).toBe("gemini-extension.json");
|
|
44
|
+
expect(manifest).toEqual({
|
|
45
|
+
// Gemini rejects anything outside [A-Za-z0-9-], so the dot in N becomes a dash.
|
|
46
|
+
name: "hello-tool",
|
|
47
|
+
version: "0.1.0",
|
|
48
|
+
description: "Says hello.",
|
|
49
|
+
mcpServers: { "hello.tool": { command: "npx", args: ["-y", "hello.tool@0.1.0", "mcp"] } },
|
|
50
|
+
// Not GEMINI.md: the always-on `agents` surface writes AGENTS.md, and Gemini's own
|
|
51
|
+
// validator fails when the named context file is missing.
|
|
52
|
+
contextFileName: "AGENTS.md",
|
|
53
|
+
// Gemini passes an extension no shell environment beyond what `settings[].envVar`
|
|
54
|
+
// allowlists, so every config key is declared here and none in an `env` map.
|
|
55
|
+
settings: [
|
|
56
|
+
{ name: "API key", description: "apiKey", envVar: "APIKEY", sensitive: true },
|
|
57
|
+
{ name: "region", description: "Service region.", envVar: "REGION" },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
// `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
|
|
61
|
+
expect(manifestFile.content).not.toContain('"skills"');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
|
|
65
|
+
expect(gemini.validate?.(project)).toEqual([
|
|
66
|
+
{
|
|
67
|
+
label: "gemini extensions validate",
|
|
68
|
+
command: "npx",
|
|
69
|
+
args: ["-y", `@google/gemini-cli@${GEMINI_PIN}`, "extensions", "validate", "."],
|
|
70
|
+
cwd: "/repo",
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
expect(gemini.verdict?.(echo, project)).toEqual({ kind: "native" });
|
|
75
|
+
expect(gemini.verdict?.(browse, project)).toEqual({
|
|
76
|
+
kind: "excluded",
|
|
77
|
+
reason: "excluded:mcp-no-host-capabilities",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI extension: one `gemini-extension.json` at the repository root, which is the entire
|
|
3
|
+
* install channel. `gemini extensions install https://github.com/<owner>/<repo>` takes any public
|
|
4
|
+
* repository carrying that file — no submission, no catalog PR — and geminicli.com/extensions
|
|
5
|
+
* lists it from the same manifest.
|
|
6
|
+
*
|
|
7
|
+
* Three things it deliberately does not emit:
|
|
8
|
+
*
|
|
9
|
+
* - No `skills` or `commands` key. Gemini auto-discovers `skills/` and `commands/` from the
|
|
10
|
+
* extension root, and the extension root is the repository root, so the `skill` surface's
|
|
11
|
+
* directory already is the extension's skills.
|
|
12
|
+
* - No `GEMINI.md`. `contextFileName` points at `AGENTS.md`, which the always-on `agents` surface
|
|
13
|
+
* writes and every other harness reads; `.agents/sync.py` renders `GEMINI.md` as the one line
|
|
14
|
+
* `@AGENTS.md` for interactive use, and the extension needs no second copy of it.
|
|
15
|
+
* - No `env` on the MCP server row. Gemini does not pass the user's shell environment to an
|
|
16
|
+
* extension: only standard-safe variables and those declared in `settings[].envVar` are
|
|
17
|
+
* allowlisted through to its MCP servers, so the config schema projects onto `settings` and an
|
|
18
|
+
* `env` map would name variables that never arrive.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { projectName } from "../identity/name.js";
|
|
22
|
+
import type { Surface } from "../model.js";
|
|
23
|
+
import { AGENTS_PATH } from "./agents.js";
|
|
24
|
+
import {
|
|
25
|
+
compact,
|
|
26
|
+
configProperties,
|
|
27
|
+
envName,
|
|
28
|
+
isSensitive,
|
|
29
|
+
json,
|
|
30
|
+
kernelLaunch,
|
|
31
|
+
mcpVerdict,
|
|
32
|
+
} from "./shared.js";
|
|
33
|
+
|
|
34
|
+
export const MANIFEST_PATH = "gemini-extension.json";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `@google/gemini-cli` release `validate()` runs. Pinned so the gate is reproducible;
|
|
38
|
+
* Renovate bumps this constant (`renovate.json` `customManagers`).
|
|
39
|
+
*/
|
|
40
|
+
export const GEMINI_PIN = "0.58.0";
|
|
41
|
+
|
|
42
|
+
/** Gemini rejects any extension name outside `[A-Za-z0-9-]`; the projection lives in `projectName`. */
|
|
43
|
+
export const extensionName = projectName.gemini;
|
|
44
|
+
|
|
45
|
+
export const surface: Surface = {
|
|
46
|
+
id: "gemini",
|
|
47
|
+
plan(project) {
|
|
48
|
+
const { identity } = project;
|
|
49
|
+
const settings = Object.entries(configProperties(project)).map(([key, property]) =>
|
|
50
|
+
compact({
|
|
51
|
+
name: (property.title as string | undefined) ?? key,
|
|
52
|
+
description: (property.description as string | undefined) ?? key,
|
|
53
|
+
envVar: envName(key),
|
|
54
|
+
sensitive: isSensitive(property) || undefined,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
const manifest = compact({
|
|
58
|
+
name: extensionName(identity.name),
|
|
59
|
+
version: identity.version,
|
|
60
|
+
description: identity.description,
|
|
61
|
+
// `${extensionPath}` is the installed copy's own directory: an install is a copy, not a
|
|
62
|
+
// checkout, so a bundled kernel is only reachable through it.
|
|
63
|
+
mcpServers: { [identity.name]: kernelLaunch(project, "${extensionPath}") },
|
|
64
|
+
contextFileName: AGENTS_PATH,
|
|
65
|
+
settings: settings.length ? settings : undefined,
|
|
66
|
+
});
|
|
67
|
+
return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
|
|
68
|
+
},
|
|
69
|
+
validate(project) {
|
|
70
|
+
// Gemini's own manifest validator: it parses the manifest, enforces the name and version
|
|
71
|
+
// rules, and fails when `contextFileName` names a file that is not there. Keyless — it never
|
|
72
|
+
// reaches the model — so it is the gate rather than a schema of our own.
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
label: "gemini extensions validate",
|
|
76
|
+
command: "npx",
|
|
77
|
+
args: ["-y", `@google/gemini-cli@${GEMINI_PIN}`, "extensions", "validate", "."],
|
|
78
|
+
cwd: project.root,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
},
|
|
82
|
+
// The extension carries MCP tool calls and nothing else: the rule is the `mcp` surface's own.
|
|
83
|
+
verdict: mcpVerdict,
|
|
84
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { parse as parseToml } from "smol-toml";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
import type { Operation, Project } from "../model.js";
|
|
9
|
+
import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
|
|
10
|
+
|
|
11
|
+
const echo: Operation = {
|
|
12
|
+
name: "echo",
|
|
13
|
+
description: "Return the text you pass in.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: { text: { type: "string" } },
|
|
18
|
+
required: ["text"],
|
|
19
|
+
},
|
|
20
|
+
outputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
|
|
21
|
+
requires: [],
|
|
22
|
+
};
|
|
23
|
+
const shoot: Operation = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
|
|
24
|
+
|
|
25
|
+
function project(overrides: Partial<Project> = {}): Project {
|
|
26
|
+
return {
|
|
27
|
+
root: "/repo",
|
|
28
|
+
tool: {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
identity: "package.json",
|
|
31
|
+
binding: "typescript",
|
|
32
|
+
surfaces: ["hermes-native", "cli"],
|
|
33
|
+
bundle: { runtime: "package" },
|
|
34
|
+
tests: { examples: { echo: { text: "hi" } } },
|
|
35
|
+
config: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
apiKey: {
|
|
39
|
+
description: "Acme key",
|
|
40
|
+
"x-toolfactory": { sensitive: true, url: "https://a" },
|
|
41
|
+
},
|
|
42
|
+
region: { description: "Acme region" },
|
|
43
|
+
},
|
|
44
|
+
required: ["apiKey"],
|
|
45
|
+
},
|
|
46
|
+
...overrides.tool,
|
|
47
|
+
},
|
|
48
|
+
identity: { name: "hello", version: "1.2.0", description: "Say hello" },
|
|
49
|
+
identityExtra: {},
|
|
50
|
+
operations: [echo, shoot],
|
|
51
|
+
toolfactoryVersion: "0.1.0",
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emitted(target: Project): Record<string, string> {
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
surface.plan(target).map((file) => [file.path, file.kind === "file" ? file.content : ""]),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const python = project({ tool: { ...project().tool, binding: "python" } });
|
|
63
|
+
|
|
64
|
+
describe("hermes-native", () => {
|
|
65
|
+
it("projects the manifest Hermes parses and the credentials it prompts for", () => {
|
|
66
|
+
const files = emitted(project());
|
|
67
|
+
const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "") as Record<
|
|
68
|
+
string,
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: manifest is untyped upstream YAML
|
|
70
|
+
any
|
|
71
|
+
>;
|
|
72
|
+
// 1, not the loader's newer-supported 2: `hermes plugins install`'s own cap is still stuck
|
|
73
|
+
// at 1 and rejects a v2 manifest outright, even though the loader and `doctor` accept it.
|
|
74
|
+
expect(manifest.manifest_version).toBe(1);
|
|
75
|
+
expect(manifest.name).toBe("hello");
|
|
76
|
+
// Only what the surface can actually run: `shoot` is excluded, so it is never advertised.
|
|
77
|
+
expect(manifest.provides_tools).toEqual(["echo"]);
|
|
78
|
+
expect(surface.verdict?.(shoot, project())).toEqual({
|
|
79
|
+
kind: "excluded",
|
|
80
|
+
reason: "excluded:implement-in-hosts",
|
|
81
|
+
});
|
|
82
|
+
// One credential declaration becomes the rich env form Hermes' config UI prompts from.
|
|
83
|
+
expect(manifest.requires_env).toEqual([
|
|
84
|
+
{
|
|
85
|
+
name: "APIKEY",
|
|
86
|
+
description: "Acme key",
|
|
87
|
+
secret: true,
|
|
88
|
+
url: "https://a",
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
expect(manifest.optional_env.map((entry: { name: string }) => entry.name)).toEqual([
|
|
92
|
+
"REGION",
|
|
93
|
+
"HELLO_ROOT",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const pyproject = parseToml(files[`${HOST_DIR}/pyproject.toml`] ?? "") as Record<
|
|
97
|
+
string,
|
|
98
|
+
// biome-ignore lint/suspicious/noExplicitAny: TOML is untyped
|
|
99
|
+
any
|
|
100
|
+
>;
|
|
101
|
+
expect(pyproject.project["entry-points"]["hermes_agent.plugins"]).toEqual({
|
|
102
|
+
hello: "hello_hermes:register",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("shims a TypeScript core out of process and calls a Python core in it", () => {
|
|
107
|
+
const shim = emitted(project())[`${pluginDir(project())}/__init__.py`] ?? "";
|
|
108
|
+
expect(surface.verdict?.(echo, project())).toEqual({
|
|
109
|
+
kind: "degraded",
|
|
110
|
+
reason: "degraded:out-of-process",
|
|
111
|
+
});
|
|
112
|
+
expect(shim).toContain('KERNEL = ["node", "--import", "tsx", "src/toolfactory/cli.ts"]');
|
|
113
|
+
expect(shim).toContain('_ROOT = os.environ.get("HELLO_ROOT")');
|
|
114
|
+
// Hermes' own per-plugin data directory reaches the kernel through the shared variable.
|
|
115
|
+
expect(shim).toContain('os.environ.setdefault("HELLO_DATA_DIR", str(ctx.state.data_dir))');
|
|
116
|
+
|
|
117
|
+
const native = emitted(python)[`${pluginDir(python)}/__init__.py`] ?? "";
|
|
118
|
+
expect(surface.verdict?.(echo, python)).toEqual({ kind: "native" });
|
|
119
|
+
expect(native).toContain("from hello.ops import OPERATIONS");
|
|
120
|
+
expect(native).not.toContain("subprocess");
|
|
121
|
+
// The published wheel resolves the pin; the checkout resolves the uv path source.
|
|
122
|
+
const pyproject = parseToml(emitted(python)[`${HOST_DIR}/pyproject.toml`] ?? "") as Record<
|
|
123
|
+
string,
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: TOML is untyped
|
|
125
|
+
any
|
|
126
|
+
>;
|
|
127
|
+
expect(pyproject.project.dependencies).toEqual(["hello>=1.2.0,<2"]);
|
|
128
|
+
expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
|
|
132
|
+
const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
|
|
133
|
+
expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
|
|
134
|
+
expect(shim).toContain("from hello_hermes import register");
|
|
135
|
+
expect(shim).toContain("def register_tool(self, **kwargs: Any) -> None:");
|
|
136
|
+
expect(shim).toContain("self.state = _State(data_dir)");
|
|
137
|
+
// `shoot` is excluded (browser), so only `echo` is registered and only `echo` is callable.
|
|
138
|
+
expect(shim).toContain('assert [r["name"] for r in ctx.registrations] == ["echo"]');
|
|
139
|
+
expect(shim).toContain('r["name"] == "echo"');
|
|
140
|
+
expect(shim).toContain('handler({"text":"hi"})');
|
|
141
|
+
// The marker comes from `echo`'s own output schema, not a hardcoded assumption.
|
|
142
|
+
expect(shim).toContain('assert "text" in result');
|
|
143
|
+
expect(shim).toContain("checkout and Node, which the shim handler spawns");
|
|
144
|
+
|
|
145
|
+
const native = emitted(python)[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
|
|
146
|
+
expect(native).toContain("from hello_hermes import register");
|
|
147
|
+
expect(native).not.toContain("and Node");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function hasHermes(): boolean {
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("hermes", ["--version"], { stdio: "ignore" });
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The only claim that matters: Hermes' own runtime discovers, imports and registers this. */
|
|
161
|
+
describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
|
|
162
|
+
it("loads and registers the generated plugin", () => {
|
|
163
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
|
|
164
|
+
try {
|
|
165
|
+
for (const file of surface.plan(project())) {
|
|
166
|
+
if (file.kind !== "file") continue;
|
|
167
|
+
mkdirSync(dirname(join(root, file.path)), { recursive: true });
|
|
168
|
+
writeFileSync(join(root, file.path), file.content);
|
|
169
|
+
}
|
|
170
|
+
const commands = surface.validate?.({ ...project(), root }) ?? [];
|
|
171
|
+
const command = commands.find((c) => c.label === "hermes plugins doctor");
|
|
172
|
+
if (!command) throw new Error("the hermes surface no longer declares a doctor validator");
|
|
173
|
+
const output = execFileSync(command.command, command.args, { cwd: root, encoding: "utf8" });
|
|
174
|
+
expect(output).toContain("registrations: 1 tool(s)");
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(root, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|