@userreason/cli 0.1.1 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +217 -0
  2. package/dist/bin.js +93 -8
  3. package/dist/catalog.js +29 -3
  4. package/dist/client.d.ts +8 -2
  5. package/dist/client.js +26 -11
  6. package/dist/first-survey-preparation.d.ts +8 -0
  7. package/dist/first-survey-preparation.js +29 -0
  8. package/dist/first-survey-source.d.ts +61 -0
  9. package/dist/first-survey-source.js +298 -0
  10. package/dist/first-survey-terminal.d.ts +5 -0
  11. package/dist/first-survey-terminal.js +16 -0
  12. package/dist/first-survey.d.ts +88 -0
  13. package/dist/first-survey.js +227 -0
  14. package/dist/help.js +6 -6
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.js +15 -0
  17. package/dist/profile.js +1 -1
  18. package/dist/recommendation-source.d.ts +13 -0
  19. package/dist/recommendation-source.js +211 -0
  20. package/dist/recommended-surveys.d.ts +30 -0
  21. package/dist/recommended-surveys.js +201 -0
  22. package/dist/setup-diagnostics.d.ts +2 -0
  23. package/dist/setup-diagnostics.js +26 -0
  24. package/dist/setup-executor.d.ts +42 -0
  25. package/dist/setup-executor.js +350 -0
  26. package/dist/setup-files.d.ts +11 -0
  27. package/dist/setup-files.js +96 -0
  28. package/dist/setup-heartbeat.d.ts +268 -0
  29. package/dist/setup-heartbeat.js +120 -0
  30. package/dist/setup-runner.d.ts +16 -0
  31. package/dist/setup-runner.js +191 -0
  32. package/dist/setup-source.d.ts +32 -0
  33. package/dist/setup-source.js +48 -0
  34. package/dist/setup-store.d.ts +37 -0
  35. package/dist/setup-store.js +266 -0
  36. package/dist/setup-terminal.d.ts +3 -0
  37. package/dist/setup-terminal.js +6 -0
  38. package/dist/setup-wire.d.ts +1367 -0
  39. package/dist/setup-wire.js +190 -0
  40. package/dist/setup.d.ts +1423 -0
  41. package/dist/setup.js +264 -0
  42. package/dist/starter-survey.d.ts +22 -0
  43. package/dist/starter-survey.js +457 -0
  44. package/dist/telemetry.d.ts +67 -0
  45. package/dist/telemetry.js +304 -0
  46. package/dist/workos.d.ts +1 -1
  47. package/dist/workos.js +4 -2
  48. package/package.json +3 -2
  49. package/skills/userreason/SKILL.md +43 -0
  50. package/skills/userreason/references/management.md +6 -0
package/README.md CHANGED
@@ -121,3 +121,220 @@ site's environment is separate from the API environment.
121
121
 
