@aipoch/open-science 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs ADDED
@@ -0,0 +1,1845 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* eslint-disable @typescript-eslint/explicit-function-return-type */
4
+
5
+ import { randomUUID } from 'node:crypto'
6
+ import { closeSync, createWriteStream, existsSync, openSync, realpathSync } from 'node:fs'
7
+ import { chmod, lstat, mkdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises'
8
+ import { basename, dirname, join, resolve } from 'node:path'
9
+ import { Readable } from 'node:stream'
10
+ import { pipeline } from 'node:stream/promises'
11
+ import { text as readStreamText } from 'node:stream/consumers'
12
+ import { spawn } from 'node:child_process'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import {
16
+ candidateConfigRoots,
17
+ findServiceState,
18
+ readWebToken,
19
+ resolveConfigRoot
20
+ } from './config-root.mjs'
21
+ import { codexLoginCommand } from './codex-login.mjs'
22
+ import { connectToOpenScience, OpenScienceApiError } from './index.mjs'
23
+ import { locateBackend } from './locate-backend.mjs'
24
+ import { protectInstallationStartup } from './installation-startup.cjs'
25
+ import { requestExistingRuntimeWeb } from './runtime-control.mjs'
26
+
27
+ const DEFAULT_PORT = 44100
28
+ const START_TIMEOUT_MS = 30_000
29
+ const STOP_TIMEOUT_MS = 15_000
30
+ const MAX_DOWNLOAD_SYMLINK_HOPS = 40
31
+
32
+ const usage = `Usage: open-science <command> [options]
33
+
34
+ Commands:
35
+ version Print the local application version (also --version or -v)
36
+ help [command] Show general or command help
37
+ paths Show local CLI, Node and configuration discovery paths
38
+ init Create the local CLI configuration directory
39
+ start Start the headless backend and localhost web UI
40
+ stop Gracefully stop the backend
41
+ status Show backend status
42
+ url Print the authenticated web URL
43
+ doctor --json Inspect headless readiness
44
+ runtime list List detected Agent runtimes
45
+ runtime install codex Prepare the app-managed Codex runtime
46
+ provider add --type official --vendor openai --model <id> --api-key-env <ENV>
47
+ connector configure literature --openalex-key-env <ENV>
48
+ cli install Install the PATH launcher
49
+ codex login [--force]
50
+ connector list | show <id> | enable <id> | disable <id>
51
+ connector add | update <id> Read configuration JSON from stdin
52
+ connector remove <id> | test <id>
53
+ credential list | add | update <id> Read credential JSON from stdin for writes
54
+ project list
55
+ project create <name> [--description <text>] [--agent-context <text> | --agent-context-file <path>]
56
+ project update <id-or-name> [--name <name>] [--description <text>] [--agent-context <text> | --agent-context-file <path> | --clear-agent-context]
57
+ project session-defaults show <id-or-name> Automation new-session defaults
58
+ project session-defaults update <id-or-name> [session options]
59
+ run --project <id-or-name> (--prompt <text> | --prompt-file <path>) [--compute-host <provider-id>] [--wait]
60
+ run status <run-id>
61
+ run cancel <run-id>
62
+ session status <session-id>
63
+ session config show <session-id>
64
+ session config update <session-id> --revision <number> [session options]
65
+ settings agent-routing show
66
+ settings agent-routing update [routing options]
67
+ plan show <session-id>
68
+ plan approve <session-id> --artifact-version <id> --revision <number>
69
+ plan reject <session-id> --artifact-version <id> --revision <number>
70
+ plan revise <session-id> --feedback <text>
71
+ artifacts list <session-id>
72
+ artifacts download <artifact-id> --output <path>
73
+
74
+ Options:
75
+ --port <port> Web service port (default: 44100)
76
+ --password-store <name> Linux OS vault (gnome-libsecret, kwallet5, kwallet6)
77
+ --config-root <path> Config directory override
78
+ --profile <path> Alias for --config-root (portable CLI profile)
79
+ --project <id-or-name> Project id or exact name
80
+ --session <id> Resume an existing session
81
+ --cwd <path> Working directory for a new or matching existing session
82
+ --prompt <text> Prompt text (or read stdin when omitted)
83
+ --prompt-file <path> Read the prompt from a UTF-8 file
84
+ --agent-context <text> Persistent Project Agent Context
85
+ --agent-context-file <path> Read Project Agent Context from a UTF-8 file
86
+ --clear-agent-context Clear a Project's persistent Agent Context
87
+ --approval-profile <profile> ask, auto, or full (default: ask)
88
+ --provider <provider-id> Configured Main provider
89
+ --model <model-id> Main model
90
+ --provider-default-model Follow the provider-owned default model
91
+ --reasoning-effort <effort> default, low, medium, high, xhigh, or max
92
+ --skill <id> Force-load a skill for this run (repeatable)
93
+ --compute-host <provider-id> Select a Compute Host execution target (repeatable)
94
+ --enable-compute-host <provider-id> Enable a Compute Host without selecting it (repeatable)
95
+ --clear-compute-hosts Clear enabled and selected Compute Hosts
96
+ --plan-first Require an approved Plan before execution
97
+ --auto-review Enable automatic review for this Session
98
+ --no-auto-review Disable automatic review for this Session
99
+ --memory Enable Memory for this Session
100
+ --no-memory Disable Memory for this Session
101
+ --specialist <id-or-name> Bind a new Session to a Specialist
102
+ --delegation <policy> allow or deny new delegated children
103
+ --framework <id> claude-code, opencode, codex, or codebuddy
104
+ --reviewer-inherit | --reviewer-provider <id> --reviewer-model <id> [--reviewer-effort <effort>]
105
+ --subagent-inherit | --subagent-provider <id> --subagent-model <id> [--subagent-effort <effort>]
106
+ --clear-provider | --clear-approval-profile | --clear-auto-review | --clear-memory
107
+ --clear-delegation | --clear-specialist Clear one automation new-session default
108
+ --permission-prompts none Deny unresolved human interactions instead of waiting (run only)
109
+ --wait Wait for the run to finish
110
+ --return-on-attention With --wait, return when the Plan needs approval
111
+ --timeout-ms <ms> Stop waiting after this many milliseconds
112
+ --cancel-on-timeout Cancel the server run when --timeout-ms expires
113
+ --jsonl With run --wait, stream one machine-readable event per line
114
+ --output <path> Artifact download destination
115
+ --credential-store <os|file> Settings credential storage (Linux headless; start only)
116
+ --no-open Do not open the browser after start
117
+ --no-sandbox Disable Chromium's process sandbox (security risk; start only)
118
+ --force Sign in again even when Codex credentials already exist
119
+ --json Emit one machine-readable result
120
+ -v, --version Print the local application version and exit
121
+ -h, --help Show this help`
122
+
123
+ // Flags that take a value, mapped to their camelCase option key (explicit so new hyphenated flags
124
+ // can't collide the way a generic slice/replace would).
125
+ const VALUE_OPTIONS = {
126
+ '--credential-store': 'credentialStore',
127
+ '--port': 'port',
128
+ '--app-path': 'appPath',
129
+ '--password-store': 'passwordStore',
130
+ '--config-root': 'configRoot',
131
+ '--profile': 'configRoot',
132
+ '--type': 'providerType',
133
+ '--vendor': 'vendor',
134
+ '--api-key-env': 'apiKeyEnv',
135
+ '--openalex-key-env': 'openAlexKeyEnv',
136
+ '--project': 'project',
137
+ '--session': 'session',
138
+ '--cwd': 'cwd',
139
+ '--prompt': 'prompt',
140
+ '--prompt-file': 'promptFile',
141
+ '--approval-profile': 'approvalProfile',
142
+ '--permission-prompts': 'permissionPrompts',
143
+ '--provider': 'provider',
144
+ '--model': 'model',
145
+ '--reasoning-effort': 'reasoningEffort',
146
+ '--framework': 'framework',
147
+ '--reviewer-provider': 'reviewerProvider',
148
+ '--reviewer-model': 'reviewerModel',
149
+ '--reviewer-effort': 'reviewerEffort',
150
+ '--subagent-provider': 'subagentProvider',
151
+ '--subagent-model': 'subagentModel',
152
+ '--subagent-effort': 'subagentEffort',
153
+ '--specialist': 'specialist',
154
+ '--delegation': 'delegation',
155
+ '--artifact-version': 'artifactVersion',
156
+ '--revision': 'revision',
157
+ '--feedback': 'feedback',
158
+ '--timeout-ms': 'timeoutMs',
159
+ '--name': 'name',
160
+ '--description': 'description',
161
+ '--agent-context': 'agentContext',
162
+ '--agent-context-file': 'agentContextFile',
163
+ '--output': 'output'
164
+ }
165
+
166
+ const TASK_COMMANDS = new Set([
167
+ 'doctor',
168
+ 'runtime',
169
+ 'project',
170
+ 'run',
171
+ 'session',
172
+ 'settings',
173
+ 'plan',
174
+ 'artifacts',
175
+ 'connector',
176
+ 'credential',
177
+ 'runtime',
178
+ 'provider',
179
+ 'cli'
180
+ ])
181
+ const GROUP_COMMANDS = new Set([
182
+ 'codex',
183
+ 'runtime',
184
+ 'project',
185
+ 'session',
186
+ 'settings',
187
+ 'plan',
188
+ 'artifacts',
189
+ 'connector',
190
+ 'credential',
191
+ 'runtime',
192
+ 'provider',
193
+ 'cli'
194
+ ])
195
+ // Project create, update, and session-defaults intentionally remain unbounded because their
196
+ // positional Project names may contain multiple unquoted words.
197
+ const POSITIONAL_LIMITS = new Map([
198
+ ['version', 0],
199
+ ['help', 1],
200
+ ['paths', 0],
201
+ ['doctor', 0],
202
+ ['runtime list', 0],
203
+ ['runtime install', 1],
204
+ ['provider add', 0],
205
+ ['connector configure', 1],
206
+ ['cli install', 0],
207
+ ['connector list', 0],
208
+ ['connector show', 1],
209
+ ['connector enable', 1],
210
+ ['connector disable', 1],
211
+ ['connector add', 0],
212
+ ['connector update', 1],
213
+ ['connector remove', 1],
214
+ ['connector test', 1],
215
+ ['credential list', 0],
216
+ ['credential add', 0],
217
+ ['credential update', 1],
218
+
219
+ ['start', 0],
220
+ ['init', 0],
221
+ ['stop', 0],
222
+ ['status', 0],
223
+ ['url', 0],
224
+ ['codex login', 0],
225
+ ['project list', 0],
226
+ ['run', 0],
227
+ ['run status', 1],
228
+ ['run cancel', 1],
229
+ ['session status', 1],
230
+ ['session config', 2],
231
+ ['settings agent-routing', 1],
232
+ ['plan show', 1],
233
+ ['plan approve', 1],
234
+ ['plan reject', 1],
235
+ ['plan revise', 1],
236
+ ['artifacts list', 1],
237
+ ['artifacts download', 1]
238
+ ])
239
+
240
+ export class CliUsageError extends Error {
241
+ constructor(message) {
242
+ super(message)
243
+ this.name = 'CliUsageError'
244
+ this.code = 'invalid_cli_usage'
245
+ this.exitCode = 2
246
+ }
247
+ }
248
+
249
+ const parsePortOption = (value) => {
250
+ const normalized = value.trim()
251
+ const port = Number(normalized)
252
+ if (!/^\d+$/.test(normalized) || !Number.isInteger(port) || port < 0 || port > 65535) {
253
+ throw new CliUsageError(`Invalid port: ${value}`)
254
+ }
255
+ return port
256
+ }
257
+
258
+ const assertPositionalLimit = (command, subcommand, positionals) => {
259
+ const commandPath = [command, subcommand].filter(Boolean).join(' ')
260
+ const limit = POSITIONAL_LIMITS.get(commandPath)
261
+ if (limit === undefined || positionals.length <= limit) return
262
+ if (limit === 0) throw new CliUsageError(`${commandPath} accepts no arguments.`)
263
+ throw new CliUsageError(
264
+ `${commandPath} accepts ${limit === 1 ? 'one argument' : 'two arguments'}.`
265
+ )
266
+ }
267
+
268
+ export const parseCliArgs = (argv) => {
269
+ const args = [...argv]
270
+ const requestedCommand = args.shift()
271
+ const command =
272
+ requestedCommand === '--version' || requestedCommand === '-v' ? 'version' : requestedCommand
273
+ const localOptions =
274
+ command === 'version'
275
+ ? ['--json', '--help', '-h']
276
+ : command === 'paths'
277
+ ? ['--json', '--help', '-h', '--config-root', '--profile']
278
+ : command === 'help'
279
+ ? ['--help', '-h']
280
+ : undefined
281
+ const subcommand =
282
+ (GROUP_COMMANDS.has(command) && args[0] && !args[0].startsWith('-')) ||
283
+ (command === 'run' && (args[0] === 'status' || args[0] === 'cancel'))
284
+ ? args.shift()
285
+ : undefined
286
+ const options = {
287
+ open: true,
288
+ json: false,
289
+ ...(TASK_COMMANDS.has(command) ? { jsonl: false } : {}),
290
+ ...(command === 'run' ? { wait: false } : {})
291
+ }
292
+ const positionals = []
293
+ while (args.length > 0) {
294
+ const arg = args.shift()
295
+ if (localOptions && arg.startsWith('-') && !localOptions.includes(arg)) {
296
+ throw new CliUsageError(`Unknown option for ${command}: ${arg}`)
297
+ }
298
+ if (arg.startsWith('--credential-store=')) {
299
+ if (options.credentialStore !== undefined)
300
+ throw new CliUsageError('Specify --credential-store only once.')
301
+ options.credentialStore = arg.slice('--credential-store='.length)
302
+ } else if (arg === '--no-open') options.open = false
303
+ else if (arg.startsWith('--password-store='))
304
+ options.passwordStore = arg.slice('--password-store='.length)
305
+ else if (arg === '--no-sandbox') options.noSandbox = true
306
+ else if (arg === '--json') options.json = true
307
+ else if (arg === '--force') options.force = true
308
+ else if (arg === '--jsonl') options.jsonl = true
309
+ else if (arg === '--wait') options.wait = true
310
+ else if (arg === '--return-on-attention') options.returnOnAttention = true
311
+ else if (arg === '--plan-first') options.planFirst = true
312
+ else if (arg === '--provider-default-model') options.providerDefaultModel = true
313
+ else if (arg === '--clear-compute-hosts') options.clearComputeHosts = true
314
+ else if (arg === '--memory') {
315
+ if (options.memoryEnabled === false) {
316
+ throw new CliUsageError('Use only one of --memory or --no-memory.')
317
+ }
318
+ options.memoryEnabled = true
319
+ } else if (arg === '--no-memory') {
320
+ if (options.memoryEnabled === true) {
321
+ throw new CliUsageError('Use only one of --memory or --no-memory.')
322
+ }
323
+ options.memoryEnabled = false
324
+ } else if (arg === '--reviewer-inherit') options.reviewerInherit = true
325
+ else if (arg === '--subagent-inherit') options.subagentInherit = true
326
+ else if (arg === '--clear-provider') options.clearProvider = true
327
+ else if (arg === '--clear-approval-profile') options.clearApprovalProfile = true
328
+ else if (arg === '--clear-auto-review') options.clearAutoReview = true
329
+ else if (arg === '--clear-memory') options.clearMemory = true
330
+ else if (arg === '--clear-delegation') options.clearDelegation = true
331
+ else if (arg === '--clear-specialist') options.clearSpecialist = true
332
+ else if (arg === '--auto-review') {
333
+ if (options.autoReviewEnabled === false) {
334
+ throw new CliUsageError('Use only one of --auto-review or --no-auto-review.')
335
+ }
336
+ options.autoReviewEnabled = true
337
+ } else if (arg === '--no-auto-review') {
338
+ if (options.autoReviewEnabled === true) {
339
+ throw new CliUsageError('Use only one of --auto-review or --no-auto-review.')
340
+ }
341
+ options.autoReviewEnabled = false
342
+ } else if (arg === '--cancel-on-timeout') options.cancelOnTimeout = true
343
+ else if (arg === '--clear-agent-context') options.clearAgentContext = true
344
+ else if (arg === '--skill') {
345
+ const value = args.shift()
346
+ if (!value) throw new CliUsageError('--skill requires a value.')
347
+ options.skills = [...(options.skills ?? []), value]
348
+ } else if (arg === '--compute-host') {
349
+ const value = args.shift()
350
+ if (!value) throw new CliUsageError('--compute-host requires a value.')
351
+ options.computeHosts = [...(options.computeHosts ?? []), value]
352
+ } else if (arg === '--enable-compute-host') {
353
+ const value = args.shift()
354
+ if (!value) throw new CliUsageError('--enable-compute-host requires a value.')
355
+ options.enabledComputeHosts = [...(options.enabledComputeHosts ?? []), value]
356
+ } else if (arg === '-h' || arg === '--help') options.help = true
357
+ else if (Object.hasOwn(VALUE_OPTIONS, arg)) {
358
+ const value = args.shift()
359
+ if (!value) throw new CliUsageError(`${arg} requires a value.`)
360
+ if (arg === '--credential-store' && options.credentialStore !== undefined)
361
+ throw new CliUsageError('Specify --credential-store only once.')
362
+ options[VALUE_OPTIONS[arg]] = value
363
+ } else if (arg.startsWith('-')) {
364
+ throw new CliUsageError(`Unknown option: ${arg}`)
365
+ } else {
366
+ positionals.push(arg)
367
+ }
368
+ }
369
+ const parsed = {
370
+ command,
371
+ ...(subcommand ? { subcommand } : {}),
372
+ ...(positionals.length ? { positionals } : {}),
373
+ options
374
+ }
375
+ if (localOptions) {
376
+ assertPositionalLimit(command, subcommand, positionals)
377
+ return parsed
378
+ }
379
+ // Help is local: it must not require execution-only options or a running backend.
380
+ if (options.help) return parsed
381
+ if (options.port !== undefined) {
382
+ options.port = parsePortOption(options.port)
383
+ }
384
+ if (options.permissionPrompts !== undefined && options.permissionPrompts !== 'none') {
385
+ throw new CliUsageError('--permission-prompts must be none.')
386
+ }
387
+ if (options.permissionPrompts === 'none' && options.planFirst) {
388
+ throw new CliUsageError('--plan-first cannot be combined with --permission-prompts none.')
389
+ }
390
+ if (options.approvalProfile && !['ask', 'auto', 'full'].includes(options.approvalProfile)) {
391
+ throw new CliUsageError(`Invalid approval profile: ${options.approvalProfile}`)
392
+ }
393
+ if (options.timeoutMs !== undefined) {
394
+ const timeoutMs = Number(options.timeoutMs)
395
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
396
+ throw new CliUsageError(`Invalid timeout: ${options.timeoutMs}`)
397
+ }
398
+ options.timeoutMs = timeoutMs
399
+ }
400
+ if (options.revision !== undefined) {
401
+ const revision = Number(options.revision)
402
+ if (!Number.isInteger(revision) || revision < 0) {
403
+ throw new CliUsageError(`Invalid revision: ${options.revision}`)
404
+ }
405
+ options.revision = revision
406
+ }
407
+ if (options.delegation && !['allow', 'deny'].includes(options.delegation)) {
408
+ throw new CliUsageError(`Invalid delegation policy: ${options.delegation}`)
409
+ }
410
+ for (const [label, value] of [
411
+ ['--reasoning-effort', options.reasoningEffort],
412
+ ['--reviewer-effort', options.reviewerEffort],
413
+ ['--subagent-effort', options.subagentEffort]
414
+ ]) {
415
+ if (
416
+ value !== undefined &&
417
+ !['default', 'low', 'medium', 'high', 'xhigh', 'max'].includes(value)
418
+ ) {
419
+ throw new CliUsageError(`Invalid ${label}: ${value}`)
420
+ }
421
+ }
422
+ if (
423
+ options.framework !== undefined &&
424
+ !['claude-code', 'opencode', 'codex', 'codebuddy'].includes(options.framework)
425
+ ) {
426
+ throw new CliUsageError(`Invalid framework: ${options.framework}`)
427
+ }
428
+ if (options.model !== undefined && options.providerDefaultModel) {
429
+ throw new CliUsageError('Use only one of --model or --provider-default-model.')
430
+ }
431
+ if (
432
+ options.provider !== undefined &&
433
+ options.model === undefined &&
434
+ !options.providerDefaultModel
435
+ ) {
436
+ throw new CliUsageError('--provider requires --model or --provider-default-model.')
437
+ }
438
+ if (
439
+ options.clearComputeHosts &&
440
+ (options.computeHosts !== undefined || options.enabledComputeHosts !== undefined)
441
+ ) {
442
+ throw new CliUsageError(
443
+ 'Use only one of Compute Host selection options or --clear-compute-hosts.'
444
+ )
445
+ }
446
+ if (options.json && options.jsonl) {
447
+ throw new CliUsageError('Use only one of --json or --jsonl.')
448
+ }
449
+ if (command === 'doctor' && !options.json) {
450
+ throw new CliUsageError('doctor requires --json.')
451
+ }
452
+ if (command === 'runtime' && subcommand !== 'list' && subcommand !== 'install') {
453
+ throw new CliUsageError(`Unknown command: runtime ${subcommand ?? ''}`.trimEnd())
454
+ }
455
+ if (options.json && (command === 'start' || command === 'url')) {
456
+ throw new CliUsageError(`--json is not supported for ${command}.`)
457
+ }
458
+ if (options.cwd !== undefined && !options.cwd.trim()) {
459
+ throw new CliUsageError('--cwd requires a non-empty path.')
460
+ }
461
+ if (options.cwd && (command !== 'run' || subcommand)) {
462
+ throw new CliUsageError('--cwd requires run.')
463
+ }
464
+ if (options.jsonl && (command !== 'run' || subcommand || !options.wait)) {
465
+ throw new CliUsageError('--jsonl requires run --wait.')
466
+ }
467
+ if (options.timeoutMs !== undefined && (command !== 'run' || subcommand || !options.wait)) {
468
+ throw new CliUsageError('--timeout-ms requires run --wait.')
469
+ }
470
+ if (options.returnOnAttention && (command !== 'run' || subcommand || !options.wait)) {
471
+ throw new CliUsageError('--return-on-attention requires run --wait.')
472
+ }
473
+ if (options.cancelOnTimeout && options.timeoutMs === undefined) {
474
+ throw new CliUsageError('--cancel-on-timeout requires --timeout-ms.')
475
+ }
476
+ if (
477
+ options.credentialStore !== undefined &&
478
+ (command !== 'start' || !['os', 'file'].includes(options.credentialStore))
479
+ ) {
480
+ throw new CliUsageError('--credential-store requires start and a value of os or file.')
481
+ }
482
+ if (
483
+ options.passwordStore !== undefined &&
484
+ (command !== 'start' ||
485
+ !['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6'].includes(options.passwordStore))
486
+ )
487
+ throw new CliUsageError('--password-store requires start and a supported Linux OS vault.')
488
+ if (options.credentialStore === 'file' && process.platform !== 'linux') {
489
+ throw new CliUsageError('--credential-store=file is supported only on Linux.')
490
+ }
491
+ if (options.noSandbox && command !== 'start') {
492
+ throw new CliUsageError('--no-sandbox requires start.')
493
+ }
494
+ if (options.force && (command !== 'codex' || subcommand !== 'login')) {
495
+ throw new CliUsageError('--force requires codex login.')
496
+ }
497
+ const isProjectCreate = command === 'project' && subcommand === 'create'
498
+ const isProjectUpdate = command === 'project' && subcommand === 'update'
499
+ const agentContextSources = [
500
+ options.agentContext !== undefined,
501
+ options.agentContextFile !== undefined,
502
+ options.clearAgentContext === true
503
+ ].filter(Boolean)
504
+ if (agentContextSources.length > 1) {
505
+ throw new CliUsageError('Use only one Agent Context source.')
506
+ }
507
+ if (agentContextSources.length > 0 && !isProjectCreate && !isProjectUpdate) {
508
+ throw new CliUsageError('Agent Context options require project create or project update.')
509
+ }
510
+ if (options.clearAgentContext && !isProjectUpdate) {
511
+ throw new CliUsageError('--clear-agent-context requires project update.')
512
+ }
513
+ if (options.name !== undefined && !isProjectUpdate) {
514
+ throw new CliUsageError('--name requires project update.')
515
+ }
516
+ if (options.name !== undefined && !options.name.trim()) {
517
+ throw new CliUsageError('--name requires a non-empty value.')
518
+ }
519
+ if (options.description !== undefined && !isProjectCreate && !isProjectUpdate) {
520
+ throw new CliUsageError('--description requires project create or project update.')
521
+ }
522
+ if (options.agentContext !== undefined) {
523
+ const context = options.agentContext.trim()
524
+ if (!context) throw new CliUsageError('Agent Context must not be empty.')
525
+ if (context.length > 16_000) {
526
+ throw new CliUsageError('Agent Context must not exceed 16000 characters.')
527
+ }
528
+ options.agentContext = context
529
+ }
530
+ if (command === 'codex' && subcommand === 'login') {
531
+ if (options.json || options.jsonl) {
532
+ throw new CliUsageError('codex login does not support machine-readable output.')
533
+ }
534
+ }
535
+ const sessionConfigAction = command === 'session' && subcommand === 'config' && positionals[0]
536
+ const projectDefaultsAction =
537
+ command === 'project' && subcommand === 'session-defaults' && positionals[0]
538
+ const agentRoutingAction =
539
+ command === 'settings' && subcommand === 'agent-routing' && positionals[0]
540
+ const isSessionConfigUpdate = sessionConfigAction === 'update'
541
+ const isProjectDefaultsUpdate = projectDefaultsAction === 'update'
542
+ const isAgentRoutingUpdate = agentRoutingAction === 'update'
543
+ const isRun = command === 'run' && !subcommand
544
+ if (isRun && options.session && options.enabledComputeHosts !== undefined) {
545
+ throw new CliUsageError(
546
+ '--enable-compute-host cannot update an existing Session; use session config update.'
547
+ )
548
+ }
549
+ if (isRun && options.session && options.clearComputeHosts) {
550
+ throw new CliUsageError(
551
+ '--clear-compute-hosts cannot update an existing Session; use session config update.'
552
+ )
553
+ }
554
+ const runOnlyOptions = [
555
+ ['--plan-first', options.planFirst],
556
+ ['--permission-prompts', options.permissionPrompts !== undefined],
557
+ ['--skill', options.skills !== undefined]
558
+ ]
559
+ for (const [label, present] of runOnlyOptions) {
560
+ if (present && !isRun) {
561
+ throw new CliUsageError(`${label} requires run.`)
562
+ }
563
+ }
564
+ const sessionOptionPresent =
565
+ options.provider !== undefined ||
566
+ (options.model !== undefined && !(command === 'provider' && subcommand === 'add')) ||
567
+ options.providerDefaultModel ||
568
+ options.reasoningEffort !== undefined ||
569
+ options.approvalProfile !== undefined ||
570
+ options.autoReviewEnabled !== undefined ||
571
+ options.memoryEnabled !== undefined ||
572
+ options.delegation !== undefined ||
573
+ options.computeHosts !== undefined ||
574
+ options.enabledComputeHosts !== undefined ||
575
+ options.clearComputeHosts
576
+ if (sessionOptionPresent && !isRun && !isSessionConfigUpdate && !isProjectDefaultsUpdate) {
577
+ throw new CliUsageError(
578
+ 'Session configuration options require run or a defaults/config update.'
579
+ )
580
+ }
581
+ if (options.specialist !== undefined && !isRun && !isProjectDefaultsUpdate) {
582
+ throw new CliUsageError('--specialist requires run or project session-defaults update.')
583
+ }
584
+ const projectClearPresent =
585
+ options.clearProvider ||
586
+ options.clearApprovalProfile ||
587
+ options.clearAutoReview ||
588
+ options.clearMemory ||
589
+ options.clearDelegation ||
590
+ options.clearSpecialist
591
+ if (projectClearPresent && !isProjectDefaultsUpdate) {
592
+ throw new CliUsageError(
593
+ 'Project default clear options require project session-defaults update.'
594
+ )
595
+ }
596
+ const routingPresent =
597
+ options.framework !== undefined ||
598
+ options.reviewerProvider !== undefined ||
599
+ options.reviewerModel !== undefined ||
600
+ options.reviewerEffort !== undefined ||
601
+ options.reviewerInherit ||
602
+ options.subagentProvider !== undefined ||
603
+ options.subagentModel !== undefined ||
604
+ options.subagentEffort !== undefined ||
605
+ options.subagentInherit
606
+ if (routingPresent && !isAgentRoutingUpdate) {
607
+ throw new CliUsageError('Agent routing options require settings agent-routing update.')
608
+ }
609
+ if (
610
+ command !== 'plan' &&
611
+ !isSessionConfigUpdate &&
612
+ (options.artifactVersion !== undefined ||
613
+ (options.revision !== undefined && !isSessionConfigUpdate) ||
614
+ options.feedback !== undefined)
615
+ ) {
616
+ throw new CliUsageError('Plan response options require a plan command.')
617
+ }
618
+ assertPositionalLimit(command, subcommand, positionals)
619
+ return parsed
620
+ }
621
+
622
+ export const isProcessAlive = (pid) => {
623
+ if (!Number.isInteger(pid) || pid <= 0) return false
624
+ try {
625
+ process.kill(pid, 0)
626
+ return true
627
+ } catch (error) {
628
+ return error.code === 'EPERM'
629
+ }
630
+ }
631
+
632
+ export const initCommand = async (options, deps = DEFAULT_DEPS) => {
633
+ const backend = await (deps.locateBackend ?? locateBackend)({ required: false })
634
+ const configRoot = resolveConfigRoot({
635
+ override: options.configRoot,
636
+ packaged: !backend.development
637
+ })
638
+ await mkdir(configRoot, { recursive: true, mode: 0o700 })
639
+ const result = { configRoot, initialized: true }
640
+ deps.log(options.json ? JSON.stringify(result) : `Open-Science is initialized at ${configRoot}.`)
641
+ return result
642
+ }
643
+
644
+ const authenticatedUrl = async (state, deps = DEFAULT_DEPS) => {
645
+ const token = await deps.readWebToken(state.configRoot)
646
+ return `http://127.0.0.1:${state.port}/?token=${encodeURIComponent(token)}`
647
+ }
648
+
649
+ const healthCheck = async (state, deps = DEFAULT_DEPS) => {
650
+ if (!state || !deps.isAlive(state.pid)) return false
651
+ try {
652
+ const token = await deps.readWebToken(state.configRoot)
653
+ const response = await deps.fetch(`http://127.0.0.1:${state.port}/api/bootstrap`, {
654
+ headers: { authorization: `Bearer ${token}` },
655
+ signal: AbortSignal.timeout(1_500)
656
+ })
657
+ return response.ok
658
+ } catch {
659
+ return false
660
+ }
661
+ }
662
+
663
+ const sleep = (milliseconds) =>
664
+ new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))
665
+
666
+ const waitForState = async (
667
+ configRoot,
668
+ deps = DEFAULT_DEPS,
669
+ timeoutMs = START_TIMEOUT_MS,
670
+ signal
671
+ ) => {
672
+ const deadline = deps.now() + timeoutMs
673
+ while (deps.now() < deadline && !signal?.aborted) {
674
+ const state = await deps.findServiceState({ override: configRoot })
675
+ if (signal?.aborted) return undefined
676
+ if (await healthCheck(state, deps)) return state
677
+ if (deps.requestExistingRuntimeWeb)
678
+ await deps.requestExistingRuntimeWeb({ configRoot }, deps.fetch)
679
+ if (signal?.aborted) return undefined
680
+ await deps.sleep(250)
681
+ }
682
+ return undefined
683
+ }
684
+
685
+ // Keep the health poll and child-process lifecycle coupled: a fatal Node startup error must not
686
+ // look like a slow service startup and consume the full CLI timeout.
687
+ export const waitForStartup = async (
688
+ configRoot,
689
+ child,
690
+ deps = DEFAULT_DEPS,
691
+ timeoutMs = START_TIMEOUT_MS
692
+ ) => {
693
+ const abortController = new AbortController()
694
+ let cleanup = () => {}
695
+ const childFailure = new Promise((resolveFailure) => {
696
+ const onExit = (code, signal) => {
697
+ // EX_TEMPFAIL means another entry won directory ownership. Wait for that owner to serve.
698
+ if ((code === 0 || code === 75) && signal === null) return
699
+ abortController.abort()
700
+ resolveFailure({ kind: 'exit', code, signal })
701
+ }
702
+ const onError = (error) => {
703
+ abortController.abort()
704
+ resolveFailure({ kind: 'error', error })
705
+ }
706
+ child.once('exit', onExit)
707
+ child.once('error', onError)
708
+ cleanup = () => {
709
+ child.off('exit', onExit)
710
+ child.off('error', onError)
711
+ }
712
+ })
713
+ const healthy = waitForState(configRoot, deps, timeoutMs, abortController.signal).then((state) =>
714
+ state ? { kind: 'ready', state } : { kind: 'timeout' }
715
+ )
716
+ try {
717
+ return await Promise.race([healthy, childFailure])
718
+ } finally {
719
+ abortController.abort()
720
+ cleanup()
721
+ }
722
+ }
723
+
724
+ const openBrowser = (url) => {
725
+ const [command, args] =
726
+ process.platform === 'win32'
727
+ ? ['cmd', ['/c', 'start', '', url]]
728
+ : process.platform === 'darwin'
729
+ ? ['open', [url]]
730
+ : ['xdg-open', [url]]
731
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
732
+ child.unref()
733
+ }
734
+
735
+ // The real I/O the commands use, bundled so tests can substitute fakes. Declared after the helpers it
736
+ // references so its initializer sees them; commands take `deps = DEFAULT_DEPS`, so production callers
737
+ // pass nothing and get these.
738
+ const DEFAULT_DEPS = {
739
+ requestExistingRuntimeWeb,
740
+ findServiceState: (options) => findServiceState(options),
741
+ readWebToken: (configRoot) => readWebToken(configRoot),
742
+ isAlive: isProcessAlive,
743
+ fetch: (input, init) => fetch(input, init),
744
+ sleep,
745
+ now: () => Date.now(),
746
+ log: (...args) => console.log(...args),
747
+ warn: (...args) => console.warn(...args)
748
+ }
749
+
750
+ // Waits for the daemon to exit after an authenticated graceful shutdown request. A PID proves only
751
+ // that some process is alive, not that it is still the daemon recorded in a potentially stale state
752
+ // file, so this function deliberately never signals it. Returns true iff the PID is no longer alive.
753
+ export const terminateDaemon = async (pid, opts) => {
754
+ const { isAlive, sleep: sleepFn, now, gracefulTimeoutMs } = opts
755
+ const deadline = now() + gracefulTimeoutMs
756
+ while (now() < deadline && isAlive(pid)) await sleepFn(250)
757
+ return !isAlive(pid)
758
+ }
759
+
760
+ // Waits for an attached web service (one riding on the desktop app) to stop responding after a graceful
761
+ // shutdown request. Returns true once it is no longer healthy. Never kills the pid — that process is the
762
+ // user's app, not a daemon this CLI owns, so if it refuses to stop we fail loudly rather than force it.
763
+ const waitForWebServiceStopped = async (state, deps, timeoutMs) => {
764
+ const deadline = deps.now() + timeoutMs
765
+ while (deps.now() < deadline) {
766
+ if (!(await healthCheck(state, deps))) return true
767
+ await deps.sleep(250)
768
+ }
769
+ return false
770
+ }
771
+
772
+ const readLogTail = async (logPath) => {
773
+ try {
774
+ const text = await readFile(logPath, 'utf8')
775
+ return text.split(/\r?\n/).slice(-30).join('\n')
776
+ } catch {
777
+ return ''
778
+ }
779
+ }
780
+
781
+ export const openLaunchLog = (logPath) => openSync(logPath, 'wx', 0o600)
782
+
783
+ export const buildBackendLaunchArgs = (backend, options, port) => [
784
+ backend.entry,
785
+ ...(backend.development ? ['--development'] : []),
786
+ ...(options.credentialStore ? [`--credential-store=${options.credentialStore}`] : []),
787
+ ...(options.passwordStore ? [`--password-store=${options.passwordStore}`] : []),
788
+ `--serve=${port}`
789
+ ]
790
+
791
+ export const formatStartupFailure = (outcome, logTail) => {
792
+ if (outcome.kind === 'error') return `Could not start Open-Science: ${outcome.error.message}`
793
+
794
+ const exitStatus = outcome.signal
795
+ ? ` after receiving ${outcome.signal}`
796
+ : ` with exit code ${outcome.code ?? 'unknown'}`
797
+ return `Open-Science exited before becoming healthy${exitStatus}.${logTail ? `\n\n${logTail}` : ''}`
798
+ }
799
+
800
+ export const startCommand = async (options, deps = DEFAULT_DEPS) => {
801
+ const existing = await findCurrentState(options, deps)
802
+ if (await healthCheck(existing, deps)) {
803
+ if (options.credentialStore !== undefined)
804
+ throw new Error(
805
+ 'Open-Science is already running. Stop it before selecting a credential store.'
806
+ )
807
+ const url = await authenticatedUrl(existing, deps)
808
+ deps.log(`Open-Science is already running (PID ${existing.pid}).`)
809
+ if (options.open) openBrowser(url)
810
+ else deps.log('Run "open-science url" to print a browser login URL.')
811
+ return { state: existing, started: false }
812
+ }
813
+
814
+ if (options.noSandbox)
815
+ throw new Error(
816
+ '--no-sandbox does not apply to the Node backend. Notebook and process sandbox protections remain enabled.'
817
+ )
818
+ const reusedRoot = await requestExistingRuntimeWeb(options, deps.fetch)
819
+ if (reusedRoot) {
820
+ const state = await findCurrentState({ ...options, configRoot: reusedRoot }, deps)
821
+ if (await healthCheck(state, deps)) {
822
+ deps.log(`Open-Science is already running (PID ${state.pid}).`)
823
+ if (options.open) openBrowser(await authenticatedUrl(state, deps))
824
+ else deps.log('Run "open-science url" to print a browser login URL.')
825
+ return { state, started: false }
826
+ }
827
+ }
828
+ if (options.appPath)
829
+ throw new CliUsageError(
830
+ '--app-path is no longer used for Node startup. Install the standalone backend package.'
831
+ )
832
+ const backend = await (deps.locateBackend ?? locateBackend)()
833
+ const configRoot = resolveConfigRoot({
834
+ packaged: !backend.development,
835
+ override: options.configRoot
836
+ })
837
+ await mkdir(configRoot, { recursive: true, mode: 0o700 })
838
+ const logPath = join(configRoot, `cli-daemon-${randomUUID()}.log`)
839
+ const logFd = openLaunchLog(logPath)
840
+ const environmentPort = process.env.OPEN_SCIENCE_WEB_PORT?.trim()
841
+ const port = options.port ?? (environmentPort ? parsePortOption(environmentPort) : DEFAULT_PORT)
842
+ const childEnv = {
843
+ ...process.env,
844
+ OPEN_SCIENCE_CONFIG_ROOT: configRoot,
845
+ OPEN_SCIENCE_WEB_PORT: String(port)
846
+ }
847
+ delete childEnv.ELECTRON_RUN_AS_NODE
848
+ if (options.credentialStore === 'file')
849
+ deps.warn(
850
+ 'Settings credentials will be stored unencrypted in local files. Use this option at every start; existing encrypted credentials are not migrated. Compute credentials still require OS secure storage.'
851
+ )
852
+ let installationLease
853
+ let logClosed = false
854
+ let startup
855
+ try {
856
+ installationLease = protectInstallationStartup(backend.entry, configRoot)
857
+ const child = spawn(backend.command, buildBackendLaunchArgs(backend, options, port), {
858
+ detached: true,
859
+ stdio: ['ignore', logFd, logFd],
860
+ windowsHide: true,
861
+ env: childEnv
862
+ })
863
+ const startupPromise = waitForStartup(configRoot, child, deps)
864
+ child.unref()
865
+ closeSync(logFd)
866
+ logClosed = true
867
+
868
+ startup = await startupPromise
869
+ } finally {
870
+ if (!logClosed) closeSync(logFd)
871
+ installationLease?.release()
872
+ }
873
+ if (startup.kind !== 'ready') {
874
+ const logTail = await readLogTail(logPath)
875
+ if (startup.kind !== 'timeout') {
876
+ throw new Error(formatStartupFailure(startup, logTail, options))
877
+ }
878
+ throw new Error(
879
+ `Open-Science did not become healthy within ${START_TIMEOUT_MS / 1000}s.${logTail ? `\n\n${logTail}` : ''}`
880
+ )
881
+ }
882
+ const state = startup.state
883
+ const url = await authenticatedUrl(state, deps)
884
+ deps.log(`Open-Science started (PID ${state.pid}).`)
885
+ if (options.open) openBrowser(url)
886
+ else deps.log('Run "open-science url" to print a browser login URL.')
887
+ return { state, started: true }
888
+ }
889
+
890
+ const findCurrentState = async (options, deps = DEFAULT_DEPS) => {
891
+ let firstLiveState
892
+ const state = await deps.findServiceState({
893
+ override: options.configRoot,
894
+ accept: async (candidate) => {
895
+ if (!deps.isAlive(candidate.pid)) {
896
+ return false
897
+ }
898
+ firstLiveState ??= candidate
899
+ return healthCheck(candidate, deps)
900
+ }
901
+ })
902
+ // Keep a live but unhealthy candidate when none passed: stop must still authenticate its shutdown
903
+ // or fail, rather than claim that an unreachable process has already stopped.
904
+ return state ?? firstLiveState
905
+ }
906
+
907
+ export const stopCommand = async (options, deps = DEFAULT_DEPS) => {
908
+ const state = await findCurrentState(options, deps)
909
+ if (!state) {
910
+ deps.log(
911
+ options.json ? JSON.stringify({ result: 'already-stopped' }) : 'Open-Science is not running.'
912
+ )
913
+ return
914
+ }
915
+ const token = await deps.readWebToken(state.configRoot)
916
+ let shutdownAccepted = false
917
+ try {
918
+ const response = await deps.fetch(`http://127.0.0.1:${state.port}/api/shutdown`, {
919
+ method: 'POST',
920
+ headers: { authorization: `Bearer ${token}` },
921
+ signal: AbortSignal.timeout(3_000)
922
+ })
923
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
924
+ shutdownAccepted = true
925
+ await response.arrayBuffer()
926
+ } catch (error) {
927
+ if (!options.json) deps.warn(`Graceful shutdown failed: ${error.message}`)
928
+ }
929
+
930
+ if (!shutdownAccepted) {
931
+ throw new Error(
932
+ `Could not safely stop Open-Science (PID ${state.pid}); the authenticated shutdown request was not accepted, so no process signal was sent.`
933
+ )
934
+ }
935
+
936
+ // Attached: the web service rides on the running desktop app. A graceful request stops only the web
937
+ // service; the app stays up, so observe service health instead of waiting for its pid to exit.
938
+ if (state.attached) {
939
+ const stopped = await waitForWebServiceStopped(state, deps, STOP_TIMEOUT_MS)
940
+ if (!stopped) {
941
+ throw new Error(
942
+ `Could not stop the Open-Science web service (PID ${state.pid}); the app is still serving.`
943
+ )
944
+ }
945
+ deps.log(
946
+ options.json
947
+ ? JSON.stringify({ result: 'web-service-stopped' })
948
+ : 'Open-Science web service stopped; the app is still running.'
949
+ )
950
+ return
951
+ }
952
+
953
+ const stopped = await terminateDaemon(state.pid, {
954
+ isAlive: deps.isAlive,
955
+ sleep: deps.sleep,
956
+ now: deps.now,
957
+ gracefulTimeoutMs: STOP_TIMEOUT_MS
958
+ })
959
+ // Only claim success (and drop the state file) once the process is confirmed gone; otherwise leave
960
+ // the state in place and fail loudly so the user isn't told it stopped when it didn't.
961
+ if (!stopped) {
962
+ throw new Error(
963
+ `Could not stop Open-Science (PID ${state.pid}); it is still running, so no process signal was sent.`
964
+ )
965
+ }
966
+ deps.log(options.json ? JSON.stringify({ result: 'daemon-stopped' }) : 'Open-Science stopped.')
967
+ }
968
+
969
+ export const statusCommand = async (options, deps = DEFAULT_DEPS) => {
970
+ const state = await findCurrentState(options, deps)
971
+ const running = await healthCheck(state, deps)
972
+ if (options.json) {
973
+ deps.log(JSON.stringify(running ? { running: true, ...state } : { running: false }, null, 2))
974
+ } else if (running) {
975
+ deps.log(`Open-Science is running (PID ${state.pid}, port ${state.port}).`)
976
+ } else {
977
+ deps.log('Open-Science is not running.')
978
+ }
979
+ if (!running) process.exitCode = 1
980
+ }
981
+
982
+ export const urlCommand = async (options, deps = DEFAULT_DEPS) => {
983
+ const state = await findCurrentState(options, deps)
984
+ if (!(await healthCheck(state, deps))) throw new Error('Open-Science is not running.')
985
+ deps.log(await authenticatedUrl(state, deps))
986
+ }
987
+
988
+ const resolveDownloadOutput = async (output) => {
989
+ let candidate = output
990
+ for (let hop = 0; ; hop += 1) {
991
+ const linkTarget = await lstat(candidate).then(
992
+ (metadata) => (metadata.isSymbolicLink() ? readlink(candidate) : undefined),
993
+ (error) => {
994
+ if (error?.code === 'ENOENT') return undefined
995
+ throw error
996
+ }
997
+ )
998
+ if (linkTarget === undefined) return candidate
999
+ if (hop === MAX_DOWNLOAD_SYMLINK_HOPS) break
1000
+ candidate = resolve(dirname(candidate), linkTarget)
1001
+ }
1002
+ throw new Error(`Too many symbolic links in artifact output: ${output}`)
1003
+ }
1004
+
1005
+ const writeDownload = async (response, output) => {
1006
+ if (!response.body) throw new Error('Artifact download returned no data.')
1007
+ const destination = await resolveDownloadOutput(output)
1008
+ const existingMode = await stat(destination).then(
1009
+ ({ mode }) => mode & 0o777,
1010
+ (error) => {
1011
+ if (error?.code === 'ENOENT') return undefined
1012
+ throw error
1013
+ }
1014
+ )
1015
+ const temporaryOutput = `${destination}.${process.pid}-${randomUUID()}.tmp`
1016
+ try {
1017
+ await pipeline(
1018
+ Readable.fromWeb(response.body),
1019
+ createWriteStream(temporaryOutput, {
1020
+ flags: 'wx',
1021
+ ...(existingMode === undefined ? {} : { mode: existingMode })
1022
+ })
1023
+ )
1024
+ if (existingMode !== undefined) await chmod(temporaryOutput, existingMode)
1025
+ await rename(temporaryOutput, destination)
1026
+ } finally {
1027
+ await rm(temporaryOutput, { force: true }).catch(() => undefined)
1028
+ }
1029
+ }
1030
+
1031
+ const TASK_DEPS = {
1032
+ connect: (options) => connectToOpenScience(options),
1033
+ readFile: (path) => readFile(path, 'utf8'),
1034
+ readBinaryFile: (path) => readFile(path),
1035
+ readStdin: () => readStreamText(process.stdin),
1036
+ writeDownload,
1037
+ log: (...args) => console.log(...args),
1038
+ warn: (...args) => console.warn(...args),
1039
+ stdinIsTTY: process.stdin.isTTY,
1040
+ setExitCode: (code) => {
1041
+ process.exitCode = code
1042
+ }
1043
+ }
1044
+
1045
+ const outputValue = (value, options, deps) => {
1046
+ if (options.json || options.jsonl) deps.log(JSON.stringify(value))
1047
+ else if (Array.isArray(value)) {
1048
+ for (const item of value) {
1049
+ deps.log(`${item.id}\t${item.name ?? item.title ?? item.path ?? ''}`.trimEnd())
1050
+ }
1051
+ } else {
1052
+ deps.log(
1053
+ typeof value === 'string'
1054
+ ? value
1055
+ : [value.id, value.name ?? value.title ?? value.status ?? value.path]
1056
+ .filter(Boolean)
1057
+ .join('\t') || JSON.stringify(value)
1058
+ )
1059
+ }
1060
+ }
1061
+
1062
+ const readPrompt = async (options, deps) => {
1063
+ const sources = [options.prompt !== undefined, options.promptFile !== undefined].filter(Boolean)
1064
+ if (sources.length > 1) throw new CliUsageError('Use only one of --prompt or --prompt-file.')
1065
+ if (options.prompt !== undefined) return options.prompt.trim()
1066
+ if (options.promptFile !== undefined) return (await deps.readFile(options.promptFile)).trim()
1067
+ if (deps.stdinIsTTY) {
1068
+ throw new CliUsageError('Provide --prompt, --prompt-file, or pipe a prompt on stdin.')
1069
+ }
1070
+ return (await deps.readStdin()).trim()
1071
+ }
1072
+
1073
+ const validateAgentContext = (value) => {
1074
+ const context = value.trim()
1075
+ if (!context) throw new CliUsageError('Agent Context must not be empty.')
1076
+ if (context.length > 16_000) {
1077
+ throw new CliUsageError('Agent Context must not exceed 16000 characters.')
1078
+ }
1079
+ return context
1080
+ }
1081
+
1082
+ const readAgentContext = async (options, deps) => {
1083
+ if (options.clearAgentContext) return ''
1084
+ if (options.agentContext !== undefined) return validateAgentContext(options.agentContext)
1085
+ if (options.agentContextFile === undefined) return undefined
1086
+ let decoded
1087
+ try {
1088
+ const bytes = await deps.readBinaryFile(options.agentContextFile)
1089
+ decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
1090
+ } catch (error) {
1091
+ if (error?.code === 'ENOENT') {
1092
+ throw new CliUsageError(`Agent Context file not found: ${options.agentContextFile}`)
1093
+ }
1094
+ if (error instanceof TypeError) {
1095
+ throw new CliUsageError('Agent Context file must contain valid UTF-8 text.')
1096
+ }
1097
+ throw error
1098
+ }
1099
+ return validateAgentContext(decoded)
1100
+ }
1101
+
1102
+ const resolveCliProject = async (client, selector) => {
1103
+ const projects = await client.listProjects()
1104
+ const byId = projects.find((project) => project.id === selector)
1105
+ if (byId) return byId
1106
+ const byName = projects.filter((project) => project.name === selector)
1107
+ if (byName.length === 1) return byName[0]
1108
+ if (byName.length > 1) {
1109
+ throw new CliUsageError(`Project name is ambiguous: ${selector}. Use a project ID.`)
1110
+ }
1111
+ throw new OpenScienceApiError(`Project not found: ${selector}`, { code: 'project_not_found' })
1112
+ }
1113
+
1114
+ const resolveCliProjectId = async (client, selector) =>
1115
+ (await resolveCliProject(client, selector)).id
1116
+
1117
+ const emitRunEvent = (event, options, deps) => {
1118
+ if (options.jsonl) {
1119
+ deps.log(JSON.stringify(event))
1120
+ } else if (event.type === 'stream.resync-required') {
1121
+ deps.warn(
1122
+ 'Run event history could not be fully replayed. Final Run state will still be read from Open-Science.'
1123
+ )
1124
+ } else if (event.type === 'run.progress') {
1125
+ if (event.data?.heartbeat) {
1126
+ const seconds = Math.max(1, Math.round((event.data.elapsedMs ?? 0) / 1_000))
1127
+ const subject =
1128
+ event.data.phase === 'provider-accepted' ? 'the first provider output' : 'the provider'
1129
+ deps.log(`Still waiting for ${subject} (${seconds}s elapsed).`)
1130
+ return
1131
+ }
1132
+ const message = {
1133
+ accepted: 'Run accepted.',
1134
+ 'session-ready': 'Session ready.',
1135
+ 'prompt-dispatched': 'Prompt dispatched to the agent.',
1136
+ 'provider-accepted': 'Provider accepted the prompt.',
1137
+ 'first-visible-output': 'First provider output received.'
1138
+ }[event.data?.phase]
1139
+ if (message) deps.log(message)
1140
+ } else if (event.type === 'permission.requested') {
1141
+ deps.warn(
1142
+ 'Run is waiting for approval. Approve the request in Open-Science Desktop or the Web UI.'
1143
+ )
1144
+ } else if (
1145
+ event.type === 'run.event' &&
1146
+ event.data?.planProjection?.lifecycle === 'awaiting_approval'
1147
+ ) {
1148
+ deps.warn('Run is waiting for Plan approval.')
1149
+ } else if (
1150
+ event.type === 'run.event' &&
1151
+ event.data?.kind !== 'message' &&
1152
+ (event.data?.title || event.data?.text)
1153
+ ) {
1154
+ deps.log(event.data.title ?? event.data.text)
1155
+ }
1156
+ }
1157
+
1158
+ const isEventForRun = (event, run) => {
1159
+ if (event?.runId) return event.runId === run.runId
1160
+ const sessionId = event?.sessionId ?? event?.data?.sessionId
1161
+ return !sessionId || sessionId === run.sessionId
1162
+ }
1163
+
1164
+ const streamRunEvents = async (eventStream, runRef, options, deps, signal) => {
1165
+ try {
1166
+ for await (const event of eventStream) {
1167
+ if (!runRef.current) {
1168
+ runRef.pending.push(event)
1169
+ continue
1170
+ }
1171
+ if (!isEventForRun(event, runRef.current)) continue
1172
+ emitRunEvent(event, options, deps)
1173
+ }
1174
+ } catch (error) {
1175
+ if (!signal.aborted) throw error
1176
+ }
1177
+ }
1178
+
1179
+ const agentConfigurationPatch = (options) => {
1180
+ const present =
1181
+ options.provider !== undefined ||
1182
+ options.model !== undefined ||
1183
+ options.providerDefaultModel ||
1184
+ options.reasoningEffort !== undefined
1185
+ if (!present) return undefined
1186
+ return {
1187
+ ...(options.provider !== undefined ? { providerId: options.provider } : {}),
1188
+ ...(options.model !== undefined
1189
+ ? { model: options.model }
1190
+ : options.providerDefaultModel
1191
+ ? { model: null }
1192
+ : {}),
1193
+ ...(options.reasoningEffort !== undefined ? { reasoningEffort: options.reasoningEffort } : {})
1194
+ }
1195
+ }
1196
+
1197
+ const computeHostsPatch = (options, current = { enabled: [], selected: [] }) => {
1198
+ if (options.clearComputeHosts) return { enabled: [], selected: [] }
1199
+ if (options.computeHosts === undefined && options.enabledComputeHosts === undefined) {
1200
+ return undefined
1201
+ }
1202
+ const selected = options.computeHosts ?? current.selected
1203
+ const enabled = [...(options.enabledComputeHosts ?? current.enabled), ...selected].filter(
1204
+ (value, index, values) => values.indexOf(value) === index
1205
+ )
1206
+ return { enabled, selected }
1207
+ }
1208
+
1209
+ const modelRouting = (options, prefix) => {
1210
+ const inherit = options[`${prefix}Inherit`]
1211
+ const providerId = options[`${prefix}Provider`]
1212
+ const model = options[`${prefix}Model`]
1213
+ const reasoningEffort = options[`${prefix}Effort`]
1214
+ if (inherit) {
1215
+ if (providerId !== undefined || model !== undefined || reasoningEffort !== undefined) {
1216
+ throw new CliUsageError(
1217
+ `--${prefix}-inherit cannot be combined with fixed ${prefix} routing options.`
1218
+ )
1219
+ }
1220
+ return { mode: 'inherit' }
1221
+ }
1222
+ if (providerId === undefined && model === undefined && reasoningEffort === undefined) {
1223
+ return undefined
1224
+ }
1225
+ if (!providerId || !model) {
1226
+ throw new CliUsageError(
1227
+ `--${prefix}-provider and --${prefix}-model are required for fixed routing.`
1228
+ )
1229
+ }
1230
+ return { mode: 'fixed', providerId, model, reasoningEffort: reasoningEffort ?? 'default' }
1231
+ }
1232
+
1233
+ const assertNoClearConflict = (clear, present, label) => {
1234
+ if (clear && present) throw new CliUsageError(`Cannot set and clear ${label} together.`)
1235
+ }
1236
+
1237
+ export const runTaskCommand = async (parsed, dependencies = {}) => {
1238
+ const deps = { ...TASK_DEPS, ...dependencies }
1239
+ const { command, subcommand, positionals = [], options } = parsed
1240
+ let client
1241
+ try {
1242
+ client = await deps.connect({ configRoot: options.configRoot })
1243
+ } catch (error) {
1244
+ if (command !== 'doctor' || error?.code !== 'daemon_unavailable' || error?.status !== undefined)
1245
+ throw error
1246
+ deps.log(
1247
+ JSON.stringify({
1248
+ ready: false,
1249
+ checks: { daemon: { status: 'missing' } },
1250
+ next: [{ code: 'daemon_unavailable', argv: ['start', '--no-open'] }]
1251
+ })
1252
+ )
1253
+ deps.setExitCode(3)
1254
+ return
1255
+ }
1256
+
1257
+ if (command === 'doctor') {
1258
+ deps.log(JSON.stringify(await client.doctor()))
1259
+ return
1260
+ }
1261
+
1262
+ if (command === 'runtime' && subcommand === 'list') {
1263
+ const runtimes = await client.listRuntimes()
1264
+ if (options.json) {
1265
+ deps.log(JSON.stringify(runtimes))
1266
+ } else {
1267
+ deps.log('FRAMEWORK\tSTATUS\tVERSION\tSOURCE')
1268
+ for (const runtime of runtimes) {
1269
+ deps.log(
1270
+ `${runtime.framework}\t${runtime.status}\t${runtime.version ?? '-'}\t${runtime.source ?? '-'}`
1271
+ )
1272
+ }
1273
+ }
1274
+ return
1275
+ }
1276
+
1277
+ if (command === 'cli' && subcommand === 'install') {
1278
+ outputValue(await client.installCli(), options, deps)
1279
+ return
1280
+ }
1281
+ if (
1282
+ command === 'runtime' ||
1283
+ command === 'provider' ||
1284
+ (command === 'connector' && subcommand === 'configure')
1285
+ ) {
1286
+ const readSecret = (name) => {
1287
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
1288
+ throw new CliUsageError('Name a credential environment variable.')
1289
+ const key = (dependencies.env ?? process.env)[name]?.trim()
1290
+ if (!key) throw new CliUsageError('The credential environment variable is empty.')
1291
+ return key
1292
+ }
1293
+ let request
1294
+ if (command === 'runtime' && subcommand === 'install' && positionals[0] === 'codex')
1295
+ request = { action: 'runtime' }
1296
+ else if (
1297
+ command === 'provider' &&
1298
+ subcommand === 'add' &&
1299
+ options.providerType === 'official' &&
1300
+ options.vendor === 'openai' &&
1301
+ options.model
1302
+ ) {
1303
+ request = { action: 'provider', key: readSecret(options.apiKeyEnv), model: options.model }
1304
+ } else if (command === 'connector' && positionals[0] === 'literature')
1305
+ request = { action: 'openalex', key: readSecret(options.openAlexKeyEnv) }
1306
+ else
1307
+ throw new CliUsageError(
1308
+ 'Supported setup commands: runtime install codex; provider add --type official --vendor openai --model <id> --api-key-env <ENV>; connector configure literature --openalex-key-env <ENV>.'
1309
+ )
1310
+ const result = await client.bootstrap(request, { timeoutMs: 600_000 })
1311
+ if (!result.ok)
1312
+ throw Object.assign(new Error(`Setup failed: ${result.code}.`), { code: result.code })
1313
+ outputValue(result, options, deps)
1314
+ return
1315
+ }
1316
+
1317
+ if (command === 'connector' || command === 'credential') {
1318
+ const id = positionals[0]
1319
+ const actions =
1320
+ command === 'connector'
1321
+ ? ['list', 'show', 'enable', 'disable', 'add', 'update', 'remove', 'test']
1322
+ : ['list', 'add', 'update']
1323
+ if (!actions.includes(subcommand))
1324
+ throw new CliUsageError(`Unknown command: ${command} ${subcommand ?? ''}`)
1325
+ if (!['list', 'add'].includes(subcommand) && !id) throw new CliUsageError('An ID is required.')
1326
+ let input
1327
+ if (subcommand === 'add' || subcommand === 'update') {
1328
+ if (deps.stdinIsTTY)
1329
+ throw new CliUsageError('Pipe a JSON configuration object through stdin.')
1330
+ try {
1331
+ input = JSON.parse(await deps.readStdin())
1332
+ } catch {
1333
+ throw new CliUsageError('Stdin must contain a valid JSON configuration object.')
1334
+ }
1335
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1336
+ throw new CliUsageError('Stdin must contain a JSON configuration object.')
1337
+ }
1338
+ }
1339
+ let result
1340
+ if (command === 'credential') {
1341
+ if (subcommand === 'list') result = await client.listCredentials()
1342
+ else if (subcommand === 'add') result = await client.createCredential(input)
1343
+ else result = await client.updateCredential(id, input)
1344
+ } else {
1345
+ if (subcommand === 'list') result = await client.listConnectors()
1346
+ else if (subcommand === 'show') result = await client.getConnector(id)
1347
+ else if (subcommand === 'enable' || subcommand === 'disable')
1348
+ result = await client.setConnectorEnabled(id, subcommand === 'enable')
1349
+ else if (subcommand === 'add') result = await client.addConnector(input)
1350
+ else if (subcommand === 'update') result = await client.updateConnector(id, input)
1351
+ else if (subcommand === 'remove') result = await client.removeConnector(id)
1352
+ else result = await client.testConnector(id)
1353
+ }
1354
+ // Preserve every safe status/configuration field in both output formats.
1355
+ deps.log(JSON.stringify(result, null, options.json ? undefined : 2))
1356
+ if (
1357
+ !options.json &&
1358
+ command === 'connector' &&
1359
+ ['add', 'update', 'remove', 'enable', 'disable'].includes(subcommand)
1360
+ ) {
1361
+ deps.log(
1362
+ 'Connector settings saved. Existing session refresh follows the configured agent framework; start a new session if its tool list is unchanged.'
1363
+ )
1364
+ }
1365
+ if (subcommand === 'test' && result?.success === false) deps.setExitCode(1)
1366
+ return
1367
+ }
1368
+
1369
+ if (command === 'project' && subcommand === 'list') {
1370
+ outputValue(await client.listProjects(), options, deps)
1371
+ return
1372
+ }
1373
+ if (command === 'project' && subcommand === 'create') {
1374
+ const name = positionals.join(' ').trim()
1375
+ if (!name) throw new CliUsageError('Project name is required.')
1376
+ const agentContext = await readAgentContext(options, deps)
1377
+ outputValue(
1378
+ await client.createProject({
1379
+ name,
1380
+ description: options.description,
1381
+ ...(agentContext === undefined ? {} : { agentContext })
1382
+ }),
1383
+ options,
1384
+ deps
1385
+ )
1386
+ return
1387
+ }
1388
+ if (command === 'project' && subcommand === 'update') {
1389
+ const selector = positionals.join(' ').trim()
1390
+ if (!selector) throw new CliUsageError('Project id or name is required.')
1391
+ const agentContext = await readAgentContext(options, deps)
1392
+ if (
1393
+ options.name === undefined &&
1394
+ options.description === undefined &&
1395
+ agentContext === undefined
1396
+ ) {
1397
+ throw new CliUsageError('Project update requires at least one field.')
1398
+ }
1399
+ const project = await resolveCliProject(client, selector)
1400
+ outputValue(
1401
+ await client.updateProject(project.id, {
1402
+ expectedUpdatedAt: project.updatedAt,
1403
+ ...(options.name === undefined ? {} : { name: options.name }),
1404
+ ...(options.description === undefined ? {} : { description: options.description }),
1405
+ ...(agentContext === undefined ? {} : { agentContext })
1406
+ }),
1407
+ options,
1408
+ deps
1409
+ )
1410
+ return
1411
+ }
1412
+ if (command === 'project' && subcommand === 'session-defaults') {
1413
+ const action = positionals[0]
1414
+ const selector = positionals.slice(1).join(' ').trim()
1415
+ if (!selector) throw new CliUsageError('Project id or name is required.')
1416
+ const project = await resolveCliProject(client, selector)
1417
+ const current = await client.getProjectSessionDefaults(project.id)
1418
+ if (action === 'show') {
1419
+ outputValue(current, options, deps)
1420
+ return
1421
+ }
1422
+ if (action !== 'update') {
1423
+ throw new CliUsageError('Use project session-defaults show or update.')
1424
+ }
1425
+ const agent = agentConfigurationPatch(options)
1426
+ assertNoClearConflict(options.clearProvider, agent !== undefined, 'the provider default')
1427
+ assertNoClearConflict(
1428
+ options.clearApprovalProfile,
1429
+ options.approvalProfile !== undefined,
1430
+ 'the approval profile default'
1431
+ )
1432
+ assertNoClearConflict(
1433
+ options.clearAutoReview,
1434
+ options.autoReviewEnabled !== undefined,
1435
+ 'the auto-review default'
1436
+ )
1437
+ assertNoClearConflict(
1438
+ options.clearMemory,
1439
+ options.memoryEnabled !== undefined,
1440
+ 'the Memory default'
1441
+ )
1442
+ assertNoClearConflict(
1443
+ options.clearDelegation,
1444
+ options.delegation !== undefined,
1445
+ 'the delegation default'
1446
+ )
1447
+ assertNoClearConflict(
1448
+ options.clearSpecialist,
1449
+ options.specialist !== undefined,
1450
+ 'the Specialist default'
1451
+ )
1452
+ const computeHosts = computeHostsPatch(options, current.configured.computeHosts)
1453
+ const patch = {
1454
+ ...(options.clearProvider
1455
+ ? { agentConfiguration: null }
1456
+ : agent
1457
+ ? { agentConfiguration: agent }
1458
+ : {}),
1459
+ ...(options.clearApprovalProfile
1460
+ ? { permissionProfile: null }
1461
+ : options.approvalProfile !== undefined
1462
+ ? { permissionProfile: options.approvalProfile }
1463
+ : {}),
1464
+ ...(options.clearAutoReview
1465
+ ? { autoReviewEnabled: null }
1466
+ : options.autoReviewEnabled !== undefined
1467
+ ? { autoReviewEnabled: options.autoReviewEnabled }
1468
+ : {}),
1469
+ ...(options.clearMemory
1470
+ ? { memoryEnabled: null }
1471
+ : options.memoryEnabled !== undefined
1472
+ ? { memoryEnabled: options.memoryEnabled }
1473
+ : {}),
1474
+ ...(options.clearDelegation
1475
+ ? { delegationPolicy: null }
1476
+ : options.delegation !== undefined
1477
+ ? { delegationPolicy: options.delegation }
1478
+ : {}),
1479
+ ...(options.clearSpecialist
1480
+ ? { specialistId: null }
1481
+ : options.specialist !== undefined
1482
+ ? { specialistId: options.specialist }
1483
+ : {}),
1484
+ ...(computeHosts !== undefined
1485
+ ? { computeHosts: options.clearComputeHosts ? null : computeHosts }
1486
+ : {})
1487
+ }
1488
+ if (Object.keys(patch).length === 0) {
1489
+ throw new CliUsageError('Project Session defaults update requires at least one field.')
1490
+ }
1491
+ outputValue(
1492
+ await client.updateProjectSessionDefaults(project.id, {
1493
+ expectedUpdatedAt: current.updatedAt,
1494
+ patch
1495
+ }),
1496
+ options,
1497
+ deps
1498
+ )
1499
+ return
1500
+ }
1501
+ if (command === 'session' && subcommand === 'status') {
1502
+ const sessionId = positionals[0]
1503
+ if (!sessionId) throw new CliUsageError('Session id is required.')
1504
+ outputValue(await client.getSession(sessionId), options, deps)
1505
+ return
1506
+ }
1507
+ if (command === 'session' && subcommand === 'config') {
1508
+ const action = positionals[0]
1509
+ const sessionId = positionals[1]
1510
+ if (!sessionId) throw new CliUsageError('Session id is required.')
1511
+ if (action === 'show') {
1512
+ outputValue(await client.getSessionConfiguration(sessionId), options, deps)
1513
+ return
1514
+ }
1515
+ if (action !== 'update') throw new CliUsageError('Use session config show or update.')
1516
+ if (options.revision === undefined) throw new CliUsageError('--revision is required.')
1517
+ const current = await client.getSessionConfiguration(sessionId)
1518
+ const computeHosts = computeHostsPatch(options, current.persisted.computeHosts)
1519
+ const patch = {
1520
+ ...(agentConfigurationPatch(options)
1521
+ ? { agentConfiguration: agentConfigurationPatch(options) }
1522
+ : {}),
1523
+ ...(options.approvalProfile !== undefined
1524
+ ? { permissionProfile: options.approvalProfile }
1525
+ : {}),
1526
+ ...(options.autoReviewEnabled !== undefined
1527
+ ? { autoReviewEnabled: options.autoReviewEnabled }
1528
+ : {}),
1529
+ ...(options.memoryEnabled !== undefined ? { memoryEnabled: options.memoryEnabled } : {}),
1530
+ ...(options.delegation !== undefined ? { delegationPolicy: options.delegation } : {}),
1531
+ ...(computeHosts !== undefined ? { computeHosts } : {})
1532
+ }
1533
+ if (Object.keys(patch).length === 0) {
1534
+ throw new CliUsageError('Session configuration update requires at least one field.')
1535
+ }
1536
+ outputValue(
1537
+ await client.updateSessionConfiguration(sessionId, {
1538
+ expectedRevision: options.revision,
1539
+ ...patch
1540
+ }),
1541
+ options,
1542
+ deps
1543
+ )
1544
+ return
1545
+ }
1546
+ if (command === 'settings' && subcommand === 'agent-routing') {
1547
+ const action = positionals[0]
1548
+ if (action === 'show') {
1549
+ outputValue(await client.getAgentRouting(), options, deps)
1550
+ return
1551
+ }
1552
+ if (action !== 'update') {
1553
+ throw new CliUsageError('Use settings agent-routing show or update.')
1554
+ }
1555
+ const reviewer = modelRouting(options, 'reviewer')
1556
+ const subagent = modelRouting(options, 'subagent')
1557
+ const request = {
1558
+ ...(options.framework !== undefined ? { framework: options.framework } : {}),
1559
+ ...(reviewer ? { reviewer } : {}),
1560
+ ...(subagent ? { subagent } : {})
1561
+ }
1562
+ if (Object.keys(request).length === 0) {
1563
+ throw new CliUsageError('Agent routing update requires at least one field.')
1564
+ }
1565
+ outputValue(await client.updateAgentRouting(request), options, deps)
1566
+ return
1567
+ }
1568
+ if (command === 'plan') {
1569
+ const sessionId = positionals[0]
1570
+ if (!sessionId) throw new CliUsageError('Session id is required.')
1571
+ if (subcommand === 'show') {
1572
+ outputValue(await client.getSessionPlan(sessionId), options, deps)
1573
+ return
1574
+ }
1575
+ if (subcommand === 'approve' || subcommand === 'reject') {
1576
+ if (!options.artifactVersion) {
1577
+ throw new CliUsageError('--artifact-version is required.')
1578
+ }
1579
+ if (options.revision === undefined) {
1580
+ throw new CliUsageError('--revision is required.')
1581
+ }
1582
+ outputValue(
1583
+ await client.respondSessionPlan(sessionId, {
1584
+ decision: subcommand === 'approve' ? 'approved' : 'rejected',
1585
+ artifactVersionId: options.artifactVersion,
1586
+ expectedRevision: options.revision
1587
+ }),
1588
+ options,
1589
+ deps
1590
+ )
1591
+ return
1592
+ }
1593
+ if (subcommand === 'revise') {
1594
+ if (!options.feedback?.trim()) throw new CliUsageError('--feedback is required.')
1595
+ outputValue(
1596
+ await client.respondSessionPlan(sessionId, { feedback: options.feedback.trim() }),
1597
+ options,
1598
+ deps
1599
+ )
1600
+ return
1601
+ }
1602
+ }
1603
+ if (command === 'artifacts' && subcommand === 'list') {
1604
+ const sessionId = positionals[0]
1605
+ if (!sessionId) throw new CliUsageError('Session id is required.')
1606
+ outputValue(await client.listArtifacts(sessionId), options, deps)
1607
+ return
1608
+ }
1609
+ if (command === 'artifacts' && subcommand === 'download') {
1610
+ const artifactId = positionals[0]
1611
+ if (!artifactId) throw new CliUsageError('Artifact id is required.')
1612
+ if (!options.output) throw new CliUsageError('--output is required.')
1613
+ const response = await client.downloadArtifact(artifactId)
1614
+ await deps.writeDownload(response, options.output)
1615
+ outputValue({ artifactId, output: resolve(options.output) }, options, deps)
1616
+ return
1617
+ }
1618
+ if (command === 'run' && subcommand === 'status') {
1619
+ const runId = positionals[0]
1620
+ if (!runId) throw new CliUsageError('Run id is required.')
1621
+ outputValue(await client.getRun(runId), options, deps)
1622
+ return
1623
+ }
1624
+ if (command === 'run' && subcommand === 'cancel') {
1625
+ const runId = positionals[0]
1626
+ if (!runId) throw new CliUsageError('Run id is required.')
1627
+ outputValue(await client.cancelRun(runId), options, deps)
1628
+ return
1629
+ }
1630
+ if (command === 'run') {
1631
+ if (!options.project) throw new CliUsageError('--project is required.')
1632
+ const projectId = await resolveCliProjectId(client, options.project)
1633
+ const prompt = await readPrompt(options, deps)
1634
+ if (!prompt) throw new CliUsageError('Prompt is required.')
1635
+ const runRef = { current: undefined, pending: [] }
1636
+ const abortController = new AbortController()
1637
+ const eventStream =
1638
+ options.wait && !options.json && typeof client.events === 'function'
1639
+ ? client.events({ signal: abortController.signal })
1640
+ : undefined
1641
+ await eventStream?.ready
1642
+ // The event stream only renders progress; waitForRun remains authoritative for the final Run
1643
+ // state, so a mid-run stream failure must not fail an otherwise-successful command.
1644
+ const eventTask = eventStream
1645
+ ? streamRunEvents(eventStream, runRef, options, deps, abortController.signal).catch(
1646
+ (error) => {
1647
+ if (abortController.signal.aborted) return
1648
+ const message = error instanceof Error ? error.message : String(error)
1649
+ deps.warn(
1650
+ `Run event stream stopped: ${message} Final Run state will still be read from Open-Science.`
1651
+ )
1652
+ }
1653
+ )
1654
+ : undefined
1655
+ let result
1656
+ try {
1657
+ const started = await client.startRun({
1658
+ project: projectId,
1659
+ prompt,
1660
+ ...(options.cwd ? { cwd: resolve(options.cwd) } : {}),
1661
+ ...(options.session ? { sessionId: options.session } : {}),
1662
+ ...(options.approvalProfile ? { permissionProfile: options.approvalProfile } : {}),
1663
+ ...(options.skills?.length ? { skillIds: options.skills } : {}),
1664
+ ...(options.permissionPrompts ? { permissionPrompts: options.permissionPrompts } : {}),
1665
+ ...(options.planFirst ? { turnIntent: 'plan-first' } : {}),
1666
+ ...(options.autoReviewEnabled !== undefined
1667
+ ? { autoReviewEnabled: options.autoReviewEnabled }
1668
+ : {}),
1669
+ ...(options.specialist ? { specialist: options.specialist } : {}),
1670
+ ...(options.delegation ? { delegationPolicy: options.delegation } : {}),
1671
+ ...(agentConfigurationPatch(options)
1672
+ ? { agentConfiguration: agentConfigurationPatch(options) }
1673
+ : {}),
1674
+ ...(options.memoryEnabled !== undefined ? { memoryEnabled: options.memoryEnabled } : {}),
1675
+ ...(options.clearComputeHosts
1676
+ ? { enabledComputeHostIds: [], computeHostIds: [] }
1677
+ : {
1678
+ ...(options.enabledComputeHosts !== undefined
1679
+ ? { enabledComputeHostIds: options.enabledComputeHosts }
1680
+ : {}),
1681
+ ...(options.computeHosts !== undefined
1682
+ ? { computeHostIds: options.computeHosts }
1683
+ : {})
1684
+ })
1685
+ })
1686
+ runRef.current = { runId: started.id, sessionId: started.sessionId }
1687
+ for (const event of runRef.pending.splice(0)) {
1688
+ if (isEventForRun(event, runRef.current)) emitRunEvent(event, options, deps)
1689
+ }
1690
+ try {
1691
+ const waitOptions = {
1692
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
1693
+ ...(options.returnOnAttention ? { returnOnAttention: true } : {})
1694
+ }
1695
+ result = options.wait
1696
+ ? Object.keys(waitOptions).length === 0
1697
+ ? await client.waitForRun(started.id)
1698
+ : await client.waitForRun(started.id, waitOptions)
1699
+ : started
1700
+ } catch (error) {
1701
+ if (options.cancelOnTimeout && error?.code === 'timeout') {
1702
+ try {
1703
+ await client.cancelRun(started.id)
1704
+ } catch (cancelError) {
1705
+ if (error instanceof Error) {
1706
+ if (error.cause === undefined) error.cause = cancelError
1707
+ const cancelMessage =
1708
+ cancelError instanceof Error ? cancelError.message : String(cancelError)
1709
+ error.message = `${error.message} Server run cancellation also failed: ${cancelMessage}`
1710
+ }
1711
+ }
1712
+ }
1713
+ throw error
1714
+ }
1715
+ } finally {
1716
+ abortController.abort()
1717
+ await eventTask
1718
+ }
1719
+ if (options.json || options.jsonl) outputValue(result, options, deps)
1720
+ else if (result.status === 'completed') deps.log(result.output || `Run completed: ${result.id}`)
1721
+ else if (result.status === 'failed') deps.log(`Run failed: ${result.error ?? result.id}`)
1722
+ else if (result.status === 'cancelled') deps.log(`Run cancelled: ${result.id}`)
1723
+ else if (result.attention?.kind === 'plan-approval') {
1724
+ const plan = result.attention.plan
1725
+ deps.log(
1726
+ `Run is waiting for Plan approval: open-science plan approve ${result.sessionId} --artifact-version ${plan.artifactVersionId} --revision ${plan.revision}`
1727
+ )
1728
+ } else deps.log(`Run started: ${result.id} (session ${result.sessionId})`)
1729
+ if (result.status === 'failed') deps.setExitCode(1)
1730
+ return
1731
+ }
1732
+
1733
+ throw new CliUsageError(`Unknown command: ${[command, subcommand].filter(Boolean).join(' ')}`)
1734
+ }
1735
+
1736
+ export const reportCliError = (error, argv = process.argv.slice(2), dependencies = {}) => {
1737
+ const deps = {
1738
+ error: (...args) => console.error(...args),
1739
+ setExitCode: (code) => {
1740
+ process.exitCode = code
1741
+ },
1742
+ ...dependencies
1743
+ }
1744
+ const code = error?.code ?? 'command_failed'
1745
+ const message = error instanceof Error ? error.message : String(error)
1746
+ const exitCode =
1747
+ error?.exitCode ??
1748
+ (code === 'daemon_unavailable'
1749
+ ? 3
1750
+ : ['project_not_found', 'session_not_found', 'run_not_found', 'artifact_not_found'].includes(
1751
+ code
1752
+ ) || code === 'specialist_not_found'
1753
+ ? 4
1754
+ : 1)
1755
+ if (argv.includes('--json') || argv.includes('--jsonl')) {
1756
+ deps.error(JSON.stringify({ error: { code, message }, exitCode }))
1757
+ } else {
1758
+ deps.error(message)
1759
+ }
1760
+ deps.setExitCode(exitCode)
1761
+ return exitCode
1762
+ }
1763
+
1764
+ const commandHelp = (command) => {
1765
+ if (!command || command === '-h' || command === '--help') return usage
1766
+ // Reuse the published command inventory rather than maintaining a second list.
1767
+ const commands = usage
1768
+ .split('Commands:\n')[1]
1769
+ .split('\n\nOptions:')[0]
1770
+ .split('\n')
1771
+ .filter((line) => line.trimStart().startsWith(`${command} `))
1772
+ if (!commands.length) throw new CliUsageError(`Unknown command: ${command}`)
1773
+ return `Usage: open-science ${command}${GROUP_COMMANDS.has(command) ? ' <subcommand>' : ''} [options]
1774
+
1775
+ Commands:
1776
+ ${commands.join('\n')}
1777
+
1778
+ Run "open-science --help" for the complete option reference.`
1779
+ }
1780
+
1781
+ const readCliMetadata = async () => {
1782
+ const here = dirname(fileURLToPath(import.meta.url))
1783
+ // Source SDK metadata has its own version; installed CLI manifests carry the release version.
1784
+ const development =
1785
+ basename(here) === 'open-science' &&
1786
+ basename(dirname(here)) === 'packages' &&
1787
+ existsSync(join(here, '../../src/main/node-entry.ts'))
1788
+ const manifestPath = join(development ? join(here, '../..') : here, 'package.json')
1789
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1790
+ if (
1791
+ manifest.name !== (development ? 'open-science' : '@aipoch/open-science') ||
1792
+ typeof manifest.version !== 'string' ||
1793
+ !/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(manifest.version)
1794
+ ) {
1795
+ throw new Error(`Invalid Open-Science CLI metadata: ${manifestPath}`)
1796
+ }
1797
+ return { version: manifest.version, development }
1798
+ }
1799
+
1800
+ const pathsCommand = async (options) => {
1801
+ const { development } = await readCliMetadata()
1802
+ const paths = {
1803
+ cliPath: fileURLToPath(import.meta.url),
1804
+ nodePath: process.execPath,
1805
+ startupConfigRoot: resolveConfigRoot({
1806
+ packaged: !development,
1807
+ override: options.configRoot
1808
+ }),
1809
+ discoveryConfigRoots: candidateConfigRoots({ override: options.configRoot })
1810
+ }
1811
+ console.log(
1812
+ options.json
1813
+ ? JSON.stringify(paths)
1814
+ : `CLI: ${paths.cliPath}\nNode: ${paths.nodePath}\nStartup configuration: ${paths.startupConfigRoot}\nService discovery (in order):\n${paths.discoveryConfigRoots.map((root) => ` ${root}`).join('\n')}`
1815
+ )
1816
+ }
1817
+
1818
+ export const runCli = async (argv = process.argv.slice(2)) => {
1819
+ const parsed = parseCliArgs(argv)
1820
+ const { command, options } = parsed
1821
+ if (options.help || !command || command === 'help' || command === '-h' || command === '--help') {
1822
+ console.log(commandHelp(command === 'help' ? parsed.positionals?.[0] : command))
1823
+ return
1824
+ }
1825
+ if (command === 'version') {
1826
+ const { version } = await readCliMetadata()
1827
+ console.log(options.json ? JSON.stringify({ version }) : version)
1828
+ } else if (command === 'paths') await pathsCommand(options)
1829
+ else if (command === 'init') await initCommand(options)
1830
+ else if (command === 'start') await startCommand(options)
1831
+ else if (command === 'stop') await stopCommand(options)
1832
+ else if (command === 'status') await statusCommand(options)
1833
+ else if (command === 'url') await urlCommand(options)
1834
+ else if (command === 'codex' && parsed.subcommand === 'login') await codexLoginCommand(options)
1835
+ else if (TASK_COMMANDS.has(command)) await runTaskCommand(parsed)
1836
+ else throw new CliUsageError(`Unknown command: ${command}\n\n${usage}`)
1837
+ }
1838
+
1839
+ const isEntryPoint =
1840
+ process.argv[1] &&
1841
+ existsSync(process.argv[1]) &&
1842
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
1843
+ if (isEntryPoint) {
1844
+ runCli().catch((error) => reportCliError(error))
1845
+ }