serpentstack 0.2.12 → 0.2.14

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.
@@ -71,6 +71,7 @@ function showHelp() {
71
71
  console.log(` ${cyan('persistent')} Status dashboard (first run = full setup)`);
72
72
  console.log(` ${cyan('persistent')} ${dim('--configure')} Edit project settings`);
73
73
  console.log(` ${cyan('persistent')} ${dim('--agents')} Change agent models, enable/disable`);
74
+ console.log(` ${cyan('persistent')} ${dim('--models')} List installed & recommended models`);
74
75
  console.log(` ${cyan('persistent')} ${dim('--start')} Launch enabled agents`);
75
76
  console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
76
77
  console.log();
@@ -141,6 +142,7 @@ async function main() {
141
142
  configure: !!flags.configure,
142
143
  agents: !!flags.agents,
143
144
  start: !!flags.start,
145
+ models: !!flags.models,
144
146
  });
145
147
  } else {
146
148
  error(`Unknown command: ${bold(noun)}`);
@@ -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, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader, spinner } from '../utils/ui.js';
7
7
  import {
8
8
  parseAgentMd,
9
9
  discoverAgents,
@@ -47,18 +47,192 @@ async function askYesNo(rl, label, defaultYes = true) {
47
47
  return val === 'y' || val === 'yes';
48
48
  }
49
49
 
50
+ // ─── Preflight ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Check all prerequisites and return a status object.
54
+ * Exits the process with helpful guidance if anything critical is missing.
55
+ */
56
+ async function preflight(projectDir) {
57
+ const soulPath = join(projectDir, '.openclaw/SOUL.md');
58
+
59
+ // Check for .openclaw workspace
60
+ if (!existsSync(soulPath)) {
61
+ error('No .openclaw/ workspace found.');
62
+ console.log();
63
+ console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('first to download skills and agent configs.')}`);
64
+ console.log();
65
+ process.exit(1);
66
+ }
67
+
68
+ // Check for agent definitions
69
+ const agentDirs = discoverAgents(projectDir);
70
+ if (agentDirs.length === 0) {
71
+ error('No agents found in .openclaw/agents/');
72
+ console.log();
73
+ console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('to download the default agents,')}`);
74
+ console.log(` ${dim('or create your own at')} ${bold('.openclaw/agents/<name>/AGENT.md')}`);
75
+ console.log();
76
+ process.exit(1);
77
+ }
78
+
79
+ // Parse agent definitions
80
+ const parsed = [];
81
+ for (const agent of agentDirs) {
82
+ try {
83
+ const agentMd = parseAgentMd(agent.agentMdPath);
84
+ parsed.push({ ...agent, agentMd });
85
+ } catch (err) {
86
+ warn(`Skipping ${bold(agent.name)}: ${err.message}`);
87
+ }
88
+ }
89
+ if (parsed.length === 0) {
90
+ error('No valid AGENT.md files found.');
91
+ console.log();
92
+ process.exit(1);
93
+ }
94
+
95
+ // Detect runtime dependencies in parallel
96
+ const spin = spinner('Checking runtime...');
97
+ const [hasOpenClaw, available] = await Promise.all([
98
+ which('openclaw'),
99
+ detectModels(),
100
+ ]);
101
+ spin.stop();
102
+
103
+ return { soulPath, parsed, hasOpenClaw, available };
104
+ }
105
+
106
+ /**
107
+ * Print a summary of what's installed and what's missing.
108
+ * Returns true if everything needed to launch is present.
109
+ */
110
+ function printPreflightStatus(hasOpenClaw, available) {
111
+ divider('Runtime');
112
+ console.log();
113
+
114
+ // OpenClaw
115
+ if (hasOpenClaw) {
116
+ console.log(` ${green('✓')} OpenClaw ${dim('— persistent agent runtime')}`);
117
+ } else {
118
+ console.log(` ${red('✗')} OpenClaw ${dim('— not installed')}`);
119
+ }
120
+
121
+ // Ollama
122
+ if (available.ollamaRunning) {
123
+ console.log(` ${green('✓')} Ollama ${dim(`— running, ${available.local.length} model(s) installed`)}`);
124
+ } else if (available.ollamaInstalled) {
125
+ console.log(` ${yellow('△')} Ollama ${dim('— installed but not running')}`);
126
+ } else {
127
+ console.log(` ${yellow('○')} Ollama ${dim('— not installed (optional, for free local models)')}`);
128
+ }
129
+
130
+ // API key
131
+ if (available.hasApiKey) {
132
+ console.log(` ${green('✓')} API key ${dim('— configured for cloud models')}`);
133
+ }
134
+
135
+ console.log();
136
+
137
+ // Actionable guidance for missing pieces
138
+ const issues = [];
139
+
140
+ if (!hasOpenClaw) {
141
+ issues.push({
142
+ label: 'Install OpenClaw (required to run agents)',
143
+ cmd: 'npm install -g openclaw@latest',
144
+ });
145
+ }
146
+
147
+ if (!available.ollamaInstalled) {
148
+ issues.push({
149
+ label: 'Install Ollama for free local models (recommended)',
150
+ cmd: 'curl -fsSL https://ollama.com/install.sh | sh',
151
+ });
152
+ } else if (!available.ollamaRunning) {
153
+ issues.push({
154
+ label: 'Start Ollama',
155
+ cmd: 'ollama serve',
156
+ });
157
+ }
158
+
159
+ if (available.ollamaRunning && available.local.length === 0) {
160
+ issues.push({
161
+ label: 'Pull a model (Ollama is running but has no models)',
162
+ cmd: 'ollama pull llama3.2',
163
+ });
164
+ }
165
+
166
+ if (issues.length > 0) {
167
+ for (const issue of issues) {
168
+ console.log(` ${dim(issue.label + ':')}`);
169
+ console.log(` ${dim('$')} ${bold(issue.cmd)}`);
170
+ console.log();
171
+ }
172
+ }
173
+
174
+ return hasOpenClaw;
175
+ }
176
+
177
+ // ─── Model Install ──────────────────────────────────────────
178
+
179
+ /**
180
+ * Run `ollama pull <model>` with live progress output.
181
+ * Returns true if the pull succeeded.
182
+ */
183
+ function ollamaPull(modelName) {
184
+ return new Promise((resolve) => {
185
+ console.log();
186
+ info(`Downloading ${bold(modelName)}... ${dim('(this may take a few minutes)')}`);
187
+ console.log();
188
+
189
+ const child = spawn('ollama', ['pull', modelName], {
190
+ stdio: ['ignore', 'pipe', 'pipe'],
191
+ });
192
+
193
+ // Stream progress to the terminal
194
+ child.stdout.on('data', (data) => {
195
+ const line = data.toString().trim();
196
+ if (line) process.stderr.write(` ${line}\r`);
197
+ });
198
+ child.stderr.on('data', (data) => {
199
+ const line = data.toString().trim();
200
+ if (line) process.stderr.write(` ${line}\r`);
201
+ });
202
+
203
+ child.on('close', (code) => {
204
+ process.stderr.write('\x1b[K'); // clear the progress line
205
+ if (code === 0) {
206
+ success(`${bold(modelName)} installed`);
207
+ console.log();
208
+ resolve(true);
209
+ } else {
210
+ error(`Failed to download ${bold(modelName)} (exit code ${code})`);
211
+ console.log();
212
+ resolve(false);
213
+ }
214
+ });
215
+
216
+ child.on('error', (err) => {
217
+ error(`Could not run ollama pull: ${err.message}`);
218
+ console.log();
219
+ resolve(false);
220
+ });
221
+ });
222
+ }
223
+
50
224
  // ─── Model Picker ───────────────────────────────────────────
