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 +4 -1
- package/lib/non-oc-wizard.js +158 -41
- package/lib/source-edit-attach.js +210 -76
- package/lib/uninstall.js +6 -2
- package/package.json +1 -1
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
|
-
|
|
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',
|
package/lib/non-oc-wizard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
126
|
-
* Docker / systemd / Lambda agents because those launchers don't read
|
|
127
|
-
*
|
|
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
|
-
*
|
|
130
|
-
* -
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
257
|
+
entry_path: pathForTelemetry(winnerPath, cwd),
|
|
166
258
|
});
|
|
167
259
|
return;
|
|
168
260
|
}
|
|
169
261
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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: '
|
|
282
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
|
|
185
283
|
return;
|
|
186
284
|
}
|
|
187
285
|
}
|
|
188
286
|
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
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 {
|
|
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
|
-
|
|
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)) {
|