122
122
  See [troubleshooting](https://docs.userreason.com/get-started/troubleshooting/)
123
123
  for installation, sign-in and configuration help.
124
+
125
+ ## Optional usage measurement
126
+
127
+ The CLI reports canonical command types, success/failure counts, durations,
128
+ execution mode and its version to the UserReason API. It never reports command
129
+ arguments, documentation searches, file paths, source code, command output,
130
+ raw errors, email addresses or credentials as analytics properties. Signed-in
131
+ requests can be associated with the verified UserReason account and workspace;
132
+ anonymous usage uses a random local installation ID. Switching accounts never
133
+ attributes an earlier account's queued activity to the next account.
134
+
135
+ Usage is batched in the per-user UserReason configuration directory, outside
136
+ your project, with a seven-day expiry, bounded storage and an upload deadline of
137
+ 500 ms for anonymous batches or 1.5 seconds for authenticated batches. Failed
138
+ uploads do not change command output or exit status.
139
+ `--offline` sends no analytics requests. Local command discovery (`cli`) also
140
+ stays offline. The queue is retried on later online commands; aggregated command
141
+ counts, rather than the number of batches, describe usage.
142
+
143
+ Disable for one invocation with `--no-telemetry`, for a process or CI job with
144
+ `UR_TELEMETRY_DISABLED=1` (or `DO_NOT_TRACK=1`), or persistently with:
145
+
146
+ ```sh
147
+ userreason telemetry disable
148
+ userreason telemetry status
149
+ userreason telemetry enable
150
+ ```
151
+
152
+ The persistent setting is shared with the installation wizard. Disabling it
153
+ also removes locally queued analytics and the random installation identity.
154
+
155
+ ### Schedule a survey and set collection targets
156
+
157
+ The JSON definition accepted by `prompt create` and `prompt update` supports an
158
+ optional `rules.collection` object. For example:
159
+
160
+ ```json
161
+ {
162
+ "starts_at": "2026-10-20T07:00:00.000Z",
163
+ "ends_at": "2026-10-27T16:00:00.000Z",
164
+ "time_zone": "Europe/Amsterdam",
165
+ "response_target": 500,
166
+ "daily_response_target": 50
167
+ }
168
+ ```
169
+
170
+ Publish the saved draft through the normal prompt command. Use null for an unset
171
+ timestamp or unlimited target, or omit the object for no schedule/targets. Targets
172
+ are shared across the campaign's versions, experiments and enrollments. They stop
173
+ new admissions while allowing already-admitted surveys to finish. `prompt list`
174
+ and prompt reads include the current collection state and completed counts.
175
+
176
+ ## Conversational surveys (development)
177
+
178
+ The development API accepts a terminal conversation as a text question with a
179
+ versioned `conversation` setting. Use the existing `prompt create`, `update`,
180
+ `validate` and `publish` commands with the complete definition. Read
181
+ `https://docs-dev.userreason.com/prompts/conversations/` for the current schema.
182
+ A conversation stands alone or starts an experiment version; it cannot follow a
183
+ rating. Quick, Focused and In-depth presets limit follow-ups and time. Review and
184
+ Send remain separate: only the visitor's accepted answer is a response.
185
+
186
+ ```sh
187
+ userreason workspace ai get --dev --workspace WORKSPACE_UUID
188
+ userreason workspace ai get --dev --workspace WORKSPACE_UUID --site SITE_UUID
189
+ userreason workspace ai set --dev --workspace WORKSPACE_UUID --file AI-SETTINGS.json
190
+ userreason results conversations PROMPT_UUID --dev --workspace WORKSPACE_UUID --site SITE_UUID
191
+ userreason conversation get CONVERSATION_UUID --dev --workspace WORKSPACE_UUID --site SITE_UUID
192
+ ```
193
+
194
+ `AI-SETTINGS.json` contains only the fields to change, for example
195
+ `{"enabled":true,"voice_enabled":false}`. Owners and admins manage allowance
196
+ controls. To change site context, pass one `--site` and a file such as
197
+ `{"product_context":"Scheduled reports email PDF or CSV files."}`; editors can
198
+ update that site context. Missing settings keep their current value.
199
+
200
+ Typed, spoken and live-test sessions share workspace AI allowance. Scripted
201
+ preview uses no allowance. Accepted text, transcript, and optional generated
202
+ analysis have different provenance; generated analysis is not a visitor answer.
203
+ These commands require the matching development backend and private development
204
+ package set. Public npm `latest` remains unchanged by this rollout.
205
+
206
+ ## Browser setup client and local execution
207
+
208
+ The exported setup-v1 client provides a proof-bound handshake, strict bounded
209
+ protocol reads, heartbeat/claim/result adapters and `runSetupExecutor` with a per-project lock for the
210
+ browser-driven wizard. It uses a separate owner-only `setup-v1` store
211
+ outside the project; it neither reads nor replaces normal login credentials.
212
+ Loading a selected expired setup removes its capability. New setup saves lazily
213
+ inspect at most 128 directory entries and remove validated setup records at
214
+ least seven days old; later saves handle remaining batches. There is no cleanup
215
+ daemon. Unknown files, symlinks, project files, locks and normal login credentials
216
+ are left alone.
217
+ The server must explicitly advertise `browser_setup` v1 as enabled. Old or
218
+ disabled servers return an unsupported result before local effects. A started
219
+ setup never silently falls back to a different installation flow.
220
+
221
+ Setup requests also propagate `--no-telemetry`, the shared saved opt-out,
222
+ `UR_TELEMETRY_DISABLED` and `DO_NOT_TRACK` to optional server-side setup
223
+ measurement. Unreadable preference state suppresses measurement without blocking
224
+ the setup request. Browser measurement preferences remain separate.
225
+
226
+ An active setup can request its simulated sample using:
227
+
228
+ ```sh
229
+ userreason setup sample --setup SETUP_UUID --directory /path/to/original/project --json
230
+ ```
231
+
232
+ Use `--api-url` or `--dev` for the same API as that setup. This operation requires
233
+ its unexpired local setup capability and an open sample-test step. It reads a
234
+ clearly labelled static sample and records only that setup's read receipt; it
235
+ does not read real feedback, create responses, consume response quota, publish
236
+ surveys or authorize ongoing CLI access. The ID in the command is not a bearer
237
+ credential. Setup credentials are never printed. `--request-id UUID` permits an
238
+ explicit idempotent retry of the read.
239
+
240
+ The executor accepts only locally prepared, browser-approved typed operations.
241
+ It writes a private intent before starting, verifies exact source/manifest hashes,
242
+ and records the result before reporting. Package-manager lockfiles are declared
243
+ managed outputs: their contents are generated during dependency resolution and
244
+ their actual hashes are saved in the local receipt, rather than predicted.
245
+ After an interruption it reconciles known output; it never blindly reruns an
246
+ unknown command. `recoverSetupProjectLock` explicitly releases a crashed owner's
247
+ lock only after that process is gone. Local receipts contain prepared file bytes,
248
+ stay outside the project with owner-only permissions, and expire after seven
249
+ days. Lazy cleanup is bounded; receipts never restore expired remote access.
250
+ The browser executor requires durable file synchronization (`fsync`). Node 24
251
+ permission mode disables that operation even for allowed paths, so setup stops
252
+ before server calls or application changes under those flags. It does not remove
253
+ permission flags or fall back to nondurable receipts.
254
+
255
+ `detectSetupTerminal` emits fixed platform/app codes only. Installing the project
256
+ agent skill does not grant continuing management access. Ordinary CLI login,
257
+ terminal installation and first-survey commands are unchanged.
258
+
259
+ `runBrowserSetup` connects this executor to the optional browser onboarding flow.
260
+ The server's source-analysis flag is checked before collecting any source. The
261
+ local collector prepares a bounded, memory-only application packet with no Git
262
+ history. `onSourcePrepared` can display the exact excerpts locally; the initial
263
+ `source-plan` request contains only relative paths, line ranges, hashes and package
264
+ metadata. The browser separately approves that packet digest and configured
265
+ providers. Immediately before `source-upload`, the runner rechecks all full-file
266
+ hashes, recollects the same snapshot and validates installed package metadata.
267
+ Source text is never saved in setup receipts. Resuming rebuilds the original
268
+ snapshot ID and refuses changed content until the browser reviews a fresh packet.
269
+
270
+ Heartbeat continues while the bounded analysis request runs. A dropped response
271
+ is reconciled by polling, not a repeated provider call. A failed preparation or
272
+ analysis needs a browser retry or skip. The browser owns candidate selection,
273
+ inactive draft creation, sample skip, finish and cancellation. The local runner
274
+ cannot create drafts, publish surveys or change ongoing CLI grants. Server rollout
275
+ flags remain off by default; these are prepared clients for a compatible release.
276
+
277
+ ## Optional first-survey guidance
278
+
279
+ The development wizard also uses the exported `starterSurvey` flow. It provides
280
+ one-question starters for improvement, workflow friction and satisfaction, with
281
+ optional background personalization. Local preparation after sign-in is
282
+ memory-only; provider processing still needs approval of the exact source packet.
283
+ The model returns wording and cited rationale, while the Worker compiles the
284
+ inactive draft. Users keep control of their edits and choose delivery separately.
285
+ An unconfigured starter matches nobody, even if it is later published unchanged.
286
+ Draft save receipts survive a lost response so retries use the same identifiers.
287
+ This does not change the legacy `first-survey start` command.
288
+
289
+ `userreason first-survey start --workspace WORKSPACE_UUID --site SITE_UUID
290
+ --directory /absolute/app` offers interactive, evidence-backed suggestions and
291
+ individual unpublished draft creation. Repeating the command resumes the private
292
+ session. `handoff` prints a coding-agent task; `cancel` prevents further work;
293
+ `delete` removes the retained plan (not already-created drafts). No publication
294
+ or source edits are automatic. Noninteractive/JSON runs cannot authorize analysis.
295
+
296
+ Only a reviewed packet from the explicit application root is uploaded. Default
297
+ bounds: 200 files, 500 KiB local text, 10 KiB source packet, 12,000 input and 4,000
298
+ output tokens, one clarification round. Symlinks, ignored/generated files and
299
+ files with detected secrets are omitted. Detection is best effort: review every
300
+ excerpt. Git subjects need separate consent and are limited to 20 commits/90 days;
301
+ no authors, diffs or network fetch. Narrow the selected paths when truncated.
302
+
303
+ Before saving, the review shows every question, rating label, answer option and
304
+ conditional route in the returned definition. The analysis deadline is 35 seconds;
305
+ the transport allows the backend's maximum deadline without cutting it off early.
306
+ Draft validation remains authoritative: unsupported survey behavior cannot be
307
+ silently converted or published by this flow.
308
+
309
+ The provider destination and configured policy are shown before approval. Local
310
+ sessions contain hashes, private proposals and mutation receipts, never submitted source
311
+ packets or credentials (generated plans can quote source); protect their owner-only config directory. They expire
312
+ after seven days. Worker plans expire after seven days and are purged on subsequent
313
+ planning activity; provider retention is separately configured/disclosed. Drafts
314
+ have their normal account retention. Deletion retains a content-free budget
315
+ receipt until expiry so it cannot reset allowance. No first-survey content is sent
316
+ to product telemetry.
317
+
318
+ Analysis requires an ongoing connection with `site:read` and `survey:write` on the
319
+ exact site; installation grants do not suffice. Use `access --open` if necessary,
320
+ reviewing replacement-grant semantics rather than broadening installation access.
321
+ Provider errors do not undo installation. Use the agent/manual workflow when
322
+ analysis is unavailable. Integration and browser eligibility remain unverified
323
+ until tested: reachable configuration is not browser verification. Instrumentation
324
+ requires a reviewed, hash-guarded diff and separate edit approval using the agent
325
+ skill; unsupported host transforms are never executed automatically.
326
+
327
+ The 0.2.1 production installation profile selects `@userreason/js@0.5.3` and
328
+ `@userreason/cli@0.2.1` exactly. Browser setup remains an explicit wizard entry
329
+ and requires an enabled compatible server; ordinary CLI authentication and
330
+ management commands retain their existing authorization flow.
331
+
332
+ Local setup execution failures retain bounded, redacted diagnostic text in the
333
+ private execution receipt. `onOperationError` displays it on failure and resume.
334
+ The detailed text never enters setup API requests or telemetry; the server still
335
+ receives only the typed outcome code.
336
+
337
+ A new run automatically recovers an existing project lock only after verifying
338
+ that its recorded process no longer exists. It still acquires the lock exclusively,
339
+ keeps all installation receipts for reconciliation, and refuses live, unreadable
340
+ or malformed locks. A live-lock error identifies the process to finish or stop.
package/dist/bin.js CHANGED
@@ -1,4 +1,8 @@
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';
2
6
  import { eventCollection } from './event-policy.js';
3
7
  import { CaptureError, capturePage, captureUrl } from './capture.js';
4
8
  import { randomUUID } from "node:crypto";
@@ -19,8 +23,14 @@ import { docsOrigin, packageProfile } from "./profile.js";
19
23
  import { createSpinner, createUiKit, detectAgentHarness, detectColorLevel } from './ui.js';
20
24
  import { nounHelp, overviewHelp, overviewNouns, suggestCommand } from './help.js';
21
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';
22
29
  /** Terminal styling applies only to a person at a terminal: never when piped, with --json, or in an agent harness. */
23
30
  let errorStyle;
31
+ let analytics;
32
+ let analyticsCommand = 'unknown';
33
+ const analyticsStarted = performance.now();
24
34
  const cliVersion = () => { try {
25
35
  return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
36
  }
@@ -97,22 +107,35 @@ async function readJsonFile(file) {
97
107
  }
98
108
  async function main() {
99
109
  const parsed = parseArgs({ allowPositionals: true, strict: true, options: {
100
- all: { type: "boolean" }, human: { type: "boolean" }, "no-color": { type: "boolean" },
110
+ directory: { type: "string" }, all: { type: "boolean" }, human: { type: "boolean" }, "no-color": { type: "boolean" }, "no-telemetry": { type: 'boolean' },
101
111
  width: { type: 'string' }, height: { type: 'string' }, 'capture-bin': { type: 'string' }, 'browser-executable': { type: 'string' }, interactive: { type: 'boolean' },
102
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" },
103
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" },
104
114
  origin: { type: "string", multiple: true }, scope: { type: "string", multiple: true }, site: { type: "string", multiple: true }, environment: { type: "string" },
105
- "request-id": { type: "string" }, "workspace-id": { type: "string" }, "site-id": { type: "string" },
115
+ "request-id": { type: "string" }, setup: { type: "string" }, "workspace-id": { type: "string" }, "site-id": { type: "string" },
106
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" },
107
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" },
108
118
  run: { type: 'string' }, variant: { type: 'string' }, 'all-attempts': { type: 'boolean' }, 'reported-only': { type: 'boolean' },
109
119
  } });
110
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
+ }
111
129
  if (action === 'delete' && positionalId && command === 'workspace')
112
130
  values.workspace = uuid(positionalId);
113
131
  if (action === 'delete' && positionalId && command === 'site')
114
132
  values.site = [uuid(positionalId)];
115
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. */ }
116
139
  const humanOut = Boolean(process.stdout.isTTY) && !values.json && (!harness || Boolean(values.human));
117
140
  const humanErr = Boolean(process.stderr.isTTY) && !values.json && (!harness || Boolean(values.human));
118
141
  const kit = createUiKit({ level: detectColorLevel({ stream: process.stdout, off: values["no-color"] }) });
@@ -123,7 +146,7 @@ async function main() {
123
146
  process.stdout.write(values.all ? cliHelp() : noun ?? overviewHelp(kit, { version: cliVersion(), development: values.dev ?? developmentApi(), trademark: humanOut }));
124
147
  return;
125
148
  }
126
- if (!overviewNouns.includes(command)) {
149
+ if (command !== "first-survey" && !overviewNouns.includes(command)) {
127
150
  const guess = suggestCommand(command);
128
151
  throw new UnknownCommand(command, guess);
129
152
  }
@@ -145,6 +168,19 @@ async function main() {
145
168
  }
146
169
  if (values.offline && values.live)
147
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
+ }
148
184
  if (command === "docs") {
149
185
  // Public documentation: no login, saved configuration or credential store is involved.
150
186
  let projectDevelopment;
@@ -178,7 +214,7 @@ async function main() {
178
214
  project = await detectProjectTarget();
179
215
  }
180
216
  catch (error) {
181
- if (!values.workspace && !values.site)
217
+ if (!values.workspace && !values.site && !(command === 'whoami' && error.code === 'ERR_ACCESS_DENIED'))
182
218
  throw error;
183
219
  }
184
220
  const explicitApi = values["api-url"] ?? process.env.UR_API_URL ?? (values.dev ? 'https://api-dev.userreason.com' : undefined);
@@ -187,8 +223,10 @@ async function main() {
187
223
  const selectedApi = explicitApi ?? project?.apiUrl;
188
224
  const development = values.dev ?? (selectedApi === 'https://api-dev.userreason.com' ? true : selectedApi === 'https://api.userreason.com' ? false : packageProfile().apiEnvironment === 'development');
189
225
  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);
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);
192
230
  let targetName;
193
231
  if (targeting && !values.workspace) {
194
232
  const siteId = values.site?.length === 1 ? values.site[0] : project?.siteId;
@@ -204,8 +242,29 @@ async function main() {
204
242
  : `Using ${printable(site.name)} (${site.id}) on ${config.apiUrl}\n`);
205
243
  }
206
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 } : {}) } });
207
246
  let result;
