@secemp/elwood 0.1.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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/examples/01-basic-query.js +38 -0
  4. package/examples/02-bug-fixer.js +57 -0
  5. package/examples/03-custom-system-prompt.js +41 -0
  6. package/examples/04-read-only-agent.js +38 -0
  7. package/examples/05-find-todos.js +37 -0
  8. package/examples/06-session-resume.js +70 -0
  9. package/examples/07-hooks-pretooluse.js +74 -0
  10. package/examples/08-hooks-posttooluse-audit.js +66 -0
  11. package/examples/09-hooks-block-etc.js +68 -0
  12. package/examples/10-hooks-redirect-sandbox.js +70 -0
  13. package/examples/11-subagents.js +54 -0
  14. package/examples/12-mcp-stdio.js +48 -0
  15. package/examples/13-mcp-http.js +54 -0
  16. package/examples/14-custom-tool.js +84 -0
  17. package/examples/15-custom-tool-unit-converter.js +132 -0
  18. package/examples/16-mcp-github.js +71 -0
  19. package/examples/17-session-store-postgres.js +78 -0
  20. package/examples/18-session-store-redis.js +65 -0
  21. package/examples/19-session-store-s3.js +67 -0
  22. package/examples/20-session-list.js +72 -0
  23. package/examples/21-hooks-notification-slack.js +78 -0
  24. package/examples/22-hooks-webhook-posttooluse.js +78 -0
  25. package/examples/23-hooks-subagent-tracker.js +59 -0
  26. package/examples/24-v2-session-api.js +62 -0
  27. package/examples/README.md +95 -0
  28. package/examples/basic.js +240 -0
  29. package/examples/smoke-test.js +296 -0
  30. package/package.json +52 -0
  31. package/src/ast-tools.js +182 -0
  32. package/src/index.js +70 -0
  33. package/src/instrumenter.js +2921 -0
  34. package/src/loader.js +306 -0
  35. package/src/locate.js +296 -0
  36. package/src/query.js +2168 -0
