serpentstack 0.2.6 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { error, bold, dim, green, cyan, getVersion, printHeader } from '../lib/utils/ui.js';
3
+ import { error, bold, dim, green, cyan, yellow, getVersion, printHeader, divider } from '../lib/utils/ui.js';
4
4
 
5
5
  // Short flag aliases
6
6
  const FLAG_ALIASES = { f: 'force', h: 'help', v: 'version', a: 'all' };
@@ -57,33 +57,36 @@ function editDistance(a, b) {
57
57
 
58
58
  function showHelp() {
59
59
  printHeader();
60
- console.log(` ${bold('Usage:')} serpentstack <command> [options]
61
-
62
- ${bold(green('New projects'))}
63
- ${cyan('stack new')} <name> Scaffold a full project from the template
64
- ${cyan('stack update')} Update template-level files to latest
65
-
66
- ${bold(green('Any project'))}
67
- ${cyan('skills')} Download base skills + persistent agent configs
68
- ${cyan('skills update')} Update base skills to latest versions
69
- ${cyan('persistent')} Manage and launch persistent agents
70
- ${cyan('persistent')} --stop Stop all running agents
71
- ${cyan('persistent')} --reconfigure Re-run setup (change models, enable/disable)
72
-
73
- ${bold('Options:')}
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
78
-
79
- ${dim('Examples:')}
80
- ${dim('$')} serpentstack stack new my-saas-app
81
- ${dim('$')} serpentstack skills
82
- ${dim('$')} serpentstack persistent
83
- ${dim('$')} serpentstack persistent --stop
84
-
85
- ${dim('Docs: https://github.com/Benja-Pauls/SerpentStack')}
86
- `);
60
+ console.log(` ${dim('Deployable fullstack templates + AI agent skills for any project')}`);
61
+ console.log();
62
+
63
+ divider('New projects');
64
+ console.log(` ${cyan('stack new')} ${dim('<name>')} Scaffold a full project from the template`);
65
+ console.log(` ${cyan('stack update')} Update template-level files to latest`);
66
+ console.log();
67
+
68
+ divider('Any project');
69
+ console.log(` ${cyan('skills')} Download all skills + persistent agent configs`);
70
+ console.log(` ${cyan('skills update')} Update base skills to latest versions`);
71
+ console.log(` ${cyan('persistent')} Manage and launch persistent background agents`);
72
+ console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
73
+ console.log(` ${cyan('persistent')} ${dim('--reconfigure')} Change models, enable/disable agents`);
74
+ console.log();
75
+
76
+ divider('Options');
77
+ console.log(` ${bold('-f')}, ${bold('--force')} Overwrite existing files`);
78
+ console.log(` ${bold('-a')}, ${bold('--all')} Include new files in updates`);
79
+ console.log(` ${bold('-v')}, ${bold('--version')} Show version`);
80
+ console.log(` ${bold('-h')}, ${bold('--help')} Show this help`);
81
+ console.log();
82
+
83
+ console.log(` ${dim('Examples:')}`);
84
+ console.log(` ${dim('$')} serpentstack stack new my-saas-app`);
85
+ console.log(` ${dim('$')} serpentstack skills`);
86
+ console.log(` ${dim('$')} serpentstack persistent`);
87
+ console.log();
88
+ console.log(` ${dim('Docs:')} ${cyan('https://github.com/Benja-Pauls/SerpentStack')}`);
89
+ console.log();
87
90
  }
88
91
 
89
92
  async function main() {
@@ -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, printBox, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
7
7
  import {
8
8
  parseAgentMd,
9
9
  discoverAgents,
@@ -53,7 +53,7 @@ async function pickModel(rl, agentName, currentModel, available) {
53
53
 
54
54
  // Local models first (free, fast, recommended)
55
55
  if (available.local.length > 0) {
56
- console.log(` ${dim('\u2500\u2500 Local models (free, no API key needed) \u2500\u2500')}`);
56
+ console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
57
57
  for (const m of available.local) {
58
58
  const isCurrent = m.id === currentModel;
59
59
  const idx = choices.length;
@@ -62,16 +62,17 @@ async function pickModel(rl, agentName, currentModel, available) {
62
62
  const num = dim(`${idx + 1}.`);
63
63
  const label = isCurrent ? bold(m.name) : m.name;
64
64
  const params = m.params ? dim(` ${m.params}`) : '';
65
+ const quant = m.quant ? dim(` ${m.quant}`) : '';
65
66
  const size = m.size ? dim(` (${m.size})`) : '';
66
67
  const tag = isCurrent ? green(' \u2190 current') : '';
67
- console.log(` ${marker} ${num} ${label}${params}${size}${tag}`);
68
+ console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
68
69
  }
69
70
  }
70
71
 
71
72
  // Cloud models (require API key, cost money)
72
73
  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')}`);
74
+ const apiNote = available.hasApiKey ? green('key ') : yellow('needs API key');
75
+ console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
75
76
  for (const m of available.cloud) {
76
77
  const isCurrent = m.id === currentModel;
77
78
  const idx = choices.length;
@@ -225,11 +226,11 @@ function printAgentLine(name, agentMd, config, statusInfo) {
225
226
  const modelStr = modelShortName(model);
226
227
 
227
228
  if (statusInfo.status === 'running') {
228
- console.log(` ${green('\u25CF')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
229
+ console.log(` ${green('')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
229
230
  } else if (statusInfo.status === 'disabled') {
230
- console.log(` ${dim('\u25CB')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
231
+ console.log(` ${dim('')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
231
232
  } else {
232
- console.log(` ${yellow('\u25CB')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('stopped')}`);
233
+ console.log(` ${yellow('')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
233
234
  }
234
235
  }
235
236
 
@@ -305,8 +306,8 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
305
306
 
306
307
  // ── If configured, show status dashboard ──
307
308
  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}`)}`);
309
+ console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
310
+ console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
310
311
  console.log();
311
312
 
312
313
  for (const { name, agentMd } of parsed) {
@@ -363,12 +364,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
363
364
  };
364
365
 
365
366
  if (templateDefaults && !existing.name) {
366
- info('Detected SerpentStack template \u2014 defaults pre-filled');
367
+ info('Detected SerpentStack template defaults pre-filled');
367
368
  console.log();
368
369
  }
369
370
 
370
- console.log(` ${bold('Project')}`);
371
- console.log(` ${dim('Press Enter to keep current values.')}`);
371
+ divider('Project');
372
+ console.log(` ${dim('Press Enter to keep defaults.')}`);
372
373
  console.log();
373
374
 
374
375
  config.project = {
@@ -417,14 +418,14 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
417
418
  }
418
419
  console.log();
419
420
 
420
- console.log(` ${bold('Agents')}`);
421
- console.log(` ${dim('Enable/disable each agent and choose its model.')}`);
421
+ divider('Agents');
422
+ console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
422
423
  console.log();
423
424
 
424
425
  for (const { name, agentMd } of parsed) {
425
426
  const existingAgent = config.agents?.[name];
426
427
  const currentEnabled = existingAgent?.enabled !== false;
427
- const currentModel = existingAgent?.model || agentMd.meta.model || 'anthropic/claude-haiku-4-20250414';
428
+ const currentModel = existingAgent?.model || 'ollama/llama3.2';
428
429
  const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
429
430
 
430
431
  console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
@@ -481,7 +482,7 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
481
482
  const toStart = [];
482
483
 
483
484
  try {
484
- console.log(` ${dim('Select agents to launch:')}`);
485
+ divider('Launch');
485
486
  console.log();
486
487
 
487
488
  for (const agent of agentsToStart) {
@@ -548,12 +549,12 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
548
549
 
549
550
  console.log();
550
551
  if (started > 0) {
551
- success(`${started} agent(s) launched`);
552
+ success(`${started} agent(s) launched — fangs out 🐍`);
552
553
  console.log();
553
554
  printBox('Manage agents', [
554
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start agents')}`,
555
- `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
556
- `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models, enable/disable')}`,
555
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start')}`,
556
+ `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
557
+ `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models')}`,
557
558
  ]);
558
559
  }
559
560
  }
@@ -4,13 +4,19 @@ import { downloadFile } from '../utils/github.js';
4
4
  import { safeWrite } from '../utils/fs-helpers.js';
5
5
  import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
6
6
  import { parseAgentMd, discoverAgents } from '../utils/agent-utils.js';
7
- import { info, success, warn, error, spinner, bold, dim, green, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
7
+ import { info, success, warn, error, spinner, bold, dim, green, cyan, divider, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
8
8
 
9
9
  const SKILLS_FILES = [
10
+ '.skills/auth/SKILL.md',
11
+ '.skills/db-migrate/SKILL.md',
12
+ '.skills/deploy/SKILL.md',
13
+ '.skills/dev-server/SKILL.md',
10
14
  '.skills/find-skills/SKILL.md',
11
15
  '.skills/generate-skills/SKILL.md',
12
16
  '.skills/git-workflow/SKILL.md',
13
17
  '.skills/model-routing/SKILL.md',
18
+ '.skills/scaffold/SKILL.md',
19
+ '.skills/test/SKILL.md',
14
20
  ];
15
21
 
16
22
  const OPENCLAW_FILES = [
@@ -29,13 +35,9 @@ const MANIFEST = [...SKILLS_FILES, ...OPENCLAW_FILES, ...DOCS_FILES];
29
35
  export async function skillsInit({ force = false } = {}) {
30
36
  printHeader();
31
37
 
32
- // Step 1: Download files
33
- console.log(` ${bold('Downloading')} ${dim('skills + persistent agent configs')}`);
34
- console.log();
35
-
36
38
  const results = { created: 0, skipped: 0, overwritten: 0, unchanged: 0, failed: 0 };
37
39
  const logs = [];
38
- const spin = spinner('Fetching from GitHub...');
40
+ const spin = spinner('Slithering through GitHub...');
39
41
 
40
42
  try {
41
43
  for (const repoPath of MANIFEST) {
@@ -55,24 +57,26 @@ export async function skillsInit({ force = false } = {}) {
55
57
  }
56
58
 
57
59
  // Group output by section
58
- console.log(` ${dim('IDE Agent Skills')}`);
60
+ divider('Skills');
59
61
  for (let i = 0; i < SKILLS_FILES.length; i++) logs[i] && console.log(logs[i]);
60
62
  console.log();
61
- console.log(` ${dim('Persistent Agent (OpenClaw)')}`);
63
+
64
+ divider('Persistent Agents');
62
65
  for (let i = SKILLS_FILES.length; i < SKILLS_FILES.length + OPENCLAW_FILES.length; i++) logs[i] && console.log(logs[i]);
63
66
  console.log();
64
- console.log(` ${dim('Documentation')}`);
67
+
68
+ divider('Docs');
65
69
  for (let i = SKILLS_FILES.length + OPENCLAW_FILES.length; i < logs.length; i++) logs[i] && console.log(logs[i]);
66
70
  console.log();
67
71
 
68
72
  // Summary line
69
73
  const parts = [];
70
74
  if (results.created > 0) parts.push(green(`${results.created} created`));
71
- if (results.overwritten > 0) parts.push(`${results.overwritten} updated`);
75
+ if (results.overwritten > 0) parts.push(cyan(`${results.overwritten} updated`));
72
76
  if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
73
77
  if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
74
78
  if (results.failed > 0) parts.push(`${results.failed} failed`);
75
- console.log(` ${parts.join(dim(' \u2022 '))}`);
79
+ console.log(` ${parts.join(dim(' · '))}`);
76
80
 
77
81
  if (results.failed === MANIFEST.length) {
78
82
  console.log();
@@ -108,17 +112,15 @@ export async function skillsInit({ force = false } = {}) {
108
112
  writeConfig(projectDir, config);
109
113
  console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
110
114
  results.created++;
111
- console.log();
112
115
  }
113
116
 
114
117
  // Next steps
115
118
  console.log();
116
- console.log(` ${bold('Next')} ${dim('\u2014 Generate project-specific skills')}`);
119
+ divider('What\'s next');
117
120
  console.log();
118
- console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
119
- console.log(` ${dim('and paste the prompt below. The agent will read your codebase,')}`);
120
- console.log(` ${dim('interview you about your conventions, and produce a full')}`);
121
- console.log(` ${dim('.skills/ directory tailored to your project.')}`);
121
+ console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, Gemini CLI, etc.)')}`);
122
+ console.log(` ${dim('and paste this prompt. It reads your codebase and generates')}`);
123
+ console.log(` ${dim('project-specific skills tailored to your stack.')}`);
122
124
 
123
125
  printPrompt([
124
126
  `Read .skills/generate-skills/SKILL.md and follow its instructions`,
@@ -133,11 +135,12 @@ export async function skillsInit({ force = false } = {}) {
133
135
  `Reference SKILL-AUTHORING.md for the format.`,
134
136
  ]);
135
137
 
136
- printBox('After generating skills, try setting up persistent agents too', [
137
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# choose agents + launch')}`,
138
+ printBox('Want persistent background agents too?', [
139
+ `${dim('$')} ${bold('serpentstack persistent')}`,
138
140
  '',
139
- `${dim('Background agents that watch your dev server, run tests,')}`,
140
- `${dim('and keep your skills up to date. Each opens in its own')}`,
141
- `${dim('terminal window. Pick which to run and choose your models.')}`,
141
+ `${dim('Agents that watch your dev server, run tests, and keep')}`,
142
+ `${dim('skills fresh each in its own terminal. Pick which to')}`,
143
+ `${dim('run and choose local or cloud models.')}`,
142
144
  ]);
145
+ console.log();
143
146
  }
@@ -2,13 +2,19 @@ import { join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { downloadFile } from '../utils/github.js';
4
4
  import { safeWrite } from '../utils/fs-helpers.js';
5
- import { info, success, warn, error, spinner, bold, dim, green, printHeader, fileStatus } from '../utils/ui.js';
5
+ import { info, success, warn, error, spinner, bold, dim, green, cyan, printHeader, fileStatus } from '../utils/ui.js';
6
6
 
7
7
  const MANIFEST = [
8
+ '.skills/auth/SKILL.md',
9
+ '.skills/db-migrate/SKILL.md',
10
+ '.skills/deploy/SKILL.md',
11
+ '.skills/dev-server/SKILL.md',
8
12
  '.skills/find-skills/SKILL.md',
9
13
  '.skills/generate-skills/SKILL.md',
10
14
  '.skills/git-workflow/SKILL.md',
11
15
  '.skills/model-routing/SKILL.md',
16
+ '.skills/scaffold/SKILL.md',
17
+ '.skills/test/SKILL.md',
12
18
  '.openclaw/SOUL.md',
13
19
  '.openclaw/agents/log-watcher/AGENT.md',
14
20
  '.openclaw/agents/test-runner/AGENT.md',
@@ -27,11 +33,8 @@ const CUSTOMIZABLE = new Set([
27
33
  export async function skillsUpdate({ force = false, all = false } = {}) {
28
34
  printHeader();
29
35
 
30
- info(`Checking for updates in ${bold(process.cwd())}`);
31
- console.log();
32
-
33
36
  const results = { updated: 0, skipped: 0, unchanged: 0, failed: 0 };
34
- const spin = spinner('Comparing with latest versions...');
37
+ const spin = spinner('Checking for updates...');
35
38
  const logs = [];
36
39
 
37
40
  try {
@@ -83,12 +86,11 @@ export async function skillsUpdate({ force = false, all = false } = {}) {
83
86
  if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
84
87
  if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
85
88
  if (results.failed > 0) parts.push(`${results.failed} failed`);
86
- console.log(` ${parts.join(dim(' \u2022 '))}`);
89
+ console.log(` ${parts.join(dim(' · '))}`);
87
90
 
88
91
  if (results.updated > 0) {
89
92
  console.log();
90
- info(`${dim('Your project-specific skills (generated by your IDE agent) are not affected.')}`);
91
- info(`${dim('Only the base SerpentStack files were updated.')}`);
93
+ info(`${dim('Only base SerpentStack files were updated — your custom skills are untouched.')}`);
92
94
  }
93
95
  console.log();
94
96
  }
@@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { cloneRepo, checkGit } from '../utils/github.js';
6
- import { info, success, warn, error, spinner, bold, dim, green, cyan, printBox, printPrompt, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, spinner, bold, dim, green, yellow, cyan, divider, printBox, printPrompt, printHeader } from '../utils/ui.js';
7
7
 
8
8
  const CLEANUP_PATHS = [
9
9
  'cli',
@@ -60,7 +60,7 @@ export async function stackNew(name) {
60
60
  printHeader();
61
61
 
62
62
  // Step 1: Check prerequisites
63
- console.log(` ${bold('Step 1/4')} ${dim('\u2014 Checking prerequisites')}`);
63
+ divider('Prerequisites');
64
64
  console.log();
65
65
 
66
66
  const checks = [
@@ -90,13 +90,13 @@ export async function stackNew(name) {
90
90
  let missing = [];
91
91
  for (const r of results) {
92
92
  if (!r.found) {
93
- console.log(` ${dim('\u2022')} ${dim(r.label)} ${dim(`\u2014 install: ${r.url}`)}`);
93
+ console.log(` ${dim('')} ${dim(r.label)} ${dim(`— install: ${r.url}`)}`);
94
94
  missing.push(r.label);
95
95
  } else if (r.versionOk === false) {
96
- console.log(` ${yellow('\u25B3')} ${r.label} ${dim(`\u2014 found ${r.version}, need newer version`)}`);
96
+ console.log(` ${yellow('')} ${r.label} ${dim(`— found ${r.version}, need newer version`)}`);
97
97
  missing.push(r.label);
98
98
  } else {
99
- console.log(` ${green('\u2713')} ${r.label}`);
99
+ console.log(` ${green('')} ${r.label}`);
100
100
  }
101
101
  }
102
102
  console.log();
@@ -113,10 +113,10 @@ export async function stackNew(name) {
113
113
  }
114
114
 
115
115
  // Step 2: Clone template
116
- console.log(` ${bold('Step 2/4')} ${dim('\u2014 Cloning template')}`);
116
+ divider('Clone');
117
117
  console.log();
118
118
 
119
- const spin = spinner(`Downloading SerpentStack template...`);
119
+ const spin = spinner('Slithering through GitHub...');
120
120
  try {
121
121
  await cloneRepo(dest);
122
122
  spin.stop();
@@ -129,7 +129,7 @@ export async function stackNew(name) {
129
129
 
130
130
  // Step 3: Clean up repo-specific files
131
131
  console.log();
132
- console.log(` ${bold('Step 3/4')} ${dim('\u2014 Preparing project')}`);
132
+ divider('Prepare');
133
133
  console.log();
134
134
 
135
135
  for (const p of CLEANUP_PATHS) {
@@ -150,16 +150,16 @@ export async function stackNew(name) {
150
150
 
151
151
  // Step 4: Summary
152
152
  console.log();
153
- console.log(` ${bold('Step 4/4')} ${dim('\u2014 Done!')}`);
153
+ divider('Ready 🐍');
154
154
  console.log();
155
155
 
156
156
  console.log(` ${dim('Your project includes:')}`);
157
- console.log(` ${green('\u2713')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership enforcement)')}`);
158
- console.log(` ${green('\u2713')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
159
- console.log(` ${green('\u2713')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
160
- console.log(` ${green('\u2713')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
161
- console.log(` ${green('\u2713')} 10 project-specific Agent Skills ${dim('(.skills/)')}`);
162
- console.log(` ${green('\u2713')} Persistent agent configs ${dim('(.openclaw/)')}`);
157
+ console.log(` ${green('')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership)')}`);
158
+ console.log(` ${green('')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
159
+ console.log(` ${green('')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
160
+ console.log(` ${green('')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
161
+ console.log(` ${green('')} 10 agent skills ${dim('(.skills/)')}`);
162
+ console.log(` ${green('')} Persistent agent configs ${dim('(.openclaw/)')}`);
163
163
  console.log();
164
164
 
165
165
  printBox('Get started', [
@@ -164,7 +164,7 @@ function generateHeartbeat(schedule) {
164
164
  }
165
165
 
166
166
  function generateAgentsConfig(meta) {
167
- const model = meta.model || 'anthropic/claude-haiku-4-20250414';
167
+ const model = meta.model || 'ollama/llama3.2';
168
168
  const tools = meta.tools || ['file-system', 'shell', 'git'];
169
169
 
170
170
  return [
@@ -3,12 +3,16 @@ import { join } from 'node:path';
3
3
 
4
4
  const CONFIG_PATH = '.openclaw/config.json';
5
5
 
6
+ // Default model for persistent agents — local first, always.
7
+ // Cloud models cost tokens per heartbeat and require API keys.
8
+ const DEFAULT_MODEL = 'ollama/llama3.2';
9
+
6
10
  /**
7
11
  * Default config structure:
8
12
  * {
9
13
  * project: { name, language, framework, devCmd, testCmd, conventions },
10
14
  * agents: {
11
- * "log-watcher": { enabled: true, model: "anthropic/claude-haiku-4-20250414" },
15
+ * "log-watcher": { enabled: true, model: "ollama/llama3.2" },
12
16
  * ...
13
17
  * }
14
18
  * }
@@ -49,9 +53,9 @@ export function detectTemplateDefaults(projectDir) {
49
53
 
50
54
  try {
51
55
  const content = readFileSync(makefile, 'utf8');
52
- // SerpentStack Makefile: must have 'make verify' AND either 'uv run' or 'uvicorn'
53
- if (!content.includes('make verify')) return null;
54
- if (!content.includes('uv run') && !content.includes('uvicorn')) return null;
56
+ // SerpentStack Makefile: must have a 'verify:' target AND 'uv run' (Python tooling)
57
+ if (!/^verify:/m.test(content)) return null;
58
+ if (!content.includes('uv run')) return null;
55
59
 
56
60
  // It's a SerpentStack template project — return smart defaults
57
61
  const defaults = {
@@ -83,7 +87,7 @@ export function detectTemplateDefaults(projectDir) {
83
87
  export function defaultAgentConfig(meta) {
84
88
  return {
85
89
  enabled: true,
86
- model: meta.model || 'anthropic/claude-haiku-4-20250414',
90
+ model: DEFAULT_MODEL,
87
91
  };
88
92
  }
89
93
 
@@ -94,7 +98,7 @@ export function getEffectiveModel(agentName, agentMeta, config) {
94
98
  if (config?.agents?.[agentName]?.model) {
95
99
  return config.agents[agentName].model;
96
100
  }
97
- return agentMeta.model || 'anthropic/claude-haiku-4-20250414';
101
+ return DEFAULT_MODEL;
98
102
  }
99
103
 
100
104
  /**
@@ -21,62 +21,87 @@ export async function detectModels() {
21
21
  }
22
22
 
23
23
  /**
24
- * Detect locally installed Ollama models with parameter counts.
25
- * Parses `ollama list` output.
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.
26
27
  */
27
28
  async function detectOllamaModels() {
28
29
  try {
29
- const output = await execAsync('ollama', ['list']);
30
- const lines = output.trim().split('\n');
31
- if (lines.length < 2) return []; // header only
30
+ const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
31
+ if (!response.ok) return [];
32
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');
33
+ const data = await response.json();
34
+ if (!data.models || !Array.isArray(data.models)) return [];
37
35
 
38
- return lines.slice(1).map(line => {
39
- if (!line.trim()) return null;
40
-
41
- const name = line.slice(0, nameEnd).trim();
36
+ return data.models.map(m => {
37
+ const name = (m.name || '').replace(':latest', '');
42
38
  if (!name) return null;
43
39
 
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', '');
40
+ // Use the real parameter count from the API when available
41
+ const details = m.details || {};
42
+ const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
43
+ const quant = details.quantization_level || '';
44
+ const sizeStr = formatBytes(m.size);
52
45
 
53
46
  return {
54
- id: `ollama/${shortName}`,
55
- name: shortName,
47
+ id: `ollama/${name}`,
48
+ name,
56
49
  params,
50
+ quant,
57
51
  size: sizeStr,
58
52
  tier: 'local',
59
53
  };
60
54
  }).filter(Boolean);
61
55
  } catch {
56
+ // Ollama not running or not installed
62
57
  return [];
63
58
  }
64
59
  }
65
60
 
66
61
  /**
67
- * Guess parameter count from file size if not in the name.
62
+ * Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
63
+ * The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
64
+ */
65
+ function formatParamCount(paramSize) {
66
+ if (!paramSize) return '';
67
+ // Already formatted like "7B" or "3.2B"
68
+ const s = String(paramSize).trim();
69
+ if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
70
+ return s;
71
+ }
72
+
73
+ /**
74
+ * Fallback: estimate parameter count from file size.
68
75
  * Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
69
76
  */
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
+ 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`;
77
82
  return '';
78
83
  }
79
84
 
85
+ /**
86
+ * Format bytes into human-readable size (e.g., "4.7 GB").
87
+ */
88
+ function formatBytes(bytes) {
89
+ if (!bytes || bytes <= 0) return '';
90
+ const gb = bytes / (1024 ** 3);
91
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
92
+ const mb = bytes / (1024 ** 2);
93
+ return `${Math.round(mb)} MB`;
94
+ }
95
+
96
+ /**
97
+ * Fetch with timeout using AbortController (Node 18+).
98
+ */
99
+ function fetchWithTimeout(url, timeoutMs) {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
102
+ return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
103
+ }
104
+
80
105
  /**
81
106
  * Check OpenClaw for configured models and API key status.
82
107
  */
package/lib/utils/ui.js CHANGED
@@ -17,10 +17,17 @@ 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
+ // ─── Brand ───────────────────────────────────────────────────
21
+
22
+ const SNAKE = '🐍';
23
+ const BRAND_COLOR = GREEN;
24
+
25
+ // ─── Text formatters ─────────────────────────────────────────
26
+
27
+ export const info = (msg) => console.log(` ${CYAN}•${RESET} ${msg}`);
28
+ export const success = (msg) => console.log(` ${GREEN}✓${RESET} ${msg}`);
29
+ export const warn = (msg) => console.log(` ${YELLOW}△${RESET} ${msg}`);
30
+ export const error = (msg) => console.error(` ${RED}✗${RESET} ${msg}`);
24
31
  export const dim = (msg) => `${DIM}${msg}${RESET}`;
25
32
  export const bold = (msg) => `${BOLD}${msg}${RESET}`;
26
33
  export const green = (msg) => `${GREEN}${msg}${RESET}`;
@@ -30,18 +37,22 @@ export const cyan = (msg) => `${CYAN}${msg}${RESET}`;
30
37
  export const magenta = (msg) => `${MAGENTA}${msg}${RESET}`;
31
38
  export const blue = (msg) => `${BLUE}${msg}${RESET}`;
32
39
 
33
- const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
40
+ // ─── Spinner ─────────────────────────────────────────────────
41
+
42
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
34
43
 
35
44
  export function spinner(msg) {
36
45
  if (NO_COLOR || !stderr.isTTY) {
37
46
  stderr.write(` ${msg}\n`);
38
- return { stop(final) { if (final) stderr.write(` ${final}\n`); } };
47
+ return { stop(final) { if (final) stderr.write(` ${final}\n`); }, update() {} };
39
48
  }
40
49
  let i = 0;
50
+ let currentMsg = msg;
41
51
  const id = setInterval(() => {
42
- stderr.write(`\r${CYAN}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]}${RESET} ${msg}`);
43
- }, 80);
52
+ stderr.write(`\r ${GREEN}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]}${RESET} ${currentMsg}`);
53
+ }, 100);
44
54
  return {
55
+ update(newMsg) { currentMsg = newMsg; },
45
56
  stop(final) {
46
57
  clearInterval(id);
47
58
  stderr.write(`\r\x1b[K`);
@@ -50,16 +61,20 @@ export function spinner(msg) {
50
61
  };
51
62
  }
52
63
 
64
+ // ─── Confirm ─────────────────────────────────────────────────
65
+
53
66
  export async function confirm(msg) {
54
67
  const rl = createInterface({ input: stdin, output: stdout });
55
68
  try {
56
- const answer = await rl.question(`${YELLOW}?${RESET} ${msg} ${dim('(y/N)')} `);
69
+ const answer = await rl.question(`${YELLOW}?${RESET} ${msg} ${DIM}(y/N)${RESET} `);
57
70
  return answer.trim().toLowerCase() === 'y';
58
71
  } finally {
59
72
  rl.close();
60
73
  }
61
74
  }
62
75
 
76
+ // ─── Version ─────────────────────────────────────────────────
77
+
63
78
  export function getVersion() {
64
79
  try {
65
80
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -69,36 +84,44 @@ export function getVersion() {
69
84
  }
70
85
  }
71
86
 
87
+ // ─── Headers & Dividers ──────────────────────────────────────
88
+
72
89
  export function printHeader() {
73
- console.log(`\n ${BOLD}${GREEN}\u2728 SerpentStack${RESET} ${DIM}v${getVersion()}${RESET}\n`);
90
+ console.log();
91
+ console.log(` ${SNAKE} ${BOLD}${GREEN}SerpentStack${RESET} ${DIM}v${getVersion()}${RESET}`);
92
+ console.log();
74
93
  }
75
94
 
95
+ export function divider(label) {
96
+ if (label) {
97
+ console.log(` ${DIM}── ${RESET}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
98
+ } else {
99
+ console.log(` ${DIM}${'─'.repeat(54)}${RESET}`);
100
+ }
101
+ }
102
+
103
+ // ─── Boxes ───────────────────────────────────────────────────
104
+
76
105
  /**
77
106
  * Print a boxed section with a title and content lines.
78
- * Used for "Next steps" and prompt blocks.
79
107
  */
80
- export function printBox(title, lines, { color = GREEN, icon = '\u25B6' } = {}) {
81
- const maxLen = Math.max(
82
- title.length + 4,
83
- ...lines.map(l => stripAnsi(l).length + 4)
84
- );
108
+ export function printBox(title, lines, { color = GREEN, icon = '' } = {}) {
109
+ const allText = [title, ...lines];
110
+ const maxLen = Math.max(...allText.map(l => stripAnsi(l).length + 4));
85
111
  const width = Math.min(Math.max(maxLen, 50), 80);
86
- const top = `${color}\u250C${'─'.repeat(width)}\u2510${RESET}`;
87
- const bot = `${color}\u2514${'─'.repeat(width)}\u2518${RESET}`;
88
112
 
89
- console.log(top);
90
- console.log(`${color}\u2502${RESET} ${BOLD}${icon} ${title}${RESET}${' '.repeat(Math.max(0, width - stripAnsi(title).length - 3))}${color}\u2502${RESET}`);
91
- console.log(`${color}\u2502${' '.repeat(width)}${color}\u2502${RESET}`);
113
+ console.log(` ${DIM}┌${'─'.repeat(width)}┐${RESET}`);
114
+ console.log(` ${DIM}│${RESET} ${BOLD}${icon} ${title}${RESET}${' '.repeat(Math.max(0, width - stripAnsi(title).length - 3))}${DIM}│${RESET}`);
115
+ console.log(` ${DIM}│${' '.repeat(width)}│${RESET}`);
92
116
  for (const line of lines) {
93
117
  const pad = Math.max(0, width - stripAnsi(line).length - 2);
94
- console.log(`${color}\u2502${RESET} ${line}${' '.repeat(pad)}${color}\u2502${RESET}`);
118
+ console.log(` ${DIM}│${RESET} ${line}${' '.repeat(pad)}${DIM}│${RESET}`);
95
119
  }
96
- console.log(bot);
120
+ console.log(` ${DIM}└${'─'.repeat(width)}┘${RESET}`);
97
121
  }
98
122
 
99
123
  /**
100
- * Print a copyable prompt block that the user can paste into their IDE agent.
101
- * Accepts a single string or an array of lines for multi-line prompts.
124
+ * Print a copyable prompt block.
102
125
  */
103
126
  export function printPrompt(promptLines, { hint = 'Copy this prompt and paste it into your IDE agent' } = {}) {
104
127
  const lines = Array.isArray(promptLines) ? promptLines : [promptLines];
@@ -107,45 +130,43 @@ export function printPrompt(promptLines, { hint = 'Copy this prompt and paste it
107
130
  const innerWidth = width - 2;
108
131
 
109
132
  console.log();
110
- console.log(` ${DIM}\u250C${'─'.repeat(innerWidth)}\u2510${RESET}`);
133
+ console.log(` ${DIM}┌${'─'.repeat(innerWidth)}┐${RESET}`);
111
134
  for (const line of lines) {
112
135
  const pad = Math.max(0, innerWidth - stripAnsi(line).length - 2);
113
- console.log(` ${DIM}\u2502${RESET} ${CYAN}${line}${RESET}${' '.repeat(pad)}${DIM}\u2502${RESET}`);
136
+ console.log(` ${DIM}│${RESET} ${CYAN}${line}${RESET}${' '.repeat(pad)}${DIM}│${RESET}`);
114
137
  }
115
- console.log(` ${DIM}\u2514${'─'.repeat(innerWidth)}\u2518${RESET}`);
116
- console.log(` ${DIM}\u2191 ${hint}${RESET}`);
138
+ console.log(` ${DIM}└${'─'.repeat(innerWidth)}┘${RESET}`);
139
+ console.log(` ${DIM} ${hint}${RESET}`);
117
140
  console.log();
118
141
  }
119
142
 
120
- /**
121
- * File status icon with color.
122
- */
143
+ // ─── File Status ─────────────────────────────────────────────
144
+
123
145
  export function fileIcon(status) {
124
146
  switch (status) {
125
- case 'created': return `${GREEN}\u2713${RESET}`;
126
- case 'overwritten': return `${CYAN}\u21BB${RESET}`;
127
- case 'skipped': return `${YELLOW}\u2022${RESET}`;
128
- case 'failed': return `${RED}\u2717${RESET}`;
129
- case 'unchanged': return `${DIM}\u2022${RESET}`;
130
- default: return `${DIM}\u2022${RESET}`;
147
+ case 'created': return `${GREEN}✓${RESET}`;
148
+ case 'overwritten': return `${CYAN}↻${RESET}`;
149
+ case 'skipped': return `${YELLOW}•${RESET}`;
150
+ case 'failed': return `${RED}✗${RESET}`;
151
+ case 'unchanged': return `${DIM}•${RESET}`;
152
+ default: return `${DIM}•${RESET}`;
131
153
  }
132
154
  }
133
155
 
134
- /**
135
- * Format a file path with its status for display.
136
- */
137
156
  export function fileStatus(path, status, detail) {
138
157
  const icon = fileIcon(status);
139
158
  switch (status) {
140
- case 'created': return ` ${icon} ${green(path)}`;
141
- case 'overwritten': return ` ${icon} ${cyan(path)} ${dim('(updated)')}`;
142
- case 'skipped': return ` ${icon} ${dim(`${path} (${detail || 'exists, skipped'})`)}`;
143
- case 'failed': return ` ${icon} ${red(path)} ${dim(`\u2014 ${detail}`)}`;
144
- case 'unchanged': return ` ${icon} ${dim(`${path} (up to date)`)}`;
145
- default: return ` ${icon} ${path}`;
159
+ case 'created': return ` ${icon} ${path}`;
160
+ case 'overwritten': return ` ${icon} ${cyan(path)} ${dim('(updated)')}`;
161
+ case 'skipped': return ` ${icon} ${dim(`${path} (${detail || 'exists, skipped'})`)}`;
162
+ case 'failed': return ` ${icon} ${red(path)} ${dim(`— ${detail}`)}`;
163
+ case 'unchanged': return ` ${icon} ${dim(`${path} (up to date)`)}`;
164
+ default: return ` ${icon} ${path}`;
146
165
  }
147
166
  }
148
167
 
149
- function stripAnsi(str) {
168
+ // ─── Utilities ───────────────────────────────────────────────
169
+
170
+ export function stripAnsi(str) {
150
171
  return str.replace(/\x1b\[[\d;]*m/g, '');
151
172
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {
7
- "serpentstack": "./bin/serpentstack.js"
7
+ "serpentstack": "bin/serpentstack.js"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=22"
@@ -27,7 +27,7 @@
27
27
  "license": "MIT",
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "https://github.com/Benja-Pauls/SerpentStack"
30
+ "url": "git+https://github.com/Benja-Pauls/SerpentStack.git"
31
31
  },
32
32
  "homepage": "https://github.com/Benja-Pauls/SerpentStack#readme",
33
33
  "author": "Ben Paulson"