@ishlabs/cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +47 -14
- package/dist/commands/ask.js +52 -0
- package/dist/commands/chat.js +64 -0
- package/dist/index.js +17 -1
- package/dist/lib/docs.js +44 -1
- package/dist/lib/output.js +4 -1
- package/dist/lib/skill-content.js +49 -2
- package/dist/lib/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as http from "node:http";
|
|
21
21
|
import * as crypto from "node:crypto";
|
|
22
|
-
import {
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
23
|
const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
24
24
|
const CLIENT_NAME = "ish CLI";
|
|
25
25
|
const DEFAULT_APP_URL = "https://app.ishlabs.io";
|
|
@@ -88,15 +88,15 @@ export function resolveSupabaseProjectFromToken(accessToken) {
|
|
|
88
88
|
}
|
|
89
89
|
// --- Browser open ---
|
|
90
90
|
function openBrowser(url) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// detached + unref so the child doesn't keep our event loop alive after the
|
|
92
|
+
// login flow finishes. stdio: "ignore" so we don't pipe-buffer its output.
|
|
93
|
+
const opts = { detached: true, stdio: "ignore" };
|
|
94
|
+
const child = process.platform === "win32"
|
|
95
|
+
? spawn("cmd", ["/c", "start", "", url], opts)
|
|
96
|
+
: process.platform === "darwin"
|
|
97
|
+
? spawn("open", [url], opts)
|
|
98
|
+
: spawn("xdg-open", [url], opts);
|
|
99
|
+
child.unref();
|
|
100
100
|
}
|
|
101
101
|
// --- JWT decode ---
|
|
102
102
|
export function decodeJwtExp(token) {
|
|
@@ -130,10 +130,15 @@ function startCallbackServer() {
|
|
|
130
130
|
const callbackPromise = new Promise((res) => {
|
|
131
131
|
resolveCallback = res;
|
|
132
132
|
});
|
|
133
|
+
// Track every socket the server accepts so we can destroy them on close.
|
|
134
|
+
// Browsers open multiple keep-alive connections (favicon prefetch, parallel
|
|
135
|
+
// request slots) and Node's server.close() waits for those to drain — that
|
|
136
|
+
// wait is what was hanging `ish login` after success.
|
|
137
|
+
const sockets = new Set();
|
|
133
138
|
const server = http.createServer((req, res) => {
|
|
134
139
|
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
135
140
|
if (reqUrl.pathname !== "/callback") {
|
|
136
|
-
res.writeHead(404);
|
|
141
|
+
res.writeHead(404, { Connection: "close" });
|
|
137
142
|
res.end();
|
|
138
143
|
return;
|
|
139
144
|
}
|
|
@@ -148,11 +153,21 @@ function startCallbackServer() {
|
|
|
148
153
|
? `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`
|
|
149
154
|
: "You can close this window and return to your terminal.";
|
|
150
155
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${headline}</title></head><body style="font-family:system-ui,-apple-system,sans-serif;padding:3em;text-align:center;color:#1f2937"><h1 style="font-weight:600;margin-bottom:1em">${headline}</h1><p style="color:#6b7280">${subline}</p></body></html>`;
|
|
151
|
-
|
|
156
|
+
// Connection: close so the browser tears down the socket immediately;
|
|
157
|
+
// otherwise HTTP/1.1 keep-alive holds the socket open and prevents
|
|
158
|
+
// server.close() from freeing the event loop, hanging `ish login`.
|
|
159
|
+
res.writeHead(cb.error ? 400 : 200, {
|
|
160
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
161
|
+
Connection: "close",
|
|
162
|
+
});
|
|
152
163
|
res.end(html);
|
|
153
164
|
if (resolveCallback)
|
|
154
165
|
resolveCallback(cb);
|
|
155
166
|
});
|
|
167
|
+
server.on("connection", (socket) => {
|
|
168
|
+
sockets.add(socket);
|
|
169
|
+
socket.on("close", () => sockets.delete(socket));
|
|
170
|
+
});
|
|
156
171
|
server.on("error", reject);
|
|
157
172
|
server.listen(0, "127.0.0.1", () => {
|
|
158
173
|
const addr = server.address();
|
|
@@ -165,6 +180,14 @@ function startCallbackServer() {
|
|
|
165
180
|
waitForCallback: () => callbackPromise,
|
|
166
181
|
close: () => {
|
|
167
182
|
server.close();
|
|
183
|
+
// Force-destroy every socket — Connection: close + closeAllConnections
|
|
184
|
+
// alone weren't enough on macOS, where the browser kept enough idle
|
|
185
|
+
// sockets alive to hold the event loop open.
|
|
186
|
+
server.closeAllConnections?.();
|
|
187
|
+
for (const socket of sockets)
|
|
188
|
+
socket.destroy();
|
|
189
|
+
sockets.clear();
|
|
190
|
+
server.unref();
|
|
168
191
|
},
|
|
169
192
|
});
|
|
170
193
|
});
|
|
@@ -218,10 +241,20 @@ export async function login() {
|
|
|
218
241
|
console.error(`If the browser doesn't open, visit:\n ${authorizeUrl.toString()}\n`);
|
|
219
242
|
openBrowser(authorizeUrl.toString());
|
|
220
243
|
console.error("Waiting for authentication...");
|
|
244
|
+
let timeoutHandle;
|
|
221
245
|
const timeoutPromise = new Promise((_, reject) => {
|
|
222
|
-
setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
|
|
246
|
+
timeoutHandle = setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
|
|
223
247
|
});
|
|
224
|
-
|
|
248
|
+
let cb;
|
|
249
|
+
try {
|
|
250
|
+
cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
// Without this, the 5-minute timer keeps the event loop alive long
|
|
254
|
+
// after the callback fires, hanging `ish login` until expiry.
|
|
255
|
+
if (timeoutHandle)
|
|
256
|
+
clearTimeout(timeoutHandle);
|
|
257
|
+
}
|
|
225
258
|
if (cb.error) {
|
|
226
259
|
throw new Error(`OAuth error: ${cb.error}${cb.errorDescription ? ` — ${cb.errorDescription}` : ""}`);
|
|
227
260
|
}
|
package/dist/commands/ask.js
CHANGED
|
@@ -347,6 +347,7 @@ Examples:
|
|
|
347
347
|
allFlagName: "--all-simulatable",
|
|
348
348
|
allFlagDescription: "Use every simulatable AI profile matching the filters",
|
|
349
349
|
})
|
|
350
|
+
.option("--no-dispatch", "Create the ask in DRAFT status without billing or dispatching the round. Hand the draft id back to the user, then start it with `ish ask dispatch <id>`. Audience flags are still required because the testers are materialized at create time. Mutually exclusive with --wait (nothing to wait for).")
|
|
350
351
|
.option("--wait", "Wait until the first round completes (or errors)")
|
|
351
352
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
352
353
|
.addHelpText("after", `
|
|
@@ -389,6 +390,9 @@ Picks come back with a \`pick_confidence\` (0..1) score per tester when
|
|
|
389
390
|
`)
|
|
390
391
|
.action(async (opts, cmd) => {
|
|
391
392
|
await withClient(cmd, async (client, globals) => {
|
|
393
|
+
if (opts.dispatch === false && opts.wait) {
|
|
394
|
+
throw new Error("--no-dispatch and --wait are incompatible — a draft ask has nothing to wait for. Drop --wait, or run `ish ask dispatch <id> --wait` after the draft is created.");
|
|
395
|
+
}
|
|
392
396
|
const wid = resolveWorkspace(opts.workspace);
|
|
393
397
|
const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
|
|
394
398
|
const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
|
|
@@ -398,6 +402,7 @@ Picks come back with a \`pick_confidence\` (0..1) score per tester when
|
|
|
398
402
|
language: opts.language,
|
|
399
403
|
tester_profile_ids: testerIds,
|
|
400
404
|
first_round: round,
|
|
405
|
+
...(opts.dispatch === false && { dispatch: false }),
|
|
401
406
|
};
|
|
402
407
|
let data = await client.post(`/products/${wid}/asks`, body);
|
|
403
408
|
if (data.id) {
|
|
@@ -414,11 +419,58 @@ Picks come back with a \`pick_confidence\` (0..1) score per tester when
|
|
|
414
419
|
result.alias = tagAlias(ALIAS_PREFIX.ask, String(result.id));
|
|
415
420
|
formatAskDetail(result, globals.json);
|
|
416
421
|
if (!globals.json && data.id) {
|
|
422
|
+
if (opts.dispatch === false) {
|
|
423
|
+
const askAlias = tagAlias(ALIAS_PREFIX.ask, data.id);
|
|
424
|
+
process.stderr.write(`\n Draft created. Start it with: ish ask dispatch ${askAlias}\n`);
|
|
425
|
+
}
|
|
417
426
|
const url = getWebUrl(globals, `/${wid}/asks/${data.id}`);
|
|
418
427
|
process.stderr.write(`\n ${terminalLink(url, "Open in browser ↗")}\n\n`);
|
|
419
428
|
}
|
|
420
429
|
});
|
|
421
430
|
});
|
|
431
|
+
// ---- dispatch -----------------------------------------------------------
|
|
432
|
+
// Pattern B-dispatch: flip a DRAFT ask to RUNNING and enqueue the worker.
|
|
433
|
+
// Idempotent on the server (409 on non-DRAFT) — surface that as a usage
|
|
434
|
+
// error rather than a transient failure.
|
|
435
|
+
ask
|
|
436
|
+
.command("dispatch")
|
|
437
|
+
.description("Dispatch a draft ask — bills credits and starts the round")
|
|
438
|
+
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
439
|
+
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
440
|
+
.option("--wait", "Wait until the first round completes (or errors)")
|
|
441
|
+
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
442
|
+
.addHelpText("after", `
|
|
443
|
+
Use after \`ish ask create --no-dispatch\` to start a draft once the user has
|
|
444
|
+
reviewed it. The dispatch is BILLABLE — credits are charged when responses
|
|
445
|
+
land, the same as a normal create.
|
|
446
|
+
|
|
447
|
+
Examples:
|
|
448
|
+
# Dispatch the active draft and wait for results:
|
|
449
|
+
$ ish ask dispatch --wait
|
|
450
|
+
|
|
451
|
+
# Dispatch a specific draft, JSON output:
|
|
452
|
+
$ ish ask dispatch a-6ec --json
|
|
453
|
+
|
|
454
|
+
A non-DRAFT ask returns a 409 (\`already dispatched\`). The CLI maps that to a
|
|
455
|
+
usage error so re-running this command is safe — no duplicate run.`)
|
|
456
|
+
.action(async (id, opts, cmd) => {
|
|
457
|
+
await withClient(cmd, async (client, globals) => {
|
|
458
|
+
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
459
|
+
let data = await client.post(`/asks/${aid}/dispatch`, {});
|
|
460
|
+
if (opts.wait) {
|
|
461
|
+
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
462
|
+
data = await pollUntilRoundDone(client, aid, 0, timeoutMs, !!globals.quiet);
|
|
463
|
+
}
|
|
464
|
+
const result = data;
|
|
465
|
+
if (result.id)
|
|
466
|
+
result.alias = tagAlias(ALIAS_PREFIX.ask, String(result.id));
|
|
467
|
+
formatAskDetail(result, globals.json);
|
|
468
|
+
if (!globals.json && data.product_id) {
|
|
469
|
+
const url = getWebUrl(globals, `/${data.product_id}/asks/${aid}`);
|
|
470
|
+
process.stderr.write(`\n ${terminalLink(url, "Open in browser ↗")}\n\n`);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
});
|
|
422
474
|
// ---- get ----------------------------------------------------------------
|
|
423
475
|
ask
|
|
424
476
|
.command("get")
|
package/dist/commands/chat.js
CHANGED
|
@@ -619,6 +619,69 @@ Examples:
|
|
|
619
619
|
});
|
|
620
620
|
});
|
|
621
621
|
}
|
|
622
|
+
function attachChatEndpointMap(parent) {
|
|
623
|
+
parent
|
|
624
|
+
.command("map")
|
|
625
|
+
.description("Map a chatbot contract from documentation (and optionally a saved endpoint draft) via /chat/test-and-map")
|
|
626
|
+
.option("--docs <file>", 'Path to documentation file (curl, JSON, OpenAPI snippet, freeform). Use "-" for stdin')
|
|
627
|
+
.option("--endpoint <id>", "Saved endpoint alias or UUID — its config seeds the draft endpoint. Optional.")
|
|
628
|
+
.option("--workspace <id>", "Workspace ID")
|
|
629
|
+
.addHelpText("after", `
|
|
630
|
+
Use this when you have documentation for a customer's chatbot (a curl example,
|
|
631
|
+
an OpenAPI excerpt, a README chunk, freeform docs) and want a fully-mapped
|
|
632
|
+
ChatbotEndpointConfig back. The backend wraps the same inference engine
|
|
633
|
+
behind \`init\` but layers two things on top: it fuses your draft endpoint's
|
|
634
|
+
URL / headers with the documentation, and when the URL is probable it can
|
|
635
|
+
run an iterative LLM agent that probes the live bot to refine the mapping.
|
|
636
|
+
|
|
637
|
+
The output is read-only — pipe \`inferred_config\` into
|
|
638
|
+
\`ish chat endpoint create --endpoint-config -\` to persist, or into
|
|
639
|
+
\`update --endpoint-config -\` to overwrite an existing endpoint.
|
|
640
|
+
|
|
641
|
+
Examples:
|
|
642
|
+
$ ish chat endpoint map --docs ./api.md | jq '.inferred_config'
|
|
643
|
+
$ ish chat endpoint map --docs - < bot.curl
|
|
644
|
+
$ ish chat endpoint map --endpoint ep-abc --docs ./refined.md`)
|
|
645
|
+
.action(async (opts, cmd) => {
|
|
646
|
+
await withClient(cmd, async (client, globals) => {
|
|
647
|
+
if (!opts.docs) {
|
|
648
|
+
throw new Error("Pass --docs <file> (or --docs - for stdin).");
|
|
649
|
+
}
|
|
650
|
+
const ws = resolveWorkspace(opts.workspace);
|
|
651
|
+
const documentation = await readFileOrStdin(opts.docs);
|
|
652
|
+
if (!documentation.trim()) {
|
|
653
|
+
throw new Error("Documentation is empty. Provide a non-empty paste.");
|
|
654
|
+
}
|
|
655
|
+
let draftEndpoint = {};
|
|
656
|
+
if (opts.endpoint !== undefined) {
|
|
657
|
+
const rid = resolveChatEndpoint(undefined, opts.endpoint);
|
|
658
|
+
const saved = await client.get(`/chatbot-endpoints/${rid}`, undefined, { timeout: 30_000 });
|
|
659
|
+
if (saved.id)
|
|
660
|
+
tagAlias(ALIAS_PREFIX.chatEndpoint, saved.id);
|
|
661
|
+
draftEndpoint = {
|
|
662
|
+
...(saved.config ?? {}),
|
|
663
|
+
isTunnelBacked: saved.isTunnelBacked,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const res = await client.post(`/products/${ws}/chat/test-and-map`, { endpoint: draftEndpoint, documentation }, { timeout: 180_000 });
|
|
667
|
+
if (res.kind === "failure") {
|
|
668
|
+
const err = new Error(res.errorMessage ?? "test-and-map failed.");
|
|
669
|
+
err.error_kind = res.errorKind;
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
const result = {
|
|
673
|
+
success: true,
|
|
674
|
+
inferred_config: res.inferredConfig,
|
|
675
|
+
confidence: res.confidence ?? null,
|
|
676
|
+
missing_signals: res.missingSignals ?? [],
|
|
677
|
+
tunnel_backed_detected: res.tunnelBackedDetected ?? false,
|
|
678
|
+
raw_response: res.rawResponse ?? null,
|
|
679
|
+
dispatched_body: res.dispatchedBody ?? null,
|
|
680
|
+
};
|
|
681
|
+
output(result, globals.json, { writePath: true });
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
}
|
|
622
685
|
// ---------------------------------------------------------------------------
|
|
623
686
|
// Command registration
|
|
624
687
|
// ---------------------------------------------------------------------------
|
|
@@ -650,6 +713,7 @@ mirrors that editing model.`);
|
|
|
650
713
|
attachChatEndpointCommands(endpoint);
|
|
651
714
|
attachChatEndpointInit(endpoint);
|
|
652
715
|
attachChatEndpointTest(endpoint);
|
|
716
|
+
attachChatEndpointMap(endpoint);
|
|
653
717
|
}
|
|
654
718
|
// Re-exported for tests / external integration if needed.
|
|
655
719
|
export { envelopeFromRow };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program, Option } from "commander";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as os from "node:os";
|
|
3
6
|
import { runTunnel, runDetached, connectStatus, disconnect } from "./connect.js";
|
|
4
7
|
import { login, decodeJwtClaims } from "./auth.js";
|
|
5
8
|
import { loadConfig, saveConfig } from "./config.js";
|
|
@@ -22,6 +25,7 @@ import { ApiClient } from "./lib/api-client.js";
|
|
|
22
25
|
import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
|
|
23
26
|
import { output } from "./lib/output.js";
|
|
24
27
|
import { ishDir } from "./lib/paths.js";
|
|
28
|
+
import { findInstalledSkill } from "./lib/skill-content.js";
|
|
25
29
|
import pkg from "../package.json" with { type: "json" };
|
|
26
30
|
const { version } = pkg;
|
|
27
31
|
program
|
|
@@ -69,7 +73,7 @@ program.exitOverride((err) => {
|
|
|
69
73
|
program
|
|
70
74
|
.option("-t, --token <token>", "Auth token (or set ISH_TOKEN env var)")
|
|
71
75
|
.option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
|
|
72
|
-
.
|
|
76
|
+
.addOption(new Option("--api-url <url>", "Override backend API URL (internal — also via ISH_API_URL)").hideHelp())
|
|
73
77
|
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
74
78
|
.option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
|
|
75
79
|
.option("--json", "Output as JSON (auto-enabled when piped)")
|
|
@@ -185,11 +189,22 @@ program
|
|
|
185
189
|
catch { /* keep id+alias only */ }
|
|
186
190
|
}
|
|
187
191
|
}
|
|
192
|
+
// Best-effort skill detection: walks parent directories from cwd so an
|
|
193
|
+
// agent invoked from a subfolder still sees a project-level skill.
|
|
194
|
+
const skillHit = findInstalledSkill(process.cwd(), fs, path, os.homedir());
|
|
195
|
+
const skill = skillHit
|
|
196
|
+
? { installed: true, path: skillHit.root, target: skillHit.target.key }
|
|
197
|
+
: {
|
|
198
|
+
installed: false,
|
|
199
|
+
hint: "Run `ish init` in your project root to install the agent skill " +
|
|
200
|
+
"(.claude/skills/ish or .agents/skills/ish).",
|
|
201
|
+
};
|
|
188
202
|
const payload = {
|
|
189
203
|
user,
|
|
190
204
|
workspace,
|
|
191
205
|
study,
|
|
192
206
|
ask,
|
|
207
|
+
skill,
|
|
193
208
|
api_url: apiUrl,
|
|
194
209
|
home: ishDir(),
|
|
195
210
|
};
|
|
@@ -213,6 +228,7 @@ program
|
|
|
213
228
|
console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
|
|
214
229
|
console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
|
|
215
230
|
console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
|
|
231
|
+
console.log(`Skill: ${skillHit ? skillHit.root : "not installed (run `ish init` to install the agent skill)"}`);
|
|
216
232
|
console.log(`Home: ${ishDir()}`);
|
|
217
233
|
console.log(`API: ${apiUrl}`);
|
|
218
234
|
if (tokenError && !token)
|
package/dist/lib/docs.js
CHANGED
|
@@ -574,6 +574,49 @@ ish ask results a-6ec
|
|
|
574
574
|
ish ask results a-6ec --json | jq '.rounds[0].aggregates'
|
|
575
575
|
\`\`\`
|
|
576
576
|
|
|
577
|
+
## Status field
|
|
578
|
+
|
|
579
|
+
Asks carry a top-level \`status\`:
|
|
580
|
+
|
|
581
|
+
- \`draft\` — created but not dispatched yet. No credits charged. Created
|
|
582
|
+
by \`ish ask create --no-dispatch\`.
|
|
583
|
+
- \`running\` — dispatched; the round is executing or queued.
|
|
584
|
+
- \`completed\` — round 1 (or the most recent round) finished.
|
|
585
|
+
- \`cancelled\` — terminated explicitly.
|
|
586
|
+
|
|
587
|
+
Surfaces in \`ish ask list\` (table column) and \`ish ask get\` (header
|
|
588
|
+
metadata line and JSON \`status\` field). Lean JSON keeps the field
|
|
589
|
+
intact — no \`--verbose\` needed to see it.
|
|
590
|
+
|
|
591
|
+
## Stage-then-dispatch (draft asks)
|
|
592
|
+
|
|
593
|
+
When you want a human to review the audience and prompt **before** any
|
|
594
|
+
credits are spent, separate creation from dispatch:
|
|
595
|
+
|
|
596
|
+
\`\`\`
|
|
597
|
+
# 1. Stage — materializes testers, no worker enqueue, no bill yet
|
|
598
|
+
ish ask create --workspace w-6ec --name "tagline AB" \\
|
|
599
|
+
--prompt "Which sounds better?" \\
|
|
600
|
+
--variant text:"Short and punchy." \\
|
|
601
|
+
--variant text:"A longer, descriptive line." \\
|
|
602
|
+
--sample 30 --wants-pick \\
|
|
603
|
+
--no-dispatch
|
|
604
|
+
|
|
605
|
+
# Returns an ask with status="draft". Hand the alias back to the user.
|
|
606
|
+
|
|
607
|
+
# 2. Dispatch — flips DRAFT → RUNNING and enqueues the round (BILLABLE)
|
|
608
|
+
ish ask dispatch a-6ec --wait
|
|
609
|
+
\`\`\`
|
|
610
|
+
|
|
611
|
+
\`--no-dispatch\` requires audience flags (testers are still materialized
|
|
612
|
+
at create time — only the worker enqueue and billing are deferred). It
|
|
613
|
+
is incompatible with \`--wait\` since there is nothing to wait for.
|
|
614
|
+
|
|
615
|
+
\`ish ask dispatch\` is idempotent on the server: a non-DRAFT ask returns
|
|
616
|
+
HTTP 409 (\`already dispatched\`) which the CLI maps to a usage error, so
|
|
617
|
+
re-running the command is safe. The user who calls \`dispatch\` is the
|
|
618
|
+
billing principal — keep that in mind for shared workspaces.
|
|
619
|
+
|
|
577
620
|
## Reading the verdict
|
|
578
621
|
|
|
579
622
|
For \`--wants-pick\` / \`--wants-ratings\` rounds, \`ask results --json\`
|
|
@@ -2201,7 +2244,7 @@ export function getPage(slug) {
|
|
|
2201
2244
|
export const AGENT_HELP_FOOTER = "\nFor agents: run `ish docs overview` for the mental model, " +
|
|
2202
2245
|
"`ish docs list` for every concept page, " +
|
|
2203
2246
|
"or `ish docs search <query>` for keyword lookup. Every command supports `--json`.\n\n" +
|
|
2204
|
-
"Global flags (--json, --fields, --verbose, --quiet, --token
|
|
2247
|
+
"Global flags (--json, --fields, --verbose, --quiet, --token) apply to all subcommands. " +
|
|
2205
2248
|
"Run `ish --help` to see them.";
|
|
2206
2249
|
/**
|
|
2207
2250
|
* Substring search over title + description + body. Title hits score
|
package/dist/lib/output.js
CHANGED
|
@@ -1452,9 +1452,10 @@ export function formatAskList(asks, json) {
|
|
|
1452
1452
|
return;
|
|
1453
1453
|
}
|
|
1454
1454
|
const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
|
|
1455
|
-
printTable(["#", "NAME", "AUDIENCE", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1455
|
+
printTable(["#", "NAME", "STATUS", "AUDIENCE", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1456
1456
|
aliasMap.get(String(a.id)) || String(a.id || ""),
|
|
1457
1457
|
String(a.name || ""),
|
|
1458
|
+
String(a.status || "-"),
|
|
1458
1459
|
String(a.audience_count ?? "0"),
|
|
1459
1460
|
String(a.round_count ?? "0"),
|
|
1460
1461
|
formatDate(a.last_round_at),
|
|
@@ -1533,6 +1534,8 @@ export function formatAskDetail(ask, json) {
|
|
|
1533
1534
|
if (ask.description)
|
|
1534
1535
|
console.log(String(ask.description));
|
|
1535
1536
|
const meta = [];
|
|
1537
|
+
if (ask.status)
|
|
1538
|
+
meta.push(String(ask.status));
|
|
1536
1539
|
if (ask.is_archived)
|
|
1537
1540
|
meta.push("archived");
|
|
1538
1541
|
meta.push(formatDate(ask.created_at));
|
|
@@ -138,6 +138,11 @@ ish study create --modality chat --endpoint my-bot --assignment "Sign up:Try to
|
|
|
138
138
|
ish study run --sample 5 --country SE --wait
|
|
139
139
|
ish ask run --new --name "..." --prompt "..." --variant text:"A" --variant text:"B" --sample 30 --wants-pick --wait
|
|
140
140
|
|
|
141
|
+
# Stage an ask for human review, then dispatch (no credits charged on stage)
|
|
142
|
+
ish ask create --name "..." --prompt "..." --variant text:"A" --variant text:"B" \
|
|
143
|
+
--sample 30 --wants-pick --no-dispatch
|
|
144
|
+
ish ask dispatch a-6ec --wait
|
|
145
|
+
|
|
141
146
|
# Results
|
|
142
147
|
ish study results
|
|
143
148
|
ish ask results a-6ec --round 1
|
|
@@ -236,6 +241,15 @@ implies \`--quiet\` so the bare value is the only thing on stdout.
|
|
|
236
241
|
follow-up question to a completed round preserves prior comments,
|
|
237
242
|
picks, and ratings; only the new question is dispatched. Pass
|
|
238
243
|
\`--redispatch-all\` for the legacy reset-and-rerun behavior.
|
|
244
|
+
- **\`ask create --no-dispatch\` stages a draft, no bill yet.** Pair
|
|
245
|
+
with \`ish ask dispatch <id>\` to flip DRAFT → RUNNING and start
|
|
246
|
+
the round. Use this when the user wants to review the audience or
|
|
247
|
+
prompt before any credits are charged. Audience flags are still
|
|
248
|
+
required (testers materialize at create time); only the worker
|
|
249
|
+
enqueue and billing are deferred. Asks now carry a top-level
|
|
250
|
+
\`status\` (\`draft | running | completed | cancelled\`) visible in
|
|
251
|
+
\`ask list\` and \`ask get\`. \`dispatch\` is idempotent — a
|
|
252
|
+
non-DRAFT ask returns 409 mapped to a usage error.
|
|
239
253
|
- **\`ask results --json\` adds \`cross_round_summary\` for 2+ rounds.**
|
|
240
254
|
Top-level field with per-round picks/winner snapshots and
|
|
241
255
|
\`picks_delta\` (R1 → last). Don't diff two \`ask results\` calls by
|
|
@@ -608,7 +622,41 @@ you can branch on plan caps before \`study create\` returns
|
|
|
608
622
|
The full reference is at \`ish docs get-page guides/chat\`,
|
|
609
623
|
secrets are at \`ish docs get-page concepts/secret\`.
|
|
610
624
|
|
|
611
|
-
## 8.
|
|
625
|
+
## 8. Stage an ask for human review, then dispatch
|
|
626
|
+
|
|
627
|
+
Goal: prepare a billable A/B but let the user inspect and approve the
|
|
628
|
+
audience + prompt before any credits are spent. Two-step flow with a
|
|
629
|
+
DRAFT status in between.
|
|
630
|
+
|
|
631
|
+
\`\`\`bash
|
|
632
|
+
# 1. Stage. No worker enqueued, no bill. Audience flags are still
|
|
633
|
+
# required — testers materialize at create time.
|
|
634
|
+
ASK=$(ish ask create --name "tagline AB" \\
|
|
635
|
+
--prompt "Which sounds better?" \\
|
|
636
|
+
--variant text:"Short and punchy." \\
|
|
637
|
+
--variant text:"A longer, descriptive line." \\
|
|
638
|
+
--sample 30 --wants-pick \\
|
|
639
|
+
--no-dispatch \\
|
|
640
|
+
--get alias)
|
|
641
|
+
|
|
642
|
+
# Hand the alias back to the user. They can inspect it:
|
|
643
|
+
# ish ask get "$ASK" # status: draft
|
|
644
|
+
# ish ask get "$ASK" --json | jq '.testers | length'
|
|
645
|
+
|
|
646
|
+
# 2. Dispatch once approved (BILLABLE). Idempotent: a non-DRAFT ask
|
|
647
|
+
# returns 409 mapped to exit 2, so re-running is safe.
|
|
648
|
+
ish ask dispatch "$ASK" --wait
|
|
649
|
+
\`\`\`
|
|
650
|
+
|
|
651
|
+
The \`status\` field on the ask reflects lifecycle (\`draft\` → \`running\`
|
|
652
|
+
→ \`completed\`); \`is_archived\` is orthogonal. \`ish ask list\` shows
|
|
653
|
+
status as a column.
|
|
654
|
+
|
|
655
|
+
\`--no-dispatch\` is incompatible with \`--wait\` — there is nothing to
|
|
656
|
+
wait for. Pass \`--wait\` to \`ish ask dispatch\` instead if you want to
|
|
657
|
+
block until the round settles.
|
|
658
|
+
|
|
659
|
+
## 9. Display-vs-capture: a script that does both
|
|
612
660
|
|
|
613
661
|
Goal: drive an A/B in a script, capture aliases without \`jq\`, and
|
|
614
662
|
still show the human a readable result table at the end.
|
|
@@ -794,7 +842,6 @@ is expected. Full UUIDs always work too. See
|
|
|
794
842
|
| \`--verbose\` | Include UUIDs + timestamps in JSON |
|
|
795
843
|
| \`-q, --quiet\` | Suppress progress messages on stderr |
|
|
796
844
|
| \`-t, --token\` | Auth token (else ISH_TOKEN env, else \`ish login\` saved) |
|
|
797
|
-
| \`--api-url\` | Override backend (default https://api.ishlabs.io) |
|
|
798
845
|
|
|
799
846
|
See \`ish docs get-page reference/json-mode\` for the full display-vs-
|
|
800
847
|
capture-vs-chain decision rule.
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -245,6 +245,7 @@ export interface SimulationConfig {
|
|
|
245
245
|
}
|
|
246
246
|
export type AskVariantKind = "image" | "text" | "audio" | "video" | "document";
|
|
247
247
|
export declare const ASK_VARIANT_KINDS: AskVariantKind[];
|
|
248
|
+
export type AskStatus = "draft" | "running" | "completed" | "cancelled";
|
|
248
249
|
export type AskRoundStatus = "running" | "completed" | "errored";
|
|
249
250
|
export type AskResponseStatus = "pending" | "completed" | "errored";
|
|
250
251
|
export interface AskVariant {
|
|
@@ -293,6 +294,7 @@ export interface AskCreateInput {
|
|
|
293
294
|
language?: string;
|
|
294
295
|
tester_profile_ids: string[];
|
|
295
296
|
first_round: AskRoundInput;
|
|
297
|
+
dispatch?: boolean;
|
|
296
298
|
}
|
|
297
299
|
export interface AskUpdateInput {
|
|
298
300
|
name?: string;
|
|
@@ -355,6 +357,7 @@ export interface Ask {
|
|
|
355
357
|
name: string;
|
|
356
358
|
description?: string | null;
|
|
357
359
|
is_archived: boolean;
|
|
360
|
+
status?: AskStatus;
|
|
358
361
|
testers: AskAudienceTester[];
|
|
359
362
|
rounds: AskRound[];
|
|
360
363
|
created_at: string;
|
|
@@ -367,6 +370,7 @@ export interface AskListItem {
|
|
|
367
370
|
name: string;
|
|
368
371
|
description?: string | null;
|
|
369
372
|
is_archived: boolean;
|
|
373
|
+
status?: AskStatus;
|
|
370
374
|
audience_count: number;
|
|
371
375
|
round_count: number;
|
|
372
376
|
last_round_at?: string | null;
|