robot-resources 1.15.0 → 1.15.2

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/lib/detect.js CHANGED
@@ -137,7 +137,10 @@ export function isCursorInstalled() {
137
137
  // means a generic project (e.g. just package.json, no LLM SDK deps yet) —
138
138
  // still picks the language but with low confidence.
139
139
 
140
- const NODE_AGENT_DEPS = [
140
+ // Exported (Phase 11.1) so the SDK-import scanner in source-edit-attach.js
141
+ // can reuse the same canonical list — single source of truth for "which npm
142
+ // packages count as an AI SDK signal."
143
+ export const NODE_AGENT_DEPS = [
141
144
  '@anthropic-ai/sdk',
142
145
  'openai',
143
146
  '@google/generative-ai',
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { select } from '@inquirer/prompts';
2
+ import { extname, isAbsolute, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { select, input } from '@inquirer/prompts';
4
5
  import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
5
6
  import { configureClaudeCode, configureCursor } from './tool-config.js';
6
7
  import { header, info, success, warn, blank, confirm } from './ui.js';
@@ -8,7 +9,7 @@ import { readConfig } from './config.mjs';
8
9
  import { installNodeShim } from './install-node-shim.js';
9
10
  import { installPythonShim } from './install-python-shim.js';
10
11
  import {
11
- detectEntryFile,
12
+ findAgentSourceFile,
12
13
  hasSourceMarker,
13
14
  writeSourceMarker,
14
15
  previewInjection,
@@ -118,75 +119,188 @@ async function emitNodeEntryPatched(payload) {
118
119
  }
119
120
  }
120
121
 
122
+ const VALID_SOURCE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
123
+
124
+ /**
125
+ * Expand `~` to $HOME. Same trick shells use. Anything else (env vars,
126
+ * `..` segments) we leave to `path.resolve` to handle.
127
+ */
128
+ function expandPath(raw, cwd) {
129
+ let p = raw.trim();
130
+ if (p === '~') p = homedir();
131
+ else if (p.startsWith('~/') || p.startsWith('~\\')) p = join(homedir(), p.slice(2));
132
+ if (!isAbsolute(p)) p = resolve(cwd, p);
133
+ return p;
134
+ }
135
+
121
136
  /**
122
- * Phase 11 — offer to inject `require('@robot-resources/router/auto')`
123
- * (or ESM equivalent) at the top of the user's entry file.
137
+ * Phase 11 + 11.1 — offer to inject `require('@robot-resources/router/auto')`
138
+ * (or ESM equivalent) at the top of the user's entry file. Phase 11 wrote
139
+ * the plumbing; Phase 11.1 swaps the package.json-gated detector for an
140
+ * SDK-import scanner that finds files actually talking to AI models.
124
141
  *
125
- * Why this is here: NODE_OPTIONS in shell config never reaches cron /
126
- * Docker / systemd / Lambda agents because those launchers don't read
127
- * shell rc files. A line in the source survives any launcher.
142
+ * Why source-edit at all: NODE_OPTIONS in shell config never reaches cron /
143
+ * Docker / systemd / Lambda agents because those launchers don't read shell
144
+ * rc files. A line in the source survives any launcher.
128
145
  *
129
- * Gates:
130
- * - No package.json or no detectable entry skip with a hint
146
+ * Decision matrix (now driven by findAgentSourceFile):
147
+ * - 0 SDK files found free-text path prompt (interactive) or skip (CI)
148
+ * - 1 candidate → preview + Y/N (skip the chooser)
149
+ * - 2+, clear winner → preview winner + Y/N (skip the chooser)
150
+ * - 2+, ambiguous → select() chooser, then Y/N
131
151
  * - Marker already present → skip silently
132
- * - Non-interactive without `autoAttachSource` → skip with a hint;
133
- * auto-rewriting source files in CI without consent is too aggressive
134
- * - Y/N declined skip silently, user can always re-run
152
+ * - Non-interactive without `autoAttachSource` → skip with a hint
153
+ *
154
+ * Source files are sacred every accept path goes through Y/N (or the
155
+ * explicit `--auto-attach-source` opt-in for CI).
135
156
  */
136
157
  async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = process.cwd() } = {}) {
137
- const detection = detectEntryFile(cwd);
138
- if (!detection.path) {
139
- info('No agent entry file detected — skipping source-attach step.');
140
- info('Re-run from your project root, or add this line yourself at the top of your entry file:');
141
- info(" require('@robot-resources/router/auto'); // CJS");
142
- info(" // or: import '@robot-resources/router/auto'; // ESM/TS");
143
- await emitNodeEntryPatched({ outcome: 'no_entry_detected' });
144
- return;
158
+ const scan = findAgentSourceFile(cwd);
159
+ const baseTelemetry = {
160
+ candidates_scanned: scan.scanned,
161
+ total_files_walked: scan.walked,
162
+ winner_score: scan.candidates[0]?.score ?? null,
163
+ runner_up_score: scan.candidates[1]?.score ?? null,
164
+ };
165
+
166
+ // ── 0 candidates ───────────────────────────────────────────────────────
167
+ if (!scan.winner) {
168
+ if (nonInteractive) {
169
+ blank();
170
+ info(`We scanned ${scan.walked} source files and found no AI SDK imports.`);
171
+ info('Add this line manually at the top of your agent entry file:');
172
+ info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
173
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'no_sdk_imports_found' });
174
+ return;
175
+ }
176
+
177
+ // Interactive 0-candidate: ask the user to point us at the file.
178
+ blank();
179
+ info(`We scanned ${scan.walked} source files in this directory and found no AI SDK imports.`);
180
+ blank();
181
+ let raw;
182
+ try {
183
+ raw = await input({
184
+ message: 'Path to your agent file (or press Enter to skip):',
185
+ default: '',
186
+ });
187
+ } catch {
188
+ // Ctrl-C or terminal closed.
189
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
190
+ return;
191
+ }
192
+ if (!raw || !raw.trim()) {
193
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
194
+ return;
195
+ }
196
+
197
+ const target = expandPath(raw, cwd);
198
+ if (!existsSync(target)) {
199
+ warn(`File does not exist: ${target}`);
200
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'failed', error: 'path_missing' });
201
+ return;
202
+ }
203
+ if (!VALID_SOURCE_EXTS.has(extname(target).toLowerCase())) {
204
+ warn(`Refusing to edit ${extname(target)} — only .js/.mjs/.cjs/.ts/.tsx/.jsx are supported.`);
205
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'failed', error: 'invalid_extension' });
206
+ return;
207
+ }
208
+ if (hasSourceMarker(target)) {
209
+ info(`${pathForTelemetry(target, cwd)} already has the auto-attach line — leaving it as-is.`);
210
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'already_present', entry_path: pathForTelemetry(target, cwd) });
211
+ return;
212
+ }
213
+
214
+ // Outside-cwd: extra confirmation. The user meant a path relative to
215
+ // their project; if they typed something far away, double-check.
216
+ const isOutsideCwd = !target.startsWith(cwd + '/') && target !== cwd;
217
+ if (isOutsideCwd) {
218
+ const ok = await confirm(`${target} is outside your current directory. Edit it anyway?`, { defaultYes: false });
219
+ if (!ok) {
220
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
221
+ return;
222
+ }
223
+ }
224
+
225
+ return await applyInjection({
226
+ target,
227
+ cwd,
228
+ nonInteractive,
229
+ autoAttachSource,
230
+ baseTelemetry,
231
+ viaPrompt: true,
232
+ });
145
233
  }
