robot-resources 1.11.1 → 1.12.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
@@ -1,20 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runWizard } from '../lib/wizard.js';
3
+ import { runWizard, runUninstallCommand } from '../lib/wizard.js';
4
4
 
5
5
  const args = process.argv.slice(2);
6
- const explicitNonInteractive =
7
- args.includes('--non-interactive') || args.includes('--yes') || args.includes('-y');
8
- const targetArg = args.find((a) => a.startsWith('--for='));
9
- const target = targetArg ? targetArg.slice('--for='.length) : null;
10
6
 
11
- // Treat piped/CI runs (no TTY on stdin OR stdout) as non-interactive so the
12
- // wizard never blocks on a prompt that can't be answered. The interactive
13
- // menu is only opened when both stdin and stdout are real terminals.
14
- const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
15
- const nonInteractive = explicitNonInteractive || !hasTty;
7
+ // --uninstall ships in Phase 0 as the counterpart to install. --purge also
8
+ // wipes ~/.robot-resources/config.json (api_key + claim_url); without it,
9
+ // the api_key is preserved across a reinstall.
10
+ if (args.includes('--uninstall')) {
11
+ const purge = args.includes('--purge');
12
+ runUninstallCommand({ purge }).catch((err) => {
13
+ console.error(`\n ✗ Uninstall failed: ${err.message}\n`);
14
+ process.exit(1);
15
+ });
16
+ } else {
17
+ const explicitNonInteractive =
18
+ args.includes('--non-interactive') || args.includes('--yes') || args.includes('-y');
19
+ const targetArg = args.find((a) => a.startsWith('--for='));
20
+ const target = targetArg ? targetArg.slice('--for='.length) : null;
16
21
 
17
- runWizard({ nonInteractive, target }).catch((err) => {
18
- console.error(`\n ✗ Setup failed: ${err.message}\n`);
19
- process.exit(1);
20
- });
22
+ // Treat piped/CI runs (no TTY on stdin OR stdout) as non-interactive so the
23
+ // wizard never blocks on a prompt that can't be answered. The interactive
24
+ // menu is only opened when both stdin and stdout are real terminals.
25
+ const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
26
+ const nonInteractive = explicitNonInteractive || !hasTty;
27
+
28
+ runWizard({ nonInteractive, target }).catch((err) => {
29
+ console.error(`\n ✗ Setup failed: ${err.message}\n`);
30
+ process.exit(1);
31
+ });
32
+ }
package/lib/detect.js CHANGED
@@ -125,3 +125,127 @@ export function isClaudeCodeInstalled() {
125
125
  export function isCursorInstalled() {
126
126
  return existsSync(join(homedir(), '.cursor'));
127
127
  }
128
+
129
+ // ── Agent-runtime detection (Phase 3) ─────────────────────────────────────
130
+ //
131
+ // Used by the non-OC wizard to pick which shim to install: a NODE_OPTIONS
132
+ // shell line for Node agents, or `pip install robot-resources` for Python.
133
+ // Returns { kind: 'node'|'python'|null, evidence: string[] } so the wizard
134
+ // can show the user WHY we picked a path (debuggable + builds trust).
135
+ //
136
+ // "Evidence" is the dep markers we found, in priority order. Empty string
137
+ // means a generic project (e.g. just package.json, no LLM SDK deps yet) —
138
+ // still picks the language but with low confidence.
139
+
140
+ const NODE_AGENT_DEPS = [
141
+ '@anthropic-ai/sdk',
142
+ 'openai',
143
+ '@google/generative-ai',
144
+ '@google-ai/generativelanguage',
145
+ 'langchain',
146
+ '@langchain/core',
147
+ '@langchain/anthropic',
148
+ '@langchain/openai',
149
+ '@langchain/google-genai',
150
+ '@langchain/langgraph',
151
+ '@mastra/core',
152
+ 'crewai-js',
153
+ 'llamaindex',
154
+ 'ai', // Vercel AI SDK
155
+ ];
156
+
157
+ const PYTHON_AGENT_DEPS = [
158
+ 'anthropic',
159
+ 'openai',
160
+ 'google-generativeai',
161
+ 'langchain',
162
+ 'langchain-anthropic',
163
+ 'langchain-openai',
164
+ 'langchain-google-genai',
165
+ 'langgraph',
166
+ 'crewai',
167
+ 'llama-index',
168
+ 'llama_index',
169
+ ];
170
+
171
+ /**
172
+ * Inspect cwd for evidence of a Node agent project. Returns null if no
173
+ * package.json, or `{ evidence: [...] }` describing matched dep markers
174
+ * (empty list means "Node project but no LLM-SDK deps detected" — generic).
175
+ */
176
+ export function detectNodeAgent(cwd = process.cwd()) {
177
+ const pkgPath = join(cwd, 'package.json');
178
+ if (!existsSync(pkgPath)) return null;
179
+
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
182
+ const allDeps = {
183
+ ...(pkg.dependencies ?? {}),
184
+ ...(pkg.devDependencies ?? {}),
185
+ ...(pkg.peerDependencies ?? {}),
186
+ };
187
+ const evidence = NODE_AGENT_DEPS.filter(
188
+ (d) => Object.prototype.hasOwnProperty.call(allDeps, d),
189
+ );
190
+ return { evidence };
191
+ } catch {
192
+ // package.json unreadable but exists — call it Node, no evidence
193
+ return { evidence: [] };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Inspect cwd for evidence of a Python agent project. Looks at
199
+ * requirements.txt + pyproject.toml. Returns null if neither exists, or
200
+ * `{ evidence: [...] }`. Empty evidence still resolves to Python — many
201
+ * agent projects use ad-hoc deps not in our markers list.
202
+ */
203
+ export function detectPythonAgent(cwd = process.cwd()) {
204
+ const reqPath = join(cwd, 'requirements.txt');
205
+ const pyProjPath = join(cwd, 'pyproject.toml');
206
+ const hasReq = existsSync(reqPath);
207
+ const hasPy = existsSync(pyProjPath);
208
+ if (!hasReq && !hasPy) return null;
209
+
210
+ const text = [
211
+ hasReq ? safeRead(reqPath) : '',
212
+ hasPy ? safeRead(pyProjPath) : '',
213
+ ].join('\n').toLowerCase();
214
+
215
+ const evidence = PYTHON_AGENT_DEPS.filter((d) => {
216
+ // Match `dep`, `dep==`, `dep>=`, `dep[extras]`, or `"dep"`/`'dep'` in
217
+ // pyproject's dependencies array. Word-boundary on the left, anything
218
+ // version-ish on the right.
219
+ const escaped = d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
220
+ const re = new RegExp(`(^|[\\s"'\\[])${escaped}(\\s|[<>=!~\\["',]|$)`, 'm');
221
+ return re.test(text);
222
+ });
223
+
224
+ return { evidence };
225
+ }
226
+
227
+ function safeRead(path) {
228
+ try { return readFileSync(path, 'utf-8'); } catch { return ''; }
229
+ }
230
+
231
+ /**
232
+ * Decide which shim to install when OpenClaw is NOT detected. Picks Node OR
233
+ * Python based on cwd shape. When BOTH are present (full-stack monorepo
234
+ * with package.json AND pyproject.toml) the caller is responsible for
235
+ * resolving the ambiguity (interactively, or default to JS in --yes mode
236
+ * per the team decision in the plan).
237
+ *
238
+ * Returns one of:
239
+ * { kind: 'node', evidence: [...] }
240
+ * { kind: 'python', evidence: [...] }
241
+ * { kind: 'both', node: {...}, python: {...} }
242
+ * { kind: null } — unknown project shape, no clear path
243
+ */
244
+ export function detectAgentRuntime(cwd = process.cwd()) {
245
+ const node = detectNodeAgent(cwd);
246
+ const python = detectPythonAgent(cwd);
247
+ if (node && python) return { kind: 'both', node, python };
248
+ if (node) return { kind: 'node', evidence: node.evidence };
249
+ if (python) return { kind: 'python', evidence: python.evidence };
250
+ return { kind: null };
251
+ }
@@ -0,0 +1,142 @@
1
+ import { writeShellLine, hasShellLine } from './shell-config.js';
2
+ import { readConfig } from './config.mjs';
3
+ import { detectNodeAgent } from './detect.js';
4
+
5
+ const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
6
+
7
+ /**
8
+ * Install the Node shim into the user's shell config. Phase 3 entry for
9
+ * the non-OC Node path.
10
+ *
11
+ * Steps:
12
+ * 1. Append the marker block to detected rc files (zsh / bash / fish)
13
+ * via shell-config.writeShellLine. Idempotent: re-running does nothing
14
+ * if the block is already present.
15
+ * 2. Emit `node_shim_installed` telemetry with the shell list, sdks
16
+ * detected, dry-run flag, plus per-file errors.
17
+ *
18
+ * The user has to start a new shell (or `source` the file) for the
19
+ * NODE_OPTIONS to take effect — we tell them this in the wizard's
20
+ * post-install message. For Phase 3 we don't try to mutate the running
21
+ * shell; that's a Phase 6 nice-to-have.
22
+ *
23
+ * Windows: shell-config.writeShellLine returns no rc files on Windows
24
+ * (we only support POSIX in P3). The wizard prints manual instructions
25
+ * for Windows users in `non-oc-wizard.js`.
26
+ *
27
+ * Returns a UI-friendly result the wizard can format and print.
28
+ */
29
+ export async function installNodeShim({ cwd = process.cwd(), dryRun = false } = {}) {
30
+ if (process.platform === 'win32') {
31
+ await emit({
32
+ shell: 'unsupported',
33
+ shell_config_path: null,
34
+ sdks_detected: detectSdks(cwd),
35
+ dry_run: dryRun,
36
+ reason: 'windows_not_supported_yet',
37
+ });
38
+ return {
39
+ ok: false,
40
+ reason: 'windows_not_supported_yet',
41
+ message:
42
+ 'Windows shell-config writing is not yet supported. Set ' +
43
+ 'NODE_OPTIONS=--require @robot-resources/router/auto manually in your ' +
44
+ 'system environment variables, or wait for Phase 6.',
45
+ };
46
+ }
47
+
48
+ const sdks = detectSdks(cwd);
49
+
50
+ if (dryRun) {
51
+ await emit({
52
+ shell: 'dryrun',
53
+ shell_config_path: null,
54
+ sdks_detected: sdks,
55
+ dry_run: true,
56
+ reason: null,
57
+ });
58
+ return { ok: true, message: 'Dry-run: would have written NODE_OPTIONS to shell rc.' };
59
+ }
60
+
61
+ const alreadyInstalled = hasShellLine();
62
+ const result = writeShellLine();
63
+
64
+ // Single shell value for the funnel even though we may have written to
65
+ // multiple rc files. Pick the dominant one for telemetry.
66
+ const dominant = pickDominantShell(result.written);
67
+
68
+ await emit({
69
+ shell: dominant,
70
+ shell_config_path: result.written.join(','),
71
+ sdks_detected: sdks,
72
+ dry_run: false,
73
+ already_installed: alreadyInstalled,
74
+ files_written: result.written.length,
75
+ files_with_errors: result.errors.length,
76
+ error_messages: result.errors.map((e) => `${e.path}: ${e.message}`).slice(0, 3),
77
+ });
78
+
79
+ if (alreadyInstalled && result.written.length === 0) {
80
+ return {
81
+ ok: true,
82
+ already: true,
83
+ message: 'NODE_OPTIONS auto-attach already installed. No changes made.',
84
+ };
85
+ }
86
+
87
+ if (result.written.length === 0 && result.errors.length > 0) {
88
+ return {
89
+ ok: false,
90
+ message: `Could not write to any shell rc file. Errors: ${result.errors.map((e) => e.message).join(', ')}`,
91
+ };
92
+ }
93
+
94
+ return {
95
+ ok: true,
96
+ written: result.written,
97
+ errors: result.errors,
98
+ message:
99
+ `Installed NODE_OPTIONS auto-attach in ${result.written.length} shell file(s). ` +
100
+ 'Open a new terminal (or source the file) for it to take effect.',
101
+ };
102
+ }
103
+
104
+ function detectSdks(cwd) {
105
+ const result = detectNodeAgent(cwd);
106
+ return result?.evidence ?? [];
107
+ }
108
+
109
+ function pickDominantShell(paths) {
110
+ // Use process.env.SHELL as the tiebreaker — that's the user's actual
111
+ // login shell. Fall back to the first written file's basename.
112
+ const shellEnv = (process.env.SHELL || '').toLowerCase();
113
+ if (shellEnv.includes('zsh')) return 'zsh';
114
+ if (shellEnv.includes('fish')) return 'fish';
115
+ if (shellEnv.includes('bash')) return 'bash';
116
+ if (paths[0]?.endsWith('.zshrc')) return 'zsh';
117
+ if (paths[0]?.endsWith('.bashrc') || paths[0]?.endsWith('.bash_profile')) return 'bash';
118
+ if (paths[0]?.endsWith('config.fish')) return 'fish';
119
+ return 'unknown';
120
+ }
121
+
122
+ async function emit(payload) {
123
+ const config = readConfig();
124
+ if (!config.api_key) return;
125
+ try {
126
+ await fetch(`${PLATFORM_URL}/v1/telemetry`, {
127
+ method: 'POST',
128
+ headers: {
129
+ 'Authorization': `Bearer ${config.api_key}`,
130
+ 'Content-Type': 'application/json',
131
+ },
132
+ body: JSON.stringify({
133
+ product: 'cli',
134
+ event_type: 'node_shim_installed',
135
+ payload: { ...payload, platform: process.platform },
136
+ }),
137
+ signal: AbortSignal.timeout(5_000),
138
+ });
139
+ } catch {
140
+ // Best-effort — never let telemetry break the install path.
141
+ }
142
+ }
@@ -0,0 +1,107 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { detectVenv, runPipInstall } from './venv-detect.js';
3
+ import { readConfig } from './config.mjs';
4
+ import { detectPythonAgent } from './detect.js';
5
+
6
+ const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
7
+
8
+ /**
9
+ * Install the Python shim into the user's active/cwd venv. Phase 3 entry
10
+ * for the non-OC Python path.
11
+ *
12
+ * Steps:
13
+ * 1. Resolve Python via detectVenv. Bail with `confidence: 'low'` if we
14
+ * can't find a venv — we never `pip install` into system Python (can
15
+ * break OS Python on Linux).
16
+ * 2. Run `pip install --upgrade robot-resources`. Captures exit code +
17
+ * stderr tail for telemetry.
18
+ * 3. Emit `python_shim_installed` telemetry with venv kind, Python
19
+ * version, detected SDK markers, pip exit code.
20
+ *
21
+ * Returns a UI-friendly result the wizard can format and print. Never
22
+ * throws — any unexpected error becomes a structured `{ ok: false, ... }`.
23
+ */
24
+ export async function installPythonShim({ cwd = process.cwd() } = {}) {
25
+ const venv = detectVenv(cwd);
26
+
27
+ if (!venv.python) {
28
+ await emit({
29
+ kind: 'none',
30
+ python_version: null,
31
+ sdks_detected: detectSdks(cwd),
32
+ pip_exit_code: null,
33
+ reason: 'no_venv_found',
34
+ });
35
+ return {
36
+ ok: false,
37
+ reason: 'no_venv_found',
38
+ message:
39
+ 'No active venv or ./.venv detected. Activate your venv first ' +
40
+ '(source .venv/bin/activate) or pass --python=/path/to/python, ' +
41
+ 'then re-run.',
42
+ };
43
+ }
44
+
45
+ const pipResult = runPipInstall({
46
+ python: venv.python,
47
+ packageSpec: 'robot-resources>=0.2.0',
48
+ });
49
+
50
+ const pythonVersion = readPythonVersion(venv.python);
51
+ const sdks = detectSdks(cwd);
52
+
53
+ await emit({
54
+ kind: venv.kind,
55
+ python_version: pythonVersion,
56
+ sdks_detected: sdks,
57
+ pip_exit_code: pipResult.code,
58
+ pip_stderr_tail: pipResult.ok ? null : pipResult.stderr,
59
+ });
60
+
61
+ return {
62
+ ok: pipResult.ok,
63
+ venv,
64
+ pythonVersion,
65
+ sdks,
66
+ pipResult,
67
+ message: pipResult.ok
68
+ ? `Installed robot-resources into ${venv.kind} venv (${venv.python})`
69
+ : `pip install failed (exit ${pipResult.code}): ${pipResult.stderr.slice(0, 200)}`,
70
+ };
71
+ }
72
+
73
+ function detectSdks(cwd) {
74
+ const result = detectPythonAgent(cwd);
75
+ return result?.evidence ?? [];
76
+ }
77
+
78
+ function readPythonVersion(python) {
79
+ try {
80
+ const r = spawnSync(python, ['--version'], { encoding: 'utf-8' });
81
+ return (r.stdout || r.stderr || '').trim().replace(/^Python\s+/, '');
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function emit(payload) {
88
+ const config = readConfig();
89
+ if (!config.api_key) return;
90
+ try {
91
+ await fetch(`${PLATFORM_URL}/v1/telemetry`, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Authorization': `Bearer ${config.api_key}`,
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ body: JSON.stringify({
98
+ product: 'cli',
99
+ event_type: 'python_shim_installed',
100
+ payload: { ...payload, platform: process.platform },
101
+ }),
102
+ signal: AbortSignal.timeout(5_000),
103
+ });
104
+ } catch {
105
+ // Best-effort — never let telemetry break the install path.
106
+ }
107
+ }
@@ -5,6 +5,8 @@ import { isClaudeCodeInstalled, isCursorInstalled } from './detect.js';
5
5
  import { configureClaudeCode, configureCursor } from './tool-config.js';
6
6
  import { header, info, success, warn, blank } from './ui.js';
7
7
  import { readConfig } from './config.mjs';
8
+ import { installNodeShim } from './install-node-shim.js';
9
+ import { installPythonShim } from './install-python-shim.js';
8
10
 
9
11
  const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
10
12
 
@@ -87,38 +89,61 @@ async function emitPathChosen(path) {
87
89
  }
88
90
  }
89
91
 
90
- function showJsPath() {
92
+ async function showJsPath() {
91
93
  blank();
92
94
  success('JS/TS integration');
93
95
  blank();
94
- info('Install:');
95
- info(' npm install @robot-resources/router');
96
- blank();
97
- info('Use:');
98
- info(' import { routePrompt } from \'@robot-resources/router/routing\';');
99
- info(' const decision = routePrompt(\'write a python function\');');
100
- info(' console.log(decision.selected_model); // e.g. \'claude-haiku-4-5\'');
96
+
97
+ const result = await installNodeShim();
98
+ if (result.ok) {
99
+ if (result.already) {
100
+ info(result.message);
101
+ } else {
102
+ success(result.message);
103
+ for (const path of result.written ?? []) {
104
+ info(` • ${path}`);
105
+ }
106
+ }
107
+ blank();
108
+ info('Once your shell picks up the new NODE_OPTIONS, every Node agent on');
109
+ info('this machine routes Anthropic SDK calls through Robot Resources.');
110
+ info('Open a new terminal — or run: source ~/.zshrc (or your shell rc)');
111
+ } else {
112
+ warn(result.message);
113
+ blank();
114
+ info('Manual install (paste into ~/.zshrc or ~/.bashrc):');
115
+ info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require @robot-resources/router/auto"');
116
+ }
101
117
  blank();
102
- info('Full docs: https://robotresources.ai/docs/langchain');
118
+ info('Docs: https://robotresources.ai/docs/langchain');
103
119
  blank();
104
120
  }
105
121
 
106
- function showPythonPath() {
122
+ async function showPythonPath() {
107
123
  blank();
108
124
  success('Python integration');
109
125
  blank();
110
- info('Install:');
111
- info(' pip install robot-resources');
112
- blank();
113
- info('Use:');
114
- info(' from robot_resources.router import route');
115
- info(' decision = route(\'write a python function\')');
116
- info(' print(decision[\'selected_model\']) # e.g. \'claude-haiku-4-5\'');
117
- blank();
118
- info('Prefer no SDK? POST directly to https://api.robotresources.ai/v1/route');
119
- info('with httpx / requests / any HTTP client. See docs.');
126
+
127
+ const result = await installPythonShim();
128
+ if (result.ok) {
129
+ success(result.message);
130
+ if (result.sdks?.length) {
131
+ info(` Detected SDKs: ${result.sdks.join(', ')}`);
132
+ }
133
+ blank();
134
+ info('Set RR_AUTOATTACH=1 in your shell, then run your Python agent.');
135
+ info('Every anthropic.Anthropic() instance routes through Robot Resources.');
136
+ info(' echo \'export RR_AUTOATTACH=1\' >> ~/.zshrc # or your shell rc');
137
+ } else {
138
+ warn(result.message);
139
+ blank();
140
+ info('Manual install (run inside your venv):');
141
+ info(' pip install --upgrade robot-resources');
142
+ info('Then set:');
143
+ info(' export RR_AUTOATTACH=1');
144
+ }
120
145
  blank();
121
- info('Full docs: https://robotresources.ai/docs/crewai');
146
+ info('Docs: https://robotresources.ai/docs/crewai');
122
147
  blank();
123
148
  }
124
149
 
@@ -171,10 +196,10 @@ function showInstallOcPath() {
171
196
  blank();
172
197
  }
173
198
 
174
- function runPath(path) {
199
+ async function runPath(path) {
175
200
  switch (path) {
176
- case 'js': showJsPath(); break;
177
- case 'python': showPythonPath(); break;
201
+ case 'js': await showJsPath(); break;
202
+ case 'python': await showPythonPath(); break;
178
203
  case 'mcp': showMcpPath(); break;
179
204
  case 'docs': showDocsPath(); break;
180
205
  case 'install-oc': showInstallOcPath(); break;
@@ -192,7 +217,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
192
217
  const normalized = normalizeTarget(target);
193
218
 
194
219
  if (normalized) {
195
- runPath(normalized);
220
+ await runPath(normalized);
196
221
  await emitPathChosen(normalized);
197
222
  return;
198
223
  }
@@ -206,6 +231,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
206
231
  info(' npx robot-resources --for=claude-code # Claude Code MCP config');
207
232
  info(' npx robot-resources --for=docs # docs URL');
208
233
  blank();
234
+ await emitPathChosen('noninteractive_no_target');
209
235
  return;
210
236
  }
211
237
 
@@ -231,11 +257,16 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
231
257
  ],
232
258
  });
233
259
  } catch (err) {
234
- // User hit Ctrl-C or terminal closed — exit cleanly.
235
- if (err && (err.name === 'ExitPromptError' || err.code === 'ABORT_ERR')) return;
260
+ // User hit Ctrl-C or terminal closed — exit cleanly, but mark the funnel
261
+ // so we can distinguish "agent shown the prompt and bailed" from
262
+ // "wizard never reached the prompt at all" in Supabase.
263
+ if (err && (err.name === 'ExitPromptError' || err.code === 'ABORT_ERR')) {
264
+ await emitPathChosen('aborted');
265
+ return;
266
+ }
236
267
  throw err;
237
268
  }
238
269
 
239
- runPath(chosen);
270
+ await runPath(chosen);
240
271
  await emitPathChosen(chosen);
241
272
  }
@@ -0,0 +1,168 @@
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 @robot-resources/router/auto"
15
+ * # <<< robot-resources <<<
16
+ *
17
+ * Behavior decisions (see plan):
18
+ * - If NODE_OPTIONS is already set with a different --require (rare; e.g.
19
+ * dd-trace), append ours after theirs. Both load. The user keeps their
20
+ * existing tooling. The shell expansion `${NODE_OPTIONS:-} ...` handles
21
+ * this correctly — POSIX shells concat space-separated --require flags.
22
+ * - Never clobber. If our marker block already exists, it's a no-op.
23
+ * - Write to ALL detected rc files (e.g. user has both .zshrc + .bashrc),
24
+ * so the user gets routing in whichever shell they actually open.
25
+ */
26
+
27
+ const MARK_BEGIN = '# >>> robot-resources: NODE_OPTIONS auto-attach >>>';
28
+ const MARK_END = '# <<< robot-resources <<<';
29
+
30
+ const POSIX_LINE =
31
+ 'export NODE_OPTIONS="${NODE_OPTIONS:-} --require @robot-resources/router/auto"';
32
+
33
+ // Fish has different syntax (no `export`, uses `set -x`). Detected separately.
34
+ const FISH_LINE =
35
+ 'set -x NODE_OPTIONS "$NODE_OPTIONS --require @robot-resources/router/auto"';
36
+
37
+ /**
38
+ * Discover which rc files are present for this user. Returns a list of
39
+ * absolute paths in priority order (zsh first, bash second, fish third).
40
+ * The wizard writes to ALL of them — users frequently edit one shell's
41
+ * rc and forget another, and we'd rather over-cover than under-cover.
42
+ */
43
+ export function listShellRcFiles(home = homedir()) {
44
+ const candidates = [
45
+ { kind: 'zsh', path: join(home, '.zshrc') },
46
+ { kind: 'bash', path: join(home, '.bashrc') },
47
+ { kind: 'bash', path: join(home, '.bash_profile') }, // macOS often uses this
48
+ { kind: 'fish', path: join(home, '.config', 'fish', 'config.fish') },
49
+ ];
50
+ return candidates.filter((c) => existsSync(c.path));
51
+ }
52
+
53
+ /**
54
+ * Returns true if at least one rc file already has our marker block.
55
+ * Used by both the wizard (skip-if-already-installed) and uninstall
56
+ * (gate the "remove" step).
57
+ */
58
+ export function hasShellLine(home = homedir()) {
59
+ for (const { path } of listShellRcFiles(home)) {
60
+ try {
61
+ const text = readFileSync(path, 'utf-8');
62
+ if (text.includes(MARK_BEGIN)) return true;
63
+ } catch { /* unreadable rc, skip */ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Idempotently append the marker block to every detected rc file. Returns
70
+ * a list of files actually modified (empty if everything already had it).
71
+ *
72
+ * Each rc file is treated independently: the writer never aborts the
73
+ * others on one failure. Per-file errors are returned as warnings the
74
+ * caller can surface.
75
+ */
76
+ export function writeShellLine(home = homedir()) {
77
+ const rcs = listShellRcFiles(home);
78
+ const written = [];
79
+ const errors = [];
80
+
81
+ if (rcs.length === 0) {
82
+ // POSIX shells but no rc file yet — create ~/.zshrc on macOS (default
83
+ // since 10.15), ~/.bashrc on Linux. Better than silently no-op'ing.
84
+ const fallback = process.platform === 'darwin'
85
+ ? { kind: 'zsh', path: join(home, '.zshrc') }
86
+ : { kind: 'bash', path: join(home, '.bashrc') };
87
+ rcs.push(fallback);
88
+ }
89
+
90
+ for (const rc of rcs) {
91
+ try {
92
+ let text = '';
93
+ try { text = readFileSync(rc.path, 'utf-8'); } catch { /* file may not exist yet */ }
94
+
95
+ if (text.includes(MARK_BEGIN)) {
96
+ // Already installed. Skip silently.
97
+ continue;
98
+ }
99
+
100
+ const line = rc.kind === 'fish' ? FISH_LINE : POSIX_LINE;
101
+ const block =
102
+ (text && !text.endsWith('\n') ? '\n' : '') +
103
+ '\n' + MARK_BEGIN + '\n' + line + '\n' + MARK_END + '\n';
104
+
105
+ // Append, don't rewrite — preserves the user's content exactly.
106
+ appendFileSync(rc.path, block, { mode: 0o644 });
107
+ written.push(rc.path);
108
+ } catch (err) {
109
+ errors.push({ path: rc.path, message: err.message });
110
+ }
111
+ }
112
+
113
+ return { written, errors };
114
+ }
115
+
116
+ /**
117
+ * Idempotently REMOVE the marker block from every detected rc file.
118
+ * Mirror of writeShellLine. Returns a list of files actually modified.
119
+ *
120
+ * Removal is text-based (find MARK_BEGIN, find MARK_END, splice). If the
121
+ * block was tampered with — e.g. user manually deleted MARK_END — we leave
122
+ * the file alone and surface a warning. Never destructive on partial state.
123
+ */
124
+ export function removeShellLine(home = homedir()) {
125
+ const rcs = listShellRcFiles(home);
126
+ const removed = [];
127
+ const errors = [];
128
+
129
+ for (const rc of rcs) {
130
+ try {
131
+ const text = readFileSync(rc.path, 'utf-8');
132
+ const startIdx = text.indexOf(MARK_BEGIN);
133
+ if (startIdx === -1) continue;
134
+ const endIdx = text.indexOf(MARK_END, startIdx);
135
+ if (endIdx === -1) {
136
+ errors.push({ path: rc.path, message: 'marker_end_missing' });
137
+ continue;
138
+ }
139
+
140
+ // Splice from MARK_BEGIN through end of MARK_END line + trailing newline.
141
+ const afterEnd = text.indexOf('\n', endIdx);
142
+ const sliceEnd = afterEnd === -1 ? text.length : afterEnd + 1;
143
+
144
+ // Walk back over the leading newline our writer added so we don't
145
+ // accumulate blank lines on repeated install/uninstall cycles.
146
+ let sliceStart = startIdx;
147
+ while (sliceStart > 0 && text[sliceStart - 1] === '\n') sliceStart--;
148
+
149
+ const next = text.slice(0, sliceStart) +
150
+ (sliceStart > 0 ? '\n' : '') +
151
+ text.slice(sliceEnd);
152
+
153
+ writeFileSync(rc.path, next, { mode: getMode(rc.path) });
154
+ removed.push(rc.path);
155
+ } catch (err) {
156
+ errors.push({ path: rc.path, message: err.message });
157
+ }
158
+ }
159
+
160
+ return { removed, errors };
161
+ }
162
+
163
+ function getMode(path) {
164
+ try { return statSync(path).mode & 0o777; } catch { return 0o644; }
165
+ }
166
+
167
+ // Exported for tests + telemetry payloads.
168
+ export { MARK_BEGIN, MARK_END, POSIX_LINE, FISH_LINE };
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { stripJson5 } from './json5.js';
5
+ import { removeShellLine } from './shell-config.js';
6
+ import { detectVenv } from './venv-detect.js';
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ /**
10
+ * Single source of truth for `npx robot-resources --uninstall`.
11
+ *
12
+ * Reverses every install path the wizard might have taken:
13
+ * 1. OC plugin directories under ~/.openclaw/extensions/ (Phase 0)
14
+ * 2. Our entries in openclaw.json (plugins.entries + plugins.allow +
15
+ * mcp.servers) (Phase 0)
16
+ * 3. NODE_OPTIONS marker block in shell rc files (Phase 3 — Node shim)
17
+ * 4. `robot-resources` PyPI package in the resolved venv (Phase 3 —
18
+ * Python shim)
19
+ * 5. With --purge: ~/.robot-resources/ config dir (api_key + claim_url)
20
+ *
21
+ * `~/.robot-resources/config.json` is preserved by default so a subsequent
22
+ * re-install reuses the same api_key (and the user's claim_url stays valid).
23
+ *
24
+ * Returns { components_removed: string[], errors: { component, message }[] }
25
+ * for telemetry. Failure to remove one component never aborts the others —
26
+ * a partial uninstall is still progress, and we want to record what worked.
27
+ */
28
+ export function runUninstall({ purge = false } = {}) {
29
+ const components_removed = [];
30
+ const errors = [];
31
+
32
+ // 1. Plugin directories under ~/.openclaw/extensions/
33
+ const pluginDirs = [
34
+ { id: 'robot-resources-router', label: 'router_plugin_dir' },
35
+ { id: 'robot-resources-scraper-oc-plugin', label: 'scraper_plugin_dir' },
36
+ ];
37
+ for (const { id, label } of pluginDirs) {
38
+ const path = join(homedir(), '.openclaw', 'extensions', id);
39
+ if (!existsSync(path)) continue;
40
+ try {
41
+ rmSync(path, { recursive: true, force: true });
42
+ components_removed.push(label);
43
+ } catch (err) {
44
+ errors.push({ component: label, message: err.message });
45
+ }
46
+ }
47
+
48
+ // 2. openclaw.json — strip our entries from plugins.entries, plugins.allow,
49
+ // and mcp.servers. Leave everything else (other plugins, user config) alone.
50
+ // Idempotent: if openclaw.json is missing or malformed, skip silently —
51
+ // that's the right behavior for "cleanup what you can find."
52
+ const ocConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
53
+ if (existsSync(ocConfigPath)) {
54
+ try {
55
+ const config = JSON.parse(stripJson5(readFileSync(ocConfigPath, 'utf-8')));
56
+ let mutated = false;
57
+
58
+ if (config?.plugins?.entries) {
59
+ for (const id of ['robot-resources-router', 'robot-resources-scraper-oc-plugin']) {
60
+ if (config.plugins.entries[id]) {
61
+ delete config.plugins.entries[id];
62
+ mutated = true;
63
+ }
64
+ }
65
+ }
66
+
67
+ if (Array.isArray(config?.plugins?.allow)) {
68
+ const before = config.plugins.allow.length;
69
+ config.plugins.allow = config.plugins.allow.filter(
70
+ (id) => id !== 'robot-resources-router' && id !== 'robot-resources-scraper-oc-plugin',
71
+ );
72
+ if (config.plugins.allow.length !== before) mutated = true;
73
+ }
74
+
75
+ if (config?.mcp?.servers?.['robot-resources-scraper']) {
76
+ delete config.mcp.servers['robot-resources-scraper'];
77
+ mutated = true;
78
+ }
79
+
80
+ if (mutated) {
81
+ writeFileSync(ocConfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
82
+ components_removed.push('openclaw_config_entries');
83
+ }
84
+ } catch (err) {
85
+ errors.push({ component: 'openclaw_config_entries', message: err.message });
86
+ }
87
+ }
88
+
89
+ // 3. Shell config — remove the NODE_OPTIONS marker block from any rc
90
+ // files Phase 3's wizard wrote to. Idempotent: no-op if not present.
91
+ try {
92
+ const result = removeShellLine();
93
+ if (result.removed.length > 0) {
94
+ components_removed.push('shell_config_node_options');
95
+ }
96
+ for (const e of result.errors) {
97
+ errors.push({ component: 'shell_config_node_options', message: `${e.path}: ${e.message}` });
98
+ }
99
+ } catch (err) {
100
+ errors.push({ component: 'shell_config_node_options', message: err.message });
101
+ }
102
+
103
+ // 4. Python shim — `pip uninstall -y robot-resources` against the resolved
104
+ // venv. Skip silently if no venv detected (the user may have installed
105
+ // via the wizard but already deleted the venv themselves).
106
+ try {
107
+ const venv = detectVenv();
108
+ if (venv.python) {
109
+ const result = spawnSync(venv.python, ['-m', 'pip', 'uninstall', '-y', 'robot-resources'], {
110
+ encoding: 'utf-8',
111
+ timeout: 60_000,
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ });
114
+ // pip exits 0 if removed, non-zero if package wasn't installed (also acceptable)
115
+ if (result.status === 0) {
116
+ components_removed.push('pip_robot_resources');
117
+ } else if (result.stderr && /not installed|skipping/i.test(result.stderr)) {
118
+ // Already gone — count as success silently.
119
+ } else if (result.status !== null) {
120
+ // Some other failure; record but don't abort
121
+ errors.push({
122
+ component: 'pip_robot_resources',
123
+ message: `pip exit ${result.status}: ${(result.stderr || '').slice(-200)}`,
124
+ });
125
+ }
126
+ }
127
+ } catch (err) {
128
+ errors.push({ component: 'pip_robot_resources', message: err.message });
129
+ }
130
+
131
+ // 5. Optionally wipe ~/.robot-resources/config.json (and any siblings)
132
+ if (purge) {
133
+ const rrDir = join(homedir(), '.robot-resources');
134
+ if (existsSync(rrDir)) {
135
+ try {
136
+ rmSync(rrDir, { recursive: true, force: true });
137
+ components_removed.push('rr_config_dir');
138
+ } catch (err) {
139
+ errors.push({ component: 'rr_config_dir', message: err.message });
140
+ }
141
+ }
142
+ }
143
+
144
+ return { components_removed, errors };
145
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Resolve the Python interpreter we'll use for `pip install robot-resources`.
7
+ *
8
+ * Resolution order (decided in the plan; never silently install into system
9
+ * Python — that can break OS Python on Linux):
10
+ *
11
+ * 1. $VIRTUAL_ENV — currently-active venv. Strongest signal.
12
+ * 2. ./.venv/bin/python — common cwd venv (uv, hatch, plain venv default).
13
+ * 3. ./venv/bin/python — alternative cwd venv name.
14
+ * 4. pyproject.toml [tool.uv]/[tool.poetry] hint — best effort.
15
+ * 5. Bail with confidence='low'. Caller prompts user or errors out with
16
+ * a `--python=/path/to/python` instruction in non-interactive mode.
17
+ *
18
+ * Returns:
19
+ * { python: string, kind: 'active'|'cwd-venv'|'pyproject', confidence: 'high' }
20
+ * OR
21
+ * { python: null, kind: 'none', confidence: 'low' }
22
+ */
23
+ export function detectVenv(cwd = process.cwd()) {
24
+ // 1. Active venv — strongest signal.
25
+ const activeVenv = process.env.VIRTUAL_ENV;
26
+ if (activeVenv) {
27
+ const candidate = join(activeVenv, binSubdir(), 'python');
28
+ const candidate3 = join(activeVenv, binSubdir(), 'python3');
29
+ if (existsSync(candidate)) {
30
+ return { python: candidate, kind: 'active', confidence: 'high' };
31
+ }
32
+ if (existsSync(candidate3)) {
33
+ return { python: candidate3, kind: 'active', confidence: 'high' };
34
+ }
35
+ }
36
+
37
+ // 2. ./.venv (uv default, hatch default, plain venv default)
38
+ for (const dirname of ['.venv', 'venv']) {
39
+ const venvDir = join(cwd, dirname);
40
+ const cands = [
41
+ join(venvDir, binSubdir(), 'python'),
42
+ join(venvDir, binSubdir(), 'python3'),
43
+ ];
44
+ for (const c of cands) {
45
+ if (existsSync(c)) {
46
+ return { python: c, kind: 'cwd-venv', confidence: 'high' };
47
+ }
48
+ }
49
+ }
50
+
51
+ // 4. Bail. Never silently install into system Python.
52
+ return { python: null, kind: 'none', confidence: 'low' };
53
+ }
54
+
55
+ function binSubdir() {
56
+ return process.platform === 'win32' ? 'Scripts' : 'bin';
57
+ }
58
+
59
+ /**
60
+ * Run `python -m pip install <package>` against the resolved interpreter.
61
+ * Captures exit code + stderr tail for telemetry. Never throws.
62
+ *
63
+ * Phase 3 ships with `--upgrade` so existing 0.1.0 installs migrate to
64
+ * the auto-attach-capable 0.2.0 transparently.
65
+ */
66
+ export function runPipInstall({ python, packageSpec, timeoutMs = 120_000 }) {
67
+ if (!python) {
68
+ return { ok: false, code: -1, stderr: 'no python interpreter resolved' };
69
+ }
70
+ const args = ['-m', 'pip', 'install', '--upgrade', packageSpec];
71
+ const result = spawnSync(python, args, {
72
+ encoding: 'utf-8',
73
+ timeout: timeoutMs,
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ });
76
+
77
+ // Trim stderr to a sane size for telemetry — pip's full output is huge.
78
+ const stderr = (result.stderr || '').slice(-500);
79
+
80
+ return {
81
+ ok: result.status === 0,
82
+ code: result.status,
83
+ stderr,
84
+ };
85
+ }
package/lib/wizard.js CHANGED
@@ -8,6 +8,7 @@ import { configureToolRouting, registerScraperMcp, restartOpenClawGateway } from
8
8
  import { checkHealth } from './health-report.js';
9
9
  import { header, step, success, warn, error, info, blank, summary } from './ui.js';
10
10
  import { runNonOcWizard } from './non-oc-wizard.js';
11
+ import { runUninstall } from './uninstall.js';
11
12
 
12
13
  // Stamped onto every CLI telemetry payload so we can tell which `robot-resources`
13
14
  // version a user actually ran. Without this, npx-cached old installers look
@@ -141,6 +142,11 @@ export async function runWizard({ nonInteractive = false, target = null } = {})
141
142
  auth_method: results.authMethod,
142
143
  non_interactive: nonInteractive,
143
144
  openclaw_detected: openclawDetected,
145
+ // Phase 3: tags which install branch the wizard is about to take.
146
+ // 'oc' for the OpenClaw path, 'non-oc' covers everything the
147
+ // non-OC wizard handles (Node shim, Python shim, MCP, docs).
148
+ // Finer per-path attribution still comes from wizard_path_chosen.
149
+ entry: openclawDetected ? 'oc' : 'non-oc',
144
150
  },
145
151
  }),
146
152
  signal: AbortSignal.timeout(5_000),
@@ -398,3 +404,71 @@ export async function runWizard({ nonInteractive = false, target = null } = {})
398
404
  }
399
405
  }
400
406
  }
