serpentstack 0.2.5 → 0.2.9

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.
@@ -1,13 +1,22 @@
1
1
  import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
  import { downloadFile } from '../utils/github.js';
3
4
  import { safeWrite } from '../utils/fs-helpers.js';
4
- import { info, success, warn, error, spinner, bold, dim, green, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
5
+ import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
6
+ import { parseAgentMd, discoverAgents } from '../utils/agent-utils.js';
7
+ import { info, success, warn, error, spinner, bold, dim, green, cyan, divider, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
5
8
 
6
9
  const SKILLS_FILES = [
10
+ '.skills/auth/SKILL.md',
11
+ '.skills/db-migrate/SKILL.md',
12
+ '.skills/deploy/SKILL.md',
13
+ '.skills/dev-server/SKILL.md',
7
14
  '.skills/find-skills/SKILL.md',
8
15
  '.skills/generate-skills/SKILL.md',
9
16
  '.skills/git-workflow/SKILL.md',
10
17
  '.skills/model-routing/SKILL.md',
18
+ '.skills/scaffold/SKILL.md',
19
+ '.skills/test/SKILL.md',
11
20
  ];
12
21
 
13
22
  const OPENCLAW_FILES = [
@@ -26,13 +35,9 @@ const MANIFEST = [...SKILLS_FILES, ...OPENCLAW_FILES, ...DOCS_FILES];
26
35
  export async function skillsInit({ force = false } = {}) {
27
36
  printHeader();
28
37
 
29
- // Step 1: Download files
30
- console.log(` ${bold('Step 1/2')} ${dim('\u2014 Downloading skills + persistent agent configs')}`);
31
- console.log();
32
-
33
- const results = { created: 0, skipped: 0, overwritten: 0, failed: 0 };
38
+ const results = { created: 0, skipped: 0, overwritten: 0, unchanged: 0, failed: 0 };
34
39
  const logs = [];
35
- const spin = spinner('Fetching from GitHub...');
40
+ const spin = spinner('Slithering through GitHub...');
36
41
 
37
42
  try {
38
43
  for (const repoPath of MANIFEST) {
@@ -52,23 +57,26 @@ export async function skillsInit({ force = false } = {}) {
52
57
  }
53
58
 
54
59
  // Group output by section
55
- console.log(` ${dim('IDE Agent Skills')}`);
60
+ divider('Skills');
56
61
  for (let i = 0; i < SKILLS_FILES.length; i++) logs[i] && console.log(logs[i]);
57
62
  console.log();
58
- console.log(` ${dim('Persistent Agent (OpenClaw)')}`);
63
+
64
+ divider('Persistent Agents');
59
65
  for (let i = SKILLS_FILES.length; i < SKILLS_FILES.length + OPENCLAW_FILES.length; i++) logs[i] && console.log(logs[i]);
60
66
  console.log();
61
- console.log(` ${dim('Documentation')}`);
67
+
68
+ divider('Docs');
62
69
  for (let i = SKILLS_FILES.length + OPENCLAW_FILES.length; i < logs.length; i++) logs[i] && console.log(logs[i]);
63
70
  console.log();
64
71
 
65
72
  // Summary line
66
73
  const parts = [];
67
74
  if (results.created > 0) parts.push(green(`${results.created} created`));
68
- if (results.overwritten > 0) parts.push(`${results.overwritten} updated`);
75
+ if (results.overwritten > 0) parts.push(cyan(`${results.overwritten} updated`));
76
+ if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
69
77
  if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
70
78
  if (results.failed > 0) parts.push(`${results.failed} failed`);
71
- console.log(` ${parts.join(dim(' \u2022 '))}`);
79
+ console.log(` ${parts.join(dim(' · '))}`);
72
80
 
73
81
  if (results.failed === MANIFEST.length) {
74
82
  console.log();
@@ -76,14 +84,43 @@ export async function skillsInit({ force = false } = {}) {
76
84
  return;
77
85
  }
78
86
 
79
- // Step 2: What to do next
87
+ // Generate default config.json if it doesn't exist
88
+ const projectDir = process.cwd();
89
+ if (!readConfig(projectDir)) {
90
+ const templateDefaults = detectTemplateDefaults(projectDir) || {};
91
+ const config = {
92
+ project: {
93
+ name: templateDefaults.name || '',
94
+ language: templateDefaults.language || '',
95
+ framework: templateDefaults.framework || '',
96
+ devCmd: templateDefaults.devCmd || '',
97
+ testCmd: templateDefaults.testCmd || '',
98
+ conventions: templateDefaults.conventions || '',
99
+ },
100
+ agents: {},
101
+ };
102
+
103
+ // Populate agent defaults from downloaded AGENT.md files
104
+ const agents = discoverAgents(projectDir);
105
+ for (const agent of agents) {
106
+ try {
107
+ const agentMd = parseAgentMd(agent.agentMdPath);
108
+ config.agents[agent.name] = defaultAgentConfig(agentMd.meta);
109
+ } catch { /* skip invalid agents */ }
110
+ }
111
+
112
+ writeConfig(projectDir, config);
113
+ console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
114
+ results.created++;
115
+ }
116
+
117
+ // Next steps
80
118
  console.log();
81
- console.log(` ${bold('Step 2/2')} ${dim('\u2014 Generate project-specific skills')}`);
119
+ divider('What\'s next');
82
120
  console.log();
83
- console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
84
- console.log(` ${dim('and paste the prompt below. The agent will read your codebase,')}`);
85
- console.log(` ${dim('interview you about your conventions, and produce a full')}`);
86
- console.log(` ${dim('.skills/ directory tailored to your project.')}`);
121
+ console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, Gemini CLI, etc.)')}`);
122
+ console.log(` ${dim('and paste this prompt. It reads your codebase and generates')}`);
123
+ console.log(` ${dim('project-specific skills tailored to your stack.')}`);
87
124
 
88
125
  printPrompt([
89
126
  `Read .skills/generate-skills/SKILL.md and follow its instructions`,
@@ -98,11 +135,12 @@ export async function skillsInit({ force = false } = {}) {
98
135
  `Reference SKILL-AUTHORING.md for the format.`,
99
136
  ]);
100
137
 
101
- printBox('After generating skills, try setting up persistent agents too', [
102
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# choose agents + launch')}`,
138
+ printBox('Want persistent background agents too?', [
139
+ `${dim('$')} ${bold('serpentstack persistent')}`,
103
140
  '',
104
- `${dim('Background agents that watch your dev server, run tests,')}`,
105
- `${dim('and keep your skills up to date. Each opens in its own')}`,
106
- `${dim('terminal window. Pick which to run and choose your models.')}`,
141
+ `${dim('Agents that watch your dev server, run tests, and keep')}`,
142
+ `${dim('skills fresh each in its own terminal. Pick which to')}`,
143
+ `${dim('run and choose local or cloud models.')}`,
107
144
  ]);
145
+ console.log();
108
146
  }
@@ -2,13 +2,19 @@ import { join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { downloadFile } from '../utils/github.js';
4
4
  import { safeWrite } from '../utils/fs-helpers.js';
5
- import { info, success, warn, error, spinner, bold, dim, green, printHeader, fileStatus } from '../utils/ui.js';
5
+ import { info, success, warn, error, spinner, bold, dim, green, cyan, printHeader, fileStatus } from '../utils/ui.js';
6
6
 
7
7
  const MANIFEST = [
8
+ '.skills/auth/SKILL.md',
9
+ '.skills/db-migrate/SKILL.md',
10
+ '.skills/deploy/SKILL.md',
11
+ '.skills/dev-server/SKILL.md',
8
12
  '.skills/find-skills/SKILL.md',
9
13
  '.skills/generate-skills/SKILL.md',
10
14
  '.skills/git-workflow/SKILL.md',
11
15
  '.skills/model-routing/SKILL.md',
16
+ '.skills/scaffold/SKILL.md',
17
+ '.skills/test/SKILL.md',
12
18
  '.openclaw/SOUL.md',
13
19
  '.openclaw/agents/log-watcher/AGENT.md',
14
20
  '.openclaw/agents/test-runner/AGENT.md',
@@ -27,11 +33,8 @@ const CUSTOMIZABLE = new Set([
27
33
  export async function skillsUpdate({ force = false, all = false } = {}) {
28
34
  printHeader();
29
35
 
30
- info(`Checking for updates in ${bold(process.cwd())}`);
31
- console.log();
32
-
33
36
  const results = { updated: 0, skipped: 0, unchanged: 0, failed: 0 };
34
- const spin = spinner('Comparing with latest versions...');
37
+ const spin = spinner('Checking for updates...');
35
38
  const logs = [];
36
39
 
37
40
  try {
@@ -83,12 +86,11 @@ export async function skillsUpdate({ force = false, all = false } = {}) {
83
86
  if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
84
87
  if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
85
88
  if (results.failed > 0) parts.push(`${results.failed} failed`);
86
- console.log(` ${parts.join(dim(' \u2022 '))}`);
89
+ console.log(` ${parts.join(dim(' · '))}`);
87
90
 
88
91
  if (results.updated > 0) {
89
92
  console.log();
90
- info(`${dim('Your project-specific skills (generated by your IDE agent) are not affected.')}`);
91
- info(`${dim('Only the base SerpentStack files were updated.')}`);
93
+ info(`${dim('Only base SerpentStack files were updated — your custom skills are untouched.')}`);
92
94
  }
93
95
  console.log();
94
96
  }
@@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { cloneRepo, checkGit } from '../utils/github.js';
6
- import { info, success, warn, error, spinner, bold, dim, green, cyan, printBox, printPrompt, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, spinner, bold, dim, green, yellow, cyan, divider, printBox, printPrompt, printHeader } from '../utils/ui.js';
7
7
 
8
8
  const CLEANUP_PATHS = [
9
9
  'cli',
@@ -26,6 +26,22 @@ function checkCommand(cmd) {
26
26
  });
27
27
  }
28
28
 
29
+ function getCommandVersion(cmd, args = ['--version']) {
30
+ return new Promise((resolve) => {
31
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
32
+ if (err) resolve(null);
33
+ else resolve(stdout.trim());
34
+ });
35
+ });
36
+ }
37
+
38
+ function parseVersion(versionStr) {
39
+ if (!versionStr) return null;
40
+ const match = versionStr.match(/(\d+)\.(\d+)/);
41
+ if (!match) return null;
42
+ return { major: parseInt(match[1]), minor: parseInt(match[2]) };
43
+ }
44
+
29
45
  export async function stackNew(name) {
30
46
  const nameError = validateName(name);
31
47
  if (nameError) {
@@ -44,25 +60,43 @@ export async function stackNew(name) {
44
60
  printHeader();
45
61
 
46
62
  // Step 1: Check prerequisites
47
- console.log(` ${bold('Step 1/4')} ${dim('\u2014 Checking prerequisites')}`);
63
+ divider('Prerequisites');
48
64
  console.log();
49
65
 
50
66
  const checks = [
51
67
  { cmd: 'git', label: 'git', url: 'https://git-scm.com/downloads' },
52
- { cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads' },
53
- { cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org' },
68
+ { cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads', minMajor: 3, minMinor: 12 },
69
+ { cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org', minMajor: 22, minMinor: 0 },
54
70
  { cmd: 'docker', label: 'Docker', url: 'https://docker.com/get-started' },
55
71
  { cmd: 'uv', label: 'uv (Python package manager)', url: 'https://docs.astral.sh/uv' },
56
72
  ];
57
73
 
58
- let missing = [];
59
- for (const { cmd, label, url } of checks) {
74
+ // Run all checks in parallel
75
+ const results = await Promise.all(checks.map(async ({ cmd, label, url, minMajor, minMinor }) => {
60
76
  const found = await checkCommand(cmd);
61
- if (found) {
62
- console.log(` ${green('\u2713')} ${label}`);
77
+ if (!found) return { label, url, found: false };
78
+
79
+ // Version check if required
80
+ if (minMajor != null) {
81
+ const vStr = await getCommandVersion(cmd);
82
+ const ver = parseVersion(vStr);
83
+ if (ver && (ver.major < minMajor || (ver.major === minMajor && ver.minor < minMinor))) {
84
+ return { label, url, found: true, versionOk: false, version: vStr };
85
+ }
86
+ }
87
+ return { label, found: true, versionOk: true };
88
+ }));
89
+
90
+ let missing = [];
91
+ for (const r of results) {
92
+ if (!r.found) {
93
+ console.log(` ${dim('○')} ${dim(r.label)} ${dim(`— install: ${r.url}`)}`);
94
+ missing.push(r.label);
95
+ } else if (r.versionOk === false) {
96
+ console.log(` ${yellow('△')} ${r.label} ${dim(`— found ${r.version}, need newer version`)}`);
97
+ missing.push(r.label);
63
98
  } else {
64
- console.log(` ${dim('\u2022')} ${dim(label)} ${dim(`\u2014 install: ${url}`)}`);
65
- missing.push(label);
99
+ console.log(` ${green('')} ${r.label}`);
66
100
  }
67
101
  }
68
102
  console.log();
@@ -79,13 +113,14 @@ export async function stackNew(name) {
79
113
  }
80
114
 
81
115
  // Step 2: Clone template
82
- console.log(` ${bold('Step 2/4')} ${dim('\u2014 Cloning template')}`);
116
+ divider('Clone');
83
117
  console.log();
84
118
 
85
- const spin = spinner(`Downloading SerpentStack template...`);
119
+ const spin = spinner('Slithering through GitHub...');
86
120
  try {
87
121
  await cloneRepo(dest);
88
- spin.stop(success(`Template cloned into ${green(name)}/`));
122
+ spin.stop();
123
+ success(`Template cloned into ${green(name)}/`);
89
124
  } catch (err) {
90
125
  spin.stop();
91
126
  error(err.message);
@@ -94,7 +129,7 @@ export async function stackNew(name) {
94
129
 
95
130
  // Step 3: Clean up repo-specific files
96
131
  console.log();
97
- console.log(` ${bold('Step 3/4')} ${dim('\u2014 Preparing project')}`);
132
+ divider('Prepare');
98
133
  console.log();
99
134
 
100
135
  for (const p of CLEANUP_PATHS) {
@@ -115,16 +150,16 @@ export async function stackNew(name) {
115
150
 
116
151
  // Step 4: Summary
117
152
  console.log();
118
- console.log(` ${bold('Step 4/4')} ${dim('\u2014 Done!')}`);
153
+ divider('Ready 🐍');
119
154
  console.log();
120
155
 
121
156
  console.log(` ${dim('Your project includes:')}`);
122
- console.log(` ${green('\u2713')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership enforcement)')}`);
123
- console.log(` ${green('\u2713')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
124
- console.log(` ${green('\u2713')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
125
- console.log(` ${green('\u2713')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
126
- console.log(` ${green('\u2713')} 10 project-specific Agent Skills ${dim('(.skills/)')}`);
127
- console.log(` ${green('\u2713')} Persistent agent configs ${dim('(.openclaw/)')}`);
157
+ console.log(` ${green('')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership)')}`);
158
+ console.log(` ${green('')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
159
+ console.log(` ${green('')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
160
+ console.log(` ${green('')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
161
+ console.log(` ${green('')} 10 agent skills ${dim('(.skills/)')}`);
162
+ console.log(` ${green('')} Persistent agent configs ${dim('(.openclaw/)')}`);
128
163
  console.log();
129
164
 
130
165
  printBox('Get started', [
@@ -164,7 +164,7 @@ function generateHeartbeat(schedule) {
164
164
  }
165
165
 
166
166
  function generateAgentsConfig(meta) {
167
- const model = meta.model || 'anthropic/claude-haiku-4-20250414';
167
+ const model = meta.model || 'ollama/llama3.2';
168
168
  const tools = meta.tools || ['file-system', 'shell', 'git'];
169
169
 
170
170
  return [
@@ -3,12 +3,16 @@ import { join } from 'node:path';
3
3
 
4
4
  const CONFIG_PATH = '.openclaw/config.json';
5
5
 
6
+ // Default model for persistent agents — local first, always.
7
+ // Cloud models cost tokens per heartbeat and require API keys.
8
+ const DEFAULT_MODEL = 'ollama/llama3.2';
9
+
6
10
  /**
7
11
  * Default config structure:
8
12
  * {
9
13
  * project: { name, language, framework, devCmd, testCmd, conventions },
10
14
  * agents: {
11
- * "log-watcher": { enabled: true, model: "anthropic/claude-haiku-4-20250414" },
15
+ * "log-watcher": { enabled: true, model: "ollama/llama3.2" },
12
16
  * ...
13
17
  * }
14
18
  * }
@@ -22,7 +26,10 @@ export function readConfig(projectDir) {
22
26
  if (!existsSync(configPath)) return null;
23
27
  try {
24
28
  return JSON.parse(readFileSync(configPath, 'utf8'));
25
- } catch {
29
+ } catch (err) {
30
+ if (err instanceof SyntaxError) {
31
+ console.error(` \x1b[33m\u25B3\x1b[0m .openclaw/config.json has invalid JSON — ignoring. Fix it or delete it to regenerate.`);
32
+ }
26
33
  return null;
27
34
  }
28
35
  }
@@ -46,9 +53,9 @@ export function detectTemplateDefaults(projectDir) {
46
53
 
47
54
  try {
48
55
  const content = readFileSync(makefile, 'utf8');
49
- // SerpentStack Makefile has these distinctive targets
50
- if (!content.includes('make verify') && !content.includes('.PHONY:') ) return null;
51
- if (!content.includes('uv run') && !content.includes('uvicorn')) return null;
56
+ // SerpentStack Makefile: must have a 'verify:' target AND 'uv run' (Python tooling)
57
+ if (!/^verify:/m.test(content)) return null;
58
+ if (!content.includes('uv run')) return null;
52
59
 
53
60
  // It's a SerpentStack template project — return smart defaults
54
61
  const defaults = {
@@ -80,7 +87,7 @@ export function detectTemplateDefaults(projectDir) {
80
87
  export function defaultAgentConfig(meta) {
81
88
  return {
82
89
  enabled: true,
83
- model: meta.model || 'anthropic/claude-haiku-4-20250414',
90
+ model: DEFAULT_MODEL,
84
91
  };
85
92
  }
86
93
 
@@ -91,7 +98,7 @@ export function getEffectiveModel(agentName, agentMeta, config) {
91
98
  if (config?.agents?.[agentName]?.model) {
92
99
  return config.agents[agentName].model;
93
100
  }
94
- return agentMeta.model || 'anthropic/claude-haiku-4-20250414';
101
+ return DEFAULT_MODEL;
95
102
  }
96
103
 
97
104
  /**
@@ -19,7 +19,7 @@ export function safeWrite(destPath, content, { force = false } = {}) {
19
19
  // Don't overwrite if content is identical
20
20
  try {
21
21
  const existing = readFileSync(destPath, 'utf8');
22
- if (existing === content) return 'skipped';
22
+ if (existing === content) return 'unchanged';
23
23
  } catch { /* proceed with overwrite */ }
24
24
 
25
25
  writeFileSync(destPath, content, 'utf8');
@@ -0,0 +1,181 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ /**
4
+ * Detect all available models: local (Ollama) and cloud (via OpenClaw auth).
5
+ * Local models are preferred for persistent agents — they're free and fast.
6
+ * Cloud models require API keys and cost money per token.
7
+ *
8
+ * Returns { local: [...], cloud: [...], hasApiKey: bool }
9
+ */
10
+ export async function detectModels() {
11
+ const [ollamaModels, openclawInfo] = await Promise.all([
12
+ detectOllamaModels(),
13
+ detectOpenClawAuth(),
14
+ ]);
15
+
16
+ return {
17
+ local: ollamaModels,
18
+ cloud: openclawInfo.models,
19
+ hasApiKey: openclawInfo.hasApiKey,
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Detect locally installed Ollama models via the REST API.
25
+ * GET http://localhost:11434/api/tags returns structured JSON with real
26
+ * parameter counts, sizes, and quantization levels — no CLI parsing needed.
27
+ */
28
+ async function detectOllamaModels() {
29
+ try {
30
+ const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
31
+ if (!response.ok) return [];
32
+
33
+ const data = await response.json();
34
+ if (!data.models || !Array.isArray(data.models)) return [];
35
+
36
+ return data.models.map(m => {
37
+ const name = (m.name || '').replace(':latest', '');
38
+ if (!name) return null;
39
+
40
+ // Use the real parameter count from the API when available
41
+ const details = m.details || {};
42
+ const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
43
+ const quant = details.quantization_level || '';
44
+ const sizeStr = formatBytes(m.size);
45
+
46
+ return {
47
+ id: `ollama/${name}`,
48
+ name,
49
+ params,
50
+ quant,
51
+ size: sizeStr,
52
+ tier: 'local',
53
+ };
54
+ }).filter(Boolean);
55
+ } catch {
56
+ // Ollama not running or not installed
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
63
+ * The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
64
+ */
65
+ function formatParamCount(paramSize) {
66
+ if (!paramSize) return '';
67
+ // Already formatted like "7B" or "3.2B"
68
+ const s = String(paramSize).trim();
69
+ if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
70
+ return s;
71
+ }
72
+
73
+ /**
74
+ * Fallback: estimate parameter count from file size.
75
+ * Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
76
+ */
77
+ function guessParamsFromSize(bytes) {
78
+ if (!bytes || bytes <= 0) return '';
79
+ const gb = bytes / (1024 ** 3);
80
+ const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
81
+ if (billions > 0) return `~${billions}B`;
82
+ return '';
83
+ }
84
+
85
+ /**
86
+ * Format bytes into human-readable size (e.g., "4.7 GB").
87
+ */
88
+ function formatBytes(bytes) {
89
+ if (!bytes || bytes <= 0) return '';
90
+ const gb = bytes / (1024 ** 3);
91
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
92
+ const mb = bytes / (1024 ** 2);
93
+ return `${Math.round(mb)} MB`;
94
+ }
95
+
96
+ /**
97
+ * Fetch with timeout using AbortController (Node 18+).
98
+ */
99
+ function fetchWithTimeout(url, timeoutMs) {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
102
+ return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
103
+ }
104
+
105
+ /**
106
+ * Check OpenClaw for configured models and API key status.
107
+ */
108
+ async function detectOpenClawAuth() {
109
+ const result = { models: [], hasApiKey: false };
110
+
111
+ try {
112
+ // Check if any API key is configured via openclaw models status
113
+ const status = await execAsync('openclaw', ['models', 'status']);
114
+
115
+ // Look for "api_key" or "configured" in the output
116
+ if (status.includes('api_key') || status.includes('configured')) {
117
+ result.hasApiKey = true;
118
+ }
119
+
120
+ // Get the model catalog for cloud options
121
+ const list = await execAsync('openclaw', ['models', 'list', '--json']);
122
+ try {
123
+ const models = JSON.parse(list);
124
+ if (Array.isArray(models)) {
125
+ result.models = models
126
+ .filter(m => m.available && !m.local)
127
+ .map(m => ({
128
+ id: m.key || m.name,
129
+ name: modelShortName(m.key || m.name),
130
+ provider: (m.key || '').split('/')[0] || 'unknown',
131
+ tier: 'cloud',
132
+ }));
133
+ }
134
+ } catch {
135
+ // Fall back to text parsing
136
+ const text = await execAsync('openclaw', ['models', 'list']);
137
+ const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
138
+ result.models = lines.map(l => {
139
+ const id = l.trim().split(/\s+/)[0];
140
+ if (!id || id.length < 3) return null;
141
+ return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
142
+ }).filter(Boolean);
143
+ }
144
+ } catch {
145
+ // OpenClaw not installed or no models configured — use defaults
146
+ result.models = [
147
+ { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
148
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
149
+ ];
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Short display name for a model ID.
157
+ */
158
+ export function modelShortName(model) {
159
+ if (!model) return 'unknown';
160
+ // Anthropic models
161
+ if (model.startsWith('anthropic/')) {
162
+ if (model.includes('haiku')) return 'Haiku';
163
+ if (model.includes('sonnet')) return 'Sonnet';
164
+ if (model.includes('opus')) return 'Opus';
165
+ return model.slice('anthropic/'.length);
166
+ }
167
+ // Ollama models
168
+ if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
169
+ // Other: strip provider prefix
170
+ if (model.includes('/')) return model.split('/').pop();
171
+ return model;
172
+ }
173
+
174
+ function execAsync(cmd, args) {
175
+ return new Promise((resolve, reject) => {
176
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
177
+ if (err) reject(err);
178
+ else resolve(stdout);
179
+ });
180
+ });
181
+ }