package/src/loader.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * loader.js
3
+ *
4
+ * Ties together locate → parse → instrument → load.
5
+ *
6
+ * The instrumented code is written to a temp `.mjs` file under
7
+ * `/tmp/elwood-<hash>/` and loaded via dynamic `import()` so that:
8
+ * - The original ESM imports inside cli.js are honoured by the Node.js
9
+ * loader (they cannot be executed inside vm.runInNewContext).
10
+ * - We keep the hook injection clean — no eval(), no new Function().
11
+ *
12
+ * Teardown removes the temp directory when the caller is done.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
17
+ import { join } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+
20
+ import { locate } from './locate.js';
21
+ import { parseBundle, generateCode } from './ast-tools.js';
22
+ import { injectHooks, detectBundlerPattern, extractFunctionMap, findCliExports, injectCliExports } from './instrumenter.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types (JSDoc)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * @typedef {Object} LoadOptions
30
+ * @property {string} [cliPath] - Override the auto-located cli.js path.
31
+ * @property {boolean} [instrument] - Enable/disable instrumentation (default: true).
32
+ * @property {(string|RegExp)[]} [intercept] - Function name patterns to intercept.
33
+ * @property {string} [hookVar] - Global hook-registry name (default: '__elwoodHooks').
34
+ * @property {boolean} [wrapApiCalls] - Wrap fetch() API calls (default: true).
35
+ * @property {boolean} [wrapToolExecutions] - Wrap tool-execution patterns (default: true).
36
+ * @property {boolean} [wrapAsyncGenerators] - Wrap async generators (default: true).
37
+ * @property {boolean} [wrapFunctionCalls] - Wrap matched function declarations (default: true).
38
+ * @property {string} [tempDir] - Custom temp directory base (default: os.tmpdir()).
39
+ * @property {boolean} [keepTemp] - Do not delete temp files on teardown (default: false).
40
+ * @property {boolean} [verbose] - Print progress messages (default: false).
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} LoadResult
45
+ * @property {any} module - The default export of the dynamically imported module.
46
+ * @property {any} exports - All named exports of the dynamically imported module.
47
+ * @property {string} tempFile - Path to the temp instrumented file.
48
+ * @property {string} cliPath - Resolved path to cli.js.
49
+ * @property {string} version - cli.js package version string.
50
+ * @property {number} patchCount - Number of hook-injection sites inserted.
51
+ * @property {HookPoint[]} hookPoints - Metadata about each injection.
52
+ * @property {BundlerInfo} bundlerInfo - Detected bundler metadata.
53
+ * @property {() => void} teardown - Call this to delete the temp file when done.
54
+ */
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Generate a short content-addressed hash for a string.
62
+ *
63
+ * @param {string} content
64
+ * @returns {string} 12-character hex string
65
+ */
66
+ function contentHash(content) {
67
+ return createHash('sha256').update(content).digest('hex').slice(0, 12);
68
+ }
69
+
70
+ /**
71
+ * Write `content` to a fresh temp directory and return the file path.
72
+ *
73
+ * @param {string} content - JS source to write
74
+ * @param {string} baseTmpDir - base temp directory (e.g. /tmp)
75
+ * @param {boolean} keepTemp - if true, skip any existing temp to avoid re-parse
76
+ * @returns {{ filePath: string, dir: string, isReused: boolean }}
77
+ */
78
+ function writeTempModule(content, baseTmpDir, keepTemp) {
79
+ const hash = contentHash(content);
80
+ const dir = join(baseTmpDir, `elwood-${hash}`);
81
+ const filePath = join(dir, 'bundle.mjs');
82
+
83
+ if (keepTemp && existsSync(filePath)) {
84
+ return { filePath, dir, isReused: true };
85
+ }
86
+
87
+ mkdirSync(dir, { recursive: true });
88
+
89
+ // Write a package.json so Node treats this directory as ESM
90
+ const pkgPath = join(dir, 'package.json');
91
+ if (!existsSync(pkgPath)) {
92
+ writeFileSync(pkgPath, JSON.stringify({ type: 'module' }, null, 2), 'utf8');
93
+ }
94
+
95
+ writeFileSync(filePath, content, 'utf8');
96
+ return { filePath, dir, isReused: false };
97
+ }
98
+
99
+ /**
100
+ * Remove a temp directory tree.
101
+ *
102
+ * @param {string} dir
103
+ */
104
+ function removeTempDir(dir) {
105
+ try {
106
+ rmSync(dir, { recursive: true, force: true });
107
+ } catch {
108
+ // Best-effort cleanup — ignore errors
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // instrument()
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Parse and instrument a CLI bundle in memory, returning the generated code.
118
+ *
119
+ * This is the lower-level API — it does not load/import anything.
120
+ *
121
+ * @param {LoadOptions} [options]
122
+ * @returns {Promise<{
123
+ * code: string,
124
+ * cliPath: string,
125
+ * version: string,
126
+ * patchCount: number,
127
+ * hookPoints: import('./instrumenter.js').HookPoint[],
128
+ * bundlerInfo: import('./instrumenter.js').BundlerInfo,
129
+ * cliExportsFound: import('./instrumenter.js').CliExports | null,
130
+ * }>}
131
+ */
132
+ async function instrument(options = {}) {
133
+ const {
134
+ cliPath: cliPathOverride,
135
+ instrument: doInstrument = true,
136
+ intercept = [],
137
+ hookVar = '__elwoodHooks',
138
+ wrapApiCalls = true,
139
+ wrapToolExecutions = true,
140
+ wrapAsyncGenerators = true,
141
+ wrapFunctionCalls = true,
142
+ verbose = false,
143
+ } = options;
144
+
145
+ const log = verbose ? (...args) => console.log('[elwood]', ...args) : () => {};
146
+
147
+ // ── 1. Locate cli.js ──────────────────────────────────────────────────────
148
+ log('Locating Claude Code CLI...');
149
+ const location = cliPathOverride
150
+ ? {
151
+ cliPath: cliPathOverride,
152
+ sdkPath: null,
153
+ packageDir: '',
154
+ version: 'unknown',
155
+ }
156
+ : await locate();
157
+
158
+ const { cliPath, version } = location;
159
+ log(`Found: ${cliPath} (v${version})`);
160
+
161
+ // ── 2. Parse ──────────────────────────────────────────────────────────────
162
+ const originalSource = readFileSync(cliPath, 'utf8');
163
+ const fileSizeMB = (originalSource.length / 1024 / 1024).toFixed(2);
164
+ log(`Parsing ${fileSizeMB} MB bundle...`);
165
+
166
+ const ast = parseBundle(cliPath);
167
+ log('Parse complete.');
168
+
169
+ // ── 3. Detect bundler pattern ─────────────────────────────────────────────
170
+ log('Detecting bundler pattern...');
171
+ const bundlerInfo = detectBundlerPattern(ast);
172
+ log(`Bundler: ${bundlerInfo.bundler} (${bundlerInfo.clues[0] ?? 'unknown'})`);
173
+
174
+ // ── 4. Instrument ─────────────────────────────────────────────────────────
175
+ let patchCount = 0;
176
+ let hookPoints = [];
177
+
178
+ if (doInstrument) {
179
+ log('Injecting hooks...');
180
+ const result = injectHooks(ast, {
181
+ intercept,
182
+ hookVar,
183
+ wrapApiCalls,
184
+ wrapToolExecutions,
185
+ wrapAsyncGenerators,
186
+ wrapFunctionCalls,
187
+ });
188
+ patchCount = result.patchCount;
189
+ hookPoints = result.hookPoints;
190
+ log(`Injected ${patchCount} hook site(s) across ${hookPoints.length} function(s).`);
191
+ } else {
192
+ log('Instrumentation disabled — passing bundle through unchanged.');
193
+ }
194
+
195
+ // ── 4b. Find and inject CLI exports (agentLoop, appStateFactory, guard) ────
196
+ let cliExportsFound = null;
197
+ if (doInstrument) {
198
+ log('Fingerprinting CLI exports (agentLoop, appStateFactory)...');
199
+ try {
200
+ cliExportsFound = findCliExports(ast);
201
+ log(
202
+ `Found agentLoop="${cliExportsFound.agentLoop?.name}" ` +
203
+ `(confidence=${cliExportsFound.agentLoop?.confidence?.toFixed(2)}), ` +
204
+ `appStateFactory="${cliExportsFound.appStateFactory?.name}" ` +
205
+ `(confidence=${cliExportsFound.appStateFactory?.confidence?.toFixed(2)})`
206
+ );
207
+ injectCliExports(ast, cliExportsFound);
208
+ log('CLI export injections applied.');
209
+ } catch (err) {
210
+ log(`Warning: CLI export fingerprinting failed: ${err.message}`);
211
+ // Non-fatal: hooks still work, but query() won't be available
212
+ }
213
+ }
214
+
215
+ // ── 5. Generate code ──────────────────────────────────────────────────────
216
+ // Pass originalSource so @babel/generator uses source-range-based generation,
217
+ // preserving original minified identifier names (e.g. MC1, be, Be5, Ga0).
218
+ // Without this, @babel/generator regenerates identifiers from AST node IDs,
219
+ // producing different names (e.g. Hi8, ft8) that don't match the safeRef() calls
220
+ // in the injected register code.
221
+ log('Generating instrumented code...');
222
+ const { code } = generateCode(ast, {}, originalSource);
223
+ log(`Generated ${(code.length / 1024 / 1024).toFixed(2)} MB.`);
224
+
225
+ return { code, cliPath, version, patchCount, hookPoints, bundlerInfo, cliExportsFound };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // load()
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * Full pipeline: locate → parse → instrument → write temp file → dynamic import.
234
+ *
235
+ * @param {LoadOptions} [options]
236
+ * @returns {Promise<LoadResult>}
237
+ */
238
+ async function load(options = {}) {
239
+ const {
240
+ tempDir: tempDirBase = tmpdir(),
241
+ keepTemp = false,
242
+ verbose = false,
243
+ } = options;
244
+
245
+ const log = verbose ? (...args) => console.log('[elwood]', ...args) : () => {};
246
+
247
+ // ── Steps 1-5: instrument ─────────────────────────────────────────────────
248
+ const { code, cliPath, version, patchCount, hookPoints, bundlerInfo, cliExportsFound } =
249
+ await instrument(options);
250
+
251
+ // ── 6. Write to temp file ─────────────────────────────────────────────────
252
+ const { filePath: tempFile, dir: tempDirPath, isReused } = writeTempModule(
253
+ code,
254
+ tempDirBase,
255
+ keepTemp
256
+ );
257
+
258
+ if (isReused) {
259
+ log(`Reusing cached temp file: ${tempFile}`);
260
+ } else {
261
+ log(`Written instrumented bundle to: ${tempFile}`);
262
+ }
263
+
264
+ // ── 7. Dynamic import ─────────────────────────────────────────────────────
265
+ log('Dynamically importing instrumented bundle...');
266
+ let mod;
267
+ try {
268
+ mod = await import(/* @vite-ignore */ tempFile);
269
+ } catch (importErr) {
270
+ // Provide a helpful error message that distinguishes instrumentation
271
+ // problems from genuine cli.js errors.
272
+ throw new Error(
273
+ `elwood: Failed to import instrumented bundle at ${tempFile}.\n` +
274
+ `This may indicate an instrumentation error. Try load({ instrument: false })\n` +
275
+ `to verify the original bundle imports cleanly.\n\n` +
276
+ `Underlying error: ${importErr.message}`
277
+ );
278
+ }
279
+ log('Import successful.');
280
+
281
+ // ── 8. Build result ────────────────────────────────────────────────────────
282
+ const exports = { ...mod };
283
+ const defaultExport = mod.default ?? mod;
284
+
285
+ function teardown() {
286
+ if (!keepTemp) {
287
+ removeTempDir(tempDirPath);
288
+ log(`Removed temp dir: ${tempDirPath}`);
289
+ }
290
+ }
291
+
292
+ return {
293
+ module: defaultExport,
294
+ exports,
295
+ tempFile,
296
+ cliPath,
297
+ version,
298
+ patchCount,
299
+ hookPoints,
300
+ bundlerInfo,
301
+ cliExportsFound,
302
+ teardown,
303
+ };
304
+ }
305
+
306
+ export { load, instrument };
package/src/locate.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * locate.js
3
+ *
4
+ * Locates the installed Claude Code CLI (cli.js and optionally sdk.mjs).
5
+ *
6
+ * Resolution order:
7
+ * 0. Node.js module resolution via createRequire — cross-platform, no subprocess.
8
+ * Works on Windows/macOS/Linux as long as @anthropic-ai/claude-code is in
9
+ * the module graph (installed globally or locally).
10
+ * 1. `which` npm package (cross-platform, no subprocess) + symlink chain.
11
+ * 2. Enumerate nvm-managed Node.js versions.
12
+ * 3. Static fallback paths.
13
+ * 4. Derive from process.execPath.
14
+ */
15
+
16
+ import { createRequire } from 'node:module';
17
+ import {
18
+ existsSync,
19
+ realpathSync,
20
+ readFileSync,
21
+ readlinkSync,
22
+ readdirSync,
23
+ } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
25
+
26
+ // createRequire anchored to this file so resolution walks node_modules correctly
27
+ const _require = createRequire(import.meta.url);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Fallback package directory candidates
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Build a list of candidate @anthropic-ai/claude-code directories from all
35
+ * nvm-managed Node.js versions found under ~/.nvm.
36
+ */
37
+ function buildNvmFallbacksSync() {
38
+ const nvmDir = process.env.NVM_DIR ?? join(process.env.HOME ?? '~', '.nvm');
39
+ const versionsRoot = join(nvmDir, 'versions', 'node');
40
+
41
+ if (!existsSync(versionsRoot)) return [];
42
+
43
+ try {
44
+ return readdirSync(versionsRoot).map(v =>
45
+ join(versionsRoot, v, 'lib', 'node_modules', '@anthropic-ai', 'claude-code')
46
+ );
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Static fallback list of well-known global install locations.
54
+ */
55
+ function staticFallbacks() {
56
+ const home = process.env.HOME ?? '';
57
+ return [
58
+ join(home, '.nvm', 'versions', 'node', 'v22.0.0', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
59
+ join(home, '.nvm', 'versions', 'node', 'v20.0.0', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
60
+ join(home, '.nvm', 'versions', 'node', 'v18.0.0', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
61
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code',
62
+ '/usr/lib/node_modules/@anthropic-ai/claude-code',
63
+ '/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code',
64
+ ];
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Safe wrapper around fs.realpathSync — returns null on any error.
73
+ *
74
+ * @param {string} p
75
+ * @returns {string | null}
76
+ */
77
+ function safeRealpath(p) {
78
+ try {
79
+ return realpathSync(p);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Walk up the directory tree from `startDir` looking for a directory that
87
+ * contains `cli.js` (the claude-code package root).
88
+ *
89
+ * @param {string} startDir
90
+ * @returns {string | null}
91
+ */
92
+ function findPackageDirUpward(startDir) {
93
+ let current = startDir;
94
+
95
+ for (let depth = 0; depth < 30; depth++) {
96
+ if (existsSync(join(current, 'cli.js'))) {
97
+ return current;
98
+ }
99
+
100
+ // Check common nesting: current/@anthropic-ai/claude-code
101
+ const nested = join(current, '@anthropic-ai', 'claude-code');
102
+ if (existsSync(join(nested, 'cli.js'))) {
103
+ return nested;
104
+ }
105
+
106
+ const parent = dirname(current);
107
+ if (parent === current) break; // reached fs root
108
+ current = parent;
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Collect every path in the symlink chain starting from `startPath`.
116
+ * Each element is one hop resolved relative to the previous one.
117
+ *
118
+ * @param {string} startPath
119
+ * @returns {string[]}
120
+ */
121
+ function collectSymlinkChain(startPath) {
122
+ const chain = [startPath];
123
+ let current = startPath;
124
+
125
+ for (let i = 0; i < 20; i++) {
126
+ let target;
127
+ try {
128
+ target = readlinkSync(current);
129
+ } catch {
130
+ // Not a symlink (or error) — end of chain
131
+ break;
132
+ }
133
+
134
+ // Resolve relative targets relative to the symlink's directory
135
+ const next = resolve(dirname(current), target);
136
+ if (chain.includes(next)) break; // cycle guard
137
+ chain.push(next);
138
+ current = next;
139
+ }
140
+
141
+ // Also include the fully-resolved real path (handles multi-hop quietly)
142
+ const real = safeRealpath(startPath);
143
+ if (real && !chain.includes(real)) {
144
+ chain.push(real);
145
+ }
146
+
147
+ return chain;
148
+ }
149
+
150
+ /**
151
+ * Read the `version` field from a package directory's package.json.
152
+ *
153
+ * @param {string} packageDir
154
+ * @returns {string}
155
+ */
156
+ function readPackageVersion(packageDir) {
157
+ const pkgPath = join(packageDir, 'package.json');
158
+ if (!existsSync(pkgPath)) return 'unknown';
159
+ try {
160
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
161
+ return pkg.version ?? 'unknown';
162
+ } catch {
163
+ return 'unknown';
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Build a result object for a found package directory.
169
+ *
170
+ * @param {string} packageDir
171
+ * @returns {{ cliPath: string, sdkPath: string|null, packageDir: string, version: string }}
172
+ */
173
+ function buildResult(packageDir) {
174
+ const cliPath = join(packageDir, 'cli.js');
175
+ const sdkCandidate = join(packageDir, 'sdk.mjs');
176
+ return {
177
+ cliPath,
178
+ sdkPath: existsSync(sdkCandidate) ? sdkCandidate : null,
179
+ packageDir,
180
+ version: readPackageVersion(packageDir),
181
+ };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Main locate() function
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Locate the Claude Code CLI installation.
190
+ *
191
+ * Resolution order:
192
+ * 1. Follow the symlink chain of `which claude` and walk each hop upward
193
+ * looking for a directory that contains cli.js.
194
+ * 2. Enumerate nvm-managed Node.js versions and check each one.
195
+ * 3. Check static fallback paths.
196
+ * 4. Derive a path from the currently running Node.js executable.
197
+ *
198
+ * @returns {Promise<{ cliPath: string, sdkPath: string|null, packageDir: string, version: string }>}
199
+ * @throws {Error} if no installation can be found
200
+ */
201
+ async function locate() {
202
+ // ── Strategy 0: Node.js module resolution (cross-platform, no subprocess) ──
203
+ // Uses createRequire to resolve @anthropic-ai/claude-code/package.json, then
204
+ // derives cli.js from that directory. This is how sdk.mjs itself finds cli.js
205
+ // internally and works on Windows/macOS/Linux without any subprocess.
206
+ try {
207
+ // resolve the package root via its package.json
208
+ const pkgJsonPath = _require.resolve('@anthropic-ai/claude-code/package.json');
209
+ const pkgDir = dirname(pkgJsonPath);
210
+ if (existsSync(join(pkgDir, 'cli.js'))) return buildResult(pkgDir);
211
+ } catch { /* package not resolvable from this location — fall through */ }
212
+
213
+ // Also try resolving sdk.mjs (the "main" entry) to derive the directory
214
+ try {
215
+ const sdkPath = _require.resolve('@anthropic-ai/claude-code');
216
+ const pkgDir = dirname(sdkPath);
217
+ if (existsSync(join(pkgDir, 'cli.js'))) return buildResult(pkgDir);
218
+ } catch { /* not found */ }
219
+
220
+ // import.meta.resolve is available in Node 18.19+ / 20.0+
221
+ // We use a dynamic eval-style call to avoid a syntax error on older runtimes
222
+ try {
223
+ const resolved = await (async () => {
224
+ // This will throw on runtimes that don't support import.meta.resolve
225
+ return import.meta.resolve?.('@anthropic-ai/claude-code/package.json');
226
+ })();
227
+ if (resolved) {
228
+ const { fileURLToPath } = await import('node:url');
229
+ const pkgDir = dirname(fileURLToPath(resolved));
230
+ if (existsSync(join(pkgDir, 'cli.js'))) return buildResult(pkgDir);
231
+ }
232
+ } catch { /* not available */ }
233
+
234
+ // ── Strategy 1: `which` npm package + symlink chain (cross-platform, no subprocess) ──
235
+ // Uses the `which` npm package (263M downloads/week) — handles Windows PATHEXT,
236
+ // pure JS, no child_process.exec.
237
+ let whichResult = null;
238
+ try {
239
+ const { default: which } = await import('which');
240
+ whichResult = which.sync('claude', { nothrow: true });
241
+ } catch {
242
+ /* which package not available or claude not on PATH */
243
+ }
244
+
245
+ if (whichResult) {
246
+ const chain = collectSymlinkChain(whichResult);
247
+
248
+ for (const hop of chain) {
249
+ const hopDir = dirname(hop);
250
+ const found = findPackageDirUpward(hopDir);
251
+ if (found) return buildResult(found);
252
+ }
253
+ }
254
+
255
+ // ── Strategy 2: nvm version enumeration ───────────────────────────────────
256
+ for (const dir of buildNvmFallbacksSync()) {
257
+ if (existsSync(join(dir, 'cli.js'))) return buildResult(dir);
258
+ }
259
+
260
+ // ── Strategy 3: static fallbacks ──────────────────────────────────────────
261
+ for (const dir of staticFallbacks()) {
262
+ if (existsSync(join(dir, 'cli.js'))) return buildResult(dir);
263
+ }
264
+
265
+ // ── Strategy 4: derive from current node executable ───────────────────────
266
+ // e.g. /home/user/.nvm/versions/node/v22.0.0/bin/node
267
+ // → /home/user/.nvm/versions/node/v22.0.0/lib/node_modules/@anthropic-ai/claude-code
268
+ const nodeExecDir = dirname(process.execPath); // …/bin
269
+ const nodePrefix = dirname(nodeExecDir); // …/versions/node/vX.Y.Z
270
+ const derivedDir = join(nodePrefix, 'lib', 'node_modules', '@anthropic-ai', 'claude-code');
271
+ if (existsSync(join(derivedDir, 'cli.js'))) return buildResult(derivedDir);
272
+
273
+ throw new Error(
274
+ [
275
+ 'Could not locate the Claude Code CLI (cli.js).',
276
+ whichResult
277
+ ? ` which('claude') → ${whichResult} (followed symlinks but cli.js not found nearby)`
278
+ : ' which(\'claude\') → (not found on PATH)',
279
+ ' Tried nvm version directories, common global install paths, and node prefix.',
280
+ ' Ensure @anthropic-ai/claude-code is installed globally:',
281
+ ' npm install -g @anthropic-ai/claude-code',
282
+ ].join('\n')
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Async wrapper around locate() — returns a Promise for ergonomic use with
288
+ * top-level await.
289
+ *
290
+ * @returns {Promise<{ cliPath: string, sdkPath: string|null, packageDir: string, version: string }>}
291
+ */
292
+ /** Alias — locate() is already async; this exists for API symmetry. */
293
+ const locateAsync = locate;
294
+
295
+ export { locate, locateAsync };
296
+ export default locate;