Skip to content

Commit 7eda9ef

Browse files
Kurt Overmierclaude
andcommitted
feat(cli): add architect, scaffold, and login commands
Wire Charter CLI to the stackbilt-engine worker for deterministic tech stack generation. Three new commands: - `charter login` — API key management (~/.charter/credentials.json) - `charter architect` — generate stack from project description - `charter scaffold` — write scaffold files from last build Engine: https://stackbilt-engine.blue-pine-edf6.workers.dev Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ba3957 commit 7eda9ef

File tree

6 files changed

+466
-0
lines changed

6 files changed

+466
-0
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* charter architect — generate a tech stack from a project description.
3+
*
4+
* Usage:
5+
* charter architect "Build a real-time chat app on Cloudflare"
6+
* charter architect --file spec.md
7+
* charter architect "API backend" --cloudflare-only --framework Hono --database D1
8+
* charter architect "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+
export async function architectCommand(options: CLIOptions, args: string[]): Promise<number> {
20+
// Parse description from positional arg or --file
21+
const filePath = getFlag(args, '--file');
22+
const positional = args.filter(a => !a.startsWith('-') && a !== filePath);
23+
let description: string;
24+
25+
if (filePath) {
26+
if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`);
27+
description = fs.readFileSync(filePath, 'utf-8').trim();
28+
} else if (positional.length > 0) {
29+
description = positional.join(' ');
30+
} else {
31+
throw new CLIError('Provide a project description:\n charter architect "Build a real-time chat app"\n charter architect --file spec.md');
32+
}
33+
34+
if (!description) throw new CLIError('Empty description.');
35+
36+
// Parse constraint flags
37+
const request: BuildRequest = { description, constraints: {} };
38+
if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true;
39+
const fw = getFlag(args, '--framework');
40+
if (fw) request.constraints!.framework = fw;
41+
const db = getFlag(args, '--database');
42+
if (db) request.constraints!.database = db;
43+
44+
const seedStr = getFlag(args, '--seed');
45+
if (seedStr) request.seed = parseInt(seedStr, 10);
46+
47+
// Load credentials (optional — engine may not require auth yet)
48+
const creds = loadCredentials();
49+
const baseUrl = getFlag(args, '--url');
50+
const client = new EngineClient({ baseUrl: baseUrl ?? creds?.baseUrl, apiKey: creds?.apiKey });
51+
52+
// Build
53+
let result: BuildResult;
54+
try {
55+
result = await client.build(request);
56+
} catch (err) {
57+
throw new CLIError(`Build failed: ${(err as Error).message}`);
58+
}
59+
60+
const dryRun = args.includes('--dry-run');
61+
62+
// JSON output
63+
if (options.format === 'json') {
64+
console.log(JSON.stringify(result, null, 2));
65+
if (!dryRun) cacheResult(result, options.configPath);
66+
return EXIT_CODE.SUCCESS;
67+
}
68+
69+
// Text output
70+
printResult(result);
71+
72+
// Write scaffold
73+
if (!dryRun) {
74+
cacheResult(result, options.configPath);
75+
console.log('');
76+
console.log(`Build cached. Run \`charter scaffold\` to write files.`);
77+
} else {
78+
console.log('');
79+
console.log('(dry run — no files written)');
80+
}
81+
82+
return EXIT_CODE.SUCCESS;
83+
}
84+
85+
function printResult(r: BuildResult): void {
86+
const c = r.compatibility;
87+
88+
console.log('');
89+
console.log(` Stack (seed: ${r.seed}, ${r.requirements.complexity})`);
90+
console.log('');
91+
92+
const maxPos = Math.max(...r.stack.map(s => s.position.length));
93+
const maxName = Math.max(...r.stack.map(s => s.name.length));
94+
for (const s of r.stack) {
95+
const pos = s.position.padEnd(maxPos);
96+
const name = s.name.padEnd(maxName);
97+
const orient = s.orientation === 'reversed' ? '↓' : '↑';
98+
const cf = s.cloudflareNative ? ' [CF]' : '';
99+
console.log(` ${pos} ${name} (${s.element}, ${orient})${cf}`);
100+
}
101+
102+
console.log('');
103+
console.log(` Compatibility: ${c.normalizedScore} (${c.pairs.length} pairs, ${c.tensions.length} tensions)`);
104+
105+
for (const p of c.pairs) {
106+
const sign = p.score > 0 ? '+' : p.score < 0 ? '' : ' ';
107+
console.log(` ${p.techs[0]} + ${p.techs[1]} = ${p.relationship} (${sign}${p.score})`);
108+
}
109+
110+
if (c.tensions.length > 0) {
111+
console.log('');
112+
console.log(' Tensions:');
113+
for (const t of c.tensions) {
114+
console.log(` ⚡ ${t.description}`);
115+
}
116+
}
117+
118+
console.log('');
119+
console.log(` Scaffold: ${Object.keys(r.scaffold).length} files`);
120+
for (const f of Object.keys(r.scaffold).sort()) {
121+
const lines = r.scaffold[f].split('\n').length;
122+
console.log(` ${f} (${lines} lines)`);
123+
}
124+
125+
console.log('');
126+
console.log(` Keywords: ${r.requirements.keywords.slice(0, 8).join(', ')}`);
127+
console.log(` Receipt: ${r.receipt.slice(0, 16)}`);
128+
}
129+
130+
function cacheResult(result: BuildResult, configPath: string): void {
131+
const dir = configPath || '.charter';
132+
if (!fs.existsSync(dir)) {
133+
fs.mkdirSync(dir, { recursive: true });
134+
}
135+
fs.writeFileSync(
136+
path.join(dir, 'last-build.json'),
137+
JSON.stringify(result, null, 2),
138+
);
139+
}

