toolfunnel 0.7.0 → 0.8.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 (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -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 -39
  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 +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -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();
@@ -1,120 +1,120 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-hook-set.js - management tool: enable, disable, or remove a hook entry.
6
- *
7
- * A first-party "gateway-run" management function: it configures ToolFunnel's
8
- * OWN hook manifest / enabled-state overlay. Discovered via list_tools and
9
- * executed via run_tool through the PreToolUse gate - it is NOT a new
10
- * 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, action: "enable" | "disable" | "remove" }.
16
- * - Prints EXACTLY ONE JSON object to stdout and exits 0 - ALWAYS exit 0, even
17
- * on a logical error, which is reported as { ok:false, error }.
18
- *
19
- * Output (stdout), exactly one JSON object:
20
- * enable/disable: { ok:true, id, action, enabled }
21
- * remove: { ok:true, id, action, removed }
22
- * failure: { ok:false, error }
23
- *
24
- * Mapping (verified against src/core/hook-loader.js):
25
- * - enable -> loader.setEnabled(id, true) (persists the state overlay + manifest;
26
- * returns false on a miss - no manifest row, no detected script - and
27
- * writes NOTHING, so a miss is reported as { ok:false, error })
28
- * - disable -> loader.setEnabled(id, false) (same miss contract)
29
- * - remove -> loader.removeEntry(id) (returns false if the id was absent;
30
- * that is information, not a fault, so ok stays true)
31
- *
32
- * Safety invariants:
33
- * - Touches only ToolFunnel's own manifest / state (resolved from __dirname).
34
- * - NO network. NEVER throws for ordinary conditions - only JSON on stdout.
35
- */
36
-
37
- const path = require('node:path');
38
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
39
- const { HOME, srcRequire } = require('./tf-env');
40
- const { loadManifest } = srcRequire('core/hook-loader');
41
-
42
- const ROOT = HOME;
43
-
44
- /** The hook manifest, relative to ROOT. */
45
- const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
46
-
47
- /**
48
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
49
- * is treated as an empty object. Returns the parsed value or a parse-error
50
- * sentinel so the caller surfaces a clean { ok:false, error }.
51
- *
52
- * @returns {{ value: any } | { parseError: string }}
53
- */
54
- function parseStructuredArgs() {
55
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
56
- if (raw === undefined || raw === null || raw === '') {
57
- return { value: {} };
58
- }
59
- try {
60
- return { value: JSON.parse(raw) };
61
- } catch (_err) {
62
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
63
- }
64
- }
65
-
66
- /**
67
- * Pure-ish core: validate args and apply the requested action. Never throws for
68
- * ordinary conditions (any loader throw is caught in main).
69
- *
70
- * @param {any} args
71
- * @returns {object} the JSON-serialisable result
72
- */
73
- function run(args) {
74
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
75
- return { ok: false, error: 'args must be an object { id, action }' };
76
- }
77
-
78
- const { id, action } = args;
79
- if (typeof id !== 'string' || id.length === 0) {
80
- return { ok: false, error: 'id must be a non-empty string' };
81
- }
82
- if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
83
- return { ok: false, error: 'action must be one of "enable", "disable", "remove"' };
84
- }
85
-
86
- const loader = loadManifest(MANIFEST);
87
-
88
- if (action === 'remove') {
89
- const removed = loader.removeEntry(id);
90
- return { ok: true, id, action, removed };
91
- }
92
-
93
- const desired = action === 'enable';
94
- // setEnabled reports whether the toggle matched anything (a manifest row or a
95
- // detected script). A miss writes nothing, so claiming ok:true here would report
96
- // a gate toggled when nothing changed.
97
- const applied = loader.setEnabled(id, desired);
98
- if (!applied) {
99
- return { ok: false, error: `no hook with id "${id}" - nothing was changed` };
100
- }
101
- return { ok: true, id, action, enabled: desired };
102
- }
103
-
104
- function main() {
105
- const parsed = parseStructuredArgs();
106
- let payload;
107
- if (parsed.parseError) {
108
- payload = { ok: false, error: parsed.parseError };
109
- } else {
110
- try {
111
- payload = run(parsed.value);
112
- } catch (err) {
113
- payload = { ok: false, error: (err && err.message) || String(err) };
114
- }
115
- }
116
- process.stdout.write(JSON.stringify(payload) + '\n');
117
- process.exit(0);
118
- }
119
-
120
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-hook-set.js - management tool: enable, disable, or remove a hook entry.
6
+ *
7
+ * A first-party "gateway-run" management function: it configures ToolFunnel's
8
+ * OWN hook manifest / enabled-state overlay. Discovered via list_tools and
9
+ * executed via run_tool through the PreToolUse gate - it is NOT a new
10
+ * 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, action: "enable" | "disable" | "remove" }.
16
+ * - Prints EXACTLY ONE JSON object to stdout and exits 0 - ALWAYS exit 0, even
17
+ * on a logical error, which is reported as { ok:false, error }.
18
+ *
19
+ * Output (stdout), exactly one JSON object:
20
+ * enable/disable: { ok:true, id, action, enabled }
21
+ * remove: { ok:true, id, action, removed }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Mapping (verified against src/core/hook-loader.js):
25
+ * - enable -> loader.setEnabled(id, true) (persists the state overlay + manifest;
26
+ * returns false on a miss - no manifest row, no detected script - and
27
+ * writes NOTHING, so a miss is reported as { ok:false, error })
28
+ * - disable -> loader.setEnabled(id, false) (same miss contract)
29
+ * - remove -> loader.removeEntry(id) (returns false if the id was absent;
30
+ * that is information, not a fault, so ok stays true)
31
+ *
32
+ * Safety invariants:
33
+ * - Touches only ToolFunnel's own manifest / state (resolved from __dirname).
34
+ * - NO network. NEVER throws for ordinary conditions - only JSON on stdout.
35
+ */
36
+
37
+ const path = require('node:path');
38
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
39
+ const { HOME, srcRequire } = require('./tf-env');
40
+ const { loadManifest } = srcRequire('core/hook-loader');
41
+
42
+ const ROOT = HOME;
43
+
44
+ /** The hook manifest, relative to ROOT. */
45
+ const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
46
+
47
+ /**
48
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
49
+ * is treated as an empty object. Returns the parsed value or a parse-error
50
+ * sentinel so the caller surfaces a clean { ok:false, error }.
51
+ *
52
+ * @returns {{ value: any } | { parseError: string }}
53
+ */
54
+ function parseStructuredArgs() {
55
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
56
+ if (raw === undefined || raw === null || raw === '') {
57
+ return { value: {} };
58
+ }
59
+ try {
60
+ return { value: JSON.parse(raw) };
61
+ } catch (_err) {
62
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Pure-ish core: validate args and apply the requested action. Never throws for
68
+ * ordinary conditions (any loader throw is caught in main).
69
+ *
70
+ * @param {any} args
71
+ * @returns {object} the JSON-serialisable result
72
+ */
73
+ function run(args) {
74
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
75
+ return { ok: false, error: 'args must be an object { id, action }' };
76
+ }
77
+
78
+ const { id, action } = args;
79
+ if (typeof id !== 'string' || id.length === 0) {
80
+ return { ok: false, error: 'id must be a non-empty string' };
81
+ }
82
+ if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
83
+ return { ok: false, error: 'action must be one of "enable", "disable", "remove"' };
84
+ }
85
+
86
+ const loader = loadManifest(MANIFEST);
87
+
88
+ if (action === 'remove') {
89
+ const removed = loader.removeEntry(id);
90
+ return { ok: true, id, action, removed };
91
+ }
92
+
93
+ const desired = action === 'enable';
94
+ // setEnabled reports whether the toggle matched anything (a manifest row or a
95
+ // detected script). A miss writes nothing, so claiming ok:true here would report
96
+ // a gate toggled when nothing changed.
97
+ const applied = loader.setEnabled(id, desired);
98
+ if (!applied) {
99
+ return { ok: false, error: `no hook with id "${id}" - nothing was changed` };
100
+ }
101
+ return { ok: true, id, action, enabled: desired };
102
+ }
103
+
104
+ function main() {
105
+ const parsed = parseStructuredArgs();
106
+ let payload;
107
+ if (parsed.parseError) {
108
+ payload = { ok: false, error: parsed.parseError };
109
+ } else {
110
+ try {
111
+ payload = run(parsed.value);
112
+ } catch (err) {
113
+ payload = { ok: false, error: (err && err.message) || String(err) };
114
+ }
115
+ }
116
+ process.stdout.write(JSON.stringify(payload) + '\n');
117
+ process.exit(0);
118
+ }
119
+
120
+ main();