toolfactory 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.3.1.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.
@@ -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 },
@@ -280,7 +289,7 @@ function pluginManifest(project, operations) {
280
289
  activation: activation(project),
281
290
  skills: shipsSkills(project) ? ["./skills"] : undefined,
282
291
  contracts: {
283
- tools: operations.map((operation) => operation.name),
292
+ tools: operations.map((operation) => toolName(project, operation)),
284
293
  ...Object.fromEntries(declared),
285
294
  },
286
295
  });
@@ -379,7 +388,7 @@ const execFileAsync = promisify(execFile);
379
388
 
380
389
  ${pythonKernel(project)}`;
381
390
  const tools = operations.map((operation) => ` tool({
382
- name: ${JSON.stringify(operation.name)},
391
+ name: ${JSON.stringify(toolName(project, operation))},
383
392
  description: ${JSON.stringify(operation.description ?? operation.name)},
384
393
  parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
385
394
  ${executeBody(project, operation)}
@@ -533,7 +542,7 @@ import entry from "./index.js";
533
542
 
534
543
  describe(${JSON.stringify(project.identity.name)}, () => {
535
544
  it("declares tool metadata", () => {
536
- expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
545
+ expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
537
546
  });
538
547
  ${registrationTest}${web}});
539
548
  `;
@@ -569,8 +578,8 @@ function e2eCase(project, operations) {
569
578
  * would answer OK even for a tool that threw, because the host reports that in a tool message too.
570
579
  * An operation that promises no output names has nothing to gate on, so it gets no failure leg.
571
580
  */
572
- function e2eFixtures(kase) {
573
- const name = kase.operation.name;
581
+ function e2eFixtures(project, kase) {
582
+ const name = toolName(project, kase.operation);
574
583
  return json({
575
584
  fixtures: [
576
585
  {
@@ -711,19 +720,19 @@ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
711
720
  it("registers its tools at runtime", async () => {
712
721
  const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
713
722
  expect(inspected.plugin.status).toBe("loaded");
714
- expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
723
+ expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
715
724
  });
716
725
 
717
- it(${JSON.stringify(`the model calls ${kase.operation.name} and the tool's result reaches the reply`)}, async () => {
726
+ it(${JSON.stringify(`the model calls ${toolName(project, kase.operation)} and the tool's result reaches the reply`)}, async () => {
718
727
  const turn = JSON.parse(
719
728
  await oc([
720
729
  "agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
721
730
  "--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
722
731
  "--message",
723
- ${JSON.stringify(`Call the tool named ${kase.operation.name}. Reply with only the exact text returned by that tool.`)},
732
+ ${JSON.stringify(`Call the tool named ${toolName(project, kase.operation)}. Reply with only the exact text returned by that tool.`)},
724
733
  ]),
725
734
  );
726
- expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation.name)});
735
+ expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(toolName(project, kase.operation))});
727
736
  expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
728
737
  }, 180_000);
729
738
  });
@@ -803,7 +812,7 @@ function readme(project, operations, kase) {
803
812
  "",
804
813
  ];
805
814
  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.", "");
815
+ 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
816
  }
