@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.
Files changed (39) hide show
  1. package/README.md +55 -6
  2. package/dist/auth.d.ts +23 -4
  3. package/dist/auth.js +165 -39
  4. package/dist/commands/ask.d.ts +12 -0
  5. package/dist/commands/ask.js +127 -2
  6. package/dist/commands/chat.d.ts +17 -0
  7. package/dist/commands/chat.js +589 -0
  8. package/dist/commands/iteration.js +232 -13
  9. package/dist/commands/secret.d.ts +20 -0
  10. package/dist/commands/secret.js +246 -0
  11. package/dist/commands/source.js +24 -2
  12. package/dist/commands/study-run.d.ts +38 -0
  13. package/dist/commands/study-run.js +199 -80
  14. package/dist/commands/study-tester.js +17 -2
  15. package/dist/commands/study.js +311 -39
  16. package/dist/commands/workspace.js +81 -0
  17. package/dist/config.d.ts +7 -0
  18. package/dist/connect.d.ts +3 -0
  19. package/dist/connect.js +359 -24
  20. package/dist/index.js +67 -9
  21. package/dist/lib/alias-hydrate.d.ts +42 -0
  22. package/dist/lib/alias-hydrate.js +175 -0
  23. package/dist/lib/alias-store.d.ts +1 -0
  24. package/dist/lib/alias-store.js +28 -1
  25. package/dist/lib/auth.js +11 -3
  26. package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
  27. package/dist/lib/chat-endpoint-formatters.js +104 -0
  28. package/dist/lib/command-helpers.d.ts +18 -0
  29. package/dist/lib/command-helpers.js +188 -53
  30. package/dist/lib/docs.js +662 -34
  31. package/dist/lib/modality.d.ts +42 -0
  32. package/dist/lib/modality.js +192 -0
  33. package/dist/lib/output.d.ts +41 -0
  34. package/dist/lib/output.js +453 -19
  35. package/dist/lib/paths.d.ts +1 -0
  36. package/dist/lib/paths.js +3 -0
  37. package/dist/lib/skill-content.js +183 -13
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +3 -3
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Best-effort alias-cache hydration on alias-miss (Pattern F).
3
+ *
4
+ * The CLI persists aliases to ``~/.ish/aliases.json``, so the cache survives
5
+ * across processes. Where it bites: the file is missing/empty (fresh install,
6
+ * `rm ~/.ish/aliases.json`, agent running in a sandbox with a fresh
7
+ * `ISH_HOME`) and the agent has an alias from a prior process or the docs.
8
+ *
9
+ * ``resolveIdAsync(input, client, hints?)`` mirrors the sync ``resolveId``
10
+ * contract but, on alias-miss, attempts a single ``GET /list`` to repopulate
11
+ * the cache before retrying. The hydrate is BEST-EFFORT: a failing list
12
+ * call is swallowed and the canonical "Unknown alias" error fires with the
13
+ * actionable suggestion in the message.
14
+ *
15
+ * For prefixes whose list endpoint requires a parent ID (study/iteration/
16
+ * ask/etc), the caller passes ``hints`` carrying the parent (workspaceId,
17
+ * studyId). Without a parent we skip the hydrate — global N+1 fan-out is
18
+ * too expensive for a papercut.
19
+ */
20
+ import { ALIAS_PREFIX, resolveId, tagAlias } from "./alias-store.js";
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const ALIAS_RE = /^[a-z]+-[0-9a-f]{3,}$|^[a-z]+\d{2,}$/;
23
+ function aliasPrefix(value) {
24
+ if (!ALIAS_RE.test(value))
25
+ return null;
26
+ const m = value.match(/^([a-z]+)/);
27
+ return m ? m[1] : null;
28
+ }
29
+ function isAliasShape(value) {
30
+ return ALIAS_RE.test(value);
31
+ }
32
+ function isUuid(value) {
33
+ return UUID_RE.test(value);
34
+ }
35
+ /**
36
+ * Try to resolve a parent ID — accepts either a UUID or a known alias. Used
37
+ * to extract ``workspaceId`` / ``studyId`` from hints before we fan out to a
38
+ * scoped list endpoint. Returns ``null`` if the value can't be resolved
39
+ * cheaply (e.g. it's an alias that's also missing from the cache).
40
+ */
41
+ function resolveParent(value) {
42
+ if (!value)
43
+ return null;
44
+ if (isUuid(value))
45
+ return value;
46
+ // Try the sync resolver but never throw.
47
+ try {
48
+ return resolveId(value);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Hit a list endpoint and tag every returned ID under the given prefix.
56
+ * Silently swallows network/auth failures — we only ever block the
57
+ * underlying call when the alias is genuinely unknown after hydrate.
58
+ */
59
+ async function hydrateList(client, prefix, path, params) {
60
+ let data;
61
+ try {
62
+ data = await client.get(path, params);
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ let items = [];
68
+ if (Array.isArray(data)) {
69
+ items = data;
70
+ }
71
+ else if (typeof data === "object" && data !== null && "items" in data) {
72
+ const inner = data.items;
73
+ if (Array.isArray(inner))
74
+ items = inner;
75
+ }
76
+ for (const item of items) {
77
+ if (typeof item !== "object" || item === null)
78
+ continue;
79
+ const id = item.id;
80
+ if (typeof id === "string" && id.length > 0) {
81
+ try {
82
+ tagAlias(prefix, id);
83
+ }
84
+ catch {
85
+ // Ignore — bad UUIDs are not catastrophic.
86
+ }
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Best-effort hydrate of the alias cache for ``alias``'s entity type. Returns
92
+ * silently on success or any failure — the caller checks the cache after
93
+ * this returns.
94
+ */
95
+ export async function hydrateForAlias(client, alias, hints = {}) {
96
+ const prefix = aliasPrefix(alias);
97
+ if (!prefix)
98
+ return;
99
+ const ws = resolveParent(hints.workspaceId);
100
+ const study = resolveParent(hints.studyId);
101
+ switch (prefix) {
102
+ case ALIAS_PREFIX.workspace: {
103
+ // Top-level — no parent.
104
+ await hydrateList(client, ALIAS_PREFIX.workspace, "/products");
105
+ return;
106
+ }
107
+ case ALIAS_PREFIX.study: {
108
+ if (!ws)
109
+ return;
110
+ await hydrateList(client, ALIAS_PREFIX.study, `/products/${ws}/studies`);
111
+ return;
112
+ }
113
+ case ALIAS_PREFIX.iteration: {
114
+ if (!study)
115
+ return;
116
+ await hydrateList(client, ALIAS_PREFIX.iteration, `/studies/${study}/iterations`);
117
+ return;
118
+ }
119
+ case ALIAS_PREFIX.testerProfile: {
120
+ if (!ws)
121
+ return;
122
+ await hydrateList(client, ALIAS_PREFIX.testerProfile, "/tester-profiles", { workspace_id: ws, type: "all", limit: "200" });
123
+ return;
124
+ }
125
+ case ALIAS_PREFIX.ask: {
126
+ if (!ws)
127
+ return;
128
+ await hydrateList(client, ALIAS_PREFIX.ask, `/products/${ws}/asks`);
129
+ return;
130
+ }
131
+ case ALIAS_PREFIX.chatEndpoint: {
132
+ if (!ws)
133
+ return;
134
+ await hydrateList(client, ALIAS_PREFIX.chatEndpoint, `/products/${ws}/chatbot-endpoints`);
135
+ return;
136
+ }
137
+ // No cheap single-call hydrate for the rest:
138
+ // tester (`t-`) — scoped to iteration
139
+ // ask round (`r-`) — nested on the ask
140
+ // audience source (`tps-`) — fetched per-id
141
+ // simulation config (`c-`) — no list endpoint yet
142
+ default:
143
+ return;
144
+ }
145
+ }
146
+ /**
147
+ * Async sibling of ``resolveId``: same UUID-or-alias contract, but on
148
+ * alias-miss attempts a best-effort hydrate via the matching list endpoint
149
+ * before retrying. Falls back to the canonical "Unknown alias" error
150
+ * (with named list-command suggestion) when the alias is still missing
151
+ * after the hydrate.
152
+ *
153
+ * Use this at command-handler entry points where the agent supplies an
154
+ * alias and the same call carries enough context (workspace / study) to
155
+ * scope the hydrate cheaply.
156
+ */
157
+ export async function resolveIdAsync(input, client, hints = {}) {
158
+ if (isUuid(input))
159
+ return input;
160
+ if (!isAliasShape(input)) {
161
+ // Fall through to the sync resolver so the "Invalid ID" guidance fires.
162
+ return resolveId(input);
163
+ }
164
+ // Cheap-path: alias already in store.
165
+ try {
166
+ return resolveId(input);
167
+ }
168
+ catch {
169
+ // Cache miss — attempt best-effort hydrate.
170
+ await hydrateForAlias(client, input, hints);
171
+ // Retry — sync resolver re-reads ``aliases.json`` from disk on every
172
+ // call (see loadAliases), so the freshly tagged entries are visible.
173
+ return resolveId(input);
174
+ }
175
+ }
@@ -17,6 +17,7 @@ export declare const ALIAS_PREFIX: {
17
17
  readonly job: "j";
18
18
  readonly ask: "a";
19
19
  readonly askRound: "r";
20
+ readonly chatEndpoint: "ep";
20
21
  };
21
22
  /**
22
23
  * Save aliases for a list of IDs under the given prefix.
@@ -20,6 +20,7 @@ export const ALIAS_PREFIX = {
20
20
  job: "j",
21
21
  ask: "a",
22
22
  askRound: "r",
23
+ chatEndpoint: "ep",
23
24
  };
24
25
  /** Format a number with zero-padding (minimum 2 digits). */
25
26
  function padNum(n) {
@@ -110,6 +111,32 @@ export function deterministicAlias(prefix, uuid) {
110
111
  }
111
112
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
112
113
  const ALIAS_RE = /^[a-z]+-[0-9a-f]{3,}$|^[a-z]+\d{2,}$/;
114
+ /**
115
+ * Suggested `ish ... list` command per alias prefix. Surfaced in the
116
+ * unknown-alias error (Pattern F) so the agent doesn't have to guess
117
+ * which list command to run.
118
+ */
119
+ const HYDRATE_HINT = {
120
+ w: "ish workspace list",
121
+ s: "ish study list",
122
+ i: "ish iteration list --study <study-id>",
123
+ tp: "ish profile list",
124
+ tps: "ish source list",
125
+ t: "ish tester get <tester-id>",
126
+ c: "ish config list",
127
+ a: "ish ask list",
128
+ r: "ish ask get <ask-id>",
129
+ ep: "ish chat endpoint list",
130
+ // Legacy two-letter prefixes the deterministic generator may have
131
+ // produced before; defaults below cover anything else.
132
+ };
133
+ function hintForPrefix(alias) {
134
+ // Pull the leading alpha run, which is the prefix.
135
+ const m = alias.match(/^([a-z]+)/);
136
+ if (!m)
137
+ return "the matching list command";
138
+ return HYDRATE_HINT[m[1]] ?? "the matching list command";
139
+ }
113
140
  /**
114
141
  * Resolve a short alias to a full UUID, or validate and pass through a full UUID.
115
142
  *
@@ -130,7 +157,7 @@ export function resolveId(input) {
130
157
  const uuid = aliases[input];
131
158
  if (uuid)
132
159
  return uuid;
133
- throw new Error(`Unknown alias "${input}". Run a list command first to generate aliases.`);
160
+ throw new Error(`Unknown alias "${input}". Run \`${hintForPrefix(input)}\` first to generate aliases.`);
134
161
  }
135
162
  // 3. Anything else — fail with helpful guidance
136
163
  throw new Error(`Invalid ID "${input}". Use a short alias (e.g. w-a3f, s-b2c) or a full UUID.\n` +
package/dist/lib/auth.js CHANGED
@@ -24,8 +24,10 @@ async function verifyToken(token, apiUrl) {
24
24
  return resp.status !== 401 && resp.status !== 403;
25
25
  }
26
26
  catch {
27
- // Network error can't verify, assume ok
28
- console.error("Warning: Could not verify token (network error). Proceeding anyway.");
27
+ // Network blip on the best-effort probe. The subsequent API call will
28
+ // surface the real auth failure (with a proper exit code 3) if there
29
+ // is one, so don't pollute stderr on every command — it fired on
30
+ // every successful run during Phase A (Pattern F / C4-finding-5).
29
31
  return true;
30
32
  }
31
33
  }
@@ -57,8 +59,14 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
57
59
  let accessToken = config.access_token;
58
60
  // Refresh if expired or close to expiry
59
61
  if (isTokenExpired(accessToken)) {
62
+ if (!config.oauth_client_id) {
63
+ throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
64
+ }
60
65
  try {
61
- const tokens = await refreshTokens(config.refresh_token, { accessToken });
66
+ const tokens = await refreshTokens(config.refresh_token, {
67
+ accessToken,
68
+ clientId: config.oauth_client_id,
69
+ });
62
70
  accessToken = tokens.accessToken;
63
71
  config.access_token = tokens.accessToken;
64
72
  config.refresh_token = tokens.refreshToken;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Lean-vs-verbose projection for ChatbotEndpointResponse.
3
+ *
4
+ * The backend returns a nested camelCase shape (id, name, productId, config,
5
+ * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
+ * fields an agent typically branches on: id/alias/name, transport, the
7
+ * outgoing url + method, the incoming messagePath, the slot-path count, and
8
+ * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ */
10
+ export interface OutgoingHttp {
11
+ url?: string;
12
+ method?: string;
13
+ mode?: string;
14
+ [key: string]: unknown;
15
+ }
16
+ export interface IncomingHttp {
17
+ messagePath?: string;
18
+ slotsContainerPaths?: string[];
19
+ [key: string]: unknown;
20
+ }
21
+ export interface ChatbotEndpointConfig {
22
+ transport?: string;
23
+ outgoing?: OutgoingHttp;
24
+ incoming?: IncomingHttp;
25
+ isTunnelBacked?: boolean;
26
+ [key: string]: unknown;
27
+ }
28
+ export interface ChatbotEndpointRow {
29
+ id?: string;
30
+ name?: string;
31
+ productId?: string;
32
+ config?: ChatbotEndpointConfig;
33
+ isTunnelBacked?: boolean;
34
+ [key: string]: unknown;
35
+ }
36
+ /** Return the round-trippable envelope used by `endpoint get --verbose`. */
37
+ export declare function envelopeFromRow(row: ChatbotEndpointRow): Record<string, unknown>;
38
+ export declare function formatChatEndpointList(rows: ChatbotEndpointRow[], json: boolean, verbose: boolean): void;
39
+ export declare function formatChatEndpointDetail(row: ChatbotEndpointRow, json: boolean, verbose: boolean): void;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Lean-vs-verbose projection for ChatbotEndpointResponse.
3
+ *
4
+ * The backend returns a nested camelCase shape (id, name, productId, config,
5
+ * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
+ * fields an agent typically branches on: id/alias/name, transport, the
7
+ * outgoing url + method, the incoming messagePath, the slot-path count, and
8
+ * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ */
10
+ import { tagAlias, ALIAS_PREFIX } from "./alias-store.js";
11
+ import { output, printTable } from "./output.js";
12
+ function leanRow(row) {
13
+ const cfg = row.config ?? {};
14
+ const out = {};
15
+ if (row.id)
16
+ out.alias = tagAlias(ALIAS_PREFIX.chatEndpoint, row.id);
17
+ if (row.id)
18
+ out.id = row.id;
19
+ if (row.name)
20
+ out.name = row.name;
21
+ if (cfg.transport)
22
+ out.transport = cfg.transport;
23
+ out.is_tunnel_backed = Boolean(row.isTunnelBacked);
24
+ if (cfg.outgoing?.url)
25
+ out.url = cfg.outgoing.url;
26
+ if (cfg.outgoing?.method)
27
+ out.method = cfg.outgoing.method;
28
+ if (cfg.outgoing?.mode)
29
+ out.mode = cfg.outgoing.mode;
30
+ if (cfg.incoming?.messagePath)
31
+ out.message_path = cfg.incoming.messagePath;
32
+ const slotsCount = Array.isArray(cfg.incoming?.slotsContainerPaths)
33
+ ? cfg.incoming.slotsContainerPaths.length
34
+ : 0;
35
+ out.slots_paths = slotsCount;
36
+ return out;
37
+ }
38
+ /** Return the round-trippable envelope used by `endpoint get --verbose`. */
39
+ export function envelopeFromRow(row) {
40
+ return {
41
+ id: row.id,
42
+ name: row.name,
43
+ isTunnelBacked: Boolean(row.isTunnelBacked),
44
+ config: row.config ?? {},
45
+ };
46
+ }
47
+ export function formatChatEndpointList(rows, json, verbose) {
48
+ if (json) {
49
+ if (verbose) {
50
+ output(rows, true);
51
+ return;
52
+ }
53
+ output(rows.map(leanRow), true);
54
+ return;
55
+ }
56
+ if (rows.length === 0) {
57
+ console.log("No chatbot endpoints.");
58
+ return;
59
+ }
60
+ const lean = rows.map(leanRow);
61
+ printTable(["#", "NAME", "TRANSPORT", "URL", "METHOD", "MODE", "TUNNEL"], lean.map((r) => [
62
+ String(r.alias ?? r.id ?? ""),
63
+ String(r.name ?? ""),
64
+ String(r.transport ?? "-"),
65
+ String(r.url ?? "-"),
66
+ String(r.method ?? "-"),
67
+ String(r.mode ?? "-"),
68
+ r.is_tunnel_backed ? "yes" : "no",
69
+ ]));
70
+ }
71
+ export function formatChatEndpointDetail(row, json, verbose) {
72
+ if (json) {
73
+ if (verbose) {
74
+ output(envelopeFromRow(row), true);
75
+ return;
76
+ }
77
+ output(leanRow(row), true);
78
+ return;
79
+ }
80
+ const cfg = row.config ?? {};
81
+ const alias = row.id ? tagAlias(ALIAS_PREFIX.chatEndpoint, row.id) : "-";
82
+ console.log(`${row.name || "Untitled"} (${alias})`);
83
+ const meta = [];
84
+ if (cfg.transport)
85
+ meta.push(String(cfg.transport));
86
+ if (row.isTunnelBacked)
87
+ meta.push("tunnel-backed");
88
+ if (meta.length > 0)
89
+ console.log(meta.join(" · "));
90
+ if (cfg.outgoing) {
91
+ console.log("");
92
+ console.log(` URL ${cfg.outgoing.url ?? "-"}`);
93
+ console.log(` Method ${cfg.outgoing.method ?? "-"}`);
94
+ console.log(` Mode ${cfg.outgoing.mode ?? "-"}`);
95
+ }
96
+ if (cfg.incoming) {
97
+ console.log("");
98
+ console.log(` Message path ${cfg.incoming.messagePath ?? "-"}`);
99
+ const slots = Array.isArray(cfg.incoming.slotsContainerPaths)
100
+ ? cfg.incoming.slotsContainerPaths.length
101
+ : 0;
102
+ console.log(` Slot paths ${slots}`);
103
+ }
104
+ }
@@ -123,6 +123,12 @@ export declare function confirmDestructive(prompt: string, opts: {
123
123
  export declare function resolveWorkspace(explicit?: string): string;
124
124
  export declare function resolveStudy(explicit?: string): string;
125
125
  export declare function resolveAsk(explicit?: string): string;
126
+ /**
127
+ * Resolve a chat endpoint id from (in order): the positional argument, the
128
+ * `--endpoint <id>` flag, the `ISH_CHAT_ENDPOINT` env var, or the active
129
+ * endpoint persisted by `ish chat endpoint use`. Throws when none are set.
130
+ */
131
+ export declare function resolveChatEndpoint(positional?: string, flag?: string): string;
126
132
  /** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
127
133
  export declare function collectRepeatable(value: string, prev?: string[]): string[];
128
134
  /**
@@ -150,4 +156,16 @@ export declare function parseWaitTimeout(raw: string | undefined, defaultMs?: nu
150
156
  * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
151
157
  * unused values.
152
158
  */
159
+ /**
160
+ * Read a `--*-config <file>` style flag value, treating "-" as "read from
161
+ * stdin" and any other value as a file path on disk. Trailing newlines on
162
+ * stdin input are stripped so the resulting string parses cleanly as JSON.
163
+ *
164
+ * Throws when "-" is passed but stdin is a TTY (no upstream pipe).
165
+ *
166
+ * Mirrors the readSecretFlag pattern in src/commands/workspace.ts; extracted
167
+ * so every `--<x>-config <file>` flag across commands shares one
168
+ * implementation.
169
+ */
170
+ export declare function readFileOrStdin(path: string): Promise<string>;
153
171
  export declare function injectGlobalWorkspaceOption(program: Command): void;