Skip to content

Commit acb45af

Browse files
Kurt Overmierclaude
andcommitted
feat: wire run command to gateway scaffold for deployment-ready output
- CLI calls POST /api/scaffold on mcp.stackbilt.dev when API key is set - Falls back to engine /build when no key (with tip message) - Gateway path produces 9 files: wrangler.toml, .ai/, typed handler, tests - Engine path produces 4-5 basic files - Added ScaffoldResult type and scaffold() method to EngineClient Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 166dcba commit acb45af

File tree

2 files changed

+131
-76
lines changed

2 files changed

+131
-76
lines changed

packages/cli/src/commands/run.ts

Lines changed: 94 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/**
22
* charter run / stackbilt run — architect + scaffold in one step.
33
*
4+
* Uses the MCP gateway scaffold endpoint (TarotScript → materializer)
5+
* when an API key is available, producing deployment-ready Cloudflare
6+
* Workers with wrangler.toml, .ai/ governance, tests, and typed handlers.
7+
*
8+
* Falls back to the engine /build endpoint when no API key is set.
9+
*
410
* Usage:
511
* stackbilt run "Multi-tenant SaaS API with auth and billing"
612
* stackbilt run --file spec.md
@@ -14,24 +20,11 @@ import type { CLIOptions } from '../index';
1420
import { EXIT_CODE, CLIError } from '../index';
1521
import { getFlag } from '../flags';
1622
import { loadCredentials } from '../credentials';
17-
import { EngineClient, type BuildRequest, type BuildResult } from '../http-client';
23+
import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client';
1824

1925
// ─── Animation ──────────────────────────────────────────────
2026

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-
27+
const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT'];
3528
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
3629

3730
function delay(ms: number): Promise<void> {
@@ -56,6 +49,23 @@ function slugify(description: string): string {
5649
return words.join('-') || 'my-project';
5750
}
5851

52+
function phaseDetail(label: string, result: ScaffoldResult): string {
53+
const fileCount = result.files.length;
54+
const adfFiles = result.files.filter(f => f.path.endsWith('.adf')).length;
55+
const testFiles = result.files.filter(f => f.path.includes('test')).length;
56+
const configFiles = result.files.filter(f => f.path === 'wrangler.toml' || f.path === 'package.json' || f.path === 'tsconfig.json').length;
57+
58+
switch (label) {
59+
case 'PRODUCT': return `requirements extracted from intent`;
60+
case 'UX': return `interface patterns mapped`;
61+
case 'RISK': return `threats identified and mitigated`;
62+
case 'ARCHITECT': return `${fileCount} files, ${configFiles} configs generated`;
63+
case 'TDD': return `${testFiles || 1} test file${testFiles !== 1 ? 's' : ''} generated`;
64+
case 'SPRINT': return `${adfFiles} governance files, sprint ready`;
65+
default: return 'done';
66+
}
67+
}
68+
5969
// ─── Command ────────────────────────────────────────────────
6070

6171
export async function runCommand(options: CLIOptions, args: string[]): Promise<number> {
@@ -76,15 +86,7 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise<n
7686
if (!description) throw new CLIError('Empty description.');
7787

7888
// 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;
8589
const seedStr = getFlag(args, '--seed');
86-
if (seedStr) request.seed = parseInt(seedStr, 10);
87-
8890
const outputDir = getFlag(args, '--output') ?? `./${slugify(description)}`;
8991
const dryRun = args.includes('--dry-run');
9092

@@ -93,97 +95,124 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise<n
9395
const baseUrl = getFlag(args, '--url');
9496
const client = new EngineClient({ baseUrl: baseUrl ?? creds?.baseUrl, apiKey: creds?.apiKey });
9597

98+
// Determine path: gateway (with API key) or engine fallback
99+
const useGateway = !!creds?.apiKey;
100+
101+
let scaffoldPromise: Promise<ScaffoldResult>;
102+
103+
if (useGateway) {
104+
// Gateway path — produces deployment-ready output (wrangler.toml, .ai/, tests)
105+
scaffoldPromise = client.scaffold({
106+
description,
107+
project_type: args.includes('--cloudflare-only') ? 'worker' : undefined,
108+
complexity: undefined,
109+
seed: seedStr ? parseInt(seedStr, 10) : undefined,
110+
});
111+
} else {
112+
// Engine fallback — basic scaffold
113+
const request: BuildRequest = { description, constraints: {} };
114+
if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true;
115+
const fw = getFlag(args, '--framework');
116+
if (fw) request.constraints!.framework = fw;
117+
const db = getFlag(args, '--database');
118+
if (db) request.constraints!.database = db;
119+
if (seedStr) request.seed = parseInt(seedStr, 10);
120+
121+
scaffoldPromise = client.build(request).then(r => ({
122+
files: Object.entries(r.scaffold).map(([p, content]) => ({ path: p, content })),
123+
fileSource: 'engine' as const,
124+
nextSteps: ['npm install', 'npm run dev'],
125+
seed: r.seed,
126+
receipt: r.receipt,
127+
}));
128+
}
129+
96130
// JSON mode — no animation
97131
if (options.format === 'json') {
98-
const result = await client.build(request);
132+
const result = await scaffoldPromise;
99133
console.log(JSON.stringify({ ...result, outputDir, dryRun }, null, 2));
100134
if (!dryRun) {
101-
writeFiles(outputDir, Object.entries(result.scaffold));
102-
cacheResult(result, options.configPath);
135+
writeFiles(outputDir, result.files);
103136
}
104137
return EXIT_CODE.SUCCESS;
105138
}
106139

107140
// Interactive mode — animated output
108141
const isTTY = process.stdout.isTTY === true;
109-
const buildPromise = client.build(request);
110142

111143
console.log('');
144+
if (!useGateway) {
145+
console.log(' \x1b[2m(tip: run `charter login --key sb_live_xxx` for deployment-ready scaffolds)\x1b[0m');
146+
console.log('');
147+
}
112148

113149
if (isTTY) {
114-
// Show spinner phases while build is in-flight
115150
let spinIdx = 0;
116-
const phaseLines = PHASES.map(p => ` ${SPINNER[0]} ${p.label.padEnd(12)} working...`);
117151

118-
// Print initial phase lines
119-
for (const line of phaseLines) {
120-
console.log(`\x1b[2m${line}\x1b[0m`);
152+
for (const label of PHASE_LABELS) {
153+
console.log(`\x1b[2m ${SPINNER[0]} ${label.padEnd(12)} working...\x1b[0m`);
121154
}
122155

123-
// Animate spinners until build completes
124156
let done = false;
125-
let result!: BuildResult;
157+
let result!: ScaffoldResult;
126158

127-
buildPromise.then(r => { result = r; done = true; }).catch(() => { done = true; });
159+
scaffoldPromise.then(r => { result = r; done = true; }).catch(() => { done = true; });
128160

129161
while (!done) {
130162
spinIdx = (spinIdx + 1) % SPINNER.length;
131-
cursorUp(PHASES.length);
132-
for (let i = 0; i < PHASES.length; i++) {
163+
cursorUp(PHASE_LABELS.length);
164+
for (const label of PHASE_LABELS) {
133165
clearLine();
134-
process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${PHASES[i].label.padEnd(12)} working...\x1b[0m\n`);
166+
process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${label.padEnd(12)} working...\x1b[0m\n`);
135167
}
136168
await delay(80);
137169
}
138170

139-
// Re-await to propagate errors
140-
result = await buildPromise;
171+
result = await scaffoldPromise;
141172

142-
// Replace spinners with completed checkmarks
143-
cursorUp(PHASES.length);
144-
for (const phase of PHASES) {
173+
cursorUp(PHASE_LABELS.length);
174+
for (const label of PHASE_LABELS) {
145175
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`);
176+
const detail = phaseDetail(label, result);
177+
process.stdout.write(` \x1b[32m❩\x1b[0m ${label.padEnd(12)} ${detail.padEnd(36)} \x1b[32m✓\x1b[0m\n`);
148178
await delay(120);
149179
}
150180
} 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)} ✓`);
181+
const result = await scaffoldPromise;
182+
for (const label of PHASE_LABELS) {
183+
console.log(` ❩ ${label.padEnd(12)} ${phaseDetail(label, result).padEnd(36)} ✓`);
155184
}
156-
await writeResult(result);
157185
}
158186

159-
// Write files
160-
const result = await buildPromise;
161-
const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b));
187+
const result = await scaffoldPromise;
162188

163189
console.log('');
164190
if (dryRun) {
165-
console.log(` → ${files.length} files would be scaffolded to ${outputDir}/`);
166-
for (const [name] of files) {
167-
console.log(` ${name}`);
191+
console.log(` → ${result.files.length} files would be scaffolded to ${outputDir}/`);
192+
for (const f of result.files) {
193+
console.log(` ${f.path}`);
168194
}
169195
console.log('');
170196
console.log(' (dry run — no files written)');
171197
} 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}`);
198+
writeFiles(outputDir, result.files);
199+
console.log(` → ${result.files.length} files scaffolded to ${outputDir}/`);
200+
console.log(` → Architecture governed · seed: ${result.seed ?? 'deterministic'}`);
201+
if (result.nextSteps && result.nextSteps.length > 0) {
202+
console.log('');
203+
console.log(' Next steps:');
204+
for (const step of result.nextSteps) {
205+
console.log(` ${step}`);
206+
}
207+
}
176208
}
177209

178210
console.log('');
179211
return EXIT_CODE.SUCCESS;
180212
}
181213

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) {
214+
function writeFiles(outputDir: string, files: Array<{ path: string; content: string }>): void {
215+
for (const { path: name, content } of files) {
187216
const target = path.join(outputDir, name);
188217
const dir = path.dirname(target);
189218
if (!fs.existsSync(dir)) {
@@ -192,14 +221,3 @@ function writeFiles(outputDir: string, files: [string, string][]): void {
192221
fs.writeFileSync(target, content);
193222
}
194223
}
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/http-client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
const DEFAULT_BASE_URL = 'https://stackbilt-engine.blue-pine-edf6.workers.dev';
8+
const GATEWAY_BASE_URL = 'https://mcp.stackbilt.dev';
89

910
export interface BuildRequest {
1011
description: string;
@@ -64,6 +65,20 @@ export interface BuildResult {
6465
};
6566
}
6667

68+
export interface ScaffoldFile {
69+
path: string;
70+
content: string;
71+
}
72+
73+
export interface ScaffoldResult {
74+
files: ScaffoldFile[];
75+
fileSource: 'engine' | 'basic' | 'none';
76+
nextSteps: string[];
77+
seed?: number;
78+
receipt?: string;
79+
facts?: Record<string, unknown>;
80+
}
81+
6782
export interface HealthResponse {
6883
status: string;
6984
version: string;
@@ -105,6 +120,28 @@ export class EngineClient {
105120
return res.json() as Promise<BuildResult>;
106121
}
107122

123+
async scaffold(request: { description: string; project_type?: string; complexity?: string; seed?: number }): Promise<ScaffoldResult> {
124+
if (!this.apiKey) {
125+
throw new Error('API key required for scaffold. Run `charter login --key sb_live_xxx` first.');
126+
}
127+
128+
const res = await fetch(`${GATEWAY_BASE_URL}/api/scaffold`, {
129+
method: 'POST',
130+
headers: {
131+
'Content-Type': 'application/json',
132+
'Authorization': `Bearer ${this.apiKey}`,
133+
},
134+
body: JSON.stringify(request),
135+
});
136+
137+
if (!res.ok) {
138+
const text = await res.text();
139+
throw new Error(`Scaffold failed (${res.status}): ${text}`);
140+
}
141+
142+
return res.json() as Promise<ScaffoldResult>;
143+
}
144+
108145
async catalog(category?: string): Promise<{ primitives: DrawnTech[]; total: number }> {
109146
const url = new URL(`${this.baseUrl}/catalog`);
110147
if (category) url.searchParams.set('category', category);

0 commit comments

Comments
 (0)