packages/cli/src/commands/login.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* charter login — API key management for Stackbilt Engine.
3+
*
4+
* Usage:
5+
* charter login --key sb_live_xxx Store API key
6+
* charter login --logout Clear stored credentials
7+
*/
8+
9+
import type { CLIOptions } from '../index';
10+
import { EXIT_CODE, CLIError } from '../index';
11+
import { getFlag } from '../flags';
12+
import { loadCredentials, saveCredentials, clearCredentials } from '../credentials';
13+
import { EngineClient } from '../http-client';
14+
15+
export async function loginCommand(options: CLIOptions, args: string[]): Promise<number> {
16+
if (args.includes('--logout')) {
17+
clearCredentials();
18+
console.log('Credentials cleared.');
19+
return EXIT_CODE.SUCCESS;
20+
}
21+
22+
const key = getFlag(args, '--key');
23+
if (!key) {
24+
const existing = loadCredentials();
25+
if (existing) {
26+
const masked = existing.apiKey.slice(0, 12) + '...' + existing.apiKey.slice(-4);
27+
console.log(`Logged in as: ${masked}`);
28+
if (existing.baseUrl) console.log(`Engine: ${existing.baseUrl}`);
29+
} else {
30+
console.log('Not logged in.');
31+
console.log('');
32+
console.log('Usage: charter login --key sb_live_xxx');
33+
console.log(' charter login --key sb_test_xxx');
34+
console.log('');
35+
console.log('Get your API key from the Stackbilt dashboard.');
36+
}
37+
return EXIT_CODE.SUCCESS;
38+
}
39+
40+
if (!key.startsWith('sb_live_') && !key.startsWith('sb_test_')) {
41+
throw new CLIError('Invalid API key format. Keys must start with sb_live_ or sb_test_.');
42+
}
43+
44+
const baseUrl = getFlag(args, '--url');
45+
46+
// Verify connectivity
47+
const client = new EngineClient({ baseUrl, apiKey: key });
48+
try {
49+
const health = await client.health();
50+
saveCredentials({ apiKey: key, baseUrl });
51+
52+
if (options.format === 'json') {
53+
console.log(JSON.stringify({ status: 'authenticated', engine: health.version, catalog: health.catalog }));
54+
} else {
55+
console.log(`Authenticated. Engine v${health.version} (${health.catalog} primitives)`);
56+
if (key.startsWith('sb_test_')) {
57+
console.log('Using test mode.');
58+
}
59+
}
60+
return EXIT_CODE.SUCCESS;
61+
} catch (err) {
62+
throw new CLIError(`Could not reach engine: ${(err as Error).message}`);
63+
}
64+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* charter scaffold — write scaffold files from the last build.
3+
*
4+
* Usage:
5+
* charter scaffold Write to current directory
6+
* charter scaffold --output ./my-app Write to specific directory
7+
* charter scaffold --dry-run Show what would be created
8+
*/
9+
10+
import * as fs from 'node:fs';
11+
import * as path from 'node:path';
12+
import type { CLIOptions } from '../index';
13+
import { EXIT_CODE, CLIError } from '../index';
14+
import { getFlag } from '../flags';
15+
import type { BuildResult } from '../http-client';
16+
17+
export async function scaffoldCommand(options: CLIOptions, args: string[]): Promise<number> {
18+
const configPath = options.configPath || '.charter';
19+
const cachePath = path.join(configPath, 'last-build.json');
20+
21+
if (!fs.existsSync(cachePath)) {
22+
throw new CLIError('No cached build found. Run `charter architect "..."` first.');
23+
}
24+
25+
let result: BuildResult;
26+
try {
27+
result = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
28+
} catch {
29+
throw new CLIError('Could not parse cached build. Run `charter architect "..."` again.');
30+
}
31+
32+
if (!result.scaffold || Object.keys(result.scaffold).length === 0) {
33+
throw new CLIError('Cached build has no scaffold files.');
34+
}
35+
36+
const outputDir = getFlag(args, '--output') ?? '.';
37+
const dryRun = args.includes('--dry-run');
38+
39+
const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b));
40+
41+
if (options.format === 'json') {
42+
const manifest = files.map(([name, content]) => ({
43+
path: path.join(outputDir, name),
44+
lines: content.split('\n').length,
45+
}));
46+
console.log(JSON.stringify({ outputDir, dryRun, files: manifest }, null, 2));
47+
if (!dryRun) writeFiles(outputDir, files);
48+
return EXIT_CODE.SUCCESS;
49+
}
50+
51+
console.log('');
52+
console.log(` Scaffold from build (seed: ${result.seed})`);
53+
console.log(` Stack: ${result.stack.map(s => s.name).join(' + ')}`);
54+
console.log(` Output: ${path.resolve(outputDir)}`);
55+
console.log('');
56+
57+
for (const [name, content] of files) {
58+
const lines = content.split('\n').length;
59+
const target = path.join(outputDir, name);
60+
const exists = fs.existsSync(target);
61+
const marker = exists ? ' (exists, will overwrite)' : '';
62+
console.log(` ${name} (${lines} lines)${marker}`);
63+
}
64+
65+
if (dryRun) {
66+
console.log('');
67+
console.log(' (dry run — no files written)');
68+
return EXIT_CODE.SUCCESS;
69+
}
70+
71+
writeFiles(outputDir, files);
72+
73+
console.log('');
74+
console.log(` ${files.length} files written.`);
75+
return EXIT_CODE.SUCCESS;
76+
}
77+
78+
function writeFiles(outputDir: string, files: [string, string][]): void {
79+
for (const [name, content] of files) {
80+
const target = path.join(outputDir, name);
81+
const dir = path.dirname(target);
82+
if (!fs.existsSync(dir)) {
83+
fs.mkdirSync(dir, { recursive: true });
84+
}
85+
fs.writeFileSync(target, content);
86+
}
87+
}

