robot-resources 1.7.0 → 1.7.2
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 +80 -48
- package/lib/health-report.js +124 -0
- package/lib/machine-id.js +31 -0
- package/lib/tool-config.js +65 -34
- package/lib/wizard.js +52 -47
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,73 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Robot Resources
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Tools for AI agents. Humans have HR. Agents have RR.
|
|
4
4
|
|
|
5
|
-
|
|
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** (intelligent model routing, 60-90% cost savings) and **Scraper** (token compression for web content, median 91% token reduction). Both run locally, both free.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
6
10
|
npx robot-resources
|
|
7
11
|
```
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
1. **GitHub OAuth** — authenticates via PKCE, saves API key to `~/.robot-resources/config.json`
|
|
12
|
-
2. **Router install** — detects Python 3.10+, creates venv, pip installs `robot-resources-router`
|
|
13
|
-
3. **Service registration** — registers router as launchd (macOS) or systemd (Linux) service
|
|
14
|
-
4. **Scraper** — installs @robot-resources/scraper for token-efficient web compression
|
|
13
|
+
One command: authenticates via GitHub OAuth, installs Router as an always-on service, registers Scraper as the default web compression tool, and configures your agent automatically.
|
|
15
14
|
|
|
16
|
-
##
|
|
15
|
+
## Products
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
### Router
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
Intelligent LLM routing proxy. Classifies each prompt by task type, filters by model capability, routes to the cheapest model that qualifies.
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
- Hybrid classification: keyword detection (~5ms) + LLM fallback for ambiguous prompts (~200ms)
|
|
22
|
+
- Dynamic thresholds: simple tasks open cheap models (0.60), complex tasks require top models (0.85)
|
|
23
|
+
- 11 models across OpenAI, Anthropic, and Google — routes within your available providers
|
|
24
|
+
- 60-90% cost savings with zero quality loss
|
|
25
|
+
- OpenAI-compatible API on localhost:3838 — change your base_url and you're done
|
|
26
|
+
- OpenClaw compatible — plugin auto-installs, works with API keys and subscriptions
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
-
|
|
28
|
+
```bash
|
|
29
|
+
npx @robot-resources/router
|
|
30
|
+
```
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
- Check `~/.robot-resources/config.json` — offer login if missing
|
|
29
|
-
- Install the tool
|
|
30
|
-
- Configure MCP if applicable
|
|
31
|
-
- Print usage instructions
|
|
32
|
+
### Scraper
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Token compression for web content. Fetches any URL, strips noise, returns clean markdown with token count.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
- Mozilla Readability extraction (0.97 F1 accuracy)
|
|
37
|
+
- Content-aware token estimation (±15% of actual BPE)
|
|
38
|
+
- 3-tier fetch: fast, stealth (TLS fingerprint), render (headless browser)
|
|
39
|
+
- Multi-page BFS crawl with robots.txt compliance
|
|
40
|
+
- Median 91% token reduction per page
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
Installed automatically via `npx robot-resources`. Available as MCP tool `scraper_compress_url(url)` in your agent.
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|------|---------------|
|
|
41
|
-
| `web/src/pages/Landing/types.ts` | Add install subsection if new section |
|
|
42
|
-
| `web/src/pages/Landing/components/ContentPanel/animatedContentScreens.ts` | Add install screen (desktop + mobile) |
|
|
43
|
-
| `web/src/pages/Landing/components/TerminalPanel/terminalCommands.ts` | Add install subcommand output |
|
|
44
|
+
### Dashboard
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
Usage dashboard with real-time telemetry. Auth via GitHub OAuth, KPI panels, routing stats, cost savings tracking. Mobile-responsive.
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|------|---------------|
|
|
49
|
-
| `web/public/llms.txt` | Add tool with `npx @robot-resources/<tool>` |
|
|
50
|
-
| `web/public/llms-full.txt` | Add detailed section with npx install |
|
|
51
|
-
| `web/public/.well-known/ai-resources.json` | Add SoftwareApplication entry with InstallAction |
|
|
48
|
+
### MCP Servers
|
|
52
49
|
|
|
53
|
-
|
|
50
|
+
Both products include MCP servers for AI agent integration:
|
|
54
51
|
|
|
55
|
-
|
|
52
|
+
```bash
|
|
53
|
+
npx -y @robot-resources/router-mcp # Router stats + config
|
|
54
|
+
npx -y @robot-resources/scraper-mcp # Scraper compression
|
|
55
|
+
```
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
## Pricing
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Free. Unlimited. No tiers. Both tools run locally — you pay your AI providers directly. No markup, no rate limits, no quotas. Your API keys never leave your machine.
|
|
60
60
|
|
|
61
|
-
##
|
|
61
|
+
## Monorepo Structure
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
robot-resources/
|
|
65
|
+
├── router/ # LLM routing proxy (Python + TypeScript MCP)
|
|
66
|
+
├── scraper/ # Token compression (TypeScript)
|
|
67
|
+
├── web/ # Landing page + dashboard (React/Vite)
|
|
68
|
+
├── platform/ # Backend API — auth, telemetry (Hono/Cloudflare Workers)
|
|
69
|
+
├── packages/ # Unified CLI installer (npx robot-resources)
|
|
70
|
+
├── skills/ # Agent skills (ClawHub)
|
|
71
|
+
├── _orchestrator/ # Business coordination
|
|
72
|
+
└── _brand/ # Brand assets and design system
|
|
73
73
|
```
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
Each product has its own setup:
|
|
78
|
+
|
|
79
|
+
- [Router](./router/README.md)
|
|
80
|
+
- [Scraper](./scraper/packages/scraper/README.md)
|
|
81
|
+
- [Platform](./platform/README.md)
|
|
82
|
+
- [CLI](./packages/cli/README.md)
|
|
83
|
+
|
|
84
|
+
## CI/CD
|
|
85
|
+
|
|
86
|
+
Unified pipeline on push/PR to main:
|
|
87
|
+
|
|
88
|
+
- **Router**: ruff, mypy, pytest
|
|
89
|
+
- **Scraper**: tsc, vitest
|
|
90
|
+
- **Web**: tsc, eslint
|
|
91
|
+
- **Publish**: 7 npm packages via OIDC (`router`, `router-mcp`, `scraper`, `scraper-mcp`, `scraper-tracking`, `cli-core`, `cli`)
|
|
92
|
+
|
|
93
|
+
## Links
|
|
94
|
+
|
|
95
|
+
- Website: https://robotresources.ai
|
|
96
|
+
- GitHub: https://github.com/robot-resources
|
|
97
|
+
- npm: `npx robot-resources`
|
|
98
|
+
- Twitter/X: https://x.com/robotresources
|
|
99
|
+
- Discord: https://robotresources.ai/discord
|
|
100
|
+
- Contact: agent@robotresources.ai
|
|
101
|
+
- Agent docs: https://robotresources.ai/llms.txt
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readConfig } from '@robot-resources/cli-core/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 openclaw-plugin 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 hasPlugin = !!ocConfig?.plugins?.entries?.['openclaw-plugin']?.enabled;
|
|
117
|
+
return {
|
|
118
|
+
healthy: hasPlugin,
|
|
119
|
+
detail: hasPlugin ? 'plugin registered' : 'openclaw-plugin not registered',
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
return { healthy: false, detail: 'openclaw.json not found' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads or creates a persistent machine identifier.
|
|
8
|
+
* Used for telemetry deduplication across sessions.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} [configDir] - Directory to store .machine-id (defaults to ~/.robot-resources)
|
|
11
|
+
* @returns {string} A UUID v4 machine identifier
|
|
12
|
+
*/
|
|
13
|
+
export function getOrCreateMachineId(configDir) {
|
|
14
|
+
const dir = configDir ?? join(homedir(), '.robot-resources');
|
|
15
|
+
const machineIdPath = join(dir, '.machine-id');
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const stored = readFileSync(machineIdPath, 'utf-8').trim();
|
|
19
|
+
if (stored) return stored;
|
|
20
|
+
} catch {
|
|
21
|
+
// File doesn't exist or can't be read — fall through to generate
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const machineId = randomUUID();
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
writeFileSync(machineIdPath, machineId, 'utf-8');
|
|
28
|
+
} catch { /* non-fatal */ }
|
|
29
|
+
|
|
30
|
+
return machineId;
|
|
31
|
+
}
|
package/lib/tool-config.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { isOpenClawInstalled, isOpenClawPluginInstalled, getOpenClawAuthMode } from './detect.js';
|
|
7
7
|
import { stripJson5 } from './json5.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Heartbeat interval in ms. OC kills processes after 5s of no output
|
|
11
|
+
* (noOutputTimeoutMs = 5000). This must stay under that threshold.
|
|
12
|
+
*/
|
|
13
|
+
const HEARTBEAT_INTERVAL_MS = 4000;
|
|
14
|
+
|
|
9
15
|
/**
|
|
10
16
|
* Run a command with a heartbeat to keep agent sessions alive.
|
|
11
|
-
* OC kills processes after 5s of no output (noOutputTimeoutMs).
|
|
12
|
-
* Prints immediately, then every 4s (safely under the 5s threshold).
|
|
13
17
|
*/
|
|
14
18
|
function spawnWithHeartbeat(cmd, args, { label, timeout = 30_000 } = {}) {
|
|
15
19
|
return new Promise((resolve, reject) => {
|
|
@@ -21,9 +25,9 @@ function spawnWithHeartbeat(cmd, args, { label, timeout = 30_000 } = {}) {
|
|
|
21
25
|
process.stdout.write(` ${label}...\n`);
|
|
22
26
|
let seconds = 0;
|
|
23
27
|
const heartbeat = setInterval(() => {
|
|
24
|
-
seconds +=
|
|
28
|
+
seconds += HEARTBEAT_INTERVAL_MS / 1000;
|
|
25
29
|
process.stdout.write(` ${label}... ${seconds}s\n`);
|
|
26
|
-
},
|
|
30
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
27
31
|
|
|
28
32
|
proc.on('close', (code) => {
|
|
29
33
|
clearInterval(heartbeat);
|
|
@@ -39,39 +43,52 @@ function spawnWithHeartbeat(cmd, args, { label, timeout = 30_000 } = {}) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
|
-
*
|
|
46
|
+
* Read openclaw.json, creating it with a minimal structure if it doesn't exist.
|
|
47
|
+
* Returns parsed config object. Throws on malformed JSON (caller handles).
|
|
48
|
+
*/
|
|
49
|
+
function readOrCreateOpenClawConfig() {
|
|
50
|
+
const configDir = join(homedir(), '.openclaw');
|
|
51
|
+
const configPath = join(configDir, 'openclaw.json');
|
|
52
|
+
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
mkdirSync(configDir, { recursive: true });
|
|
55
|
+
const minimal = {};
|
|
56
|
+
writeFileSync(configPath, JSON.stringify(minimal, null, 2) + '\n', 'utf-8');
|
|
57
|
+
return minimal;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
61
|
+
return JSON.parse(stripJson5(raw));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trust the Robot Resources plugin in OpenClaw config.
|
|
43
66
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
67
|
+
* Adds "openclaw-plugin" to plugins.allow so OpenClaw loads it without
|
|
68
|
+
* provenance warnings. The plugin's before_model_resolve hook intercepts
|
|
69
|
+
* ALL LLM calls regardless of the default model — no need to change the
|
|
70
|
+
* default model (which causes LiveSessionModelSwitchError in OC).
|
|
47
71
|
*
|
|
48
72
|
* Returns true if the config was updated, false otherwise.
|
|
49
73
|
*/
|
|
50
|
-
function
|
|
74
|
+
function trustPlugin() {
|
|
51
75
|
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
52
76
|
|
|
53
77
|
try {
|
|
54
|
-
const
|
|
55
|
-
const config = JSON.parse(stripJson5(raw));
|
|
56
|
-
|
|
57
|
-
// Ensure agents.defaults.model exists
|
|
58
|
-
if (!config.agents) config.agents = {};
|
|
59
|
-
if (!config.agents.defaults) config.agents.defaults = {};
|
|
60
|
-
if (!config.agents.defaults.model) config.agents.defaults.model = {};
|
|
78
|
+
const config = readOrCreateOpenClawConfig();
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
if (!config.plugins) config.plugins = {};
|
|
81
|
+
if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
if (currentPrimary && currentPrimary.startsWith('robot-resources/')) {
|
|
83
|
+
if (config.plugins.allow.includes('openclaw-plugin')) {
|
|
66
84
|
return false;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
config.
|
|
87
|
+
config.plugins.allow.push('openclaw-plugin');
|
|
70
88
|
|
|
71
89
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
72
90
|
return true;
|
|
73
91
|
} catch {
|
|
74
|
-
// Config missing or malformed — non-fatal
|
|
75
92
|
return false;
|
|
76
93
|
}
|
|
77
94
|
}
|
|
@@ -89,8 +106,7 @@ function registerScraperMcp() {
|
|
|
89
106
|
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
90
107
|
|
|
91
108
|
try {
|
|
92
|
-
const
|
|
93
|
-
const config = JSON.parse(stripJson5(raw));
|
|
109
|
+
const config = readOrCreateOpenClawConfig();
|
|
94
110
|
|
|
95
111
|
if (!config.mcp) config.mcp = {};
|
|
96
112
|
if (!config.mcp.servers) config.mcp.servers = {};
|
|
@@ -141,8 +157,7 @@ function registerPluginEntry() {
|
|
|
141
157
|
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
142
158
|
|
|
143
159
|
try {
|
|
144
|
-
const
|
|
145
|
-
const config = JSON.parse(stripJson5(raw));
|
|
160
|
+
const config = readOrCreateOpenClawConfig();
|
|
146
161
|
|
|
147
162
|
if (!config.plugins) config.plugins = {};
|
|
148
163
|
if (!config.plugins.entries) config.plugins.entries = {};
|
|
@@ -188,9 +203,8 @@ function configureOpenClaw() {
|
|
|
188
203
|
// Register plugin in openclaw.json so OC loads it on gateway start.
|
|
189
204
|
registerPluginEntry();
|
|
190
205
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
const configActivated = activateRouterModel();
|
|
206
|
+
// Trust the plugin so OC loads it without provenance warnings.
|
|
207
|
+
const configActivated = trustPlugin();
|
|
194
208
|
|
|
195
209
|
return {
|
|
196
210
|
name: 'OpenClaw',
|
|
@@ -245,13 +259,30 @@ export function configureToolRouting() {
|
|
|
245
259
|
|
|
246
260
|
/**
|
|
247
261
|
* Restart the OpenClaw gateway so it picks up new plugin + config.
|
|
248
|
-
*
|
|
262
|
+
* Retries up to 3 times with 2s backoff — gateway restart MUST succeed
|
|
263
|
+
* for MCP tools to be available. Silent failure = tools broken for user.
|
|
249
264
|
*/
|
|
250
265
|
async function restartOpenClawGateway() {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
const MAX_RETRIES = 3;
|
|
267
|
+
const BACKOFF_MS = 2000;
|
|
268
|
+
let lastError;
|
|
269
|
+
|
|
270
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
271
|
+
try {
|
|
272
|
+
await spawnWithHeartbeat('openclaw', ['gateway', 'restart'], {
|
|
273
|
+
label: `Restarting gateway${attempt > 1 ? ` (attempt ${attempt}/${MAX_RETRIES})` : ''}`,
|
|
274
|
+
timeout: 15_000,
|
|
275
|
+
});
|
|
276
|
+
return; // success
|
|
277
|
+
} catch (err) {
|
|
278
|
+
lastError = err;
|
|
279
|
+
if (attempt < MAX_RETRIES) {
|
|
280
|
+
await new Promise(r => setTimeout(r, BACKOFF_MS));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw lastError;
|
|
255
286
|
}
|
|
256
287
|
|
|
257
288
|
// Exported for testing and direct use
|
package/lib/wizard.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir, hostname } from 'node:os';
|
|
1
4
|
import { readConfig, writeConfig } from '@robot-resources/cli-core/config.mjs';
|
|
2
5
|
import { findPython, isPortAvailable, isHeadless, isOpenClawInstalled } from './detect.js';
|
|
6
|
+
import { getOrCreateMachineId } from './machine-id.js';
|
|
3
7
|
import { setupRouter, isRouterInstalled, getVenvPythonPath } from './python-bridge.js';
|
|
4
8
|
import { installService, isServiceRunning, isServiceInstalled } from './service.js';
|
|
5
9
|
import { configureToolRouting, registerScraperMcp, restartOpenClawGateway } from './tool-config.js';
|
|
10
|
+
import { checkHealth } from './health-report.js';
|
|
6
11
|
import { header, step, success, warn, error, info, blank, summary } from './ui.js';
|
|
7
12
|
/**
|
|
8
13
|
* Main setup wizard. Handles the full onboarding flow:
|
|
@@ -36,24 +41,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
36
41
|
const config = readConfig();
|
|
37
42
|
if (!config.api_key && !process.env.RR_API_KEY) {
|
|
38
43
|
try {
|
|
39
|
-
const
|
|
40
|
-
const { join } = await import('node:path');
|
|
41
|
-
const { homedir } = await import('node:os');
|
|
42
|
-
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
43
|
-
const { randomUUID } = await import('node:crypto');
|
|
44
|
-
|
|
45
|
-
const rrDir = join(homedir(), '.robot-resources');
|
|
46
|
-
const machineIdPath = join(rrDir, '.machine-id');
|
|
47
|
-
let machineId;
|
|
48
|
-
try {
|
|
49
|
-
machineId = readFileSync(machineIdPath, 'utf-8').trim();
|
|
50
|
-
} catch {
|
|
51
|
-
machineId = randomUUID();
|
|
52
|
-
try {
|
|
53
|
-
mkdirSync(rrDir, { recursive: true });
|
|
54
|
-
writeFileSync(machineIdPath, machineId, 'utf-8');
|
|
55
|
-
} catch { /* non-fatal */ }
|
|
56
|
-
}
|
|
44
|
+
const machineId = getOrCreateMachineId();
|
|
57
45
|
|
|
58
46
|
const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
|
|
59
47
|
const res = await fetch(`${platformUrl}/v1/auth/signup`, {
|
|
@@ -184,7 +172,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
184
172
|
success(`${r.name}: already configured`);
|
|
185
173
|
} else if (r.action === 'installed') {
|
|
186
174
|
success(`${r.name}: plugin installed`);
|
|
187
|
-
if (r.configActivated) success(`${r.name}:
|
|
175
|
+
if (r.configActivated) success(`${r.name}: plugin trusted in openclaw.json`);
|
|
188
176
|
if (r.note) info(` ${r.note}`);
|
|
189
177
|
} else if (r.action === 'instructions') {
|
|
190
178
|
warn(`${r.name}: manual configuration needed:`);
|
|
@@ -218,10 +206,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
218
206
|
} else {
|
|
219
207
|
// Either already registered, or no openclaw.json
|
|
220
208
|
try {
|
|
221
|
-
const
|
|
222
|
-
const { join: joinPath } = await import('node:path');
|
|
223
|
-
const { homedir: home } = await import('node:os');
|
|
224
|
-
const ocConfig = JSON.parse(readFs(joinPath(home(), '.openclaw', 'openclaw.json'), 'utf-8'));
|
|
209
|
+
const ocConfig = JSON.parse(readFileSync(join(homedir(), '.openclaw', 'openclaw.json'), 'utf-8'));
|
|
225
210
|
if (ocConfig?.mcp?.servers?.['robot-resources-scraper']) {
|
|
226
211
|
success('Scraper MCP already registered in OpenClaw');
|
|
227
212
|
results.scraper = true;
|
|
@@ -354,17 +339,52 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
354
339
|
blank();
|
|
355
340
|
}
|
|
356
341
|
|
|
342
|
+
// ── Best-effort: Single gateway restart ────────────────────────────────
|
|
343
|
+
//
|
|
344
|
+
// Merged from two previous restarts (after plugin install + after scraper
|
|
345
|
+
// registration). Everything is already installed and registered above.
|
|
346
|
+
// If the session dies during this restart, the gateway picks up changes
|
|
347
|
+
// on its next natural restart.
|
|
348
|
+
|
|
349
|
+
if (isOpenClawInstalled() && (results.tools?.some(r => r.action === 'installed') || scraperRegistered)) {
|
|
350
|
+
try {
|
|
351
|
+
await restartOpenClawGateway();
|
|
352
|
+
success('OpenClaw gateway restarted');
|
|
353
|
+
} catch {
|
|
354
|
+
warn('Gateway restart failed — tools may not be available until OpenClaw restarts.');
|
|
355
|
+
warn('Run manually: openclaw gateway restart');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Post-install health check ──────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
let healthReport = null;
|
|
362
|
+
if (somethingInstalled) {
|
|
363
|
+
try {
|
|
364
|
+
blank();
|
|
365
|
+
step('Running post-install health check...');
|
|
366
|
+
healthReport = await checkHealth();
|
|
367
|
+
|
|
368
|
+
if (healthReport.status === 'healthy') {
|
|
369
|
+
success(`Health: all components healthy`);
|
|
370
|
+
} else if (healthReport.status === 'partial') {
|
|
371
|
+
warn(`Health: ${healthReport.summary}`);
|
|
372
|
+
} else {
|
|
373
|
+
error(`Health: ${healthReport.summary}`);
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
// Non-fatal — health check is informational
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
357
380
|
// ── Status file (persists results for agents that lose the session) ─────
|
|
358
381
|
|
|
359
382
|
if (somethingInstalled) {
|
|
360
383
|
try {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
mkFs(statusDir, { recursive: true });
|
|
366
|
-
const pkgVersion = JSON.parse(readFs(new URL('../package.json', import.meta.url), 'utf-8')).version;
|
|
367
|
-
writeFs(joinP(statusDir, 'wizard-status.json'), JSON.stringify({
|
|
384
|
+
const statusDir = join(homedir(), '.robot-resources');
|
|
385
|
+
mkdirSync(statusDir, { recursive: true });
|
|
386
|
+
const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
|
|
387
|
+
writeFileSync(join(statusDir, 'wizard-status.json'), JSON.stringify({
|
|
368
388
|
completed_at: new Date().toISOString(),
|
|
369
389
|
version: pkgVersion,
|
|
370
390
|
router: results.router || false,
|
|
@@ -372,25 +392,10 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
372
392
|
scraper: results.scraper || false,
|
|
373
393
|
plugin: results.tools?.some(r => r.action === 'installed') || false,
|
|
374
394
|
claim_url: results.claimUrl || readConfig().claim_url || null,
|
|
375
|
-
|
|
395
|
+
health_report: healthReport || null,
|
|
396
|
+
}, null, 2) + '\n', { mode: 0o600, encoding: 'utf-8' });
|
|
376
397
|
} catch {
|
|
377
398
|
// Non-fatal — status file is a convenience, not required
|
|
378
399
|
}
|
|
379
400
|
}
|
|
380
|
-
|
|
381
|
-
// ── Best-effort: Single gateway restart ────────────────────────────────
|
|
382
|
-
//
|
|
383
|
-
// Merged from two previous restarts (after plugin install + after scraper
|
|
384
|
-
// registration). Everything is already installed and registered above.
|
|
385
|
-
// If the session dies during this restart, the gateway picks up changes
|
|
386
|
-
// on its next natural restart.
|
|
387
|
-
|
|
388
|
-
if (isOpenClawInstalled() && (results.tools?.some(r => r.action === 'installed') || scraperRegistered)) {
|
|
389
|
-
try {
|
|
390
|
-
await restartOpenClawGateway();
|
|
391
|
-
success('OpenClaw gateway restarted');
|
|
392
|
-
} catch {
|
|
393
|
-
// Best-effort — gateway picks up changes on next restart
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
401
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robot-resources",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"description": "Robot Resources — AI agent
|
|
3
|
+
"version": "1.7.2",
|
|
4
|
+
"description": "Robot Resources — AI agent tools. One command to install everything.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"robot-resources-setup": "./bin/setup.js"
|