serpentstack 0.2.5 → 0.2.6

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.
@@ -2,6 +2,9 @@
2
2
 
3
3
  import { error, bold, dim, green, cyan, getVersion, printHeader } from '../lib/utils/ui.js';
4
4
 
5
+ // Short flag aliases
6
+ const FLAG_ALIASES = { f: 'force', h: 'help', v: 'version', a: 'all' };
7
+
5
8
  function parseArgs(args) {
6
9
  const flags = {};
7
10
  const positional = [];
@@ -9,6 +12,13 @@ function parseArgs(args) {
9
12
  if (arg.startsWith('--')) {
10
13
  const [key, val] = arg.slice(2).split('=');
11
14
  flags[key] = val ?? true;
15
+ } else if (arg.startsWith('-') && arg.length > 1 && !arg.startsWith('--')) {
16
+ // Short flags: -f, -h, -v, -a, or combined like -fa
17
+ for (const ch of arg.slice(1)) {
18
+ const long = FLAG_ALIASES[ch];
19
+ if (long) flags[long] = true;
20
+ else flags[ch] = true;
21
+ }
12
22
  } else {
13
23
  positional.push(arg);
14
24
  }
@@ -16,6 +26,35 @@ function parseArgs(args) {
16
26
  return { flags, positional };
17
27
  }
18
28
 
29
+ // Known commands for fuzzy matching on typos
30
+ const KNOWN_COMMANDS = ['stack', 'skills', 'persistent'];
31
+
32
+ function suggestCommand(input) {
33
+ const lower = input.toLowerCase();
34
+ let best = null, bestDist = 3; // threshold: edit distance ≤ 2
35
+ for (const cmd of KNOWN_COMMANDS) {
36
+ if (cmd.startsWith(lower) || lower.startsWith(cmd)) return cmd;
37
+ const d = editDistance(lower, cmd);
38
+ if (d < bestDist) { bestDist = d; best = cmd; }
39
+ }
40
+ return best;
41
+ }
42
+
43
+ function editDistance(a, b) {
44
+ const m = a.length, n = b.length;
45
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1));
46
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
47
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
48
+ for (let i = 1; i <= m; i++)
49
+ for (let j = 1; j <= n; j++)
50
+ dp[i][j] = Math.min(
51
+ dp[i - 1][j] + 1,
52
+ dp[i][j - 1] + 1,
53
+ dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
54
+ );
55
+ return dp[m][n];
56
+ }
57
+
19
58
  function showHelp() {
20
59
  printHeader();
21
60
  console.log(` ${bold('Usage:')} serpentstack <command> [options]
@@ -32,10 +71,10 @@ function showHelp() {
32
71
  ${cyan('persistent')} --reconfigure Re-run setup (change models, enable/disable)
33
72
 
34
73
  ${bold('Options:')}
35
- --force Overwrite existing files
36
- --all Include new files in updates (skills update)
37
- --version Show version
38
- --help Show this help
74
+ -f, --force Overwrite existing files
75
+ -a, --all Include new files in updates (skills update)
76
+ -v, --version Show version
77
+ -h, --help Show this help
39
78
 
40
79
  ${dim('Examples:')}
41
80
  ${dim('$')} serpentstack stack new my-saas-app
@@ -95,7 +134,12 @@ async function main() {
95
134
  await persistent({ stop: !!flags.stop, reconfigure: !!flags.reconfigure });
96
135
  } else {
97
136
  error(`Unknown command: ${bold(noun)}`);
98
- console.log(`\n Run ${bold('serpentstack --help')} to see available commands.\n`);
137
+ const suggestion = suggestCommand(noun);
138
+ if (suggestion) {
139
+ console.log(`\n Did you mean ${bold(suggestion)}? Run ${bold(`serpentstack ${suggestion}`)} or ${bold('serpentstack --help')}.\n`);
140
+ } else {
141
+ console.log(`\n Run ${bold('serpentstack --help')} to see available commands.\n`);
142
+ }
99
143
  process.exit(1);
100
144
  }
101
145
  }
@@ -3,7 +3,7 @@ import { join, resolve } from 'node:path';
3
3
  import { execFile, spawn } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { stdin, stdout } from 'node:process';
6
- import { info, success, warn, error, confirm, bold, dim, green, cyan, yellow, red, printBox, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, bold, dim, green, cyan, yellow, red, printBox, printHeader } from '../utils/ui.js';
7
7
  import {
8
8
  parseAgentMd,
9
9
  discoverAgents,
@@ -22,6 +22,9 @@ import {
22
22
  getEffectiveModel,
23
23
  isAgentEnabled,
24
24
  } from '../utils/config.js';
25
+ import { detectModels, modelShortName } from '../utils/models.js';
26
+
27
+ // ─── Helpers ────────────────────────────────────────────────
25
28
 
26
29
  function which(cmd) {
27
30
  return new Promise((resolve) => {
@@ -29,74 +32,138 @@ function which(cmd) {
29
32
  });
30
33
  }
31
34
 
32
- function modelShortName(model) {
33
- if (model.includes('haiku')) return 'Haiku';
34
- if (model.includes('sonnet')) return 'Sonnet';
35
- if (model.includes('opus')) return 'Opus';
36
- if (model.includes('ollama') || model.includes(':')) return model.split('/').pop();
37
- return model;
35
+ async function ask(rl, label, defaultValue) {
36
+ const hint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
37
+ const answer = await rl.question(` ${green('?')} ${bold(label)}${hint}: `);
38
+ return answer.trim() || defaultValue || '';
38
39
  }
39
40
 
40
- async function askQuestion(rl, label, hint, defaultValue) {
41
- const defaultHint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
42
- const hintStr = hint ? ` ${dim(hint)}` : '';
43
- const answer = await rl.question(` ${green('?')} ${bold(label)}${hintStr}${defaultHint}: `);
44
- return answer.trim() || defaultValue || '';
41
+ async function askYesNo(rl, label, defaultYes = true) {
42
+ const hint = defaultYes ? dim('[Y/n]') : dim('[y/N]');
43
+ const answer = await rl.question(` ${green('?')} ${label} ${hint} `);
44
+ const val = answer.trim().toLowerCase();
45
+ if (defaultYes) return val !== 'n' && val !== 'no';
46
+ return val === 'y' || val === 'yes';
47
+ }
48
+
49
+ // ─── Model Picker ───────────────────────────────────────────
50
+
51
+ async function pickModel(rl, agentName, currentModel, available) {
52
+ const choices = [];
53
+
54
+ // Local models first (free, fast, recommended)
55
+ if (available.local.length > 0) {
56
+ console.log(` ${dim('\u2500\u2500 Local models (free, no API key needed) \u2500\u2500')}`);
57
+ for (const m of available.local) {
58
+ const isCurrent = m.id === currentModel;
59
+ const idx = choices.length;
60
+ choices.push(m);
61
+ const marker = isCurrent ? green('>') : ' ';
62
+ const num = dim(`${idx + 1}.`);
63
+ const label = isCurrent ? bold(m.name) : m.name;
64
+ const params = m.params ? dim(` ${m.params}`) : '';
65
+ const size = m.size ? dim(` (${m.size})`) : '';
66
+ const tag = isCurrent ? green(' \u2190 current') : '';
67
+ console.log(` ${marker} ${num} ${label}${params}${size}${tag}`);
68
+ }
69
+ }
70
+
71
+ // Cloud models (require API key, cost money)
72
+ if (available.cloud.length > 0) {
73
+ const apiNote = available.hasApiKey ? dim('API key configured') : yellow('requires API key');
74
+ console.log(` ${dim('\u2500\u2500 Cloud models')} (${apiNote}) ${dim('\u2500\u2500')}`);
75
+ for (const m of available.cloud) {
76
+ const isCurrent = m.id === currentModel;
77
+ const idx = choices.length;
78
+ choices.push(m);
79
+ const marker = isCurrent ? green('>') : ' ';
80
+ const num = dim(`${idx + 1}.`);
81
+ const label = isCurrent ? bold(m.name) : m.name;
82
+ const provider = m.provider ? dim(` (${m.provider})`) : '';
83
+ const tag = isCurrent ? green(' \u2190 current') : '';
84
+ console.log(` ${marker} ${num} ${label}${provider}${tag}`);
85
+ }
86
+ }
87
+
88
+ // If current model isn't in either list, add it
89
+ if (!choices.some(c => c.id === currentModel)) {
90
+ choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
91
+ // Re-render isn't needed since we'll just note it
92
+ console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
93
+ }
94
+
95
+ const currentIdx = choices.findIndex(c => c.id === currentModel);
96
+ const defaultNum = currentIdx >= 0 ? currentIdx + 1 : 1;
97
+
98
+ const answer = await rl.question(` ${dim(`Enter 1-${choices.length}`)} ${dim(`[${defaultNum}]`)}: `);
99
+ const idx = parseInt(answer.trim(), 10) - 1;
100
+
101
+ const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
102
+
103
+ // Warn about cloud model costs
104
+ if (selected.tier === 'cloud' && available.local.length > 0) {
105
+ warn(`Cloud models cost tokens per heartbeat cycle. Consider a local model for persistent agents.`);
106
+ }
107
+ if (selected.tier === 'cloud' && !available.hasApiKey) {
108
+ warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
109
+ }
110
+
111
+ return selected.id;
45
112
  }
46
113
 
47
114
  // ─── Terminal Spawning ──────────────────────────────────────
48
115
 
49
- /**
50
- * Open a new terminal window/tab running the given command.
51
- * Returns the method used ('terminal', 'iterm', 'fallback').
52
- */
53
116
  function openInTerminal(title, command, cwd) {
54
117
  const platform = process.platform;
55
118
  const termProgram = process.env.TERM_PROGRAM || '';
119
+ const safeCwd = cwd.replace(/'/g, "'\\''").replace(/"/g, '\\"');
120
+ const safeCmd = command.replace(/"/g, '\\"');
56
121
 
57
122
  if (platform === 'darwin') {
58
123
  if (termProgram === 'iTerm.app') {
59
- // iTerm2: open a new tab
60
- const script = `
61
- tell application "iTerm"
62
- tell current window
63
- create tab with default profile
64
- tell current session
65
- set name to "${title}"
66
- write text "cd ${escapeShell(cwd)} && ${command}"
67
- end tell
68
- end tell
69
- end tell
70
- `;
124
+ const script = `tell application "iTerm"
125
+ tell current window
126
+ create tab with default profile
127
+ tell current session
128
+ set name to "${title}"
129
+ write text "cd '${safeCwd}' && ${safeCmd}"
130
+ end tell
131
+ end tell
132
+ end tell`;
71
133
  spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
72
- return 'iterm';
73
- } else {
74
- // Terminal.app: open a new tab
75
- const script = `
76
- tell application "Terminal"
77
- activate
78
- do script "cd ${escapeShell(cwd)} && ${command}"
79
- end tell
80
- `;
81
- spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
82
- return 'terminal';
134
+ return 'iTerm';
83
135
  }
84
- } else if (platform === 'linux') {
85
- // Try common Linux terminal emulators
136
+ const script = `tell application "Terminal"
137
+ activate
138
+ do script "cd '${safeCwd}' && ${safeCmd}"
139
+ end tell`;
140
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
141
+ return 'Terminal';
142
+ }
143
+
144
+ if (platform === 'linux') {
145
+ const shellCmd = `cd '${safeCwd}' && ${command}; exec bash`;
86
146
  const terminals = [
87
- ['gnome-terminal', ['--title', title, '--', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
88
- ['xterm', ['-title', title, '-e', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
89
- ['konsole', ['--new-tab', '-e', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
147
+ ['gnome-terminal', ['--title', title, '--', 'bash', '-c', shellCmd]],
148
+ ['kitty', ['--title', title, 'bash', '-c', shellCmd]],
149
+ ['alacritty', ['--title', title, '-e', 'bash', '-c', shellCmd]],
150
+ ['wezterm', ['start', '--', 'bash', '-c', shellCmd]],
151
+ ['konsole', ['--new-tab', '-e', 'bash', '-c', shellCmd]],
152
+ ['xterm', ['-title', title, '-e', 'bash', '-c', shellCmd]],
90
153
  ];
91
-
92
154
  for (const [bin, args] of terminals) {
93
155
  try {
94
- spawn(bin, args, { stdio: 'ignore', detached: true }).unref();
95
- return bin;
156
+ const child = spawn(bin, args, { stdio: 'ignore', detached: true });
157
+ child.unref();
158
+ // Verify it didn't immediately fail
159
+ const alive = child.pid && !child.killed;
160
+ if (alive) return bin;
96
161
  } catch { continue; }
97
162
  }
98
- } else if (platform === 'win32') {
99
- spawn('cmd.exe', ['/c', 'start', 'cmd', '/k', `cd /d ${cwd} && ${command}`], {
163
+ }
164
+
165
+ if (platform === 'win32') {
166
+ spawn('cmd.exe', ['/c', 'start', `"${title}"`, 'cmd', '/k', `cd /d "${cwd}" && ${command}`], {
100
167
  stdio: 'ignore', detached: true,
101
168
  }).unref();
102
169
  return 'cmd';
@@ -105,10 +172,6 @@ function openInTerminal(title, command, cwd) {
105
172
  return null;
106
173
  }
107
174
 
108
- function escapeShell(str) {
109
- return str.replace(/'/g, "'\\''");
110
- }
111
-
112
175
  // ─── Stop Flow ──────────────────────────────────────────────
113
176
 
114
177
  function stopAllAgents(projectDir) {
@@ -118,156 +181,33 @@ function stopAllAgents(projectDir) {
118
181
  if (running.length === 0) {
119
182
  info('No agents are currently running.');
120
183
  console.log();
121
- return;
184
+ return 0;
122
185
  }
123
186
 
124
- console.log(` ${dim('Stopping')} ${bold(String(running.length))} ${dim('agent(s)...')}`);
125
- console.log();
126
-
127
187
  let stopped = 0;
128
188
  for (const { name, pid } of running) {
129
189
  try {
130
190
  process.kill(pid, 'SIGTERM');
131
191
  removePid(projectDir, name);
132
192
  cleanWorkspace(projectDir, name);
133
- success(`${bold(name)} stopped ${dim(`(PID ${pid})`)}`);
193
+ success(`Stopped ${bold(name)} ${dim(`(PID ${pid})`)}`);
134
194
  stopped++;
135
195
  } catch (err) {
136
196
  if (err.code === 'ESRCH') {
137
197
  removePid(projectDir, name);
138
- success(`${bold(name)} already stopped`);
139
- stopped++;
198
+ // Don't count already-dead processes as "stopped"
140
199
  } else {
141
200
  error(`Failed to stop ${bold(name)}: ${err.message}`);
142
201
  }
143
202
  }
144
203
  }
145
204
 
146
- console.log();
147
- success(`${green(String(stopped))} agent(s) stopped`);
148
- console.log();
149
- }
150
-
151
- // ─── Configure Project ──────────────────────────────────────
152
-
153
- async function configureProject(projectDir, existingConfig) {
154
- const rl = createInterface({ input: stdin, output: stdout });
155
- const templateDefaults = detectTemplateDefaults(projectDir);
156
- const existing = existingConfig?.project || {};
157
-
158
- const defaults = {
159
- name: existing.name || templateDefaults?.name || '',
160
- language: existing.language || templateDefaults?.language || '',
161
- framework: existing.framework || templateDefaults?.framework || '',
162
- devCmd: existing.devCmd || templateDefaults?.devCmd || '',
163
- testCmd: existing.testCmd || templateDefaults?.testCmd || '',
164
- conventions: existing.conventions || templateDefaults?.conventions || '',
165
- };
166
-
167
- if (templateDefaults && !existing.name) {
168
- console.log();
169
- info('Detected SerpentStack template — defaults pre-filled');
170
- }
171
-
172
- console.log();
173
- console.log(` ${bold('Configure your project')}`);
174
- console.log(` ${dim('Press Enter to accept defaults shown in [brackets].')}`);
175
- console.log();
176
-
177
- try {
178
- const project = {
179
- name: await askQuestion(rl, 'Project name', '(e.g., Acme API)', defaults.name),
180
- language: await askQuestion(rl, 'Primary language', '(e.g., Python, TypeScript)', defaults.language),
181
- framework: await askQuestion(rl, 'Framework', '(e.g., FastAPI, Next.js)', defaults.framework),
182
- devCmd: await askQuestion(rl, 'Dev server command', '(e.g., make dev)', defaults.devCmd),
183
- testCmd: await askQuestion(rl, 'Test command', '(e.g., make test)', defaults.testCmd),
184
- conventions: await askQuestion(rl, 'Key conventions', '(brief)', defaults.conventions),
185
- };
186
-
187
- console.log();
188
-
189
- // Update SOUL.md with project context
190
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
191
- if (existsSync(soulPath)) {
192
- let soul = readFileSync(soulPath, 'utf8');
193
-
194
- const projectContext = [
195
- `# ${project.name} — Persistent Development Agents`,
196
- '',
197
- `**Project:** ${project.name}`,
198
- `**Language:** ${project.language}`,
199
- `**Framework:** ${project.framework}`,
200
- `**Dev server:** \`${project.devCmd}\``,
201
- `**Tests:** \`${project.testCmd}\``,
202
- `**Conventions:** ${project.conventions}`,
203
- '',
204
- '---',
205
- '',
206
- ].join('\n');
207
-
208
- const dashIndex = soul.indexOf('---');
209
- if (dashIndex !== -1) {
210
- soul = projectContext + soul.slice(dashIndex + 3).trimStart();
211
- } else {
212
- soul = projectContext + soul;
213
- }
214
-
215
- writeFileSync(soulPath, soul, 'utf8');
216
- success(`Updated ${bold('.openclaw/SOUL.md')} with ${green(project.name)} project context`);
217
- }
218
-
219
- return project;
220
- } finally {
221
- rl.close();
222
- }
223
- }
224
-
225
- // ─── Configure Single Agent ─────────────────────────────────
226
-
227
- async function configureAgent(rl, name, agentMd, existingAgent) {
228
- const currentEnabled = existingAgent?.enabled !== false;
229
- const currentModel = existingAgent?.model || agentMd.meta.model || 'anthropic/claude-haiku-4-20250414';
230
-
231
- const enableStr = await askQuestion(rl, 'Enabled?', '(y/n)', currentEnabled ? 'y' : 'n');
232
- const enabled = enableStr.toLowerCase() !== 'n';
233
-
234
- let model = currentModel;
235
- if (enabled) {
236
- model = await askQuestion(rl, 'Model', '(e.g., anthropic/claude-haiku-4-20250414, ollama/llama3)', currentModel);
237
- }
238
-
239
- return { enabled, model };
240
- }
241
-
242
- // ─── Install OpenClaw ───────────────────────────────────────
243
-
244
- async function installOpenClaw() {
245
- console.log();
246
- warn('OpenClaw is not installed.');
247
- console.log();
248
- console.log(` ${dim('OpenClaw is the persistent agent runtime. Each agent')}`);
249
- console.log(` ${dim('opens in its own terminal window.')}`);
250
- console.log();
251
-
252
- const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
253
- if (!install) {
254
- console.log();
255
- info('Install manually when ready:');
256
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
257
- console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
205
+ if (stopped > 0) {
258
206
  console.log();
259
- return false;
207
+ success(`${stopped} agent(s) stopped`);
260
208
  }
261
-
262
- console.log();
263
- info('Installing OpenClaw...');
264
- await new Promise((resolve, reject) => {
265
- const child = spawn('npm', ['install', '-g', 'openclaw@latest'], { stdio: 'inherit' });
266
- child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`)));
267
- });
268
209
  console.log();
269
- success('OpenClaw installed');
270
- return true;
210
+ return stopped;
271
211
  }
272
212
 
273
213
  // ─── Agent Status ───────────────────────────────────────────
@@ -279,7 +219,7 @@ function getAgentStatus(projectDir, name, config) {
279
219
  return { status: 'stopped', pid: null };
280
220
  }
281
221
 
282
- function printAgentStatus(name, agentMd, config, statusInfo) {
222
+ function printAgentLine(name, agentMd, config, statusInfo) {
283
223
  const model = getEffectiveModel(name, agentMd.meta, config);
284
224
  const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
285
225
  const modelStr = modelShortName(model);
@@ -300,144 +240,254 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
300
240
 
301
241
  printHeader();
302
242
 
303
- // --stop flag
243
+ // ── Stop ──
304
244
  if (stop) {
305
245
  stopAllAgents(projectDir);
306
246
  return;
307
247
  }
308
248
 
309
- // Check workspace exists
249
+ // ── Preflight checks ──
310
250
  const soulPath = join(projectDir, '.openclaw/SOUL.md');
311
251
  if (!existsSync(soulPath)) {
312
252
  error('No .openclaw/ workspace found.');
313
- console.log();
314
253
  console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
315
254
  console.log();
316
255
  process.exit(1);
317
256
  }
318
257
 
319
- // Discover agents
320
258
  const agents = discoverAgents(projectDir);
321
259
  if (agents.length === 0) {
322
260
  error('No agents found in .openclaw/agents/');
323
- console.log();
324
261
  console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
325
262
  console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
326
263
  console.log();
327
264
  process.exit(1);
328
265
  }
329
266
 
267
+ // Check OpenClaw early — don't waste time configuring if it's missing
268
+ const hasOpenClaw = await which('openclaw');
269
+ if (!hasOpenClaw) {
270
+ warn('OpenClaw is not installed.');
271
+ console.log();
272
+ console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
273
+ console.log(` ${dim('Install it first, then re-run this command:')}`);
274
+ console.log();
275
+ console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
276
+ console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
277
+ console.log();
278
+ process.exit(1);
279
+ }
280
+
330
281
  cleanStalePids(projectDir);
331
282
 
332
- // Parse all AGENT.md files
283
+ // Parse agent definitions
333
284
  const parsed = [];
334
285
  for (const agent of agents) {
335
286
  try {
336
287
  const agentMd = parseAgentMd(agent.agentMdPath);
337
288
  parsed.push({ ...agent, agentMd });
338
289
  } catch (err) {
339
- error(`${bold(agent.name)}: ${err.message}`);
290
+ warn(`Skipping ${bold(agent.name)}: ${err.message}`);
340
291
  }
341
292
  }
342
-
343
293
  if (parsed.length === 0) {
344
- error('No valid agents found. Check your AGENT.md files.');
294
+ error('No valid AGENT.md files found.');
345
295
  console.log();
346
296
  process.exit(1);
347
297
  }
348
298
 
349
- // Load or create config
299
+ // Load config
350
300
  let config = readConfig(projectDir) || { project: {}, agents: {} };
351
301
  const needsSetup = !config.project?.name || reconfigure;
352
302
 
353
- if (needsSetup) {
354
- // First time or --reconfigure: full setup
355
- console.log(` ${bold('Project Setup')}`);
303
+ // Detect models in background while we show status
304
+ const modelsPromise = detectModels();
356
305
 
357
- config.project = await configureProject(projectDir, config);
306
+ // ── If configured, show status dashboard ──
307
+ if (!needsSetup) {
308
+ console.log(` ${bold(config.project.name)} ${dim(`\u2014 ${config.project.framework}`)}`);
309
+ console.log(` ${dim(`Dev: ${config.project.devCmd} \u2022 Test: ${config.project.testCmd}`)}`);
358
310
  console.log();
359
311
 
360
- // Configure agents
361
- console.log(` ${bold('Agent Configuration')}`);
312
+ for (const { name, agentMd } of parsed) {
313
+ const statusInfo = getAgentStatus(projectDir, name, config);
314
+ printAgentLine(name, agentMd, config, statusInfo);
315
+ }
362
316
  console.log();
363
317
 
364
- const rl = createInterface({ input: stdin, output: stdout });
365
- try {
366
- for (const { name, agentMd } of parsed) {
367
- const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
368
- console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
369
- console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
318
+ // Determine what to do
319
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
320
+ const runningNames = new Set(listPids(projectDir).map(p => p.name));
321
+ const startable = enabledAgents.filter(a => !runningNames.has(a.name));
370
322
 
371
- const agentConfig = await configureAgent(rl, name, agentMd, config.agents?.[name]);
372
- config.agents[name] = agentConfig;
323
+ if (startable.length === 0 && runningNames.size > 0) {
324
+ info('All enabled agents are running.');
325
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --stop')} ${dim('to stop them.')}`);
326
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to change settings.')}`);
327
+ console.log();
328
+ return;
329
+ }
373
330
 
374
- const status = agentConfig.enabled ? green('enabled') : dim('disabled');
375
- console.log(` ${dim('\u2192')} ${status}${agentConfig.enabled ? `, ${dim(modelShortName(agentConfig.model))}` : ''}`);
376
- console.log();
377
- }
378
- } finally {
379
- rl.close();
331
+ if (startable.length === 0) {
332
+ info('No agents are enabled.');
333
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to enable agents.')}`);
334
+ console.log();
335
+ return;
380
336
  }
381
337
 
382
- writeConfig(projectDir, config);
383
- success(`Saved ${bold('.openclaw/config.json')}`);
338
+ // Start startable agents
339
+ await launchAgents(projectDir, startable, config, soulPath);
340
+ return;
341
+ }
342
+
343
+ // ── First-time setup / reconfigure ──
344
+ if (reconfigure) {
345
+ info('Reconfiguring...');
384
346
  console.log();
385
347
  }
