toolfactory 0.3.1 → 0.4.3

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 (44) 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.js +1 -0
  14. package/dist/surfaces/openclaw-native.test.js +4 -1
  15. package/dist/surfaces/shared.js +2 -0
  16. package/dist/surfaces/vscode-extension.js +1 -1
  17. package/dist/surfaces/web.d.ts +3 -3
  18. package/dist/surfaces/web.js +3 -3
  19. package/dist/surfaces/workflows.js +8 -1
  20. package/dist/surfaces/workflows.test.js +64 -1
  21. package/dist/toolfactory/cli.js +1 -1
  22. package/dist/toolfactory/mcp.js +1 -1
  23. package/package.json +1 -1
  24. package/src/bindings/typescript.test.ts +13 -0
  25. package/src/commands.test.ts +34 -0
  26. package/src/commands.ts +29 -2
  27. package/src/hosts/github.ts +2 -3
  28. package/src/hosts/openclaw.ts +3 -3
  29. package/src/model.ts +20 -1
  30. package/src/project/gate.ts +3 -4
  31. package/src/surfaces/agents.test.ts +1 -0
  32. package/src/surfaces/agents.ts +7 -1
  33. package/src/surfaces/openclaw-native.test.ts +4 -1
  34. package/src/surfaces/openclaw-native.ts +1 -0
  35. package/src/surfaces/shared.ts +1 -0
  36. package/src/surfaces/vscode-extension.ts +1 -1
  37. package/src/surfaces/web.ts +3 -3
  38. package/src/surfaces/workflows.test.ts +75 -1
  39. package/src/surfaces/workflows.ts +9 -3
  40. package/src/toolfactory/cli.ts +1 -1
  41. package/src/toolfactory/mcp.ts +1 -1
  42. package/web/dist/assets/index-DHsze9w4.js +10 -0
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/assets/index-D95aMjZ6.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.3.1.tgz`
25
+ - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.4.3.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", () => {
@@ -251,6 +251,7 @@ function packageJson(project, operations, kase) {
251
251
  dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
252
252
  devDependencies: {
253
253
  ...OPENCLAW_SCAFFOLD.devDependencies,
254
+ openclaw: openclawVersion(project),
254
255
  ...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
255
256
  ...extra?.devDependencies,
256
257
  },
@@ -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 },
@@ -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,11 +36,11 @@ 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.50.0";
41
41
  readonly react: "^19.2.8";
42
42
  readonly "react-dom": "^19.2.8";
43
- readonly shadcn: "^4.21.0";
43
+ readonly shadcn: "^4.21.1";
44
44
  readonly tailwindcss: "^4.3.3";
45
45
  readonly "tw-animate-css": "^1.4.0";
46
46
  };
@@ -149,11 +149,11 @@ 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.50.0",
154
154
  react: "^19.2.8",
155
155
  "react-dom": "^19.2.8",
156
- shadcn: "^4.21.0",
156
+ shadcn: "^4.21.1",
157
157
  tailwindcss: "^4.3.3",
158
158
  "tw-animate-css": "^1.4.0",
159
159
  },
@@ -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.3.1");
14
+ .version("0.4.3");
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.3.1" });
33
+ const server = new McpServer({ name: "toolfactory", version: "0.4.3" });
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.3.1",
3
+ "version": "0.4.3",
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");
@@ -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)