208
- if (command === "login" || command === "access") {
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") {
209
268
  const open = (url) => {
210
269
  if (!values.open)
211
270
  return;
@@ -328,6 +387,25 @@ async function main() {
328
387
  result = await client.deletion('request', { ...target, request_id, expected_revision: required('revision'), confirmation_token: required('confirmation-token'), confirm_name: values.confirm });
329
388
  }
330
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
+ }
331
409
  else if (command === "workspace" && action === "list")
332
410
  result = (await client.account()).workspaces;
333
411
  else if (command === "workspace" && action === "create")
@@ -364,6 +442,8 @@ async function main() {
364
442
  result = await client.read('insights.overview', { ...context, ...range, ...filters });
365
443
  else if (command === "results" && action === "prompt")
366
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')) });
367
447
  else if (command === "results" && action === "experiments")
368
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 });
369
449
  else if (command === "results" && action === "breakdown")
@@ -498,6 +578,8 @@ async function main() {
498
578
  // A person at a terminal has just read the progress lines; the JSON result needs --json or a pipe.
499
579
  if ((command === 'login' || command === 'access') && humanOut && !values["no-wait"])
500
580
  return;
581
+ if (command === 'first-survey' && !values.json)
582
+ return;
501
583
  const view = humanOut ? humanView(kit, command, action, result) : undefined;
502
584
  process.stdout.write((view ? view.join('\n') : humanOut ? kit.json(result) : JSON.stringify(result, null, values.json ? undefined : 2)) + "\n");
503
585
  function humanView(ui, name, verb, data) {
@@ -519,7 +601,7 @@ async function main() {
519
601
  }
520
602
  main().catch(error => {
521
603
  const json = process.argv.includes("--json");
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 } : {}) };
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 } : {}) };
523
605
  const exitCode = error instanceof CaptureError && error.code === 'capture_cancelled' ? 130 : 1;
