Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/framework-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@gemstack/framework": minor
---

feat: preflight checks + `framework doctor`

A live run now checks its prerequisites first and fails early with a clear fix
("`claude` not found - install Claude Code ...") instead of a cryptic mid-run
spawn error. Adds a `framework doctor` command that reports the checks, and a
`--skip-preflight` escape hatch. `--fake` never runs preflight (it needs no CLI).
22 changes: 22 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ test('runCli errors when --deploy dokploy lacks its config', async () => {
assert.equal(code, 2)
})

test('parseArgs reads the doctor subcommand, not as intent', () => {
const opts = parseArgs(['doctor'])
assert.equal(opts.doctor, true)
assert.equal(opts.intent, '')
})

test('runCli doctor reports checks and exits by their outcome', async () => {
const { io, out } = capture()
const code = await runCli(['doctor'], io)
const text = out.join('\n')
assert.match(text, /node:/)
assert.match(text, /claude-code:/)
assert.ok(code === 0 || code === 1) // depends on whether claude is installed here
})

test('runCli --fake skips preflight (offline never needs the agent CLI)', async () => {
const { io } = capture()
// No claude probe is invoked for --fake; this must succeed regardless of env.
const code = await runCli(['--fake', '--no-dashboard'], io)
assert.equal(code, 0)
})

