serpentstack 0.2.3 → 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 agent
31
- ${cyan('skills persistent')} --stop Stop the background agent
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
+ }
@@ -12,8 +12,9 @@ const SKILLS_FILES = [
12
12
 
13
13
  const OPENCLAW_FILES = [
14
14
  '.openclaw/SOUL.md',
15
- '.openclaw/HEARTBEAT.md',
16
- '.openclaw/AGENTS.md',
15
+ '.openclaw/agents/log-watcher/AGENT.md',
16
+ '.openclaw/agents/test-runner/AGENT.md',
17
+ '.openclaw/agents/skill-maintainer/AGENT.md',
17
18
  ];
18
19
 
19
20
  const DOCS_FILES = [
@@ -86,20 +87,22 @@ export async function skillsInit({ force = false } = {}) {
86
87
 
87
88
  printPrompt([
88
89
  `Read .skills/generate-skills/SKILL.md and follow its instructions`,
89
- `to generate project-specific skills for this codebase. Interview me`,
90
- `about my architecture decisionshow I handle transactions, auth,`,
91
- `error patterns, testing strategy, and deployment. Ask about the`,
92
- `business domain too: what this app does, key user flows, and where`,
93
- `agents are most likely to make mistakes. Write each skill as a`,
94
- `SKILL.md with complete templates an agent can copy, not vague`,
95
- `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.`,
96
99
  ]);
97
100
 
98
- printBox('After generating skills, try setting up a persistent agent too', [
99
- `${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# guided setup + start')}`,
101
+ printBox('After generating skills, try setting up persistent agents too', [
102
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# choose agents + launch')}`,
100
103
  '',
101
- `${dim('A background agent that watches your dev server, catches')}`,
102
- `${dim('errors, runs tests, and keeps your skills up to date.')}`,
103
- `${dim('Customize its behavior by editing the files in .openclaw/.')}`,
104
+ `${dim('Background agents that watch your dev server, run tests,')}`,
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.')}`,
104
107
  ]);
105
108
  }
@@ -10,13 +10,19 @@ const MANIFEST = [
10
10
  '.skills/git-workflow/SKILL.md',
11
11
  '.skills/model-routing/SKILL.md',
12
12
  '.openclaw/SOUL.md',
13
- '.openclaw/HEARTBEAT.md',
14
- '.openclaw/AGENTS.md',
13
+ '.openclaw/agents/log-watcher/AGENT.md',
14
+ '.openclaw/agents/test-runner/AGENT.md',
15
+ '.openclaw/agents/skill-maintainer/AGENT.md',
15
16
  'SKILL-AUTHORING.md',
16
17
  ];
17
18
 
18
- // OpenClaw files are meant to be customized — warn before overwriting
19
- const CUSTOMIZABLE = new Set(['.openclaw/SOUL.md', '.openclaw/HEARTBEAT.md', '.openclaw/AGENTS.md']);
19
+ // OpenClaw files that are meant to be customized — warn before overwriting
20
+ const CUSTOMIZABLE = new Set([
21
+ '.openclaw/SOUL.md',
22
+ '.openclaw/agents/log-watcher/AGENT.md',
23
+ '.openclaw/agents/test-runner/AGENT.md',
24
+ '.openclaw/agents/skill-maintainer/AGENT.md',
25
+ ]);
20
26
 
21
27
  export async function skillsUpdate({ force = false, all = false } = {}) {
22
28
  printHeader();
@@ -0,0 +1,288 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { homedir } from 'node:os';
5
+
6
+ const STATE_ROOT = join(homedir(), '.serpentstack');
7
+
8
+ /**
9
+ * Parse an AGENT.md file — YAML frontmatter between --- delimiters + markdown body.
10
+ * Returns { meta: { name, description, model, schedule, tools }, body: string }
11
+ */
12
+ export function parseAgentMd(filePath) {
13
+ const raw = readFileSync(filePath, 'utf8');
14
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
15
+ if (!match) {
16
+ throw new Error(`Invalid AGENT.md format — missing frontmatter: ${filePath}`);
17
+ }
18
+
19
+ const meta = parseYamlFrontmatter(match[1]);
20
+ const body = match[2].trim();
21
+ return { meta, body };
22
+ }
23
+
24
+ /**
25
+ * Minimal YAML parser for AGENT.md frontmatter.
26
+ * Handles: scalars, simple lists, and lists of objects (schedule).
27
+ * No external dependencies.
28
+ */
29
+ function parseYamlFrontmatter(yaml) {
30
+ const result = {};
31
+ const lines = yaml.split('\n');
32
+ let i = 0;
33
+
34
+ while (i < lines.length) {
35
+ const line = lines[i];
36
+
37
+ // Skip blank lines
38
+ if (!line.trim()) { i++; continue; }
39
+
40
+ const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
41
+ if (!keyMatch) { i++; continue; }
42
+
43
+ const key = keyMatch[1];
44
+ const inlineValue = keyMatch[2].trim();
45
+
46
+ // Check if next lines are list items
47
+ if (!inlineValue && i + 1 < lines.length && lines[i + 1].match(/^\s+-/)) {
48
+ // It's a list
49
+ const items = [];
50
+ i++;
51
+ while (i < lines.length && lines[i].match(/^\s+-/)) {
52
+ const itemLine = lines[i].replace(/^\s+-\s*/, '');
53
+
54
+ // Check if this is a key: value item (for schedule objects)
55
+ if (itemLine.includes(':')) {
56
+ const obj = {};
57
+ // Parse inline key: value
58
+ const parts = itemLine.match(/(\w[\w-]*):\s*(.*)/);
59
+ if (parts) obj[parts[1]] = parts[2].trim();
60
+
61
+ // Check for continuation lines of this object
62
+ i++;
63
+ while (i < lines.length && lines[i].match(/^\s{4,}\w/) && !lines[i].match(/^\s+-/)) {
64
+ const subMatch = lines[i].trim().match(/^(\w[\w-]*):\s*(.*)/);
65
+ if (subMatch) obj[subMatch[1]] = subMatch[2].trim();
66
+ i++;
67
+ }
68
+ items.push(obj);
69
+ } else {
70
+ items.push(itemLine);
71
+ i++;
72
+ }
73
+ }
74
+ result[key] = items;
75
+ } else {
76
+ result[key] = inlineValue;
77
+ i++;
78
+ }
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Discover all agents in .openclaw/agents/ directory.
86
+ * Returns array of { name, dir, agentMdPath }
87
+ */
88
+ export function discoverAgents(projectDir) {
89
+ const agentsDir = join(projectDir, '.openclaw', 'agents');
90
+ if (!existsSync(agentsDir)) return [];
91
+
92
+ const agents = [];
93
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
94
+ if (!entry.isDirectory()) continue;
95
+ const agentMd = join(agentsDir, entry.name, 'AGENT.md');
96
+ if (existsSync(agentMd)) {
97
+ agents.push({
98
+ name: entry.name,
99
+ dir: join(agentsDir, entry.name),
100
+ agentMdPath: agentMd,
101
+ });
102
+ }
103
+ }
104
+
105
+ return agents.sort((a, b) => a.name.localeCompare(b.name));
106
+ }
107
+
108
+ /**
109
+ * Generate an OpenClaw runtime workspace for a single agent.
110
+ * Combines the shared SOUL.md with agent-specific config.
111
+ *
112
+ * Creates files in ~/.serpentstack/agents/<project-hash>/<agent-name>/
113
+ * Returns the workspace path.
114
+ */
115
+ export function generateWorkspace(projectDir, agentName, agentMd, sharedSoul) {
116
+ const hash = projectHash(projectDir);
117
+ const workspaceDir = join(STATE_ROOT, 'agents', hash, agentName);
118
+ mkdirSync(workspaceDir, { recursive: true });
119
+
120
+ // SOUL.md = shared project soul + agent-specific instructions
121
+ const soul = [
122
+ sharedSoul,
123
+ '',
124
+ '---',
125
+ '',
126
+ `# Agent: ${agentMd.meta.name || agentName}`,
127
+ '',
128
+ agentMd.meta.description ? `> ${agentMd.meta.description}` : '',
129
+ '',
130
+ agentMd.body,
131
+ ].filter(Boolean).join('\n');
132
+
133
+ writeFileSync(join(workspaceDir, 'SOUL.md'), soul, 'utf8');
134
+
135
+ // HEARTBEAT.md = generated from schedule frontmatter
136
+ const heartbeat = generateHeartbeat(agentMd.meta.schedule || []);
137
+ writeFileSync(join(workspaceDir, 'HEARTBEAT.md'), heartbeat, 'utf8');
138
+
139
+ // AGENTS.md = generated from model and tools frontmatter
140
+ const agentsConfig = generateAgentsConfig(agentMd.meta);
141
+ writeFileSync(join(workspaceDir, 'AGENTS.md'), agentsConfig, 'utf8');
142
+
143
+ return workspaceDir;
144
+ }
145
+
146
+ function generateHeartbeat(schedule) {
147
+ const lines = ['# Heartbeat Schedule', ''];
148
+
149
+ if (schedule.length === 0) {
150
+ lines.push('No scheduled checks configured.');
151
+ return lines.join('\n');
152
+ }
153
+
154
+ for (const entry of schedule) {
155
+ const interval = entry.every || 'unknown';
156
+ const task = entry.task || entry.check || 'unnamed-task';
157
+ lines.push(`## Every ${interval}: ${task}`);
158
+ lines.push('');
159
+ lines.push(`Run the \`${task}\` check as defined in the agent instructions.`);
160
+ lines.push('');
161
+ }
162
+
163
+ return lines.join('\n');
164
+ }
165
+
166
+ function generateAgentsConfig(meta) {
167
+ const model = meta.model || 'anthropic/claude-haiku-4-20250414';
168
+ const tools = meta.tools || ['file-system', 'shell', 'git'];
169
+
170
+ return [
171
+ '# Agent Configuration',
172
+ '',
173
+ '## Workspace',
174
+ '',
175
+ '```yaml',
176
+ `name: ${meta.name || 'unnamed-agent'}`,
177
+ `description: ${meta.description || 'Persistent agent'}`,
178
+ 'workspace: .',
179
+ '```',
180
+ '',
181
+ '## Model',
182
+ '',
183
+ '```yaml',
184
+ `primary_model: ${model}`,
185
+ `heartbeat_model: ${model}`,
186
+ '```',
187
+ '',
188
+ '## Tool Access',
189
+ '',
190
+ ...tools.map(t => `- **${t}**`),
191
+ '',
192
+ '## Operating Rules',
193
+ '',
194
+ '1. Read `.skills/` on startup — follow project conventions.',
195
+ '2. Notify before fixing — report issues with context.',
196
+ '3. Run verification after changes.',
197
+ '4. Keep memory lean.',
198
+ '',
199
+ ].join('\n');
200
+ }
201
+
202
+ // ─── PID Management ──────────────────────────────────────────
203
+
204
+ /**
205
+ * Write a PID file for a running agent.
206
+ */
207
+ export function writePid(projectDir, agentName, pid) {
208
+ const pidDir = join(STATE_ROOT, 'pids', projectHash(projectDir));
209
+ mkdirSync(pidDir, { recursive: true });
210
+ writeFileSync(join(pidDir, `${agentName}.pid`), String(pid), 'utf8');
211
+ }
212
+
213
+ /**
214
+ * Read the PID for a running agent. Returns null if not found.
215
+ */
216
+ export function readPid(projectDir, agentName) {
217
+ const pidFile = join(STATE_ROOT, 'pids', projectHash(projectDir), `${agentName}.pid`);
218
+ if (!existsSync(pidFile)) return null;
219
+ const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
220
+ return isNaN(pid) ? null : pid;
221
+ }
222
+
223
+ /**
224
+ * Remove the PID file for an agent.
225
+ */
226
+ export function removePid(projectDir, agentName) {
227
+ const pidFile = join(STATE_ROOT, 'pids', projectHash(projectDir), `${agentName}.pid`);
228
+ if (existsSync(pidFile)) unlinkSync(pidFile);
229
+ }
230
+
231
+ /**
232
+ * List all agents with PID files for this project.
233
+ * Returns array of { name, pid }
234
+ */
235
+ export function listPids(projectDir) {
236
+ const pidDir = join(STATE_ROOT, 'pids', projectHash(projectDir));
237
+ if (!existsSync(pidDir)) return [];
238
+
239
+ return readdirSync(pidDir)
240
+ .filter(f => f.endsWith('.pid'))
241
+ .map(f => {
242
+ const name = f.replace('.pid', '');
243
+ const pid = readPid(projectDir, name);
244
+ return pid ? { name, pid } : null;
245
+ })
246
+ .filter(Boolean);
247
+ }
248
+
249
+ /**
250
+ * Check if a process is alive.
251
+ */
252
+ export function isProcessAlive(pid) {
253
+ try {
254
+ process.kill(pid, 0);
255
+ return true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Clean up PID files for dead processes.
263
+ */
264
+ export function cleanStalePids(projectDir) {
265
+ for (const { name, pid } of listPids(projectDir)) {
266
+ if (!isProcessAlive(pid)) {
267
+ removePid(projectDir, name);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Clean up generated workspace files for an agent.
274
+ */
275
+ export function cleanWorkspace(projectDir, agentName) {
276
+ const hash = projectHash(projectDir);
277
+ const workspaceDir = join(STATE_ROOT, 'agents', hash, agentName);
278
+ if (existsSync(workspaceDir)) {
279
+ rmSync(workspaceDir, { recursive: true, force: true });
280
+ }
281
+ }
282
+
283
+ // ─── Helpers ─────────────────────────────────────────────────
284
+
285
+ function projectHash(projectDir) {
286
+ const absolute = resolve(projectDir);
287
+ return createHash('sha256').update(absolute).digest('hex').slice(0, 12);
288
+ }
@@ -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.3",
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,249 +0,0 @@
1
- import { existsSync } 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, printBox, printHeader } from '../utils/ui.js';
7
-
8
- function which(cmd) {
9
- return new Promise((resolve) => {
10
- execFile('which', [cmd], (err) => resolve(!err));
11
- });
12
- }
13
-
14
- function checkOpenClawWorkspace() {
15
- const dir = join(process.cwd(), '.openclaw');
16
- const required = ['SOUL.md', 'HEARTBEAT.md', 'AGENTS.md'];
17
- if (!existsSync(dir)) return false;
18
- return required.every((f) => existsSync(join(dir, f)));
19
- }
20
-
21
- function checkSoulCustomized() {
22
- const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
23
- if (!existsSync(soulPath)) return false;
24
- const { readFileSync } = require_fs();
25
- const content = readFileSync(soulPath, 'utf8');
26
- // If it still has the template placeholder, it's not customized
27
- return !content.includes('{{PROJECT_NAME}}') && !content.startsWith('# SerpentStack');
28
- }
29
-
30
- function require_fs() {
31
- // Lazy import to avoid top-level dynamic import
32
- return { readFileSync: existsSync ? (await_import()).readFileSync : null };
33
- }
34
-
35
- async function askQuestion(rl, label, hint) {
36
- const answer = await rl.question(` ${green('?')} ${bold(label)}${hint ? ` ${dim(hint)}` : ''}: `);
37
- return answer.trim();
38
- }
39
-
40
- async function customizeWorkspace() {
41
- const rl = createInterface({ input: stdin, output: stdout });
42
-
43
- console.log();
44
- console.log(` ${bold('Configure your persistent agent')}`);
45
- console.log(` ${dim("Answer a few questions so the agent understands your project.")}`);
46
- console.log();
47
-
48
- try {
49
- const name = await askQuestion(rl, 'Project name', '(e.g., Acme API)');
50
- const lang = await askQuestion(rl, 'Primary language', '(e.g., Python, TypeScript)');
51
- const framework = await askQuestion(rl, 'Framework', '(e.g., FastAPI, Next.js, Django)');
52
- const devCmd = await askQuestion(rl, 'Dev server command', '(e.g., make dev, npm run dev)');
53
- const testCmd = await askQuestion(rl, 'Test command', '(e.g., make test, pytest, npm test)');
54
- const conventions = await askQuestion(rl, 'Key conventions', '(brief, e.g., "services flush, routes commit")');
55
-
56
- console.log();
57
-
58
- // Read and update SOUL.md with project-specific info
59
- const { readFileSync, writeFileSync } = await import('node:fs');
60
- const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
61
- let soul = readFileSync(soulPath, 'utf8');
62
-
63
- const projectContext = [
64
- `# ${name} — Persistent Development Agent`,
65
- '',
66
- `**Project:** ${name}`,
67
- `**Language:** ${lang}`,
68
- `**Framework:** ${framework}`,
69
- `**Dev server:** \`${devCmd}\``,
70
- `**Tests:** \`${testCmd}\``,
71
- `**Conventions:** ${conventions}`,
72
- '',
73
- '---',
74
- '',
75
- ].join('\n');
76
-
77
- const dashIndex = soul.indexOf('---');
78
- if (dashIndex !== -1) {
79
- soul = projectContext + soul.slice(dashIndex + 3).trimStart();
80
- } else {
81
- soul = projectContext + soul;
82
- }
83
-
84
- writeFileSync(soulPath, soul, 'utf8');
85
- success(`Updated ${bold('.openclaw/SOUL.md')} with ${green(name)} project context`);
86
-
87
- // Update HEARTBEAT.md with the actual dev/test commands
88
- const heartbeatPath = join(process.cwd(), '.openclaw/HEARTBEAT.md');
89
- if (existsSync(heartbeatPath)) {
90
- let heartbeat = readFileSync(heartbeatPath, 'utf8');
91
- // Replace placeholder commands if they exist
92
- heartbeat = heartbeat.replace(/make dev/g, devCmd);
93
- heartbeat = heartbeat.replace(/make test/g, testCmd);
94
- writeFileSync(heartbeatPath, heartbeat, 'utf8');
95
- success(`Updated ${bold('.openclaw/HEARTBEAT.md')} with your dev/test commands`);
96
- }
97
-
98
- return true;
99
- } finally {
100
- rl.close();
101
- }
102
- }
103
-
104
- async function installOpenClaw() {
105
- console.log();
106
- warn('OpenClaw is not installed.');
107
- console.log();
108
- console.log(` ${dim('OpenClaw is the persistent agent runtime. It runs in the background,')}`);
109
- console.log(` ${dim('watching your dev server and running health checks on a schedule.')}`);
110
- console.log();
111
-
112
- const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
113
- if (!install) {
114
- console.log();
115
- info(`Install manually when ready:`);
116
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
117
- console.log(` ${dim('$')} ${bold('serpentstack skills persistent')}`);
118
- console.log();
119
- return false;
120
- }
121
-
122
- console.log();
123
- info('Installing OpenClaw...');
124
- await new Promise((resolve, reject) => {
125
- const child = spawn('npm', ['install', '-g', 'openclaw@latest'], { stdio: 'inherit' });
126
- child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`)));
127
- });
128
- console.log();
129
- success('OpenClaw installed');
130
- return true;
131
- }
132
-
133
- async function startAgent() {
134
- console.log();
135
- info('Starting persistent agent...');
136
- console.log();
137
- console.log(` ${dim('The agent will:')}`);
138
- console.log(` ${dim('\u2022 Watch your dev server for errors')}`);
139
- console.log(` ${dim('\u2022 Run tests on a schedule')}`);
140
- console.log(` ${dim('\u2022 Flag when skills go stale')}`);
141
- console.log(` ${dim('\u2022 Propose fixes with full context')}`);
142
- console.log();
143
-
144
- const child = spawn('openclaw', ['start', '--workspace', '.openclaw/'], {
145
- stdio: 'inherit',
146
- cwd: process.cwd(),
147
- });
148
-
149
- child.on('error', (err) => {
150
- error(`Failed to start OpenClaw: ${err.message}`);
151
- process.exit(1);
152
- });
153
-
154
- child.on('close', (code) => {
155
- if (code !== 0) {
156
- error(`OpenClaw exited with code ${code}`);
157
- process.exit(code);
158
- }
159
- });
160
- }
161
-
162
- export async function skillsPersistent({ stop = false } = {}) {
163
- // --stop is the only flag — everything else is a guided flow
164
- if (stop) {
165
- const hasOpenClaw = await which('openclaw');
166
- if (!hasOpenClaw) {
167
- error('OpenClaw is not installed. Nothing to stop.');
168
- return;
169
- }
170
- info('Stopping persistent agent...');
171
- await new Promise((resolve, reject) => {
172
- execFile('openclaw', ['stop'], (err, _stdout, stderr) => {
173
- if (err) reject(new Error(stderr || err.message));
174
- else resolve();
175
- });
176
- });
177
- success('Persistent agent stopped');
178
- console.log();
179
- return;
180
- }
181
-
182
- // === Guided setup flow ===
183
- printHeader();
184
-
185
- // Step 1: Check for .openclaw/ workspace
186
- console.log(` ${bold('Step 1/3')} ${dim('\u2014 Check workspace')}`);
187
- console.log();
188
-
189
- if (!checkOpenClawWorkspace()) {
190
- error('No .openclaw/ workspace found.');
191
- console.log();
192
- console.log(` Run ${bold('serpentstack skills init')} first to download the workspace files.`);
193
- console.log();
194
- process.exit(1);
195
- }
196
-
197
- success('.openclaw/ workspace found');
198
- console.log();
199
-
200
- // Step 2: Customize if needed
201
- console.log(` ${bold('Step 2/3')} ${dim('\u2014 Configure for your project')}`);
202
-
203
- // Check if SOUL.md looks like it has been customized
204
- const { readFileSync } = await import('node:fs');
205
- const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
206
- const soulContent = readFileSync(soulPath, 'utf8');
207
- const isCustomized = !soulContent.startsWith('# SerpentStack') && !soulContent.includes('{{PROJECT_NAME}}');
208
-
209
- if (isCustomized) {
210
- console.log();
211
- success('Workspace already customized');
212
- console.log();
213
-
214
- const reconfigure = await confirm('Reconfigure? (will overwrite current settings)');
215
- if (reconfigure) {
216
- await customizeWorkspace();
217
- }
218
- console.log();
219
- } else {
220
- await customizeWorkspace();
221
- console.log();
222
- }
223
-
224
- // Step 3: Install OpenClaw + start
225
- console.log(` ${bold('Step 3/3')} ${dim('\u2014 Start the agent')}`);
226
-
227
- const hasOpenClaw = await which('openclaw');
228
- if (!hasOpenClaw) {
229
- const installed = await installOpenClaw();
230
- if (!installed) return;
231
- } else {
232
- success('OpenClaw is installed');
233
- }
234
-
235
- const shouldStart = await confirm('Start the persistent agent now?');
236
- if (!shouldStart) {
237
- console.log();
238
- printBox('Start later', [
239
- `${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# run setup again')}`,
240
- `${dim('$')} ${bold('openclaw start --workspace .openclaw/')} ${dim('# start directly')}`,
241
- '',
242
- `${dim('To stop:')}`,
243
- `${dim('$')} ${bold('serpentstack skills persistent --stop')}`,
244
- ]);
245
- return;
246
- }
247
-
248
- await startAgent();
249
- }