toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * base64-tool.js — a safe, dependency-free local Base64 codec tool.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Encode UTF-8 text to standard Base64, or decode Base64 back to UTF-8 text.
10
+ * A genuinely useful utility for the Tool Manager: inspecting / producing
11
+ * Base64 blobs is a frequent chore and this gives a gated, no-side-effect way
12
+ * to do it. Uses ONLY Node built-ins (Buffer) — no network, no filesystem.
13
+ *
14
+ * Contract with the register (the register's `defaultRunScript`)
15
+ * ----------------------------------------------------------
16
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
17
+ * - Args: { op: "encode" | "decode", text: <string> }.
18
+ * - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even
19
+ * on bad input. Bad input is reported as { ok:false, error } rather than a
20
+ * thrown exception, because the gated run-path treats exit-0-with-parseable
21
+ * stdout as a completed run.
22
+ *
23
+ * Output (stdout), exactly one JSON object:
24
+ * success: { ok:true, op, result }
25
+ * failure: { ok:false, error }
26
+ *
27
+ * Notes on tolerance
28
+ * ------------------
29
+ * - encode: any string is encodable. A non-string `text` is rejected.
30
+ * - decode: Node's Buffer.from(x,'base64') is itself lenient (it skips
31
+ * non-base64 chars), so to give callers a MEANINGFUL "bad base64" signal we
32
+ * validate the input shape first and round-trip-check the result. If the
33
+ * re-encoded form does not match a normalised version of the input, we
34
+ * report ok:false rather than silently returning garbage.
35
+ *
36
+ * Safety invariants (mirror the isolation rule):
37
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
38
+ * - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
39
+ */
40
+
41
+ /**
42
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
43
+ * Returns the parsed value, or a sentinel describing a parse failure so the
44
+ * caller can surface a clean { ok:false, error } instead of crashing.
45
+ *
46
+ * @returns {{ value: any } | { parseError: string }}
47
+ */
48
+ function parseStructuredArgs() {
49
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
50
+ if (raw === undefined || raw === null || raw === '') {
51
+ return { value: null };
52
+ }
53
+ try {
54
+ return { value: JSON.parse(raw) };
55
+ } catch (_err) {
56
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Decode tolerantly: validate that the input looks like Base64, decode it, and
62
+ * round-trip-check so genuinely malformed input is reported rather than mangled.
63
+ *
64
+ * @param {string} text the candidate Base64 string
65
+ * @returns {{ ok:true, result:string } | { ok:false, error:string }}
66
+ */
67
+ function decodeTolerant(text) {
68
+ // Strip surrounding whitespace; Base64 may legitimately contain internal
69
+ // newlines (MIME wrapping), so remove all ASCII whitespace before validating.
70
+ const stripped = String(text).replace(/\s+/g, '');
71
+ if (stripped.length === 0) {
72
+ // Empty input decodes to empty string — a valid, unambiguous result.
73
+ return { ok: true, result: '' };
74
+ }
75
+ // Standard Base64 alphabet (with optional '=' padding). Reject anything else
76
+ // up front so the caller gets a clear "bad base64" rather than silent garbage.
77
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(stripped)) {
78
+ return { ok: false, error: 'input is not valid Base64' };
79
+ }
80
+ // Length of a Base64 string (sans padding-trimming nuance) must be a multiple
81
+ // of 4 once padding is included; reject obviously truncated input.
82
+ if (stripped.length % 4 !== 0) {
83
+ return { ok: false, error: 'input is not valid Base64 (bad length)' };
84
+ }
85
+ const buf = Buffer.from(stripped, 'base64');
86
+ // Round-trip: re-encode and compare to the normalised input. Buffer's decoder
87
+ // is lenient, so this catches inputs that "decode" but were never valid.
88
+ const reencoded = buf.toString('base64');
89
+ if (reencoded !== stripped) {
90
+ return { ok: false, error: 'input is not valid Base64' };
91
+ }
92
+ return { ok: true, result: buf.toString('utf8') };
93
+ }
94
+
95
+ /**
96
+ * Pure core: given the parsed args, produce the result object. Never throws.
97
+ *
98
+ * @param {any} args
99
+ * @returns {object} the JSON-serialisable result
100
+ */
101
+ function run(args) {
102
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
103
+ return { ok: false, error: 'args must be an object { op, text }' };
104
+ }
105
+ const { op, text } = args;
106
+ if (op !== 'encode' && op !== 'decode') {
107
+ return { ok: false, error: 'op must be "encode" or "decode"' };
108
+ }
109
+ if (typeof text !== 'string') {
110
+ return { ok: false, error: 'text must be a string' };
111
+ }
112
+ if (op === 'encode') {
113
+ return { ok: true, op: 'encode', result: Buffer.from(text, 'utf8').toString('base64') };
114
+ }
115
+ // op === 'decode'
116
+ const decoded = decodeTolerant(text);
117
+ if (!decoded.ok) return { ok: false, error: decoded.error };
118
+ return { ok: true, op: 'decode', result: decoded.result };
119
+ }
120
+
121
+ function main() {
122
+ const parsed = parseStructuredArgs();
123
+ let payload;
124
+ if (parsed.parseError) {
125
+ payload = { ok: false, error: parsed.parseError };
126
+ } else {
127
+ try {
128
+ payload = run(parsed.value);
129
+ } catch (err) {
130
+ // Defensive: run() is written not to throw, but never let an unexpected
131
+ // error escape as a non-zero exit / unparseable stdout.
132
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
133
+ }
134
+ }
135
+ process.stdout.write(JSON.stringify(payload) + '\n');
136
+ process.exit(0);
137
+ }
138
+
139
+ main();
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * danger-tool.js — a demo "dangerous" tool used to PROVE the gate works.
6
+ *
7
+ * The safety case is: `toolfunnel_run_tool` must route through the hook
8
+ * engine's PreToolUse gate, and a DENY there must mean the tool's side effect
9
+ * never happens.
10
+ *
11
+ * This tool's observable side effect is: it APPENDS one line to the file named
12
+ * by the env var TOOLFUNNEL_DANGER_LOG. A test arranges a PreToolUse hook that
13
+ * DENIES this tool, runs the gated path, and then asserts the log file was NOT
14
+ * written (or did not gain a line). If the gate had failed open, the line would
15
+ * appear.
16
+ *
17
+ * When it DOES run (gate allows, or run-direct), it:
18
+ * 1. appends `<ISO timestamp> danger-tool fired <args>\n` to TOOLFUNNEL_DANGER_LOG
19
+ * 2. prints a JSON confirmation to stdout
20
+ * 3. exits 0
21
+ *
22
+ * If TOOLFUNNEL_DANGER_LOG is unset, it still "fires" (prints, exits 0) but
23
+ * writes nothing — so it is never destructive on its own; the log is the only
24
+ * effect and the caller chooses the path.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+
29
+ function structuredArgs() {
30
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
31
+ if (!raw) return null;
32
+ try {
33
+ return JSON.parse(raw);
34
+ } catch (_err) {
35
+ return { _parseError: raw };
36
+ }
37
+ }
38
+
39
+ function main() {
40
+ const logPath = process.env.TOOLFUNNEL_DANGER_LOG;
41
+ const args = structuredArgs();
42
+ const argv = process.argv.slice(2);
43
+ let wrote = false;
44
+
45
+ if (logPath) {
46
+ const line =
47
+ new Date().toISOString() +
48
+ ' danger-tool fired ' +
49
+ JSON.stringify({ args, argv }) +
50
+ '\n';
51
+ fs.appendFileSync(logPath, line, 'utf8');
52
+ wrote = true;
53
+ }
54
+
55
+ process.stdout.write(
56
+ JSON.stringify({ ok: true, fired: true, wrote, logPath: logPath || null }) + '\n'
57
+ );
58
+ process.exit(0);
59
+ }
60
+
61
+ main();
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * echo-tool.js — a safe local demo tool.
6
+ *
7
+ * Prints a JSON object of the args it was invoked with, then exits 0.
8
+ * It has NO side effects: it touches no filesystem, no network, no env-driven
9
+ * writes. It exists so the register / run-path / gate tests have a benign tool
10
+ * to exercise (the "allowed" counterpart to danger-tool.js).
11
+ *
12
+ * Args are read two ways so callers can use whichever is convenient:
13
+ * 1. process.argv — positional CLI args, e.g. `node echo-tool.js a b c`
14
+ * 2. TOOLFUNNEL_TOOL_ARGS — a JSON-encoded object/array of structured args
15
+ * (this is how the register's "script" invoke passes `args` through).
16
+ *
17
+ * Output (stdout): a single JSON object { ok, args, argv }.
18
+ */
19
+
20
+ function parseStructuredArgs() {
21
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
22
+ if (!raw) return null;
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch (_err) {
26
+ // Malformed env — surface it rather than silently dropping.
27
+ return { _parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${raw}` };
28
+ }
29
+ }
30
+
31
+ function main() {
32
+ const argv = process.argv.slice(2);
33
+ const args = parseStructuredArgs();
34
+ const payload = { ok: true, args, argv };
35
+ process.stdout.write(JSON.stringify(payload) + '\n');
36
+ process.exit(0);
37
+ }
38
+
39
+ main();
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * hash-tool.js — a safe, dependency-free local cryptographic-digest tool.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Hash UTF-8 text with a chosen algorithm (sha256 default; sha1 / md5 also
10
+ * offered) and return the lowercase hex digest. Useful from the Tool Manager
11
+ * for checksums, fingerprinting, and quick integrity checks. Uses ONLY the
12
+ * Node built-in `node:crypto` — no network, no filesystem, no new deps.
13
+ *
14
+ * Contract with the register (the register's `defaultRunScript`)
15
+ * ----------------------------------------------------------
16
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
17
+ * - Args: { algo?: "sha256" | "sha1" | "md5", text: <string> }.
18
+ * `algo` defaults to "sha256" when omitted.
19
+ * - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even
20
+ * on bad input (reported as { ok:false, error }), never a thrown exception.
21
+ *
22
+ * Output (stdout), exactly one JSON object:
23
+ * success: { ok:true, algo, hexdigest }
24
+ * failure: { ok:false, error }
25
+ *
26
+ * Safety invariants (mirror the isolation rule):
27
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
28
+ * - Only an explicit allow-list of algorithms is accepted, so the tool can
29
+ * never be coaxed into an unexpected / unavailable OpenSSL algorithm name.
30
+ * - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
31
+ */
32
+
33
+ const crypto = require('node:crypto');
34
+
35
+ /** Algorithms this tool will accept (the names the task specifies). */
36
+ const ALLOWED_ALGOS = new Set(['sha256', 'sha1', 'md5']);
37
+ const DEFAULT_ALGO = 'sha256';
38
+
39
+ /**
40
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
41
+ * @returns {{ value: any } | { parseError: string }}
42
+ */
43
+ function parseStructuredArgs() {
44
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
45
+ if (raw === undefined || raw === null || raw === '') {
46
+ return { value: null };
47
+ }
48
+ try {
49
+ return { value: JSON.parse(raw) };
50
+ } catch (_err) {
51
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Pure core: given the parsed args, produce the result object. Never throws.
57
+ *
58
+ * @param {any} args
59
+ * @returns {object} the JSON-serialisable result
60
+ */
61
+ function run(args) {
62
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
63
+ return { ok: false, error: 'args must be an object { algo?, text }' };
64
+ }
65
+ const { text } = args;
66
+ const algo = args.algo === undefined || args.algo === null ? DEFAULT_ALGO : args.algo;
67
+ if (typeof algo !== 'string' || !ALLOWED_ALGOS.has(algo)) {
68
+ return { ok: false, error: `algo must be one of ${[...ALLOWED_ALGOS].join(', ')}` };
69
+ }
70
+ if (typeof text !== 'string') {
71
+ return { ok: false, error: 'text must be a string' };
72
+ }
73
+ // createHash can throw only for an unavailable algorithm; the allow-list above
74
+ // covers the universally-available ones, but wrap defensively just in case a
75
+ // hardened OpenSSL build (e.g. FIPS) disables md5/sha1.
76
+ let hexdigest;
77
+ try {
78
+ hexdigest = crypto.createHash(algo).update(text, 'utf8').digest('hex');
79
+ } catch (err) {
80
+ return { ok: false, error: `hash failed for algo "${algo}": ${(err && err.message) || String(err)}` };
81
+ }
82
+ return { ok: true, algo, hexdigest };
83
+ }
84
+
85
+ function main() {
86
+ const parsed = parseStructuredArgs();
87
+ let payload;
88
+ if (parsed.parseError) {
89
+ payload = { ok: false, error: parsed.parseError };
90
+ } else {
91
+ try {
92
+ payload = run(parsed.value);
93
+ } catch (err) {
94
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
95
+ }
96
+ }
97
+ process.stdout.write(JSON.stringify(payload) + '\n');
98
+ process.exit(0);
99
+ }
100
+
101
+ main();
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * json-tool.js — a safe, dependency-free local JSON formatter / validator.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Validate a JSON string and either pretty-print it (default) or minify it.
10
+ * Useful from the Tool Manager for tidying up JSON blobs or confirming a string
11
+ * is valid JSON. Pure JSON.parse / JSON.stringify — no network, no filesystem.
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>, indent?: <integer 0..10>, minify?: <boolean> }.
17
+ * indent defaults to 2 (used for the pretty form).
18
+ * minify true → return the compact (no-whitespace) form instead.
19
+ * - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even
20
+ * for invalid JSON input (reported as { ok:false, error }).
21
+ *
22
+ * Output (stdout), exactly one JSON object:
23
+ * pretty: { ok:true, pretty: "<indented json>" } (minify falsey)
24
+ * minify: { ok:true, minified: "<compact json>" } (minify true)
25
+ * failure: { ok:false, error } (invalid JSON / bad args)
26
+ *
27
+ * Safety invariants (mirror the isolation rule):
28
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
29
+ * - indent is clamped to a sane [0,10] range so a huge indent cannot blow up.
30
+ * - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
31
+ */
32
+
33
+ const MAX_INDENT = 10;
34
+ const DEFAULT_INDENT = 2;
35
+
36
+ /**
37
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
38
+ * @returns {{ value: any } | { parseError: string }}
39
+ */
40
+ function parseStructuredArgs() {
41
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
42
+ if (raw === undefined || raw === null || raw === '') {
43
+ return { value: null };
44
+ }
45
+ try {
46
+ return { value: JSON.parse(raw) };
47
+ } catch (_err) {
48
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Pure core: given the parsed args, produce the result object. Never throws.
54
+ *
55
+ * @param {any} args
56
+ * @returns {object} the JSON-serialisable result
57
+ */
58
+ function run(args) {
59
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
60
+ return { ok: false, error: 'args must be an object { text, indent?, minify? }' };
61
+ }
62
+ const { text } = args;
63
+ if (typeof text !== 'string') {
64
+ return { ok: false, error: 'text must be a string' };
65
+ }
66
+
67
+ // Validate indent (only meaningful for the pretty path, but validate eagerly).
68
+ let indent = DEFAULT_INDENT;
69
+ if (args.indent !== undefined && args.indent !== null) {
70
+ if (typeof args.indent !== 'number' || !Number.isInteger(args.indent)) {
71
+ return { ok: false, error: 'indent must be an integer' };
72
+ }
73
+ if (args.indent < 0 || args.indent > MAX_INDENT) {
74
+ return { ok: false, error: `indent must be between 0 and ${MAX_INDENT}` };
75
+ }
76
+ indent = args.indent;
77
+ }
78
+
79
+ // Parse the supplied JSON text — THIS is the validation step.
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(text);
83
+ } catch (err) {
84
+ return { ok: false, error: `invalid JSON: ${(err && err.message) || String(err)}` };
85
+ }
86
+
87
+ if (args.minify === true) {
88
+ return { ok: true, minified: JSON.stringify(parsed) };
89
+ }
90
+ return { ok: true, pretty: JSON.stringify(parsed, null, indent) };
91
+ }
92
+
93
+ function main() {
94
+ const parsed = parseStructuredArgs();
95
+ let payload;
96
+ if (parsed.parseError) {
97
+ payload = { ok: false, error: parsed.parseError };
98
+ } else {
99
+ try {
100
+ payload = run(parsed.value);
101
+ } catch (err) {
102
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
103
+ }
104
+ }
105
+ process.stdout.write(JSON.stringify(payload) + '\n');
106
+ process.exit(0);
107
+ }
108
+
109
+ main();
@@ -0,0 +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();