toolfactory 0.2.4 → 0.4.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 +1 -1
- package/dist/bindings/typescript.test.js +11 -0
- package/dist/commands.js +26 -2
- package/dist/commands.test.js +30 -0
- package/dist/hosts/github.js +2 -3
- package/dist/hosts/openclaw.js +3 -2
- package/dist/model.d.ts +4 -0
- package/dist/model.js +16 -1
- package/dist/project/gate.js +3 -4
- package/dist/project/history.d.ts +4 -0
- package/dist/surfaces/agents.js +7 -1
- package/dist/surfaces/agents.test.js +1 -0
- package/dist/surfaces/openclaw-native.d.ts +8 -1
- package/dist/surfaces/openclaw-native.js +21 -11
- package/dist/surfaces/openclaw-native.test.js +8 -5
- package/dist/surfaces/shared.js +2 -0
- package/dist/surfaces/vscode-extension.js +1 -1
- package/dist/surfaces/web.d.ts +2 -2
- package/dist/surfaces/web.js +2 -2
- package/dist/surfaces/workflows.js +8 -1
- package/dist/surfaces/workflows.test.js +64 -1
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/bindings/typescript.test.ts +13 -0
- package/src/commands.test.ts +34 -0
- package/src/commands.ts +29 -2
- package/src/hosts/github.ts +2 -3
- package/src/hosts/openclaw.ts +3 -3
- package/src/model.ts +20 -1
- package/src/project/gate.ts +3 -4
- package/src/surfaces/agents.test.ts +1 -0
- package/src/surfaces/agents.ts +7 -1
- package/src/surfaces/openclaw-native.test.ts +8 -5
- package/src/surfaces/openclaw-native.ts +22 -11
- package/src/surfaces/shared.ts +1 -0
- package/src/surfaces/vscode-extension.ts +1 -1
- package/src/surfaces/web.ts +2 -2
- package/src/surfaces/workflows.test.ts +75 -1
- package/src/surfaces/workflows.ts +9 -3
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/index-AwHZabeT.js +10 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-GlWisnfJ.js +0 -10
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ generated and every surface still installs, builds and publishes.
|
|
|
22
22
|
- **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/toolfactory`
|
|
23
23
|
- **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout
|
|
24
24
|
- **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/toolfactory#hosts/hermes/toolfactory_hermes`
|
|
25
|
-
- **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.2.
|
|
25
|
+
- **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.4.2.tgz`
|
|
26
26
|
- **Web app** — `npx -y toolfactory mcp --http --open` serves the operations page beside the
|
|
27
27
|
MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and
|
|
28
28
|
returns the URL.
|
|
@@ -88,6 +88,17 @@ describe("typescript binding", () => {
|
|
|
88
88
|
const scripts = JSON.parse(text(scaffold(project(["cli"])), "package.json")).scripts;
|
|
89
89
|
expect(scripts["test:live"]).toBe("node --env-file-if-exists=.env node_modules/vitest/vitest.mjs run tests/live.test.ts");
|
|
90
90
|
});
|
|
91
|
+
it("supports a host-vault live gate without making credentials globally required config", () => {
|
|
92
|
+
const base = project(["cli"]);
|
|
93
|
+
base.tool.tests.live = { credentials: ["LIVE_VAULT_READY"], ci: false };
|
|
94
|
+
base.operations = [{ name: "read", inputSchema: { type: "object" }, requires: [] }];
|
|
95
|
+
const live = liveTest(base)[0];
|
|
96
|
+
if (live?.kind !== "region")
|
|
97
|
+
throw new Error("expected a region file");
|
|
98
|
+
expect(live.regions[0]?.content).toContain('const CREDENTIALS = ["LIVE_VAULT_READY"];');
|
|
99
|
+
expect(base.tool.config?.required).toBeUndefined();
|
|
100
|
+
expect(ToolConfigSchema.safeParse({ ...base.tool, tests: { live: { credentials: [] } } }).success).toBe(false);
|
|
101
|
+
});
|
|
91
102
|
it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
|
|
92
103
|
const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
|
|
93
104
|
const mcp = text(files, "src/toolfactory/mcp.ts");
|
package/dist/commands.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
-
import {
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
10
|
import { parseEnv } from "node:util";
|
|
10
11
|
import { getBinding } from "./bindings/index.js";
|
|
11
12
|
import { LIVE_TEST_COMMAND } from "./bindings/python.js";
|
|
@@ -72,6 +73,26 @@ function agentSetup(root) {
|
|
|
72
73
|
.flatMap((line) => line.match(/^Generated ([^:]+):/)?.slice(1, 2) ?? []);
|
|
73
74
|
return { setup: result.status === 0, harnesses };
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The shared OpenClaw project registry (owner decision 2026-09-19, `docs/adoption.md`): a tool
|
|
78
|
+
* scaffolded under `~/.openclaw/projects/<id>` gets its project row at `init` time, best-effort
|
|
79
|
+
* the way `.agents/setup` is — no reachable gateway (or CLI) still leaves a scaffolded project,
|
|
80
|
+
* and the next step carries the exact call to run. Session creation stays with the calling agent.
|
|
81
|
+
* TOOLFACTORY_OPENCLAW_PROJECTS_ROOT overrides the registry root (deployments, tests).
|
|
82
|
+
*/
|
|
83
|
+
function registerOpenClawProject(root, dryRun) {
|
|
84
|
+
const registryRoot = process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT ?? join(homedir(), ".openclaw", "projects");
|
|
85
|
+
const beneath = relative(registryRoot, root);
|
|
86
|
+
if (!beneath || beneath.startsWith("..") || resolve(beneath) === beneath)
|
|
87
|
+
return undefined;
|
|
88
|
+
const args = ["gateway", "call", "projects.register", "--params", JSON.stringify({ path: root })];
|
|
89
|
+
if (dryRun)
|
|
90
|
+
return ("OpenClaw: run `openclaw " + args.join(" ") + "` to add it to the shared project registry.");
|
|
91
|
+
const registered = spawnSync("openclaw", args, { timeout: 60_000 });
|
|
92
|
+
return registered.status === 0
|
|
93
|
+
? "OpenClaw: registered in the shared project registry; bind one visible dashboard session to it for ongoing work."
|
|
94
|
+
: "OpenClaw: registration did not succeed — run the `openclaw gateway call projects.register` step once the gateway is reachable.";
|
|
95
|
+
}
|
|
75
96
|
function nextSteps(project, agentConfig) {
|
|
76
97
|
const cli = toolfactoryCli(project);
|
|
77
98
|
const ops = project.tool.binding === "typescript"
|
|
@@ -194,10 +215,13 @@ export function init(options) {
|
|
|
194
215
|
git(root, [...commitIdentity(root), "commit", "-q", "--no-verify", "-m", "toolfactory init"]);
|
|
195
216
|
}
|
|
196
217
|
}
|
|
218
|
+
const steps = nextSteps(built.project, agentConfig);
|
|
219
|
+
// Last, because registration wants the committed repository on record, not a bare scaffold.
|
|
220
|
+
const openclaw = registerOpenClawProject(root, options.dryRun === true);
|
|
197
221
|
const result = {
|
|
198
222
|
written,
|
|
199
223
|
agentConfig,
|
|
200
|
-
nextSteps:
|
|
224
|
+
nextSteps: openclaw ? [...steps, openclaw] : steps,
|
|
201
225
|
};
|
|
202
226
|
if (options.repo) {
|
|
203
227
|
const project = loadProject(root);
|
package/dist/commands.test.js
CHANGED
|
@@ -213,3 +213,33 @@ describe("unpublish", () => {
|
|
|
213
213
|
expect(result.steps.some((step) => step.run.includes("npm deprecate probe@'*'"))).toBe(true);
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
+
describe("registerOpenClawProject", () => {
|
|
217
|
+
it("reports the exact register call for a tool scaffolded under the shared registry (dry-run)", () => {
|
|
218
|
+
const registry = join(mkdtempSync(join(tmpdir(), "tf-home-")), ".openclaw", "projects");
|
|
219
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT = registry;
|
|
220
|
+
const root = join(registry, "probe");
|
|
221
|
+
const result = commands.init({
|
|
222
|
+
root,
|
|
223
|
+
name: "probe",
|
|
224
|
+
binding: "typescript",
|
|
225
|
+
setup: false,
|
|
226
|
+
git: false,
|
|
227
|
+
dryRun: true,
|
|
228
|
+
});
|
|
229
|
+
const line = result.nextSteps.find((step) => step.startsWith("OpenClaw:"));
|
|
230
|
+
expect(line).toBe("OpenClaw: run `openclaw gateway call projects.register --params " +
|
|
231
|
+
JSON.stringify({ path: root }) +
|
|
232
|
+
"` to add it to the shared project registry.");
|
|
233
|
+
});
|
|
234
|
+
it("says nothing outside the shared registry", () => {
|
|
235
|
+
const result = commands.init({
|
|
236
|
+
root: join(tmpdir(), "tf-plain"),
|
|
237
|
+
name: "probe",
|
|
238
|
+
binding: "typescript",
|
|
239
|
+
setup: false,
|
|
240
|
+
git: false,
|
|
241
|
+
dryRun: true,
|
|
242
|
+
});
|
|
243
|
+
expect(result.nextSteps.some((step) => step.startsWith("OpenClaw:"))).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/dist/hosts/github.js
CHANGED
|
@@ -45,12 +45,11 @@ export function bootstrapRepo(project, options = {}) {
|
|
|
45
45
|
const manual = (options.manual ?? []).filter((step) => !step.startsWith("npm:"));
|
|
46
46
|
const envFile = options.envFile ?? join(project.root, ".env");
|
|
47
47
|
const values = existsSync(envFile) ? parseEnv(readFileSync(envFile, "utf8")) : {};
|
|
48
|
-
const keys = liveCredentials(project);
|
|
48
|
+
const keys = project.tool.tests.live?.ci === false ? [] : liveCredentials(project);
|
|
49
49
|
const commands = [];
|
|
50
50
|
const written = [];
|
|
51
51
|
const missing = [];
|
|
52
|
-
//
|
|
53
|
-
// config key that is both required and sensitive is the whole trigger (§6 T4).
|
|
52
|
+
// A local-vault-only live tier does not need a GitHub environment or copied account secrets.
|
|
54
53
|
if (keys.length) {
|
|
55
54
|
const ids = reviewers.map((login) => {
|
|
56
55
|
const args = ["api", `users/${login}`, "--jq", ".id"];
|
package/dist/hosts/openclaw.js
CHANGED
|
@@ -24,7 +24,8 @@ const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
|
|
|
24
24
|
*/
|
|
25
25
|
function overridden(project) {
|
|
26
26
|
const openclaw = project.tool.openclaw;
|
|
27
|
-
|
|
27
|
+
// The host development dependency is deliberately pinned to the recorded scaffold release.
|
|
28
|
+
const paths = new Set([".devDependencies.openclaw"]);
|
|
28
29
|
for (const section of ["dependencies", "peerDependencies", "devDependencies"]) {
|
|
29
30
|
for (const key of Object.keys(openclaw?.[section] ?? {}))
|
|
30
31
|
paths.add(`.${section}.${key}`);
|
|
@@ -74,7 +75,7 @@ export function scaffoldDrift(project) {
|
|
|
74
75
|
// The probe must run the exact openclaw this project pins (author override or the
|
|
75
76
|
// scaffold default) - never whatever happens to be on PATH, which made the drift verdict
|
|
76
77
|
// environment-dependent: the scaffold openclawVersion is the running CLI own version.
|
|
77
|
-
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.
|
|
78
|
+
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
78
79
|
const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
|
|
79
80
|
try {
|
|
80
81
|
const probe = existsSync(pinned)
|
package/dist/model.d.ts
CHANGED
|
@@ -89,6 +89,10 @@ export declare const ToolConfigSchema: z.ZodObject<{
|
|
|
89
89
|
config: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
90
90
|
tests: z.ZodDefault<z.ZodObject<{
|
|
91
91
|
examples: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
92
|
+
live: z.ZodOptional<z.ZodObject<{
|
|
93
|
+
credentials: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
94
|
+
ci: z.ZodOptional<z.ZodBoolean>;
|
|
95
|
+
}, z.core.$strip>>;
|
|
92
96
|
}, z.core.$strip>>;
|
|
93
97
|
npm: z.ZodOptional<z.ZodObject<{
|
|
94
98
|
scope: z.ZodOptional<z.ZodString>;
|
package/dist/model.js
CHANGED
|
@@ -102,7 +102,22 @@ export const ToolConfigSchema = z
|
|
|
102
102
|
property.default === undefined), "A sensitive config property must not declare a default: it would be committed into every manifest.")
|
|
103
103
|
.describe("JSON Schema 2020-12 object for the tool's configuration; mark secrets with x-toolfactory.sensitive."),
|
|
104
104
|
tests: z
|
|
105
|
-
.object({
|
|
105
|
+
.object({
|
|
106
|
+
examples: z.record(z.string(), jsonObject).default({}),
|
|
107
|
+
live: z
|
|
108
|
+
.object({
|
|
109
|
+
credentials: z
|
|
110
|
+
.array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/))
|
|
111
|
+
.min(1)
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Environment gates for live tests when auth is supplied outside tool config, such as a host vault. Names only, never values."),
|
|
114
|
+
ci: z
|
|
115
|
+
.boolean()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Set false for live tests that require a local vault or browser unavailable on hosted CI. The native local live-test tier is still generated."),
|
|
118
|
+
})
|
|
119
|
+
.optional(),
|
|
120
|
+
})
|
|
106
121
|
.default({ examples: {} })
|
|
107
122
|
.describe("Example arguments per operation, used by the generated surface-smoke tests."),
|
|
108
123
|
npm: z.object({ scope: z.string().optional() }).optional(),
|
package/dist/project/gate.js
CHANGED
|
@@ -258,11 +258,10 @@ export function packageSteps(project, options = {}) {
|
|
|
258
258
|
run: [
|
|
259
259
|
`npm --prefix ${OPENCLAW_HOST_DIR} install`,
|
|
260
260
|
`npm --prefix ${OPENCLAW_HOST_DIR} run build`,
|
|
261
|
-
`v=$(node -p "require('./package.json').version")`,
|
|
262
261
|
`cp ${OPENCLAW_HOST_DIR}/package.json ${OPENCLAW_HOST_DIR}/package.json.prepack`,
|
|
263
|
-
`(
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
`(trap 'mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json' EXIT; ` +
|
|
263
|
+
`(cd ${OPENCLAW_HOST_DIR} && npm pkg set 'dependencies.${npmName(project)}=${project.identity.version}') && ` +
|
|
264
|
+
`npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR})`,
|
|
266
265
|
].join(" && "),
|
|
267
266
|
});
|
|
268
267
|
}
|
|
@@ -16,6 +16,10 @@ export declare function toolAtRef(root: string, ref: string): {
|
|
|
16
16
|
};
|
|
17
17
|
tests: {
|
|
18
18
|
examples: Record<string, Record<string, unknown>>;
|
|
19
|
+
live?: {
|
|
20
|
+
credentials?: string[] | undefined;
|
|
21
|
+
ci?: boolean | undefined;
|
|
22
|
+
} | undefined;
|
|
19
23
|
};
|
|
20
24
|
$schema?: string | undefined;
|
|
21
25
|
runtime?: "kernel" | "none" | undefined;
|
package/dist/surfaces/agents.js
CHANGED
|
@@ -519,7 +519,13 @@ function setupTail(project) {
|
|
|
519
519
|
" exit 1",
|
|
520
520
|
"fi",
|
|
521
521
|
]
|
|
522
|
-
: [
|
|
522
|
+
: [
|
|
523
|
+
"if [ -f package-lock.json ]; then",
|
|
524
|
+
" npm ci --no-audit --no-fund",
|
|
525
|
+
"else",
|
|
526
|
+
" npm install --no-audit --no-fund",
|
|
527
|
+
"fi",
|
|
528
|
+
];
|
|
523
529
|
return [
|
|
524
530
|
"",
|
|
525
531
|
"# The pre-commit hook installed above converges agent config; toolfactory's drift gate goes in",
|
|
@@ -106,6 +106,7 @@ describe("agents", () => {
|
|
|
106
106
|
expect(setup).not.toContain("# Project setup goes here");
|
|
107
107
|
// Owner ask #11: a commit cannot leave a generated file stale, with or without CI.
|
|
108
108
|
expect(setup).toContain("npx toolfactory check >/dev/null");
|
|
109
|
+
expect(setup).toContain("if [ -f package-lock.json ]; then\n npm ci --no-audit --no-fund");
|
|
109
110
|
expect(setup).toContain("npm install --no-audit --no-fund");
|
|
110
111
|
});
|
|
111
112
|
it("installs uv before Python projects run the vendored setup workflow", () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Project, type Surface } from "../model.js";
|
|
1
|
+
import { type Operation, type Project, type Surface } from "../model.js";
|
|
2
2
|
import { type Registration } from "../report/coverage.js";
|
|
3
3
|
/** Transcribed from `openclaw plugins init --type tool`; `validate()` proves them still current. */
|
|
4
4
|
export declare const OPENCLAW_SCAFFOLD: {
|
|
@@ -64,6 +64,13 @@ export declare const OPENCLAW_SCAFFOLD: {
|
|
|
64
64
|
* comparison is identical in every environment.
|
|
65
65
|
*/
|
|
66
66
|
export declare function openclawVersion(project: Project): string;
|
|
67
|
+
/**
|
|
68
|
+
* The gateway has one flat tool namespace, so the plugin's tool names are the tool id prefix
|
|
69
|
+
* plus the operation name with dots flattened: unprefixed names collide across plugins (two
|
|
70
|
+
* `web`s) and dotted ones fold onto core tools (`sessions.list` → `sessions_list`). The
|
|
71
|
+
* kernel, CLI and MCP server keep the bare operation names; only this host renames.
|
|
72
|
+
*/
|
|
73
|
+
export declare function toolName(project: Project, operation: Operation): string;
|
|
67
74
|
export declare const OPENCLAW_ADDITIONS: {
|
|
68
75
|
/** Without it `tsc` emits a `dist/` from source that failed type-checking. */
|
|
69
76
|
readonly compilerOptions: {
|
|
@@ -84,6 +84,15 @@ export default defineConfig({
|
|
|
84
84
|
export function openclawVersion(project) {
|
|
85
85
|
return project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The gateway has one flat tool namespace, so the plugin's tool names are the tool id prefix
|
|
89
|
+
* plus the operation name with dots flattened: unprefixed names collide across plugins (two
|
|
90
|
+
* `web`s) and dotted ones fold onto core tools (`sessions.list` → `sessions_list`). The
|
|
91
|
+
* kernel, CLI and MCP server keep the bare operation names; only this host renames.
|
|
92
|
+
*/
|
|
93
|
+
export function toolName(project, operation) {
|
|
94
|
+
return `${project.identity.name}_${operation.name.replace(/\./g, "_")}`;
|
|
95
|
+
}
|
|
87
96
|
export const OPENCLAW_ADDITIONS = {
|
|
88
97
|
/** Without it `tsc` emits a `dist/` from source that failed type-checking. */
|
|
89
98
|
compilerOptions: { noEmitOnError: true },
|
|
@@ -242,6 +251,7 @@ function packageJson(project, operations, kase) {
|
|
|
242
251
|
dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
|
|
243
252
|
devDependencies: {
|
|
244
253
|
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
254
|
+
openclaw: openclawVersion(project),
|
|
245
255
|
...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
|
|
246
256
|
...extra?.devDependencies,
|
|
247
257
|
},
|
|
@@ -280,7 +290,7 @@ function pluginManifest(project, operations) {
|
|
|
280
290
|
activation: activation(project),
|
|
281
291
|
skills: shipsSkills(project) ? ["./skills"] : undefined,
|
|
282
292
|
contracts: {
|
|
283
|
-
tools: operations.map((operation) => operation
|
|
293
|
+
tools: operations.map((operation) => toolName(project, operation)),
|
|
284
294
|
...Object.fromEntries(declared),
|
|
285
295
|
},
|
|
286
296
|
});
|
|
@@ -379,7 +389,7 @@ const execFileAsync = promisify(execFile);
|
|
|
379
389
|
|
|
380
390
|
${pythonKernel(project)}`;
|
|
381
391
|
const tools = operations.map((operation) => ` tool({
|
|
382
|
-
name: ${JSON.stringify(operation
|
|
392
|
+
name: ${JSON.stringify(toolName(project, operation))},
|
|
383
393
|
description: ${JSON.stringify(operation.description ?? operation.name)},
|
|
384
394
|
parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
|
|
385
395
|
${executeBody(project, operation)}
|
|
@@ -533,7 +543,7 @@ import entry from "./index.js";
|
|
|
533
543
|
|
|
534
544
|
describe(${JSON.stringify(project.identity.name)}, () => {
|
|
535
545
|
it("declares tool metadata", () => {
|
|
536
|
-
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
546
|
+
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
537
547
|
});
|
|
538
548
|
${registrationTest}${web}});
|
|
539
549
|
`;
|
|
@@ -569,8 +579,8 @@ function e2eCase(project, operations) {
|
|
|
569
579
|
* would answer OK even for a tool that threw, because the host reports that in a tool message too.
|
|
570
580
|
* An operation that promises no output names has nothing to gate on, so it gets no failure leg.
|
|
571
581
|
*/
|
|
572
|
-
function e2eFixtures(kase) {
|
|
573
|
-
const name = kase.operation
|
|
582
|
+
function e2eFixtures(project, kase) {
|
|
583
|
+
const name = toolName(project, kase.operation);
|
|
574
584
|
return json({
|
|
575
585
|
fixtures: [
|
|
576
586
|
{
|
|
@@ -711,19 +721,19 @@ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
|
|
|
711
721
|
it("registers its tools at runtime", async () => {
|
|
712
722
|
const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
|
|
713
723
|
expect(inspected.plugin.status).toBe("loaded");
|
|
714
|
-
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
724
|
+
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
715
725
|
});
|
|
716
726
|
|
|
717
|
-
it(${JSON.stringify(`the model calls ${kase.operation
|
|
727
|
+
it(${JSON.stringify(`the model calls ${toolName(project, kase.operation)} and the tool's result reaches the reply`)}, async () => {
|
|
718
728
|
const turn = JSON.parse(
|
|
719
729
|
await oc([
|
|
720
730
|
"agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
|
|
721
731
|
"--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
|
|
722
732
|
"--message",
|
|
723
|
-
${JSON.stringify(`Call the tool named ${kase.operation
|
|
733
|
+
${JSON.stringify(`Call the tool named ${toolName(project, kase.operation)}. Reply with only the exact text returned by that tool.`)},
|
|
724
734
|
]),
|
|
725
735
|
);
|
|
726
|
-
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation
|
|
736
|
+
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(toolName(project, kase.operation))});
|
|
727
737
|
expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
|
|
728
738
|
}, 180_000);
|
|
729
739
|
});
|
|
@@ -803,7 +813,7 @@ function readme(project, operations, kase) {
|
|
|
803
813
|
"",
|
|
804
814
|
];
|
|
805
815
|
if (kase) {
|
|
806
|
-
lines.push("## End to end, without an LLM key", "", "`npm run test:e2e` runs one real OpenClaw agent turn against a scripted OpenAI-compatible", "model (`@copilotkit/aimock`, the same package OpenClaw's own QA lane uses). `e2e/fixtures.json`", "is projected from `dev.toolfactory/ops.json` and `tool.json`'s `tests.examples`: the model asks", `for \`${kase.operation
|
|
816
|
+
lines.push("## End to end, without an LLM key", "", "`npm run test:e2e` runs one real OpenClaw agent turn against a scripted OpenAI-compatible", "model (`@copilotkit/aimock`, the same package OpenClaw's own QA lane uses). `e2e/fixtures.json`", "is projected from `dev.toolfactory/ops.json` and `tool.json`'s `tests.examples`: the model asks", `for \`${toolName(project, kase.operation)}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`, "result comes back carrying what its output schema promises.", "");
|
|
807
817
|
}
|
|
808
818
|
if (declared.length) {
|
|
809
819
|
lines.push("## Contributes", "", "Declared in `dev.toolfactory/tool.json` (`openclaw.registers`). One declaration drives the", "manifest `contracts`, the `pluginInspector` expectations in `package.json` and `src/index.test.ts`;", "the call itself is yours, below the `// /tf:entry` marker in `src/index.ts`. Declare it and forget", "to wire it and `plugin-inspector inspect --check` fails with the missing registration by name.", "", ...declared.map((registration) => `- \`api.${registration.api}(…)\` → \`contracts.${registration.contract}\`: ${registration.ids
|
|
@@ -890,7 +900,7 @@ export const surface = {
|
|
|
890
900
|
{
|
|
891
901
|
kind: "file",
|
|
892
902
|
path: `${HOST_DIR}/e2e/fixtures.json`,
|
|
893
|
-
content: e2eFixtures(kase),
|
|
903
|
+
content: e2eFixtures(project, kase),
|
|
894
904
|
},
|
|
895
905
|
{
|
|
896
906
|
kind: "file",
|
|
@@ -107,7 +107,7 @@ describe("openclaw-native", () => {
|
|
|
107
107
|
it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
|
|
108
108
|
const files = emitted(project());
|
|
109
109
|
const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
|
|
110
|
-
expect(manifest.contracts).toEqual({ tools: ["
|
|
110
|
+
expect(manifest.contracts).toEqual({ tools: ["hello_echo"] });
|
|
111
111
|
expect(manifest.activation).toEqual({ onStartup: true });
|
|
112
112
|
expect(surface.verdict?.(shoot, project())).toEqual({
|
|
113
113
|
kind: "excluded",
|
|
@@ -117,7 +117,10 @@ describe("openclaw-native", () => {
|
|
|
117
117
|
expect(files[`${HOST_DIR}/README.md`]).toContain("excluded:implement-in-hosts");
|
|
118
118
|
expect(files[`${HOST_DIR}/README.md`]).toContain("`shoot` — requires browser");
|
|
119
119
|
const pkg = JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}");
|
|
120
|
-
expect(pkg.devDependencies).toMatchObject(
|
|
120
|
+
expect(pkg.devDependencies).toMatchObject({
|
|
121
|
+
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
122
|
+
openclaw: OPENCLAW_SCAFFOLD.openclawVersion,
|
|
123
|
+
});
|
|
121
124
|
expect(pkg.openclaw).toEqual({
|
|
122
125
|
extensions: ["./dist/index.js"],
|
|
123
126
|
compat: { pluginApi: OPENCLAW_SCAFFOLD.pluginApi },
|
|
@@ -152,7 +155,7 @@ describe("openclaw-native", () => {
|
|
|
152
155
|
expect(index).toContain("api.registerControlUiDescriptor(descriptor)");
|
|
153
156
|
// Neither registrar is a manifest contract key; both are what the inspector expects to see.
|
|
154
157
|
expect(JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}").contracts).toEqual({
|
|
155
|
-
tools: ["
|
|
158
|
+
tools: ["hello_echo"],
|
|
156
159
|
});
|
|
157
160
|
expect(JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}").pluginInspector.plugin.expect
|
|
158
161
|
.registrations).toEqual(["registerHttpRoute", "registerControlUiDescriptor"]);
|
|
@@ -227,8 +230,8 @@ describe("openclaw-native", () => {
|
|
|
227
230
|
// when the tool's own result carries a name its output schema promises; leg 3 catches the rest.
|
|
228
231
|
expect(fixtures.fixtures).toEqual([
|
|
229
232
|
{
|
|
230
|
-
match: { toolName: "
|
|
231
|
-
response: { toolCalls: [{ name: "
|
|
233
|
+
match: { toolName: "hello_echo", hasToolResult: false },
|
|
234
|
+
response: { toolCalls: [{ name: "hello_echo", arguments: { text: "hello" } }] },
|
|
232
235
|
},
|
|
233
236
|
{
|
|
234
237
|
match: { hasToolResult: true, toolResultContains: "echoed" },
|
package/dist/surfaces/shared.js
CHANGED
|
@@ -94,6 +94,8 @@ export function dataDirEnvName(project) {
|
|
|
94
94
|
* A tool with none has nothing to gate a live tier on, so no live test and no `live` CI job.
|
|
95
95
|
*/
|
|
96
96
|
export function liveCredentials(project) {
|
|
97
|
+
if (project.tool.tests.live?.credentials)
|
|
98
|
+
return project.tool.tests.live.credentials;
|
|
97
99
|
const properties = configProperties(project);
|
|
98
100
|
const required = new Set(requiredConfig(project));
|
|
99
101
|
return Object.keys(properties).filter((key) => required.has(key) && isSensitive(properties[key]));
|
package/dist/surfaces/web.d.ts
CHANGED
|
@@ -36,8 +36,8 @@ export declare const WEB_SCAFFOLD: {
|
|
|
36
36
|
readonly "@fontsource-variable/geist": "^5.3.0";
|
|
37
37
|
readonly "@tailwindcss/vite": "^4.3.3";
|
|
38
38
|
readonly "class-variance-authority": "^0.7.1";
|
|
39
|
-
readonly cn: "^0.
|
|
40
|
-
readonly "lucide-react": "^1.
|
|
39
|
+
readonly cn: "^0.4.0";
|
|
40
|
+
readonly "lucide-react": "^1.48.0";
|
|
41
41
|
readonly react: "^19.2.8";
|
|
42
42
|
readonly "react-dom": "^19.2.8";
|
|
43
43
|
readonly shadcn: "^4.21.0";
|
package/dist/surfaces/web.js
CHANGED
|
@@ -149,8 +149,8 @@ export default defineConfig({
|
|
|
149
149
|
"@fontsource-variable/geist": "^5.3.0",
|
|
150
150
|
"@tailwindcss/vite": "^4.3.3",
|
|
151
151
|
"class-variance-authority": "^0.7.1",
|
|
152
|
-
cn: "^0.
|
|
153
|
-
"lucide-react": "^1.
|
|
152
|
+
cn: "^0.4.0",
|
|
153
|
+
"lucide-react": "^1.48.0",
|
|
154
154
|
react: "^19.2.8",
|
|
155
155
|
"react-dom": "^19.2.8",
|
|
156
156
|
shadcn: "^4.21.0",
|
|
@@ -150,10 +150,17 @@ function actionStep(step, matrix) {
|
|
|
150
150
|
* (an environment gates deployments, not the checkout an untrusted PR ships).
|
|
151
151
|
*/
|
|
152
152
|
function liveJob(project) {
|
|
153
|
+
if (project.tool.tests.live?.ci === false)
|
|
154
|
+
return undefined;
|
|
153
155
|
if (liveCredentials(project).length === 0)
|
|
154
156
|
return undefined;
|
|
155
157
|
const properties = configProperties(project);
|
|
156
|
-
const secrets =
|
|
158
|
+
const secrets = [
|
|
159
|
+
...new Set([
|
|
160
|
+
...Object.keys(properties).filter((key) => isSensitive(properties[key])),
|
|
161
|
+
...liveCredentials(project),
|
|
162
|
+
]),
|
|
163
|
+
];
|
|
157
164
|
const pmName = project.packageManager ?? "npm";
|
|
158
165
|
const pm = PACKAGE_MANAGER_COMMANDS[pmName];
|
|
159
166
|
const steps = project.tool.binding === "typescript"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
@@ -365,6 +365,18 @@ describe("workflows", () => {
|
|
|
365
365
|
expect(composeHermes.services.hermes.command[2]).toContain("hermes plugins doctor /work/hosts/hermes/hello_hermes --ci");
|
|
366
366
|
expect(composeHermes.services.openclaw).toBeUndefined();
|
|
367
367
|
});
|
|
368
|
+
it("supports explicit live credentials without making runtime configuration mandatory", () => {
|
|
369
|
+
const target = project(["cli"]);
|
|
370
|
+
target.tool.config = undefined;
|
|
371
|
+
target.tool.tests.live = { credentials: ["LIVE_ACCOUNT_READY"] };
|
|
372
|
+
const ci = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
373
|
+
expect(ci.jobs.live.env).toEqual({ LIVE_ACCOUNT_READY: "${{ secrets.LIVE_ACCOUNT_READY }}" });
|
|
374
|
+
expect(ci.jobs.live.environment).toBe("live-tests");
|
|
375
|
+
target.tool.tests.live.ci = false;
|
|
376
|
+
const localOnly = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
377
|
+
expect(localOnly.jobs.live).toBeUndefined();
|
|
378
|
+
expect(localOnly.on.workflow_dispatch).toBeUndefined();
|
|
379
|
+
});
|
|
368
380
|
it("adds the T4 live job, and workflow_dispatch, iff a config key is required and sensitive", () => {
|
|
369
381
|
const ci = yamlParse(emitted(project(["cli"]))[".github/workflows/ci.yml"]);
|
|
370
382
|
expect(ci.on.workflow_dispatch).toEqual({});
|
|
@@ -513,6 +525,10 @@ describe("bootstrap-repo", () => {
|
|
|
513
525
|
expect(release.manual.join(" ")).toContain("granular access tokens with bypass 2FA are unsupported");
|
|
514
526
|
expect(release.manual.join(" ")).toContain("NPM_TRUSTED_PUBLISHER");
|
|
515
527
|
expect(release.manual.filter((step) => step.startsWith("npm:"))).toHaveLength(1);
|
|
528
|
+
noCredential.tool.tests.live = { credentials: ["LOCAL_VAULT_READY"], ci: false };
|
|
529
|
+
const localOnly = bootstrapRepo(noCredential, { dryRun: true });
|
|
530
|
+
expect(localOnly.secrets).toEqual([]);
|
|
531
|
+
expect(localOnly.commands.some((command) => command.includes("live-tests"))).toBe(false);
|
|
516
532
|
});
|
|
517
533
|
});
|
|
518
534
|
/**
|
|
@@ -546,6 +562,53 @@ describe("gate", () => {
|
|
|
546
562
|
"openclaw end-to-end (scripted model, no LLM key)",
|
|
547
563
|
]);
|
|
548
564
|
});
|
|
565
|
+
it("packs the actual core dependency and restores the host manifest after success or failure", () => {
|
|
566
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-plugin-pack-"));
|
|
567
|
+
mkdirSync(join(root, "hosts/openclaw"), { recursive: true });
|
|
568
|
+
mkdirSync(join(root, "bin"));
|
|
569
|
+
const manifest = {
|
|
570
|
+
name: "openclaw-plugin-example",
|
|
571
|
+
dependencies: { "@example/core": "file:../.." },
|
|
572
|
+
};
|
|
573
|
+
writeFileSync(join(root, "hosts/openclaw/package.json"), JSON.stringify(manifest));
|
|
574
|
+
writeFileSync(join(root, "bin/npm"), `#!/usr/bin/env node
|
|
575
|
+
const fs = require('node:fs');
|
|
576
|
+
const path = require('node:path');
|
|
577
|
+
const args = process.argv.slice(2);
|
|
578
|
+
if (args[0] === 'pkg') {
|
|
579
|
+
const value = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
580
|
+
const [key, version] = args[2].slice('dependencies.'.length).split('=');
|
|
581
|
+
value.dependencies[key] = version;
|
|
582
|
+
fs.writeFileSync('package.json', JSON.stringify(value));
|
|
583
|
+
} else if (args[0] === 'pack') {
|
|
584
|
+
fs.copyFileSync('hosts/openclaw/package.json', 'packed.json');
|
|
585
|
+
process.exit(Number(process.env.PACK_EXIT || 0));
|
|
586
|
+
}
|
|
587
|
+
`, { mode: 0o755 });
|
|
588
|
+
const target = project(["npm", "openclaw-native"], {
|
|
589
|
+
identity: { name: "core", version: "1.2.3", description: "Example" },
|
|
590
|
+
});
|
|
591
|
+
// npmName is the publication mapping, not the Tool Factory package's name.
|
|
592
|
+
target.tool.npm = { scope: "@example" };
|
|
593
|
+
const command = packageSteps(target).find((step) => step.name === "OpenClaw plugin tarball")?.run;
|
|
594
|
+
if (!command)
|
|
595
|
+
throw new Error("OpenClaw packaging step is missing");
|
|
596
|
+
for (const code of [0, 7]) {
|
|
597
|
+
const result = spawnSync("sh", ["-c", command], {
|
|
598
|
+
cwd: root,
|
|
599
|
+
env: {
|
|
600
|
+
...process.env,
|
|
601
|
+
PATH: `${join(root, "bin")}:${process.env.PATH}`,
|
|
602
|
+
PACK_EXIT: String(code),
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
expect(result.status, result.stderr.toString()).toBe(code);
|
|
606
|
+
expect(JSON.parse(readFileSync(join(root, "packed.json"), "utf8")).dependencies).toEqual({
|
|
607
|
+
"@example/core": "1.2.3",
|
|
608
|
+
});
|
|
609
|
+
expect(JSON.parse(readFileSync(join(root, "hosts/openclaw/package.json"), "utf8"))).toEqual(manifest);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
549
612
|
it("packages one asset per selected distribution surface into dist/release", () => {
|
|
550
613
|
const target = project([
|
|
551
614
|
"npm",
|
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.2
|
|
14
|
+
.version("0.4.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.2
|
|
33
|
+
const server = new McpServer({ name: "toolfactory", version: "0.4.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.2
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -120,6 +120,19 @@ describe("typescript binding", () => {
|
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it("supports a host-vault live gate without making credentials globally required config", () => {
|
|
124
|
+
const base = project(["cli"]);
|
|
125
|
+
base.tool.tests.live = { credentials: ["LIVE_VAULT_READY"], ci: false };
|
|
126
|
+
base.operations = [{ name: "read", inputSchema: { type: "object" }, requires: [] }];
|
|
127
|
+
const live = liveTest(base)[0];
|
|
128
|
+
if (live?.kind !== "region") throw new Error("expected a region file");
|
|
129
|
+
expect(live.regions[0]?.content).toContain('const CREDENTIALS = ["LIVE_VAULT_READY"];');
|
|
130
|
+
expect(base.tool.config?.required).toBeUndefined();
|
|
131
|
+
expect(
|
|
132
|
+
ToolConfigSchema.safeParse({ ...base.tool, tests: { live: { credentials: [] } } }).success,
|
|
133
|
+
).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
123
136
|
it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
|
|
124
137
|
const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
|
|
125
138
|
const mcp = text(files, "src/toolfactory/mcp.ts");
|