51
225
 
52
226
  async function pickModel(rl, agentName, currentModel, available) {
53
227
  const choices = [];
54
228
 
55
- // Local models first (free, fast, recommended)
229
+ // Section 1: Installed local models
56
230
  if (available.local.length > 0) {
57
- console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
231
+ console.log(` ${dim('── Installed')} ${green('ready')} ${dim('────────────────────')}`);
58
232
  for (const m of available.local) {
59
233
  const isCurrent = m.id === currentModel;
60
234
  const idx = choices.length;
61
- choices.push(m);
235
+ choices.push({ ...m, action: 'use' });
62
236
  const marker = isCurrent ? green('>') : ' ';
63
237
  const num = dim(`${idx + 1}.`);
64
238
  const label = isCurrent ? bold(m.name) : m.name;
@@ -70,14 +244,48 @@ async function pickModel(rl, agentName, currentModel, available) {
70
244
  }
71
245
  }
72
246
 
73
- // Cloud models (require API key, cost money)
247
+ // Section 2: Recommended models (not installed, auto-download on select)
248
+ if (available.ollamaInstalled && available.recommended.length > 0) {
249
+ const liveTag = available.recommendedLive
250
+ ? dim(`fetched from ollama.com`)
251
+ : dim(`cached list`);
252
+ console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${dim(') ──')}`);
253
+ // Show a reasonable subset (not 50 models)
254
+ const toShow = available.recommended.slice(0, 8);
255
+ for (const r of toShow) {
256
+ const idx = choices.length;
257
+ const isCurrent = `ollama/${r.name}` === currentModel;
258
+ choices.push({
259
+ id: `ollama/${r.name}`,
260
+ name: r.name,
261
+ params: r.params,
262
+ size: r.size,
263
+ description: r.description,
264
+ tier: 'downloadable',
265
+ action: 'download',
266
+ });
267
+ const marker = isCurrent ? green('>') : ' ';
268
+ const num = dim(`${idx + 1}.`);
269
+ const label = isCurrent ? bold(r.name) : r.name;
270
+ const params = r.params ? dim(` ${r.params}`) : '';
271
+ const size = r.size ? dim(` (${r.size})`) : '';
272
+ const desc = r.description ? dim(` — ${r.description}`) : '';
273
+ const tag = isCurrent ? green(' ← current') : '';
274
+ console.log(` ${marker} ${num} ${label}${params}${size}${desc}${tag}`);
275
+ }
276
+ if (available.recommended.length > toShow.length) {
277
+ console.log(` ${dim(`... and ${available.recommended.length - toShow.length} more at`)} ${cyan('ollama.com/library')}`);
278
+ }
279
+ }
280
+
281
+ // Section 3: Cloud models
74
282
  if (available.cloud.length > 0) {
75
283
  const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
76
284
  console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
77
285
  for (const m of available.cloud) {
78
286
  const isCurrent = m.id === currentModel;
79
287
  const idx = choices.length;
80
- choices.push(m);
288
+ choices.push({ ...m, action: 'use' });
81
289
  const marker = isCurrent ? green('>') : ' ';
82
290
  const num = dim(`${idx + 1}.`);
83
291
  const label = isCurrent ? bold(m.name) : m.name;
@@ -87,9 +295,16 @@ async function pickModel(rl, agentName, currentModel, available) {
87
295
  }
88
296
  }
89
297
 
90
- // If current model isn't in either list, add it
298
+ if (choices.length === 0) {
299
+ warn('No models available. Install Ollama and pull a model first.');
300
+ console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
301
+ console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
302
+ return currentModel;
303
+ }
304
+
305
+ // If current model isn't in any list, add it at the top
91
306
  if (!choices.some(c => c.id === currentModel)) {
92
- choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
307
+ choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
93
308
  console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
94
309
  }
95
310
 
@@ -101,9 +316,22 @@ async function pickModel(rl, agentName, currentModel, available) {
101
316
 
102
317
  const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
103
318
 
319
+ // If they selected a downloadable model, pull it now
320
+ if (selected.action === 'download') {
321
+ // Close rl temporarily so ollama pull can use the terminal
322
+ rl.pause();
323
+ const pulled = await ollamaPull(selected.name);
324
+ rl.resume();
325
+
326
+ if (!pulled) {
327
+ warn(`Download failed. Keeping previous model: ${bold(modelShortName(currentModel))}`);
328
+ return currentModel;
329
+ }
330
+ }
331
+
104
332
  // Warn about cloud model costs
105
- if (selected.tier === 'cloud' && available.local.length > 0) {
106
- warn(`Cloud models cost tokens per heartbeat cycle. Consider a local model for persistent agents.`);
333
+ if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
334
+ warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
107
335
  }
108
336
  if (selected.tier === 'cloud' && !available.hasApiKey) {
109
337
  warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
@@ -156,8 +384,7 @@ end tell`;
156
384
  try {
157
385
  const child = spawn(bin, args, { stdio: 'ignore', detached: true });
158
386
  child.unref();
159
- const alive = child.pid && !child.killed;
160
- if (alive) return bin;
387
+ if (child.pid && !child.killed) return bin;
161
388
  } catch { continue; }
162
389
  }
163
390
  }
@@ -237,6 +464,8 @@ function printStatusDashboard(config, parsed, projectDir) {
237
464
  console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
238
465
  console.log();
239
466
 
467
+ divider('Agents');
468
+ console.log();
240
469
  for (const { name, agentMd } of parsed) {
241
470
  const statusInfo = getAgentStatus(projectDir, name, config);
242
471
  printAgentLine(name, agentMd, config, statusInfo);
@@ -244,7 +473,66 @@ function printStatusDashboard(config, parsed, projectDir) {
244
473
  console.log();
245
474
  }
246
475
 
247
- // ─── Configure Flow (project settings) ─────────────────────
476
+ // ─── Models Command ─────────────────────────────────────────
477
+
478
+ async function runModels(available) {
479
+ divider('Installed Models');
480
+ console.log();
481
+
482
+ if (available.local.length > 0) {
483
+ for (const m of available.local) {
484
+ const params = m.params ? dim(` ${m.params}`) : '';
485
+ const quant = m.quant ? dim(` ${m.quant}`) : '';
486
+ const size = m.size ? dim(` (${m.size})`) : '';
487
+ console.log(` ${green('●')} ${bold(m.name)}${params}${quant}${size}`);
488
+ }
489
+ } else {
490
+ console.log(` ${dim('No local models installed.')}`);
491
+ }
492
+
493
+ if (available.cloud.length > 0) {
494
+ console.log();
495
+ const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
496
+ console.log(` ${dim('Cloud models')} ${apiNote}`);
497
+ for (const m of available.cloud) {
498
+ console.log(` ${dim('●')} ${m.name} ${dim(`(${m.provider})`)}`);
499
+ }
500
+ }
501
+
502
+ console.log();
503
+
504
+ // Show recommended models to install
505
+ if (available.recommended.length > 0) {
506
+ divider('Recommended Models');
507
+ console.log();
508
+ if (available.recommendedLive) {
509
+ success(`Fetched latest models from ${cyan('ollama.com/library')}`);
510
+ } else {
511
+ warn(`Could not reach ollama.com — showing cached recommendations`);
512
+ }
513
+ console.log();
514
+ for (const r of available.recommended) {
515
+ console.log(` ${dim('$')} ${bold(`ollama pull ${r.name}`)} ${dim(`${r.params} — ${r.description}`)}`);
516
+ }
517
+ console.log();
518
+ }
519
+
520
+ // Status summary
521
+ if (!available.ollamaInstalled) {
522
+ console.log(` ${dim('Install Ollama for free local models:')}`);
523
+ console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
524
+ console.log();
525
+ } else if (!available.ollamaRunning) {
526
+ console.log(` ${dim('Start Ollama to use local models:')}`);
527
+ console.log(` ${dim('$')} ${bold('ollama serve')}`);
528
+ console.log();
529
+ }
530
+
531
+ console.log(` ${dim('Browse all models:')} ${cyan('https://ollama.com/library')}`);
532
+ console.log();
533
+ }
534
+
535
+ // ─── Configure Flow ─────────────────────────────────────────
248
536
 
249
537
  async function runConfigure(projectDir, config, soulPath) {
250
538
  const rl = createInterface({ input: stdin, output: stdout });
@@ -280,7 +568,7 @@ async function runConfigure(projectDir, config, soulPath) {
280
568
  conventions: await ask(rl, 'Key conventions', defaults.conventions),
281
569
  };
282
570
 
283
- // Update SOUL.md with project context
571
+ // Update SOUL.md
284
572
  if (existsSync(soulPath)) {
285
573
  let soul = readFileSync(soulPath, 'utf8');
286
574
  const ctx = [
@@ -305,29 +593,15 @@ async function runConfigure(projectDir, config, soulPath) {
305
593
  rl.close();
306
594
  }
307
595
 
308
- // Mark as user-confirmed
309
596
  config._configured = true;
310
597
  writeConfig(projectDir, config);
311
598
  success(`Saved ${bold('.openclaw/config.json')}`);
312
599
  console.log();
313
600
  }
314
601
 
315
- // ─── Agents Flow (enable/disable + model selection) ─────────
316
-
317
- async function runAgents(projectDir, config, parsed) {
318
- const available = await detectModels();
319
-
320
- if (available.local.length > 0) {
321
- info(`${available.local.length} local model(s) detected via Ollama`);
322
- } else {
323
- warn('No local models found. Install Ollama and pull a model for free persistent agents:');
324
- console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
325
- }
326
- if (available.hasApiKey) {
327
- info('API key configured for cloud models');
328
- }
329
- console.log();
602
+ // ─── Agents Flow ────────────────────────────────────────────
330
603
 
604
+ async function runAgents(projectDir, config, parsed, available) {
331
605
  divider('Agents');
332
606
  console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
333
607
  console.log();
@@ -371,7 +645,15 @@ async function runAgents(projectDir, config, parsed) {
371
645
 
372
646
  // ─── Start Flow ─────────────────────────────────────────────
373
647
 
374
- async function runStart(projectDir, parsed, config, soulPath) {
648
+ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
649
+ if (!hasOpenClaw) {
650
+ error('Cannot launch agents — OpenClaw is not installed.');
651
+ console.log();
652
+ console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
653
+ console.log();
654
+ return;
655
+ }
656
+
375
657
  const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
376
658
  const runningNames = new Set(listPids(projectDir).map(p => p.name));
377
659
  const startable = enabledAgents.filter(a => !runningNames.has(a.name));
@@ -430,11 +712,10 @@ async function runStart(projectDir, parsed, config, soulPath) {
430
712
  const absProject = resolve(projectDir);
431
713
 
432
714
  const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
433
-
434
715
  const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
435
716
 
436
717
  if (method) {
437
- writePid(projectDir, name, -1); // -1 = terminal-managed
718
+ writePid(projectDir, name, -1);
438
719
  success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
439
720
  started++;
440
721
  } else {
@@ -459,105 +740,59 @@ async function runStart(projectDir, parsed, config, soulPath) {
459
740
  if (started > 0) {
460
741
  success(`${started} agent(s) launched — fangs out 🐍`);
461
742
  console.log();
462
- printBox('Manage agents', [
463
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status dashboard')}`,
464
- `${dim('$')} ${bold('serpentstack persistent --start')} ${dim('# launch agents')}`,
465
- `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
466
- `${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
467
- `${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
468
- ]);
469
743
  }
470
744
  }
471
745
 
472
746
  // ─── Main Entry Point ───────────────────────────────────────
473
747
 
474
- export async function persistent({ stop = false, configure = false, agents = false, start = false } = {}) {
748
+ export async function persistent({ stop = false, configure = false, agents = false, start = false, models = false } = {}) {
475
749
  const projectDir = process.cwd();
476
750
 
477
751
  printHeader();
478
752
 
479
- // ── Stop ──
753
+ // ── Stop (doesn't need full preflight) ──
480
754
  if (stop) {
755
+ cleanStalePids(projectDir);
481
756
  stopAllAgents(projectDir);
482
757
  return;
483
758
  }
484
759
 
485
- // ── Preflight checks ──
486
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
487
- if (!existsSync(soulPath)) {
488
- error('No .openclaw/ workspace found.');
489
- console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
490
- console.log();
491
- process.exit(1);
492
- }
493
-
494
- const agentDirs = discoverAgents(projectDir);
495
- if (agentDirs.length === 0) {
496
- error('No agents found in .openclaw/agents/');
497
- console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
498
- console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
499
- console.log();
500
- process.exit(1);
501
- }
502
-
503
- // Check OpenClaw early
504
- const hasOpenClaw = await which('openclaw');
505
- if (!hasOpenClaw) {
506
- warn('OpenClaw is not installed.');
507
- console.log();
508
- console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
509
- console.log(` ${dim('Install it first, then re-run this command:')}`);
510
- console.log();
511
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
512
- console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
513
- console.log();
514
- process.exit(1);
515
- }
516
-
760
+ // ── Full preflight (checks workspace, agents, runtime) ──
761
+ const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
517
762
  cleanStalePids(projectDir);
518
763
 
519
- // Parse agent definitions
520
- const parsed = [];
521
- for (const agent of agentDirs) {
522
- try {
523
- const agentMd = parseAgentMd(agent.agentMdPath);
524
- parsed.push({ ...agent, agentMd });
525
- } catch (err) {
526
- warn(`Skipping ${bold(agent.name)}: ${err.message}`);
527
- }
528
- }
529
- if (parsed.length === 0) {
530
- error('No valid AGENT.md files found.');
531
- console.log();
532
- process.exit(1);
533
- }
534
-
535
764
  // Load config
536
765
  let config = readConfig(projectDir) || { project: {}, agents: {} };
537
766
  const isConfigured = !!config._configured;
538
767
 
539
- // ── Explicit flag: --configure ──
768
+ // ── --models: list installed and recommended models ──
769
+ if (models) {
770
+ await runModels(available);
771
+ return;
772
+ }
773
+
774
+ // ── --configure: edit project settings ──
540
775
  if (configure) {
541
776
  await runConfigure(projectDir, config, soulPath);
542
777
  return;
543
778
  }
544
779
 
545
- // ── Explicit flag: --agents ──
780
+ // ── --agents: edit agent models and enabled state ──
546
781
  if (agents) {
547
- config = readConfig(projectDir) || config; // re-read in case --configure was run first
548
- await runAgents(projectDir, config, parsed);
782
+ config = readConfig(projectDir) || config;
783
+ await runAgents(projectDir, config, parsed, available);
549
784
  return;
550
785
  }
551
786
 
552
- // ── Explicit flag: --start ──
787
+ // ── --start: launch agents ──
553
788
  if (start) {
554
- await runStart(projectDir, parsed, config, soulPath);
789
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
555
790
  return;
556
791
  }
557
792
 
558
- // ── Default: bare `serpentstack persistent` ──
793
+ // ── Bare `serpentstack persistent` ──
559
794
  if (isConfigured) {
560
- // Already set up — show dashboard
795
+ // Show dashboard
561
796
  printStatusDashboard(config, parsed, projectDir);
562
797
 
563
798
  const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
@@ -578,27 +813,53 @@ export async function persistent({ stop = false, configure = false, agents = fal
578
813
  `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
579
814
  `${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
580
815
  `${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
816
+ `${dim('$')} ${bold('serpentstack persistent --models')} ${dim('# list & install models')}`,
581
817
  ]);
582
818
  console.log();
583
819
  return;
584
820
  }
585
821
 
586
- // ── First-time setup: full walkthrough ──
587
- info('First-time setup — let\'s configure your project and agents.');
588
- console.log();
822
+ // ── First-time setup: guided walkthrough ──
823
+
824
+ // Step 0: Show runtime status
825
+ const canLaunch = printPreflightStatus(hasOpenClaw, available);
826
+
827
+ if (!canLaunch) {
828
+ console.log(` ${dim('Install the missing dependencies above, then run:')}`);
829
+ console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
830
+ console.log();
831
+
832
+ // Still let them configure even without OpenClaw
833
+ const rl = createInterface({ input: stdin, output: stdout });
834
+ let proceed;
835
+ try {
836
+ proceed = await askYesNo(rl, `Continue with project configuration anyway?`, true);
837
+ } finally {
838
+ rl.close();
839
+ }
840
+
841
+ if (!proceed) {
842
+ console.log();
843
+ return;
844
+ }
845
+ console.log();
846
+ }
589
847
 
590
848
  // Step 1: Project settings
591
849
  await runConfigure(projectDir, config, soulPath);
592
-
593
- // Re-read config (runConfigure saved it)
594
850
  config = readConfig(projectDir) || config;
595
851
 
596
852
  // Step 2: Agent settings
597
- await runAgents(projectDir, config, parsed);
598
-
599
- // Re-read config (runAgents saved it)
853
+ await runAgents(projectDir, config, parsed, available);
600
854
  config = readConfig(projectDir) || config;
601
855
 
602
- // Step 3: Launch
603
- await runStart(projectDir, parsed, config, soulPath);
856
+ // Step 3: Launch (only if OpenClaw is installed)
857
+ if (canLaunch) {
858
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
859
+ } else {
860
+ console.log();
861
+ info('Skipping launch — install OpenClaw first, then run:');
862
+ console.log(` ${dim('$')} ${bold('serpentstack persistent --start')}`);
863
+ console.log();
864
+ }
604
865
  }
@@ -1,43 +1,149 @@
1
1
  import { execFile } from 'node:child_process';
2
2
 
3
+ // ─── Fallback Recommendations ───────────────────────────────
4
+ // Used only when the Ollama library API is unreachable.
5
+ // When online, we fetch fresh models from ollama.com/api/tags.
6
+
7
+ const FALLBACK_RECOMMENDATIONS = [
8
+ { name: 'gemma3:4b', params: '4B', size: '8.0 GB', description: 'Small and fast — great for log watching' },
9
+ { name: 'ministral-3:3b', params: '3B', size: '4.3 GB', description: 'Lightweight, good for simple tasks' },
10
+ { name: 'ministral-3:8b', params: '8B', size: '9.7 GB', description: 'Balanced — good default for most agents' },
11
+ { name: 'devstral-small-2:24b', params: '24B', size: '14.6 GB', description: 'Code-focused, strong for test runner agents' },
12
+ ];
13
+
14
+ // Max model size to recommend (16 GB — fits on most dev machines)
15
+ const MAX_RECOMMEND_SIZE = 16 * 1024 ** 3;
16
+
3
17
  /**
4
18
  * 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.
19
+ * Also fetches recommended models from the Ollama library.
7
20
  *
8
- * Returns { local: [...], cloud: [...], hasApiKey: bool }
21
+ * Returns {
22
+ * local: [...],
23
+ * cloud: [...],
24
+ * hasApiKey: bool,
25
+ * ollamaRunning: bool,
26
+ * ollamaInstalled: bool,
27
+ * openclawInstalled: bool,
28
+ * recommended: [...], // models user doesn't have yet
29
+ * }
9
30
  */
10
31
  export async function detectModels() {
11
- const [ollamaModels, openclawInfo] = await Promise.all([
12
- detectOllamaModels(),
32
+ const [ollamaStatus, openclawInfo, libraryResult] = await Promise.all([
33
+ detectOllamaStatus(),
13
34
  detectOpenClawAuth(),
35
+ fetchOllamaLibrary(),
14
36
  ]);
15
37
 
38
+ // Filter out models the user already has installed
39
+ const installedNames = new Set(ollamaStatus.models.map(m => m.name.split(':')[0]));
40
+ const recommended = libraryResult.models.filter(r => {
41
+ const baseName = r.name.split(':')[0];
42
+ return !installedNames.has(baseName);
43
+ });
44
+
16
45
  return {
17
- local: ollamaModels,
46
+ local: ollamaStatus.models,
18
47
  cloud: openclawInfo.models,
19
48
  hasApiKey: openclawInfo.hasApiKey,
49
+ ollamaRunning: ollamaStatus.running,
50
+ ollamaInstalled: ollamaStatus.installed,
51
+ openclawInstalled: openclawInfo.installed,
52
+ recommended,
53
+ recommendedLive: libraryResult.live,
20
54
  };
21
55
  }
22
56
 
57
+ // ─── Ollama Library (live from ollama.com) ───────────────────
58
+
23
59
  /**
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.
60
+ * Fetch available models from the Ollama library API.
61
+ * Filters to models suitable for persistent agents (< MAX_RECOMMEND_SIZE).
62
+ * Falls back to a hardcoded list if the API is unreachable.
27
63
  */
28
- async function detectOllamaModels() {
64
+ async function fetchOllamaLibrary() {
65
+ try {
66
+ const response = await fetchWithTimeout('https://ollama.com/api/tags', 5000);
67
+ if (!response.ok) return { models: FALLBACK_RECOMMENDATIONS, live: false };
68
+
69
+ const data = await response.json();
70
+ if (!data.models || !Array.isArray(data.models)) return { models: FALLBACK_RECOMMENDATIONS, live: false };
71
+
72
+ // Filter to models that fit on a dev machine
73
+ const suitable = data.models
74
+ .filter(m => m.size > 0 && m.size <= MAX_RECOMMEND_SIZE)
75
+ .sort((a, b) => new Date(b.modified_at) - new Date(a.modified_at))
76
+ .map(m => {
77
+ const name = m.name || '';
78
+ const params = extractParams(name, m.size);
79
+ return {
80
+ name,
81
+ params,
82
+ size: formatBytes(m.size),
83
+ description: describeModel(name),
84
+ };
85
+ });
86
+
87
+ return suitable.length > 0
88
+ ? { models: suitable, live: true }
89
+ : { models: FALLBACK_RECOMMENDATIONS, live: false };
90
+ } catch {
91
+ return { models: FALLBACK_RECOMMENDATIONS, live: false };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generate a short description for a model based on its name.
97
+ */
98
+ function describeModel(name) {
99
+ const n = name.toLowerCase();
100
+ if (n.includes('devstral') || n.includes('codellama') || n.includes('deepseek-coder') || n.includes('coder'))
101
+ return 'Code-focused';
102
+ if (n.includes('gemma')) return 'Google, general purpose';
103
+ if (n.includes('llama')) return 'Meta, general purpose';
104
+ if (n.includes('mistral') || n.includes('ministral')) return 'Mistral AI';
105
+ if (n.includes('qwen')) return 'Alibaba, multilingual';
106
+ if (n.includes('nemotron')) return 'NVIDIA';
107
+ if (n.includes('gpt-oss')) return 'Open-source GPT variant';
108
+ return 'General purpose';
109
+ }
110
+
111
+ /**
112
+ * Extract parameter count from model name tag or estimate from size.
113
+ */
114
+ function extractParams(name, size) {
115
+ // Check for explicit param count in the name (e.g., "gemma3:4b", "ministral-3:8b")
116
+ const tagMatch = name.match(/:(\d+\.?\d*)([bBmM])/);
117
+ if (tagMatch) return `${tagMatch[1]}${tagMatch[2].toUpperCase()}`;
118
+
119
+ // Estimate from size
120
+ return guessParamsFromSize(size);
121
+ }
122
+
123
+ // ─── Ollama Local Status ────────────────────────────────────
124
+
125
+ async function detectOllamaStatus() {
126
+ const result = { installed: false, running: false, models: [] };
127
+
128
+ try {
129
+ await execAsync('which', ['ollama']);
130
+ result.installed = true;
131
+ } catch {
132
+ return result;
133
+ }
134
+
29
135
  try {
30
136
  const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
31
- if (!response.ok) return [];
137
+ if (!response.ok) return result;
32
138
 
139
+ result.running = true;
33
140
  const data = await response.json();
34
- if (!data.models || !Array.isArray(data.models)) return [];
141
+ if (!data.models || !Array.isArray(data.models)) return result;
35
142
 
36
- return data.models.map(m => {
143
+ result.models = data.models.map(m => {
37
144
  const name = (m.name || '').replace(':latest', '');
38
145
  if (!name) return null;
39
146
 
40
- // Use the real parameter count from the API when available
41
147
  const details = m.details || {};
42
148
  const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
43
149
  const quant = details.quantization_level || '';
@@ -53,77 +159,44 @@ async function detectOllamaModels() {
53
159
  };
54
160
  }).filter(Boolean);
55
161
  } catch {
56
- // Ollama not running or not installed
57
- return [];
162
+ // Ollama installed but not running
58
163
  }
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
164
 
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`;
165
+ return result;
94
166
  }
95
167
 
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
- }
168
+ // ─── OpenClaw Auth & Cloud Models ───────────────────────────
104
169
 
105
- /**
106
- * Check OpenClaw for configured models and API key status.
107
- */
108
170
  async function detectOpenClawAuth() {
109
- const result = { models: [], hasApiKey: false };
171
+ const result = { models: [], hasApiKey: false, installed: false };
110
172
 
111
173
  try {
112
- // Check if any API key is configured via openclaw models status
113
- const status = await execAsync('openclaw', ['models', 'status']);
174
+ await execAsync('which', ['openclaw']);
175
+ result.installed = true;
176
+ } catch {
177
+ result.models = [
178
+ { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
179
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
180
+ ];
181
+ return result;
182
+ }
114
183
 
115
- // Look for "api_key" or "configured" in the output
116
- if (status.includes('api_key') || status.includes('configured')) {
184
+ try {
185
+ const status = await execAsync('openclaw', ['models', 'status']);
186
+ // Check for API key in status output (e.g. "api_key=1" or "Configured models")
187
+ if (status.includes('api_key=') || status.includes('Configured models')) {
117
188
  result.hasApiKey = true;
118
189
  }
119
190
 
120
- // Get the model catalog for cloud options
121
- const list = await execAsync('openclaw', ['models', 'list', '--json']);
191
+ // Try JSON output first, fall back to text parsing
122
192
  try {
123
- const models = JSON.parse(list);
124
- if (Array.isArray(models)) {
125
- result.models = models
126
- .filter(m => m.available && !m.local)
193
+ const list = await execAsync('openclaw', ['models', 'list', '--json']);
194
+ const parsed = JSON.parse(list);
195
+ // Handle both { models: [...] } and bare array
196
+ const modelsArr = Array.isArray(parsed) ? parsed : (parsed.models || []);
197
+ if (modelsArr.length > 0) {
198
+ result.models = modelsArr
199
+ .filter(m => !m.local) // only cloud models
127
200
  .map(m => ({
128
201
  id: m.key || m.name,
129
202
  name: modelShortName(m.key || m.name),
@@ -132,17 +205,18 @@ async function detectOpenClawAuth() {
132
205
  }));
133
206
  }
134
207
  } 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);
208
+ // Fall back to text output parsing
209
+ try {
210
+ const text = await execAsync('openclaw', ['models', 'list']);
211
+ const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
212
+ result.models = lines.map(l => {
213
+ const id = l.trim().split(/\s+/)[0];
214
+ if (!id || id.length < 3) return null;
215
+ return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
216
+ }).filter(Boolean);
217
+ } catch { /* use fallback below */ }
143
218
  }
144
219
  } catch {
145
- // OpenClaw not installed or no models configured — use defaults
146
220
  result.models = [
147
221
  { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
148
222
  { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
@@ -152,21 +226,49 @@ async function detectOpenClawAuth() {
152
226
  return result;
153
227
  }
154
228
 
229
+ // ─── Formatting Helpers ─────────────────────────────────────
230
+
231
+ function formatParamCount(paramSize) {
232
+ if (!paramSize) return '';
233
+ const s = String(paramSize).trim();
234
+ if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
235
+ return s;
236
+ }
237
+
238
+ function guessParamsFromSize(bytes) {
239
+ if (!bytes || bytes <= 0) return '';
240
+ const gb = bytes / (1024 ** 3);
241
+ const billions = Math.round(gb * 2);
242
+ if (billions > 0) return `~${billions}B`;
243
+ return '';
244
+ }
245
+
246
+ function formatBytes(bytes) {
247
+ if (!bytes || bytes <= 0) return '';
248
+ const gb = bytes / (1024 ** 3);
249
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
250
+ const mb = bytes / (1024 ** 2);
251
+ return `${Math.round(mb)} MB`;
252
+ }
253
+
254
+ function fetchWithTimeout(url, timeoutMs) {
255
+ const controller = new AbortController();
256
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
257
+ return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
258
+ }
259
+
155
260
  /**
156
261
  * Short display name for a model ID.
157
262
  */
158
263
  export function modelShortName(model) {
159
264
  if (!model) return 'unknown';
160
- // Anthropic models
161
265
  if (model.startsWith('anthropic/')) {
162
266
  if (model.includes('haiku')) return 'Haiku';
163
267
  if (model.includes('sonnet')) return 'Sonnet';
164
268
  if (model.includes('opus')) return 'Opus';
165
269
  return model.slice('anthropic/'.length);
166
270
  }
167
- // Ollama models
168
271
  if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
169
- // Other: strip provider prefix
170
272
  if (model.includes('/')) return model.split('/').pop();
171
273
  return model;
172
274
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {