startup.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. /**
  2. * The one-shot app's command-line provider: it parses the task positional,
  3. * `--session-id`, `--json`, and `--help`, then publishes
  4. * {@link HEADLESS_STARTUP_SERVICE}. The runner is an ordinary consumer whose
  5. * lazy config waits for that service.
  6. * @module @deepseek-ai/dsh-headless/startup
  7. */
  8. import { Command, CommanderError } from 'commander'
  9. import type { Context } from '@deepseek-ai/cordis'
  10. import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
  11. import { boundJsonLine } from './json-stream.ts'
  12. import { internals } from './startup-internals.ts'
  13. /** Stable Cordis plugin name. */
  14. export const name = 'headless-startup'
  15. /** Services required before the task can be resolved. */
  16. export const inject = ['cmdlineArgs']
  17. /** Service provided by this plugin and injected by the one-shot runner. */
  18. export const HEADLESS_STARTUP_SERVICE = 'headlessStartup'
  19. /** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */
  20. export interface HeadlessStartupValues {
  21. /** The task text this invocation asked for; absent when the runner reads stdin. */
  22. task: string | undefined
  23. /** Exact Session identity to adopt; absent for a fresh random identity. */
  24. sessionId: string | undefined
  25. /** Whether stdout carries the machine-readable event stream instead of final text. */
  26. json: boolean
  27. }
  28. /**
  29. * This app's command: the task positional, its options, and its help text.
  30. * @returns a fresh program, so one process can parse more than once (tests).
  31. */
  32. function headlessCommand(): Command {
  33. return new Command()
  34. .name('dsh --profile headless')
  35. .description('Answer one task and exit; the answer goes to stdout and diagnostics to stderr.')
  36. .helpOption('-h, --help', 'show this help')
  37. .option('--json', 'write newline-delimited run events to stdout instead of the final message')
  38. .option('--session-id <id>', 'adopt the persisted Session with this id; an unknown id is an error')
  39. .argument('[task...]', 'the task text; multiple words are joined by spaces, and `-` reads stdin')
  40. .addHelpText('after', `
  41. Examples:
  42. dsh --profile headless "run the tests" answer one task and exit
  43. echo "run the tests" | dsh --profile headless read the task from stdin
  44. dsh --profile headless --json "run the tests" emit machine-readable run events
  45. dsh --profile headless --session-id session-… "continue" resume an existing Session
  46. `)
  47. }
  48. /**
  49. * Whether the raw invocation asks for the machine-readable stream. The scan
  50. * stops at `--` and skips a `--session-id` value, so a literal `--json` used as
  51. * an option value or a positional never installs the JSON error override.
  52. * @param argv - the invocation's raw arguments.
  53. * @returns whether `--json` is a real flag of this invocation.
  54. */
  55. function jsonRequested(argv: readonly string[]): boolean {
  56. for (let index = 0; index < argv.length; index += 1) {
  57. const argument = argv[index]
  58. if (argument === '--') return false
  59. if (argument === '--json') return true
  60. if (argument === '--session-id') index += 1
  61. }
  62. return false
  63. }
  64. /**
  65. * Parse and provide the one-shot task as an ordinary Cordis service. The
  66. * command's action publishes the task; a missing task on an interactive stdin
  67. * is a usage error, so on rejection (and on `--help`) nothing is provided.
  68. * @param ctx - plugin context carrying the command line.
  69. */
  70. export function apply(ctx: Context): void {
  71. const program = headlessCommand()
  72. // The raw snapshot decides the JSON contract: Commander rejects a grammar
  73. // error (an unknown option, a missing option value) before the action runs,
  74. // and such a rejection still owes a --json caller the error event.
  75. if (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) {
  76. program.error = (message: string, errorOptions?: Parameters<Command['error']>[1]): never => {
  77. // The event message matches the runner's runtime errors, which carry no
  78. // commander `error: ` prefix.
  79. const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') })
  80. internals.stdout.write(`${payload}\n`)
  81. // The JSON contract keeps stderr to `dsh:` diagnostics, so commander's
  82. // own print of this message must not run; throwing the same control-flow
  83. // error still leaves through the launcher's exit path.
  84. throw new CommanderError(1, errorOptions?.code ?? 'commander.error', message)
  85. }
  86. }
  87. program.action(() => {
  88. if (program.args.length > 1 && program.args.includes('-')) {
  89. program.error('error: `-` must be the only task argument')
  90. }
  91. const joined = program.args.join(' ')
  92. if (program.args.length > 0 && joined.trim() === '') {
  93. program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
  94. }
  95. const task = program.args.length === 0 ? undefined : joined
  96. if (task === undefined && internals.stdinIsTty()) {
  97. program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
  98. }
  99. const options = program.opts<{ json?: boolean; sessionId?: string }>()
  100. // A SessionId is opaque, so whitespace is part of the identity: validate
  101. // emptiness on the trimmed value but hand the runner the exact string.
  102. const sessionId = options.sessionId
  103. if (sessionId !== undefined && sessionId.trim() === '') {
  104. program.error('error: --session-id requires a non-empty session id')
  105. }
  106. ctx.provide(HEADLESS_STARTUP_SERVICE, {
  107. task,
  108. sessionId,
  109. json: options.json === true,
  110. } satisfies HeadlessStartupValues)
  111. })
  112. parseCmdline(ctx, program)
  113. }