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