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.
@@ -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('Step 1/2')} ${dim('\u2014 Downloading skills + persistent agent configs')}`);
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
- // Step 2: What to do next
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('Step 2/2')} ${dim('\u2014 Generate project-specific skills')}`);
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. 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.`,
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 skills persistent')} ${dim('# guided setup + start')}`,
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 agent in .openclaw/agents/')}`,
104
- `${dim('runs independently add, remove, or customize them freely.')}`,
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
  }