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
13 changes: 13 additions & 0 deletions .changeset/open-loop-cli-preset-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@gemstack/framework': minor
---

CLI: `--preset <name>` runs a build under an Open Loop domain preset, with
`--autopilot` / `--technical` mode flags (#256).

`--preset` resolves a shipped domain preset by name (via `builtinDomainPresets` +
`selectPreset`) and hands it to `runFramework`, so its loops, prompts, and skills
frame the build. `--autopilot` / `--technical` activate the preset's `conditions`
variants (applied at load time and narrated). An unknown preset name is a usage
error that lists the available presets; the mode flags note when given without a
preset. Additive: a run with no `--preset` is unchanged.
49 changes: 49 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import {
activeModes,
buildDeployTarget,
chooseSessionLink,
claudeDriverOptions,
CLAUDE_CODE_SESSION_LIST,
parseArgs,
resolveDomainPreset,
runCli,
type CliIO,
} from './cli.js'
Expand Down Expand Up @@ -58,6 +60,53 @@ test('parseArgs persists by default and reads --resume / --no-persist (#211)', (
assert.equal(opts.persist, false)
})

test('parseArgs reads --preset and the mode flags (#256)', () => {
const opts = parseArgs(['--preset', 'software-development', '--autopilot', '--technical', 'x'])
assert.equal(opts.preset, 'software-development')
assert.equal(opts.autopilot, true)
assert.equal(opts.technical, true)
const dflt = parseArgs(['x'])
assert.equal(dflt.preset, undefined)
assert.equal(dflt.autopilot, false)
assert.equal(dflt.technical, false)
})

test('activeModes maps the mode flags to Open Loop mode names', () => {
assert.deepEqual(activeModes({ autopilot: false, technical: false }), [])
assert.deepEqual(activeModes({ autopilot: true, technical: false }), ['autopilot'])
assert.deepEqual(activeModes({ autopilot: true, technical: true }), ['autopilot', 'technical'])
})

test('resolveDomainPreset resolves a shipped preset by name (#254/#256)', async () => {
const none = await resolveDomainPreset(undefined, [])
assert.deepEqual(none, {})

const { preset, error } = await resolveDomainPreset('software-development', [])
assert.equal(error, undefined)
assert.equal(preset?.name, 'software-development')
assert.ok((preset?.loops.length ?? 0) >= 1)

const bad = await resolveDomainPreset('no-such-domain', [])
assert.equal(bad.preset, undefined)
assert.match(bad.error!, /unknown --preset: no-such-domain/)
assert.match(bad.error!, /software-development/) // lists what's available
})

test('runCli rejects an unknown --preset with a usage error (exit 2)', async () => {
const { io, err } = capture()
const code = await runCli(['--preset', 'nope', 'a blog'], io)
assert.equal(code, 2)
assert.ok(err.some(l => /unknown --preset: nope/.test(l)))
})

test('runCli notes mode flags given without a preset', async () => {
const { io, err } = capture()
// --fake so the note fires before any real run; unknown-preset path is not hit.
const code = await runCli(['--fake', '--no-dashboard', '--autopilot'], io)
assert.equal(code, 0)
assert.ok(err.some(l => /have no effect without --preset/.test(l)))
})

test('chooseSessionLink defaults a live run to the claude.ai/code session list (#212)', () => {
assert.equal(chooseSessionLink({ sessionLink: undefined }, false), CLAUDE_CODE_SESSION_LIST)
assert.equal(CLAUDE_CODE_SESSION_LIST, 'https://claude.ai/code')
Expand Down
72 changes: 71 additions & 1 deletion packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { cloudflareTarget, dokployTarget, nodeLedgerFs, saveLedger, type DeployTarget } from '@gemstack/ai-autopilot'
import {
builtinDomainPresets,
cloudflareTarget,
dokployTarget,
nodeLedgerFs,
saveLedger,
selectPreset,
type DeployTarget,
type DomainPreset,
} from '@gemstack/ai-autopilot'
import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type PermissionMode } from './driver/index.js'
import { hostExecutor } from './host-exec.js'
import { startDashboard, type Dashboard } from './dashboard/index.js'
Expand Down Expand Up @@ -61,6 +70,10 @@ Options:
--cwd <dir> Workspace the agent builds in (default: current directory).
--model <id> Model to pass through to the wrapped agent.
--scope <prototype|full> How much app to build (default: full).
--preset <name> Run under an Open Loop domain preset (its loops + prompts
+ skills frame the build), e.g. software-development.
--autopilot Activate the preset's Autopilot mode variants.
--technical Activate the preset's Technical mode variants.
--compose-extensions Opt the built-in capability extensions in (auth, data,
rbac, crud, shell) so the agent composes them instead of
hand-rolling. Vike-only; installed framework-* extensions
Expand Down Expand Up @@ -111,6 +124,9 @@ export interface CliOptions {
cwd?: string | undefined
model?: string | undefined
scope: 'prototype' | 'full'
preset?: string | undefined
autopilot: boolean
technical: boolean
maxPasses?: number
deploy?: string | undefined
cfProject?: string | undefined
Expand Down Expand Up @@ -142,6 +158,8 @@ export function parseArgs(argv: string[]): CliOptions {
skipPreflight: false,
intent: '',
scope: 'full',
autopilot: false,
technical: false,
dashboard: true,
composeExtensions: false,
skipPermissions: false,
Expand Down Expand Up @@ -170,6 +188,15 @@ export function parseArgs(argv: string[]): CliOptions {
case '--compose-extensions':
opts.composeExtensions = true
break
case '--preset':
opts.preset = argv[++i]
break
case '--autopilot':
opts.autopilot = true
break
case '--technical':
opts.technical = true
break
case '--resume':
opts.resume = true
break
Expand Down Expand Up @@ -277,6 +304,34 @@ export function claudeDriverOptions(opts: Pick<CliOptions, 'permissionMode' | 's
: { permissionMode: opts.permissionMode ?? 'bypassPermissions' }
}

/** The active Open Loop modes from the mode flags, in a stable order. */
export function activeModes(opts: Pick<CliOptions, 'autopilot' | 'technical'>): string[] {
const modes: string[] = []
if (opts.autopilot) modes.push('autopilot')
if (opts.technical) modes.push('technical')
return modes
}

/**
* Resolve `--preset <name>` to a shipped {@link DomainPreset}, loaded with the
* active `modes` so its conditions variants are selected (#254, #256). Returns
* nothing when no `--preset` was given; an error (with the available names) when
* the name does not match a built-in.
*/
export async function resolveDomainPreset(
name: string | undefined,
modes: readonly string[],
): Promise<{ preset?: DomainPreset; error?: string }> {
if (!name) return {}
const presets = await builtinDomainPresets({ modes })
const preset = selectPreset(presets, name)
if (!preset) {
const available = presets.map(p => p.name).join(', ') || '(none shipped)'
return { error: `unknown --preset: ${name}. Available: ${available}` }
}
return { preset }
}

/**
* The `framework` command. Wires the parsed options into {@link runFramework}
* over a live dashboard + terminal narration, and resolves with an exit code.
Expand Down Expand Up @@ -316,6 +371,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return 2
}

// Resolve an Open Loop domain preset by name (#256), loaded with the active mode
// variants. A bad name is a usage error before we do any work. The mode flags
// only act on a preset, so note when they are given without one.
const modes = activeModes(opts)
const { preset: domainPreset, error: presetError } = await resolveDomainPreset(opts.preset, modes)
if (presetError) {
io.err(presetError)
io.err('Run `framework --help` for usage.')
return 2
}
if (modes.length && !domainPreset) {
io.err(`note: ${modes.join(' + ')} mode(s) have no effect without --preset.`)
}

// Fail early and clearly if a live run's prerequisites are missing.
if (!fake && !opts.skipPreflight) {
const pre = await preflight()
Expand Down Expand Up @@ -428,6 +497,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(serve ? { serve } : {}),
...(discovered ? { extensions: discovered } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(domainPreset ? { preset: domainPreset, ...(modes.length ? { modes } : {}) } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
return link ? { sessionLink: link } : {}
Expand Down
Loading