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.
- package/bin/serpentstack.js +24 -18
- package/lib/commands/persistent.js +528 -0
- package/lib/commands/skills-init.js +12 -10
- package/lib/utils/config.js +103 -0
- package/package.json +1 -1
- package/lib/commands/skills-persistent.js +0 -358
package/bin/serpentstack.js
CHANGED
|
@@ -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
|
|
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('
|
|
24
|
-
${cyan('stack new')} <name> Scaffold a
|
|
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('
|
|
28
|
-
${cyan('skills
|
|
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('
|
|
31
|
-
${cyan('
|
|
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
|
|
42
|
-
${dim('$')} serpentstack
|
|
43
|
-
${dim('$')} serpentstack
|
|
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
|
|
51
|
-
const { flags, positional } =
|
|
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(
|
|
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
|
|
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.
|
|
91
|
-
|
|
92
|
-
`
|
|
93
|
-
`
|
|
94
|
-
`
|
|
95
|
-
`
|
|
96
|
-
`
|
|
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
|
|
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
|
|
104
|
-
`${dim('
|
|
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,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
|
-
}
|