808
817
  if (declared.length) {
809
818
  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 +899,7 @@ export const surface = {
890
899
  {
891
900
  kind: "file",
892
901
  path: `${HOST_DIR}/e2e/fixtures.json`,
893
- content: e2eFixtures(kase),
902
+ content: e2eFixtures(project, kase),
894
903
  },
895
904
  {
896
905
  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",
@@ -152,7 +152,7 @@ describe("openclaw-native", () => {
152
152
  expect(index).toContain("api.registerControlUiDescriptor(descriptor)");
153
153
  // Neither registrar is a manifest contract key; both are what the inspector expects to see.
154
154
  expect(JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}").contracts).toEqual({
155
- tools: ["echo"],
155
+ tools: ["hello_echo"],
156
156
  });
157
157
  expect(JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}").pluginInspector.plugin.expect
158
158
  .registrations).toEqual(["registerHttpRoute", "registerControlUiDescriptor"]);
@@ -227,8 +227,8 @@ describe("openclaw-native", () => {
227
227
  // when the tool's own result carries a name its output schema promises; leg 3 catches the rest.
228
228
  expect(fixtures.fixtures).toEqual([
229
229
  {
230
- match: { toolName: "echo", hasToolResult: false },
231
- response: { toolCalls: [{ name: "echo", arguments: { text: "hello" } }] },
230
+ match: { toolName: "hello_echo", hasToolResult: false },
231
+ response: { toolCalls: [{ name: "hello_echo", arguments: { text: "hello" } }] },
232
232
  },
233
233
  {
234
234
  match: { hasToolResult: true, toolResultContains: "echoed" },
@@ -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.3.1");
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.3.1" });
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.3.1",
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": {
@@ -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",
@@ -169,7 +169,7 @@ describe("openclaw-native", () => {
169
169
  expect(index).toContain("api.registerControlUiDescriptor(descriptor)");
170
170
  // Neither registrar is a manifest contract key; both are what the inspector expects to see.
171
171
  expect(JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}").contracts).toEqual({
172
- tools: ["echo"],
172
+ tools: ["hello_echo"],
173
173
  });
174
174
  expect(
175
175
  JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}").pluginInspector.plugin.expect
@@ -259,8 +259,8 @@ describe("openclaw-native", () => {
259
259
  // when the tool's own result carries a name its output schema promises; leg 3 catches the rest.
260
260
  expect(fixtures.fixtures).toEqual([
261
261
  {
262
- match: { toolName: "echo", hasToolResult: false },
263
- response: { toolCalls: [{ name: "echo", arguments: { text: "hello" } }] },
262
+ match: { toolName: "hello_echo", hasToolResult: false },
263
+ response: { toolCalls: [{ name: "hello_echo", arguments: { text: "hello" } }] },
264
264
  },
265
265
  {
266
266
  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 },
@@ -317,7 +327,7 @@ function pluginManifest(project: Project, operations: Operation[]): Record<strin
317
327
  activation: activation(project),
318
328
  skills: shipsSkills(project) ? ["./skills"] : undefined,
319
329
  contracts: {
320
- tools: operations.map((operation) => operation.name),
330
+ tools: operations.map((operation) => toolName(project, operation)),
321
331
  ...Object.fromEntries(declared),
322
332
  },
323
333
  }) as Record<string, unknown>;
@@ -421,7 +431,7 @@ const execFileAsync = promisify(execFile);
421
431
  ${pythonKernel(project)}`;
422
432
  const tools = operations.map(
423
433
  (operation) => ` tool({
424
- name: ${JSON.stringify(operation.name)},
434
+ name: ${JSON.stringify(toolName(project, operation))},
425
435
  description: ${JSON.stringify(operation.description ?? operation.name)},
426
436
  parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
427
437
  ${executeBody(project, operation)}
@@ -582,7 +592,7 @@ import entry from "./index.js";
582
592
 
583
593
  describe(${JSON.stringify(project.identity.name)}, () => {
584
594
  it("declares tool metadata", () => {
585
- expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
595
+ expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
586
596
  });
587
597
  ${registrationTest}${web}});
588
598
  `;
@@ -628,8 +638,8 @@ function e2eCase(project: Project, operations: Operation[]): E2eCase | undefined
628
638
  * would answer OK even for a tool that threw, because the host reports that in a tool message too.
629
639
  * An operation that promises no output names has nothing to gate on, so it gets no failure leg.
630
640
  */
631
- function e2eFixtures(kase: E2eCase): string {
632
- const name = kase.operation.name;
641
+ function e2eFixtures(project: Project, kase: E2eCase): string {
642
+ const name = toolName(project, kase.operation);
633
643
  return json({
634
644
  fixtures: [
635
645
  {
@@ -775,19 +785,19 @@ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
775
785
  it("registers its tools at runtime", async () => {
776
786
  const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
777
787
  expect(inspected.plugin.status).toBe("loaded");
778
- expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
788
+ expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
779
789
  });
780
790
 
781
- it(${JSON.stringify(`the model calls ${kase.operation.name} and the tool's result reaches the reply`)}, async () => {
791
+ it(${JSON.stringify(`the model calls ${toolName(project, kase.operation)} and the tool's result reaches the reply`)}, async () => {
782
792
  const turn = JSON.parse(
783
793
  await oc([
784
794
  "agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
785
795
  "--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
786
796
  "--message",
787
- ${JSON.stringify(`Call the tool named ${kase.operation.name}. Reply with only the exact text returned by that tool.`)},
797
+ ${JSON.stringify(`Call the tool named ${toolName(project, kase.operation)}. Reply with only the exact text returned by that tool.`)},
788
798
  ]),
789
799
  );
790
- expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation.name)});
800
+ expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(toolName(project, kase.operation))});
791
801
  expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
792
802
  }, 180_000);
793
803
  });
@@ -877,7 +887,7 @@ function readme(project: Project, operations: Operation[], kase?: E2eCase): stri
877
887
  "`npm run test:e2e` runs one real OpenClaw agent turn against a scripted OpenAI-compatible",
878
888
  "model (`@copilotkit/aimock`, the same package OpenClaw's own QA lane uses). `e2e/fixtures.json`",
879
889
  "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`,
890
+ `for \`${toolName(project, kase.operation)}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`,
881
891
  "result comes back carrying what its output schema promises.",
882
892
  "",
883
893
  );
@@ -1008,7 +1018,7 @@ export const surface: Surface = {
1008
1018
  {
1009
1019
  kind: "file" as const,
1010
1020
  path: `${HOST_DIR}/e2e/fixtures.json`,
1011
- content: e2eFixtures(kase),
1021
+ content: e2eFixtures(project, kase),
1012
1022
  },
1013
1023
  {
1014
1024
  kind: "file" as const,
@@ -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.3.1");
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.3.1" });
37
37
  for (const op of operations) {
38
38
  if (!introspecting && !serves(op, "mcp")) continue;
39
39
  server.registerTool(