@aipoch/open-science 0.0.0-stage → 1.0.1

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