serpentstack 0.1.0
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 +105 -0
- package/lib/commands/skills-init.js +68 -0
- package/lib/commands/skills-persistent.js +148 -0
- package/lib/commands/skills-update.js +80 -0
- package/lib/commands/stack-new.js +80 -0
- package/lib/commands/stack-update.js +64 -0
- package/lib/utils/fs-helpers.js +31 -0
- package/lib/utils/github.js +74 -0
- package/lib/utils/ui.js +64 -0
- package/package.json +34 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { error, bold, getVersion, printHeader } from '../lib/utils/ui.js';
|
|
4
|
+
|
|
5
|
+
function parseFlags(args) {
|
|
6
|
+
const flags = {};
|
|
7
|
+
const positional = [];
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
if (arg.startsWith('--')) {
|
|
10
|
+
const [key, val] = arg.slice(2).split('=');
|
|
11
|
+
flags[key] = val ?? true;
|
|
12
|
+
} else {
|
|
13
|
+
positional.push(arg);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { flags, positional };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function showHelp() {
|
|
20
|
+
printHeader();
|
|
21
|
+
console.log(` ${bold('Usage:')} serpentstack <command> [options]
|
|
22
|
+
|
|
23
|
+
${bold('Stack commands')} (new projects):
|
|
24
|
+
stack new <name> Scaffold a new project from the template
|
|
25
|
+
stack update Update template-level files to latest
|
|
26
|
+
|
|
27
|
+
${bold('Skills commands')} (any project):
|
|
28
|
+
skills init Download base skills + persistent agent configs
|
|
29
|
+
skills update Update base skills to latest versions
|
|
30
|
+
skills persistent --create Set up OpenClaw workspace for your project
|
|
31
|
+
skills persistent --start Install OpenClaw (if needed) and start agent
|
|
32
|
+
skills persistent --stop Stop the background agent
|
|
33
|
+
|
|
34
|
+
${bold('Options:')}
|
|
35
|
+
--force Overwrite existing files
|
|
36
|
+
--all Include new files in updates (skills update)
|
|
37
|
+
--version Show version
|
|
38
|
+
--help Show this help
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const [,, noun, verb, ...rest] = process.argv;
|
|
44
|
+
const { flags, positional } = parseFlags(rest);
|
|
45
|
+
|
|
46
|
+
// Top-level flags
|
|
47
|
+
if (noun === '--version' || flags.version) {
|
|
48
|
+
console.log(getVersion());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!noun || noun === '--help' || flags.help) {
|
|
52
|
+
showHelp();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (noun === 'stack') {
|
|
57
|
+
if (verb === 'new') {
|
|
58
|
+
const { stackNew } = await import('../lib/commands/stack-new.js');
|
|
59
|
+
await stackNew(positional[0]);
|
|
60
|
+
} else if (verb === 'update') {
|
|
61
|
+
const { stackUpdate } = await import('../lib/commands/stack-update.js');
|
|
62
|
+
await stackUpdate({ force: !!flags.force });
|
|
63
|
+
} else {
|
|
64
|
+
error(`Unknown stack command: ${verb}`);
|
|
65
|
+
console.log(`\n Available: ${bold('stack new <name>')}, ${bold('stack update')}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
} else if (noun === 'skills') {
|
|
69
|
+
if (verb === 'init') {
|
|
70
|
+
const { skillsInit } = await import('../lib/commands/skills-init.js');
|
|
71
|
+
await skillsInit({ force: !!flags.force });
|
|
72
|
+
} else if (verb === 'update') {
|
|
73
|
+
const { skillsUpdate } = await import('../lib/commands/skills-update.js');
|
|
74
|
+
await skillsUpdate({ force: !!flags.force, all: !!flags.all });
|
|
75
|
+
} else if (verb === 'persistent') {
|
|
76
|
+
const { skillsPersistent } = await import('../lib/commands/skills-persistent.js');
|
|
77
|
+
await skillsPersistent({
|
|
78
|
+
create: !!flags.create,
|
|
79
|
+
start: !!flags.start,
|
|
80
|
+
stop: !!flags.stop,
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
error(`Unknown skills command: ${verb}`);
|
|
84
|
+
console.log(`\n Available: ${bold('skills init')}, ${bold('skills update')}, ${bold('skills persistent')}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
error(`Unknown command: ${noun}`);
|
|
89
|
+
showHelp();
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Global error handling
|
|
95
|
+
process.on('unhandledRejection', (err) => {
|
|
96
|
+
error(err.message || 'An unexpected error occurred');
|
|
97
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
main().catch((err) => {
|
|
102
|
+
error(err.message || 'An unexpected error occurred');
|
|
103
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { downloadFile } from '../utils/github.js';
|
|
3
|
+
import { safeWrite } from '../utils/fs-helpers.js';
|
|
4
|
+
import { info, success, warn, error, spinner, bold, dim } from '../utils/ui.js';
|
|
5
|
+
|
|
6
|
+
const MANIFEST = [
|
|
7
|
+
'.skills/find-skills/SKILL.md',
|
|
8
|
+
'.skills/generate-skills/SKILL.md',
|
|
9
|
+
'.skills/git-workflow/SKILL.md',
|
|
10
|
+
'.skills/model-routing/SKILL.md',
|
|
11
|
+
'.openclaw/SOUL.md',
|
|
12
|
+
'.openclaw/HEARTBEAT.md',
|
|
13
|
+
'.openclaw/AGENTS.md',
|
|
14
|
+
'SKILL-AUTHORING.md',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export async function skillsInit({ force = false } = {}) {
|
|
18
|
+
info(`Downloading SerpentStack skills into ${bold(process.cwd())}`);
|
|
19
|
+
console.log();
|
|
20
|
+
|
|
21
|
+
const results = { created: 0, skipped: 0, overwritten: 0, failed: 0 };
|
|
22
|
+
const spin = spinner('Downloading files from GitHub...');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
for (const repoPath of MANIFEST) {
|
|
26
|
+
const destPath = join(process.cwd(), repoPath);
|
|
27
|
+
try {
|
|
28
|
+
const content = await downloadFile(repoPath);
|
|
29
|
+
const status = safeWrite(destPath, content, { force });
|
|
30
|
+
results[status]++;
|
|
31
|
+
|
|
32
|
+
const icon = status === 'created' ? '\u2713'
|
|
33
|
+
: status === 'overwritten' ? '\u21BB'
|
|
34
|
+
: '\u2022';
|
|
35
|
+
const label = status === 'skipped' ? dim(`${repoPath} (exists, skipped)`)
|
|
36
|
+
: status === 'overwritten' ? `${repoPath} (updated)`
|
|
37
|
+
: repoPath;
|
|
38
|
+
|
|
39
|
+
// Buffer output — will show after spinner stops
|
|
40
|
+
results[`_log_${repoPath}`] = ` ${icon} ${label}`;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
results.failed++;
|
|
43
|
+
results[`_log_${repoPath}`] = ` \u2717 ${repoPath} — ${err.message}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
spin.stop();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Print file results
|
|
51
|
+
for (const repoPath of MANIFEST) {
|
|
52
|
+
console.log(results[`_log_${repoPath}`]);
|
|
53
|
+
}
|
|
54
|
+
console.log();
|
|
55
|
+
|
|
56
|
+
// Summary
|
|
57
|
+
if (results.created > 0) success(`${results.created} file(s) created`);
|
|
58
|
+
if (results.overwritten > 0) success(`${results.overwritten} file(s) updated`);
|
|
59
|
+
if (results.skipped > 0) warn(`${results.skipped} file(s) skipped (already exist — use ${bold('--force')} to overwrite)`);
|
|
60
|
+
if (results.failed > 0) error(`${results.failed} file(s) failed to download`);
|
|
61
|
+
|
|
62
|
+
// Next steps
|
|
63
|
+
console.log();
|
|
64
|
+
info(bold('Next steps:'));
|
|
65
|
+
console.log(` 1. Open your IDE agent and say: ${bold('"generate skills for my project"')}`);
|
|
66
|
+
console.log(` 2. Start the persistent agent: ${bold('serpentstack skills persistent --start')}`);
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 } 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
|
+
async function persistentCreate() {
|
|
22
|
+
if (!checkOpenClawWorkspace()) {
|
|
23
|
+
error('No .openclaw/ workspace found. Run `serpentstack skills init` first.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
info('Customizing OpenClaw workspace for your project...');
|
|
28
|
+
console.log();
|
|
29
|
+
|
|
30
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
31
|
+
try {
|
|
32
|
+
const name = await rl.question(` Project name: `);
|
|
33
|
+
const lang = await rl.question(` Primary language ${dim('(e.g., Python, TypeScript)')}: `);
|
|
34
|
+
const framework = await rl.question(` Framework ${dim('(e.g., FastAPI, Next.js, Django)')}: `);
|
|
35
|
+
const conventions = await rl.question(` Key conventions ${dim('(brief, e.g., "services flush, routes commit")')}: `);
|
|
36
|
+
|
|
37
|
+
console.log();
|
|
38
|
+
|
|
39
|
+
// Read and update SOUL.md with project-specific info
|
|
40
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
41
|
+
const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
|
|
42
|
+
let soul = readFileSync(soulPath, 'utf8');
|
|
43
|
+
|
|
44
|
+
// Prepend project-specific context
|
|
45
|
+
const projectContext = [
|
|
46
|
+
`# ${name} — Persistent Development Agent`,
|
|
47
|
+
'',
|
|
48
|
+
`**Project:** ${name}`,
|
|
49
|
+
`**Language:** ${lang}`,
|
|
50
|
+
`**Framework:** ${framework}`,
|
|
51
|
+
`**Conventions:** ${conventions}`,
|
|
52
|
+
'',
|
|
53
|
+
'---',
|
|
54
|
+
'',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
// Replace the first heading and everything before the first ---
|
|
58
|
+
const dashIndex = soul.indexOf('---');
|
|
59
|
+
if (dashIndex !== -1) {
|
|
60
|
+
soul = projectContext + soul.slice(dashIndex + 3).trimStart();
|
|
61
|
+
} else {
|
|
62
|
+
soul = projectContext + soul;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
writeFileSync(soulPath, soul, 'utf8');
|
|
66
|
+
success(`Updated .openclaw/SOUL.md with ${bold(name)} project context`);
|
|
67
|
+
info(`Review and customize .openclaw/ files, then run: ${bold('serpentstack skills persistent --start')}`);
|
|
68
|
+
} finally {
|
|
69
|
+
rl.close();
|
|
70
|
+
}
|
|
71
|
+
console.log();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function persistentStart() {
|
|
75
|
+
if (!checkOpenClawWorkspace()) {
|
|
76
|
+
error('No .openclaw/ workspace found. Run `serpentstack skills init` first.');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hasOpenClaw = await which('openclaw');
|
|
81
|
+
if (!hasOpenClaw) {
|
|
82
|
+
warn('OpenClaw is not installed.');
|
|
83
|
+
const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
|
|
84
|
+
if (!install) {
|
|
85
|
+
info(`Install manually: ${bold('npm install -g openclaw@latest')}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
info('Installing OpenClaw...');
|
|
89
|
+
await new Promise((resolve, reject) => {
|
|
90
|
+
const child = spawn('npm', ['install', '-g', 'openclaw@latest'], { stdio: 'inherit' });
|
|
91
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`)));
|
|
92
|
+
});
|
|
93
|
+
success('OpenClaw installed');
|
|
94
|
+
console.log();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
info('Starting persistent agent...');
|
|
98
|
+
const child = spawn('openclaw', ['start', '--workspace', '.openclaw/'], {
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
cwd: process.cwd(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
child.on('error', (err) => {
|
|
104
|
+
error(`Failed to start OpenClaw: ${err.message}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
child.on('close', (code) => {
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
error(`OpenClaw exited with code ${code}`);
|
|
111
|
+
process.exit(code);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function persistentStop() {
|
|
117
|
+
const hasOpenClaw = await which('openclaw');
|
|
118
|
+
if (!hasOpenClaw) {
|
|
119
|
+
error('OpenClaw is not installed. Nothing to stop.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
info('Stopping persistent agent...');
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
execFile('openclaw', ['stop'], (err, _stdout, stderr) => {
|
|
126
|
+
if (err) {
|
|
127
|
+
reject(new Error(stderr || err.message));
|
|
128
|
+
} else {
|
|
129
|
+
resolve();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
success('Persistent agent stopped');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function skillsPersistent({ create = false, start = false, stop = false } = {}) {
|
|
137
|
+
if (create) return persistentCreate();
|
|
138
|
+
if (start) return persistentStart();
|
|
139
|
+
if (stop) return persistentStop();
|
|
140
|
+
|
|
141
|
+
error('Specify an action: --create, --start, or --stop');
|
|
142
|
+
console.log();
|
|
143
|
+
console.log(` ${bold('serpentstack skills persistent --create')} Set up the workspace`);
|
|
144
|
+
console.log(` ${bold('serpentstack skills persistent --start')} Start the background agent`);
|
|
145
|
+
console.log(` ${bold('serpentstack skills persistent --stop')} Stop the background agent`);
|
|
146
|
+
console.log();
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { downloadFile } from '../utils/github.js';
|
|
4
|
+
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
|
+
import { info, success, warn, error, spinner, bold, dim } from '../utils/ui.js';
|
|
6
|
+
|
|
7
|
+
const MANIFEST = [
|
|
8
|
+
'.skills/find-skills/SKILL.md',
|
|
9
|
+
'.skills/generate-skills/SKILL.md',
|
|
10
|
+
'.skills/git-workflow/SKILL.md',
|
|
11
|
+
'.skills/model-routing/SKILL.md',
|
|
12
|
+
'.openclaw/SOUL.md',
|
|
13
|
+
'.openclaw/HEARTBEAT.md',
|
|
14
|
+
'.openclaw/AGENTS.md',
|
|
15
|
+
'SKILL-AUTHORING.md',
|
|
16
|
+
];
|
|
17
|
+
|
|
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']);
|
|
20
|
+
|
|
21
|
+
export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
22
|
+
info(`Updating SerpentStack skills in ${bold(process.cwd())}`);
|
|
23
|
+
console.log();
|
|
24
|
+
|
|
25
|
+
const results = { updated: 0, skipped: 0, unchanged: 0, failed: 0 };
|
|
26
|
+
const spin = spinner('Checking for updates...');
|
|
27
|
+
const logs = [];
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for (const repoPath of MANIFEST) {
|
|
31
|
+
const destPath = join(process.cwd(), repoPath);
|
|
32
|
+
|
|
33
|
+
// By default, only update files that already exist locally
|
|
34
|
+
if (!existsSync(destPath) && !all) {
|
|
35
|
+
logs.push(` \u2022 ${dim(`${repoPath} (not installed — use ${bold('--all')} to add)`)}`);
|
|
36
|
+
results.skipped++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const content = await downloadFile(repoPath);
|
|
42
|
+
|
|
43
|
+
// Check if local content is identical
|
|
44
|
+
if (existsSync(destPath)) {
|
|
45
|
+
const local = readFileSync(destPath, 'utf8');
|
|
46
|
+
if (local === content) {
|
|
47
|
+
logs.push(` \u2022 ${dim(`${repoPath} (up to date)`)}`);
|
|
48
|
+
results.unchanged++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Warn about customizable files
|
|
53
|
+
if (CUSTOMIZABLE.has(repoPath) && !force) {
|
|
54
|
+
logs.push(` ! ${repoPath} (has local changes — use ${bold('--force')} to overwrite)`);
|
|
55
|
+
results.skipped++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const status = safeWrite(destPath, content, { force: true });
|
|
61
|
+
logs.push(` \u21BB ${repoPath} (${status})`);
|
|
62
|
+
results.updated++;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
results.failed++;
|
|
65
|
+
logs.push(` \u2717 ${repoPath} — ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
spin.stop();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const log of logs) console.log(log);
|
|
73
|
+
console.log();
|
|
74
|
+
|
|
75
|
+
if (results.updated > 0) success(`${results.updated} file(s) updated`);
|
|
76
|
+
if (results.unchanged > 0) info(`${results.unchanged} file(s) already up to date`);
|
|
77
|
+
if (results.skipped > 0) warn(`${results.skipped} file(s) skipped`);
|
|
78
|
+
if (results.failed > 0) error(`${results.failed} file(s) failed`);
|
|
79
|
+
console.log();
|
|
80
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { cloneRepo, checkGit } from '../utils/github.js';
|
|
6
|
+
import { info, success, error, spinner, bold } from '../utils/ui.js';
|
|
7
|
+
|
|
8
|
+
const CLEANUP_PATHS = [
|
|
9
|
+
'cli',
|
|
10
|
+
'references',
|
|
11
|
+
'BRIEF.md',
|
|
12
|
+
'PRINCIPLES.md',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function validateName(name) {
|
|
16
|
+
if (!name) return 'Project name is required';
|
|
17
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
|
|
18
|
+
return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function stackNew(name) {
|
|
24
|
+
const nameError = validateName(name);
|
|
25
|
+
if (nameError) {
|
|
26
|
+
error(nameError);
|
|
27
|
+
console.log(`\n Usage: ${bold('serpentstack stack new <project-name>')}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dest = resolve(process.cwd(), name);
|
|
32
|
+
|
|
33
|
+
if (existsSync(dest)) {
|
|
34
|
+
error(`Directory ${bold(name)} already exists`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hasGit = await checkGit();
|
|
39
|
+
if (!hasGit) {
|
|
40
|
+
error('git is not installed. Install git first: https://git-scm.com/downloads');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clone
|
|
45
|
+
const spin = spinner(`Cloning SerpentStack template into ${name}/...`);
|
|
46
|
+
try {
|
|
47
|
+
await cloneRepo(dest);
|
|
48
|
+
spin.stop(success(`Template cloned into ${bold(name)}/`));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
spin.stop();
|
|
51
|
+
error(err.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Clean up repo-specific files
|
|
56
|
+
for (const p of CLEANUP_PATHS) {
|
|
57
|
+
const full = join(dest, p);
|
|
58
|
+
if (existsSync(full)) {
|
|
59
|
+
await rm(full, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Initialize fresh git repo
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
execFile('git', ['init'], { cwd: dest }, (err) => {
|
|
66
|
+
if (err) reject(new Error(`git init failed: ${err.message}`));
|
|
67
|
+
else resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
success('Initialized fresh git repository');
|
|
71
|
+
|
|
72
|
+
// Next steps
|
|
73
|
+
console.log();
|
|
74
|
+
info(bold('Next steps:'));
|
|
75
|
+
console.log(` cd ${name}`);
|
|
76
|
+
console.log(` make init ${bold('#')} interactive project setup`);
|
|
77
|
+
console.log(` make setup ${bold('#')} install dependencies`);
|
|
78
|
+
console.log(` make dev ${bold('#')} start dev server`);
|
|
79
|
+
console.log();
|
|
80
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { downloadFile } from '../utils/github.js';
|
|
4
|
+
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
|
+
import { info, success, warn, error, spinner, bold, dim } from '../utils/ui.js';
|
|
6
|
+
|
|
7
|
+
// Template-level files that may be updated upstream
|
|
8
|
+
const TEMPLATE_FILES = [
|
|
9
|
+
'Makefile',
|
|
10
|
+
'docker-compose.yml',
|
|
11
|
+
'scripts/init.py',
|
|
12
|
+
'.cursorrules',
|
|
13
|
+
'.github/copilot-instructions.md',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export async function stackUpdate({ force = false } = {}) {
|
|
17
|
+
// Verify we're in a SerpentStack project
|
|
18
|
+
if (!existsSync(join(process.cwd(), 'Makefile')) || !existsSync(join(process.cwd(), 'backend'))) {
|
|
19
|
+
error('Not a SerpentStack project. Run this from a directory created with `serpentstack stack new`.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
info(`Checking for template updates in ${bold(process.cwd())}`);
|
|
24
|
+
console.log();
|
|
25
|
+
|
|
26
|
+
const results = { updated: 0, unchanged: 0, failed: 0 };
|
|
27
|
+
const spin = spinner('Fetching latest template files...');
|
|
28
|
+
const logs = [];
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
for (const repoPath of TEMPLATE_FILES) {
|
|
32
|
+
const destPath = join(process.cwd(), repoPath);
|
|
33
|
+
try {
|
|
34
|
+
const content = await downloadFile(repoPath);
|
|
35
|
+
|
|
36
|
+
if (existsSync(destPath)) {
|
|
37
|
+
const local = readFileSync(destPath, 'utf8');
|
|
38
|
+
if (local === content) {
|
|
39
|
+
logs.push(` \u2022 ${dim(`${repoPath} (up to date)`)}`);
|
|
40
|
+
results.unchanged++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const status = safeWrite(destPath, content, { force: true });
|
|
46
|
+
logs.push(` \u21BB ${repoPath} (${status})`);
|
|
47
|
+
results.updated++;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
results.failed++;
|
|
50
|
+
logs.push(` \u2717 ${repoPath} — ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
spin.stop();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const log of logs) console.log(log);
|
|
58
|
+
console.log();
|
|
59
|
+
|
|
60
|
+
if (results.updated > 0) success(`${results.updated} file(s) updated`);
|
|
61
|
+
if (results.unchanged > 0) info(`${results.unchanged} file(s) already up to date`);
|
|
62
|
+
if (results.failed > 0) error(`${results.failed} file(s) failed`);
|
|
63
|
+
console.log();
|
|
64
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Write content to a file with overwrite protection.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} destPath - Absolute or relative path to write to
|
|
8
|
+
* @param {string} content - File content
|
|
9
|
+
* @param {{ force?: boolean }} options
|
|
10
|
+
* @returns {'created' | 'overwritten' | 'skipped'}
|
|
11
|
+
*/
|
|
12
|
+
export function safeWrite(destPath, content, { force = false } = {}) {
|
|
13
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
14
|
+
|
|
15
|
+
if (existsSync(destPath)) {
|
|
16
|
+
if (!force) {
|
|
17
|
+
return 'skipped';
|
|
18
|
+
}
|
|
19
|
+
// Don't overwrite if content is identical
|
|
20
|
+
try {
|
|
21
|
+
const existing = readFileSync(destPath, 'utf8');
|
|
22
|
+
if (existing === content) return 'skipped';
|
|
23
|
+
} catch { /* proceed with overwrite */ }
|
|
24
|
+
|
|
25
|
+
writeFileSync(destPath, content, 'utf8');
|
|
26
|
+
return 'overwritten';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
writeFileSync(destPath, content, 'utf8');
|
|
30
|
+
return 'created';
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const GITHUB_REPO = process.env.SERPENTSTACK_REPO || 'Benja-Pauls/SerpentStack';
|
|
6
|
+
const GITHUB_BRANCH = process.env.SERPENTSTACK_BRANCH || 'main';
|
|
7
|
+
const RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}`;
|
|
8
|
+
const CLONE_URL = `https://github.com/${GITHUB_REPO}.git`;
|
|
9
|
+
|
|
10
|
+
function authHeaders() {
|
|
11
|
+
const token = process.env.GITHUB_TOKEN;
|
|
12
|
+
return token ? { Authorization: `token ${token}` } : {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Download a single file from the SerpentStack repo.
|
|
17
|
+
* Returns the file content as a string.
|
|
18
|
+
* Retries once on network failure.
|
|
19
|
+
*/
|
|
20
|
+
export async function downloadFile(repoPath) {
|
|
21
|
+
const url = `${RAW_BASE}/${repoPath}`;
|
|
22
|
+
let lastError;
|
|
23
|
+
|
|
24
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(url, { headers: authHeaders() });
|
|
27
|
+
if (res.status === 404) {
|
|
28
|
+
throw new Error(`File not found: ${repoPath} (checked ${url})`);
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new Error(`GitHub returned ${res.status} for ${repoPath}`);
|
|
32
|
+
}
|
|
33
|
+
return await res.text();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
lastError = err;
|
|
36
|
+
if (attempt === 0 && err.cause?.code === 'ENOTFOUND') {
|
|
37
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
38
|
+
} else {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw lastError;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clone the SerpentStack repo into `dest` with --depth=1.
|
|
48
|
+
* Removes .git/ after cloning so the user starts fresh.
|
|
49
|
+
*/
|
|
50
|
+
export async function cloneRepo(dest) {
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
execFile('git', ['clone', '--depth=1', CLONE_URL, dest], (err, _stdout, stderr) => {
|
|
53
|
+
if (err) {
|
|
54
|
+
const msg = stderr?.includes('not found')
|
|
55
|
+
? `Could not clone ${CLONE_URL}. Check your internet connection.`
|
|
56
|
+
: `git clone failed: ${stderr || err.message}`;
|
|
57
|
+
reject(new Error(msg));
|
|
58
|
+
} else {
|
|
59
|
+
resolve();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await rm(join(dest, '.git'), { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if git is installed.
|
|
69
|
+
*/
|
|
70
|
+
export function checkGit() {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
execFile('git', ['--version'], (err) => resolve(!err));
|
|
73
|
+
});
|
|
74
|
+
}
|
package/lib/utils/ui.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { stdin, stdout, stderr } from 'node:process';
|
|
4
|
+
|
|
5
|
+
const NO_COLOR = !!process.env.NO_COLOR || !stdout.isTTY;
|
|
6
|
+
|
|
7
|
+
const c = (code) => NO_COLOR ? '' : `\x1b[${code}m`;
|
|
8
|
+
const RESET = c(0);
|
|
9
|
+
const BOLD = c(1);
|
|
10
|
+
const DIM = c(2);
|
|
11
|
+
const RED = c(31);
|
|
12
|
+
const GREEN = c(32);
|
|
13
|
+
const YELLOW = c(33);
|
|
14
|
+
const CYAN = c(36);
|
|
15
|
+
|
|
16
|
+
export const info = (msg) => console.log(`${CYAN}i${RESET} ${msg}`);
|
|
17
|
+
export const success = (msg) => console.log(`${GREEN}\u2713${RESET} ${msg}`);
|
|
18
|
+
export const warn = (msg) => console.log(`${YELLOW}!${RESET} ${msg}`);
|
|
19
|
+
export const error = (msg) => console.error(`${RED}\u2717${RESET} ${msg}`);
|
|
20
|
+
export const dim = (msg) => `${DIM}${msg}${RESET}`;
|
|
21
|
+
export const bold = (msg) => `${BOLD}${msg}${RESET}`;
|
|
22
|
+
|
|
23
|
+
const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
|
|
24
|
+
|
|
25
|
+
export function spinner(msg) {
|
|
26
|
+
if (NO_COLOR || !stderr.isTTY) {
|
|
27
|
+
stderr.write(` ${msg}\n`);
|
|
28
|
+
return { stop(final) { if (final) stderr.write(` ${final}\n`); } };
|
|
29
|
+
}
|
|
30
|
+
let i = 0;
|
|
31
|
+
const id = setInterval(() => {
|
|
32
|
+
stderr.write(`\r${CYAN}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]}${RESET} ${msg}`);
|
|
33
|
+
}, 80);
|
|
34
|
+
return {
|
|
35
|
+
stop(final) {
|
|
36
|
+
clearInterval(id);
|
|
37
|
+
stderr.write(`\r\x1b[K`);
|
|
38
|
+
if (final) console.log(final);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function confirm(msg) {
|
|
44
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
try {
|
|
46
|
+
const answer = await rl.question(`${YELLOW}?${RESET} ${msg} ${dim('(y/N)')} `);
|
|
47
|
+
return answer.trim().toLowerCase() === 'y';
|
|
48
|
+
} finally {
|
|
49
|
+
rl.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getVersion() {
|
|
54
|
+
try {
|
|
55
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
56
|
+
return pkg.version;
|
|
57
|
+
} catch {
|
|
58
|
+
return '0.0.0';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function printHeader() {
|
|
63
|
+
console.log(`\n ${BOLD}${CYAN}\u2728 SerpentStack${RESET} ${DIM}v${getVersion()}${RESET}\n`);
|
|
64
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "serpentstack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"serpentstack": "./bin/serpentstack.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"lib/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"skills",
|
|
20
|
+
"development",
|
|
21
|
+
"fullstack",
|
|
22
|
+
"fastapi",
|
|
23
|
+
"react",
|
|
24
|
+
"openclaw",
|
|
25
|
+
"persistent-agent"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/Benja-Pauls/SerpentStack"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Benja-Pauls/SerpentStack#readme",
|
|
33
|
+
"author": "Ben Paulson"
|
|
34
|
+
}
|