serpentstack 0.2.4 → 0.2.5

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { error, bold, dim, green, cyan, getVersion, printHeader } from '../lib/utils/ui.js';
4
4
 
5
- function parseFlags(args) {
5
+ function parseArgs(args) {
6
6
  const flags = {};
7
7
  const positional = [];
8
8
  for (const arg of args) {
@@ -20,15 +20,16 @@ function showHelp() {
20
20
  printHeader();
21
21
  console.log(` ${bold('Usage:')} serpentstack <command> [options]
22
22
 
23
- ${bold(green('Stack commands'))} ${dim('(new projects)')}
24
- ${cyan('stack new')} <name> Scaffold a new project from the template
23
+ ${bold(green('New projects'))}
24
+ ${cyan('stack new')} <name> Scaffold a full project from the template
25
25
  ${cyan('stack update')} Update template-level files to latest
26
26
 
27
- ${bold(green('Skills commands'))} ${dim('(any project)')}
28
- ${cyan('skills init')} Download base skills + persistent agent configs
27
+ ${bold(green('Any project'))}
28
+ ${cyan('skills')} Download base skills + persistent agent configs
29
29
  ${cyan('skills update')} Update base skills to latest versions
30
- ${cyan('skills persistent')} Guided setup: configure + install + start all agents
31
- ${cyan('skills persistent')} --stop Stop all running agents
30
+ ${cyan('persistent')} Manage and launch persistent agents
31
+ ${cyan('persistent')} --stop Stop all running agents
32
+ ${cyan('persistent')} --reconfigure Re-run setup (change models, enable/disable)
32
33
 
33
34
  ${bold('Options:')}
34
35
  --force Overwrite existing files
@@ -38,17 +39,21 @@ function showHelp() {
38
39
 
39
40
  ${dim('Examples:')}
40
41
  ${dim('$')} serpentstack stack new my-saas-app
41
- ${dim('$')} serpentstack skills init
42
- ${dim('$')} serpentstack skills persistent
43
- ${dim('$')} serpentstack skills persistent --stop
42
+ ${dim('$')} serpentstack skills
43
+ ${dim('$')} serpentstack persistent
44
+ ${dim('$')} serpentstack persistent --stop
44
45
 
45
46
  ${dim('Docs: https://github.com/Benja-Pauls/SerpentStack')}
46
47
  `);
47
48
  }
48
49
 
49
50
  async function main() {
50
- const [,, noun, verb, ...rest] = process.argv;
51
- const { flags, positional } = parseFlags(rest);
51
+ const rawArgs = process.argv.slice(2);
52
+ const { flags, positional } = parseArgs(rawArgs);
53
+
54
+ const noun = positional[0];
55
+ const verb = positional[1];
56
+ const rest = positional.slice(2);
52
57
 
53
58
  // Top-level flags
54
59
  if (noun === '--version' || flags.version) {
@@ -63,7 +68,7 @@ async function main() {
63
68
  if (noun === 'stack') {
64
69
  if (verb === 'new') {
65
70
  const { stackNew } = await import('../lib/commands/stack-new.js');
66
- await stackNew(positional[0]);
71
+ await stackNew(rest[0]);
67
72
  } else if (verb === 'update') {
68
73
  const { stackUpdate } = await import('../lib/commands/stack-update.js');
69
74
  await stackUpdate({ force: !!flags.force });
@@ -73,20 +78,21 @@ async function main() {
73
78
  process.exit(1);
74
79
  }
75
80
  } else if (noun === 'skills') {
76
- if (verb === 'init') {
81
+ if (!verb || verb === 'init') {
82
+ // `serpentstack skills` or `serpentstack skills init` both work
77
83
  const { skillsInit } = await import('../lib/commands/skills-init.js');
78
84
  await skillsInit({ force: !!flags.force });
79
85
  } else if (verb === 'update') {
80
86
  const { skillsUpdate } = await import('../lib/commands/skills-update.js');
81
87
  await skillsUpdate({ force: !!flags.force, all: !!flags.all });
82
- } else if (verb === 'persistent') {
83
- const { skillsPersistent } = await import('../lib/commands/skills-persistent.js');
84
- await skillsPersistent({ stop: !!flags.stop });
85
88
  } else {
86
89
  error(`Unknown skills command: ${verb}`);
87
- console.log(`\n Available: ${bold('skills init')}, ${bold('skills update')}, ${bold('skills persistent')}\n`);
90
+ console.log(`\n Available: ${bold('skills')}, ${bold('skills update')}\n`);
88
91
  process.exit(1);
89
92
  }
93
+ } else if (noun === 'persistent') {
94
+ const { persistent } = await import('../lib/commands/persistent.js');
95
+ await persistent({ stop: !!flags.stop, reconfigure: !!flags.reconfigure });
90
96
  } else {
91
97
  error(`Unknown command: ${bold(noun)}`);
92
98
  console.log(`\n Run ${bold('serpentstack --help')} to see available commands.\n`);
@@ -0,0 +1,528 @@
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, confirm, 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
+
26
+ function which(cmd) {
27
+ return new Promise((resolve) => {
28
+ execFile('which', [cmd], (err) => resolve(!err));
29
+ });
30
+ }
31
+
32
+ function modelShortName(model) {
33
+ if (model.includes('haiku')) return 'Haiku';
34
+ if (model.includes('sonnet')) return 'Sonnet';
35
+ if (model.includes('opus')) return 'Opus';
36
+ if (model.includes('ollama') || model.includes(':')) return model.split('/').pop();
37
+ return model;
38
+ }
39
+
40
+ async function askQuestion(rl, label, hint, defaultValue) {
41
+ const defaultHint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
42
+ const hintStr = hint ? ` ${dim(hint)}` : '';
43
+ const answer = await rl.question(` ${green('?')} ${bold(label)}${hintStr}${defaultHint}: `);
44
+ return answer.trim() || defaultValue || '';
45
+ }
46
+
47
+ // ─── Terminal Spawning ──────────────────────────────────────
48
+
49
+ /**
50
+ * Open a new terminal window/tab running the given command.
51
+ * Returns the method used ('terminal', 'iterm', 'fallback').
52
+ */
53
+ function openInTerminal(title, command, cwd) {
54
+ const platform = process.platform;
55
+ const termProgram = process.env.TERM_PROGRAM || '';
56
+
57
+ if (platform === 'darwin') {
58
+ if (termProgram === 'iTerm.app') {
59
+ // iTerm2: open a new tab
60
+ const script = `
61
+ tell application "iTerm"
62
+ tell current window
63
+ create tab with default profile
64
+ tell current session
65
+ set name to "${title}"
66
+ write text "cd ${escapeShell(cwd)} && ${command}"
67
+ end tell
68
+ end tell
69
+ end tell
70
+ `;
71
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
72
+ return 'iterm';
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';
83
+ }
84
+ } else if (platform === 'linux') {
85
+ // Try common Linux terminal emulators
86
+ const terminals = [
87
+ ['gnome-terminal', ['--title', title, '--', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
88
+ ['xterm', ['-title', title, '-e', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
89
+ ['konsole', ['--new-tab', '-e', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
90
+ ];
91
+
92
+ for (const [bin, args] of terminals) {
93
+ try {
94
+ spawn(bin, args, { stdio: 'ignore', detached: true }).unref();
95
+ return bin;
96
+ } catch { continue; }
97
+ }
98
+ } else if (platform === 'win32') {
99
+ spawn('cmd.exe', ['/c', 'start', 'cmd', '/k', `cd /d ${cwd} && ${command}`], {
100
+ stdio: 'ignore', detached: true,
101
+ }).unref();
102
+ return 'cmd';
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ function escapeShell(str) {
109
+ return str.replace(/'/g, "'\\''");
110
+ }
111
+
112
+ // ─── Stop Flow ──────────────────────────────────────────────
113
+
114
+ function stopAllAgents(projectDir) {
115
+ cleanStalePids(projectDir);
116
+ const running = listPids(projectDir);
117
+
118
+ if (running.length === 0) {
119
+ info('No agents are currently running.');
120
+ console.log();
121
+ return;
122
+ }
123
+
124
+ console.log(` ${dim('Stopping')} ${bold(String(running.length))} ${dim('agent(s)...')}`);
125
+ console.log();
126
+
127
+ let stopped = 0;
128
+ for (const { name, pid } of running) {
129
+ try {
130
+ process.kill(pid, 'SIGTERM');
131
+ removePid(projectDir, name);
132
+ cleanWorkspace(projectDir, name);
133
+ success(`${bold(name)} stopped ${dim(`(PID ${pid})`)}`);
134
+ stopped++;
135
+ } catch (err) {
136
+ if (err.code === 'ESRCH') {
137
+ removePid(projectDir, name);
138
+ success(`${bold(name)} already stopped`);
139
+ stopped++;
140
+ } else {
141
+ error(`Failed to stop ${bold(name)}: ${err.message}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ console.log();
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')}`);
258
+ console.log();
259
+ return false;
260
+ }
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
+ console.log();
269
+ success('OpenClaw installed');
270
+ return true;
271
+ }
272
+
273
+ // ─── Agent Status ───────────────────────────────────────────
274
+
275
+ function getAgentStatus(projectDir, name, config) {
276
+ const pid = listPids(projectDir).find(p => p.name === name);
277
+ if (pid && isProcessAlive(pid.pid)) return { status: 'running', pid: pid.pid };
278
+ if (!isAgentEnabled(name, config)) return { status: 'disabled', pid: null };
279
+ return { status: 'stopped', pid: null };
280
+ }
281
+
282
+ function printAgentStatus(name, agentMd, config, statusInfo) {
283
+ const model = getEffectiveModel(name, agentMd.meta, config);
284
+ const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
285
+ const modelStr = modelShortName(model);
286
+
287
+ if (statusInfo.status === 'running') {
288
+ console.log(` ${green('\u25CF')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
289
+ } else if (statusInfo.status === 'disabled') {
290
+ console.log(` ${dim('\u25CB')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
291
+ } else {
292
+ console.log(` ${yellow('\u25CB')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('stopped')}`);
293
+ }
294
+ }
295
+
296
+ // ─── Main Flow ──────────────────────────────────────────────
297
+
298
+ export async function persistent({ stop = false, reconfigure = false } = {}) {
299
+ const projectDir = process.cwd();
300
+
301
+ printHeader();
302
+
303
+ // --stop flag
304
+ if (stop) {
305
+ stopAllAgents(projectDir);
306
+ return;
307
+ }
308
+
309
+ // Check workspace exists
310
+ const soulPath = join(projectDir, '.openclaw/SOUL.md');
311
+ if (!existsSync(soulPath)) {
312
+ error('No .openclaw/ workspace found.');
313
+ console.log();
314
+ console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
315
+ console.log();
316
+ process.exit(1);
317
+ }
318
+
319
+ // Discover agents
320
+ const agents = discoverAgents(projectDir);
321
+ if (agents.length === 0) {
322
+ error('No agents found in .openclaw/agents/');
323
+ console.log();
324
+ console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
325
+ console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
326
+ console.log();
327
+ process.exit(1);
328
+ }
329
+
330
+ cleanStalePids(projectDir);
331
+
332
+ // Parse all AGENT.md files
333
+ const parsed = [];
334
+ for (const agent of agents) {
335
+ try {
336
+ const agentMd = parseAgentMd(agent.agentMdPath);
337
+ parsed.push({ ...agent, agentMd });
338
+ } catch (err) {
339
+ error(`${bold(agent.name)}: ${err.message}`);
340
+ }
341
+ }
342
+
343
+ if (parsed.length === 0) {
344
+ error('No valid agents found. Check your AGENT.md files.');
345
+ console.log();
346
+ process.exit(1);
347
+ }
348
+
349
+ // Load or create config
350
+ let config = readConfig(projectDir) || { project: {}, agents: {} };
351
+ const needsSetup = !config.project?.name || reconfigure;
352
+
353
+ if (needsSetup) {
354
+ // First time or --reconfigure: full setup
355
+ console.log(` ${bold('Project Setup')}`);
356
+
357
+ config.project = await configureProject(projectDir, config);
358
+ console.log();
359
+
360
+ // Configure agents
361
+ console.log(` ${bold('Agent Configuration')}`);
362
+ console.log();
363
+
364
+ const rl = createInterface({ input: stdin, output: stdout });
365
+ try {
366
+ for (const { name, agentMd } of parsed) {
367
+ const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
368
+ console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
369
+ console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
370
+
371
+ const agentConfig = await configureAgent(rl, name, agentMd, config.agents?.[name]);
372
+ config.agents[name] = agentConfig;
373
+
374
+ const status = agentConfig.enabled ? green('enabled') : dim('disabled');
375
+ console.log(` ${dim('\u2192')} ${status}${agentConfig.enabled ? `, ${dim(modelShortName(agentConfig.model))}` : ''}`);
376
+ console.log();
377
+ }
378
+ } finally {
379
+ rl.close();
380
+ }
381
+
382
+ writeConfig(projectDir, config);
383
+ success(`Saved ${bold('.openclaw/config.json')}`);
384
+ console.log();
385
+ }
386
+
387
+ // Show current status
388
+ console.log(` ${bold('Agents')}`);
389
+ console.log();
390
+
391
+ for (const { name, agentMd } of parsed) {
392
+ const statusInfo = getAgentStatus(projectDir, name, config);
393
+ printAgentStatus(name, agentMd, config, statusInfo);
394
+ }
395
+ console.log();
396
+
397
+ // Determine which agents can be started
398
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
399
+ const runningNames = new Set(listPids(projectDir).map(p => p.name));
400
+ const startableAgents = enabledAgents.filter(a => !runningNames.has(a.name));
401
+ const runningAgents = enabledAgents.filter(a => runningNames.has(a.name));
402
+
403
+ if (startableAgents.length === 0 && runningAgents.length > 0) {
404
+ info('All enabled agents are already running.');
405
+ console.log();
406
+
407
+ const doStop = await confirm('Stop all agents?');
408
+ if (doStop) {
409
+ console.log();
410
+ stopAllAgents(projectDir);
411
+ }
412
+ return;
413
+ }
414
+
415
+ if (startableAgents.length === 0) {
416
+ info('No agents are enabled. Run with --reconfigure to enable agents.');
417
+ console.log();
418
+ return;
419
+ }
420
+
421
+ // Let user pick which agents to start
422
+ const rl = createInterface({ input: stdin, output: stdout });
423
+ const toStart = [];
424
+
425
+ try {
426
+ if (startableAgents.length === 1) {
427
+ const a = startableAgents[0];
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();
433
+
434
+ for (const agent of startableAgents) {
435
+ const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
436
+ const answer = await rl.question(` ${green('?')} Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}? ${dim('[Y/n]')} `);
437
+ if (answer.trim().toLowerCase() !== 'n') {
438
+ toStart.push(agent);
439
+ }
440
+ }
441
+ }
442
+ } finally {
443
+ rl.close();
444
+ }
445
+
446
+ if (toStart.length === 0) {
447
+ console.log();
448
+ info('No agents selected.');
449
+ console.log();
450
+ return;
451
+ }
452
+
453
+ // Check OpenClaw
454
+ const hasOpenClaw = await which('openclaw');
455
+ if (!hasOpenClaw) {
456
+ const installed = await installOpenClaw();
457
+ if (!installed) return;
458
+ }
459
+
460
+ console.log();
461
+
462
+ // Start each agent in its own terminal window
463
+ const sharedSoul = readFileSync(soulPath, 'utf8');
464
+ let started = 0;
465
+
466
+ for (const { name, agentMd } of toStart) {
467
+ try {
468
+ const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
469
+ const overriddenMd = {
470
+ ...agentMd,
471
+ meta: { ...agentMd.meta, model: effectiveModel },
472
+ };
473
+
474
+ const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
475
+ const absWorkspace = resolve(workspacePath);
476
+ const absProject = resolve(projectDir);
477
+
478
+ // Build the openclaw command
479
+ const openclawCmd = `OPENCLAW_STATE_DIR="${join(absWorkspace, '.state')}" openclaw start --workspace "${absWorkspace}"`;
480
+
481
+ // Try to open in a new terminal window
482
+ const method = openInTerminal(
483
+ `SerpentStack: ${name}`,
484
+ openclawCmd,
485
+ absProject,
486
+ );
487
+
488
+ if (method) {
489
+ // Record PID tracking (we won't have the exact PID from the terminal,
490
+ // but we record a placeholder so --stop can find/kill it)
491
+ success(`${bold(name)} opened in new ${method} window ${dim(`(${modelShortName(effectiveModel)})`)}`);
492
+ started++;
493
+ } else {
494
+ // Fallback: start in background if no terminal method works
495
+ warn(`Could not open terminal for ${bold(name)} — starting in background`);
496
+ const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
497
+ stdio: 'ignore',
498
+ detached: true,
499
+ cwd: absProject,
500
+ env: { ...process.env, OPENCLAW_STATE_DIR: join(absWorkspace, '.state') },
501
+ });
502
+ child.unref();
503
+ writePid(projectDir, name, child.pid);
504
+ success(`${bold(name)} started in background ${dim(`PID ${child.pid}`)}`);
505
+ started++;
506
+ }
507
+ } catch (err) {
508
+ error(`${bold(name)}: ${err.message}`);
509
+ }
510
+ }
511
+
512
+ console.log();
513
+ if (started > 0) {
514
+ success(`${green(String(started))} agent(s) launched`);
515
+ console.log();
516
+ printBox('Manage agents', [
517
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# view status + start agents')}`,
518
+ `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
519
+ `${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
+ ]);
524
+ } else {
525
+ error('No agents were started. Check the errors above.');
526
+ console.log();
527
+ }
528
+ }
@@ -87,20 +87,22 @@ export async function skillsInit({ force = false } = {}) {
87
87
 
88
88
  printPrompt([
89
89
  `Read .skills/generate-skills/SKILL.md and follow its instructions`,
90
- `to generate project-specific skills for this codebase. Interview me`,
91
- `about my architecture decisionshow I handle transactions, auth,`,
92
- `error patterns, testing strategy, and deployment. Ask about the`,
93
- `business domain too: what this app does, key user flows, and where`,
94
- `agents are most likely to make mistakes. Write each skill as a`,
95
- `SKILL.md with complete templates an agent can copy, not vague`,
96
- `descriptions. Reference SKILL-AUTHORING.md for the format.`,
90
+ `to generate project-specific skills for this codebase. If`,
91
+ `.openclaw/config.json exists, read it first it has my project`,
92
+ `name, language, framework, and conventions. Interview me about my`,
93
+ `architecture decisions transactions, auth, error patterns,`,
94
+ `testing strategy, and deployment. Ask about the business domain`,
95
+ `too: what this app does, key user flows, and where agents are`,
96
+ `most likely to make mistakes. Write each skill as a SKILL.md with`,
97
+ `complete templates an agent can copy, not vague descriptions.`,
98
+ `Reference SKILL-AUTHORING.md for the format.`,
97
99
  ]);
98
100
 
99
101
  printBox('After generating skills, try setting up persistent agents too', [
100
- `${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# guided setup + start')}`,
102
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# choose agents + launch')}`,
101
103
  '',
102
104
  `${dim('Background agents that watch your dev server, run tests,')}`,
103
- `${dim('and keep your skills up to date. Each agent in .openclaw/agents/')}`,
104
- `${dim('runs independently add, remove, or customize them freely.')}`,
105
+ `${dim('and keep your skills up to date. Each opens in its own')}`,
106
+ `${dim('terminal window. Pick which to run and choose your models.')}`,
105
107
  ]);
106
108
  }
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const CONFIG_PATH = '.openclaw/config.json';
5
+
6
+ /**
7
+ * Default config structure:
8
+ * {
9
+ * project: { name, language, framework, devCmd, testCmd, conventions },
10
+ * agents: {
11
+ * "log-watcher": { enabled: true, model: "anthropic/claude-haiku-4-20250414" },
12
+ * ...
13
+ * }
14
+ * }
15
+ */
16
+
17
+ /**
18
+ * Read the config file. Returns null if it doesn't exist.
19
+ */
20
+ export function readConfig(projectDir) {
21
+ const configPath = join(projectDir, CONFIG_PATH);
22
+ if (!existsSync(configPath)) return null;
23
+ try {
24
+ return JSON.parse(readFileSync(configPath, 'utf8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Write config to .openclaw/config.json
32
+ */
33
+ export function writeConfig(projectDir, config) {
34
+ const configPath = join(projectDir, CONFIG_PATH);
35
+ mkdirSync(join(projectDir, '.openclaw'), { recursive: true });
36
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
37
+ }
38
+
39
+ /**
40
+ * Detect if this is a SerpentStack template project and return defaults.
41
+ * Returns null if not a template project.
42
+ */
43
+ export function detectTemplateDefaults(projectDir) {
44
+ const makefile = join(projectDir, 'Makefile');
45
+ if (!existsSync(makefile)) return null;
46
+
47
+ try {
48
+ const content = readFileSync(makefile, 'utf8');
49
+ // SerpentStack Makefile has these distinctive targets
50
+ if (!content.includes('make verify') && !content.includes('.PHONY:') ) return null;
51
+ if (!content.includes('uv run') && !content.includes('uvicorn')) return null;
52
+
53
+ // It's a SerpentStack template project — return smart defaults
54
+ const defaults = {
55
+ language: 'Python + TypeScript',
56
+ framework: 'FastAPI + React',
57
+ devCmd: 'make dev',
58
+ testCmd: 'make verify',
59
+ conventions: 'Services flush, routes commit. Domain returns, not exceptions. Real Postgres in tests.',
60
+ };
61
+
62
+ // Try to detect project name from scripts/init.py or package.json
63
+ const pkgPath = join(projectDir, 'frontend', 'package.json');
64
+ if (existsSync(pkgPath)) {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
67
+ if (pkg.name && pkg.name !== 'frontend') defaults.name = pkg.name;
68
+ } catch { /* ignore */ }
69
+ }
70
+
71
+ return defaults;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Build a default agent config entry from an AGENT.md's parsed meta.
79
+ */
80
+ export function defaultAgentConfig(meta) {
81
+ return {
82
+ enabled: true,
83
+ model: meta.model || 'anthropic/claude-haiku-4-20250414',
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Get the effective model for an agent, respecting config overrides.
89
+ */
90
+ export function getEffectiveModel(agentName, agentMeta, config) {
91
+ if (config?.agents?.[agentName]?.model) {
92
+ return config.agents[agentName].model;
93
+ }
94
+ return agentMeta.model || 'anthropic/claude-haiku-4-20250414';
95
+ }
96
+
97
+ /**
98
+ * Check if an agent is enabled in the config.
99
+ */
100
+ export function isAgentEnabled(agentName, config) {
101
+ if (!config?.agents?.[agentName]) return true; // enabled by default
102
+ return config.agents[agentName].enabled !== false;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,358 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } 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, confirm, bold, dim, green, cyan, yellow, printBox, printHeader } from '../utils/ui.js';
7
- import {
8
- parseAgentMd,
9
- discoverAgents,
10
- generateWorkspace,
11
- writePid,
12
- readPid,
13
- removePid,
14
- listPids,
15
- isProcessAlive,
16
- cleanStalePids,
17
- cleanWorkspace,
18
- } from '../utils/agent-utils.js';
19
-
20
- function which(cmd) {
21
- return new Promise((resolve) => {
22
- execFile('which', [cmd], (err) => resolve(!err));
23
- });
24
- }
25
-
26
- async function askQuestion(rl, label, hint) {
27
- const answer = await rl.question(` ${green('?')} ${bold(label)}${hint ? ` ${dim(hint)}` : ''}: `);
28
- return answer.trim();
29
- }
30
-
31
- // ─── Stop Flow ──────────────────────────────────────────────
32
-
33
- async function stopAllAgents(projectDir) {
34
- cleanStalePids(projectDir);
35
- const running = listPids(projectDir);
36
-
37
- if (running.length === 0) {
38
- info('No agents are currently running.');
39
- console.log();
40
- return;
41
- }
42
-
43
- console.log(` ${dim('Stopping')} ${bold(String(running.length))} ${dim('agent(s)...')}`);
44
- console.log();
45
-
46
- let stopped = 0;
47
- for (const { name, pid } of running) {
48
- try {
49
- process.kill(pid, 'SIGTERM');
50
- removePid(projectDir, name);
51
- cleanWorkspace(projectDir, name);
52
- success(`${bold(name)} stopped ${dim(`(PID ${pid})`)}`);
53
- stopped++;
54
- } catch (err) {
55
- if (err.code === 'ESRCH') {
56
- // Process already dead
57
- removePid(projectDir, name);
58
- success(`${bold(name)} already stopped`);
59
- stopped++;
60
- } else {
61
- error(`Failed to stop ${bold(name)}: ${err.message}`);
62
- }
63
- }
64
- }
65
-
66
- console.log();
67
- success(`${green(String(stopped))} agent(s) stopped`);
68
- console.log();
69
- }
70
-
71
- // ─── Customize Workspace ────────────────────────────────────
72
-
73
- async function customizeWorkspace(projectDir) {
74
- const rl = createInterface({ input: stdin, output: stdout });
75
-
76
- console.log();
77
- console.log(` ${bold('Configure your project identity')}`);
78
- console.log(` ${dim('Answer a few questions so all agents understand your project.')}`);
79
- console.log();
80
-
81
- try {
82
- const name = await askQuestion(rl, 'Project name', '(e.g., Acme API)');
83
- const lang = await askQuestion(rl, 'Primary language', '(e.g., Python, TypeScript)');
84
- const framework = await askQuestion(rl, 'Framework', '(e.g., FastAPI, Next.js, Django)');
85
- const devCmd = await askQuestion(rl, 'Dev server command', '(e.g., make dev, npm run dev)');
86
- const testCmd = await askQuestion(rl, 'Test command', '(e.g., make test, pytest, npm test)');
87
- const conventions = await askQuestion(rl, 'Key conventions', '(brief, e.g., "services flush, routes commit")');
88
-
89
- console.log();
90
-
91
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
92
- let soul = readFileSync(soulPath, 'utf8');
93
-
94
- const projectContext = [
95
- `# ${name} — Persistent Development Agents`,
96
- '',
97
- `**Project:** ${name}`,
98
- `**Language:** ${lang}`,
99
- `**Framework:** ${framework}`,
100
- `**Dev server:** \`${devCmd}\``,
101
- `**Tests:** \`${testCmd}\``,
102
- `**Conventions:** ${conventions}`,
103
- '',
104
- '---',
105
- '',
106
- ].join('\n');
107
-
108
- const dashIndex = soul.indexOf('---');
109
- if (dashIndex !== -1) {
110
- soul = projectContext + soul.slice(dashIndex + 3).trimStart();
111
- } else {
112
- soul = projectContext + soul;
113
- }
114
-
115
- writeFileSync(soulPath, soul, 'utf8');
116
- success(`Updated ${bold('.openclaw/SOUL.md')} with ${green(name)} project context`);
117
-
118
- return true;
119
- } finally {
120
- rl.close();
121
- }
122
- }
123
-
124
- // ─── Install OpenClaw ───────────────────────────────────────
125
-
126
- async function installOpenClaw() {
127
- console.log();
128
- warn('OpenClaw is not installed.');
129
- console.log();
130
- console.log(` ${dim('OpenClaw is the persistent agent runtime. Each agent in')}`);
131
- console.log(` ${dim('.openclaw/agents/ runs as a separate OpenClaw process.')}`);
132
- console.log();
133
-
134
- const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
135
- if (!install) {
136
- console.log();
137
- info('Install manually when ready:');
138
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
139
- console.log(` ${dim('$')} ${bold('serpentstack skills persistent')}`);
140
- console.log();
141
- return false;
142
- }
143
-
144
- console.log();
145
- info('Installing OpenClaw...');
146
- await new Promise((resolve, reject) => {
147
- const child = spawn('npm', ['install', '-g', 'openclaw@latest'], { stdio: 'inherit' });
148
- child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`)));
149
- });
150
- console.log();
151
- success('OpenClaw installed');
152
- return true;
153
- }
154
-
155
- // ─── Start Agents ───────────────────────────────────────────
156
-
157
- function startAgent(projectDir, agentName, workspacePath) {
158
- return new Promise((resolve, reject) => {
159
- const child = spawn('openclaw', ['start', '--workspace', workspacePath], {
160
- stdio: 'ignore',
161
- detached: true,
162
- cwd: projectDir,
163
- env: {
164
- ...process.env,
165
- OPENCLAW_STATE_DIR: join(workspacePath, '.state'),
166
- },
167
- });
168
-
169
- child.unref();
170
-
171
- child.on('error', (err) => {
172
- reject(new Error(`Failed to start ${agentName}: ${err.message}`));
173
- });
174
-
175
- // Give it a moment to start, then record PID
176
- setTimeout(() => {
177
- writePid(projectDir, agentName, child.pid);
178
- resolve(child.pid);
179
- }, 500);
180
- });
181
- }
182
-
183
- // ─── Main Flow ──────────────────────────────────────────────
184
-
185
- export async function skillsPersistent({ stop = false } = {}) {
186
- const projectDir = process.cwd();
187
-
188
- // --stop flag
189
- if (stop) {
190
- printHeader();
191
- await stopAllAgents(projectDir);
192
- return;
193
- }
194
-
195
- // === Guided setup flow ===
196
- printHeader();
197
-
198
- // Step 1: Check workspace
199
- console.log(` ${bold('Step 1/4')} ${dim('\u2014 Check workspace')}`);
200
- console.log();
201
-
202
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
203
- if (!existsSync(soulPath)) {
204
- error('No .openclaw/ workspace found.');
205
- console.log();
206
- console.log(` Run ${bold('serpentstack skills init')} first to download the workspace files.`);
207
- console.log();
208
- process.exit(1);
209
- }
210
- success('.openclaw/SOUL.md found');
211
-
212
- const agents = discoverAgents(projectDir);
213
- if (agents.length === 0) {
214
- error('No agents found in .openclaw/agents/');
215
- console.log();
216
- console.log(` Run ${bold('serpentstack skills init')} to download the default agents,`);
217
- console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
218
- console.log();
219
- process.exit(1);
220
- }
221
- success(`${green(String(agents.length))} agent(s) found in .openclaw/agents/`);
222
- console.log();
223
-
224
- // Clean up any stale PIDs from previous runs
225
- cleanStalePids(projectDir);
226
-
227
- // Check if any agents are already running
228
- const alreadyRunning = listPids(projectDir);
229
- if (alreadyRunning.length > 0) {
230
- warn(`${bold(String(alreadyRunning.length))} agent(s) already running`);
231
- for (const { name, pid } of alreadyRunning) {
232
- console.log(` ${dim('\u2022')} ${bold(name)} ${dim(`(PID ${pid})`)}`);
233
- }
234
- console.log();
235
- const restart = await confirm('Stop running agents and restart?');
236
- if (restart) {
237
- await stopAllAgents(projectDir);
238
- } else {
239
- console.log();
240
- info('Keeping existing agents running.');
241
- console.log();
242
- return;
243
- }
244
- }
245
-
246
- // Step 2: Configure project identity
247
- console.log(` ${bold('Step 2/4')} ${dim('\u2014 Configure project identity')}`);
248
-
249
- const soulContent = readFileSync(soulPath, 'utf8');
250
- const isCustomized = !soulContent.startsWith('# SerpentStack') && !soulContent.includes('{{PROJECT_NAME}}');
251
-
252
- if (isCustomized) {
253
- console.log();
254
- success('SOUL.md already customized');
255
- console.log();
256
- const reconfigure = await confirm('Reconfigure? (will overwrite current settings)');
257
- if (reconfigure) {
258
- await customizeWorkspace(projectDir);
259
- }
260
- console.log();
261
- } else {
262
- await customizeWorkspace(projectDir);
263
- console.log();
264
- }
265
-
266
- // Step 3: Select agents
267
- console.log(` ${bold('Step 3/4')} ${dim('\u2014 Review agents')}`);
268
- console.log();
269
-
270
- // Parse all agents and display them
271
- const parsed = [];
272
- for (const agent of agents) {
273
- try {
274
- const agentMd = parseAgentMd(agent.agentMdPath);
275
- parsed.push({ ...agent, agentMd });
276
-
277
- const model = agentMd.meta.model || 'default';
278
- const modelShort = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : model.includes('opus') ? 'Opus' : model;
279
- const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
280
-
281
- success(`${bold(agent.name)} ${dim(agentMd.meta.description || '')}`);
282
- console.log(` ${dim(`Model: ${modelShort}`)}${schedule ? dim(` \u2022 Schedule: ${schedule}`) : ''}`);
283
- } catch (err) {
284
- error(`${bold(agent.name)}: ${err.message}`);
285
- }
286
- }
287
-
288
- if (parsed.length === 0) {
289
- console.log();
290
- error('No valid agents found. Check your AGENT.md files.');
291
- console.log();
292
- process.exit(1);
293
- }
294
-
295
- console.log();
296
- console.log(` ${dim(`${parsed.length} agent(s) will be started. Delete an agent's folder to disable it.`)}`);
297
- console.log();
298
-
299
- // Step 4: Install + start
300
- console.log(` ${bold('Step 4/4')} ${dim('\u2014 Start agents')}`);
301
-
302
- const hasOpenClaw = await which('openclaw');
303
- if (!hasOpenClaw) {
304
- const installed = await installOpenClaw();
305
- if (!installed) return;
306
- } else {
307
- success('OpenClaw is installed');
308
- }
309
-
310
- const shouldStart = await confirm(`Start ${parsed.length} agent(s) now?`);
311
- if (!shouldStart) {
312
- console.log();
313
- printBox('Start later', [
314
- `${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# run setup again')}`,
315
- `${dim('$')} ${bold('serpentstack skills persistent --stop')} ${dim('# stop all agents')}`,
316
- ]);
317
- return;
318
- }
319
-
320
- console.log();
321
-
322
- // Read shared SOUL.md
323
- const sharedSoul = readFileSync(soulPath, 'utf8');
324
- let started = 0;
325
-
326
- for (const { name, agentMd } of parsed) {
327
- try {
328
- const workspacePath = generateWorkspace(projectDir, name, agentMd, sharedSoul);
329
- const pid = await startAgent(projectDir, name, workspacePath);
330
-
331
- const model = agentMd.meta.model || 'default';
332
- const modelShort = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : model.includes('opus') ? 'Opus' : model;
333
- const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
334
-
335
- success(`${bold(name)} started ${dim(`(${modelShort}, ${schedule || 'no schedule'}) PID ${pid}`)}`);
336
- started++;
337
- } catch (err) {
338
- error(`${bold(name)}: ${err.message}`);
339
- }
340
- }
341
-
342
- console.log();
343
- if (started > 0) {
344
- success(`${green(String(started))} agent(s) running`);
345
- console.log();
346
- printBox('Manage your agents', [
347
- `${dim('$')} ${bold('serpentstack skills persistent --stop')} ${dim('# stop all agents')}`,
348
- `${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# restart / reconfigure')}`,
349
- '',
350
- `${dim('Add agents:')} ${dim('Create .openclaw/agents/<name>/AGENT.md')}`,
351
- `${dim('Remove agents:')} ${dim('Delete the agent folder from .openclaw/agents/')}`,
352
- `${dim('Customize:')} ${dim('Edit AGENT.md frontmatter (model, schedule, tools)')}`,
353
- ]);
354
- } else {
355
- error('No agents were started. Check the errors above.');
356
- console.log();
357
- }
358
- }