524
606
  if (error instanceof ApiError && error.code === 'authorization_required')
525
607
  detail.message += '. The current login lacks access to this target. Run userreason access --open to review workspace access and permissions.';
@@ -530,4 +612,7 @@ main().catch(error => {
530
612
  }
531
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");
532
614
  process.exitCode = exitCode;
615
+ }).finally(async () => {
616
+ analytics?.command(analyticsCommand, !process.exitCode, performance.now() - analyticsStarted);
617
+ await analytics?.flush();
533
618
  });
package/dist/catalog.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { SCOPES } from './contracts.js';
2
2
  // Shared by terminal help, offline command discovery, and the bundled CLI reference.
3
- const usages = `userreason login [--open]
3
+ const usages = `userreason telemetry enable|disable|status
4
+ userreason setup sample --setup UUID [--directory PATH] [--request-id UUID]
5
+ userreason first-survey start|handoff|cancel|delete --workspace UUID --site UUID [--directory PATH]
6
+ userreason login [--open]
4
7
  userreason access [--open] [--no-wait]
5
8
  userreason logout
6
9
  userreason whoami
@@ -8,6 +11,9 @@ userreason deletion status --id UUID --workspace UUID
8
11
  userreason workspace delete UUID [--preview] [--confirm NAME --revision N --confirmation-token TOKEN]
