toolfunnel 0.6.0 → 0.7.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 (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  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 +120 -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 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,134 +1,134 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-tool-add.js - a first-party MANAGEMENT tool that ADDS a register entry.
6
- *
7
- * Purpose
8
- * -------
9
- * The write-side counterpart to the read-only status / inventory tools: it lets
10
- * the manager surface a NEW tool in the register at runtime (no reconnect - the
11
- * running gateway watches tools.register.json and hot-reloads its register on
12
- * change). Optionally it also writes the host-local script the new entry invokes,
13
- * so a single call can create both the register entry AND its backing script
14
- * atomically.
15
- *
16
- * This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the register
17
- * and, optionally, a script under the scripts root). It runs through the same
18
- * register -> resolveExecution -> gated run path as any other script tool, so
19
- * the PreToolUse gate still applies to invoking it.
20
- *
21
- * Contract with the register (registry.js `defaultRunScript`)
22
- * ----------------------------------------------------------
23
- * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
24
- * - Args: { id, name, summary?, category?, instructions?, inputSchema?,
25
- * invoke: { type:'script'|'shell', path?|command? },
26
- * scriptText? }
27
- * inputSchema (a JSON Schema object) is what a HOT-promoted tool advertises verbatim in
28
- * the top-level tools/list - dropping it here was the gap between "your own tools" and
29
- * "your own tools AND schemas".
30
- * When scriptText is a string AND invoke.type === 'script', the script is
31
- * written first (registry.writeScript), THEN the entry is added.
32
- * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
33
- * a logical error (reported as { ok:false, error }), never a thrown exception.
34
- *
35
- * Output (stdout), exactly one JSON object:
36
- * success: { ok:true, ...<the added register entry>, scriptPath?: "<abs path>" }
37
- * failure: { ok:false, error }
38
- *
39
- * Safety invariants:
40
- * - Touches ONLY files inside the toolfunnel root, resolved from THIS script's
41
- * own location (__dirname) - never a caller-supplied absolute path. The
42
- * register's writeScript guards path-escape (basename + inside-root check).
43
- * - NO network. Writes are atomic (temp + fsync + rename) via the register module.
44
- * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
45
- */
46
-
47
- const path = require('node:path');
48
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
49
- const { HOME, srcRequire } = require('./tf-env');
50
- const { loadRegistry } = srcRequire('tools/registry');
51
-
52
- const ROOT = HOME;
53
- const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
54
- const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
55
-
56
- /**
57
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
58
- * @returns {{ value: any } | { parseError: string }}
59
- */
60
- function parseStructuredArgs() {
61
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
62
- if (raw === undefined || raw === null || raw === '') {
63
- return { value: null };
64
- }
65
- try {
66
- return { value: JSON.parse(raw) };
67
- } catch (_err) {
68
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
69
- }
70
- }
71
-
72
- /**
73
- * Pure core: given the parsed args, add the register entry (and optionally write
74
- * its backing script first). Throws are caught by main() and surfaced as
75
- * { ok:false, error }; loadRegistry / writeScript / add all throw on bad shape.
76
- *
77
- * @param {any} args
78
- * @returns {object} the JSON-serialisable result
79
- */
80
- function run(args) {
81
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
82
- return {
83
- ok: false,
84
- error: 'args must be an object { id, name, summary?, category?, instructions?, inputSchema?, mode?, invoke?, scriptText? }',
85
- };
86
- }
87
- const { id, name, summary, category, instructions, inputSchema, mode, invoke, scriptText } = args;
88
-
89
- // Build a clean register entry (only carry through the optional fields when
90
- // present so the persisted entry stays tidy). registry.add validates shape.
91
- // `mode` MUST ride along: a reference-mode tool has NO invoke at all, and dropping the field
92
- // here made the agent path reject every reference tool with "entry.invoke is required" while
93
- // the UI path accepted them.
94
- const entry = { id, name };
95
- if (invoke !== undefined) entry.invoke = invoke;
96
- if (mode !== undefined) entry.mode = mode;
97
- if (summary !== undefined) entry.summary = summary;
98
- if (category !== undefined) entry.category = category;
99
- if (instructions !== undefined) entry.instructions = instructions;
100
- if (inputSchema !== undefined) entry.inputSchema = inputSchema; // validated by registry.add
101
-
102
-
103
- const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
104
-
105
- // When a script body is supplied for a script invoke, write it FIRST so the
106
- // entry never references a missing file once add() succeeds.
107
- let scriptPath;
108
- if (typeof scriptText === 'string' && invoke && invoke.type === 'script') {
109
- scriptPath = registry.writeScript(invoke.path, scriptText);
110
- }
111
-
112
- const added = registry.add(entry);
113
- const result = { ok: true, ...added };
114
- if (scriptPath) result.scriptPath = scriptPath;
115
- return result;
116
- }
117
-
118
- function main() {
119
- const parsed = parseStructuredArgs();
120
- let payload;
121
- if (parsed.parseError) {
122
- payload = { ok: false, error: parsed.parseError };
123
- } else {
124
- try {
125
- payload = run(parsed.value);
126
- } catch (err) {
127
- payload = { ok: false, error: (err && err.message) || String(err) };
128
- }
129
- }
130
- process.stdout.write(JSON.stringify(payload) + '\n');
131
- process.exit(0);
132
- }
133
-
134
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-tool-add.js - a first-party MANAGEMENT tool that ADDS a register entry.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * The write-side counterpart to the read-only status / inventory tools: it lets
10
+ * the manager surface a NEW tool in the register at runtime (no reconnect - the
11
+ * running gateway watches tools.register.json and hot-reloads its register on
12
+ * change). Optionally it also writes the host-local script the new entry invokes,
13
+ * so a single call can create both the register entry AND its backing script
14
+ * atomically.
15
+ *
16
+ * This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the register
17
+ * and, optionally, a script under the scripts root). It runs through the same
18
+ * register -> resolveExecution -> gated run path as any other script tool, so
19
+ * the PreToolUse gate still applies to invoking it.
20
+ *
21
+ * Contract with the register (registry.js `defaultRunScript`)
22
+ * ----------------------------------------------------------
23
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
24
+ * - Args: { id, name, summary?, category?, instructions?, inputSchema?,
25
+ * invoke: { type:'script'|'shell', path?|command? },
26
+ * scriptText? }
27
+ * inputSchema (a JSON Schema object) is what a HOT-promoted tool advertises verbatim in
28
+ * the top-level tools/list - dropping it here was the gap between "your own tools" and
29
+ * "your own tools AND schemas".
30
+ * When scriptText is a string AND invoke.type === 'script', the script is
31
+ * written first (registry.writeScript), THEN the entry is added.
32
+ * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
33
+ * a logical error (reported as { ok:false, error }), never a thrown exception.
34
+ *
35
+ * Output (stdout), exactly one JSON object:
36
+ * success: { ok:true, ...<the added register entry>, scriptPath?: "<abs path>" }
37
+ * failure: { ok:false, error }
38
+ *
39
+ * Safety invariants:
40
+ * - Touches ONLY files inside the toolfunnel root, resolved from THIS script's
41
+ * own location (__dirname) - never a caller-supplied absolute path. The
42
+ * register's writeScript guards path-escape (basename + inside-root check).
43
+ * - NO network. Writes are atomic (temp + fsync + rename) via the register module.
44
+ * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
45
+ */
46
+
47
+ const path = require('node:path');
48
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
49
+ const { HOME, srcRequire } = require('./tf-env');
50
+ const { loadRegistry } = srcRequire('tools/registry');
51
+
52
+ const ROOT = HOME;
53
+ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
54
+ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
55
+
56
+ /**
57
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
58
+ * @returns {{ value: any } | { parseError: string }}
59
+ */
60
+ function parseStructuredArgs() {
61
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
62
+ if (raw === undefined || raw === null || raw === '') {
63
+ return { value: null };
64
+ }
65
+ try {
66
+ return { value: JSON.parse(raw) };
67
+ } catch (_err) {
68
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Pure core: given the parsed args, add the register entry (and optionally write
74
+ * its backing script first). Throws are caught by main() and surfaced as
75
+ * { ok:false, error }; loadRegistry / writeScript / add all throw on bad shape.
76
+ *
77
+ * @param {any} args
78
+ * @returns {object} the JSON-serialisable result
79
+ */
80
+ function run(args) {
81
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
82
+ return {
83
+ ok: false,
84
+ error: 'args must be an object { id, name, summary?, category?, instructions?, inputSchema?, mode?, invoke?, scriptText? }',
85
+ };
86
+ }
87
+ const { id, name, summary, category, instructions, inputSchema, mode, invoke, scriptText } = args;
88
+
89
+ // Build a clean register entry (only carry through the optional fields when
90
+ // present so the persisted entry stays tidy). registry.add validates shape.
91
+ // `mode` MUST ride along: a reference-mode tool has NO invoke at all, and dropping the field
92
+ // here made the agent path reject every reference tool with "entry.invoke is required" while
93
+ // the UI path accepted them.
94
+ const entry = { id, name };
95
+ if (invoke !== undefined) entry.invoke = invoke;
96
+ if (mode !== undefined) entry.mode = mode;
97
+ if (summary !== undefined) entry.summary = summary;
98
+ if (category !== undefined) entry.category = category;
99
+ if (instructions !== undefined) entry.instructions = instructions;
100
+ if (inputSchema !== undefined) entry.inputSchema = inputSchema; // validated by registry.add
101
+
102
+
103
+ const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
104
+
105
+ // When a script body is supplied for a script invoke, write it FIRST so the
106
+ // entry never references a missing file once add() succeeds.
107
+ let scriptPath;
108
+ if (typeof scriptText === 'string' && invoke && invoke.type === 'script') {
109
+ scriptPath = registry.writeScript(invoke.path, scriptText);
110
+ }
111
+
112
+ const added = registry.add(entry);
113
+ const result = { ok: true, ...added };
114
+ if (scriptPath) result.scriptPath = scriptPath;
115
+ return result;
116
+ }
117
+
118
+ function main() {
119
+ const parsed = parseStructuredArgs();
120
+ let payload;
121
+ if (parsed.parseError) {
122
+ payload = { ok: false, error: parsed.parseError };
123
+ } else {
124
+ try {
125
+ payload = run(parsed.value);
126
+ } catch (err) {
127
+ payload = { ok: false, error: (err && err.message) || String(err) };
128
+ }
129
+ }
130
+ process.stdout.write(JSON.stringify(payload) + '\n');
131
+ process.exit(0);
132
+ }
133
+
134
+ main();
@@ -1,142 +1,142 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-tool-set.js - a first-party MANAGEMENT tool that toggles or removes a tool.
6
- *
7
- * Purpose
8
- * -------
9
- * The state-side counterpart to tf-tool-add: it flips a tool's ACTIVE/DISABLED
10
- * overlay flag (enable / disable) or fully removes a tool (drops the register
11
- * entry AND clears its state overlay). Register/state edits are visible to the
12
- * meta-tools on the next call - no reconnect.
13
- *
14
- * This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the per-tool
15
- * state overlay, and for `remove`, the register too). It runs through the same
16
- * gated run path as any other script tool.
17
- *
18
- * Contract with the register (registry.js `defaultRunScript`)
19
- * ----------------------------------------------------------
20
- * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
21
- * - Args: { id, action: 'enable' | 'disable' | 'hot' | 'unhot' | 'hide' | 'unhide' | 'remove' }.
22
- * enable/disable -> setToolEnabled(state, id, bool) (overlay merge; preserves hidden/hot)
23
- * hot/unhot -> setToolHot(state, id, bool) (overlay merge; preserves enabled/hidden)
24
- * - promote/demote the tool on the TOP-LEVEL every-turn surface (the matrix).
25
- * hide/unhide -> setToolHidden(state, id, bool) (overlay merge; preserves enabled/hot)
26
- * - declutter the MANAGER views only (tf_list + the UI); does NOT change
27
- * what the connected AI sees (the lean list / top-level surface).
28
- * remove -> registry.remove(id) AND clearToolState(state, id)
29
- * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
30
- * a logical error (reported as { ok:false, error }), never a thrown exception.
31
- *
32
- * Output (stdout), exactly one JSON object:
33
- * enable/disable: { ok:true, id, action, enabled, state }
34
- * hot/unhot: { ok:true, id, action, hot, state }
35
- * hide/unhide: { ok:true, id, action, hidden, state }
36
- * remove: { ok:true, id, action, removed:true, state }
37
- * failure: { ok:false, error }
38
- * (`state` is the resulting overlay map after the operation.)
39
- *
40
- * Safety invariants:
41
- * - Touches ONLY config files inside the toolfunnel root, resolved from THIS
42
- * script's own location (__dirname) - never a caller-supplied path.
43
- * - NO network. Writes are atomic (temp + fsync + rename) via the store modules.
44
- * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
45
- */
46
-
47
- const path = require('node:path');
48
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
49
- const { HOME, srcRequire } = require('./tf-env');
50
- const { loadRegistry } = srcRequire('tools/registry');
51
- const { setToolEnabled, setToolHot, setToolHidden, clearToolState } = srcRequire('tools/tool-state');
52
-
53
- const ROOT = HOME;
54
- const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
55
- const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
56
- const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
57
-
58
- /**
59
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
60
- * @returns {{ value: any } | { parseError: string }}
61
- */
62
- function parseStructuredArgs() {
63
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
64
- if (raw === undefined || raw === null || raw === '') {
65
- return { value: null };
66
- }
67
- try {
68
- return { value: JSON.parse(raw) };
69
- } catch (_err) {
70
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
71
- }
72
- }
73
-
74
- /**
75
- * Pure core: given the parsed args, perform the requested state/register change.
76
- * Throws are caught by main() and surfaced as { ok:false, error }; registry.remove
77
- * throws on an unknown id, setToolEnabled/clearToolState throw on a bad id.
78
- *
79
- * @param {any} args
80
- * @returns {object} the JSON-serialisable result
81
- */
82
- function run(args) {
83
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
84
- return { ok: false, error: "args must be an object { id, action: 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }" };
85
- }
86
- const { id, action } = args;
87
- if (typeof id !== 'string' || id.length === 0) {
88
- return { ok: false, error: 'id must be a non-empty string' };
89
- }
90
-
91
- if (action === 'enable' || action === 'disable') {
92
- const enabled = action === 'enable';
93
- const state = setToolEnabled(STATE_PATH, id, enabled);
94
- return { ok: true, id, action, enabled, state };
95
- }
96
-
97
- if (action === 'hot' || action === 'unhot') {
98
- // Promote (hot) / demote (unhot) the tool on the TOP-LEVEL every-turn surface. Keyed by the
99
- // surfaced name (a local id, an upstream surfaced name, or a meta-tool name) - the same key the
100
- // server's matrix assembler reads. The id is NOT verified against the register here: an upstream
101
- // surfaced name or a meta-tool name is a legitimate key with no register entry.
102
- const hot = action === 'hot';
103
- const state = setToolHot(STATE_PATH, id, hot);
104
- return { ok: true, id, action, hot, state };
105
- }
106
-
107
- if (action === 'hide' || action === 'unhide') {
108
- // Declutter the MANAGER views (tf_list + the UI) only - hide/unhide does NOT change the lean list
109
- // or the top-level surface the connected AI sees. Same id-not-verified policy as hot/unhot.
110
- const hidden = action === 'hide';
111
- const state = setToolHidden(STATE_PATH, id, hidden);
112
- return { ok: true, id, action, hidden, state };
113
- }
114
-
115
- if (action === 'remove') {
116
- // Drop the register entry first (throws on unknown id), then clear any overlay.
117
- const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
118
- registry.remove(id);
119
- const state = clearToolState(STATE_PATH, id);
120
- return { ok: true, id, action, removed: true, state };
121
- }
122
-
123
- return { ok: false, error: "action must be one of 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove'" };
124
- }
125
-
126
- function main() {
127
- const parsed = parseStructuredArgs();
128
- let payload;
129
- if (parsed.parseError) {
130
- payload = { ok: false, error: parsed.parseError };
131
- } else {
132
- try {
133
- payload = run(parsed.value);
134
- } catch (err) {
135
- payload = { ok: false, error: (err && err.message) || String(err) };
136
- }
137
- }
138
- process.stdout.write(JSON.stringify(payload) + '\n');
139
- process.exit(0);
140
- }
141
-
142
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-tool-set.js - a first-party MANAGEMENT tool that toggles or removes a tool.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * The state-side counterpart to tf-tool-add: it flips a tool's ACTIVE/DISABLED
10
+ * overlay flag (enable / disable) or fully removes a tool (drops the register
11
+ * entry AND clears its state overlay). Register/state edits are visible to the
12
+ * meta-tools on the next call - no reconnect.
13
+ *
14
+ * This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the per-tool
15
+ * state overlay, and for `remove`, the register too). It runs through the same
16
+ * gated run path as any other script tool.
17
+ *
18
+ * Contract with the register (registry.js `defaultRunScript`)
19
+ * ----------------------------------------------------------
20
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
21
+ * - Args: { id, action: 'enable' | 'disable' | 'hot' | 'unhot' | 'hide' | 'unhide' | 'remove' }.
22
+ * enable/disable -> setToolEnabled(state, id, bool) (overlay merge; preserves hidden/hot)
23
+ * hot/unhot -> setToolHot(state, id, bool) (overlay merge; preserves enabled/hidden)
24
+ * - promote/demote the tool on the TOP-LEVEL every-turn surface (the matrix).
25
+ * hide/unhide -> setToolHidden(state, id, bool) (overlay merge; preserves enabled/hot)
26
+ * - declutter the MANAGER views only (tf_list + the UI); does NOT change
27
+ * what the connected AI sees (the lean list / top-level surface).
28
+ * remove -> registry.remove(id) AND clearToolState(state, id)
29
+ * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
30
+ * a logical error (reported as { ok:false, error }), never a thrown exception.
31
+ *
32
+ * Output (stdout), exactly one JSON object:
33
+ * enable/disable: { ok:true, id, action, enabled, state }
34
+ * hot/unhot: { ok:true, id, action, hot, state }
35
+ * hide/unhide: { ok:true, id, action, hidden, state }
36
+ * remove: { ok:true, id, action, removed:true, state }
37
+ * failure: { ok:false, error }
38
+ * (`state` is the resulting overlay map after the operation.)
39
+ *
40
+ * Safety invariants:
41
+ * - Touches ONLY config files inside the toolfunnel root, resolved from THIS
42
+ * script's own location (__dirname) - never a caller-supplied path.
43
+ * - NO network. Writes are atomic (temp + fsync + rename) via the store modules.
44
+ * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
45
+ */
46
+
47
+ const path = require('node:path');
48
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
49
+ const { HOME, srcRequire } = require('./tf-env');
50
+ const { loadRegistry } = srcRequire('tools/registry');
51
+ const { setToolEnabled, setToolHot, setToolHidden, clearToolState } = srcRequire('tools/tool-state');
52
+
53
+ const ROOT = HOME;
54
+ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
55
+ const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
56
+ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
57
+
58
+ /**
59
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
60
+ * @returns {{ value: any } | { parseError: string }}
61
+ */
62
+ function parseStructuredArgs() {
63
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
64
+ if (raw === undefined || raw === null || raw === '') {
65
+ return { value: null };
66
+ }
67
+ try {
68
+ return { value: JSON.parse(raw) };
69
+ } catch (_err) {
70
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Pure core: given the parsed args, perform the requested state/register change.
76
+ * Throws are caught by main() and surfaced as { ok:false, error }; registry.remove
77
+ * throws on an unknown id, setToolEnabled/clearToolState throw on a bad id.
78
+ *
79
+ * @param {any} args
80
+ * @returns {object} the JSON-serialisable result
81
+ */
82
+ function run(args) {
83
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
84
+ return { ok: false, error: "args must be an object { id, action: 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }" };
85
+ }
86
+ const { id, action } = args;
87
+ if (typeof id !== 'string' || id.length === 0) {
88
+ return { ok: false, error: 'id must be a non-empty string' };
89
+ }
90
+
91
+ if (action === 'enable' || action === 'disable') {
92
+ const enabled = action === 'enable';
93
+ const state = setToolEnabled(STATE_PATH, id, enabled);
94
+ return { ok: true, id, action, enabled, state };
95
+ }
96
+
97
+ if (action === 'hot' || action === 'unhot') {
98
+ // Promote (hot) / demote (unhot) the tool on the TOP-LEVEL every-turn surface. Keyed by the
99
+ // surfaced name (a local id, an upstream surfaced name, or a meta-tool name) - the same key the
100
+ // server's matrix assembler reads. The id is NOT verified against the register here: an upstream
101
+ // surfaced name or a meta-tool name is a legitimate key with no register entry.
102
+ const hot = action === 'hot';
103
+ const state = setToolHot(STATE_PATH, id, hot);
104
+ return { ok: true, id, action, hot, state };
105
+ }
106
+
107
+ if (action === 'hide' || action === 'unhide') {
108
+ // Declutter the MANAGER views (tf_list + the UI) only - hide/unhide does NOT change the lean list
109
+ // or the top-level surface the connected AI sees. Same id-not-verified policy as hot/unhot.
110
+ const hidden = action === 'hide';
111
+ const state = setToolHidden(STATE_PATH, id, hidden);
112
+ return { ok: true, id, action, hidden, state };
113
+ }
114
+
115
+ if (action === 'remove') {
116
+ // Drop the register entry first (throws on unknown id), then clear any overlay.
117
+ const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
118
+ registry.remove(id);
119
+ const state = clearToolState(STATE_PATH, id);
120
+ return { ok: true, id, action, removed: true, state };
121
+ }
122
+
123
+ return { ok: false, error: "action must be one of 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove'" };
124
+ }
125
+
126
+ function main() {
127
+ const parsed = parseStructuredArgs();
128
+ let payload;
129
+ if (parsed.parseError) {
130
+ payload = { ok: false, error: parsed.parseError };
131
+ } else {
132
+ try {
133
+ payload = run(parsed.value);
134
+ } catch (err) {
135
+ payload = { ok: false, error: (err && err.message) || String(err) };
136
+ }
137
+ }
138
+ process.stdout.write(JSON.stringify(payload) + '\n');
139
+ process.exit(0);
140
+ }
141
+
142
+ main();