diff --git a/.changeset/open-loop-cli-preset-flags.md b/.changeset/open-loop-cli-preset-flags.md new file mode 100644 index 0000000..526ac26 --- /dev/null +++ b/.changeset/open-loop-cli-preset-flags.md @@ -0,0 +1,13 @@ +--- +'@gemstack/framework': minor +--- + +CLI: `--preset ` 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. diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 5b2c7fc..95031b8 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -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' @@ -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') diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 35832e6..791d7dc 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -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' @@ -61,6 +70,10 @@ Options: --cwd Workspace the agent builds in (default: current directory). --model Model to pass through to the wrapped agent. --scope How much app to build (default: full). + --preset 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 @@ -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 @@ -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, @@ -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 @@ -277,6 +304,34 @@ export function claudeDriverOptions(opts: Pick): string[] { + const modes: string[] = [] + if (opts.autopilot) modes.push('autopilot') + if (opts.technical) modes.push('technical') + return modes +} + +/** + * Resolve `--preset ` 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. @@ -316,6 +371,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { const link = chooseSessionLink(opts, fake) return link ? { sessionLink: link } : {}