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