@ishlabs/cli 0.23.1 → 0.24.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/commands/ask.js +4 -4
- package/dist/commands/iteration.js +25 -3
- package/dist/commands/study-run.js +3 -3
- package/dist/commands/study-share.d.ts +18 -0
- package/dist/commands/study-share.js +117 -0
- package/dist/commands/study.js +54 -7
- package/dist/commands/workspace.js +4 -1
- package/dist/connect.d.ts +4 -2
- package/dist/connect.js +151 -11
- package/dist/index.js +63 -6
- package/dist/lib/ask-questions.d.ts +15 -5
- package/dist/lib/ask-questions.js +34 -11
- package/dist/lib/auth.d.ts +1 -0
- package/dist/lib/auth.js +7 -1
- package/dist/lib/billing.d.ts +30 -16
- package/dist/lib/billing.js +77 -27
- package/dist/lib/command-helpers.js +33 -5
- package/dist/lib/docs.js +140 -8
- package/dist/lib/modality.d.ts +10 -1
- package/dist/lib/modality.js +21 -0
- package/dist/lib/output.js +8 -1
- package/dist/lib/reverse-proxy.d.ts +19 -0
- package/dist/lib/reverse-proxy.js +87 -0
- package/dist/lib/reverse-proxy.test.d.ts +10 -0
- package/dist/lib/reverse-proxy.test.js +149 -0
- package/dist/lib/segmentation.d.ts +31 -0
- package/dist/lib/segmentation.js +105 -0
- package/dist/lib/skill-content.js +76 -4
- package/dist/lib/types.d.ts +2 -0
- package/package.json +3 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the reverse-proxy module. Spins up two mock HTTP servers,
|
|
3
|
+
* routes through the proxy, and asserts paths land on the right upstream
|
|
4
|
+
* with the full path preserved. Also verifies a raw WebSocket upgrade
|
|
5
|
+
* routes via the prefix rules.
|
|
6
|
+
*
|
|
7
|
+
* Compiled to dist/lib/reverse-proxy.test.js and runnable with:
|
|
8
|
+
* node --test dist/lib/reverse-proxy.test.js
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { startReverseProxy } from "./reverse-proxy.js";
|
|
14
|
+
function startMockServer(name) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const hits = [];
|
|
17
|
+
const sockets = new Set();
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
hits.push({ url: req.url ?? "", upgrade: false });
|
|
20
|
+
res.writeHead(200, { "Content-Type": "text/plain", "X-Mock-Name": name });
|
|
21
|
+
res.end(`${name}:${req.url}`);
|
|
22
|
+
});
|
|
23
|
+
server.on("connection", (socket) => {
|
|
24
|
+
sockets.add(socket);
|
|
25
|
+
socket.on("close", () => sockets.delete(socket));
|
|
26
|
+
});
|
|
27
|
+
server.on("upgrade", (req, socket) => {
|
|
28
|
+
hits.push({ url: req.url ?? "", upgrade: true });
|
|
29
|
+
sockets.add(socket);
|
|
30
|
+
socket.on("close", () => sockets.delete(socket));
|
|
31
|
+
// Minimal handshake: accept the upgrade with a static accept token so we
|
|
32
|
+
// don't pull in the `ws` library just for the test.
|
|
33
|
+
const acceptKey = req.headers["sec-websocket-key"];
|
|
34
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
35
|
+
"Upgrade: websocket\r\n" +
|
|
36
|
+
"Connection: Upgrade\r\n" +
|
|
37
|
+
`Sec-WebSocket-Accept: ${acceptKey ?? "x"}\r\n` +
|
|
38
|
+
`X-Mock-Name: ${name}\r\n\r\n`);
|
|
39
|
+
});
|
|
40
|
+
server.on("error", reject);
|
|
41
|
+
server.listen(0, "127.0.0.1", () => {
|
|
42
|
+
const addr = server.address();
|
|
43
|
+
resolve({
|
|
44
|
+
port: addr.port,
|
|
45
|
+
hits,
|
|
46
|
+
close: () => new Promise((r) => {
|
|
47
|
+
server.closeAllConnections?.();
|
|
48
|
+
for (const s of sockets)
|
|
49
|
+
s.destroy();
|
|
50
|
+
sockets.clear();
|
|
51
|
+
server.close(() => r());
|
|
52
|
+
server.unref();
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
test("reverse-proxy routes by prefix and preserves the full path", async () => {
|
|
59
|
+
const primary = await startMockServer("primary");
|
|
60
|
+
const api = await startMockServer("api");
|
|
61
|
+
const proxy = await startReverseProxy({
|
|
62
|
+
primaryPort: primary.port,
|
|
63
|
+
routes: [{ prefix: "/api", target: `http://127.0.0.1:${api.port}` }],
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const root = await fetch(`http://127.0.0.1:${proxy.port}/`);
|
|
67
|
+
assert.equal(root.status, 200);
|
|
68
|
+
assert.equal(root.headers.get("x-mock-name"), "primary");
|
|
69
|
+
assert.equal(await root.text(), "primary:/");
|
|
70
|
+
assert.equal(primary.hits.at(-1)?.url, "/");
|
|
71
|
+
const apiHit = await fetch(`http://127.0.0.1:${proxy.port}/api/health`);
|
|
72
|
+
assert.equal(apiHit.status, 200);
|
|
73
|
+
assert.equal(apiHit.headers.get("x-mock-name"), "api");
|
|
74
|
+
// Full path preserved — the upstream sees `/api/health`, NOT `/health`.
|
|
75
|
+
assert.equal(await apiHit.text(), "api:/api/health");
|
|
76
|
+
assert.equal(api.hits.at(-1)?.url, "/api/health");
|
|
77
|
+
// Non-matching path that just happens to start with the prefix letters
|
|
78
|
+
// must fall through to primary (segment-boundary match, not substring).
|
|
79
|
+
const apiary = await fetch(`http://127.0.0.1:${proxy.port}/apiary`);
|
|
80
|
+
assert.equal(apiary.headers.get("x-mock-name"), "primary");
|
|
81
|
+
const deep = await fetch(`http://127.0.0.1:${proxy.port}/api/v1/users`);
|
|
82
|
+
assert.equal(deep.headers.get("x-mock-name"), "api");
|
|
83
|
+
assert.equal(await deep.text(), "api:/api/v1/users");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await proxy.close();
|
|
87
|
+
await primary.close();
|
|
88
|
+
await api.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test("reverse-proxy routes WebSocket upgrades by prefix", async () => {
|
|
92
|
+
const primary = await startMockServer("primary");
|
|
93
|
+
const api = await startMockServer("api");
|
|
94
|
+
const proxy = await startReverseProxy({
|
|
95
|
+
primaryPort: primary.port,
|
|
96
|
+
routes: [{ prefix: "/api", target: `http://127.0.0.1:${api.port}` }],
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const status = await new Promise((resolve, reject) => {
|
|
100
|
+
const req = http.request({
|
|
101
|
+
host: "127.0.0.1",
|
|
102
|
+
port: proxy.port,
|
|
103
|
+
path: "/api/ws",
|
|
104
|
+
method: "GET",
|
|
105
|
+
headers: {
|
|
106
|
+
Connection: "Upgrade",
|
|
107
|
+
Upgrade: "websocket",
|
|
108
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
109
|
+
"Sec-WebSocket-Version": "13",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
req.on("upgrade", (res, socket) => {
|
|
113
|
+
resolve({
|
|
114
|
+
statusLine: `HTTP/1.1 ${res.statusCode} ${res.statusMessage}`,
|
|
115
|
+
mockName: typeof res.headers["x-mock-name"] === "string"
|
|
116
|
+
? res.headers["x-mock-name"]
|
|
117
|
+
: undefined,
|
|
118
|
+
});
|
|
119
|
+
socket.destroy();
|
|
120
|
+
});
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
req.end();
|
|
123
|
+
});
|
|
124
|
+
assert.match(status.statusLine, /^HTTP\/1\.1 101/);
|
|
125
|
+
assert.equal(status.mockName, "api");
|
|
126
|
+
assert.ok(api.hits.some((h) => h.upgrade && h.url === "/api/ws"));
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await proxy.close();
|
|
130
|
+
await primary.close();
|
|
131
|
+
await api.close();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("reverse-proxy returns 502 when upstream is down", async () => {
|
|
135
|
+
// No primary mock — pick an arbitrary port nothing is bound on.
|
|
136
|
+
const proxy = await startReverseProxy({
|
|
137
|
+
primaryPort: 1, // privileged, definitely not listening to our process
|
|
138
|
+
routes: [],
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`http://127.0.0.1:${proxy.port}/whatever`);
|
|
142
|
+
assert.equal(res.status, 502);
|
|
143
|
+
const body = await res.text();
|
|
144
|
+
assert.match(body, /Bad gateway/i);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await proxy.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation + nudge for media/text `segmentation` (the parsed value of
|
|
3
|
+
* `--segmentation-json` on `study create` / `iteration create`).
|
|
4
|
+
*
|
|
5
|
+
* THE PRINCIPLE these guard: **segments are semantic sections, not
|
|
6
|
+
* paragraphs.** Group related paragraphs into a few coherent sections
|
|
7
|
+
* (intro → argument → conclusion). A long article is usually 3–6 sections,
|
|
8
|
+
* not one per paragraph; `paragraph_start`/`paragraph_end` only mark where a
|
|
9
|
+
* section begins and ends — the unit is the *section*.
|
|
10
|
+
*
|
|
11
|
+
* - `validateSegmentation` is FATAL (throws ValidationError → exit 2) on a
|
|
12
|
+
* malformed `section_based` shape — most importantly a missing/empty label,
|
|
13
|
+
* which the backend would otherwise reject after a network round-trip.
|
|
14
|
+
* - `warnIfOverSegmented` is NON-FATAL: an agent that ignores the docs and
|
|
15
|
+
* emits one section per paragraph gets a stderr nudge, but is never blocked
|
|
16
|
+
* (over-segmenting can be intentional).
|
|
17
|
+
*
|
|
18
|
+
* Both take the already-JSON-parsed object; `undefined` is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
/** Throw on a malformed segmentation shape. No-op for undefined / unknown types. */
|
|
21
|
+
export declare function validateSegmentation(seg: unknown): void;
|
|
22
|
+
/**
|
|
23
|
+
* Non-fatal nudge toward semantic sections. Conservative on purpose: only
|
|
24
|
+
* fires for `section_based` with >= 5 sections that EACH span a single
|
|
25
|
+
* paragraph — the signature of one-section-per-paragraph — so a genuine
|
|
26
|
+
* 3-section piece never trips it. stderr only (keeps --json stdout clean);
|
|
27
|
+
* suppressed under --quiet.
|
|
28
|
+
*/
|
|
29
|
+
export declare function warnIfOverSegmented(seg: unknown, opts?: {
|
|
30
|
+
quiet?: boolean;
|
|
31
|
+
}): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation + nudge for media/text `segmentation` (the parsed value of
|
|
3
|
+
* `--segmentation-json` on `study create` / `iteration create`).
|
|
4
|
+
*
|
|
5
|
+
* THE PRINCIPLE these guard: **segments are semantic sections, not
|
|
6
|
+
* paragraphs.** Group related paragraphs into a few coherent sections
|
|
7
|
+
* (intro → argument → conclusion). A long article is usually 3–6 sections,
|
|
8
|
+
* not one per paragraph; `paragraph_start`/`paragraph_end` only mark where a
|
|
9
|
+
* section begins and ends — the unit is the *section*.
|
|
10
|
+
*
|
|
11
|
+
* - `validateSegmentation` is FATAL (throws ValidationError → exit 2) on a
|
|
12
|
+
* malformed `section_based` shape — most importantly a missing/empty label,
|
|
13
|
+
* which the backend would otherwise reject after a network round-trip.
|
|
14
|
+
* - `warnIfOverSegmented` is NON-FATAL: an agent that ignores the docs and
|
|
15
|
+
* emits one section per paragraph gets a stderr nudge, but is never blocked
|
|
16
|
+
* (over-segmenting can be intentional).
|
|
17
|
+
*
|
|
18
|
+
* Both take the already-JSON-parsed object; `undefined` is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
import { writeSync } from "node:fs";
|
|
21
|
+
import { c } from "./colors.js";
|
|
22
|
+
import { ValidationError } from "./output.js";
|
|
23
|
+
/** Throw on a malformed segmentation shape. No-op for undefined / unknown types. */
|
|
24
|
+
export function validateSegmentation(seg) {
|
|
25
|
+
if (!seg || typeof seg !== "object")
|
|
26
|
+
return;
|
|
27
|
+
const s = seg;
|
|
28
|
+
if (s.type === "section_based") {
|
|
29
|
+
const sections = s.sections;
|
|
30
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
31
|
+
throw new ValidationError("section_based segmentation needs a non-empty `sections` array.", [], "Group related paragraphs into a few semantic sections (intro, argument, conclusion) — not one per paragraph.");
|
|
32
|
+
}
|
|
33
|
+
sections.forEach((raw, i) => {
|
|
34
|
+
const sec = (raw ?? {});
|
|
35
|
+
const name = typeof sec.name === "string" ? sec.name.trim() : "";
|
|
36
|
+
const label = typeof sec.label === "string" ? sec.label.trim() : "";
|
|
37
|
+
if (!name) {
|
|
38
|
+
throw new ValidationError(`section_based sections[${i}] is missing a non-empty \`name\`.`, []);
|
|
39
|
+
}
|
|
40
|
+
if (!label) {
|
|
41
|
+
throw new ValidationError(`section_based sections[${i}] ("${name}") is missing a non-empty \`label\`. ` +
|
|
42
|
+
"Every section needs a human-readable label — it surfaces in the participant UI and in results.", []);
|
|
43
|
+
}
|
|
44
|
+
// Paragraph-bounded sections: validate the range when present. (A
|
|
45
|
+
// marker-bounded section_based variant may omit these — don't require.)
|
|
46
|
+
const start = sec.paragraph_start;
|
|
47
|
+
const end = sec.paragraph_end;
|
|
48
|
+
if (start !== undefined || end !== undefined) {
|
|
49
|
+
if (typeof start !== "number" || typeof end !== "number" || start < 0 || end <= start) {
|
|
50
|
+
throw new ValidationError(`section_based sections[${i}] ("${name}") has an invalid paragraph range ` +
|
|
51
|
+
`(paragraph_start=${String(start)}, paragraph_end=${String(end)}). ` +
|
|
52
|
+
"Need paragraph_start >= 0 and paragraph_end > paragraph_start.", []);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (s.type === "time_based") {
|
|
59
|
+
const iv = s.intervals_seconds;
|
|
60
|
+
if (Array.isArray(iv)) {
|
|
61
|
+
for (let i = 1; i < iv.length; i++) {
|
|
62
|
+
const prev = iv[i - 1];
|
|
63
|
+
const cur = iv[i];
|
|
64
|
+
if (typeof prev !== "number" || typeof cur !== "number" || cur <= prev) {
|
|
65
|
+
throw new ValidationError(`time_based intervals_seconds must be strictly ascending numbers ` +
|
|
66
|
+
`(problem at index ${i}: ${String(prev)} → ${String(cur)}).`, []);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Non-fatal nudge toward semantic sections. Conservative on purpose: only
|
|
74
|
+
* fires for `section_based` with >= 5 sections that EACH span a single
|
|
75
|
+
* paragraph — the signature of one-section-per-paragraph — so a genuine
|
|
76
|
+
* 3-section piece never trips it. stderr only (keeps --json stdout clean);
|
|
77
|
+
* suppressed under --quiet.
|
|
78
|
+
*/
|
|
79
|
+
export function warnIfOverSegmented(seg, opts = {}) {
|
|
80
|
+
if (opts.quiet)
|
|
81
|
+
return;
|
|
82
|
+
if (!seg || typeof seg !== "object")
|
|
83
|
+
return;
|
|
84
|
+
const s = seg;
|
|
85
|
+
if (s.type !== "section_based" || !Array.isArray(s.sections))
|
|
86
|
+
return;
|
|
87
|
+
const sections = s.sections;
|
|
88
|
+
if (sections.length < 5)
|
|
89
|
+
return;
|
|
90
|
+
const allSingleParagraph = sections.every((sec) => {
|
|
91
|
+
const start = sec?.paragraph_start;
|
|
92
|
+
const end = sec?.paragraph_end;
|
|
93
|
+
return typeof start === "number" && typeof end === "number" && end - start <= 1;
|
|
94
|
+
});
|
|
95
|
+
if (!allSingleParagraph)
|
|
96
|
+
return;
|
|
97
|
+
// Synchronous fd-2 write, not console.error: this fires moments before the
|
|
98
|
+
// command's own output + a process.exit (via exitWithFlush), which truncates
|
|
99
|
+
// async-buffered stderr writes to a pipe/file. writeSync guarantees the nudge
|
|
100
|
+
// lands.
|
|
101
|
+
writeSync(2, `${c.yellow}⚠ ${sections.length} single-paragraph sections.${c.reset} ` +
|
|
102
|
+
"Segments are meant to be semantic sections — group related paragraphs into a few " +
|
|
103
|
+
"coherent sections (e.g. intro → argument → conclusion), not one per paragraph. " +
|
|
104
|
+
"A long article is usually 3–6 sections. Proceeding as-is.\n");
|
|
105
|
+
}
|
|
@@ -207,6 +207,10 @@ The most common multi-turn question: "user wants to change X — re-use the exis
|
|
|
207
207
|
|
|
208
208
|
When in doubt: side-by-side comparison usually beats in-place edits. Ids are cheap; result history isn't.
|
|
209
209
|
|
|
210
|
+
## Sharing results (no-login link)
|
|
211
|
+
|
|
212
|
+
To hand a study to someone **without an ish account** — a prospect, a stakeholder — create a public share link. \`ish study share [study]\` prints a no-login \`share_url\` to the web viewer (summary, key insights, participant journeys, interactive frames, segment breakdowns). \`ish study share --list\` lists your links; \`ish study unshare <token>\` revokes one (takes the **raw token**, not a study id/alias). \`--expires <days>\` auto-expires the link. Brand the link by setting a workspace logo first: \`ish workspace update <id> --logo <url>\` — the logo shows on the shared page. Share **after** the study has run + been analyzed, so the viewer renders the summary + insights. Deep dive: \`ish docs get-page concepts/sharing\`. (CLI-only — the MCP has no share tool yet.)
|
|
213
|
+
|
|
210
214
|
## Pitfalls
|
|
211
215
|
|
|
212
216
|
- **Cold start on free plan**: \`workspace_create\` returns \`usage_limit_reached\` at the free-plan cap (1 workspace). Always inspect with \`workspace_list\` first. **MCP-only recipe** (no \`--ensure\` available): \`workspace_list\` → if non-empty, use the first; if empty, \`workspace_create\`; if \`workspace_create\` returns \`usage_limit_reached\`, re-call \`workspace_list\` (a workspace exists you didn't see — possibly created by another session). **CLI shortcut**: \`ish workspace create --name <name> --ensure\` is idempotent by name.
|
|
@@ -222,9 +226,10 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
|
|
|
222
226
|
- **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
|
|
223
227
|
- **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
|
|
224
228
|
- **All destructive deletes require \`--yes\` in non-TTY mode**: \`ish workspace delete\`, \`study delete\`, \`ask delete\`, \`person delete\`, \`source delete\`, \`chat endpoint delete\`. In \`--json\` mode (or any piped/non-TTY invocation), omitting \`--yes\` refuses with \`error_kind: "ConfirmationRequired"\` + an \`example\` field showing the same command with \`--yes\` appended. \`workspace delete\` is the highest-blast-radius: it removes ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints — the prompt names them explicitly.
|
|
225
|
-
- **\`ish login\` is idempotent**: with a
|
|
229
|
+
- **\`ish login\` is idempotent**: with a saved token that is unexpired *and* still accepted by the API, \`ish login\` short-circuits with "Already logged in" and **does not open a new browser tab**. If the token is unexpired but the server rejects it (revoked, rotated signing key, or minted against the wrong env — e.g. a dev-Supabase token while calling the prod api), it re-runs the browser flow instead of falsely reporting success. Use \`--force\` (or \`-f\`) only when actually switching accounts.
|
|
226
230
|
- **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
|
|
227
231
|
- **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
|
|
232
|
+
- **Share link URL host ≠ API host**: \`ish study share\` prints the backend-built \`share_url\` (the web frontend host). Use it verbatim — never reconstruct the URL from the API host or app URL; they differ. \`ish study unshare\` takes the **raw token** (from \`study share\` / \`study share --list\`), not a study id or alias.
|
|
228
233
|
|
|
229
234
|
## When in doubt
|
|
230
235
|
|
|
@@ -259,14 +264,22 @@ ish person generate \\
|
|
|
259
264
|
# 4. Define the study + iteration A in one call (one-shot path).
|
|
260
265
|
# The same shape works for image (--image-urls), video / audio /
|
|
261
266
|
# document (--content-url <url>), and chat (--endpoint <id>).
|
|
267
|
+
# For text/media you can also pass --segmentation-json (+ email-styling
|
|
268
|
+
# --content-html/--sender-name/--sender-email/--featured-image-url) here,
|
|
269
|
+
# so a single SEGMENTED iteration is one call — no separate iteration
|
|
270
|
+
# create (which would leave an empty A + a redundant B).
|
|
271
|
+
# Segments are SEMANTIC sections, not paragraphs: group related paragraphs
|
|
272
|
+
# into a few coherent sections (a long article is usually 3-6 sections, not
|
|
273
|
+
# one per paragraph). The CLI errors on a missing label and warns on
|
|
274
|
+
# one-section-per-paragraph.
|
|
262
275
|
ish study create --name "Onboarding UX" --modality interactive \\
|
|
263
276
|
--url https://example.com --screen-format desktop \\
|
|
264
277
|
--assignment "Sign up:Complete the signup flow" \\
|
|
265
278
|
--question "How easy was it?"
|
|
266
279
|
ish study use s-…
|
|
267
280
|
|
|
268
|
-
# (Optional) add a
|
|
269
|
-
# ish iteration create --url https://example.com/v2
|
|
281
|
+
# (Optional) add a SECOND iteration only when you actually want to A/B:
|
|
282
|
+
# ish iteration create --url https://example.com/v2 # auto-named "B" (next letter), not "CLI <date>"
|
|
270
283
|
|
|
271
284
|
# 6. Run, blocking until done
|
|
272
285
|
ish study run --all --wait
|
|
@@ -302,6 +315,59 @@ ish study get s-… # human: "✓ Add to cart 4/5 (80%)" p
|
|
|
302
315
|
ish study get s-… --json --verbose # step_completion[] incl. sample_failures[].participant_id
|
|
303
316
|
\`\`\`
|
|
304
317
|
|
|
318
|
+
### Rich question types (slider, likert, choice, number)
|
|
319
|
+
|
|
320
|
+
\`--question\` makes simple text questions only. For \`slider\`, \`likert\`,
|
|
321
|
+
\`single-choice\`, \`multiple-choice\`, \`number\`, or \`timing: "before"\`, use
|
|
322
|
+
\`--questionnaire\` — which takes **inline JSON, an @file, or a path** (no temp
|
|
323
|
+
file required). \`--question\` and \`--questionnaire\` are mutually exclusive.
|
|
324
|
+
|
|
325
|
+
\`\`\`bash
|
|
326
|
+
ish study create --name "Pricing page" --modality interactive --url https://example.com \\
|
|
327
|
+
--assignment "React:Look around as you normally would" \\
|
|
328
|
+
--questionnaire '[
|
|
329
|
+
{"question":"What do you think this does?","type":"text","timing":"after"},
|
|
330
|
+
{"question":"How easy was it to understand?","type":"slider","min":0,"max":10},
|
|
331
|
+
{"question":"How strongly do you agree it is for you?","type":"likert",
|
|
332
|
+
"labels":["Strongly disagree","Disagree","Neutral","Agree","Strongly agree"]},
|
|
333
|
+
{"question":"Which fits best?","type":"single-choice","options":["A","B","C"]},
|
|
334
|
+
{"question":"How many seats would you need?","type":"number"}
|
|
335
|
+
]'
|
|
336
|
+
# @file and path forms also work: --questionnaire @/tmp/q.json | ./q.json
|
|
337
|
+
\`\`\`
|
|
338
|
+
|
|
339
|
+
\`slider\` takes \`min\`/\`max\`(/\`step\`); \`likert\` takes \`labels\`;
|
|
340
|
+
\`single-choice\`/\`multiple-choice\` take \`options\`. The CLI tolerates
|
|
341
|
+
underscored type spellings (\`single_choice\`); the backend validates the shape.
|
|
342
|
+
Same input forms on \`ish ask … --questions\`. See \`ish docs get-page concepts/questionnaire\`.
|
|
343
|
+
|
|
344
|
+
### 1b. Share the results with someone (no-login link)
|
|
345
|
+
|
|
346
|
+
Goal: hand the finished study to a prospect or stakeholder who has no ish
|
|
347
|
+
account. Run + analyze first so the public viewer renders the summary.
|
|
348
|
+
|
|
349
|
+
\`\`\`bash
|
|
350
|
+
# (optional) brand the workspace — the logo shows on the shared page
|
|
351
|
+
ish workspace update w-… --logo https://logo.clearbit.com/acme.com
|
|
352
|
+
|
|
353
|
+
# generate the AI summary + key insights (needs ≥5 completed participants)
|
|
354
|
+
ish study analyze s-… --wait
|
|
355
|
+
|
|
356
|
+
# create the public, no-login link — the printed share_url is the deliverable
|
|
357
|
+
ish study share s-…
|
|
358
|
+
# → https://<frontend>/share/study/Hk9_… (paste into an email)
|
|
359
|
+
|
|
360
|
+
ish study share s-… --expires 30 # auto-expire in 30 days
|
|
361
|
+
ish study share s-… --json # { token, share_url, expires_at, created_at, id }
|
|
362
|
+
|
|
363
|
+
# manage links
|
|
364
|
+
ish study share --list # all your links: token, study, expires, revoked
|
|
365
|
+
ish study unshare Hk9_… --yes # revoke by RAW TOKEN — URL stops working
|
|
366
|
+
\`\`\`
|
|
367
|
+
|
|
368
|
+
The \`share_url\` host is the web frontend (built server-side) — use it
|
|
369
|
+
verbatim; don't reconstruct it. Deep dive: \`ish docs get-page concepts/sharing\`.
|
|
370
|
+
|
|
305
371
|
## 2. Quick A/B ask with image variants
|
|
306
372
|
|
|
307
373
|
Goal: ship 30 simulated reactions to two hero images, with a "which do
|
|
@@ -1035,6 +1101,12 @@ table, projection shapes, and the defensive null-handling rules.
|
|
|
1035
1101
|
- For \`ask\` write-paths (update/archive/wait/add-questions/add-people),
|
|
1036
1102
|
default JSON is compact (changed fields + alias). Pass \`--verbose\` for
|
|
1037
1103
|
the full Ask payload.
|
|
1104
|
+
- The \`ish study create\`/\`update --json\` echo always shows
|
|
1105
|
+
\`assignments\` and \`interview_questions\` — \`[]\` when the study has
|
|
1106
|
+
none, never dropped. Trust it: an empty \`assignments\` means you
|
|
1107
|
+
genuinely created a study with no assignment (add one before
|
|
1108
|
+
\`study run\`, or it fails with "Study has no assignments"); you don't
|
|
1109
|
+
need a follow-up \`study get --verbose\` to tell "none" from "stripped".
|
|
1038
1110
|
- \`person generate --json\` returns \`{job: {id, status, person_ids},
|
|
1039
1111
|
profiles: [...]}\`; each person is the lean person shape with its
|
|
1040
1112
|
evidence-grounded \`scenarios\` attached (\`--no-scenarios\` to omit,
|
|
@@ -1127,7 +1199,7 @@ ish <command> --help
|
|
|
1127
1199
|
| \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
|
|
1128
1200
|
| | clients (Cursor, VS Code, Claude Code, | |
|
|
1129
1201
|
| | Claude Desktop, Windsurf). Idempotent. | |
|
|
1130
|
-
| \`login\` | Browser-based auth. Idempotent: short-circuits
|
|
1202
|
+
| \`login\` | Browser-based auth. Idempotent: short-circuits only when the saved token is unexpired AND server-accepted. \`--force\` to switch accounts. | — |
|
|
1131
1203
|
| \`logout\` | Clear saved credentials | — |
|
|
1132
1204
|
| \`status\` | Show active session (user, workspace, | concepts/active-context |
|
|
1133
1205
|
| | study, ask, token validity) — alias \`whoami\` | |
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface ProductUpdateInput {
|
|
|
22
22
|
description?: string;
|
|
23
23
|
color?: string;
|
|
24
24
|
base_url?: string;
|
|
25
|
+
/** External logo image URL — backend sets `logo.path` directly (feeds product_logo_url on shared study links). */
|
|
26
|
+
logo_url?: string;
|
|
25
27
|
}
|
|
26
28
|
export type SecretScope = "agent" | "project";
|
|
27
29
|
export type SecretVariableType = "secret" | "variable";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ishlabs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "The command-line interface for ish",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,9 +45,11 @@
|
|
|
45
45
|
"@sentry/bun": "^10.13.0",
|
|
46
46
|
"@sentry/node": "^10.13.0",
|
|
47
47
|
"commander": "^13.0.0",
|
|
48
|
+
"http-proxy": "^1.18.1",
|
|
48
49
|
"playwright-core": "^1.58.2"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
52
|
+
"@types/http-proxy": "^1.17.17",
|
|
51
53
|
"@types/node": "^22.0.0",
|
|
52
54
|
"typescript": "^5.7.0"
|
|
53
55
|
}
|