Files

159 lines
7.6 KiB
TypeScript

/**
* The one-shot app's command-line provider: it parses the task positional,
* Session identity, permission options, output mode, and help, then publishes
* {@link HEADLESS_STARTUP_SERVICE}. The runner is an ordinary consumer whose
* lazy config waits for that service.
* @module @deepseek-ai/dsh-headless/startup
*/
import { Command, CommanderError, Option } from 'commander'
import { isAbsolute } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
import { APPROVAL_POLICIES, type ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { boundJsonLine } from './json-stream.ts'
import { internals } from './startup-internals.ts'
/** Stable Cordis plugin name. */
export const name = 'headless-startup'
/** Services required before the task can be resolved. */
export const inject = ['cmdlineArgs']
/** Service provided by this plugin and injected by the one-shot runner. */
export const HEADLESS_STARTUP_SERVICE = 'headlessStartup'
/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */
export interface HeadlessStartupValues {
/** The task text this invocation asked for; absent when the runner reads stdin. */
task: string | undefined
/** Exact Session identity to adopt; absent for a fresh random identity. */
sessionId: string | undefined
/** Whether stdout carries the machine-readable event stream instead of final text. */
json: boolean
/** Permission preset to apply before the task; absent preserves session policy. */
permissionMode?: string
/** Approval behavior applied after the permission preset; absent keeps the current policy. */
approvalPolicy?: ApprovalPolicy
/** Explicit absolute write grants for this invocation; absent adds no directories. */
additionalWritableRoots?: string[]
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Parsed arguments of this headless invocation. */
headlessStartup: HeadlessStartupValues
}
}
/**
* This app's command: the task positional, its options, and its help text.
* @returns a fresh program, so one process can parse more than once (tests).
*/
function headlessCommand(): Command {
return new Command()
.name('dsh --profile headless')
.description('Answer one task and exit; the answer goes to stdout and diagnostics to stderr.')
.helpOption('-h, --help', 'show this help')
.option('--json', 'write newline-delimited run events to stdout instead of the final message')
.option('--session-id <id>', 'adopt the persisted Session with this id; an unknown id is an error')
.option('--permission-mode <preset>', 'select a configured permission preset for this task, including on resume')
.addOption(new Option('--approval-policy <policy>', 'set approval behavior after selecting the permission preset')
.choices([...APPROVAL_POLICIES]))
.option('--add-dir <path>', 'allow an additional physical absolute directory under workspace-write (repeatable)',
(value: string, previous?: string[]) => [...previous ?? [], value])
.argument('[task...]', 'the task text; multiple words are joined by spaces, and `-` reads stdin')
.addHelpText('after', `
Examples:
dsh --profile headless "run the tests" answer one task and exit
echo "run the tests" | dsh --profile headless read the task from stdin
dsh --profile headless --json "run the tests" emit machine-readable run events
dsh --profile headless --session-id session-… "continue" resume an existing Session
dsh --profile headless --permission-mode workspace-write --add-dir /repo/.git "commit the fix"
grant shared Git metadata for this task
`)
}
/**
* Whether the raw invocation asks for the machine-readable stream. The scan
* stops at `--` and skips option operands, so a literal `--json` used as
* an option value or a positional never installs the JSON error override.
* @param argv - the invocation's raw arguments.
* @returns whether `--json` is a real flag of this invocation.
*/
function jsonRequested(argv: readonly string[]): boolean {
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--') return false
if (argument === '--json') return true
if (argument === '--session-id' || argument === '--permission-mode' || argument === '--approval-policy' || argument === '--add-dir') index += 1
}
return false
}
/**
* Parse and provide the one-shot task as an ordinary Cordis service. The
* command's action publishes the task; a missing task on an interactive stdin
* is a usage error, so on rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
*/
export function apply(ctx: Context): void {
const program = headlessCommand()
// The raw snapshot decides the JSON contract: Commander rejects a grammar
// error (an unknown option, a missing option value) before the action runs,
// and such a rejection still owes a --json caller the error event.
if (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) {
program.error = (message: string, errorOptions?: Parameters<Command['error']>[1]): never => {
// The event message matches the runner's runtime errors, which carry no
// commander `error: ` prefix.
const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') })
internals.stdout.write(`${payload}\n`)
// The JSON contract keeps stderr to `dsh:` diagnostics, so commander's
// own print of this message must not run; throwing the same control-flow
// error still leaves through the launcher's exit path.
throw new CommanderError(1, errorOptions?.code ?? 'commander.error', message)
}
}
program.action(() => {
if (program.args.length > 1 && program.args.includes('-')) {
program.error('error: `-` must be the only task argument')
}
const joined = program.args.join(' ')
if (program.args.length > 0 && joined.trim() === '') {
program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
}
const task = program.args.length === 0 ? undefined : joined
if (task === undefined && internals.stdinIsTty()) {
program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
}
const options = program.opts<{
json?: boolean
sessionId?: string
permissionMode?: string
approvalPolicy?: ApprovalPolicy
addDir?: string[]
}>()
// A SessionId is opaque, so whitespace is part of the identity: validate
// emptiness on the trimmed value but hand the runner the exact string.
const sessionId = options.sessionId
if (sessionId !== undefined && sessionId.trim() === '') {
program.error('error: --session-id requires a non-empty session id')
}
if (options.permissionMode !== undefined && options.permissionMode.trim() === '') {
program.error('error: --permission-mode requires a non-empty preset name')
}
if (options.addDir?.some(path => !isAbsolute(path) || path.includes('\0')) === true) {
program.error('error: --add-dir requires an absolute directory path in the execution environment')
}
ctx.provide(HEADLESS_STARTUP_SERVICE, {
task,
sessionId,
json: options.json === true,
...options.permissionMode === undefined ? {} : { permissionMode: options.permissionMode.trim() },
...options.approvalPolicy === undefined ? {} : { approvalPolicy: options.approvalPolicy },
...options.addDir === undefined ? {} : { additionalWritableRoots: [...new Set(options.addDir)] },
} satisfies HeadlessStartupValues)
})
parseCmdline(ctx, program)
}