@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
@@ -0,0 +1,304 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { configDirectory } from './config.js';
6
+ import { apiUrl } from './contracts.js';
7
+ import { commandCatalog } from './catalog.js';
8
+ export const WIZARD_FLOW_VERSION = '2026-10-26.1';
9
+ export const WIZARD_CONTENT_VERSION = '2026-10-26.1';
10
+ export const WIZARD_STEPS = ['run', 'authentication', 'target_selection', 'options', 'sdk_install', 'cli_install', 'initialization', 'configuration_check', 'agent_note', 'agent_skill', 'cli_access', 'global_cli'];
11
+ export const WIZARD_PHASES = ['eligible', 'offered', 'selected', 'declined', 'applied', 'failed', 'skipped', 'already_present', 'cancelled', 'started', 'completed'];
12
+ export const TELEMETRY_REASONS = ['not_requested', 'installation_pending', 'already_present', 'up_to_date', 'unsupported', 'existing_content_preserved', 'permission_denied', 'validation_failed', 'network_error', 'operation_failed', 'user_cancelled', 'explicit_skip', 'packages_current'];
13
+ const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
14
+ const versionPattern = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/;
15
+ const ttl = 7 * 24 * 60 * 60 * 1000;
16
+ const maximumRecords = 160;
17
+ const maximumFileBytes = 128 * 1024;
18
+ const commands = new Set([...commandCatalog.map(item => item.command), 'unknown', 'help']);
19
+ const directory = () => join(configDirectory(), 'telemetry');
20
+ const disabledEnvironment = () => /^(1|true|yes)$/i.test(process.env.UR_TELEMETRY_DISABLED ?? '') || process.env.DO_NOT_TRACK === '1';
21
+ const number = (value, max = 86_400_000) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max;
22
+ const duration = (value) => Math.min(86_400_000, Math.max(0, Math.round(value)));
23
+ /** Read only regular, bounded files. Local analytics state must never follow a symlink. */
24
+ async function readJson(path) {
25
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try {
27
+ const stat = await handle.stat();
28
+ if (!stat.isFile() || stat.size > maximumFileBytes)
29
+ throw new Error('Invalid telemetry state');
30
+ return JSON.parse(await handle.readFile('utf8'));
31
+ }
32
+ finally {
33
+ await handle.close();
34
+ }
35
+ }
36
+ async function ensureDirectory() {
37
+ await mkdir(directory(), { recursive: true, mode: 0o700 });
38
+ const stat = await lstat(directory());
39
+ if (!stat.isDirectory() || stat.isSymbolicLink())
40
+ throw new Error('Invalid telemetry directory');
41
+ }
42
+ async function atomicJson(path, value) {
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ const file = await open(temporary, 'wx', 0o600);
46
+ try {
47
+ await file.writeFile(JSON.stringify(value) + '\n');
48
+ }
49
+ finally {
50
+ await file.close();
51
+ }
52
+ await rename(temporary, path);
53
+ }
54
+ finally {
55
+ await rm(temporary, { force: true }).catch(() => { });
56
+ }
57
+ }
58
+ export async function telemetryEnabled() {
59
+ if (disabledEnvironment())
60
+ return false;
61
+ try {
62
+ return (await readJson(join(directory(), 'preferences.json'))).enabled !== false;
63
+ }
64
+ catch (error) {
65
+ return error.code === 'ENOENT';
66
+ }
67
+ }
68
+ export async function setTelemetryEnabled(enabled) {
69
+ await ensureDirectory();
70
+ await atomicJson(join(directory(), 'preferences.json'), { enabled });
71
+ if (!enabled)
72
+ for (const entry of (await readdir(directory())).slice(0, 512)) {
73
+ if (/^(?:queue-[a-f0-9]{64}\.json(?:\.lock)?|installation\.json)$/.test(entry))
74
+ await rm(join(directory(), entry), { force: true }).catch(() => { });
75
+ }
76
+ }
77
+ export function telemetryCommand(positionals, help = false) {
78
+ if (help || !positionals.length)
79
+ return 'help';
80
+ // Only compare against our static catalog; never emit a caller-provided argument.
81
+ return [...commands].filter(item => item !== 'unknown' && item !== 'help')
82
+ .sort((left, right) => right.length - left.length)
83
+ .find(item => item.split(' ').every((word, index) => positionals[index] === word)) ?? 'unknown';
84
+ }
85
+ function validRecord(value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value))
87
+ return false;
88
+ const record = value;
89
+ const common = ['event_id', 'occurred_at', 'run_id', 'surface', 'release_version', 'execution_mode', 'workspace_id', 'site_id', 'source_site_environment'];
90
+ const allowed = record.event === 'ur.wizard.step' ? [...common, 'event', 'flow_version', 'content_version', 'step_id', 'phase', 'duration_ms', 'reason', 'selection_source', 'framework', 'package_manager', 'resumed', 'guidance_file'] : [...common, 'event', 'command', 'success_count', 'failure_count', 'total_duration_ms', 'max_duration_ms'];
91
+ if (Object.keys(record).some(key => !allowed.includes(key)) || !idPattern.test(record.event_id) || !idPattern.test(record.run_id) || !versionPattern.test(record.release_version) || !['interactive', 'agent', 'ci', 'noninteractive'].includes(record.execution_mode))
92
+ return false;
93
+ if (!Number.isFinite(Date.parse(record.occurred_at)) || Date.now() - Date.parse(record.occurred_at) > ttl || Date.parse(record.occurred_at) > Date.now() + 300_000)
94
+ return false;
95
+ if ((record.workspace_id && !idPattern.test(record.workspace_id)) || (record.site_id && (!record.workspace_id || !idPattern.test(record.site_id))) || (record.source_site_environment && !['development', 'production'].includes(record.source_site_environment)))
96
+ return false;
97
+ if (record.event === 'ur.wizard.step')
98
+ return record.surface === 'wizard' && WIZARD_STEPS.includes(record.step_id) && WIZARD_PHASES.includes(record.phase) && versionPattern.test(record.flow_version) && versionPattern.test(record.content_version)
99
+ && (record.duration_ms === undefined || number(record.duration_ms)) && (record.reason === undefined || TELEMETRY_REASONS.includes(record.reason))
100
+ && (record.selection_source === undefined || ['prompt', 'flag', 'default'].includes(record.selection_source))
101
+ && (record.framework === undefined || ['react', 'next', 'vue', 'vanilla'].includes(record.framework))
102
+ && (record.package_manager === undefined || ['npm', 'pnpm', 'yarn'].includes(record.package_manager))
103
+ && (record.resumed === undefined || typeof record.resumed === 'boolean') && (record.guidance_file === undefined || ['AGENTS.md', 'CLAUDE.md'].includes(record.guidance_file));
104
+ return record.event === 'ur.cli.usage' && record.surface === 'cli' && commands.has(record.command)
105
+ && Number.isSafeInteger(record.success_count) && number(record.success_count, 10000) && Number.isSafeInteger(record.failure_count) && number(record.failure_count, 10000)
106
+ && record.success_count + record.failure_count > 0 && number(record.total_duration_ms, 864000000000) && number(record.max_duration_ms) && record.total_duration_ms >= record.max_duration_ms;
107
+ }
108
+ /** A best-effort recorder. Credentials stay in memory and never enter the spool or payload. */
109
+ export class ToolTelemetry {
110
+ options;
111
+ runId = randomUUID();
112
+ pending = [];
113
+ session = null;
114
+ context = {};
115
+ wizardContext = {};
116
+ active = true;
117
+ fetcher;
118
+ origin;
119
+ constructor(options) {
120
+ this.options = options;
121
+ this.fetcher = options.fetcher ?? fetch;
122
+ this.origin = apiUrl(options.apiUrl);
123
+ this.active = !options.disabled && !disabledEnvironment() && versionPattern.test(options.releaseVersion);
124
+ }
125
+ configure(input) {
126
+ if (input.apiUrl)
127
+ this.origin = apiUrl(input.apiUrl);
128
+ if (input.context)
129
+ this.context = { ...input.context };
130
+ if (input.wizard)
131
+ this.wizardContext = { ...this.wizardContext, ...input.wizard };
132
+ }
133
+ setSession(session) { this.session = session?.apiUrl === this.origin && session.expiresAt > Date.now() ? session : null; }
134
+ observeCredentials(credentials) {
135
+ return {
136
+ load: async () => { const session = await credentials.load(); this.setSession(session); return session; },
137
+ save: async (session) => { await credentials.save(session); this.setSession(session); },
138
+ clear: async () => { await credentials.clear(); this.setSession(null); },
139
+ };
140
+ }
141
+ scope() { return this.session ? createHash('sha256').update(`user:${this.session.userId}`).digest('hex') : 'anonymous'; }
142
+ common() {
143
+ return { event_id: randomUUID(), occurred_at: new Date().toISOString(), run_id: this.runId, surface: this.options.surface, release_version: this.options.releaseVersion, execution_mode: this.options.executionMode,
144
+ // Target IDs are included only with authenticated records. They are still verified at ingestion.
145
+ ...(this.session ? this.context : { source_site_environment: this.context.source_site_environment }) };
146
+ }
147
+ step(step_id, phase, details = {}) {
148
+ if (!this.active || this.options.surface !== 'wizard' || this.pending.length >= maximumRecords)
149
+ return;
150
+ const record = { ...this.common(), ...this.wizardContext, ...details, event: 'ur.wizard.step', flow_version: WIZARD_FLOW_VERSION, content_version: WIZARD_CONTENT_VERSION, step_id, phase };
151
+ if (record.duration_ms !== undefined)
152
+ record.duration_ms = duration(record.duration_ms);
153
+ if (validRecord(record))
154
+ this.pending.push({ scope: this.scope(), record });
155
+ }
156
+ command(command, success, durationMs) {
157
+ if (!this.active || this.options.surface !== 'cli')
158
+ return;
159
+ const record = { ...this.common(), event: 'ur.cli.usage', command: commands.has(command) ? command : 'unknown', success_count: success ? 1 : 0, failure_count: success ? 0 : 1, total_duration_ms: duration(durationMs), max_duration_ms: duration(durationMs) };
160
+ if (validRecord(record))
161
+ this.pending.push({ scope: this.scope(), record });
162
+ }
163
+ /** Save a bounded checkpoint without introducing a network call into a wizard step. */
164
+ checkpoint() { return this.flush(false, true); }
165
+ async flush(force = false, storeOnly = false) {
166
+ if (!this.active)
167
+ return;
168
+ try {
169
+ if (!await telemetryEnabled()) {
170
+ this.pending = [];
171
+ return;
172
+ }
173
+ await ensureDirectory();
174
+ const scopes = new Set(['anonymous', this.scope(), ...this.pending.map(item => item.scope)]);
175
+ for (const scope of scopes) {
176
+ const currentScope = this.scope();
177
+ // An old user partition may be saved, but never sent with a new account's token.
178
+ const authorized = scope === 'anonymous' || (scope === currentScope && this.session && this.session.expiresAt > Date.now());
179
+ const key = createHash('sha256').update(`${this.origin}\n${scope}`).digest('hex');
180
+ const path = join(directory(), `queue-${key}.json`), lockPath = `${path}.lock`;
181
+ let lock;
182
+ try {
183
+ lock = await open(lockPath, 'wx', 0o600);
184
+ }
185
+ catch {
186
+ // Uploads last at most 1.5 s; a much older lock belongs to an interrupted process.
187
+ try {
188
+ if (Date.now() - (await lstat(lockPath)).mtimeMs > 30_000)
189
+ await rm(lockPath);
190
+ }
191
+ catch { /* Another process owns or removed it. */ }
192
+ continue;
193
+ }
194
+ try {
195
+ const queued = this.pending.filter(item => item.scope === scope).map(item => item.record);
196
+ let state = { schema_version: 1, installation_id: await this.installationId(), last_attempt: 0, frozen_ids: [], records: [] };
197
+ try {
198
+ const saved = await readJson(path);
199
+ if (saved.schema_version === 1 && idPattern.test(saved.installation_id) && Array.isArray(saved.records))
200
+ state = { ...state, last_attempt: number(saved.last_attempt, Number.MAX_SAFE_INTEGER) ? saved.last_attempt : 0, frozen_ids: Array.isArray(saved.frozen_ids) ? saved.frozen_ids.filter(id => typeof id === 'string' && idPattern.test(id)).slice(0, maximumRecords) : [], records: saved.records.filter(validRecord) };
201
+ }
202
+ catch { /* Missing, malformed or unsafe state is discarded. */ }
203
+ state.records = this.aggregate([...state.records, ...queued], new Set(state.frozen_ids)).slice(-maximumRecords);
204
+ state.frozen_ids = state.frozen_ids.filter(id => state.records.some(record => record.event_id === id));
205
+ await atomicJson(path, state);
206
+ this.pending = this.pending.filter(item => item.scope !== scope);
207
+ if (!state.records.length || storeOnly || this.options.offline || !authorized || (!force && Date.now() - state.last_attempt < (this.options.minimumIntervalMs ?? 60_000)))
208
+ continue;
209
+ const records = [];
210
+ for (const record of state.records.slice(0, 50)) {
211
+ const candidate = { schema_version: 1, installation_id: state.installation_id, records: [...records, record] };
212
+ if (Buffer.byteLength(JSON.stringify(candidate)) > 30_000)
213
+ break;
214
+ records.push(record);
215
+ }
216
+ if (!records.length)
217
+ continue;
218
+ state.last_attempt = Date.now();
219
+ // A timed-out upload may already have arrived. Never mutate a record after its first attempt.
220
+ state.frozen_ids = [...new Set([...state.frozen_ids, ...records.map(record => record.event_id)])];
221
+ await atomicJson(path, state);
222
+ const response = await this.fetcher(`${this.origin}/v1/telemetry/developer-tools`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(scope !== 'anonymous' && this.session ? { Authorization: `Bearer ${this.session.accessToken}` } : {}) }, redirect: 'error', signal: AbortSignal.timeout(scope === 'anonymous' ? 500 : 1500), body: JSON.stringify({ schema_version: 1, installation_id: state.installation_id, records }) });
223
+ await response.body?.cancel().catch(() => { });
224
+ if (response.ok) {
225
+ state.records = state.records.slice(records.length);
226
+ state.frozen_ids = state.frozen_ids.filter(id => state.records.some(record => record.event_id === id));
227
+ await atomicJson(path, state);
228
+ }
229
+ }
230
+ catch { /* A failed anonymous upload must not discard the authenticated partition. */ }
231
+ finally {
232
+ await lock.close();
233
+ await rm(lockPath, { force: true }).catch(() => { });
234
+ }
235
+ }
236
+ await this.cleanup();
237
+ }
238
+ catch { /* Telemetry must not change command results, output or exit status. */ }
239
+ }
240
+ aggregate(records, frozen) {
241
+ const result = [], aggregates = new Map();
242
+ for (const record of records) {
243
+ if (record.event !== 'ur.cli.usage' || frozen.has(record.event_id)) {
244
+ result.push(record);
245
+ continue;
246
+ }
247
+ const key = JSON.stringify([record.command, record.release_version, record.execution_mode, record.workspace_id, record.site_id, record.source_site_environment]);
248
+ const previous = aggregates.get(key);
249
+ if (!previous || previous.success_count + record.success_count > 10000 || previous.failure_count + record.failure_count > 10000 || previous.total_duration_ms + record.total_duration_ms > 864000000000) {
250
+ const next = { ...record };
251
+ aggregates.set(key, next);
252
+ result.push(next);
253
+ }
254
+ else {
255
+ previous.success_count += record.success_count;
256
+ previous.failure_count += record.failure_count;
257
+ previous.total_duration_ms += record.total_duration_ms;
258
+ previous.max_duration_ms = Math.max(previous.max_duration_ms, record.max_duration_ms);
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+ async installationId() {
264
+ const path = join(directory(), 'installation.json');
265
+ try {
266
+ const state = await readJson(path);
267
+ if (state.id && idPattern.test(state.id))
268
+ return state.id;
269
+ }
270
+ catch { /* Create a random installation id if absent. */ }
271
+ const id = randomUUID();
272
+ try {
273
+ const file = await open(path, 'wx', 0o600);
274
+ try {
275
+ await file.writeFile(JSON.stringify({ id }));
276
+ }
277
+ finally {
278
+ await file.close();
279
+ }
280
+ return id;
281
+ }
282
+ catch {
283
+ const state = await readJson(path);
284
+ if (state.id && idPattern.test(state.id))
285
+ return state.id;
286
+ throw new Error('Invalid installation identity');
287
+ }
288
+ }
289
+ async cleanup() {
290
+ const entries = (await readdir(directory())).filter(name => /^queue-[a-f0-9]{64}\.json$/.test(name)).slice(0, 512);
291
+ const files = await Promise.all(entries.map(async (name) => ({ path: join(directory(), name), stat: await lstat(join(directory(), name)) })));
292
+ files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
293
+ for (const [index, { path, stat }] of files.entries()) {
294
+ if (stat.isSymbolicLink() || Date.now() - stat.mtimeMs > ttl || index >= 32) {
295
+ try {
296
+ await lstat(`${path}.lock`);
297
+ }
298
+ catch {
299
+ await rm(path, { force: true });
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ type Role = 'accent' | 'good' | 'warn' | 'danger' | 'muted' | 'code' | 'faint';
2
+ export declare const stripAnsi: (value: string) => string;
3
+ export declare const textWidth: (value: string) => number;
4
+ /** 0 none, 1 sixteen colours, 2 256 colours, 3 truecolor. Honours NO_COLOR and FORCE_COLOR. */
5
+ export type ColorLevel = 0 | 1 | 2 | 3;
6
+ export declare function detectColorLevel({ stream, env, off }?: {
7
+ stream?: {
8
+ isTTY?: boolean;
9
+ };
10
+ env?: NodeJS.ProcessEnv;
11
+ off?: boolean;
12
+ }): ColorLevel;
13
+ export declare function detectAgentHarness(env?: NodeJS.ProcessEnv): string | null;
14
+ export interface UiKit {
15
+ level: ColorLevel;
16
+ c: Record<Role | 'bold', (text: string) => string>;
17
+ S: {
18
+ info: string;
19
+ done: string;
20
+ ask: string;
21
+ warn: string;
22
+ error: string;
23
+ section: string;
24
+ bar: string;
25
+ rule: string;
26
+ arrow: string;
27
+ dot: string;
28
+ frames: string[];
29
+ };
30
+ symbol: {
31
+ info: string;
32
+ done: string;
33
+ ask: string;
34
+ warn: string;
35
+ error: string;
36
+ };
37
+ cols(rows: [string, string?][], options?: {
38
+ gap?: number;
39
+ }): string[];
40
+ table(head: string[], rows: string[][]): string[];
41
+ json(value: unknown, indent?: number): string;
42
+ header(options: {
43
+ mode: string;
44
+ version: string;
45
+ detail?: string | string[];
46
+ trademark?: boolean;
47
+ }): string[];
48
+ }
49
+ export declare function createUiKit({ level, light, ascii, stream }?: {
50
+ level?: ColorLevel;
51
+ light?: boolean;
52
+ ascii?: boolean;
53
+ stream?: {
54
+ isTTY?: boolean;
55
+ };
56
+ }): UiKit;
57
+ /** Stable word prefixes for piped, CI and agent output, so lines can be filtered with grep. */
58
+ export type PlainKind = 'info' | 'done' | 'warn' | 'error' | 'skip' | 'next';
59
+ export declare const plainLine: (kind: PlainKind, text: string) => string;
60
+ /**
61
+ * A one-line spinner. Unlike clack's it leaves no guide line behind, so a finished task can be replaced
62
+ * by a single result line. Without a terminal it prints nothing while it runs.
63
+ */
64
+ export interface Spinner {
65
+ start(message: string): void;
66
+ message(message: string): void;
67
+ stop(finalLine?: string): void;
68
+ }
69
+ export declare function createSpinner(kit: UiKit, stream?: {
70
+ isTTY?: boolean;
71
+ write(chunk: string): unknown;
72
+ }, options?: {
73
+ onCancel?: () => void;
74
+ }): Spinner;
75
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,107 @@
1
+ // Terminal presentation shared by the CLI and the installation wizard: palette, symbols, colour and
2
+ // agent detection, aligned columns, tables, highlighted JSON and a one-line spinner. Pure code: it
3
+ // reads no files and makes no network requests, so discovery commands stay permission-safe.
4
+ const ESC = '\x1b[';
5
+ const DARK = { accent: [141, 151, 238], good: [76, 195, 138], warn: [240, 163, 94], danger: [242, 119, 125], muted: [139, 139, 150], code: [134, 212, 217], faint: [92, 92, 102] };
6
+ const LIGHT = { accent: [64, 73, 168], good: [24, 121, 78], warn: [180, 72, 12], danger: [194, 42, 50], muted: [95, 96, 104], code: [11, 111, 120], faint: [170, 170, 180] };
7
+ const DARK256 = { accent: 105, good: 78, warn: 215, danger: 210, muted: 245, code: 116, faint: 240 };
8
+ const LIGHT256 = { accent: 61, good: 29, warn: 130, danger: 160, muted: 241, code: 30, faint: 248 };
9
+ const DARK16 = { accent: 94, good: 32, warn: 33, danger: 31, muted: 90, code: 36, faint: 90 };
10
+ const LIGHT16 = { accent: 34, good: 32, warn: 33, danger: 31, muted: 90, code: 36, faint: 90 };
11
+ export const stripAnsi = (value) => String(value).replace(/\x1b\[[0-9;]*m/g, '');
12
+ export const textWidth = (value) => [...stripAnsi(value)].length;
13
+ export function detectColorLevel({ stream = process.stdout, env = process.env, off = false } = {}) {
14
+ if (off || (env.NO_COLOR ?? '') !== '' || env.FORCE_COLOR === '0')
15
+ return 0;
16
+ const forced = (env.FORCE_COLOR ?? '') !== '';
17
+ if (!forced && (!stream.isTTY || env.TERM === 'dumb'))
18
+ return 0;
19
+ if (/truecolor|24bit/i.test(env.COLORTERM ?? ''))
20
+ return 3;
21
+ if (/256/.test(env.TERM ?? '') || env.TERM_PROGRAM === 'Apple_Terminal')
22
+ return 2;
23
+ return 1;
24
+ }
25
+ /** The variable that identified an agent harness or CI run, from the same set cf checks; null in a person's terminal. */
26
+ const HARNESS = ['CLAUDECODE', 'CURSOR_AGENT', 'GEMINI_CLI', 'OPENCODE', 'CODEX_THREAD_ID', 'AMP_CURRENT_THREAD_ID', 'AI_AGENT', 'CI'];
27
+ export function detectAgentHarness(env = process.env) {
28
+ return HARNESS.find(key => { const value = env[key]; return value && !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); }) ?? null;
29
+ }
30
+ export function createUiKit({ level, light = false, ascii = false, stream = process.stdout } = {}) {
31
+ const lv = level ?? detectColorLevel({ stream });
32
+ const rgb = light ? LIGHT : DARK, p256 = light ? LIGHT256 : DARK256, p16 = light ? LIGHT16 : DARK16;
33
+ const open = (role) => (lv === 3 ? `${ESC}38;2;${rgb[role].join(';')}m` : lv === 2 ? `${ESC}38;5;${p256[role]}m` : `${ESC}${p16[role]}m`);
34
+ const paint = (role) => (text) => (lv === 0 ? String(text) : `${open(role)}${text}${ESC}39m`);
35
+ const c = {
36
+ accent: paint('accent'), good: paint('good'), warn: paint('warn'), danger: paint('danger'),
37
+ muted: paint('muted'), code: paint('code'), faint: paint('faint'),
38
+ bold: (text) => (lv === 0 ? String(text) : `${ESC}1m${text}${ESC}22m`),
39
+ };
40
+ const S = ascii
41
+ ? { info: '*', done: '+', ask: 'o', warn: '!', error: 'x', section: '+', bar: '|', rule: '-', arrow: '>', dot: '-', frames: ['-', '\\', '|', '/'] }
42
+ : { info: '●', done: '◆', ask: '◇', warn: '▲', error: '■', section: '├', bar: '│', rule: '─', arrow: '›', dot: '·', frames: ['◒', '◐', '◓', '◑'] };
43
+ const symbol = { info: c.accent(S.info), done: c.good(S.done), ask: c.accent(S.ask), warn: c.warn(S.warn), error: c.danger(S.error) };
44
+ const cols = (rows, { gap = 2 } = {}) => {
45
+ const first = Math.max(0, ...rows.map(([left]) => textWidth(left)));
46
+ return rows.map(([left, right]) => (right === undefined ? left : `${left}${' '.repeat(first - textWidth(left) + gap)}${c.muted(right)}`));
47
+ };
48
+ const table = (head, rows) => {
49
+ const sizes = head.map((title, i) => Math.max(textWidth(title), ...rows.map(row => textWidth(row[i] ?? ''))));
50
+ const line = (cells) => cells.map((cell, i) => (i === cells.length - 1 ? cell : cell + ' '.repeat((sizes[i] ?? 0) - textWidth(cell) + 2))).join('');
51
+ return [c.muted(line(head)), ...rows.map(line)];
52
+ };
53
+ const json = (value, indent = 2) => {
54
+ const text = JSON.stringify(value, null, indent);
55
+ if (lv === 0)
56
+ return text;
57
+ return text
58
+ .replace(/"((?:[^"\\]|\\.)*)":/g, (_, key) => `${c.accent(`"${key}"`)}:`)
59
+ .replace(/: "((?:[^"\\]|\\.)*)"/g, (_, val) => `: ${c.good(`"${val}"`)}`)
60
+ .replace(/: (-?\d+\.?\d*|true|false)/g, (_, val) => `: ${c.warn(val)}`)
61
+ .replace(/: (null)/g, (_, val) => `: ${c.muted(val)}`);
62
+ };
63
+ // The trademark sign belongs to the terminal header only: never in plain, piped, JSON or ASCII output.
64
+ const header = ({ mode, version, detail, trademark = false }) => {
65
+ const brand = trademark && !ascii ? 'UserReason™' : 'userreason';
66
+ const lines = [`${c.bold(c.accent(brand))} ${c.muted(`${mode} ${S.dot} v${version}`)}`];
67
+ for (const line of [detail ?? []].flat())
68
+ lines.push(c.muted(line));
69
+ lines.push(c.faint(S.rule.repeat(54)));
70
+ return lines;
71
+ };
72
+ return { level: lv, c, S, symbol, cols, table, json, header };
73
+ }
74
+ export const plainLine = (kind, text) => `${kind.padEnd(6)} ${stripAnsi(text)}`;
75
+ export function createSpinner(kit, stream = process.stderr, options = {}) {
76
+ const live = Boolean(stream.isTTY);
77
+ let timer, frame = 0, text = '', started = 0;
78
+ const draw = () => {
79
+ const seconds = Math.floor((Date.now() - started) / 1000);
80
+ stream.write(`\r${ESC}2K${kit.c.accent(kit.S.frames[frame++ % kit.S.frames.length])} ${text}${seconds > 0 ? kit.c.muted(` ${kit.S.dot} ${seconds}s`) : ''}`);
81
+ };
82
+ const restore = () => stream.write(`\r${ESC}2K${ESC}?25h`);
83
+ const interrupt = () => { clearInterval(timer); restore(); options.onCancel?.(); process.exit(130); };
84
+ return {
85
+ start(message) {
86
+ text = message;
87
+ started = Date.now();
88
+ if (!live)
89
+ return;
90
+ stream.write(`${ESC}?25l`);
91
+ draw();
92
+ timer = setInterval(draw, 90);
93
+ process.once('SIGINT', interrupt);
94
+ },
95
+ message(message) { text = message; },
96
+ /** Remove the spinner line; optionally print a final line in its place. */
97
+ stop(finalLine) {
98
+ if (live) {
99
+ clearInterval(timer);
100
+ process.off('SIGINT', interrupt);
101
+ restore();
102
+ }
103
+ if (finalLine)
104
+ stream.write(`${finalLine}\n`);
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,51 @@
1
+ import type { Account, Grant, Site, Workspace } from './contracts.js';
2
+ import { type UiKit } from './ui.js';
3
+ /** "89 days", "5 hours", "12 minutes" until a timestamp, or null once it has passed. */
4
+ export declare function expiresIn(iso: string, now?: number): string | null;
5
+ /** "all workspaces, expires in 90 days" for the line printed once access is approved. */
6
+ export declare function grantSummary(grant: Pick<Grant, 'all_workspaces' | 'workspace_ids' | 'workspace_id' | 'expires_at'> | undefined, now?: number): string;
7
+ export interface WhoamiResult {
8
+ user?: Account['user'];
9
+ workspaces?: Workspace[];
10
+ grants?: Grant[];
11
+ api_url: string;
12
+ project?: ({
13
+ file?: string;
14
+ site?: Site;
15
+ access?: string;
16
+ next_step?: string;
17
+ }) | null;
18
+ }
19
+ export declare function whoamiView(kit: UiKit, result: WhoamiResult, options: {
20
+ development: boolean;
21
+ now?: number;
22
+ }): string[];
23
+ export declare function sitesView(kit: UiKit, sites: Site[], workspace?: string): string[];
24
+ export declare function workspacesView(kit: UiKit, workspaces: Workspace[]): string[];
25
+ interface PromptRow {
26
+ id?: unknown;
27
+ name?: unknown;
28
+ status?: unknown;
29
+ revision?: unknown;
30
+ }
31
+ export declare function promptsView(kit: UiKit, page: {
32
+ items?: PromptRow[];
33
+ next?: unknown;
34
+ }, site?: string): string[];
35
+ interface OverviewPrompt {
36
+ name?: unknown;
37
+ impressions?: unknown;
38
+ responses?: unknown;
39
+ status?: unknown;
40
+ }
41
+ export declare function resultsOverviewView(kit: UiKit, overview: {
42
+ range?: {
43
+ days?: number;
44
+ };
45
+ totals?: {
46
+ impressions?: number;
47
+ responses?: number;
48
+ };
49
+ prompts?: OverviewPrompt[];
50
+ }, site?: string): string[];
51
+ export {};
package/dist/views.js ADDED
@@ -0,0 +1,93 @@
1
+ import { textWidth } from './ui.js';
2
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
3
+ /** "89 days", "5 hours", "12 minutes" until a timestamp, or null once it has passed. */
4
+ export function expiresIn(iso, now = Date.now()) {
5
+ const ms = Date.parse(iso) - now;
6
+ if (!Number.isFinite(ms) || ms <= 0)
7
+ return null;
8
+ const minutes = Math.ceil(ms / 60_000), hours = Math.ceil(ms / 3_600_000), days = Math.floor(ms / 86_400_000);
9
+ return minutes < 60 ? plural(minutes, 'minute') : hours < 48 ? plural(hours, 'hour') : plural(days, 'day');
10
+ }
11
+ const domains = (site) => (site.allowed_origins.length ? site.allowed_origins.map(value => value.replace(/^https?:\/\//, '')).join(', ') : 'any domain');
12
+ const environmentLabel = (apiUrl, development) => `${development ? 'development' : 'production'} API (${apiUrl.replace(/^https?:\/\//, '')})`;
13
+ /** What a grant covers, in words: its workspaces. */
14
+ function grantScope(grant) {
15
+ if (grant.all_workspaces)
16
+ return 'all workspaces';
17
+ const count = grant.workspace_ids?.length ?? (grant.workspace_id ? 1 : 0);
18
+ return count > 0 ? plural(count, 'workspace') : 'this account';
19
+ }
20
+ /** "all workspaces, expires in 90 days" for the line printed once access is approved. */
21
+ export function grantSummary(grant, now = Date.now()) {
22
+ if (!grant)
23
+ return '';
24
+ const left = expiresIn(grant.expires_at, now);
25
+ return [grantScope(grant), left ? `expires in ${left}` : ''].filter(Boolean).join(', ');
26
+ }
27
+ export function whoamiView(kit, result, options) {
28
+ const { c, S } = kit;
29
+ const row = (name, text) => ` ${name.padEnd(14)}${text}`;
30
+ const email = result.user?.email ?? result.user?.name ?? result.user?.id ?? 'signed in';
31
+ const environment = environmentLabel(result.api_url, options.development);
32
+ const lines = [`${c.bold(email)}${' '.repeat(Math.max(2, 46 - textWidth(email)))}${c.muted(environment)}`, ''];
33
+ lines.push(c.bold('Workspaces'));
34
+ const workspaces = result.workspaces ?? [];
35
+ lines.push(...(workspaces.length ? workspaces.map(workspace => row(workspace.name, c.muted(workspace.role ?? 'member'))) : [row('none', c.muted('create one with userreason workspace create --name NAME'))]));
36
+ lines.push(c.bold('Access'));
37
+ const active = (result.grants ?? []).filter(grant => !grant.revoked_at && expiresIn(grant.expires_at, options.now));
38
+ if (!active.length)
39
+ lines.push(row('none', c.muted('run userreason access --open to approve access')));
40
+ for (const grant of active) {
41
+ lines.push(row('CLI access', `${grantScope(grant)} ${c.muted(`${S.dot} expires in ${expiresIn(grant.expires_at, options.now)}`)}`));
42
+ lines.push(row('', c.code(grant.scopes.join(' '))));
43
+ }
44
+ if (result.project) {
45
+ lines.push(c.bold('Project'));
46
+ const { site, access, file, next_step } = result.project;
47
+ if (site)
48
+ lines.push(row(site.name, `${c.muted(`(${site.environment}, ${domains(site)})`)} ${S.dot} ${c.good('access approved')}`));
49
+ else
50
+ lines.push(row('this project', `${c.muted(`(${access ?? 'no access'})`)}${next_step ? ` ${S.dot} ${c.code(next_step)}` : ''}`));
51
+ if (file)
52
+ lines.push(row('', c.muted(file)));
53
+ }
54
+ return lines;
55
+ }
56
+ export function sitesView(kit, sites, workspace) {
57
+ const { c } = kit;
58
+ const title = c.bold(workspace ? `Sites in ${workspace}` : 'Sites');
59
+ if (!sites.length)
60
+ return [title, '', c.muted('No sites yet. Create one with userreason site create --name NAME.')];
61
+ return [title, '', ...kit.table(['NAME', 'ENVIRONMENT', 'DOMAINS', 'ID'], sites.map(site => [site.name, site.environment, domains(site), c.code(site.id)]))];
62
+ }
63
+ export function workspacesView(kit, workspaces) {
64
+ const { c } = kit;
65
+ if (!workspaces.length)
66
+ return [c.bold('Workspaces'), '', c.muted('No workspaces yet. Create one with userreason workspace create --name NAME.')];
67
+ return [c.bold('Workspaces'), '', ...kit.table(['NAME', 'ROLE', 'ID'], workspaces.map(workspace => [workspace.name, workspace.role ?? 'member', c.code(workspace.id)]))];
68
+ }
69
+ export function promptsView(kit, page, site) {
70
+ const { c } = kit;
71
+ const title = `${c.bold('Surveys')}${site ? ` ${c.muted(`(${site})`)}` : ''}`;
72
+ const items = Array.isArray(page.items) ? page.items : [];
73
+ if (!items.length)
74
+ return [title, '', c.muted('No surveys yet. Create one with userreason prompt create --file PROMPT.json.')];
75
+ const status = (value) => { const text = String(value ?? ''); return text === 'published' || text === 'active' ? c.good(text) : c.muted(text); };
76
+ const lines = [title, '', ...kit.table(['NAME', 'STATUS', 'REV', 'ID'], items.map(item => [String(item.name ?? ''), status(item.status), String(item.revision ?? ''), c.code(String(item.id ?? ''))]))];
77
+ if (page.next)
78
+ lines.push('', c.muted(`More: add --after ${String(page.next)}`));
79
+ return lines;
80
+ }
81
+ export function resultsOverviewView(kit, overview, site) {
82
+ const { c } = kit;
83
+ const days = overview.range?.days;
84
+ const detail = [site, days ? `last ${plural(days, 'day')}` : ''].filter(Boolean).join(', ');
85
+ const title = `${c.bold('Results')}${detail ? ` ${c.muted(`(${detail})`)}` : ''}`;
86
+ const prompts = Array.isArray(overview.prompts) ? overview.prompts : [];
87
+ const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
88
+ const rate = (shown, answered) => (shown > 0 ? `${Math.round((answered / shown) * 100)}%` : '–');
89
+ const table = (rows) => (rows.length ? kit.table(['SURVEY', 'SHOWN', 'ANSWERED', 'RATE'], rows) : [c.muted('No surveys in this range.')]);
90
+ const rows = prompts.map(prompt => [String(prompt.name ?? ''), number(prompt.impressions).toLocaleString('en'), number(prompt.responses).toLocaleString('en'), rate(number(prompt.impressions), number(prompt.responses))]);
91
+ const total = overview.totals && prompts.length > 1 ? [c.muted(`Total: ${number(overview.totals.impressions).toLocaleString('en')} shown, ${number(overview.totals.responses).toLocaleString('en')} answered (${rate(number(overview.totals.impressions), number(overview.totals.responses))})`)] : [];
92
+ return [title, '', ...table(rows), ...(total.length ? ['', ...total] : [])];
93
+ }
package/dist/workos.d.ts CHANGED
@@ -17,5 +17,5 @@ export declare function login(config: ClientConfig, credentials: Credentials, on
17
17
  sleep?: typeof sleep;
18
18
  now?: () => number;
19
19
  }): Promise<Session>;
20
- export declare function refresh(config: ClientConfig, credentials: Credentials, current: Session, fetcher?: Fetch): Promise<Session>;
20
+ export declare function refresh(config: ClientConfig, credentials: Credentials, current: Session, fetcher?: Fetch, signal?: AbortSignal): Promise<Session>;
21
21
  export {};