robot-resources 1.14.2 → 1.15.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.
package/bin/setup.js CHANGED
@@ -25,13 +25,18 @@ if (args.includes('--uninstall')) {
25
25
  const scopeArg = args.find((a) => a.startsWith('--scope='));
26
26
  const scope = scopeArg ? scopeArg.slice('--scope='.length) : 'full';
27
27
 
28
+ // Phase 11: --auto-attach-source opts into source-edit injection in non-
29
+ // interactive contexts (CI). Default off — auto-rewriting source files
30
+ // without consent is too aggressive. Interactive runs always show Y/N.
31
+ const autoAttachSource = args.includes('--auto-attach-source');
32
+
28
33
  // Treat piped/CI runs (no TTY on stdin OR stdout) as non-interactive so the
29
34
  // wizard never blocks on a prompt that can't be answered. The interactive
30
35
  // menu is only opened when both stdin and stdout are real terminals.
31
36
  const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
32
37
  const nonInteractive = explicitNonInteractive || !hasTty;
33
38
 
34
- runWizard({ nonInteractive, target, scope }).catch((err) => {
39
+ runWizard({ nonInteractive, target, scope, autoAttachSource }).catch((err) => {
35
40
  console.error(`\n ✗ Setup failed: ${err.message}\n`);
36
41
  process.exit(1);
37
42
  });
@@ -3,10 +3,17 @@ import { join } from 'node:path';
3
3
  import { select } from '@inquirer/prompts';
4
4
  import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
5
5
  import { configureClaudeCode, configureCursor } from './tool-config.js';
6
- import { header, info, success, warn, blank } from './ui.js';
6
+ import { header, info, success, warn, blank, confirm } from './ui.js';
7
7
  import { readConfig } from './config.mjs';
8
8
  import { installNodeShim } from './install-node-shim.js';
9
9
  import { installPythonShim } from './install-python-shim.js';
10
+ import {
11
+ detectEntryFile,
12
+ hasSourceMarker,
13
+ writeSourceMarker,
14
+ previewInjection,
15
+ pathForTelemetry,
16
+ } from './source-edit-attach.js';
10
17
 
11
18
  const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
12
19
 
@@ -89,7 +96,143 @@ async function emitPathChosen(path) {
89
96
  }
90
97
  }
91
98
 
92
- async function showJsPath() {
99
+ async function emitNodeEntryPatched(payload) {
100
+ const config = readConfig();
101
+ if (!config.api_key) return;
102
+ try {
103
+ await fetch(`${PLATFORM_URL}/v1/telemetry`, {
104
+ method: 'POST',
105
+ headers: {
106
+ Authorization: `Bearer ${config.api_key}`,
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify({
110
+ product: 'cli',
111
+ event_type: 'node_entry_patched',
112
+ payload: { ...payload, platform: process.platform },
113
+ }),
114
+ signal: AbortSignal.timeout(5_000),
115
+ });
116
+ } catch {
117
+ // Best-effort — never let telemetry break the install path.
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Phase 11 — offer to inject `require('@robot-resources/router/auto')`
123
+ * (or ESM equivalent) at the top of the user's entry file.
124
+ *
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.
128
+ *
129
+ * Gates:
130
+ * - No package.json or no detectable entry → skip with a hint
131
+ * - 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
135
+ */
136
+ 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;
145
+ }
146
+
147
+ if (hasSourceMarker(detection.path)) {
148
+ info(`${pathForTelemetry(detection.path, cwd)} already has the auto-attach line — leaving it as-is.`);
149
+ await emitNodeEntryPatched({
150
+ outcome: 'already_present',
151
+ entry_path: pathForTelemetry(detection.path, cwd),
152
+ });
153
+ return;
154
+ }
155
+
156
+ // Non-interactive: skip unless explicit opt-in. Source files are sacred.
157
+ if (nonInteractive && !autoAttachSource) {
158
+ blank();
159
+ info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
160
+ 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)}:`);
162
+ info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
163
+ await emitNodeEntryPatched({
164
+ outcome: 'skipped_non_interactive',
165
+ entry_path: pathForTelemetry(detection.path, cwd),
166
+ });
167
+ return;
168
+ }
169
+
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,
176
+ }));
177
+ 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
+ });
183
+ if (target === '__skip__') {
184
+ await emitNodeEntryPatched({ outcome: 'declined_ambiguous_pick' });
185
+ return;
186
+ }
187
+ }
188
+
189
+ // Show the diff and ask Y/N (unless auto-attach is explicitly opt-in).
190
+ const preview = previewInjection(target);
191
+ blank();
192
+ success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
193
+ blank();
194
+ info(`File: ${pathForTelemetry(target, cwd)}`);
195
+ blank();
196
+ for (const line of preview.split('\n')) info(line);
197
+ blank();
198
+
199
+ let proceed = autoAttachSource;
200
+ if (!nonInteractive && !autoAttachSource) {
201
+ proceed = await confirm('Add the line?', { defaultYes: true });
202
+ }
203
+
204
+ if (!proceed) {
205
+ info('Skipped — you can re-run anytime.');
206
+ await emitNodeEntryPatched({
207
+ outcome: 'declined',
208
+ entry_path: pathForTelemetry(target, cwd),
209
+ });
210
+ return;
211
+ }
212
+
213
+ const result = writeSourceMarker(target);
214
+ if (result.ok) {
215
+ success(`Added the auto-attach line to ${pathForTelemetry(target, cwd)} (${result.syntax}).`);
216
+ if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
217
+ info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
218
+ await emitNodeEntryPatched({
219
+ outcome: 'patched',
220
+ entry_path: pathForTelemetry(target, cwd),
221
+ import_syntax: result.syntax,
222
+ backup_written: !!result.backupWritten,
223
+ });
224
+ } else {
225
+ warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
226
+ info('You can add the line yourself — see the snippet above.');
227
+ await emitNodeEntryPatched({
228
+ outcome: 'failed',
229
+ entry_path: pathForTelemetry(target, cwd),
230
+ error: result.error,
231
+ });
232
+ }
233
+ }
234
+
235
+ async function showJsPath({ nonInteractive = false, autoAttachSource = false } = {}) {
93
236
  blank();
94
237
  success('JS/TS integration');
95
238
  blank();
@@ -105,14 +248,10 @@ async function showJsPath() {
105
248
  }
106
249
  }
107
250
  blank();
108
- info('Once your shell picks up the new NODE_OPTIONS, every Node agent on');
109
- info('this machine routes Anthropic, OpenAI, and Google SDK calls through');
110
- info('Robot Resources.');
111
- if (process.platform === 'win32') {
112
- info('Open a new cmd / PowerShell window — current terminals will not see the change.');
113
- } else {
114
- info('Open a new terminal — or run: source ~/.zshrc (or your shell rc)');
115
- }
251
+ info('Phase 3+ NODE_OPTIONS shim installed. Works for desktop dev sessions');
252
+ info('after a terminal restart, but does NOT reach cron / Docker / systemd /');
253
+ info('Lambda. The next step adds a one-line require to your agent source so');
254
+ info('routing works regardless of how the process is launched.');
116
255
  } else {
117
256
  warn(result.message);
118
257
  blank();
@@ -124,6 +263,11 @@ async function showJsPath() {
124
263
  info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require ~/.robot-resources/router/auto.cjs"');
125
264
  }
126
265
  }
266
+
267
+ // Phase 11 — source-edit injection. Reaches cron / Docker / systemd / Lambda
268
+ // since the line lives in the user's source, not in shell config.
269
+ await maybeInjectSourceEdit({ nonInteractive, autoAttachSource });
270
+
127
271
  blank();
128
272
  info('Docs: https://robotresources.ai/docs/langchain');
129
273
  blank();
@@ -204,9 +348,9 @@ function showInstallOcPath() {
204
348
  blank();
205
349
  }
206
350
 
207
- async function runPath(path) {
351
+ async function runPath(path, opts = {}) {
208
352
  switch (path) {
209
- case 'js': await showJsPath(); break;
353
+ case 'js': await showJsPath(opts); break;
210
354
  case 'python': await showPythonPath(); break;
211
355
  case 'mcp': showMcpPath(); break;
212
356
  case 'docs': showDocsPath(); break;
@@ -221,11 +365,11 @@ async function runPath(path) {
221
365
  * - non-interactive AND no target: print hint with --for= options and exit
222
366
  * - interactive: 5-option menu via @inquirer/prompts.select
223
367
  */
224
- export async function runNonOcWizard({ nonInteractive = false, target = null } = {}) {
368
+ export async function runNonOcWizard({ nonInteractive = false, target = null, autoAttachSource = false } = {}) {
225
369
  const normalized = normalizeTarget(target);
226
370
 
227
371
  if (normalized) {
228
- await runPath(normalized);
372
+ await runPath(normalized, { nonInteractive, autoAttachSource });
229
373
  await emitPathChosen(normalized);
230
374
  return;
231
375
  }
@@ -249,7 +393,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
249
393
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
250
394
  info(' Pass --for=<other> to override, or --uninstall to remove later.');
251
395
  blank();
252
- await runPath(autoTarget);
396
+ await runPath(autoTarget, { nonInteractive, autoAttachSource });
253
397
  await emitPathChosen(autoTarget);
254
398
  return;
255
399
  }
@@ -334,7 +478,9 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
334
478
  if (autoTarget) {
335
479
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
336
480
  blank();
337
- await runPath(autoTarget);
481
+ // Treat the timed-out interactive session as non-interactive for the
482
+ // source-edit step too — we don't have a TTY to ask the Y/N on.
483
+ await runPath(autoTarget, { nonInteractive: true, autoAttachSource });
338
484
  await emitPathChosen(autoTarget);
339
485
  return;
340
486
  }
@@ -347,6 +493,6 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
347
493
  return;
348
494
  }
349
495
 
350
- await runPath(chosen);
496
+ await runPath(chosen, { nonInteractive, autoAttachSource });
351
497
  await emitPathChosen(chosen);
352
498
  }
@@ -0,0 +1,335 @@
1
+ import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from 'node:fs';
2
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+
4
+ /**
5
+ * Source-edit injection for non-OC Node agents (Phase 11).
6
+ *
7
+ * The Phase-3 NODE_OPTIONS approach only reaches processes that inherit
8
+ * from a shell that loaded ~/.bashrc or ~/.zshrc. Cron / Docker / systemd /
9
+ * Lambda / Cloud Run / serverless functions do NOT inherit shell rc files,
10
+ * so the shim never loads in the agents that matter most. Funnel data
11
+ * showed a 45-point cliff: 53% reach `node_shim_installed`, only 8.6% ever
12
+ * emit `adapter_attached`.
13
+ *
14
+ * This module injects ONE LINE at the top of the user's entry source file:
15
+ *
16
+ * // >>> robot-resources: auto-attach >>>
17
+ * require('@robot-resources/router/auto');
18
+ * // <<< robot-resources <<<
19
+ *
20
+ * (or `import '@robot-resources/router/auto';` for ESM/TS files.)
21
+ *
22
+ * The line runs whenever the agent process starts, regardless of how it
23
+ * was launched. Cron, Docker, systemd, Lambda — all work the same.
24
+ *
25
+ * Marker-block convention mirrors shell-config.js exactly so an `--uninstall`
26
+ * remove pass is text-based and never destructive on partial state.
27
+ *
28
+ * Backup: a `.rr-backup` is written once on first inject (never overwritten
29
+ * on re-inject) so `--uninstall --purge` can restore the original source
30
+ * even if the marker block was tampered with.
31
+ */
32
+
33
+ export const MARK_BEGIN = '// >>> robot-resources: auto-attach >>>';
34
+ export const MARK_END = '// <<< robot-resources <<<';
35
+
36
+ const REQUIRE_LINE = "require('@robot-resources/router/auto');";
37
+ const IMPORT_LINE = "import '@robot-resources/router/auto';";
38
+
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
+ /**
45
+ * Decide which import syntax to inject for a given source file.
46
+ * Implements Node's resolution rules verbatim:
47
+ * .cjs → cjs
48
+ * .mjs → esm
49
+ * .ts / .tsx → emit `import` (works under both ts-node CJS and tsx/ESM)
50
+ * .js / .jsx → walk up to nearest package.json, check "type" field.
51
+ * "module" → esm; anything else (or absent) → cjs.
52
+ */
53
+ export function detectImportSyntax(entryPath) {
54
+ const ext = extname(entryPath).toLowerCase();
55
+ if (ext === '.cjs') return 'cjs';
56
+ if (ext === '.mjs') return 'esm';
57
+ if (ext === '.ts' || ext === '.tsx') return 'esm';
58
+
59
+ // Walk up from the file's directory until we hit a package.json or
60
+ // exhaust the path. This is what Node itself does for `.js` files.
61
+ let dir = dirname(resolve(entryPath));
62
+ while (true) {
63
+ const pkgPath = join(dir, 'package.json');
64
+ if (existsSync(pkgPath)) {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
67
+ return pkg.type === 'module' ? 'esm' : 'cjs';
68
+ } catch {
69
+ return 'cjs';
70
+ }
71
+ }
72
+ const parent = dirname(dir);
73
+ if (parent === dir) return 'cjs';
74
+ dir = parent;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Locate the user's agent entry file under cwd. Staged fallback through
80
+ * standard package.json fields, then a convention scan.
81
+ *
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)
86
+ *
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}
96
+ */
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);
112
+ }
113
+ }
114
+
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
+ }
125
+
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
+ }
132
+ }
133
+
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);
146
+ }
147
+
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()));
153
+
154
+ return { path: viable[0] ?? null, candidates: viable };
155
+ }
156
+
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);
162
+ }
163
+
164
+ /**
165
+ * Returns true if the given source file already contains our marker.
166
+ * Used to skip-if-already-installed and to gate uninstall.
167
+ */
168
+ export function hasSourceMarker(filePath) {
169
+ try {
170
+ return readFileSync(filePath, 'utf-8').includes(MARK_BEGIN);
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Idempotently inject the auto-attach marker block at the top of the file
178
+ * (after shebang, if present). Writes a one-time backup at `${filePath}.rr-backup`.
179
+ *
180
+ * Returns { ok, alreadyInstalled, path, syntax, backupPath, error }.
181
+ */
182
+ export function writeSourceMarker(filePath, opts = {}) {
183
+ const syntax = opts.syntax ?? detectImportSyntax(filePath);
184
+
185
+ let original;
186
+ try { original = readFileSync(filePath, 'utf-8'); }
187
+ catch (err) {
188
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `read_failed: ${err.message}` };
189
+ }
190
+
191
+ if (original.includes(MARK_BEGIN)) {
192
+ return { ok: true, alreadyInstalled: true, path: filePath, syntax, backupPath: backupPathFor(filePath) };
193
+ }
194
+
195
+ // Backup once. Never overwrite — preserves the user's pristine original
196
+ // even if they manually edit before our second pass.
197
+ const backupPath = backupPathFor(filePath);
198
+ let backupWritten = false;
199
+ if (!existsSync(backupPath)) {
200
+ try {
201
+ writeFileSync(backupPath, original, { mode: getMode(filePath) });
202
+ backupWritten = true;
203
+ } catch (err) {
204
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `backup_failed: ${err.message}` };
205
+ }
206
+ }
207
+
208
+ // Insert position: after shebang line if present, otherwise at top.
209
+ // We do NOT try to skip past `"use strict";` — modern Node treats it as
210
+ // a normal directive; placing our require/import before it is harmless.
211
+ const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
212
+ const block = `${MARK_BEGIN}\n${line}\n${MARK_END}\n`;
213
+
214
+ let next;
215
+ if (original.startsWith('#!')) {
216
+ const nl = original.indexOf('\n');
217
+ if (nl === -1) {
218
+ // single-line file, only a shebang — append our block on a new line
219
+ next = original + '\n' + block;
220
+ } else {
221
+ next = original.slice(0, nl + 1) + block + original.slice(nl + 1);
222
+ }
223
+ } else {
224
+ next = block + original;
225
+ }
226
+
227
+ try {
228
+ writeFileSync(filePath, next, { mode: getMode(filePath) });
229
+ } catch (err) {
230
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `write_failed: ${err.message}`, backupPath, backupWritten };
231
+ }
232
+
233
+ return { ok: true, alreadyInstalled: false, path: filePath, syntax, backupPath, backupWritten };
234
+ }
235
+
236
+ /**
237
+ * Idempotently remove the marker block from the source file. Mirror of
238
+ * writeSourceMarker. Returns { ok, removed, restored, path, error }.
239
+ *
240
+ * If `restoreFromBackup` is true and a `.rr-backup` exists, restores from
241
+ * the backup instead of splicing. Used for `--purge`. The backup is
242
+ * deleted after a successful restore.
243
+ */
244
+ export function removeSourceMarker(filePath, { restoreFromBackup = false } = {}) {
245
+ let original;
246
+ try { original = readFileSync(filePath, 'utf-8'); }
247
+ catch (err) {
248
+ return { ok: false, removed: false, restored: false, path: filePath, error: `read_failed: ${err.message}` };
249
+ }
250
+
251
+ if (restoreFromBackup) {
252
+ const backupPath = backupPathFor(filePath);
253
+ if (existsSync(backupPath)) {
254
+ try {
255
+ const pristine = readFileSync(backupPath, 'utf-8');
256
+ writeFileSync(filePath, pristine, { mode: getMode(filePath) });
257
+ unlinkSync(backupPath);
258
+ return { ok: true, removed: true, restored: true, path: filePath };
259
+ } catch (err) {
260
+ return { ok: false, removed: false, restored: false, path: filePath, error: `restore_failed: ${err.message}` };
261
+ }
262
+ }
263
+ // No backup → fall through to marker splice.
264
+ }
265
+
266
+ const startIdx = original.indexOf(MARK_BEGIN);
267
+ if (startIdx === -1) {
268
+ return { ok: true, removed: false, restored: false, path: filePath };
269
+ }
270
+ const endIdx = original.indexOf(MARK_END, startIdx);
271
+ if (endIdx === -1) {
272
+ return { ok: false, removed: false, restored: false, path: filePath, error: 'marker_end_missing' };
273
+ }
274
+
275
+ // Splice from MARK_BEGIN through end of MARK_END line + the trailing
276
+ // newline our writer added. Walk backward over leading newlines so
277
+ // repeated install/uninstall cycles don't accumulate blanks.
278
+ const afterEnd = original.indexOf('\n', endIdx);
279
+ const sliceEnd = afterEnd === -1 ? original.length : afterEnd + 1;
280
+
281
+ let sliceStart = startIdx;
282
+ while (sliceStart > 0 && original[sliceStart - 1] === '\n') sliceStart--;
283
+
284
+ const next = original.slice(0, sliceStart) +
285
+ (sliceStart > 0 ? '\n' : '') +
286
+ original.slice(sliceEnd);
287
+
288
+ try {
289
+ writeFileSync(filePath, next, { mode: getMode(filePath) });
290
+ } catch (err) {
291
+ return { ok: false, removed: false, restored: false, path: filePath, error: `write_failed: ${err.message}` };
292
+ }
293
+
294
+ return { ok: true, removed: true, restored: false, path: filePath };
295
+ }
296
+
297
+ /**
298
+ * Build the proposed diff a wizard can show before asking Y/N. Returns a
299
+ * UI-friendly string with the marker block + a few lines of context.
300
+ */
301
+ export function previewInjection(filePath, opts = {}) {
302
+ const syntax = opts.syntax ?? detectImportSyntax(filePath);
303
+ const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
304
+ let original = '';
305
+ try { original = readFileSync(filePath, 'utf-8'); } catch { /* ignore */ }
306
+ const firstLines = original.split('\n').slice(0, 3).join('\n');
307
+ return [
308
+ `+ ${MARK_BEGIN}`,
309
+ `+ ${line}`,
310
+ `+ ${MARK_END}`,
311
+ firstLines.split('\n').map((l) => ` ${l}`).join('\n'),
312
+ ].join('\n');
313
+ }
314
+
315
+ function backupPathFor(filePath) {
316
+ return `${filePath}.rr-backup`;
317
+ }
318
+
319
+ function getMode(filePath) {
320
+ try { return statSync(filePath).mode & 0o777; } catch { return 0o644; }
321
+ }
322
+
323
+ /**
324
+ * Best-effort cwd-relative path for telemetry, falling back to basename
325
+ * if the file is outside cwd. Used to avoid leaking absolute paths (which
326
+ * can contain usernames) into Supabase.
327
+ */
328
+ export function pathForTelemetry(filePath, cwd = process.cwd()) {
329
+ const rel = relative(cwd, filePath);
330
+ if (rel.startsWith('..') || rel.includes(sep + '..' + sep)) {
331
+ // Outside cwd — basename only.
332
+ return filePath.split(sep).pop();
333
+ }
334
+ return rel;
335
+ }
package/lib/uninstall.js CHANGED
@@ -6,6 +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
10
 
10
11
  /**
11
12
  * Single source of truth for `npx robot-resources --uninstall`.
@@ -115,6 +116,36 @@ export function runUninstall({ purge = false } = {}) {
115
116
  }
116
117
  }
117
118
 
119
+ // 3a. Phase 11 — source-edit auto-attach line. If the wizard injected
120
+ // `require('@robot-resources/router/auto')` at the top of the user's
121
+ // entry file, peel the marker block out (or restore from .rr-backup
122
+ // when --purge). Idempotent: no-op when no entry detected or marker
123
+ // not present. Runs against cwd, so users who --uninstall from a
124
+ // different repo won't accidentally wipe the wrong file.
125
+ try {
126
+ const detection = detectEntryFile();
127
+ if (detection.path && hasSourceMarker(detection.path)) {
128
+ const r = removeSourceMarker(detection.path, { restoreFromBackup: !!purge });
129
+ if (r.ok && (r.removed || r.restored)) {
130
+ components_removed.push(r.restored ? 'node_entry_restored_from_backup' : 'node_entry_marker_removed');
131
+ } else if (!r.ok) {
132
+ errors.push({ component: 'node_entry_source_edit', message: r.error || 'unknown' });
133
+ }
134
+ }
135
+ // --purge also wipes any leftover .rr-backup (covers the case where the
136
+ // user manually deleted the marker but kept the backup file around).
137
+ if (purge && detection.path && existsSync(`${detection.path}.rr-backup`)) {
138
+ try {
139
+ rmSync(`${detection.path}.rr-backup`, { force: true });
140
+ components_removed.push('node_entry_backup_purged');
141
+ } catch (err) {
142
+ errors.push({ component: 'node_entry_backup_purged', message: err.message });
143
+ }
144
+ }
145
+ } catch (err) {
146
+ errors.push({ component: 'node_entry_source_edit', message: err.message });
147
+ }
148
+
118
149
  // 3b. Copied router dir at ~/.robot-resources/router/ (Phase 8). The shell
119
150
  // line points at this absolute path — once the line is gone, the
120
151
  // copied files are dead weight. Remove them.
package/lib/wizard.js CHANGED
@@ -26,6 +26,43 @@ const CLI_VERSION = (() => {
26
26
  }
27
27
  })();
28
28
 
29
+ /**
30
+ * Fire `install_complete` once after the wizard finishes — for BOTH OC
31
+ * and non-OC paths. Phase 11 fix: previously this only fired on the OC
32
+ * path because the non-OC branch returned early. The 7-day funnel showed
33
+ * 0/58 non-OC users hitting this event for a week. Now both paths fire
34
+ * with a `path: 'oc' | 'non-oc'` discriminator so funnel queries can
35
+ * segment without a second event type.
36
+ *
37
+ * Best-effort with one retry. Total budget: ~20s. Telemetry never blocks
38
+ * the wizard exit beyond that.
39
+ */
40
+ async function emitInstallComplete({ apiKey, payload }) {
41
+ if (!apiKey) return;
42
+ const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
43
+ const body = JSON.stringify({
44
+ product: 'cli',
45
+ event_type: 'install_complete',
46
+ payload,
47
+ });
48
+ for (let attempt = 0; attempt < 2; attempt++) {
49
+ try {
50
+ const res = await fetch(`${platformUrl}/v1/telemetry`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Authorization': `Bearer ${apiKey}`,
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body,
57
+ signal: AbortSignal.timeout(10_000),
58
+ });
59
+ if (res.ok) break;
60
+ } catch {
61
+ // Try again on next iteration.
62
+ }
63
+ }
64
+ }
65
+
29
66
  /**
30
67
  * Main setup wizard. In Option 4 (post-PR-2.5) the wizard does NOT install
31
68
  * a Python daemon, register a system service, or run a localhost health
@@ -43,7 +80,7 @@ const CLI_VERSION = (() => {
43
80
  *
44
81
  * No Python, no venv, no systemd, no port probe.
45
82
  */
46
- export async function runWizard({ nonInteractive = false, target = null, scope = 'full' } = {}) {
83
+ export async function runWizard({ nonInteractive = false, target = null, scope = 'full', autoAttachSource = false } = {}) {
47
84
  header();
48
85
 
49
86
  // Detect OC once up front. Used both to branch into the non-OC wizard and
@@ -165,7 +202,31 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
165
202
  // MCP config / docs / install-OC). The non-OC wizard's wizard_path_chosen
166
203
  // telemetry now fires too, since Step 0 above provisioned an api_key.
167
204
  if (!openclawDetected) {
168
- await runNonOcWizard({ nonInteractive, target });
205
+ await runNonOcWizard({ nonInteractive, target, autoAttachSource });
206
+
207
+ // Phase 11: install_complete now fires for non-OC too. Closes the
208
+ // funnel signal that was 0/58 for a week.
209
+ if (results.auth) {
210
+ try {
211
+ const config = readConfig();
212
+ await emitInstallComplete({
213
+ apiKey: config.api_key,
214
+ payload: {
215
+ source: 'wizard',
216
+ path: 'non-oc',
217
+ cli_version: CLI_VERSION,
218
+ target: target ?? null,
219
+ scope,
220
+ auto_attach_source: !!autoAttachSource,
221
+ platform: process.platform,
222
+ os_release: osRelease(),
223
+ node_version: process.version,
224
+ install_duration_ms: Date.now() - wizardStartMs,
225
+ non_interactive: nonInteractive,
226
+ },
227
+ });
228
+ } catch { /* non-fatal */ }
229
+ }
169
230
  return;
170
231
  }
171
232
 
@@ -250,54 +311,32 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
250
311
 
251
312
  // ── Install Complete Telemetry ───────────────────────────────────────────
252
313
  //
253
- // Fire once after install, using the API key directly (not from config read-back).
254
- // This immediately populates last_used_at and proves the key works end-to-end.
255
- //
256
- // Retry once with longer timeout — Cloudflare analytics showed client-side
257
- // aborts on the original 5s single-attempt, leaving stranded signups with
258
- // no telemetry. Two 10s attempts catch the long tail. Still fire-and-forget.
314
+ // Fire once after the OC install path completes. Non-OC fires its own
315
+ // install_complete higher up (right after runNonOcWizard returns), so
316
+ // both paths now produce this funnel signal — see emitInstallComplete().
259
317
 
260
318
  if (results.auth) {
261
319
  try {
262
320
  const config = readConfig();
263
- const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
264
- const installPayload = {
265
- source: 'wizard',
266
- cli_version: CLI_VERSION,
267
- plugin_installed: results.pluginInstalled,
268
- scraper: results.scraper || false,
269
- platform: process.platform,
270
- os_release: osRelease(),
271
- node_version: process.version,
272
- install_duration_ms: Date.now() - wizardStartMs,
273
- openclaw_detected: results.openclawDetected,
274
- openclaw_config_patched: results.openclawConfigPatched,
275
- scraper_mcp_registered: results.scraperMcpRegistered,
276
- };
277
- const body = JSON.stringify({
278
- product: 'cli',
279
- event_type: 'install_complete',
280
- payload: installPayload,
321
+ await emitInstallComplete({
322
+ apiKey: config.api_key,
323
+ payload: {
324
+ source: 'wizard',
325
+ path: 'oc',
326
+ cli_version: CLI_VERSION,
327
+ plugin_installed: results.pluginInstalled,
328
+ scraper: results.scraper || false,
329
+ platform: process.platform,
330
+ os_release: osRelease(),
331
+ node_version: process.version,
332
+ install_duration_ms: Date.now() - wizardStartMs,
333
+ openclaw_detected: results.openclawDetected,
334
+ openclaw_config_patched: results.openclawConfigPatched,
335
+ scraper_mcp_registered: results.scraperMcpRegistered,
336
+ },
281
337
  });
282
-
283
- for (let attempt = 0; attempt < 2; attempt++) {
284
- try {
285
- const res = await fetch(`${platformUrl}/v1/telemetry`, {
286
- method: 'POST',
287
- headers: {
288
- 'Authorization': `Bearer ${config.api_key}`,
289
- 'Content-Type': 'application/json',
290
- },
291
- body,
292
- signal: AbortSignal.timeout(10_000),
293
- });
294
- if (res.ok) break;
295
- } catch {
296
- // Try again on next iteration; outer catch handles total failure
297
- }
298
- }
299
338
  } catch {
300
- // Non-fatal — install_complete is best-effort
339
+ // Non-fatal — install_complete is best-effort.
301
340
  }
302
341
  }
303
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {