cli.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * Command parser and one-turn driver for `dsh-cli-demo`. The executable wrapper
  3. * owns process signals; this module owns output, durability, and cleanup.
  4. * @module @deepseek-ai/dsh-cli-demo/cli
  5. */
  6. import { parseArgs } from 'node:util'
  7. import type { Context } from 'cordis'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm'
  10. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  11. import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
  12. const CLI_NAME = 'dsh-cli-demo'
  13. const DEFAULT_CONFIG_PATH = './cordis.yml'
  14. const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
  15. const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p <task> | <task>)\n`
  16. /** Supported CLI output encodings. */
  17. export type OutputFormat = typeof OUTPUT_FORMATS[number]
  18. /** Parsed command: help exits before boot; run carries one validated task. */
  19. export type CliCommand =
  20. | { readonly kind: 'help' }
  21. | {
  22. readonly kind: 'run'
  23. readonly configPath: string
  24. readonly outputFormat: OutputFormat
  25. readonly task: string
  26. }
  27. /** DSH-native final record emitted by JSON modes. */
  28. export interface CliResult {
  29. readonly type: 'result'
  30. readonly sessionId: string
  31. readonly output: string
  32. readonly usage?: TokenUsage
  33. }
  34. /** Options for one turn against the configured top-level agent. */
  35. export interface OneShotOptions {
  36. /** Exactly one nonblank user task. */
  37. readonly task: string
  38. /** Optional signal that cancels the selected agent. */
  39. readonly signal?: AbortSignal
  40. /** Synchronous task-turn observer; a throw cancels the agent and fails the run after flush. */
  41. readonly onEvent?: (sessionId: string, event: SessionEvent) => void
  42. }
  43. /** Injectable process boundaries used by {@link executeCli}. */
  44. export interface CliRuntime {
  45. /** Process cwd for config resolution and `.env` loading. */
  46. readonly cwd?: string
  47. /** Cancellation signal, normally aborted by SIGINT or SIGTERM. */
  48. readonly signal?: AbortSignal
  49. /** Loader boot boundary. */
  50. readonly boot?: (name: string, absoluteConfigPath: string) => Promise<Context>
  51. /** Optional `.env` loader boundary. */
  52. readonly loadEnv?: (name: string, dir: string, warn: (line: string) => void) => void
  53. /** Stdout sink; throws are treated as output failures. */
  54. readonly writeStdout?: (chunk: string) => unknown
  55. /** Stderr diagnostic sink. */
  56. readonly writeStderr?: (chunk: string) => unknown
  57. /** Context disposal boundary. */
  58. readonly dispose?: (ctx: Context) => Promise<void>
  59. }
  60. interface ParsedArguments {
  61. readonly values: {
  62. readonly config?: string
  63. readonly 'output-format'?: string
  64. readonly help?: boolean
  65. readonly prompt?: string
  66. }
  67. readonly positionals: string[]
  68. }
  69. class CliArgumentError extends Error {
  70. constructor(message: string) {
  71. super(message)
  72. this.name = 'CliArgumentError'
  73. }
  74. }
  75. class CliInterruptedError extends Error {
  76. constructor(reason: string) {
  77. super(reason)
  78. this.name = 'CliInterruptedError'
  79. }
  80. }
  81. /** Render an arbitrary value without trusting its type traps or string coercion. */
  82. function renderUnknown(value: unknown): string {
  83. try {
  84. return String(value)
  85. } catch {
  86. return '[unrenderable thrown value]'
  87. }
  88. }
  89. /** Normalize an arbitrary thrown value without letting inspection escape containment. */
  90. function toError(error: unknown): Error {
  91. try {
  92. if (error instanceof Error) return error
  93. } catch {
  94. // A hostile proxy may throw during instanceof; use the total renderer below.
  95. }
  96. return new Error(renderUnknown(error))
  97. }
  98. function interruptionReason(signal: AbortSignal): string {
  99. return signal.reason === undefined ? 'interrupted' : renderUnknown(signal.reason)
  100. }
  101. /**
  102. * Parse the bin arguments and enforce the one-positional-task contract.
  103. * @param args - arguments after the executable name.
  104. * @returns a help or run command.
  105. * @throws {@link CliArgumentError} for unknown flags, invalid formats, or task cardinality.
  106. */
  107. export function parseCliArgs(args: readonly string[]): CliCommand {
  108. let parsed: ParsedArguments
  109. try {
  110. parsed = parseArgs({
  111. args: [...args],
  112. options: {
  113. config: { type: 'string' },
  114. 'output-format': { type: 'string' },
  115. help: { type: 'boolean' },
  116. prompt: { type: 'string', short: 'p' },
  117. },
  118. allowPositionals: true,
  119. strict: true,
  120. })
  121. } catch (error: unknown) {
  122. throw new CliArgumentError(toError(error).message)
  123. }
  124. if (parsed.values.help === true) return { kind: 'help' }
  125. const prompt = parsed.values.prompt
  126. if (prompt !== undefined && parsed.positionals.length > 0) {
  127. throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive')
  128. }
  129. if (prompt === undefined && parsed.positionals.length !== 1) {
  130. throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
  131. }
  132. // Cardinality was checked above, so the fallback index zero exists.
  133. // oxlint-disable-next-line typescript/no-non-null-assertion
  134. const task = prompt ?? parsed.positionals[0]!
  135. if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
  136. const requestedFormat = parsed.values['output-format'] ?? 'text'
  137. if (!OUTPUT_FORMATS.some(format => format === requestedFormat)) {
  138. throw new CliArgumentError(`unsupported output format ${JSON.stringify(requestedFormat)}`)
  139. }
  140. return {
  141. kind: 'run',
  142. configPath: parsed.values.config ?? DEFAULT_CONFIG_PATH,
  143. outputFormat: requestedFormat as OutputFormat,
  144. task,
  145. }
  146. }
  147. function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage {
  148. const next: TokenUsage = {
  149. inputTokens: (total?.inputTokens ?? 0) + step.inputTokens,
  150. outputTokens: (total?.outputTokens ?? 0) + step.outputTokens,
  151. }
  152. for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) {
  153. if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0)
  154. }
  155. return next
  156. }
  157. function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string | undefined {
  158. const blocks = event.data.message.content.filter(block => block.type === 'text')
  159. return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('')
  160. }
  161. /** Wait for startup quiescence while making pre-run cancellation terminal. */
  162. async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<void> {
  163. if (signal === undefined) {
  164. await agent.whenIdle()
  165. return
  166. }
  167. if (signal.aborted) {
  168. agent.cancel({ kind: 'user' })
  169. throw new CliInterruptedError(interruptionReason(signal))
  170. }
  171. await new Promise<void>((resolve, reject) => {
  172. const onAbort = (): void => {
  173. agent.cancel({ kind: 'user' })
  174. reject(new CliInterruptedError(interruptionReason(signal)))
  175. }
  176. signal.addEventListener('abort', onAbort, { once: true })
  177. void agent.whenIdle().then(resolve, reject).finally(() => {
  178. signal.removeEventListener('abort', onAbort)
  179. })
  180. })
  181. }
  182. /**
  183. * Run one owned activity interval on the configured top-level agent, from the
  184. * task's durable enqueue receipt through whole-agent idle.
  185. * @param ctx - settled Loader root containing one agent plus `ctx.sessions`.
  186. * @param options - task, optional cancellation, and optional stream observer.
  187. * @returns the DSH-native result envelope after durable quiescence.
  188. */
  189. export async function runOneShot(ctx: Context, options: OneShotOptions): Promise<CliResult> {
  190. const agents = ctx.get('agents')?.roots() ?? []
  191. const [agent] = agents
  192. if (agent === undefined || agents.length !== 1) {
  193. throw new Error(`config must create exactly one top-level agent, found ${agents.length}`)
  194. }
  195. await waitForStartupIdle(agent, options.signal)
  196. const message = createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })
  197. let received = false
  198. let output = ''
  199. const usageByStep = new Map<string, TokenUsage>()
  200. let outputError: Error | undefined
  201. let interrupted: CliInterruptedError | undefined
  202. const observe = (sessionId: string, event: SessionEvent): void => {
  203. if (outputError !== undefined || options.onEvent === undefined) return
  204. try {
  205. options.onEvent(sessionId, event)
  206. } catch (error: unknown) {
  207. outputError = toError(error)
  208. queueMicrotask(() => {
  209. agent.cancel({ kind: 'user' })
  210. })
  211. }
  212. }
  213. const disposeListener = ctx.on('session/event', (session, event) => {
  214. if (session !== agent.session) return
  215. if (!received) {
  216. if (event.type !== 'agent/inbox/spliced'
  217. || !event.data.inserted.some(inserted => inserted.id === message.id)) return
  218. received = true
  219. }
  220. observe(session.id, event)
  221. if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
  222. usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
  223. }
  224. if (event.type === 'assistant/message') {
  225. output = assistantText(event) ?? output
  226. if (event.data.usage !== undefined) {
  227. usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
  228. }
  229. }
  230. })
  231. const signal = options.signal
  232. let onAbort: (() => void) | undefined
  233. if (signal !== undefined) {
  234. onAbort = (): void => {
  235. interrupted ??= new CliInterruptedError(interruptionReason(signal))
  236. agent.cancel({ kind: 'user' })
  237. }
  238. signal.addEventListener('abort', onAbort, { once: true })
  239. /* v8 ignore next -- closes the race between startup-idle completion and listener registration */
  240. if (signal.aborted) onAbort()
  241. }
  242. try {
  243. if (interrupted === undefined) agent.followup(message)
  244. await agent.whenIdle()
  245. } finally {
  246. if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
  247. disposeListener()
  248. }
  249. await ctx.sessions.flush(agent.session)
  250. if (outputError !== undefined) throw outputError
  251. if (interrupted !== undefined) throw interrupted
  252. const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
  253. return {
  254. type: 'result',
  255. sessionId: agent.session.id,
  256. output,
  257. ...usage === undefined ? {} : { usage },
  258. }
  259. }
  260. function renderResult(outputFormat: OutputFormat, result: CliResult): string {
  261. return outputFormat === 'text' ? `${result.output}\n` : `${JSON.stringify(result)}\n`
  262. }
  263. /**
  264. * Race Loader boot with cancellation without abandoning a context that becomes
  265. * available after the caller has been released. Waiting for that late context
  266. * would recreate the signal hang, so its disposal and diagnostics run detached.
  267. */
  268. async function bootInterruptibly(
  269. start: () => Promise<Context>,
  270. signal: AbortSignal | undefined,
  271. disposeLateContext: (ctx: Context) => Promise<void>,
  272. reportLateDisposalFailure: (error: unknown) => void,
  273. ): Promise<Context> {
  274. if (signal === undefined) return await start()
  275. if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal))
  276. let onAbort!: () => void
  277. const interruptedBoot = new Promise<never>((_resolve, reject) => {
  278. onAbort = (): void => {
  279. reject(new CliInterruptedError(interruptionReason(signal)))
  280. }
  281. signal.addEventListener('abort', onAbort, { once: true })
  282. /* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */
  283. if (signal.aborted) onAbort()
  284. })
  285. const booting = Promise.resolve().then(start)
  286. try {
  287. return await Promise.race([booting, interruptedBoot])
  288. } catch (error: unknown) {
  289. // The awaited race permits the signal to change after the preflight check.
  290. // oxlint-disable-next-line typescript/no-unnecessary-condition
  291. if (signal.aborted) {
  292. void booting.then(
  293. async (lateContext) => {
  294. try {
  295. await disposeLateContext(lateContext)
  296. } catch (error: unknown) {
  297. reportLateDisposalFailure(error)
  298. }
  299. },
  300. () => {},
  301. )
  302. }
  303. throw error
  304. } finally {
  305. signal.removeEventListener('abort', onAbort)
  306. }
  307. }
  308. /**
  309. * Execute one CLI invocation. Argument and boot failures never write stdout;
  310. * context disposal is awaited before return, and its failure does not replace
  311. * an earlier diagnostic.
  312. * @param args - arguments after the executable name.
  313. * @param runtime - optional injected process boundaries for tests and embedding.
  314. * @returns the ordinary process exit code; the thin bin overrides it for Unix signals.
  315. */
  316. export async function executeCli(args: readonly string[], runtime: CliRuntime = {}): Promise<number> {
  317. /* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
  318. const writeStdout = runtime.writeStdout ?? (chunk => process.stdout.write(chunk))
  319. /* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
  320. const writeStderr = runtime.writeStderr ?? (chunk => process.stderr.write(chunk))
  321. let command: CliCommand
  322. try {
  323. command = parseCliArgs(args)
  324. } catch (error: unknown) {
  325. writeStderr(`${CLI_NAME}: ${toError(error).message}\n${USAGE}`)
  326. return 1
  327. }
  328. if (command.kind === 'help') {
  329. writeStdout(USAGE)
  330. return 0
  331. }
  332. /* v8 ignore next -- default process cwd is exercised by the built-bin smoke */
  333. const cwd = runtime.cwd ?? process.cwd()
  334. /* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
  335. const loadEnvironment = runtime.loadEnv ?? loadEnv
  336. /* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
  337. const bootContext = runtime.boot ?? boot
  338. /* v8 ignore next -- default disposal is exercised by the built-bin smoke */
  339. const disposeContext = runtime.dispose ?? (target => target.fiber.dispose())
  340. let ctx: Context | undefined
  341. let exitCode = 1
  342. let diagnostic: string | undefined
  343. try {
  344. loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
  345. ctx = await bootInterruptibly(
  346. () => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)),
  347. runtime.signal,
  348. disposeContext,
  349. error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`),
  350. )
  351. const result = await runOneShot(ctx, {
  352. task: command.task,
  353. ...runtime.signal === undefined ? {} : { signal: runtime.signal },
  354. ...command.outputFormat === 'stream-json'
  355. ? { onEvent: (sessionId: string, event: SessionEvent) => {
  356. writeStdout(`${JSON.stringify({ type: 'session_event', sessionId, event })}\n`)
  357. } }
  358. : {},
  359. })
  360. writeStdout(renderResult(command.outputFormat, result))
  361. exitCode = 0
  362. } catch (error: unknown) {
  363. diagnostic = `${CLI_NAME}: ${toError(error).message}\n`
  364. } finally {
  365. if (ctx !== undefined) {
  366. try {
  367. await disposeContext(ctx)
  368. } catch (error: unknown) {
  369. diagnostic = `${diagnostic ?? ''}${CLI_NAME}: dispose failed: ${toError(error).message}\n`
  370. exitCode = 1
  371. }
  372. }
  373. }
  374. if (diagnostic !== undefined) writeStderr(diagnostic)
  375. return exitCode
  376. }