@ishlabs/cli 0.9.0 → 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.
- package/README.md +54 -5
- package/dist/commands/ask.d.ts +12 -0
- package/dist/commands/ask.js +127 -2
- package/dist/commands/chat.d.ts +17 -0
- package/dist/commands/chat.js +589 -0
- package/dist/commands/iteration.js +134 -14
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/study-run.d.ts +38 -0
- package/dist/commands/study-run.js +199 -80
- package/dist/commands/study-tester.js +17 -2
- package/dist/commands/study.js +309 -37
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +3 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +346 -22
- package/dist/index.js +64 -6
- package/dist/lib/alias-hydrate.d.ts +42 -0
- package/dist/lib/alias-hydrate.js +175 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +28 -1
- package/dist/lib/auth.js +4 -2
- package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
- package/dist/lib/chat-endpoint-formatters.js +104 -0
- package/dist/lib/command-helpers.d.ts +18 -0
- package/dist/lib/command-helpers.js +105 -3
- package/dist/lib/docs.js +542 -17
- package/dist/lib/modality.d.ts +42 -0
- package/dist/lib/modality.js +192 -0
- package/dist/lib/output.d.ts +41 -0
- package/dist/lib/output.js +453 -19
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +182 -12
- package/dist/lib/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -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 };
|