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