serpentstack 0.2.6 → 0.2.9
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 +31 -28
- package/lib/commands/persistent.js +22 -21
- package/lib/commands/skills-init.js +25 -22
- package/lib/commands/skills-update.js +10 -8
- package/lib/commands/stack-new.js +15 -15
- package/lib/utils/agent-utils.js +1 -1
- package/lib/utils/config.js +10 -6
- package/lib/utils/models.js +56 -31
- package/lib/utils/ui.js +69 -48
- package/package.json +3 -3
package/bin/serpentstack.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { error, bold, dim, green, cyan, getVersion, printHeader } from '../lib/utils/ui.js';
|
|
3
|
+
import { error, bold, dim, green, cyan, yellow, getVersion, printHeader, divider } from '../lib/utils/ui.js';
|
|
4
4
|
|
|
5
5
|
// Short flag aliases
|
|
6
6
|
const FLAG_ALIASES = { f: 'force', h: 'help', v: 'version', a: 'all' };
|
|
@@ -57,33 +57,36 @@ function editDistance(a, b) {
|
|
|
57
57
|
|
|
58
58
|
function showHelp() {
|
|
59
59
|
printHeader();
|
|
60
|
-
console.log(` ${
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
${cyan('stack
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
${cyan('
|
|
70
|
-
${cyan('
|
|
71
|
-
${cyan('persistent')}
|
|
72
|
-
|
|
73
|
-
${
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
${
|
|
80
|
-
${
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
${dim('
|
|
86
|
-
`);
|
|
60
|
+
console.log(` ${dim('Deployable fullstack templates + AI agent skills for any project')}`);
|
|
61
|
+
console.log();
|
|
62
|
+
|
|
63
|
+
divider('New projects');
|
|
64
|
+
console.log(` ${cyan('stack new')} ${dim('<name>')} Scaffold a full project from the template`);
|
|
65
|
+
console.log(` ${cyan('stack update')} Update template-level files to latest`);
|
|
66
|
+
console.log();
|
|
67
|
+
|
|
68
|
+
divider('Any project');
|
|
69
|
+
console.log(` ${cyan('skills')} Download all skills + persistent agent configs`);
|
|
70
|
+
console.log(` ${cyan('skills update')} Update base skills to latest versions`);
|
|
71
|
+
console.log(` ${cyan('persistent')} Manage and launch persistent background agents`);
|
|
72
|
+
console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
|
|
73
|
+
console.log(` ${cyan('persistent')} ${dim('--reconfigure')} Change models, enable/disable agents`);
|
|
74
|
+
console.log();
|
|
75
|
+
|
|
76
|
+
divider('Options');
|
|
77
|
+
console.log(` ${bold('-f')}, ${bold('--force')} Overwrite existing files`);
|
|
78
|
+
console.log(` ${bold('-a')}, ${bold('--all')} Include new files in updates`);
|
|
79
|
+
console.log(` ${bold('-v')}, ${bold('--version')} Show version`);
|
|
80
|
+
console.log(` ${bold('-h')}, ${bold('--help')} Show this help`);
|
|
81
|
+
console.log();
|
|
82
|
+
|
|
83
|
+
console.log(` ${dim('Examples:')}`);
|
|
84
|
+
console.log(` ${dim('$')} serpentstack stack new my-saas-app`);
|
|
85
|
+
console.log(` ${dim('$')} serpentstack skills`);
|
|
86
|
+
console.log(` ${dim('$')} serpentstack persistent`);
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(` ${dim('Docs:')} ${cyan('https://github.com/Benja-Pauls/SerpentStack')}`);
|
|
89
|
+
console.log();
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
async function main() {
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from 'node:path';
|
|
|
3
3
|
import { execFile, spawn } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { stdin, stdout } from 'node:process';
|
|
6
|
-
import { info, success, warn, error, bold, dim, green, cyan, yellow, red, printBox, printHeader } from '../utils/ui.js';
|
|
6
|
+
import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
|
|
7
7
|
import {
|
|
8
8
|
parseAgentMd,
|
|
9
9
|
discoverAgents,
|
|
@@ -53,7 +53,7 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
53
53
|
|
|
54
54
|
// Local models first (free, fast, recommended)
|
|
55
55
|
if (available.local.length > 0) {
|
|
56
|
-
console.log(` ${dim('
|
|
56
|
+
console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
|
|
57
57
|
for (const m of available.local) {
|
|
58
58
|
const isCurrent = m.id === currentModel;
|
|
59
59
|
const idx = choices.length;
|
|
@@ -62,16 +62,17 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
62
62
|
const num = dim(`${idx + 1}.`);
|
|
63
63
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
64
64
|
const params = m.params ? dim(` ${m.params}`) : '';
|
|
65
|
+
const quant = m.quant ? dim(` ${m.quant}`) : '';
|
|
65
66
|
const size = m.size ? dim(` (${m.size})`) : '';
|
|
66
67
|
const tag = isCurrent ? green(' \u2190 current') : '';
|
|
67
|
-
console.log(` ${marker} ${num} ${label}${params}${size}${tag}`);
|
|
68
|
+
console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// Cloud models (require API key, cost money)
|
|
72
73
|
if (available.cloud.length > 0) {
|
|
73
|
-
const apiNote = available.hasApiKey ?
|
|
74
|
-
console.log(` ${dim('
|
|
74
|
+
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
75
|
+
console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
|
|
75
76
|
for (const m of available.cloud) {
|
|
76
77
|
const isCurrent = m.id === currentModel;
|
|
77
78
|
const idx = choices.length;
|
|
@@ -225,11 +226,11 @@ function printAgentLine(name, agentMd, config, statusInfo) {
|
|
|
225
226
|
const modelStr = modelShortName(model);
|
|
226
227
|
|
|
227
228
|
if (statusInfo.status === 'running') {
|
|
228
|
-
console.log(`
|
|
229
|
+
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
|
|
229
230
|
} else if (statusInfo.status === 'disabled') {
|
|
230
|
-
console.log(`
|
|
231
|
+
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
231
232
|
} else {
|
|
232
|
-
console.log(`
|
|
233
|
+
console.log(` ${yellow('○')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
|
|
@@ -305,8 +306,8 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
305
306
|
|
|
306
307
|
// ── If configured, show status dashboard ──
|
|
307
308
|
if (!needsSetup) {
|
|
308
|
-
console.log(` ${bold(config.project.name)} ${dim(
|
|
309
|
-
console.log(` ${dim(`Dev: ${config.project.devCmd}
|
|
309
|
+
console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
|
|
310
|
+
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
310
311
|
console.log();
|
|
311
312
|
|
|
312
313
|
for (const { name, agentMd } of parsed) {
|
|
@@ -363,12 +364,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
363
364
|
};
|
|
364
365
|
|
|
365
366
|
if (templateDefaults && !existing.name) {
|
|
366
|
-
info('Detected SerpentStack template
|
|
367
|
+
info('Detected SerpentStack template — defaults pre-filled');
|
|
367
368
|
console.log();
|
|
368
369
|
}
|
|
369
370
|
|
|
370
|
-
|
|
371
|
-
console.log(` ${dim('Press Enter to keep
|
|
371
|
+
divider('Project');
|
|
372
|
+
console.log(` ${dim('Press Enter to keep defaults.')}`);
|
|
372
373
|
console.log();
|
|
373
374
|
|
|
374
375
|
config.project = {
|
|
@@ -417,14 +418,14 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
417
418
|
}
|
|
418
419
|
console.log();
|
|
419
420
|
|
|
420
|
-
|
|
421
|
-
console.log(` ${dim('Enable/disable each agent and
|
|
421
|
+
divider('Agents');
|
|
422
|
+
console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
|
|
422
423
|
console.log();
|
|
423
424
|
|
|
424
425
|
for (const { name, agentMd } of parsed) {
|
|
425
426
|
const existingAgent = config.agents?.[name];
|
|
426
427
|
const currentEnabled = existingAgent?.enabled !== false;
|
|
427
|
-
const currentModel = existingAgent?.model ||
|
|
428
|
+
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
428
429
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
429
430
|
|
|
430
431
|
console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
|
|
@@ -481,7 +482,7 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
481
482
|
const toStart = [];
|
|
482
483
|
|
|
483
484
|
try {
|
|
484
|
-
|
|
485
|
+
divider('Launch');
|
|
485
486
|
console.log();
|
|
486
487
|
|
|
487
488
|
for (const agent of agentsToStart) {
|
|
@@ -548,12 +549,12 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
548
549
|
|
|
549
550
|
console.log();
|
|
550
551
|
if (started > 0) {
|
|
551
|
-
success(`${started} agent(s) launched
|
|
552
|
+
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
552
553
|
console.log();
|
|
553
554
|
printBox('Manage agents', [
|
|
554
|
-
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start
|
|
555
|
-
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all
|
|
556
|
-
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models
|
|
555
|
+
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start')}`,
|
|
556
|
+
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
557
|
+
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models')}`,
|
|
557
558
|
]);
|
|
558
559
|
}
|
|
559
560
|
}
|
|
@@ -4,13 +4,19 @@ import { downloadFile } from '../utils/github.js';
|
|
|
4
4
|
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
5
|
import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
|
|
6
6
|
import { parseAgentMd, discoverAgents } from '../utils/agent-utils.js';
|
|
7
|
-
import { info, success, warn, error, spinner, bold, dim, green, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
|
|
7
|
+
import { info, success, warn, error, spinner, bold, dim, green, cyan, divider, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
|
|
8
8
|
|
|
9
9
|
const SKILLS_FILES = [
|
|
10
|
+
'.skills/auth/SKILL.md',
|
|
11
|
+
'.skills/db-migrate/SKILL.md',
|
|
12
|
+
'.skills/deploy/SKILL.md',
|
|
13
|
+
'.skills/dev-server/SKILL.md',
|
|
10
14
|
'.skills/find-skills/SKILL.md',
|
|
11
15
|
'.skills/generate-skills/SKILL.md',
|
|
12
16
|
'.skills/git-workflow/SKILL.md',
|
|
13
17
|
'.skills/model-routing/SKILL.md',
|
|
18
|
+
'.skills/scaffold/SKILL.md',
|
|
19
|
+
'.skills/test/SKILL.md',
|
|
14
20
|
];
|
|
15
21
|
|
|
16
22
|
const OPENCLAW_FILES = [
|
|
@@ -29,13 +35,9 @@ const MANIFEST = [...SKILLS_FILES, ...OPENCLAW_FILES, ...DOCS_FILES];
|
|
|
29
35
|
export async function skillsInit({ force = false } = {}) {
|
|
30
36
|
printHeader();
|
|
31
37
|
|
|
32
|
-
// Step 1: Download files
|
|
33
|
-
console.log(` ${bold('Downloading')} ${dim('skills + persistent agent configs')}`);
|
|
34
|
-
console.log();
|
|
35
|
-
|
|
36
38
|
const results = { created: 0, skipped: 0, overwritten: 0, unchanged: 0, failed: 0 };
|
|
37
39
|
const logs = [];
|
|
38
|
-
const spin = spinner('
|
|
40
|
+
const spin = spinner('Slithering through GitHub...');
|
|
39
41
|
|
|
40
42
|
try {
|
|
41
43
|
for (const repoPath of MANIFEST) {
|
|
@@ -55,24 +57,26 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// Group output by section
|
|
58
|
-
|
|
60
|
+
divider('Skills');
|
|
59
61
|
for (let i = 0; i < SKILLS_FILES.length; i++) logs[i] && console.log(logs[i]);
|
|
60
62
|
console.log();
|
|
61
|
-
|
|
63
|
+
|
|
64
|
+
divider('Persistent Agents');
|
|
62
65
|
for (let i = SKILLS_FILES.length; i < SKILLS_FILES.length + OPENCLAW_FILES.length; i++) logs[i] && console.log(logs[i]);
|
|
63
66
|
console.log();
|
|
64
|
-
|
|
67
|
+
|
|
68
|
+
divider('Docs');
|
|
65
69
|
for (let i = SKILLS_FILES.length + OPENCLAW_FILES.length; i < logs.length; i++) logs[i] && console.log(logs[i]);
|
|
66
70
|
console.log();
|
|
67
71
|
|
|
68
72
|
// Summary line
|
|
69
73
|
const parts = [];
|
|
70
74
|
if (results.created > 0) parts.push(green(`${results.created} created`));
|
|
71
|
-
if (results.overwritten > 0) parts.push(`${results.overwritten} updated`);
|
|
75
|
+
if (results.overwritten > 0) parts.push(cyan(`${results.overwritten} updated`));
|
|
72
76
|
if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
|
|
73
77
|
if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
|
|
74
78
|
if (results.failed > 0) parts.push(`${results.failed} failed`);
|
|
75
|
-
console.log(` ${parts.join(dim('
|
|
79
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
76
80
|
|
|
77
81
|
if (results.failed === MANIFEST.length) {
|
|
78
82
|
console.log();
|
|
@@ -108,17 +112,15 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
108
112
|
writeConfig(projectDir, config);
|
|
109
113
|
console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
|
|
110
114
|
results.created++;
|
|
111
|
-
console.log();
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
// Next steps
|
|
115
118
|
console.log();
|
|
116
|
-
|
|
119
|
+
divider('What\'s next');
|
|
117
120
|
console.log();
|
|
118
|
-
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
|
|
119
|
-
console.log(` ${dim('and paste
|
|
120
|
-
console.log(` ${dim('
|
|
121
|
-
console.log(` ${dim('.skills/ directory tailored to your project.')}`);
|
|
121
|
+
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, Gemini CLI, etc.)')}`);
|
|
122
|
+
console.log(` ${dim('and paste this prompt. It reads your codebase and generates')}`);
|
|
123
|
+
console.log(` ${dim('project-specific skills tailored to your stack.')}`);
|
|
122
124
|
|
|
123
125
|
printPrompt([
|
|
124
126
|
`Read .skills/generate-skills/SKILL.md and follow its instructions`,
|
|
@@ -133,11 +135,12 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
133
135
|
`Reference SKILL-AUTHORING.md for the format.`,
|
|
134
136
|
]);
|
|
135
137
|
|
|
136
|
-
printBox('
|
|
137
|
-
`${dim('$')} ${bold('serpentstack persistent')}
|
|
138
|
+
printBox('Want persistent background agents too?', [
|
|
139
|
+
`${dim('$')} ${bold('serpentstack persistent')}`,
|
|
138
140
|
'',
|
|
139
|
-
`${dim('
|
|
140
|
-
`${dim('
|
|
141
|
-
`${dim('
|
|
141
|
+
`${dim('Agents that watch your dev server, run tests, and keep')}`,
|
|
142
|
+
`${dim('skills fresh — each in its own terminal. Pick which to')}`,
|
|
143
|
+
`${dim('run and choose local or cloud models.')}`,
|
|
142
144
|
]);
|
|
145
|
+
console.log();
|
|
143
146
|
}
|
|
@@ -2,13 +2,19 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { downloadFile } from '../utils/github.js';
|
|
4
4
|
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
|
-
import { info, success, warn, error, spinner, bold, dim, green, printHeader, fileStatus } from '../utils/ui.js';
|
|
5
|
+
import { info, success, warn, error, spinner, bold, dim, green, cyan, printHeader, fileStatus } from '../utils/ui.js';
|
|
6
6
|
|
|
7
7
|
const MANIFEST = [
|
|
8
|
+
'.skills/auth/SKILL.md',
|
|
9
|
+
'.skills/db-migrate/SKILL.md',
|
|
10
|
+
'.skills/deploy/SKILL.md',
|
|
11
|
+
'.skills/dev-server/SKILL.md',
|
|
8
12
|
'.skills/find-skills/SKILL.md',
|
|
9
13
|
'.skills/generate-skills/SKILL.md',
|
|
10
14
|
'.skills/git-workflow/SKILL.md',
|
|
11
15
|
'.skills/model-routing/SKILL.md',
|
|
16
|
+
'.skills/scaffold/SKILL.md',
|
|
17
|
+
'.skills/test/SKILL.md',
|
|
12
18
|
'.openclaw/SOUL.md',
|
|
13
19
|
'.openclaw/agents/log-watcher/AGENT.md',
|
|
14
20
|
'.openclaw/agents/test-runner/AGENT.md',
|
|
@@ -27,11 +33,8 @@ const CUSTOMIZABLE = new Set([
|
|
|
27
33
|
export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
28
34
|
printHeader();
|
|
29
35
|
|
|
30
|
-
info(`Checking for updates in ${bold(process.cwd())}`);
|
|
31
|
-
console.log();
|
|
32
|
-
|
|
33
36
|
const results = { updated: 0, skipped: 0, unchanged: 0, failed: 0 };
|
|
34
|
-
const spin = spinner('
|
|
37
|
+
const spin = spinner('Checking for updates...');
|
|
35
38
|
const logs = [];
|
|
36
39
|
|
|
37
40
|
try {
|
|
@@ -83,12 +86,11 @@ export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
|
83
86
|
if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
|
|
84
87
|
if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
|
|
85
88
|
if (results.failed > 0) parts.push(`${results.failed} failed`);
|
|
86
|
-
console.log(` ${parts.join(dim('
|
|
89
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
87
90
|
|
|
88
91
|
if (results.updated > 0) {
|
|
89
92
|
console.log();
|
|
90
|
-
info(`${dim('
|
|
91
|
-
info(`${dim('Only the base SerpentStack files were updated.')}`);
|
|
93
|
+
info(`${dim('Only base SerpentStack files were updated — your custom skills are untouched.')}`);
|
|
92
94
|
}
|
|
93
95
|
console.log();
|
|
94
96
|
}
|
|
@@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { cloneRepo, checkGit } from '../utils/github.js';
|
|
6
|
-
import { info, success, warn, error, spinner, bold, dim, green, cyan, printBox, printPrompt, printHeader } from '../utils/ui.js';
|
|
6
|
+
import { info, success, warn, error, spinner, bold, dim, green, yellow, cyan, divider, printBox, printPrompt, printHeader } from '../utils/ui.js';
|
|
7
7
|
|
|
8
8
|
const CLEANUP_PATHS = [
|
|
9
9
|
'cli',
|
|
@@ -60,7 +60,7 @@ export async function stackNew(name) {
|
|
|
60
60
|
printHeader();
|
|
61
61
|
|
|
62
62
|
// Step 1: Check prerequisites
|
|
63
|
-
|
|
63
|
+
divider('Prerequisites');
|
|
64
64
|
console.log();
|
|
65
65
|
|
|
66
66
|
const checks = [
|
|
@@ -90,13 +90,13 @@ export async function stackNew(name) {
|
|
|
90
90
|
let missing = [];
|
|
91
91
|
for (const r of results) {
|
|
92
92
|
if (!r.found) {
|
|
93
|
-
console.log(`
|
|
93
|
+
console.log(` ${dim('○')} ${dim(r.label)} ${dim(`— install: ${r.url}`)}`);
|
|
94
94
|
missing.push(r.label);
|
|
95
95
|
} else if (r.versionOk === false) {
|
|
96
|
-
console.log(`
|
|
96
|
+
console.log(` ${yellow('△')} ${r.label} ${dim(`— found ${r.version}, need newer version`)}`);
|
|
97
97
|
missing.push(r.label);
|
|
98
98
|
} else {
|
|
99
|
-
console.log(`
|
|
99
|
+
console.log(` ${green('✓')} ${r.label}`);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
console.log();
|
|
@@ -113,10 +113,10 @@ export async function stackNew(name) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// Step 2: Clone template
|
|
116
|
-
|
|
116
|
+
divider('Clone');
|
|
117
117
|
console.log();
|
|
118
118
|
|
|
119
|
-
const spin = spinner(
|
|
119
|
+
const spin = spinner('Slithering through GitHub...');
|
|
120
120
|
try {
|
|
121
121
|
await cloneRepo(dest);
|
|
122
122
|
spin.stop();
|
|
@@ -129,7 +129,7 @@ export async function stackNew(name) {
|
|
|
129
129
|
|
|
130
130
|
// Step 3: Clean up repo-specific files
|
|
131
131
|
console.log();
|
|
132
|
-
|
|
132
|
+
divider('Prepare');
|
|
133
133
|
console.log();
|
|
134
134
|
|
|
135
135
|
for (const p of CLEANUP_PATHS) {
|
|
@@ -150,16 +150,16 @@ export async function stackNew(name) {
|
|
|
150
150
|
|
|
151
151
|
// Step 4: Summary
|
|
152
152
|
console.log();
|
|
153
|
-
|
|
153
|
+
divider('Ready 🐍');
|
|
154
154
|
console.log();
|
|
155
155
|
|
|
156
156
|
console.log(` ${dim('Your project includes:')}`);
|
|
157
|
-
console.log(`
|
|
158
|
-
console.log(`
|
|
159
|
-
console.log(`
|
|
160
|
-
console.log(`
|
|
161
|
-
console.log(`
|
|
162
|
-
console.log(`
|
|
157
|
+
console.log(` ${green('✓')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership)')}`);
|
|
158
|
+
console.log(` ${green('✓')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
|
|
159
|
+
console.log(` ${green('✓')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
|
|
160
|
+
console.log(` ${green('✓')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
|
|
161
|
+
console.log(` ${green('✓')} 10 agent skills ${dim('(.skills/)')}`);
|
|
162
|
+
console.log(` ${green('✓')} Persistent agent configs ${dim('(.openclaw/)')}`);
|
|
163
163
|
console.log();
|
|
164
164
|
|
|
165
165
|
printBox('Get started', [
|
package/lib/utils/agent-utils.js
CHANGED
|
@@ -164,7 +164,7 @@ function generateHeartbeat(schedule) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
function generateAgentsConfig(meta) {
|
|
167
|
-
const model = meta.model || '
|
|
167
|
+
const model = meta.model || 'ollama/llama3.2';
|
|
168
168
|
const tools = meta.tools || ['file-system', 'shell', 'git'];
|
|
169
169
|
|
|
170
170
|
return [
|
package/lib/utils/config.js
CHANGED
|
@@ -3,12 +3,16 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
const CONFIG_PATH = '.openclaw/config.json';
|
|
5
5
|
|
|
6
|
+
// Default model for persistent agents — local first, always.
|
|
7
|
+
// Cloud models cost tokens per heartbeat and require API keys.
|
|
8
|
+
const DEFAULT_MODEL = 'ollama/llama3.2';
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* Default config structure:
|
|
8
12
|
* {
|
|
9
13
|
* project: { name, language, framework, devCmd, testCmd, conventions },
|
|
10
14
|
* agents: {
|
|
11
|
-
* "log-watcher": { enabled: true, model: "
|
|
15
|
+
* "log-watcher": { enabled: true, model: "ollama/llama3.2" },
|
|
12
16
|
* ...
|
|
13
17
|
* }
|
|
14
18
|
* }
|
|
@@ -49,9 +53,9 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
49
53
|
|
|
50
54
|
try {
|
|
51
55
|
const content = readFileSync(makefile, 'utf8');
|
|
52
|
-
// SerpentStack Makefile: must have '
|
|
53
|
-
if (
|
|
54
|
-
if (!content.includes('uv run')
|
|
56
|
+
// SerpentStack Makefile: must have a 'verify:' target AND 'uv run' (Python tooling)
|
|
57
|
+
if (!/^verify:/m.test(content)) return null;
|
|
58
|
+
if (!content.includes('uv run')) return null;
|
|
55
59
|
|
|
56
60
|
// It's a SerpentStack template project — return smart defaults
|
|
57
61
|
const defaults = {
|
|
@@ -83,7 +87,7 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
83
87
|
export function defaultAgentConfig(meta) {
|
|
84
88
|
return {
|
|
85
89
|
enabled: true,
|
|
86
|
-
model:
|
|
90
|
+
model: DEFAULT_MODEL,
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -94,7 +98,7 @@ export function getEffectiveModel(agentName, agentMeta, config) {
|
|
|
94
98
|
if (config?.agents?.[agentName]?.model) {
|
|
95
99
|
return config.agents[agentName].model;
|
|
96
100
|
}
|
|
97
|
-
return
|
|
101
|
+
return DEFAULT_MODEL;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/**
|
package/lib/utils/models.js
CHANGED
|
@@ -21,62 +21,87 @@ export async function detectModels() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Detect locally installed Ollama models
|
|
25
|
-
*
|
|
24
|
+
* Detect locally installed Ollama models via the REST API.
|
|
25
|
+
* GET http://localhost:11434/api/tags returns structured JSON with real
|
|
26
|
+
* parameter counts, sizes, and quantization levels — no CLI parsing needed.
|
|
26
27
|
*/
|
|
27
28
|
async function detectOllamaModels() {
|
|
28
29
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
if (lines.length < 2) return []; // header only
|
|
30
|
+
const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
|
|
31
|
+
if (!response.ok) return [];
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const nameEnd = header.indexOf('ID');
|
|
36
|
-
const sizeStart = header.indexOf('SIZE');
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
if (!data.models || !Array.isArray(data.models)) return [];
|
|
37
35
|
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const name = line.slice(0, nameEnd).trim();
|
|
36
|
+
return data.models.map(m => {
|
|
37
|
+
const name = (m.name || '').replace(':latest', '');
|
|
42
38
|
if (!name) return null;
|
|
43
39
|
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const params = paramMatch ? paramMatch[1] + 'B' : guessParams(name, sizeStr);
|
|
50
|
-
|
|
51
|
-
const shortName = name.replace(':latest', '');
|
|
40
|
+
// Use the real parameter count from the API when available
|
|
41
|
+
const details = m.details || {};
|
|
42
|
+
const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
|
|
43
|
+
const quant = details.quantization_level || '';
|
|
44
|
+
const sizeStr = formatBytes(m.size);
|
|
52
45
|
|
|
53
46
|
return {
|
|
54
|
-
id: `ollama/${
|
|
55
|
-
name
|
|
47
|
+
id: `ollama/${name}`,
|
|
48
|
+
name,
|
|
56
49
|
params,
|
|
50
|
+
quant,
|
|
57
51
|
size: sizeStr,
|
|
58
52
|
tier: 'local',
|
|
59
53
|
};
|
|
60
54
|
}).filter(Boolean);
|
|
61
55
|
} catch {
|
|
56
|
+
// Ollama not running or not installed
|
|
62
57
|
return [];
|
|
63
58
|
}
|
|
64
59
|
}
|
|
65
60
|
|
|
66
61
|
/**
|
|
67
|
-
*
|
|
62
|
+
* Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
|
|
63
|
+
* The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
|
|
64
|
+
*/
|
|
65
|
+
function formatParamCount(paramSize) {
|
|
66
|
+
if (!paramSize) return '';
|
|
67
|
+
// Already formatted like "7B" or "3.2B"
|
|
68
|
+
const s = String(paramSize).trim();
|
|
69
|
+
if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fallback: estimate parameter count from file size.
|
|
68
75
|
* Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
|
|
69
76
|
*/
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (billions > 0) return `~${billions}B`;
|
|
76
|
-
}
|
|
77
|
+
function guessParamsFromSize(bytes) {
|
|
78
|
+
if (!bytes || bytes <= 0) return '';
|
|
79
|
+
const gb = bytes / (1024 ** 3);
|
|
80
|
+
const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
|
|
81
|
+
if (billions > 0) return `~${billions}B`;
|
|
77
82
|
return '';
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Format bytes into human-readable size (e.g., "4.7 GB").
|
|
87
|
+
*/
|
|
88
|
+
function formatBytes(bytes) {
|
|
89
|
+
if (!bytes || bytes <= 0) return '';
|
|
90
|
+
const gb = bytes / (1024 ** 3);
|
|
91
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
92
|
+
const mb = bytes / (1024 ** 2);
|
|
93
|
+
return `${Math.round(mb)} MB`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch with timeout using AbortController (Node 18+).
|
|
98
|
+
*/
|
|
99
|
+
function fetchWithTimeout(url, timeoutMs) {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
102
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
/**
|
|
81
106
|
* Check OpenClaw for configured models and API key status.
|
|
82
107
|
*/
|
package/lib/utils/ui.js
CHANGED
|
@@ -17,10 +17,17 @@ const MAGENTA = c(35);
|
|
|
17
17
|
const CYAN = c(36);
|
|
18
18
|
const BG_DIM = c(100);
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// ─── Brand ───────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const SNAKE = '🐍';
|
|
23
|
+
const BRAND_COLOR = GREEN;
|
|
24
|
+
|
|
25
|
+
// ─── Text formatters ─────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const info = (msg) => console.log(` ${CYAN}•${RESET} ${msg}`);
|
|
28
|
+
export const success = (msg) => console.log(` ${GREEN}✓${RESET} ${msg}`);
|
|
29
|
+
export const warn = (msg) => console.log(` ${YELLOW}△${RESET} ${msg}`);
|
|
30
|
+
export const error = (msg) => console.error(` ${RED}✗${RESET} ${msg}`);
|
|
24
31
|
export const dim = (msg) => `${DIM}${msg}${RESET}`;
|
|
25
32
|
export const bold = (msg) => `${BOLD}${msg}${RESET}`;
|
|
26
33
|
export const green = (msg) => `${GREEN}${msg}${RESET}`;
|
|
@@ -30,18 +37,22 @@ export const cyan = (msg) => `${CYAN}${msg}${RESET}`;
|
|
|
30
37
|
export const magenta = (msg) => `${MAGENTA}${msg}${RESET}`;
|
|
31
38
|
export const blue = (msg) => `${BLUE}${msg}${RESET}`;
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
// ─── Spinner ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
|
|
34
43
|
|
|
35
44
|
export function spinner(msg) {
|
|
36
45
|
if (NO_COLOR || !stderr.isTTY) {
|
|
37
46
|
stderr.write(` ${msg}\n`);
|
|
38
|
-
return { stop(final) { if (final) stderr.write(` ${final}\n`); } };
|
|
47
|
+
return { stop(final) { if (final) stderr.write(` ${final}\n`); }, update() {} };
|
|
39
48
|
}
|
|
40
49
|
let i = 0;
|
|
50
|
+
let currentMsg = msg;
|
|
41
51
|
const id = setInterval(() => {
|
|
42
|
-
stderr.write(`\r${
|
|
43
|
-
},
|
|
52
|
+
stderr.write(`\r ${GREEN}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]}${RESET} ${currentMsg}`);
|
|
53
|
+
}, 100);
|
|
44
54
|
return {
|
|
55
|
+
update(newMsg) { currentMsg = newMsg; },
|
|
45
56
|
stop(final) {
|
|
46
57
|
clearInterval(id);
|
|
47
58
|
stderr.write(`\r\x1b[K`);
|
|
@@ -50,16 +61,20 @@ export function spinner(msg) {
|
|
|
50
61
|
};
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
// ─── Confirm ─────────────────────────────────────────────────
|
|
65
|
+
|
|
53
66
|
export async function confirm(msg) {
|
|
54
67
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
55
68
|
try {
|
|
56
|
-
const answer = await rl.question(`${YELLOW}?${RESET} ${msg} ${
|
|
69
|
+
const answer = await rl.question(`${YELLOW}?${RESET} ${msg} ${DIM}(y/N)${RESET} `);
|
|
57
70
|
return answer.trim().toLowerCase() === 'y';
|
|
58
71
|
} finally {
|
|
59
72
|
rl.close();
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
76
|
+
// ─── Version ─────────────────────────────────────────────────
|
|
77
|
+
|
|
63
78
|
export function getVersion() {
|
|
64
79
|
try {
|
|
65
80
|
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
@@ -69,36 +84,44 @@ export function getVersion() {
|
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
86
|
|
|
87
|
+
// ─── Headers & Dividers ──────────────────────────────────────
|
|
88
|
+
|
|
72
89
|
export function printHeader() {
|
|
73
|
-
console.log(
|
|
90
|
+
console.log();
|
|
91
|
+
console.log(` ${SNAKE} ${BOLD}${GREEN}SerpentStack${RESET} ${DIM}v${getVersion()}${RESET}`);
|
|
92
|
+
console.log();
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
export function divider(label) {
|
|
96
|
+
if (label) {
|
|
97
|
+
console.log(` ${DIM}── ${RESET}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
|
|
98
|
+
} else {
|
|
99
|
+
console.log(` ${DIM}${'─'.repeat(54)}${RESET}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Boxes ───────────────────────────────────────────────────
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* Print a boxed section with a title and content lines.
|
|
78
|
-
* Used for "Next steps" and prompt blocks.
|
|
79
107
|
*/
|
|
80
|
-
export function printBox(title, lines, { color = GREEN, icon = '
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
...lines.map(l => stripAnsi(l).length + 4)
|
|
84
|
-
);
|
|
108
|
+
export function printBox(title, lines, { color = GREEN, icon = '▶' } = {}) {
|
|
109
|
+
const allText = [title, ...lines];
|
|
110
|
+
const maxLen = Math.max(...allText.map(l => stripAnsi(l).length + 4));
|
|
85
111
|
const width = Math.min(Math.max(maxLen, 50), 80);
|
|
86
|
-
const top = `${color}\u250C${'─'.repeat(width)}\u2510${RESET}`;
|
|
87
|
-
const bot = `${color}\u2514${'─'.repeat(width)}\u2518${RESET}`;
|
|
88
112
|
|
|
89
|
-
console.log(
|
|
90
|
-
console.log(
|
|
91
|
-
console.log(
|
|
113
|
+
console.log(` ${DIM}┌${'─'.repeat(width)}┐${RESET}`);
|
|
114
|
+
console.log(` ${DIM}│${RESET} ${BOLD}${icon} ${title}${RESET}${' '.repeat(Math.max(0, width - stripAnsi(title).length - 3))}${DIM}│${RESET}`);
|
|
115
|
+
console.log(` ${DIM}│${' '.repeat(width)}│${RESET}`);
|
|
92
116
|
for (const line of lines) {
|
|
93
117
|
const pad = Math.max(0, width - stripAnsi(line).length - 2);
|
|
94
|
-
console.log(
|
|
118
|
+
console.log(` ${DIM}│${RESET} ${line}${' '.repeat(pad)}${DIM}│${RESET}`);
|
|
95
119
|
}
|
|
96
|
-
console.log(
|
|
120
|
+
console.log(` ${DIM}└${'─'.repeat(width)}┘${RESET}`);
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
/**
|
|
100
|
-
* Print a copyable prompt block
|
|
101
|
-
* Accepts a single string or an array of lines for multi-line prompts.
|
|
124
|
+
* Print a copyable prompt block.
|
|
102
125
|
*/
|
|
103
126
|
export function printPrompt(promptLines, { hint = 'Copy this prompt and paste it into your IDE agent' } = {}) {
|
|
104
127
|
const lines = Array.isArray(promptLines) ? promptLines : [promptLines];
|
|
@@ -107,45 +130,43 @@ export function printPrompt(promptLines, { hint = 'Copy this prompt and paste it
|
|
|
107
130
|
const innerWidth = width - 2;
|
|
108
131
|
|
|
109
132
|
console.log();
|
|
110
|
-
console.log(` ${DIM}
|
|
133
|
+
console.log(` ${DIM}┌${'─'.repeat(innerWidth)}┐${RESET}`);
|
|
111
134
|
for (const line of lines) {
|
|
112
135
|
const pad = Math.max(0, innerWidth - stripAnsi(line).length - 2);
|
|
113
|
-
console.log(` ${DIM}
|
|
136
|
+
console.log(` ${DIM}│${RESET} ${CYAN}${line}${RESET}${' '.repeat(pad)}${DIM}│${RESET}`);
|
|
114
137
|
}
|
|
115
|
-
console.log(` ${DIM}
|
|
116
|
-
console.log(` ${DIM}
|
|
138
|
+
console.log(` ${DIM}└${'─'.repeat(innerWidth)}┘${RESET}`);
|
|
139
|
+
console.log(` ${DIM}↑ ${hint}${RESET}`);
|
|
117
140
|
console.log();
|
|
118
141
|
}
|
|
119
142
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*/
|
|
143
|
+
// ─── File Status ─────────────────────────────────────────────
|
|
144
|
+
|
|
123
145
|
export function fileIcon(status) {
|
|
124
146
|
switch (status) {
|
|
125
|
-
case 'created': return `${GREEN}
|
|
126
|
-
case 'overwritten': return `${CYAN}
|
|
127
|
-
case 'skipped': return `${YELLOW}
|
|
128
|
-
case 'failed': return `${RED}
|
|
129
|
-
case 'unchanged': return `${DIM}
|
|
130
|
-
default: return `${DIM}
|
|
147
|
+
case 'created': return `${GREEN}✓${RESET}`;
|
|
148
|
+
case 'overwritten': return `${CYAN}↻${RESET}`;
|
|
149
|
+
case 'skipped': return `${YELLOW}•${RESET}`;
|
|
150
|
+
case 'failed': return `${RED}✗${RESET}`;
|
|
151
|
+
case 'unchanged': return `${DIM}•${RESET}`;
|
|
152
|
+
default: return `${DIM}•${RESET}`;
|
|
131
153
|
}
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
/**
|
|
135
|
-
* Format a file path with its status for display.
|
|
136
|
-
*/
|
|
137
156
|
export function fileStatus(path, status, detail) {
|
|
138
157
|
const icon = fileIcon(status);
|
|
139
158
|
switch (status) {
|
|
140
|
-
case 'created': return `
|
|
141
|
-
case 'overwritten': return `
|
|
142
|
-
case 'skipped': return `
|
|
143
|
-
case 'failed': return `
|
|
144
|
-
case 'unchanged': return `
|
|
145
|
-
default: return `
|
|
159
|
+
case 'created': return ` ${icon} ${path}`;
|
|
160
|
+
case 'overwritten': return ` ${icon} ${cyan(path)} ${dim('(updated)')}`;
|
|
161
|
+
case 'skipped': return ` ${icon} ${dim(`${path} (${detail || 'exists, skipped'})`)}`;
|
|
162
|
+
case 'failed': return ` ${icon} ${red(path)} ${dim(`— ${detail}`)}`;
|
|
163
|
+
case 'unchanged': return ` ${icon} ${dim(`${path} (up to date)`)}`;
|
|
164
|
+
default: return ` ${icon} ${path}`;
|
|
146
165
|
}
|
|
147
166
|
}
|
|
148
167
|
|
|
149
|
-
|
|
168
|
+
// ─── Utilities ───────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export function stripAnsi(str) {
|
|
150
171
|
return str.replace(/\x1b\[[\d;]*m/g, '');
|
|
151
172
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serpentstack",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"serpentstack": "
|
|
7
|
+
"serpentstack": "bin/serpentstack.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=22"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/Benja-Pauls/SerpentStack"
|
|
30
|
+
"url": "git+https://github.com/Benja-Pauls/SerpentStack.git"
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/Benja-Pauls/SerpentStack#readme",
|
|
33
33
|
"author": "Ben Paulson"
|