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/src/commands.test.ts
CHANGED
|
@@ -251,3 +251,37 @@ describe("unpublish", () => {
|
|
|
251
251
|
expect(result.steps.some((step) => step.run.includes("npm deprecate probe@'*'"))).toBe(true);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
describe("registerOpenClawProject", () => {
|
|
256
|
+
it("reports the exact register call for a tool scaffolded under the shared registry (dry-run)", () => {
|
|
257
|
+
const registry = join(mkdtempSync(join(tmpdir(), "tf-home-")), ".openclaw", "projects");
|
|
258
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT = registry;
|
|
259
|
+
const root = join(registry, "probe");
|
|
260
|
+
const result = commands.init({
|
|
261
|
+
root,
|
|
262
|
+
name: "probe",
|
|
263
|
+
binding: "typescript",
|
|
264
|
+
setup: false,
|
|
265
|
+
git: false,
|
|
266
|
+
dryRun: true,
|
|
267
|
+
});
|
|
268
|
+
const line = result.nextSteps.find((step) => step.startsWith("OpenClaw:"));
|
|
269
|
+
expect(line).toBe(
|
|
270
|
+
"OpenClaw: run `openclaw gateway call projects.register --params " +
|
|
271
|
+
JSON.stringify({ path: root }) +
|
|
272
|
+
"` to add it to the shared project registry.",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("says nothing outside the shared registry", () => {
|
|
277
|
+
const result = commands.init({
|
|
278
|
+
root: join(tmpdir(), "tf-plain"),
|
|
279
|
+
name: "probe",
|
|
280
|
+
binding: "typescript",
|
|
281
|
+
setup: false,
|
|
282
|
+
git: false,
|
|
283
|
+
dryRun: true,
|
|
284
|
+
});
|
|
285
|
+
expect(result.nextSteps.some((step) => step.startsWith("OpenClaw:"))).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
package/src/commands.ts
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";
|
|
@@ -163,6 +164,29 @@ function agentSetup(root: string): InitResult["agentConfig"] {
|
|
|
163
164
|
return { setup: result.status === 0, harnesses };
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The shared OpenClaw project registry (owner decision 2026-09-19, `docs/adoption.md`): a tool
|
|
169
|
+
* scaffolded under `~/.openclaw/projects/<id>` gets its project row at `init` time, best-effort
|
|
170
|
+
* the way `.agents/setup` is — no reachable gateway (or CLI) still leaves a scaffolded project,
|
|
171
|
+
* and the next step carries the exact call to run. Session creation stays with the calling agent.
|
|
172
|
+
* TOOLFACTORY_OPENCLAW_PROJECTS_ROOT overrides the registry root (deployments, tests).
|
|
173
|
+
*/
|
|
174
|
+
function registerOpenClawProject(root: string, dryRun: boolean): string | undefined {
|
|
175
|
+
const registryRoot =
|
|
176
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT ?? join(homedir(), ".openclaw", "projects");
|
|
177
|
+
const beneath = relative(registryRoot, root);
|
|
178
|
+
if (!beneath || beneath.startsWith("..") || resolve(beneath) === beneath) return undefined;
|
|
179
|
+
const args = ["gateway", "call", "projects.register", "--params", JSON.stringify({ path: root })];
|
|
180
|
+
if (dryRun)
|
|
181
|
+
return (
|
|
182
|
+
"OpenClaw: run `openclaw " + args.join(" ") + "` to add it to the shared project registry."
|
|
183
|
+
);
|
|
184
|
+
const registered = spawnSync("openclaw", args, { timeout: 60_000 });
|
|
185
|
+
return registered.status === 0
|
|
186
|
+
? "OpenClaw: registered in the shared project registry; bind one visible dashboard session to it for ongoing work."
|
|
187
|
+
: "OpenClaw: registration did not succeed — run the `openclaw gateway call projects.register` step once the gateway is reachable.";
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
function nextSteps(project: Project, agentConfig: InitResult["agentConfig"]): string[] {
|
|
167
191
|
const cli = toolfactoryCli(project);
|
|
168
192
|
const ops =
|
|
@@ -290,10 +314,13 @@ export function init(options: InitOptions): InitResult {
|
|
|
290
314
|
git(root, [...commitIdentity(root), "commit", "-q", "--no-verify", "-m", "toolfactory init"]);
|
|
291
315
|
}
|
|
292
316
|
}
|
|
317
|
+
const steps = nextSteps(built.project, agentConfig);
|
|
318
|
+
// Last, because registration wants the committed repository on record, not a bare scaffold.
|
|
319
|
+
const openclaw = registerOpenClawProject(root, options.dryRun === true);
|
|
293
320
|
const result: InitResult = {
|
|
294
321
|
written,
|
|
295
322
|
agentConfig,
|
|
296
|
-
nextSteps:
|
|
323
|
+
nextSteps: openclaw ? [...steps, openclaw] : steps,
|
|
297
324
|
};
|
|
298
325
|
if (options.repo) {
|
|
299
326
|
const project = loadProject(root);
|
package/src/hosts/github.ts
CHANGED
|
@@ -87,13 +87,12 @@ export function bootstrapRepo(project: Project, options: BootstrapOptions = {}):
|
|
|
87
87
|
const manual = (options.manual ?? []).filter((step) => !step.startsWith("npm:"));
|
|
88
88
|
const envFile = options.envFile ?? join(project.root, ".env");
|
|
89
89
|
const values = existsSync(envFile) ? parseEnv(readFileSync(envFile, "utf8")) : {};
|
|
90
|
-
const keys = liveCredentials(project);
|
|
90
|
+
const keys = project.tool.tests.live?.ci === false ? [] : liveCredentials(project);
|
|
91
91
|
const commands: string[] = [];
|
|
92
92
|
const written: string[] = [];
|
|
93
93
|
const missing: string[] = [];
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
// config key that is both required and sensitive is the whole trigger (§6 T4).
|
|
95
|
+
// A local-vault-only live tier does not need a GitHub environment or copied account secrets.
|
|
97
96
|
if (keys.length) {
|
|
98
97
|
const ids = reviewers.map((login) => {
|
|
99
98
|
const args = ["api", `users/${login}`, "--jq", ".id"];
|
package/src/hosts/openclaw.ts
CHANGED
|
@@ -29,7 +29,8 @@ const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
|
|
|
29
29
|
*/
|
|
30
30
|
function overridden(project: Project): Set<string> {
|
|
31
31
|
const openclaw = project.tool.openclaw;
|
|
32
|
-
|
|
32
|
+
// The host development dependency is deliberately pinned to the recorded scaffold release.
|
|
33
|
+
const paths = new Set<string>([".devDependencies.openclaw"]);
|
|
33
34
|
for (const section of ["dependencies", "peerDependencies", "devDependencies"] as const) {
|
|
34
35
|
for (const key of Object.keys(openclaw?.[section] ?? {})) paths.add(`.${section}.${key}`);
|
|
35
36
|
}
|
|
@@ -78,8 +79,7 @@ export function scaffoldDrift(project: Project): string[] {
|
|
|
78
79
|
// The probe must run the exact openclaw this project pins (author override or the
|
|
79
80
|
// scaffold default) - never whatever happens to be on PATH, which made the drift verdict
|
|
80
81
|
// environment-dependent: the scaffold openclawVersion is the running CLI own version.
|
|
81
|
-
const pin =
|
|
82
|
-
project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.devDependencies.openclaw;
|
|
82
|
+
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
83
83
|
const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
|
|
84
84
|
try {
|
|
85
85
|
const probe = existsSync(pinned)
|
package/src/model.ts
CHANGED
|
@@ -131,7 +131,26 @@ export const ToolConfigSchema = z
|
|
|
131
131
|
"JSON Schema 2020-12 object for the tool's configuration; mark secrets with x-toolfactory.sensitive.",
|
|
132
132
|
),
|
|
133
133
|
tests: z
|
|
134
|
-
.object({
|
|
134
|
+
.object({
|
|
135
|
+
examples: z.record(z.string(), jsonObject).default({}),
|
|
136
|
+
live: z
|
|
137
|
+
.object({
|
|
138
|
+
credentials: z
|
|
139
|
+
.array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/))
|
|
140
|
+
.min(1)
|
|
141
|
+
.optional()
|
|
142
|
+
.describe(
|
|
143
|
+
"Environment gates for live tests when auth is supplied outside tool config, such as a host vault. Names only, never values.",
|
|
144
|
+
),
|
|
145
|
+
ci: z
|
|
146
|
+
.boolean()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe(
|
|
149
|
+
"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.",
|
|
150
|
+
),
|
|
151
|
+
})
|
|
152
|
+
.optional(),
|
|
153
|
+
})
|
|
135
154
|
.default({ examples: {} })
|
|
136
155
|
.describe("Example arguments per operation, used by the generated surface-smoke tests."),
|
|
137
156
|
npm: z.object({ scope: z.string().optional() }).optional(),
|
package/src/project/gate.ts
CHANGED
|
@@ -307,11 +307,10 @@ export function packageSteps(project: Project, options: { system?: boolean } = {
|
|
|
307
307
|
run: [
|
|
308
308
|
`npm --prefix ${OPENCLAW_HOST_DIR} install`,
|
|
309
309
|
`npm --prefix ${OPENCLAW_HOST_DIR} run build`,
|
|
310
|
-
`v=$(node -p "require('./package.json').version")`,
|
|
311
310
|
`cp ${OPENCLAW_HOST_DIR}/package.json ${OPENCLAW_HOST_DIR}/package.json.prepack`,
|
|
312
|
-
`(
|
|
313
|
-
|
|
314
|
-
|
|
311
|
+
`(trap 'mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json' EXIT; ` +
|
|
312
|
+
`(cd ${OPENCLAW_HOST_DIR} && npm pkg set 'dependencies.${npmName(project)}=${project.identity.version}') && ` +
|
|
313
|
+
`npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR})`,
|
|
315
314
|
].join(" && "),
|
|
316
315
|
});
|
|
317
316
|
}
|
|
@@ -126,6 +126,7 @@ describe("agents", () => {
|
|
|
126
126
|
expect(setup).not.toContain("# Project setup goes here");
|
|
127
127
|
// Owner ask #11: a commit cannot leave a generated file stale, with or without CI.
|
|
128
128
|
expect(setup).toContain("npx toolfactory check >/dev/null");
|
|
129
|
+
expect(setup).toContain("if [ -f package-lock.json ]; then\n npm ci --no-audit --no-fund");
|
|
129
130
|
expect(setup).toContain("npm install --no-audit --no-fund");
|
|
130
131
|
});
|
|
131
132
|
|
package/src/surfaces/agents.ts
CHANGED
|
@@ -637,7 +637,13 @@ function setupTail(project: Project): string {
|
|
|
637
637
|
" exit 1",
|
|
638
638
|
"fi",
|
|
639
639
|
]
|
|
640
|
-
: [
|
|
640
|
+
: [
|
|
641
|
+
"if [ -f package-lock.json ]; then",
|
|
642
|
+
" npm ci --no-audit --no-fund",
|
|
643
|
+
"else",
|
|
644
|
+
" npm install --no-audit --no-fund",
|
|
645
|
+
"fi",
|
|
646
|
+
];
|
|
641
647
|
return [
|
|
642
648
|
"",
|
|
643
649
|
"# The pre-commit hook installed above converges agent config; toolfactory's drift gate goes in",
|
|
@@ -122,7 +122,7 @@ describe("openclaw-native", () => {
|
|
|
122
122
|
it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
|
|
123
123
|
const files = emitted(project());
|
|
124
124
|
const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
|
|
125
|
-
expect(manifest.contracts).toEqual({ tools: ["
|
|
125
|
+
expect(manifest.contracts).toEqual({ tools: ["hello_echo"] });
|
|
126
126
|
expect(manifest.activation).toEqual({ onStartup: true });
|
|
127
127
|
expect(surface.verdict?.(shoot, project())).toEqual({
|
|
128
128
|
kind: "excluded",
|
|
@@ -133,7 +133,10 @@ describe("openclaw-native", () => {
|
|
|
133
133
|
expect(files[`${HOST_DIR}/README.md`]).toContain("`shoot` — requires browser");
|
|
134
134
|
|
|
135
135
|
const pkg = JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}");
|
|
136
|
-
expect(pkg.devDependencies).toMatchObject(
|
|
136
|
+
expect(pkg.devDependencies).toMatchObject({
|
|
137
|
+
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
138
|
+
openclaw: OPENCLAW_SCAFFOLD.openclawVersion,
|
|
139
|
+
});
|
|
137
140
|
expect(pkg.openclaw).toEqual({
|
|
138
141
|
extensions: ["./dist/index.js"],
|
|
139
142
|
compat: { pluginApi: OPENCLAW_SCAFFOLD.pluginApi },
|
|
@@ -169,7 +172,7 @@ describe("openclaw-native", () => {
|
|
|
169
172
|
expect(index).toContain("api.registerControlUiDescriptor(descriptor)");
|
|
170
173
|
// Neither registrar is a manifest contract key; both are what the inspector expects to see.
|
|
171
174
|
expect(JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}").contracts).toEqual({
|
|
172
|
-
tools: ["
|
|
175
|
+
tools: ["hello_echo"],
|
|
173
176
|
});
|
|
174
177
|
expect(
|
|
175
178
|
JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}").pluginInspector.plugin.expect
|
|
@@ -259,8 +262,8 @@ describe("openclaw-native", () => {
|
|
|
259
262
|
// when the tool's own result carries a name its output schema promises; leg 3 catches the rest.
|
|
260
263
|
expect(fixtures.fixtures).toEqual([
|
|
261
264
|
{
|
|
262
|
-
match: { toolName: "
|
|
263
|
-
response: { toolCalls: [{ name: "
|
|
265
|
+
match: { toolName: "hello_echo", hasToolResult: false },
|
|
266
|
+
response: { toolCalls: [{ name: "hello_echo", arguments: { text: "hello" } }] },
|
|
264
267
|
},
|
|
265
268
|
{
|
|
266
269
|
match: { hasToolResult: true, toolResultContains: "echoed" },
|
|
@@ -97,6 +97,16 @@ export function openclawVersion(project: Project): string {
|
|
|
97
97
|
return project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The gateway has one flat tool namespace, so the plugin's tool names are the tool id prefix
|
|
102
|
+
* plus the operation name with dots flattened: unprefixed names collide across plugins (two
|
|
103
|
+
* `web`s) and dotted ones fold onto core tools (`sessions.list` → `sessions_list`). The
|
|
104
|
+
* kernel, CLI and MCP server keep the bare operation names; only this host renames.
|
|
105
|
+
*/
|
|
106
|
+
export function toolName(project: Project, operation: Operation): string {
|
|
107
|
+
return `${project.identity.name}_${operation.name.replace(/\./g, "_")}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
export const OPENCLAW_ADDITIONS = {
|
|
101
111
|
/** Without it `tsc` emits a `dist/` from source that failed type-checking. */
|
|
102
112
|
compilerOptions: { noEmitOnError: true },
|
|
@@ -278,6 +288,7 @@ function packageJson(
|
|
|
278
288
|
dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
|
|
279
289
|
devDependencies: {
|
|
280
290
|
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
291
|
+
openclaw: openclawVersion(project),
|
|
281
292
|
...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
|
|
282
293
|
...extra?.devDependencies,
|
|
283
294
|
},
|
|
@@ -317,7 +328,7 @@ function pluginManifest(project: Project, operations: Operation[]): Record<strin
|
|
|
317
328
|
activation: activation(project),
|
|
318
329
|
skills: shipsSkills(project) ? ["./skills"] : undefined,
|
|
319
330
|
contracts: {
|
|
320
|
-
tools: operations.map((operation) => operation
|
|
331
|
+
tools: operations.map((operation) => toolName(project, operation)),
|
|
321
332
|
...Object.fromEntries(declared),
|
|
322
333
|
},
|
|
323
334
|
}) as Record<string, unknown>;
|
|
@@ -421,7 +432,7 @@ const execFileAsync = promisify(execFile);
|
|
|
421
432
|
${pythonKernel(project)}`;
|
|
422
433
|
const tools = operations.map(
|
|
423
434
|
(operation) => ` tool({
|
|
424
|
-
name: ${JSON.stringify(operation
|
|
435
|
+
name: ${JSON.stringify(toolName(project, operation))},
|
|
425
436
|
description: ${JSON.stringify(operation.description ?? operation.name)},
|
|
426
437
|
parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
|
|
427
438
|
${executeBody(project, operation)}
|
|
@@ -582,7 +593,7 @@ import entry from "./index.js";
|
|
|
582
593
|
|
|
583
594
|
describe(${JSON.stringify(project.identity.name)}, () => {
|
|
584
595
|
it("declares tool metadata", () => {
|
|
585
|
-
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
596
|
+
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
586
597
|
});
|
|
587
598
|
${registrationTest}${web}});
|
|
588
599
|
`;
|
|
@@ -628,8 +639,8 @@ function e2eCase(project: Project, operations: Operation[]): E2eCase | undefined
|
|
|
628
639
|
* would answer OK even for a tool that threw, because the host reports that in a tool message too.
|
|
629
640
|
* An operation that promises no output names has nothing to gate on, so it gets no failure leg.
|
|
630
641
|
*/
|
|
631
|
-
function e2eFixtures(kase: E2eCase): string {
|
|
632
|
-
const name = kase.operation
|
|
642
|
+
function e2eFixtures(project: Project, kase: E2eCase): string {
|
|
643
|
+
const name = toolName(project, kase.operation);
|
|
633
644
|
return json({
|
|
634
645
|
fixtures: [
|
|
635
646
|
{
|
|
@@ -775,19 +786,19 @@ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
|
|
|
775
786
|
it("registers its tools at runtime", async () => {
|
|
776
787
|
const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
|
|
777
788
|
expect(inspected.plugin.status).toBe("loaded");
|
|
778
|
-
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
789
|
+
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
779
790
|
});
|
|
780
791
|
|
|
781
|
-
it(${JSON.stringify(`the model calls ${kase.operation
|
|
792
|
+
it(${JSON.stringify(`the model calls ${toolName(project, kase.operation)} and the tool's result reaches the reply`)}, async () => {
|
|
782
793
|
const turn = JSON.parse(
|
|
783
794
|
await oc([
|
|
784
795
|
"agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
|
|
785
796
|
"--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
|
|
786
797
|
"--message",
|
|
787
|
-
${JSON.stringify(`Call the tool named ${kase.operation
|
|
798
|
+
${JSON.stringify(`Call the tool named ${toolName(project, kase.operation)}. Reply with only the exact text returned by that tool.`)},
|
|
788
799
|
]),
|
|
789
800
|
);
|
|
790
|
-
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation
|
|
801
|
+
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(toolName(project, kase.operation))});
|
|
791
802
|
expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
|
|
792
803
|
}, 180_000);
|
|
793
804
|
});
|
|
@@ -877,7 +888,7 @@ function readme(project: Project, operations: Operation[], kase?: E2eCase): stri
|
|
|
877
888
|
"`npm run test:e2e` runs one real OpenClaw agent turn against a scripted OpenAI-compatible",
|
|
878
889
|
"model (`@copilotkit/aimock`, the same package OpenClaw's own QA lane uses). `e2e/fixtures.json`",
|
|
879
890
|
"is projected from `dev.toolfactory/ops.json` and `tool.json`'s `tests.examples`: the model asks",
|
|
880
|
-
`for \`${kase.operation
|
|
891
|
+
`for \`${toolName(project, kase.operation)}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`,
|
|
881
892
|
"result comes back carrying what its output schema promises.",
|
|
882
893
|
"",
|
|
883
894
|
);
|
|
@@ -1008,7 +1019,7 @@ export const surface: Surface = {
|
|
|
1008
1019
|
{
|
|
1009
1020
|
kind: "file" as const,
|
|
1010
1021
|
path: `${HOST_DIR}/e2e/fixtures.json`,
|
|
1011
|
-
content: e2eFixtures(kase),
|
|
1022
|
+
content: e2eFixtures(project, kase),
|
|
1012
1023
|
},
|
|
1013
1024
|
{
|
|
1014
1025
|
kind: "file" as const,
|
package/src/surfaces/shared.ts
CHANGED
|
@@ -118,6 +118,7 @@ export function dataDirEnvName(project: Project): string {
|
|
|
118
118
|
* A tool with none has nothing to gate a live tier on, so no live test and no `live` CI job.
|
|
119
119
|
*/
|
|
120
120
|
export function liveCredentials(project: Project): string[] {
|
|
121
|
+
if (project.tool.tests.live?.credentials) return project.tool.tests.live.credentials;
|
|
121
122
|
const properties = configProperties(project);
|
|
122
123
|
const required = new Set(requiredConfig(project));
|
|
123
124
|
return Object.keys(properties).filter(
|
package/src/surfaces/web.ts
CHANGED
|
@@ -153,8 +153,8 @@ export default defineConfig({
|
|
|
153
153
|
"@fontsource-variable/geist": "^5.3.0",
|
|
154
154
|
"@tailwindcss/vite": "^4.3.3",
|
|
155
155
|
"class-variance-authority": "^0.7.1",
|
|
156
|
-
cn: "^0.
|
|
157
|
-
"lucide-react": "^1.
|
|
156
|
+
cn: "^0.4.0",
|
|
157
|
+
"lucide-react": "^1.48.0",
|
|
158
158
|
react: "^19.2.8",
|
|
159
159
|
"react-dom": "^19.2.8",
|
|
160
160
|
shadcn: "^4.21.0",
|
|
@@ -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";
|
|
@@ -457,6 +457,20 @@ describe("workflows", () => {
|
|
|
457
457
|
expect(composeHermes.services.openclaw).toBeUndefined();
|
|
458
458
|
});
|
|
459
459
|
|
|
460
|
+
it("supports explicit live credentials without making runtime configuration mandatory", () => {
|
|
461
|
+
const target = project(["cli"]);
|
|
462
|
+
target.tool.config = undefined;
|
|
463
|
+
target.tool.tests.live = { credentials: ["LIVE_ACCOUNT_READY"] };
|
|
464
|
+
const ci = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
465
|
+
expect(ci.jobs.live.env).toEqual({ LIVE_ACCOUNT_READY: "${{ secrets.LIVE_ACCOUNT_READY }}" });
|
|
466
|
+
expect(ci.jobs.live.environment).toBe("live-tests");
|
|
467
|
+
|
|
468
|
+
target.tool.tests.live.ci = false;
|
|
469
|
+
const localOnly = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
470
|
+
expect(localOnly.jobs.live).toBeUndefined();
|
|
471
|
+
expect(localOnly.on.workflow_dispatch).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
460
474
|
it("adds the T4 live job, and workflow_dispatch, iff a config key is required and sensitive", () => {
|
|
461
475
|
const ci = yamlParse(emitted(project(["cli"]))[".github/workflows/ci.yml"]);
|
|
462
476
|
expect(ci.on.workflow_dispatch).toEqual({});
|
|
@@ -626,6 +640,11 @@ describe("bootstrap-repo", () => {
|
|
|
626
640
|
);
|
|
627
641
|
expect(release.manual.join(" ")).toContain("NPM_TRUSTED_PUBLISHER");
|
|
628
642
|
expect(release.manual.filter((step) => step.startsWith("npm:"))).toHaveLength(1);
|
|
643
|
+
|
|
644
|
+
noCredential.tool.tests.live = { credentials: ["LOCAL_VAULT_READY"], ci: false };
|
|
645
|
+
const localOnly = bootstrapRepo(noCredential, { dryRun: true });
|
|
646
|
+
expect(localOnly.secrets).toEqual([]);
|
|
647
|
+
expect(localOnly.commands.some((command) => command.includes("live-tests"))).toBe(false);
|
|
629
648
|
});
|
|
630
649
|
});
|
|
631
650
|
|
|
@@ -662,6 +681,61 @@ describe("gate", () => {
|
|
|
662
681
|
]);
|
|
663
682
|
});
|
|
664
683
|
|
|
684
|
+
it("packs the actual core dependency and restores the host manifest after success or failure", () => {
|
|
685
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-plugin-pack-"));
|
|
686
|
+
mkdirSync(join(root, "hosts/openclaw"), { recursive: true });
|
|
687
|
+
mkdirSync(join(root, "bin"));
|
|
688
|
+
const manifest = {
|
|
689
|
+
name: "openclaw-plugin-example",
|
|
690
|
+
dependencies: { "@example/core": "file:../.." },
|
|
691
|
+
};
|
|
692
|
+
writeFileSync(join(root, "hosts/openclaw/package.json"), JSON.stringify(manifest));
|
|
693
|
+
writeFileSync(
|
|
694
|
+
join(root, "bin/npm"),
|
|
695
|
+
`#!/usr/bin/env node
|
|
696
|
+
const fs = require('node:fs');
|
|
697
|
+
const path = require('node:path');
|
|
698
|
+
const args = process.argv.slice(2);
|
|
699
|
+
if (args[0] === 'pkg') {
|
|
700
|
+
const value = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
701
|
+
const [key, version] = args[2].slice('dependencies.'.length).split('=');
|
|
702
|
+
value.dependencies[key] = version;
|
|
703
|
+
fs.writeFileSync('package.json', JSON.stringify(value));
|
|
704
|
+
} else if (args[0] === 'pack') {
|
|
705
|
+
fs.copyFileSync('hosts/openclaw/package.json', 'packed.json');
|
|
706
|
+
process.exit(Number(process.env.PACK_EXIT || 0));
|
|
707
|
+
}
|
|
708
|
+
`,
|
|
709
|
+
{ mode: 0o755 },
|
|
710
|
+
);
|
|
711
|
+
const target = project(["npm", "openclaw-native"], {
|
|
712
|
+
identity: { name: "core", version: "1.2.3", description: "Example" },
|
|
713
|
+
});
|
|
714
|
+
// npmName is the publication mapping, not the Tool Factory package's name.
|
|
715
|
+
target.tool.npm = { scope: "@example" };
|
|
716
|
+
const command = packageSteps(target).find(
|
|
717
|
+
(step) => step.name === "OpenClaw plugin tarball",
|
|
718
|
+
)?.run;
|
|
719
|
+
if (!command) throw new Error("OpenClaw packaging step is missing");
|
|
720
|
+
for (const code of [0, 7]) {
|
|
721
|
+
const result = spawnSync("sh", ["-c", command], {
|
|
722
|
+
cwd: root,
|
|
723
|
+
env: {
|
|
724
|
+
...process.env,
|
|
725
|
+
PATH: `${join(root, "bin")}:${process.env.PATH}`,
|
|
726
|
+
PACK_EXIT: String(code),
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
expect(result.status, result.stderr.toString()).toBe(code);
|
|
730
|
+
expect(JSON.parse(readFileSync(join(root, "packed.json"), "utf8")).dependencies).toEqual({
|
|
731
|
+
"@example/core": "1.2.3",
|
|
732
|
+
});
|
|
733
|
+
expect(JSON.parse(readFileSync(join(root, "hosts/openclaw/package.json"), "utf8"))).toEqual(
|
|
734
|
+
manifest,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
665
739
|
it("packages one asset per selected distribution surface into dist/release", () => {
|
|
666
740
|
const target = project([
|
|
667
741
|
"npm",
|
|
@@ -199,11 +199,17 @@ function actionStep(step: GateStep, matrix: boolean): Step {
|
|
|
199
199
|
* (an environment gates deployments, not the checkout an untrusted PR ships).
|
|
200
200
|
*/
|
|
201
201
|
function liveJob(project: Project): Record<string, unknown> | undefined {
|
|
202
|
+
if (project.tool.tests.live?.ci === false) return undefined;
|
|
202
203
|
if (liveCredentials(project).length === 0) return undefined;
|
|
203
204
|
const properties = configProperties(project);
|
|
204
|
-
const secrets =
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
const secrets = [
|
|
206
|
+
...new Set([
|
|
207
|
+
...Object.keys(properties).filter((key) =>
|
|
208
|
+
isSensitive(properties[key] as Record<string, unknown>),
|
|
209
|
+
),
|
|
210
|
+
...liveCredentials(project),
|
|
211
|
+
]),
|
|
212
|
+
];
|
|
207
213
|
const pmName = project.packageManager ?? "npm";
|
|
208
214
|
const pm = PACKAGE_MANAGER_COMMANDS[pmName];
|
|
209
215
|
const steps: Step[] =
|
package/src/toolfactory/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ const operations = [...authored, web];
|
|
|
13
13
|
const program = new Command()
|
|
14
14
|
.name("toolfactory")
|
|
15
15
|
.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.")
|
|
16
|
-
.version("0.2
|
|
16
|
+
.version("0.4.2");
|
|
17
17
|
|
|
18
18
|
for (const op of operations) {
|
|
19
19
|
if (!serves(op, "cli")) continue;
|
package/src/toolfactory/mcp.ts
CHANGED
|
@@ -33,7 +33,7 @@ const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
|
|
|
33
33
|
|
|
34
34
|
/** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
|
|
35
35
|
function createServer(): McpServer {
|
|
36
|
-
const server = new McpServer({ name: "toolfactory", version: "0.2
|
|
36
|
+
const server = new McpServer({ name: "toolfactory", version: "0.4.2" });
|
|
37
37
|
for (const op of operations) {
|
|
38
38
|
if (!introspecting && !serves(op, "mcp")) continue;
|
|
39
39
|
server.registerTool(
|