@userreason/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,63 +1,424 @@
1
1
  #!/usr/bin/env node
2
+ import { eventCollection } from './event-policy.js';
3
+ import { CaptureError, capturePage, captureUrl } from './capture.js';
4
+ import { randomUUID } from "node:crypto";
2
5
  import { parseArgs } from "node:util";
3
6
  import { spawn } from "node:child_process";
4
- import { createReadStream } from "node:fs";
7
+ import { createReadStream, readFileSync } from "node:fs";
5
8
  import { UserReasonClient, ApiError } from "./client.js";
6
9
  import { resolveConfig, saveConfig } from "./config.js";
7
- import { keychainCredentials } from "./credentials.js";
10
+ import { resolveCredentials, CredentialStoreError } from "./credentials.js";
8
11
  import { object, origin, scope, uuid } from "./contracts.js";
9
- import { login } from "./workos.js";
10
- const help = `UserReason CLI\n\nuserreason login [--open]\nuserreason logout\nuserreason whoami\nuserreason workspace list\nuserreason workspace create --name NAME\nuserreason site list --workspace UUID\nuserreason site create --workspace UUID --name NAME --origin URL [--origin URL]\nuserreason grant request --scope site:read [--scope site:create] [--workspace UUID] [--wait]\nuserreason grant status --id UUID [--wait]\nuserreason command --file COMMAND.json\nuserreason query --workspace UUID --resource surveys [--site UUID] [--limit 50] [--after UUID]\n\nGlobal options: --dev, --api-url ORIGIN, --client-id CLIENT_ID, --json\nCreation options: --request-id UUID, --workspace-id UUID / --site-id UUID\nSite environment: --environment development|production (default production)\nCredentials are stored in the OS credential store. WorkOS API keys are never required.\n`;
12
+ import { DocsError, formatDocsSearch, getDocsPage, printable, searchDocs } from "./docs.js";
13
+ import { authorizeCli } from "./authorize.js";
14
+ import { detectProjectTarget } from "./project.js";
15
+ import { cliHelp, commandCatalog, getCommandReference, searchCommands } from './catalog.js';
16
+ import { AuthenticationError } from './workos.js';
17
+ import { ConfigurationError, NetworkError } from './errors.js';
18
+ import { docsOrigin, packageProfile } from "./profile.js";
19
+ import { createSpinner, createUiKit, detectAgentHarness, detectColorLevel } from './ui.js';
20
+ import { nounHelp, overviewHelp, overviewNouns, suggestCommand } from './help.js';
21
+ import { grantSummary, promptsView, resultsOverviewView, sitesView, whoamiView, workspacesView } from './views.js';
22
+ /** Terminal styling applies only to a person at a terminal: never when piped, with --json, or in an agent harness. */
23
+ let errorStyle;
24
+ const cliVersion = () => { try {
25
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ } };
30
+ const developmentApi = () => { try {
31
+ return packageProfile().apiEnvironment === 'development';
32
+ }
33
+ catch {
34
+ return false;
35
+ } };
36
+ class UnknownCommand extends Error {
37
+ word;
38
+ suggestion;
39
+ constructor(word, suggestion) {
40
+ super(`Unknown command "${word}".${suggestion ? ` Did you mean userreason ${suggestion}?` : ''} Run userreason --help to see all commands.`);
41
+ this.word = word;
42
+ this.suggestion = suggestion;
43
+ }
44
+ }
45
+ /** Split an error message into its headline and the sentences that tell the person what to do. */
46
+ function splitMessage(message) {
47
+ const [first = message, ...rest] = message.split(/(?<=[.!?])\s+(?=[A-Z])/);
48
+ return { headline: first.replace(/\.$/, ''), hints: rest };
49
+ }
50
+ function humanError(kit, detail, error, retryInfo) {
51
+ const { c, S } = kit;
52
+ const lines = [];
53
+ let headline, hints;
54
+ if (error instanceof UnknownCommand) {
55
+ headline = `Unknown command ${c.code(error.word)}`;
56
+ hints = [...(error.suggestion ? [`${c.muted('Did you mean')} ${c.code(`userreason ${error.suggestion}`)}${c.muted('?')}`] : []), `${c.muted('Search:')} ${c.code(`userreason cli search "${error.word}"`)}`];
57
+ }
58
+ else if (error instanceof ApiError && error.code === 'authorization_required') {
59
+ headline = `Access required ${c.muted(`${S.dot} the current login lacks access to this target`)}`;
60
+ hints = [`${c.muted('Review workspace access:')} ${c.code('userreason access --open')}`, ...(detail.approval_url ? [`${c.muted('Approval link:')} ${c.code(detail.approval_url)}`] : [])];
61
+ }
62
+ else if (detail.error === 'invalid_grant') {
63
+ headline = `Not signed in ${c.muted(`${S.dot} your session expired or was revoked`)}`;
64
+ hints = [`${c.muted('Run')} ${c.code('userreason login --open')}`];
65
+ }
66
+ else {
67
+ const split = splitMessage(detail.message);
68
+ headline = split.headline;
69
+ hints = split.hints.map(hint => c.muted(hint));
70
+ }
71
+ if (retryInfo) {
72
+ const id = retryInfo.prompt_id ?? retryInfo.audience_id ?? retryInfo.event_definition_id;
73
+ hints.push(`${c.muted('Retry with the same input:')} ${c.code(`${id ? `--id ${id} ` : ''}--request-id ${retryInfo.request_id}`)}`, c.muted('Read the resource first if you change the request.'));
74
+ }
75
+ lines.push(`${kit.symbol.error} ${headline}`, ...hints.map(hint => `${c.danger(S.bar)} ${hint}`));
76
+ return lines;
77
+ }
78
+ let retry;
79
+ async function readJsonFile(file) {
80
+ const chunks = [];
81
+ let size = 0;
82
+ for await (const part of createReadStream(file, { highWaterMark: 16_384 })) {
83
+ const chunk = Buffer.from(part);
84
+ size += chunk.byteLength;
85
+ if (size > 65_536)
86
+ throw new Error("JSON file exceeds 64 KB");
87
+ chunks.push(chunk);
88
+ }
89
+ let body;
90
+ try {
91
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
92
+ }
93
+ catch {
94
+ throw new Error("File must contain valid UTF-8 JSON");
95
+ }
96
+ return object(body, "JSON file");
97
+ }
11
98
  async function main() {
12
99
  const parsed = parseArgs({ allowPositionals: true, strict: true, options: {
13
- help: { type: "boolean", short: "h" }, json: { type: "boolean" }, dev: { type: "boolean" }, open: { type: "boolean" }, wait: { type: "boolean" },
14
- "api-url": { type: "string" }, "client-id": { type: "string" }, name: { type: "string" }, workspace: { type: "string" }, id: { type: "string" },
100
+ all: { type: "boolean" }, human: { type: "boolean" }, "no-color": { type: "boolean" },
101
+ width: { type: 'string' }, height: { type: 'string' }, 'capture-bin': { type: 'string' }, 'browser-executable': { type: 'string' }, interactive: { type: 'boolean' },
102
+ preview: { type: "boolean" }, confirm: { type: "string" }, help: { type: "boolean", short: "h" }, json: { type: "boolean" }, dev: { type: "boolean" }, open: { type: "boolean" }, wait: { type: "boolean" }, "no-wait": { type: "boolean" },
103
+ offline: { type: "boolean" }, live: { type: "boolean" }, "credential-store": { type: "string" }, "api-url": { type: "string" }, "docs-url": { type: "string" }, "client-id": { type: "string" }, name: { type: "string" }, workspace: { type: "string" }, id: { type: "string" },
15
104
  origin: { type: "string", multiple: true }, scope: { type: "string", multiple: true }, site: { type: "string", multiple: true }, environment: { type: "string" },
16
105
  "request-id": { type: "string" }, "workspace-id": { type: "string" }, "site-id": { type: "string" },
17
- file: { type: "string" }, resource: { type: "string" }, limit: { type: "string" }, after: { type: "string" },
106
+ file: { type: "string" }, "context-file": { type: "string" }, purpose: { type: "string" }, mode: { type: "string" }, "confirmation-token": { type: "string" }, resource: { type: "string" }, limit: { type: "string" }, after: { type: "string" }, revision: { type: "string" }, "prompt-id": { type: "string" },
107
+ days: { type: "string" }, "time-zone": { type: "string" }, country: { type: "string" }, segment: { type: "string" }, by: { type: "string" }, user: { type: "string" }, kind: { type: "string" }, search: { type: "string" }, cursor: { type: "string" },
108
+ run: { type: 'string' }, variant: { type: 'string' }, 'all-attempts': { type: 'boolean' }, 'reported-only': { type: 'boolean' },
18
109
  } });
19
- const values = parsed.values, [command, action] = parsed.positionals;
110
+ const values = parsed.values, [command, action, positionalId] = parsed.positionals;
111
+ if (action === 'delete' && positionalId && command === 'workspace')
112
+ values.workspace = uuid(positionalId);
113
+ if (action === 'delete' && positionalId && command === 'site')
114
+ values.site = [uuid(positionalId)];
115
+ const harness = detectAgentHarness();
116
+ const humanOut = Boolean(process.stdout.isTTY) && !values.json && (!harness || Boolean(values.human));
117
+ const humanErr = Boolean(process.stderr.isTTY) && !values.json && (!harness || Boolean(values.human));
118
+ const kit = createUiKit({ level: detectColorLevel({ stream: process.stdout, off: values["no-color"] }) });
119
+ const errKit = createUiKit({ level: detectColorLevel({ stream: process.stderr, off: values["no-color"] }), stream: process.stderr });
120
+ errorStyle = { human: humanErr, kit: errKit };
20
121
  if (values.help || !command) {
21
- process.stdout.write(help);
122
+ const noun = command && values.help ? nounHelp(kit, command) : undefined;
123
+ process.stdout.write(values.all ? cliHelp() : noun ?? overviewHelp(kit, { version: cliVersion(), development: values.dev ?? developmentApi(), trademark: humanOut }));
124
+ return;
125
+ }
126
+ if (!overviewNouns.includes(command)) {
127
+ const guess = suggestCommand(command);
128
+ throw new UnknownCommand(command, guess);
129
+ }
130
+ // A command name without an action is a usage error, as before: the actions go to stderr and the exit code is 1.
131
+ if (!action && nounHelp(errKit, command)) {
132
+ process.stderr.write(nounHelp(errKit, command));
133
+ process.exitCode = 1;
22
134
  return;
23
135
  }
24
136
  const required = (key) => { const value = values[key]; if (typeof value !== "string" || !value)
25
137
  throw new Error(`Missing --${key}`); return value; };
26
- const config = await resolveConfig({ development: values.dev, apiUrl: values["api-url"], clientId: values["client-id"] });
27
- const credentials = keychainCredentials(config), client = new UserReasonClient(config, credentials);
28
- let result;
29
- if (command === "login") {
30
- const authenticated = await login(config, credentials, device => {
31
- process.stderr.write(`Open ${device.verificationUriComplete}\nConfirm code: ${device.userCode}\n`);
32
- if (values.open) {
33
- const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
34
- const child = spawn(command, [device.verificationUriComplete], { stdio: "ignore", detached: true });
35
- child.on("error", () => { });
36
- child.unref();
138
+ if (command === "cli") {
139
+ const words = parsed.positionals.slice(2).join(' ');
140
+ const result = action === 'search' ? searchCommands(words, values.limit === undefined ? 5 : Number(values.limit)) : action === 'get' ? getCommandReference(words) : action === 'list' ? commandCatalog : undefined;
141
+ if (!result)
142
+ throw new Error('Use userreason cli search "QUESTION", cli get "COMMAND", or cli list');
143
+ process.stdout.write((humanOut ? kit.json(result) : JSON.stringify(result, null, values.json ? undefined : 2)) + '\n');
144
+ return;
145
+ }
146
+ if (values.offline && values.live)
147
+ throw new Error('Choose --offline or --live');
148
+ if (command === "docs") {
149
+ // Public documentation: no login, saved configuration or credential store is involved.
150
+ let projectDevelopment;
151
+ if (!values['docs-url'] && !process.env.UR_DOCS_URL && values.dev === undefined) {
152
+ try {
153
+ const target = await detectProjectTarget();
154
+ if (target && ['https://api.userreason.com', 'https://api-dev.userreason.com'].includes(target.apiUrl))
155
+ projectDevelopment = target.apiUrl === 'https://api-dev.userreason.com';
37
156
  }
157
+ catch { /* Reading docs never requires a resolvable project. */ }
158
+ }
159
+ const docs = { offline: values.offline, live: values.live ?? false, origin: values["docs-url"] ?? (process.env.UR_DOCS_URL || docsOrigin(values.dev ?? projectDevelopment ?? packageProfile().apiEnvironment === "development")) }, words = parsed.positionals.slice(2);
160
+ if (action === "search") {
161
+ const found = await searchDocs({ ...docs, query: words.join(" "), limit: values.limit === undefined ? undefined : Number(values.limit) });
162
+ if (found.warning)
163
+ process.stderr.write(found.warning.message + '\nUsing the bundled CLI reference.\n');
164
+ process.stdout.write(values.json ? JSON.stringify(found) + "\n" : formatDocsSearch(found));
165
+ }
166
+ else if (action === "get") {
167
+ if (words.length > 1)
168
+ throw new Error("docs get reads one page");
169
+ const page = await getDocsPage({ ...docs, target: words[0] ?? "" });
170
+ process.stdout.write(values.json ? JSON.stringify(page) + "\n" : printable(page.markdown).trimEnd() + "\n");
171
+ }
172
+ else
173
+ throw new Error('Use userreason docs search "QUESTION" or userreason docs get URL_OR_PATH');
174
+ return;
175
+ }
176
+ let project;
177
+ try {
178
+ project = await detectProjectTarget();
179
+ }
180
+ catch (error) {
181
+ if (!values.workspace && !values.site)
182
+ throw error;
183
+ }
184
+ const explicitApi = values["api-url"] ?? process.env.UR_API_URL ?? (values.dev ? 'https://api-dev.userreason.com' : undefined);
185
+ if (project && !values.workspace && !values.site && explicitApi && new URL(explicitApi).origin !== project.apiUrl)
186
+ throw new Error('The selected API differs from the project SDK. Supply --site and --workspace explicitly to target another environment.');
187
+ const selectedApi = explicitApi ?? project?.apiUrl;
188
+ const development = values.dev ?? (selectedApi === 'https://api-dev.userreason.com' ? true : selectedApi === 'https://api.userreason.com' ? false : packageProfile().apiEnvironment === 'development');
189
+ const config = await resolveConfig({ development, apiUrl: selectedApi, clientId: values["client-id"], credentialStore: values["credential-store"] });
190
+ const credentials = resolveCredentials(config), client = new UserReasonClient(config, credentials);
191
+ const targeting = ['preview', 'prompt', 'audience', 'results', 'responses', 'user', 'event', 'member', 'query', 'site'].includes(command);
192
+ let targetName;
193
+ if (targeting && !values.workspace) {
194
+ const siteId = values.site?.length === 1 ? values.site[0] : project?.siteId;
195
+ if (siteId) {
196
+ const site = await client.siteById(uuid(siteId));
197
+ targetName = printable(site.name);
198
+ values.workspace = site.workspace_id;
199
+ if (!values.site && project && !(command === 'site' && ['list', 'create'].includes(action ?? '')))
200
+ values.site = [site.id];
201
+ if (!values.json)
202
+ process.stderr.write(humanErr
203
+ ? `${errKit.symbol.info} ${errKit.c.muted(`Using ${printable(site.name)} (${site.environment})`)}\n`
204
+ : `Using ${printable(site.name)} (${site.id}) on ${config.apiUrl}\n`);
205
+ }
206
+ }
207
+ let result;
208
+ if (command === "login" || command === "access") {
209
+ const open = (url) => {
210
+ if (!values.open)
211
+ return;
212
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
213
+ const child = spawn(command, [url], { stdio: "ignore", detached: true });
214
+ child.on("error", () => { });
215
+ child.unref();
216
+ };
217
+ let spinner = undefined;
218
+ const say = (kind, text) => process.stderr.write(`${errKit.symbol[kind]} ${text}\n`);
219
+ const link = (url) => process.stderr.write(`${errKit.c.faint(errKit.S.bar)} ${errKit.c.code(url)}\n`);
220
+ const wait = (text) => { spinner = createSpinner(errKit, process.stderr); spinner.start(text); };
221
+ result = await authorizeCli(config, credentials, {
222
+ client, wait: !values["no-wait"],
223
+ onDevice: device => {
224
+ if (!humanErr) {
225
+ process.stderr.write(`Open ${device.verificationUriComplete}\nConfirm code: ${device.userCode}\n`);
226
+ open(device.verificationUriComplete);
227
+ return;
228
+ }
229
+ say('info', `${values.open ? 'Opening the browser to sign in' : 'Open the link to sign in'} ${errKit.S.dot} ${errKit.c.muted('code')} ${errKit.c.code(device.userCode)}`);
230
+ if (!values.open)
231
+ link(device.verificationUriComplete);
232
+ open(device.verificationUriComplete);
233
+ wait('Waiting for sign-in');
234
+ },
235
+ onApproval: request => {
236
+ if (humanErr && spinner) {
237
+ spinner.stop();
238
+ spinner = undefined;
239
+ say('done', 'Signed in');
240
+ }
241
+ if (!request.approval_url)
242
+ return;
243
+ if (!humanErr) {
244
+ process.stderr.write(`Review workspace access and permissions: ${request.approval_url}\n`);
245
+ open(request.approval_url);
246
+ return;
247
+ }
248
+ say('info', `Review workspace access and permissions in the browser`);
249
+ if (!values.open)
250
+ link(request.approval_url);
251
+ open(request.approval_url);
252
+ if (!values["no-wait"])
253
+ wait('Waiting for approval');
254
+ },
38
255
  });
39
- await saveConfig(config, values.dev);
40
- result = { authenticated: true, email: authenticated.email, api_url: config.apiUrl };
256
+ spinner?.stop();
257
+ if (humanErr && !values["no-wait"]) {
258
+ const grant = result.grant;
259
+ const summary = grantSummary(grant);
260
+ say('done', `Access approved${summary ? ` ${errKit.c.muted(`(${summary})`)}` : ''}`);
261
+ }
262
+ await saveConfig(config, development);
41
263
  }
42
264
  else if (command === "logout") {
43
265
  await credentials.clear();
44
266
  result = { logged_out: true, message: "Local credentials removed. Revoke approved agent access in the dashboard if needed." };
45
267
  }
46
- else if (command === "whoami")
47
- result = await client.account();
268
+ else if (command === "whoami") {
269
+ const account = await client.account();
270
+ let target = null;
271
+ if (project) {
272
+ try {
273
+ target = { ...project, site: await client.siteById(project.siteId), access: 'approved' };
274
+ }
275
+ catch (error) {
276
+ if (!(error instanceof ApiError))
277
+ throw error;
278
+ target = { ...project, access: error.code, next_step: 'userreason access --open' };
279
+ }
280
+ }
281
+ result = { ...account, api_url: config.apiUrl, project: target };
282
+ }
283
+ else if (command === 'preview' && action === 'capture') {
284
+ if (!positionalId || parsed.positionals.length !== 3)
285
+ throw new Error('Use userreason preview capture URL --dev');
286
+ const url = captureUrl(positionalId);
287
+ if (values.site?.length !== 1)
288
+ throw new Error('Capture requires one --site or a detected project site');
289
+ const workspaceId = uuid(required('workspace')), siteId = uuid(values.site[0]);
290
+ // Check the environment and existing read access before launching Chrome or downloading tools.
291
+ await client.previewCaptures(workspaceId, siteId, true);
292
+ if (!values.json)
293
+ process.stderr.write('Capturing locally with SingleFile. Only the HTML capture is uploaded.\n');
294
+ const start = performance.now();
295
+ const html = await capturePage(url.href, {
296
+ width: values.width === undefined ? undefined : Number(values.width), height: values.height === undefined ? undefined : Number(values.height),
297
+ engine: values['capture-bin'], browser: values['browser-executable'],
298
+ interactive: values.interactive,
299
+ onProgress: values.json ? undefined : message => process.stderr.write(message + '\n'),
300
+ });
301
+ const captureMs = performance.now() - start, uploadStart = performance.now();
302
+ if (!values.json)
303
+ process.stderr.write(`Captured ${(html.byteLength / 1024).toFixed(0)} KiB. Uploading to the development preview…\n`);
304
+ const saved = object(await client.uploadPreview(workspaceId, siteId, values.name ?? url.hostname, html), 'capture');
305
+ result = { ...saved, capture_ms: Math.round(captureMs), upload_ms: Math.round(performance.now() - uploadStart) };
306
+ if (values.open) {
307
+ const links = object(saved.links, 'capture links'), target = new URL(String(links.preview));
308
+ if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password)
309
+ throw new Error('Invalid dashboard preview URL');
310
+ const child = spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open', [target.href], { stdio: 'ignore', detached: true });
311
+ child.on('error', () => { });
312
+ child.unref();
313
+ }
314
+ }
315
+ else if (command === 'deletion' && action === 'status')
316
+ result = await client.deletion('status', { workspace_id: uuid(required('workspace')), id: uuid(required('id')) });
317
+ else if (['workspace', 'site', 'prompt'].includes(command) && action === 'delete') {
318
+ const workspace_id = uuid(required('workspace'));
319
+ const resource_id = command === 'workspace' ? workspace_id : command === 'site' ? uuid(positionalId ?? values.site?.[0] ?? required('id')) : uuid(positionalId ?? required('id'));
320
+ if (command !== 'workspace' && values.site?.length !== 1)
321
+ throw new Error('Deletion requires exactly one --site');
322
+ const target = { workspace_id, resource_type: command, resource_id, ...(command !== 'workspace' ? { site_id: uuid(values.site[0]) } : {}) };
323
+ if (values.preview || !values.confirm)
324
+ result = await client.deletion('preview', target);
325
+ else {
326
+ const request_id = uuid(values['request-id'] ?? randomUUID());
327
+ retry = { request_id };
328
+ result = await client.deletion('request', { ...target, request_id, expected_revision: required('revision'), confirmation_token: required('confirmation-token'), confirm_name: values.confirm });
329
+ }
330
+ }
48
331
  else if (command === "workspace" && action === "list")
49
332
  result = (await client.account()).workspaces;
50
333
  else if (command === "workspace" && action === "create")
51
334
  result = await client.createWorkspace(required("name"), { requestId: values["request-id"], workspaceId: values["workspace-id"] });
52
335
  else if (command === "site" && action === "list")
53
336
  result = await client.sites(uuid(required("workspace")));
337
+ else if (["results", "responses", "user", "event", "member"].includes(command) || (command === "site" && (action === "activity" || action === "frequency" || action === "events")) || (command === "prompt" && action === "history")) {
338
+ // Reads the dashboard also shows, and the two site settings with commands of their own.
339
+ const workspaceId = uuid(required("workspace"));
340
+ if (command === "member") {
341
+ if (action !== "list")
342
+ throw new Error("Use userreason member list");
343
+ result = await client.read('members.list', { workspaceId });
344
+ }
345
+ else {
346
+ if (!values.site || values.site.length !== 1)
347
+ throw new Error("This command requires one --site");
348
+ const context = { workspaceId, siteId: uuid(values.site[0]) }, sub = parsed.positionals[2];
349
+ const number = (key) => values[key] === undefined ? undefined : Number(values[key]);
350
+ const segment = values.segment === undefined ? [] : values.segment.split("=");
351
+ if (values.segment !== undefined && (segment.length !== 2 || !segment[0]))
352
+ throw new Error("Use --segment key=value, such as --segment browser=Chrome");
353
+ const range = { days: number("days"), time_zone: values["time-zone"] };
354
+ const filters = { country: values.country, ...(segment.length ? { segment_key: segment[0], segment_value: segment[1] } : {}) };
355
+ const page = { cursor: values.cursor, limit: number("limit") };
356
+ const requestId = uuid(values["request-id"] ?? randomUUID());
357
+ const send = async (body, ids = {}) => {
358
+ retry = { request_id: requestId, ...ids };
359
+ const sent = { ...object(await client.command({ ...body, workspace_id: context.workspaceId, site_id: context.siteId, request_id: requestId }), 'result'), ...retry };
360
+ retry = undefined;
361
+ return sent;
362
+ };
363
+ if (command === "results" && action === "overview")
364
+ result = await client.read('insights.overview', { ...context, ...range, ...filters });
365
+ else if (command === "results" && action === "prompt")
366
+ result = await client.read('insights.prompt', { ...context, ...range, ...filters, prompt_id: uuid(positionalId ?? required("prompt-id")) });
367
+ else if (command === "results" && action === "experiments")
368
+ result = await client.read('insights.experiments', { ...context, prompt_id: uuid(positionalId ?? required('prompt-id')), run_id: values.run, all_attempts: values['all-attempts'] ?? false, reported_only: values['reported-only'] ?? false });
369
+ else if (command === "results" && action === "breakdown")
370
+ result = await client.read('insights.breakdown', { ...context, ...range, ...filters, by: required("by"), prompt_id: values["prompt-id"] });
371
+ else if (command === "responses" && action === "list")
372
+ result = await client.read('responses.list', { ...context, ...filters, ...page, days: number("days"), prompt_id: values["prompt-id"], user_id: values.user, search: values.search, run_id: values.run, variant_id: values.variant });
373
+ else if (command === "user" && action === "list")
374
+ result = await client.read('users.list', { ...context, ...page, days: number("days"), kind: values.kind, country: values.country, search: values.search });
375
+ else if (command === "user" && action === "get")
376
+ result = await client.read('users.get', { ...context, user_id: uuid(positionalId ?? required("user")) });
377
+ else if (command === "user" && action === "eligibility") {
378
+ // A file tries an audience that is not published yet on this person: an audience file, or the rule alone.
379
+ const file = values.file ? await readJsonFile(values.file) : undefined;
380
+ result = await client.read('users.eligibility', { ...context, user_id: uuid(positionalId ?? required("user")), draft: file ? { audience: 'rule' in file && !('type' in file) ? file.rule : file } : undefined });
381
+ }
382
+ else if (command === "prompt")
383
+ result = await client.read('prompts.history', context);
384
+ else if (command === "event" && action === "overview")
385
+ result = await client.read('events.overview', { ...context, ...range });
386
+ else if (command === "event" && action === "list")
387
+ result = await client.query({ ...context, resource: 'events', limit: number("limit"), after: values.after });
388
+ else if (command === "event" && (action === "create" || action === "update")) {
389
+ const eventId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
390
+ const file = await readJsonFile(required("file"));
391
+ result = await send({ action: `event.${action}`, event_definition_id: eventId, ...(action === 'create' ? { event_key: file.event_key } : { expected_revision: required("revision") }), properties: file.properties ?? {}, collection: eventCollection(file, values.purpose) }, { event_definition_id: eventId });
392
+ }
393
+ else if (command === "site" && action === "events") {
394
+ const mode = required("mode");
395
+ if (!['all', 'registered', 'explicit'].includes(mode))
396
+ throw new Error('Mode must be all, registered or explicit');
397
+ const limits = values.file ? (await readJsonFile(values.file)) : undefined;
398
+ if (sub === 'preview')
399
+ result = await client.query({ ...context, resource: 'event_capture', eventCaptureMode: mode, eventCollectionLimits: limits });
400
+ else if (sub === 'set')
401
+ result = await send({ action: 'site.events.update', event_capture_mode: mode, event_collection_limits: limits, expected_revision: required('revision'), confirmation_token: values['confirmation-token'] });
402
+ else
403
+ throw new Error('Use site events preview or site events set');
404
+ }
405
+ else if (command === "site" && action === "activity")
406
+ result = await client.read('site.activity', context);
407
+ else if (command === "site" && sub === "get") {
408
+ const record = await client.siteRecord(context);
409
+ result = { site_id: record.id, revision: record.revision, frequency_policy: record.frequency_policy ?? null };
410
+ }
411
+ else if (command === "site" && (sub === "set" || sub === "reset"))
412
+ result = await send({ action: 'site.frequency.update', expected_revision: required("revision"), frequency_policy: sub === "set" ? await readJsonFile(required("file")) : null });
413
+ else
414
+ throw new Error(`Unknown ${command} action. Run userreason --help`);
415
+ }
416
+ }
54
417
  else if (command === "site" && action === "create") {
55
418
  const environment = values.environment ?? (values.dev ? "development" : "production");
56
419
  if (environment !== "production" && environment !== "development")
57
420
  throw new Error("Invalid --environment");
58
- if (!values.origin?.length)
59
- throw new Error("Supply one or more --origin values");
60
- result = await client.createSite({ workspaceId: uuid(required("workspace")), name: required("name"), environment, origins: [...new Set(values.origin.map(value => origin(value, environment)))], requestId: values["request-id"], siteId: values["site-id"] });
421
+ result = await client.createSite({ workspaceId: uuid(required("workspace")), name: required("name"), environment, origins: [...new Set((values.origin ?? []).map(value => origin(value, environment)))], requestId: values["request-id"], siteId: values["site-id"] });
61
422
  }
62
423
  else if (command === "grant" && action === "request") {
63
424
  const request = await client.requestGrant({ workspaceId: values.workspace, siteIds: values.site, scopes: (values.scope ?? []).flatMap(value => value.split(",")).map(scope), requestId: values["request-id"] });
@@ -68,36 +429,105 @@ async function main() {
68
429
  else if (command === "grant" && action === "status")
69
430
  result = values.wait ? await client.waitForGrant(required("id")) : await client.grantStatus(required("id"));
70
431
  else if (command === "command") {
71
- const chunks = [];
72
- let size = 0;
73
- for await (const part of createReadStream(required("file"), { highWaterMark: 16_384 })) {
74
- const chunk = Buffer.from(part);
75
- size += chunk.byteLength;
76
- if (size > 65_536)
77
- throw new Error("Command JSON file exceeds 64 KB");
78
- chunks.push(chunk);
79
- }
80
- let body;
81
- try {
82
- body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
83
- }
84
- catch {
85
- throw new Error("Command file must contain valid UTF-8 JSON");
86
- }
87
- result = await client.command(object(body, "command"));
432
+ result = await client.command(await readJsonFile(required("file")));
88
433
  }
89
434
  else if (command === "query") {
90
435
  if (values.site && values.site.length !== 1)
91
436
  throw new Error("A query accepts one --site");
92
- result = await client.query({ workspaceId: required("workspace"), siteId: values.site?.[0], resource: required("resource"), limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after });
437
+ result = await client.query({ workspaceId: required("workspace"), siteId: values.site?.[0], resource: required("resource"), limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after, id: values.id, promptId: values["prompt-id"] });
438
+ }
439
+ else if (command === "prompt") {
440
+ if (!values.site || values.site.length !== 1)
441
+ throw new Error("Survey commands require one --site");
442
+ const context = { workspaceId: uuid(required("workspace")), siteId: uuid(values.site[0]) };
443
+ const page = { limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after };
444
+ if (action === "validate")
445
+ result = await client.read('prompts.validate', { ...context, definition: await readJsonFile(required("file")), ...(values["context-file"] ? await readJsonFile(values["context-file"]) : {}) });
446
+ else if (action === "list")
447
+ result = await client.prompts({ ...context, ...page });
448
+ else {
449
+ if (positionalId && values.id && positionalId !== values.id)
450
+ throw new Error("Survey IDs disagree");
451
+ const promptId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
452
+ if (action === "get")
453
+ result = await client.prompt({ ...context, promptId });
454
+ else if (action === "responses")
455
+ result = await client.query({ ...context, ...page, resource: 'responses', promptId });
456
+ else {
457
+ if (!["create", "update", "publish", "pause", "resume", "archive"].includes(action ?? ''))
458
+ throw new Error("Unknown survey action");
459
+ const definition = action === 'create' || action === 'update' ? await readJsonFile(required("file")) : undefined;
460
+ const requestId = uuid(values["request-id"] ?? randomUUID());
461
+ retry = { request_id: requestId, prompt_id: promptId };
462
+ result = { ...object(await client.promptCommand({ ...context, action: action, promptId, requestId, revision: values.revision, definition }), 'survey result'), ...retry };
463
+ retry = undefined;
464
+ }
465
+ }
466
+ }
467
+ else if (command === "audience") {
468
+ if (!values.site || values.site.length !== 1)
469
+ throw new Error("Audience commands require one --site");
470
+ const context = { workspaceId: uuid(required("workspace")), siteId: uuid(values.site[0]) };
471
+ if (action === "list")
472
+ result = await client.audiences({ ...context, limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after });
473
+ else {
474
+ if (positionalId && values.id && positionalId !== values.id)
475
+ throw new Error("Audience IDs disagree");
476
+ const audienceId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
477
+ if (action === "get")
478
+ result = await client.audience({ ...context, audienceId });
479
+ else {
480
+ if (!["create", "update", "publish", "discard", "archive"].includes(action ?? ''))
481
+ throw new Error("Unknown audience action");
482
+ const audience = action === 'create' || action === 'update' ? await readJsonFile(required("file")) : undefined;
483
+ const requestId = uuid(values["request-id"] ?? randomUUID());
484
+ retry = { request_id: requestId, audience_id: audienceId };
485
+ result = { ...object(await client.audienceCommand({ ...context, action: action, audienceId, requestId, revision: values.revision, audience }), 'audience result'), ...retry };
486
+ retry = undefined;
487
+ }
488
+ }
93
489
  }
94
490
  else
95
491
  throw new Error("Unknown command. Run userreason --help");
96
- process.stdout.write(JSON.stringify(result, null, values.json ? undefined : 2) + "\n");
492
+ if (command === 'logout' && humanErr) {
493
+ process.stderr.write(`${errKit.symbol.done} Signed out ${errKit.c.muted(`${errKit.S.dot} local credentials removed`)}\n${errKit.symbol.info} ${errKit.c.muted('Revoke approved agent access in the dashboard if needed.')}\n`);
494
+ if (!humanOut)
495
+ process.stdout.write(JSON.stringify(result, null, values.json ? undefined : 2) + "\n");
496
+ return;
497
+ }
498
+ // A person at a terminal has just read the progress lines; the JSON result needs --json or a pipe.
499
+ if ((command === 'login' || command === 'access') && humanOut && !values["no-wait"])
500
+ return;
501
+ const view = humanOut ? humanView(kit, command, action, result) : undefined;
502
+ process.stdout.write((view ? view.join('\n') : humanOut ? kit.json(result) : JSON.stringify(result, null, values.json ? undefined : 2)) + "\n");
503
+ function humanView(ui, name, verb, data) {
504
+ try {
505
+ if (name === 'whoami')
506
+ return whoamiView(ui, data, { development });
507
+ if (name === 'site' && verb === 'list' && Array.isArray(data))
508
+ return sitesView(ui, data);
509
+ if (name === 'workspace' && verb === 'list' && Array.isArray(data))
510
+ return workspacesView(ui, data);
511
+ if (name === 'prompt' && verb === 'list' && data && typeof data === 'object')
512
+ return promptsView(ui, data, targetName);
513
+ if (name === 'results' && verb === 'overview' && data && typeof data === 'object')
514
+ return resultsOverviewView(ui, data, targetName);
515
+ }
516
+ catch { /* A response in an unexpected shape falls back to highlighted JSON. */ }
517
+ return undefined;
518
+ }
97
519
  }
98
520
  main().catch(error => {
99
521
  const json = process.argv.includes("--json");
100
- const detail = { error: error instanceof ApiError ? error.code : "command_failed", message: error instanceof Error ? error.message : "Command failed", ...(error instanceof ApiError && error.approvalUrl ? { approval_url: error.approvalUrl } : {}) };
101
- process.stderr.write((json ? JSON.stringify(detail) : detail.message) + "\n");
102
- process.exitCode = 1;
522
+ const detail = { error: error instanceof ApiError || error instanceof DocsError || error instanceof CredentialStoreError || error instanceof NetworkError || error instanceof AuthenticationError || error instanceof ConfigurationError || error instanceof CaptureError ? error.code : "command_failed", message: error instanceof Error ? error.message : "Command failed", ...(error instanceof ApiError && error.approvalUrl ? { approval_url: error.approvalUrl } : {}), ...(retry ? { retry } : {}) };
523
+ const exitCode = error instanceof CaptureError && error.code === 'capture_cancelled' ? 130 : 1;
524
+ if (error instanceof ApiError && error.code === 'authorization_required')
525
+ detail.message += '. The current login lacks access to this target. Run userreason access --open to review workspace access and permissions.';
526
+ if (!json && errorStyle?.human) {
527
+ process.stderr.write(humanError(errorStyle.kit, detail, error, retry).join('\n') + '\n');
528
+ process.exitCode = exitCode;
529
+ return;
530
+ }
531
+ process.stderr.write((json ? JSON.stringify(detail) : detail.message + (retry ? `\nRetry with the same input, --id ${retry.prompt_id} --request-id ${retry.request_id}; read the survey first if changing the request.` : "")) + "\n");
532
+ process.exitCode = exitCode;
103
533
  });
@@ -0,0 +1,18 @@
1
+ /** External, pinned capture tool; no SingleFile source is bundled into the UserReason package. */
2
+ export declare const CAPTURE_ENGINE = "single-file-cli@2.0.77";
3
+ export type CaptureOptions = {
4
+ width?: number;
5
+ height?: number;
6
+ engine?: string;
7
+ browser?: string;
8
+ timeoutMs?: number;
9
+ interactive?: boolean;
10
+ onProgress?: (message: string) => void;
11
+ };
12
+ export declare class CaptureError extends Error {
13
+ code: string;
14
+ constructor(code: string, message: string);
15
+ }
16
+ export declare function captureUrl(value: string): URL;
17
+ export declare function captureArguments(url: string, output: string, options?: CaptureOptions): string[];
18
+ export declare function capturePage(value: string, options?: CaptureOptions): Promise<Uint8Array>;