9
12
  userreason site delete UUID --workspace UUID [--preview] [--confirm NAME --revision N --confirmation-token TOKEN]
10
13
  userreason prompt delete UUID --workspace UUID --site UUID [--preview] [--confirm NAME --revision N --confirmation-token TOKEN]
14
+ userreason workspace ai get --workspace UUID [--site UUID]
15
+ userreason workspace ai set --file SETTINGS.json --workspace UUID [--site UUID]
16
+ userreason conversation get UUID --workspace UUID --site UUID
11
17
  userreason workspace list
12
18
  userreason workspace create --name NAME
13
19
  userreason site list --workspace UUID
@@ -29,6 +35,7 @@ userreason audience update UUID --file AUDIENCE.json --revision N --workspace UU
29
35
  userreason audience publish|discard|archive UUID --revision N --workspace UUID --site UUID
30
36
  userreason results overview --workspace UUID --site UUID [--days 1|7|30|90] [--time-zone ZONE]
31
37
  userreason results prompt UUID --workspace UUID --site UUID [--days N] [--country NL] [--segment browser=Chrome]
38
+ userreason results conversations UUID --workspace UUID --site UUID [--days N]
32
39
  userreason results experiments UUID --workspace UUID --site UUID [--run UUID] [--all-attempts] [--reported-only]
