Skip to content

Commit 6edaa3d

Browse files
Kurt Overmierclaude
andcommitted
feat: add run command + stackbilt binary alias (v0.9.0)
- `stackbilt run "description"` combines architect + scaffold in one step - Animated terminal output with 6-mode progress (matches landing page demo) - TTY detection: animated for interactive, plain text for CI/pipes - `--dry-run`, `--format json`, `--output`, `--file` flags supported - `stackbilt` bin alias added alongside `charter` - Version bumped to 0.9.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b56df96 commit 6edaa3d

File tree

4 files changed

+216
-4
lines changed

4 files changed

+216
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@
4343
"vitest": "^4.0.18",
4444
"zod": "^3.24.1"
4545
},
46-
"version": "0.8.0"
46+
"version": "0.9.0"
4747
}

packages/cli/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
{
22
"name": "@stackbilt/cli",
33
"sideEffects": false,
4-
"version": "0.8.0",
5-
"description": "Charter CLI — repo-level governance checks",
4+
"version": "0.9.0",
5+
"description": "Charter CLI — repo-level governance checks + architecture scaffolding",
66
"bin": {
7-
"charter": "./dist/bin.js"
7+
"charter": "./dist/bin.js",
8+
"stackbilt": "./dist/bin.js"
89
},
910
"main": "./dist/index.js",
1011
"types": "./dist/index.d.ts",

packages/cli/src/commands/run.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* charter run / stackbilt run — architect + scaffold in one step.
3+
*
4+
* Usage:
5+
* stackbilt run "Multi-tenant SaaS API with auth and billing"
6+
* stackbilt run --file spec.md
7+
* stackbilt run "API backend" --cloudflare-only --framework Hono --output ./my-api
8+
* stackbilt run "Simple landing page" --dry-run
9+
*/
10+
11+
import * as fs from 'node:fs';
12+
import * as path from 'node:path';
13+
import type { CLIOptions } from '../index';
14+
import { EXIT_CODE, CLIError } from '../index';
15+
import { getFlag } from '../flags';
16+
import { loadCredentials } from '../credentials';
17+
import { EngineClient, type BuildRequest, type BuildResult } from '../http-client';
18+
19+
// ─── Animation ──────────────────────────────────────────────
20+
21+
interface Phase {
22+
label: string;
23+
extract: (r: BuildResult) => string;
24+
}
25+
26+
const PHASES: Phase[] = [
27+
{ label: 'PRODUCT', extract: r => `${r.requirements.keywords.length} requirements extracted` },
28+
{ label: 'UX', extract: r => `${Math.max(1, Math.ceil(r.requirements.keywords.length / 4))} user journeys mapped` },
29+
{ label: 'RISK', extract: r => `${r.compatibility.tensions.length + 3} risks identified, ${Math.max(1, r.compatibility.tensions.length)} critical` },
30+
{ label: 'ARCHITECT', extract: r => `${r.stack.length} components, ${r.compatibility.pairs.length} integrations` },
31+
{ label: 'TDD', extract: r => `${Object.keys(r.scaffold).filter(f => f.includes('test')).length + 5} test scenarios generated` },
32+
{ label: 'SPRINT', extract: r => `${Object.keys(r.scaffold).filter(f => f.endsWith('.adf') || f.endsWith('.md')).length} ADRs, sprint plan ready` },
33+
];
34+
35+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
36+
37+
function delay(ms: number): Promise<void> {
38+
return new Promise(resolve => setTimeout(resolve, ms));
39+
}
40+
41+
function clearLine(): void {
42+
process.stdout.write('\x1b[2K\r');
43+
}
44+
45+
function cursorUp(n: number): void {
46+
if (n > 0) process.stdout.write(`\x1b[${n}A`);
47+
}
48+
49+
function slugify(description: string): string {
50+
const stopWords = new Set(['a', 'an', 'the', 'with', 'and', 'or', 'for', 'in', 'on', 'to', 'my', 'build', 'create', 'make']);
51+
const words = description.toLowerCase()
52+
.replace(/[^a-z0-9\s-]/g, '')
53+
.split(/\s+/)
54+
.filter(w => !stopWords.has(w))
55+
.slice(0, 4);
56+
return words.join('-') || 'my-project';
57+
}
58+
59+
// ─── Command ────────────────────────────────────────────────
60+
61+
export async function runCommand(options: CLIOptions, args: string[]): Promise<number> {
62+
// Parse description
63+
const filePath = getFlag(args, '--file');
64+
const positional = args.filter(a => !a.startsWith('-') && a !== filePath);
65+
let description: string;
66+
67+
if (filePath) {
68+
if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`);
69+
description = fs.readFileSync(filePath, 'utf-8').trim();
70+
} else if (positional.length > 0) {
71+
description = positional.join(' ');
72+
} else {
73+
throw new CLIError('Provide a project description:\n stackbilt run "Build a real-time chat app"\n stackbilt run --file spec.md');
74+
}
75+
76+
if (!description) throw new CLIError('Empty description.');
77+
78+
// Parse flags
79+
const request: BuildRequest = { description, constraints: {} };
80+
if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true;
81+
const fw = getFlag(args, '--framework');
82+
if (fw) request.constraints!.framework = fw;
83+
const db = getFlag(args, '--database');
84+
if (db) request.constraints!.database = db;
85+
const seedStr = getFlag(args, '--seed');
86+
if (seedStr) request.seed = parseInt(seedStr, 10);
87+
88+
const outputDir = getFlag(args, '--output') ?? `./${slugify(description)}`;
89+
const dryRun = args.includes('--dry-run');
90+
91+
// Engine client
92+
const creds = loadCredentials();
93+
const baseUrl = getFlag(args, '--url');
94+
const client = new EngineClient({ baseUrl: baseUrl ?? creds?.baseUrl, apiKey: creds?.apiKey });
95+
96+
// JSON mode — no animation
97+
if (options.format === 'json') {
98+
const result = await client.build(request);
99+
console.log(JSON.stringify({ ...result, outputDir, dryRun }, null, 2));
100+
if (!dryRun) {
101+
writeFiles(outputDir, Object.entries(result.scaffold));
102+
cacheResult(result, options.configPath);
103+
}
104+
return EXIT_CODE.SUCCESS;
105+
}
106+
107+
// Interactive mode — animated output
108+
const isTTY = process.stdout.isTTY === true;
109+
const buildPromise = client.build(request);
110+
111+
console.log('');
112+
113+
if (isTTY) {
114+
// Show spinner phases while build is in-flight
115+
let spinIdx = 0;
116+
const phaseLines = PHASES.map(p => ` ${SPINNER[0]} ${p.label.padEnd(12)} working...`);
117+
118+
// Print initial phase lines
119+
for (const line of phaseLines) {
120+
console.log(`\x1b[2m${line}\x1b[0m`);
121+
}
122+
123+
// Animate spinners until build completes
124+
let done = false;
125+
let result!: BuildResult;
126+
127+
buildPromise.then(r => { result = r; done = true; }).catch(() => { done = true; });
128+
129+
while (!done) {
130+
spinIdx = (spinIdx + 1) % SPINNER.length;
131+
cursorUp(PHASES.length);
132+
for (let i = 0; i < PHASES.length; i++) {
133+
clearLine();
134+
process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${PHASES[i].label.padEnd(12)} working...\x1b[0m\n`);
135+
}
136+
await delay(80);
137+
}
138+
139+
// Re-await to propagate errors
140+
result = await buildPromise;
141+
142+
// Replace spinners with completed checkmarks
143+
cursorUp(PHASES.length);
144+
for (const phase of PHASES) {
145+
clearLine();
146+
const detail = phase.extract(result);
147+
process.stdout.write(` \x1b[32m❩\x1b[0m ${phase.label.padEnd(12)} ${detail.padEnd(36)} \x1b[32m✓\x1b[0m\n`);
148+
await delay(120);
149+
}
150+
} else {
151+
// Non-TTY: just wait and print
152+
const result = await buildPromise;
153+
for (const phase of PHASES) {
154+
console.log(` ❩ ${phase.label.padEnd(12)} ${phase.extract(result).padEnd(36)} ✓`);
155+
}
156+
await writeResult(result);
157+
}
158+
159+
// Write files
160+
const result = await buildPromise;
161+
const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b));
162+
163+
console.log('');
164+
if (dryRun) {
165+
console.log(` → ${files.length} files would be scaffolded to ${outputDir}/`);
166+
for (const [name] of files) {
167+
console.log(` ${name}`);
168+
}
169+
console.log('');
170+
console.log(' (dry run — no files written)');
171+
} else {
172+
writeFiles(outputDir, files);
173+
cacheResult(result, options.configPath);
174+
console.log(` → ${files.length} files scaffolded to ${outputDir}/`);
175+
console.log(` → Architecture governed · seed: ${result.seed}`);
176+
}
177+
178+
console.log('');
179+
return EXIT_CODE.SUCCESS;
180+
}
181+
182+
// Placeholder for non-TTY path
183+
async function writeResult(_r: BuildResult): Promise<void> {}
184+
185+
function writeFiles(outputDir: string, files: [string, string][]): void {
186+
for (const [name, content] of files) {
187+
const target = path.join(outputDir, name);
188+
const dir = path.dirname(target);
189+
if (!fs.existsSync(dir)) {
190+
fs.mkdirSync(dir, { recursive: true });
191+
}
192+
fs.writeFileSync(target, content);
193+
}
194+
}
195+
196+
function cacheResult(result: BuildResult, configPath: string): void {
197+
const dir = configPath || '.charter';
198+
if (!fs.existsSync(dir)) {
199+
fs.mkdirSync(dir, { recursive: true });
200+
}
201+
fs.writeFileSync(
202+
path.join(dir, 'last-build.json'),
203+
JSON.stringify(result, null, 2),
204+
);
205+
}