386
348
 
387
- // Show current status
388
- console.log(` ${bold('Agents')}`);
389
- console.log();
349
+ const rl = createInterface({ input: stdin, output: stdout });
350
+ let configDirty = false;
390
351
 
391
- for (const { name, agentMd } of parsed) {
392
- const statusInfo = getAgentStatus(projectDir, name, config);
393
- printAgentStatus(name, agentMd, config, statusInfo);
394
- }
395
- console.log();
352
+ try {
353
+ // ── Project configuration ──
354
+ const templateDefaults = detectTemplateDefaults(projectDir);
355
+ const existing = config.project || {};
356
+ const defaults = {
357
+ name: existing.name || templateDefaults?.name || '',
358
+ language: existing.language || templateDefaults?.language || '',
359
+ framework: existing.framework || templateDefaults?.framework || '',
360
+ devCmd: existing.devCmd || templateDefaults?.devCmd || '',
361
+ testCmd: existing.testCmd || templateDefaults?.testCmd || '',
362
+ conventions: existing.conventions || templateDefaults?.conventions || '',
363
+ };
396
364
 
397
- // Determine which agents can be started
398
- const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
399
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
400
- const startableAgents = enabledAgents.filter(a => !runningNames.has(a.name));
401
- const runningAgents = enabledAgents.filter(a => runningNames.has(a.name));
365
+ if (templateDefaults && !existing.name) {
366
+ info('Detected SerpentStack template \u2014 defaults pre-filled');
367
+ console.log();
368
+ }
402
369
 
403
- if (startableAgents.length === 0 && runningAgents.length > 0) {
404
- info('All enabled agents are already running.');
370
+ console.log(` ${bold('Project')}`);
371
+ console.log(` ${dim('Press Enter to keep current values.')}`);
405
372
  console.log();
406
373
 
407
- const doStop = await confirm('Stop all agents?');
408
- if (doStop) {
374
+ config.project = {
375
+ name: await ask(rl, 'Project name', defaults.name),
376
+ language: await ask(rl, 'Primary language', defaults.language),
377
+ framework: await ask(rl, 'Framework', defaults.framework),
378
+ devCmd: await ask(rl, 'Dev server command', defaults.devCmd),
379
+ testCmd: await ask(rl, 'Test command', defaults.testCmd),
380
+ conventions: await ask(rl, 'Key conventions', defaults.conventions),
381
+ };
382
+ configDirty = true;
383
+
384
+ // Update SOUL.md
385
+ if (existsSync(soulPath)) {
386
+ let soul = readFileSync(soulPath, 'utf8');
387
+ const ctx = [
388
+ `# ${config.project.name} \u2014 Persistent Development Agents`,
389
+ '',
390
+ `**Project:** ${config.project.name}`,
391
+ `**Language:** ${config.project.language}`,
392
+ `**Framework:** ${config.project.framework}`,
393
+ `**Dev server:** \`${config.project.devCmd}\``,
394
+ `**Tests:** \`${config.project.testCmd}\``,
395
+ `**Conventions:** ${config.project.conventions}`,
396
+ '', '---', '',
397
+ ].join('\n');
398
+ const dashIdx = soul.indexOf('---');
399
+ soul = dashIdx !== -1 ? ctx + soul.slice(dashIdx + 3).trimStart() : ctx + soul;
400
+ writeFileSync(soulPath, soul, 'utf8');
401
+ }
402
+ console.log();
403
+ success(`Updated ${bold('.openclaw/SOUL.md')}`);
404
+ console.log();
405
+
406
+ // ── Agent configuration ──
407
+ const available = await modelsPromise;
408
+
409
+ if (available.local.length > 0) {
410
+ info(`${available.local.length} local model(s) detected via Ollama`);
411
+ } else {
412
+ warn('No local models found. Install Ollama and pull a model for free persistent agents:');
413
+ console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
414
+ }
415
+ if (available.hasApiKey) {
416
+ info('API key configured for cloud models');
417
+ }
418
+ console.log();
419
+
420
+ console.log(` ${bold('Agents')}`);
421
+ console.log(` ${dim('Enable/disable each agent and choose its model.')}`);
422
+ console.log();
423
+
424
+ for (const { name, agentMd } of parsed) {
425
+ const existingAgent = config.agents?.[name];
426
+ const currentEnabled = existingAgent?.enabled !== false;
427
+ const currentModel = existingAgent?.model || agentMd.meta.model || 'anthropic/claude-haiku-4-20250414';
428
+ const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
429
+
430
+ console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
431
+ console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
432
+
433
+ const enabled = await askYesNo(rl, `Enable ${bold(name)}?`, currentEnabled);
434
+
435
+ let model = currentModel;
436
+ if (enabled) {
437
+ console.log();
438
+ model = await pickModel(rl, name, currentModel, available);
439
+ }
440
+
441
+ config.agents[name] = { enabled, model };
442
+
443
+ const status = enabled ? green('\u2713 enabled') : dim('\u2717 disabled');
444
+ const modelLabel = enabled ? `, ${modelShortName(model)}` : '';
445
+ console.log(` ${status}${modelLabel}`);
446
+ console.log();
447
+ }
448
+
449
+ configDirty = true;
450
+ } finally {
451
+ rl.close();
452
+ // Only save if we completed configuration
453
+ if (configDirty) {
454
+ writeConfig(projectDir, config);
455
+ success(`Saved ${bold('.openclaw/config.json')}`);
409
456
  console.log();
410
- stopAllAgents(projectDir);
411
457
  }
412
- return;
413
458
  }
414
459
 
415
- if (startableAgents.length === 0) {
416
- info('No agents are enabled. Run with --reconfigure to enable agents.');
460
+ // Show status and launch
461
+ for (const { name, agentMd } of parsed) {
462
+ const statusInfo = getAgentStatus(projectDir, name, config);
463
+ printAgentLine(name, agentMd, config, statusInfo);
464
+ }
465
+ console.log();
466
+
467
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
468
+ if (enabledAgents.length === 0) {
469
+ info('No agents enabled. Run with --reconfigure to enable agents.');
417
470
  console.log();
418
471
  return;
419
472
  }
420
473
 
421
- // Let user pick which agents to start
474
+ await launchAgents(projectDir, enabledAgents, config, soulPath);
475
+ }
476
+
477
+ // ─── Launch Flow ────────────────────────────────────────────
478
+
479
+ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
422
480
  const rl = createInterface({ input: stdin, output: stdout });
423
481
  const toStart = [];
424
482
 
425
483
  try {
426
- if (startableAgents.length === 1) {
427
- const a = startableAgents[0];
428
- const start = await confirm(`Start ${bold(a.name)}?`);
429
- if (start) toStart.push(a);
430
- } else {
431
- console.log(` ${dim('Which agents would you like to start?')}`);
432
- console.log();
484
+ console.log(` ${dim('Select agents to launch:')}`);
485
+ console.log();
433
486
 
434
- for (const agent of startableAgents) {
435
- const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
436
- const answer = await rl.question(` ${green('?')} Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}? ${dim('[Y/n]')} `);
437
- if (answer.trim().toLowerCase() !== 'n') {
438
- toStart.push(agent);
439
- }
440
- }
487
+ for (const agent of agentsToStart) {
488
+ const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
489
+ const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
490
+ if (yes) toStart.push(agent);
441
491
  }
442
492
  } finally {
443
493
  rl.close();
@@ -450,16 +500,8 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
450
500
  return;
451
501
  }
452
502
 
453
- // Check OpenClaw
454
- const hasOpenClaw = await which('openclaw');
455
- if (!hasOpenClaw) {
456
- const installed = await installOpenClaw();
457
- if (!installed) return;
458
- }
459
-
460
503
  console.log();
461
504
 
462
- // Start each agent in its own terminal window
463
505
  const sharedSoul = readFileSync(soulPath, 'utf8');
464
506
  let started = 0;
465
507
 
@@ -475,24 +517,19 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
475
517
  const absWorkspace = resolve(workspacePath);
476
518
  const absProject = resolve(projectDir);
477
519
 
478
- // Build the openclaw command
479
- const openclawCmd = `OPENCLAW_STATE_DIR="${join(absWorkspace, '.state')}" openclaw start --workspace "${absWorkspace}"`;
520
+ const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
480
521
 
481
- // Try to open in a new terminal window
482
- const method = openInTerminal(
483
- `SerpentStack: ${name}`,
484
- openclawCmd,
485
- absProject,
486
- );
522
+ const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
487
523
 
488
524
  if (method) {
489
- // Record PID tracking (we won't have the exact PID from the terminal,
490
- // but we record a placeholder so --stop can find/kill it)
491
- success(`${bold(name)} opened in new ${method} window ${dim(`(${modelShortName(effectiveModel)})`)}`);
525
+ // For terminal-spawned agents, record workspace path so we can track it
526
+ // The terminal process will create its own PID we record ours as a marker
527
+ writePid(projectDir, name, -1); // -1 = terminal-managed
528
+ success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
492
529
  started++;
493
530
  } else {
494
- // Fallback: start in background if no terminal method works
495
- warn(`Could not open terminal for ${bold(name)} — starting in background`);
531
+ // Fallback: background process
532
+ warn(`No terminal detected \u2014 starting ${bold(name)} in background`);
496
533
  const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
497
534
  stdio: 'ignore',
498
535
  detached: true,
@@ -501,7 +538,7 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
501
538
  });
502
539
  child.unref();
503
540
  writePid(projectDir, name, child.pid);
504
- success(`${bold(name)} started in background ${dim(`PID ${child.pid}`)}`);
541
+ success(`${bold(name)} started ${dim(`PID ${child.pid}`)}`);
505
542
  started++;
506
543
  }
507
544
  } catch (err) {
@@ -511,18 +548,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
511
548
 
512
549
  console.log();
513
550
  if (started > 0) {
514
- success(`${green(String(started))} agent(s) launched`);
551
+ success(`${started} agent(s) launched`);
515
552
  console.log();
516
553
  printBox('Manage agents', [
517
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# view status + start agents')}`,
554
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start agents')}`,
518
555
  `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
519
556
  `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models, enable/disable')}`,
520
- '',
521
- `${dim('Each agent runs in its own terminal window.')}`,
522
- `${dim('Close a window to stop that agent.')}`,
523
557
  ]);
524
- } else {
525
- error('No agents were started. Check the errors above.');
526
- console.log();
527
558
  }
528
559
  }