33
40
  userreason results breakdown --by country|language|device_type|os|browser --workspace UUID --site UUID [--prompt-id UUID]
34
41
  userreason responses list --workspace UUID --site UUID [--prompt-id UUID] [--run UUID] [--variant ID] [--user UUID] [--search TEXT] [--days N] [--cursor CURSOR]
@@ -54,16 +61,23 @@ userreason cli search "QUESTION" [--limit 5]
54
61
  userreason cli get "COMMAND"
55
62
  userreason cli list`;
56
63
  const descriptions = {
64
+ setup: 'Read the clearly simulated answers for one active browser setup using its separate local setup capability; no ordinary login or real response data.',
65
+ telemetry: 'Enable, disable or inspect optional anonymous usage measurement. UR_TELEMETRY_DISABLED=1 and --no-telemetry disable it too.',
57
66
  preview: 'Capture a webpage locally and upload a responsive HTML background to the development preview lab.',
58
67
  deletion: 'Read the status of a permanent deletion job.',
59
68
  'workspace delete': 'Preview or explicitly confirm permanent removal of a workspace and all its data.',
60
69
  'site delete': 'Preview or explicitly confirm permanent removal of a site and all its data.',
61
70
  'prompt delete': 'Preview or explicitly confirm permanent removal of a survey, versions and responses.',
71
+ 'first-survey': 'Review consented source-backed proposals and explicitly save real unpublished drafts; handoff requires no upload.',
62
72
  login: 'Sign in and approve workspace access and permissions in the browser.',
63
73
  access: 'Renew or change approved workspace access and permissions.',
64
74
  logout: 'Remove the session from the selected local credential store.',
65
75
  whoami: 'Inspect the signed-in account, API, workspace grants and project site.',
66
76
  workspace: 'List or create workspaces.',
77
+ 'workspace ai get': 'Read the shared conversation allowance and optional site product context.',
78
+ 'workspace ai set': 'Update authorized conversation controls or site product context from a complete settings file.',
79
+ conversation: 'Read a conversation transcript and reviewed answer with source provenance.',
80
+ 'results conversations': 'Read conversational survey results, completion, timing and answer evidence.',
67
81
  site: 'List or create sites, inspect SDK connection activity, or manage survey frequency limits.',
68
82
  grant: 'Request deliberately restricted access or inspect approval status.',
69
83
  prompt: 'Manage feedback surveys and their questions.',
@@ -94,10 +108,16 @@ const descriptions = {
94
108
  export const commandSummary = (command) => descriptions[command];
95
109
  function scopeFor(command) {
96
110
  const [group, action] = command.split(' ');
111
+ if (group === 'first-survey')
112
+ return action === 'handoff' ? [] : ['site:read', 'survey:write'];
97
113
  if (group === 'preview')
98
114
  return ['site:read', 'survey:write'];
99
115
  if (action === 'delete')
100
116
  return ['resource:delete'];
117
+ if (command === 'workspace ai set')
118
+ return ['workspace:read'];
119
+ if (group === 'conversation')
120
+ return ['site:read'];
101
121
  if (group === 'workspace')
102
122
  return [action === 'create' ? 'workspace:create' : 'workspace:read'];
103
123
  if (group === 'member')
@@ -117,14 +137,20 @@ function scopeFor(command) {
117
137
  }
118
138
  export const commandCatalog = usages.split('\n').flatMap(line => {
119
139
  const withoutBinary = line.replace(/^userreason /, '');
120
- const path = withoutBinary.match(/^[a-z]+(?:[| ][a-z]+)*/)?.[0].trim() ?? '';
140
+ const path = withoutBinary.match(/^[a-z]+(?:-[a-z]+)*(?:[| ][a-z]+(?:-[a-z]+)*)*/)?.[0].trim() ?? '';
121
141
  const parts = path.split(' '), last = parts.pop();
122
142
  return last.split('|').map(action => {
123
143
  const command = [...parts, action].join(' ');
124
- const targeted = ['preview', 'site', 'prompt', 'audience', 'results', 'responses', 'user', 'event', 'query'].includes(parts[0] ?? action);
144
+ const targeted = ['conversation', 'preview', 'site', 'prompt', 'audience', 'results', 'responses', 'user', 'event', 'query'].includes(parts[0] ?? action);
125
145
  const notes = targeted ? ['Workspace and site default to the project SDK initialization. Explicit flags override detection. Missing access never selects another site.'] : [];
146
+ if (command === 'workspace ai set')
147
+ notes.push('Owner/admin is required for workspace allowance controls. Site product context accepts editors. Updating one setting does not reset omitted settings.');
148
+ if (command.startsWith('conversation') || command === 'results conversations')
149
+ notes.push('Development feature. Accepted answers are separate from generated analysis; retain speaker, typed/spoken and recorded/live provenance.');
126
150
  if (command === 'preview capture')
127
151
  notes.push('Development API only. Requires Chrome; first use downloads a pinned external SingleFile CLI via npm. Captures use a fresh browser profile, preserve responsive assets and expire after seven days. Use --interactive to answer browser dialogs in a visible capture window. Returns links.preview.');
152
+ if (command === 'setup sample')
153
+ notes.push('Requires an explicitly selected setup ID and its unexpired local capability for this API/project. Records only a setup-scoped sample-read receipt; never creates real responses, consumes response quota or grants ongoing access. Browser setup must be enabled by the server.');
128
154
  if (['create', 'update'].includes(action) && ['prompt', 'audience'].includes(parts[0]))
129
155
  notes.push('The JSON file is a complete definition, not a patch. Saving a draft does not publish it.');
130
156
  if (parts[0] === 'prompt' && ['create', 'update', 'publish', 'pause', 'resume', 'archive'].includes(action))
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@ import { type Fetch } from "./workos.js";
4
4
  export type PromptAction = 'create' | 'update' | 'publish' | 'pause' | 'resume' | 'archive';
5
5
  export type AudienceAction = 'create' | 'update' | 'publish' | 'discard' | 'archive';
6
6
  /** What the dashboard reads beside records: results, the response feed, users, events and members. */
7
- export declare const READ_OPERATIONS: readonly ["insights.overview", "insights.prompt", "insights.experiments", "insights.breakdown", "responses.list", "users.list", "users.get", "users.eligibility", "prompts.validate", "prompts.history", "events.overview", "site.activity", "members.list"];
7
+ export declare const READ_OPERATIONS: readonly ["workspace.ai", "conversation.transcript", "insights.conversations", "insights.overview", "insights.prompt", "insights.experiments", "insights.breakdown", "responses.list", "users.list", "users.get", "users.eligibility", "prompts.validate", "prompts.history", "events.overview", "site.activity", "members.list"];
8
8
  export type ReadOperation = typeof READ_OPERATIONS[number];
9
9
  export declare class ApiError extends Error {
10
10
  status: number;
@@ -22,7 +22,7 @@ export declare class UserReasonClient {
22
22
  lock?: <T>(operation: () => Promise<T>) => Promise<T>;
23
23
  });
24
24
  private token;
25
- request(path: string, body?: unknown): Promise<unknown>;
25
+ request(path: string, body?: unknown, signal?: AbortSignal): Promise<unknown>;
26
26
  private authenticatedRequest;
27
27
  previewCaptures(workspaceId: string, siteId: string, forUpload?: boolean): Promise<unknown>;
28
28
  uploadPreview(workspaceId: string, siteId: string, name: string, html: Uint8Array): Promise<unknown>;
@@ -69,6 +69,12 @@ export declare class UserReasonClient {
69
69
  workspaceId: string;
70
70
  siteId?: string;
71
71
  } & Record<string, unknown>): Promise<unknown>;
72
+ /** Workspace allowance settings and optional site product context; server enforces the caller's role. */
73
+ updateWorkspaceAi(input: {
74
+ workspaceId: string;
75
+ siteId?: string;
76
+ settings: Record<string, unknown>;
77
+ }): Promise<unknown>;
72
78
  /** One site as stored, with its revision and frequency policy (null while it follows the starting values). */
73
79
  siteRecord(input: {
74
80
  workspaceId: string;
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ import { refresh, AuthenticationError } from "./workos.js";
4
4
  import { withSessionLock } from "./lock.js";
5
5
  import { fetchWithErrors } from './errors.js';
6
6
  /** What the dashboard reads beside records: results, the response feed, users, events and members. */
7
- export const READ_OPERATIONS = ['insights.overview', 'insights.prompt', 'insights.experiments', 'insights.breakdown', 'responses.list', 'users.list', 'users.get', 'users.eligibility', 'prompts.validate', 'prompts.history', 'events.overview', 'site.activity', 'members.list'];
7
+ export const READ_OPERATIONS = ['workspace.ai', 'conversation.transcript', 'insights.conversations', 'insights.overview', 'insights.prompt', 'insights.experiments', 'insights.breakdown', 'responses.list', 'users.list', 'users.get', 'users.eligibility', 'prompts.validate', 'prompts.history', 'events.overview', 'site.activity', 'members.list'];
8
8
  export class ApiError extends Error {
9
9
  status;
10
10
  code;
@@ -27,8 +27,9 @@ export class UserReasonClient {
27
27
  this.options = options;
28
28
  this.fetcher = options.fetcher ?? fetch;
29
29
  }
30
- async token(forceRefresh = false, previous) {
30
+ async token(forceRefresh = false, previous, signal) {
31
31
  const read = async () => {
32
+ signal?.throwIfAborted();
32
33
  const current = await this.credentials.load();
33
34
  if (!current)
34
35
  throw new AuthenticationError('login_required', 'Run userreason login --open first. Existing Keychain logins can be used with --credential-store keychain.');
@@ -49,20 +50,25 @@ export class UserReasonClient {
49
50
  return current;
50
51
  if (current.accessToken.startsWith('ur-install.'))
51
52
  throw new AuthenticationError('installation_expired', 'Installation access expired or was revoked; rerun the wizard to reconnect');
52
- return refresh(this.config, this.credentials, current, this.fetcher);
53
+ return refresh(this.config, this.credentials, current, this.fetcher, signal);
53
54
  });
54
55
  }
55
- async request(path, body) {
56
- return this.authenticatedRequest(path, body === undefined ? undefined : JSON.stringify(body), 'application/json');
56
+ async request(path, body, signal) {
57
+ return this.authenticatedRequest(path, body === undefined ? undefined : JSON.stringify(body), 'application/json', signal);
57
58
  }
58
- async authenticatedRequest(path, body, contentType) {
59
+ async authenticatedRequest(path, body, contentType, signal) {
59
60
  if (!path.startsWith("/onboarding/v1/") || path.includes("//") || path.includes("\\"))
60
61
  throw new Error("Invalid UserReason API path");
61
- let session = await this.token();
62
+ // Planning may use the API's full 40-second deadline. One transport budget
63
+ // also bounds authentication refresh; cancellation must survive a retry.
64
+ const requestSignal = AbortSignal.any([AbortSignal.timeout(path === '/onboarding/v1/planning/analyze' ? 45_000 : 20_000), ...(signal ? [signal] : [])]);
65
+ requestSignal.throwIfAborted();
66
+ let session = await this.token(false, undefined, requestSignal);
62
67
  for (let attempt = 0; attempt < 2; attempt++) {
63
- const response = await fetchWithErrors(this.fetcher, this.config.apiUrl + path, { method: body === undefined ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session.accessToken}`, "x-ur-client": "cli", "Content-Type": contentType }, ...(body === undefined ? {} : { body: body }), signal: AbortSignal.timeout(20_000) }, 'UserReason API request');
68
+ requestSignal.throwIfAborted();
69
+ const response = await fetchWithErrors(this.fetcher, this.config.apiUrl + path, { method: body === undefined ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session.accessToken}`, "x-ur-client": "cli", "Content-Type": contentType }, ...(body === undefined ? {} : { body: body }), signal: requestSignal }, 'UserReason API request');
64
70
  if (response.status === 401 && attempt === 0) {
65
- session = await this.token(true, session.accessToken);
71
+ session = await this.token(true, session.accessToken, requestSignal);
66
72
  continue;
67
73
  }
68
74
  let result;
@@ -167,18 +173,27 @@ export class UserReasonClient {
167
173
  if (!READ_OPERATIONS.includes(operation))
168
174
  throw new Error("Unsupported read operation");
169
175
  const { workspaceId, siteId, ...rest } = input;
170
- if (operation !== 'members.list' && !siteId)
176
+ if (!['members.list', 'workspace.ai'].includes(operation) && !siteId)
171
177
  throw new Error("This read requires a site");
172
178
  if (rest.run_id !== undefined)
173
179
  uuid(string(rest.run_id, 'Experiment run ID'));
174
180
  if (rest.variant_id && !rest.run_id)
175
181
  throw new Error('A variant filter requires an experiment run');
176
- const body = { workspace_id: uuid(workspaceId), ...(operation === 'members.list' ? {} : { site_id: uuid(siteId) }) };
182
+ const body = { workspace_id: uuid(workspaceId), ...(operation === 'members.list' || !siteId ? {} : { site_id: uuid(siteId) }) };
177
183
  for (const [key, value] of Object.entries(rest))
178
184
  if (value !== undefined && value !== null && value !== '')
179
185
  body[key] = value;
180
186
  return this.request(`/onboarding/v1/read/${operation}`, body);
181
187
  }
188
+ /** Workspace allowance settings and optional site product context; server enforces the caller's role. */
189
+ async updateWorkspaceAi(input) {
190
+ const allowed = new Set(['enabled', 'voice_enabled', 'monthly_limit_microusd', 'concurrent_limit', 'preview_daily_limit', 'product_context']);
191
+ if (!Object.keys(input.settings).length || Object.keys(input.settings).some(key => !allowed.has(key)))
192
+ throw new Error('AI settings require supported setting fields only');
193
+ if ('product_context' in input.settings && !input.siteId)
194
+ throw new Error('Product context requires a site');
195
+ return this.request('/onboarding/v1/workspace/ai/update', { ...input.settings, workspace_id: uuid(input.workspaceId), ...(input.siteId ? { site_id: uuid(input.siteId) } : {}) });
196
+ }
182
197
  /** One site as stored, with its revision and frequency policy (null while it follows the starting values). */
183
198
  async siteRecord(input) {
184
199
  const page = object(await this.query({ ...input, resource: 'sites', id: input.siteId, limit: 1 }), 'sites');