@cookiyai/cookiy-cli 0.0.0-stage → 1.22.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/dist/index.js +810 -0
  4. package/package.json +68 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cookiy AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @cookiyai/cookiy-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line client for Cookiy AI — run user research, quant surveys, and recruit participants from your terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Install once (requires Node.js 18+)
9
+ npm install -g @cookiyai/cookiy-cli
10
+
11
+ # Upgrade
12
+ npm update -g @cookiyai/cookiy-cli
13
+ ```
14
+
15
+ The commands remain `cookiy` and `cookiy-cli`.
16
+
17
+ If you previously installed the unscoped package, migrate with:
18
+
19
+ ```bash
20
+ npm uninstall -g cookiy-cli
21
+ npm install -g @cookiyai/cookiy-cli
22
+ ```
23
+
24
+ For CI and automated tests, set `COOKIY_SKIP_UPDATE_CHECK=1` to disable background update checks and update-cache writes.
25
+
26
+ ## Output and request conventions
27
+
28
+ - Successful V1 responses print `data` to stdout. Standard API errors print the server's `error` to stderr and exit nonzero. HTTP 401 errors additionally receive a top-level `login_url`; precedence is server top-level `login_url`, server `details.login_url`, then the CLI-derived configured-server URL. The server's `details` value is unchanged.
29
+ - Other V1 runtime errors print JSON `{code,message,details?}` to stderr: `HTTP_<status>` for nonstandard HTTP errors, `NETWORK_ERROR` for connection failures, `REQUEST_TIMEOUT` for HTTP timeouts, and `CLI_ERROR` for unexpected exceptions. A nonstandard 401 uses `UNAUTHORIZED` with the same login hint. Local token/argument errors and help/save-token messages keep their existing text format; stderr is not universally JSON.
30
+ - `study guide update --json` accepts both nested JSON (`'{"meta":{"sample_size":8}}'`) and dotted keys (`'{"meta.sample_size":8}'`). Dotted keys are expanded before the REST request is sent.
31
+ - Guide updates use `--base-revision` for optimistic concurrency. The deprecated `--idempotency-key` remains optional for compatibility; the current server ignores it and does not return a receipt. After a timeout or revision conflict, get the latest guide before deciding whether another edit is needed.
32
+ - Synthetic interviews accept `--persona` and the deprecated `--plain-text` alias. Both send the REST `persona` field; `--persona` takes precedence if both are supplied.
33
+ - `study create` waits for the discussion guide and returns the ready canonical guide, so `study guide wait` is not supported.
34
+ - `study report wait --study-id <id> [--timeout-ms <n>]` polls the Study activity every 15 seconds, with the final sleep capped by the remaining wait budget and followed by one last Activity request. Each HTTP request keeps the normal RPC timeout. `report_generation_in_progress` keeps waiting; `report_ready` prints the share-link response; `report_failed`, `report_not_requested`, missing states, and unknown states fail immediately. All wait failures print JSON to stderr and exit nonzero. A `WAIT_TIMEOUT` error includes the latest complete Study activity in `details`, so a calling workflow can inspect it and retry.
35
+ - Early pipe closure (for example `| head`) is silent and preserves the command's exit status. Other output-write failures print a text diagnostic when stderr is usable and exit nonzero.
36
+ - `quant raw-response` reads the server's JSON response and prints its `csv` field to stdout, with the CLI's usual trailing newline (also for empty CSV). Save it with `cookiy quant raw-response --survey-id 12345 > responses.csv`.
package/dist/index.js ADDED
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/config.ts
7
+ import os from "os";
8
+ import path from "path";
9
+ var VERSION = "1.22.1";
10
+ var DEFAULT_SERVER_URL = "https://s-api.cookiy.ai";
11
+ var API_RPC_TIMEOUT = parseInt(
12
+ process.env.COOKIY_API_RPC_TIMEOUT || "600",
13
+ 10
14
+ );
15
+ var runtime = {
16
+ tokenPath: process.env.COOKIY_CREDENTIALS || path.join(os.homedir(), ".cookiy", "token.txt"),
17
+ accessToken: ""
18
+ };
19
+ function resolveServerBase() {
20
+ return process.env.COOKIY_SERVER_URL || DEFAULT_SERVER_URL;
21
+ }
22
+ function resolveLoginUrl() {
23
+ return `${resolveServerBase()}/oauth/cli/start`;
24
+ }
25
+
26
+ // src/auth.ts
27
+ import fs from "fs";
28
+ import path2 from "path";
29
+
30
+ // src/util.ts
31
+ var CliError = class extends Error {
32
+ constructor(code, message, details) {
33
+ super(message);
34
+ this.code = code;
35
+ this.details = details;
36
+ this.name = "CliError";
37
+ }
38
+ code;
39
+ details;
40
+ };
41
+ function writeFully(stream, text) {
42
+ const output = text.endsWith("\n") ? text : `${text}
43
+ `;
44
+ return new Promise((resolve, reject) => {
45
+ stream.once("error", reject);
46
+ stream.write(output, (error) => {
47
+ if (error) {
48
+ reject(error);
49
+ return;
50
+ }
51
+ stream.removeListener("error", reject);
52
+ resolve();
53
+ });
54
+ });
55
+ }
56
+ async function exitWithOutput(options) {
57
+ try {
58
+ if (options.stdout !== void 0) {
59
+ await writeFully(process.stdout, options.stdout);
60
+ }
61
+ if (options.stderr !== void 0) {
62
+ await writeFully(process.stderr, options.stderr);
63
+ }
64
+ } catch (error) {
65
+ if (error?.code === "EPIPE") {
66
+ process.exit(options.code);
67
+ }
68
+ if (!process.stderr.destroyed) {
69
+ try {
70
+ await writeFully(process.stderr, `Failed to write output: ${error instanceof Error ? error.message : String(error)}`);
71
+ } catch {
72
+ }
73
+ }
74
+ process.exit(options.code === 0 ? 1 : options.code);
75
+ }
76
+ process.exit(options.code);
77
+ }
78
+ function die(msg, code = 1) {
79
+ console.error(msg);
80
+ process.exit(code);
81
+ }
82
+ function dieNoAccess(_detail) {
83
+ die(`Access denied \u2014 token is missing or expired.
84
+ Sign in: ${resolveLoginUrl()}`);
85
+ }
86
+ function parseIntOption(name) {
87
+ return (val) => {
88
+ if (!/^-?\d+$/.test(val)) die(`--${name} requires an integer, got: ${val}`);
89
+ return parseInt(val, 10);
90
+ };
91
+ }
92
+ function parseJsonArrayOption(name) {
93
+ return (val) => {
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(val);
97
+ } catch {
98
+ die(`--${name} requires a JSON array, got: ${val}`);
99
+ }
100
+ if (!Array.isArray(parsed)) die(`--${name} requires a JSON array, got: ${val}`);
101
+ return parsed;
102
+ };
103
+ }
104
+ function parseJsonObjectOption(name) {
105
+ return (val) => {
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(val);
109
+ } catch {
110
+ die(`--${name} requires a JSON object, got: ${val}`);
111
+ }
112
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
113
+ die(`--${name} requires a JSON object, got: ${val}`);
114
+ }
115
+ return parsed;
116
+ };
117
+ }
118
+ function mergeRawJson(payload, rawJson, name = "json") {
119
+ if (!rawJson) return payload;
120
+ try {
121
+ const extra = JSON.parse(rawJson);
122
+ if (typeof extra !== "object" || extra === null || Array.isArray(extra)) {
123
+ die(`--${name} requires a JSON object`);
124
+ }
125
+ return { ...payload, ...extra };
126
+ } catch (e) {
127
+ const msg = e instanceof Error ? e.message : String(e);
128
+ die(`Invalid --${name} value: ${msg}`);
129
+ }
130
+ }
131
+
132
+ // src/auth.ts
133
+ function loadCredentials() {
134
+ if (!fs.existsSync(runtime.tokenPath)) dieNoAccess();
135
+ runtime.accessToken = fs.readFileSync(runtime.tokenPath, "utf8").replace(/\r?\n/g, "").trim();
136
+ if (!runtime.accessToken) dieNoAccess();
137
+ }
138
+ async function runSaveToken(input) {
139
+ const raw = input.trim();
140
+ if (!raw) die("Usage: cookiy save-token <access_token_or_json>");
141
+ let at = "";
142
+ try {
143
+ const parsed = JSON.parse(raw);
144
+ if (parsed.access_token) at = parsed.access_token;
145
+ } catch {
146
+ at = raw;
147
+ }
148
+ if (!at) die("Could not find access_token in input.");
149
+ try {
150
+ const dir = path2.dirname(runtime.tokenPath);
151
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
152
+ fs.writeFileSync(runtime.tokenPath, at, { mode: 384 });
153
+ } catch (e) {
154
+ const msg = e instanceof Error ? e.message : String(e);
155
+ die(`[save-token] failed to write ${runtime.tokenPath}: ${msg}`);
156
+ }
157
+ console.error(`Token saved to ${runtime.tokenPath}`);
158
+ }
159
+
160
+ // src/update-check.ts
161
+ import fs2 from "fs";
162
+ import path3 from "path";
163
+ import os2 from "os";
164
+ import { spawn } from "child_process";
165
+ var CACHE_FILE = path3.join(os2.homedir(), ".cookiy", "update-check");
166
+ var INTERVAL_MS = 24 * 60 * 60 * 1e3;
167
+ var PROBE_SCRIPT = `
168
+ const https = require('https');
169
+ const { spawn } = require('child_process');
170
+ const current = ${JSON.stringify(VERSION)};
171
+ function gt(a, b) {
172
+ const pa = a.split('.').map(Number);
173
+ const pb = b.split('.').map(Number);
174
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
175
+ const ai = pa[i] || 0, bi = pb[i] || 0;
176
+ if (ai > bi) return true;
177
+ if (ai < bi) return false;
178
+ }
179
+ return false;
180
+ }
181
+ const req = https.get(
182
+ 'https://registry.npmjs.org/@cookiyai%2Fcookiy-cli/latest',
183
+ { timeout: 3000, headers: { Accept: 'application/json' } },
184
+ (res) => {
185
+ let body = '';
186
+ res.on('data', (c) => (body += c));
187
+ res.on('end', () => {
188
+ try {
189
+ const latest = JSON.parse(body).version;
190
+ if (typeof latest !== 'string' || !gt(latest, current)) return;
191
+ const child = spawn(
192
+ 'npm',
193
+ ['install', '-g', '--silent', '--no-audit', '--no-fund', '@cookiyai/cookiy-cli@' + latest],
194
+ { detached: true, stdio: 'ignore', windowsHide: true },
195
+ );
196
+ child.unref();
197
+ } catch (_) {}
198
+ });
199
+ },
200
+ );
201
+ req.on('error', () => {});
202
+ req.on('timeout', () => req.destroy());
203
+ `;
204
+ function scheduleBackgroundUpdate() {
205
+ if (process.env.COOKIY_SKIP_UPDATE_CHECK === "1") return;
206
+ try {
207
+ const now = Date.now();
208
+ if (fs2.existsSync(CACHE_FILE)) {
209
+ const last = parseInt(fs2.readFileSync(CACHE_FILE, "utf8"), 10) || 0;
210
+ if (now - last < INTERVAL_MS) return;
211
+ }
212
+ fs2.mkdirSync(path3.dirname(CACHE_FILE), { recursive: true });
213
+ fs2.writeFileSync(CACHE_FILE, String(now));
214
+ const child = spawn(process.execPath, ["-e", PROBE_SCRIPT], {
215
+ detached: true,
216
+ stdio: "ignore",
217
+ windowsHide: true
218
+ });
219
+ child.unref();
220
+ } catch {
221
+ }
222
+ }
223
+
224
+ // src/v1Client.ts
225
+ var V1RequestError = class extends Error {
226
+ constructor(status, message, body, details) {
227
+ super(message);
228
+ this.status = status;
229
+ this.body = body;
230
+ this.details = details;
231
+ this.name = "V1RequestError";
232
+ }
233
+ status;
234
+ body;
235
+ details;
236
+ };
237
+ var MAX_BODY_LEN = 500;
238
+ function truncate(s) {
239
+ if (s.length <= MAX_BODY_LEN) return s;
240
+ const dropped = s.length - MAX_BODY_LEN;
241
+ return `${s.slice(0, MAX_BODY_LEN)}
242
+ \u2026 (truncated, ${dropped} more chars)`;
243
+ }
244
+ function summarizeHtml(s) {
245
+ const title = s.match(/<title[^>]*>\s*([^<]+?)\s*<\/title>/i)?.[1];
246
+ const h1 = s.match(/<h1[^>]*>\s*([^<]+?)\s*<\/h1>/i)?.[1];
247
+ const summary = (title ?? h1)?.trim();
248
+ if (summary) return `(HTML body) ${summary}`;
249
+ return `(HTML body, ${s.length} chars suppressed)`;
250
+ }
251
+ function formatBody(parsed, text) {
252
+ const trimmed = text.trim();
253
+ if (!trimmed) return null;
254
+ if (parsed && typeof parsed === "object") {
255
+ return truncate(JSON.stringify(parsed, null, 2));
256
+ }
257
+ if (/^\s*<(!doctype|html\b|head\b|body\b)/i.test(trimmed)) {
258
+ return summarizeHtml(trimmed);
259
+ }
260
+ return truncate(trimmed);
261
+ }
262
+ function isRecord(value) {
263
+ return typeof value === "object" && value !== null && !Array.isArray(value);
264
+ }
265
+ function resolveErrorLoginUrl(error) {
266
+ if (typeof error.login_url === "string") return error.login_url;
267
+ if (isRecord(error.details) && typeof error.details.login_url === "string") {
268
+ return error.details.login_url;
269
+ }
270
+ return resolveLoginUrl();
271
+ }
272
+ function unwrapSuccessResponse(value) {
273
+ return isRecord(value) && value.ok === true && Object.hasOwn(value, "data") ? value.data : value;
274
+ }
275
+ function extractApiError(value) {
276
+ return isRecord(value) && value.ok === false && Object.hasOwn(value, "error") ? value.error : void 0;
277
+ }
278
+ function buildUrl(path4, query) {
279
+ const base = resolveServerBase().replace(/\/$/, "");
280
+ const rel = path4.startsWith("/") ? path4 : `/${path4}`;
281
+ const url = new URL(`${base}/api${rel}`);
282
+ if (query) {
283
+ for (const [k, v] of Object.entries(query)) {
284
+ if (v === void 0 || v === null) continue;
285
+ url.searchParams.set(k, String(v));
286
+ }
287
+ }
288
+ return url.toString();
289
+ }
290
+ async function request(method, path4, opts) {
291
+ const url = buildUrl(path4, opts?.query);
292
+ const controller = new AbortController();
293
+ let timedOut = false;
294
+ const timeoutId = setTimeout(() => {
295
+ timedOut = true;
296
+ controller.abort();
297
+ }, API_RPC_TIMEOUT * 1e3);
298
+ try {
299
+ const headers = {
300
+ Accept: "application/json",
301
+ Authorization: `Bearer ${runtime.accessToken}`
302
+ };
303
+ if (opts?.body !== void 0) headers["Content-Type"] = "application/json";
304
+ const res = await fetch(url, {
305
+ method,
306
+ headers,
307
+ body: opts?.body !== void 0 ? JSON.stringify(opts.body) : void 0,
308
+ signal: controller.signal
309
+ });
310
+ const text = await res.text();
311
+ let parsed = void 0;
312
+ if (text.trim()) {
313
+ try {
314
+ parsed = JSON.parse(text);
315
+ } catch {
316
+ parsed = text;
317
+ }
318
+ }
319
+ if (res.status < 200 || res.status >= 300) {
320
+ const bodyDisplay = formatBody(parsed, text);
321
+ const serverMessage = parsed && typeof parsed === "object" ? parsed.message : void 0;
322
+ const head = `[HTTP ${res.status}]`;
323
+ const msg = serverMessage ? `${head} ${String(serverMessage)}` : head;
324
+ throw new V1RequestError(res.status, msg, parsed, bodyDisplay ?? void 0);
325
+ }
326
+ return unwrapSuccessResponse(parsed);
327
+ } catch (e) {
328
+ if (e instanceof V1RequestError) throw e;
329
+ if (timedOut) {
330
+ throw new CliError("REQUEST_TIMEOUT", `[timeout ${API_RPC_TIMEOUT}s]`);
331
+ }
332
+ const err = e;
333
+ const reason = err?.cause?.code ?? err?.cause?.message ?? err?.message ?? String(e);
334
+ throw new CliError("NETWORK_ERROR", `[fetch error] ${reason}`);
335
+ } finally {
336
+ clearTimeout(timeoutId);
337
+ }
338
+ }
339
+ var v1 = {
340
+ get: (path4, query) => request("GET", path4, { query }),
341
+ post: (path4, body) => request("POST", path4, { body }),
342
+ patch: (path4, body) => request("PATCH", path4, { body }),
343
+ delete: (path4) => request("DELETE", path4)
344
+ };
345
+ async function runV1(fn) {
346
+ try {
347
+ const result = await fn();
348
+ return exitWithOutput({
349
+ code: 0,
350
+ stdout: result === void 0 ? void 0 : typeof result === "string" ? result : JSON.stringify(result, null, 2)
351
+ });
352
+ } catch (e) {
353
+ let error;
354
+ if (e instanceof V1RequestError) {
355
+ error = extractApiError(e.body);
356
+ if (error === void 0) {
357
+ error = e.status === 401 ? {
358
+ code: "UNAUTHORIZED",
359
+ message: "Access denied \u2014 token is missing or expired."
360
+ } : { code: `HTTP_${e.status}`, message: e.message, details: e.details };
361
+ }
362
+ if (e.status === 401 && isRecord(error)) {
363
+ error = { ...error, login_url: resolveErrorLoginUrl(error) };
364
+ }
365
+ } else if (e instanceof CliError) {
366
+ error = { code: e.code, message: e.message, details: e.details };
367
+ } else {
368
+ error = { code: "CLI_ERROR", message: e instanceof Error ? e.message : String(e) };
369
+ }
370
+ return exitWithOutput({
371
+ code: 1,
372
+ stderr: JSON.stringify(error, null, 2)
373
+ });
374
+ }
375
+ }
376
+
377
+ // src/commands/study.ts
378
+ var REPORT_POLL_INTERVAL_MS = 15e3;
379
+ function expandDottedKeys(input, result = /* @__PURE__ */ Object.create(null), prefix = "") {
380
+ for (const [key, value] of Object.entries(input)) {
381
+ const patchPath = prefix ? `${prefix}.${key}` : key;
382
+ const parts = key.split(".");
383
+ let cursor = result;
384
+ for (const part of parts.slice(0, -1)) {
385
+ if (cursor[part] === null || typeof cursor[part] !== "object" || Array.isArray(cursor[part])) {
386
+ if (Object.hasOwn(cursor, part)) die(`Conflicting patch path: ${patchPath}`);
387
+ cursor[part] = /* @__PURE__ */ Object.create(null);
388
+ }
389
+ cursor = cursor[part];
390
+ }
391
+ const lastPart = parts.at(-1);
392
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
393
+ if (cursor[lastPart] === null || typeof cursor[lastPart] !== "object" || Array.isArray(cursor[lastPart])) {
394
+ if (Object.hasOwn(cursor, lastPart)) die(`Conflicting patch path: ${patchPath}`);
395
+ cursor[lastPart] = /* @__PURE__ */ Object.create(null);
396
+ }
397
+ expandDottedKeys(
398
+ value,
399
+ cursor[lastPart],
400
+ patchPath
401
+ );
402
+ } else {
403
+ if (Object.hasOwn(cursor, lastPart)) die(`Conflicting patch path: ${patchPath}`);
404
+ cursor[lastPart] = value;
405
+ }
406
+ }
407
+ return result;
408
+ }
409
+ async function waitForReport(studyId, timeoutMs) {
410
+ const deadline = Date.now() + timeoutMs;
411
+ while (true) {
412
+ const activity = await v1.get(`/v1/studies/${studyId}/activity`);
413
+ const report = activity?.sources?.report ?? {};
414
+ const status = typeof report.status === "string" ? report.status : "";
415
+ if (status === "report_ready") {
416
+ return v1.post(`/v1/studies/${studyId}/report/share-link`, {});
417
+ }
418
+ if (status === "report_failed") {
419
+ throw new CliError(
420
+ "GENERATION_FAILED",
421
+ "Report generation failed",
422
+ report
423
+ );
424
+ }
425
+ if (status === "report_not_requested") {
426
+ throw new CliError(
427
+ "REPORT_NOT_REQUESTED",
428
+ "Report generation has not been requested",
429
+ report
430
+ );
431
+ }
432
+ if (status !== "report_generation_in_progress") {
433
+ throw new CliError(
434
+ "UNEXPECTED_STATUS",
435
+ `Unexpected report status: ${status || "(missing)"}`,
436
+ report
437
+ );
438
+ }
439
+ const remaining = deadline - Date.now();
440
+ if (remaining <= 0) {
441
+ throw new CliError("WAIT_TIMEOUT", `Timed out after ${timeoutMs}ms`, activity);
442
+ }
443
+ const sleepMs = Math.min(REPORT_POLL_INTERVAL_MS, remaining);
444
+ await new Promise((resolve) => setTimeout(resolve, sleepMs));
445
+ }
446
+ }
447
+ function registerStudy(program2) {
448
+ const study = program2.command("study").description("Studies: list, create, status, guide, interview, report, upload");
449
+ study.command("list").description("list studies").option("--limit <n>", "max items", parseIntOption("limit")).option("--cursor <s>", "pagination cursor").action(async (opts) => {
450
+ const query = {};
451
+ if (opts.limit !== void 0) query.limit = opts.limit;
452
+ if (opts.cursor) query.cursor = opts.cursor;
453
+ await runV1(() => v1.get("/v1/studies", query));
454
+ });
455
+ study.command("status").description("study record and activity").requiredOption("--study-id <uuid>", "study id").action(async (opts) => {
456
+ await runV1(
457
+ () => v1.get(`/v1/studies/${encodeURIComponent(opts.studyId)}/activity`)
458
+ );
459
+ });
460
+ study.command("create").description("create study from natural language").requiredOption("--query <s>", "natural-language brief").option("--thinking <s>", "thinking / scratch notes").option(
461
+ "--attachments <json-array>",
462
+ `JSON array, e.g. '[{"s3_key":"..."}]'`,
463
+ parseJsonArrayOption("attachments")
464
+ ).option("--json <obj>", "extra JSON fields merged into request").action(
465
+ async (opts) => {
466
+ let payload = { query: opts.query };
467
+ if (opts.thinking !== void 0) payload.thinking = opts.thinking;
468
+ if (opts.attachments !== void 0) payload.attachments = opts.attachments;
469
+ payload = mergeRawJson(payload, opts.json);
470
+ await runV1(() => v1.post("/v1/studies", payload));
471
+ }
472
+ );
473
+ study.command("upload").description("attach media (image upload)").requiredOption("--content-type <s>", "MIME content type").option("--image-data <s>", "base64 image data").option("--image-url <s>", "image url").action(
474
+ async (opts) => {
475
+ if (!opts.imageData && !opts.imageUrl) {
476
+ die("study upload requires --image-data or --image-url");
477
+ }
478
+ const body = { content_type: opts.contentType };
479
+ if (opts.imageData) body.image_data = opts.imageData;
480
+ if (opts.imageUrl) body.image_url = opts.imageUrl;
481
+ await runV1(() => v1.post("/v1/studies/media/upload", body));
482
+ }
483
+ );
484
+ const guide = study.command("guide").description("discussion guide: get | update");
485
+ guide.command("get").description("get discussion guide").requiredOption("--study-id <uuid>", "study id").action(async (opts) => {
486
+ await runV1(
487
+ () => v1.get(
488
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/discussion-guide`
489
+ )
490
+ );
491
+ });
492
+ guide.command("update").description("apply patch to discussion guide").requiredOption("--study-id <uuid>", "study id").requiredOption("--base-revision <s>", "base revision").option("--idempotency-key <s>", "legacy compatibility only; guide updates use base revision").option("--change-message <s>", "change message").requiredOption(
493
+ "--json <patch>",
494
+ "patch JSON object (required)",
495
+ parseJsonObjectOption("json")
496
+ ).action(
497
+ async (opts) => {
498
+ const body = {
499
+ base_revision: opts.baseRevision,
500
+ patch: expandDottedKeys(opts.json)
501
+ };
502
+ if (opts.idempotencyKey !== void 0)
503
+ body.idempotency_key = opts.idempotencyKey;
504
+ if (opts.changeMessage !== void 0)
505
+ body.change_message = opts.changeMessage;
506
+ await runV1(
507
+ () => v1.patch(
508
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/discussion-guide`,
509
+ body
510
+ )
511
+ );
512
+ }
513
+ );
514
+ const interview = study.command("interview").description("interviews: list | playback");
515
+ interview.command("list").description("list interviews (includes synthetic)").requiredOption("--study-id <uuid>", "study id").option("--cursor <s>", "pagination cursor").action(async (opts) => {
516
+ const query = { include_simulation: "true" };
517
+ if (opts.cursor) query.cursor = opts.cursor;
518
+ await runV1(
519
+ () => v1.get(
520
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/interviews`,
521
+ query
522
+ )
523
+ );
524
+ });
525
+ const playback = interview.command("playback").description("interview playback: url | content");
526
+ playback.command("url").description("playback recording URL (all interviews if --interview-id omitted)").requiredOption("--study-id <uuid>", "study id").option("--interview-id <uuid>", "specific interview (omit to fetch all)").action(
527
+ async (opts) => {
528
+ const query = { view: "url" };
529
+ if (opts.interviewId) query.interview_id = opts.interviewId;
530
+ await runV1(
531
+ () => v1.get(
532
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/interviews/playback`,
533
+ query
534
+ )
535
+ );
536
+ }
537
+ );
538
+ playback.command("content").description("playback transcript content (all interviews if --interview-id omitted)").requiredOption("--study-id <uuid>", "study id").option("--interview-id <uuid>", "specific interview (omit to fetch all)").action(
539
+ async (opts) => {
540
+ const query = { view: "transcript" };
541
+ if (opts.interviewId) query.interview_id = opts.interviewId;
542
+ await runV1(
543
+ () => v1.get(
544
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/interviews/playback`,
545
+ query
546
+ )
547
+ );
548
+ }
549
+ );
550
+ const synthetic = study.command("run-synthetic-user").description("synthetic user interviews");
551
+ synthetic.command("start").description("start synthetic interviews").requiredOption("--study-id <uuid>", "study id").option(
552
+ "--persona-count <n>",
553
+ "number of personas",
554
+ parseIntOption("persona-count")
555
+ ).option("--persona <s>", "persona / profile description").option("--plain-text <s>", "deprecated alias for --persona").action(
556
+ async (opts) => {
557
+ const body = {};
558
+ if (opts.personaCount !== void 0) body.persona_count = opts.personaCount;
559
+ const persona = opts.persona ?? opts.plainText;
560
+ if (persona !== void 0) body.persona = persona;
561
+ await runV1(
562
+ () => v1.post(
563
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/fake-interview`,
564
+ body
565
+ )
566
+ );
567
+ }
568
+ );
569
+ const report = study.command("report").description("study report: generate | content | link | wait");
570
+ report.command("generate").description("generate report").requiredOption("--study-id <uuid>", "study id").option("--skip-synthetic-interview", "skip synthetic interview step", false).action(
571
+ async (opts) => {
572
+ const body = {};
573
+ if (opts.skipSyntheticInterview) body.skip_synthetic_interview = true;
574
+ await runV1(
575
+ () => v1.post(
576
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/report/generate`,
577
+ body
578
+ )
579
+ );
580
+ }
581
+ );
582
+ report.command("content").description("report content JSON").requiredOption("--study-id <uuid>", "study id").action(async (opts) => {
583
+ await runV1(
584
+ () => v1.get(
585
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/report/content`
586
+ )
587
+ );
588
+ });
589
+ report.command("link").description("report share link").requiredOption("--study-id <uuid>", "study id").action(async (opts) => {
590
+ await runV1(
591
+ () => v1.post(
592
+ `/v1/studies/${encodeURIComponent(opts.studyId)}/report/share-link`,
593
+ {}
594
+ )
595
+ );
596
+ });
597
+ report.command("wait").description("wait for report generation, then print its share link").requiredOption("--study-id <uuid>", "study id").option(
598
+ "--timeout-ms <n>",
599
+ "polling timeout in ms (default 300000)",
600
+ parseIntOption("timeout-ms"),
601
+ 3e5
602
+ ).action(async (opts) => {
603
+ if (opts.timeoutMs <= 0) {
604
+ die("--timeout-ms requires a positive integer");
605
+ }
606
+ await runV1(
607
+ () => waitForReport(encodeURIComponent(opts.studyId), opts.timeoutMs)
608
+ );
609
+ });
610
+ }
611
+
612
+ // src/commands/quant.ts
613
+ function registerQuant(program2) {
614
+ const quant = program2.command("quant").description(
615
+ "Quantitative surveys: list, create, get, update, status, report, raw-response"
616
+ );
617
+ quant.command("list").description("list surveys visible to operator").action(async () => {
618
+ await runV1(() => v1.get("/v1/quant/surveys"));
619
+ });
620
+ quant.command("create").description("create survey (multi-language)").requiredOption(
621
+ "--json <obj>",
622
+ "JSON object: survey_title, languages[], groups[], quotas[], ...",
623
+ parseJsonObjectOption("json")
624
+ ).action(async (opts) => {
625
+ await runV1(() => v1.post("/v1/quant/surveys", opts.json));
626
+ });
627
+ quant.command("get").description("survey detail").requiredOption("--survey-id <id>", "numeric sid").option("--language <lang>", "language code").action(async (opts) => {
628
+ const query = {};
629
+ if (opts.language) query.language = opts.language;
630
+ await runV1(
631
+ () => v1.get(`/v1/quant/surveys/${encodeURIComponent(opts.surveyId)}`, query)
632
+ );
633
+ });
634
+ quant.command("update").description("patch survey").requiredOption("--survey-id <id>", "numeric sid").requiredOption(
635
+ "--json <obj>",
636
+ "JSON object with survey/groups/questions/quotas_*",
637
+ parseJsonObjectOption("json")
638
+ ).action(
639
+ async (opts) => {
640
+ await runV1(
641
+ () => v1.patch(
642
+ `/v1/quant/surveys/${encodeURIComponent(opts.surveyId)}`,
643
+ opts.json
644
+ )
645
+ );
646
+ }
647
+ );
648
+ quant.command("status").description("combined survey + recruit status").requiredOption("--survey-id <id>", "numeric sid").action(async (opts) => {
649
+ await runV1(
650
+ () => v1.get(
651
+ `/v1/quant/surveys/${encodeURIComponent(opts.surveyId)}/status`
652
+ )
653
+ );
654
+ });
655
+ quant.command("report").description("survey report (structured JSON + raw)").requiredOption("--survey-id <id>", "numeric sid").action(async (opts) => {
656
+ await runV1(
657
+ () => v1.get(
658
+ `/v1/quant/surveys/${encodeURIComponent(opts.surveyId)}/report`
659
+ )
660
+ );
661
+ });
662
+ quant.command("raw-response").description("raw survey responses as CSV").requiredOption("--survey-id <id>", "numeric sid").option("--include-incomplete", "include incomplete responses", false).action(
663
+ async (opts) => {
664
+ const query = {
665
+ only_completed: !opts.includeIncomplete
666
+ };
667
+ await runV1(async () => {
668
+ const result = await v1.get(
669
+ `/v1/quant/surveys/${encodeURIComponent(opts.surveyId)}/raw-responses`,
670
+ query
671
+ );
672
+ if (typeof result !== "object" || result === null || !("csv" in result) || typeof result.csv !== "string") {
673
+ throw new CliError("INVALID_RESPONSE", "Quant raw-response must contain a string csv field.");
674
+ }
675
+ return result.csv;
676
+ });
677
+ }
678
+ );
679
+ }
680
+
681
+ // src/commands/recruit.ts
682
+ function registerRecruit(program2) {
683
+ const recruit = program2.command("recruit").description("Participant recruitment (auto-detects qualitative vs quant)");
684
+ recruit.command("start").description("launch recruitment (or incrementally add participants)").option("--study-id <uuid>", "study id (required for interview studies)").option(
685
+ "--survey-public-url <url>",
686
+ "public survey URL (auto-selects quant mode)"
687
+ ).option("--confirmation-token <s>", "step-2 confirmation token").option("--plain-text <s>", "audience description / free-text brief").option(
688
+ "--incremental-participants <n>",
689
+ "add N participants on top of existing",
690
+ parseIntOption("incremental-participants")
691
+ ).action(
692
+ async (opts) => {
693
+ if (!opts.confirmationToken && !opts.plainText) {
694
+ die("recruit start: --plain-text is required");
695
+ }
696
+ const isStudyBased = !!opts.studyId;
697
+ const isQuantOnly = !isStudyBased && !!opts.surveyPublicUrl;
698
+ if (!isStudyBased && !isQuantOnly) {
699
+ die(
700
+ "recruit start: either --study-id or --survey-public-url is required"
701
+ );
702
+ }
703
+ const body = { force_reconfigure: true };
704
+ if (opts.confirmationToken) body.confirmation_token = opts.confirmationToken;
705
+ if (opts.plainText !== void 0) body.plain_text = opts.plainText;
706
+ if (opts.incrementalParticipants !== void 0) {
707
+ body.incremental_participants = opts.incrementalParticipants;
708
+ }
709
+ const step = opts.confirmationToken ? "confirm" : "preview";
710
+ let path4;
711
+ if (isStudyBased) {
712
+ path4 = `/v1/studies/${encodeURIComponent(opts.studyId)}/recruit/${step}`;
713
+ } else {
714
+ body.survey_public_url = opts.surveyPublicUrl;
715
+ path4 = `/v1/quant/recruit/${step}`;
716
+ }
717
+ await runV1(() => v1.post(path4, body));
718
+ }
719
+ );
720
+ }
721
+
722
+ // src/commands/billing.ts
723
+ function registerBilling(program2) {
724
+ const billing = program2.command("billing").description("Billing: balance, checkout, price-table, transactions");
725
+ billing.command("balance").description("one-line balance summary").action(async () => {
726
+ await runV1(() => v1.get("/v1/billing/balance"));
727
+ });
728
+ billing.command("checkout").description("create Stripe checkout session").requiredOption(
729
+ "--amount-usd-cents <n>",
730
+ "USD cents (min 100). Internally mapped to amount_cents.",
731
+ parseIntOption("amount-usd-cents")
732
+ ).action(async (opts) => {
733
+ await runV1(
734
+ () => v1.post("/v1/billing/cash-credit/checkout", {
735
+ amount_cents: opts.amountUsdCents
736
+ })
737
+ );
738
+ });
739
+ billing.command("price-table").description("current pricing table").action(async () => {
740
+ await runV1(() => v1.get("/v1/billing/price-table"));
741
+ });
742
+ billing.command("transactions").description("wallet ledger").option("--limit <n>", "max items", parseIntOption("limit")).option("--cursor <iso8601>", "pagination cursor").option("--study-id <uuid>", "filter by study").option("--survey-id <id>", "filter by survey sid (sent as string)").action(
743
+ async (opts) => {
744
+ const query = {};
745
+ if (opts.limit !== void 0) query.limit = opts.limit;
746
+ if (opts.cursor) query.cursor = opts.cursor;
747
+ if (opts.studyId) query.study_id = opts.studyId;
748
+ if (opts.surveyId !== void 0) query.survey_id = String(opts.surveyId);
749
+ await runV1(() => v1.get("/v1/billing/transactions", query));
750
+ }
751
+ );
752
+ }
753
+
754
+ // src/commands/user.ts
755
+ function registerUser(program2) {
756
+ const user = program2.command("user").description("User: info");
757
+ user.command("info").description("current user API metadata").action(async () => {
758
+ await runV1(() => v1.get("/v1/user/info"));
759
+ });
760
+ }
761
+
762
+ // src/index.ts
763
+ scheduleBackgroundUpdate();
764
+ var program = new Command();
765
+ program.helpCommand(false);
766
+ program.name("cookiy").description(
767
+ "Cookiy CLI - command-line client for Cookiy AI, end-to-end user research at scale."
768
+ ).version(VERSION, "-v, --version", "Output the version number").option("--token <path>", "Token file path (default ~/.cookiy/token.txt)").showHelpAfterError('(run "cookiy --help" for usage)').addHelpText(
769
+ "after",
770
+ `
771
+ Examples:
772
+ $ cookiy save-token eyJhbGciOi...
773
+ $ cookiy study list --limit 10
774
+ $ cookiy study create --query "..."
775
+ $ cookiy study report generate --study-id 123
776
+ $ cookiy quant list
777
+ $ cookiy user info
778
+ $ cookiy billing transactions --limit 50
779
+
780
+ Sign in: https://s-api.cookiy.ai/oauth/cli/start`
781
+ );
782
+ program.hook("preAction", (_thisCmd, actionCmd) => {
783
+ const g = program.opts();
784
+ if (g.token) runtime.tokenPath = g.token;
785
+ if (actionCmd.name() === "save-token") return;
786
+ loadCredentials();
787
+ });
788
+ program.command("save-token").description("Validate and save an access token (from browser sign-in)").argument(
789
+ "<token_or_json>",
790
+ "raw access_token string or JSON with access_token field"
791
+ ).action(async (input) => {
792
+ const g = program.opts();
793
+ if (g.token) runtime.tokenPath = g.token;
794
+ await runSaveToken(input);
795
+ });
796
+ registerStudy(program);
797
+ registerQuant(program);
798
+ registerRecruit(program);
799
+ registerBilling(program);
800
+ registerUser(program);
801
+ function disableHelpCommand(cmd) {
802
+ cmd.helpCommand(false);
803
+ for (const sub of cmd.commands) disableHelpCommand(sub);
804
+ }
805
+ disableHelpCommand(program);
806
+ program.parseAsync(process.argv).catch((e) => {
807
+ const msg = e instanceof Error ? e.message : String(e);
808
+ console.error(msg);
809
+ process.exit(1);
810
+ });
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@cookiyai/cookiy-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.22.1",
4
+ "description": "Cookiy AI CLI — end-to-end user research from your terminal: AI interviews, synthetic users, quant surveys, participant recruitment.",
5
+ "keywords": [
6
+ "cookiy",
7
+ "cli",
8
+ "user-research",
9
+ "ux-research",
10
+ "interview",
11
+ "survey",
12
+ "synthetic-user",
13
+ "ai",
14
+ "agent",
15
+ "ai-agent",
16
+ "claude",
17
+ "claude-code",
18
+ "cursor",
19
+ "codex",
20
+ "openclaw",
21
+ "windsurf",
22
+ "manus",
23
+ "cline"
24
+ ],
25
+ "homepage": "https://cookiy.ai",
26
+ "bugs": {
27
+ "url": "https://github.com/cookiy-ai/cookiy-cli/issues"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/cookiy-ai/cookiy-cli.git"
32
+ },
33
+ "license": "MIT",
34
+ "author": "Cookiy AI <info@cookiy.com>",
35
+ "type": "module",
36
+ "bin": {
37
+ "cookiy": "dist/index.js",
38
+ "cookiy-cli": "dist/index.js"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "dev": "tsup --watch",
55
+ "typecheck": "tsc --noEmit",
56
+ "start": "node dist/index.js",
57
+ "test": "npm run build && vitest run",
58
+ "test:watch": "vitest",
59
+ "prepublishOnly": "npm run build"
60
+ },
61
+ "dependencies": {
62
+ "commander": "^12.1.0"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^20.14.0",
66
+ "tsup": "^8.3.0",
67
+ "typescript": "^5.6.0",
68
+ "vitest": "^4.1.5"
69
+ }
70
+ }