From 83809afcd379a83bcad132b6a947c301d5864395 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:10:29 -0500 Subject: [PATCH 1/6] feat(bootstrap): add --mode lean for fast, install-safe project onboarding (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skips migrate, install, and populate phases. Install failure can no longer produce a `partial` status — lean mode emits a deterministic install command as the first required next step instead. Fully compatible with --yes, --ci, --force, and --security-sensitive. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/__tests__/bootstrap.test.ts | 83 ++++++++++++++ packages/cli/src/commands/bootstrap.ts | 109 ++++++++++++------- packages/cli/src/index.ts | 3 +- 3 files changed, 155 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index a9a993c..00b3596 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -301,4 +301,87 @@ load state.adf always const hasHint = installStep.warnings.some((w: string) => w.includes('--no-frozen-lockfile')); expect(hasHint).toBe(true); }); + + describe('--mode lean', () => { + it('skips migrate, install, and populate phases (status: skip)', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + const migrateStep = report.steps.find((s: { name: string }) => s.name === 'migrate'); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + const populateStep = report.steps.find((s: { name: string }) => s.name === 'populate'); + expect(migrateStep.status).toBe('skip'); + expect(installStep.status).toBe('skip'); + expect(populateStep.status).toBe('skip'); + }); + + it('exits with status success even when install would have failed', async () => { + execSyncOverride = () => { + throw new Error('ERR_PNPM_FROZEN_LOCKFILE: Lockfile is not up-to-date'); + }; + + logs = []; + try { + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + } finally { + execSyncOverride = null; + } + + const report = JSON.parse(logs[0]); + expect(report.status).toBe('success'); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + expect(installStep.status).toBe('skip'); + }); + + it('emits the detected package manager install command as the first required next step', async () => { + fs.writeFileSync('pnpm-lock.yaml', ''); + + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(4); + const first = report.nextSteps[0]; + expect(first.cmd).toBe('pnpm install'); + expect(first.required).toBe(true); + }); + + it('emits exactly 4 deterministic next steps in the correct order', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(4); + expect(report.nextSteps[0].cmd).toMatch(/install$/); + expect(report.nextSteps[1].cmd).toBe('charter hook install --commit-msg'); + expect(report.nextSteps[2].cmd).toBe('charter hook install --pre-commit'); + expect(report.nextSteps[3].cmd).toBe('charter serve'); + }); + + it('--mode lean combined with --skip-install is not an error', async () => { + logs = []; + const exitCode = await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + const report = JSON.parse(logs[0]); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + expect(installStep.status).toBe('skip'); + }); + }); }); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 1085955..5fa6185 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -82,6 +82,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const securitySensitive = args.includes('--security-sensitive'); const nonInteractive = options.yes; const setupOverwrite = options.yes || force; + const leanMode = getFlag(args, '--mode') === 'lean'; if (ciTarget && ciTarget !== 'github') { throw new CLIError(`Unsupported CI target: ${ciTarget}. Supported: github`); @@ -121,7 +122,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro } if (options.format === 'text') { - console.log('[1/7] Detecting stack...'); + console.log(`[1/${leanMode ? '4' : '7'}] Detecting stack...`); console.log(` Stack: ${selectedPreset} (${detection.confidence} confidence)`); console.log(` Monorepo: ${detection.monorepo ? 'yes' : 'no'}${detection.monorepo && detection.signals.hasPnpm ? ' (pnpm workspace)' : ''}`); if (detection.warnings.length > 0) { @@ -145,7 +146,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += setupResult.step.warnings.length; if (options.format === 'text') { - console.log('[2/7] Setting up governance...'); + console.log(`[2/${leanMode ? '4' : '7'}] Setting up governance...`); for (const f of (setupResult.step.details.created as string[] || [])) { console.log(` Created ${f}`); } @@ -163,7 +164,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += adfResult.step.warnings.length; if (options.format === 'text') { - console.log('[3/7] Initializing ADF context...'); + console.log(`[3/${leanMode ? '4' : '7'}] Initializing ADF context...`); for (const f of (adfResult.step.details.files as string[] || [])) { console.log(` Created ${f}`); } @@ -234,11 +235,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 4: Migrate Agent Configs // ======================================================================== - const migrateResult = runMigratePhase(options, nonInteractive); + const migrateResult = leanMode + ? { step: { name: 'migrate' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : runMigratePhase(options, nonInteractive); result.steps.push(migrateResult.step); warnings += migrateResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[4/7] Migrating agent configs...'); if (migrateResult.step.status === 'skip') { console.log(' Skipped (no migratable files)'); @@ -256,11 +259,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 5: Install // ======================================================================== - const installResult = runInstallPhase(options, skipInstall); + const installResult = leanMode + ? { step: { name: 'install' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : runInstallPhase(options, skipInstall); result.steps.push(installResult.step); warnings += installResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[5/7] Installing dependencies...'); if (skipInstall) { console.log(' Skipped (--skip-install)'); @@ -285,11 +290,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 6: Populate (#89) // ======================================================================== - const populateResult = await runPopulatePhase(options); + const populateResult = leanMode + ? { step: { name: 'populate' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : await runPopulatePhase(options); result.steps.push(populateResult.step); warnings += populateResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[6/7] Auto-populating ADF modules...'); const populated = populateResult.step.details.populated as number; const skipped = populateResult.step.details.skipped as number; @@ -309,7 +316,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += doctorResult.step.warnings.length; if (options.format === 'text') { - console.log('[7/7] Running health check...'); + console.log(`[${leanMode ? '4/4' : '7/7'}] Running health check...`); if (skipDoctor) { console.log(' Skipped (--skip-doctor)'); } else { @@ -328,47 +335,71 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const failCount = result.steps.filter(s => s.status === 'fail').length; result.status = failCount === 0 ? 'success' : failCount < result.steps.length ? 'partial' : 'failure'; - // Build next steps - result.nextSteps.push({ - cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration', - required: false, - reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', - }); - result.nextSteps.push({ - cmd: 'Review .charter/patterns/ and customize for your stack', - required: false, - reason: 'Customize blessed stack patterns', - }); - result.nextSteps.push({ - cmd: 'git add .charter .ai CLAUDE.md .cursorrules agents.md && git commit -m "chore: bootstrap charter governance"', - required: false, - reason: 'Commit governance baseline', - }); - - // Gate hook next-steps on being inside a git repo - if (inGitRepo) { + if (leanMode) { + const leanPm = detectPackageManagerFromLockfiles(); + result.nextSteps.push({ + cmd: `${leanPm} install`, + required: true, + reason: 'Install dependencies (skipped in lean mode)', + }); + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); result.nextSteps.push({ cmd: 'charter hook install --pre-commit', required: false, reason: 'Install pre-commit hook for ADF evidence gate', }); result.nextSteps.push({ - cmd: 'charter hook install --commit-msg', + cmd: 'charter serve', required: false, - reason: 'Install commit-msg hook for trailer enforcement', + reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', }); + } else { + // Build next steps result.nextSteps.push({ - cmd: 'echo \'charter context --write\' >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit', + cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration', required: false, - reason: 'Keep .charter/context.md fresh after each commit (charter brief auto-refresh)', + reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', + }); + result.nextSteps.push({ + cmd: 'Review .charter/patterns/ and customize for your stack', + required: false, + reason: 'Customize blessed stack patterns', + }); + result.nextSteps.push({ + cmd: 'git add .charter .ai CLAUDE.md .cursorrules agents.md && git commit -m "chore: bootstrap charter governance"', + required: false, + reason: 'Commit governance baseline', }); - } - result.nextSteps.push({ - cmd: 'charter hook print --claude # paste output into .claude/settings.json → hooks.UserPromptSubmit', - required: false, - reason: 'Auto-refresh context at session start so charter_context returns live state, not a cold snapshot, before the agent acts', - }); + // Gate hook next-steps on being inside a git repo + if (inGitRepo) { + result.nextSteps.push({ + cmd: 'charter hook install --pre-commit', + required: false, + reason: 'Install pre-commit hook for ADF evidence gate', + }); + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); + result.nextSteps.push({ + cmd: 'echo \'charter context --write\' >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit', + required: false, + reason: 'Keep .charter/context.md fresh after each commit (charter brief auto-refresh)', + }); + } + + result.nextSteps.push({ + cmd: 'charter hook print --claude # paste output into .claude/settings.json → hooks.UserPromptSubmit', + required: false, + reason: 'Auto-refresh context at session start so charter_context returns live state, not a cold snapshot, before the agent acts', + }); + } // ======================================================================== // Governance Gaps — surface what's configured but not enforced diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 23a9276..bf2e908 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,8 +39,9 @@ charter - repo-level governance toolkit Usage: charter Show immediate governance value + risk snapshot - charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] + charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] [--mode lean] One-command repo onboarding (detect + setup + ADF + install + doctor) + --mode lean: run only detect, setup, adf-init, doctor; skip migrate/install/populate --security-sensitive adds SECURITY.md, hard security drift denies, and a security test check charter context [--stdout-only] [--verbose] [--write] Pre-digested repo brief for AI agents (routes, hotspots, governance) From d579f628dded72bbbe7a17f091acd1ddbd75efa3 Mon Sep 17 00:00:00 2001 From: Stackbilt Admin Date: Sat, 23 May 2026 07:21:49 -0500 Subject: [PATCH 2/6] feat(drift): scan template literal bodies for anti-patterns (#102) (#181) Extracts backtick template strings from .ts/.tsx/.js/.mjs files and scans their bodies against drift patterns, attributing violations to virtual filenames (e.g. src/foo.ts[template:0]). Catches security anti-patterns inside code-factory functions that emit string templates. No signature changes to scanForDrift. Co-authored-by: Kurt Overmier Co-authored-by: Claude Sonnet 4.6 --- .../src/__tests__/templateLiterals.test.ts | 156 ++++++++++++++++++ packages/drift/src/index.ts | 59 +++++++ 2 files changed, 215 insertions(+) create mode 100644 packages/drift/src/__tests__/templateLiterals.test.ts diff --git a/packages/drift/src/__tests__/templateLiterals.test.ts b/packages/drift/src/__tests__/templateLiterals.test.ts new file mode 100644 index 0000000..dc80850 --- /dev/null +++ b/packages/drift/src/__tests__/templateLiterals.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { extractTemplateLiterals, scanForDrift } from '../index'; +import type { Pattern } from '@stackbilt/types'; + +describe('extractTemplateLiterals', () => { + it('returns empty map for non-TS/JS files', () => { + const result = extractTemplateLiterals('const x = `hello world from template`;', 'config.yaml'); + expect(result).toEqual({}); + }); + + it('returns empty map for .txt files', () => { + const result = extractTemplateLiterals('`some long template content here`', 'readme.txt'); + expect(result).toEqual({}); + }); + + it('returns empty map when file has no template literals', () => { + const result = extractTemplateLiterals('const x = "hello"; const y = 42;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('skips template literals with 20 chars or fewer', () => { + // 15 chars — should be skipped + const result = extractTemplateLiterals('const x = `short15chars`;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('includes template literals longer than 20 chars', () => { + // 100+ chars — should be included + const body = 'this is a sufficiently long template literal body that exceeds the threshold'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/util.ts'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.values(result)[0]).toBe(body); + }); + + it('uses virtual filename format filename[template:N]', () => { + const body = 'this is a sufficiently long template literal body content'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/templates/hmac.ts'); + expect(Object.keys(result)[0]).toBe('src/templates/hmac.ts[template:0]'); + }); + + it('indexes multiple template literals sequentially', () => { + const body1 = 'first template literal body that is long enough to count'; + const body2 = 'second template literal body that is also long enough to count'; + const content = `const a = \`${body1}\`;\nconst b = \`${body2}\`;`; + const result = extractTemplateLiterals(content, 'src/util.ts'); + expect(Object.keys(result)).toContain('src/util.ts[template:0]'); + expect(Object.keys(result)).toContain('src/util.ts[template:1]'); + expect(result['src/util.ts[template:0]']).toBe(body1); + expect(result['src/util.ts[template:1]']).toBe(body2); + }); + + it('works for .js files', () => { + const body = 'function body that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`module.exports = \`${body}\`;`, 'lib/factory.js'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.js[template:0]'); + }); + + it('works for .mjs files', () => { + const body = 'export default template that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`export default \`${body}\`;`, 'lib/factory.mjs'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.mjs[template:0]'); + }); + + it('works for .tsx files', () => { + const body = 'react template literal body that is long enough to be extracted'; + const result = extractTemplateLiterals(`const style = \`${body}\`;`, 'src/Component.tsx'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('src/Component.tsx[template:0]'); + }); +}); + +describe('scanForDrift with template literal extraction', () => { + const makePattern = (name: string, antiPatterns: string | null): Pattern => ({ + id: '1', + name, + category: 'SECURITY', + blessedSolution: 'Use constant-time comparison', + rationale: null, + antiPatterns, + documentationUrl: null, + relatedLedgerId: null, + status: 'ACTIVE', + createdAt: '2025-01-01', + projectId: null, + }); + + it('detects timing attack inside a template literal (code-factory pattern)', () => { + // A function that returns a multi-line backtick string containing the vulnerable code + const content = [ + 'export function hmacVerifyFunction(): string {', + ' return `', + 'function verify(a, b) {', + ' return a === b;', + '}', + '`;', + '}', + ].join('\n'); + + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'src/templates/hmac.ts': content }; + const report = scanForDrift(files, [pattern]); + + // Should find a violation attributed to the virtual template filename + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toMatch(/^src\/templates\/hmac\.ts\[template:\d+\]$/); + expect(templateViolations[0].snippet).toContain('a === b'); + }); + + it('attributes template violations to virtual filename, not original file', () => { + const content = 'export const tmpl = `\nreturn inputA === inputB;\n`;'; + const pattern = makePattern('no-direct-compare', 'Avoid /inputA === inputB/'); + const files = { 'src/codegen/auth.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.startsWith('src/codegen/auth.ts[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toBe('src/codegen/auth.ts[template:0]'); + }); + + it('does not template-scan non-TS/JS files', () => { + // YAML file containing backtick-like content should not be template-scanned + const content = 'pattern: |\n return a === b;\n'; + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'config.yaml': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations).toHaveLength(0); + }); + + it('line numbers in template violations are 1-indexed relative to template body', () => { + // Template body: line 1 is "// begin generated code" (padding to exceed 20 chars), + // line 2 has the vulnerable pattern + const content = 'const gen = `\n// begin generated code\nreturn x === y;\n`;'; + const pattern = makePattern('no-direct-eq', 'Avoid /x === y/'); + const files = { 'src/gen.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + // line 1 is "// begin generated code" + // line 2 is "return x === y;" + expect(templateViolations[0].line).toBe(3); + }); +}); diff --git a/packages/drift/src/index.ts b/packages/drift/src/index.ts index 0c8faa2..c2de6f3 100644 --- a/packages/drift/src/index.ts +++ b/packages/drift/src/index.ts @@ -57,6 +57,32 @@ export function scanForDrift( } } } + + // Also scan extracted template literals as virtual files + const templateVirtualFiles = extractTemplateLiterals(content, filename); + for (const [vFilename, vContent] of Object.entries(templateVirtualFiles)) { + for (const pattern of patterns) { + if (pattern.antiPatterns) { + const rules = extractRules(pattern.antiPatterns); + + for (const rule of rules) { + const lines = vContent.split('\n'); + lines.forEach((line, index) => { + if (rule.test(line)) { + violations.push({ + file: vFilename, + line: index + 1, + snippet: line.trim().substring(0, 100), + patternName: pattern.name, + antiPattern: pattern.antiPatterns!, + severity: 'MAJOR' + }); + } + }); + } + } + } + } } const score = Math.max(0, 1.0 - (violations.length * 0.1)); @@ -70,6 +96,39 @@ export function scanForDrift( }; } +/** + * Extract template literal bodies from a source file. + * + * For .ts, .tsx, .js, and .mjs files, finds all backtick template literal + * bodies and returns them as a map of virtual filenames to extracted content. + * Virtual filenames have the format: `filename[template:N]` + * + * Handles nested backticks conservatively via non-greedy matching. + * Short template literals (≤20 chars) are skipped as trivially uninteresting. + * + * @param content - File content to extract from + * @param filename - Filename used to determine extension and virtual names + * @returns Map of virtual filename → extracted template body + */ +export function extractTemplateLiterals(content: string, filename: string): Record { + const supportedExtensions = ['.ts', '.tsx', '.js', '.mjs']; + const isSupported = supportedExtensions.some(ext => filename.endsWith(ext)); + if (!isSupported) return {}; + + const result: Record = {}; + // Match outermost backtick strings — simplified: find `...` blocks + const re = /`([\s\S]*?)`/g; + let match: RegExpExecArray | null; + let i = 0; + while ((match = re.exec(content)) !== null) { + const body = match[1]; + if (body.length > 20) { // skip trivially short template strings + result[`${filename}[template:${i++}]`] = body; + } + } + return result; +} + // ============================================================================ // Rule Extraction // ============================================================================ From cb120816201d7c735315ab832a12e101868cd9ae Mon Sep 17 00:00:00 2001 From: Stackbilt Admin Date: Sat, 23 May 2026 07:21:52 -0500 Subject: [PATCH 3/6] feat(context-refresh): add repo-intel source for GitHub history snapshots (#138) (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New --sources repo-intel pulls open/closed issues, PRs, and release cadence via gh CLI and writes .charter/repo-intel/snapshot.json. Computes a summary (openIssueCount, stalledIssues, recurringLabels, mergeVelocity, releaseCadence) contributed to context.adf openWork/recentActivity sections. Fails gracefully when gh is unavailable or repo has no GitHub remote — emits a warning, not an error. Co-authored-by: Kurt Overmier Co-authored-by: Claude Sonnet 4.6 --- docs/cli-reference.md | 15 +- .../context-refresh-repo-intel.test.ts | 256 +++++++++++++++++ packages/cli/src/commands/context-refresh.ts | 264 +++++++++++++++++- 3 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/__tests__/context-refresh-repo-intel.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 53c20c4..3bf54e2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -484,12 +484,13 @@ Seeds for hotspot analysis are chosen by resolved preset (from `.charter/config. Generates a live session snapshot and writes it to `.ai/context.adf` plus `.ai/context.snapshot.json`. -Phase 2 supports `git` and `github` sources with fail-closed behavior for missing GitHub credentials. +Supports `git`, `github`, and `repo-intel` sources with fail-closed behavior for missing GitHub credentials and graceful skip when the `gh` CLI is unavailable. ```bash npx charter context-refresh npx charter context-refresh --sources git npx charter context-refresh --sources git,github +npx charter context-refresh --sources repo-intel npx charter context-refresh --output CONTEXT.md npx charter context-refresh --ai-dir .ai npx charter context-refresh --once --ttl-minutes 30 @@ -498,7 +499,7 @@ npx charter context-refresh --format json #### Flags -- `--sources ` — context sources to include. Supported: `git`, `github`. +- `--sources ` — context sources to include. Supported: `git`, `github`, `repo-intel`. - `--output ` — optionally mirror a markdown snapshot to a file (for session briefs/docs). - `--ai-dir ` — target ADF directory (default: `.ai`), output file is `/context.adf`. - `--once` — skip refresh when an existing snapshot is newer than TTL. @@ -528,6 +529,16 @@ Optional config path: `.charter/context-sources.json` If `github` is enabled but `GITHUB_TOKEN` is missing, refresh continues without hard failure and records `sources.github.available = false` plus warnings. +If `repo-intel` is enabled but the `gh` CLI is not installed or has no GitHub remote, refresh continues without hard failure and records a warning. When available, `repo-intel` writes a full payload to `.charter/repo-intel/snapshot.json`. + +#### Sources reference + +| Source | Description | +|--------|-------------| +| `git` | Local git branch, working tree, and recent commit log. | +| `github` | Open issues from the GitHub API (requires `GITHUB_TOKEN`). | +| `repo-intel` | GitHub history via the `gh` CLI — open/closed issues, PRs, releases, and a computed summary (`openIssueCount`, `mergeVelocity`, `stalledIssues`, `recurringLabels`, `releaseCadence`). Writes `.charter/repo-intel/snapshot.json`. Skips gracefully when `gh` is unavailable. | + For active implementation status and next-session handoff details, see [Context Refresh Resume Guide](/context-refresh-resume). ### charter surface diff --git a/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts new file mode 100644 index 0000000..d5e8c5d --- /dev/null +++ b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for the repo-intel source in context-refresh. + * + * Uses vi.mock at the top level (required for ESM) to intercept execFileSync. + * A module-level `ghResponder` variable is mutated per-test so the hoisted + * mock factory can dispatch different responses without re-mocking. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../index'; +import { contextRefreshCommand } from '../commands/context-refresh'; + +// --------------------------------------------------------------------------- +// Module-level gh responder — set this before each test, read by the mock +// --------------------------------------------------------------------------- +type GhResponder = ((args: string[]) => string) | null; +let ghResponder: GhResponder = null; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn( + (cmd: string, args: unknown, opts: unknown): string => { + if (cmd === 'gh') { + if (!ghResponder) throw new Error('ENOENT: gh not found'); + return ghResponder(args as string[]); + } + // Pass through to real execFileSync for git and everything else + return actual.execFileSync( + cmd, + args as string[], + opts as Parameters[2], + ) as string; + }, + ), + }; +}); + +// --------------------------------------------------------------------------- +// Test scaffolding +// --------------------------------------------------------------------------- +const options: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-repo-intel-test-')); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + ghResponder = null; +}); + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + ghResponder = null; +}); + +// --------------------------------------------------------------------------- +// Fake data helpers +// --------------------------------------------------------------------------- +const fakeOpenIssues = [ + { + number: 1, + title: 'Fix bug in auth flow', + labels: [{ name: 'bug' }], + assignees: [], + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-10T00:00:00Z', + comments: 2, + }, + { + number: 2, + title: 'Improve onboarding docs', + labels: [{ name: 'docs' }], + assignees: [], + createdAt: '2026-03-01T00:00:00Z', + // Old updatedAt — should count as stalled (>30 days ago from 2026-05-23) + updatedAt: '2026-03-01T00:00:00Z', + comments: 0, + }, +]; + +const fakeClosedIssues = [ + { number: 3, title: 'Old bug 1', labels: [{ name: 'bug' }], closedAt: '2026-02-01T00:00:00Z' }, + { number: 4, title: 'Old bug 2', labels: [{ name: 'bug' }], closedAt: '2026-02-10T00:00:00Z' }, + { number: 5, title: 'Old bug 3', labels: [{ name: 'bug' }], closedAt: '2026-02-15T00:00:00Z' }, +]; + +const fakePRs = [ + { + number: 10, + title: 'feat: new feature', + state: 'MERGED', + author: { login: 'alice' }, + // Merged 5 days ago — should count toward mergeVelocity + mergedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: '2026-05-10T00:00:00Z', + reviewDecision: 'APPROVED', + labels: [], + }, +]; + +const fakeReleases = [ + // 9 days apart → releaseCadence should be 9 + { tagName: 'v1.0.0', publishedAt: '2026-05-01T00:00:00Z', isLatest: false }, + { tagName: 'v1.1.0', publishedAt: '2026-05-10T00:00:00Z', isLatest: true }, +]; + +function makeFullGhResponder(): GhResponder { + return (args: string[]) => { + if (args[0] === '--version') return 'gh version 2.0.0 (2026-01-01)'; + if (args[0] === 'issue') { + // args: ['issue', 'list', '--limit', '50', '--state', 'open', '--json', '...'] + const stateIdx = args.indexOf('--state'); + const state = stateIdx >= 0 ? args[stateIdx + 1] : undefined; + if (state === 'open') return JSON.stringify(fakeOpenIssues); + if (state === 'closed') return JSON.stringify(fakeClosedIssues); + return '[]'; + } + if (args[0] === 'pr') return JSON.stringify(fakePRs); + if (args[0] === 'release') return JSON.stringify(fakeReleases); + return '[]'; + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('context-refresh repo-intel source', () => { + it('writes .charter/repo-intel/snapshot.json and summary contains openIssueCount', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + // Snapshot file must be written + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { + available: boolean; + summary: { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; + }; + openIssues: unknown[]; + closedIssues: unknown[]; + pullRequests: unknown[]; + releases: unknown[]; + }; + + expect(snapshot.available).toBe(true); + expect(snapshot.summary.openIssueCount).toBe(2); + // Issue 2 was last updated 2026-03-01, which is >30 days before 2026-05-23 + expect(snapshot.summary.stalledIssues).toBeGreaterThanOrEqual(1); + // "bug" label appears 3 times in closed issues + expect(snapshot.summary.recurringLabels).toContain('bug'); + // PR merged 5 days ago is within 30-day window + expect(snapshot.summary.mergeVelocity).toBeGreaterThanOrEqual(1); + // Two releases 9 days apart → cadence of 9 + expect(snapshot.summary.releaseCadence).toBe(9); + // Raw arrays are present + expect(Array.isArray(snapshot.openIssues)).toBe(true); + expect(snapshot.openIssues).toHaveLength(2); + expect(Array.isArray(snapshot.closedIssues)).toBe(true); + expect(snapshot.closedIssues).toHaveLength(3); + expect(Array.isArray(snapshot.pullRequests)).toBe(true); + expect(Array.isArray(snapshot.releases)).toBe(true); + }); + + it('source appears in sourcesUsed and produces repo-intel entries in context.adf', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { sourcesUsed: string[]; warnings: string[] }; + expect(payload.sourcesUsed).toContain('repo-intel'); + expect(payload.warnings).toHaveLength(0); + + const adf = fs.readFileSync(path.join(tmp, '.ai', 'context.adf'), 'utf8'); + expect(adf).toContain('repo-intel'); + }); + + it('skips gracefully when gh CLI is not available — warning but no hard error', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + // Leave ghResponder = null → mock throws ENOENT for any gh call + ghResponder = null; + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { + status: string; + sourcesUsed: string[]; + warnings: string[]; + errors: string[]; + }; + + // Graceful degradation: ok status, a warning, no errors + expect(payload.status).toBe('ok'); + expect(payload.sourcesUsed).not.toContain('repo-intel'); + expect(payload.warnings.some((w) => w.includes('repo-intel'))).toBe(true); + expect(payload.errors).toHaveLength(0); + + // Snapshot file must NOT be written when gh is unavailable + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/context-refresh.ts b/packages/cli/src/commands/context-refresh.ts index 67b1bed..e9ddc00 100644 --- a/packages/cli/src/commands/context-refresh.ts +++ b/packages/cli/src/commands/context-refresh.ts @@ -15,7 +15,7 @@ import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -type ContextSource = 'git' | 'github'; +type ContextSource = 'git' | 'github' | 'repo-intel'; interface GitCommit { hash: string; @@ -50,6 +50,60 @@ interface GitHubSnapshot { error?: string; } +// repo-intel types +interface RepoIntelIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + assignees: Array<{ login: string }>; + createdAt: string; + updatedAt: string; + comments: number; +} + +interface RepoIntelClosedIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + closedAt: string; +} + +interface RepoIntelPR { + number: number; + title: string; + state: string; + author: { login: string }; + mergedAt: string | null; + createdAt: string; + reviewDecision: string | null; + labels: Array<{ name: string }>; +} + +interface RepoIntelRelease { + tagName: string; + publishedAt: string; + isLatest: boolean; +} + +interface RepoIntelSummary { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; +} + +interface RepoIntelSnapshot { + available: boolean; + generatedAt: string; + openIssues: RepoIntelIssue[]; + closedIssues: RepoIntelClosedIssue[]; + pullRequests: RepoIntelPR[]; + releases: RepoIntelRelease[]; + summary: RepoIntelSummary; + error?: string; +} + interface DerivedItem { source: ContextSource; type: string; @@ -70,6 +124,7 @@ interface ContextSnapshot { sources: { git: GitSnapshot; github: GitHubSnapshot; + 'repo-intel': RepoIntelSnapshot; }; openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -100,6 +155,9 @@ interface ContextConfig { includePullRequests: boolean; includeChecks: boolean; }; + 'repo-intel': { + enabled: boolean; + }; }; } @@ -117,7 +175,7 @@ interface ContextRefreshIO { log?: (message: string) => void; } -const SOURCE_SET = new Set(['git', 'github']); +const SOURCE_SET = new Set(['git', 'github', 'repo-intel']); const DEFAULT_CONFIG: ContextConfig = { version: 1, defaults: { @@ -140,6 +198,9 @@ const DEFAULT_CONFIG: ContextConfig = { includePullRequests: true, includeChecks: true, }, + 'repo-intel': { + enabled: true, + }, }, }; @@ -335,6 +396,11 @@ function loadContextConfig(configPath: string): ContextConfig { cfg.sources.github.includeChecks = github.includeChecks; } } + const repoIntelCfg = sources['repo-intel']; + if (repoIntelCfg && typeof repoIntelCfg === 'object') { + const ri = repoIntelCfg as Record; + if (typeof ri.enabled === 'boolean') cfg.sources['repo-intel'].enabled = ri.enabled; + } } return cfg; @@ -348,7 +414,7 @@ function parseRequestedSources(sourcesFlag: string | undefined, fallback: Contex .filter((entry) => entry.length > 0); const invalid = requested.filter((entry) => !SOURCE_SET.has(entry as ContextSource)); if (invalid.length > 0) { - throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github.`); + throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github, repo-intel.`); } return [...new Set(requested as ContextSource[])]; } @@ -410,7 +476,21 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro } } - const derived = deriveAggregates(git, github); + const repoIntelEnabled = resolved.sourcesRequested.includes('repo-intel') && resolved.config.sources['repo-intel'].enabled; + const repoIntel = repoIntelEnabled + ? collectRepoIntelSnapshot(cwd, generatedAt) + : { available: false, generatedAt, openIssues: [], closedIssues: [], pullRequests: [], releases: [], summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, error: 'disabled' }; + if (repoIntel.available) { + sourcesUsed.push('repo-intel'); + // Persist full snapshot to .charter/repo-intel/snapshot.json + const repoIntelSnapshotPath = path.resolve(cwd, '.charter', 'repo-intel', 'snapshot.json'); + fs.mkdirSync(path.dirname(repoIntelSnapshotPath), { recursive: true }); + fs.writeFileSync(repoIntelSnapshotPath, JSON.stringify(repoIntel, null, 2), 'utf8'); + } else if (resolved.sourcesRequested.includes('repo-intel') && repoIntel.error && repoIntel.error !== 'disabled') { + warnings.push(`repo-intel source unavailable: ${repoIntel.error}`); + } + + const derived = deriveAggregates(git, github, repoIntel); return { version: 1, @@ -425,6 +505,7 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro sources: { git, github, + 'repo-intel': repoIntel, }, openWork: derived.openWork, recentActivity: derived.recentActivity, @@ -598,9 +679,154 @@ async function collectGitHubSnapshot(config: ContextConfig, issueLimit: number): }; } +function runGhCommand(args: string[], cwd?: string): string | null { + try { + const output = execFileSync('gh', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return output.trim(); + } catch { + return null; + } +} + +function collectRepoIntelSnapshot(cwd: string, generatedAt: string): RepoIntelSnapshot { + + const empty: RepoIntelSnapshot = { + available: false, + generatedAt, + openIssues: [], + closedIssues: [], + pullRequests: [], + releases: [], + summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, + }; + + // Check if gh CLI is available + const ghVersion = runGhCommand(['--version'], cwd); + if (!ghVersion) { + return { ...empty, error: 'gh CLI not available' }; + } + + // Open issues (last 50, sorted by updated) + const openIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '50', '--state', 'open', + '--json', 'number,title,labels,assignees,createdAt,updatedAt,comments', + ], cwd); + if (!openIssuesRaw) { + return { ...empty, error: 'no GitHub remote or gh auth required' }; + } + + let openIssues: RepoIntelIssue[]; + try { + openIssues = JSON.parse(openIssuesRaw) as RepoIntelIssue[]; + } catch { + return { ...empty, error: 'invalid_json: open issues response' }; + } + + // Recent closed issues (last 20) + const closedIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '20', '--state', 'closed', + '--json', 'number,title,labels,closedAt', + ], cwd); + let closedIssues: RepoIntelClosedIssue[] = []; + if (closedIssuesRaw) { + try { + closedIssues = JSON.parse(closedIssuesRaw) as RepoIntelClosedIssue[]; + } catch { /* ignore parse failures for supplemental data */ } + } + + // Recent PRs (last 30, all states) + const prsRaw = runGhCommand([ + 'pr', 'list', '--limit', '30', '--state', 'all', + '--json', 'number,title,state,author,mergedAt,createdAt,reviewDecision,labels', + ], cwd); + let pullRequests: RepoIntelPR[] = []; + if (prsRaw) { + try { + pullRequests = JSON.parse(prsRaw) as RepoIntelPR[]; + } catch { /* ignore */ } + } + + // Release cadence (last 10 releases) + const releasesRaw = runGhCommand([ + 'release', 'list', '--limit', '10', + '--json', 'tagName,publishedAt,isLatest', + ], cwd); + let releases: RepoIntelRelease[] = []; + if (releasesRaw) { + try { + releases = JSON.parse(releasesRaw) as RepoIntelRelease[]; + } catch { /* ignore */ } + } + + // Compute summary + const now = Date.now(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + + const stalledIssues = openIssues.filter((issue) => { + const updatedMs = Date.parse(issue.updatedAt); + return Number.isFinite(updatedMs) && (now - updatedMs) > thirtyDaysMs; + }).length; + + // Count label occurrences in closed issues + const labelCounts = new Map(); + for (const issue of closedIssues) { + for (const label of issue.labels) { + const name = label.name; + labelCounts.set(name, (labelCounts.get(name) ?? 0) + 1); + } + } + const recurringLabels = [...labelCounts.entries()] + .filter(([, count]) => count >= 3) + .sort((a, b) => b[1] - a[1]) + .map(([name]) => name); + + const mergeVelocity = pullRequests.filter((pr) => { + if (!pr.mergedAt) return false; + const mergedMs = Date.parse(pr.mergedAt); + return Number.isFinite(mergedMs) && (now - mergedMs) <= thirtyDaysMs; + }).length; + + let releaseCadence: number | null = null; + const lastFiveReleases = releases + .slice(0, 5) + .map((r) => Date.parse(r.publishedAt)) + .filter((ms) => Number.isFinite(ms)) + .sort((a, b) => b - a); + if (lastFiveReleases.length >= 2) { + const gaps: number[] = []; + for (let i = 0; i < lastFiveReleases.length - 1; i++) { + gaps.push((lastFiveReleases[i]! - lastFiveReleases[i + 1]!) / (24 * 60 * 60 * 1000)); + } + releaseCadence = Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length); + } + + const summary: RepoIntelSummary = { + openIssueCount: openIssues.length, + stalledIssues, + recurringLabels, + mergeVelocity, + releaseCadence, + }; + + return { + available: true, + generatedAt, + openIssues, + closedIssues, + pullRequests, + releases, + summary, + }; +} + function deriveAggregates( git: GitSnapshot, github: GitHubSnapshot, + repoIntel: RepoIntelSnapshot, ): { openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -663,6 +889,36 @@ function deriveAggregates( } } + if (repoIntel.available) { + const s = repoIntel.summary; + recentActivity.push({ + source: 'repo-intel', + type: 'summary', + summary: `repo-intel: ${s.openIssueCount} open issues, ${s.mergeVelocity} PRs merged in last 30d, ${s.stalledIssues} stalled`, + }); + if (s.stalledIssues > 0) { + openWork.push({ + source: 'repo-intel', + type: 'stalled-issues', + summary: `${s.stalledIssues} open issue(s) with no activity in 30+ days`, + }); + } + if (s.recurringLabels.length > 0) { + pendingDecisions.push({ + source: 'repo-intel', + type: 'recurring-labels', + summary: `Recurring closed-issue labels (≥3 times): ${s.recurringLabels.slice(0, 5).join(', ')}`, + }); + } + if (s.releaseCadence !== null) { + recentActivity.push({ + source: 'repo-intel', + type: 'release-cadence', + summary: `Avg release cadence: ~${s.releaseCadence} day(s) between last 5 releases`, + }); + } + } + return { openWork, recentActivity, pendingDecisions }; } From 0f8495fb9446341310393585a4fd9b9c91dfb978 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:29:27 -0500 Subject: [PATCH 4/6] fix(bootstrap): gate lean-mode hook next-steps on inGitRepo Hooks cannot be installed outside a git repo. The non-lean path already gated these steps on inGitRepo; lean mode was missing the same guard. Tests updated to mock isGitRepo via git-helpers module mock (runGit uses execFileSync, not execSync, so the existing execSync override couldn't cover it). Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/__tests__/bootstrap.test.ts | 27 ++++++++++++++++++-- packages/cli/src/commands/bootstrap.ts | 22 ++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index 00b3596..7697118 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -8,6 +8,13 @@ import { driftCommand } from '../commands/drift'; import type { CLIOptions } from '../index'; import { parseAdf, parseManifest } from '@stackbilt/adf'; +// Controlled per-test override for isGitRepo (git-helpers uses execFileSync, not execSync) +let mockIsGitRepo: boolean | null = null; +vi.mock('../git-helpers', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isGitRepo: () => mockIsGitRepo !== null ? mockIsGitRepo : actual.isGitRepo() }; +}); + // Controlled per-test override for execSync (module-level mock needed for ESM-treated builtins) let execSyncOverride: (((...args: unknown[]) => unknown) | null) = null; vi.mock('node:child_process', async (importOriginal) => { @@ -342,12 +349,13 @@ load state.adf always it('emits the detected package manager install command as the first required next step', async () => { fs.writeFileSync('pnpm-lock.yaml', ''); - + mockIsGitRepo = true; logs = []; await bootstrapCommand( { ...baseOptions, format: 'json' }, ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], ); + mockIsGitRepo = null; const report = JSON.parse(logs[0]); expect(report.nextSteps.length).toBe(4); @@ -356,12 +364,14 @@ load state.adf always expect(first.required).toBe(true); }); - it('emits exactly 4 deterministic next steps in the correct order', async () => { + it('emits exactly 4 deterministic next steps in the correct order (in a git repo)', async () => { + mockIsGitRepo = true; logs = []; await bootstrapCommand( { ...baseOptions, format: 'json' }, ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], ); + mockIsGitRepo = null; const report = JSON.parse(logs[0]); expect(report.nextSteps.length).toBe(4); @@ -371,6 +381,19 @@ load state.adf always expect(report.nextSteps[3].cmd).toBe('charter serve'); }); + it('emits only install + serve next steps outside a git repo', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(2); + expect(report.nextSteps[0].cmd).toMatch(/install$/); + expect(report.nextSteps[1].cmd).toBe('charter serve'); + }); + it('--mode lean combined with --skip-install is not an error', async () => { logs = []; const exitCode = await bootstrapCommand( diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 5fa6185..1f48815 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -342,16 +342,18 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro required: true, reason: 'Install dependencies (skipped in lean mode)', }); - result.nextSteps.push({ - cmd: 'charter hook install --commit-msg', - required: false, - reason: 'Install commit-msg hook for trailer enforcement', - }); - result.nextSteps.push({ - cmd: 'charter hook install --pre-commit', - required: false, - reason: 'Install pre-commit hook for ADF evidence gate', - }); + if (inGitRepo) { + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); + result.nextSteps.push({ + cmd: 'charter hook install --pre-commit', + required: false, + reason: 'Install pre-commit hook for ADF evidence gate', + }); + } result.nextSteps.push({ cmd: 'charter serve', required: false, From c9ca5d3764339087b57ce412b8e2236fff62e619 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 09:28:12 -0500 Subject: [PATCH 5/6] feat(cli)!: remove commercial surface from @stackbilt/cli at Charter 1.0 (#127) Phase 4 of RFC #112. Deletes run/architect/scaffold/login commands, credentials.ts, http-client.ts, scaffold-contract-types.ts, and all related tests. Drops the `stackbilt` bin alias. Bumps @stackbilt/cli to 1.0.0. @stackbilt/build@0.1.0 is the new home for these commands. BREAKING CHANGE: run, architect, scaffold, login commands and stackbilt bin removed. Install @stackbilt/build for the long-term home. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 ++ README.md | 11 - packages/cli/package.json | 7 +- .../cli/src/__tests__/auth-wiring.test.ts | 149 ----------- .../cli/src/__tests__/credentials.test.ts | 143 ----------- .../src/__tests__/deprecated-commands.test.ts | 72 ------ packages/cli/src/__tests__/login.test.ts | 78 ------ packages/cli/src/commands/architect.ts | 146 ----------- .../cli/src/commands/deprecation-warning.ts | 20 -- packages/cli/src/commands/login.ts | 88 ------- packages/cli/src/commands/run.ts | 235 ------------------ packages/cli/src/commands/scaffold.ts | 90 ------- packages/cli/src/credentials.ts | 81 ------ packages/cli/src/http-client.ts | 170 ------------- packages/cli/src/index.ts | 26 -- .../cli/src/types/scaffold-contract-types.ts | 100 -------- 16 files changed, 17 insertions(+), 1413 deletions(-) delete mode 100644 packages/cli/src/__tests__/auth-wiring.test.ts delete mode 100644 packages/cli/src/__tests__/credentials.test.ts delete mode 100644 packages/cli/src/__tests__/deprecated-commands.test.ts delete mode 100644 packages/cli/src/__tests__/login.test.ts delete mode 100644 packages/cli/src/commands/architect.ts delete mode 100644 packages/cli/src/commands/deprecation-warning.ts delete mode 100644 packages/cli/src/commands/login.ts delete mode 100644 packages/cli/src/commands/run.ts delete mode 100644 packages/cli/src/commands/scaffold.ts delete mode 100644 packages/cli/src/credentials.ts delete mode 100644 packages/cli/src/http-client.ts delete mode 100644 packages/cli/src/types/scaffold-contract-types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5cc10..1258be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Added +## [1.0.0] - 2026-05-23 + +**Breaking:** `run`, `architect`, `scaffold`, and `login` commands removed from `@stackbilt/cli`. The `stackbilt` binary alias is also removed. These commands have moved to [`@stackbilt/build`](https://www.npmjs.com/package/@stackbilt/build) — install it with `npm install -g @stackbilt/build`. Migration tracked in [RFC #112](https://github.com/Stackbilt-dev/charter/issues/112). + +### Removed + +- `charter run`, `charter architect`, `charter scaffold`, `charter login` — removed after four minor release cycles of deprecation warnings (0.13–0.17). All four commands live in `@stackbilt/build@0.1.0+`. +- `stackbilt` bin alias — `@stackbilt/cli` now ships only the `charter` binary. Run `npm install -g @stackbilt/build` to get the `stackbilt` bin. +- `credentials.ts`, `http-client.ts`, `types/scaffold-contract-types.ts` — removed with the commands. `@stackbilt/cli` now has zero network calls, no bearer-token handling, and no references to `stackbilt.dev`. + +### Changed + +- Package description updated: "Charter CLI — repo-level governance toolkit" (drops scaffolding claim). + ## [0.17.0] - 2026-05-23 Minor release closing issue #155 — session continuity is now first-class in the charter toolchain. diff --git a/README.md b/README.md index d9e0433..b46253d 100644 --- a/README.md +++ b/README.md @@ -177,17 +177,6 @@ Deterministic codebase analysis — no LLM calls, zero runtime dependencies. `bl All commands support `--format json` with `nextActions` hints for agent workflows. -### Build (deprecated — moving to `@stackbilt/build`) - -> These four commands reach external Stackbilt endpoints and are being extracted into a separate `@stackbilt/build` npm package. Governance-only users don't need them. Migration tracked in [RFC #112](https://github.com/Stackbilt-dev/charter/issues/112). - -```bash -stackbilt run "description" # Architect + scaffold in one step -charter architect "description" # Generate stack selection -charter scaffold --output ./my-project # Write files from last build -charter login --key sb_live_xxx # Store API key (deprecated — prefer STACKBILT_API_KEY env var) -``` - ### Exit codes - `0`: success diff --git a/packages/cli/package.json b/packages/cli/package.json index 165328f..29a729d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,10 @@ { "name": "@stackbilt/cli", "sideEffects": false, - "version": "0.17.0", - "description": "Charter CLI — repo-level governance checks + architecture scaffolding", + "version": "1.0.0", + "description": "Charter CLI — repo-level governance toolkit", "bin": { - "charter": "./dist/bin.js", - "stackbilt": "./dist/bin.js" + "charter": "./dist/bin.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/cli/src/__tests__/auth-wiring.test.ts b/packages/cli/src/__tests__/auth-wiring.test.ts deleted file mode 100644 index d0a3b1d..0000000 --- a/packages/cli/src/__tests__/auth-wiring.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Hoisted mock state: vi.mock factories run before any imports, so the -// EngineClient class mock must reach these via vi.hoisted. -const hoisted = vi.hoisted(() => ({ - buildFn: vi.fn(), - scaffoldFn: vi.fn(), - constructorArgs: [] as Array<{ baseUrl?: string; apiKey?: string | null }>, -})); - -vi.mock('../credentials', async () => { - const actual = await vi.importActual('../credentials'); - return { ...actual, resolveApiKey: vi.fn() }; -}); - -vi.mock('../http-client', () => { - return { - EngineClient: class { - constructor(opts: { baseUrl?: string; apiKey?: string | null }) { - hoisted.constructorArgs.push(opts); - } - build = hoisted.buildFn; - scaffold = hoisted.scaffoldFn; - health = vi.fn(); - catalog = vi.fn(); - }, - }; -}); - -import { resolveApiKey } from '../credentials'; -import { architectCommand } from '../commands/architect'; -import { runCommand } from '../commands/run'; -import type { CLIOptions } from '../index'; - -const mockedResolveApiKey = vi.mocked(resolveApiKey); - -const options: CLIOptions = { - format: 'json', - configPath: '.charter', - ciMode: false, - yes: true, -}; - -function fakeBuildResult() { - return { - stack: [], - compatibility: { - pairs: [], - totalScore: 0, - normalizedScore: 0, - dominant: '', - tensions: [], - }, - scaffold: {}, - seed: 1, - receipt: 'receipt', - requirements: { - description: 'anything', - keywords: [], - constraints: {}, - complexity: 'moderate', - }, - }; -} - -function fakeScaffoldResult() { - return { - files: [], - fileSource: 'engine' as const, - nextSteps: [], - }; -} - -let tmpCwd: string; - -beforeEach(() => { - tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-wiring-')); - process.chdir(tmpCwd); - fs.mkdirSync(path.join(tmpCwd, '.charter'), { recursive: true }); - hoisted.buildFn.mockReset().mockResolvedValue(fakeBuildResult()); - hoisted.scaffoldFn.mockReset().mockResolvedValue(fakeScaffoldResult()); - hoisted.constructorArgs.length = 0; - mockedResolveApiKey.mockReset(); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - vi.spyOn(process.stderr, 'write').mockImplementation(() => true); -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.chdir(os.tmpdir()); - fs.rmSync(tmpCwd, { recursive: true, force: true }); -}); - -describe('architect — auth wiring', () => { - it('forwards the env-sourced API key (and custom baseUrl) to EngineClient', async () => { - mockedResolveApiKey.mockReturnValue({ - apiKey: 'ea_env_wiring', - source: 'env', - baseUrl: 'https://engine.example', - }); - - await architectCommand(options, ['a simple project description']); - - expect(hoisted.constructorArgs).toHaveLength(1); - expect(hoisted.constructorArgs[0].apiKey).toBe('ea_env_wiring'); - expect(hoisted.constructorArgs[0].baseUrl).toBe('https://engine.example'); - }); - - it('passes apiKey=null to EngineClient when resolveApiKey returns null', async () => { - mockedResolveApiKey.mockReturnValue(null); - - await architectCommand(options, ['unauthenticated fallback']); - - expect(hoisted.constructorArgs[0].apiKey).toBeNull(); - }); -}); - -describe('run — gateway vs engine routing', () => { - it('uses the gateway (scaffold) when the env var provides an API key', async () => { - mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_gateway', source: 'env' }); - - await runCommand(options, ['a description', '--dry-run']); - - expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); - expect(hoisted.buildFn).not.toHaveBeenCalled(); - }); - - it('falls back to engine /build when no API key is resolved', async () => { - mockedResolveApiKey.mockReturnValue(null); - - await runCommand(options, ['a description', '--dry-run']); - - expect(hoisted.buildFn).toHaveBeenCalledTimes(1); - expect(hoisted.scaffoldFn).not.toHaveBeenCalled(); - }); - - it('uses the gateway when login-stored credentials are resolved (parity with env path)', async () => { - mockedResolveApiKey.mockReturnValue({ apiKey: 'sb_live_stored', source: 'credentials' }); - - await runCommand(options, ['a description', '--dry-run']); - - expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); - expect(hoisted.buildFn).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/__tests__/credentials.test.ts b/packages/cli/src/__tests__/credentials.test.ts deleted file mode 100644 index 401f590..0000000 --- a/packages/cli/src/__tests__/credentials.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Isolate loadCredentials from the developer's real ~/.charter/credentials.json -// by mocking node:fs. Each test configures the fs mock explicitly. -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); - return { - ...actual, - existsSync: vi.fn(() => false), - readFileSync: vi.fn(), - }; -}); - -import * as fs from 'node:fs'; -import { resolveApiKey, API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR } from '../credentials'; - -const mockedFs = fs as unknown as { - existsSync: ReturnType; - readFileSync: ReturnType; -}; - -function stubStoredCredentials(apiKey: string, baseUrl?: string): void { - mockedFs.existsSync.mockReturnValue(true); - mockedFs.readFileSync.mockReturnValue(JSON.stringify({ apiKey, baseUrl })); -} - -function stubNoStoredCredentials(): void { - mockedFs.existsSync.mockReturnValue(false); - mockedFs.readFileSync.mockImplementation(() => { - throw new Error('readFileSync should not be called when existsSync=false'); - }); -} - -describe('resolveApiKey', () => { - const originalKeyEnv = process.env[API_KEY_ENV_VAR]; - const originalBaseUrlEnv = process.env[API_BASE_URL_ENV_VAR]; - - beforeEach(() => { - delete process.env[API_KEY_ENV_VAR]; - delete process.env[API_BASE_URL_ENV_VAR]; - mockedFs.existsSync.mockReset(); - mockedFs.readFileSync.mockReset(); - stubNoStoredCredentials(); - }); - - afterEach(() => { - if (originalKeyEnv === undefined) delete process.env[API_KEY_ENV_VAR]; - else process.env[API_KEY_ENV_VAR] = originalKeyEnv; - if (originalBaseUrlEnv === undefined) delete process.env[API_BASE_URL_ENV_VAR]; - else process.env[API_BASE_URL_ENV_VAR] = originalBaseUrlEnv; - }); - - it('returns env var when set', () => { - process.env[API_KEY_ENV_VAR] = 'ea_test_from_env_12345'; - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('env'); - expect(result!.apiKey).toBe('ea_test_from_env_12345'); - }); - - it('env var wins when both env var and stored credentials are present', () => { - process.env[API_KEY_ENV_VAR] = 'ea_env_wins'; - stubStoredCredentials('sb_live_should_be_ignored', 'https://stored.example'); - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('env'); - expect(result!.apiKey).toBe('ea_env_wins'); - }); - - it('trims whitespace from the env var', () => { - process.env[API_KEY_ENV_VAR] = ' sb_test_abc '; - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('env'); - expect(result!.apiKey).toBe('sb_test_abc'); - }); - - it('empty env var falls through to stored credentials', () => { - process.env[API_KEY_ENV_VAR] = ''; - stubStoredCredentials('sb_live_from_disk'); - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('credentials'); - expect(result!.apiKey).toBe('sb_live_from_disk'); - }); - - it('whitespace-only env var falls through to stored credentials', () => { - process.env[API_KEY_ENV_VAR] = ' \t '; - stubStoredCredentials('sb_live_from_disk'); - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('credentials'); - expect(result!.apiKey).toBe('sb_live_from_disk'); - }); - - it('returns null when neither env var nor stored credentials are present', () => { - stubNoStoredCredentials(); - - const result = resolveApiKey(); - - expect(result).toBeNull(); - }); - - it('env-var path adopts STACKBILT_API_BASE_URL when set', () => { - process.env[API_KEY_ENV_VAR] = 'ea_with_custom_url'; - process.env[API_BASE_URL_ENV_VAR] = 'https://engine.internal.example'; - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('env'); - expect(result!.baseUrl).toBe('https://engine.internal.example'); - }); - - it('env-var path leaves baseUrl undefined when STACKBILT_API_BASE_URL is unset', () => { - process.env[API_KEY_ENV_VAR] = 'ea_without_custom_url'; - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.baseUrl).toBeUndefined(); - }); - - it('credentials path carries baseUrl from the stored file', () => { - stubStoredCredentials('sb_live_from_disk', 'https://engine.custom.example'); - - const result = resolveApiKey(); - - expect(result).not.toBeNull(); - expect(result!.source).toBe('credentials'); - expect(result!.baseUrl).toBe('https://engine.custom.example'); - }); -}); diff --git a/packages/cli/src/__tests__/deprecated-commands.test.ts b/packages/cli/src/__tests__/deprecated-commands.test.ts deleted file mode 100644 index 3d41922..0000000 --- a/packages/cli/src/__tests__/deprecated-commands.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { architectCommand } from '../commands/architect'; -import { runCommand } from '../commands/run'; -import { scaffoldCommand } from '../commands/scaffold'; -import { loginCommand } from '../commands/login'; -import type { CLIOptions } from '../index'; -import { CLIError } from '../index'; -import { DEPRECATION_WARNING_ENV_VAR } from '../commands/deprecation-warning'; - -const baseOptions: CLIOptions = { - format: 'text', - configPath: '.charter', - ciMode: false, - yes: false, -}; - -describe('deprecated build commands warnings', () => { - const originalSuppress = process.env[DEPRECATION_WARNING_ENV_VAR]; - - beforeEach(() => { - delete process.env[DEPRECATION_WARNING_ENV_VAR]; - vi.restoreAllMocks(); - }); - - afterEach(() => { - if (originalSuppress === undefined) { - delete process.env[DEPRECATION_WARNING_ENV_VAR]; - } else { - process.env[DEPRECATION_WARNING_ENV_VAR] = originalSuppress; - } - vi.restoreAllMocks(); - }); - - it('emits warning for login', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - vi.spyOn(console, 'log').mockImplementation(() => {}); - - await loginCommand(baseOptions, []); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toContain('charter login'); - expect(stderrOutput).toContain('@stackbilt/build'); - }); - - it('emits warning for architect', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - - await expect(architectCommand(baseOptions, [])).rejects.toBeInstanceOf(CLIError); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toContain('charter architect'); - }); - - it('emits warning for run', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - - await expect(runCommand(baseOptions, [])).rejects.toBeInstanceOf(CLIError); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toContain('charter run'); - }); - - it('emits warning for scaffold', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - const options = { ...baseOptions, configPath: '.charter-missing-cache-for-test' }; - - await expect(scaffoldCommand(options, [])).rejects.toBeInstanceOf(CLIError); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toContain('charter scaffold'); - }); -}); diff --git a/packages/cli/src/__tests__/login.test.ts b/packages/cli/src/__tests__/login.test.ts deleted file mode 100644 index 836df4c..0000000 --- a/packages/cli/src/__tests__/login.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loginCommand } from '../commands/login'; -import type { CLIOptions } from '../index'; -import { API_KEY_ENV_VAR } from '../credentials'; -import { DEPRECATION_WARNING_ENV_VAR } from '../commands/deprecation-warning'; - -const options: CLIOptions = { - format: 'text', - configPath: '.charter', - ciMode: false, - yes: false, -}; - -describe('charter login — deprecation notice', () => { - const originalEnv = process.env[API_KEY_ENV_VAR]; - const originalSuppress = process.env[DEPRECATION_WARNING_ENV_VAR]; - - beforeEach(() => { - delete process.env[API_KEY_ENV_VAR]; - }); - - afterEach(() => { - if (originalEnv === undefined) { - delete process.env[API_KEY_ENV_VAR]; - } else { - process.env[API_KEY_ENV_VAR] = originalEnv; - } - if (originalSuppress === undefined) { - delete process.env[DEPRECATION_WARNING_ENV_VAR]; - } else { - process.env[DEPRECATION_WARNING_ENV_VAR] = originalSuppress; - } - vi.restoreAllMocks(); - }); - - it('writes a deprecation notice to stderr when invoked without args', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - vi.spyOn(console, 'log').mockImplementation(() => {}); - - await loginCommand(options, []); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toMatch(/deprecated/i); - expect(stderrOutput).toContain('@stackbilt/build'); - }); - - it('reports env-var usage when STACKBILT_API_KEY is set and no --key flag', async () => { - process.env[API_KEY_ENV_VAR] = 'ea_login_test_key'; - vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await loginCommand(options, []); - - const stdoutOutput = log.mock.calls.map((c) => String(c[0])).join('\n'); - expect(stdoutOutput).toMatch(new RegExp(`Using ${API_KEY_ENV_VAR} from environment`)); - }); - - it('suppresses warning when CHARTER_NO_DEPRECATION_WARNING=1', async () => { - process.env[DEPRECATION_WARNING_ENV_VAR] = '1'; - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - vi.spyOn(console, 'log').mockImplementation(() => {}); - - await loginCommand(options, []); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toBe(''); - }); - - it('suppresses warning when --no-deprecation-warning is passed', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - vi.spyOn(console, 'log').mockImplementation(() => {}); - - await loginCommand(options, ['--no-deprecation-warning']); - - const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join(''); - expect(stderrOutput).toBe(''); - }); -}); diff --git a/packages/cli/src/commands/architect.ts b/packages/cli/src/commands/architect.ts deleted file mode 100644 index 5974b70..0000000 --- a/packages/cli/src/commands/architect.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * charter architect — generate a tech stack from a project description. - * - * Usage: - * charter architect "Build a real-time chat app on Cloudflare" - * charter architect --file spec.md - * charter architect "API backend" --cloudflare-only --framework Hono --database D1 - * charter architect "Simple landing page" --dry-run - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { CLIOptions } from '../index'; -import { EXIT_CODE, CLIError } from '../index'; -import { getFlag } from '../flags'; -import { resolveApiKey, API_KEY_ENV_VAR } from '../credentials'; -import { EngineClient, type BuildRequest, type BuildResult } from '../http-client'; -import { printBuildCommandDeprecationWarning } from './deprecation-warning'; - -export async function architectCommand(options: CLIOptions, args: string[]): Promise { - printBuildCommandDeprecationWarning('architect', args); - - // Parse description from positional arg or --file - const filePath = getFlag(args, '--file'); - const positional = args.filter(a => !a.startsWith('-') && a !== filePath); - let description: string; - - if (filePath) { - if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); - description = fs.readFileSync(filePath, 'utf-8').trim(); - } else if (positional.length > 0) { - description = positional.join(' '); - } else { - throw new CLIError('Provide a project description:\n charter architect "Build a real-time chat app"\n charter architect --file spec.md'); - } - - if (!description) throw new CLIError('Empty description.'); - - // Parse constraint flags - const request: BuildRequest = { description, constraints: {} }; - if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; - const fw = getFlag(args, '--framework'); - if (fw) request.constraints!.framework = fw; - const db = getFlag(args, '--database'); - if (db) request.constraints!.database = db; - - const seedStr = getFlag(args, '--seed'); - if (seedStr) request.seed = parseInt(seedStr, 10); - - // Resolve API key — env var wins over stored credentials. Engine /build is - // currently unauthenticated, so missing credentials is not an error here. - const resolved = resolveApiKey(); - const baseUrl = getFlag(args, '--url'); - const client = new EngineClient({ - baseUrl: baseUrl ?? resolved?.baseUrl, - apiKey: resolved?.apiKey ?? null, - }); - - // Build - let result: BuildResult; - try { - result = await client.build(request); - } catch (err) { - throw new CLIError(`Build failed: ${(err as Error).message}`); - } - - const dryRun = args.includes('--dry-run'); - - // JSON output - if (options.format === 'json') { - console.log(JSON.stringify(result, null, 2)); - if (!dryRun) cacheResult(result, options.configPath); - return EXIT_CODE.SUCCESS; - } - - // Text output - printResult(result); - - // Write scaffold - if (!dryRun) { - cacheResult(result, options.configPath); - console.log(''); - console.log(`Build cached. Run \`charter scaffold\` to write files.`); - } else { - console.log(''); - console.log('(dry run — no files written)'); - } - - return EXIT_CODE.SUCCESS; -} - -function printResult(r: BuildResult): void { - const c = r.compatibility; - - console.log(''); - console.log(` Stack (seed: ${r.seed}, ${r.requirements.complexity})`); - console.log(''); - - const maxPos = Math.max(...r.stack.map(s => s.position.length)); - const maxName = Math.max(...r.stack.map(s => s.name.length)); - for (const s of r.stack) { - const pos = s.position.padEnd(maxPos); - const name = s.name.padEnd(maxName); - const orient = s.orientation === 'reversed' ? '↓' : '↑'; - const cf = s.cloudflareNative ? ' [CF]' : ''; - console.log(` ${pos} ${name} (${s.element}, ${orient})${cf}`); - } - - console.log(''); - console.log(` Compatibility: ${c.normalizedScore} (${c.pairs.length} pairs, ${c.tensions.length} tensions)`); - - for (const p of c.pairs) { - const sign = p.score > 0 ? '+' : p.score < 0 ? '' : ' '; - console.log(` ${p.techs[0]} + ${p.techs[1]} = ${p.relationship} (${sign}${p.score})`); - } - - if (c.tensions.length > 0) { - console.log(''); - console.log(' Tensions:'); - for (const t of c.tensions) { - console.log(` ⚡ ${t.description}`); - } - } - - console.log(''); - console.log(` Scaffold: ${Object.keys(r.scaffold).length} files`); - for (const f of Object.keys(r.scaffold).sort()) { - const lines = r.scaffold[f].split('\n').length; - console.log(` ${f} (${lines} lines)`); - } - - console.log(''); - console.log(` Keywords: ${r.requirements.keywords.slice(0, 8).join(', ')}`); - console.log(` Receipt: ${r.receipt.slice(0, 16)}`); -} - -function cacheResult(result: BuildResult, configPath: string): void { - const dir = configPath || '.charter'; - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync( - path.join(dir, 'last-build.json'), - JSON.stringify(result, null, 2), - ); -} diff --git a/packages/cli/src/commands/deprecation-warning.ts b/packages/cli/src/commands/deprecation-warning.ts deleted file mode 100644 index d81384a..0000000 --- a/packages/cli/src/commands/deprecation-warning.ts +++ /dev/null @@ -1,20 +0,0 @@ -const RFC_112_URL = 'https://github.com/Stackbilt-dev/charter/issues/112'; -export const DEPRECATION_WARNING_ENV_VAR = 'CHARTER_NO_DEPRECATION_WARNING'; -export const DEPRECATION_WARNING_FLAG = '--no-deprecation-warning'; - -function warningSuppressed(args: string[]): boolean { - return args.includes(DEPRECATION_WARNING_FLAG) || process.env[DEPRECATION_WARNING_ENV_VAR] === '1'; -} - -export function printBuildCommandDeprecationWarning(command: string, args: string[]): void { - if (warningSuppressed(args)) { - return; - } - - process.stderr.write( - `⚠ charter ${command} is deprecated and will be removed in Charter 1.0.\n` + - ' Install @stackbilt/build for the long-term home of this command:\n' + - ' npm install -g @stackbilt/build\n' + - ` See ${RFC_112_URL} for context.\n`, - ); -} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts deleted file mode 100644 index 72d6daf..0000000 --- a/packages/cli/src/commands/login.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * charter login — API key management for Stackbilt Engine. - * - * DEPRECATED: on-disk credential storage moves out of this OSS package in 1.0. - * Set STACKBILT_API_KEY in the environment instead. - * - * Usage: - * charter login --key sb_live_xxx Store API key (deprecated) - * charter login --logout Clear stored credentials - */ - -import type { CLIOptions } from '../index'; -import { EXIT_CODE, CLIError } from '../index'; -import { getFlag } from '../flags'; -import { printBuildCommandDeprecationWarning } from './deprecation-warning'; -import { - loadCredentials, - saveCredentials, - clearCredentials, - API_KEY_ENV_VAR, -} from '../credentials'; -import { EngineClient } from '../http-client'; - -export async function loginCommand(options: CLIOptions, args: string[]): Promise { - printBuildCommandDeprecationWarning('login', args); - - if (args.includes('--logout')) { - clearCredentials(); - console.log('Credentials cleared.'); - return EXIT_CODE.SUCCESS; - } - - const key = getFlag(args, '--key'); - if (!key) { - const existing = loadCredentials(); - const envKey = process.env[API_KEY_ENV_VAR]; - if (envKey && envKey.trim().length > 0) { - const masked = envKey.slice(0, 12) + '...' + envKey.slice(-4); - console.log(`Using ${API_KEY_ENV_VAR} from environment: ${masked}`); - return EXIT_CODE.SUCCESS; - } - if (existing) { - const masked = existing.apiKey.slice(0, 12) + '...' + existing.apiKey.slice(-4); - console.log(`Logged in as: ${masked}`); - if (existing.baseUrl) console.log(`Engine: ${existing.baseUrl}`); - } else { - console.log('Not logged in.'); - console.log(''); - console.log(`Preferred: export ${API_KEY_ENV_VAR}=ea_xxx (or sb_live_xxx, sb_test_xxx).`); - console.log(''); - console.log('Deprecated alternative:'); - console.log(' charter login --key ea_xxx'); - console.log(' charter login --key sb_live_xxx'); - console.log(' charter login --key sb_test_xxx'); - console.log(''); - console.log('Get your API key from auth.stackbilt.dev (ea_) or the Stackbilt dashboard (sb_).'); - } - return EXIT_CODE.SUCCESS; - } - - const VALID_PREFIXES = ['ea_', 'sb_live_', 'sb_test_']; - if (!VALID_PREFIXES.some((p) => key.startsWith(p))) { - throw new CLIError( - `Invalid API key format. Keys must start with one of: ${VALID_PREFIXES.join(', ')}.` - ); - } - - const baseUrl = getFlag(args, '--url'); - - // Verify connectivity - const client = new EngineClient({ baseUrl, apiKey: key }); - try { - const health = await client.health(); - saveCredentials({ apiKey: key, baseUrl }); - - if (options.format === 'json') { - console.log(JSON.stringify({ status: 'authenticated', engine: health.version, catalog: health.catalog })); - } else { - console.log(`Authenticated. Engine v${health.version} (${health.catalog} primitives)`); - if (key.startsWith('sb_test_')) { - console.log('Using test mode.'); - } - } - return EXIT_CODE.SUCCESS; - } catch (err) { - throw new CLIError(`Could not reach engine: ${(err as Error).message}`); - } -} diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts deleted file mode 100644 index 62afc9d..0000000 --- a/packages/cli/src/commands/run.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * charter run / stackbilt run — architect + scaffold in one step. - * - * Uses the MCP gateway scaffold endpoint (TarotScript → materializer) - * when an API key is available, producing deployment-ready Cloudflare - * Workers with wrangler.toml, .ai/ governance, tests, and typed handlers. - * - * Falls back to the engine /build endpoint when no API key is set. - * - * Usage: - * stackbilt run "Multi-tenant SaaS API with auth and billing" - * stackbilt run --file spec.md - * stackbilt run "API backend" --cloudflare-only --framework Hono --output ./my-api - * stackbilt run "Simple landing page" --dry-run - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { CLIOptions } from '../index'; -import { EXIT_CODE, CLIError } from '../index'; -import { getFlag } from '../flags'; -import { resolveApiKey, API_KEY_ENV_VAR } from '../credentials'; -import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client'; -import { printBuildCommandDeprecationWarning } from './deprecation-warning'; - -// ─── Animation ────────────────────────────────────────────── - -const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT']; -const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function clearLine(): void { - process.stdout.write('\x1b[2K\r'); -} - -function cursorUp(n: number): void { - if (n > 0) process.stdout.write(`\x1b[${n}A`); -} - -function slugify(description: string): string { - const stopWords = new Set(['a', 'an', 'the', 'with', 'and', 'or', 'for', 'in', 'on', 'to', 'my', 'build', 'create', 'make']); - const words = description.toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .split(/\s+/) - .filter(w => !stopWords.has(w)) - .slice(0, 4); - return words.join('-') || 'my-project'; -} - -function phaseDetail(label: string, result: ScaffoldResult): string { - const fileCount = result.files.length; - const adfFiles = result.files.filter(f => f.path.endsWith('.adf')).length; - const testFiles = result.files.filter(f => f.path.includes('test')).length; - const configFiles = result.files.filter(f => f.path === 'wrangler.toml' || f.path === 'package.json' || f.path === 'tsconfig.json').length; - - switch (label) { - case 'PRODUCT': return `requirements extracted from intent`; - case 'UX': return `interface patterns mapped`; - case 'RISK': return `threats identified and mitigated`; - case 'ARCHITECT': return `${fileCount} files, ${configFiles} configs generated`; - case 'TDD': return `${testFiles || 1} test file${testFiles !== 1 ? 's' : ''} generated`; - case 'SPRINT': return `${adfFiles} governance files, sprint ready`; - default: return 'done'; - } -} - -// ─── Command ──────────────────────────────────────────────── - -export async function runCommand(options: CLIOptions, args: string[]): Promise { - printBuildCommandDeprecationWarning('run', args); - - // Parse flags first (getFlag consumes flag + value from args) - const filePath = getFlag(args, '--file'); - const outputDir = getFlag(args, '--output'); - const seedStr = getFlag(args, '--seed'); - const urlOverride = getFlag(args, '--url'); - const fwOverride = getFlag(args, '--framework'); - const dbOverride = getFlag(args, '--database'); - - // Collect flag values to exclude from positional args - const flagValues = new Set([filePath, outputDir, seedStr, urlOverride, fwOverride, dbOverride].filter(Boolean)); - - // Parse description from remaining positional args - const positional = args.filter(a => !a.startsWith('-') && !flagValues.has(a)); - let description: string; - - if (filePath) { - if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); - description = fs.readFileSync(filePath, 'utf-8').trim(); - } else if (positional.length > 0) { - description = positional.join(' '); - } else { - throw new CLIError('Provide a project description:\n stackbilt run "Build a real-time chat app"\n stackbilt run --file spec.md'); - } - - if (!description) throw new CLIError('Empty description.'); - - const resolvedOutput = outputDir ?? `./${slugify(description)}`; - const dryRun = args.includes('--dry-run'); - - // Engine client — env var wins over stored credentials. - const resolved = resolveApiKey(); - const baseUrl = urlOverride; - const client = new EngineClient({ - baseUrl: baseUrl ?? resolved?.baseUrl, - apiKey: resolved?.apiKey ?? null, - }); - - // Determine path: gateway (with API key) or engine fallback - const useGateway = !!resolved?.apiKey; - - let scaffoldPromise: Promise; - - if (useGateway) { - // Gateway path — produces deployment-ready output (wrangler.toml, .ai/, tests) - scaffoldPromise = client.scaffold({ - description, - project_type: args.includes('--cloudflare-only') ? 'worker' : undefined, - complexity: undefined, - seed: seedStr ? parseInt(seedStr, 10) : undefined, - }); - } else { - // Engine fallback — basic scaffold - const request: BuildRequest = { description, constraints: {} }; - if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; - if (fwOverride) request.constraints!.framework = fwOverride; - if (dbOverride) request.constraints!.database = dbOverride; - if (seedStr) request.seed = parseInt(seedStr, 10); - - scaffoldPromise = client.build(request).then(r => ({ - files: Object.entries(r.scaffold).map(([p, content]) => ({ path: p, content, role: 'scaffold' as const })), - fileSource: 'engine' as const, - nextSteps: ['npm install', 'npm run dev'], - seed: r.seed, - receipt: r.receipt, - })); - } - - // JSON mode — no animation - if (options.format === 'json') { - const result = await scaffoldPromise; - console.log(JSON.stringify({ ...result, outputDir: resolvedOutput, dryRun }, null, 2)); - if (!dryRun) { - writeFiles(resolvedOutput, result.files); - } - return EXIT_CODE.SUCCESS; - } - - // Interactive mode — animated output - const isTTY = process.stdout.isTTY === true; - - console.log(''); - if (!useGateway) { - console.log(' \x1b[2m(tip: run `charter login --key sb_live_xxx` for deployment-ready scaffolds)\x1b[0m'); - console.log(''); - } - - if (isTTY) { - let spinIdx = 0; - - for (const label of PHASE_LABELS) { - console.log(`\x1b[2m ${SPINNER[0]} ${label.padEnd(12)} working...\x1b[0m`); - } - - let done = false; - let result!: ScaffoldResult; - - scaffoldPromise.then(r => { result = r; done = true; }).catch(() => { done = true; }); - - while (!done) { - spinIdx = (spinIdx + 1) % SPINNER.length; - cursorUp(PHASE_LABELS.length); - for (const label of PHASE_LABELS) { - clearLine(); - process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${label.padEnd(12)} working...\x1b[0m\n`); - } - await delay(80); - } - - result = await scaffoldPromise; - - cursorUp(PHASE_LABELS.length); - for (const label of PHASE_LABELS) { - clearLine(); - const detail = phaseDetail(label, result); - process.stdout.write(` \x1b[32m❩\x1b[0m ${label.padEnd(12)} ${detail.padEnd(36)} \x1b[32m✓\x1b[0m\n`); - await delay(120); - } - } else { - const result = await scaffoldPromise; - for (const label of PHASE_LABELS) { - console.log(` ❩ ${label.padEnd(12)} ${phaseDetail(label, result).padEnd(36)} ✓`); - } - } - - const result = await scaffoldPromise; - - console.log(''); - if (dryRun) { - console.log(` → ${result.files.length} files would be scaffolded to ${resolvedOutput}/`); - for (const f of result.files) { - console.log(` ${f.path}`); - } - console.log(''); - console.log(' (dry run — no files written)'); - } else { - writeFiles(resolvedOutput, result.files); - console.log(` → ${result.files.length} files scaffolded to ${resolvedOutput}/`); - console.log(` → Architecture governed · seed: ${result.seed ?? 'deterministic'}`); - if (result.nextSteps && result.nextSteps.length > 0) { - console.log(''); - console.log(' Next steps:'); - for (const step of result.nextSteps) { - console.log(` ${step}`); - } - } - } - - console.log(''); - return EXIT_CODE.SUCCESS; -} - -function writeFiles(outputDir: string, files: Array<{ path: string; content: string }>): void { - for (const { path: name, content } of files) { - const target = path.join(outputDir, name); - const dir = path.dirname(target); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(target, content); - } -} diff --git a/packages/cli/src/commands/scaffold.ts b/packages/cli/src/commands/scaffold.ts deleted file mode 100644 index d6c8184..0000000 --- a/packages/cli/src/commands/scaffold.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * charter scaffold — write scaffold files from the last build. - * - * Usage: - * charter scaffold Write to current directory - * charter scaffold --output ./my-app Write to specific directory - * charter scaffold --dry-run Show what would be created - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { CLIOptions } from '../index'; -import { EXIT_CODE, CLIError } from '../index'; -import { getFlag } from '../flags'; -import type { BuildResult } from '../http-client'; -import { printBuildCommandDeprecationWarning } from './deprecation-warning'; - -export async function scaffoldCommand(options: CLIOptions, args: string[]): Promise { - printBuildCommandDeprecationWarning('scaffold', args); - - const configPath = options.configPath || '.charter'; - const cachePath = path.join(configPath, 'last-build.json'); - - if (!fs.existsSync(cachePath)) { - throw new CLIError('No cached build found. Run `charter architect "..."` first.'); - } - - let result: BuildResult; - try { - result = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); - } catch { - throw new CLIError('Could not parse cached build. Run `charter architect "..."` again.'); - } - - if (!result.scaffold || Object.keys(result.scaffold).length === 0) { - throw new CLIError('Cached build has no scaffold files.'); - } - - const outputDir = getFlag(args, '--output') ?? '.'; - const dryRun = args.includes('--dry-run'); - - const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b)); - - if (options.format === 'json') { - const manifest = files.map(([name, content]) => ({ - path: path.join(outputDir, name), - lines: content.split('\n').length, - })); - console.log(JSON.stringify({ outputDir, dryRun, files: manifest }, null, 2)); - if (!dryRun) writeFiles(outputDir, files); - return EXIT_CODE.SUCCESS; - } - - console.log(''); - console.log(` Scaffold from build (seed: ${result.seed})`); - console.log(` Stack: ${result.stack.map(s => s.name).join(' + ')}`); - console.log(` Output: ${path.resolve(outputDir)}`); - console.log(''); - - for (const [name, content] of files) { - const lines = content.split('\n').length; - const target = path.join(outputDir, name); - const exists = fs.existsSync(target); - const marker = exists ? ' (exists, will overwrite)' : ''; - console.log(` ${name} (${lines} lines)${marker}`); - } - - if (dryRun) { - console.log(''); - console.log(' (dry run — no files written)'); - return EXIT_CODE.SUCCESS; - } - - writeFiles(outputDir, files); - - console.log(''); - console.log(` ${files.length} files written.`); - return EXIT_CODE.SUCCESS; -} - -function writeFiles(outputDir: string, files: [string, string][]): void { - for (const [name, content] of files) { - const target = path.join(outputDir, name); - const dir = path.dirname(target); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(target, content); - } -} diff --git a/packages/cli/src/credentials.ts b/packages/cli/src/credentials.ts deleted file mode 100644 index 0f3fcbb..0000000 --- a/packages/cli/src/credentials.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Credential storage for Stackbilt API key. - * - * Two auth sources are supported: - * 1. STACKBILT_API_KEY environment variable (preferred; no on-disk state). - * 2. ~/.charter/credentials.json (mode 0o600; populated by `charter login`). - * - * `charter login` will be removed in 1.0 — on-disk credential storage moves - * out of this OSS package. New integrations should use the env var. - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -export interface Credentials { - apiKey: string; - baseUrl?: string; -} - -const CRED_DIR = path.join(os.homedir(), '.charter'); -const CRED_FILE = path.join(CRED_DIR, 'credentials.json'); -const API_KEY_ENV_VAR = 'STACKBILT_API_KEY'; -const API_BASE_URL_ENV_VAR = 'STACKBILT_API_BASE_URL'; - -export function loadCredentials(): Credentials | null { - if (!fs.existsSync(CRED_FILE)) return null; - try { - const raw = fs.readFileSync(CRED_FILE, 'utf-8'); - const parsed = JSON.parse(raw); - if (!parsed.apiKey || typeof parsed.apiKey !== 'string') return null; - return parsed as Credentials; - } catch { - return null; - } -} - -export function saveCredentials(creds: Credentials): void { - if (!fs.existsSync(CRED_DIR)) { - fs.mkdirSync(CRED_DIR, { recursive: true }); - } - fs.writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); -} - -export function clearCredentials(): void { - if (fs.existsSync(CRED_FILE)) { - fs.unlinkSync(CRED_FILE); - } -} - -export interface ResolvedApiKey { - apiKey: string; - source: 'env' | 'credentials'; - baseUrl?: string; -} - -/** - * Resolve the Stackbilt API key from env var (preferred) or stored credentials. - * Returns null when neither source has a key. - * - * When the env path is taken, an optional STACKBILT_API_BASE_URL env var may - * carry a custom engine base URL — keeping parity with `charter login --url`. - */ -export function resolveApiKey(): ResolvedApiKey | null { - const fromEnv = process.env[API_KEY_ENV_VAR]; - if (fromEnv && fromEnv.trim().length > 0) { - const baseUrlFromEnv = process.env[API_BASE_URL_ENV_VAR]?.trim(); - return { - apiKey: fromEnv.trim(), - source: 'env', - baseUrl: baseUrlFromEnv && baseUrlFromEnv.length > 0 ? baseUrlFromEnv : undefined, - }; - } - const stored = loadCredentials(); - if (stored) { - return { apiKey: stored.apiKey, source: 'credentials', baseUrl: stored.baseUrl }; - } - return null; -} - -export { API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR }; diff --git a/packages/cli/src/http-client.ts b/packages/cli/src/http-client.ts deleted file mode 100644 index 5598181..0000000 --- a/packages/cli/src/http-client.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * HTTP client for the Stackbilt Engine API. - * - * Uses native fetch() (Node 18+). Zero external dependencies. - */ - -import type { - ScaffoldFileType, - GovernanceDocsType, - PromptContextType, -} from './types/scaffold-contract-types'; - -const DEFAULT_BASE_URL = process.env.STACKBILT_ENGINE_URL ?? 'https://api.stackbilt.dev/engine'; -const GATEWAY_BASE_URL = 'https://mcp.stackbilt.dev'; - -export interface BuildRequest { - description: string; - constraints?: { - cloudflareOnly?: boolean; - framework?: string; - database?: string; - needsAuth?: boolean; - needsRealtime?: boolean; - needsQueue?: boolean; - needsStorage?: boolean; - }; - seed?: number; - tier?: 'blessed' | 'all'; -} - -export interface DrawnTech { - id: number; - name: string; - category: string; - element: string; - maturity: string; - tier: string; - cloudflareNative: boolean; - traits: string[]; - keywords: { upright: string[]; reversed: string[] }; - orientation: 'upright' | 'reversed'; - position: string; -} - -export interface CompatPair { - positions: [string, string]; - techs: [string, string]; - elements: [string, string]; - relationship: string; - score: number; - description: string; -} - -export interface BuildResult { - stack: DrawnTech[]; - compatibility: { - pairs: CompatPair[]; - totalScore: number; - normalizedScore: number; - dominant: string; - tensions: { elements: [string, string]; description: string }[]; - }; - scaffold: Record; - seed: number; - receipt: string; - requirements: { - description: string; - keywords: string[]; - constraints: Record; - complexity: string; - }; -} - -/** Canonical scaffold file shape — vendored from @stackbilt/contracts (see ./types/scaffold-contract-types). */ -export type ScaffoldFile = ScaffoldFileType; - -/** - * CLI-specific superset of MaterializerResult. - * - * Includes the contract's shape (files with role, nextSteps, promptContext, - * governance) plus CLI-only fields (fileSource, seed, receipt, facts). - * promptContext and governance are optional because the engine fallback - * path does not produce them. - */ -export interface ScaffoldResult { - files: ScaffoldFile[]; - fileSource: 'engine' | 'basic' | 'none'; - nextSteps: string[]; - seed?: number; - receipt?: string; - facts?: Record; - promptContext?: PromptContextType; - governance?: GovernanceDocsType; -} - -export interface HealthResponse { - status: string; - version: string; - engine: string; - catalog: number; - positions: string[]; -} - -export class EngineClient { - private baseUrl: string; - private apiKey: string | null; - - constructor(options: { baseUrl?: string; apiKey?: string | null }) { - this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); - this.apiKey = options.apiKey ?? null; - } - - async health(): Promise { - const res = await fetch(`${this.baseUrl}/health`); - if (!res.ok) throw new Error(`Engine health check failed: ${res.status}`); - return res.json() as Promise; - } - - async build(request: BuildRequest): Promise { - const headers: Record = { 'Content-Type': 'application/json' }; - if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`; - - const res = await fetch(`${this.baseUrl}/build`, { - method: 'POST', - headers, - body: JSON.stringify(request), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`Engine build failed (${res.status}): ${text}`); - } - - return res.json() as Promise; - } - - async scaffold(request: { description: string; project_type?: string; complexity?: string; seed?: number }): Promise { - if (!this.apiKey) { - throw new Error( - 'API key required for scaffold. Set STACKBILT_API_KEY in the environment, ' + - 'or (deprecated) run `charter login --key sb_live_xxx`.', - ); - } - - const res = await fetch(`${GATEWAY_BASE_URL}/api/scaffold`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(request), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`Scaffold failed (${res.status}): ${text}`); - } - - return res.json() as Promise; - } - - async catalog(category?: string): Promise<{ primitives: DrawnTech[]; total: number }> { - const url = new URL(`${this.baseUrl}/catalog`); - if (category) url.searchParams.set('category', category); - - const res = await fetch(url.toString()); - if (!res.ok) throw new Error(`Engine catalog failed: ${res.status}`); - return res.json() as Promise<{ primitives: DrawnTech[]; total: number }>; - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bf2e908..d484b2d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,10 +18,6 @@ import { adfCommand } from './commands/adf'; import { serveCommand } from './commands/serve'; import { bootstrapCommand } from './commands/bootstrap'; import { telemetryCommand } from './commands/telemetry'; -import { loginCommand } from './commands/login'; -import { architectCommand } from './commands/architect'; -import { scaffoldCommand } from './commands/scaffold'; -import { runCommand } from './commands/run'; import { scoreCommand } from './commands/score'; import { blastCommand } from './commands/blast'; import { surfaceCommand } from './commands/surface'; @@ -72,14 +68,6 @@ Usage: charter adf ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate, metrics) charter serve [--name ] [--ai-dir ] Expose ADF project context as an MCP server (stdio, for Claude Code/Codex/Cursor) - charter login --key Store Stackbilt API key - charter login --logout Clear stored credentials - charter architect Generate tech stack from project description - charter architect --file Generate tech stack from spec file - charter scaffold [--output ] [--dry-run] - Write scaffold files from last build - charter run Architect + scaffold in one step (animated) - charter run --file Same, from spec file charter score [--ai-dir ] AI-readiness audit for the current repo charter blast [ ...] [--root ] [--depth ] Compute blast radius: which files transitively depend on the seeds @@ -101,8 +89,6 @@ Options: --detect-only Setup only: print detected stack/preset and exit --no-dependency-sync Setup only: do not rewrite devDependencies["@stackbilt/cli"] - --no-deprecation-warning - Suppress deprecation warnings for login/architect/scaffold/run `; export const EXIT_CODE = { @@ -229,18 +215,6 @@ export async function run(args: string[]): Promise { case 'telemetry': exitCode = await telemetryCommand(options, restArgs); break; - case 'login': - exitCode = await loginCommand(options, restArgs); - break; - case 'architect': - exitCode = await architectCommand(options, restArgs); - break; - case 'scaffold': - exitCode = await scaffoldCommand(options, restArgs); - break; - case 'run': - exitCode = await runCommand(options, restArgs); - break; case 'score': exitCode = await scoreCommand(options, restArgs); break; diff --git a/packages/cli/src/types/scaffold-contract-types.ts b/packages/cli/src/types/scaffold-contract-types.ts deleted file mode 100644 index 634ea8b..0000000 --- a/packages/cli/src/types/scaffold-contract-types.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Vendored type definitions for the Stackbilt scaffold-response contract. - * - * These types are structurally copied from @stackbilt/contracts to avoid - * a file: workspace dependency on an unpublished sibling repo. They are - * type-only (erased at compile time, zero runtime cost) and are used - * internally by http-client.ts — they are NOT re-exported from the public - * CLI surface in src/index.ts. - * - * When @stackbilt/contracts is properly published to npm, this file can be - * deleted and http-client.ts can re-import from the real package. - * - * Upstream source (as of @stackbilt/contracts@0.1.0): - * contracts/dist/scaffold-response/scaffold-response.contract.d.ts - * - * Type aliases follow the upstream naming convention with the `Type` suffix - * (e.g. `ScaffoldFile` → `ScaffoldFileType`), matching the re-exports in - * contracts/dist/scaffold-response/index.d.ts. - */ - -export type FileRoleType = 'config' | 'scaffold' | 'governance' | 'test' | 'doc'; - -export interface ScaffoldFileType { - path: string; - content: string; - role: FileRoleType; -} - -export interface GovernanceDocsType { - threat_model: string; - adr: string; - test_plan: string; -} - -export interface PromptContextMetaType { - project_type: string; - complexity: string; - confidence: string; - seed: number; -} - -export interface PromptContextRequirementType { - name: string; - priority: string; - effort: string; - acceptance: string; -} - -export interface PromptContextInterfaceType { - name: string; - layout: string; - components: string; -} - -export interface PromptContextThreatType { - name: string; - owasp: string; - likelihood: string; - impact: string; - mitigation: string; - detection: string; - response_time: string; -} - -export interface PromptContextRuntimeType { - name: string; - tier: string; - traits: string; -} - -export interface PromptContextTestPlanType { - name: string; - framework: string; - ci_stage: string; - coverage: string; - setup: string; - assertion_style: string; -} - -export interface PromptContextFirstTaskType { - name: string; - estimate: string; - complexity: string; - deliverable: string; - adr: string; -} - -export interface PromptContextType { - intention: string; - pattern: string; - meta: PromptContextMetaType; - requirement: PromptContextRequirementType; - interface: PromptContextInterfaceType; - threat: PromptContextThreatType; - runtime: PromptContextRuntimeType; - test_plan: PromptContextTestPlanType; - first_task: PromptContextFirstTaskType; - governance: GovernanceDocsType; - files: ScaffoldFileType[]; -} From ee1eab9332d3374551f237488df54e4cfcc5850d Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 09:38:11 -0500 Subject: [PATCH 6/6] fix(cli): add repo-intel to context-refresh help; ignore package-lock.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HELP text for `context-refresh --sources` was missing the third valid value `repo-intel` (added in the repo-intel source commit on this branch) - Add `package-lock.json` to .gitignore — repo uses pnpm; the npm lockfile has no business being tracked here Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 33 ++++++++++++++++++--------------- packages/cli/src/index.ts | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 15e573b..67443a1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,23 +12,26 @@ coverage/ plans/ governance/ +# npm lockfile — this repo uses pnpm (pnpm-lock.yaml); ignore npm-generated lockfile +package-lock.json + # local telemetry artifacts .charter/telemetry/ # scenario harness results harness/results/ -# cc-taskrunner worktree protection -C:* -node_modules/ -.pnpm-store/ -__pycache__/ -# cc-taskrunner worktree protection -C:* -node_modules/ -.pnpm-store/ -__pycache__/ -# cc-taskrunner worktree protection -C:* -node_modules/ -.pnpm-store/ -__pycache__/ +# cc-taskrunner worktree protection +C:* +node_modules/ +.pnpm-store/ +__pycache__/ +# cc-taskrunner worktree protection +C:* +node_modules/ +.pnpm-store/ +__pycache__/ +# cc-taskrunner worktree protection +C:* +node_modules/ +.pnpm-store/ +__pycache__/ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d484b2d..861d212 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -41,7 +41,7 @@ Usage: --security-sensitive adds SECURITY.md, hard security drift denies, and a security test check charter context [--stdout-only] [--verbose] [--write] Pre-digested repo brief for AI agents (routes, hotspots, governance) - charter context-refresh [--sources git,github] [--output CONTEXT.md] [--ai-dir ] [--once] [--ttl-minutes ] [--force] + charter context-refresh [--sources git,github,repo-intel] [--output CONTEXT.md] [--ai-dir ] [--once] [--ttl-minutes ] [--force] Live session snapshot to .ai/context.adf + .ai/context.snapshot.json charter setup [--ci github] [--preset ] [--detect-only] [--no-dependency-sync] Bootstrap .charter/ and optional CI workflow