serpentstack 0.2.4 → 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 +73 -23
- package/lib/commands/persistent.js +559 -0
- package/lib/commands/skills-init.js +51 -14
- package/lib/commands/stack-new.js +44 -9
- package/lib/utils/config.js +106 -0
- 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/lib/commands/skills-persistent.js +0 -358
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
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';
|
|
7
|
+
import {
|
|
8
|
+
parseAgentMd,
|
|
9
|
+
discoverAgents,
|
|
10
|
+
generateWorkspace,
|
|
11
|
+
writePid,
|
|
12
|
+
removePid,
|
|
13
|
+
listPids,
|
|
14
|
+
cleanStalePids,
|
|
15
|
+
cleanWorkspace,
|
|
16
|
+
isProcessAlive,
|
|
17
|
+
} from '../utils/agent-utils.js';
|
|
18
|
+
import {
|
|
19
|
+
readConfig,
|
|
20
|
+
writeConfig,
|
|
21
|
+
detectTemplateDefaults,
|
|
22
|
+
getEffectiveModel,
|
|
23
|
+
isAgentEnabled,
|
|
24
|
+
} from '../utils/config.js';
|
|
25
|
+
import { detectModels, modelShortName } from '../utils/models.js';
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function which(cmd) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile('which', [cmd], (err) => resolve(!err));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
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 || '';
|
|
39
|
+
}
|
|
40
|
+
|
|
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;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Terminal Spawning ──────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function openInTerminal(title, command, cwd) {
|
|
117
|
+
const platform = process.platform;
|
|
118
|
+
const termProgram = process.env.TERM_PROGRAM || '';
|
|
119
|
+
const safeCwd = cwd.replace(/'/g, "'\\''").replace(/"/g, '\\"');
|
|
120
|
+
const safeCmd = command.replace(/"/g, '\\"');
|
|
121
|
+
|
|
122
|
+
if (platform === 'darwin') {
|
|
123
|
+
if (termProgram === 'iTerm.app') {
|
|
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`;
|
|
133
|
+
spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
|
|
134
|
+
return 'iTerm';
|
|
135
|
+
}
|
|
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`;
|
|
146
|
+
const terminals = [
|
|
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]],
|
|
153
|
+
];
|
|
154
|
+
for (const [bin, args] of terminals) {
|
|
155
|
+
try {
|
|
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;
|
|
161
|
+
} catch { continue; }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (platform === 'win32') {
|
|
166
|
+
spawn('cmd.exe', ['/c', 'start', `"${title}"`, 'cmd', '/k', `cd /d "${cwd}" && ${command}`], {
|
|
167
|
+
stdio: 'ignore', detached: true,
|
|
168
|
+
}).unref();
|
|
169
|
+
return 'cmd';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Stop Flow ──────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function stopAllAgents(projectDir) {
|
|
178
|
+
cleanStalePids(projectDir);
|
|
179
|
+
const running = listPids(projectDir);
|
|
180
|
+
|
|
181
|
+
if (running.length === 0) {
|
|
182
|
+
info('No agents are currently running.');
|
|
183
|
+
console.log();
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let stopped = 0;
|
|
188
|
+
for (const { name, pid } of running) {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 'SIGTERM');
|
|
191
|
+
removePid(projectDir, name);
|
|
192
|
+
cleanWorkspace(projectDir, name);
|
|
193
|
+
success(`Stopped ${bold(name)} ${dim(`(PID ${pid})`)}`);
|
|
194
|
+
stopped++;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err.code === 'ESRCH') {
|
|
197
|
+
removePid(projectDir, name);
|
|
198
|
+
// Don't count already-dead processes as "stopped"
|
|
199
|
+
} else {
|
|
200
|
+
error(`Failed to stop ${bold(name)}: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (stopped > 0) {
|
|
206
|
+
console.log();
|
|
207
|
+
success(`${stopped} agent(s) stopped`);
|
|
208
|
+
}
|
|
209
|
+
console.log();
|
|
210
|
+
return stopped;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Agent Status ───────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function getAgentStatus(projectDir, name, config) {
|
|
216
|
+
const pid = listPids(projectDir).find(p => p.name === name);
|
|
217
|
+
if (pid && isProcessAlive(pid.pid)) return { status: 'running', pid: pid.pid };
|
|
218
|
+
if (!isAgentEnabled(name, config)) return { status: 'disabled', pid: null };
|
|
219
|
+
return { status: 'stopped', pid: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function printAgentLine(name, agentMd, config, statusInfo) {
|
|
223
|
+
const model = getEffectiveModel(name, agentMd.meta, config);
|
|
224
|
+
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
225
|
+
const modelStr = modelShortName(model);
|
|
226
|
+
|
|
227
|
+
if (statusInfo.status === 'running') {
|
|
228
|
+
console.log(` ${green('\u25CF')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
|
|
229
|
+
} else if (statusInfo.status === 'disabled') {
|
|
230
|
+
console.log(` ${dim('\u25CB')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
231
|
+
} else {
|
|
232
|
+
console.log(` ${yellow('\u25CB')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('stopped')}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Main Flow ──────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
239
|
+
const projectDir = process.cwd();
|
|
240
|
+
|
|
241
|
+
printHeader();
|
|
242
|
+
|
|
243
|
+
// ── Stop ──
|
|
244
|
+
if (stop) {
|
|
245
|
+
stopAllAgents(projectDir);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Preflight checks ──
|
|
250
|
+
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
251
|
+
if (!existsSync(soulPath)) {
|
|
252
|
+
error('No .openclaw/ workspace found.');
|
|
253
|
+
console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
|
|
254
|
+
console.log();
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const agents = discoverAgents(projectDir);
|
|
259
|
+
if (agents.length === 0) {
|
|
260
|
+
error('No agents found in .openclaw/agents/');
|
|
261
|
+
console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
|
|
262
|
+
console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
|
|
263
|
+
console.log();
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
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
|
+
|
|
281
|
+
cleanStalePids(projectDir);
|
|
282
|
+
|
|
283
|
+
// Parse agent definitions
|
|
284
|
+
const parsed = [];
|
|
285
|
+
for (const agent of agents) {
|
|
286
|
+
try {
|
|
287
|
+
const agentMd = parseAgentMd(agent.agentMdPath);
|
|
288
|
+
parsed.push({ ...agent, agentMd });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
warn(`Skipping ${bold(agent.name)}: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (parsed.length === 0) {
|
|
294
|
+
error('No valid AGENT.md files found.');
|
|
295
|
+
console.log();
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Load config
|
|
300
|
+
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
301
|
+
const needsSetup = !config.project?.name || reconfigure;
|
|
302
|
+
|
|
303
|
+
// Detect models in background while we show status
|
|
304
|
+
const modelsPromise = detectModels();
|
|
305
|
+
|
|
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}`)}`);
|
|
310
|
+
console.log();
|
|
311
|
+
|
|
312
|
+
for (const { name, agentMd } of parsed) {
|
|
313
|
+
const statusInfo = getAgentStatus(projectDir, name, config);
|
|
314
|
+
printAgentLine(name, agentMd, config, statusInfo);
|
|
315
|
+
}
|
|
316
|
+
console.log();
|
|
317
|
+
|
|
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));
|
|
322
|
+
|
|
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
|
+
}
|
|
330
|
+
|
|
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;
|
|
336
|
+
}
|
|
337
|
+
|
|
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...');
|
|
346
|
+
console.log();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
350
|
+
let configDirty = false;
|
|
351
|
+
|
|
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
|
+
};
|
|
364
|
+
|
|
365
|
+
if (templateDefaults && !existing.name) {
|
|
366
|
+
info('Detected SerpentStack template \u2014 defaults pre-filled');
|
|
367
|
+
console.log();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log(` ${bold('Project')}`);
|
|
371
|
+
console.log(` ${dim('Press Enter to keep current values.')}`);
|
|
372
|
+
console.log();
|
|
373
|
+
|
|
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')}`);
|
|
456
|
+
console.log();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
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.');
|
|
470
|
+
console.log();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await launchAgents(projectDir, enabledAgents, config, soulPath);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Launch Flow ────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
480
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
481
|
+
const toStart = [];
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
console.log(` ${dim('Select agents to launch:')}`);
|
|
485
|
+
console.log();
|
|
486
|
+
|
|
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);
|
|
491
|
+
}
|
|
492
|
+
} finally {
|
|
493
|
+
rl.close();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (toStart.length === 0) {
|
|
497
|
+
console.log();
|
|
498
|
+
info('No agents selected.');
|
|
499
|
+
console.log();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
console.log();
|
|
504
|
+
|
|
505
|
+
const sharedSoul = readFileSync(soulPath, 'utf8');
|
|
506
|
+
let started = 0;
|
|
507
|
+
|
|
508
|
+
for (const { name, agentMd } of toStart) {
|
|
509
|
+
try {
|
|
510
|
+
const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
|
|
511
|
+
const overriddenMd = {
|
|
512
|
+
...agentMd,
|
|
513
|
+
meta: { ...agentMd.meta, model: effectiveModel },
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
|
|
517
|
+
const absWorkspace = resolve(workspacePath);
|
|
518
|
+
const absProject = resolve(projectDir);
|
|
519
|
+
|
|
520
|
+
const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
|
|
521
|
+
|
|
522
|
+
const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
|
|
523
|
+
|
|
524
|
+
if (method) {
|
|
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)})`)}`);
|
|
529
|
+
started++;
|
|
530
|
+
} else {
|
|
531
|
+
// Fallback: background process
|
|
532
|
+
warn(`No terminal detected \u2014 starting ${bold(name)} in background`);
|
|
533
|
+
const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
|
|
534
|
+
stdio: 'ignore',
|
|
535
|
+
detached: true,
|
|
536
|
+
cwd: absProject,
|
|
537
|
+
env: { ...process.env, OPENCLAW_STATE_DIR: join(absWorkspace, '.state') },
|
|
538
|
+
});
|
|
539
|
+
child.unref();
|
|
540
|
+
writePid(projectDir, name, child.pid);
|
|
541
|
+
success(`${bold(name)} started ${dim(`PID ${child.pid}`)}`);
|
|
542
|
+
started++;
|
|
543
|
+
}
|
|
544
|
+
} catch (err) {
|
|
545
|
+
error(`${bold(name)}: ${err.message}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log();
|
|
550
|
+
if (started > 0) {
|
|
551
|
+
success(`${started} agent(s) launched`);
|
|
552
|
+
console.log();
|
|
553
|
+
printBox('Manage agents', [
|
|
554
|
+
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start agents')}`,
|
|
555
|
+
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
|
|
556
|
+
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models, enable/disable')}`,
|
|
557
|
+
]);
|
|
558
|
+
}
|
|
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,')}`);
|
|
@@ -87,20 +122,22 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
87
122
|
|
|
88
123
|
printPrompt([
|
|
89
124
|
`Read .skills/generate-skills/SKILL.md and follow its instructions`,
|
|
90
|
-
`to generate project-specific skills for this codebase.
|
|
91
|
-
|
|
92
|
-
`
|
|
93
|
-
`
|
|
94
|
-
`
|
|
95
|
-
`
|
|
96
|
-
`
|
|
125
|
+
`to generate project-specific skills for this codebase. If`,
|
|
126
|
+
`.openclaw/config.json exists, read it first — it has my project`,
|
|
127
|
+
`name, language, framework, and conventions. Interview me about my`,
|
|
128
|
+
`architecture decisions — transactions, auth, error patterns,`,
|
|
129
|
+
`testing strategy, and deployment. Ask about the business domain`,
|
|
130
|
+
`too: what this app does, key user flows, and where agents are`,
|
|
131
|
+
`most likely to make mistakes. Write each skill as a SKILL.md with`,
|
|
132
|
+
`complete templates an agent can copy, not vague descriptions.`,
|
|
133
|
+
`Reference SKILL-AUTHORING.md for the format.`,
|
|
97
134
|
]);
|
|
98
135
|
|
|
99
136
|
printBox('After generating skills, try setting up persistent agents too', [
|
|
100
|
-
`${dim('$')} ${bold('serpentstack
|
|
137
|
+
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# choose agents + launch')}`,
|
|
101
138
|
'',
|
|
102
139
|
`${dim('Background agents that watch your dev server, run tests,')}`,
|
|
103
|
-
`${dim('and keep your skills up to date. Each
|
|
104
|
-
`${dim('
|
|
140
|
+
`${dim('and keep your skills up to date. Each opens in its own')}`,
|
|
141
|
+
`${dim('terminal window. Pick which to run and choose your models.')}`,
|
|
105
142
|
]);
|
|
106
143
|
}
|