serpentstack 0.2.6 → 0.2.10
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.
- package/bin/serpentstack.js +31 -28
- package/lib/commands/persistent.js +32 -29
- package/lib/commands/skills-init.js +36 -30
- package/lib/commands/skills-update.js +10 -8
- package/lib/commands/stack-new.js +15 -15
- package/lib/utils/agent-utils.js +1 -1
- package/lib/utils/config.js +241 -13
- package/lib/utils/models.js +56 -31
- package/lib/utils/ui.js +69 -48
- package/package.json +3 -3
package/bin/serpentstack.js
CHANGED
|
@@ -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(` ${
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
${cyan('stack
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
${cyan('
|
|
70
|
-
${cyan('
|
|
71
|
-
${cyan('persistent')}
|
|
72
|
-
|
|
73
|
-
${
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
${
|
|
80
|
-
${
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
${dim('
|
|
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,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import {
|
|
19
19
|
readConfig,
|
|
20
20
|
writeConfig,
|
|
21
|
+
detectProjectDefaults,
|
|
21
22
|
detectTemplateDefaults,
|
|
22
23
|
getEffectiveModel,
|
|
23
24
|
isAgentEnabled,
|
|
@@ -53,7 +54,7 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
53
54
|
|
|
54
55
|
// Local models first (free, fast, recommended)
|
|
55
56
|
if (available.local.length > 0) {
|
|
56
|
-
console.log(` ${dim('
|
|
57
|
+
console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
|
|
57
58
|
for (const m of available.local) {
|
|
58
59
|
const isCurrent = m.id === currentModel;
|
|
59
60
|
const idx = choices.length;
|
|
@@ -62,16 +63,17 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
62
63
|
const num = dim(`${idx + 1}.`);
|
|
63
64
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
64
65
|
const params = m.params ? dim(` ${m.params}`) : '';
|
|
66
|
+
const quant = m.quant ? dim(` ${m.quant}`) : '';
|
|
65
67
|
const size = m.size ? dim(` (${m.size})`) : '';
|
|
66
68
|
const tag = isCurrent ? green(' \u2190 current') : '';
|
|
67
|
-
console.log(` ${marker} ${num} ${label}${params}${size}${tag}`);
|
|
69
|
+
console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
// Cloud models (require API key, cost money)
|
|
72
74
|
if (available.cloud.length > 0) {
|
|
73
|
-
const apiNote = available.hasApiKey ?
|
|
74
|
-
console.log(` ${dim('
|
|
75
|
+
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
76
|
+
console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
|
|
75
77
|
for (const m of available.cloud) {
|
|
76
78
|
const isCurrent = m.id === currentModel;
|
|
77
79
|
const idx = choices.length;
|
|
@@ -225,11 +227,11 @@ function printAgentLine(name, agentMd, config, statusInfo) {
|
|
|
225
227
|
const modelStr = modelShortName(model);
|
|
226
228
|
|
|
227
229
|
if (statusInfo.status === 'running') {
|
|
228
|
-
console.log(`
|
|
230
|
+
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
|
|
229
231
|
} else if (statusInfo.status === 'disabled') {
|
|
230
|
-
console.log(`
|
|
232
|
+
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
231
233
|
} else {
|
|
232
|
-
console.log(`
|
|
234
|
+
console.log(` ${yellow('○')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
|
|
@@ -305,8 +307,8 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
305
307
|
|
|
306
308
|
// ── If configured, show status dashboard ──
|
|
307
309
|
if (!needsSetup) {
|
|
308
|
-
console.log(` ${bold(config.project.name)} ${dim(
|
|
309
|
-
console.log(` ${dim(`Dev: ${config.project.devCmd}
|
|
310
|
+
console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
|
|
311
|
+
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
310
312
|
console.log();
|
|
311
313
|
|
|
312
314
|
for (const { name, agentMd } of parsed) {
|
|
@@ -351,24 +353,25 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
351
353
|
|
|
352
354
|
try {
|
|
353
355
|
// ── Project configuration ──
|
|
354
|
-
const
|
|
356
|
+
const detected = detectProjectDefaults(projectDir);
|
|
357
|
+
const template = detectTemplateDefaults(projectDir);
|
|
355
358
|
const existing = config.project || {};
|
|
356
359
|
const defaults = {
|
|
357
|
-
name: existing.name ||
|
|
358
|
-
language: existing.language ||
|
|
359
|
-
framework: existing.framework ||
|
|
360
|
-
devCmd: existing.devCmd ||
|
|
361
|
-
testCmd: existing.testCmd ||
|
|
362
|
-
conventions: existing.conventions ||
|
|
360
|
+
name: existing.name || template?.name || detected.name,
|
|
361
|
+
language: existing.language || template?.language || detected.language,
|
|
362
|
+
framework: existing.framework || template?.framework || detected.framework,
|
|
363
|
+
devCmd: existing.devCmd || template?.devCmd || detected.devCmd,
|
|
364
|
+
testCmd: existing.testCmd || template?.testCmd || detected.testCmd,
|
|
365
|
+
conventions: existing.conventions || template?.conventions || detected.conventions,
|
|
363
366
|
};
|
|
364
367
|
|
|
365
|
-
if (
|
|
366
|
-
info('Detected SerpentStack template
|
|
368
|
+
if (template && !existing.name) {
|
|
369
|
+
info('Detected SerpentStack template — defaults pre-filled');
|
|
367
370
|
console.log();
|
|
368
371
|
}
|
|
369
372
|
|
|
370
|
-
|
|
371
|
-
console.log(` ${dim('Press Enter to keep
|
|
373
|
+
divider('Project');
|
|
374
|
+
console.log(` ${dim('Press Enter to keep defaults.')}`);
|
|
372
375
|
console.log();
|
|
373
376
|
|
|
374
377
|
config.project = {
|
|
@@ -417,14 +420,14 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
417
420
|
}
|
|
418
421
|
console.log();
|
|
419
422
|
|
|
420
|
-
|
|
421
|
-
console.log(` ${dim('Enable/disable each agent and
|
|
423
|
+
divider('Agents');
|
|
424
|
+
console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
|
|
422
425
|
console.log();
|
|
423
426
|
|
|
424
427
|
for (const { name, agentMd } of parsed) {
|
|
425
428
|
const existingAgent = config.agents?.[name];
|
|
426
429
|
const currentEnabled = existingAgent?.enabled !== false;
|
|
427
|
-
const currentModel = existingAgent?.model ||
|
|
430
|
+
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
428
431
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
429
432
|
|
|
430
433
|
console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
|
|
@@ -481,7 +484,7 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
481
484
|
const toStart = [];
|
|
482
485
|
|
|
483
486
|
try {
|
|
484
|
-
|
|
487
|
+
divider('Launch');
|
|
485
488
|
console.log();
|
|
486
489
|
|
|
487
490
|
for (const agent of agentsToStart) {
|
|
@@ -548,12 +551,12 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
548
551
|
|
|
549
552
|
console.log();
|
|
550
553
|
if (started > 0) {
|
|
551
|
-
success(`${started} agent(s) launched
|
|
554
|
+
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
552
555
|
console.log();
|
|
553
556
|
printBox('Manage agents', [
|
|
554
|
-
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start
|
|
555
|
-
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all
|
|
556
|
-
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models
|
|
557
|
+
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start')}`,
|
|
558
|
+
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
559
|
+
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models')}`,
|
|
557
560
|
]);
|
|
558
561
|
}
|
|
559
562
|
}
|
|
@@ -2,15 +2,21 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { downloadFile } from '../utils/github.js';
|
|
4
4
|
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
|
-
import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
|
|
5
|
+
import { readConfig, writeConfig, detectProjectDefaults, 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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
79
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
76
80
|
|
|
77
81
|
if (results.failed === MANIFEST.length) {
|
|
78
82
|
console.log();
|
|
@@ -83,15 +87,18 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
83
87
|
// Generate default config.json if it doesn't exist
|
|
84
88
|
const projectDir = process.cwd();
|
|
85
89
|
if (!readConfig(projectDir)) {
|
|
86
|
-
|
|
90
|
+
// Auto-detect project info from filesystem
|
|
91
|
+
const detected = detectProjectDefaults(projectDir);
|
|
92
|
+
// SerpentStack template overrides (more specific)
|
|
93
|
+
const template = detectTemplateDefaults(projectDir);
|
|
87
94
|
const config = {
|
|
88
95
|
project: {
|
|
89
|
-
name:
|
|
90
|
-
language:
|
|
91
|
-
framework:
|
|
92
|
-
devCmd:
|
|
93
|
-
testCmd:
|
|
94
|
-
conventions:
|
|
96
|
+
name: template?.name || detected.name,
|
|
97
|
+
language: template?.language || detected.language,
|
|
98
|
+
framework: template?.framework || detected.framework,
|
|
99
|
+
devCmd: template?.devCmd || detected.devCmd,
|
|
100
|
+
testCmd: template?.testCmd || detected.testCmd,
|
|
101
|
+
conventions: template?.conventions || detected.conventions,
|
|
95
102
|
},
|
|
96
103
|
agents: {},
|
|
97
104
|
};
|
|
@@ -108,17 +115,15 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
108
115
|
writeConfig(projectDir, config);
|
|
109
116
|
console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
|
|
110
117
|
results.created++;
|
|
111
|
-
console.log();
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
// Next steps
|
|
115
121
|
console.log();
|
|
116
|
-
|
|
122
|
+
divider('What\'s next');
|
|
117
123
|
console.log();
|
|
118
|
-
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
|
|
119
|
-
console.log(` ${dim('and paste
|
|
120
|
-
console.log(` ${dim('
|
|
121
|
-
console.log(` ${dim('.skills/ directory tailored to your project.')}`);
|
|
124
|
+
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, Gemini CLI, etc.)')}`);
|
|
125
|
+
console.log(` ${dim('and paste this prompt. It reads your codebase and generates')}`);
|
|
126
|
+
console.log(` ${dim('project-specific skills tailored to your stack.')}`);
|
|
122
127
|
|
|
123
128
|
printPrompt([
|
|
124
129
|
`Read .skills/generate-skills/SKILL.md and follow its instructions`,
|
|
@@ -133,11 +138,12 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
133
138
|
`Reference SKILL-AUTHORING.md for the format.`,
|
|
134
139
|
]);
|
|
135
140
|
|
|
136
|
-
printBox('
|
|
137
|
-
`${dim('$')} ${bold('serpentstack persistent')}
|
|
141
|
+
printBox('Want persistent background agents too?', [
|
|
142
|
+
`${dim('$')} ${bold('serpentstack persistent')}`,
|
|
138
143
|
'',
|
|
139
|
-
`${dim('
|
|
140
|
-
`${dim('
|
|
141
|
-
`${dim('
|
|
144
|
+
`${dim('Agents that watch your dev server, run tests, and keep')}`,
|
|
145
|
+
`${dim('skills fresh — each in its own terminal. Pick which to')}`,
|
|
146
|
+
`${dim('run and choose local or cloud models.')}`,
|
|
142
147
|
]);
|
|
148
|
+
console.log();
|
|
143
149
|
}
|
|
@@ -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('
|
|
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('
|
|
89
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
87
90
|
|
|
88
91
|
if (results.updated > 0) {
|
|
89
92
|
console.log();
|
|
90
|
-
info(`${dim('
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
116
|
+
divider('Clone');
|
|
117
117
|
console.log();
|
|
118
118
|
|
|
119
|
-
const spin = spinner(
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
divider('Ready 🐍');
|
|
154
154
|
console.log();
|
|
155
155
|
|
|
156
156
|
console.log(` ${dim('Your project includes:')}`);
|
|
157
|
-
console.log(`
|
|
158
|
-
console.log(`
|
|
159
|
-
console.log(`
|
|
160
|
-
console.log(`
|
|
161
|
-
console.log(`
|
|
162
|
-
console.log(`
|
|
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', [
|
package/lib/utils/agent-utils.js
CHANGED
|
@@ -164,7 +164,7 @@ function generateHeartbeat(schedule) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
function generateAgentsConfig(meta) {
|
|
167
|
-
const model = meta.model || '
|
|
167
|
+
const model = meta.model || 'ollama/llama3.2';
|
|
168
168
|
const tools = meta.tools || ['file-system', 'shell', 'git'];
|
|
169
169
|
|
|
170
170
|
return [
|
package/lib/utils/config.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, basename } 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: "
|
|
15
|
+
* "log-watcher": { enabled: true, model: "ollama/llama3.2" },
|
|
12
16
|
* ...
|
|
13
17
|
* }
|
|
14
18
|
* }
|
|
@@ -24,7 +28,7 @@ export function readConfig(projectDir) {
|
|
|
24
28
|
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
25
29
|
} catch (err) {
|
|
26
30
|
if (err instanceof SyntaxError) {
|
|
27
|
-
console.error(` \x1b[33m
|
|
31
|
+
console.error(` \x1b[33m△\x1b[0m .openclaw/config.json has invalid JSON — ignoring. Fix it or delete it to regenerate.`);
|
|
28
32
|
}
|
|
29
33
|
return null;
|
|
30
34
|
}
|
|
@@ -39,9 +43,30 @@ export function writeConfig(projectDir, config) {
|
|
|
39
43
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
// ─── Project Detection ───────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect project defaults by inspecting the filesystem.
|
|
50
|
+
* Works for ANY project — detects name, language, framework, commands.
|
|
51
|
+
* Always returns an object with every field populated (never empty strings).
|
|
52
|
+
* When nothing can be detected, returns sensible "fill me in" defaults
|
|
53
|
+
* that read naturally in the config file and prompt the user to update.
|
|
54
|
+
*/
|
|
55
|
+
export function detectProjectDefaults(projectDir) {
|
|
56
|
+
return {
|
|
57
|
+
name: detectProjectName(projectDir),
|
|
58
|
+
language: detectLanguage(projectDir),
|
|
59
|
+
framework: detectFramework(projectDir),
|
|
60
|
+
devCmd: detectDevCmd(projectDir),
|
|
61
|
+
testCmd: detectTestCmd(projectDir),
|
|
62
|
+
conventions: detectConventions(projectDir),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
/**
|
|
43
|
-
* Detect if this is a SerpentStack template project and return
|
|
44
|
-
* Returns null if not a template project.
|
|
67
|
+
* Detect if this is a SerpentStack template project and return overrides.
|
|
68
|
+
* Returns null if not a template project. When non-null, these values
|
|
69
|
+
* take priority over the generic detection.
|
|
45
70
|
*/
|
|
46
71
|
export function detectTemplateDefaults(projectDir) {
|
|
47
72
|
const makefile = join(projectDir, 'Makefile');
|
|
@@ -49,11 +74,11 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
49
74
|
|
|
50
75
|
try {
|
|
51
76
|
const content = readFileSync(makefile, 'utf8');
|
|
52
|
-
// SerpentStack Makefile: must have '
|
|
53
|
-
if (
|
|
54
|
-
if (!content.includes('uv run')
|
|
77
|
+
// SerpentStack Makefile: must have a 'verify:' target AND 'uv run' (Python tooling)
|
|
78
|
+
if (!/^verify:/m.test(content)) return null;
|
|
79
|
+
if (!content.includes('uv run')) return null;
|
|
55
80
|
|
|
56
|
-
// It's a SerpentStack template project — return
|
|
81
|
+
// It's a SerpentStack template project — return specific defaults
|
|
57
82
|
const defaults = {
|
|
58
83
|
language: 'Python + TypeScript',
|
|
59
84
|
framework: 'FastAPI + React',
|
|
@@ -62,7 +87,7 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
62
87
|
conventions: 'Services flush, routes commit. Domain returns, not exceptions. Real Postgres in tests.',
|
|
63
88
|
};
|
|
64
89
|
|
|
65
|
-
// Try to detect project name from
|
|
90
|
+
// Try to detect project name from frontend/package.json
|
|
66
91
|
const pkgPath = join(projectDir, 'frontend', 'package.json');
|
|
67
92
|
if (existsSync(pkgPath)) {
|
|
68
93
|
try {
|
|
@@ -77,13 +102,216 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
77
102
|
}
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
// ─── Detection helpers ───────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
function detectProjectName(projectDir) {
|
|
108
|
+
// 1. package.json name
|
|
109
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
110
|
+
if (existsSync(pkgPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
113
|
+
if (pkg.name && pkg.name !== 'unnamed') return pkg.name;
|
|
114
|
+
} catch { /* ignore */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. pyproject.toml name
|
|
118
|
+
const pyprojectPath = join(projectDir, 'pyproject.toml');
|
|
119
|
+
if (existsSync(pyprojectPath)) {
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(pyprojectPath, 'utf8');
|
|
122
|
+
const match = content.match(/^name\s*=\s*"([^"]+)"/m);
|
|
123
|
+
if (match) return match[1];
|
|
124
|
+
} catch { /* ignore */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Cargo.toml name
|
|
128
|
+
const cargoPath = join(projectDir, 'Cargo.toml');
|
|
129
|
+
if (existsSync(cargoPath)) {
|
|
130
|
+
try {
|
|
131
|
+
const content = readFileSync(cargoPath, 'utf8');
|
|
132
|
+
const match = content.match(/^name\s*=\s*"([^"]+)"/m);
|
|
133
|
+
if (match) return match[1];
|
|
134
|
+
} catch { /* ignore */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. Fall back to directory name
|
|
138
|
+
return basename(projectDir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectLanguage(projectDir) {
|
|
142
|
+
const langs = [];
|
|
143
|
+
if (existsSync(join(projectDir, 'pyproject.toml')) || existsSync(join(projectDir, 'requirements.txt')) || existsSync(join(projectDir, 'setup.py'))) {
|
|
144
|
+
langs.push('Python');
|
|
145
|
+
}
|
|
146
|
+
if (existsSync(join(projectDir, 'tsconfig.json'))) {
|
|
147
|
+
langs.push('TypeScript');
|
|
148
|
+
} else if (existsSync(join(projectDir, 'package.json'))) {
|
|
149
|
+
langs.push('JavaScript');
|
|
150
|
+
}
|
|
151
|
+
if (existsSync(join(projectDir, 'go.mod'))) {
|
|
152
|
+
langs.push('Go');
|
|
153
|
+
}
|
|
154
|
+
if (existsSync(join(projectDir, 'Cargo.toml'))) {
|
|
155
|
+
langs.push('Rust');
|
|
156
|
+
}
|
|
157
|
+
if (existsSync(join(projectDir, 'pom.xml')) || existsSync(join(projectDir, 'build.gradle'))) {
|
|
158
|
+
langs.push('Java');
|
|
159
|
+
}
|
|
160
|
+
if (existsSync(join(projectDir, 'Gemfile'))) {
|
|
161
|
+
langs.push('Ruby');
|
|
162
|
+
}
|
|
163
|
+
// If nothing detected, use SerpentStack template default since
|
|
164
|
+
// that's the most common use case (user just ran `stack new`)
|
|
165
|
+
return langs.length > 0 ? langs.join(' + ') : 'Python + TypeScript';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function detectFramework(projectDir) {
|
|
169
|
+
const frameworks = [];
|
|
170
|
+
|
|
171
|
+
// Python frameworks
|
|
172
|
+
const pyFiles = [join(projectDir, 'pyproject.toml'), join(projectDir, 'requirements.txt')];
|
|
173
|
+
for (const f of pyFiles) {
|
|
174
|
+
if (!existsSync(f)) continue;
|
|
175
|
+
try {
|
|
176
|
+
const content = readFileSync(f, 'utf8');
|
|
177
|
+
if (content.includes('fastapi')) frameworks.push('FastAPI');
|
|
178
|
+
else if (content.includes('django')) frameworks.push('Django');
|
|
179
|
+
else if (content.includes('flask')) frameworks.push('Flask');
|
|
180
|
+
break;
|
|
181
|
+
} catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// JS/TS frameworks
|
|
185
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
186
|
+
if (existsSync(pkgPath)) {
|
|
187
|
+
try {
|
|
188
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
189
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
190
|
+
if (allDeps['next']) frameworks.push('Next.js');
|
|
191
|
+
else if (allDeps['react']) frameworks.push('React');
|
|
192
|
+
else if (allDeps['vue']) frameworks.push('Vue');
|
|
193
|
+
else if (allDeps['svelte'] || allDeps['@sveltejs/kit']) frameworks.push('Svelte');
|
|
194
|
+
else if (allDeps['express']) frameworks.push('Express');
|
|
195
|
+
} catch { /* ignore */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Go frameworks
|
|
199
|
+
const goModPath = join(projectDir, 'go.mod');
|
|
200
|
+
if (existsSync(goModPath)) {
|
|
201
|
+
try {
|
|
202
|
+
const content = readFileSync(goModPath, 'utf8');
|
|
203
|
+
if (content.includes('gin-gonic')) frameworks.push('Gin');
|
|
204
|
+
else if (content.includes('labstack/echo')) frameworks.push('Echo');
|
|
205
|
+
else if (content.includes('gofiber')) frameworks.push('Fiber');
|
|
206
|
+
else frameworks.push('Go');
|
|
207
|
+
} catch { /* ignore */ }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Ruby
|
|
211
|
+
const gemfilePath = join(projectDir, 'Gemfile');
|
|
212
|
+
if (existsSync(gemfilePath)) {
|
|
213
|
+
try {
|
|
214
|
+
const content = readFileSync(gemfilePath, 'utf8');
|
|
215
|
+
if (content.includes('rails')) frameworks.push('Rails');
|
|
216
|
+
else if (content.includes('sinatra')) frameworks.push('Sinatra');
|
|
217
|
+
} catch { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return frameworks.length > 0 ? frameworks.join(' + ') : 'FastAPI + React';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function detectDevCmd(projectDir) {
|
|
224
|
+
// Makefile targets
|
|
225
|
+
const makefile = join(projectDir, 'Makefile');
|
|
226
|
+
if (existsSync(makefile)) {
|
|
227
|
+
try {
|
|
228
|
+
const content = readFileSync(makefile, 'utf8');
|
|
229
|
+
if (/^dev:/m.test(content)) return 'make dev';
|
|
230
|
+
if (/^start:/m.test(content)) return 'make start';
|
|
231
|
+
if (/^run:/m.test(content)) return 'make run';
|
|
232
|
+
if (/^serve:/m.test(content)) return 'make serve';
|
|
233
|
+
} catch { /* ignore */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// package.json scripts
|
|
237
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
238
|
+
if (existsSync(pkgPath)) {
|
|
239
|
+
try {
|
|
240
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
241
|
+
const scripts = pkg.scripts || {};
|
|
242
|
+
if (scripts.dev) return 'npm run dev';
|
|
243
|
+
if (scripts.start) return 'npm start';
|
|
244
|
+
if (scripts.serve) return 'npm run serve';
|
|
245
|
+
} catch { /* ignore */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Python
|
|
249
|
+
if (existsSync(join(projectDir, 'manage.py'))) return 'python manage.py runserver';
|
|
250
|
+
if (existsSync(join(projectDir, 'pyproject.toml'))) return 'uv run python -m app';
|
|
251
|
+
|
|
252
|
+
return 'make dev';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function detectTestCmd(projectDir) {
|
|
256
|
+
// Makefile targets
|
|
257
|
+
const makefile = join(projectDir, 'Makefile');
|
|
258
|
+
if (existsSync(makefile)) {
|
|
259
|
+
try {
|
|
260
|
+
const content = readFileSync(makefile, 'utf8');
|
|
261
|
+
if (/^verify:/m.test(content)) return 'make verify';
|
|
262
|
+
if (/^test:/m.test(content)) return 'make test';
|
|
263
|
+
if (/^check:/m.test(content)) return 'make check';
|
|
264
|
+
} catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// package.json scripts
|
|
268
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
269
|
+
if (existsSync(pkgPath)) {
|
|
270
|
+
try {
|
|
271
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
272
|
+
const scripts = pkg.scripts || {};
|
|
273
|
+
if (scripts.test && scripts.test !== 'echo "Error: no test specified" && exit 1') {
|
|
274
|
+
return 'npm test';
|
|
275
|
+
}
|
|
276
|
+
} catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Python
|
|
280
|
+
if (existsSync(join(projectDir, 'pyproject.toml')) || existsSync(join(projectDir, 'setup.py'))) {
|
|
281
|
+
return 'pytest';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Go
|
|
285
|
+
if (existsSync(join(projectDir, 'go.mod'))) return 'go test ./...';
|
|
286
|
+
|
|
287
|
+
// Rust
|
|
288
|
+
if (existsSync(join(projectDir, 'Cargo.toml'))) return 'cargo test';
|
|
289
|
+
|
|
290
|
+
return 'make verify';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function detectConventions(projectDir) {
|
|
294
|
+
// Check if this looks like a SerpentStack template (has the standard Makefile)
|
|
295
|
+
const makefile = join(projectDir, 'Makefile');
|
|
296
|
+
if (existsSync(makefile)) {
|
|
297
|
+
try {
|
|
298
|
+
const content = readFileSync(makefile, 'utf8');
|
|
299
|
+
if (/^verify:/m.test(content) && content.includes('uv run')) {
|
|
300
|
+
return 'Services flush, routes commit. Domain returns, not exceptions. Real Postgres in tests.';
|
|
301
|
+
}
|
|
302
|
+
} catch { /* ignore */ }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return 'Follow existing patterns. Match the style of surrounding code.';
|
|
306
|
+
}
|
|
307
|
+
|
|
80
308
|
/**
|
|
81
309
|
* Build a default agent config entry from an AGENT.md's parsed meta.
|
|
82
310
|
*/
|
|
83
311
|
export function defaultAgentConfig(meta) {
|
|
84
312
|
return {
|
|
85
313
|
enabled: true,
|
|
86
|
-
model:
|
|
314
|
+
model: DEFAULT_MODEL,
|
|
87
315
|
};
|
|
88
316
|
}
|
|
89
317
|
|
|
@@ -94,7 +322,7 @@ export function getEffectiveModel(agentName, agentMeta, config) {
|
|
|
94
322
|
if (config?.agents?.[agentName]?.model) {
|
|
95
323
|
return config.agents[agentName].model;
|
|
96
324
|
}
|
|
97
|
-
return
|
|
325
|
+
return DEFAULT_MODEL;
|
|
98
326
|
}
|
|
99
327
|
|
|
100
328
|
/**
|
package/lib/utils/models.js
CHANGED
|
@@ -21,62 +21,87 @@ export async function detectModels() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Detect locally installed Ollama models
|
|
25
|
-
*
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
39
|
-
|
|
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
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
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/${
|
|
55
|
-
name
|
|
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
|
-
*
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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${
|
|
43
|
-
},
|
|
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} ${
|
|
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(
|
|
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 = '
|
|
81
|
-
const
|
|
82
|
-
|
|
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(
|
|
90
|
-
console.log(
|
|
91
|
-
console.log(
|
|
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(
|
|
118
|
+
console.log(` ${DIM}│${RESET} ${line}${' '.repeat(pad)}${DIM}│${RESET}`);
|
|
95
119
|
}
|
|
96
|
-
console.log(
|
|
120
|
+
console.log(` ${DIM}└${'─'.repeat(width)}┘${RESET}`);
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
/**
|
|
100
|
-
* Print a copyable prompt block
|
|
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}
|
|
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}
|
|
136
|
+
console.log(` ${DIM}│${RESET} ${CYAN}${line}${RESET}${' '.repeat(pad)}${DIM}│${RESET}`);
|
|
114
137
|
}
|
|
115
|
-
console.log(` ${DIM}
|
|
116
|
-
console.log(` ${DIM}
|
|
138
|
+
console.log(` ${DIM}└${'─'.repeat(innerWidth)}┘${RESET}`);
|
|
139
|
+
console.log(` ${DIM}↑ ${hint}${RESET}`);
|
|
117
140
|
console.log();
|
|
118
141
|
}
|
|
119
142
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*/
|
|
143
|
+
// ─── File Status ─────────────────────────────────────────────
|
|
144
|
+
|
|
123
145
|
export function fileIcon(status) {
|
|
124
146
|
switch (status) {
|
|
125
|
-
case 'created': return `${GREEN}
|
|
126
|
-
case 'overwritten': return `${CYAN}
|
|
127
|
-
case 'skipped': return `${YELLOW}
|
|
128
|
-
case 'failed': return `${RED}
|
|
129
|
-
case 'unchanged': return `${DIM}
|
|
130
|
-
default: return `${DIM}
|
|
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 `
|
|
141
|
-
case 'overwritten': return `
|
|
142
|
-
case 'skipped': return `
|
|
143
|
-
case 'failed': return `
|
|
144
|
-
case 'unchanged': return `
|
|
145
|
-
default: return `
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.10",
|
|
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": "
|
|
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"
|