packages/cli/src/credentials.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Credential storage for Stackbilt API key.
3+
*
4+
* Persists to ~/.charter/credentials.json (mode 0o600).
5+
*/
6+
7+
import * as fs from 'node:fs';
8+
import * as path from 'node:path';
9+
import * as os from 'node:os';
10+
11+
export interface Credentials {
12+
apiKey: string;
13+
baseUrl?: string;
14+
}
15+
16+
const CRED_DIR = path.join(os.homedir(), '.charter');
17+
const CRED_FILE = path.join(CRED_DIR, 'credentials.json');
18+
19+
export function loadCredentials(): Credentials | null {
20+
if (!fs.existsSync(CRED_FILE)) return null;
21+
try {
22+
const raw = fs.readFileSync(CRED_FILE, 'utf-8');
23+
const parsed = JSON.parse(raw);
24+
if (!parsed.apiKey || typeof parsed.apiKey !== 'string') return null;
25+
return parsed as Credentials;
26+
} catch {
27+
return null;
28+
}
29+
}
30+
31+
export function saveCredentials(creds: Credentials): void {
32+
if (!fs.existsSync(CRED_DIR)) {
33+
fs.mkdirSync(CRED_DIR, { recursive: true });
34+
}
35+
fs.writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
36+
}
37+
38+
export function clearCredentials(): void {
39+
if (fs.existsSync(CRED_FILE)) {
40+
fs.unlinkSync(CRED_FILE);
41+
}
42+
}

0 commit comments

Comments
 (0)