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.
- package/bin/serpentstack.js +73 -23
- package/lib/commands/persistent.js +559 -0
- package/lib/commands/skills-init.js +51 -14
- package/lib/commands/stack-new.js +44 -9
- package/lib/utils/config.js +106 -0
- package/lib/utils/fs-helpers.js +1 -1
- package/lib/utils/models.js +156 -0
- package/lib/utils/ui.js +5 -5
- package/package.json +1 -1
- package/lib/commands/skills-persistent.js +0 -358
|
@@ -26,6 +26,22 @@ function checkCommand(cmd) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function getCommandVersion(cmd, args = ['--version']) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|
|
32
|
+
if (err) resolve(null);
|
|
33
|
+
else resolve(stdout.trim());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseVersion(versionStr) {
|
|
39
|
+
if (!versionStr) return null;
|
|
40
|
+
const match = versionStr.match(/(\d+)\.(\d+)/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
return { major: parseInt(match[1]), minor: parseInt(match[2]) };
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
export async function stackNew(name) {
|
|
30
46
|
const nameError = validateName(name);
|
|
31
47
|
if (nameError) {
|
|
@@ -49,20 +65,38 @@ export async function stackNew(name) {
|
|
|
49
65
|
|
|
50
66
|
const checks = [
|
|
51
67
|
{ cmd: 'git', label: 'git', url: 'https://git-scm.com/downloads' },
|
|
52
|
-
{ cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads' },
|
|
53
|
-
{ cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org' },
|
|
68
|
+
{ cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads', minMajor: 3, minMinor: 12 },
|
|
69
|
+
{ cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org', minMajor: 22, minMinor: 0 },
|
|
54
70
|
{ cmd: 'docker', label: 'Docker', url: 'https://docker.com/get-started' },
|
|
55
71
|
{ cmd: 'uv', label: 'uv (Python package manager)', url: 'https://docs.astral.sh/uv' },
|
|
56
72
|
];
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
// Run all checks in parallel
|
|
75
|
+
const results = await Promise.all(checks.map(async ({ cmd, label, url, minMajor, minMinor }) => {
|
|
60
76
|
const found = await checkCommand(cmd);
|
|
61
|
-
if (found) {
|
|
62
|
-
|
|
77
|
+
if (!found) return { label, url, found: false };
|
|
78
|
+
|
|
79
|
+
// Version check if required
|
|
80
|
+
if (minMajor != null) {
|
|
81
|
+
const vStr = await getCommandVersion(cmd);
|
|
82
|
+
const ver = parseVersion(vStr);
|
|
83
|
+
if (ver && (ver.major < minMajor || (ver.major === minMajor && ver.minor < minMinor))) {
|
|
84
|
+
return { label, url, found: true, versionOk: false, version: vStr };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { label, found: true, versionOk: true };
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
let missing = [];
|
|
91
|
+
for (const r of results) {
|
|
92
|
+
if (!r.found) {
|
|
93
|
+
console.log(` ${dim('\u2022')} ${dim(r.label)} ${dim(`\u2014 install: ${r.url}`)}`);
|
|
94
|
+
missing.push(r.label);
|
|
95
|
+
} else if (r.versionOk === false) {
|
|
96
|
+
console.log(` ${yellow('\u25B3')} ${r.label} ${dim(`\u2014 found ${r.version}, need newer version`)}`);
|
|
97
|
+
missing.push(r.label);
|
|
63
98
|
} else {
|
|
64
|
-
console.log(` ${
|
|
65
|
-
missing.push(label);
|
|
99
|
+
console.log(` ${green('\u2713')} ${r.label}`);
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
console.log();
|
|
@@ -85,7 +119,8 @@ export async function stackNew(name) {
|
|
|
85
119
|
const spin = spinner(`Downloading SerpentStack template...`);
|
|
86
120
|
try {
|
|
87
121
|
await cloneRepo(dest);
|
|
88
|
-
spin.stop(
|
|
122
|
+
spin.stop();
|
|
123
|
+
success(`Template cloned into ${green(name)}/`);
|
|
89
124
|
} catch (err) {
|
|
90
125
|
spin.stop();
|
|
91
126
|
error(err.message);
|
|
@@ -0,0 +1,106 @@
|
|
|
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 (err) {
|
|
26
|
+
if (err instanceof SyntaxError) {
|
|
27
|
+
console.error(` \x1b[33m\u25B3\x1b[0m .openclaw/config.json has invalid JSON — ignoring. Fix it or delete it to regenerate.`);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write config to .openclaw/config.json
|
|
35
|
+
*/
|
|
36
|
+
export function writeConfig(projectDir, config) {
|
|
37
|
+
const configPath = join(projectDir, CONFIG_PATH);
|
|
38
|
+
mkdirSync(join(projectDir, '.openclaw'), { recursive: true });
|
|
39
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect if this is a SerpentStack template project and return defaults.
|
|
44
|
+
* Returns null if not a template project.
|
|
45
|
+
*/
|
|
46
|
+
export function detectTemplateDefaults(projectDir) {
|
|
47
|
+
const makefile = join(projectDir, 'Makefile');
|
|
48
|
+
if (!existsSync(makefile)) return null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(makefile, 'utf8');
|
|
52
|
+
// SerpentStack Makefile: must have 'make verify' AND either 'uv run' or 'uvicorn'
|
|
53
|
+
if (!content.includes('make verify')) return null;
|
|
54
|
+
if (!content.includes('uv run') && !content.includes('uvicorn')) return null;
|
|
55
|
+
|
|
56
|
+
// It's a SerpentStack template project — return smart defaults
|
|
57
|
+
const defaults = {
|
|
58
|
+
language: 'Python + TypeScript',
|
|
59
|
+
framework: 'FastAPI + React',
|
|
60
|
+
devCmd: 'make dev',
|
|
61
|
+
testCmd: 'make verify',
|
|
62
|
+
conventions: 'Services flush, routes commit. Domain returns, not exceptions. Real Postgres in tests.',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Try to detect project name from scripts/init.py or package.json
|
|
66
|
+
const pkgPath = join(projectDir, 'frontend', 'package.json');
|
|
67
|
+
if (existsSync(pkgPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
70
|
+
if (pkg.name && pkg.name !== 'frontend') defaults.name = pkg.name;
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return defaults;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a default agent config entry from an AGENT.md's parsed meta.
|
|
82
|
+
*/
|
|
83
|
+
export function defaultAgentConfig(meta) {
|
|
84
|
+
return {
|
|
85
|
+
enabled: true,
|
|
86
|
+
model: meta.model || 'anthropic/claude-haiku-4-20250414',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the effective model for an agent, respecting config overrides.
|
|
92
|
+
*/
|
|
93
|
+
export function getEffectiveModel(agentName, agentMeta, config) {
|
|
94
|
+
if (config?.agents?.[agentName]?.model) {
|
|
95
|
+
return config.agents[agentName].model;
|
|
96
|
+
}
|
|
97
|
+
return agentMeta.model || 'anthropic/claude-haiku-4-20250414';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if an agent is enabled in the config.
|
|
102
|
+
*/
|
|
103
|
+
export function isAgentEnabled(agentName, config) {
|
|
104
|
+
if (!config?.agents?.[agentName]) return true; // enabled by default
|
|
105
|
+
return config.agents[agentName].enabled !== false;
|
|
106
|
+
}
|
package/lib/utils/fs-helpers.js
CHANGED
|
@@ -19,7 +19,7 @@ export function safeWrite(destPath, content, { force = false } = {}) {
|
|
|
19
19
|
// Don't overwrite if content is identical
|
|
20
20
|
try {
|
|
21
21
|
const existing = readFileSync(destPath, 'utf8');
|
|
22
|
-
if (existing === content) return '
|
|
22
|
+
if (existing === content) return 'unchanged';
|
|
23
23
|
} catch { /* proceed with overwrite */ }
|
|
24
24
|
|
|
25
25
|
writeFileSync(destPath, content, 'utf8');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect all available models: local (Ollama) and cloud (via OpenClaw auth).
|
|
5
|
+
* Local models are preferred for persistent agents — they're free and fast.
|
|
6
|
+
* Cloud models require API keys and cost money per token.
|
|
7
|
+
*
|
|
8
|
+
* Returns { local: [...], cloud: [...], hasApiKey: bool }
|
|
9
|
+
*/
|
|
10
|
+
export async function detectModels() {
|
|
11
|
+
const [ollamaModels, openclawInfo] = await Promise.all([
|
|
12
|
+
detectOllamaModels(),
|
|
13
|
+
detectOpenClawAuth(),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
local: ollamaModels,
|
|
18
|
+
cloud: openclawInfo.models,
|
|
19
|
+
hasApiKey: openclawInfo.hasApiKey,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect locally installed Ollama models with parameter counts.
|
|
25
|
+
* Parses `ollama list` output.
|
|
26
|
+
*/
|
|
27
|
+
async function detectOllamaModels() {
|
|
28
|
+
try {
|
|
29
|
+
const output = await execAsync('ollama', ['list']);
|
|
30
|
+
const lines = output.trim().split('\n');
|
|
31
|
+
if (lines.length < 2) return []; // header only
|
|
32
|
+
|
|
33
|
+
// Parse header to find column positions
|
|
34
|
+
const header = lines[0];
|
|
35
|
+
const nameEnd = header.indexOf('ID');
|
|
36
|
+
const sizeStart = header.indexOf('SIZE');
|
|
37
|
+
|
|
38
|
+
return lines.slice(1).map(line => {
|
|
39
|
+
if (!line.trim()) return null;
|
|
40
|
+
|
|
41
|
+
const name = line.slice(0, nameEnd).trim();
|
|
42
|
+
if (!name) return null;
|
|
43
|
+
|
|
44
|
+
// Extract size (e.g., "4.7 GB", "1.3 GB")
|
|
45
|
+
const sizeStr = sizeStart >= 0 ? line.slice(sizeStart).trim().split(/\s{2,}/)[0] : '';
|
|
46
|
+
|
|
47
|
+
// Estimate parameter count from model name (e.g., "llama3.2:3b", "qwen2.5-coder:7b")
|
|
48
|
+
const paramMatch = name.match(/[:\-](\d+\.?\d*)[bB]/);
|
|
49
|
+
const params = paramMatch ? paramMatch[1] + 'B' : guessParams(name, sizeStr);
|
|
50
|
+
|
|
51
|
+
const shortName = name.replace(':latest', '');
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
id: `ollama/${shortName}`,
|
|
55
|
+
name: shortName,
|
|
56
|
+
params,
|
|
57
|
+
size: sizeStr,
|
|
58
|
+
tier: 'local',
|
|
59
|
+
};
|
|
60
|
+
}).filter(Boolean);
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Guess parameter count from file size if not in the name.
|
|
68
|
+
* Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
|
|
69
|
+
*/
|
|
70
|
+
function guessParams(name, sizeStr) {
|
|
71
|
+
const gbMatch = sizeStr.match(/([\d.]+)\s*GB/i);
|
|
72
|
+
if (gbMatch) {
|
|
73
|
+
const gb = parseFloat(gbMatch[1]);
|
|
74
|
+
const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
|
|
75
|
+
if (billions > 0) return `~${billions}B`;
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check OpenClaw for configured models and API key status.
|
|
82
|
+
*/
|
|
83
|
+
async function detectOpenClawAuth() {
|
|
84
|
+
const result = { models: [], hasApiKey: false };
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// Check if any API key is configured via openclaw models status
|
|
88
|
+
const status = await execAsync('openclaw', ['models', 'status']);
|
|
89
|
+
|
|
90
|
+
// Look for "api_key" or "configured" in the output
|
|
91
|
+
if (status.includes('api_key') || status.includes('configured')) {
|
|
92
|
+
result.hasApiKey = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get the model catalog for cloud options
|
|
96
|
+
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
97
|
+
try {
|
|
98
|
+
const models = JSON.parse(list);
|
|
99
|
+
if (Array.isArray(models)) {
|
|
100
|
+
result.models = models
|
|
101
|
+
.filter(m => m.available && !m.local)
|
|
102
|
+
.map(m => ({
|
|
103
|
+
id: m.key || m.name,
|
|
104
|
+
name: modelShortName(m.key || m.name),
|
|
105
|
+
provider: (m.key || '').split('/')[0] || 'unknown',
|
|
106
|
+
tier: 'cloud',
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Fall back to text parsing
|
|
111
|
+
const text = await execAsync('openclaw', ['models', 'list']);
|
|
112
|
+
const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
|
|
113
|
+
result.models = lines.map(l => {
|
|
114
|
+
const id = l.trim().split(/\s+/)[0];
|
|
115
|
+
if (!id || id.length < 3) return null;
|
|
116
|
+
return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
|
|
117
|
+
}).filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// OpenClaw not installed or no models configured — use defaults
|
|
121
|
+
result.models = [
|
|
122
|
+
{ id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
|
|
123
|
+
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Short display name for a model ID.
|
|
132
|
+
*/
|
|
133
|
+
export function modelShortName(model) {
|
|
134
|
+
if (!model) return 'unknown';
|
|
135
|
+
// Anthropic models
|
|
136
|
+
if (model.startsWith('anthropic/')) {
|
|
137
|
+
if (model.includes('haiku')) return 'Haiku';
|
|
138
|
+
if (model.includes('sonnet')) return 'Sonnet';
|
|
139
|
+
if (model.includes('opus')) return 'Opus';
|
|
140
|
+
return model.slice('anthropic/'.length);
|
|
141
|
+
}
|
|
142
|
+
// Ollama models
|
|
143
|
+
if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
|
|
144
|
+
// Other: strip provider prefix
|
|
145
|
+
if (model.includes('/')) return model.split('/').pop();
|
|
146
|
+
return model;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function execAsync(cmd, args) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|
|
152
|
+
if (err) reject(err);
|
|
153
|
+
else resolve(stdout);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
package/lib/utils/ui.js
CHANGED
|
@@ -17,10 +17,10 @@ const MAGENTA = c(35);
|
|
|
17
17
|
const CYAN = c(36);
|
|
18
18
|
const BG_DIM = c(100);
|
|
19
19
|
|
|
20
|
-
export const info = (msg) => console.log(
|
|
21
|
-
export const success = (msg) => console.log(
|
|
22
|
-
export const warn = (msg) => console.log(
|
|
23
|
-
export const error = (msg) => console.error(
|
|
20
|
+
export const info = (msg) => console.log(` ${CYAN}\u2022${RESET} ${msg}`);
|
|
21
|
+
export const success = (msg) => console.log(` ${GREEN}\u2713${RESET} ${msg}`);
|
|
22
|
+
export const warn = (msg) => console.log(` ${YELLOW}\u25B3${RESET} ${msg}`);
|
|
23
|
+
export const error = (msg) => console.error(` ${RED}\u2717${RESET} ${msg}`);
|
|
24
24
|
export const dim = (msg) => `${DIM}${msg}${RESET}`;
|
|
25
25
|
export const bold = (msg) => `${BOLD}${msg}${RESET}`;
|
|
26
26
|
export const green = (msg) => `${GREEN}${msg}${RESET}`;
|
|
@@ -147,5 +147,5 @@ export function fileStatus(path, status, detail) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
function stripAnsi(str) {
|
|
150
|
-
return str.replace(/\x1b\[\d
|
|
150
|
+
return str.replace(/\x1b\[[\d;]*m/g, '');
|
|
151
151
|
}
|