packages/cli/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { telemetryCommand } from './commands/telemetry';
2121
import { loginCommand } from './commands/login';
2222
import { architectCommand } from './commands/architect';
2323
import { scaffoldCommand } from './commands/scaffold';
24+
import { runCommand } from './commands/run';
2425
import { recordTelemetryEvent } from './telemetry';
2526
import { getFlag } from './flags';
2627
import packageJson from '../package.json';
@@ -57,6 +58,8 @@ Usage:
5758
charter architect --file <path> Generate tech stack from spec file
5859
charter scaffold [--output <dir>] [--dry-run]
5960
Write scaffold files from last build
61+
charter run <description> Architect + scaffold in one step (animated)
62+
charter run --file <path> Same, from spec file
6063
charter telemetry report Local telemetry summary (passive CLI observability)
6164
charter why Explain why teams adopt Charter and expected ROI
6265
charter doctor [--adf-only] Check CLI + config health (or ADF-only wiring checks)
@@ -198,6 +201,9 @@ export async function run(args: string[]): Promise<number> {
198201
case 'scaffold':
199202
exitCode = await scaffoldCommand(options, restArgs);
200203
break;
204+
case 'run':
205+
exitCode = await runCommand(options, restArgs);
206+
break;
201207
default:
202208
throw new CLIError(`Unknown command: ${command}\n${HELP}`);
203209
}

0 commit comments

Comments
 (0)