robot-resources 1.15.1 → 1.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/detect.js DELETED
@@ -1,254 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { stripJson5 } from './json5.js';
5
-
6
- /**
7
- * Check if OpenClaw is installed.
8
- */
9
- export function isOpenClawInstalled() {
10
- const home = homedir();
11
- return existsSync(join(home, '.openclaw')) || existsSync(join(home, 'openclaw.json'));
12
- }
13
-
14
- /**
15
- * Check if the Robot Resources OpenClaw plugin is already installed.
16
- */
17
- export function isOpenClawPluginInstalled() {
18
- const home = homedir();
19
- const extDir = join(home, '.openclaw', 'extensions');
20
- return existsSync(join(extDir, 'openclaw-plugin'))
21
- || existsSync(join(extDir, 'robot-resources-router'));
22
- }
23
-
24
- /**
25
- * Check if the Robot Resources scraper OC plugin is already installed.
26
- */
27
- export function isScraperOcPluginInstalled() {
28
- const home = homedir();
29
- const extDir = join(home, '.openclaw', 'extensions');
30
- return existsSync(join(extDir, 'robot-resources-scraper-oc-plugin'));
31
- }
32
-
33
- /**
34
- * Detect OpenClaw auth mode: 'subscription' (OAuth token) or 'apikey'.
35
- *
36
- * Subscription users authenticate via Anthropic OAuth tokens.
37
- * Anthropic rejects these tokens from third-party clients, so the
38
- * only viable routing path is the OpenClaw plugin (not HTTP proxy).
39
- *
40
- * Detection order:
41
- * 1. ANTHROPIC_AUTH_TOKEN env var → subscription
42
- * 2. openclaw.json config:
43
- * a. auth.type === 'oauth' | 'subscription' → subscription
44
- * b. auth.profiles.*.mode === 'token' → subscription
45
- * c. gateway.auth.mode === 'token' → subscription
46
- * d. providers.anthropic.authToken → subscription
47
- * e. providers.anthropic.apiKey → apikey
48
- * 3. Default → apikey (conservative — proxy works fine)
49
- */
50
- export function getOpenClawAuthMode() {
51
- // Env var is the strongest signal
52
- if (process.env.ANTHROPIC_AUTH_TOKEN) return 'subscription';
53
- if (process.env.ANTHROPIC_API_KEY) return 'apikey';
54
-
55
- // Try reading openclaw.json
56
- const home = homedir();
57
- const candidates = [
58
- join(home, '.openclaw', 'openclaw.json'),
59
- join(home, 'openclaw.json'),
60
- ];
61
-
62
- for (const configPath of candidates) {
63
- if (!existsSync(configPath)) continue;
64
-
65
- try {
66
- const raw = readFileSync(configPath, 'utf-8');
67
- const config = JSON.parse(stripJson5(raw));
68
-
69
- // Check explicit auth type
70
- if (config.auth?.type === 'oauth' || config.auth?.type === 'subscription') {
71
- return 'subscription';
72
- }
73
-
74
- // Check auth profiles (real OC config: auth.profiles["anthropic:default"].mode)
75
- const profiles = config.auth?.profiles;
76
- if (profiles && typeof profiles === 'object') {
77
- for (const profile of Object.values(profiles)) {
78
- if (profile?.mode === 'token') return 'subscription';
79
- }
80
- }
81
-
82
- // Check gateway auth mode
83
- if (config.gateway?.auth?.mode === 'token') return 'subscription';
84
-
85
- // Check for authToken in providers
86
- const anthropic = config.models?.providers?.anthropic
87
- || config.providers?.anthropic;
88
- if (anthropic?.authToken) return 'subscription';
89
- if (anthropic?.apiKey) return 'apikey';
90
- } catch {
91
- // Config unreadable — fall through
92
- }
93
- }
94
-
95
- return 'apikey';
96
- }
97
-
98
- /**
99
- * Detect if the environment is headless (no browser available).
100
- * On headless servers, login() tries xdg-open which fails silently,
101
- * then hangs for 120s waiting for a callback that never comes.
102
- */
103
- export function isHeadless() {
104
- // SSH session — no local browser
105
- if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return true;
106
- // Linux without a display server
107
- if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
108
- // Docker / container
109
- if (process.env.container || process.env.DOCKER_CONTAINER) return true;
110
- return false;
111
- }
112
-
113
- /**
114
- * Check if Claude Code is installed.
115
- * Looks for ~/.claude/ directory which Claude Code creates on first run.
116
- */
117
- export function isClaudeCodeInstalled() {
118
- return existsSync(join(homedir(), '.claude'));
119
- }
120
-
121
- /**
122
- * Check if Cursor is installed.
123
- * Looks for ~/.cursor/ directory which Cursor creates on first run.
124
- */
125
- export function isCursorInstalled() {
126
- return existsSync(join(homedir(), '.cursor'));
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
- // 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 = [
144
- '@anthropic-ai/sdk',
145
- 'openai',
146
- '@google/generative-ai',
147
- '@google-ai/generativelanguage',
148
- 'langchain',
149
- '@langchain/core',
150
- '@langchain/anthropic',
151
- '@langchain/openai',
152
- '@langchain/google-genai',
153
- '@langchain/langgraph',
154
- '@mastra/core',
155
- 'crewai-js',
156
- 'llamaindex',
157
- 'ai', // Vercel AI SDK
158
- ];
159
-
160
- const PYTHON_AGENT_DEPS = [
161
- 'anthropic',
162
- 'openai',
163
- 'google-generativeai',
164
- 'langchain',
165
- 'langchain-anthropic',
166
- 'langchain-openai',
167
- 'langchain-google-genai',
168
- 'langgraph',
169
- 'crewai',
170
- 'llama-index',
171
- 'llama_index',
172
- ];
173
-
174
- /**
175
- * Inspect cwd for evidence of a Node agent project. Returns null if no
176
- * package.json, or `{ evidence: [...] }` describing matched dep markers
177
- * (empty list means "Node project but no LLM-SDK deps detected" — generic).
178
- */
179
- export function detectNodeAgent(cwd = process.cwd()) {
180
- const pkgPath = join(cwd, 'package.json');
181
- if (!existsSync(pkgPath)) return null;
182
-
183
- try {
184
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
185
- const allDeps = {
186
- ...(pkg.dependencies ?? {}),
187
- ...(pkg.devDependencies ?? {}),
188
- ...(pkg.peerDependencies ?? {}),
189
- };
190
- const evidence = NODE_AGENT_DEPS.filter(
191
- (d) => Object.prototype.hasOwnProperty.call(allDeps, d),
192
- );
193
- return { evidence };
194
- } catch {
195
- // package.json unreadable but exists — call it Node, no evidence
196
- return { evidence: [] };
197
- }
198
- }
199
-
200
- /**
201
- * Inspect cwd for evidence of a Python agent project. Looks at
202
- * requirements.txt + pyproject.toml. Returns null if neither exists, or
203
- * `{ evidence: [...] }`. Empty evidence still resolves to Python — many
204
- * agent projects use ad-hoc deps not in our markers list.
205
- */
206
- export function detectPythonAgent(cwd = process.cwd()) {
207
- const reqPath = join(cwd, 'requirements.txt');
208
- const pyProjPath = join(cwd, 'pyproject.toml');
209
- const hasReq = existsSync(reqPath);
210
- const hasPy = existsSync(pyProjPath);
211
- if (!hasReq && !hasPy) return null;
212
-
213
- const text = [
214
- hasReq ? safeRead(reqPath) : '',
215
- hasPy ? safeRead(pyProjPath) : '',
216
- ].join('\n').toLowerCase();
217
-
218
- const evidence = PYTHON_AGENT_DEPS.filter((d) => {
219
- // Match `dep`, `dep==`, `dep>=`, `dep[extras]`, or `"dep"`/`'dep'` in
220
- // pyproject's dependencies array. Word-boundary on the left, anything
221
- // version-ish on the right.
222
- const escaped = d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
223
- const re = new RegExp(`(^|[\\s"'\\[])${escaped}(\\s|[<>=!~\\["',]|$)`, 'm');
224
- return re.test(text);
225
- });
226
-
227
- return { evidence };
228
- }
229
-
230
- function safeRead(path) {
231
- try { return readFileSync(path, 'utf-8'); } catch { return ''; }
232
- }
233
-
234
- /**
235
- * Decide which shim to install when OpenClaw is NOT detected. Picks Node OR
236
- * Python based on cwd shape. When BOTH are present (full-stack monorepo
237
- * with package.json AND pyproject.toml) the caller is responsible for
238
- * resolving the ambiguity (interactively, or default to JS in --yes mode
239
- * per the team decision in the plan).
240
- *
241
- * Returns one of:
242
- * { kind: 'node', evidence: [...] }
243
- * { kind: 'python', evidence: [...] }
244
- * { kind: 'both', node: {...}, python: {...} }
245
- * { kind: null } — unknown project shape, no clear path
246
- */
247
- export function detectAgentRuntime(cwd = process.cwd()) {
248
- const node = detectNodeAgent(cwd);
249
- const python = detectPythonAgent(cwd);
250
- if (node && python) return { kind: 'both', node, python };
251
- if (node) return { kind: 'node', evidence: node.evidence };
252
- if (python) return { kind: 'python', evidence: python.evidence };
253
- return { kind: null };
254
- }
@@ -1,130 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { readConfig } from './config.mjs';
5
-
6
- const PROBE_TIMEOUT_MS = 5_000;
7
-
8
- /**
9
- * Run post-install health checks against all Robot Resources components.
10
- *
11
- * Probes:
12
- * 1. Router — GET http://127.0.0.1:3838/health
13
- * 2. Scraper — check openclaw.json for scraper MCP registration
14
- * 3. Platform — GET {platformUrl}/v1/health with api_key
15
- * 4. MCP — check openclaw.json for robot-resources-router registration
16
- *
17
- * @returns {{ status: 'healthy'|'partial'|'failed', components: Object, summary: string }}
18
- */
19
- export async function checkHealth() {
20
- const config = readConfig();
21
-
22
- const [router, scraper, platform, mcp] = await Promise.all([
23
- probeRouter(),
24
- probeScraper(),
25
- probePlatform(config),
26
- probeMcp(),
27
- ]);
28
-
29
- const components = { router, scraper, platform, mcp };
30
- const healthyCount = Object.values(components).filter((c) => c.healthy).length;
31
- const total = Object.keys(components).length;
32
-
33
- let status;
34
- if (healthyCount === total) {
35
- status = 'healthy';
36
- } else if (healthyCount === 0) {
37
- status = 'failed';
38
- } else {
39
- status = 'partial';
40
- }
41
-
42
- const failing = Object.entries(components)
43
- .filter(([, c]) => !c.healthy)
44
- .map(([name, c]) => `${name}: ${c.detail}`);
45
-
46
- const summary =
47
- status === 'healthy'
48
- ? `All ${total} components healthy.`
49
- : `${healthyCount}/${total} healthy. Issues: ${failing.join('; ')}`;
50
-
51
- return { status, components, summary };
52
- }
53
-
54
- async function probeRouter() {
55
- try {
56
- if (typeof fetch === 'undefined') {
57
- return { healthy: false, detail: 'fetch unavailable' };
58
- }
59
- const res = await fetch('http://127.0.0.1:3838/health', {
60
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
61
- });
62
- if (!res.ok) {
63
- return { healthy: false, detail: `HTTP ${res.status}` };
64
- }
65
- const data = await res.json();
66
- if (data.status === 'healthy' || data.status === 'degraded') {
67
- return { healthy: true, detail: `running (v${data.version || 'unknown'})` };
68
- }
69
- return { healthy: false, detail: `status: ${data.status}` };
70
- } catch (err) {
71
- const detail = err.name === 'AbortError' ? 'timeout' : 'unreachable';
72
- return { healthy: false, detail };
73
- }
74
- }
75
-
76
- function probeScraper() {
77
- try {
78
- const ocPath = join(homedir(), '.openclaw', 'openclaw.json');
79
- const ocConfig = JSON.parse(readFileSync(ocPath, 'utf-8'));
80
- const hasServer = !!ocConfig?.mcp?.servers?.['robot-resources-scraper'];
81
- return {
82
- healthy: hasServer,
83
- detail: hasServer ? 'MCP registered' : 'scraper MCP not registered',
84
- };
85
- } catch {
86
- return { healthy: false, detail: 'openclaw.json not found' };
87
- }
88
- }
89
-
90
- async function probePlatform(config) {
91
- if (!config.api_key) {
92
- return { healthy: false, detail: 'no API key configured' };
93
- }
94
- try {
95
- if (typeof fetch === 'undefined') {
96
- return { healthy: false, detail: 'fetch unavailable' };
97
- }
98
- const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
99
- const res = await fetch(`${platformUrl}/health`, {
100
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
101
- });
102
- return {
103
- healthy: res.ok,
104
- detail: res.ok ? 'reachable' : `HTTP ${res.status}`,
105
- };
106
- } catch (err) {
107
- const detail = err.name === 'AbortError' ? 'timeout' : 'unreachable';
108
- return { healthy: false, detail };
109
- }
110
- }
111
-
112
- function probeMcp() {
113
- try {
114
- const ocPath = join(homedir(), '.openclaw', 'openclaw.json');
115
- const ocConfig = JSON.parse(readFileSync(ocPath, 'utf-8'));
116
- const hasRouter = !!ocConfig?.plugins?.entries?.['robot-resources-router']?.enabled;
117
- const hasScraper = !!ocConfig?.plugins?.entries?.['robot-resources-scraper-oc-plugin']?.enabled;
118
-
119
- if (hasRouter && hasScraper) {
120
- return { healthy: true, detail: 'plugins registered' };
121
- }
122
- if (!hasRouter && !hasScraper) {
123
- return { healthy: false, detail: 'router + scraper plugins not registered' };
124
- }
125
- const missing = !hasRouter ? 'robot-resources-router' : 'robot-resources-scraper-oc-plugin';
126
- return { healthy: false, detail: `${missing} not registered` };
127
- } catch {
128
- return { healthy: false, detail: 'openclaw.json not found' };
129
- }
130
- }
@@ -1,188 +0,0 @@
1
- import { writeShellLine, hasShellLine } from './shell-config.js';
2
- import { readConfig } from './config.mjs';
3
- import { detectNodeAgent } from './detect.js';
4
- import { installRouterFiles } from './install-router-files.js';
5
- import { writePersistedNodeOptions } from './windows-env.js';
6
-
7
- const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
8
-
9
- /**
10
- * Install the Node shim into the user's shell config (POSIX) or user
11
- * environment registry (Windows).
12
- *
13
- * POSIX path (Phases 3 + 8):
14
- * 1. Copy `@robot-resources/router` to ~/.robot-resources/router/
15
- * (absolute path; survives cwd changes + npm/npx cache cleanup).
16
- * 2. Append marker block with `--require <abs path>` to detected rc files
17
- * (zsh / bash / fish).
18
- *
19
- * Windows path (Phase 9):
20
- * 1. Same router-files copy — `homedir()` is platform-aware.
21
- * 2. `setx NODE_OPTIONS "..."` writes to HKCU\\Environment so every new
22
- * cmd / PowerShell / Win+R-launched Node process inherits it.
23
- *
24
- * Both paths emit `node_shim_installed` telemetry. The user has to open a
25
- * new terminal for the change to take effect.
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
- const sdks = detectSdks(cwd);
31
-
32
- if (dryRun) {
33
- await emit({
34
- shell: 'dryrun',
35
- shell_config_path: null,
36
- sdks_detected: sdks,
37
- dry_run: true,
38
- reason: null,
39
- });
40
- return { ok: true, message: 'Dry-run: would have written NODE_OPTIONS.' };
41
- }
42
-
43
- // Phase 8: copy router to an absolute path under ~/.robot-resources/router/
44
- // before we wire the env config. If the copy fails, we don't write a
45
- // broken NODE_OPTIONS line on either platform.
46
- let autoPath;
47
- try {
48
- autoPath = installRouterFiles();
49
- } catch (err) {
50
- await emit({
51
- shell: process.platform === 'win32' ? 'win32' : 'unknown',
52
- shell_config_path: null,
53
- sdks_detected: sdks,
54
- dry_run: false,
55
- reason: 'router_copy_failed',
56
- error_messages: [err.message],
57
- });
58
- return {
59
- ok: false,
60
- message: `Could not copy router files to ~/.robot-resources/router/: ${err.message}`,
61
- };
62
- }
63
-
64
- // Windows branch — Phase 9. Mirrors the POSIX flow in shape: detect
65
- // already-installed via the persisted registry value, write via setx,
66
- // emit equivalent telemetry.
67
- if (process.platform === 'win32') {
68
- const winResult = writePersistedNodeOptions({ autoPath });
69
- await emit({
70
- shell: 'win32',
71
- shell_config_path: 'HKCU\\Environment\\NODE_OPTIONS',
72
- sdks_detected: sdks,
73
- dry_run: false,
74
- already_installed: !!winResult.already,
75
- files_written: winResult.ok && !winResult.already ? 1 : 0,
76
- files_with_errors: winResult.ok ? 0 : 1,
77
- error_messages: winResult.ok ? [] : [winResult.error_message || winResult.reason || 'unknown'],
78
- auto_path: autoPath,
79
- win_node_options_length: winResult.length,
80
- reason: winResult.ok ? null : winResult.reason,
81
- });
82
- if (winResult.ok && winResult.already) {
83
- return {
84
- ok: true,
85
- already: true,
86
- message: 'NODE_OPTIONS already includes the auto-attach line. No changes made.',
87
- };
88
- }
89
- if (!winResult.ok) {
90
- return {
91
- ok: false,
92
- reason: winResult.reason,
93
- message: `Could not set NODE_OPTIONS via setx (${winResult.reason}): ${winResult.error_message || ''}`,
94
- };
95
- }
96
- return {
97
- ok: true,
98
- written: ['HKCU\\Environment\\NODE_OPTIONS'],
99
- errors: [],
100
- message:
101
- 'Set NODE_OPTIONS in your user environment (HKCU\\Environment). ' +
102
- 'Open a new terminal for it to take effect. Existing terminals will not see the change.',
103
- };
104
- }
105
-
106
- const alreadyInstalled = hasShellLine();
107
- const result = writeShellLine({ autoPath });
108
-
109
- // Single shell value for the funnel even though we may have written to
110
- // multiple rc files. Pick the dominant one for telemetry.
111
- const dominant = pickDominantShell(result.written);
112
-
113
- await emit({
114
- shell: dominant,
115
- shell_config_path: result.written.join(','),
116
- sdks_detected: sdks,
117
- dry_run: false,
118
- already_installed: alreadyInstalled,
119
- files_written: result.written.length,
120
- files_with_errors: result.errors.length,
121
- error_messages: result.errors.map((e) => `${e.path}: ${e.message}`).slice(0, 3),
122
- auto_path: autoPath,
123
- });
124
-
125
- if (alreadyInstalled && result.written.length === 0) {
126
- return {
127
- ok: true,
128
- already: true,
129
- message: 'NODE_OPTIONS auto-attach already installed. No changes made.',
130
- };
131
- }
132
-
133
- if (result.written.length === 0 && result.errors.length > 0) {
134
- return {
135
- ok: false,
136
- message: `Could not write to any shell rc file. Errors: ${result.errors.map((e) => e.message).join(', ')}`,
137
- };
138
- }
139
-
140
- return {
141
- ok: true,
142
- written: result.written,
143
- errors: result.errors,
144
- message:
145
- `Installed NODE_OPTIONS auto-attach in ${result.written.length} shell file(s). ` +
146
- 'Open a new terminal (or source the file) for it to take effect.',
147
- };
148
- }
149
-
150
- function detectSdks(cwd) {
151
- const result = detectNodeAgent(cwd);
152
- return result?.evidence ?? [];
153
- }
154
-
155
- function pickDominantShell(paths) {
156
- // Use process.env.SHELL as the tiebreaker — that's the user's actual
157
- // login shell. Fall back to the first written file's basename.
158
- const shellEnv = (process.env.SHELL || '').toLowerCase();
159
- if (shellEnv.includes('zsh')) return 'zsh';
160
- if (shellEnv.includes('fish')) return 'fish';
161
- if (shellEnv.includes('bash')) return 'bash';
162
- if (paths[0]?.endsWith('.zshrc')) return 'zsh';
163
- if (paths[0]?.endsWith('.bashrc') || paths[0]?.endsWith('.bash_profile')) return 'bash';
164
- if (paths[0]?.endsWith('config.fish')) return 'fish';
165
- return 'unknown';
166
- }
167
-
168
- async function emit(payload) {
169
- const config = readConfig();
170
- if (!config.api_key) return;
171
- try {
172
- await fetch(`${PLATFORM_URL}/v1/telemetry`, {
173
- method: 'POST',
174
- headers: {
175
- 'Authorization': `Bearer ${config.api_key}`,
176
- 'Content-Type': 'application/json',
177
- },
178
- body: JSON.stringify({
179
- product: 'cli',
180
- event_type: 'node_shim_installed',
181
- payload: { ...payload, platform: process.platform },
182
- }),
183
- signal: AbortSignal.timeout(5_000),
184
- });
185
- } catch {
186
- // Best-effort — never let telemetry break the install path.
187
- }
188
- }
@@ -1,107 +0,0 @@
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
- }