@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.md +814 -0
- package/LICENSE +201 -0
- package/README.md +284 -2
- package/appimage-launcher.mjs +90 -0
- package/browser-import.d.ts +66 -0
- package/browser-import.mjs +389 -0
- package/cli.mjs +1845 -0
- package/codex-login.mjs +298 -0
- package/config-root.mjs +65 -0
- package/index.d.ts +677 -0
- package/index.mjs +761 -0
- package/installation-startup.cjs +205 -0
- package/locate-app.mjs +95 -0
- package/locate-backend.mjs +63 -0
- package/package.json +45 -4
- package/runtime-control.mjs +51 -0
- package/runtime-package.mjs +19 -0
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
|
+
}
|