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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/bindings/typescript.test.js +11 -0
  3. package/dist/commands.js +26 -2
  4. package/dist/commands.test.js +30 -0
  5. package/dist/hosts/github.js +2 -3
  6. package/dist/hosts/openclaw.js +3 -2
  7. package/dist/model.d.ts +4 -0
  8. package/dist/model.js +16 -1
  9. package/dist/project/gate.js +3 -4
  10. package/dist/project/history.d.ts +4 -0
  11. package/dist/surfaces/agents.js +7 -1
  12. package/dist/surfaces/agents.test.js +1 -0
  13. package/dist/surfaces/openclaw-native.d.ts +8 -1
  14. package/dist/surfaces/openclaw-native.js +21 -11
  15. package/dist/surfaces/openclaw-native.test.js +8 -5
  16. package/dist/surfaces/shared.js +2 -0
  17. package/dist/surfaces/vscode-extension.js +1 -1
  18. package/dist/surfaces/web.d.ts +2 -2
  19. package/dist/surfaces/web.js +2 -2
  20. package/dist/surfaces/workflows.js +8 -1
  21. package/dist/surfaces/workflows.test.js +64 -1
  22. package/dist/toolfactory/cli.js +1 -1
  23. package/dist/toolfactory/mcp.js +1 -1
  24. package/package.json +1 -1
  25. package/src/bindings/typescript.test.ts +13 -0
  26. package/src/commands.test.ts +34 -0
  27. package/src/commands.ts +29 -2
  28. package/src/hosts/github.ts +2 -3
  29. package/src/hosts/openclaw.ts +3 -3
  30. package/src/model.ts +20 -1
  31. package/src/project/gate.ts +3 -4
  32. package/src/surfaces/agents.test.ts +1 -0
  33. package/src/surfaces/agents.ts +7 -1
  34. package/src/surfaces/openclaw-native.test.ts +8 -5
  35. package/src/surfaces/openclaw-native.ts +22 -11
  36. package/src/surfaces/shared.ts +1 -0
  37. package/src/surfaces/vscode-extension.ts +1 -1
  38. package/src/surfaces/web.ts +2 -2
  39. package/src/surfaces/workflows.test.ts +75 -1
  40. package/src/surfaces/workflows.ts +9 -3
  41. package/src/toolfactory/cli.ts +1 -1
  42. package/src/toolfactory/mcp.ts +1 -1
  43. package/web/dist/assets/index-AwHZabeT.js +10 -0
  44. package/web/dist/index.html +1 -1
  45. 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.4.tgz`
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 { dirname, join, resolve } from "node:path";
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: nextSteps(built.project, agentConfig),
224
+ nextSteps: openclaw ? [...steps, openclaw] : steps,
201
225
  };
202
226
  if (options.repo) {
203
227
  const project = loadProject(root);
@@ -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
+ });
@@ -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
- // The live tier's own environment, and only when the project has a live tier: a `tool.json`
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"];
@@ -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
- const paths = new Set();
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.devDependencies.openclaw;
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({ examples: z.record(z.string(), jsonObject).default({}) })
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(),
@@ -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
- `(cd ${OPENCLAW_HOST_DIR} && npm pkg set dependencies.toolfactory=$v)`,
264
- `npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR}`,
265
- `mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json`,
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;
@@ -519,7 +519,13 @@ function setupTail(project) {
519
519
  " exit 1",
520
520
  "fi",
521
521
  ]
522
- : ["npm install --no-audit --no-fund"];
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.name),
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.name)},
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.name))});
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.name;
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.name))});
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.name} and the tool's result reaches the reply`)}, async () => {
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.name}. Reply with only the exact text returned by that tool.`)},
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.name)});
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.name}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`, "result comes back carrying what its output schema promises.", "");
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: ["echo"] });
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(OPENCLAW_SCAFFOLD.devDependencies);
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: ["echo"],
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: "echo", hasToolResult: false },
231
- response: { toolCalls: [{ name: "echo", arguments: { text: "hello" } }] },
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" },
@@ -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]));
@@ -239,7 +239,7 @@ export const surface = {
239
239
  format: "json",
240
240
  patch: {
241
241
  compilerOptions: {
242
- module: "Node16",
242
+ module: "NodeNext",
243
243
  target: "ES2022",
244
244
  lib: ["ES2022"],
245
245
  types: ["node", "mocha"],
@@ -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.3.0";
40
- readonly "lucide-react": "^1.46.0";
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";
@@ -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.3.0",
153
- "lucide-react": "^1.46.0",
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 = Object.keys(properties).filter((key) => isSensitive(properties[key]));
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",
@@ -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.4");
14
+ .version("0.4.2");
15
15
  for (const op of operations) {
16
16
  if (!serves(op, "cli"))
17
17
  continue;
@@ -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.4" });
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.4",
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");