test('runCli --fake --no-dashboard runs the whole flow offline to production-grade', async () => {
const { io, out } = capture()
const code = await runCli(['--fake', '--no-dashboard'], io)
Expand Down
31 changes: 31 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { startDashboard, type Dashboard } from './dashboard/index.js'
import { formatFrameworkEvent, type FrameworkEvent } from './events.js'
import { runFramework, type DeployDecision, type RunFrameworkOptions, type ServeConfig } from './run.js'
import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js'
import { preflight } from './preflight.js'

/** Where the CLI writes. Injectable so tests capture output. */
export interface CliIO {
Expand All @@ -26,6 +27,7 @@ const HELP = `The Framework — turnkey AI orchestration that wraps a coding age
Usage:
framework [intent...] Build what you describe, from scratch.
framework --fake Run the offline demo (no CLI, no model, deterministic).
framework doctor Check prerequisites (Claude Code installed, etc.).

Options:
--fake Use the fake driver + scripted run (offline / CI).
Expand All @@ -47,6 +49,7 @@ Options:
--dokploy-app <id> Dokploy application id (required for --deploy dokploy).
--port <n> Dashboard port (default: 4477).
--no-dashboard Do not start the localhost dashboard.
--skip-preflight Skip the prerequisite checks before a live run.
--session-link <url> Link to the live agent session (shown on the dashboard).
-h, --help Show this help.
-v, --version Print the version.
Expand All @@ -61,6 +64,8 @@ export interface CliOptions {
help: boolean
version: boolean
fake: boolean
doctor: boolean
skipPreflight: boolean
intent: string
cwd?: string | undefined
model?: string | undefined
Expand Down Expand Up @@ -89,6 +94,8 @@ export function parseArgs(argv: string[]): CliOptions {
help: false,
version: false,
fake: false,
doctor: false,
skipPreflight: false,
intent: '',
scope: 'full',
dashboard: true,
Expand All @@ -113,6 +120,9 @@ export function parseArgs(argv: string[]): CliOptions {
case '--no-dashboard':
opts.dashboard = false
break
case '--skip-preflight':
opts.skipPreflight = true
break
case '--dangerously-skip-permissions':
opts.skipPermissions = true
break
Expand Down Expand Up @@ -187,6 +197,11 @@ export function parseArgs(argv: string[]): CliOptions {
else words.push(arg)
}
}
// `framework doctor` is a subcommand, not an intent.
if (words[0] === 'doctor') {
opts.doctor = true
words.shift()
}
opts.intent = words.join(' ').trim()
return opts
}
Expand All @@ -211,6 +226,12 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
io.out(VERSION)
return 0
}
if (opts.doctor) {
const result = await preflight()
for (const check of result.checks) io.out(`${check.ok ? '✓' : '✗'} ${check.name}: ${check.detail}`)
io.out(result.ok ? '\nAll good. You are ready to build.' : '\nSome checks failed. Fix them, then try again.')
return result.ok ? 0 : 1
}

const fake = opts.fake
const intent = opts.intent || (fake ? FAKE_INTENT : '')
Expand All @@ -219,6 +240,16 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return 2
}

// Fail early and clearly if a live run's prerequisites are missing.
if (!fake && !opts.skipPreflight) {
const pre = await preflight()
if (!pre.ok) {
for (const check of pre.checks.filter(c => !c.ok)) io.err(`✗ ${check.name}: ${check.detail}`)
io.err('Preflight failed. Fix the above, or pass --skip-preflight, or try `framework --fake`.')
return 2
}
}

const claudeOpts: ClaudeCodeDriverOptions = {
...(opts.permissionMode ? { permissionMode: opts.permissionMode } : {}),
...(opts.skipPermissions ? { dangerouslySkipPermissions: true } : {}),
Expand Down
7 changes: 7 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export { hostExecutor, type HostExecutorOptions } from './host-exec.js'
export { type FrameworkEvent, formatFrameworkEvent } from './events.js'
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js'
export {
preflight,
type PreflightResult,
type PreflightCheck,
type PreflightOptions,
type VersionProbe,
} from './preflight.js'
export {
fakeDriver,
FAKE_INTENT,
Expand Down
27 changes: 27 additions & 0 deletions packages/framework/src/preflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { preflight } from './preflight.js'

test('preflight passes when the agent CLI is present', async () => {
const result = await preflight({ probe: () => Promise.resolve({ ok: true, stdout: '1.2.3 (Claude Code)' }) })
assert.equal(result.ok, true)
const cc = result.checks.find(c => c.name === 'claude-code')
assert.equal(cc?.ok, true)
assert.match(cc!.detail, /1\.2\.3/)
})

test('preflight fails with install guidance when the agent CLI is missing', async () => {
const result = await preflight({ bin: 'claude', probe: () => Promise.resolve({ ok: false, stdout: '' }) })
assert.equal(result.ok, false)
const cc = result.checks.find(c => c.name === 'claude-code')
assert.equal(cc?.ok, false)
assert.match(cc!.detail, /not found/)
assert.match(cc!.detail, /claude\.com\/claude-code/)
})

test('preflight always reports the node version', async () => {
const result = await preflight({ probe: () => Promise.resolve({ ok: true, stdout: 'x' }) })
const node = result.checks.find(c => c.name === 'node')
assert.equal(node?.ok, true)
assert.equal(node?.detail, process.version)
})
62 changes: 62 additions & 0 deletions packages/framework/src/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { execFile } from 'node:child_process'

/**
* Preflight checks for a live run. A turnkey tool should fail *early and
* clearly* when a prerequisite is missing, not spawn a broken process mid-run.
* The main one: is the wrapped agent's CLI (Claude Code) actually installed and
* runnable? `--fake` needs none of this, so preflight only gates live runs.
*/

/** One preflight check's outcome. */
export interface PreflightCheck {
name: string
ok: boolean
/** Human-readable detail: the version when ok, or how to fix it when not. */
detail: string
}

/** The result of running all preflight checks. */
export interface PreflightResult {
ok: boolean
checks: PreflightCheck[]
}

/** Probe a binary for a version string. Injectable so tests need no real CLI. */
export type VersionProbe = (bin: string) => Promise<{ ok: boolean; stdout: string }>

const CLAUDE_INSTALL_HINT = 'install Claude Code and make sure `claude` is on your PATH: https://claude.com/claude-code'

function defaultProbe(bin: string): Promise<{ ok: boolean; stdout: string }> {
return new Promise(resolvePromise => {
execFile(bin, ['--version'], { timeout: 10_000 }, (err, stdout) => {
resolvePromise({ ok: !err, stdout: String(stdout) })
})
})
}

/** Options for {@link preflight}. */
export interface PreflightOptions {
/** The agent CLI binary to probe. Default `"claude"`. */
bin?: string
/** Version probe override (tests). Default runs `<bin> --version`. */
probe?: VersionProbe
}

/**
* Run the preflight checks: Node is implicit (we are running), and the wrapped
* agent CLI must be installed. Returns every check plus an overall `ok`.
*/
export async function preflight(opts: PreflightOptions = {}): Promise<PreflightResult> {
const bin = opts.bin ?? 'claude'
const probe = opts.probe ?? defaultProbe
const checks: PreflightCheck[] = [{ name: 'node', ok: true, detail: process.version }]

const cc = await probe(bin)
checks.push(
cc.ok
? { name: 'claude-code', ok: true, detail: cc.stdout.trim() || 'installed' }
: { name: 'claude-code', ok: false, detail: `\`${bin}\` not found — ${CLAUDE_INSTALL_HINT}` },
)

return { ok: checks.every(c => c.ok), checks }
}
Loading