@ishlabs/cli 0.8.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -6
- package/dist/auth.d.ts +23 -4
- package/dist/auth.js +165 -39
- 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 +232 -13
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/source.js +24 -2
- 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 +311 -39
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +7 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +359 -24
- package/dist/index.js +67 -9
- 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 +11 -3
- 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 +188 -53
- package/dist/lib/docs.js +662 -34
- 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 +183 -13
- package/dist/lib/types.d.ts +15 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ Workspace (= product, top-level container)
|
|
|
88
88
|
```bash
|
|
89
89
|
ish login # browser auth
|
|
90
90
|
ish logout
|
|
91
|
-
ish connect <port> # Cloudflare tunnel exposing localhost
|
|
91
|
+
ish connect <port> # Cloudflare tunnel exposing localhost (--detach, ish disconnect, ish connect status)
|
|
92
92
|
ish upgrade # self-update (single-binary installs only)
|
|
93
93
|
ish upgrade --release 0.8.1 # pin a specific release
|
|
94
94
|
```
|
|
@@ -98,15 +98,21 @@ ish upgrade --release 0.8.1 # pin a specific release
|
|
|
98
98
|
### Workspaces, studies, iterations, profiles, configs (CRUD groups)
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
ish workspace list | create | get | update | delete | use
|
|
101
|
+
ish workspace list | create | get | update | delete | use | info
|
|
102
102
|
ish workspace site-access status | basic-auth | cookie | login | affirm-public | clear
|
|
103
103
|
ish study list | create | generate | get | results | update | delete | use
|
|
104
104
|
ish iteration list | create | get | update | delete
|
|
105
105
|
ish profile list | create | generate | get | update | delete
|
|
106
|
-
ish source upload | get
|
|
106
|
+
ish source upload | get | delete
|
|
107
107
|
ish config list | create | get | schema | update | delete
|
|
108
|
+
ish chat endpoint list | create | get | update | delete | use | init | test
|
|
109
|
+
ish secret list | set | delete
|
|
108
110
|
```
|
|
109
111
|
|
|
112
|
+
`ish workspace info` reports `studies_used / studies_max / testers_used / testers_max / tier` so an agent can branch on plan caps before a destructive call returns `error_code: usage_limit_reached`.
|
|
113
|
+
|
|
114
|
+
`ish chat endpoint` configures HTTP-bot endpoints for chat-modality studies (auto-detect from a curl example, smoke-test, edit). `ish secret` is the per-workspace KV store referenced from chatbot endpoint headers via `{{secret:KEY}}` placeholders. Run `ish docs get-page guides/chat` for the end-to-end recipe.
|
|
115
|
+
|
|
110
116
|
Testers live as a nested group on a study (low-level — usually created via `study run`):
|
|
111
117
|
|
|
112
118
|
```bash
|
|
@@ -347,16 +353,59 @@ ish profile generate --source tps-3a4 --propose-count
|
|
|
347
353
|
ish profile generate --source tps-3a4 --count 4
|
|
348
354
|
```
|
|
349
355
|
|
|
356
|
+
### Chat-modality studies — `ish chat`
|
|
357
|
+
|
|
358
|
+
Configure a customer chatbot endpoint and run chat-modality studies against it.
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# Author from a curl example (or hand-write the config)
|
|
362
|
+
ish chat endpoint init --from-curl ./bot.curl --name my-bot
|
|
363
|
+
ish chat endpoint create --endpoint-config ./bot-config.json --name "my-bot"
|
|
364
|
+
|
|
365
|
+
# CRUD on saved endpoints (every dialog edit reduces to one of these)
|
|
366
|
+
ish chat endpoint list
|
|
367
|
+
ish chat endpoint get ep-abc --verbose # round-trippable {id, name, isTunnelBacked, config}
|
|
368
|
+
ish chat endpoint update ep-abc --name "Production support bot"
|
|
369
|
+
ish chat endpoint update ep-abc --url https://api.example.com/v2/chat --mode stateless
|
|
370
|
+
ish chat endpoint get ep-abc --verbose | jq '.config.outgoing.headers["X-API-Key"] = "{{secret:KEY}}"' \
|
|
371
|
+
| ish chat endpoint update ep-abc --endpoint-config -
|
|
372
|
+
ish chat endpoint delete ep-abc
|
|
373
|
+
ish chat endpoint use ep-abc # set as the active chat endpoint
|
|
374
|
+
|
|
375
|
+
# Smoke test the connection (single turn; tunnel pre-flight when applicable)
|
|
376
|
+
ish chat endpoint test ep-abc -m "Hello"
|
|
377
|
+
ish chat endpoint test ep-abc -m "Tell me more" --conversation-id "$CID" # stateful threading
|
|
378
|
+
|
|
379
|
+
# Run a chat-modality study using the saved endpoint (existing study verbs).
|
|
380
|
+
# Audience size lives on study run via --sample / --all / --profile.
|
|
381
|
+
ish study create --modality chat --endpoint ep-abc --name "Sign-up Q1" --assignment "Sign up:Try to sign up"
|
|
382
|
+
ish study run --study stu-xyz --sample 5 --wait
|
|
383
|
+
ish study results stu-xyz --json | jq '.testers'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Local bots (`localhost` / `127.0.0.1` / `0.0.0.0`) auto-flag `is_tunnel_backed=true` on `init`; pair with `ish connect <port>` in another shell. Override with `--tunnel-backed` / `--no-tunnel-backed`.
|
|
387
|
+
|
|
388
|
+
`init` returns `confidence` (`high` / `medium` / `low`) and a `missingSignals: [...]` array naming any inputs the inference couldn't observe (e.g. `["response_shape", "message_path"]` when no response sample is provided). When confidence is `low`, verify with `chat endpoint test` before running a study.
|
|
389
|
+
|
|
390
|
+
Failures from `chat endpoint test` carry a structured `error_kind`: `TunnelInactive` (run `ish connect <port>` first), `BotUnreachable` (URL/port wrong or bot down), `BotResponseError` (non-2xx with a status code), `BotEnvelopeError` (200 OK with the bot's own error in the body — see `raw_excerpt`), `BotInvalidResponseError` (response doesn't match the parsing schema), `BotAuthError`, `BotTimeoutError`, `BotRetryExhaustedError`.
|
|
391
|
+
|
|
392
|
+
Full guide: `ish docs get-page guides/chat`.
|
|
393
|
+
|
|
350
394
|
### Expose localhost
|
|
351
395
|
|
|
352
|
-
For interactive studies that need to reach a service running on your machine:
|
|
396
|
+
For interactive studies (and chat endpoints with `is_tunnel_backed=true`) that need to reach a service running on your machine:
|
|
353
397
|
|
|
354
398
|
```bash
|
|
355
|
-
ish connect 3000
|
|
399
|
+
ish connect 3000 # foreground Cloudflare tunnel to :3000
|
|
400
|
+
ish connect 3000 --detach --json # fork after first heartbeat; prints {pid, tunnel_url, registered}
|
|
401
|
+
ish connect status --json # {active, pid, tunnel_url, registered_at} or {active:false}
|
|
402
|
+
ish disconnect --json # graceful shutdown of an active tunnel
|
|
356
403
|
ISH_TOKEN=YOUR_TOKEN ish connect 8080
|
|
357
404
|
```
|
|
358
405
|
|
|
359
|
-
`connect` is
|
|
406
|
+
Foreground `connect` is long-running — keep it open while testers run. The tunnel URL prints prominently after "Connected"; pass `--json` for one-line machine-readable output (`{"status":"connected","tunnel_url":"...","local_port":3000,"registered":true}`). The `--detach` form forks after the first successful heartbeat and returns immediately, tracking PID + URL in `~/.ish/connect.lock` so `connect status` and `disconnect` find it later.
|
|
407
|
+
|
|
408
|
+
Destructive verbs in `--json` mode (e.g. `chat endpoint delete`, `study delete`) require an explicit `--yes`; the rejection envelope carries `error_kind: "ConfirmationRequired"` and an `example` field with the same command + `--yes` appended, so an agent can recover without re-reading the help text.
|
|
360
409
|
|
|
361
410
|
## Global flags
|
|
362
411
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-based authentication via
|
|
3
|
-
*
|
|
2
|
+
* Browser-based authentication via Supabase OAuth Server (RFC 6749 / OAuth 2.1).
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. DCR (RFC 7591) — register a one-shot public OAuth client at the Supabase
|
|
6
|
+
* OAuth Server with the loopback redirect URI we just opened a port for.
|
|
7
|
+
* 2. PKCE (RFC 7636 S256) — generate verifier + challenge.
|
|
8
|
+
* 3. Loopback redirect (RFC 8252) — listen on 127.0.0.1:<random> and open the
|
|
9
|
+
* browser at the Supabase /oauth/authorize URL.
|
|
10
|
+
* 4. After the user signs in + consents at <ish-frontend>/oauth/consent,
|
|
11
|
+
* Supabase redirects back to our loopback with ?code=&state=.
|
|
12
|
+
* 5. Exchange code → tokens at /oauth/token, return access + refresh.
|
|
13
|
+
*
|
|
14
|
+
* Refresh tokens minted here flow back through /oauth/token (not the legacy
|
|
15
|
+
* /auth/v1/token endpoint), so refreshTokens() targets the same URL.
|
|
16
|
+
*
|
|
17
|
+
* Tokens land in ~/.ish/config.json with the same shape as before; the MCP
|
|
18
|
+
* server's `local` mode keeps reading them without changes.
|
|
4
19
|
*/
|
|
5
20
|
export declare function getAppUrl(): string;
|
|
6
21
|
export declare function getSupabaseUrl(): string;
|
|
@@ -17,9 +32,10 @@ export declare function resolveSupabaseProjectFromToken(accessToken: string | un
|
|
|
17
32
|
export declare function decodeJwtExp(token: string): number;
|
|
18
33
|
export declare function decodeJwtClaims(token: string): Record<string, unknown> | undefined;
|
|
19
34
|
export declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
|
|
20
|
-
export declare function login(
|
|
35
|
+
export declare function login(): Promise<{
|
|
21
36
|
accessToken: string;
|
|
22
37
|
refreshToken: string;
|
|
38
|
+
clientId: string;
|
|
23
39
|
}>;
|
|
24
40
|
/**
|
|
25
41
|
* Thrown by `refreshTokens` when the refresh attempt failed permanently and
|
|
@@ -36,9 +52,12 @@ export declare class AuthRefreshPermanentError extends Error {
|
|
|
36
52
|
readonly body: string;
|
|
37
53
|
constructor(httpStatus: number, body: string, errorCode: string | undefined);
|
|
38
54
|
}
|
|
39
|
-
export declare function refreshTokens(refreshToken: string, options
|
|
55
|
+
export declare function refreshTokens(refreshToken: string, options: {
|
|
40
56
|
/** The (possibly expired) access token. Used to pick the correct Supabase project. */
|
|
41
57
|
accessToken?: string;
|
|
58
|
+
/** OAuth client_id from DCR at login time. Supabase requires it on
|
|
59
|
+
* refresh because the issued client is public (no client secret). */
|
|
60
|
+
clientId: string;
|
|
42
61
|
/** Force a specific Supabase project URL (e.g. for tests). */
|
|
43
62
|
supabaseUrl?: string;
|
|
44
63
|
/** Force a specific anon/publishable key. */
|
package/dist/auth.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-based authentication via
|
|
3
|
-
*
|
|
2
|
+
* Browser-based authentication via Supabase OAuth Server (RFC 6749 / OAuth 2.1).
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. DCR (RFC 7591) — register a one-shot public OAuth client at the Supabase
|
|
6
|
+
* OAuth Server with the loopback redirect URI we just opened a port for.
|
|
7
|
+
* 2. PKCE (RFC 7636 S256) — generate verifier + challenge.
|
|
8
|
+
* 3. Loopback redirect (RFC 8252) — listen on 127.0.0.1:<random> and open the
|
|
9
|
+
* browser at the Supabase /oauth/authorize URL.
|
|
10
|
+
* 4. After the user signs in + consents at <ish-frontend>/oauth/consent,
|
|
11
|
+
* Supabase redirects back to our loopback with ?code=&state=.
|
|
12
|
+
* 5. Exchange code → tokens at /oauth/token, return access + refresh.
|
|
13
|
+
*
|
|
14
|
+
* Refresh tokens minted here flow back through /oauth/token (not the legacy
|
|
15
|
+
* /auth/v1/token endpoint), so refreshTokens() targets the same URL.
|
|
16
|
+
*
|
|
17
|
+
* Tokens land in ~/.ish/config.json with the same shape as before; the MCP
|
|
18
|
+
* server's `local` mode keeps reading them without changes.
|
|
4
19
|
*/
|
|
20
|
+
import * as http from "node:http";
|
|
5
21
|
import * as crypto from "node:crypto";
|
|
6
22
|
import { execFile } from "node:child_process";
|
|
7
|
-
const
|
|
8
|
-
const
|
|
23
|
+
const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
24
|
+
const CLIENT_NAME = "ish CLI";
|
|
9
25
|
const DEFAULT_APP_URL = "https://app.ishlabs.io";
|
|
10
26
|
// Known Supabase projects, keyed by hostname. The CLI may be talking to either
|
|
11
27
|
// production or development depending on how the user logged in — the access
|
|
@@ -13,12 +29,12 @@ const DEFAULT_APP_URL = "https://app.ishlabs.io";
|
|
|
13
29
|
// must send refresh requests back to that same project (with its matching
|
|
14
30
|
// publishable/anon key) or Supabase will reject them.
|
|
15
31
|
const SUPABASE_PROJECTS = {
|
|
16
|
-
//
|
|
32
|
+
// Development (default — local dev work hits this project)
|
|
17
33
|
"muqvgnqyubmqnfnqwxuk.supabase.co": {
|
|
18
34
|
url: "https://muqvgnqyubmqnfnqwxuk.supabase.co",
|
|
19
35
|
anonKey: "sb_publishable_pxXwY9EaWFwkR7h728NWvQ_NFqGfh8K",
|
|
20
36
|
},
|
|
21
|
-
//
|
|
37
|
+
// Production
|
|
22
38
|
"hngymyxdyamokpbeakps.supabase.co": {
|
|
23
39
|
url: "https://hngymyxdyamokpbeakps.supabase.co",
|
|
24
40
|
anonKey: "sb_publishable_JlS-HfwNyDqLNbrfbrkUlw_PSdZJdo2",
|
|
@@ -108,35 +124,141 @@ export function isTokenExpired(token, bufferSeconds = 300) {
|
|
|
108
124
|
return true;
|
|
109
125
|
return Date.now() / 1000 >= exp - bufferSeconds;
|
|
110
126
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const resp = await fetch(`${url}/api/plugin/auth/poll?state=${state}`, {
|
|
125
|
-
signal: AbortSignal.timeout(10_000),
|
|
126
|
-
});
|
|
127
|
-
if (resp.status === 200) {
|
|
128
|
-
const data = await resp.json();
|
|
129
|
-
if (data.status === "complete" && data.access_token && data.refresh_token) {
|
|
130
|
-
return { accessToken: data.access_token, refreshToken: data.refresh_token };
|
|
131
|
-
}
|
|
127
|
+
function startCallbackServer() {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
let resolveCallback = null;
|
|
130
|
+
const callbackPromise = new Promise((res) => {
|
|
131
|
+
resolveCallback = res;
|
|
132
|
+
});
|
|
133
|
+
const server = http.createServer((req, res) => {
|
|
134
|
+
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
135
|
+
if (reqUrl.pathname !== "/callback") {
|
|
136
|
+
res.writeHead(404);
|
|
137
|
+
res.end();
|
|
138
|
+
return;
|
|
132
139
|
}
|
|
133
|
-
|
|
140
|
+
const cb = {
|
|
141
|
+
code: reqUrl.searchParams.get("code") ?? undefined,
|
|
142
|
+
state: reqUrl.searchParams.get("state") ?? undefined,
|
|
143
|
+
error: reqUrl.searchParams.get("error") ?? undefined,
|
|
144
|
+
errorDescription: reqUrl.searchParams.get("error_description") ?? undefined,
|
|
145
|
+
};
|
|
146
|
+
const headline = cb.error ? "Sign-in failed" : "Signed in to Ish";
|
|
147
|
+
const subline = cb.error
|
|
148
|
+
? `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`
|
|
149
|
+
: "You can close this window and return to your terminal.";
|
|
150
|
+
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
|
+
res.writeHead(cb.error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
|
|
152
|
+
res.end(html);
|
|
153
|
+
if (resolveCallback)
|
|
154
|
+
resolveCallback(cb);
|
|
155
|
+
});
|
|
156
|
+
server.on("error", reject);
|
|
157
|
+
server.listen(0, "127.0.0.1", () => {
|
|
158
|
+
const addr = server.address();
|
|
159
|
+
if (!addr || typeof addr === "string") {
|
|
160
|
+
reject(new Error("Failed to start callback server"));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
resolve({
|
|
164
|
+
port: addr.port,
|
|
165
|
+
waitForCallback: () => callbackPromise,
|
|
166
|
+
close: () => {
|
|
167
|
+
server.close();
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function registerOAuthClient(supabaseUrl, redirectUri) {
|
|
174
|
+
const resp = await fetch(`${supabaseUrl}/auth/v1/oauth/clients/register`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "Content-Type": "application/json" },
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
client_name: CLIENT_NAME,
|
|
179
|
+
redirect_uris: [redirectUri],
|
|
180
|
+
application_type: "native",
|
|
181
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
182
|
+
response_types: ["code"],
|
|
183
|
+
token_endpoint_auth_method: "none",
|
|
184
|
+
}),
|
|
185
|
+
signal: AbortSignal.timeout(15_000),
|
|
186
|
+
});
|
|
187
|
+
if (!resp.ok) {
|
|
188
|
+
throw new Error(`Dynamic client registration failed (HTTP ${resp.status}): ${await resp.text().catch(() => "")}`);
|
|
189
|
+
}
|
|
190
|
+
const data = await resp.json();
|
|
191
|
+
if (typeof data.client_id !== "string") {
|
|
192
|
+
throw new Error("Dynamic client registration response missing client_id");
|
|
193
|
+
}
|
|
194
|
+
return data.client_id;
|
|
195
|
+
}
|
|
196
|
+
function generatePkcePair() {
|
|
197
|
+
const verifier = crypto.randomBytes(64).toString("base64url");
|
|
198
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
199
|
+
return { verifier, challenge };
|
|
200
|
+
}
|
|
201
|
+
export async function login() {
|
|
202
|
+
const supabaseUrl = getSupabaseUrl();
|
|
203
|
+
const server = await startCallbackServer();
|
|
204
|
+
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
205
|
+
try {
|
|
206
|
+
const clientId = await registerOAuthClient(supabaseUrl, redirectUri);
|
|
207
|
+
const { verifier, challenge } = generatePkcePair();
|
|
208
|
+
const state = crypto.randomBytes(24).toString("base64url");
|
|
209
|
+
const authorizeUrl = new URL(`${supabaseUrl}/auth/v1/oauth/authorize`);
|
|
210
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
211
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
212
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
213
|
+
authorizeUrl.searchParams.set("state", state);
|
|
214
|
+
authorizeUrl.searchParams.set("scope", "openid email profile");
|
|
215
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
216
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
217
|
+
console.error("Opening browser to sign in...");
|
|
218
|
+
console.error(`If the browser doesn't open, visit:\n ${authorizeUrl.toString()}\n`);
|
|
219
|
+
openBrowser(authorizeUrl.toString());
|
|
220
|
+
console.error("Waiting for authentication...");
|
|
221
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
222
|
+
setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
|
|
223
|
+
});
|
|
224
|
+
const cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
|
|
225
|
+
if (cb.error) {
|
|
226
|
+
throw new Error(`OAuth error: ${cb.error}${cb.errorDescription ? ` — ${cb.errorDescription}` : ""}`);
|
|
134
227
|
}
|
|
135
|
-
|
|
136
|
-
|
|
228
|
+
if (cb.state !== state) {
|
|
229
|
+
throw new Error("OAuth state mismatch — possible CSRF attempt. Please try again.");
|
|
230
|
+
}
|
|
231
|
+
if (!cb.code) {
|
|
232
|
+
throw new Error("No authorization code received from Supabase.");
|
|
137
233
|
}
|
|
234
|
+
const tokenResp = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
237
|
+
body: new URLSearchParams({
|
|
238
|
+
grant_type: "authorization_code",
|
|
239
|
+
code: cb.code,
|
|
240
|
+
redirect_uri: redirectUri,
|
|
241
|
+
client_id: clientId,
|
|
242
|
+
code_verifier: verifier,
|
|
243
|
+
}).toString(),
|
|
244
|
+
signal: AbortSignal.timeout(15_000),
|
|
245
|
+
});
|
|
246
|
+
if (!tokenResp.ok) {
|
|
247
|
+
throw new Error(`Token exchange failed (HTTP ${tokenResp.status}): ${await tokenResp.text().catch(() => "")}`);
|
|
248
|
+
}
|
|
249
|
+
const tokenData = await tokenResp.json();
|
|
250
|
+
if (typeof tokenData.access_token !== "string" || typeof tokenData.refresh_token !== "string") {
|
|
251
|
+
throw new Error("Token exchange response missing access_token or refresh_token");
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
accessToken: tokenData.access_token,
|
|
255
|
+
refreshToken: tokenData.refresh_token,
|
|
256
|
+
clientId,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
server.close();
|
|
138
261
|
}
|
|
139
|
-
throw new Error("Login timed out. Please try again.");
|
|
140
262
|
}
|
|
141
263
|
// --- Token refresh ---
|
|
142
264
|
/**
|
|
@@ -177,16 +299,20 @@ function parseRefreshErrorCode(body) {
|
|
|
177
299
|
return undefined;
|
|
178
300
|
}
|
|
179
301
|
export async function refreshTokens(refreshToken, options) {
|
|
180
|
-
const project = options
|
|
302
|
+
const project = options.supabaseUrl && options.anonKey
|
|
181
303
|
? { url: options.supabaseUrl, anonKey: options.anonKey }
|
|
182
|
-
: resolveSupabaseProjectFromToken(options
|
|
183
|
-
|
|
304
|
+
: resolveSupabaseProjectFromToken(options.accessToken);
|
|
305
|
+
// OAuth-server-minted tokens refresh through /oauth/token. The legacy
|
|
306
|
+
// /auth/v1/token endpoint is for password/magic-link flows and won't
|
|
307
|
+
// accept refresh tokens issued under the OAuth grant.
|
|
308
|
+
const resp = await fetch(`${project.url}/auth/v1/oauth/token`, {
|
|
184
309
|
method: "POST",
|
|
185
|
-
headers: {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
310
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
311
|
+
body: new URLSearchParams({
|
|
312
|
+
grant_type: "refresh_token",
|
|
313
|
+
refresh_token: refreshToken,
|
|
314
|
+
client_id: options.clientId,
|
|
315
|
+
}).toString(),
|
|
190
316
|
signal: AbortSignal.timeout(10_000),
|
|
191
317
|
});
|
|
192
318
|
if (!resp.ok) {
|
package/dist/commands/ask.d.ts
CHANGED
|
@@ -2,4 +2,16 @@
|
|
|
2
2
|
* ish ask — Create and run asks (multi-round surveys with variants).
|
|
3
3
|
*/
|
|
4
4
|
import type { Command } from "commander";
|
|
5
|
+
import type { AudienceSubset } from "../lib/types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
|
|
8
|
+
* an `AudienceSubset` payload (Pattern B). Both flags must be passed
|
|
9
|
+
* together or neither — half a subset is a misconfiguration the agent
|
|
10
|
+
* should fix before dispatch, not a silent fallthrough to the full
|
|
11
|
+
* audience.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when neither flag is set; throws when only one is
|
|
14
|
+
* set or when `--subset-round` isn't a positive integer.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseAudienceSubset(subsetRound: string | undefined, subsetVariant: string | undefined): AudienceSubset | undefined;
|
|
5
17
|
export declare function registerAskCommands(program: Command): void;
|
package/dist/commands/ask.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
7
7
|
import { formatAskList, formatAskDetail, formatRoundDetail, formatAskResults, output, } from "../lib/output.js";
|
|
8
8
|
import { parseVariantInputs, uploadAndBuildVariants, } from "../lib/ask-variants.js";
|
|
9
9
|
import { loadQuestionsManifest } from "../lib/ask-questions.js";
|
|
10
|
+
import { ApiError } from "../lib/api-client.js";
|
|
10
11
|
const POLL_INTERVAL_MS = 5_000;
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Helpers
|
|
@@ -108,6 +109,32 @@ async function buildRoundInput(client, productId, opts, quiet) {
|
|
|
108
109
|
round.questions = questions;
|
|
109
110
|
return round;
|
|
110
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
|
|
114
|
+
* an `AudienceSubset` payload (Pattern B). Both flags must be passed
|
|
115
|
+
* together or neither — half a subset is a misconfiguration the agent
|
|
116
|
+
* should fix before dispatch, not a silent fallthrough to the full
|
|
117
|
+
* audience.
|
|
118
|
+
*
|
|
119
|
+
* Returns `undefined` when neither flag is set; throws when only one is
|
|
120
|
+
* set or when `--subset-round` isn't a positive integer.
|
|
121
|
+
*/
|
|
122
|
+
export function parseAudienceSubset(subsetRound, subsetVariant) {
|
|
123
|
+
if (subsetRound === undefined && subsetVariant === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (subsetRound === undefined || subsetVariant === undefined) {
|
|
126
|
+
throw new Error("--subset-round and --subset-variant must be passed together (or both omitted).");
|
|
127
|
+
}
|
|
128
|
+
const round = Number.parseInt(subsetRound, 10);
|
|
129
|
+
if (!Number.isFinite(round) || round < 1 || !/^\d+$/.test(subsetRound)) {
|
|
130
|
+
throw new Error(`--subset-round must be a positive integer (got "${subsetRound}").`);
|
|
131
|
+
}
|
|
132
|
+
const trimmedVariant = subsetVariant.trim();
|
|
133
|
+
if (trimmedVariant.length === 0) {
|
|
134
|
+
throw new Error("--subset-variant must be a variant UUID (got empty string).");
|
|
135
|
+
}
|
|
136
|
+
return { round, picked_variant_id: trimmedVariant };
|
|
137
|
+
}
|
|
111
138
|
// ---------------------------------------------------------------------------
|
|
112
139
|
// Command registration
|
|
113
140
|
// ---------------------------------------------------------------------------
|
|
@@ -145,6 +172,8 @@ Concept pages: ish docs get-page concepts/ask
|
|
|
145
172
|
allFlagName: "--all-simulatable",
|
|
146
173
|
allFlagDescription: "Use every simulatable AI profile matching the filters (with --new only)",
|
|
147
174
|
})
|
|
175
|
+
.option("--subset-round <n>", "Drill-in subset (Pattern B) — append-round only. 1-indexed prior round to filter against. Pair with --subset-variant.")
|
|
176
|
+
.option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — append-round only. Variant id (UUID) on the prior round whose pickers should inherit. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round's `ask results --json`.")
|
|
148
177
|
.option("--wait", "Wait until the round completes (or errors)")
|
|
149
178
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
150
179
|
.addHelpText("after", `
|
|
@@ -169,6 +198,9 @@ Examples:
|
|
|
169
198
|
if (pickedId) {
|
|
170
199
|
throw new Error("Cannot pass an ask id together with --new. Drop the id, or drop --new to append a round.");
|
|
171
200
|
}
|
|
201
|
+
if (opts.subsetRound !== undefined || opts.subsetVariant !== undefined) {
|
|
202
|
+
throw new Error("--subset-round / --subset-variant are only valid when appending to an existing ask. Drop --new or drop the subset flags.");
|
|
203
|
+
}
|
|
172
204
|
const wid = resolveWorkspace(opts.workspace);
|
|
173
205
|
const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
|
|
174
206
|
const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
|
|
@@ -180,7 +212,28 @@ Examples:
|
|
|
180
212
|
tester_profile_ids: testerIds,
|
|
181
213
|
first_round: round,
|
|
182
214
|
};
|
|
183
|
-
|
|
215
|
+
// M5 / Pattern G: `ask run --new` POSTs to a non-idempotent
|
|
216
|
+
// create endpoint. If the backend errors after the row is
|
|
217
|
+
// committed (a 500 mid-pipeline, a network timeout after the
|
|
218
|
+
// POST landed), an automatic retry would create a duplicate
|
|
219
|
+
// ask. Override `retryable` to false on any failure here so
|
|
220
|
+
// agents don't auto-retry. The error envelope also reminds
|
|
221
|
+
// the agent to inspect `ish ask list --workspace <id>` before
|
|
222
|
+
// re-running, since the resource may already exist.
|
|
223
|
+
let data;
|
|
224
|
+
try {
|
|
225
|
+
data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (err instanceof ApiError) {
|
|
229
|
+
err.retryable = false;
|
|
230
|
+
const tagged = err;
|
|
231
|
+
tagged.suggestions = [
|
|
232
|
+
`\`ish ask list --workspace ${wid}\` to check whether the ask was created server-side before retrying — \`ask run --new\` is non-idempotent and will duplicate on retry.`,
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
184
237
|
if (data.id) {
|
|
185
238
|
const config = loadConfig();
|
|
186
239
|
config.ask = data.id;
|
|
@@ -223,6 +276,9 @@ Examples:
|
|
|
223
276
|
}
|
|
224
277
|
const ask = await client.get(`/asks/${aid}`);
|
|
225
278
|
const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
|
|
279
|
+
const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
|
|
280
|
+
if (subset)
|
|
281
|
+
round.audience_subset = subset;
|
|
226
282
|
const created = await client.post(`/asks/${aid}/rounds`, round);
|
|
227
283
|
if (opts.wait) {
|
|
228
284
|
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
@@ -519,14 +575,32 @@ the model's self-reported confidence in its variant choice. See
|
|
|
519
575
|
.option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
|
|
520
576
|
.option("--wants-ratings", "Each tester rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, testers leave a free-form comment only.")
|
|
521
577
|
.option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
|
|
578
|
+
.option("--subset-round <n>", "Drill-in subset (Pattern B) — 1-indexed prior round to filter against. Pair with --subset-variant. The new round dispatches only to testers who picked --subset-variant on round N.")
|
|
579
|
+
.option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — variant id (UUID) on the prior round whose pickers should inherit. Pair with --subset-round. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round.")
|
|
522
580
|
.option("--wait", "Wait until the new round completes")
|
|
523
581
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
524
|
-
.addHelpText("after",
|
|
582
|
+
.addHelpText("after", `
|
|
583
|
+
Examples:
|
|
584
|
+
# Append round 2 to the same audience.
|
|
585
|
+
$ ish ask add-round a-6ec --prompt "And now?" --variant text:"Hello" --variant text:"Hi" --wait
|
|
586
|
+
|
|
587
|
+
# Drill round 2 into the round-1-A-pickers (Pattern B).
|
|
588
|
+
$ ish ask add-round a-6ec \\
|
|
589
|
+
--prompt "What would make you actually click?" \\
|
|
590
|
+
--subset-round 1 --subset-variant 5f3a... \\
|
|
591
|
+
--wait
|
|
592
|
+
|
|
593
|
+
If --subset-round / --subset-variant fails to resolve (round missing, variant
|
|
594
|
+
not on that round, or zero pickers), the backend returns a 422 with
|
|
595
|
+
error_kind: "audience_subset_invalid".`)
|
|
525
596
|
.action(async (id, opts, cmd) => {
|
|
526
597
|
await withClient(cmd, async (client, globals) => {
|
|
527
598
|
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
528
599
|
const ask = await client.get(`/asks/${aid}`);
|
|
529
600
|
const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
|
|
601
|
+
const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
|
|
602
|
+
if (subset)
|
|
603
|
+
round.audience_subset = subset;
|
|
530
604
|
const created = await client.post(`/asks/${aid}/rounds`, round);
|
|
531
605
|
if (opts.wait) {
|
|
532
606
|
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
@@ -607,6 +681,57 @@ text, slider, likert, single-choice, multiple-choice, number.`)
|
|
|
607
681
|
}, globals.json);
|
|
608
682
|
});
|
|
609
683
|
});
|
|
684
|
+
// ---- retry --------------------------------------------------------------
|
|
685
|
+
ask
|
|
686
|
+
.command("retry")
|
|
687
|
+
.description("Re-dispatch only the errored responses on a round (idempotent: zero-errored is a no-op).")
|
|
688
|
+
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
689
|
+
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
690
|
+
.requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
|
|
691
|
+
.option("--wait", "Wait until the retried round completes (or errors)")
|
|
692
|
+
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
693
|
+
.addHelpText("after", `
|
|
694
|
+
Examples:
|
|
695
|
+
# Retry the errored 4 of 5 testers on round 1.
|
|
696
|
+
$ ish ask retry a-d3e --round 1
|
|
697
|
+
|
|
698
|
+
# Retry and wait for the round to settle.
|
|
699
|
+
$ ish ask retry a-d3e --round 1 --wait
|
|
700
|
+
|
|
701
|
+
Notes:
|
|
702
|
+
- COMPLETED responses are left untouched. Only ERRORED rows are reset to PENDING and re-run from scratch.
|
|
703
|
+
- The round flips back to RUNNING for the duration of the retry; the prior round summary is dropped and rebuilt once the retry settles.
|
|
704
|
+
- On a round with no errored responses, the verb is a no-op and returns the round unchanged.`)
|
|
705
|
+
.action(async (id, opts, cmd) => {
|
|
706
|
+
await withClient(cmd, async (client, globals) => {
|
|
707
|
+
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
708
|
+
const ask = await client.get(`/asks/${aid}`);
|
|
709
|
+
const round = getRoundByIndexOrId(ask, opts.round);
|
|
710
|
+
const updated = await client.post(`/asks/${aid}/rounds/${round.id}/retry`, {});
|
|
711
|
+
if (opts.wait) {
|
|
712
|
+
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
713
|
+
await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
|
|
714
|
+
const refreshed = await client.get(`/asks/${aid}`);
|
|
715
|
+
const target = refreshed.rounds.find((r) => r.id === updated.id);
|
|
716
|
+
if (target) {
|
|
717
|
+
formatRoundDetail(target, globals.json);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (!globals.json || globals.verbose) {
|
|
722
|
+
formatRoundDetail(updated, globals.json);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
output({
|
|
726
|
+
id: aid,
|
|
727
|
+
alias: tagAlias(ALIAS_PREFIX.ask, aid),
|
|
728
|
+
round: {
|
|
729
|
+
round_number: updated.order_index + 1,
|
|
730
|
+
status: updated.status,
|
|
731
|
+
},
|
|
732
|
+
}, globals.json);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
610
735
|
// ---- add-testers --------------------------------------------------------
|
|
611
736
|
const askAddTesters = ask
|
|
612
737
|
.command("add-testers")
|
|
@@ -0,0 +1,17 @@
|
|
|
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 type { Command } from "commander";
|
|
15
|
+
import { envelopeFromRow } from "../lib/chat-endpoint-formatters.js";
|
|
16
|
+
export declare function registerChatCommand(program: Command): void;
|
|
17
|
+
export { envelopeFromRow };
|