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.
- package/README.md +228 -231
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -372
- package/docs/KNOWN_BUGS.md +80 -99
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -70
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -350
- package/package.json +3 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -122
- package/src/extend/configure.md +88 -87
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1246
- package/src/mcp/expose-store.js +577 -460
- package/src/mcp/gated-run.js +210 -201
- package/src/mcp/http-transport.js +1563 -1311
- package/src/mcp/mcp-client.js +1070 -907
- package/src/mcp/modern.js +406 -372
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -2688
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1174
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1348
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* child-reaper.js - last-resort cleanup for spawned hook/tool children.
|
|
5
|
+
*
|
|
6
|
+
* The hook runner and both local executors spawn their children `detached` on POSIX so the
|
|
7
|
+
* process GROUP can be killed as a tree. setsid() has a consequence: the children leave the
|
|
8
|
+
* gateway's session, so a terminal Ctrl-C no longer reaches them, and a gateway that dies
|
|
9
|
+
* without executing its per-child kill path leaves them reparented to init running to their
|
|
10
|
+
* natural end - with their supervising timeout timers dead alongside the gateway.
|
|
11
|
+
*
|
|
12
|
+
* This module is the counterpart: every spawn site registers its child here and unregisters on
|
|
13
|
+
* settle; process exit / SIGINT / SIGTERM sweeps whatever is still tracked. Synchronous kills
|
|
14
|
+
* only - an 'exit' handler cannot await. Windows gets child.kill() (the shell only; taskkill is
|
|
15
|
+
* async and cannot run here) - best effort, matching the platform's weaker group semantics.
|
|
16
|
+
* A SIGKILLed gateway sweeps nothing; that limit is inherent to SIGKILL.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const live = new Set();
|
|
20
|
+
let installed = false;
|
|
21
|
+
|
|
22
|
+
function sweep() {
|
|
23
|
+
for (const child of live) {
|
|
24
|
+
try {
|
|
25
|
+
if (child.pid != null && process.platform !== 'win32') {
|
|
26
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
|
|
27
|
+
} else {
|
|
28
|
+
child.kill();
|
|
29
|
+
}
|
|
30
|
+
} catch (_e) { /* already gone */ }
|
|
31
|
+
}
|
|
32
|
+
live.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function install() {
|
|
36
|
+
if (installed) return;
|
|
37
|
+
installed = true;
|
|
38
|
+
process.on('exit', sweep);
|
|
39
|
+
// On a signal, sweep and re-raise the default behaviour by exiting: only do so if nobody
|
|
40
|
+
// else handles the signal (respect an embedding host's own handlers).
|
|
41
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
42
|
+
process.on(sig, () => {
|
|
43
|
+
sweep();
|
|
44
|
+
if (process.listenerCount(sig) === 1) process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register a spawned child for the last-resort sweep. Call untrack() when it settles. */
|
|
50
|
+
function track(child) {
|
|
51
|
+
if (!child || typeof child.kill !== 'function') return;
|
|
52
|
+
install();
|
|
53
|
+
live.add(child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove a settled child from the sweep set. Safe to call twice. */
|
|
57
|
+
function untrack(child) {
|
|
58
|
+
live.delete(child);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { track, untrack };
|
package/src/core/config-home.js
CHANGED
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
|
|
5
|
-
*
|
|
6
|
-
* The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
|
|
7
|
-
* directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
|
|
8
|
-
* hooks, curation, and identity. Config must be able to live in a home of its own:
|
|
9
|
-
*
|
|
10
|
-
* resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
|
|
11
|
-
*
|
|
12
|
-
* The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
|
|
13
|
-
* (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
|
|
14
|
-
* empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
|
|
15
|
-
* empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
|
|
16
|
-
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
-
* existing file, so a home survives package updates by construction.
|
|
18
|
-
*
|
|
19
|
-
* Two env vars are the process-tree contract (children inherit them via defaultRunScript's
|
|
20
|
-
* spawn env):
|
|
21
|
-
* TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
|
|
22
|
-
* can't skew between processes with different cwds).
|
|
23
|
-
* TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
|
|
24
|
-
* script SEEDED INTO an external home can still require the engine code
|
|
25
|
-
* (src/...) it was copied away from. A script always prefers its own
|
|
26
|
-
* __dirname-derived root when that root contains the engine (the git-clone
|
|
27
|
-
* layout), so a stale inherited value can never shadow a real local install.
|
|
28
|
-
*
|
|
29
|
-
* Zero new dependencies. Node built-ins only.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
const fs = require('node:fs');
|
|
33
|
-
const path = require('node:path');
|
|
34
|
-
|
|
35
|
-
/** The package root: this file lives at <pkg>/src/core/config-home.js. */
|
|
36
|
-
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Resolve the config home. Pure - no filesystem writes, no env mutation.
|
|
40
|
-
* @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
|
|
41
|
-
* @returns {string} absolute config-home path
|
|
42
|
-
*/
|
|
43
|
-
function resolveConfigHome(opts) {
|
|
44
|
-
const o = opts || {};
|
|
45
|
-
const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
|
|
46
|
-
const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
|
|
47
|
-
? process.env.TOOLFUNNEL_HOME.trim()
|
|
48
|
-
: null;
|
|
49
|
-
const chosen = fromFlag || fromEnv;
|
|
50
|
-
return chosen ? path.resolve(chosen) : PKG_ROOT;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Recursively copy a directory, skipping any destination file that already exists. */
|
|
54
|
-
function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
55
|
-
let entries;
|
|
56
|
-
try {
|
|
57
|
-
entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
58
|
-
} catch (_e) {
|
|
59
|
-
return; // a missing source dir seeds nothing (defensive)
|
|
60
|
-
}
|
|
61
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
62
|
-
for (const e of entries) {
|
|
63
|
-
const from = path.join(srcDir, e.name);
|
|
64
|
-
const to = path.join(destDir, e.name);
|
|
65
|
-
if (e.isDirectory()) {
|
|
66
|
-
copyTreeIfAbsent(from, to, seeded);
|
|
67
|
-
} else if (e.isFile() && !fs.existsSync(to)) {
|
|
68
|
-
fs.copyFileSync(from, to);
|
|
69
|
-
seeded.push(to);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
|
|
76
|
-
* Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
|
|
77
|
-
* package updates - that is the whole point). home === package root -> a no-op (the git-clone
|
|
78
|
-
* layout is already complete). Throws only if the home cannot be created at all.
|
|
79
|
-
*
|
|
80
|
-
* Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
|
|
81
|
-
* off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
|
|
82
|
-
* npm-wrap ships its OWN copy inside its bundled home).
|
|
83
|
-
*
|
|
84
|
-
* @param {string} home absolute config-home path (from resolveConfigHome)
|
|
85
|
-
* @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
|
|
86
|
-
*/
|
|
87
|
-
function ensureConfigHome(home) {
|
|
88
|
-
const seeded = [];
|
|
89
|
-
if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
|
|
90
|
-
fs.mkdirSync(home, { recursive: true });
|
|
91
|
-
|
|
92
|
-
// The register + every shipped script (management tf_* + demo tools). The management scripts
|
|
93
|
-
// are config-tree citizens (the register's invoke paths point at them), so they travel.
|
|
94
|
-
copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
|
|
95
|
-
// The state overlay is user-state, not a shipped default - drop a copied one if the package
|
|
96
|
-
// tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
|
|
97
|
-
const strayState = path.join(home, 'tools', 'tools.state.json');
|
|
98
|
-
if (seeded.includes(strayState)) {
|
|
99
|
-
try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
|
|
100
|
-
seeded.splice(seeded.indexOf(strayState), 1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// The upstream/curation store and the hooks tree. NOTE the npm tarball ships
|
|
104
|
-
// mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
|
|
105
|
-
// tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
|
|
106
|
-
// the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
|
|
107
|
-
// connected to nothing, exactly like a fresh clone.
|
|
108
|
-
copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
|
|
109
|
-
const exposePath = path.join(home, 'mcp', 'expose.json');
|
|
110
|
-
if (!fs.existsSync(exposePath)) {
|
|
111
|
-
fs.mkdirSync(path.dirname(exposePath), { recursive: true });
|
|
112
|
-
fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
|
|
113
|
-
seeded.push(exposePath);
|
|
114
|
-
}
|
|
115
|
-
copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
|
|
116
|
-
return { home, seeded };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
|
|
121
|
-
* process-tree contract back into the env (see the header) so every module loaded AFTER this -
|
|
122
|
-
* and every child tool spawned later - reads the same resolved home.
|
|
123
|
-
* @param {{dir?: string}} [opts]
|
|
124
|
-
* @returns {{home: string, seeded: string[]}}
|
|
125
|
-
*/
|
|
126
|
-
function initConfigHome(opts) {
|
|
127
|
-
const home = resolveConfigHome(opts);
|
|
128
|
-
const res = ensureConfigHome(home);
|
|
129
|
-
process.env.TOOLFUNNEL_HOME = res.home;
|
|
130
|
-
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
131
|
-
return res;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
|
|
5
|
+
*
|
|
6
|
+
* The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
|
|
7
|
+
* directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
|
|
8
|
+
* hooks, curation, and identity. Config must be able to live in a home of its own:
|
|
9
|
+
*
|
|
10
|
+
* resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
|
|
11
|
+
*
|
|
12
|
+
* The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
|
|
13
|
+
* (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
|
|
14
|
+
* empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
|
|
15
|
+
* empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
|
|
16
|
+
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
+
* existing file, so a home survives package updates by construction.
|
|
18
|
+
*
|
|
19
|
+
* Two env vars are the process-tree contract (children inherit them via defaultRunScript's
|
|
20
|
+
* spawn env):
|
|
21
|
+
* TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
|
|
22
|
+
* can't skew between processes with different cwds).
|
|
23
|
+
* TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
|
|
24
|
+
* script SEEDED INTO an external home can still require the engine code
|
|
25
|
+
* (src/...) it was copied away from. A script always prefers its own
|
|
26
|
+
* __dirname-derived root when that root contains the engine (the git-clone
|
|
27
|
+
* layout), so a stale inherited value can never shadow a real local install.
|
|
28
|
+
*
|
|
29
|
+
* Zero new dependencies. Node built-ins only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
/** The package root: this file lives at <pkg>/src/core/config-home.js. */
|
|
36
|
+
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the config home. Pure - no filesystem writes, no env mutation.
|
|
40
|
+
* @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
|
|
41
|
+
* @returns {string} absolute config-home path
|
|
42
|
+
*/
|
|
43
|
+
function resolveConfigHome(opts) {
|
|
44
|
+
const o = opts || {};
|
|
45
|
+
const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
|
|
46
|
+
const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
|
|
47
|
+
? process.env.TOOLFUNNEL_HOME.trim()
|
|
48
|
+
: null;
|
|
49
|
+
const chosen = fromFlag || fromEnv;
|
|
50
|
+
return chosen ? path.resolve(chosen) : PKG_ROOT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Recursively copy a directory, skipping any destination file that already exists. */
|
|
54
|
+
function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
return; // a missing source dir seeds nothing (defensive)
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
const from = path.join(srcDir, e.name);
|
|
64
|
+
const to = path.join(destDir, e.name);
|
|
65
|
+
if (e.isDirectory()) {
|
|
66
|
+
copyTreeIfAbsent(from, to, seeded);
|
|
67
|
+
} else if (e.isFile() && !fs.existsSync(to)) {
|
|
68
|
+
fs.copyFileSync(from, to);
|
|
69
|
+
seeded.push(to);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
|
|
76
|
+
* Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
|
|
77
|
+
* package updates - that is the whole point). home === package root -> a no-op (the git-clone
|
|
78
|
+
* layout is already complete). Throws only if the home cannot be created at all.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
|
|
81
|
+
* off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
|
|
82
|
+
* npm-wrap ships its OWN copy inside its bundled home).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} home absolute config-home path (from resolveConfigHome)
|
|
85
|
+
* @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
|
|
86
|
+
*/
|
|
87
|
+
function ensureConfigHome(home) {
|
|
88
|
+
const seeded = [];
|
|
89
|
+
if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
|
|
90
|
+
fs.mkdirSync(home, { recursive: true });
|
|
91
|
+
|
|
92
|
+
// The register + every shipped script (management tf_* + demo tools). The management scripts
|
|
93
|
+
// are config-tree citizens (the register's invoke paths point at them), so they travel.
|
|
94
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
|
|
95
|
+
// The state overlay is user-state, not a shipped default - drop a copied one if the package
|
|
96
|
+
// tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
|
|
97
|
+
const strayState = path.join(home, 'tools', 'tools.state.json');
|
|
98
|
+
if (seeded.includes(strayState)) {
|
|
99
|
+
try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
|
|
100
|
+
seeded.splice(seeded.indexOf(strayState), 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The upstream/curation store and the hooks tree. NOTE the npm tarball ships
|
|
104
|
+
// mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
|
|
105
|
+
// tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
|
|
106
|
+
// the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
|
|
107
|
+
// connected to nothing, exactly like a fresh clone.
|
|
108
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
|
|
109
|
+
const exposePath = path.join(home, 'mcp', 'expose.json');
|
|
110
|
+
if (!fs.existsSync(exposePath)) {
|
|
111
|
+
fs.mkdirSync(path.dirname(exposePath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
|
|
113
|
+
seeded.push(exposePath);
|
|
114
|
+
}
|
|
115
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
|
|
116
|
+
return { home, seeded };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
|
|
121
|
+
* process-tree contract back into the env (see the header) so every module loaded AFTER this -
|
|
122
|
+
* and every child tool spawned later - reads the same resolved home.
|
|
123
|
+
* @param {{dir?: string}} [opts]
|
|
124
|
+
* @returns {{home: string, seeded: string[]}}
|
|
125
|
+
*/
|
|
126
|
+
function initConfigHome(opts) {
|
|
127
|
+
const home = resolveConfigHome(opts);
|
|
128
|
+
const res = ensureConfigHome(home);
|
|
129
|
+
process.env.TOOLFUNNEL_HOME = res.home;
|
|
130
|
+
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
131
|
+
return res;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
|
package/src/core/events.js
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* events.js - lifecycle event names + per-event stdin payload builder.
|
|
5
|
-
*
|
|
6
|
-
* This is the contract surface
|
|
7
|
-
* (the exact stdin JSON shape). Field names are FROZEN
|
|
8
|
-
* match Claude Code's documented hook input byte-for-byte so the real hook
|
|
9
|
-
* scripts run unchanged. Do not rename or add fields.
|
|
10
|
-
*
|
|
11
|
-
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The six lifecycle events v3 supports
|
|
16
|
-
* Values equal the `hook_event_name` written into the stdin payload.
|
|
17
|
-
* Frozen so callers can rely on identity comparison and can't mutate the set.
|
|
18
|
-
*/
|
|
19
|
-
const EVENTS = Object.freeze({
|
|
20
|
-
SessionStart: 'SessionStart',
|
|
21
|
-
UserPromptSubmit: 'UserPromptSubmit',
|
|
22
|
-
PreToolUse: 'PreToolUse',
|
|
23
|
-
PostToolUse: 'PostToolUse',
|
|
24
|
-
Stop: 'Stop',
|
|
25
|
-
PreCompact: 'PreCompact',
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// Set of valid event names for fast membership checks / validation.
|
|
29
|
-
const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Build the stdin JSON object the runner pipes to a hook command.
|
|
33
|
-
*
|
|
34
|
-
* Every event carries the four common fields
|
|
35
|
-
* session_id, transcript_path, cwd, hook_event_name
|
|
36
|
-
* supplied by `ctx`. Each event then adds its own fields from `extra`:
|
|
37
|
-
*
|
|
38
|
-
* SessionStart -> source : "startup" | "resume" | "compact"
|
|
39
|
-
* UserPromptSubmit -> prompt : "<user text>"
|
|
40
|
-
* PreToolUse -> tool_name, tool_input
|
|
41
|
-
* PostToolUse -> tool_name, tool_input, tool_response
|
|
42
|
-
* Stop -> stop_hook_active : true | false
|
|
43
|
-
* PreCompact -> trigger ("manual"|"auto"), custom_instructions
|
|
44
|
-
*
|
|
45
|
-
* @param {string} event one of EVENTS (the hook_event_name).
|
|
46
|
-
* @param {object} ctx common fields: { session_id, transcript_path, cwd }.
|
|
47
|
-
* @param {object} [extra] event-specific fields (see table above).
|
|
48
|
-
* @returns {object} the stdin payload, with frozen field names
|
|
49
|
-
* @throws {Error} if `event` is not one of the six known events.
|
|
50
|
-
*/
|
|
51
|
-
function buildPayload(event, ctx, extra) {
|
|
52
|
-
if (!EVENT_NAMES.includes(event)) {
|
|
53
|
-
throw new Error(`buildPayload: unknown event "${event}"`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const c = ctx || {};
|
|
57
|
-
const e = extra || {};
|
|
58
|
-
|
|
59
|
-
// Common fields - present on EVERY event, in
|
|
60
|
-
// hook_event_name is always the canonical event string (not whatever ctx held).
|
|
61
|
-
const payload = {
|
|
62
|
-
session_id: c.session_id,
|
|
63
|
-
transcript_path: c.transcript_path,
|
|
64
|
-
cwd: c.cwd,
|
|
65
|
-
hook_event_name: event,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// Event-specific additions. Each branch sets EXACTLY the fields
|
|
69
|
-
// no more, no fewer - so the payload matches Claude Code's shape precisely.
|
|
70
|
-
switch (event) {
|
|
71
|
-
case EVENTS.SessionStart:
|
|
72
|
-
payload.source = e.source;
|
|
73
|
-
break;
|
|
74
|
-
|
|
75
|
-
case EVENTS.UserPromptSubmit:
|
|
76
|
-
payload.prompt = e.prompt;
|
|
77
|
-
break;
|
|
78
|
-
|
|
79
|
-
case EVENTS.PreToolUse:
|
|
80
|
-
payload.tool_name = e.tool_name;
|
|
81
|
-
payload.tool_input = e.tool_input;
|
|
82
|
-
break;
|
|
83
|
-
|
|
84
|
-
case EVENTS.PostToolUse:
|
|
85
|
-
payload.tool_name = e.tool_name;
|
|
86
|
-
payload.tool_input = e.tool_input;
|
|
87
|
-
payload.tool_response = e.tool_response;
|
|
88
|
-
break;
|
|
89
|
-
|
|
90
|
-
case EVENTS.Stop:
|
|
91
|
-
payload.stop_hook_active = e.stop_hook_active;
|
|
92
|
-
break;
|
|
93
|
-
|
|
94
|
-
case EVENTS.PreCompact:
|
|
95
|
-
payload.trigger = e.trigger;
|
|
96
|
-
payload.custom_instructions = e.custom_instructions;
|
|
97
|
-
break;
|
|
98
|
-
|
|
99
|
-
// No default: the membership check above already rejected unknown events.
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return payload;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
module.exports = { EVENTS, EVENT_NAMES, buildPayload };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* events.js - lifecycle event names + per-event stdin payload builder.
|
|
5
|
+
*
|
|
6
|
+
* This is the hook contract surface: the six lifecycle events and
|
|
7
|
+
* (the exact stdin JSON shape). Field names are FROZEN - they are the hook wire contract and must
|
|
8
|
+
* match Claude Code's documented hook input byte-for-byte so the real hook
|
|
9
|
+
* scripts run unchanged. Do not rename or add fields.
|
|
10
|
+
*
|
|
11
|
+
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The six lifecycle events v3 supports.
|
|
16
|
+
* Values equal the `hook_event_name` written into the stdin payload.
|
|
17
|
+
* Frozen so callers can rely on identity comparison and can't mutate the set.
|
|
18
|
+
*/
|
|
19
|
+
const EVENTS = Object.freeze({
|
|
20
|
+
SessionStart: 'SessionStart',
|
|
21
|
+
UserPromptSubmit: 'UserPromptSubmit',
|
|
22
|
+
PreToolUse: 'PreToolUse',
|
|
23
|
+
PostToolUse: 'PostToolUse',
|
|
24
|
+
Stop: 'Stop',
|
|
25
|
+
PreCompact: 'PreCompact',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Set of valid event names for fast membership checks / validation.
|
|
29
|
+
const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the stdin JSON object the runner pipes to a hook command.
|
|
33
|
+
*
|
|
34
|
+
* Every event carries the same four common fields:
|
|
35
|
+
* session_id, transcript_path, cwd, hook_event_name
|
|
36
|
+
* supplied by `ctx`. Each event then adds its own fields from `extra`:
|
|
37
|
+
*
|
|
38
|
+
* SessionStart -> source : "startup" | "resume" | "compact"
|
|
39
|
+
* UserPromptSubmit -> prompt : "<user text>"
|
|
40
|
+
* PreToolUse -> tool_name, tool_input
|
|
41
|
+
* PostToolUse -> tool_name, tool_input, tool_response
|
|
42
|
+
* Stop -> stop_hook_active : true | false
|
|
43
|
+
* PreCompact -> trigger ("manual"|"auto"), custom_instructions
|
|
44
|
+
*
|
|
45
|
+
* @param {string} event one of EVENTS (the hook_event_name).
|
|
46
|
+
* @param {object} ctx common fields: { session_id, transcript_path, cwd }.
|
|
47
|
+
* @param {object} [extra] event-specific fields (see table above).
|
|
48
|
+
* @returns {object} the stdin payload, with the contract's frozen field names.
|
|
49
|
+
* @throws {Error} if `event` is not one of the six known events.
|
|
50
|
+
*/
|
|
51
|
+
function buildPayload(event, ctx, extra) {
|
|
52
|
+
if (!EVENT_NAMES.includes(event)) {
|
|
53
|
+
throw new Error(`buildPayload: unknown event "${event}"`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const c = ctx || {};
|
|
57
|
+
const e = extra || {};
|
|
58
|
+
|
|
59
|
+
// Common fields - present on EVERY event, in their contract order.
|
|
60
|
+
// hook_event_name is always the canonical event string (not whatever ctx held).
|
|
61
|
+
const payload = {
|
|
62
|
+
session_id: c.session_id,
|
|
63
|
+
transcript_path: c.transcript_path,
|
|
64
|
+
cwd: c.cwd,
|
|
65
|
+
hook_event_name: event,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Event-specific additions. Each branch sets EXACTLY the fields the contract names -
|
|
69
|
+
// no more, no fewer - so the payload matches Claude Code's shape precisely.
|
|
70
|
+
switch (event) {
|
|
71
|
+
case EVENTS.SessionStart:
|
|
72
|
+
payload.source = e.source;
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case EVENTS.UserPromptSubmit:
|
|
76
|
+
payload.prompt = e.prompt;
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case EVENTS.PreToolUse:
|
|
80
|
+
payload.tool_name = e.tool_name;
|
|
81
|
+
payload.tool_input = e.tool_input;
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case EVENTS.PostToolUse:
|
|
85
|
+
payload.tool_name = e.tool_name;
|
|
86
|
+
payload.tool_input = e.tool_input;
|
|
87
|
+
payload.tool_response = e.tool_response;
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case EVENTS.Stop:
|
|
91
|
+
payload.stop_hook_active = e.stop_hook_active;
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case EVENTS.PreCompact:
|
|
95
|
+
payload.trigger = e.trigger;
|
|
96
|
+
payload.custom_instructions = e.custom_instructions;
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
// No default: the membership check above already rejected unknown events.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return payload;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { EVENTS, EVENT_NAMES, buildPayload };
|