@ishlabs/cli 0.8.1 → 0.8.3

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 +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +2 -12
  47. package/dist/lib/local-sim/install.js +22 -30
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +3 -2
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
package/dist/index.js CHANGED
@@ -7,83 +7,131 @@ import { upgrade } from "./upgrade.js";
7
7
  import { registerWorkspaceCommands } from "./commands/workspace.js";
8
8
  import { registerStudyCommands } from "./commands/study.js";
9
9
  import { registerIterationCommands } from "./commands/iteration.js";
10
- import { registerTesterProfileCommands } from "./commands/tester-profile.js";
11
- import { registerTesterCommands } from "./commands/tester.js";
12
- import { registerSimulationCommands } from "./commands/simulation.js";
10
+ import { registerProfileCommands } from "./commands/profile.js";
11
+ import { registerSourceCommands } from "./commands/source.js";
13
12
  import { registerConfigCommands } from "./commands/config.js";
13
+ import { registerAskCommands } from "./commands/ask.js";
14
+ import { registerDocsCommands } from "./commands/docs.js";
15
+ import { registerInitCommands } from "./commands/init.js";
16
+ import { AGENT_HELP_FOOTER } from "./lib/docs.js";
17
+ import { runInline, EXIT_USAGE } from "./lib/command-helpers.js";
18
+ import { output } from "./lib/output.js";
14
19
  import pkg from "../package.json" with { type: "json" };
15
20
  const { version } = pkg;
16
21
  program
17
22
  .name("ish")
18
- .description("Ish CLI — manage workspaces, studies, simulations, and more")
19
- .version(version);
23
+ .description("Ish CLI — run studies and asks against AI tester audiences")
24
+ .version(version)
25
+ .addHelpText("after", AGENT_HELP_FOOTER);
26
+ // Unified error envelope for Commander-level failures (unknown command,
27
+ // missing required option, etc.) so JSON consumers see the same shape
28
+ // regardless of whether the error originated in the CLI argv parser or
29
+ // the API. Without this, Commander would print plain text to stderr and
30
+ // — for missingMandatoryOptionValue — exit 0, breaking shell pipelines.
31
+ //
32
+ // Suppress Commander's own error formatter so the only error line is the
33
+ // envelope our exitOverride handler emits below.
34
+ program.configureOutput({
35
+ outputError: () => { },
36
+ });
37
+ program.exitOverride((err) => {
38
+ // Help and --version are normal exit-0 paths, not errors.
39
+ if (err.code === "commander.helpDisplayed"
40
+ || err.code === "commander.version"
41
+ || err.code === "commander.help") {
42
+ process.exit(0);
43
+ }
44
+ // Detect --json without relying on parsed opts (parse may have failed).
45
+ const useJson = process.argv.includes("--json") || !process.stdout.isTTY;
46
+ const envelope = {
47
+ error: err.message,
48
+ error_code: "usage_error",
49
+ status: 0,
50
+ retryable: false,
51
+ suggestions: ["Run `ish <command> --help` for usage"],
52
+ };
53
+ if (useJson) {
54
+ console.error(JSON.stringify(envelope));
55
+ }
56
+ else {
57
+ console.error(`Error: ${err.message}`);
58
+ console.error(" → Run `ish <command> --help` for usage");
59
+ }
60
+ process.exit(EXIT_USAGE);
61
+ });
20
62
  // Global options
21
63
  program
22
64
  .option("-t, --token <token>", "Auth token (or set ISH_TOKEN env var)")
65
+ .option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
23
66
  .option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
24
67
  .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
25
68
  .option("--json", "Output as JSON (auto-enabled when piped)")
26
69
  .option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
27
70
  .option("--verbose", "Include full UUIDs and timestamps in JSON output")
28
- .option("-q, --quiet", "Suppress progress messages on stderr");
71
+ .option("--no-color", "Disable colored output (also honored: NO_COLOR env var)")
72
+ .option("-q, --quiet", "Suppress progress messages on stderr (no-op for read commands that emit none)");
29
73
  // --- Inline commands (from upstream) ---
30
74
  program
31
75
  .command("login")
32
76
  .description("Authenticate with Ish via your browser")
33
77
  .action(async (_opts, cmd) => {
34
- try {
35
- const globals = cmd.optsWithGlobals();
78
+ await runInline(cmd, async (globals) => {
36
79
  const appUrl = globals.dev ? "http://localhost:3000" : getAppUrl();
37
80
  const tokens = await login(appUrl);
38
81
  const config = loadConfig();
39
82
  config.access_token = tokens.accessToken;
40
83
  config.refresh_token = tokens.refreshToken;
41
84
  saveConfig(config);
42
- console.log("\nLogin successful!");
43
- }
44
- catch (e) {
45
- console.error(`Login failed: ${e instanceof Error ? e.message : e}`);
46
- process.exit(1);
47
- }
85
+ output({ message: "Login successful" }, globals.json);
86
+ });
48
87
  });
49
88
  program
50
89
  .command("logout")
51
90
  .description("Remove saved authentication credentials")
52
- .action(() => {
53
- const config = loadConfig();
54
- delete config.access_token;
55
- delete config.refresh_token;
56
- delete config.token;
57
- saveConfig(config);
58
- console.log("Logged out.");
91
+ .action(async (_opts, cmd) => {
92
+ await runInline(cmd, async (globals) => {
93
+ const config = loadConfig();
94
+ delete config.access_token;
95
+ delete config.refresh_token;
96
+ delete config.token;
97
+ saveConfig(config);
98
+ output({ message: "Logged out" }, globals.json);
99
+ });
59
100
  });
60
101
  program
61
102
  .command("connect")
62
103
  .description("Expose your localhost to Ish via a Cloudflare tunnel")
63
104
  .argument("<port>", "Local port to connect (e.g. 3000)")
105
+ .addHelpText("after", "\nNote: --json emits structured one-line JSON for connected/disconnected events. --fields and --quiet have limited effect; use --json for machine-readable output.")
64
106
  .action(async (port, _opts, cmd) => {
65
- const portNum = parseInt(port, 10);
66
- if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
67
- console.error(`Invalid port: ${port}`);
68
- process.exit(1);
69
- }
70
- const globals = cmd.optsWithGlobals();
71
- const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
72
- await runTunnel(portNum, globals.token, apiUrl);
107
+ await runInline(cmd, async (globals) => {
108
+ const portNum = parseInt(port, 10);
109
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
110
+ throw new Error(`Invalid port: ${port}`);
111
+ }
112
+ const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
113
+ await runTunnel(portNum, globals.token, apiUrl, globals.tokenFile, {
114
+ json: globals.json,
115
+ quiet: globals.quiet,
116
+ });
117
+ });
73
118
  });
74
119
  // --- Modular command groups ---
75
120
  registerWorkspaceCommands(program);
76
121
  registerStudyCommands(program);
77
122
  registerIterationCommands(program);
78
- registerTesterProfileCommands(program);
79
- registerTesterCommands(program);
80
- registerSimulationCommands(program);
123
+ registerProfileCommands(program);
124
+ registerSourceCommands(program);
81
125
  registerConfigCommands(program);
126
+ registerAskCommands(program);
127
+ registerDocsCommands(program);
128
+ registerInitCommands(program);
82
129
  program
83
130
  .command("upgrade")
84
131
  .description("Update ish to the latest version")
85
- .option("--version <version>", "Install a specific version")
132
+ .option("--release <version>", "Install a specific release (e.g. 0.8.1)")
133
+ .addHelpText("after", "\nPin a specific release with --release <version>. Note: --version is the global CLI-version flag; use --release here.")
86
134
  .action(async (options) => {
87
- await upgrade(version, options.version);
135
+ await upgrade(version, options.release);
88
136
  });
89
137
  program.parse();
@@ -11,9 +11,12 @@ export declare const ALIAS_PREFIX: {
11
11
  readonly study: "s";
12
12
  readonly iteration: "i";
13
13
  readonly testerProfile: "tp";
14
+ readonly testerProfileSource: "tps";
14
15
  readonly tester: "t";
15
16
  readonly config: "c";
16
17
  readonly job: "j";
18
+ readonly ask: "a";
19
+ readonly askRound: "r";
17
20
  };
18
21
  /**
19
22
  * Save aliases for a list of IDs under the given prefix.
@@ -7,17 +7,19 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import * as os from "node:os";
11
- const ALIASES_FILE = path.join(os.homedir(), ".ish", "aliases.json");
10
+ import { aliasesPath } from "./paths.js";
12
11
  /** Entity type → alias prefix */
13
12
  export const ALIAS_PREFIX = {
14
13
  workspace: "w",
15
14
  study: "s",
16
15
  iteration: "i",
17
16
  testerProfile: "tp",
17
+ testerProfileSource: "tps",
18
18
  tester: "t",
19
19
  config: "c",
20
20
  job: "j",
21
+ ask: "a",
22
+ askRound: "r",
21
23
  };
22
24
  /** Format a number with zero-padding (minimum 2 digits). */
23
25
  function padNum(n) {
@@ -25,8 +27,8 @@ function padNum(n) {
25
27
  }
26
28
  function loadAliases() {
27
29
  try {
28
- if (fs.existsSync(ALIASES_FILE)) {
29
- return JSON.parse(fs.readFileSync(ALIASES_FILE, "utf-8"));
30
+ if (fs.existsSync(aliasesPath())) {
31
+ return JSON.parse(fs.readFileSync(aliasesPath(), "utf-8"));
30
32
  }
31
33
  }
32
34
  catch {
@@ -35,11 +37,11 @@ function loadAliases() {
35
37
  return {};
36
38
  }
37
39
  function persistAliases(aliases) {
38
- const dir = path.dirname(ALIASES_FILE);
40
+ const dir = path.dirname(aliasesPath());
39
41
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
40
- const tmp = ALIASES_FILE + ".tmp";
42
+ const tmp = aliasesPath() + ".tmp";
41
43
  fs.writeFileSync(tmp, JSON.stringify(aliases, null, 2) + "\n", { mode: 0o600 });
42
- fs.renameSync(tmp, ALIASES_FILE);
44
+ fs.renameSync(tmp, aliasesPath());
43
45
  }
44
46
  /**
45
47
  * Save aliases for a list of IDs under the given prefix.
@@ -9,6 +9,9 @@ export declare class ApiError extends Error {
9
9
  retryable: boolean;
10
10
  constructor(status: number, statusText: string, body: unknown);
11
11
  }
12
+ interface RequestOpts {
13
+ timeout?: number;
14
+ }
12
15
  export declare class ApiClient {
13
16
  private baseUrl;
14
17
  private token;
@@ -18,12 +21,11 @@ export declare class ApiClient {
18
21
  });
19
22
  get accessToken(): string;
20
23
  private headers;
21
- get<T = unknown>(path: string, params?: Record<string, string>): Promise<T>;
22
- post<T = unknown>(path: string, body?: unknown, opts?: {
23
- timeout?: number;
24
- }): Promise<T>;
25
- put<T = unknown>(path: string, body?: unknown): Promise<T>;
26
- del(path: string): Promise<void>;
24
+ get<T = unknown>(path: string, params?: Record<string, string | string[]>, opts?: RequestOpts): Promise<T>;
25
+ post<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
26
+ put<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
27
+ patch<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
28
+ del(path: string, opts?: RequestOpts): Promise<void>;
27
29
  localSimInit(body: {
28
30
  tester_id: string;
29
31
  study_id: string;
@@ -56,3 +58,4 @@ export declare class ApiClient {
56
58
  }>;
57
59
  private handleResponse;
58
60
  }
61
+ export {};
@@ -11,6 +11,8 @@ function mapErrorCode(status) {
11
11
  return "not_found";
12
12
  if (status === 402)
13
13
  return "insufficient_credits";
14
+ if (status === 408)
15
+ return "timeout";
14
16
  if (status === 422)
15
17
  return "validation_error";
16
18
  if (status === 429)
@@ -20,7 +22,7 @@ function mapErrorCode(status) {
20
22
  return "request_failed";
21
23
  }
22
24
  function isRetryable(status) {
23
- return status === 429 || status >= 500;
25
+ return status === 408 || status === 429 || status >= 500;
24
26
  }
25
27
  export class ApiError extends Error {
26
28
  status;
@@ -41,6 +43,21 @@ export class ApiError extends Error {
41
43
  this.retryable = isRetryable(status);
42
44
  }
43
45
  }
46
+ const DEFAULT_TIMEOUT_MS = 15_000;
47
+ function timeoutError(method, timeoutMs) {
48
+ const seconds = Math.round(timeoutMs / 1000);
49
+ return new ApiError(408, "Request Timeout", { detail: `${method} request timed out after ${seconds}s. The server may be slow — try again.` });
50
+ }
51
+ function isAbortTimeout(err) {
52
+ return err instanceof DOMException
53
+ && (err.name === "TimeoutError" || err.name === "AbortError");
54
+ }
55
+ function networkError(url) {
56
+ const err = new ApiError(0, "Network Error", { detail: `Could not reach API at ${url}` });
57
+ err.error_code = "network_error";
58
+ err.retryable = true;
59
+ return err;
60
+ }
44
61
  export class ApiClient {
45
62
  baseUrl;
46
63
  token;
@@ -57,34 +74,50 @@ export class ApiClient {
57
74
  "Content-Type": "application/json",
58
75
  };
59
76
  }
60
- async get(path, params) {
77
+ async get(path, params, opts) {
61
78
  let url = `${this.baseUrl}${path}`;
62
79
  if (params) {
63
- const filtered = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
64
- if (filtered.length > 0) {
65
- url += "?" + new URLSearchParams(filtered).toString();
80
+ const tuples = [];
81
+ for (const [k, v] of Object.entries(params)) {
82
+ if (v === undefined || v === "")
83
+ continue;
84
+ if (Array.isArray(v)) {
85
+ for (const item of v) {
86
+ if (item !== undefined && item !== "")
87
+ tuples.push([k, String(item)]);
88
+ }
89
+ }
90
+ else {
91
+ tuples.push([k, v]);
92
+ }
93
+ }
94
+ if (tuples.length > 0) {
95
+ url += "?" + new URLSearchParams(tuples).toString();
66
96
  }
67
97
  }
98
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
68
99
  let res;
69
100
  try {
70
101
  res = await fetch(url, {
71
102
  headers: this.headers(),
72
- signal: AbortSignal.timeout(15_000),
103
+ signal: AbortSignal.timeout(timeout),
73
104
  });
74
105
  }
75
106
  catch (err) {
76
- if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
77
- throw new Error('Request timed out after 15s. The server may be slow — try again.');
78
- }
107
+ if (isAbortTimeout(err))
108
+ throw timeoutError("GET", timeout);
109
+ if (err instanceof TypeError)
110
+ throw networkError(url);
79
111
  throw err;
80
112
  }
81
113
  return this.handleResponse(res);
82
114
  }
83
115
  async post(path, body, opts) {
84
- const timeout = opts?.timeout ?? 15_000;
116
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
117
+ const url = `${this.baseUrl}${path}`;
85
118
  let res;
86
119
  try {
87
- res = await fetch(`${this.baseUrl}${path}`, {
120
+ res = await fetch(url, {
88
121
  method: "POST",
89
122
  headers: this.headers(),
90
123
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -92,44 +125,72 @@ export class ApiClient {
92
125
  });
93
126
  }
94
127
  catch (err) {
95
- if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
96
- throw new Error(`Request timed out after ${timeout / 1000}s. The server may be slow — try again.`);
97
- }
128
+ if (isAbortTimeout(err))
129
+ throw timeoutError("POST", timeout);
130
+ if (err instanceof TypeError)
131
+ throw networkError(url);
98
132
  throw err;
99
133
  }
100
134
  return this.handleResponse(res);
101
135
  }
102
- async put(path, body) {
136
+ async put(path, body, opts) {
137
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
138
+ const url = `${this.baseUrl}${path}`;
103
139
  let res;
104
140
  try {
105
- res = await fetch(`${this.baseUrl}${path}`, {
141
+ res = await fetch(url, {
106
142
  method: "PUT",
107
143
  headers: this.headers(),
108
144
  body: body !== undefined ? JSON.stringify(body) : undefined,
109
- signal: AbortSignal.timeout(15_000),
145
+ signal: AbortSignal.timeout(timeout),
110
146
  });
111
147
  }
112
148
  catch (err) {
113
- if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
114
- throw new Error('Request timed out after 15s. The server may be slow — try again.');
115
- }
149
+ if (isAbortTimeout(err))
150
+ throw timeoutError("PUT", timeout);
151
+ if (err instanceof TypeError)
152
+ throw networkError(url);
116
153
  throw err;
117
154
  }
118
155
  return this.handleResponse(res);
119
156
  }
120
- async del(path) {
157
+ async patch(path, body, opts) {
158
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
159
+ const url = `${this.baseUrl}${path}`;
121
160
  let res;
122
161
  try {
123
- res = await fetch(`${this.baseUrl}${path}`, {
162
+ res = await fetch(url, {
163
+ method: "PATCH",
164
+ headers: this.headers(),
165
+ body: body !== undefined ? JSON.stringify(body) : undefined,
166
+ signal: AbortSignal.timeout(timeout),
167
+ });
168
+ }
169
+ catch (err) {
170
+ if (isAbortTimeout(err))
171
+ throw timeoutError("PATCH", timeout);
172
+ if (err instanceof TypeError)
173
+ throw networkError(url);
174
+ throw err;
175
+ }
176
+ return this.handleResponse(res);
177
+ }
178
+ async del(path, opts) {
179
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
180
+ const url = `${this.baseUrl}${path}`;
181
+ let res;
182
+ try {
183
+ res = await fetch(url, {
124
184
  method: "DELETE",
125
185
  headers: this.headers(),
126
- signal: AbortSignal.timeout(15_000),
186
+ signal: AbortSignal.timeout(timeout),
127
187
  });
128
188
  }
129
189
  catch (err) {
130
- if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
131
- throw new Error('Request timed out after 15s. The server may be slow — try again.');
132
- }
190
+ if (isAbortTimeout(err))
191
+ throw timeoutError("DELETE", timeout);
192
+ if (err instanceof TypeError)
193
+ throw networkError(url);
133
194
  throw err;
134
195
  }
135
196
  if (!res.ok) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Loader/validator for `--questions <file.json>`.
3
+ *
4
+ * Accepts a JSON array of InterviewQuestion entries. Mirrors the loose validation
5
+ * used by `ish study create --questions`: requires `question: string` on every entry,
6
+ * passes the rest through. The backend is the source of truth for the full schema.
7
+ */
8
+ import type { InterviewQuestion } from "./types.js";
9
+ export declare function loadQuestionsManifest(filePath: string): InterviewQuestion[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Loader/validator for `--questions <file.json>`.
3
+ *
4
+ * Accepts a JSON array of InterviewQuestion entries. Mirrors the loose validation
5
+ * used by `ish study create --questions`: requires `question: string` on every entry,
6
+ * passes the rest through. The backend is the source of truth for the full schema.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve as resolvePath } from "node:path";
10
+ export function loadQuestionsManifest(filePath) {
11
+ let raw;
12
+ try {
13
+ raw = readFileSync(resolvePath(filePath), "utf-8");
14
+ }
15
+ catch {
16
+ throw new Error(`Cannot read questions file: ${filePath}`);
17
+ }
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(raw);
21
+ }
22
+ catch {
23
+ throw new Error(`Invalid JSON in questions file: ${filePath}`);
24
+ }
25
+ if (!Array.isArray(parsed) || parsed.length === 0) {
26
+ throw new Error(`Questions file must be a non-empty JSON array: ${filePath}`);
27
+ }
28
+ for (let i = 0; i < parsed.length; i++) {
29
+ const q = parsed[i];
30
+ if (!q || typeof q !== "object" || typeof q.question !== "string" || !q.question.trim()) {
31
+ throw new Error(`questions[${i}].question must be a non-empty string.`);
32
+ }
33
+ }
34
+ return parsed;
35
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Parse `--variant` flags and `--variants <manifest.json>` into AskVariantInput[],
3
+ * uploading any local files to the asks variant-upload endpoint along the way.
4
+ *
5
+ * Flag form: `--variant <kind>:<value>[::label=<label>]`
6
+ * text:"hello" → literal text
7
+ * text:@./body.md → text loaded from file
8
+ * ./logo.png → media; kind inferred from MIME
9
+ * image:./logo.png → media; kind override
10
+ * ./hero.png::label=B → media with label
11
+ *
12
+ * Manifest form (--variants ./manifest.json): JSON array of
13
+ * { kind: "text"|"image"|..., label?: string, content?: string, file?: string }
14
+ * - kind="text": `content` holds the literal text (or `file` to load text from disk).
15
+ * - kind=media: `file` is a local path to upload, or `content` is an already-uploaded file_path.
16
+ */
17
+ import type { ApiClient } from "./api-client.js";
18
+ import type { AskVariantInput, AskVariantKind } from "./types.js";
19
+ /** Internal representation of a parsed variant before any upload happens. */
20
+ export interface ParsedVariant {
21
+ kind: AskVariantKind;
22
+ label?: string;
23
+ /** For kind=text: the literal text. For media: a local file path or an already-set file_path. */
24
+ source: string;
25
+ /** Whether `source` is a local file that still needs to be uploaded. */
26
+ needsUpload: boolean;
27
+ }
28
+ /** Parse a single `--variant` flag value. */
29
+ export declare function parseVariantFlag(raw: string): ParsedVariant;
30
+ export declare function parseVariantFlags(flags: string[]): ParsedVariant[];
31
+ /** Load a JSON manifest (an array of variant entries) from disk. */
32
+ export declare function loadVariantManifest(filePath: string): ParsedVariant[];
33
+ /**
34
+ * Walk the parsed variants, request signed upload URLs for the ones that need
35
+ * uploading, PUT each file to its signed URL, then return an AskVariantInput[]
36
+ * with the right `content` value (file_path for media, literal for text).
37
+ */
38
+ export declare function uploadAndBuildVariants(client: ApiClient, productId: string, parsed: ParsedVariant[], opts?: {
39
+ quiet?: boolean;
40
+ }): Promise<AskVariantInput[]>;
41
+ /**
42
+ * Convenience: parse flags OR manifest, validate that they're not both set, and
43
+ * return the parsed list. Caller still has to call `uploadAndBuildVariants`.
44
+ */
45
+ export declare function parseVariantInputs(opts: {
46
+ variant?: string[];
47
+ variants?: string;
48
+ }): ParsedVariant[];