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.
@@ -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
- let missing = [];
59
- for (const { cmd, label, url } of checks) {
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
- console.log(` ${green('\u2713')} ${label}`);
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(` ${dim('\u2022')} ${dim(label)} ${dim(`\u2014 install: ${url}`)}`);
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(success(`Template cloned into ${green(name)}/`));
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
+ }
@@ -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 'skipped';
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(`${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}`);
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+m/g, '');
150
+ return str.replace(/\x1b\[[\d;]*m/g, '');
151
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {