serpentstack 0.2.13 → 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.
@@ -174,18 +174,65 @@ function printPreflightStatus(hasOpenClaw, available) {
174
174
  return hasOpenClaw;
175
175
  }
176
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
+
177
224
  // ─── Model Picker ───────────────────────────────────────────
178
225
 
179
226
  async function pickModel(rl, agentName, currentModel, available) {
180
227
  const choices = [];
181
228
 
182
- // Local models first
229
+ // Section 1: Installed local models
183
230
  if (available.local.length > 0) {
184
- console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
231
+ console.log(` ${dim('── Installed')} ${green('ready')} ${dim('────────────────────')}`);
185
232
  for (const m of available.local) {
186
233
  const isCurrent = m.id === currentModel;
187
234
  const idx = choices.length;
188
- choices.push(m);
235
+ choices.push({ ...m, action: 'use' });
189
236
  const marker = isCurrent ? green('>') : ' ';
190
237
  const num = dim(`${idx + 1}.`);
191
238
  const label = isCurrent ? bold(m.name) : m.name;
@@ -197,14 +244,48 @@ async function pickModel(rl, agentName, currentModel, available) {
197
244
  }
198
245
  }
199
246
 
200
- // Cloud models
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
201
282
  if (available.cloud.length > 0) {
202
283
  const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
203
284
  console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
204
285
  for (const m of available.cloud) {
205
286
  const isCurrent = m.id === currentModel;
206
287
  const idx = choices.length;
207
- choices.push(m);
288
+ choices.push({ ...m, action: 'use' });
208
289
  const marker = isCurrent ? green('>') : ' ';
209
290
  const num = dim(`${idx + 1}.`);
210
291
  const label = isCurrent ? bold(m.name) : m.name;
@@ -216,13 +297,14 @@ async function pickModel(rl, agentName, currentModel, available) {
216
297
 
217
298
  if (choices.length === 0) {
218
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')}`);
219
301
  console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
220
302
  return currentModel;
221
303
  }
222
304
 
223
- // If current model isn't in either list, add it
305
+ // If current model isn't in any list, add it at the top
224
306
  if (!choices.some(c => c.id === currentModel)) {
225
- choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
307
+ choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
226
308
  console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
227
309
  }
228
310
 
@@ -234,7 +316,21 @@ async function pickModel(rl, agentName, currentModel, available) {
234
316
 
235
317
  const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
236
318
 
237
- if (selected.tier === 'cloud' && available.local.length > 0) {
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
+
332
+ // Warn about cloud model costs
333
+ if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
238
334
  warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
239
335
  }
240
336
  if (selected.tier === 'cloud' && !available.hasApiKey) {
@@ -183,16 +183,20 @@ async function detectOpenClawAuth() {
183
183
 
184
184
  try {
185
185
  const status = await execAsync('openclaw', ['models', 'status']);
186
- if (status.includes('api_key') || status.includes('configured')) {
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')) {
187
188
  result.hasApiKey = true;
188
189
  }
189
190
 
190
- const list = await execAsync('openclaw', ['models', 'list', '--json']);
191
+ // Try JSON output first, fall back to text parsing
191
192
  try {
192
- const models = JSON.parse(list);
193
- if (Array.isArray(models)) {
194
- result.models = models
195
- .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
196
200
  .map(m => ({
197
201
  id: m.key || m.name,
198
202
  name: modelShortName(m.key || m.name),
@@ -201,13 +205,16 @@ async function detectOpenClawAuth() {
201
205
  }));
202
206
  }
203
207
  } catch {
204
- const text = await execAsync('openclaw', ['models', 'list']);
205
- const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
206
- result.models = lines.map(l => {
207
- const id = l.trim().split(/\s+/)[0];
208
- if (!id || id.length < 3) return null;
209
- return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
210
- }).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 */ }
211
218
  }
212
219
  } catch {
213
220
  result.models = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.13",
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": {