407
+
408
+ /**
409
+ * Uninstall counterpart to runWizard. Removes the OC plugin install side
410
+ * (router + scraper plugin dirs, openclaw.json entries) via uninstall.js.
411
+ *
412
+ * config.json (and its api_key) is preserved by default so a later re-install
413
+ * keeps the same identity. `--purge` wipes ~/.robot-resources/ as well.
414
+ *
415
+ * Telemetry: emits `wizard_uninstalled` with the list of components actually
416
+ * removed plus any per-component errors. Fire-and-forget — never block the
417
+ * uninstall on telemetry latency or failure.
418
+ */
419
+ export async function runUninstallCommand({ purge = false } = {}) {
420
+ header();
421
+ step(purge ? 'Uninstalling Robot Resources (purge)...' : 'Uninstalling Robot Resources...');
422
+
423
+ const result = runUninstall({ purge });
424
+
425
+ blank();
426
+ if (result.components_removed.length === 0 && result.errors.length === 0) {
427
+ info('Nothing to remove — Robot Resources was not installed in this account.');
428
+ } else {
429
+ if (result.components_removed.length > 0) {
430
+ success(`Removed: ${result.components_removed.join(', ')}`);
431
+ }
432
+ for (const e of result.errors) {
433
+ warn(`${e.component}: ${e.message}`);
434
+ }
435
+ }
436
+
437
+ // Preserve the api_key (unless --purge) so re-running `npx robot-resources`
438
+ // doesn't issue a second key. Tell the user explicitly so they can purge if
439
+ // they really want a clean slate.
440
+ if (!purge) {
441
+ blank();
442
+ info('Kept ~/.robot-resources/config.json (your api_key + claim_url).');
443
+ info('Re-run with --purge to wipe it.');
444
+ }
445
+
446
+ // Best-effort telemetry — same shape as the rest of the CLI's calls.
447
+ try {
448
+ const config = readConfig();
449
+ if (config.api_key) {
450
+ const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
451
+ await fetch(`${platformUrl}/v1/telemetry`, {
452
+ method: 'POST',
453
+ headers: {
454
+ 'Authorization': `Bearer ${config.api_key}`,
455
+ 'Content-Type': 'application/json',
456
+ },
457
+ body: JSON.stringify({
458
+ product: 'cli',
459
+ event_type: 'wizard_uninstalled',
460
+ payload: {
461
+ cli_version: CLI_VERSION,
462
+ purge,
463
+ components_removed: result.components_removed,
464
+ error_count: result.errors.length,
465
+ platform: process.platform,
466
+ },
467
+ }),
468
+ signal: AbortSignal.timeout(5_000),
469
+ });
470
+ }
471
+ } catch {
472
+ // Non-fatal — telemetry must never block the uninstall path.
473
+ }
474
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {