diff --git a/package.json b/package.json index 16d6973..10e9a18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis/cli", - "version": "0.1.6", + "version": "0.8.0", "description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 1602da2..a6bccba 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -71,34 +71,47 @@ export function registerConnect(session: Command): void { .description( 'Connect to a cloud session: view the conversation, follow it live, and send messages', ) - .option('--no-backlog', 'skip printing the stored transcript on open') + .option('--no-steps', 'skip replaying prior steps on open') + .option( + '--no-input', + 'follow read-only: never open the composer, even on a keyed session (for non-interactive callers)', + ) .addHelpText( 'after', `\nMessage mode: render the conversation, follow it live, and send lines through -the session inbox — single-writer-safe and usable headless / inside a sandbox.`, +the session inbox — single-writer-safe and usable headless / inside a sandbox. +Pass --no-input to follow read-only from a script or agent (no TTY needed).`, ) - .action(async (sessionId: string | undefined, opts: { backlog: boolean }) => { + .action(async (sessionId: string | undefined, opts: { steps: boolean; input: boolean }) => { await runAction(async () => { const id = resolveConnectSessionId(sessionId) - await runConnect(id, opts.backlog) + await runConnect(id, opts.steps, !opts.input) }) }) } -export async function runConnect(sessionId: string, backlog: boolean): Promise { +export async function runConnect( + sessionId: string, + showSteps: boolean, + readOnly = false, +): Promise { const api = new ApiClient() const token = requireToken() const wsBase = resolveWsBase(resolveApiBase()) const [session, me] = await Promise.all([api.getAgentSession(sessionId), api.whoami()]) - const { canSend, reason } = connectability(session) + const c = connectability(session) + // --no-input forces watch-only even when the session would accept messages. + const canSend = readOnly ? false : c.canSend + const reason = + readOnly && c.canSend ? 'read-only (--no-input) — following without the composer' : c.reason console.log(`session: ${session.id} (${session.status})`) if (session.agent_config_id) console.log(`config: ${session.agent_config_id}`) console.log(`url: ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) if (reason) console.log(reason) - if (backlog) { + if (showSteps) { const steps = await api.getAgentSessionSteps(sessionId) if (steps.length > 0) { const ordered = [...steps].sort( diff --git a/src/commands/session.tsx b/src/commands/session.tsx index eafccb8..fac73fe 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -21,6 +21,7 @@ import { parseScope, parseWhen, toInt, + toNumber, } from '../lib/args' import { sessionUrl } from '../lib/urls' import { @@ -84,7 +85,14 @@ export function registerSession(program: Command): void { session .command('start') .description('Start a new agent session (POST /v1/sessions)') - .option('-c, --config ', 'start from a saved agent config id') + .argument( + '[prompt]', + 'what the agent should do this session (positional shorthand for --prompt)', + ) + .option( + '-c, --config ', + 'start from a saved agent config id (default: the platform default config)', + ) .option( '-f, --config-file ', 'start from an inline agent config (.yaml/.yml or .json file)', @@ -101,9 +109,21 @@ export function registerSession(program: Command): void { '--config-override-file ', 'read the partial config override from a file (.yaml/.yml or .json) instead of inline', ) + .option('--model ', 'set claude.model for this session (e.g. claude-opus-4-8)') + .option('--system ', 'set claude.system (the agent system prompt) for this session') + .option( + '--repo ', + 'check out a repository in the sandbox (repeatable; "name" defaults owner to your account)', + collect, + [] as string[], + ) + .option('--cpu ', 'sandbox vCPUs (e.g. 2 or 0.5)', toNumber) + .option('--memory ', 'sandbox memory (e.g. 8GB)') + .option('--timeout ', 'sandbox timeout (e.g. 30m or 1h)') + .option('--budget ', 'per-run spend limit in USD for this session (limits.run)', toNumber) .option( '-p, --prompt ', - 'per-session instructions appended to the initial user query for this session', + "the session prompt, appended to the agent's initial user query (or pass it positionally)", ) .option( '-m, --metadata ', @@ -111,55 +131,89 @@ export function registerSession(program: Command): void { collectKeyValue, {} as Record, ) - .option('-w, --watch', 'stream the session live until it reaches a terminal status') + .option('-d, --detach', 'start and return immediately (fire-and-forget; the default)') + .option( + '-w, --watch', + 'block until the session reaches a terminal status, streaming live output', + ) + .option( + '--quiet', + 'with --watch, wait without streaming — print only the final result and exit with a matching code', + ) .option( '--connect', 'after starting, wait for the sandbox and connect: view the conversation, follow it live, and send messages', ) .option('--json', 'output raw JSON') .action( - async (opts: { - config?: string - configFile?: string - template?: string - configOverride?: string - configOverrideFile?: string - prompt?: string - metadata: Record - watch?: boolean - connect?: boolean - json?: boolean - }) => { + async ( + promptArg: string | undefined, + opts: { + config?: string + configFile?: string + template?: string + configOverride?: string + configOverrideFile?: string + model?: string + system?: string + repo: string[] + cpu?: number + memory?: string + timeout?: string + budget?: number + prompt?: string + metadata: Record + detach?: boolean + watch?: boolean + quiet?: boolean + connect?: boolean + json?: boolean + }, + ) => { await runAction(async () => { - // The server enforces "exactly one of config / config_id / template_id"; - // pre-check locally for a clearer error than a bare 400. + // A config source is optional: with none, the server runs on the + // platform default config and the prompt is the sole instruction. + // At most one source may be given. const sources = [opts.config, opts.configFile, opts.template].filter(Boolean) - if (sources.length === 0) { - throw new Error('provide one of --config , --config-file , or --template ') - } if (sources.length > 1) { throw new Error('provide only one of --config / --config-file / --template') } - // --connect takes over the terminal, so it can't pair with the - // NDJSON stream (--json) or the read-only watcher (--watch). + // The prompt is either positional or --prompt, not both. + if (promptArg !== undefined && opts.prompt !== undefined) { + throw new Error('provide the prompt positionally or with --prompt, not both') + } + const promptText = promptArg ?? opts.prompt + // At most one attach mode. --detach is the default made explicit; + // --watch blocks (live, or quiet with --quiet); --connect is interactive. + const modes = [ + opts.detach && '--detach', + opts.watch && '--watch', + opts.connect && '--connect', + ].filter(Boolean) + if (modes.length > 1) { + throw new Error(`provide at most one of ${modes.join(' / ')}`) + } + if (opts.quiet && !opts.watch) { + throw new Error('--quiet only applies with --watch') + } + // --connect takes over the terminal, so it can't emit the NDJSON stream. if (opts.connect && opts.json) { throw new Error('--connect is interactive and cannot be combined with --json') } - if (opts.connect && opts.watch) { - throw new Error('provide only one of --connect / --watch') - } const req: StartAgentSessionRequest = { metadata: opts.metadata, } if (opts.config) req.config_id = opts.config if (opts.configFile) req.config = readConfigFile(opts.configFile) if (opts.template) req.template_id = opts.template - // Merged onto the chosen config and re-validated server-side; set - // limits.run here to override this session's budget. - applyConfigOverride(req, opts) + // Sugar flags (--model, --repo, --cpu, ...) and the raw + // --config-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 // Appended to the initial user query at build time; gives this // session instructions on top of the config's shared system prompt. - if (opts.prompt) req.prompt = opts.prompt + if (promptText) req.prompt = promptText const api = new ApiClient() const session = await api.startAgentSession(req) @@ -176,7 +230,13 @@ export function registerSession(program: Command): void { console.log(`✓ started session ${session.id}`) await printSessionUrl(api, session.id) } - await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + // --quiet blocks on status only (no live output stream); either way + // the terminal status sets the exit code. + if (opts.quiet) { + await watchSession(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } else { + await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } return } @@ -194,7 +254,7 @@ export function registerSession(program: Command): void { session .command('list') .description('List recent agent sessions (GET /v1/sessions)') - .option('-c, --config-id ', 'filter by config id') + .option('-c, --config ', 'filter by config id') .option('-s, --source ', 'filter by source (repeatable)', collect, [] as string[]) .option( '-a, --author ', @@ -207,7 +267,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action( async (opts: { - configId?: string + config?: string source: string[] author?: string days?: number @@ -219,7 +279,7 @@ export function registerSession(program: Command): void { await runAction(async () => { const api = new ApiClient() const sessions = await api.listAgentSessions({ - config_id: opts.configId, + config_id: opts.config, source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, author_id: opts.author ? await resolveAuthorId(api, opts.author) : undefined, days: opts.days, @@ -254,7 +314,7 @@ export function registerSession(program: Command): void { session .command('search ') .description( - 'Search sessions by transcript text, recap text, created PR, or similarity (GET /v1/sessions/search)', + 'Search sessions by step text, recap text, created PR, or similarity (GET /v1/sessions/search)', ) .addHelpText( 'after', @@ -267,7 +327,7 @@ export function registerSession(program: Command): void { '-a, --author ', 'only sessions attributed to this developer (a GitHub login, see `agent github members`)', ) - .option('--agent ', 'only sessions run by this agent config (repeatable)', collect, [] as string[]) + .option('-c, --config ', 'only sessions run by this saved config (repeatable)', collect, [] as string[]) .option('-s, --source ', 'filter by source (repeatable)', collectSource, [] as string[]) .option('-r, --repo ', 'only sessions on this repository ("owner/name" or a bare name)') .option('--status ', 'filter by session status (repeatable)', collectStatus, [] as string[]) @@ -282,7 +342,7 @@ export function registerSession(program: Command): void { query: string, opts: { author?: string - agent: string[] + config: string[] source: string[] repo?: string status: string[] @@ -302,7 +362,7 @@ export function registerSession(program: Command): void { scope: opts.scope as SessionSearchScope, source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, author_id: authorId === undefined ? undefined : [authorId], - agent_config_id: opts.agent.length ? opts.agent : undefined, + agent_config_id: opts.config.length ? opts.config : undefined, session_ids: opts.session.length ? opts.session : undefined, repo: opts.repo, status: opts.status.length ? (opts.status as AgentSessionStatus[]) : undefined, @@ -332,7 +392,7 @@ export function registerSession(program: Command): void { session .command('steps ') - .description("Read a session's stored transcript (GET /v1/sessions/{id}/steps)") + .description("Read a session's steps (GET /v1/sessions/{id}/steps)") .option('--json', 'output raw JSON (full step payloads)') .action(async (sessionId: string, opts: { json?: boolean }) => { await runAction(async () => { @@ -426,16 +486,28 @@ export function registerSession(program: Command): void { session .command('get ') .description('Get a single agent session (GET /v1/sessions/{id})') - .option('-w, --watch', 'stream the session live until it reaches a terminal status') + .option( + '-w, --watch', + 'block until the session reaches a terminal status, streaming live output', + ) + .option('--quiet', 'with --watch, wait without streaming — print only the final result') .option('--json', 'output raw JSON') - .action(async (sessionId: string, opts: { watch?: boolean; json?: boolean }) => { - await runAction(async () => { - const api = new ApiClient() - if (opts.watch) { - if (!opts.json) await printSessionUrl(api, sessionId) - await watchSessionStreaming(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) - return - } + .action( + async (sessionId: string, opts: { watch?: boolean; quiet?: boolean; json?: boolean }) => { + await runAction(async () => { + const api = new ApiClient() + if (opts.quiet && !opts.watch) { + throw new Error('--quiet only applies with --watch') + } + if (opts.watch) { + if (!opts.json) await printSessionUrl(api, sessionId) + if (opts.quiet) { + await watchSession(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } else { + await watchSessionStreaming(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } + return + } if (opts.json) { printJson(await api.getAgentSession(sessionId)) return @@ -451,7 +523,7 @@ export function registerSession(program: Command): void { .command('replay ') .description("Re-run an existing session's trigger input (POST /v1/sessions/{id}/replay)") .option( - '-c, --config-id ', + '-c, --config ', "run against a different saved config instead of the original session's snapshot", ) .option( @@ -464,25 +536,33 @@ export function registerSession(program: Command): void { ) .option( '-p, --prompt ', - "per-session instructions; omit to inherit the original session's prompt, pass '' to clear it", + "the session prompt; omit to inherit the original session's prompt, pass '' to clear it", ) - .option('-w, --watch', 'stream the session live until it reaches a terminal status') + .option( + '-w, --watch', + 'block until the session reaches a terminal status, streaming live output', + ) + .option('--quiet', 'with --watch, wait without streaming — print only the final result') .option('--json', 'output raw JSON') .action( async ( sessionId: string, opts: { - configId?: string + config?: string configOverride?: string configOverrideFile?: string prompt?: string watch?: boolean + quiet?: boolean json?: boolean }, ) => { await runAction(async () => { + if (opts.quiet && !opts.watch) { + throw new Error('--quiet only applies with --watch') + } const req: ReplayAgentSessionRequest = {} - if (opts.configId) req.config_id = opts.configId + if (opts.config) req.config_id = opts.config applyConfigOverride(req, opts) // Distinguish "flag omitted" (inherit the original prompt) from // `--prompt ''` (clear it): only set the field when the flag was passed. @@ -496,7 +576,11 @@ export function registerSession(program: Command): void { console.log(`✓ started replay ${session.id} (from ${sessionId})`) await printSessionUrl(api, session.id) } - await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + if (opts.quiet) { + await watchSession(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } else { + await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } return } if (opts.json) { @@ -513,10 +597,10 @@ export function registerSession(program: Command): void { // Laptop → cloud handoff (design: LOCAL_CLAUDE_CODE.md §7.2): snapshot the // dirty working tree as a commit (without disturbing it), push it to // refs/ellipsis/handoff/, and start a fresh cloud session on the - // built-in handoff config with the instructions as its prompt — never a + // built-in handoff config with that prompt as its query — never a // literal `claude --resume` of the local session. session - .command('handoff ') + .command('handoff ') .description('Hand the current repo + a synced session off to a cloud agent') .requiredOption( '-p, --parent ', @@ -526,7 +610,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action( async ( - instructions: string, + prompt: string, opts: { parent: string; cwd?: string; json?: boolean }, ) => { await runAction(async () => { @@ -547,7 +631,7 @@ export function registerSession(program: Command): void { const api = new ApiClient() const session = await api.startAgentSession({ handoff: { parent_session_id: opts.parent, repo, sha, ref }, - prompt: instructions, + prompt, }) if (opts.json) { printJson(session) @@ -810,6 +894,7 @@ export async function watchSession( console.log('') printSessionSummary(s) } + if (exitCodeForStatus(s.status) !== 0) process.exitCode = 1 return } await sleep(intervalMs) @@ -860,6 +945,86 @@ export function applyConfigOverride( } } +// Build the single structured config override for `session start`. The raw +// --config-override / --config-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 + model?: string + system?: string + repo?: string[] + cpu?: number + memory?: string + timeout?: string + budget?: number +}): Record | undefined { + if (opts.configOverride && opts.configOverrideFile) { + throw new Error('provide only one of --config-override / --config-override-file') + } + let base: Record = {} + if (opts.configOverrideFile) { + base = readMappingFile(opts.configOverrideFile, 'config override') + } else if (opts.configOverride) { + const parsed = parseYaml(opts.configOverride) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('config override must be a mapping of fields') + } + base = parsed as Record + } + + const sugar: Record = {} + const claude: Record = {} + if (opts.model !== undefined) claude.model = opts.model + if (opts.system !== undefined) claude.system = opts.system + if (Object.keys(claude).length) sugar.claude = claude + + const compute: Record = {} + if (opts.cpu !== undefined) compute.cpu = opts.cpu + if (opts.memory !== undefined) compute.memory = opts.memory + if (opts.timeout !== undefined) compute.timeout = opts.timeout + const sandbox: Record = {} + if (Object.keys(compute).length) sandbox.compute = compute + if (opts.repo && opts.repo.length) sandbox.repositories = opts.repo.map(parseRepo) + if (Object.keys(sandbox).length) sugar.sandbox = sandbox + + if (opts.budget !== undefined) sugar.limits = { run: opts.budget } + + const merged = deepMerge(base, sugar) + return Object.keys(merged).length ? merged : undefined +} + +// Parse a --repo value into a sandbox.repositories entry. "owner/name" sets +// both; a bare "name" omits owner so the server defaults it to the account. +function parseRepo(value: string): { name: string; owner?: string } { + const parts = value.split('/') + if (parts.length === 1 && parts[0]) return { name: parts[0] } + if (parts.length === 2 && parts[0] && parts[1]) return { owner: parts[0], name: parts[1] } + throw new Error(`--repo must be "name" or "owner/name", got "${value}"`) +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +// Recursively merge `over` onto `base`: nested objects merge, everything else +// (including arrays) is replaced by `over`. +function deepMerge( + base: Record, + over: Record, +): Record { + const out: Record = { ...base } + for (const [k, v] of Object.entries(over)) { + const b = out[k] + out[k] = isPlainObject(b) && isPlainObject(v) ? deepMerge(b, v) : v + } + return out +} + // Parse an inline agent config from disk, choosing the parser by file // extension: .yaml/.yml as YAML, .json as JSON. (YAML is a JSON superset, so // unknown extensions fall back to YAML, which still accepts JSON input.) diff --git a/src/lib/args.ts b/src/lib/args.ts index c7bc02e..fc3fcb6 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -21,6 +21,16 @@ export function toInt(value: string): number { return n } +// Parse a decimal number (allows fractions, e.g. cpu 0.5 or a 0.50 budget), +// rejecting non-numeric input up front. +export function toNumber(value: string): number { + const n = value.trim() === '' ? NaN : Number(value) + if (!Number.isFinite(n)) { + throw new InvalidArgumentError(`expected a number, got "${value}"`) + } + return n +} + // Values the server accepts for the session facets, mirrored here so a typo // fails fast with the full list instead of a server-side 422. export const SESSION_SOURCES = [ diff --git a/test/session.test.ts b/test/session.test.ts index 1dedf16..a680b33 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { applyConfigOverride, + buildStartOverride, fetchTranscript, readConfigFile, watchSession, @@ -73,6 +74,24 @@ describe('watchSession', () => { expect(get).toHaveBeenCalledTimes(1) } }) + + it('sets a failure exit code on a non-completed terminal status (for --wait)', async () => { + process.exitCode = 0 + const get = vi.fn().mockResolvedValueOnce(session('error')) + const api = { getAgentSession: get } as unknown as ApiClient + await watchSession(api, 'session_1', 5, true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 + }) + + it('leaves the exit code clean on a completed status', async () => { + process.exitCode = 0 + const get = vi.fn().mockResolvedValueOnce(session('completed')) + const api = { getAgentSession: get } as unknown as ApiClient + await watchSession(api, 'session_1', 5, true) + expect(process.exitCode).toBe(0) + process.exitCode = 0 + }) }) describe('readConfigFile', () => { @@ -166,6 +185,76 @@ describe('applyConfigOverride', () => { }) }) +describe('buildStartOverride', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-start-override-')) + const write = (name: string, body: string): string => { + const path = join(dir, name) + writeFileSync(path, body) + return path + } + + it('returns undefined when nothing is set', () => { + expect(buildStartOverride({})).toBeUndefined() + }) + + it('maps each sugar flag to its config path', () => { + expect( + buildStartOverride({ + model: 'claude-opus-4-8', + system: 'do the thing', + repo: ['ellipsis-dev/ellipsis', 'solo'], + cpu: 2, + memory: '8GB', + timeout: '30m', + budget: 0.5, + }), + ).toEqual({ + claude: { model: 'claude-opus-4-8', system: 'do the thing' }, + sandbox: { + compute: { cpu: 2, memory: '8GB', timeout: '30m' }, + repositories: [{ owner: 'ellipsis-dev', name: 'ellipsis' }, { name: 'solo' }], + }, + limits: { run: 0.5 }, + }) + }) + + 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', + model: 'claude-opus-4-8', + }), + ).toEqual({ + claude: { model: 'claude-opus-4-8', system: 'base' }, + enabled: false, + }) + }) + + it('uses a file override as the base', () => { + const path = write('base.yaml', 'limits:\n run: 1\n') + expect(buildStartOverride({ configOverrideFile: path, budget: 5 })).toEqual({ + limits: { run: 5 }, + }) + }) + + 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/) + }) + + it('rejects a non-mapping inline override', () => { + expect(() => buildStartOverride({ configOverride: '- a\n- b\n' })).toThrow( + /config override must be a mapping/, + ) + }) + + it('rejects a malformed --repo value', () => { + expect(() => buildStartOverride({ repo: ['a/b/c'] })).toThrow(/--repo must be/) + }) +}) + describe('fetchTranscript', () => { const transcript = (overrides: Partial = {}): SessionTranscript => ({ process_id: 'proc_1',