robot-resources 1.14.1 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -50
- package/bin/setup.js +6 -1
- package/lib/non-oc-wizard.js +163 -17
- package/lib/source-edit-attach.js +335 -0
- package/lib/uninstall.js +31 -0
- package/lib/wizard.js +83 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,78 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Robot Resources
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Tools for AI agents and agentic software. Humans have HR. Agents have RR.
|
|
4
|
+
|
|
5
|
+
Robot Resources builds tools for AI agents and any software that makes LLM API calls — chatbots, RAG pipelines, AI-powered apps, internal tools. Two products today: **Router** (smart model selection — picks the right model for each task, 60-90% cost savings as a side effect) and **Scraper** (token compression for web content, median 91% token reduction). Both run locally. Both free.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
4
8
|
|
|
5
9
|
```bash
|
|
6
|
-
npx robot-resources
|
|
10
|
+
npx -y robot-resources
|
|
7
11
|
```
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
Detects your project shape (Node / Python / OpenClaw) and installs the right shim. No login, no API keys to enter, no signup. Router uses your existing provider keys — they never leave your machine.
|
|
14
|
+
|
|
15
|
+
## Router
|
|
16
|
+
|
|
17
|
+
Smart model selection. Classifies each prompt by task type (coding / reasoning / analysis / simple_qa / creative / general), filters by model capability, then within the qualifying set picks the cheapest. Routes the right model for the task — cost savings (60-90% across mixed workloads) follow from that, not the other way around. Hybrid classification: keyword fast-path (~5ms, ~70% of prompts) + LLM slow-path for ambiguous prompts (~200ms). Routes across Anthropic, OpenAI, and Google when the corresponding keys are present.
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
Three ways to install on a dev machine:
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- Non-OC + cwd has `package.json` with LangChain/LangGraph/Mastra → preselects "JS/TS agent."
|
|
17
|
-
- Non-OC + cwd has `requirements.txt` / `pyproject.toml` → preselects "Python agent."
|
|
18
|
-
- Non-OC + Cursor or Claude Code installed → preselects "MCP tool."
|
|
19
|
-
3. **Runs the chosen path**:
|
|
20
|
-
- **JS/TS agent** → prints `npm install @robot-resources/router` + `import { routePrompt }` example
|
|
21
|
-
- **Python agent** → prints `pip install robot-resources` + `from robot_resources.router import route` example, plus an httpx fallback if you'd rather skip the SDK
|
|
22
|
-
- **Cursor / Claude Code** → writes the scraper MCP config into `~/.cursor/mcp.json` / `~/.claude/settings.json`
|
|
23
|
-
- **Docs** → prints the URL + exits
|
|
24
|
-
- **Install OpenClaw first** → redirect message + exits
|
|
21
|
+
- **OpenClaw users** get an in-process plugin inside the OC gateway. Anthropic, OpenAI, and Google calls each route to their native upstream — no cross-shape body translation.
|
|
22
|
+
- **Node projects** get an auto-attach shim (`NODE_OPTIONS=--require .../auto.cjs`). Every Anthropic, OpenAI, and Google SDK call from any Node process routes automatically. No code changes.
|
|
23
|
+
- **Python projects** get a `.pth` auto-attach shim in your venv. Every `anthropic` / `openai` / `google.generativeai` SDK call routes automatically. No code changes.
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
For runtimes that ignore `NODE_OPTIONS` / `.pth` (Bun, Deno, Vercel Edge, Go, Rust, etc.), call the HTTP API directly: `POST https://api.robotresources.ai/v1/route`. Authed by API key, 100 req/min, CORS open.
|
|
27
26
|
|
|
27
|
+
For explicit control inside JS / Python code, use the routing-decision library:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @robot-resources/router # JS / TS
|
|
31
|
+
pip install robot-resources # Python (singular package name)
|
|
28
32
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { routePrompt } from '@robot-resources/router/routing';
|
|
36
|
+
const decision = routePrompt('write a python function that reverses a string');
|
|
37
|
+
// decision.selected_model → 'claude-haiku-4-5' (or similar — cheapest qualifying)
|
|
34
38
|
```
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
```python
|
|
41
|
+
from robot_resources.router import route
|
|
42
|
+
decision = route('write a python function that reverses a string')
|
|
43
|
+
```
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
Returns a routing decision; your code makes the actual LLM call with the selected model. Each request goes from your machine straight to the lab's API (`api.anthropic.com` / `api.openai.com` / `generativelanguage.googleapis.com`) using your existing key for that lab. Nothing is relayed through our infrastructure.
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
## Scraper
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
export RR_API_KEY=rr_live_... # skip signup, use this key
|
|
44
|
-
npx robot-resources --for=cursor # or whatever path applies
|
|
45
|
-
```
|
|
49
|
+
Token compression for web content. Fetches any URL, strips noise, returns clean markdown with token count. Median 91% token reduction per page (verified across 41 page types). Mozilla Readability extraction (0.97 F1). Content-aware token estimation calibrated per content type, ±15% of actual BPE. 3-tier fetch (fast / stealth via TLS fingerprint / render via headless browser), BFS multi-page crawl, robots.txt compliance.
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
Three ways to consume:
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
| JS/TS agent | `@robot-resources/router/routing` — pure ESM, zero deps, offline keyword classifier. | npm |
|
|
53
|
-
| Python agent | `robot-resources` (singular) — thin httpx client over `/v1/route`. | PyPI |
|
|
54
|
-
| HTTP API | Any language with curl/fetch. Authed by API key. | `POST https://api.robotresources.ai/v1/route` |
|
|
55
|
-
| Cursor / Claude Code MCP | Scraper MCP wired into your tool's config (web fetches → 91% smaller markdown). | `~/.cursor/mcp.json` or `~/.claude/settings.json` |
|
|
53
|
+
- **JS library** — `npm install @robot-resources/scraper` → `import { scrape } from '@robot-resources/scraper'`
|
|
54
|
+
- **MCP server** — `npx -y @robot-resources/scraper scraper-mcp` exposes `scraper_compress_url(url)` and crawl tools to any MCP-compatible client. Auto-wired into OpenClaw by the wizard; for other clients (Cursor, Claude Code, Windsurf), add manually to your client's MCP config.
|
|
55
|
+
- **OpenClaw plugin** — installed automatically via `npx robot-resources`. Hooks `before_tool_call` to redirect `web_fetch` through scraper compression.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
No API keys, no config.
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## Deploying to production
|
|
60
60
|
|
|
61
|
-
The
|
|
61
|
+
The wizard's shell-config install reaches dev machines only — production processes don't read `.bashrc`, and env vars come from your deploy config. Copy-paste recipes for setting `NODE_OPTIONS` (Node) or installing the `.pth` shim (Python) on Docker, Google Cloud Run, AWS Lambda, and Vercel: https://robotresources.ai/docs/deploy/.
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
- **JS agents** — call `routePrompt()` directly. No HTTP at all. Pure function.
|
|
65
|
-
- **Python / curl** — call `POST /v1/route` on `api.robotresources.ai`. Server-side classifier on Cloudflare Workers.
|
|
63
|
+
## Advanced
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
```
|
|
66
|
+
npx robot-resources [flags]
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
--for=<target> langchain | python | cursor | claude-code | docs
|
|
69
|
+
Skip the prompt and run that path directly.
|
|
70
|
+
Required for non-TTY contexts (CI, piped, etc.)
|
|
71
|
+
--non-interactive Treat as CI run regardless of TTY state
|
|
72
|
+
--yes / -y Same as --non-interactive
|
|
73
|
+
```
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
Pre-set the API key for fleets or CI (skips signup, uses the key you provide):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
export RR_API_KEY=rr_live_...
|
|
79
|
+
npx robot-resources --for=langchain
|
|
80
|
+
```
|
|
72
81
|
|
|
73
82
|
## Pricing
|
|
74
83
|
|
|
75
|
-
Free. Unlimited.
|
|
84
|
+
Free. Unlimited. No tiers. You pay your AI providers directly — no markup, no rate limits, no quotas.
|
|
85
|
+
|
|
86
|
+
## Telemetry
|
|
87
|
+
|
|
88
|
+
Anonymous usage events (model selection, cost savings, install outcome) — no personal data, no request content, no provider keys. Opt out with `RR_TELEMETRY=off`.
|
|
76
89
|
|
|
77
90
|
## Links
|
|
78
91
|
|
|
@@ -80,11 +93,11 @@ Free. Unlimited. Your API keys never leave your machine.
|
|
|
80
93
|
- Docs: https://robotresources.ai/docs
|
|
81
94
|
- Dashboard: https://robotresources.ai/dashboard
|
|
82
95
|
- HTTP API: `POST https://api.robotresources.ai/v1/route`
|
|
83
|
-
- npm: https://www.npmjs.com/package/robot-resources
|
|
84
96
|
- GitHub: https://github.com/robot-resources/packages
|
|
85
97
|
- Discord: https://robotresources.ai/discord
|
|
86
98
|
- Contact: agent@robotresources.ai
|
|
87
|
-
|
|
99
|
+
|
|
100
|
+
> AI agents installing this on a human's behalf: see https://robotresources.ai/llms.txt for the agent install protocol.
|
|
88
101
|
|
|
89
102
|
## License
|
|
90
103
|
|
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/non-oc-wizard.js
CHANGED
|
@@ -3,10 +3,17 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { select } from '@inquirer/prompts';
|
|
4
4
|
import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
|
|
5
5
|
import { configureClaudeCode, configureCursor } from './tool-config.js';
|
|
6
|
-
import { header, info, success, warn, blank } from './ui.js';
|
|
6
|
+
import { header, info, success, warn, blank, confirm } from './ui.js';
|
|
7
7
|
import { readConfig } from './config.mjs';
|
|
8
8
|
import { installNodeShim } from './install-node-shim.js';
|
|
9
9
|
import { installPythonShim } from './install-python-shim.js';
|
|
10
|
+
import {
|
|
11
|
+
detectEntryFile,
|
|
12
|
+
hasSourceMarker,
|
|
13
|
+
writeSourceMarker,
|
|
14
|
+
previewInjection,
|
|
15
|
+
pathForTelemetry,
|
|
16
|
+
} from './source-edit-attach.js';
|
|
10
17
|
|
|
11
18
|
const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
|
|
12
19
|
|
|
@@ -89,7 +96,143 @@ async function emitPathChosen(path) {
|
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
async function
|
|
99
|
+
async function emitNodeEntryPatched(payload) {
|
|
100
|
+
const config = readConfig();
|
|
101
|
+
if (!config.api_key) return;
|
|
102
|
+
try {
|
|
103
|
+
await fetch(`${PLATFORM_URL}/v1/telemetry`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
product: 'cli',
|
|
111
|
+
event_type: 'node_entry_patched',
|
|
112
|
+
payload: { ...payload, platform: process.platform },
|
|
113
|
+
}),
|
|
114
|
+
signal: AbortSignal.timeout(5_000),
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
// Best-effort — never let telemetry break the install path.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Phase 11 — offer to inject `require('@robot-resources/router/auto')`
|
|
123
|
+
* (or ESM equivalent) at the top of the user's entry file.
|
|
124
|
+
*
|
|
125
|
+
* Why this is here: NODE_OPTIONS in shell config never reaches cron /
|
|
126
|
+
* Docker / systemd / Lambda agents because those launchers don't read
|
|
127
|
+
* shell rc files. A line in the source survives any launcher.
|
|
128
|
+
*
|
|
129
|
+
* Gates:
|
|
130
|
+
* - No package.json or no detectable entry → skip with a hint
|
|
131
|
+
* - Marker already present → skip silently
|
|
132
|
+
* - Non-interactive without `autoAttachSource` → skip with a hint;
|
|
133
|
+
* auto-rewriting source files in CI without consent is too aggressive
|
|
134
|
+
* - Y/N declined → skip silently, user can always re-run
|
|
135
|
+
*/
|
|
136
|
+
async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = process.cwd() } = {}) {
|
|
137
|
+
const detection = detectEntryFile(cwd);
|
|
138
|
+
if (!detection.path) {
|
|
139
|
+
info('No agent entry file detected — skipping source-attach step.');
|
|
140
|
+
info('Re-run from your project root, or add this line yourself at the top of your entry file:');
|
|
141
|
+
info(" require('@robot-resources/router/auto'); // CJS");
|
|
142
|
+
info(" // or: import '@robot-resources/router/auto'; // ESM/TS");
|
|
143
|
+
await emitNodeEntryPatched({ outcome: 'no_entry_detected' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (hasSourceMarker(detection.path)) {
|
|
148
|
+
info(`${pathForTelemetry(detection.path, cwd)} already has the auto-attach line — leaving it as-is.`);
|
|
149
|
+
await emitNodeEntryPatched({
|
|
150
|
+
outcome: 'already_present',
|
|
151
|
+
entry_path: pathForTelemetry(detection.path, cwd),
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Non-interactive: skip unless explicit opt-in. Source files are sacred.
|
|
157
|
+
if (nonInteractive && !autoAttachSource) {
|
|
158
|
+
blank();
|
|
159
|
+
info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
|
|
160
|
+
info(`To enable it in CI, re-run with: npx robot-resources --for=langchain --auto-attach-source`);
|
|
161
|
+
info(`Or add this line manually at the top of ${pathForTelemetry(detection.path, cwd)}:`);
|
|
162
|
+
info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
|
|
163
|
+
await emitNodeEntryPatched({
|
|
164
|
+
outcome: 'skipped_non_interactive',
|
|
165
|
+
entry_path: pathForTelemetry(detection.path, cwd),
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Multi-candidate ambiguity → ask which entry the user actually runs.
|
|
171
|
+
let target = detection.path;
|
|
172
|
+
if (!nonInteractive && detection.candidates.length > 1) {
|
|
173
|
+
const choices = detection.candidates.map((c) => ({
|
|
174
|
+
name: pathForTelemetry(c, cwd),
|
|
175
|
+
value: c,
|
|
176
|
+
}));
|
|
177
|
+
choices.push({ name: 'Skip — I\'ll add the line manually', value: '__skip__' });
|
|
178
|
+
target = await select({
|
|
179
|
+
message: 'Multiple entry candidates found. Which file runs your agent?',
|
|
180
|
+
default: detection.path,
|
|
181
|
+
choices,
|
|
182
|
+
});
|
|
183
|
+
if (target === '__skip__') {
|
|
184
|
+
await emitNodeEntryPatched({ outcome: 'declined_ambiguous_pick' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Show the diff and ask Y/N (unless auto-attach is explicitly opt-in).
|
|
190
|
+
const preview = previewInjection(target);
|
|
191
|
+
blank();
|
|
192
|
+
success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
|
|
193
|
+
blank();
|
|
194
|
+
info(`File: ${pathForTelemetry(target, cwd)}`);
|
|
195
|
+
blank();
|
|
196
|
+
for (const line of preview.split('\n')) info(line);
|
|
197
|
+
blank();
|
|
198
|
+
|
|
199
|
+
let proceed = autoAttachSource;
|
|
200
|
+
if (!nonInteractive && !autoAttachSource) {
|
|
201
|
+
proceed = await confirm('Add the line?', { defaultYes: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!proceed) {
|
|
205
|
+
info('Skipped — you can re-run anytime.');
|
|
206
|
+
await emitNodeEntryPatched({
|
|
207
|
+
outcome: 'declined',
|
|
208
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = writeSourceMarker(target);
|
|
214
|
+
if (result.ok) {
|
|
215
|
+
success(`Added the auto-attach line to ${pathForTelemetry(target, cwd)} (${result.syntax}).`);
|
|
216
|
+
if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
|
|
217
|
+
info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
|
|
218
|
+
await emitNodeEntryPatched({
|
|
219
|
+
outcome: 'patched',
|
|
220
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
221
|
+
import_syntax: result.syntax,
|
|
222
|
+
backup_written: !!result.backupWritten,
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
|
|
226
|
+
info('You can add the line yourself — see the snippet above.');
|
|
227
|
+
await emitNodeEntryPatched({
|
|
228
|
+
outcome: 'failed',
|
|
229
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
230
|
+
error: result.error,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function showJsPath({ nonInteractive = false, autoAttachSource = false } = {}) {
|
|
93
236
|
blank();
|
|
94
237
|
success('JS/TS integration');
|
|
95
238
|
blank();
|
|
@@ -105,14 +248,10 @@ async function showJsPath() {
|
|
|
105
248
|
}
|
|
106
249
|
}
|
|
107
250
|
blank();
|
|
108
|
-
info('
|
|
109
|
-
info('
|
|
110
|
-
info('
|
|
111
|
-
|
|
112
|
-
info('Open a new cmd / PowerShell window — current terminals will not see the change.');
|
|
113
|
-
} else {
|
|
114
|
-
info('Open a new terminal — or run: source ~/.zshrc (or your shell rc)');
|
|
115
|
-
}
|
|
251
|
+
info('Phase 3+ NODE_OPTIONS shim installed. Works for desktop dev sessions');
|
|
252
|
+
info('after a terminal restart, but does NOT reach cron / Docker / systemd /');
|
|
253
|
+
info('Lambda. The next step adds a one-line require to your agent source so');
|
|
254
|
+
info('routing works regardless of how the process is launched.');
|
|
116
255
|
} else {
|
|
117
256
|
warn(result.message);
|
|
118
257
|
blank();
|
|
@@ -124,6 +263,11 @@ async function showJsPath() {
|
|
|
124
263
|
info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require ~/.robot-resources/router/auto.cjs"');
|
|
125
264
|
}
|
|
126
265
|
}
|
|
266
|
+
|
|
267
|
+
// Phase 11 — source-edit injection. Reaches cron / Docker / systemd / Lambda
|
|
268
|
+
// since the line lives in the user's source, not in shell config.
|
|
269
|
+
await maybeInjectSourceEdit({ nonInteractive, autoAttachSource });
|
|
270
|
+
|
|
127
271
|
blank();
|
|
128
272
|
info('Docs: https://robotresources.ai/docs/langchain');
|
|
129
273
|
blank();
|
|
@@ -204,9 +348,9 @@ function showInstallOcPath() {
|
|
|
204
348
|
blank();
|
|
205
349
|
}
|
|
206
350
|
|
|
207
|
-
async function runPath(path) {
|
|
351
|
+
async function runPath(path, opts = {}) {
|
|
208
352
|
switch (path) {
|
|
209
|
-
case 'js': await showJsPath(); break;
|
|
353
|
+
case 'js': await showJsPath(opts); break;
|
|
210
354
|
case 'python': await showPythonPath(); break;
|
|
211
355
|
case 'mcp': showMcpPath(); break;
|
|
212
356
|
case 'docs': showDocsPath(); break;
|
|
@@ -221,11 +365,11 @@ async function runPath(path) {
|
|
|
221
365
|
* - non-interactive AND no target: print hint with --for= options and exit
|
|
222
366
|
* - interactive: 5-option menu via @inquirer/prompts.select
|
|
223
367
|
*/
|
|
224
|
-
export async function runNonOcWizard({ nonInteractive = false, target = null } = {}) {
|
|
368
|
+
export async function runNonOcWizard({ nonInteractive = false, target = null, autoAttachSource = false } = {}) {
|
|
225
369
|
const normalized = normalizeTarget(target);
|
|
226
370
|
|
|
227
371
|
if (normalized) {
|
|
228
|
-
await runPath(normalized);
|
|
372
|
+
await runPath(normalized, { nonInteractive, autoAttachSource });
|
|
229
373
|
await emitPathChosen(normalized);
|
|
230
374
|
return;
|
|
231
375
|
}
|
|
@@ -249,7 +393,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
249
393
|
info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
|
|
250
394
|
info(' Pass --for=<other> to override, or --uninstall to remove later.');
|
|
251
395
|
blank();
|
|
252
|
-
await runPath(autoTarget);
|
|
396
|
+
await runPath(autoTarget, { nonInteractive, autoAttachSource });
|
|
253
397
|
await emitPathChosen(autoTarget);
|
|
254
398
|
return;
|
|
255
399
|
}
|
|
@@ -334,7 +478,9 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
334
478
|
if (autoTarget) {
|
|
335
479
|
info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
|
|
336
480
|
blank();
|
|
337
|
-
|
|
481
|
+
// Treat the timed-out interactive session as non-interactive for the
|
|
482
|
+
// source-edit step too — we don't have a TTY to ask the Y/N on.
|
|
483
|
+
await runPath(autoTarget, { nonInteractive: true, autoAttachSource });
|
|
338
484
|
await emitPathChosen(autoTarget);
|
|
339
485
|
return;
|
|
340
486
|
}
|
|
@@ -347,6 +493,6 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
347
493
|
return;
|
|
348
494
|
}
|
|
349
495
|
|
|
350
|
-
await runPath(chosen);
|
|
496
|
+
await runPath(chosen, { nonInteractive, autoAttachSource });
|
|
351
497
|
await emitPathChosen(chosen);
|
|
352
498
|
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Source-edit injection for non-OC Node agents (Phase 11).
|
|
6
|
+
*
|
|
7
|
+
* The Phase-3 NODE_OPTIONS approach only reaches processes that inherit
|
|
8
|
+
* from a shell that loaded ~/.bashrc or ~/.zshrc. Cron / Docker / systemd /
|
|
9
|
+
* Lambda / Cloud Run / serverless functions do NOT inherit shell rc files,
|
|
10
|
+
* so the shim never loads in the agents that matter most. Funnel data
|
|
11
|
+
* showed a 45-point cliff: 53% reach `node_shim_installed`, only 8.6% ever
|
|
12
|
+
* emit `adapter_attached`.
|
|
13
|
+
*
|
|
14
|
+
* This module injects ONE LINE at the top of the user's entry source file:
|
|
15
|
+
*
|
|
16
|
+
* // >>> robot-resources: auto-attach >>>
|
|
17
|
+
* require('@robot-resources/router/auto');
|
|
18
|
+
* // <<< robot-resources <<<
|
|
19
|
+
*
|
|
20
|
+
* (or `import '@robot-resources/router/auto';` for ESM/TS files.)
|
|
21
|
+
*
|
|
22
|
+
* The line runs whenever the agent process starts, regardless of how it
|
|
23
|
+
* was launched. Cron, Docker, systemd, Lambda — all work the same.
|
|
24
|
+
*
|
|
25
|
+
* Marker-block convention mirrors shell-config.js exactly so an `--uninstall`
|
|
26
|
+
* remove pass is text-based and never destructive on partial state.
|
|
27
|
+
*
|
|
28
|
+
* Backup: a `.rr-backup` is written once on first inject (never overwritten
|
|
29
|
+
* on re-inject) so `--uninstall --purge` can restore the original source
|
|
30
|
+
* even if the marker block was tampered with.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const MARK_BEGIN = '// >>> robot-resources: auto-attach >>>';
|
|
34
|
+
export const MARK_END = '// <<< robot-resources <<<';
|
|
35
|
+
|
|
36
|
+
const REQUIRE_LINE = "require('@robot-resources/router/auto');";
|
|
37
|
+
const IMPORT_LINE = "import '@robot-resources/router/auto';";
|
|
38
|
+
|
|
39
|
+
// File extensions we'll treat as Node source — anything else, refuse to
|
|
40
|
+
// edit. Keeps us out of build artifacts (.min.js), declaration files (.d.ts),
|
|
41
|
+
// JSON, etc.
|
|
42
|
+
const VALID_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide which import syntax to inject for a given source file.
|
|
46
|
+
* Implements Node's resolution rules verbatim:
|
|
47
|
+
* .cjs → cjs
|
|
48
|
+
* .mjs → esm
|
|
49
|
+
* .ts / .tsx → emit `import` (works under both ts-node CJS and tsx/ESM)
|
|
50
|
+
* .js / .jsx → walk up to nearest package.json, check "type" field.
|
|
51
|
+
* "module" → esm; anything else (or absent) → cjs.
|
|
52
|
+
*/
|
|
53
|
+
export function detectImportSyntax(entryPath) {
|
|
54
|
+
const ext = extname(entryPath).toLowerCase();
|
|
55
|
+
if (ext === '.cjs') return 'cjs';
|
|
56
|
+
if (ext === '.mjs') return 'esm';
|
|
57
|
+
if (ext === '.ts' || ext === '.tsx') return 'esm';
|
|
58
|
+
|
|
59
|
+
// Walk up from the file's directory until we hit a package.json or
|
|
60
|
+
// exhaust the path. This is what Node itself does for `.js` files.
|
|
61
|
+
let dir = dirname(resolve(entryPath));
|
|
62
|
+
while (true) {
|
|
63
|
+
const pkgPath = join(dir, 'package.json');
|
|
64
|
+
if (existsSync(pkgPath)) {
|
|
65
|
+
try {
|
|
66
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
67
|
+
return pkg.type === 'module' ? 'esm' : 'cjs';
|
|
68
|
+
} catch {
|
|
69
|
+
return 'cjs';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir) return 'cjs';
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Locate the user's agent entry file under cwd. Staged fallback through
|
|
80
|
+
* standard package.json fields, then a convention scan.
|
|
81
|
+
*
|
|
82
|
+
* Returns { path, candidates }:
|
|
83
|
+
* - path: absolute path to the chosen entry, or null if nothing matched
|
|
84
|
+
* - candidates: every viable candidate found (used by the wizard to
|
|
85
|
+
* show a select() prompt when ambiguous)
|
|
86
|
+
*
|
|
87
|
+
* Priority (first match wins for `path`, all viable hits go in `candidates`):
|
|
88
|
+
* 1. package.json `bin` — single-string or single-key object → entry
|
|
89
|
+
* 2. package.json `main`
|
|
90
|
+
* 3. package.json `exports["."]` (or `exports["."].default`)
|
|
91
|
+
* 4. parsed package.json `scripts.start` — regex against
|
|
92
|
+
* `(node|tsx|ts-node|bun) <file>`
|
|
93
|
+
* 5. convention scan: src/index.{ts,js,mjs,cjs}, index.{ts,js,mjs,cjs},
|
|
94
|
+
* src/agent.{ts,js}, agent.{ts,js}, src/main.{ts,js}, main.{ts,js},
|
|
95
|
+
* src/app.{ts,js}, app.{ts,js}, src/bot.{ts,js}, bot.{ts,js}
|
|
96
|
+
*/
|
|
97
|
+
export function detectEntryFile(cwd = process.cwd()) {
|
|
98
|
+
const candidates = [];
|
|
99
|
+
const pkgPath = join(cwd, 'package.json');
|
|
100
|
+
let pkg = null;
|
|
101
|
+
if (existsSync(pkgPath)) {
|
|
102
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { /* malformed */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (pkg) {
|
|
106
|
+
// Tier 1: bin
|
|
107
|
+
if (typeof pkg.bin === 'string') {
|
|
108
|
+
addCandidate(candidates, cwd, pkg.bin);
|
|
109
|
+
} else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
110
|
+
for (const v of Object.values(pkg.bin)) {
|
|
111
|
+
if (typeof v === 'string') addCandidate(candidates, cwd, v);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Tier 2: main
|
|
116
|
+
if (typeof pkg.main === 'string') addCandidate(candidates, cwd, pkg.main);
|
|
117
|
+
|
|
118
|
+
// Tier 3: exports["."]
|
|
119
|
+
const root = pkg.exports?.['.'] ?? pkg.exports;
|
|
120
|
+
if (typeof root === 'string') addCandidate(candidates, cwd, root);
|
|
121
|
+
else if (root && typeof root === 'object') {
|
|
122
|
+
const dflt = root.default ?? root.import ?? root.require;
|
|
123
|
+
if (typeof dflt === 'string') addCandidate(candidates, cwd, dflt);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tier 4: scripts.start parse
|
|
127
|
+
if (typeof pkg.scripts?.start === 'string') {
|
|
128
|
+
// Match `node ./foo.js`, `tsx src/agent.ts`, `bun run agent.ts` etc.
|
|
129
|
+
const m = pkg.scripts.start.match(/(?:node|tsx|ts-node|bun(?:\s+run)?)\s+(\S+)/);
|
|
130
|
+
if (m) addCandidate(candidates, cwd, m[1]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Tier 5: convention scan — only adds if the candidate file actually exists.
|
|
135
|
+
const conventions = [
|
|
136
|
+
'src/index.ts', 'src/index.js', 'src/index.mjs', 'src/index.cjs',
|
|
137
|
+
'index.ts', 'index.js', 'index.mjs', 'index.cjs',
|
|
138
|
+
'src/agent.ts', 'src/agent.js', 'agent.ts', 'agent.js',
|
|
139
|
+
'src/main.ts', 'src/main.js', 'main.ts', 'main.js',
|
|
140
|
+
'src/app.ts', 'src/app.js', 'app.ts', 'app.js',
|
|
141
|
+
'src/bot.ts', 'src/bot.js', 'bot.ts', 'bot.js',
|
|
142
|
+
];
|
|
143
|
+
for (const rel of conventions) {
|
|
144
|
+
const abs = join(cwd, rel);
|
|
145
|
+
if (existsSync(abs) && !candidates.includes(abs)) candidates.push(abs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Filter to files that exist and have a valid extension. The package.json
|
|
149
|
+
// tiers might point at files that don't exist yet (e.g. a `main` of
|
|
150
|
+
// `dist/index.js` before the user has built). Don't try to inject into
|
|
151
|
+
// missing files.
|
|
152
|
+
const viable = candidates.filter((c) => existsSync(c) && VALID_EXTS.has(extname(c).toLowerCase()));
|
|
153
|
+
|
|
154
|
+
return { path: viable[0] ?? null, candidates: viable };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function addCandidate(arr, cwd, rel) {
|
|
158
|
+
// Strip leading ./, normalize, resolve relative to cwd.
|
|
159
|
+
const cleaned = rel.replace(/^\.\//, '');
|
|
160
|
+
const abs = join(cwd, cleaned);
|
|
161
|
+
if (!arr.includes(abs)) arr.push(abs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns true if the given source file already contains our marker.
|
|
166
|
+
* Used to skip-if-already-installed and to gate uninstall.
|
|
167
|
+
*/
|
|
168
|
+
export function hasSourceMarker(filePath) {
|
|
169
|
+
try {
|
|
170
|
+
return readFileSync(filePath, 'utf-8').includes(MARK_BEGIN);
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Idempotently inject the auto-attach marker block at the top of the file
|
|
178
|
+
* (after shebang, if present). Writes a one-time backup at `${filePath}.rr-backup`.
|
|
179
|
+
*
|
|
180
|
+
* Returns { ok, alreadyInstalled, path, syntax, backupPath, error }.
|
|
181
|
+
*/
|
|
182
|
+
export function writeSourceMarker(filePath, opts = {}) {
|
|
183
|
+
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
184
|
+
|
|
185
|
+
let original;
|
|
186
|
+
try { original = readFileSync(filePath, 'utf-8'); }
|
|
187
|
+
catch (err) {
|
|
188
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `read_failed: ${err.message}` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (original.includes(MARK_BEGIN)) {
|
|
192
|
+
return { ok: true, alreadyInstalled: true, path: filePath, syntax, backupPath: backupPathFor(filePath) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Backup once. Never overwrite — preserves the user's pristine original
|
|
196
|
+
// even if they manually edit before our second pass.
|
|
197
|
+
const backupPath = backupPathFor(filePath);
|
|
198
|
+
let backupWritten = false;
|
|
199
|
+
if (!existsSync(backupPath)) {
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(backupPath, original, { mode: getMode(filePath) });
|
|
202
|
+
backupWritten = true;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `backup_failed: ${err.message}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Insert position: after shebang line if present, otherwise at top.
|
|
209
|
+
// We do NOT try to skip past `"use strict";` — modern Node treats it as
|
|
210
|
+
// a normal directive; placing our require/import before it is harmless.
|
|
211
|
+
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
212
|
+
const block = `${MARK_BEGIN}\n${line}\n${MARK_END}\n`;
|
|
213
|
+
|
|
214
|
+
let next;
|
|
215
|
+
if (original.startsWith('#!')) {
|
|
216
|
+
const nl = original.indexOf('\n');
|
|
217
|
+
if (nl === -1) {
|
|
218
|
+
// single-line file, only a shebang — append our block on a new line
|
|
219
|
+
next = original + '\n' + block;
|
|
220
|
+
} else {
|
|
221
|
+
next = original.slice(0, nl + 1) + block + original.slice(nl + 1);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
next = block + original;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `write_failed: ${err.message}`, backupPath, backupWritten };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { ok: true, alreadyInstalled: false, path: filePath, syntax, backupPath, backupWritten };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Idempotently remove the marker block from the source file. Mirror of
|
|
238
|
+
* writeSourceMarker. Returns { ok, removed, restored, path, error }.
|
|
239
|
+
*
|
|
240
|
+
* If `restoreFromBackup` is true and a `.rr-backup` exists, restores from
|
|
241
|
+
* the backup instead of splicing. Used for `--purge`. The backup is
|
|
242
|
+
* deleted after a successful restore.
|
|
243
|
+
*/
|
|
244
|
+
export function removeSourceMarker(filePath, { restoreFromBackup = false } = {}) {
|
|
245
|
+
let original;
|
|
246
|
+
try { original = readFileSync(filePath, 'utf-8'); }
|
|
247
|
+
catch (err) {
|
|
248
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `read_failed: ${err.message}` };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (restoreFromBackup) {
|
|
252
|
+
const backupPath = backupPathFor(filePath);
|
|
253
|
+
if (existsSync(backupPath)) {
|
|
254
|
+
try {
|
|
255
|
+
const pristine = readFileSync(backupPath, 'utf-8');
|
|
256
|
+
writeFileSync(filePath, pristine, { mode: getMode(filePath) });
|
|
257
|
+
unlinkSync(backupPath);
|
|
258
|
+
return { ok: true, removed: true, restored: true, path: filePath };
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `restore_failed: ${err.message}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// No backup → fall through to marker splice.
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const startIdx = original.indexOf(MARK_BEGIN);
|
|
267
|
+
if (startIdx === -1) {
|
|
268
|
+
return { ok: true, removed: false, restored: false, path: filePath };
|
|
269
|
+
}
|
|
270
|
+
const endIdx = original.indexOf(MARK_END, startIdx);
|
|
271
|
+
if (endIdx === -1) {
|
|
272
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: 'marker_end_missing' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Splice from MARK_BEGIN through end of MARK_END line + the trailing
|
|
276
|
+
// newline our writer added. Walk backward over leading newlines so
|
|
277
|
+
// repeated install/uninstall cycles don't accumulate blanks.
|
|
278
|
+
const afterEnd = original.indexOf('\n', endIdx);
|
|
279
|
+
const sliceEnd = afterEnd === -1 ? original.length : afterEnd + 1;
|
|
280
|
+
|
|
281
|
+
let sliceStart = startIdx;
|
|
282
|
+
while (sliceStart > 0 && original[sliceStart - 1] === '\n') sliceStart--;
|
|
283
|
+
|
|
284
|
+
const next = original.slice(0, sliceStart) +
|
|
285
|
+
(sliceStart > 0 ? '\n' : '') +
|
|
286
|
+
original.slice(sliceEnd);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `write_failed: ${err.message}` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { ok: true, removed: true, restored: false, path: filePath };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build the proposed diff a wizard can show before asking Y/N. Returns a
|
|
299
|
+
* UI-friendly string with the marker block + a few lines of context.
|
|
300
|
+
*/
|
|
301
|
+
export function previewInjection(filePath, opts = {}) {
|
|
302
|
+
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
303
|
+
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
304
|
+
let original = '';
|
|
305
|
+
try { original = readFileSync(filePath, 'utf-8'); } catch { /* ignore */ }
|
|
306
|
+
const firstLines = original.split('\n').slice(0, 3).join('\n');
|
|
307
|
+
return [
|
|
308
|
+
`+ ${MARK_BEGIN}`,
|
|
309
|
+
`+ ${line}`,
|
|
310
|
+
`+ ${MARK_END}`,
|
|
311
|
+
firstLines.split('\n').map((l) => ` ${l}`).join('\n'),
|
|
312
|
+
].join('\n');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function backupPathFor(filePath) {
|
|
316
|
+
return `${filePath}.rr-backup`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getMode(filePath) {
|
|
320
|
+
try { return statSync(filePath).mode & 0o777; } catch { return 0o644; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Best-effort cwd-relative path for telemetry, falling back to basename
|
|
325
|
+
* if the file is outside cwd. Used to avoid leaking absolute paths (which
|
|
326
|
+
* can contain usernames) into Supabase.
|
|
327
|
+
*/
|
|
328
|
+
export function pathForTelemetry(filePath, cwd = process.cwd()) {
|
|
329
|
+
const rel = relative(cwd, filePath);
|
|
330
|
+
if (rel.startsWith('..') || rel.includes(sep + '..' + sep)) {
|
|
331
|
+
// Outside cwd — basename only.
|
|
332
|
+
return filePath.split(sep).pop();
|
|
333
|
+
}
|
|
334
|
+
return rel;
|
|
335
|
+
}
|
package/lib/uninstall.js
CHANGED
|
@@ -6,6 +6,7 @@ import { removeShellLine } from './shell-config.js';
|
|
|
6
6
|
import { detectVenv } from './venv-detect.js';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { removePersistedNodeOptions } from './windows-env.js';
|
|
9
|
+
import { detectEntryFile, hasSourceMarker, removeSourceMarker } from './source-edit-attach.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Single source of truth for `npx robot-resources --uninstall`.
|
|
@@ -115,6 +116,36 @@ export function runUninstall({ purge = false } = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// 3a. Phase 11 — source-edit auto-attach line. If the wizard injected
|
|
120
|
+
// `require('@robot-resources/router/auto')` at the top of the user's
|
|
121
|
+
// entry file, peel the marker block out (or restore from .rr-backup
|
|
122
|
+
// when --purge). Idempotent: no-op when no entry detected or marker
|
|
123
|
+
// not present. Runs against cwd, so users who --uninstall from a
|
|
124
|
+
// different repo won't accidentally wipe the wrong file.
|
|
125
|
+
try {
|
|
126
|
+
const detection = detectEntryFile();
|
|
127
|
+
if (detection.path && hasSourceMarker(detection.path)) {
|
|
128
|
+
const r = removeSourceMarker(detection.path, { restoreFromBackup: !!purge });
|
|
129
|
+
if (r.ok && (r.removed || r.restored)) {
|
|
130
|
+
components_removed.push(r.restored ? 'node_entry_restored_from_backup' : 'node_entry_marker_removed');
|
|
131
|
+
} else if (!r.ok) {
|
|
132
|
+
errors.push({ component: 'node_entry_source_edit', message: r.error || 'unknown' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// --purge also wipes any leftover .rr-backup (covers the case where the
|
|
136
|
+
// user manually deleted the marker but kept the backup file around).
|
|
137
|
+
if (purge && detection.path && existsSync(`${detection.path}.rr-backup`)) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(`${detection.path}.rr-backup`, { force: true });
|
|
140
|
+
components_removed.push('node_entry_backup_purged');
|
|
141
|
+
} catch (err) {
|
|
142
|
+
errors.push({ component: 'node_entry_backup_purged', message: err.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
errors.push({ component: 'node_entry_source_edit', message: err.message });
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
// 3b. Copied router dir at ~/.robot-resources/router/ (Phase 8). The shell
|
|
119
150
|
// line points at this absolute path — once the line is gone, the
|
|
120
151
|
// copied files are dead weight. Remove them.
|
package/lib/wizard.js
CHANGED
|
@@ -26,6 +26,43 @@ const CLI_VERSION = (() => {
|
|
|
26
26
|
}
|
|
27
27
|
})();
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Fire `install_complete` once after the wizard finishes — for BOTH OC
|
|
31
|
+
* and non-OC paths. Phase 11 fix: previously this only fired on the OC
|
|
32
|
+
* path because the non-OC branch returned early. The 7-day funnel showed
|
|
33
|
+
* 0/58 non-OC users hitting this event for a week. Now both paths fire
|
|
34
|
+
* with a `path: 'oc' | 'non-oc'` discriminator so funnel queries can
|
|
35
|
+
* segment without a second event type.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort with one retry. Total budget: ~20s. Telemetry never blocks
|
|
38
|
+
* the wizard exit beyond that.
|
|
39
|
+
*/
|
|
40
|
+
async function emitInstallComplete({ apiKey, payload }) {
|
|
41
|
+
if (!apiKey) return;
|
|
42
|
+
const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
|
|
43
|
+
const body = JSON.stringify({
|
|
44
|
+
product: 'cli',
|
|
45
|
+
event_type: 'install_complete',
|
|
46
|
+
payload,
|
|
47
|
+
});
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${platformUrl}/v1/telemetry`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body,
|
|
57
|
+
signal: AbortSignal.timeout(10_000),
|
|
58
|
+
});
|
|
59
|
+
if (res.ok) break;
|
|
60
|
+
} catch {
|
|
61
|
+
// Try again on next iteration.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* Main setup wizard. In Option 4 (post-PR-2.5) the wizard does NOT install
|
|
31
68
|
* a Python daemon, register a system service, or run a localhost health
|
|
@@ -43,7 +80,7 @@ const CLI_VERSION = (() => {
|
|
|
43
80
|
*
|
|
44
81
|
* No Python, no venv, no systemd, no port probe.
|
|
45
82
|
*/
|
|
46
|
-
export async function runWizard({ nonInteractive = false, target = null, scope = 'full' } = {}) {
|
|
83
|
+
export async function runWizard({ nonInteractive = false, target = null, scope = 'full', autoAttachSource = false } = {}) {
|
|
47
84
|
header();
|
|
48
85
|
|
|
49
86
|
// Detect OC once up front. Used both to branch into the non-OC wizard and
|
|
@@ -165,7 +202,31 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
|
|
|
165
202
|
// MCP config / docs / install-OC). The non-OC wizard's wizard_path_chosen
|
|
166
203
|
// telemetry now fires too, since Step 0 above provisioned an api_key.
|
|
167
204
|
if (!openclawDetected) {
|
|
168
|
-
await runNonOcWizard({ nonInteractive, target });
|
|
205
|
+
await runNonOcWizard({ nonInteractive, target, autoAttachSource });
|
|
206
|
+
|
|
207
|
+
// Phase 11: install_complete now fires for non-OC too. Closes the
|
|
208
|
+
// funnel signal that was 0/58 for a week.
|
|
209
|
+
if (results.auth) {
|
|
210
|
+
try {
|
|
211
|
+
const config = readConfig();
|
|
212
|
+
await emitInstallComplete({
|
|
213
|
+
apiKey: config.api_key,
|
|
214
|
+
payload: {
|
|
215
|
+
source: 'wizard',
|
|
216
|
+
path: 'non-oc',
|
|
217
|
+
cli_version: CLI_VERSION,
|
|
218
|
+
target: target ?? null,
|
|
219
|
+
scope,
|
|
220
|
+
auto_attach_source: !!autoAttachSource,
|
|
221
|
+
platform: process.platform,
|
|
222
|
+
os_release: osRelease(),
|
|
223
|
+
node_version: process.version,
|
|
224
|
+
install_duration_ms: Date.now() - wizardStartMs,
|
|
225
|
+
non_interactive: nonInteractive,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
} catch { /* non-fatal */ }
|
|
229
|
+
}
|
|
169
230
|
return;
|
|
170
231
|
}
|
|
171
232
|
|
|
@@ -250,54 +311,32 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
|
|
|
250
311
|
|
|
251
312
|
// ── Install Complete Telemetry ───────────────────────────────────────────
|
|
252
313
|
//
|
|
253
|
-
// Fire once after
|
|
254
|
-
//
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|