diff --git a/bun.lock b/bun.lock index 0ddc58b..d9779e3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.16.0", + "@ellipsis-dev/sdk": "/tmp/ellipsis-dev-sdk-0.17.0.tgz", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -35,7 +35,7 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.16.0", "", {}, "sha512-brj9VpVtKrfyjvCJegyaEvOjMcW5Czul4s4q99U20hE7/Bver+WbId0yM8PC8G4n3ZS6SgJPAHBR3u8+jIoegA=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@/tmp/ellipsis-dev-sdk-0.17.0.tgz", {}, "sha512-6x19WBjT+vLpAR7qzuXetkoYlLMniQp47wGMxHvDY8jaGBrmqw2UbViPUk+vQoR1ASmNO0YgMy9q4YLshtp1Ug=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 92fa6eb..fba0063 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.16.0", + "@ellipsis-dev/sdk": "^0.17.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/cli.tsx b/src/cli.tsx index 6eaf1f0..ec9d619 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -6,6 +6,7 @@ import { registerMe } from './commands/me' import { registerSession } from './commands/session' import { registerReview } from './commands/review' import { registerConfig } from './commands/config' +import { registerEnvironment } from './commands/environment' import { registerVariable } from './commands/variable' import { registerFile } from './commands/file' import { registerTemplate } from './commands/template' @@ -42,6 +43,7 @@ registerMe(program) registerSession(program) registerReview(program) registerConfig(program) +registerEnvironment(program) registerVariable(program) registerFile(program) registerTemplate(program) diff --git a/src/commands/config.ts b/src/commands/config.ts index 11c3b35..4d871aa 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,4 +1,5 @@ import { type Command } from 'commander' +import { parse as parseYaml } from 'yaml' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { basename, dirname, extname } from 'node:path' import { api } from '../lib/api' @@ -117,21 +118,36 @@ export function registerConfig(program: Command): void { json?: boolean }) => { await runAction(async () => { - // The server enforces "exactly one of config / template_id"; - // pre-check locally for a clearer error than a bare 400. if (!opts.file === !opts.template) { throw new Error('provide exactly one of --file or --template ') } if (opts.path && !opts.repo) { throw new Error('--path names a location in a repository, so it needs --repo ') } - const req: CreateAgentConfigRequest = { - repository: opts.repo, - path: opts.path, + const client = api() + // Templates left the create request (#6394): resolve the slug to its + // YAML and create from that config. + const config = opts.file + ? (readConfigFile(opts.file) as AgentConfig) + : (parseYaml((await client.agents.templates.get(opts.template!)).yaml) as AgentConfig) + const req: CreateAgentConfigRequest = { config } + const created = await client.agents.configs.create(req) + // With --repo the agent is then moved into the repository by pull + // request (create + link, the two-step the API exposes today). + if (opts.repo) { + const linked = await client.agents.configs.link(created.config.id, { + repository: opts.repo, + path: opts.path, + }) + if (opts.json) { + printJson(linked) + return + } + console.log(`✓ created "${configName(linked.config)}" (${linked.config.id}) — live now`) + console.log(`✓ opened a pull request adding the agent config (${linked.path})`) + console.log(linked.pull_request_url) + return } - if (opts.file) req.config = readConfigFile(opts.file) as AgentConfig - if (opts.template) req.template_id = opts.template - const created = await api().agents.configs.create(req) if (opts.json) { printJson(created) return @@ -415,13 +431,17 @@ export function registerConfig(program: Command): void { return } await runAction(async () => { - printCreated( - await api().agents.configs.create({ - template_id: opts.template, - repository: opts.repo!, - path: opts.path, - }), - ) + const client = api() + const template = await client.agents.templates.get(opts.template!) + const created = await client.agents.configs.create({ + config: parseYaml(template.yaml) as AgentConfig, + }) + const linked = await client.agents.configs.link(created.config.id, { + repository: opts.repo!, + path: opts.path, + }) + console.log(`✓ opened a pull request adding the agent config (${linked.path})`) + console.log(linked.pull_request_url) }) return } @@ -443,15 +463,7 @@ export function registerConfig(program: Command): void { const COMMIT_HINT = 'Commit it to your default branch. Ellipsis syncs agent configs from GitHub.' -// A create answers two ways: with a repository the agent waits on a pull -// request, without one it is already live and has no file. function printCreated(created: CreatedAgentConfig): void { - if (created.pull_request_url) { - console.log(`✓ opened a pull request adding the agent config (${created.path})`) - console.log(created.pull_request_url) - console.log('Merge it to deploy the agent.') - return - } console.log(`✓ created "${configName(created.config)}" (${created.config.id}) — live now`) console.log('It has no file; change it with `agent config edit`, or `agent config link` to move it into a repo.') } diff --git a/src/commands/environment.ts b/src/commands/environment.ts new file mode 100644 index 0000000..65c778e --- /dev/null +++ b/src/commands/environment.ts @@ -0,0 +1,347 @@ +import { type Command } from 'commander' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { api } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' +import { repoFromCwd } from '../lib/git' +import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output' +import { resolveRepoFlag } from './config' +import { readConfigFile } from './session' +import type { + EnvironmentConfig, + EnvironmentDefaults, + SavedEnvironment, +} from '../lib/types' + +const DEFAULT_ENVIRONMENT_PATH = 'agents/environments/my_environment.yaml' + +export function registerEnvironment(program: Command): void { + const environment = alsoKnownAs( + program + .command('environment') + .description('Manage the environments agents run in: repos, variables, MCP servers, image'), + 'environments', + 'env', + ) + + apiRoutes( + alsoKnownAs( + environment.command('list').description('List your saved environments'), + 'ls', + ), + 'GET /v1/environments', + ) + .option('--json', 'output raw JSON') + .action(async (opts: { json?: boolean }) => { + await runAction(async () => { + const { environments } = await api().environments.list() + if (opts.json) { + printJson(environments) + return + } + if (environments.length === 0) { + console.log('No environments found.') + return + } + printTable( + ['NAME', 'ID', 'SOURCE', 'UPDATED'], + environments.map((e) => [e.name, e.id, environmentSource(e), formatTs(e.updated_at)]), + ) + }) + }) + + apiRoutes( + environment + .command('get ') + .description('Print one environment as YAML (by id or name), or as JSON with --json'), + 'GET /v1/environments/{id}', + ) + .option('--json', 'output raw JSON') + .action(async (environmentId: string, opts: { json?: boolean }) => { + await runAction(async () => { + const { environment: e } = await api().environments.get(environmentId) + if (opts.json) { + printJson(e) + return + } + printYaml(e.environment) + }) + }) + + apiRoutes( + environment + .command('create') + .description('Create an environment from a file, live immediately'), + 'POST /v1/environments', + ) + .requiredOption('-f, --file ', 'environment file (.yaml/.yml or .json) to create from') + .option('--json', 'output raw JSON') + .action(async (opts: { file: string; json?: boolean }) => { + await runAction(async () => { + const created = await api().environments.create({ + environment: readConfigFile(opts.file) as EnvironmentConfig, + }) + if (opts.json) { + printJson(created) + return + } + const e = created.environment + console.log(`✓ created "${e.name}" (${e.id}) — live now`) + console.log( + 'Reference it from agent configs (`environment: ' + + e.name + + '`) or make it the default: `agent environment default set ' + + e.name + + '`.', + ) + }) + }) + + apiRoutes( + alsoKnownAs( + environment + .command('edit ') + .description("Replace an API-managed environment's definition from a file, live immediately"), + 'update', + ), + 'PUT /v1/environments/{id}', + ) + .requiredOption('-f, --file ', 'environment file (.yaml/.yml or .json) to replace it with') + .option('--json', 'output raw JSON') + .action(async (environmentId: string, opts: { file: string; json?: boolean }) => { + await runAction(async () => { + const { environment: updated } = await api().environments.update(environmentId, { + environment: readConfigFile(opts.file) as EnvironmentConfig, + }) + if (opts.json) { + printJson(updated) + return + } + console.log(`✓ updated "${updated.name}" (${updated.id}) — future sessions use it`) + }) + }) + + apiRoutes( + alsoKnownAs( + environment + .command('delete ') + .description('Delete an API-managed environment; agents still referencing it fail at start'), + 'rm', + ), + 'DELETE /v1/environments/{id}', + ) + .option('--json', 'output raw JSON') + .action(async (environmentId: string, opts: { json?: boolean }) => { + await runAction(async () => { + await api().environments.delete(environmentId) + if (opts.json) printJson({ id: environmentId, deleted: true }) + else console.log(`✓ deleted ${environmentId}`) + }) + }) + + // ------------------------------- defaults -------------------------------- + // The default-environment ladder a config-less session resolves: repo + // default -> account default -> the built-in basic sandbox. Same rung + // addressing as `agent config default`. + const defaults = apiRoutes( + alsoKnownAs( + environment + .command('default') + .description('Show or set which environment serves sessions that name none'), + 'defaults', + ), + 'GET /v1/environments/defaults', + ) + .option('--json', 'output raw JSON') + // Bare `agent environment default`: the effective default for the repo + // you're standing in, computed locally from GET /defaults + the origin + // remote (the same ladder session start resolves server-side). + .action(async (opts: { json?: boolean }) => { + await runAction(async () => { + const ladder = await api().environments.defaults.list() + const repo = repoFromCwd(process.cwd()) + const repoRung = repo ? repoDefault(ladder, repo) : undefined + const effective = repoRung ?? ladder.account ?? null + if (opts.json) { + printJson({ repository: repo ?? null, effective }) + return + } + if (!effective) { + console.log( + repo + ? `no default environment for ${repo} or the account (sessions get the basic sandbox)` + : 'no account default environment set (sessions get the basic sandbox)', + ) + return + } + const rung = repoRung ? `repo default for ${repo}` : 'account default' + console.log(`using environment "${effective}" (${rung})`) + }) + }) + + apiRoutes( + alsoKnownAs( + defaults + .command('list') + .description('List every default environment that is set, account rung and per-repo rungs'), + 'ls', + ), + 'GET /v1/environments/defaults', + ) + .option('--json', 'output raw JSON') + .action(async (_opts: { json?: boolean }, cmd: Command) => { + await runAction(async () => { + const client = api() + const ladder = await client.environments.defaults.list() + if (cmd.optsWithGlobals().json) { + printJson(ladder) + return + } + const rungs: [string, string][] = [ + ...(ladder.account ? ([['account', ladder.account]] as [string, string][]) : []), + ...Object.entries(ladder.repositories), + ] + if (rungs.length === 0) { + console.log('No default environments set. Sessions get the basic sandbox.') + return + } + const names = new Map( + (await client.environments.list()).environments.map((e) => [e.id, e.name]), + ) + printTable( + ['RUNG', 'ENVIRONMENT', 'ENVIRONMENT ID'], + rungs.map(([rung, id]) => [rung, names.get(id) ?? id, id]), + ) + }) + }) + + apiRoutes( + defaults + .command('set ') + .description('Set the account default environment, or a repo default with --repo'), + 'PUT /v1/environments/defaults', + ) + .option( + '-r, --repo [repository]', + 'target a repo rung: "owner/name", or no value for the repo you are standing in', + ) + .option('--json', 'output raw JSON') + .action( + async ( + environmentId: string, + opts: { repo?: string | boolean; json?: boolean }, + cmd: Command, + ) => { + await runAction(async () => { + const repository = resolveRepoFlag(opts.repo) + const ladder = await api().environments.defaults.set({ + environment: environmentId, + ...(repository ? { repository } : {}), + }) + if (cmd.optsWithGlobals().json) { + printJson(ladder) + return + } + const rung = repository ? `default for ${repository}` : 'account default' + const id = repository ? repoDefault(ladder, repository) : ladder.account + console.log(`✓ set ${rung} to ${id ?? environmentId}`) + }) + }, + ) + + apiRoutes( + alsoKnownAs( + defaults + .command('clear') + .description('Clear the account default environment, or a repo default with --repo'), + 'rm', + 'delete', + ), + 'DELETE /v1/environments/defaults', + ) + .option( + '-r, --repo [repository]', + 'target a repo rung: "owner/name", or no value for the repo you are standing in', + ) + .action(async (opts: { repo?: string | boolean }) => { + await runAction(async () => { + const repository = resolveRepoFlag(opts.repo) + await api().environments.defaults.delete({ repository }) + console.log( + `✓ cleared ${repository ? `default environment for ${repository}` : 'account default environment'}`, + ) + }) + }) + + environment + .command('init [path]') + .description( + `Scaffold a starter environment YAML locally (default: ${DEFAULT_ENVIRONMENT_PATH})`, + ) + .option('--force', 'overwrite the file if it already exists') + .action(async (path: string | undefined, opts: { force?: boolean }) => { + const target = path ?? DEFAULT_ENVIRONMENT_PATH + if (existsSync(target) && !opts.force) { + console.error(`error: ${target} already exists (use --force to overwrite)`) + process.exitCode = 1 + return + } + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, STARTER_ENVIRONMENT) + console.log(`✓ wrote ${target}`) + console.log( + 'Commit it to your default branch (Ellipsis syncs it from GitHub), or create it now: `agent environment create -f ' + + target + + '`.', + ) + }) +} + +function environmentSource(e: SavedEnvironment): string { + if (e.source_details) return e.source_details.path + return 'api' +} + +function repoDefault(ladder: EnvironmentDefaults, repo: string): string | undefined { + const match = Object.entries(ladder.repositories).find( + ([name]) => name.toLowerCase() === repo.toLowerCase(), + ) + return match?.[1] +} + +const STARTER_ENVIRONMENT = `# Ellipsis environment: the machine your agents run in, defined once for the +# team. Commit to your default branch (synced locations: agents/, .agents/, +# ellipsis/, .ellipsis/), or create it live with \`agent environment create -f\`. +ellipsis: + kind: environment + name: my-environment + +# Repositories checked out into every session. +repositories: + - name: my-repo + +# Environment variables injected into the sandbox. Omit \`value\` to resolve +# the name from your stored secrets (\`agent variable set NAME=...\`). +variables: + - name: MY_TOKEN + +# MCP servers available to agents. Built-ins by name (linear, slack); bring +# your own with \`url:\` (remote) or \`command:\` (stdio). \${NAME} in +# env/header values resolves from your stored secrets at session start. +mcp_servers: + - linear +# - name: sentry +# url: https://mcp.sentry.dev/mcp +# headers: +# Authorization: "Bearer \${SENTRY_AUTH_TOKEN}" + +# Toolchain baked into the cached image (runs once per image build). +image: + setup: | + echo "install CLIs and dependencies here" + +# Sandbox sizing. +compute: + cpu: 2 + memory: 4GB +` diff --git a/src/commands/help.ts b/src/commands/help.ts index f15937e..f2d3aca 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,10 +1,11 @@ import type { Command } from 'commander' +import { parse as parseYaml } from 'yaml' import { api, APIError } from '../lib/api' import { apiRoutes } from '../lib/help' import { runAction } from '../lib/output' import { repoFromCwd } from '../lib/git' import { startConnect } from './session' -import type { StartAgentSessionRequest } from '../lib/types' +import type { AgentConfig, StartAgentSessionRequest } from '../lib/types' // The template behind `agent help --interactive`. Kebab-case like every other // slug in the registry; it is not served yet, so a 404 here is expected and @@ -64,14 +65,16 @@ export function resolveCommandPath(program: Command, path: string[]): Command | } async function startHelperSession(): Promise { - const req: StartAgentSessionRequest = { template_id: HELPER_TEMPLATE_SLUG } + const template = await api().agents.templates.get(HELPER_TEMPLATE_SLUG) + const req: StartAgentSessionRequest = { + config: parseYaml(template.yaml) as AgentConfig, + } // Same as `session start`: send the repo we're standing in so the helper can // answer questions about this checkout. Ignored server-side if unknown. const contextRepo = repoFromCwd(process.cwd()) if (contextRepo) req.repository = contextRepo - // No prompt: the helper opens idle and waits for the question, like a bare - // `agent`, rather than running a workflow against a fabricated kickoff. - req.idle_start = true + // No prompt: the helper opens idle and waits for the question (a promptless + // start is idle by definition since #6394). try { const { session } = await api().sessions.start(req) diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 8cdf5e6..2d0466d 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -99,12 +99,16 @@ export function registerSession(program: Command): void { 'start from a maintained session template (e.g. ellipsis-helper)', ) .option( - '--config-override ', - 'partial agent config (YAML/JSON) merged onto the chosen config for this session, e.g. "budget:\\n session: 5"', + '-e, --environment ', + 'run in a saved environment, by id or name (only without -c/-f: an agent config decides its own environment)', ) .option( - '--config-override-file ', - 'read the partial config override from a file (.yaml/.yml or .json) instead of inline', + '--override ', + 'partial patch (YAML/JSON) on the resolved session config, applied last, e.g. "budget:\\n session: 5"', + ) + .option( + '--override-file ', + 'read the partial override from a file (.yaml/.yml or .json) instead of inline', ) .option( '--model ', @@ -156,8 +160,9 @@ export function registerSession(program: Command): void { config?: string configFile?: string template?: string - configOverride?: string - configOverrideFile?: string + environment?: string + override?: string + overrideFile?: string model?: string system?: string repo: string[] @@ -213,7 +218,23 @@ export function registerSession(program: Command): void { } if (opts.config) req.config_id = opts.config if (opts.configFile) req.config = readConfigFile(opts.configFile) as AgentConfig - if (opts.template) req.template_id = opts.template + // Templates left the start request (#6394): resolve the slug to its + // YAML via GET /v1/agents/templates/{slug} and start inline. + if (opts.template) { + const template = await api().agents.templates.get(opts.template) + req.config = parseYaml(template.yaml) as AgentConfig + } + // The environment is only a session-level choice when no config + // decides it; the server 400s the combination, so pre-check locally + // for a clearer error. + if (opts.environment) { + if (opts.config || opts.configFile) { + throw new Error( + '--environment cannot be combined with --config/--config-file: the agent config decides its environment', + ) + } + req.environment = opts.environment + } // The repo we're standing in (origin remote), sent unconditionally — // with no config source it picks the repo rung of the server's // defaults ladder, and either way the server merges it into the @@ -222,22 +243,19 @@ export function registerSession(program: Command): void { const contextRepo = repoFromCwd(process.cwd()) if (contextRepo) req.repository = contextRepo // Sugar flags (--model, --repo, --cpu, ...) and the raw - // --config-override are merged into one structured override, applied + // --override are merged into one structured override, applied // onto the chosen (or default) config and re-validated server-side. const override = buildStartOverride(opts) - if (override) req.config_override = override + if (override) req.override = override // Appended to the initial user query at build time; gives this // session instructions on top of the config's shared system prompt. if (promptText) req.prompt = promptText // Skip the image cache for the initial provision (wakes cache as // usual); the fresh build's snapshot becomes the new cache entry. if (opts.rebuild) req.force_rebuild = true - // A promptless connect (a bare `agent`) starts the session idle: - // no fabricated kickoff message, Claude Code waits at the prompt - // for whatever is typed into the composer, like a local `claude`. - // A promptless --watch/--detach start still runs the config's - // workflow, so this only applies to the interactive open. - if (opts.connect && !promptText) req.idle_start = true + // A promptless start opens idle: no fabricated kickoff message, + // Claude Code waits at the prompt like a local `claude` (the + // server-side contract since #6394 — nothing extra to send). const client = api() const { session } = await client.sessions.start(req) @@ -256,6 +274,21 @@ export function registerSession(program: Command): void { configNote = `using config "${resolvedConfigName}" (account default)` } } + // Same transparency for the environment ladder: say which + // environment the server resolved when the session didn't name one. + const environmentSource = session.environment?.source + if (session.environment?.environment_id && environmentSource !== 'request') { + const label = + environmentSource === 'repo_default' + ? 'repo default' + : environmentSource === 'account_default' + ? 'account default' + : environmentSource === 'agent_config' + ? 'from the agent config' + : environmentSource + const note = `using environment ${session.environment.environment_id} (${label})` + configNote = configNote ? `${configNote}; ${note}` : note + } if (opts.connect) { // A non-interactive config refuses the stream/messages surface, so @@ -630,12 +663,12 @@ export function registerSession(program: Command): void { "run against a different saved agent config instead of the original session's snapshot", ) .option( - '--config-override ', - 'partial agent config (YAML/JSON) merged onto the config for this replay, e.g. "claude:\\n model: claude-opus-4-8"', + '--override ', + 'partial patch (YAML/JSON) on the replayed config, e.g. "claude:\\n model: claude-opus-4-8"', ) .option( - '--config-override-file ', - 'read the partial config override from a file (.yaml/.yml or .json) instead of inline', + '--override-file ', + 'read the partial override from a file (.yaml/.yml or .json) instead of inline', ) .option( '-p, --prompt ', @@ -652,8 +685,8 @@ export function registerSession(program: Command): void { sessionId: string, opts: { config?: string - configOverride?: string - configOverrideFile?: string + override?: string + overrideFile?: string prompt?: string watch?: boolean quiet?: boolean @@ -905,36 +938,39 @@ async function printSessionUrl(client: Ellipsis, sessionId: string): Promise | null - config_override_yaml?: string | null + override?: Record | null }, - opts: { configOverride?: string; configOverrideFile?: string }, + opts: { override?: string; overrideFile?: string }, ): void { - if (opts.configOverride && opts.configOverrideFile) { - throw new Error('provide only one of --config-override / --config-override-file') + if (opts.override && opts.overrideFile) { + throw new Error('provide only one of --override / --override-file') } - if (opts.configOverride) req.config_override_yaml = opts.configOverride - if (opts.configOverrideFile) { - req.config_override = readMappingFile(opts.configOverrideFile, 'config override') + if (opts.overrideFile) { + req.override = readMappingFile(opts.overrideFile, 'override') + } else if (opts.override) { + const parsed = parseYaml(opts.override) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('--override must be a YAML/JSON mapping of fields to override') + } + req.override = parsed as Record } } // Build the single structured config override for `session start`. The raw -// --config-override / --config-override-file supplies the base mapping (any +// --override / --override-file supplies the base mapping (any // field); the sugar flags (--model, --system, --repo, --cpu, --memory, // --timeout, --budget) are assembled into a partial config and deep-merged on // top, so an explicit flag wins over the same field in a raw override. Returns // undefined when nothing was set (no override sent). The result is applied onto // the chosen (or default) config and re-validated server-side. export function buildStartOverride(opts: { - configOverride?: string - configOverrideFile?: string + override?: string + overrideFile?: string model?: string system?: string repo?: string[] @@ -943,14 +979,14 @@ export function buildStartOverride(opts: { timeout?: string budget?: number }): Record | undefined { - if (opts.configOverride && opts.configOverrideFile) { - throw new Error('provide only one of --config-override / --config-override-file') + if (opts.override && opts.overrideFile) { + throw new Error('provide only one of --override / --override-file') } let base: Record = {} - if (opts.configOverrideFile) { - base = readMappingFile(opts.configOverrideFile, 'config override') - } else if (opts.configOverride) { - const parsed = parseYaml(opts.configOverride) + if (opts.overrideFile) { + base = readMappingFile(opts.overrideFile, 'override') + } else if (opts.override) { + const parsed = parseYaml(opts.override) if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('config override must be a mapping of fields') } diff --git a/src/commands/variable.ts b/src/commands/variable.ts index b4f10fc..b347105 100644 --- a/src/commands/variable.ts +++ b/src/commands/variable.ts @@ -12,7 +12,6 @@ export function registerVariable(program: Command): void { .description('Manage the env variables injected into every sandbox (values are write-only)'), 'variables', 'var', - 'env', ) apiRoutes( diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 89c46dc..f02be6e 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -465,7 +465,7 @@ export function applyComposerChoices( delete req.repository } } - if (Object.keys(override).length > 0) req.config_override = override + if (Object.keys(override).length > 0) req.override = override return req } diff --git a/src/lib/types.ts b/src/lib/types.ts index 5562a2d..34166f6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -54,12 +54,19 @@ export type AgentConfig = S['AgentConfig'] export type SavedAgentConfig = S['Config'] export type ListAgentConfigsResponse = S['AgentConfigsListResponse'] export type CreateAgentConfigRequest = Parameters[0] -export type CreatedAgentConfig = S['CreateAgentConfigResponse'] +export type CreatedAgentConfig = S['AgentConfigResponse'] export type ConfigManagedBy = S['ConfigManagedBy'] export type LinkedAgentConfig = S['LinkAgentConfigResponse'] export type AgentDefaults = S['AgentDefaults'] export type PutAgentDefaultRequest = S['PutAgentDefaultRequest'] +// ------------------------------ environments ------------------------------- + +export type EnvironmentConfig = S['EnvironmentConfig'] +export type SavedEnvironment = S['Environment'] +export type ListEnvironmentsResponse = S['EnvironmentsListResponse'] +export type EnvironmentDefaults = S['EnvironmentDefaults'] + // -------------------------------- templates ------------------------------- export type AgentTemplate = S['AgentTemplate'] diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 6c3f3ca..5eeb700 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -40,9 +40,10 @@ export function canHostSessionsUi(): boolean { // Claude Code waits at its prompt, so the first composer message opens turn 0 // (a local `claude` with no argument). export function defaultStartRequest(prompt: string): StartAgentSessionRequest { + // A promptless start opens idle by definition (the server-side contract + // since #6394): Claude Code waits at its prompt for the first message. const req: StartAgentSessionRequest = {} if (prompt) req.prompt = prompt - else req.idle_start = true const contextRepo = repoFromCwd(process.cwd()) if (contextRepo) req.repository = contextRepo return req diff --git a/test/session.test.ts b/test/session.test.ts index 472325f..6e6e198 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -155,41 +155,41 @@ describe('applyConfigOverride', () => { } it('passes an inline override through as the YAML/JSON string', () => { - const req: { config_override?: Record; config_override_yaml?: string } = {} - applyConfigOverride(req, { configOverride: 'claude:\n model: claude-opus-4-8' }) - expect(req).toEqual({ config_override_yaml: 'claude:\n model: claude-opus-4-8' }) + const req: { override?: Record } = {} + applyConfigOverride(req, { override: 'claude:\n model: claude-opus-4-8' }) + expect(req).toEqual({ override: { claude: { model: 'claude-opus-4-8' } } }) }) it('reads and parses a file override into the structured mapping', () => { const path = write('override.yaml', 'budget:\n session: 5\n') - const req: { config_override?: Record; config_override_yaml?: string } = {} - applyConfigOverride(req, { configOverrideFile: path }) - expect(req).toEqual({ config_override: { budget: { session: 5 } } }) + const req: { override?: Record } = {} + applyConfigOverride(req, { overrideFile: path }) + expect(req).toEqual({ override: { budget: { session: 5 } } }) }) it('rejects passing both inline and file forms', () => { const path = write('both.yaml', 'enabled: false\n') expect(() => - applyConfigOverride({}, { configOverride: 'enabled: false', configOverrideFile: path }), - ).toThrow(/only one of --config-override \/ --config-override-file/) + applyConfigOverride({}, { override: 'enabled: false', overrideFile: path }), + ).toThrow(/only one of --override \/ --override-file/) }) it('is a no-op when neither form is given', () => { - const req: { config_override?: Record; config_override_yaml?: string } = {} + const req: { override?: Record } = {} applyConfigOverride(req, {}) expect(req).toEqual({}) }) it('surfaces an override-specific error when the file is missing', () => { - expect(() => applyConfigOverride({}, { configOverrideFile: join(dir, 'nope.yaml') })).toThrow( - /could not read config override file/, + expect(() => applyConfigOverride({}, { overrideFile: join(dir, 'nope.yaml') })).toThrow( + /could not read override file/, ) }) it('surfaces an override-specific error for a non-mapping file', () => { const path = write('list.yaml', '- a\n- b\n') - expect(() => applyConfigOverride({}, { configOverrideFile: path })).toThrow( - /could not parse YAML config override file/, + expect(() => applyConfigOverride({}, { overrideFile: path })).toThrow( + /could not parse YAML override file/, ) }) }) @@ -230,7 +230,7 @@ describe('buildStartOverride', () => { it('deep-merges sugar flags on top of a raw inline override (flags win)', () => { expect( buildStartOverride({ - configOverride: 'claude:\n model: claude-haiku-4-5-20251001\n system: base\nenabled: false', + override: 'claude:\n model: claude-haiku-4-5-20251001\n system: base\nenabled: false', model: 'claude-opus-4-8', }), ).toEqual({ @@ -241,7 +241,7 @@ describe('buildStartOverride', () => { it('uses a file override as the base', () => { const path = write('base.yaml', 'budget:\n session: 1\n') - expect(buildStartOverride({ configOverrideFile: path, budget: 5 })).toEqual({ + expect(buildStartOverride({ overrideFile: path, budget: 5 })).toEqual({ budget: { session: 5 }, }) }) @@ -249,12 +249,12 @@ describe('buildStartOverride', () => { it('rejects both inline and file override forms', () => { const path = write('both.yaml', 'enabled: false\n') expect(() => - buildStartOverride({ configOverride: 'enabled: false', configOverrideFile: path }), - ).toThrow(/only one of --config-override \/ --config-override-file/) + buildStartOverride({ override: 'enabled: false', overrideFile: path }), + ).toThrow(/only one of --override \/ --override-file/) }) it('rejects a non-mapping inline override', () => { - expect(() => buildStartOverride({ configOverride: '- a\n- b\n' })).toThrow( + expect(() => buildStartOverride({ override: '- a\n- b\n' })).toThrow( /config override must be a mapping/, ) }) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 02a6dec..20d3cde 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -549,7 +549,7 @@ describe('applyComposerChoices', () => { it('sends no repository override while the picker is untouched', () => { const req = applyComposerChoices({ repository: 'acme/api' }, { ...untouched, model: 'claude-opus-5' }) - expect(req.config_override).toEqual({ claude: { model: 'claude-opus-5' } }) + expect(req.override).toEqual({ claude: { model: 'claude-opus-5' } }) expect(req.repository).toBe('acme/api') }) @@ -559,7 +559,7 @@ describe('applyComposerChoices', () => { { repository: 'acme/api' }, { ...untouched, repos: ['acme/api', 'acme/web', 'acme/infra'] }, ) - expect(req.config_override).toEqual({ + expect(req.override).toEqual({ environment: { repositories: [ { owner: 'acme', name: 'api' }, @@ -575,7 +575,7 @@ describe('applyComposerChoices', () => { it('checks out no repository at all when every box is unchecked', () => { const req = applyComposerChoices({ repository: 'acme/api' }, { ...untouched, repos: [] }) - expect(req.config_override).toEqual({ environment: { repositories: [] } }) + expect(req.override).toEqual({ environment: { repositories: [] } }) // The server merges `repository` into the checkout unconditionally, so an // empty set only holds if the context repo goes too. expect(req.repository).toBeUndefined() @@ -583,7 +583,7 @@ describe('applyComposerChoices', () => { it('drops the context repo when the selection excludes it', () => { const req = applyComposerChoices({ repository: 'acme/api' }, { ...untouched, repos: ['acme/web'] }) - expect(req.config_override).toEqual({ + expect(req.override).toEqual({ environment: { repositories: [{ owner: 'acme', name: 'web' }] }, }) expect(req.repository).toBeUndefined() @@ -591,8 +591,8 @@ describe('applyComposerChoices', () => { it('overrides under the environment key the config schema uses, not the old sandbox one', () => { const req = applyComposerChoices({}, { ...untouched, repos: ['acme/web'] }) - expect(req.config_override).not.toHaveProperty('sandbox') - expect(req.config_override).toHaveProperty('environment') + expect(req.override).not.toHaveProperty('sandbox') + expect(req.override).toHaveProperty('environment') }) it('carries a chosen agent config and model through', () => { @@ -603,7 +603,7 @@ describe('applyComposerChoices', () => { expect(req).toEqual({ prompt: 'ship it', config_id: 'cfg_1', - config_override: { claude: { model: 'claude-fable-5' } }, + override: { claude: { model: 'claude-fable-5' } }, }) })