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
@@ -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 { 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";
@@ -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: nextSteps(built.project, agentConfig),
323
+ nextSteps: openclaw ? [...steps, openclaw] : steps,
297
324
  };
298
325
  if (options.repo) {
299
326
  const project = loadProject(root);
@@ -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
- // The live tier's own environment, and only when the project has a live tier: a `tool.json`
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"];
@@ -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
- const paths = new Set<string>();
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({ examples: z.record(z.string(), jsonObject).default({}) })
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(),
@@ -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
- `(cd ${OPENCLAW_HOST_DIR} && npm pkg set dependencies.toolfactory=$v)`,
313
- `npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR}`,
314
- `mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json`,
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
 
@@ -637,7 +637,13 @@ function setupTail(project: Project): string {
637
637
  " exit 1",
638
638
  "fi",
639
639
  ]
640
- : ["npm install --no-audit --no-fund"];
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: ["echo"] });
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(OPENCLAW_SCAFFOLD.devDependencies);
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: ["echo"],
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: "echo", hasToolResult: false },
263
- response: { toolCalls: [{ name: "echo", arguments: { text: "hello" } }] },
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.name),
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.name)},
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.name))});
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.name;
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.name))});
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.name} and the tool's result reaches the reply`)}, async () => {
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.name}. Reply with only the exact text returned by that tool.`)},
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.name)});
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.name}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`,
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,
@@ -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(
@@ -272,7 +272,7 @@ export const surface: Surface = {
272
272
  format: "json",
273
273
  patch: {
274
274
  compilerOptions: {
275
- module: "Node16",
275
+ module: "NodeNext",
276
276
  target: "ES2022",
277
277
  lib: ["ES2022"],
278
278
  types: ["node", "mocha"],
@@ -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.3.0",
157
- "lucide-react": "^1.46.0",
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 = Object.keys(properties).filter((key) =>
205
- isSensitive(properties[key] as Record<string, unknown>),
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[] =
@@ -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.4");
16
+ .version("0.4.2");
17
17
 
18
18
  for (const op of operations) {
19
19
  if (!serves(op, "cli")) continue;
@@ -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.4" });
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(