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,584 @@
1
+ /**
2
+ * Hermes native plugin — the §4.4 escape hatch on the Python side. Hermes ships no plugin
3
+ * scaffolder, so the package under `hosts/hermes/` is written to the shape Hermes' own loader
4
+ * reads: a directory holding `plugin.yaml` + `__init__.py` (`hermes_cli/plugins.py`
5
+ * `_load_directory_module`), packaged for pip through the `hermes_agent.plugins` entry point
6
+ * so the same code installs either way.
7
+ *
8
+ * Two contracts from `hermes_cli/plugins.py` are hard and are the only reason this file writes
9
+ * Python at all:
10
+ * - `PluginContext.register_tool(name, toolset, schema, handler, check_fn, requires_env, ...)`
11
+ * takes an OpenAI-style `schema` of `{name, description, parameters}` (line ~1778);
12
+ * - a tool handler takes `(args: dict, **kwargs)` and returns a **JSON string**; raising
13
+ * propagates into dispatch, so every generated handler returns `{"error": ...}` instead.
14
+ *
15
+ * `validate()` runs `hermes plugins doctor --ci`, which loads the plugin through the real
16
+ * runtime under a temp `HERMES_HOME` with network denied, so none of the above is assumed —
17
+ * `doctor` proves registration only, never a handler call (`hermes_cli/plugin_dev.py`'s
18
+ * `doctor_plugin()` tears the registration down without dispatching). `hosts/hermes/tests/
19
+ * test_plugin.py` proves the rest: a fake `PluginContext` collects `register_tool(...)` calls
20
+ * and drives one real handler, entirely without importing Hermes. It is deliberately not built
21
+ * on `hermes_cli.plugin_dev`'s `_doctor_runtime` / `tools.registry.registry.dispatch` — that
22
+ * machinery works, but its own module docstring calls it "deliberately private Doctor
23
+ * machinery, not a standalone plugin test framework", and shipping it into every generated repo
24
+ * would pin thousands of projects to an underscore-prefixed API upstream has declined to
25
+ * support. The fake-ctx test gets the same behavioural coverage with no Hermes import at all.
26
+ */
27
+ import { join } from "node:path";
28
+ import { stringify as toml } from "smol-toml";
29
+ import { stringify as yaml } from "yaml";
30
+ import { KERNEL_DIR } from "../bindings/typescript.js";
31
+ import { projectName } from "../identity/name.js";
32
+ import { isPortable, type Operation, type Project, type Surface, type Verdict } from "../model.js";
33
+ import { includedOperations } from "../report/coverage.js";
34
+ import {
35
+ compact,
36
+ configProperties,
37
+ dataDirEnvName,
38
+ envName,
39
+ isSensitive,
40
+ pypiName,
41
+ requiredConfig,
42
+ rootEnvName,
43
+ } from "./shared.js";
44
+
45
+ export const HOST_DIR = "hosts/hermes";
46
+
47
+ /**
48
+ * `hermes_cli/plugins.py` `SUPPORTED_MANIFEST_VERSION` is 2 and the loader parses every field
49
+ * toolfactory emits (`python_dependencies`, `license`, `homepage`, …) regardless of the declared
50
+ * version — but `hermes plugins install`'s own cap, `hermes_cli/plugins_cmd.py`
51
+ * `_SUPPORTED_MANIFEST_VERSION`, is still 1 and rejects a v2 manifest outright before ever
52
+ * loading it (verified against the pinned commit: `Plugin '<name>' requires manifest_version 2,
53
+ * but this installer only supports up to 1`). Declaring 1 costs nothing — v1 is "supported
54
+ * forever" — and is what makes `hermes plugins install file://…` actually install.
55
+ */
56
+ export const MANIFEST_VERSION = 1;
57
+
58
+ /**
59
+ * The Hermes this generator was proven against (C5). Hermes ships through its own installer
60
+ * (it refuses wheel builds from git, and the PyPI release predates `plugins doctor`), so CI and
61
+ * the container harness run that installer pinned to this commit.
62
+ */
63
+ export const HERMES_PIN = {
64
+ commit: "180291162ff4df0d42b5dc4fecd08005cf7cebf9",
65
+ installer: "https://hermes-agent.nousresearch.com/install.sh",
66
+ };
67
+
68
+ /** The one-line install CI and compose share: headless, pinned, no browser or bundled skills. */
69
+ export function hermesInstall(): string {
70
+ return `curl -fsSL ${HERMES_PIN.installer} | bash -s -- --commit ${HERMES_PIN.commit} --skip-setup --skip-browser --skip-computer-use --non-interactive --no-skills`;
71
+ }
72
+
73
+ const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
74
+
75
+ /**
76
+ * The plugin package. It is a sibling of the author's own package rather than part of it: a
77
+ * Hermes directory plugin *is* a Python package, and the two must be importable side by side.
78
+ */
79
+ export function pluginPackage(project: Project): string {
80
+ return `${projectName.pythonPackage(project.identity.name)}_hermes`;
81
+ }
82
+
83
+ /** The directory Hermes discovers: it carries both the manifest and the module. */
84
+ export function pluginDir(project: Project): string {
85
+ return `${HOST_DIR}/${pluginPackage(project)}`;
86
+ }
87
+
88
+ /** A plugin may invent its own toolset name; it is enabled by default (brief §9). */
89
+ export function toolset(project: Project): string {
90
+ return project.tool.hermes?.toolset ?? projectName.hermesToolset(project.identity.name);
91
+ }
92
+
93
+ function verdict(operation: Operation, project: Project): Verdict {
94
+ if (!isPortable(operation)) return { kind: "excluded", reason: "excluded:implement-in-hosts" };
95
+ return project.tool.binding === "python"
96
+ ? { kind: "native" }
97
+ : { kind: "degraded", reason: "degraded:out-of-process" };
98
+ }
99
+
100
+ /** Config keys reach the core as environment variables, upper-cased, as both bindings read them. */
101
+
102
+ interface EnvEntry {
103
+ name: string;
104
+ description: string;
105
+ secret?: boolean;
106
+ url?: string;
107
+ }
108
+
109
+ /**
110
+ * `requires_env` / `optional_env`, from the one credential declaration in `tool.json`
111
+ * (§6). Hermes accepts a bare name or the rich `{name, description, secret, url}` form its
112
+ * config UI prompts from (`hermes_cli/config.py`, labelled by the variable name); the rich
113
+ * form is what carries the `x-toolfactory` metadata across.
114
+ */
115
+ export function envDeclarations(project: Project): { required: EnvEntry[]; optional: EnvEntry[] } {
116
+ const required = new Set(requiredConfig(project));
117
+ const entries = Object.entries(configProperties(project)).map(([key, property]) => {
118
+ const meta = (property["x-toolfactory"] ?? {}) as { url?: string };
119
+ const description = typeof property.description === "string" ? property.description : key;
120
+ return {
121
+ // Only schema-required keys are required by Hermes; an optional secret stays optional.
122
+ needed: required.has(key),
123
+ entry: compact({
124
+ name: envName(key),
125
+ description,
126
+ // `hermes plugins install` masks the prompt on `secret`; config.py accepts either key.
127
+ secret: isSensitive(property) || undefined,
128
+ url: meta.url,
129
+ }) as EnvEntry,
130
+ };
131
+ });
132
+ return {
133
+ required: entries.filter((e) => e.needed).map((e) => e.entry),
134
+ optional: entries.filter((e) => !e.needed).map((e) => e.entry),
135
+ };
136
+ }
137
+
138
+ /** `<name>>=<version>,<<next major>` — the upper bound Plugin Doctor asks every pin to carry. */
139
+ function pinnedCore(project: Project): string | undefined {
140
+ const version = project.identity.version;
141
+ if (!version) return undefined;
142
+ const major = Number(version.split(".")[0]);
143
+ const upper = Number.isFinite(major) ? `,<${major + 1}` : "";
144
+ return `${pypiName(project)}>=${version}${upper}`;
145
+ }
146
+
147
+ /** The tool table Hermes registers: `{name, description, parameters}` per `register_tool`. */
148
+ export function toolSchemas(operations: Operation[]): Record<string, unknown>[] {
149
+ return operations.map((operation) => {
150
+ // `parameters` is handed straight to the model provider; `$schema` is not a function-call key.
151
+ const { $schema, ...parameters } = operation.inputSchema;
152
+ return {
153
+ name: operation.name,
154
+ description: operation.description ?? operation.name,
155
+ parameters,
156
+ };
157
+ });
158
+ }
159
+
160
+ /**
161
+ * The one operation `hosts/hermes/tests/test_plugin.py` calls for real: the first callable one
162
+ * `tool.json` `tests.examples` supplies complete arguments for — the same predicate
163
+ * `openclaw-native.ts`'s `e2eCase` uses, so one declaration drives both credential-free suites —
164
+ * else the first that needs none. An operation whose required input nobody supplied gets no
165
+ * lane rather than a call guaranteed to fail. `marker` mirrors the OpenClaw e2e gate: a name the
166
+ * operation's own output schema promises, so the assertion means the handler actually ran rather
167
+ * than merely not throwing.
168
+ */
169
+ function testCase(
170
+ project: Project,
171
+ operations: Operation[],
172
+ ): { operation: Operation; args: Record<string, unknown>; marker: string | undefined } | undefined {
173
+ const examples = project.tool.tests.examples;
174
+ const callable = (candidate: Operation) => {
175
+ const args = examples[candidate.name] ?? {};
176
+ const required = candidate.inputSchema.required;
177
+ return (Array.isArray(required) ? (required as string[]) : []).every((key) => key in args);
178
+ };
179
+ const candidates = operations.filter(callable);
180
+ const operation = candidates.find((candidate) => examples[candidate.name]) ?? candidates[0];
181
+ if (!operation) return undefined;
182
+ const schema = operation.outputSchema ?? {};
183
+ const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
184
+ const properties = Object.keys((schema.properties ?? {}) as Record<string, unknown>);
185
+ return { operation, args: examples[operation.name] ?? {}, marker: required[0] ?? properties[0] };
186
+ }
187
+
188
+ /** A Python identifier from an operation name, for the generated test function's suffix. */
189
+ function pySafeName(name: string): string {
190
+ return name.replace(/[^A-Za-z0-9_]/g, "_");
191
+ }
192
+
193
+ /**
194
+ * `hosts/hermes/tests/test_plugin.py`. See the module doc comment for why this exists instead of
195
+ * `hermes_cli.plugin_dev`.
196
+ */
197
+ function testPluginPy(project: Project, operations: Operation[]): string {
198
+ const pkg = pluginPackage(project);
199
+ const names = operations.map((operation) => operation.name);
200
+ const kase = testCase(project, operations);
201
+ const handlerTest = kase
202
+ ? `
203
+
204
+
205
+ def test_${pySafeName(kase.operation.name)}_handler_returns_the_real_result() -> None:
206
+ with tempfile.TemporaryDirectory() as tmp:
207
+ ctx = FakeCtx(Path(tmp))
208
+ register(ctx)
209
+ handler = next(r["handler"] for r in ctx.registrations if r["name"] == ${JSON.stringify(kase.operation.name)})
210
+ result = json.loads(handler(${JSON.stringify(kase.args)}))
211
+ assert "error" not in result${kase.marker ? `\n assert ${JSON.stringify(kase.marker)} in result` : ""}`
212
+ : "";
213
+ return `${HEADER}"""Registers the generated plugin through a fake \`PluginContext\` and, when \`tool.json\`
214
+ \`tests.examples\` supplies one, calls a real handler — everything \`hermes plugins doctor --ci\`
215
+ proves for registration, plus the one call doctor never makes. No Hermes import: needs only this
216
+ checkout${project.tool.binding === "python" ? "" : " and Node, which the shim handler spawns"}.
217
+ """
218
+
219
+ from __future__ import annotations
220
+
221
+ import json
222
+ import tempfile
223
+ from pathlib import Path
224
+ from typing import Any
225
+
226
+ from ${pkg} import register
227
+
228
+
229
+ class _State:
230
+ def __init__(self, data_dir: Path) -> None:
231
+ self.data_dir = data_dir
232
+
233
+
234
+ class FakeCtx:
235
+ """The slice of \`hermes_cli.plugins.PluginContext\` that \`register(ctx)\` touches."""
236
+
237
+ def __init__(self, data_dir: Path) -> None:
238
+ self.state = _State(data_dir)
239
+ self.registrations: list[dict[str, Any]] = []
240
+
241
+ def register_tool(self, **kwargs: Any) -> None:
242
+ self.registrations.append(kwargs)
243
+
244
+
245
+ def test_register_adds_every_declared_tool() -> None:
246
+ with tempfile.TemporaryDirectory() as tmp:
247
+ ctx = FakeCtx(Path(tmp))
248
+ register(ctx)
249
+ assert [r["name"] for r in ctx.registrations] == ${JSON.stringify(names)}${handlerTest}
250
+ `;
251
+ }
252
+
253
+ function manifest(project: Project, operations: Operation[]): string {
254
+ const { identity } = project;
255
+ const { required, optional } = envDeclarations(project);
256
+ const shim = project.tool.binding !== "python";
257
+ if (shim) {
258
+ optional.push({
259
+ name: rootEnvName(project),
260
+ description: `Path to the ${identity.name} checkout the kernel CLI runs from (default: the plugin's own repository).`,
261
+ });
262
+ }
263
+ const core = project.tool.binding === "python" ? pinnedCore(project) : undefined;
264
+ const author = identity.author?.name
265
+ ? [identity.author.name, identity.author.email].filter(Boolean).join(", ")
266
+ : undefined;
267
+ const document = compact({
268
+ manifest_version: MANIFEST_VERSION,
269
+ name: identity.name,
270
+ version: identity.version,
271
+ description: identity.description,
272
+ author,
273
+ license: identity.license,
274
+ homepage: identity.homepage,
275
+ provides_tools: operations.map((operation) => operation.name),
276
+ requires_env: required.length ? required : undefined,
277
+ optional_env: optional.length ? optional : undefined,
278
+ python_dependencies: core ? [core] : undefined,
279
+ });
280
+ return `${HEADER}${yaml(document)}`;
281
+ }
282
+
283
+ function pyproject(project: Project): string {
284
+ const core = pinnedCore(project) ?? pypiName(project);
285
+ const python = project.tool.binding === "python";
286
+ const document = compact({
287
+ project: compact({
288
+ name: `${pypiName(project)}-hermes-plugin`,
289
+ version: project.identity.version,
290
+ description: `${project.identity.description ?? project.identity.name} — Hermes plugin.`,
291
+ license: project.identity.license,
292
+ "requires-python": ">=3.11",
293
+ // The shim variant spawns the kernel CLI, so it depends on nothing importable.
294
+ dependencies: python ? [core] : [],
295
+ "entry-points": {
296
+ "hermes_agent.plugins": { [project.identity.name]: `${pluginPackage(project)}:register` },
297
+ },
298
+ }),
299
+ "build-system": { requires: ["hatchling"], "build-backend": "hatchling.build" },
300
+ tool: compact({
301
+ hatch: { build: { targets: { wheel: { packages: [pluginPackage(project)] } } } },
302
+ // Develop against the checkout; the pin above is what the published wheel resolves.
303
+ uv: python
304
+ ? { sources: { [pypiName(project)]: { path: "../..", editable: true } } }
305
+ : undefined,
306
+ }),
307
+ });
308
+ return `${toml(document)}\n`;
309
+ }
310
+
311
+ /** isort order: plain `import` before `from`, alphabetical within each. */
312
+ function sortImports(lines: string[]): string {
313
+ const rank = (line: string) => (line.startsWith("import ") ? 0 : 1);
314
+ return [...lines].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)).join("\n");
315
+ }
316
+
317
+ /** The module's import block: the standard library, then everything the core variant needs. */
318
+ function importBlock(core: PluginCore): string {
319
+ const stdlib = ["import json", "import os", "from typing import Any, Callable", ...core.stdlib];
320
+ return [sortImports(stdlib), core.external.length ? sortImports(core.external) : ""]
321
+ .filter(Boolean)
322
+ .join("\n\n");
323
+ }
324
+
325
+ interface PluginCore {
326
+ /** Standard-library statements, merged into the module's stdlib group. */
327
+ stdlib: string[];
328
+ /** Everything outside the standard library, merged into the group after it. */
329
+ external: string[];
330
+ body: string;
331
+ }
332
+
333
+ /** Python core: the handler calls the author's own operation in-process. */
334
+ function pythonCore(project: Project): PluginCore {
335
+ const pkg = projectName.pythonPackage(project.identity.name);
336
+ return {
337
+ stdlib: ["import asyncio", "import inspect"],
338
+ external: [
339
+ "from pydantic import BaseModel",
340
+ `from ${pkg}.ops import OPERATIONS`,
341
+ `from ${pkg}.toolfactory.config import context`,
342
+ ],
343
+ body: `def _operation(name: str) -> Any:
344
+ """Fail loudly if the core package and this plugin disagree on the operations."""
345
+ for operation in OPERATIONS:
346
+ if operation.name == name:
347
+ return operation
348
+ raise LookupError(f"operation {name} is missing from the core package")
349
+
350
+
351
+ def _handler(name: str) -> Callable[..., str]:
352
+ operation = _operation(name)
353
+
354
+ def call(args: dict, **_kwargs: Any) -> str:
355
+ try:
356
+ result = operation.handler(operation.input(**args), context())
357
+ if inspect.isawaitable(result):
358
+ result = asyncio.run(result)
359
+ if isinstance(result, BaseModel):
360
+ result = result.model_dump(mode="json")
361
+ return json.dumps(result, default=str)
362
+ except Exception as exc:
363
+ return json.dumps({"error": str(exc)})
364
+
365
+ call.__name__ = f"{name}_handler"
366
+ return call`,
367
+ };
368
+ }
369
+
370
+ /** TypeScript core: the handler shells out to the kernel CLI — `degraded:out-of-process`. */
371
+ function typescriptCore(project: Project): PluginCore {
372
+ return {
373
+ stdlib: ["import subprocess", "from pathlib import Path"],
374
+ external: [],
375
+ body: `#: The plugin sits at <repo>/${HOST_DIR}/<package>, so in a checkout the root is
376
+ #: three levels up; once Hermes has copied the plugin into $HERMES_HOME/plugins,
377
+ #: ${rootEnvName(project)} is how the operator says where it went.
378
+ _ROOT = os.environ.get(${JSON.stringify(rootEnvName(project))})
379
+ REPO_ROOT = Path(_ROOT) if _ROOT else Path(__file__).resolve().parents[3]
380
+ KERNEL = ["node", "--import", "tsx", "${KERNEL_DIR}/cli.ts"]
381
+
382
+
383
+
384
+ def _handler(name: str) -> Callable[..., str]:
385
+ def call(args: dict, **_kwargs: Any) -> str:
386
+ try:
387
+ done = subprocess.run(
388
+ [*KERNEL, name, "--json", json.dumps(args)],
389
+ cwd=REPO_ROOT,
390
+ capture_output=True,
391
+ text=True,
392
+ check=True,
393
+ )
394
+ return done.stdout.strip()
395
+ except subprocess.CalledProcessError as exc:
396
+ detail = (exc.stderr or exc.stdout or "").strip()
397
+ return json.dumps({"error": detail or f"{name} exited {exc.returncode}"})
398
+ except Exception as exc:
399
+ return json.dumps({"error": str(exc)})
400
+
401
+ call.__name__ = f"{name}_handler"
402
+ return call`,
403
+ };
404
+ }
405
+
406
+ function initPy(project: Project, operations: Operation[]): string {
407
+ const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
408
+ const names = envDeclarations(project).required.map((entry) => entry.name);
409
+ return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
410
+
411
+ Every handler returns a JSON string and never raises: Hermes dispatches tool
412
+ calls without a guard of its own, so an exception here escapes into the turn.
413
+ """
414
+
415
+ from __future__ import annotations
416
+
417
+ ${importBlock(core)}
418
+
419
+ TOOLSET = ${JSON.stringify(toolset(project))}
420
+ REQUIRES_ENV = ${JSON.stringify(names)}
421
+ TOOLS = json.loads(
422
+ r"""${JSON.stringify(toolSchemas(operations), null, 2)}"""
423
+ )
424
+
425
+
426
+ def _ready() -> bool:
427
+ """Hermes gates dispatch on this: every declared credential has to be present."""
428
+ return all(os.environ.get(name) for name in REQUIRES_ENV)
429
+
430
+
431
+ ${core.body}
432
+
433
+
434
+ def register(ctx: Any) -> None:
435
+ # Hermes' own per-plugin data directory, through the variable every host uses.
436
+ os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
437
+ for schema in TOOLS:
438
+ ctx.register_tool(
439
+ name=schema["name"],
440
+ toolset=TOOLSET,
441
+ schema=schema,
442
+ handler=_handler(schema["name"]),
443
+ check_fn=_ready,
444
+ requires_env=list(REQUIRES_ENV),
445
+ description=schema["description"],
446
+ )
447
+ `;
448
+ }
449
+
450
+ function readme(project: Project, operations: Operation[]): string {
451
+ const shim = project.tool.binding !== "python";
452
+ const excluded = project.operations.filter(
453
+ (operation) => verdict(operation, project).kind === "excluded",
454
+ );
455
+ const pkg = pluginPackage(project);
456
+ const lines = [
457
+ `# ${project.identity.name} — Hermes plugin`,
458
+ "",
459
+ `Generated by toolfactory from \`${project.tool.identity}\` and \`dev.toolfactory/ops.json\`.`,
460
+ `\`${pkg}/\` is a Hermes directory plugin (\`plugin.yaml\` + \`__init__.py\`) and a Python`,
461
+ "package at the same time, so the same code loads from a checkout and from PyPI.",
462
+ "",
463
+ "## Validate",
464
+ "",
465
+ "```bash",
466
+ `hermes plugins doctor ${pluginDir(project)} --ci`,
467
+ "```",
468
+ "",
469
+ "## Install",
470
+ "",
471
+ "```bash",
472
+ `pip install . # registers the hermes_agent.plugins entry point`,
473
+ `hermes plugins enable ${project.identity.name}`,
474
+ "```",
475
+ "",
476
+ "Developing against this checkout, without publishing a package first:",
477
+ "",
478
+ "```bash",
479
+ `hermes plugins install file://<repo>#${pluginDir(project)} --force`,
480
+ "```",
481
+ "",
482
+ "Hermes copies from that URL rather than linking it — there is no dev-link mode — so re-run",
483
+ "the command above after every change. `file://` sources (like `http://`) trigger a security",
484
+ "warning at install time; that is expected for a local checkout, not a sign anything is",
485
+ "wrong. Each `hermes` invocation is a fresh process that already re-reads the plugin, so",
486
+ "nothing needs restarting; `hermes gateway restart` only matters if the long-running",
487
+ "messaging gateway (Telegram, Discord, WhatsApp, …), not the CLI itself, is what needs the",
488
+ "new registration.",
489
+ "",
490
+ `Remove it cleanly with \`hermes plugins remove ${project.identity.name}\` (aliases \`rm\`,`,
491
+ "`uninstall`) — it deletes Hermes' own copy and never touches this checkout.",
492
+ "",
493
+ "## Validate without Hermes",
494
+ "",
495
+ "```bash",
496
+ "cd hosts/hermes && uv run --with pytest pytest -q",
497
+ "```",
498
+ "",
499
+ "Registers the generated plugin through a fake `PluginContext` and calls one handler for",
500
+ "real — the same behaviour `hermes plugins doctor --ci` proves for registration, plus the",
501
+ "call doctor never makes, with no Hermes install required.",
502
+ "",
503
+ "## Tools",
504
+ "",
505
+ ...(operations.length
506
+ ? operations.map(
507
+ (operation) =>
508
+ `- \`${operation.name}\` — ${operation.description ?? "no description"} (${
509
+ shim ? "degraded:out-of-process" : "native"
510
+ })`,
511
+ )
512
+ : ["_No operation reaches this surface yet._"]),
513
+ "",
514
+ `All of them register into the \`${toolset(project)}\` toolset.`,
515
+ "",
516
+ ];
517
+ if (shim) {
518
+ lines.push(
519
+ `Shim variant: the core is ${project.tool.binding}, so each handler spawns the kernel CLI`,
520
+ "from the repository root. Registration and configuration are native; nothing is borrowed",
521
+ "in-process. A native plugin requires a Python core.",
522
+ "",
523
+ `Installed outside the checkout, set \`${rootEnvName(project)}\` to the repository path — the`,
524
+ "default is resolved relative to this directory, which only holds inside the repository.",
525
+ "",
526
+ );
527
+ }
528
+ if (excluded.length) {
529
+ lines.push(
530
+ "## Not generated — `excluded:implement-in-hosts`",
531
+ "",
532
+ "These operations declare a capability no portable core can satisfy, so toolfactory omits",
533
+ "them rather than shipping a tool that fails at call time. This package is the escape",
534
+ "hatch: write them here by hand against the full PluginContext, then run `toolfactory",
535
+ `adopt ${pluginDir(project)}/__init__.py\` so your edits are never regenerated away.`,
536
+ "",
537
+ ...excluded.map(
538
+ (operation) => `- \`${operation.name}\` — requires ${operation.requires.join(", ")}`,
539
+ ),
540
+ "",
541
+ );
542
+ }
543
+ return lines.join("\n");
544
+ }
545
+
546
+ export const surface: Surface = {
547
+ id: "hermes-native",
548
+ plan(project) {
549
+ const operations = includedOperations(project, surface);
550
+ const dir = pluginDir(project);
551
+ return [
552
+ { kind: "file", path: `${dir}/plugin.yaml`, content: manifest(project, operations) },
553
+ { kind: "file", path: `${dir}/__init__.py`, content: initPy(project, operations) },
554
+ { kind: "file", path: `${HOST_DIR}/pyproject.toml`, content: pyproject(project) },
555
+ { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
556
+ {
557
+ kind: "file",
558
+ path: `${HOST_DIR}/tests/test_plugin.py`,
559
+ content: testPluginPy(project, operations),
560
+ },
561
+ ];
562
+ },
563
+ validate(project) {
564
+ return [
565
+ {
566
+ // Registration and one real handler call, with no Hermes import and therefore no
567
+ // Hermes install — safe to run everywhere `uv` already runs.
568
+ label: "hermes plugin behaviour",
569
+ command: "uv",
570
+ args: ["run", "--with", "pytest", "pytest", "-q"],
571
+ cwd: join(project.root, HOST_DIR),
572
+ },
573
+ {
574
+ // Loads the plugin through Hermes' real discovery, import and registration path,
575
+ // under a temporary HERMES_HOME with outbound sockets denied.
576
+ label: "hermes plugins doctor",
577
+ command: "hermes",
578
+ args: ["plugins", "doctor", pluginDir(project), "--ci"],
579
+ cwd: project.root,
580
+ },
581
+ ];
582
+ },
583
+ verdict,
584
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The kernel surfaces: `mcp` (the MCP server over stdio) and `cli` (one subcommand per
3
+ * operation plus `mcp`). Both are the binding's generated files; the binding owns the
4
+ * templates, these surfaces own the verdicts and the smoke commands.
5
+ */
6
+ import { getBinding } from "../bindings/index.js";
7
+ import type { Project, Surface } from "../model.js";
8
+ import { TOOLFACTORY_DIR } from "../project/lock.js";
9
+ import { cliVerdict, json, mcpVerdict } from "./shared.js";
10
+
11
+ /** MCP Inspector session config pointing at the dev kernel (no build step). */
12
+ export const INSPECTOR_CONFIG_PATH = `${TOOLFACTORY_DIR}/inspector.json`;
13
+
14
+ function inspectorConfig(project: Project): string {
15
+ const launch = getBinding(project.tool.binding).kernelCommand(project);
16
+ return json({ mcpServers: { [project.identity.name]: launch } });
17
+ }
18
+
19
+ export const mcp: Surface = {
20
+ id: "mcp",
21
+ // The kernel itself is generated for every tool (plan.ts); this surface ships it.
22
+ plan(project) {
23
+ // A build output: it points at absolute-free launch args, is gitignored, and is rebuilt.
24
+ return [
25
+ {
26
+ kind: "file",
27
+ path: INSPECTOR_CONFIG_PATH,
28
+ content: inspectorConfig(project),
29
+ output: true,
30
+ },
31
+ ];
32
+ },
33
+ validate(project) {
34
+ return [
35
+ {
36
+ label: "mcp-inspector tools/list",
37
+ command: "npx",
38
+ args: [
39
+ "--yes",
40
+ "@modelcontextprotocol/inspector",
41
+ "--cli",
42
+ "--config",
43
+ INSPECTOR_CONFIG_PATH,
44
+ "--server",
45
+ project.identity.name,
46
+ "--method",
47
+ "tools/list",
48
+ ],
49
+ cwd: project.root,
50
+ },
51
+ ];
52
+ },
53
+ verdict: mcpVerdict,
54
+ };
55
+
56
+ export const cli: Surface = {
57
+ id: "cli",
58
+ plan(project) {
59
+ return getBinding(project.tool.binding).cli(project);
60
+ },
61
+ validate(project) {
62
+ const launch = getBinding(project.tool.binding).cliCommand(project);
63
+ return [
64
+ { label: "cli --help", ...launch, args: [...launch.args, "--help"], cwd: project.root },
65
+ ];
66
+ },
67
+ verdict: cliVerdict,
68
+ };