146
234
 
147
- if (hasSourceMarker(detection.path)) {
148
- info(`${pathForTelemetry(detection.path, cwd)} already has the auto-attach line — leaving it as-is.`);
235
+ // ── 1+ candidates ──────────────────────────────────────────────────────
236
+ const winnerPath = scan.winner;
237
+
238
+ if (hasSourceMarker(winnerPath)) {
239
+ info(`${pathForTelemetry(winnerPath, cwd)} already has the auto-attach line — leaving it as-is.`);
149
240
  await emitNodeEntryPatched({
241
+ ...baseTelemetry,
150
242
  outcome: 'already_present',
151
- entry_path: pathForTelemetry(detection.path, cwd),
243
+ entry_path: pathForTelemetry(winnerPath, cwd),
152
244
  });
153
245
  return;
154
246
  }
155
247
 
156
- // Non-interactive: skip unless explicit opt-in. Source files are sacred.
157
248
  if (nonInteractive && !autoAttachSource) {
158
249
  blank();
159
250
  info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
160
251
  info(`To enable it in CI, re-run with: npx robot-resources --for=langchain --auto-attach-source`);
161
- info(`Or add this line manually at the top of ${pathForTelemetry(detection.path, cwd)}:`);
252
+ info(`Or add this line manually at the top of ${pathForTelemetry(winnerPath, cwd)}:`);
162
253
  info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
163
254
  await emitNodeEntryPatched({
255
+ ...baseTelemetry,
164
256
  outcome: 'skipped_non_interactive',
165
- entry_path: pathForTelemetry(detection.path, cwd),
257
+ entry_path: pathForTelemetry(winnerPath, cwd),
166
258
  });
167
259
  return;
168
260
  }
169
261
 
170
- // Multi-candidate ambiguity ask which entry the user actually runs.
171
- let target = detection.path;
172
- if (!nonInteractive && detection.candidates.length > 1) {
173
- const choices = detection.candidates.map((c) => ({
174
- name: pathForTelemetry(c, cwd),
175
- value: c,
262
+ // Resolve the actual target. Skip the chooser when winner is unambiguous —
263
+ // most users see exactly one Y/N.
264
+ let target = winnerPath;
265
+ if (!nonInteractive && scan.ambiguous && scan.candidates.length > 1) {
266
+ const choices = scan.candidates.slice(0, 6).map((c) => ({
267
+ name: `${pathForTelemetry(c.path, cwd)} (score ${c.score})`,
268
+ value: c.path,
176
269
  }));
177
270
  choices.push({ name: 'Skip — I\'ll add the line manually', value: '__skip__' });
178
- target = await select({
179
- message: 'Multiple entry candidates found. Which file runs your agent?',
180
- default: detection.path,
181
- choices,
182
- });
271
+ try {
272
+ target = await select({
273
+ message: 'Multiple files import an AI SDK. Which one is your agent?',
274
+ default: winnerPath,
275
+ choices,
276
+ });
277
+ } catch {
278
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
279
+ return;
280
+ }
183
281
  if (target === '__skip__') {
184
- await emitNodeEntryPatched({ outcome: 'declined_ambiguous_pick' });
282
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
185
283
  return;
186
284
  }
187
285
  }
188
286
 
189
- // Show the diff and ask Y/N (unless auto-attach is explicitly opt-in).
287
+ return await applyInjection({
288
+ target,
289
+ cwd,
290
+ nonInteractive,
291
+ autoAttachSource,
292
+ baseTelemetry,
293
+ viaPrompt: false,
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Common write path used by both the "winner detected" and "user typed a
299
+ * path" branches. Shows the diff, asks Y/N (unless --auto-attach-source),
300
+ * writes the marker, emits telemetry. Single source of truth so the two
301
+ * branches can't drift.
302
+ */
303
+ async function applyInjection({ target, cwd, nonInteractive, autoAttachSource, baseTelemetry, viaPrompt }) {
190
304
  const preview = previewInjection(target);
191
305
  blank();
192
306
  success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
@@ -204,6 +318,7 @@ async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = p
204
318
  if (!proceed) {
205
319
  info('Skipped — you can re-run anytime.');
206
320
  await emitNodeEntryPatched({
321
+ ...baseTelemetry,
207
322
  outcome: 'declined',
208
323
  entry_path: pathForTelemetry(target, cwd),
209
324
  });
@@ -216,7 +331,8 @@ async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = p
216
331
  if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
217
332
  info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
218
333
  await emitNodeEntryPatched({
219
- outcome: 'patched',
334
+ ...baseTelemetry,
335
+ outcome: viaPrompt ? 'patched_via_prompt' : 'patched',
220
336
  entry_path: pathForTelemetry(target, cwd),
221
337
  import_syntax: result.syntax,
222
338
  backup_written: !!result.backupWritten,
@@ -225,6 +341,7 @@ async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = p
225
341
  warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
226
342
  info('You can add the line yourself — see the snippet above.');
227
343
  await emitNodeEntryPatched({
344
+ ...baseTelemetry,
228
345
  outcome: 'failed',
229
346
  entry_path: pathForTelemetry(target, cwd),
230
347
  error: result.error,
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from 'node:fs';
2
- import { dirname, extname, join, relative, resolve, sep } from 'node:path';
1
+ import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync, openSync, readSync, closeSync } from 'node:fs';
2
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+ import { NODE_AGENT_DEPS } from './detect.js';
3
4
 
4
5
  /**
5
6
  * Source-edit injection for non-OC Node agents (Phase 11).
@@ -36,11 +37,6 @@ export const MARK_END = '// <<< robot-resources <<<';
36
37
  const REQUIRE_LINE = "require('@robot-resources/router/auto');";
37
38
  const IMPORT_LINE = "import '@robot-resources/router/auto';";
38
39
 
39
- // File extensions we'll treat as Node source — anything else, refuse to
40
- // edit. Keeps us out of build artifacts (.min.js), declaration files (.d.ts),
41
- // JSON, etc.
42
- const VALID_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']);
43
-
44
40
  /**
45
41
  * Decide which import syntax to inject for a given source file.
46
42
  * Implements Node's resolution rules verbatim:
@@ -75,90 +71,228 @@ export function detectImportSyntax(entryPath) {
75
71
  }
76
72
  }
77
73
 
74
+ // Phase 11.1 — directories pruned during the recursive walk. Skipping these
75
+ // is the difference between a 50ms walk and a 30s one. Hardcoded — chasing
76
+ // `.gitignore` would add a transitive dep risk for marginal coverage gain
77
+ // (real false-positive rate from these dirs is already ~0).
78
+ const SKIP_DIRS = new Set([
79
+ 'node_modules', 'dist', 'build', '.next', 'out', 'coverage',
80
+ '.git', 'venv', '.venv', '__pycache__', '.svelte-kit', 'target',
81
+ ]);
82
+
83
+ // Walker bounds. 4KB read because imports are always hoisted to the top of
84
+ // the file — anything below isn't an import, and reading more is waste on
85
+ // network filesystems / WSL. Depth 2 catches `src/agent.ts`,
86
+ // `apps/api/src/index.ts`. File cap 500 stops huge monorepos from blowing
87
+ // the wizard's budget.
88
+ const WALK_DEPTH_MAX = 2;
89
+ const WALK_FILE_CAP = 500;
90
+ const READ_BYTES = 4096;
91
+
92
+ const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
93
+
94
+ // Deliberately omits `index` — too generic to reliably signal "this is the
95
+ // agent." A CLI tool's `cli.js` (matched via package.json `bin`) will lose
96
+ // to a generic `index.js` if `index` is in this set, even though `bin` is
97
+ // what the user actually runs. Names here are strong agent signals on
98
+ // their own; `index` requires a complementary signal (`main`/`bin` match)
99
+ // to be picked up.
100
+ const ENTRY_NAME_PATTERN = /^(agent|bot|app|main|server|worker|handler)\./i;
101
+ const TEST_FILENAME_PATTERN = /\.(test|spec)\./i;
102
+ const TEST_PATH_FRAGMENT = /[\\/](?:test|tests|__tests__|spec|examples|scripts)[\\/]/i;
103
+
78
104
  /**
79
- * Locate the user's agent entry file under cwd. Staged fallback through
80
- * standard package.json fields, then a convention scan.
105
+ * Locate the user's agent source file under cwd by scanning for AI-SDK
106
+ * imports. Returns { winner, candidates, scanned, walked, ambiguous }.
81
107
  *
82
- * Returns { path, candidates }:
83
- * - path: absolute path to the chosen entry, or null if nothing matched
84
- * - candidates: every viable candidate found (used by the wizard to
85
- * show a select() prompt when ambiguous)
108
+ * `winner` absolute path of the highest-scoring file, or null if no file
109
+ * in cwd imports any SDK from `NODE_AGENT_DEPS`.
110
+ * `candidates` every file that scored > 0, sorted by score descending.
111
+ * Each entry: { path, score, syntax, reasons: string[] }.
112
+ * `scanned` — count of files that imported any SDK (subset of `walked`).
113
+ * `walked` — total source files inspected (capped at WALK_FILE_CAP).
114
+ * `ambiguous` — true when winner's lead over runner-up is too narrow to
115
+ * silently pick (`winner.score < runner_up.score + 2 &&
116
+ * winner.score < runner_up.score * 1.5`). The wizard surfaces
117
+ * a chooser only when this is true.
86
118
  *
87
- * Priority (first match wins for `path`, all viable hits go in `candidates`):
88
- * 1. package.json `bin` single-string or single-key object entry
89
- * 2. package.json `main`
90
- * 3. package.json `exports["."]` (or `exports["."].default`)
91
- * 4. parsed package.json `scripts.start` regex against
92
- * `(node|tsx|ts-node|bun) <file>`
93
- * 5. convention scan: src/index.{ts,js,mjs,cjs}, index.{ts,js,mjs,cjs},
94
- * src/agent.{ts,js}, agent.{ts,js}, src/main.{ts,js}, main.{ts,js},
95
- * src/app.{ts,js}, app.{ts,js}, src/bot.{ts,js}, bot.{ts,js}
119
+ * Why this replaces the package.json-gated detector: production data after
120
+ * the Phase 11 release showed 5 of 5 fresh real users (JP/US/RU) failing
121
+ * with `outcome: 'no_entry_detected'` because they ran the wizard from
122
+ * a server's home dir, with no `package.json` at cwd. Real Node agents
123
+ * commonly look like a single `agent.js` next to a `node_modules/` —
124
+ * `package.json` is a project-shape signal, not an agent signal. The
125
+ * actual ground truth for "this is an agent" is "this file imports an
126
+ * AI SDK." `package.json` here downgrades to a *ranking hint*, used to
127
+ * boost candidates whose path matches `bin` or `main`, but never to
128
+ * gate detection.
96
129
  */
97
- export function detectEntryFile(cwd = process.cwd()) {
98
- const candidates = [];
99
- const pkgPath = join(cwd, 'package.json');
100
- let pkg = null;
101
- if (existsSync(pkgPath)) {
102
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { /* malformed */ }
103
- }
104
-
105
- if (pkg) {
106
- // Tier 1: bin
107
- if (typeof pkg.bin === 'string') {
108
- addCandidate(candidates, cwd, pkg.bin);
109
- } else if (pkg.bin && typeof pkg.bin === 'object') {
110
- for (const v of Object.values(pkg.bin)) {
111
- if (typeof v === 'string') addCandidate(candidates, cwd, v);
130
+ export function findAgentSourceFile(cwd = process.cwd()) {
131
+ let walked = 0;
132
+ const sdkFiles = [];
133
+
134
+ // Recursive walker with hard caps. Each recursion increments depth;
135
+ // depth 0 is cwd itself, depth 1 is direct children, etc.
136
+ const walk = (dir, depth) => {
137
+ if (walked >= WALK_FILE_CAP) return;
138
+ if (depth > WALK_DEPTH_MAX) return;
139
+ let entries;
140
+ try {
141
+ entries = readdirSync(dir, { withFileTypes: true });
142
+ } catch {
143
+ // Permission or read error — skip this directory silently.
144
+ return;
145
+ }
146
+ for (const entry of entries) {
147
+ if (walked >= WALK_FILE_CAP) return;
148
+ if (entry.isDirectory()) {
149
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
150
+ walk(join(dir, entry.name), depth + 1);
151
+ } else if (entry.isFile()) {
152
+ const ext = extname(entry.name).toLowerCase();
153
+ if (!SCAN_EXTS.has(ext)) continue;
154
+ walked++;
155
+ const abs = join(dir, entry.name);
156
+ const head = readHead(abs);
157
+ if (!head) continue;
158
+ const sdks = sdksFoundIn(head);
159
+ if (sdks.size > 0) {
160
+ sdkFiles.push({ path: abs, head, sdks, depth });
161
+ }
112
162
  }
113
163
  }
164
+ };
165
+
166
+ walk(cwd, 0);
167
+
168
+ // Score each candidate. The scoring system — finalised after a design
169
+ // review — is:
170
+ // +5 imports any NODE_AGENT_DEPS SDK (boolean per file; multiple
171
+ // imports from the same file don't pile points, so a wrapper file
172
+ // with 6 re-exports doesn't outrank an actual agent file)
173
+ // +1 also has a constructor call (`new Anthropic(...)`, etc.)
174
+ // +4 filename matches /^(agent|bot|index|app|main|server|worker|handler)\./
175
+ // +1 file lives at cwd root (depth 0)
176
+ // -3 file is under /test/, /tests/, /__tests__/, /spec/, /examples/, /scripts/
177
+ // -3 filename matches *.test.* or *.spec.*
178
+ // +5 file matches package.json `bin` (when present — `bin` strictly dominates `main`)
179
+ // +3 file matches package.json `main` (when present and ≠ `bin`)
180
+ const pkg = readPkg(cwd);
181
+ const binPath = pkg ? resolveBinPath(pkg, cwd) : null;
182
+ const mainPath = pkg ? resolveMainPath(pkg, cwd) : null;
183
+
184
+ const candidates = sdkFiles.map(({ path, head, sdks, depth }) => {
185
+ const reasons = [];
186
+ let score = 0;
187
+
188
+ score += 5; reasons.push(`+5 imports SDK (${[...sdks].join(',')})`);
189
+
190
+ if (hasConstructor(head)) { score += 1; reasons.push('+1 constructor call'); }
191
+
192
+ if (ENTRY_NAME_PATTERN.test(basename(path))) { score += 4; reasons.push('+4 entry-name match'); }
193
+
194
+ if (depth === 0) { score += 1; reasons.push('+1 cwd root'); }
195
+
196
+ if (TEST_PATH_FRAGMENT.test(path)) { score -= 3; reasons.push('-3 test/spec/examples path'); }
197
+ if (TEST_FILENAME_PATTERN.test(basename(path))) { score -= 3; reasons.push('-3 test/spec filename'); }
198
+
199
+ if (binPath && path === binPath) { score += 5; reasons.push('+5 package.json bin'); }
200
+ else if (mainPath && path === mainPath) { score += 3; reasons.push('+3 package.json main'); }
201
+
202
+ return {
203
+ path,
204
+ score,
205
+ syntax: detectImportSyntax(path),
206
+ reasons,
207
+ };
208
+ }).sort((a, b) => b.score - a.score);
209
+
210
+ const winner = candidates[0] ?? null;
211
+ const runnerUp = candidates[1] ?? null;
212
+
213
+ // "Clear winner" threshold: lead ≥ 2 absolute OR ≥ 1.5x runner-up.
214
+ // Either condition means we silently pick `winner`. Both close → ambiguous.
215
+ let ambiguous = false;
216
+ if (runnerUp) {
217
+ const leadOk = winner.score >= runnerUp.score + 2;
218
+ const ratioOk = winner.score >= runnerUp.score * 1.5;
219
+ ambiguous = !(leadOk || ratioOk);
220
+ }
114
221
 
115
- // Tier 2: main
116
- if (typeof pkg.main === 'string') addCandidate(candidates, cwd, pkg.main);
117
-
118
- // Tier 3: exports["."]
119
- const root = pkg.exports?.['.'] ?? pkg.exports;
120
- if (typeof root === 'string') addCandidate(candidates, cwd, root);
121
- else if (root && typeof root === 'object') {
122
- const dflt = root.default ?? root.import ?? root.require;
123
- if (typeof dflt === 'string') addCandidate(candidates, cwd, dflt);
124
- }
222
+ return {
223
+ winner: winner?.path ?? null,
224
+ candidates,
225
+ scanned: sdkFiles.length,
226
+ walked,
227
+ ambiguous,
228
+ };
229
+ }
125
230
 
126
- // Tier 4: scripts.start parse
127
- if (typeof pkg.scripts?.start === 'string') {
128
- // Match `node ./foo.js`, `tsx src/agent.ts`, `bun run agent.ts` etc.
129
- const m = pkg.scripts.start.match(/(?:node|tsx|ts-node|bun(?:\s+run)?)\s+(\S+)/);
130
- if (m) addCandidate(candidates, cwd, m[1]);
131
- }
231
+ /**
232
+ * Read the head of a file (first READ_BYTES bytes). Imports are always
233
+ * hoisted to the top of a Node module — TypeScript and ESM both enforce
234
+ * import hoisting, and CJS users put requires at the top by convention.
235
+ * Reading more is waste on slow filesystems.
236
+ */
237
+ function readHead(filePath) {
238
+ let fd;
239
+ try {
240
+ fd = openSync(filePath, 'r');
241
+ const buf = Buffer.alloc(READ_BYTES);
242
+ const bytes = readSync(fd, buf, 0, READ_BYTES, 0);
243
+ return buf.slice(0, bytes).toString('utf-8');
244
+ } catch {
245
+ return null;
246
+ } finally {
247
+ if (fd != null) { try { closeSync(fd); } catch { /* ignore */ } }
132
248
  }
249
+ }
133
250
 
134
- // Tier 5: convention scan — only adds if the candidate file actually exists.
135
- const conventions = [
136
- 'src/index.ts', 'src/index.js', 'src/index.mjs', 'src/index.cjs',
137
- 'index.ts', 'index.js', 'index.mjs', 'index.cjs',
138
- 'src/agent.ts', 'src/agent.js', 'agent.ts', 'agent.js',
139
- 'src/main.ts', 'src/main.js', 'main.ts', 'main.js',
140
- 'src/app.ts', 'src/app.js', 'app.ts', 'app.js',
141
- 'src/bot.ts', 'src/bot.js', 'bot.ts', 'bot.js',
142
- ];
143
- for (const rel of conventions) {
144
- const abs = join(cwd, rel);
145
- if (existsSync(abs) && !candidates.includes(abs)) candidates.push(abs);
251
+ /**
252
+ * Return the set of SDK package names found in `text`. Boolean per SDK —
253
+ * multiple `require('@anthropic-ai/sdk')` lines in the same file count
254
+ * once. Both CJS (`require('foo')`) and ESM (`from 'foo'`) shapes are
255
+ * checked; we don't care about quote style.
256
+ */
257
+ function sdksFoundIn(text) {
258
+ const found = new Set();
259
+ for (const sdk of NODE_AGENT_DEPS) {
260
+ const escaped = sdk.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
+ const requireRe = new RegExp(`require\\(\\s*['"]${escaped}['"]\\s*\\)`);
262
+ const importRe = new RegExp(`from\\s+['"]${escaped}['"]`);
263
+ if (requireRe.test(text) || importRe.test(text)) found.add(sdk);
146
264
  }
265
+ return found;
266
+ }
147
267
 
148
- // Filter to files that exist and have a valid extension. The package.json
149
- // tiers might point at files that don't exist yet (e.g. a `main` of
150
- // `dist/index.js` before the user has built). Don't try to inject into
151
- // missing files.
152
- const viable = candidates.filter((c) => existsSync(c) && VALID_EXTS.has(extname(c).toLowerCase()));
268
+ function hasConstructor(text) {
269
+ // Cheap regex; over-matches `if (new Anthropic())` etc. that's fine,
270
+ // they still count as evidence that this file talks to a model.
271
+ return /new\s+(Anthropic|OpenAI|GoogleGenerativeAI)\s*\(/.test(text);
272
+ }
153
273
 
154
- return { path: viable[0] ?? null, candidates: viable };
274
+ function readPkg(cwd) {
275
+ const p = join(cwd, 'package.json');
276
+ if (!existsSync(p)) return null;
277
+ try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
278
+ }
279
+
280
+ function resolveBinPath(pkg, cwd) {
281
+ let raw = null;
282
+ if (typeof pkg.bin === 'string') raw = pkg.bin;
283
+ else if (pkg.bin && typeof pkg.bin === 'object') {
284
+ const vals = Object.values(pkg.bin).filter((v) => typeof v === 'string');
285
+ if (vals.length === 1) raw = vals[0];
286
+ }
287
+ if (!raw) return null;
288
+ const abs = join(cwd, raw.replace(/^\.\//, ''));
289
+ return existsSync(abs) ? abs : null;
155
290
  }
156
291
 
157
- function addCandidate(arr, cwd, rel) {
158
- // Strip leading ./, normalize, resolve relative to cwd.
159
- const cleaned = rel.replace(/^\.\//, '');
160
- const abs = join(cwd, cleaned);
161
- if (!arr.includes(abs)) arr.push(abs);
292
+ function resolveMainPath(pkg, cwd) {
293
+ if (typeof pkg.main !== 'string') return null;
294
+ const abs = join(cwd, pkg.main.replace(/^\.\//, ''));
295
+ return existsSync(abs) ? abs : null;
162
296
  }
163
297
 
164
298
  /**
package/lib/uninstall.js CHANGED
@@ -6,7 +6,7 @@ import { removeShellLine } from './shell-config.js';
6
6
  import { detectVenv } from './venv-detect.js';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { removePersistedNodeOptions } from './windows-env.js';
9
- import { detectEntryFile, hasSourceMarker, removeSourceMarker } from './source-edit-attach.js';
9
+ import { findAgentSourceFile, hasSourceMarker, removeSourceMarker } from './source-edit-attach.js';
10
10
 
11
11
  /**
12
12
  * Single source of truth for `npx robot-resources --uninstall`.
@@ -123,7 +123,11 @@ export function runUninstall({ purge = false } = {}) {
123
123
  // not present. Runs against cwd, so users who --uninstall from a
124
124
  // different repo won't accidentally wipe the wrong file.
125
125
  try {
126
- const detection = detectEntryFile();
126
+ // Phase 11.1: detector renamed to findAgentSourceFile, returns
127
+ // { winner, candidates, ... }. Adapt to the same `path` field name
128
+ // so the rest of the block reads naturally.
129
+ const scan = findAgentSourceFile();
130
+ const detection = { path: scan.winner };
127
131
  if (detection.path && hasSourceMarker(detection.path)) {
128
132
  const r = removeSourceMarker(detection.path, { restoreFromBackup: !!purge });
129
133
  if (r.ok && (r.removed || r.restored)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {