robot-resources 1.14.2 → 1.15.1

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
  });
package/lib/detect.js CHANGED
@@ -137,7 +137,10 @@ export function isCursorInstalled() {
137
137
  // means a generic project (e.g. just package.json, no LLM SDK deps yet) —
138
138
  // still picks the language but with low confidence.
139
139
 
140
- const NODE_AGENT_DEPS = [
140
+ // Exported (Phase 11.1) so the SDK-import scanner in source-edit-attach.js
141
+ // can reuse the same canonical list — single source of truth for "which npm
142
+ // packages count as an AI SDK signal."
143
+ export const NODE_AGENT_DEPS = [
141
144
  '@anthropic-ai/sdk',
142
145
  'openai',
143
146
  '@google/generative-ai',
@@ -1,12 +1,20 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { select } from '@inquirer/prompts';
2
+ import { extname, isAbsolute, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { select, input } from '@inquirer/prompts';
4
5
  import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
5
6
  import { configureClaudeCode, configureCursor } from './tool-config.js';
6
- import { header, info, success, warn, blank } from './ui.js';
7
+ import { header, info, success, warn, blank, confirm } from './ui.js';
7
8
  import { readConfig } from './config.mjs';
8
9
  import { installNodeShim } from './install-node-shim.js';
9
10
  import { installPythonShim } from './install-python-shim.js';
11
+ import {
12
+ findAgentSourceFile,
13
+ hasSourceMarker,
14
+ writeSourceMarker,
15
+ previewInjection,
16
+ pathForTelemetry,
17
+ } from './source-edit-attach.js';
10
18
 
11
19
  const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
12
20
 
@@ -89,7 +97,259 @@ async function emitPathChosen(path) {
89
97
  }
90
98
  }
91
99
 
92
- async function showJsPath() {
100
+ async function emitNodeEntryPatched(payload) {
101
+ const config = readConfig();
102
+ if (!config.api_key) return;
103
+ try {
104
+ await fetch(`${PLATFORM_URL}/v1/telemetry`, {
105
+ method: 'POST',
106
+ headers: {
107
+ Authorization: `Bearer ${config.api_key}`,
108
+ 'Content-Type': 'application/json',
109
+ },
110
+ body: JSON.stringify({
111
+ product: 'cli',
112
+ event_type: 'node_entry_patched',
113
+ payload: { ...payload, platform: process.platform },
114
+ }),
115
+ signal: AbortSignal.timeout(5_000),
116
+ });
117
+ } catch {
118
+ // Best-effort — never let telemetry break the install path.
119
+ }
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
+
136
+ /**
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.
141
+ *
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.
145
+ *
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
151
+ * - Marker already present → skip silently
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).
156
+ */
157
+ async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = process.cwd() } = {}) {
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
+ });
233
+ }
234
+
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.`);
240
+ await emitNodeEntryPatched({
241
+ ...baseTelemetry,
242
+ outcome: 'already_present',
243
+ entry_path: pathForTelemetry(winnerPath, cwd),
244
+ });
245
+ return;
246
+ }
247
+
248
+ if (nonInteractive && !autoAttachSource) {
249
+ blank();
250
+ info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
251
+ info(`To enable it in CI, re-run with: npx robot-resources --for=langchain --auto-attach-source`);
252
+ info(`Or add this line manually at the top of ${pathForTelemetry(winnerPath, cwd)}:`);
253
+ info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
254
+ await emitNodeEntryPatched({
255
+ ...baseTelemetry,
256
+ outcome: 'skipped_non_interactive',
257
+ entry_path: pathForTelemetry(winnerPath, cwd),
258
+ });
259
+ return;
260
+ }
261
+
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,
269
+ }));
270
+ choices.push({ name: 'Skip — I\'ll add the line manually', value: '__skip__' });
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
+ }
281
+ if (target === '__skip__') {
282
+ await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
283
+ return;
284
+ }
285
+ }
286
+
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 }) {
304
+ const preview = previewInjection(target);
305
+ blank();
306
+ success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
307
+ blank();
308
+ info(`File: ${pathForTelemetry(target, cwd)}`);
309
+ blank();
310
+ for (const line of preview.split('\n')) info(line);
311
+ blank();
312
+
313
+ let proceed = autoAttachSource;
314
+ if (!nonInteractive && !autoAttachSource) {
315
+ proceed = await confirm('Add the line?', { defaultYes: true });
316
+ }
317
+
318
+ if (!proceed) {
319
+ info('Skipped — you can re-run anytime.');
320
+ await emitNodeEntryPatched({
321
+ ...baseTelemetry,
322
+ outcome: 'declined',
323
+ entry_path: pathForTelemetry(target, cwd),
324
+ });
325
+ return;
326
+ }
327
+
328
+ const result = writeSourceMarker(target);
329
+ if (result.ok) {
330
+ success(`Added the auto-attach line to ${pathForTelemetry(target, cwd)} (${result.syntax}).`);
331
+ if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
332
+ info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
333
+ await emitNodeEntryPatched({
334
+ ...baseTelemetry,
335
+ outcome: viaPrompt ? 'patched_via_prompt' : 'patched',
336
+ entry_path: pathForTelemetry(target, cwd),
337
+ import_syntax: result.syntax,
338
+ backup_written: !!result.backupWritten,
339
+ });
340
+ } else {
341
+ warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
342
+ info('You can add the line yourself — see the snippet above.');
343
+ await emitNodeEntryPatched({
344
+ ...baseTelemetry,
345
+ outcome: 'failed',
346
+ entry_path: pathForTelemetry(target, cwd),
347
+ error: result.error,
348
+ });
349
+ }
350
+ }
351
+
352
+ async function showJsPath({ nonInteractive = false, autoAttachSource = false } = {}) {
93
353
  blank();
94
354
  success('JS/TS integration');
95
355
  blank();
@@ -105,14 +365,10 @@ async function showJsPath() {
105
365
  }
106
366
  }
107
367
  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
- }
368
+ info('Phase 3+ NODE_OPTIONS shim installed. Works for desktop dev sessions');
369
+ info('after a terminal restart, but does NOT reach cron / Docker / systemd /');
370
+ info('Lambda. The next step adds a one-line require to your agent source so');
371
+ info('routing works regardless of how the process is launched.');
116
372
  } else {
117
373
  warn(result.message);
118
374
  blank();
@@ -124,6 +380,11 @@ async function showJsPath() {
124
380
  info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require ~/.robot-resources/router/auto.cjs"');
125
381
  }
126
382
  }
383
+
384
+ // Phase 11 — source-edit injection. Reaches cron / Docker / systemd / Lambda
385
+ // since the line lives in the user's source, not in shell config.
386
+ await maybeInjectSourceEdit({ nonInteractive, autoAttachSource });
387
+
127
388
  blank();
128
389
  info('Docs: https://robotresources.ai/docs/langchain');
129
390
  blank();
@@ -204,9 +465,9 @@ function showInstallOcPath() {
204
465
  blank();
205
466
  }
206
467
 
207
- async function runPath(path) {
468
+ async function runPath(path, opts = {}) {
208
469
  switch (path) {
209
- case 'js': await showJsPath(); break;
470
+ case 'js': await showJsPath(opts); break;
210
471
  case 'python': await showPythonPath(); break;
211
472
  case 'mcp': showMcpPath(); break;
212
473
  case 'docs': showDocsPath(); break;
@@ -221,11 +482,11 @@ async function runPath(path) {
221
482
  * - non-interactive AND no target: print hint with --for= options and exit
222
483
  * - interactive: 5-option menu via @inquirer/prompts.select
223
484
  */
224
- export async function runNonOcWizard({ nonInteractive = false, target = null } = {}) {
485
+ export async function runNonOcWizard({ nonInteractive = false, target = null, autoAttachSource = false } = {}) {
225
486
  const normalized = normalizeTarget(target);
226
487
 
227
488
  if (normalized) {
228
- await runPath(normalized);
489
+ await runPath(normalized, { nonInteractive, autoAttachSource });
229
490
  await emitPathChosen(normalized);
230
491
  return;
231
492
  }
@@ -249,7 +510,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
249
510
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
250
511
  info(' Pass --for=<other> to override, or --uninstall to remove later.');
251
512
  blank();
252
- await runPath(autoTarget);
513
+ await runPath(autoTarget, { nonInteractive, autoAttachSource });
253
514
  await emitPathChosen(autoTarget);
254
515
  return;
255
516
  }
@@ -334,7 +595,9 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
334
595
  if (autoTarget) {
335
596
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
336
597
  blank();
337
- await runPath(autoTarget);
598
+ // Treat the timed-out interactive session as non-interactive for the
599
+ // source-edit step too — we don't have a TTY to ask the Y/N on.
600
+ await runPath(autoTarget, { nonInteractive: true, autoAttachSource });
338
601
  await emitPathChosen(autoTarget);
339
602
  return;
340
603
  }
@@ -347,6 +610,6 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
347
610
  return;
348
611
  }
349
612
 
350
- await runPath(chosen);
613
+ await runPath(chosen, { nonInteractive, autoAttachSource });
351
614
  await emitPathChosen(chosen);
352
615
  }
@@ -0,0 +1,469 @@
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
+ }
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.1",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {