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