robot-resources 1.15.2 → 1.15.3
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/package.json +7 -49
- package/README.md +0 -104
- package/bin/setup.js +0 -43
- package/lib/auth.mjs +0 -261
- package/lib/config.mjs +0 -55
- package/lib/detect.js +0 -254
- package/lib/health-report.js +0 -130
- package/lib/install-node-shim.js +0 -188
- package/lib/install-python-shim.js +0 -107
- package/lib/install-router-files.js +0 -48
- package/lib/json5.js +0 -16
- package/lib/login.mjs +0 -54
- package/lib/machine-id.js +0 -31
- package/lib/non-oc-wizard.js +0 -615
- package/lib/shell-config.js +0 -183
- package/lib/source-edit-attach.js +0 -469
- package/lib/tool-config.js +0 -504
- package/lib/ui.js +0 -87
- package/lib/uninstall.js +0 -208
- package/lib/venv-detect.js +0 -85
- package/lib/windows-env.js +0 -202
- package/lib/wizard.js +0 -523
package/lib/shell-config.js
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, appendFileSync, statSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Idempotent writer for the NODE_OPTIONS auto-attach line in shell rc files.
|
|
7
|
-
*
|
|
8
|
-
* Phase 3 ships POSIX-only support: zsh, bash, fish. Windows ships a printed-
|
|
9
|
-
* instructions fallback (Phase 6 problem). Every write is wrapped in a
|
|
10
|
-
* marker block so `--uninstall` can find and remove cleanly without
|
|
11
|
-
* regex-matching against the user's actual shell content:
|
|
12
|
-
*
|
|
13
|
-
* # >>> robot-resources: NODE_OPTIONS auto-attach >>>
|
|
14
|
-
* export NODE_OPTIONS="${NODE_OPTIONS:-} --require /Users/x/.robot-resources/router/auto.cjs"
|
|
15
|
-
* # <<< robot-resources <<<
|
|
16
|
-
*
|
|
17
|
-
* Phase 8 fix: NODE_OPTIONS now uses an ABSOLUTE PATH to the auto.cjs the
|
|
18
|
-
* wizard copied to ~/.robot-resources/router/. The previous bare-module
|
|
19
|
-
* form `--require @robot-resources/router/auto` only resolved when the user
|
|
20
|
-
* was cd'd inside a project that had `@robot-resources/router` in its
|
|
21
|
-
* node_modules — and broke EVERY Node command from any other cwd with
|
|
22
|
-
* `Cannot find module`. Result: every wizard-success Node user pre-Phase-8
|
|
23
|
-
* had a NODE_OPTIONS line that crashed `node`/`npm`/etc. Symptom in
|
|
24
|
-
* Supabase: `node_shim_installed: 8` but `adapter_attached: 0`.
|
|
25
|
-
*
|
|
26
|
-
* Behavior decisions (preserved from Phase 3):
|
|
27
|
-
* - If NODE_OPTIONS is already set with a different --require (rare; e.g.
|
|
28
|
-
* dd-trace), append ours after theirs. Both load. The user keeps their
|
|
29
|
-
* existing tooling. The shell expansion `${NODE_OPTIONS:-} ...` handles
|
|
30
|
-
* this correctly — POSIX shells concat space-separated --require flags.
|
|
31
|
-
* - Never clobber. If our marker block already exists, it's a no-op.
|
|
32
|
-
* - Write to ALL detected rc files (e.g. user has both .zshrc + .bashrc),
|
|
33
|
-
* so the user gets routing in whichever shell they actually open.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
const MARK_BEGIN = '# >>> robot-resources: NODE_OPTIONS auto-attach >>>';
|
|
37
|
-
const MARK_END = '# <<< robot-resources <<<';
|
|
38
|
-
|
|
39
|
-
function buildPosixLine(autoPath) {
|
|
40
|
-
return `export NODE_OPTIONS="\${NODE_OPTIONS:-} --require ${autoPath}"`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function buildFishLine(autoPath) {
|
|
44
|
-
// Fish has different syntax (no `export`, uses `set -x`).
|
|
45
|
-
return `set -x NODE_OPTIONS "$NODE_OPTIONS --require ${autoPath}"`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Discover which rc files are present for this user. Returns a list of
|
|
50
|
-
* absolute paths in priority order (zsh first, bash second, fish third).
|
|
51
|
-
* The wizard writes to ALL of them — users frequently edit one shell's
|
|
52
|
-
* rc and forget another, and we'd rather over-cover than under-cover.
|
|
53
|
-
*/
|
|
54
|
-
export function listShellRcFiles(home = homedir()) {
|
|
55
|
-
const candidates = [
|
|
56
|
-
{ kind: 'zsh', path: join(home, '.zshrc') },
|
|
57
|
-
{ kind: 'bash', path: join(home, '.bashrc') },
|
|
58
|
-
{ kind: 'bash', path: join(home, '.bash_profile') }, // macOS often uses this
|
|
59
|
-
{ kind: 'fish', path: join(home, '.config', 'fish', 'config.fish') },
|
|
60
|
-
];
|
|
61
|
-
return candidates.filter((c) => existsSync(c.path));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Returns true if at least one rc file already has our marker block.
|
|
66
|
-
* Used by both the wizard (skip-if-already-installed) and uninstall
|
|
67
|
-
* (gate the "remove" step).
|
|
68
|
-
*/
|
|
69
|
-
export function hasShellLine(home = homedir()) {
|
|
70
|
-
for (const { path } of listShellRcFiles(home)) {
|
|
71
|
-
try {
|
|
72
|
-
const text = readFileSync(path, 'utf-8');
|
|
73
|
-
if (text.includes(MARK_BEGIN)) return true;
|
|
74
|
-
} catch { /* unreadable rc, skip */ }
|
|
75
|
-
}
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Idempotently append the marker block to every detected rc file. Returns
|
|
81
|
-
* a list of files actually modified (empty if everything already had it).
|
|
82
|
-
*
|
|
83
|
-
* Each rc file is treated independently: the writer never aborts the
|
|
84
|
-
* others on one failure. Per-file errors are returned as warnings the
|
|
85
|
-
* caller can surface.
|
|
86
|
-
*/
|
|
87
|
-
export function writeShellLine({ autoPath, home = homedir() }) {
|
|
88
|
-
if (!autoPath) {
|
|
89
|
-
throw new Error('writeShellLine requires { autoPath } — absolute path to auto.cjs');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const rcs = listShellRcFiles(home);
|
|
93
|
-
const written = [];
|
|
94
|
-
const errors = [];
|
|
95
|
-
|
|
96
|
-
if (rcs.length === 0) {
|
|
97
|
-
// POSIX shells but no rc file yet — create ~/.zshrc on macOS (default
|
|
98
|
-
// since 10.15), ~/.bashrc on Linux. Better than silently no-op'ing.
|
|
99
|
-
const fallback = process.platform === 'darwin'
|
|
100
|
-
? { kind: 'zsh', path: join(home, '.zshrc') }
|
|
101
|
-
: { kind: 'bash', path: join(home, '.bashrc') };
|
|
102
|
-
rcs.push(fallback);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for (const rc of rcs) {
|
|
106
|
-
try {
|
|
107
|
-
let text = '';
|
|
108
|
-
try { text = readFileSync(rc.path, 'utf-8'); } catch { /* file may not exist yet */ }
|
|
109
|
-
|
|
110
|
-
if (text.includes(MARK_BEGIN)) {
|
|
111
|
-
// Already installed. Skip silently.
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const line = rc.kind === 'fish' ? buildFishLine(autoPath) : buildPosixLine(autoPath);
|
|
116
|
-
const block =
|
|
117
|
-
(text && !text.endsWith('\n') ? '\n' : '') +
|
|
118
|
-
'\n' + MARK_BEGIN + '\n' + line + '\n' + MARK_END + '\n';
|
|
119
|
-
|
|
120
|
-
// Append, don't rewrite — preserves the user's content exactly.
|
|
121
|
-
appendFileSync(rc.path, block, { mode: 0o644 });
|
|
122
|
-
written.push(rc.path);
|
|
123
|
-
} catch (err) {
|
|
124
|
-
errors.push({ path: rc.path, message: err.message });
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { written, errors };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Idempotently REMOVE the marker block from every detected rc file.
|
|
133
|
-
* Mirror of writeShellLine. Returns a list of files actually modified.
|
|
134
|
-
*
|
|
135
|
-
* Removal is text-based (find MARK_BEGIN, find MARK_END, splice). If the
|
|
136
|
-
* block was tampered with — e.g. user manually deleted MARK_END — we leave
|
|
137
|
-
* the file alone and surface a warning. Never destructive on partial state.
|
|
138
|
-
*/
|
|
139
|
-
export function removeShellLine(home = homedir()) {
|
|
140
|
-
const rcs = listShellRcFiles(home);
|
|
141
|
-
const removed = [];
|
|
142
|
-
const errors = [];
|
|
143
|
-
|
|
144
|
-
for (const rc of rcs) {
|
|
145
|
-
try {
|
|
146
|
-
const text = readFileSync(rc.path, 'utf-8');
|
|
147
|
-
const startIdx = text.indexOf(MARK_BEGIN);
|
|
148
|
-
if (startIdx === -1) continue;
|
|
149
|
-
const endIdx = text.indexOf(MARK_END, startIdx);
|
|
150
|
-
if (endIdx === -1) {
|
|
151
|
-
errors.push({ path: rc.path, message: 'marker_end_missing' });
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Splice from MARK_BEGIN through end of MARK_END line + trailing newline.
|
|
156
|
-
const afterEnd = text.indexOf('\n', endIdx);
|
|
157
|
-
const sliceEnd = afterEnd === -1 ? text.length : afterEnd + 1;
|
|
158
|
-
|
|
159
|
-
// Walk back over the leading newline our writer added so we don't
|
|
160
|
-
// accumulate blank lines on repeated install/uninstall cycles.
|
|
161
|
-
let sliceStart = startIdx;
|
|
162
|
-
while (sliceStart > 0 && text[sliceStart - 1] === '\n') sliceStart--;
|
|
163
|
-
|
|
164
|
-
const next = text.slice(0, sliceStart) +
|
|
165
|
-
(sliceStart > 0 ? '\n' : '') +
|
|
166
|
-
text.slice(sliceEnd);
|
|
167
|
-
|
|
168
|
-
writeFileSync(rc.path, next, { mode: getMode(rc.path) });
|
|
169
|
-
removed.push(rc.path);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
errors.push({ path: rc.path, message: err.message });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return { removed, errors };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function getMode(path) {
|
|
179
|
-
try { return statSync(path).mode & 0o777; } catch { return 0o644; }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Exported for tests + telemetry payloads.
|
|
183
|
-
export { MARK_BEGIN, MARK_END, buildPosixLine, buildFishLine };
|
|
@@ -1,469 +0,0 @@
|
|
|
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';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Source-edit injection for non-OC Node agents (Phase 11).
|
|
7
|
-
*
|
|
8
|
-
* The Phase-3 NODE_OPTIONS approach only reaches processes that inherit
|
|
9
|
-
* from a shell that loaded ~/.bashrc or ~/.zshrc. Cron / Docker / systemd /
|
|
10
|
-
* Lambda / Cloud Run / serverless functions do NOT inherit shell rc files,
|
|
11
|
-
* so the shim never loads in the agents that matter most. Funnel data
|
|
12
|
-
* showed a 45-point cliff: 53% reach `node_shim_installed`, only 8.6% ever
|
|
13
|
-
* emit `adapter_attached`.
|
|
14
|
-
*
|
|
15
|
-
* This module injects ONE LINE at the top of the user's entry source file:
|
|
16
|
-
*
|
|
17
|
-
* // >>> robot-resources: auto-attach >>>
|
|
18
|
-
* require('@robot-resources/router/auto');
|
|
19
|
-
* // <<< robot-resources <<<
|
|
20
|
-
*
|
|
21
|
-
* (or `import '@robot-resources/router/auto';` for ESM/TS files.)
|
|
22
|
-
*
|
|
23
|
-
* The line runs whenever the agent process starts, regardless of how it
|
|
24
|
-
* was launched. Cron, Docker, systemd, Lambda — all work the same.
|
|
25
|
-
*
|
|
26
|
-
* Marker-block convention mirrors shell-config.js exactly so an `--uninstall`
|
|
27
|
-
* remove pass is text-based and never destructive on partial state.
|
|
28
|
-
*
|
|
29
|
-
* Backup: a `.rr-backup` is written once on first inject (never overwritten
|
|
30
|
-
* on re-inject) so `--uninstall --purge` can restore the original source
|
|
31
|
-
* even if the marker block was tampered with.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
export const MARK_BEGIN = '// >>> robot-resources: auto-attach >>>';
|
|
35
|
-
export const MARK_END = '// <<< robot-resources <<<';
|
|
36
|
-
|
|
37
|
-
const REQUIRE_LINE = "require('@robot-resources/router/auto');";
|
|
38
|
-
const IMPORT_LINE = "import '@robot-resources/router/auto';";
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Decide which import syntax to inject for a given source file.
|
|
42
|
-
* Implements Node's resolution rules verbatim:
|
|
43
|
-
* .cjs → cjs
|
|
44
|
-
* .mjs → esm
|
|
45
|
-
* .ts / .tsx → emit `import` (works under both ts-node CJS and tsx/ESM)
|
|
46
|
-
* .js / .jsx → walk up to nearest package.json, check "type" field.
|
|
47
|
-
* "module" → esm; anything else (or absent) → cjs.
|
|
48
|
-
*/
|
|
49
|
-
export function detectImportSyntax(entryPath) {
|
|
50
|
-
const ext = extname(entryPath).toLowerCase();
|
|
51
|
-
if (ext === '.cjs') return 'cjs';
|
|
52
|
-
if (ext === '.mjs') return 'esm';
|
|
53
|
-
if (ext === '.ts' || ext === '.tsx') return 'esm';
|
|
54
|
-
|
|
55
|
-
// Walk up from the file's directory until we hit a package.json or
|
|
56
|
-
// exhaust the path. This is what Node itself does for `.js` files.
|
|
57
|
-
let dir = dirname(resolve(entryPath));
|
|
58
|
-
while (true) {
|
|
59
|
-
const pkgPath = join(dir, 'package.json');
|
|
60
|
-
if (existsSync(pkgPath)) {
|
|
61
|
-
try {
|
|
62
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
63
|
-
return pkg.type === 'module' ? 'esm' : 'cjs';
|
|
64
|
-
} catch {
|
|
65
|
-
return 'cjs';
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const parent = dirname(dir);
|
|
69
|
-
if (parent === dir) return 'cjs';
|
|
70
|
-
dir = parent;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
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
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Locate the user's agent source file under cwd by scanning for AI-SDK
|
|
106
|
-
* imports. Returns { winner, candidates, scanned, walked, ambiguous }.
|
|
107
|
-
*
|
|
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.
|
|
118
|
-
*
|
|
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.
|
|
129
|
-
*/
|
|
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
|
-
}
|
|
162
|
-
}
|
|
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
|
-
}
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
winner: winner?.path ?? null,
|
|
224
|
-
candidates,
|
|
225
|
-
scanned: sdkFiles.length,
|
|
226
|
-
walked,
|
|
227
|
-
ambiguous,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
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 */ } }
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
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);
|
|
264
|
-
}
|
|
265
|
-
return found;
|
|
266
|
-
}
|
|
267
|
-
|
|
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
|
-
}
|
|
273
|
-
|
|
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;
|
|
290
|
-
}
|
|
291
|
-
|
|
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;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Returns true if the given source file already contains our marker.
|
|
300
|
-
* Used to skip-if-already-installed and to gate uninstall.
|
|
301
|
-
*/
|
|
302
|
-
export function hasSourceMarker(filePath) {
|
|
303
|
-
try {
|
|
304
|
-
return readFileSync(filePath, 'utf-8').includes(MARK_BEGIN);
|
|
305
|
-
} catch {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Idempotently inject the auto-attach marker block at the top of the file
|
|
312
|
-
* (after shebang, if present). Writes a one-time backup at `${filePath}.rr-backup`.
|
|
313
|
-
*
|
|
314
|
-
* Returns { ok, alreadyInstalled, path, syntax, backupPath, error }.
|
|
315
|
-
*/
|
|
316
|
-
export function writeSourceMarker(filePath, opts = {}) {
|
|
317
|
-
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
318
|
-
|
|
319
|
-
let original;
|
|
320
|
-
try { original = readFileSync(filePath, 'utf-8'); }
|
|
321
|
-
catch (err) {
|
|
322
|
-
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `read_failed: ${err.message}` };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (original.includes(MARK_BEGIN)) {
|
|
326
|
-
return { ok: true, alreadyInstalled: true, path: filePath, syntax, backupPath: backupPathFor(filePath) };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Backup once. Never overwrite — preserves the user's pristine original
|
|
330
|
-
// even if they manually edit before our second pass.
|
|
331
|
-
const backupPath = backupPathFor(filePath);
|
|
332
|
-
let backupWritten = false;
|
|
333
|
-
if (!existsSync(backupPath)) {
|
|
334
|
-
try {
|
|
335
|
-
writeFileSync(backupPath, original, { mode: getMode(filePath) });
|
|
336
|
-
backupWritten = true;
|
|
337
|
-
} catch (err) {
|
|
338
|
-
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `backup_failed: ${err.message}` };
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Insert position: after shebang line if present, otherwise at top.
|
|
343
|
-
// We do NOT try to skip past `"use strict";` — modern Node treats it as
|
|
344
|
-
// a normal directive; placing our require/import before it is harmless.
|
|
345
|
-
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
346
|
-
const block = `${MARK_BEGIN}\n${line}\n${MARK_END}\n`;
|
|
347
|
-
|
|
348
|
-
let next;
|
|
349
|
-
if (original.startsWith('#!')) {
|
|
350
|
-
const nl = original.indexOf('\n');
|
|
351
|
-
if (nl === -1) {
|
|
352
|
-
// single-line file, only a shebang — append our block on a new line
|
|
353
|
-
next = original + '\n' + block;
|
|
354
|
-
} else {
|
|
355
|
-
next = original.slice(0, nl + 1) + block + original.slice(nl + 1);
|
|
356
|
-
}
|
|
357
|
-
} else {
|
|
358
|
-
next = block + original;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
363
|
-
} catch (err) {
|
|
364
|
-
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `write_failed: ${err.message}`, backupPath, backupWritten };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return { ok: true, alreadyInstalled: false, path: filePath, syntax, backupPath, backupWritten };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Idempotently remove the marker block from the source file. Mirror of
|
|
372
|
-
* writeSourceMarker. Returns { ok, removed, restored, path, error }.
|
|
373
|
-
*
|
|
374
|
-
* If `restoreFromBackup` is true and a `.rr-backup` exists, restores from
|
|
375
|
-
* the backup instead of splicing. Used for `--purge`. The backup is
|
|
376
|
-
* deleted after a successful restore.
|
|
377
|
-
*/
|
|
378
|
-
export function removeSourceMarker(filePath, { restoreFromBackup = false } = {}) {
|
|
379
|
-
let original;
|
|
380
|
-
try { original = readFileSync(filePath, 'utf-8'); }
|
|
381
|
-
catch (err) {
|
|
382
|
-
return { ok: false, removed: false, restored: false, path: filePath, error: `read_failed: ${err.message}` };
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (restoreFromBackup) {
|
|
386
|
-
const backupPath = backupPathFor(filePath);
|
|
387
|
-
if (existsSync(backupPath)) {
|
|
388
|
-
try {
|
|
389
|
-
const pristine = readFileSync(backupPath, 'utf-8');
|
|
390
|
-
writeFileSync(filePath, pristine, { mode: getMode(filePath) });
|
|
391
|
-
unlinkSync(backupPath);
|
|
392
|
-
return { ok: true, removed: true, restored: true, path: filePath };
|
|
393
|
-
} catch (err) {
|
|
394
|
-
return { ok: false, removed: false, restored: false, path: filePath, error: `restore_failed: ${err.message}` };
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// No backup → fall through to marker splice.
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const startIdx = original.indexOf(MARK_BEGIN);
|
|
401
|
-
if (startIdx === -1) {
|
|
402
|
-
return { ok: true, removed: false, restored: false, path: filePath };
|
|
403
|
-
}
|
|
404
|
-
const endIdx = original.indexOf(MARK_END, startIdx);
|
|
405
|
-
if (endIdx === -1) {
|
|
406
|
-
return { ok: false, removed: false, restored: false, path: filePath, error: 'marker_end_missing' };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Splice from MARK_BEGIN through end of MARK_END line + the trailing
|
|
410
|
-
// newline our writer added. Walk backward over leading newlines so
|
|
411
|
-
// repeated install/uninstall cycles don't accumulate blanks.
|
|
412
|
-
const afterEnd = original.indexOf('\n', endIdx);
|
|
413
|
-
const sliceEnd = afterEnd === -1 ? original.length : afterEnd + 1;
|
|
414
|
-
|
|
415
|
-
let sliceStart = startIdx;
|
|
416
|
-
while (sliceStart > 0 && original[sliceStart - 1] === '\n') sliceStart--;
|
|
417
|
-
|
|
418
|
-
const next = original.slice(0, sliceStart) +
|
|
419
|
-
(sliceStart > 0 ? '\n' : '') +
|
|
420
|
-
original.slice(sliceEnd);
|
|
421
|
-
|
|
422
|
-
try {
|
|
423
|
-
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
424
|
-
} catch (err) {
|
|
425
|
-
return { ok: false, removed: false, restored: false, path: filePath, error: `write_failed: ${err.message}` };
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return { ok: true, removed: true, restored: false, path: filePath };
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Build the proposed diff a wizard can show before asking Y/N. Returns a
|
|
433
|
-
* UI-friendly string with the marker block + a few lines of context.
|
|
434
|
-
*/
|
|
435
|
-
export function previewInjection(filePath, opts = {}) {
|
|
436
|
-
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
437
|
-
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
438
|
-
let original = '';
|
|
439
|
-
try { original = readFileSync(filePath, 'utf-8'); } catch { /* ignore */ }
|
|
440
|
-
const firstLines = original.split('\n').slice(0, 3).join('\n');
|
|
441
|
-
return [
|
|
442
|
-
`+ ${MARK_BEGIN}`,
|
|
443
|
-
`+ ${line}`,
|
|
444
|
-
`+ ${MARK_END}`,
|
|
445
|
-
firstLines.split('\n').map((l) => ` ${l}`).join('\n'),
|
|
446
|
-
].join('\n');
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function backupPathFor(filePath) {
|
|
450
|
-
return `${filePath}.rr-backup`;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function getMode(filePath) {
|
|
454
|
-
try { return statSync(filePath).mode & 0o777; } catch { return 0o644; }
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Best-effort cwd-relative path for telemetry, falling back to basename
|
|
459
|
-
* if the file is outside cwd. Used to avoid leaking absolute paths (which
|
|
460
|
-
* can contain usernames) into Supabase.
|
|
461
|
-
*/
|
|
462
|
-
export function pathForTelemetry(filePath, cwd = process.cwd()) {
|
|
463
|
-
const rel = relative(cwd, filePath);
|
|
464
|
-
if (rel.startsWith('..') || rel.includes(sep + '..' + sep)) {
|
|
465
|
-
// Outside cwd — basename only.
|
|
466
|
-
return filePath.split(sep).pop();
|
|
467
|
-
}
|
|
468
|
-
return rel;
|
|
469
|
-
}
|