@agentrq/agentrq-ws 0.7.0

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/src/cli.js ADDED
@@ -0,0 +1,126 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { parseArgs } from 'node:util'
5
+
6
+ import { COMMANDS, findCommand, findGroup } from './commands.js'
7
+ import { resolveServer } from './config.js'
8
+ import { ServerError, UserError } from './errors.js'
9
+ import { GLOBAL_OPTIONS, commandHelp, groupHelp, mainHelp } from './help.js'
10
+ import { McpClient } from './mcp.js'
11
+ import { VERSION } from './version.js'
12
+
13
+ /** Strip the `description` keys parseArgs does not understand. */
14
+ function optionSpec(options) {
15
+ const spec = {}
16
+ for (const [name, { description, ...rest }] of Object.entries(options || {})) {
17
+ spec[name] = rest
18
+ }
19
+ return spec
20
+ }
21
+
22
+ /**
23
+ * Run the CLI.
24
+ *
25
+ * Everything the process touches — argv, streams, cwd, env, and how a client is
26
+ * made — arrives as an argument, so the whole command surface is testable
27
+ * without spawning a process or opening a socket.
28
+ */
29
+ export async function run({
30
+ argv = [],
31
+ stdout = process.stdout,
32
+ stderr = process.stderr,
33
+ stdin = process.stdin,
34
+ cwd = process.cwd(),
35
+ env = process.env,
36
+ createClient = (server) => new McpClient(server),
37
+ } = {}) {
38
+ const write = (stream, text) => stream.write(`${text}\n`)
39
+
40
+ try {
41
+ if (argv.length === 0 || argv[0] === 'help') {
42
+ const target = argv.slice(1)
43
+ if (target.length > 0) {
44
+ const { command } = findCommand(target)
45
+ if (command) {
46
+ write(stdout, commandHelp(command))
47
+ return 0
48
+ }
49
+ const group = findGroup(target)
50
+ if (group) {
51
+ write(stdout, groupHelp(group))
52
+ return 0
53
+ }
54
+ throw new UserError(`unknown command "${target.join(' ')}"`)
55
+ }
56
+ write(stdout, mainHelp())
57
+ return 0
58
+ }
59
+ if (argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version') {
60
+ write(stdout, VERSION)
61
+ return 0
62
+ }
63
+
64
+ const { command, rest } = findCommand(argv)
65
+ if (!command) {
66
+ // `task` on its own names a family, not a command. Listing what it holds
67
+ // is the useful answer; git and docker both behave this way, and it
68
+ // still exits non-zero unless help was what was actually asked for.
69
+ const group = findGroup(argv)
70
+ if (group) {
71
+ write(stdout, groupHelp(group))
72
+ return argv.some((arg) => arg === '--help' || arg === '-h') ? 0 : 1
73
+ }
74
+ throw new UserError(
75
+ `unknown command "${argv.join(' ')}".\n` +
76
+ `Try one of: ${COMMANDS.map((c) => c.path.join(' ')).join(', ')}\n` +
77
+ 'Run `agentrq-ws help` for the full list.',
78
+ )
79
+ }
80
+
81
+ let values
82
+ let positionals
83
+ try {
84
+ ;({ values, positionals } = parseArgs({
85
+ args: rest,
86
+ options: { ...optionSpec(GLOBAL_OPTIONS), ...optionSpec(command.options) },
87
+ allowPositionals: true,
88
+ }))
89
+ } catch (err) {
90
+ throw new UserError(`${err.message}\n\nUsage: ${command.usage}`)
91
+ }
92
+
93
+ if (values.help) {
94
+ write(stdout, commandHelp(command))
95
+ return 0
96
+ }
97
+ if (values.version) {
98
+ write(stdout, VERSION)
99
+ return 0
100
+ }
101
+
102
+ const server = resolveServer({ cwd, configPath: values.config, serverName: values.server, env })
103
+ const client = createClient(server)
104
+
105
+ let outcome
106
+ try {
107
+ outcome = await command.run({ client, values, positionals, stdin, cwd, env, server })
108
+ } finally {
109
+ if (typeof client.close === 'function') await client.close()
110
+ }
111
+
112
+ if (values.json) {
113
+ write(stdout, JSON.stringify(outcome.data ?? outcome.result ?? { text: outcome.text }, null, 2))
114
+ } else if (outcome.text) {
115
+ write(stdout, outcome.text)
116
+ }
117
+ return 0
118
+ } catch (err) {
119
+ if (err instanceof UserError || err instanceof ServerError) {
120
+ write(stderr, `agentrq-ws: ${err.message}`)
121
+ return err.exitCode || 1
122
+ }
123
+ write(stderr, `agentrq-ws: unexpected error: ${err && err.stack ? err.stack : err}`)
124
+ return 1
125
+ }
126
+ }
@@ -0,0 +1,390 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ import { indexAttachments, readAttachment, resolveOutputPath, writeAttachment } from './attachments.js'
7
+ import { UserError } from './errors.js'
8
+
9
+ /** Statuses the workspace accepts, mirrored from isValidTaskStatus on the backend. */
10
+ export const TASK_STATUSES = ['notstarted', 'ongoing', 'completed', 'rejected', 'cron', 'blocked']
11
+
12
+ const asArray = (value) => (value === undefined ? [] : Array.isArray(value) ? value : [value])
13
+
14
+ /** Read the whole of stdin, so `--body -` and piped text work. */
15
+ export async function readStdin(stream) {
16
+ const chunks = []
17
+ for await (const chunk of stream) chunks.push(chunk)
18
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8')
19
+ }
20
+
21
+ /**
22
+ * Resolve text that may be given inline, read from a file, or piped in.
23
+ *
24
+ * Task bodies and memory contents are routinely longer than a shell argument
25
+ * wants to be, so every place that takes prose takes `@path` or `-` as well.
26
+ */
27
+ export async function resolveText(value, { stdin, what }) {
28
+ if (value === undefined) return undefined
29
+ if (value === '-') {
30
+ if (!stdin) throw new UserError(`no stdin to read ${what} from`)
31
+ return await readStdin(stdin)
32
+ }
33
+ if (typeof value === 'string' && value.startsWith('@')) {
34
+ const path = value.slice(1)
35
+ try {
36
+ return readFileSync(path, 'utf8')
37
+ } catch (err) {
38
+ throw new UserError(`cannot read ${what} from ${path}: ${err.message}`)
39
+ }
40
+ }
41
+ return value
42
+ }
43
+
44
+ /** Build the attachment array for a tool call from repeated --attach flags. */
45
+ export function collectAttachments(values) {
46
+ const paths = asArray(values.attach)
47
+ if (paths.length === 0) return undefined
48
+ return paths.map((path) => readAttachment(path))
49
+ }
50
+
51
+ /** Parse `question=answer` pairs for publishEvent's faq. */
52
+ export function parseFaq(values) {
53
+ const pairs = asArray(values.faq)
54
+ if (pairs.length === 0) return undefined
55
+ return pairs.map((pair) => {
56
+ const at = String(pair).indexOf('=')
57
+ if (at <= 0) {
58
+ throw new UserError(`--faq expects question=answer, got "${pair}"`)
59
+ }
60
+ return { question: pair.slice(0, at), answer: pair.slice(at + 1) }
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Build an elicitation schema from repeated `--field name[:type[:description]]`.
66
+ *
67
+ * The protocol restricts these to a flat object of primitives, which is what
68
+ * makes describing one on a command line reasonable at all. `--schema @file`
69
+ * stays available for anything this shorthand cannot say.
70
+ */
71
+ export function buildRequestedSchema(values) {
72
+ const fields = asArray(values.field)
73
+ if (fields.length === 0) return undefined
74
+ const properties = {}
75
+ const required = []
76
+ for (const field of fields) {
77
+ const [name, type = 'string', ...rest] = String(field).split(':')
78
+ if (!name) throw new UserError(`--field expects a name, got "${field}"`)
79
+ if (!['string', 'number', 'integer', 'boolean'].includes(type)) {
80
+ throw new UserError(`--field "${name}" has unsupported type "${type}" (string, number, integer, boolean)`)
81
+ }
82
+ properties[name] = { type }
83
+ if (rest.length > 0) properties[name].description = rest.join(':')
84
+ required.push(name)
85
+ }
86
+ return { type: 'object', properties, required }
87
+ }
88
+
89
+ function requirePositional(positionals, index, name) {
90
+ const value = positionals[index]
91
+ if (value === undefined || value === '') throw new UserError(`missing required argument <${name}>`)
92
+ return value
93
+ }
94
+
95
+ /**
96
+ * `downloadAttachment` returns base64 and nothing else, so the filename has to
97
+ * come from the task. A task whose text does not mention the id still
98
+ * downloads — under the id as a name — because refusing would be worse than a
99
+ * plainly-named file.
100
+ */
101
+ async function downloadAttachment(ctx, attachmentId, taskId, out) {
102
+ const { text: taskText } = await ctx.client.callTool('getTask', {
103
+ taskId,
104
+ includeConversation: true,
105
+ limit: 200,
106
+ })
107
+ const known = indexAttachments(taskText).get(attachmentId)
108
+ const filename = (known && known.filename) || attachmentId
109
+
110
+ const { text: base64 } = await ctx.client.callTool('downloadAttachment', { attachmentId, taskId })
111
+ if (!base64.trim()) {
112
+ throw new UserError(`attachment ${attachmentId} is empty or was not found on task ${taskId}`)
113
+ }
114
+ const path = resolveOutputPath(filename, out, { cwd: ctx.cwd })
115
+ return writeAttachment(path, base64)
116
+ }
117
+
118
+ export const COMMANDS = [
119
+ {
120
+ path: ['workspace'],
121
+ summary: 'Show the workspace title and mission',
122
+ usage: 'agentrq-ws workspace',
123
+ async run(ctx) {
124
+ return ctx.client.callTool('getWorkspace', {})
125
+ },
126
+ },
127
+ {
128
+ path: ['task', 'get'],
129
+ summary: "Fetch a task by id, optionally with its conversation",
130
+ usage: 'agentrq-ws task get <taskId> [--conversation] [--cursor N] [--limit N]',
131
+ options: {
132
+ conversation: { type: 'boolean', short: 'c', description: "Include the task's chat history" },
133
+ cursor: { type: 'string', description: 'Message pagination offset (default 0)' },
134
+ limit: { type: 'string', description: 'Maximum messages to return (default 5)' },
135
+ },
136
+ async run(ctx) {
137
+ const taskId = requirePositional(ctx.positionals, 0, 'taskId')
138
+ return ctx.client.callTool('getTask', {
139
+ taskId,
140
+ includeConversation: ctx.values.conversation || undefined,
141
+ cursor: ctx.values.cursor ? Number(ctx.values.cursor) : undefined,
142
+ limit: ctx.values.limit ? Number(ctx.values.limit) : undefined,
143
+ })
144
+ },
145
+ },
146
+ {
147
+ path: ['task', 'next'],
148
+ summary: 'Take the next not-started task (dequeues the work queue)',
149
+ usage: 'agentrq-ws task next [--conversation]',
150
+ options: {
151
+ conversation: { type: 'boolean', short: 'c', description: "Include the task's chat history" },
152
+ },
153
+ async run(ctx) {
154
+ // Kept as its own verb rather than a bare `task get`: this call mutates
155
+ // the queue, and a command that silently claims work is a bad surprise.
156
+ return ctx.client.callTool('getTask', {
157
+ includeConversation: ctx.values.conversation || undefined,
158
+ })
159
+ },
160
+ },
161
+ {
162
+ path: ['task', 'create'],
163
+ summary: 'Create a task',
164
+ usage: 'agentrq-ws task create <title> [--body TEXT|@file|-] [--attach PATH]...',
165
+ options: {
166
+ body: { type: 'string', short: 'b', description: 'Task details (@file or - for stdin)' },
167
+ assignee: { type: 'string', description: "'human' or 'agent' (default agent)" },
168
+ cron: { type: 'string', description: "5-field cron schedule, e.g. '30 * * * *'" },
169
+ event: { type: 'string', description: 'Event id to publish when the task completes' },
170
+ attach: { type: 'string', multiple: true, description: 'File to attach (repeatable)' },
171
+ },
172
+ async run(ctx) {
173
+ const title = requirePositional(ctx.positionals, 0, 'title')
174
+ const body = await resolveText(ctx.values.body, { stdin: ctx.stdin, what: 'the task body' })
175
+ return ctx.client.callTool('createTask', {
176
+ title,
177
+ body: body ?? '',
178
+ assignee: ctx.values.assignee,
179
+ cronSchedule: ctx.values.cron,
180
+ eventId: ctx.values.event,
181
+ attachments: collectAttachments(ctx.values),
182
+ })
183
+ },
184
+ },
185
+ {
186
+ path: ['task', 'status'],
187
+ summary: 'Update a task status',
188
+ usage: `agentrq-ws task status <taskId> <${TASK_STATUSES.join('|')}>`,
189
+ async run(ctx) {
190
+ const taskId = requirePositional(ctx.positionals, 0, 'taskId')
191
+ const status = requirePositional(ctx.positionals, 1, 'status')
192
+ if (!TASK_STATUSES.includes(status)) {
193
+ throw new UserError(`unknown status "${status}" (expected one of: ${TASK_STATUSES.join(', ')})`)
194
+ }
195
+ return ctx.client.callTool('updateTaskStatus', { taskId, status })
196
+ },
197
+ },
198
+ {
199
+ path: ['reply'],
200
+ summary: 'Send a message to a task, optionally with files attached',
201
+ usage: 'agentrq-ws reply <taskId> <text|@file|-> [--attach PATH]...',
202
+ options: {
203
+ attach: { type: 'string', multiple: true, description: 'File to attach (repeatable)' },
204
+ },
205
+ async run(ctx) {
206
+ const chatId = requirePositional(ctx.positionals, 0, 'taskId')
207
+ const raw = requirePositional(ctx.positionals, 1, 'text')
208
+ const text = await resolveText(raw, { stdin: ctx.stdin, what: 'the reply text' })
209
+ return ctx.client.callTool('reply', {
210
+ chatId,
211
+ text,
212
+ attachments: collectAttachments(ctx.values),
213
+ })
214
+ },
215
+ },
216
+ {
217
+ path: ['attachment', 'get'],
218
+ summary: 'Download an attachment to a file (no base64, ever)',
219
+ usage: 'agentrq-ws attachment get <attachmentId> --task <taskId> [--out DIR|FILE]',
220
+ options: {
221
+ task: { type: 'string', short: 't', description: 'The task holding the attachment (required)' },
222
+ out: { type: 'string', short: 'o', description: 'Destination directory or file (default: OS temp dir)' },
223
+ },
224
+ async run(ctx) {
225
+ const attachmentId = requirePositional(ctx.positionals, 0, 'attachmentId')
226
+ if (!ctx.values.task) throw new UserError('--task <taskId> is required to locate the attachment')
227
+ const { path, bytes } = await downloadAttachment(ctx, attachmentId, ctx.values.task, ctx.values.out)
228
+ return { text: path, data: { path, bytes } }
229
+ },
230
+ },
231
+ {
232
+ path: ['memory', 'load'],
233
+ summary: 'Read a workspace memory (defaults to the index)',
234
+ usage: 'agentrq-ws memory load [name]',
235
+ async run(ctx) {
236
+ return ctx.client.callTool('loadMemory', { name: ctx.positionals[0] })
237
+ },
238
+ },
239
+ {
240
+ path: ['memory', 'save'],
241
+ summary: 'Replace a workspace memory',
242
+ usage: 'agentrq-ws memory save [name] --content TEXT|@file|-',
243
+ options: {
244
+ content: { type: 'string', short: 'C', description: 'The full new content (@file or - for stdin)' },
245
+ },
246
+ async run(ctx) {
247
+ if (ctx.values.content === undefined) throw new UserError('--content is required (use @file or - to read from stdin)')
248
+ const content = await resolveText(ctx.values.content, { stdin: ctx.stdin, what: 'the memory content' })
249
+ return ctx.client.callTool('saveMemory', { name: ctx.positionals[0], content })
250
+ },
251
+ },
252
+ {
253
+ path: ['memory', 'delete'],
254
+ summary: 'Delete a workspace memory',
255
+ usage: 'agentrq-ws memory delete [name]',
256
+ async run(ctx) {
257
+ return ctx.client.callTool('deleteMemory', { name: ctx.positionals[0] })
258
+ },
259
+ },
260
+ {
261
+ path: ['event', 'publish'],
262
+ summary: 'Publish a named event',
263
+ usage: 'agentrq-ws event publish <name> [--payload TEXT|@file|-] [--task ID] [--faq Q=A]...',
264
+ options: {
265
+ payload: { type: 'string', short: 'p', description: 'What happened (@file or - for stdin)' },
266
+ task: { type: 'string', short: 't', description: 'The task this publish completes' },
267
+ faq: { type: 'string', multiple: true, description: 'question=answer pair (repeatable)' },
268
+ },
269
+ async run(ctx) {
270
+ const name = requirePositional(ctx.positionals, 0, 'name')
271
+ const payload = await resolveText(ctx.values.payload, { stdin: ctx.stdin, what: 'the payload' })
272
+ return ctx.client.callTool('publishEvent', {
273
+ name,
274
+ payload,
275
+ taskId: ctx.values.task,
276
+ faq: parseFaq(ctx.values),
277
+ })
278
+ },
279
+ },
280
+ {
281
+ path: ['ask'],
282
+ summary: 'Ask the human a question and wait for the answer',
283
+ usage: 'agentrq-ws ask <taskId> <message> [--field name[:type[:description]]]... [--url URL]',
284
+ options: {
285
+ mode: { type: 'string', short: 'm', description: "'form' or 'url' (inferred when omitted)" },
286
+ field: { type: 'string', multiple: true, description: 'Form field, e.g. branch:string:Which branch' },
287
+ schema: { type: 'string', description: 'Full requestedSchema as JSON (@file supported)' },
288
+ url: { type: 'string', short: 'u', description: 'Link to show the human (mode=url)' },
289
+ timeout: { type: 'string', description: 'Seconds to wait, max 3600 (default 3600)' },
290
+ },
291
+ async run(ctx) {
292
+ const taskId = requirePositional(ctx.positionals, 0, 'taskId')
293
+ const message = requirePositional(ctx.positionals, 1, 'message')
294
+ let requestedSchema = buildRequestedSchema(ctx.values)
295
+ if (ctx.values.schema !== undefined) {
296
+ const raw = await resolveText(ctx.values.schema, { stdin: ctx.stdin, what: 'the schema' })
297
+ try {
298
+ requestedSchema = JSON.parse(raw)
299
+ } catch (err) {
300
+ throw new UserError(`--schema is not valid JSON: ${err.message}`)
301
+ }
302
+ }
303
+ // The mode is almost always implied by which of the two was given, and
304
+ // making somebody state it as well is ceremony.
305
+ const mode = ctx.values.mode || (ctx.values.url ? 'url' : 'form')
306
+ if (mode === 'url' && !ctx.values.url) throw new UserError('mode=url needs --url')
307
+ if (mode === 'form' && !requestedSchema) {
308
+ throw new UserError('mode=form needs at least one --field (or --schema)')
309
+ }
310
+ return ctx.client.callTool('elicit', {
311
+ taskId,
312
+ message,
313
+ mode,
314
+ requestedSchema,
315
+ url: ctx.values.url,
316
+ timeoutSeconds: ctx.values.timeout ? Number(ctx.values.timeout) : undefined,
317
+ })
318
+ },
319
+ },
320
+ {
321
+ path: ['tools'],
322
+ summary: 'List the tools this workspace server offers',
323
+ usage: 'agentrq-ws tools',
324
+ async run(ctx) {
325
+ const tools = await ctx.client.listTools()
326
+ return {
327
+ text: tools.map((tool) => `${tool.name}\n ${(tool.description || '').split('\n')[0]}`).join('\n'),
328
+ data: tools,
329
+ }
330
+ },
331
+ },
332
+ {
333
+ path: ['call'],
334
+ summary: 'Call any workspace tool directly with JSON arguments',
335
+ usage: "agentrq-ws call <tool> [--args '{\"k\":\"v\"}'|@file|-]",
336
+ options: {
337
+ args: { type: 'string', short: 'a', description: 'Tool arguments as JSON (@file or - for stdin)' },
338
+ },
339
+ async run(ctx) {
340
+ // An escape hatch, so a tool added to the server tomorrow is reachable
341
+ // today without waiting for this CLI to grow a verb for it.
342
+ const name = requirePositional(ctx.positionals, 0, 'tool')
343
+ const raw = await resolveText(ctx.values.args, { stdin: ctx.stdin, what: 'the arguments' })
344
+ let args = {}
345
+ if (raw !== undefined && String(raw).trim() !== '') {
346
+ try {
347
+ args = JSON.parse(raw)
348
+ } catch (err) {
349
+ throw new UserError(`--args is not valid JSON: ${err.message}`)
350
+ }
351
+ }
352
+ return ctx.client.callTool(name, args)
353
+ },
354
+ },
355
+ ]
356
+
357
+ /**
358
+ * Match a leading path that names a family rather than a command — `task`,
359
+ * `memory` — so it can be answered with that family's commands instead of
360
+ * "unknown command", which is a dead end for somebody exploring.
361
+ */
362
+ export function findGroup(argv) {
363
+ const segments = []
364
+ for (const arg of argv) {
365
+ if (String(arg).startsWith('-')) break
366
+ segments.push(arg)
367
+ }
368
+ if (segments.length === 0) return null
369
+
370
+ const members = COMMANDS.filter(
371
+ (command) =>
372
+ command.path.length > segments.length &&
373
+ segments.every((segment, i) => command.path[i] === segment),
374
+ )
375
+ return members.length > 0 ? { segments, members } : null
376
+ }
377
+
378
+ /**
379
+ * Match argv against the command table, longest path first so `task get` wins
380
+ * over a hypothetical `task`.
381
+ */
382
+ export function findCommand(argv) {
383
+ const sorted = [...COMMANDS].sort((a, b) => b.path.length - a.path.length)
384
+ for (const command of sorted) {
385
+ if (command.path.every((segment, i) => argv[i] === segment)) {
386
+ return { command, rest: argv.slice(command.path.length) }
387
+ }
388
+ }
389
+ return { command: null, rest: argv }
390
+ }
package/src/config.js ADDED
@@ -0,0 +1,120 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { dirname, join, parse as parsePath, resolve } from 'node:path'
6
+
7
+ import { UserError } from './errors.js'
8
+
9
+ export const CONFIG_FILENAME = '.mcp.json'
10
+
11
+ /**
12
+ * Walk up from `startDir` looking for a `.mcp.json`.
13
+ *
14
+ * The CLI is meant to be run from inside a workspace checkout the way `git` is,
15
+ * and an agent's working directory is often a subdirectory of the one holding
16
+ * the config, so stopping at the first level would refuse to work from exactly
17
+ * the places people run it from.
18
+ *
19
+ * Returns the absolute path, or null when no ancestor has one.
20
+ */
21
+ export function findConfigFile(startDir, { readFile = readFileSync } = {}) {
22
+ let dir = resolve(startDir)
23
+ const { root } = parsePath(dir)
24
+ for (;;) {
25
+ const candidate = join(dir, CONFIG_FILENAME)
26
+ try {
27
+ readFile(candidate)
28
+ return candidate
29
+ } catch {
30
+ // Not here — keep walking.
31
+ }
32
+ if (dir === root) return null
33
+ const parent = dirname(dir)
34
+ if (parent === dir) return null
35
+ dir = parent
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Pick which server in an `.mcp.json` is the workspace.
41
+ *
42
+ * A config with one entry is unambiguous. With several, an `agentrq` name is
43
+ * the strong hint, and anything still ambiguous is an error that lists the
44
+ * names rather than a guess — connecting to the wrong server would act on the
45
+ * wrong workspace, which is not something to be quietly wrong about.
46
+ */
47
+ export function selectServer(servers, requested) {
48
+ const names = Object.keys(servers || {})
49
+ if (names.length === 0) {
50
+ throw new UserError(`no servers defined in ${CONFIG_FILENAME}`)
51
+ }
52
+ if (requested) {
53
+ if (!servers[requested]) {
54
+ throw new UserError(
55
+ `no server named "${requested}" in ${CONFIG_FILENAME} (have: ${names.join(', ')})`,
56
+ )
57
+ }
58
+ return requested
59
+ }
60
+ if (names.length === 1) return names[0]
61
+
62
+ const agentrq = names.filter((n) => /agentrq/i.test(n))
63
+ if (agentrq.length === 1) return agentrq[0]
64
+
65
+ throw new UserError(
66
+ `${CONFIG_FILENAME} defines several servers (${names.join(', ')}); ` +
67
+ 'choose one with --server <name>',
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Resolve the workspace endpoint the CLI should talk to.
73
+ *
74
+ * `AGENTRQ_WS_URL` wins outright so the CLI can be pointed somewhere without a
75
+ * config file at all (CI, a one-off script). Otherwise the workspace's own
76
+ * `.mcp.json` is the source of the URL and its embedded token.
77
+ */
78
+ export function resolveServer({
79
+ cwd = process.cwd(),
80
+ configPath,
81
+ serverName,
82
+ env = process.env,
83
+ readFile = readFileSync,
84
+ } = {}) {
85
+ if (env.AGENTRQ_WS_URL) {
86
+ return { name: serverName || 'env', url: env.AGENTRQ_WS_URL, headers: {}, source: 'AGENTRQ_WS_URL' }
87
+ }
88
+
89
+ const path = configPath ? resolve(cwd, configPath) : findConfigFile(cwd, { readFile })
90
+ if (!path) {
91
+ throw new UserError(
92
+ `no ${CONFIG_FILENAME} found in ${resolve(cwd)} or any parent directory.\n` +
93
+ 'Run agentrq-ws from a workspace directory, or set AGENTRQ_WS_URL.',
94
+ )
95
+ }
96
+
97
+ let raw
98
+ try {
99
+ raw = readFile(path, 'utf8')
100
+ } catch (err) {
101
+ throw new UserError(`cannot read ${path}: ${err.message}`)
102
+ }
103
+
104
+ let parsed
105
+ try {
106
+ parsed = JSON.parse(String(raw))
107
+ } catch (err) {
108
+ throw new UserError(`${path} is not valid JSON: ${err.message}`)
109
+ }
110
+
111
+ const name = selectServer(parsed.mcpServers, serverName || env.AGENTRQ_WS_SERVER)
112
+ const entry = parsed.mcpServers[name] || {}
113
+ if (!entry.url) {
114
+ throw new UserError(
115
+ `server "${name}" in ${path} has no url. ` +
116
+ 'agentrq-ws speaks HTTP to a workspace server; stdio servers are not supported.',
117
+ )
118
+ }
119
+ return { name, url: entry.url, headers: entry.headers || {}, source: path }
120
+ }
package/src/errors.js ADDED
@@ -0,0 +1,26 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ /**
5
+ * An error caused by how the command was invoked or configured, rather than a
6
+ * fault in the CLI. These print as a plain message with no stack trace: a
7
+ * missing .mcp.json is not a crash, and showing somebody a stack for one
8
+ * teaches them to ignore stacks.
9
+ */
10
+ export class UserError extends Error {
11
+ constructor(message, { exitCode = 1 } = {}) {
12
+ super(message)
13
+ this.name = 'UserError'
14
+ this.exitCode = exitCode
15
+ }
16
+ }
17
+
18
+ /** An error reported by the workspace server, either as HTTP or as a tool result. */
19
+ export class ServerError extends Error {
20
+ constructor(message, { status } = {}) {
21
+ super(message)
22
+ this.name = 'ServerError'
23
+ this.status = status
24
+ this.exitCode = 1
25
+ }
26
+ }