toolfactory 0.1.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.
Files changed (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,956 @@
1
+ /**
2
+ * OpenClaw native plugin — the §4.4 escape hatch. The package under `hosts/openclaw/` is the
3
+ * scaffold `openclaw plugins init <id> --type tool` writes, with the tool list projected from
4
+ * the operations OpenClaw can actually run.
5
+ *
6
+ * A plugin that contributes something other than tools (a realtime-voice provider, a speech
7
+ * provider) is the same package: `defineToolPlugin` keeps the upstream build/validate gates,
8
+ * and the host registrations it declares in `tool.json` (`openclaw.registers`) drive the
9
+ * manifest contracts, the plugin inspector's expectations and the generated test. The one
10
+ * thing toolfactory cannot write is the registration body, so `src/index.ts` is a region file:
11
+ * toolfactory owns the entry, the author owns the tail that wires the registrations.
12
+ *
13
+ * §8 C2 ("mirror scaffolds by execution, not transcription"): `plan()` is pure and therefore
14
+ * has to carry the scaffold's constants, so `validate()` runs the real generator and fails when
15
+ * any of them drifted. Every pinned value lives in OPENCLAW_SCAFFOLD below and nowhere else.
16
+ */
17
+ import { execArgv } from "node:process";
18
+ import { KERNEL_DIR } from "../bindings/typescript.js";
19
+ import { DRIFT_ENTRY } from "../hosts/openclaw.js";
20
+ import { projectName } from "../identity/name.js";
21
+ import { isPortable } from "../model.js";
22
+ import { includedOperations } from "../report/coverage.js";
23
+ import { compact, configProperties, dataDirEnvName, envName, has, isSensitive, json, npmName, rootEnvName, } from "./shared.js";
24
+ /** Transcribed from `openclaw plugins init --type tool`; `validate()` proves them still current. */
25
+ export const OPENCLAW_SCAFFOLD = {
26
+ /** The OpenClaw release the scaffold was taken from (`package.json openclaw.build`). */
27
+ openclawVersion: "2026.9.2",
28
+ /** `peerDependencies.openclaw` and `openclaw.compat.pluginApi`. */
29
+ pluginApi: ">=2026.5.17",
30
+ private: true,
31
+ scripts: {
32
+ build: "tsc -p tsconfig.json",
33
+ "plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
34
+ "plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
35
+ test: "vitest run --config ./vitest.config.ts",
36
+ },
37
+ files: ["dist", "openclaw.plugin.json", "README.md"],
38
+ dependencies: { typebox: "^1.1.38" },
39
+ devDependencies: { openclaw: "latest", typescript: "^5.9.0", vitest: "^3.2.0" },
40
+ tsconfig: {
41
+ compilerOptions: {
42
+ target: "ES2022",
43
+ module: "NodeNext",
44
+ moduleResolution: "NodeNext",
45
+ strict: true,
46
+ declaration: true,
47
+ outDir: "dist",
48
+ skipLibCheck: true,
49
+ },
50
+ include: ["src/**/*.ts"],
51
+ },
52
+ vitestConfig: `import { defineConfig } from "vitest/config";
53
+
54
+ export default defineConfig({
55
+ test: {
56
+ environment: "node",
57
+ include: ["src/**/*.test.ts"],
58
+ },
59
+ });
60
+ `,
61
+ /** Entry module, relative to the plugin root, as `openclaw.extensions` records it. */
62
+ entry: "./dist/index.js",
63
+ sdkModule: "openclaw/plugin-sdk/tool-plugin",
64
+ /** `resolveStateDir()`: the directory OpenClaw keeps its own state in. */
65
+ statePathsModule: "openclaw/plugin-sdk/state-paths",
66
+ /** Where `OpenClawPluginApi` and every registrar's parameter type come from. */
67
+ entryModule: "openclaw/plugin-sdk/plugin-entry",
68
+ /** `pluginInspector` in package.json, as `--type provider` writes it. */
69
+ inspector: { version: 1, priority: "high", seams: ["plugin-runtime"], sourceRoot: "." },
70
+ };
71
+ /**
72
+ * toolfactory's additions over the upstream scaffold. Each is a key `openclaw plugins init` does
73
+ * not write, which is why the drift check (`src/hosts/openclaw.ts`) requires every scaffold key to
74
+ * survive into the projection but allows extra ones: upstream stays the source of truth for
75
+ * everything it does write.
76
+ */
77
+ export const OPENCLAW_ADDITIONS = {
78
+ /** Without it `tsc` emits a `dist/` from source that failed type-checking. */
79
+ compilerOptions: { noEmitOnError: true },
80
+ /** The scripted-model end-to-end lane; OpenClaw's own QA lane drives OpenClaw through aimock. */
81
+ aimock: "^1.39.0",
82
+ e2eScript: "npm run build && vitest run --config ./vitest.e2e.config.ts",
83
+ /**
84
+ * OpenClaw's install-time safety scan rejects a `node_modules` symlink whose target is outside
85
+ * the install root (`src/plugins/install-security-scan.runtime.ts`), which is exactly what npm
86
+ * makes of the `file:../..` core dependency. `install-links` materialises it as a real directory.
87
+ */
88
+ npmrc: `# OpenClaw's install-time safety scan refuses a node_modules symlink pointing outside the
89
+ # install root, which is what npm makes of a file: dependency. Materialise it instead.
90
+ install-links=true
91
+ `,
92
+ /** The scripted model the e2e fixtures answer as; any id works, nothing resolves it upstream. */
93
+ model: "openai/gpt-5.6-luna",
94
+ };
95
+ export const HOST_DIR = "hosts/openclaw";
96
+ const HEADER = "// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
97
+ /** The region of `src/index.ts` toolfactory owns; the tail below the end marker is the author's. */
98
+ export const ENTRY_BEGIN = "// tf:entry";
99
+ export const ENTRY_END = "// /tf:entry";
100
+ /** `titleFromId` from OpenClaw's scaffold generator. */
101
+ export function displayName(id) {
102
+ return projectName.display(id);
103
+ }
104
+ /** Host registrations the plugin declares beyond tools. */
105
+ export function registrations(project) {
106
+ return project.tool.openclaw?.registers ?? [];
107
+ }
108
+ /**
109
+ * The gateway route and the Control UI tab the plugin adds when the tool also has a `web`
110
+ * surface. Only for the in-process binding: the route hands requests to the core package's own
111
+ * exported handler, so `<prefix>/mcp` is answered by the same operations without a second
112
+ * listener, and `<prefix>/` is the built page. A Python tool's plugin is an out-of-process
113
+ * shim with no such handler to mount, so it gets the README's `mcp --http --open` line instead.
114
+ */
115
+ function servesWeb(project, operations) {
116
+ return has(project, "web") && project.tool.binding === "typescript" && operations.length > 0;
117
+ }
118
+ /** The prefix the route claims and the tab points at; one constant, three uses. */
119
+ function webPath(project) {
120
+ return `/plugins/${project.identity.name}/web`;
121
+ }
122
+ /** `activation` for both the entry and the manifest; OpenClaw's own default is startup. */
123
+ export function activation(project) {
124
+ return project.tool.openclaw?.activation ?? { onStartup: true };
125
+ }
126
+ /**
127
+ * OpenClaw stores `normalizeJsonSchema(schema)` in the plugin manifest, so emit schemas that are
128
+ * already in that shape and `openclaw plugins build --check` stays a byte-for-byte agreement.
129
+ */
130
+ export function normalizeSchema(value) {
131
+ if (Array.isArray(value))
132
+ return value.map(normalizeSchema);
133
+ if (!value || typeof value !== "object")
134
+ return value;
135
+ const record = { ...value };
136
+ delete record.$schema;
137
+ for (const [key, nested] of Object.entries(record))
138
+ record[key] = normalizeSchema(nested);
139
+ const propertyNames = record.propertyNames;
140
+ if (propertyNames && typeof propertyNames === "object" && propertyNames.type === "string") {
141
+ delete record.propertyNames;
142
+ }
143
+ if (Array.isArray(record.required) && record.required.length === 0)
144
+ delete record.required;
145
+ return record;
146
+ }
147
+ /** A JSON value as a TypeScript literal, re-indented to sit at `indent` columns. */
148
+ function literal(value, indent) {
149
+ return JSON.stringify(value, null, 2).replaceAll("\n", `\n${" ".repeat(indent)}`);
150
+ }
151
+ /** `EMPTY_TOOL_PLUGIN_CONFIG_SCHEMA` from the SDK: what a plugin with no config schema reports. */
152
+ const EMPTY_CONFIG_SCHEMA = { type: "object", properties: {}, additionalProperties: false };
153
+ function hasConfig(project) {
154
+ return Boolean(project.tool.config) && Object.keys(configProperties(project)).length > 0;
155
+ }
156
+ /** The plugin's `configSchema`: the tool's own config schema, or OpenClaw's empty default. */
157
+ export function configSchema(project) {
158
+ return hasConfig(project)
159
+ ? normalizeSchema(project.tool.config)
160
+ : EMPTY_CONFIG_SCHEMA;
161
+ }
162
+ function pluginDescription(project) {
163
+ return (project.identity.description ?? `Add ${displayName(project.identity.name)} tools to OpenClaw.`);
164
+ }
165
+ function verdict(operation, project) {
166
+ if (!isPortable(operation))
167
+ return { kind: "excluded", reason: "excluded:implement-in-hosts" };
168
+ return project.tool.binding === "typescript"
169
+ ? { kind: "native" }
170
+ : { kind: "degraded", reason: "degraded:out-of-process" };
171
+ }
172
+ /** The API range the plugin declares; the author's `pluginApi` wins over the scaffold's. */
173
+ function pluginApi(project) {
174
+ return project.tool.openclaw?.pluginApi ?? OPENCLAW_SCAFFOLD.pluginApi;
175
+ }
176
+ /** The manifest contract keys the plugin fills: `tools` when it has any, plus every declared one. */
177
+ function contractKeys(project, operations) {
178
+ const declared = registrations(project).map((registration) => registration.contract);
179
+ return [...new Set([...(operations.length ? ["tools"] : []), ...declared])];
180
+ }
181
+ /**
182
+ * The inspector fixture, in the shape `--type provider` writes: it re-derives the registrations
183
+ * from the source and the contracts from the manifest, so a declaration nobody wired fails
184
+ * `plugin-inspector inspect --check` by name.
185
+ */
186
+ /**
187
+ * Every registrar the entry calls: the ones `tool.json` declares, plus — generated, not
188
+ * declared — the web tab's route and descriptor. Neither of those is a manifest contract key,
189
+ * so `contracts` is unchanged; the inspector's static scan is what has to agree.
190
+ */
191
+ function expectedRegistrations(project, operations) {
192
+ return [
193
+ ...registrations(project).map((registration) => registration.api),
194
+ ...(servesWeb(project, operations) ? ["registerHttpRoute", "registerControlUiDescriptor"] : []),
195
+ ];
196
+ }
197
+ function pluginInspector(project, operations) {
198
+ return {
199
+ version: OPENCLAW_SCAFFOLD.inspector.version,
200
+ plugin: {
201
+ id: project.identity.name,
202
+ priority: OPENCLAW_SCAFFOLD.inspector.priority,
203
+ seams: OPENCLAW_SCAFFOLD.inspector.seams,
204
+ sourceRoot: OPENCLAW_SCAFFOLD.inspector.sourceRoot,
205
+ expect: {
206
+ registrations: expectedRegistrations(project, operations),
207
+ manifestContracts: contractKeys(project, operations),
208
+ },
209
+ },
210
+ };
211
+ }
212
+ function packageJson(project, operations, kase) {
213
+ // The core is imported in-process only when an operation reaches this surface.
214
+ const core = project.tool.binding === "typescript" && operations.length
215
+ ? { [npmName(project)]: "file:../.." }
216
+ : undefined;
217
+ const extra = project.tool.openclaw;
218
+ const e2e = kase ? { "test:e2e": OPENCLAW_ADDITIONS.e2eScript } : undefined;
219
+ return json(compact({
220
+ name: projectName.openclawPackage(project.identity.name),
221
+ version: project.identity.version ?? "0.0.0",
222
+ description: pluginDescription(project),
223
+ type: "module",
224
+ private: OPENCLAW_SCAFFOLD.private,
225
+ scripts: { ...OPENCLAW_SCAFFOLD.scripts, ...e2e },
226
+ files: OPENCLAW_SCAFFOLD.files,
227
+ peerDependencies: { openclaw: pluginApi(project), ...extra?.peerDependencies },
228
+ dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
229
+ devDependencies: {
230
+ ...OPENCLAW_SCAFFOLD.devDependencies,
231
+ ...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
232
+ ...extra?.devDependencies,
233
+ },
234
+ openclaw: {
235
+ extensions: [OPENCLAW_SCAFFOLD.entry],
236
+ compat: { pluginApi: pluginApi(project) },
237
+ build: { openclawVersion: OPENCLAW_SCAFFOLD.openclawVersion },
238
+ },
239
+ pluginInspector: expectedRegistrations(project, operations).length
240
+ ? pluginInspector(project, operations)
241
+ : undefined,
242
+ }));
243
+ }
244
+ /**
245
+ * `openclaw plugins build` regenerates this file; emitting it keeps `--check` green from a clean
246
+ * tree. The builder owns `contracts.tools` and preserves every other contract key, so a declared
247
+ * registration's ids survive a rebuild. `uiHints` is manifest-owned too: it is how OpenClaw's
248
+ * config UI learns which keys to mask, from the same `x-toolfactory.sensitive` declaration every
249
+ * other surface reads.
250
+ */
251
+ function pluginManifest(project, operations) {
252
+ const sensitive = Object.entries(configProperties(project))
253
+ .filter(([, property]) => isSensitive(property))
254
+ .map(([key]) => [key, { sensitive: true }]);
255
+ const declared = registrations(project).map((registration) => [
256
+ registration.contract,
257
+ registration.ids,
258
+ ]);
259
+ return json(compact({
260
+ id: project.identity.name,
261
+ name: displayName(project.identity.name),
262
+ description: pluginDescription(project),
263
+ version: project.identity.version ?? "0.0.0",
264
+ configSchema: configSchema(project),
265
+ uiHints: sensitive.length ? Object.fromEntries(sensitive) : undefined,
266
+ activation: activation(project),
267
+ contracts: {
268
+ tools: operations.map((operation) => operation.name),
269
+ ...Object.fromEntries(declared),
270
+ },
271
+ }));
272
+ }
273
+ function executeBody(project, operation) {
274
+ if (project.tool.binding === "typescript") {
275
+ return `execute: async (params, config) =>
276
+ operation(${JSON.stringify(operation.name)}).handler(params as never, {
277
+ config: config as Record<string, string | undefined>,
278
+ dataDir: dataDir(),
279
+ }),`;
280
+ }
281
+ return `execute: async (params, config) => kernel(${JSON.stringify(operation.name)}, params, config),`;
282
+ }
283
+ function pythonKernel(project) {
284
+ const module = `${projectName.pythonPackage(project.identity.name)}.toolfactory.cli`;
285
+ return `const repoRoot =
286
+ process.env[${JSON.stringify(rootEnvName(project))}] ?? fileURLToPath(new URL("../../../", import.meta.url));
287
+
288
+ /**
289
+ * Shim variant: the core is Python, so every call crosses a process boundary and config
290
+ * travels as environment, exactly as it does for the CLI and the MCP server.
291
+ */
292
+ async function kernel(name: string, params: unknown, config: unknown): Promise<unknown> {
293
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
294
+ for (const [key, value] of Object.entries((config ?? {}) as Record<string, unknown>)) {
295
+ if (value !== undefined && value !== null) env[key.toUpperCase().replace(/[^A-Z0-9]/g, "_")] = String(value);
296
+ }
297
+ env[${JSON.stringify(dataDirEnvName(project))}] = dataDir();
298
+ const { stdout } = await execFileAsync(
299
+ "uv",
300
+ ["run", "python", "-m", ${JSON.stringify(module)}, name, "--json", JSON.stringify(params)],
301
+ { cwd: repoRoot, env },
302
+ );
303
+ return JSON.parse(stdout) as unknown;
304
+ }
305
+ `;
306
+ }
307
+ /** `Parameters<OpenClawPluginApi["<api>"]>[0]`: the typed route to a registrar's argument. */
308
+ function registrarType(api) {
309
+ return `Parameters<OpenClawPluginApi[${JSON.stringify(api)}]>[0]`;
310
+ }
311
+ /** The generated region of `src/index.ts`: imports, the entry, and how to reach it from the tail. */
312
+ function indexRegion(project, operations) {
313
+ const typescript = project.tool.binding === "typescript";
314
+ const shim = !typescript && operations.length > 0;
315
+ const imports = [
316
+ ...(shim
317
+ ? [
318
+ `import { execFile } from "node:child_process";`,
319
+ `import { fileURLToPath } from "node:url";`,
320
+ `import { promisify } from "node:util";`,
321
+ ]
322
+ : []),
323
+ ...(operations.length ? [`import { join } from "node:path";`] : []),
324
+ `import { defineToolPlugin } from ${JSON.stringify(OPENCLAW_SCAFFOLD.sdkModule)};`,
325
+ ...(operations.length
326
+ ? [`import { resolveStateDir } from ${JSON.stringify(OPENCLAW_SCAFFOLD.statePathsModule)};`]
327
+ : []),
328
+ ...(operations.length || hasConfig(project) ? [`import { Type } from "typebox";`] : []),
329
+ ...(typescript && operations.length
330
+ ? [`import { operations } from ${JSON.stringify(`${npmName(project)}/dist/ops.js`)};`]
331
+ : []),
332
+ ...(servesWeb(project, operations)
333
+ ? [
334
+ `import { handler } from ${JSON.stringify(`${npmName(project)}/dist/${KERNEL_DIR.replace(/^src\//, "")}/mcp.js`)};`,
335
+ `import type { OpenClawPluginApi } from ${JSON.stringify(OPENCLAW_SCAFFOLD.entryModule)};`,
336
+ ]
337
+ : []),
338
+ ];
339
+ // The host's own state directory is what the tool keeps its data under; the kernel's
340
+ // `<N>_DATA_DIR` fallback only has to answer when no host supplies one.
341
+ const dataDir = !operations.length
342
+ ? ""
343
+ : `
344
+ /**
345
+ * Where this tool may keep its own state: OpenClaw's state directory, one folder per plugin.
346
+ * Lazy, so that importing this module resolves no path and touches no disk — every host loader
347
+ * and \`plugin-inspector check --runtime\` import the entry long before any tool runs.
348
+ */
349
+ const dataDir = () => join(resolveStateDir(), "plugin-data", ${JSON.stringify(project.identity.name)});
350
+ `;
351
+ const preamble = !operations.length
352
+ ? ""
353
+ : typescript
354
+ ? `
355
+ /** Fail loudly if the core package and this plugin ever disagree about the operation list. */
356
+ function operation(name: string) {
357
+ const found = operations.find((candidate) => candidate.name === name);
358
+ if (!found) throw new Error(\`operation \${name} is missing from the core package\`);
359
+ return found;
360
+ }
361
+ `
362
+ : `
363
+ const execFileAsync = promisify(execFile);
364
+
365
+ ${pythonKernel(project)}`;
366
+ const tools = operations.map((operation) => ` tool({
367
+ name: ${JSON.stringify(operation.name)},
368
+ description: ${JSON.stringify(operation.description ?? operation.name)},
369
+ parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
370
+ ${executeBody(project, operation)}
371
+ }),`);
372
+ const configLine = !hasConfig(project)
373
+ ? ""
374
+ : ` configSchema: Type.Unsafe<Record<string, unknown>>(${literal(configSchema(project), 2)}),\n`;
375
+ const declared = registrations(project).flatMap((registration) => [
376
+ `// - \`api.${registration.api}\` → contracts.${registration.contract}: ${registration.ids.join(", ")}`,
377
+ `// argument type: ${registrarType(registration.api)}`,
378
+ ]);
379
+ const trailer = declared.length
380
+ ? [
381
+ "",
382
+ "// Declared in dev.toolfactory/tool.json (openclaw.registers), asserted by index.test.ts and by",
383
+ "// `plugin-inspector inspect --check`; wire each one below the end marker.",
384
+ ...declared,
385
+ ]
386
+ : [];
387
+ const web = !servesWeb(project, operations)
388
+ ? ""
389
+ : `
390
+ /**
391
+ * The web app, as a tab in the Control UI: one prefix route the gateway authenticates, handled
392
+ * by the core package's own exported router — so \`<prefix>/mcp\` is answered in-process by the
393
+ * same operations and everything else is the built page — and one descriptor that makes the
394
+ * dashboard render it, instead of a URL an operator has to go and find.
395
+ */
396
+ const WEB_PATH = ${JSON.stringify(webPath(project))};
397
+
398
+ const registerTools = entry.register;
399
+ entry.register = (api: OpenClawPluginApi) => {
400
+ registerTools(api);
401
+ api.registerHttpRoute({
402
+ path: WEB_PATH,
403
+ match: "prefix",
404
+ // A Control UI tab may only point at a gateway-authenticated route, and the operator's
405
+ // gateway session is the authentication — the page needs no pairing token of its own.
406
+ auth: "gateway",
407
+ // \`env: false\`: the Secrets panel writes the developer's \`.env\` in their checkout, and
408
+ // this plugin runs in the gateway's working directory, not theirs.
409
+ handler: handler({ path: \`\${WEB_PATH}/mcp\`, prefix: WEB_PATH, env: false }),
410
+ });
411
+ const descriptor = {
412
+ id: ${JSON.stringify(`${project.identity.name}-web`)},
413
+ surface: "tab" as const,
414
+ label: ${JSON.stringify(displayName(project.identity.name))},
415
+ path: \`\${WEB_PATH}/\`,
416
+ };
417
+ // \`api.session.controls\` is the current facade and the top-level registrar its deprecated
418
+ // alias; the plugin inspector's mock SDK has only the latter, so take whichever is there.
419
+ const controls = api.session?.controls;
420
+ if (controls) controls.registerControlUiDescriptor(descriptor);
421
+ else api.registerControlUiDescriptor(descriptor);
422
+ };
423
+ `;
424
+ return `
425
+ ${HEADER}${imports.join("\n")}
426
+ ${dataDir}${preamble}
427
+ const entry = defineToolPlugin({
428
+ id: ${JSON.stringify(project.identity.name)},
429
+ name: ${JSON.stringify(displayName(project.identity.name))},
430
+ description: ${JSON.stringify(pluginDescription(project))},
431
+ activation: ${literal(activation(project), 2)},
432
+ ${configLine}${tools.length ? ` tools: (tool) => [\n${tools.join("\n")}\n ],` : " tools: () => [],"}
433
+ });
434
+ ${web}${trailer.join("\n")}
435
+ `;
436
+ }
437
+ /** Written once, when `src/index.ts` does not exist: the region plus the author's tail. */
438
+ function indexTemplate(project) {
439
+ const first = registrations(project)[0];
440
+ const example = !first
441
+ ? []
442
+ : [
443
+ "",
444
+ "// Wire the declared registrations by wrapping the generated entry (commented out so the",
445
+ "// plugin builds before you fill it in):",
446
+ "//",
447
+ `// import type { OpenClawPluginApi } from ${JSON.stringify(OPENCLAW_SCAFFOLD.entryModule)};`,
448
+ "//",
449
+ "// const registerTools = entry.register;",
450
+ "// entry.register = (api: OpenClawPluginApi) => {",
451
+ "// registerTools(api);",
452
+ `// const provider: ${registrarType(first.api)} = { id: ${JSON.stringify(first.ids[0] ?? project.identity.name)}, /* ... */ };`,
453
+ `// api.${first.api}(provider);`,
454
+ "// };",
455
+ ];
456
+ return [`${ENTRY_BEGIN}${ENTRY_END}`, ...example, "", "export default entry;", ""].join("\n");
457
+ }
458
+ /**
459
+ * The generated test proves both halves of the entry: the tool metadata OpenClaw reads from
460
+ * `defineToolPlugin`, and — for every declared registration — that the author's tail actually
461
+ * calls the registrar with the declared ids. A recorder standing in for `OpenClawPluginApi` is
462
+ * how the upstream provider scaffold's own test does it.
463
+ */
464
+ function indexTestTs(project, operations) {
465
+ const declared = registrations(project);
466
+ const registrationTest = !declared.length
467
+ ? ""
468
+ : `
469
+ it("registers what dev.toolfactory/tool.json declares", () => {
470
+ const registered: Record<string, unknown[]> = {};
471
+ const record = (name: string) => (value: unknown) => {
472
+ (registered[name] ??= []).push(value);
473
+ };
474
+ const api = {
475
+ registerTool: record("registerTool"),
476
+ ${declared.map((registration) => ` ${registration.api}: record(${JSON.stringify(registration.api)}),`).join("\n")}
477
+ } as Partial<OpenClawPluginApi>;
478
+
479
+ entry.register(api as OpenClawPluginApi);
480
+
481
+ ${declared
482
+ .map((registration) => ` expect(
483
+ (registered[${JSON.stringify(registration.api)}] ?? []).map((value) => (value as { id: string }).id),
484
+ ).toEqual(${JSON.stringify(registration.ids)});`)
485
+ .join("\n")}
486
+ });
487
+ `;
488
+ const web = !servesWeb(project, operations)
489
+ ? ""
490
+ : `
491
+ it("serves the web app as a Control UI tab", () => {
492
+ const registered: Record<string, unknown[]> = {};
493
+ const record = (name: string) => (value: unknown) => {
494
+ (registered[name] ??= []).push(value);
495
+ };
496
+ const api = {
497
+ registerTool: record("registerTool"),
498
+ registerHttpRoute: record("registerHttpRoute"),
499
+ registerControlUiDescriptor: record("registerControlUiDescriptor"),
500
+ } as Partial<OpenClawPluginApi>;
501
+
502
+ entry.register(api as OpenClawPluginApi);
503
+
504
+ expect(registered.registerHttpRoute).toEqual([
505
+ expect.objectContaining({ path: ${JSON.stringify(webPath(project))}, match: "prefix", auth: "gateway" }),
506
+ ]);
507
+ expect(registered.registerControlUiDescriptor).toEqual([
508
+ expect.objectContaining({ surface: "tab", path: ${JSON.stringify(`${webPath(project)}/`)} }),
509
+ ]);
510
+ });
511
+ `;
512
+ const entryImport = declared.length || servesWeb(project, operations)
513
+ ? `import type { OpenClawPluginApi } from ${JSON.stringify(OPENCLAW_SCAFFOLD.entryModule)};\n`
514
+ : "";
515
+ return `${HEADER}import { getToolPluginMetadata } from ${JSON.stringify(OPENCLAW_SCAFFOLD.sdkModule)};
516
+ ${entryImport}import { describe, expect, it } from "vitest";
517
+ import entry from "./index.js";
518
+
519
+ describe(${JSON.stringify(project.identity.name)}, () => {
520
+ it("declares tool metadata", () => {
521
+ expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
522
+ });
523
+ ${registrationTest}${web}});
524
+ `;
525
+ }
526
+ /**
527
+ * The single operation the end-to-end turn drives: the first one `tool.json` names in
528
+ * `tests.examples`. One turn, not one per operation — the model sees every tool in the same
529
+ * request, so a second `toolName` leg could not discriminate. The declaration is the whole opt-in:
530
+ * a real turn really runs the operation, so which one that is has to be the author's choice and
531
+ * never a guess at whichever needs no arguments.
532
+ */
533
+ function e2eCase(project, operations) {
534
+ const examples = project.tool.tests.examples;
535
+ const operation = operations.find((candidate) => examples[candidate.name]);
536
+ if (!operation)
537
+ return undefined;
538
+ const schema = operation.outputSchema ?? {};
539
+ const required = Array.isArray(schema.required) ? schema.required : [];
540
+ const properties = Object.keys((schema.properties ?? {}));
541
+ const token = envName(project.identity.name);
542
+ return {
543
+ operation,
544
+ args: examples[operation.name] ?? {},
545
+ marker: required[0] ?? properties[0],
546
+ ok: `${token}_OK`,
547
+ fail: `${token}_FAIL`,
548
+ };
549
+ }
550
+ /**
551
+ * Three legs, and the middle one is what makes the assertion mean something: leg 1 emits the tool
552
+ * call with the authored arguments, leg 2 answers OK only when the tool's own result carries a
553
+ * name its output schema promises, leg 3 catches everything else. Drop leg 2's gate and the mock
554
+ * would answer OK even for a tool that threw, because the host reports that in a tool message too.
555
+ * An operation that promises no output names has nothing to gate on, so it gets no failure leg.
556
+ */
557
+ function e2eFixtures(kase) {
558
+ const name = kase.operation.name;
559
+ return json({
560
+ fixtures: [
561
+ {
562
+ match: { toolName: name, hasToolResult: false },
563
+ response: { toolCalls: [{ name, arguments: kase.args }] },
564
+ },
565
+ {
566
+ match: compact({ hasToolResult: true, toolResultContains: kase.marker }),
567
+ response: { content: kase.ok },
568
+ },
569
+ ...(kase.marker
570
+ ? [{ match: { hasToolResult: true }, response: { content: kase.fail } }]
571
+ : []),
572
+ ],
573
+ });
574
+ }
575
+ const E2E_CONFIG = `${HEADER}import { defineConfig } from "vitest/config";
576
+
577
+ export default defineConfig({
578
+ test: {
579
+ environment: "node",
580
+ include: ["e2e/**/*.e2e.test.ts"],
581
+ testTimeout: 180_000,
582
+ hookTimeout: 300_000,
583
+ },
584
+ });
585
+ `;
586
+ /**
587
+ * The credential-free end-to-end lane: a real OpenClaw agent turn, through the real plugin, with
588
+ * `@copilotkit/aimock` standing in for the model. Nothing here is discoverable from the docs, so
589
+ * the three constraints that each cost a failing run are generated as comments.
590
+ */
591
+ function e2eTest(project, operations, kase) {
592
+ const id = project.identity.name;
593
+ const config = {
594
+ plugins: { enabled: true },
595
+ models: {
596
+ mode: "merge",
597
+ providers: {
598
+ openai: {
599
+ baseUrl: "__BASE_URL__",
600
+ apiKey: "sk-aimock-dummy",
601
+ api: "openai-responses",
602
+ agentRuntime: { id: "openclaw" },
603
+ request: { allowPrivateNetwork: true },
604
+ models: [
605
+ {
606
+ id: "gpt-5.6-luna",
607
+ name: "gpt-5.6-luna",
608
+ api: "openai-responses",
609
+ agentRuntime: { id: "openclaw" },
610
+ reasoning: false,
611
+ input: ["text"],
612
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
613
+ contextWindow: 128000,
614
+ contextTokens: 96000,
615
+ maxTokens: 4096,
616
+ },
617
+ ],
618
+ },
619
+ },
620
+ },
621
+ agents: {
622
+ defaults: {
623
+ model: { primary: OPENCLAW_ADDITIONS.model },
624
+ models: {
625
+ [OPENCLAW_ADDITIONS.model]: {
626
+ agentRuntime: { id: "openclaw" },
627
+ params: { transport: "sse", openaiWsWarmup: false },
628
+ },
629
+ },
630
+ },
631
+ },
632
+ };
633
+ // The base URL is only known once aimock has bound a port, so splice the expression back in.
634
+ const configLiteral = literal(config, 2).replace(JSON.stringify("__BASE_URL__"), "`${aimock().url}/v1`");
635
+ return `${HEADER}// One real OpenClaw agent turn against a scripted OpenAI-compatible model — no LLM key. The
636
+ // fixtures in e2e/fixtures.json are projected from dev.toolfactory/ops.json and tool.json's
637
+ // tests.examples; OpenClaw's own QA lane drives OpenClaw through this same aimock package.
638
+ //
639
+ // Three constraints, none of them discoverable, each of which costs a failing run:
640
+ // 1. Every openclaw call must be ASYNC. useAimock serves the model inside this Vitest worker, so
641
+ // execFileSync would block the event loop and the child's POST /v1/responses is never answered.
642
+ // 2. VITEST and every VITEST_* variable must be stripped from the child environment: with them
643
+ // set the published openclaw CLI writes zero bytes and exits 0. OpenClaw does the same
644
+ // sanitisation before spawning its own CLI (src/auto-reply/reply/commands-openclaw-cli.ts).
645
+ // 3. \`plugins install --link\`, never a copy: a plain install copies this package's whole tree,
646
+ // node_modules/openclaw included, into the state directory.
647
+ import { execFile } from "node:child_process";
648
+ import { mkdtempSync, writeFileSync } from "node:fs";
649
+ import { tmpdir } from "node:os";
650
+ import { dirname, join } from "node:path";
651
+ import { fileURLToPath } from "node:url";
652
+ import { promisify } from "node:util";
653
+ import { useAimock } from "@copilotkit/aimock/vitest";
654
+ import { beforeAll, describe, expect, it } from "vitest";
655
+
656
+ const execFileAsync = promisify(execFile);
657
+ const here = dirname(fileURLToPath(import.meta.url));
658
+ const pluginDir = join(here, "..");
659
+ const openclaw = join(pluginDir, "node_modules", "openclaw", "openclaw.mjs");
660
+ const MODEL = ${JSON.stringify(OPENCLAW_ADDITIONS.model)};
661
+
662
+ const aimock = useAimock({ port: 0, fixtures: join(here, "fixtures.json") });
663
+
664
+ let env: NodeJS.ProcessEnv;
665
+
666
+ async function oc(args: string[]): Promise<string> {
667
+ const { stdout } = await execFileAsync(process.execPath, [openclaw, ...args], {
668
+ env,
669
+ encoding: "utf8",
670
+ maxBuffer: 64 * 1024 * 1024,
671
+ });
672
+ return stdout;
673
+ }
674
+
675
+ beforeAll(async () => {
676
+ const home = mkdtempSync(join(tmpdir(), "openclaw-e2e-"));
677
+ const configPath = join(home, "openclaw.json");
678
+ const clean = Object.fromEntries(
679
+ Object.entries(process.env).filter(([key]) => key !== "VITEST" && !key.startsWith("VITEST_")),
680
+ );
681
+ env = {
682
+ ...clean,
683
+ HOME: home,
684
+ OPENCLAW_STATE_DIR: home,
685
+ OPENCLAW_CONFIG_PATH: configPath,
686
+ OPENAI_API_KEY: "sk-aimock-dummy",
687
+ };
688
+ // The shape OpenClaw's own e2e fixtures use: one loopback OpenAI-compatible provider, a dummy
689
+ // key, and allowPrivateNetwork, without which OpenClaw refuses a loopback provider URL.
690
+ const config = ${configLiteral};
691
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
692
+ await oc(["plugins", "install", "--link", pluginDir, "--force", "--accept-capabilities"]);
693
+ }, 300_000);
694
+
695
+ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
696
+ it("registers its tools at runtime", async () => {
697
+ const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
698
+ expect(inspected.plugin.status).toBe("loaded");
699
+ expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation.name))});
700
+ });
701
+
702
+ it(${JSON.stringify(`the model calls ${kase.operation.name} and the tool's result reaches the reply`)}, async () => {
703
+ const turn = JSON.parse(
704
+ await oc([
705
+ "agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
706
+ "--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
707
+ "--message",
708
+ ${JSON.stringify(`Call the tool named ${kase.operation.name}. Reply with only the exact text returned by that tool.`)},
709
+ ]),
710
+ );
711
+ expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation.name)});
712
+ expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
713
+ }, 180_000);
714
+ });
715
+ `;
716
+ }
717
+ /**
718
+ * What a third-party plugin can reach at OpenClaw HEAD. Checked against the clone at
719
+ * openclaw 2026.8.2 / git 0635e09a; every claim names the file it was read from, so the next
720
+ * reader can re-check it instead of trusting this paragraph.
721
+ */
722
+ function boundary() {
723
+ return [
724
+ "## Boundary — what a plugin can reach",
725
+ "",
726
+ "Everything this plugin does goes through `OpenClawPluginApi` (`src/plugins/plugin-api.types.ts`",
727
+ "upstream, re-exported as `openclaw/plugin-sdk/plugin-entry`). Two things a Codex-facing voice",
728
+ "plugin would like are **not** reachable from a third-party plugin at OpenClaw HEAD:",
729
+ "",
730
+ "- **The shared Codex app-server client.** A plugin may observe it —",
731
+ " `api.registerCodexAppServerExtensionFactory` (`src/plugins/plugin-api.types.ts`) delivers",
732
+ " read-only `tool_result` events (`src/plugins/codex-app-server-extension-types.ts`) — but the",
733
+ " client itself (`src/agents/harness/codex-app-server-extensions.ts`,",
734
+ " `src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts`) has no `plugin-sdk`",
735
+ " export in the published package, so a plugin cannot open or drive a Codex session.",
736
+ "- **The native-subagent monitor.** `src/plugins/plugin-api.types.ts` has no subagent seam at all;",
737
+ " the monitor lives in gateway/agent internals (`src/gateway/server-plugin-subagent-runtime.ts`,",
738
+ " `src/agents/subagents/`), none of which is exported.",
739
+ "",
740
+ "Smallest upstream ask that would close both: export the Codex app-server session client as",
741
+ "`openclaw/plugin-sdk/codex-app-server-extensions`, and add one subagent lifecycle subscription",
742
+ "next to `registerCodexAppServerExtensionFactory` in `src/plugins/plugin-api.types.ts`. Until",
743
+ "then this plugin builds, validates, tests and publishes exactly as it does now; only the two",
744
+ "capabilities above have to be driven from OpenClaw itself.",
745
+ "",
746
+ ];
747
+ }
748
+ function readme(project, operations, kase) {
749
+ const excluded = project.operations.filter((operation) => verdict(operation, project).kind === "excluded");
750
+ const shim = project.tool.binding !== "typescript";
751
+ const declared = registrations(project);
752
+ const lines = [
753
+ `# ${displayName(project.identity.name)} — OpenClaw plugin`,
754
+ "",
755
+ `Generated by toolfactory from \`${project.tool.identity}\` and \`dev.toolfactory/ops.json\`.`,
756
+ "The layout is whatever `openclaw plugins init --type tool` writes; the tool list, the manifest",
757
+ "contracts and the entry between the `tf:entry` markers in `src/index.ts` are projected.",
758
+ "",
759
+ "## Build",
760
+ "",
761
+ "```bash",
762
+ "npm install",
763
+ "npm run plugin:build",
764
+ "npm run plugin:validate",
765
+ "npm test",
766
+ ...(kase ? ["npm run test:e2e"] : []),
767
+ "```",
768
+ "",
769
+ "`.npmrc` sets `install-links=true`. OpenClaw's install-time safety scan refuses a",
770
+ "`node_modules` symlink whose target lies outside the install root, which is what npm makes of a",
771
+ "`file:` dependency such as the core package in `package.json`; with `install-links` npm",
772
+ "materialises it as a real directory and `openclaw plugins install --link` succeeds.",
773
+ "",
774
+ "## Install into a live OpenClaw, and remove it again",
775
+ "",
776
+ "```bash",
777
+ `openclaw plugins install --link ${HOST_DIR} --force --accept-capabilities`,
778
+ `openclaw plugins inspect ${project.identity.name} --runtime --json`,
779
+ `openclaw plugins disable ${project.identity.name}`,
780
+ "```",
781
+ "",
782
+ "`--link` registers this checkout in place rather than copying it (a copy takes",
783
+ "`node_modules/openclaw` with it), so the plugin's origin is a config entry and",
784
+ "`plugins disable` is what removes it again, leaving the checkout untouched.",
785
+ `\`openclaw plugins uninstall ${project.identity.name} --keep-files\` is the equivalent for a`,
786
+ "plugin installed as a package; against a linked checkout it reports conflicting registry rows",
787
+ "and changes nothing (openclaw 2026.8.2).",
788
+ "",
789
+ ];
790
+ if (kase) {
791
+ 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.", "");
792
+ }
793
+ if (declared.length) {
794
+ 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
795
+ .map((id) => `\`${id}\``)
796
+ .join(", ")} — argument type \`${registrarType(registration.api)}\``), "");
797
+ }
798
+ lines.push("## Tools", "", ...(operations.length
799
+ ? operations.map((operation) => `- \`${operation.name}\` — ${operation.description ?? "no description"} (${shim ? "degraded:out-of-process" : "native"})`)
800
+ : [
801
+ declared.length
802
+ ? "_None: this plugin contributes the registrations above, not tools. `contracts.tools` is empty and `defineToolPlugin` still supplies the metadata `openclaw plugins build` and `validate` check._"
803
+ : "_No operation reaches this surface yet._",
804
+ ]), "");
805
+ if (shim && operations.length) {
806
+ lines.push(`Shim variant: the core is ${project.tool.binding}, so each tool spawns the kernel CLI and no host`, "capability is borrowed in-process. A native plugin requires a TypeScript core.", "");
807
+ }
808
+ if (declared.length)
809
+ lines.push(...boundary());
810
+ if (excluded.length) {
811
+ lines.push("## Not generated — `excluded:implement-in-hosts`", "", "These operations declare a capability no portable core can satisfy, so toolfactory omits them", "rather than shipping a tool that throws at call time. This package is the escape hatch: write", `them here by hand in the author's half of \`${HOST_DIR}/src/index.ts\` (below the \`// /tf:entry\``, "marker), which no rebuild touches.", "", ...excluded.map((operation) => `- \`${operation.name}\` — requires ${operation.requires.join(", ")}`), "");
812
+ }
813
+ return lines.join("\n");
814
+ }
815
+ export const surface = {
816
+ id: "openclaw-native",
817
+ plan(project) {
818
+ const operations = includedOperations(project, surface);
819
+ const kase = e2eCase(project, operations);
820
+ return [
821
+ {
822
+ kind: "file",
823
+ path: `${HOST_DIR}/package.json`,
824
+ content: packageJson(project, operations, kase),
825
+ },
826
+ {
827
+ kind: "file",
828
+ path: `${HOST_DIR}/tsconfig.json`,
829
+ content: json({
830
+ ...OPENCLAW_SCAFFOLD.tsconfig,
831
+ compilerOptions: {
832
+ ...OPENCLAW_SCAFFOLD.tsconfig.compilerOptions,
833
+ ...OPENCLAW_ADDITIONS.compilerOptions,
834
+ },
835
+ }),
836
+ },
837
+ { kind: "file", path: `${HOST_DIR}/.npmrc`, content: OPENCLAW_ADDITIONS.npmrc },
838
+ {
839
+ kind: "file",
840
+ path: `${HOST_DIR}/vitest.config.ts`,
841
+ content: OPENCLAW_SCAFFOLD.vitestConfig,
842
+ },
843
+ {
844
+ kind: "file",
845
+ path: `${HOST_DIR}/openclaw.plugin.json`,
846
+ content: pluginManifest(project, operations),
847
+ },
848
+ {
849
+ kind: "region",
850
+ path: `${HOST_DIR}/src/index.ts`,
851
+ regions: [
852
+ { begin: ENTRY_BEGIN, end: ENTRY_END, content: indexRegion(project, operations) },
853
+ ],
854
+ template: indexTemplate(project),
855
+ },
856
+ {
857
+ kind: "file",
858
+ path: `${HOST_DIR}/src/index.test.ts`,
859
+ content: indexTestTs(project, operations),
860
+ },
861
+ ...(kase
862
+ ? [
863
+ {
864
+ kind: "file",
865
+ path: `${HOST_DIR}/vitest.e2e.config.ts`,
866
+ content: E2E_CONFIG,
867
+ },
868
+ {
869
+ kind: "file",
870
+ path: `${HOST_DIR}/e2e/fixtures.json`,
871
+ content: e2eFixtures(kase),
872
+ },
873
+ {
874
+ kind: "file",
875
+ path: `${HOST_DIR}/e2e/openclaw.e2e.test.ts`,
876
+ content: e2eTest(project, operations, kase),
877
+ },
878
+ ]
879
+ : []),
880
+ { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations, kase) },
881
+ ];
882
+ },
883
+ validate(project) {
884
+ const at = { cwd: project.root };
885
+ // Run the openclaw the plugin pins as a devDependency, not whatever happens to be on PATH.
886
+ const openclaw = ["--prefix", HOST_DIR, "exec", "--no", "--", "openclaw", "plugins"];
887
+ const entry = ["--root", HOST_DIR, "--entry", OPENCLAW_SCAFFOLD.entry];
888
+ // `plugin-inspector <subcommand> <flags>`; `--out` is resolved against `--plugin-root`, which
889
+ // keeps the reports out of the author's tree.
890
+ const inspector = (subcommand) => [
891
+ "--yes",
892
+ "@openclaw/plugin-inspector",
893
+ subcommand,
894
+ "--plugin-root",
895
+ HOST_DIR,
896
+ "--no-openclaw",
897
+ "--out",
898
+ "node_modules/.cache/plugin-inspector",
899
+ ];
900
+ const operations = includedOperations(project, surface);
901
+ return [
902
+ // hosts/openclaw type-checks against the core's emitted declarations, so build it first —
903
+ // unless no operation reaches the plugin, in which case it never imports the core.
904
+ ...(project.tool.binding === "typescript" && operations.length
905
+ ? [{ label: "core build", command: "npm", args: ["run", "build"], ...at }]
906
+ : []),
907
+ {
908
+ label: "npm install (hosts/openclaw)",
909
+ command: "npm",
910
+ args: ["--prefix", HOST_DIR, "install"],
911
+ ...at,
912
+ },
913
+ // C2: the scaffold is mirrored by running the real generator, never by trusting constants.
914
+ {
915
+ label: "openclaw scaffold drift",
916
+ command: "node",
917
+ args: [...execArgv, DRIFT_ENTRY, project.root],
918
+ ...at,
919
+ },
920
+ {
921
+ label: "tsc (hosts/openclaw)",
922
+ command: "npm",
923
+ args: ["--prefix", HOST_DIR, "run", "build"],
924
+ ...at,
925
+ },
926
+ {
927
+ label: "openclaw plugins build --check",
928
+ command: "npm",
929
+ args: [...openclaw, "build", "--check", ...entry],
930
+ ...at,
931
+ },
932
+ {
933
+ label: "openclaw plugins validate",
934
+ command: "npm",
935
+ args: [...openclaw, "validate", ...entry],
936
+ ...at,
937
+ },
938
+ {
939
+ label: "@openclaw/plugin-inspector inspect",
940
+ command: "npx",
941
+ args: [...inspector("inspect"), "--check"],
942
+ ...at,
943
+ },
944
+ // The runtime lane imports `dist/index.js` against a stubbed SDK: the one check that fails
945
+ // when module load stops being side-effect-free (a top-level `resolveStateDir()` did).
946
+ {
947
+ label: "@openclaw/plugin-inspector check --runtime --mock-sdk",
948
+ command: "npx",
949
+ args: [...inspector("check"), "--runtime", "--mock-sdk"],
950
+ env: { PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1" },
951
+ ...at,
952
+ },
953
+ ];
954
+ },
955
+ verdict,
956
+ };