toolfunnel 0.5.0 → 0.6.0

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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,131 +1,131 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * text-stats.js — a safe, dependency-free local text-statistics tool.
6
- *
7
- * Purpose
8
- * -------
9
- * Count characters, words, lines, and UTF-8 bytes for a string. A handy
10
- * Tool-Manager utility for sizing prompts, snippets, and payloads. Pure
11
- * string work — no network, no filesystem, no new deps.
12
- *
13
- * Contract with the register (the register's `defaultRunScript`)
14
- * ----------------------------------------------------------
15
- * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
16
- * - Args: { text: <string> }.
17
- * - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even
18
- * on bad input (reported as { ok:false, error }), never a thrown exception.
19
- *
20
- * Output (stdout), exactly one JSON object:
21
- * success: { ok:true, chars, words, lines, bytes }
22
- * failure: { ok:false, error }
23
- *
24
- * Counting conventions (documented so callers know exactly what they get):
25
- * - chars : JS string length (UTF-16 code units — the same number `"".length`
26
- * reports). Documented as such; for ASCII text it equals the visible
27
- * character count.
28
- * - words : maximal runs of non-whitespace separated by whitespace. The empty
29
- * / all-whitespace string has 0 words.
30
- * - lines : number of lines. The empty string is 0 lines; otherwise it is the
31
- * count of newline-separated segments (a trailing newline does NOT
32
- * add a phantom empty final line — `"a\n"` is 1 line, `"a\nb"` is 2).
33
- * - bytes : UTF-8 byte length (Buffer.byteLength), the on-the-wire size.
34
- *
35
- * Safety invariants (mirror the isolation rule):
36
- * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
37
- * - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
38
- */
39
-
40
- /**
41
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
42
- * @returns {{ value: any } | { parseError: string }}
43
- */
44
- function parseStructuredArgs() {
45
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
46
- if (raw === undefined || raw === null || raw === '') {
47
- return { value: null };
48
- }
49
- try {
50
- return { value: JSON.parse(raw) };
51
- } catch (_err) {
52
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
53
- }
54
- }
55
-
56
- /**
57
- * Count lines per the documented convention: 0 for the empty string, else the
58
- * number of newline-separated segments WITHOUT counting a phantom trailing
59
- * empty line. We normalise CRLF/CR to LF first so line counts match what a user
60
- * sees regardless of the platform that produced the text.
61
- *
62
- * @param {string} text
63
- * @returns {number}
64
- */
65
- function countLines(text) {
66
- if (text.length === 0) return 0;
67
- const normalised = text.replace(/\r\n?/g, '\n');
68
- // Trailing newline should not add an empty line: trim a single trailing '\n'
69
- // before splitting, then add 1 for the final (now-unterminated) line.
70
- const trimmed = normalised.endsWith('\n') ? normalised.slice(0, -1) : normalised;
71
- // Each '\n' in `trimmed` separates two lines → lines = (#newlines) + 1.
72
- let newlines = 0;
73
- for (let i = 0; i < trimmed.length; i += 1) {
74
- if (trimmed.charCodeAt(i) === 10) newlines += 1;
75
- }
76
- return newlines + 1;
77
- }
78
-
79
- /**
80
- * Count words: maximal non-whitespace runs. Empty / whitespace-only → 0.
81
- *
82
- * @param {string} text
83
- * @returns {number}
84
- */
85
- function countWords(text) {
86
- const trimmed = text.trim();
87
- if (trimmed.length === 0) return 0;
88
- // Split on any whitespace run; the trim above guarantees no empty edge tokens.
89
- return trimmed.split(/\s+/).length;
90
- }
91
-
92
- /**
93
- * Pure core: given the parsed args, produce the result object. Never throws.
94
- *
95
- * @param {any} args
96
- * @returns {object} the JSON-serialisable result
97
- */
98
- function run(args) {
99
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
100
- return { ok: false, error: 'args must be an object { text }' };
101
- }
102
- const { text } = args;
103
- if (typeof text !== 'string') {
104
- return { ok: false, error: 'text must be a string' };
105
- }
106
- return {
107
- ok: true,
108
- chars: text.length,
109
- words: countWords(text),
110
- lines: countLines(text),
111
- bytes: Buffer.byteLength(text, 'utf8'),
112
- };
113
- }
114
-
115
- function main() {
116
- const parsed = parseStructuredArgs();
117
- let payload;
118
- if (parsed.parseError) {
119
- payload = { ok: false, error: parsed.parseError };
120
- } else {
121
- try {
122
- payload = run(parsed.value);
123
- } catch (err) {
124
- payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
125
- }
126
- }
127
- process.stdout.write(JSON.stringify(payload) + '\n');
128
- process.exit(0);
129
- }
130
-
131
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * text-stats.js - a safe, dependency-free local text-statistics tool.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Count characters, words, lines, and UTF-8 bytes for a string. A handy
10
+ * Tool-Manager utility for sizing prompts, snippets, and payloads. Pure
11
+ * string work - no network, no filesystem, no new deps.
12
+ *
13
+ * Contract with the register (the register's `defaultRunScript`)
14
+ * ----------------------------------------------------------
15
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
16
+ * - Args: { text: <string> }.
17
+ * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even
18
+ * on bad input (reported as { ok:false, error }), never a thrown exception.
19
+ *
20
+ * Output (stdout), exactly one JSON object:
21
+ * success: { ok:true, chars, words, lines, bytes }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Counting conventions (documented so callers know exactly what they get):
25
+ * - chars : JS string length (UTF-16 code units - the same number `"".length`
26
+ * reports). Documented as such; for ASCII text it equals the visible
27
+ * character count.
28
+ * - words : maximal runs of non-whitespace separated by whitespace. The empty
29
+ * / all-whitespace string has 0 words.
30
+ * - lines : number of lines. The empty string is 0 lines; otherwise it is the
31
+ * count of newline-separated segments (a trailing newline does NOT
32
+ * add a phantom empty final line - `"a\n"` is 1 line, `"a\nb"` is 2).
33
+ * - bytes : UTF-8 byte length (Buffer.byteLength), the on-the-wire size.
34
+ *
35
+ * Safety invariants (mirror the isolation rule):
36
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
37
+ * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
38
+ */
39
+
40
+ /**
41
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
42
+ * @returns {{ value: any } | { parseError: string }}
43
+ */
44
+ function parseStructuredArgs() {
45
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
46
+ if (raw === undefined || raw === null || raw === '') {
47
+ return { value: null };
48
+ }
49
+ try {
50
+ return { value: JSON.parse(raw) };
51
+ } catch (_err) {
52
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Count lines per the documented convention: 0 for the empty string, else the
58
+ * number of newline-separated segments WITHOUT counting a phantom trailing
59
+ * empty line. We normalise CRLF/CR to LF first so line counts match what a user
60
+ * sees regardless of the platform that produced the text.
61
+ *
62
+ * @param {string} text
63
+ * @returns {number}
64
+ */
65
+ function countLines(text) {
66
+ if (text.length === 0) return 0;
67
+ const normalised = text.replace(/\r\n?/g, '\n');
68
+ // Trailing newline should not add an empty line: trim a single trailing '\n'
69
+ // before splitting, then add 1 for the final (now-unterminated) line.
70
+ const trimmed = normalised.endsWith('\n') ? normalised.slice(0, -1) : normalised;
71
+ // Each '\n' in `trimmed` separates two lines -> lines = (#newlines) + 1.
72
+ let newlines = 0;
73
+ for (let i = 0; i < trimmed.length; i += 1) {
74
+ if (trimmed.charCodeAt(i) === 10) newlines += 1;
75
+ }
76
+ return newlines + 1;
77
+ }
78
+
79
+ /**
80
+ * Count words: maximal non-whitespace runs. Empty / whitespace-only -> 0.
81
+ *
82
+ * @param {string} text
83
+ * @returns {number}
84
+ */
85
+ function countWords(text) {
86
+ const trimmed = text.trim();
87
+ if (trimmed.length === 0) return 0;
88
+ // Split on any whitespace run; the trim above guarantees no empty edge tokens.
89
+ return trimmed.split(/\s+/).length;
90
+ }
91
+
92
+ /**
93
+ * Pure core: given the parsed args, produce the result object. Never throws.
94
+ *
95
+ * @param {any} args
96
+ * @returns {object} the JSON-serialisable result
97
+ */
98
+ function run(args) {
99
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
100
+ return { ok: false, error: 'args must be an object { text }' };
101
+ }
102
+ const { text } = args;
103
+ if (typeof text !== 'string') {
104
+ return { ok: false, error: 'text must be a string' };
105
+ }
106
+ return {
107
+ ok: true,
108
+ chars: text.length,
109
+ words: countWords(text),
110
+ lines: countLines(text),
111
+ bytes: Buffer.byteLength(text, 'utf8'),
112
+ };
113
+ }
114
+
115
+ function main() {
116
+ const parsed = parseStructuredArgs();
117
+ let payload;
118
+ if (parsed.parseError) {
119
+ payload = { ok: false, error: parsed.parseError };
120
+ } else {
121
+ try {
122
+ payload = run(parsed.value);
123
+ } catch (err) {
124
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
125
+ }
126
+ }
127
+ process.stdout.write(JSON.stringify(payload) + '\n');
128
+ process.exit(0);
129
+ }
130
+
131
+ main();
@@ -1,43 +1,43 @@
1
- 'use strict';
2
-
3
- /**
4
- * tf-env.js — shared HOME/ENGINE resolution for the tf-* management scripts.
5
- *
6
- * A management script runs in one of two layouts:
7
- * 1. The git-clone original: it lives at <pkg>/tools/scripts/, and TWO dirs up is a root that
8
- * holds BOTH the config (tools/ mcp/ hooks/) and the engine code (src/).
9
- * 2. A copy SEEDED into an external CONFIG HOME (TOOLFUNNEL_HOME / --config-dir — see
10
- * src/core/config-home.js): the config sits beside it, but the engine code does NOT.
11
- * The gateway that spawned it wrote the process-tree contract into the env:
12
- * TOOLFUNNEL_HOME (the resolved absolute home) + TOOLFUNNEL_PKG (the package root).
13
- *
14
- * Resolution rules:
15
- * - ENGINE: prefer the LOCAL __dirname-derived root whenever it actually contains the engine
16
- * (layout 1) so a stale inherited TOOLFUNNEL_PKG can never shadow a real install; else the
17
- * env; else the local root anyway (the require failure is then the clearest error).
18
- * - HOME: TOOLFUNNEL_HOME when set (the gateway always sets it), else the local root.
19
- *
20
- * Same-dir require ('./tf-env') keeps this working in BOTH layouts — the seeder copies the whole
21
- * scripts directory, so this file always travels with its callers. Node built-ins only.
22
- */
23
-
24
- const fs = require('node:fs');
25
- const path = require('node:path');
26
-
27
- const LOCAL_ROOT = path.resolve(__dirname, '..', '..');
28
- const HAS_LOCAL_ENGINE = fs.existsSync(path.join(LOCAL_ROOT, 'src', 'tools', 'registry.js'));
29
-
30
- /** The package root holding the engine code (src/…). */
31
- const PKG = HAS_LOCAL_ENGINE ? LOCAL_ROOT : (process.env.TOOLFUNNEL_PKG || LOCAL_ROOT);
32
-
33
- /** The config home holding tools/ mcp/ hooks/ (the mutable state this script edits). */
34
- const HOME = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
35
- ? path.resolve(process.env.TOOLFUNNEL_HOME.trim())
36
- : LOCAL_ROOT;
37
-
38
- /** Require an engine module by its src-relative path, e.g. srcRequire('tools/registry'). */
39
- function srcRequire(rel) {
40
- return require(path.join(PKG, 'src', rel));
41
- }
42
-
43
- module.exports = { PKG, HOME, srcRequire };
1
+ 'use strict';
2
+
3
+ /**
4
+ * tf-env.js - shared HOME/ENGINE resolution for the tf-* management scripts.
5
+ *
6
+ * A management script runs in one of two layouts:
7
+ * 1. The git-clone original: it lives at <pkg>/tools/scripts/, and TWO dirs up is a root that
8
+ * holds BOTH the config (tools/ mcp/ hooks/) and the engine code (src/).
9
+ * 2. A copy SEEDED into an external CONFIG HOME (TOOLFUNNEL_HOME / --config-dir - see
10
+ * src/core/config-home.js): the config sits beside it, but the engine code does NOT.
11
+ * The gateway that spawned it wrote the process-tree contract into the env:
12
+ * TOOLFUNNEL_HOME (the resolved absolute home) + TOOLFUNNEL_PKG (the package root).
13
+ *
14
+ * Resolution rules:
15
+ * - ENGINE: prefer the LOCAL __dirname-derived root whenever it actually contains the engine
16
+ * (layout 1) so a stale inherited TOOLFUNNEL_PKG can never shadow a real install; else the
17
+ * env; else the local root anyway (the require failure is then the clearest error).
18
+ * - HOME: TOOLFUNNEL_HOME when set (the gateway always sets it), else the local root.
19
+ *
20
+ * Same-dir require ('./tf-env') keeps this working in BOTH layouts - the seeder copies the whole
21
+ * scripts directory, so this file always travels with its callers. Node built-ins only.
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+
27
+ const LOCAL_ROOT = path.resolve(__dirname, '..', '..');
28
+ const HAS_LOCAL_ENGINE = fs.existsSync(path.join(LOCAL_ROOT, 'src', 'tools', 'registry.js'));
29
+
30
+ /** The package root holding the engine code (src/...). */
31
+ const PKG = HAS_LOCAL_ENGINE ? LOCAL_ROOT : (process.env.TOOLFUNNEL_PKG || LOCAL_ROOT);
32
+
33
+ /** The config home holding tools/ mcp/ hooks/ (the mutable state this script edits). */
34
+ const HOME = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
35
+ ? path.resolve(process.env.TOOLFUNNEL_HOME.trim())
36
+ : LOCAL_ROOT;
37
+
38
+ /** Require an engine module by its src-relative path, e.g. srcRequire('tools/registry'). */
39
+ function srcRequire(rel) {
40
+ return require(path.join(PKG, 'src', rel));
41
+ }
42
+
43
+ module.exports = { PKG, HOME, srcRequire };
@@ -1,151 +1,151 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-hook-add.js — management tool: add a hook entry to the hook manifest.
6
- *
7
- * A first-party "gateway-run" management function: it configures ToolFunnel's
8
- * OWN hook manifest (and, optionally, drops an inline hook script into the
9
- * scripts copy dir). Discovered via list_tools and executed via run_tool
10
- * through the PreToolUse gate — it is NOT a new MCP-protocol command.
11
- *
12
- * Contract with the register (`defaultRunScript`)
13
- * ----------------------------------------------------------
14
- * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} if absent).
15
- * - Args: { id, event, matcher?, command, script?, timeout?, enabled?=true,
16
- * description?, scriptText? }.
17
- * - Prints EXACTLY ONE JSON object to stdout and exits 0 — ALWAYS exit 0, even
18
- * on a logical error, which is reported as { ok:false, error }.
19
- *
20
- * Output (stdout), exactly one JSON object:
21
- * success: { ok:true, id, hook, scriptWritten }
22
- * failure: { ok:false, error }
23
- *
24
- * Ordering note (verified against src/core/hook-loader.js)
25
- * -------------------------------------------------------
26
- * HookLoader.writeScript(id, text) resolves the script path from the spec and
27
- * THROWS for an unknown id (getSpec must find the row). Since addEntry rejects
28
- * duplicate ids, this tool only ever creates NEW hooks — so the manifest row
29
- * must be registered FIRST, then the inline script can be written. If the
30
- * inline write fails, the just-added entry is rolled back so the add stays
31
- * atomic (the intent behind a write-then-register ordering).
32
- *
33
- * Safety invariants:
34
- * - Touches only ToolFunnel's own manifest + scripts dir (resolved from
35
- * __dirname, never a caller-supplied absolute path).
36
- * - NO network. NEVER throws for ordinary conditions — only JSON on stdout.
37
- */
38
-
39
- const path = require('node:path');
40
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
41
- const { HOME, srcRequire } = require('./tf-env');
42
- const { loadManifest } = srcRequire('core/hook-loader');
43
-
44
- const ROOT = HOME;
45
-
46
- /** The hook manifest, relative to ROOT. */
47
- const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
48
-
49
- /**
50
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
51
- * is treated as an empty object. Returns the parsed value or a parse-error
52
- * sentinel so the caller surfaces a clean { ok:false, error }.
53
- *
54
- * @returns {{ value: any } | { parseError: string }}
55
- */
56
- function parseStructuredArgs() {
57
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
58
- if (raw === undefined || raw === null || raw === '') {
59
- return { value: {} };
60
- }
61
- try {
62
- return { value: JSON.parse(raw) };
63
- } catch (_err) {
64
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
65
- }
66
- }
67
-
68
- /**
69
- * Pure-ish core: validate args, register the hook, optionally write the inline
70
- * script. Never throws for ordinary conditions (addEntry's own validation errors
71
- * are caught in main).
72
- *
73
- * @param {any} args
74
- * @returns {object} the JSON-serialisable result
75
- */
76
- function run(args) {
77
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
78
- return { ok: false, error: 'args must be an object { id, event, command, ... }' };
79
- }
80
-
81
- const { id, event, matcher, command, script, timeout, description, scriptText } = args;
82
-
83
- // Clear up-front checks for fields addEntry does not (id/event/command are
84
- // re-validated by addEntry). scriptText is checked before any mutation so a
85
- // bad value never leaves a half-added entry behind.
86
- if (typeof id !== 'string' || id.length === 0) {
87
- return { ok: false, error: 'id must be a non-empty string' };
88
- }
89
- if (scriptText !== undefined && typeof scriptText !== 'string') {
90
- return { ok: false, error: 'scriptText must be a string when provided' };
91
- }
92
-
93
- // enabled defaults to TRUE (opt-out) for this tool, unlike the loader's
94
- // addEntry default of false — so it is passed explicitly.
95
- const enabled = args.enabled === undefined ? true : args.enabled === true;
96
-
97
- // Build the spec from only the supplied optional fields (keeps the manifest
98
- // tidy); addEntry stores them verbatim.
99
- const spec = { id, event, command, enabled };
100
- if (matcher !== undefined) spec.matcher = matcher;
101
- if (script !== undefined) spec.script = script;
102
- if (timeout !== undefined) spec.timeout = timeout;
103
- if (description !== undefined) spec.description = description;
104
-
105
- const loader = loadManifest(MANIFEST);
106
-
107
- // Register first (see Ordering note in the header). addEntry validates
108
- // id/event/command and rejects duplicate ids by throwing — caught in main.
109
- const stored = loader.addEntry(spec);
110
-
111
- let scriptWritten = false;
112
- if (typeof scriptText === 'string') {
113
- try {
114
- loader.writeScript(id, scriptText);
115
- scriptWritten = true;
116
- } catch (err) {
117
- // Roll the entry back so a failed inline write leaves no orphan row.
118
- try {
119
- loader.removeEntry(id);
120
- } catch (_) {
121
- /* best-effort rollback */
122
- }
123
- return {
124
- ok: false,
125
- error: `script write failed (entry rolled back): ${(err && err.message) || String(err)}`,
126
- };
127
- }
128
- }
129
-
130
- return { ok: true, id, hook: stored, scriptWritten };
131
- }
132
-
133
- function main() {
134
- const parsed = parseStructuredArgs();
135
- let payload;
136
- if (parsed.parseError) {
137
- payload = { ok: false, error: parsed.parseError };
138
- } else {
139
- try {
140
- payload = run(parsed.value);
141
- } catch (err) {
142
- // addEntry throws on validation / duplicate-id failures — report them as
143
- // a logical error, still exit 0.
144
- payload = { ok: false, error: (err && err.message) || String(err) };
145
- }
146
- }
147
- process.stdout.write(JSON.stringify(payload) + '\n');
148
- process.exit(0);
149
- }
150
-
151
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-hook-add.js - management tool: add a hook entry to the hook manifest.
6
+ *
7
+ * A first-party "gateway-run" management function: it configures ToolFunnel's
8
+ * OWN hook manifest (and, optionally, drops an inline hook script into the
9
+ * scripts copy dir). Discovered via list_tools and executed via run_tool
10
+ * through the PreToolUse gate - it is NOT a new MCP-protocol command.
11
+ *
12
+ * Contract with the register (`defaultRunScript`)
13
+ * ----------------------------------------------------------
14
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} if absent).
15
+ * - Args: { id, event, matcher?, command, script?, timeout?, enabled?=true,
16
+ * description?, scriptText? }.
17
+ * - Prints EXACTLY ONE JSON object to stdout and exits 0 - ALWAYS exit 0, even
18
+ * on a logical error, which is reported as { ok:false, error }.
19
+ *
20
+ * Output (stdout), exactly one JSON object:
21
+ * success: { ok:true, id, hook, scriptWritten }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Ordering note (verified against src/core/hook-loader.js)
25
+ * -------------------------------------------------------
26
+ * HookLoader.writeScript(id, text) resolves the script path from the spec and
27
+ * THROWS for an unknown id (getSpec must find the row). Since addEntry rejects
28
+ * duplicate ids, this tool only ever creates NEW hooks - so the manifest row
29
+ * must be registered FIRST, then the inline script can be written. If the
30
+ * inline write fails, the just-added entry is rolled back so the add stays
31
+ * atomic (the intent behind a write-then-register ordering).
32
+ *
33
+ * Safety invariants:
34
+ * - Touches only ToolFunnel's own manifest + scripts dir (resolved from
35
+ * __dirname, never a caller-supplied absolute path).
36
+ * - NO network. NEVER throws for ordinary conditions - only JSON on stdout.
37
+ */
38
+
39
+ const path = require('node:path');
40
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
41
+ const { HOME, srcRequire } = require('./tf-env');
42
+ const { loadManifest } = srcRequire('core/hook-loader');
43
+
44
+ const ROOT = HOME;
45
+
46
+ /** The hook manifest, relative to ROOT. */
47
+ const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
48
+
49
+ /**
50
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
51
+ * is treated as an empty object. Returns the parsed value or a parse-error
52
+ * sentinel so the caller surfaces a clean { ok:false, error }.
53
+ *
54
+ * @returns {{ value: any } | { parseError: string }}
55
+ */
56
+ function parseStructuredArgs() {
57
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
58
+ if (raw === undefined || raw === null || raw === '') {
59
+ return { value: {} };
60
+ }
61
+ try {
62
+ return { value: JSON.parse(raw) };
63
+ } catch (_err) {
64
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Pure-ish core: validate args, register the hook, optionally write the inline
70
+ * script. Never throws for ordinary conditions (addEntry's own validation errors
71
+ * are caught in main).
72
+ *
73
+ * @param {any} args
74
+ * @returns {object} the JSON-serialisable result
75
+ */
76
+ function run(args) {
77
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
78
+ return { ok: false, error: 'args must be an object { id, event, command, ... }' };
79
+ }
80
+
81
+ const { id, event, matcher, command, script, timeout, description, scriptText } = args;
82
+
83
+ // Clear up-front checks for fields addEntry does not (id/event/command are
84
+ // re-validated by addEntry). scriptText is checked before any mutation so a
85
+ // bad value never leaves a half-added entry behind.
86
+ if (typeof id !== 'string' || id.length === 0) {
87
+ return { ok: false, error: 'id must be a non-empty string' };
88
+ }
89
+ if (scriptText !== undefined && typeof scriptText !== 'string') {
90
+ return { ok: false, error: 'scriptText must be a string when provided' };
91
+ }
92
+
93
+ // enabled defaults to TRUE (opt-out) for this tool, unlike the loader's
94
+ // addEntry default of false - so it is passed explicitly.
95
+ const enabled = args.enabled === undefined ? true : args.enabled === true;
96
+
97
+ // Build the spec from only the supplied optional fields (keeps the manifest
98
+ // tidy); addEntry stores them verbatim.
99
+ const spec = { id, event, command, enabled };
100
+ if (matcher !== undefined) spec.matcher = matcher;
101
+ if (script !== undefined) spec.script = script;
102
+ if (timeout !== undefined) spec.timeout = timeout;
103
+ if (description !== undefined) spec.description = description;
104
+
105
+ const loader = loadManifest(MANIFEST);
106
+
107
+ // Register first (see Ordering note in the header). addEntry validates
108
+ // id/event/command and rejects duplicate ids by throwing - caught in main.
109
+ const stored = loader.addEntry(spec);
110
+
111
+ let scriptWritten = false;
112
+ if (typeof scriptText === 'string') {
113
+ try {
114
+ loader.writeScript(id, scriptText);
115
+ scriptWritten = true;
116
+ } catch (err) {
117
+ // Roll the entry back so a failed inline write leaves no orphan row.
118
+ try {
119
+ loader.removeEntry(id);
120
+ } catch (_) {
121
+ /* best-effort rollback */
122
+ }
123
+ return {
124
+ ok: false,
125
+ error: `script write failed (entry rolled back): ${(err && err.message) || String(err)}`,
126
+ };
127
+ }
128
+ }
129
+
130
+ return { ok: true, id, hook: stored, scriptWritten };
131
+ }
132
+
133
+ function main() {
134
+ const parsed = parseStructuredArgs();
135
+ let payload;
136
+ if (parsed.parseError) {
137
+ payload = { ok: false, error: parsed.parseError };
138
+ } else {
139
+ try {
140
+ payload = run(parsed.value);
141
+ } catch (err) {
142
+ // addEntry throws on validation / duplicate-id failures - report them as
143
+ // a logical error, still exit 0.
144
+ payload = { ok: false, error: (err && err.message) || String(err) };
145
+ }
146
+ }
147
+ process.stdout.write(JSON.stringify(payload) + '\n');
148
+ process.exit(0);
149
+ }
150
+
151
+ main();