@@ -1,6 +1,9 @@
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';
5
+ import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
6
+ import { parseAgentMd, discoverAgents } from '../utils/agent-utils.js';
4
7
  import { info, success, warn, error, spinner, bold, dim, green, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
5
8
 
6
9
  const SKILLS_FILES = [
@@ -27,10 +30,10 @@ export async function skillsInit({ force = false } = {}) {
27
30
  printHeader();
28
31
 
29
32
  // Step 1: Download files
30
- console.log(` ${bold('Step 1/2')} ${dim('\u2014 Downloading skills + persistent agent configs')}`);
33
+ console.log(` ${bold('Downloading')} ${dim('skills + persistent agent configs')}`);
31
34
  console.log();
32
35
 
33
- const results = { created: 0, skipped: 0, overwritten: 0, failed: 0 };
36
+ const results = { created: 0, skipped: 0, overwritten: 0, unchanged: 0, failed: 0 };
34
37
  const logs = [];
35
38
  const spin = spinner('Fetching from GitHub...');
36
39
 
@@ -66,6 +69,7 @@ export async function skillsInit({ force = false } = {}) {
66
69
  const parts = [];
67
70
  if (results.created > 0) parts.push(green(`${results.created} created`));
68
71
  if (results.overwritten > 0) parts.push(`${results.overwritten} updated`);
72
+ if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
69
73
  if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
70
74
  if (results.failed > 0) parts.push(`${results.failed} failed`);
71
75
  console.log(` ${parts.join(dim(' \u2022 '))}`);
@@ -76,9 +80,40 @@ export async function skillsInit({ force = false } = {}) {
76
80
  return;
77
81
  }
78
82
 
79
- // Step 2: What to do next
83
+ // Generate default config.json if it doesn't exist
84
+ const projectDir = process.cwd();
85
+ if (!readConfig(projectDir)) {
86
+ const templateDefaults = detectTemplateDefaults(projectDir) || {};
87
+ const config = {
88
+ project: {
89
+ name: templateDefaults.name || '',
90
+ language: templateDefaults.language || '',
91
+ framework: templateDefaults.framework || '',
92
+ devCmd: templateDefaults.devCmd || '',
93
+ testCmd: templateDefaults.testCmd || '',
94
+ conventions: templateDefaults.conventions || '',
95
+ },
96
+ agents: {},
97
+ };
98
+
99
+ // Populate agent defaults from downloaded AGENT.md files
100
+ const agents = discoverAgents(projectDir);
101
+ for (const agent of agents) {
102
+ try {
103
+ const agentMd = parseAgentMd(agent.agentMdPath);
104
+ config.agents[agent.name] = defaultAgentConfig(agentMd.meta);
105
+ } catch { /* skip invalid agents */ }
106
+ }
107
+
108
+ writeConfig(projectDir, config);
109
+ console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
110
+ results.created++;
111
+ console.log();
112
+ }
113
+
114
+ // Next steps
80
115
  console.log();
81
- console.log(` ${bold('Step 2/2')} ${dim('\u2014 Generate project-specific skills')}`);
116
+ console.log(` ${bold('Next')} ${dim('\u2014 Generate project-specific skills')}`);
82
117
  console.log();
83
118
  console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
84
119
  console.log(` ${dim('and paste the prompt below. The agent will read your codebase,')}`);
@@ -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) {
@@ -49,20 +65,38 @@ export async function stackNew(name) {
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('\u2022')} ${dim(r.label)} ${dim(`\u2014 install: ${r.url}`)}`);
94
+ missing.push(r.label);
95
+ } else if (r.versionOk === false) {
96
+ console.log(` ${yellow('\u25B3')} ${r.label} ${dim(`\u2014 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('\u2713')} ${r.label}`);
66
100
  }
67
101
  }
68
102
  console.log();
@@ -85,7 +119,8 @@ export async function stackNew(name) {
85
119
  const spin = spinner(`Downloading SerpentStack template...`);
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);
@@ -22,7 +22,10 @@ export function readConfig(projectDir) {
22
22
  if (!existsSync(configPath)) return null;
23
23
  try {
24
24
  return JSON.parse(readFileSync(configPath, 'utf8'));
25
- } catch {
25
+ } catch (err) {
26
+ if (err instanceof SyntaxError) {
27
+ console.error(` \x1b[33m\u25B3\x1b[0m .openclaw/config.json has invalid JSON — ignoring. Fix it or delete it to regenerate.`);
28
+ }
26
29
  return null;
27
30
  }
28
31
  }
@@ -46,8 +49,8 @@ export function detectTemplateDefaults(projectDir) {
46
49
 
47
50
  try {
48
51
  const content = readFileSync(makefile, 'utf8');
49
- // SerpentStack Makefile has these distinctive targets
50
- if (!content.includes('make verify') && !content.includes('.PHONY:') ) return null;
52
+ // SerpentStack Makefile: must have 'make verify' AND either 'uv run' or 'uvicorn'
53
+ if (!content.includes('make verify')) return null;
51
54
  if (!content.includes('uv run') && !content.includes('uvicorn')) return null;
52
55
 
53
56
  // It's a SerpentStack template project — return smart defaults
@@ -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,156 @@
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 with parameter counts.
25
+ * Parses `ollama list` output.
26
+ */
27
+ async function detectOllamaModels() {
28
+ try {
29
+ const output = await execAsync('ollama', ['list']);
30
+ const lines = output.trim().split('\n');
31
+ if (lines.length < 2) return []; // header only
32
+
33
+ // Parse header to find column positions
34
+ const header = lines[0];
35
+ const nameEnd = header.indexOf('ID');
36
+ const sizeStart = header.indexOf('SIZE');
37
+
38
+ return lines.slice(1).map(line => {
39
+ if (!line.trim()) return null;
40
+
41
+ const name = line.slice(0, nameEnd).trim();
42
+ if (!name) return null;
43
+
44
+ // Extract size (e.g., "4.7 GB", "1.3 GB")
45
+ const sizeStr = sizeStart >= 0 ? line.slice(sizeStart).trim().split(/\s{2,}/)[0] : '';
46
+
47
+ // Estimate parameter count from model name (e.g., "llama3.2:3b", "qwen2.5-coder:7b")
48
+ const paramMatch = name.match(/[:\-](\d+\.?\d*)[bB]/);
49
+ const params = paramMatch ? paramMatch[1] + 'B' : guessParams(name, sizeStr);
50
+
51
+ const shortName = name.replace(':latest', '');
52
+
53
+ return {
54
+ id: `ollama/${shortName}`,
55
+ name: shortName,
56
+ params,
57
+ size: sizeStr,
58
+ tier: 'local',
59
+ };
60
+ }).filter(Boolean);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Guess parameter count from file size if not in the name.
68
+ * Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
69
+ */
70
+ function guessParams(name, sizeStr) {
71
+ const gbMatch = sizeStr.match(/([\d.]+)\s*GB/i);
72
+ if (gbMatch) {
73
+ const gb = parseFloat(gbMatch[1]);
74
+ const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
75
+ if (billions > 0) return `~${billions}B`;
76
+ }
77
+ return '';
78
+ }
79
+
80
+ /**
81
+ * Check OpenClaw for configured models and API key status.
82
+ */
83
+ async function detectOpenClawAuth() {
84
+ const result = { models: [], hasApiKey: false };
85
+
86
+ try {
87
+ // Check if any API key is configured via openclaw models status
88
+ const status = await execAsync('openclaw', ['models', 'status']);
89
+
90
+ // Look for "api_key" or "configured" in the output
91
+ if (status.includes('api_key') || status.includes('configured')) {
92
+ result.hasApiKey = true;
93
+ }
94
+
95
+ // Get the model catalog for cloud options
96
+ const list = await execAsync('openclaw', ['models', 'list', '--json']);
97
+ try {
98
+ const models = JSON.parse(list);
99
+ if (Array.isArray(models)) {
100
+ result.models = models
101
+ .filter(m => m.available && !m.local)
102
+ .map(m => ({
103
+ id: m.key || m.name,
104
+ name: modelShortName(m.key || m.name),
105
+ provider: (m.key || '').split('/')[0] || 'unknown',
106
+ tier: 'cloud',
107
+ }));
108
+ }
109
+ } catch {
110
+ // Fall back to text parsing
111
+ const text = await execAsync('openclaw', ['models', 'list']);
112
+ const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
113
+ result.models = lines.map(l => {
114
+ const id = l.trim().split(/\s+/)[0];
115
+ if (!id || id.length < 3) return null;
116
+ return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
117
+ }).filter(Boolean);
118
+ }
119
+ } catch {
120
+ // OpenClaw not installed or no models configured — use defaults
121
+ result.models = [
122
+ { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
123
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
124
+ ];
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Short display name for a model ID.
132
+ */
133
+ export function modelShortName(model) {
134
+ if (!model) return 'unknown';
135
+ // Anthropic models
136
+ if (model.startsWith('anthropic/')) {
137
+ if (model.includes('haiku')) return 'Haiku';
138
+ if (model.includes('sonnet')) return 'Sonnet';
139
+ if (model.includes('opus')) return 'Opus';
140
+ return model.slice('anthropic/'.length);
141
+ }
142
+ // Ollama models
143
+ if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
144
+ // Other: strip provider prefix
145
+ if (model.includes('/')) return model.split('/').pop();
146
+ return model;
147
+ }
148
+
149
+ function execAsync(cmd, args) {
150
+ return new Promise((resolve, reject) => {
151
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
152
+ if (err) reject(err);
153
+ else resolve(stdout);
154
+ });
155
+ });
156
+ }
package/lib/utils/ui.js CHANGED
@@ -17,10 +17,10 @@ const MAGENTA = c(35);
17
17
  const CYAN = c(36);
18
18
  const BG_DIM = c(100);
19
19
 
20
- export const info = (msg) => console.log(`${CYAN}\u2022${RESET} ${msg}`);
21
- export const success = (msg) => console.log(`${GREEN}\u2713${RESET} ${msg}`);
22
- export const warn = (msg) => console.log(`${YELLOW}\u25B3${RESET} ${msg}`);
23
- export const error = (msg) => console.error(`${RED}\u2717${RESET} ${msg}`);
20
+ export const info = (msg) => console.log(` ${CYAN}\u2022${RESET} ${msg}`);
21
+ export const success = (msg) => console.log(` ${GREEN}\u2713${RESET} ${msg}`);
22
+ export const warn = (msg) => console.log(` ${YELLOW}\u25B3${RESET} ${msg}`);
23
+ export const error = (msg) => console.error(` ${RED}\u2717${RESET} ${msg}`);
24
24
  export const dim = (msg) => `${DIM}${msg}${RESET}`;
25
25
  export const bold = (msg) => `${BOLD}${msg}${RESET}`;
26
26
  export const green = (msg) => `${GREEN}${msg}${RESET}`;
@@ -147,5 +147,5 @@ export function fileStatus(path, status, detail) {
147
147
  }
148
148
 
149
149
  function stripAnsi(str) {
150
- return str.replace(/\x1b\[\d+m/g, '');
150
+ return str.replace(/\x1b\[[\d;]*m/g, '');
151
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {