@ishlabs/cli 0.8.5 → 0.10.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 (39) hide show
  1. package/README.md +55 -6
  2. package/dist/auth.d.ts +23 -4
  3. package/dist/auth.js +165 -39
  4. package/dist/commands/ask.d.ts +12 -0
  5. package/dist/commands/ask.js +127 -2
  6. package/dist/commands/chat.d.ts +17 -0
  7. package/dist/commands/chat.js +589 -0
  8. package/dist/commands/iteration.js +232 -13
  9. package/dist/commands/secret.d.ts +20 -0
  10. package/dist/commands/secret.js +246 -0
  11. package/dist/commands/source.js +24 -2
  12. package/dist/commands/study-run.d.ts +38 -0
  13. package/dist/commands/study-run.js +199 -80
  14. package/dist/commands/study-tester.js +17 -2
  15. package/dist/commands/study.js +311 -39
  16. package/dist/commands/workspace.js +81 -0
  17. package/dist/config.d.ts +7 -0
  18. package/dist/connect.d.ts +3 -0
  19. package/dist/connect.js +359 -24
  20. package/dist/index.js +67 -9
  21. package/dist/lib/alias-hydrate.d.ts +42 -0
  22. package/dist/lib/alias-hydrate.js +175 -0
  23. package/dist/lib/alias-store.d.ts +1 -0
  24. package/dist/lib/alias-store.js +28 -1
  25. package/dist/lib/auth.js +11 -3
  26. package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
  27. package/dist/lib/chat-endpoint-formatters.js +104 -0
  28. package/dist/lib/command-helpers.d.ts +18 -0
  29. package/dist/lib/command-helpers.js +188 -53
  30. package/dist/lib/docs.js +662 -34
  31. package/dist/lib/modality.d.ts +42 -0
  32. package/dist/lib/modality.js +192 -0
  33. package/dist/lib/output.d.ts +41 -0
  34. package/dist/lib/output.js +453 -19
  35. package/dist/lib/paths.d.ts +1 -0
  36. package/dist/lib/paths.js +3 -0
  37. package/dist/lib/skill-content.js +183 -13
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +3 -3
@@ -0,0 +1,589 @@
1
+ /**
2
+ * ish chat — Configure chatbot endpoints and run chat-modality studies.
3
+ *
4
+ * The CLI's primary user is autonomous AI agents. Every verb here is
5
+ * scriptable: deterministic JSON outputs, no interactive prompts, no
6
+ * REPLs. Endpoint editing matches the editor dialog's semantics
7
+ * (full-replace via PUT) plus client-side field-shorthand flags for
8
+ * common one-line edits.
9
+ *
10
+ * Chat-modality studies are reached via the existing `ish study create
11
+ * --modality chat --endpoint <id>` extension; this file does NOT
12
+ * fork a parallel `chat run` verb tree.
13
+ */
14
+ import { withClient, runInline, createClient, resolveWorkspace, resolveChatEndpoint, readFileOrStdin, confirmDestructive, } from "../lib/command-helpers.js";
15
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
16
+ import { loadConfig, saveConfig } from "../config.js";
17
+ import { ApiError } from "../lib/api-client.js";
18
+ import { output } from "../lib/output.js";
19
+ import { formatChatEndpointList, formatChatEndpointDetail, envelopeFromRow, } from "../lib/chat-endpoint-formatters.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+ function parseEndpointConfigFile(content) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(content);
27
+ }
28
+ catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ throw new Error(`--endpoint-config is not valid JSON: ${message}`);
31
+ }
32
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
33
+ throw new Error("--endpoint-config must be a JSON object.");
34
+ }
35
+ const obj = parsed;
36
+ // Envelope form: { id?, name, config, isTunnelBacked? } — extract .config.
37
+ if (obj.config && typeof obj.config === "object" && !Array.isArray(obj.config)) {
38
+ return {
39
+ config: obj.config,
40
+ envelopeName: typeof obj.name === "string" ? obj.name : undefined,
41
+ envelopeIsTunnelBacked: typeof obj.isTunnelBacked === "boolean" ? obj.isTunnelBacked : undefined,
42
+ };
43
+ }
44
+ // Bare ChatbotEndpointConfig
45
+ return { config: obj };
46
+ }
47
+ function urlLooksLocal(url) {
48
+ if (!url)
49
+ return false;
50
+ try {
51
+ const u = new URL(url);
52
+ const host = u.hostname.toLowerCase();
53
+ return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0";
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ function inferredToConfig(inferred) {
60
+ return {
61
+ transport: inferred.transport,
62
+ outgoing: {
63
+ url: inferred.outgoing.url ?? undefined,
64
+ method: inferred.outgoing.method,
65
+ headers: inferred.outgoing.headers ?? {},
66
+ bodyTemplate: inferred.outgoing.bodyTemplate ?? {},
67
+ mode: inferred.outgoing.mode,
68
+ roleAliases: inferred.outgoing.roleAliases ?? {},
69
+ },
70
+ incoming: inferred.incoming,
71
+ asyncPoll: inferred.asyncPoll ?? null,
72
+ };
73
+ }
74
+ async function tunnelGuard(client) {
75
+ try {
76
+ await client.get("/connect/active");
77
+ }
78
+ catch (err) {
79
+ if (err instanceof ApiError && err.status === 404) {
80
+ const e = new Error("This endpoint is configured to use a local tunnel, but no active tunnel was found. Run `ish connect <port>` first, then retry.");
81
+ e.error_kind = "TunnelInactive";
82
+ throw e;
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // list / get / create / update / delete / use
89
+ // ---------------------------------------------------------------------------
90
+ function attachChatEndpointCommands(parent) {
91
+ parent
92
+ .command("list")
93
+ .description("List chatbot endpoints in the current workspace")
94
+ .option("--workspace <id>", "Workspace ID")
95
+ .addHelpText("after", "\nExamples:\n $ ish chat endpoint list\n $ ish chat endpoint list --json | jq '.[].alias'")
96
+ .action(async (opts, cmd) => {
97
+ await withClient(cmd, async (client, globals) => {
98
+ const ws = resolveWorkspace(opts.workspace);
99
+ const data = await client.get(`/products/${ws}/chatbot-endpoints`);
100
+ // Tag aliases for downstream resolveId on subsequent commands.
101
+ for (const row of data) {
102
+ if (row.id)
103
+ tagAlias(ALIAS_PREFIX.chatEndpoint, row.id);
104
+ }
105
+ formatChatEndpointList(data, globals.json, globals.verbose);
106
+ });
107
+ });
108
+ parent
109
+ .command("get")
110
+ .description("Get a chatbot endpoint by id (or the active endpoint)")
111
+ .argument("[id]", "Endpoint alias or UUID; defaults to the active endpoint")
112
+ .option("--endpoint <id>", "Endpoint alias or UUID (alternative to positional)")
113
+ .addHelpText("after", `
114
+ With --verbose (or piped), emits the round-trippable envelope
115
+ {id, name, isTunnelBacked, config} that update --endpoint-config accepts.
116
+
117
+ Examples:
118
+ $ ish chat endpoint get ep-abc
119
+ $ ish chat endpoint get ep-abc --verbose | jq '.config' | ish chat endpoint update ep-abc --endpoint-config -`)
120
+ .action(async (id, opts, cmd) => {
121
+ await withClient(cmd, async (client, globals) => {
122
+ const rid = resolveChatEndpoint(id, opts.endpoint);
123
+ const row = await client.get(`/chatbot-endpoints/${rid}`);
124
+ if (row.id)
125
+ tagAlias(ALIAS_PREFIX.chatEndpoint, row.id);
126
+ formatChatEndpointDetail(row, globals.json, globals.verbose);
127
+ });
128
+ });
129
+ parent
130
+ .command("create")
131
+ .description("Create a chatbot endpoint from a config file or stdin")
132
+ .requiredOption("--endpoint-config <file>", 'Path to JSON file (or "-" for stdin)')
133
+ .option("--name <name>", "Override the name from the config file")
134
+ .option("--workspace <id>", "Workspace ID")
135
+ .option("--tunnel-backed", "Mark this endpoint as tunnel-backed (overrides envelope/config)")
136
+ .option("--no-tunnel-backed", "Force tunnel-backed off (overrides envelope/config)")
137
+ .addHelpText("after", `
138
+ Accepts either a bare ChatbotEndpointConfig or an envelope { name, config, isTunnelBacked }.
139
+ With --name, the flag wins over any envelope name. With --tunnel-backed / --no-tunnel-backed,
140
+ the flag wins over any envelope/config isTunnelBacked.
141
+
142
+ Examples:
143
+ $ ish chat endpoint create --endpoint-config ./bot.json --name "Production"
144
+ $ cat ./bot.json | ish chat endpoint create --endpoint-config - --tunnel-backed`)
145
+ .action(async (opts, cmd) => {
146
+ await withClient(cmd, async (client, globals) => {
147
+ const ws = resolveWorkspace(opts.workspace);
148
+ const raw = await readFileOrStdin(opts.endpointConfig);
149
+ const { config, envelopeName, envelopeIsTunnelBacked } = parseEndpointConfigFile(raw);
150
+ // Name resolution: --name > envelope.name. Required by the backend.
151
+ const name = opts.name ?? envelopeName;
152
+ if (!name || name.length === 0) {
153
+ throw new Error("Endpoint requires a name. Pass --name <name> or include name in the envelope.");
154
+ }
155
+ // Tunnel-backed resolution: explicit flag > envelope > config.isTunnelBacked > false.
156
+ let isTunnelBacked;
157
+ if (opts.tunnelBacked === true)
158
+ isTunnelBacked = true;
159
+ else if (opts.tunnelBacked === false)
160
+ isTunnelBacked = false;
161
+ else if (envelopeIsTunnelBacked !== undefined)
162
+ isTunnelBacked = envelopeIsTunnelBacked;
163
+ else if (typeof config.isTunnelBacked === "boolean")
164
+ isTunnelBacked = config.isTunnelBacked;
165
+ else
166
+ isTunnelBacked = false;
167
+ const body = { name, config, isTunnelBacked };
168
+ // 30 s tolerates dev-backend cold-start (JWKS round-trip + warm
169
+ // connection pool) which can occasionally push a simple insert
170
+ // past the 15 s default.
171
+ const created = await client.post(`/products/${ws}/chatbot-endpoints`, body, { timeout: 30_000 });
172
+ if (created.id) {
173
+ const alias = tagAlias(ALIAS_PREFIX.chatEndpoint, created.id);
174
+ if (!globals.quiet)
175
+ console.error(`Created endpoint ${alias}`);
176
+ }
177
+ formatChatEndpointDetail(created, globals.json, globals.verbose);
178
+ });
179
+ });
180
+ parent
181
+ .command("update")
182
+ .description("Update a chatbot endpoint (full replace via --endpoint-config, or per-field shorthand)")
183
+ .argument("[id]", "Endpoint alias or UUID; defaults to the active endpoint")
184
+ .option("--endpoint <id>", "Endpoint alias or UUID (alternative to positional)")
185
+ .option("--endpoint-config <file>", 'Replace config from JSON file (or "-" for stdin)')
186
+ .option("--name <name>", "Rename the endpoint")
187
+ .option("--url <url>", "Replace outgoing.url")
188
+ .option("--method <method>", "Replace outgoing.method (POST | PUT)")
189
+ .option("--mode <mode>", "Replace outgoing.mode (stateful | stateless)")
190
+ .option("--tunnel-backed", "Set isTunnelBacked=true")
191
+ .option("--no-tunnel-backed", "Set isTunnelBacked=false")
192
+ .addHelpText("after", `
193
+ The dialog persists via PUT (full replace); per-field flags fetch the current
194
+ endpoint, apply the override, and PUT the merged result. Field flags win over
195
+ --endpoint-config on conflict. Combine flags freely:
196
+
197
+ $ ish chat endpoint update ep-abc --name "Production"
198
+ $ ish chat endpoint update ep-abc --url https://api.example.com/v2/chat
199
+ $ ish chat endpoint get ep-abc --verbose \\
200
+ | jq '.config.incoming.slotsContainerPaths += ["response.options"]' \\
201
+ | ish chat endpoint update ep-abc --endpoint-config -`)
202
+ .action(async (id, opts, cmd) => {
203
+ await withClient(cmd, async (client, globals) => {
204
+ const rid = resolveChatEndpoint(id, opts.endpoint);
205
+ const fieldFlagsSet = opts.name !== undefined
206
+ || opts.url !== undefined
207
+ || opts.method !== undefined
208
+ || opts.mode !== undefined
209
+ || opts.tunnelBacked !== undefined;
210
+ if (!opts.endpointConfig && !fieldFlagsSet) {
211
+ throw new Error("Pass --endpoint-config <file> or at least one field flag (--name, --url, --method, --mode, --tunnel-backed).");
212
+ }
213
+ // Validate enum-ish field flags client-side so the agent gets a clear
214
+ // error rather than a 422 from the backend.
215
+ if (opts.method !== undefined && !["POST", "PUT"].includes(opts.method)) {
216
+ throw new Error(`--method must be POST or PUT (got "${opts.method}").`);
217
+ }
218
+ if (opts.mode !== undefined && !["stateful", "stateless"].includes(opts.mode)) {
219
+ throw new Error(`--mode must be stateful or stateless (got "${opts.mode}").`);
220
+ }
221
+ // Lazy memoised fetch of the current endpoint. PUT is full-replace, so
222
+ // backfill paths (per-field shorthand, or --endpoint-config without
223
+ // name / isTunnelBacked supplied by the envelope) need the live row;
224
+ // memoising guarantees a single GET no matter how many backfills are
225
+ // needed and avoids a torn payload from two independent reads.
226
+ let cached = null;
227
+ const fetchCurrent = async () => cached ?? (cached = await client.get(`/chatbot-endpoints/${rid}`));
228
+ let baseConfig;
229
+ let baseName;
230
+ let baseIsTunnelBacked;
231
+ if (opts.endpointConfig) {
232
+ const raw = await readFileOrStdin(opts.endpointConfig);
233
+ const parsed = parseEndpointConfigFile(raw);
234
+ baseConfig = parsed.config;
235
+ if (parsed.envelopeName !== undefined) {
236
+ baseName = parsed.envelopeName;
237
+ }
238
+ else if (opts.name !== undefined) {
239
+ baseName = opts.name;
240
+ }
241
+ else {
242
+ baseName = (await fetchCurrent()).name ?? "";
243
+ }
244
+ if (parsed.envelopeIsTunnelBacked !== undefined) {
245
+ baseIsTunnelBacked = parsed.envelopeIsTunnelBacked;
246
+ }
247
+ else if (typeof baseConfig.isTunnelBacked === "boolean") {
248
+ baseIsTunnelBacked = baseConfig.isTunnelBacked;
249
+ }
250
+ else {
251
+ baseIsTunnelBacked = Boolean((await fetchCurrent()).isTunnelBacked);
252
+ }
253
+ }
254
+ else {
255
+ // No bulk file — fetch and apply per-field overrides.
256
+ const current = await fetchCurrent();
257
+ baseConfig = current.config ?? {};
258
+ baseName = current.name ?? "";
259
+ baseIsTunnelBacked = Boolean(current.isTunnelBacked);
260
+ }
261
+ // Apply per-field overrides (these win over the bulk file contents).
262
+ if (opts.name !== undefined)
263
+ baseName = opts.name;
264
+ if (opts.tunnelBacked === true)
265
+ baseIsTunnelBacked = true;
266
+ else if (opts.tunnelBacked === false)
267
+ baseIsTunnelBacked = false;
268
+ const outgoing = { ...(baseConfig.outgoing ?? {}) };
269
+ if (opts.url !== undefined)
270
+ outgoing.url = opts.url;
271
+ if (opts.method !== undefined)
272
+ outgoing.method = opts.method;
273
+ if (opts.mode !== undefined)
274
+ outgoing.mode = opts.mode;
275
+ const mergedConfig = {
276
+ ...baseConfig,
277
+ outgoing,
278
+ };
279
+ const body = {
280
+ name: baseName,
281
+ config: mergedConfig,
282
+ isTunnelBacked: baseIsTunnelBacked,
283
+ };
284
+ const updated = await client.put(`/chatbot-endpoints/${rid}`, body);
285
+ if (updated.id) {
286
+ const alias = tagAlias(ALIAS_PREFIX.chatEndpoint, updated.id);
287
+ if (!globals.quiet)
288
+ console.error(`Updated endpoint ${alias}`);
289
+ }
290
+ formatChatEndpointDetail(updated, globals.json, globals.verbose);
291
+ });
292
+ });
293
+ parent
294
+ .command("delete")
295
+ .description("Delete a chatbot endpoint")
296
+ .argument("[id]", "Endpoint alias or UUID; defaults to the active endpoint")
297
+ .option("--endpoint <id>", "Endpoint alias or UUID (alternative to positional)")
298
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
299
+ .addHelpText("after", "\nExamples:\n $ ish chat endpoint delete ep-abc --yes\n $ ish chat endpoint delete --endpoint ep-abc --yes --json")
300
+ .action(async (id, opts, cmd) => {
301
+ await withClient(cmd, async (client, globals) => {
302
+ const rid = resolveChatEndpoint(id, opts.endpoint);
303
+ await confirmDestructive(`Delete chatbot endpoint ${tagAlias(ALIAS_PREFIX.chatEndpoint, rid)}? This cannot be undone.`, { yes: opts.yes, json: globals.json });
304
+ await client.del(`/chatbot-endpoints/${rid}`);
305
+ // If the deleted endpoint was the active one, clear it.
306
+ const config = loadConfig();
307
+ if (config.chat_endpoint === rid) {
308
+ delete config.chat_endpoint;
309
+ saveConfig(config);
310
+ }
311
+ output({
312
+ success: true,
313
+ deleted: true,
314
+ id: rid,
315
+ alias: tagAlias(ALIAS_PREFIX.chatEndpoint, rid),
316
+ }, globals.json, { writePath: true });
317
+ });
318
+ });
319
+ parent
320
+ .command("use")
321
+ .description("Set the active chat endpoint (saved to ~/.ish/config.json)")
322
+ .argument("[id]", "Endpoint alias or UUID")
323
+ .option("--clear", "Remove the active chat endpoint from config")
324
+ .addHelpText("after", "\nExamples:\n $ ish chat endpoint use ep-abc\n $ ish chat endpoint use --clear")
325
+ .action(async (id, opts, cmd) => {
326
+ await runInline(cmd, async (globals) => {
327
+ if (opts.clear) {
328
+ const config = loadConfig();
329
+ delete config.chat_endpoint;
330
+ saveConfig(config);
331
+ if (!globals.quiet)
332
+ console.error("Cleared active chat endpoint.");
333
+ return;
334
+ }
335
+ if (!id) {
336
+ throw new Error("Provide a chat endpoint alias or UUID, or use --clear.");
337
+ }
338
+ const rid = resolveId(id);
339
+ // Verify the endpoint exists before persisting so `use` matches the
340
+ // contract of `study use` / `ask use` (active id is always valid).
341
+ // 30 s tolerates dev-backend cold-start; the GET itself is cheap.
342
+ const client = await createClient(globals);
343
+ const row = await client.get(`/chatbot-endpoints/${rid}`, undefined, { timeout: 30_000 });
344
+ const config = loadConfig();
345
+ config.chat_endpoint = rid;
346
+ saveConfig(config);
347
+ if (!globals.quiet)
348
+ console.error(`Active chat endpoint set to "${row.name || rid}".`);
349
+ });
350
+ });
351
+ }
352
+ // ---------------------------------------------------------------------------
353
+ // init — auto-detect-shape onboarding
354
+ // ---------------------------------------------------------------------------
355
+ function attachChatEndpointInit(parent) {
356
+ parent
357
+ .command("init")
358
+ .description("Author an endpoint from a curl/JSON sample via auto-detect-shape")
359
+ .option("--from-curl <file>", 'Path to a curl example file (or "-" for stdin)')
360
+ .option("--from-json <file>", 'Path to a JSON request/response sample (or "-" for stdin)')
361
+ .option("--name <name>", "Save the inferred config under this display name")
362
+ .option("--no-save", "Infer the shape without persisting it")
363
+ .option("--workspace <id>", "Workspace ID")
364
+ .option("--tunnel-backed", "Force isTunnelBacked=true (overrides localhost auto-detect)")
365
+ .option("--no-tunnel-backed", "Force isTunnelBacked=false (overrides localhost auto-detect)")
366
+ .addHelpText("after", `
367
+ Pass exactly one of --from-curl or --from-json. Both accept "-" for stdin.
368
+
369
+ isTunnelBacked decision: explicit flag wins; else true when the inferred URL
370
+ points at localhost / 127.0.0.1 / 0.0.0.0.
371
+
372
+ Examples:
373
+ $ ish chat endpoint init --from-curl ./bot.curl --name my-bot
374
+ $ ish chat endpoint init --from-json ./shape.json --no-save | jq '.config'`)
375
+ .action(async (opts, cmd) => {
376
+ await withClient(cmd, async (client, globals) => {
377
+ if (!opts.fromCurl && !opts.fromJson) {
378
+ throw new Error("Pass exactly one of --from-curl <file> or --from-json <file>.");
379
+ }
380
+ if (opts.fromCurl && opts.fromJson) {
381
+ throw new Error("Pass either --from-curl or --from-json, not both.");
382
+ }
383
+ const ws = resolveWorkspace(opts.workspace);
384
+ const path = (opts.fromCurl ?? opts.fromJson);
385
+ const paste = await readFileOrStdin(path);
386
+ const inferRes = await client.post(`/products/${ws}/chat/auto-detect-shape`, { paste }, { timeout: 120_000 });
387
+ if (!inferRes.ok) {
388
+ // Surface as a structured failure envelope on stdout AND throw so
389
+ // the wrapper sets a non-zero exit. The thrown Error carries the
390
+ // shape's error_kind for the agent to branch on.
391
+ const err = new Error(inferRes.errorMessage ?? "auto-detect-shape failed.");
392
+ err.error_kind = inferRes.errorKind;
393
+ throw err;
394
+ }
395
+ const inferred = inferRes.inferred;
396
+ const config = inferredToConfig(inferred);
397
+ const inferredUrl = inferred.outgoing.url ?? null;
398
+ const detectedTunnel = urlLooksLocal(inferredUrl);
399
+ let tunnelBacked;
400
+ if (opts.tunnelBacked === true)
401
+ tunnelBacked = true;
402
+ else if (opts.tunnelBacked === false)
403
+ tunnelBacked = false;
404
+ else
405
+ tunnelBacked = detectedTunnel;
406
+ config.isTunnelBacked = tunnelBacked;
407
+ const warnings = [];
408
+ if (!inferredUrl) {
409
+ warnings.push("Inferred shape has no URL; set --url before testing.");
410
+ }
411
+ if (inferred.confidence !== "high") {
412
+ warnings.push(`Auto-detect confidence: ${inferred.confidence} — verify the shape before running.`);
413
+ }
414
+ // Decide whether to save. --no-save short-circuits; otherwise save when
415
+ // a name is available (--name wins; else fall back to the inferred
416
+ // URL host as a sensible default — agents can rename later).
417
+ let inferredHost;
418
+ if (inferredUrl) {
419
+ try {
420
+ inferredHost = new URL(inferredUrl).hostname || undefined;
421
+ }
422
+ catch {
423
+ inferredHost = undefined;
424
+ }
425
+ }
426
+ let endpointId = null;
427
+ let endpointAlias = null;
428
+ let saved = false;
429
+ const saveExplicitlyDisabled = opts.save === false;
430
+ const fallbackName = inferredHost
431
+ ? `inferred:${inferredHost}`
432
+ : (inferredUrl ? `inferred:${inferredUrl}` : undefined);
433
+ const proposedName = opts.name ?? fallbackName;
434
+ if (!saveExplicitlyDisabled && proposedName) {
435
+ const created = await client.post(`/products/${ws}/chatbot-endpoints`, { name: proposedName, config, isTunnelBacked: tunnelBacked });
436
+ if (created.id) {
437
+ endpointId = created.id;
438
+ endpointAlias = tagAlias(ALIAS_PREFIX.chatEndpoint, created.id);
439
+ saved = true;
440
+ if (!globals.quiet) {
441
+ console.error(`Created endpoint ${endpointAlias}`);
442
+ }
443
+ }
444
+ }
445
+ const missingSignals = Array.isArray(inferred.missingSignals)
446
+ ? inferred.missingSignals
447
+ : [];
448
+ const result = {
449
+ success: true,
450
+ saved,
451
+ endpoint_id: endpointId,
452
+ alias: endpointAlias,
453
+ config,
454
+ tunnel_backed: tunnelBacked,
455
+ tunnel_backed_detected: detectedTunnel,
456
+ confidence: inferred.confidence,
457
+ explanation: inferred.explanation,
458
+ missingSignals,
459
+ warnings,
460
+ };
461
+ output(result, globals.json, { writePath: true });
462
+ });
463
+ });
464
+ }
465
+ // ---------------------------------------------------------------------------
466
+ // test — single-turn smoke test
467
+ // ---------------------------------------------------------------------------
468
+ function attachChatEndpointTest(parent) {
469
+ parent
470
+ .command("test")
471
+ .description("Send one message to a saved endpoint and return the bot's reply")
472
+ .argument("[endpoint-id]", "Endpoint alias or UUID; defaults to the active endpoint")
473
+ .option("--endpoint <id>", "Endpoint alias or UUID (alternative to positional)")
474
+ .option("-m, --message <text>", "Message to send", "Hello")
475
+ .option("--conversation-id <id>", "Thread state across multiple invocations (stateful endpoints)")
476
+ .option("--tester <json>", "Tester persona JSON ({name, locale, ...}) exposed via {{tester.*}}")
477
+ .option("--include-request", "Include the dispatched POST body in the output")
478
+ .option("--workspace <id>", "Workspace ID")
479
+ .addHelpText("after", `
480
+ Pre-flight: when the saved endpoint is tunnel-backed, GETs /connect/active and
481
+ exits 5 with error_kind=TunnelInactive on miss.
482
+
483
+ Examples:
484
+ $ ish chat endpoint test ep-abc -m "Hello"
485
+ $ ish chat endpoint test ep-abc -m "Yes" --conversation-id conv-123
486
+ $ ish chat endpoint test ep-abc --tester '{"name":"Pat","locale":"en-US"}'`)
487
+ .action(async (id, opts, cmd) => {
488
+ await withClient(cmd, async (client, globals) => {
489
+ const ws = resolveWorkspace(opts.workspace);
490
+ const rid = resolveChatEndpoint(id, opts.endpoint);
491
+ // 30 s tolerates dev-backend cold-start; the GET itself is cheap.
492
+ const saved = await client.get(`/chatbot-endpoints/${rid}`, undefined, { timeout: 30_000 });
493
+ if (saved.id)
494
+ tagAlias(ALIAS_PREFIX.chatEndpoint, saved.id);
495
+ if (saved.isTunnelBacked) {
496
+ await tunnelGuard(client);
497
+ }
498
+ let testerBlock;
499
+ if (opts.tester !== undefined) {
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(opts.tester);
503
+ }
504
+ catch (err) {
505
+ const message = err instanceof Error ? err.message : String(err);
506
+ throw new Error(`--tester must be a JSON object: ${message}`);
507
+ }
508
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
509
+ throw new Error("--tester must be a JSON object.");
510
+ }
511
+ testerBlock = parsed;
512
+ }
513
+ // The backend test-endpoint route accepts the same envelope shape the
514
+ // editor sends — drop the saved config in unchanged, with isTunnelBacked
515
+ // hoisted onto the config so the route's coercion sees it.
516
+ const endpointPayload = {
517
+ ...(saved.config ?? {}),
518
+ isTunnelBacked: saved.isTunnelBacked,
519
+ };
520
+ const body = {
521
+ endpoint: endpointPayload,
522
+ sample_message: opts.message,
523
+ };
524
+ if (testerBlock)
525
+ body.tester = testerBlock;
526
+ if (opts.conversationId)
527
+ body.conversation_id = opts.conversationId;
528
+ const res = await client.post(`/products/${ws}/chat/test-endpoint`, body, { timeout: 90_000 });
529
+ if (!res.ok) {
530
+ const err = new Error(res.error_message ?? "chat endpoint test failed.");
531
+ err.error_kind = res.error_kind;
532
+ // Surface dispatched body on failure too when requested.
533
+ if (opts.includeRequest && res.dispatched_body !== undefined) {
534
+ err.dispatched_body = res.dispatched_body;
535
+ }
536
+ throw err;
537
+ }
538
+ const reply = res.bot_reply;
539
+ const result = {
540
+ success: true,
541
+ text: reply.text,
542
+ conversation_id: reply.conversation_id ?? null,
543
+ slots: reply.slots ?? [],
544
+ references: reply.references ?? [],
545
+ bot_latency_ms: reply.bot_latency_ms ?? null,
546
+ end_of_conversation: reply.end_of_conversation ?? false,
547
+ detected_affordances: res.detected_affordances ?? null,
548
+ };
549
+ if (opts.includeRequest) {
550
+ result.dispatched_body = res.dispatched_body ?? null;
551
+ }
552
+ output(result, globals.json);
553
+ });
554
+ });
555
+ }
556
+ // ---------------------------------------------------------------------------
557
+ // Command registration
558
+ // ---------------------------------------------------------------------------
559
+ export function registerChatCommand(program) {
560
+ const chat = program
561
+ .command("chat")
562
+ .description("Author chatbot endpoints and run chat-modality studies")
563
+ .addHelpText("after", `
564
+ Use \`ish chat endpoint\` to configure the bot the persona will talk to.
565
+ Run a chat study via the existing flow:
566
+
567
+ $ ish chat endpoint init --from-curl ./bot.curl --name my-bot
568
+ $ ish chat endpoint test my-bot -m "Hello"
569
+ $ ish study create --modality chat --endpoint my-bot --assignment "Sign up:Try to sign up"
570
+ $ ish study run --study <study-id> --sample 5 --wait
571
+
572
+ Concept pages: ish docs get-page guides/chat`);
573
+ const endpoint = chat
574
+ .command("endpoint")
575
+ .description("Manage saved chatbot endpoints")
576
+ .addHelpText("after", `
577
+ Endpoints are workspace-scoped. The active endpoint (set with
578
+ \`ish chat endpoint use <id>\`) is the default for verbs that
579
+ take an optional endpoint id.
580
+
581
+ The dialog at https://app.ishlabs.io/<workspace>/chatbot-endpoints
582
+ edits the same resources via PUT /chatbot-endpoints/{id}; the CLI
583
+ mirrors that editing model.`);
584
+ attachChatEndpointCommands(endpoint);
585
+ attachChatEndpointInit(endpoint);
586
+ attachChatEndpointTest(endpoint);
587
+ }
588
+ // Re-exported for tests / external integration if needed.
589
+ export { envelopeFromRow };