@ishlabs/cli 0.19.0 → 0.20.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/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import * as os from "node:os";
6
6
  import { runTunnel, runDetached, connectStatus, disconnect } from "./connect.js";
7
- import { login, decodeJwtClaims } from "./auth.js";
7
+ import { login, decodeJwtClaims, isTokenExpired } from "./auth.js";
8
8
  import { loadConfig, saveConfig } from "./config.js";
9
9
  import { upgrade } from "./upgrade.js";
10
10
  import { registerWorkspaceCommands } from "./commands/workspace.js";
@@ -27,8 +27,9 @@ import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
27
27
  import { output } from "./lib/output.js";
28
28
  import { ishDir } from "./lib/paths.js";
29
29
  import { findInstalledSkill } from "./lib/skill-content.js";
30
- import { exitWithFlush, initObservability } from "./lib/observability.js";
30
+ import { initObservability } from "./lib/observability.js";
31
31
  import { setSurfaceBaggage } from "./lib/baggage.js";
32
+ import { resolveId } from "./lib/alias-store.js";
32
33
  import pkg from "../package.json" with { type: "json" };
33
34
  const { version } = pkg;
34
35
  // Bootstrap observability before anything else so the very first
@@ -58,31 +59,61 @@ program
58
59
  program.configureOutput({
59
60
  outputError: () => { },
60
61
  });
62
+ // Enable Commander's built-in suggestion engine ("did you mean 'workspace'?"
63
+ // for typos). Closes part of ISSUE-022.
64
+ program.showSuggestionAfterError(true);
61
65
  program.exitOverride((err) => {
62
- // Help and --version are normal exit-0 paths, not errors.
66
+ // Help and --version are normal exit-0 paths, not errors. process.exit
67
+ // is synchronous so the right exit code propagates regardless of any
68
+ // in-flight async work — Commander will otherwise rethrow CommanderError
69
+ // and Node's default uncaughtException handler exits with the error's
70
+ // own exitCode (1) before our async exitWithFlush gets to run.
63
71
  if (err.code === "commander.helpDisplayed"
64
72
  || err.code === "commander.version"
65
73
  || err.code === "commander.help") {
66
- void exitWithFlush(0);
67
- return;
74
+ process.exit(0);
68
75
  }
76
+ // Pattern E + ISSUE-022: the previous envelope used a literal "<command>"
77
+ // placeholder in suggestions that was never substituted. Reconstruct
78
+ // the right help target based on what kind of error fired:
79
+ // - unknownCommand → point at top-level `ish --help` (the user's
80
+ // firstWord IS the typo; no point pointing to it)
81
+ // - missingArgument /
82
+ // requiredOption → point at the actual command's --help
83
+ // (`ish workspace --help` etc.)
84
+ // ISSUE-022.
85
+ const userArgs = process.argv.slice(2);
86
+ const firstWord = userArgs.find((a) => !a.startsWith("-")) ?? "";
87
+ const isUnknownCommand = err.code === "commander.unknownCommand"
88
+ || err.code === "commander.help" && /unknown/i.test(err.message);
89
+ const helpPath = isUnknownCommand || !firstWord ? "ish --help" : `ish ${firstWord} --help`;
69
90
  // Detect --json without relying on parsed opts (parse may have failed).
70
91
  const useJson = process.argv.includes("--json") || !process.stdout.isTTY;
92
+ // Strip the redundant "error: " prefix Commander adds — the envelope
93
+ // field is already called "error" (ISSUE-022).
94
+ const cleanedMessage = err.message.replace(/^error:\s*/i, "");
71
95
  const envelope = {
72
- error: err.message,
96
+ error: cleanedMessage,
73
97
  error_code: "usage_error",
74
- status: 0,
98
+ // Pattern D: omit `status` entirely when there's no upstream HTTP
99
+ // status to report. The previous `status: 0` was meaningless and
100
+ // confused agents branching on the field.
75
101
  retryable: false,
76
- suggestions: ["Run `ish <command> --help` for usage"],
102
+ suggestions: [`Run \`${helpPath}\` for usage`],
77
103
  };
78
104
  if (useJson) {
79
105
  console.error(JSON.stringify(envelope));
80
106
  }
81
107
  else {
82
- console.error(`Error: ${err.message}`);
83
- console.error(" → Run `ish <command> --help` for usage");
108
+ console.error(`Error: ${cleanedMessage}`);
109
+ console.error(` → Run \`${helpPath}\``);
84
110
  }
85
- void exitWithFlush(EXIT_USAGE);
111
+ // Synchronous exit so the documented usage exit code (2) actually
112
+ // propagates. exitWithFlush is async — its inner process.exit only
113
+ // fires after Commander has already rethrown CommanderError, at which
114
+ // point Node's default handler exits with the error's exitCode (1).
115
+ // Pattern E (ISSUE-021).
116
+ process.exit(EXIT_USAGE);
86
117
  });
87
118
  // Global options
88
119
  program
@@ -102,8 +133,55 @@ program
102
133
  program
103
134
  .command("login")
104
135
  .description("Authenticate with ish via your browser")
105
- .action(async (_opts, cmd) => {
136
+ .option("-f, --force", "Re-run the browser flow even if already authenticated (replaces the saved token)")
137
+ .addHelpText("after", `
138
+ By default, \`ish login\` short-circuits when there's already a valid
139
+ saved token — it tells you who you're logged in as and exits without
140
+ opening a browser. Pass --force to re-run the flow anyway (e.g. to
141
+ switch accounts).
142
+
143
+ Without the guard, every reflexive \`ish login\` opens a new browser
144
+ tab AND registers a fresh OAuth client (orphaning the previous one
145
+ on the auth server). The guard prevents both.
146
+
147
+ Examples:
148
+ $ ish login # noop if already authenticated
149
+ $ ish login --force # always re-run`)
150
+ .action(async (opts, cmd) => {
106
151
  await runInline(cmd, async (globals) => {
152
+ // ISSUE-007: idempotency guard. Default behavior on an already-
153
+ // authenticated session is to short-circuit with a friendly
154
+ // "Already logged in as ..." message. Pass --force to bypass.
155
+ if (!opts.force) {
156
+ const existing = loadConfig();
157
+ const existingToken = existing.access_token;
158
+ if (existingToken && !isTokenExpired(existingToken)) {
159
+ const claims = decodeJwtClaims(existingToken);
160
+ const email = typeof claims?.email === "string" ? claims.email : "(no email in token)";
161
+ const exp = typeof claims?.exp === "number" ? claims.exp : 0;
162
+ const remainingSec = Math.max(0, exp - Math.floor(Date.now() / 1000));
163
+ const remainingMin = Math.floor(remainingSec / 60);
164
+ // Code-review #7: previously this called output(...) for the
165
+ // structured envelope AND console.error(...) for the human line —
166
+ // human-mode users saw the key-value dump on stdout AND the
167
+ // cleaner sentence on stderr (duplicate). JSON consumers get the
168
+ // structured envelope; human-mode users get just the stderr
169
+ // sentence. Single output path per mode.
170
+ if (globals.json) {
171
+ output({
172
+ message: "Already logged in",
173
+ email,
174
+ token_valid: true,
175
+ expires_in_seconds: remainingSec,
176
+ hint: "Pass --force to re-run the browser flow (e.g. to switch accounts).",
177
+ }, true);
178
+ }
179
+ else if (!globals.quiet) {
180
+ console.error(`Already logged in as ${email} (token valid, expires in ${remainingMin}m). Pass --force to switch accounts.`);
181
+ }
182
+ return;
183
+ }
184
+ }
107
185
  const tokens = await login({ dev: globals.dev });
108
186
  const config = loadConfig();
109
187
  config.access_token = tokens.accessToken;
@@ -159,19 +237,39 @@ program
159
237
  // Resolve names of active resources. Failures are non-fatal — we still
160
238
  // return the saved IDs so the user knows what's configured.
161
239
  const client = token ? new ApiClient({ apiUrl, token }) : null;
240
+ // Helper: look up an active ref's name; on failure attach a `warning`
241
+ // field so the user can tell apart "name unavailable due to transient"
242
+ // vs. "this active ref is orphan — switch or clear it". Pattern A.
243
+ const ORPHAN_HINT = (kind) => `Active ${kind} is no longer accessible (deleted, moved workspace, or auth issue). Use \`ish ${kind} use <id>\` to switch, or \`ish ${kind} use --clear\` to drop.`;
244
+ const lookupName = async (path) => {
245
+ if (!client)
246
+ return {};
247
+ try {
248
+ const row = await client.get(path);
249
+ return row?.name ? { name: row.name } : {};
250
+ }
251
+ catch (err) {
252
+ // Distinguish missing-entity (404) from other failures. Either
253
+ // way the user benefits from the warning; the 404 specifically
254
+ // means "orphan", anything else is "couldn't confirm".
255
+ const status = err?.status;
256
+ if (status === 404)
257
+ return { warning: "orphan — entity no longer exists in this workspace" };
258
+ return { warning: "name lookup failed (auth, network, or permission)" };
259
+ }
260
+ };
162
261
  let workspace = null;
163
262
  if (config.workspace) {
164
263
  workspace = {
165
264
  id: config.workspace,
166
265
  alias: tagAlias(ALIAS_PREFIX.workspace, config.workspace),
167
266
  };
168
- if (client) {
169
- try {
170
- const ws = await client.get(`/products/${config.workspace}`);
171
- if (ws?.name)
172
- workspace.name = ws.name;
173
- }
174
- catch { /* keep id+alias only */ }
267
+ const { name, warning } = await lookupName(`/products/${config.workspace}`);
268
+ if (name)
269
+ workspace.name = name;
270
+ if (warning) {
271
+ workspace.warning = warning;
272
+ workspace.hint = ORPHAN_HINT("workspace");
175
273
  }
176
274
  }
177
275
  let study = null;
@@ -180,13 +278,12 @@ program
180
278
  id: config.study,
181
279
  alias: tagAlias(ALIAS_PREFIX.study, config.study),
182
280
  };
183
- if (client) {
184
- try {
185
- const s = await client.get(`/studies/${config.study}`);
186
- if (s?.name)
187
- study.name = s.name;
188
- }
189
- catch { /* keep id+alias only */ }
281
+ const { name, warning } = await lookupName(`/studies/${config.study}`);
282
+ if (name)
283
+ study.name = name;
284
+ if (warning) {
285
+ study.warning = warning;
286
+ study.hint = ORPHAN_HINT("study");
190
287
  }
191
288
  }
192
289
  let ask = null;
@@ -195,13 +292,29 @@ program
195
292
  id: config.ask,
196
293
  alias: tagAlias(ALIAS_PREFIX.ask, config.ask),
197
294
  };
198
- if (client) {
199
- try {
200
- const a = await client.get(`/asks/${config.ask}`);
201
- if (a?.name)
202
- ask.name = a.name;
203
- }
204
- catch { /* keep id+alias only */ }
295
+ const { name, warning } = await lookupName(`/asks/${config.ask}`);
296
+ if (name)
297
+ ask.name = name;
298
+ if (warning) {
299
+ ask.warning = warning;
300
+ ask.hint = ORPHAN_HINT("ask");
301
+ }
302
+ }
303
+ // Pattern A: chat_endpoint was silently absent from `ish status` even
304
+ // when set (ISSUE-017). Surface it the same shape as workspace/study/ask.
305
+ let chatEndpoint = null;
306
+ if (config.chat_endpoint) {
307
+ chatEndpoint = {
308
+ id: config.chat_endpoint,
309
+ alias: tagAlias(ALIAS_PREFIX.chatEndpoint, config.chat_endpoint),
310
+ };
311
+ const { name, warning } = await lookupName(`/chatbot-endpoints/${config.chat_endpoint}`);
312
+ if (name)
313
+ chatEndpoint.name = name;
314
+ if (warning) {
315
+ chatEndpoint.warning = warning;
316
+ chatEndpoint.hint =
317
+ "Active chat endpoint is no longer accessible. Use `ish chat endpoint use <id>` to switch or `ish chat endpoint use --clear` to drop.";
205
318
  }
206
319
  }
207
320
  // Best-effort skill detection: walks parent directories from cwd so an
@@ -219,12 +332,21 @@ program
219
332
  workspace,
220
333
  study,
221
334
  ask,
335
+ chat_endpoint: chatEndpoint,
222
336
  skill,
223
337
  api_url: apiUrl,
224
338
  home: ishDir(),
225
339
  };
226
- if (tokenError && !token)
227
- payload.hint = `Run \`ish login\`. (${tokenError})`;
340
+ if (tokenError && !token) {
341
+ // Pattern B (ISSUE-001): the inner message already tells the user
342
+ // to run `ish login` (auth.ts emits "Session expired. Run \"ish
343
+ // login\" to re-authenticate." / "No auth token found. Run \"ish
344
+ // login\"…"). Don't re-wrap with another "Run `ish login`. (…)"
345
+ // shell — that produced nested JSON soup with duplicated
346
+ // suggestions in the original bug. Pass the canonical message
347
+ // straight through.
348
+ payload.hint = tokenError;
349
+ }
228
350
  if (globals.json) {
229
351
  output(payload, true);
230
352
  return;
@@ -239,15 +361,31 @@ program
239
361
  return `${h}h${m % 60}m`;
240
362
  return `${m}m`;
241
363
  };
364
+ const renderRef = (ref) => {
365
+ if (!ref)
366
+ return "—";
367
+ const base = `${ref.name ?? "(name unavailable)"} (${ref.alias})`;
368
+ return ref.warning ? `${base} ⚠ ${ref.warning}` : base;
369
+ };
242
370
  console.log(`User: ${user ? `${user.email ?? "(no email)"} (token ${user.token_valid ? "valid" : "expired"}, expires in ${fmtSeconds(user.expires_in_seconds)})` : "(not logged in — run `ish login`)"}`);
243
- console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
244
- console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
245
- console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
371
+ console.log(`Workspace: ${renderRef(workspace)}`);
372
+ console.log(`Study: ${renderRef(study)}`);
373
+ console.log(`Ask: ${renderRef(ask)}`);
374
+ console.log(`Chat ep: ${renderRef(chatEndpoint)}`);
246
375
  console.log(`Skill: ${skillHit ? skillHit.root : "not installed (run `ish init` to install the agent skill)"}`);
247
376
  console.log(`Home: ${ishDir()}`);
248
377
  console.log(`API: ${apiUrl}`);
249
378
  if (tokenError && !token)
250
379
  console.error(`\nHint: ${payload.hint}`);
380
+ // Surface orphan hints once at the bottom so they're visible in human
381
+ // output (the JSON envelope has them inline on each ref).
382
+ for (const [kind, ref] of [["workspace", workspace], ["study", study], ["ask", ask], ["chat_endpoint", chatEndpoint]]) {
383
+ if (ref?.warning) {
384
+ console.error(`\n⚠ ${kind}: ${ref.warning}`);
385
+ if (ref.hint)
386
+ console.error(` → ${ref.hint}`);
387
+ }
388
+ }
251
389
  });
252
390
  });
253
391
  const connectCmd = program
@@ -319,9 +457,31 @@ program
319
457
  .command("upgrade")
320
458
  .description("Update ish to the latest version")
321
459
  .option("--release <version>", "Install a specific release (e.g. 0.8.1)")
322
- .addHelpText("after", "\nPin a specific release with --release <version>. Note: --version is the global CLI-version flag; use --release here.")
323
- .action(async (options) => {
324
- await upgrade(version, options.release);
460
+ .addHelpText("after", `
461
+ Downloads the latest standalone binary from https://ishlabs.io/api/releases/
462
+ and atomically replaces the running executable at \`$execPath\` (typically
463
+ \`~/.ish/bin/ish\`). TLS-verified; no checksum. Already-up-to-date is a
464
+ no-op.
465
+
466
+ Pin a specific release with --release <version>. Note: --version is the
467
+ global CLI-version flag; use --release here.
468
+
469
+ Requires a standalone binary install (curl|sh or brew). On npm-installed
470
+ CLIs (\`npm install -g @ishlabs/cli\`), upgrade refuses with an exit-code-2
471
+ usage error and a pointer to \`npm install -g @ishlabs/cli@latest\` —
472
+ overwriting \`node\` would break every other Node tool.
473
+
474
+ Examples:
475
+ $ ish upgrade
476
+ $ ish upgrade --release 0.20.0`)
477
+ // Pattern C: wrap in runInline so errors flow through outputError +
478
+ // exitCodeFromError. Previously the raw Error was caught by Commander's
479
+ // default handler — leaked bun/node stack traces and exited 0 on
480
+ // validation failures like `--release "not-a-version"` (ISSUE-012).
481
+ .action(async (options, cmd) => {
482
+ await runInline(cmd, async (_globals) => {
483
+ await upgrade(version, options.release);
484
+ });
325
485
  });
326
486
  injectGlobalWorkspaceOption(program);
327
487
  // L3 / Pattern J: surface --get and --fields on every verb's --help.
@@ -344,11 +504,19 @@ function injectAgentTipsFooter(cmd) {
344
504
  }
345
505
  }
346
506
  injectAgentTipsFooter(program);
347
- // Single source of `client.surface` baggage: fires before EVERY
348
- // subcommand's action handler. `actionCommand.name()` is the leaf
507
+ // Single source of `client.surface` + `workspace_id` baggage: fires before
508
+ // EVERY subcommand's action handler. `actionCommand.name()` is the leaf
349
509
  // command name (e.g. "list" inside `ish workspace list`). For groups,
350
510
  // Commander joins names via `.parent` — flatten to the dotted path so
351
511
  // the backend sees `client.surface=workspace.list` not just `list`.
512
+ //
513
+ // Workspace resolution mirrors `resolveWorkspace` in command-helpers.ts:
514
+ // (1) merged --workspace from action + program globals, (2) ISH_WORKSPACE,
515
+ // (3) the persisted active workspace in ~/.ish/config.json. Aliases
516
+ // (`w-abc`) are normalised to UUIDs via `resolveId`. Resolution is
517
+ // best-effort here — if the value is a bogus alias the action's own
518
+ // `resolveWorkspace` will throw the right error; we just skip stamping
519
+ // baggage rather than crashing the preAction hook.
352
520
  program.hook("preAction", (_thisCommand, actionCommand) => {
353
521
  const parts = [];
354
522
  let cmd = actionCommand;
@@ -356,6 +524,21 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
356
524
  parts.unshift(cmd.name());
357
525
  cmd = cmd.parent;
358
526
  }
359
- setSurfaceBaggage(parts.join(".") || actionCommand.name());
527
+ const surface = parts.join(".") || actionCommand.name();
528
+ let workspaceId;
529
+ try {
530
+ const merged = actionCommand.optsWithGlobals();
531
+ const raw = (typeof merged.workspace === "string" && merged.workspace) ||
532
+ process.env.ISH_WORKSPACE ||
533
+ loadConfig().workspace ||
534
+ undefined;
535
+ if (raw)
536
+ workspaceId = resolveId(raw);
537
+ }
538
+ catch {
539
+ // Bogus alias / unresolvable: leave workspace_id off baggage; the
540
+ // action's own resolver will surface a clean error.
541
+ }
542
+ setSurfaceBaggage({ surface, workspaceId });
360
543
  });
361
544
  program.parse();
@@ -122,7 +122,12 @@ const HYDRATE_HINT = {
122
122
  s: "ish study list",
123
123
  i: "ish iteration list --study <study-id>",
124
124
  p: "ish person list",
125
- ps: "ish source list",
125
+ // ISSUE-038 (partial): `ish source list` is not implemented in 0.19.0.
126
+ // Until it lands, advertise the upload-from-known-path or get-by-uuid
127
+ // workflows rather than the non-existent list command. The deterministic
128
+ // alias map at ~/.ish/aliases.json carries any sources the CLI has
129
+ // touched in this session.
130
+ ps: "ish source upload <file> # or `cat ~/.ish/aliases.json | grep ^ps-` to recover prior aliases",
126
131
  pt: "ish participant get <participant-id>",
127
132
  c: "ish config list",
128
133
  a: "ish ask list",
@@ -158,11 +163,25 @@ export function resolveId(input) {
158
163
  const uuid = aliases[input];
159
164
  if (uuid)
160
165
  return uuid;
161
- throw new Error(`Unknown alias "${input}". Run \`${hintForPrefix(input)}\` first to generate aliases.`);
166
+ // Pattern D + ISSUE-038 follow-through: tag as `not_found` for
167
+ // exit-code-4 consistency with the alias-format-unknown branch and
168
+ // the server's 404 path. Splits the hint onto its own line so the
169
+ // recovery suggestion reads cleanly even when the hint contains
170
+ // embedded comments (e.g. for `ps-` which has no list command yet).
171
+ const aliasErr = new Error(`Unknown alias "${input}". To generate aliases, run:\n ${hintForPrefix(input)}`);
172
+ aliasErr.error_code = "not_found";
173
+ throw aliasErr;
162
174
  }
163
- // 3. Anything else — fail with helpful guidance
164
- throw new Error(`Invalid ID "${input}". Use a short alias (e.g. w-a3f, s-b2c) or a full UUID.\n` +
175
+ // 3. Anything else — fail with helpful guidance.
176
+ // Pattern D (ISSUE-029): tag as `not_found` so the alias-format-unknown
177
+ // path returns the same envelope shape and exit code (4) as the server's
178
+ // 404 path. Previously the local pre-check returned exit 2 / client_error
179
+ // while the server 404 returned exit 4 / not_found — asymmetric for the
180
+ // same user-visible outcome ("that thing doesn't exist").
181
+ const err = new Error(`Invalid ID "${input}". Use a short alias (e.g. w-a3f, s-b2c) or a full UUID.\n` +
165
182
  "Run a list command first to see available aliases:\n" +
166
183
  " ish workspace list\n" +
167
184
  " ish study list --workspace <id>");
185
+ err.error_code = "not_found";
186
+ throw err;
168
187
  }
package/dist/lib/auth.js CHANGED
@@ -77,8 +77,20 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
77
77
  if (err instanceof TypeError || (err instanceof Error && err.message.includes('fetch'))) {
78
78
  throw new Error('Could not refresh session (network error). Check your connection or run "ish login".');
79
79
  }
80
- const detail = err instanceof Error && err.message.startsWith("Token refresh failed") ? ` (${err.message})` : "";
81
- throw new Error(`Session expired. Run "ish login" to re-authenticate.${detail}`);
80
+ // Pattern B (ISSUE-001 / ISSUE-008): default-mode error is the
81
+ // clean sentence; the raw Supabase HTTP body is only useful for
82
+ // debugging and surfacing it in the `hint` field produces nested
83
+ // JSON soup in `ish status`. Gate behind `--verbose`. Detection
84
+ // is via argv (process.argv) rather than threading globals through
85
+ // because resolveToken sits below the command-helpers layer.
86
+ const verbose = process.argv.includes("--verbose");
87
+ const detail = verbose && err instanceof Error && err.message.startsWith("Token refresh failed")
88
+ ? ` (${err.message})`
89
+ : "";
90
+ const sessionErr = new Error(`Session expired. Run "ish login" to re-authenticate.${detail}`);
91
+ // Pattern D continuity: tag for consistent exit code 3 mapping.
92
+ sessionErr.error_code = "auth_failed";
93
+ throw sessionErr;
82
94
  }
83
95
  }
84
96
  if (await verifyToken(accessToken, apiUrl)) {
@@ -93,6 +105,12 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
93
105
  }
94
106
  throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
95
107
  }
96
- // 6. No token found
97
- throw new Error('No auth token found. Run "ish login", set ISH_TOKEN, or pass --token <token> / --token-file <path>.');
108
+ // 6. No token found.
109
+ // Pattern D (ISSUE-010): tag with the same `error_code` that invalid-
110
+ // token errors carry (`auth_failed`) so agents branching on
111
+ // `error_code === "auth_failed"` to re-prompt for login handle both
112
+ // the no-token and invalid-token cases identically.
113
+ const err = new Error('No auth token found. Run "ish login", set ISH_TOKEN, or pass --token <token> / --token-file <path>.');
114
+ err.error_code = "auth_failed";
115
+ throw err;
98
116
  }
@@ -2,10 +2,11 @@
2
2
  * Helpers for setting OTel Baggage entries and propagating them on
3
3
  * outbound fetches.
4
4
  *
5
- * The CLI carries three baggage entries on every backend call so the
6
- * backend can attribute spans + Sentry events to the right surface:
5
+ * The CLI carries four baggage entries on every backend call so the
6
+ * backend can attribute spans + Sentry events to the right surface and,
7
+ * when the command is workspace-scoped, the right workspace:
7
8
  *
8
- * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>
9
+ * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>[,workspace_id=<uuid>]
9
10
  *
10
11
  * In Node mode, `@opentelemetry/instrumentation-undici` will inject the
11
12
  * active baggage onto outbound `fetch` automatically once
@@ -23,13 +24,21 @@ export declare const BAGGAGE_KEY_CLIENT_NAME = "client.name";
23
24
  export declare const BAGGAGE_KEY_CLIENT_SURFACE = "client.surface";
24
25
  export declare const BAGGAGE_KEY_CLIENT_VERSION = "client.version";
25
26
  export declare const BAGGAGE_KEY_CONSENT_ANALYTICS = "consent.analytics";
27
+ export declare const BAGGAGE_KEY_WORKSPACE_ID = "workspace_id";
26
28
  /** The fixed ``client.name`` we tag every CLI invocation with. The plan
27
29
  * pins this string — backend dashboards filter on it. */
28
30
  export declare const CLIENT_NAME = "ish-cli";
31
+ export interface SurfaceBaggageInput {
32
+ surface: string;
33
+ /** Resolved workspace UUID (raw, not peppered). The backend HMACs this
34
+ * at the PostHog boundary; baggage / Sentry / logs see the raw UUID. */
35
+ workspaceId?: string | null;
36
+ }
29
37
  /**
30
38
  * Stamp the active OTel baggage with ``client.name`` / ``client.surface``
31
- * / ``client.version`` and stash a process-local copy for the
32
- * manual-injection ``withBaggage`` helper.
39
+ * / ``client.version`` (always) and ``workspace_id`` (when the command
40
+ * carries one), then stash a process-local copy for the manual-injection
41
+ * ``withBaggage`` helper.
33
42
  *
34
43
  * Idempotent — subsequent calls overwrite the previous surface, which is
35
44
  * what we want when Commander invokes ``preAction`` once per resolved
@@ -49,7 +58,7 @@ export declare const CLIENT_NAME = "ish-cli";
49
58
  * Safe to call even with no OTel SDK registered — `@opentelemetry/api`
50
59
  * ships no-op fallbacks.
51
60
  */
52
- export declare function setSurfaceBaggage(commandName: string): void;
61
+ export declare function setSurfaceBaggage(input: SurfaceBaggageInput): void;
53
62
  /**
54
63
  * Manually inject the active baggage into a ``HeadersInit`` value.
55
64
  *
@@ -2,10 +2,11 @@
2
2
  * Helpers for setting OTel Baggage entries and propagating them on
3
3
  * outbound fetches.
4
4
  *
5
- * The CLI carries three baggage entries on every backend call so the
6
- * backend can attribute spans + Sentry events to the right surface:
5
+ * The CLI carries four baggage entries on every backend call so the
6
+ * backend can attribute spans + Sentry events to the right surface and,
7
+ * when the command is workspace-scoped, the right workspace:
7
8
  *
8
- * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>
9
+ * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>[,workspace_id=<uuid>]
9
10
  *
10
11
  * In Node mode, `@opentelemetry/instrumentation-undici` will inject the
11
12
  * active baggage onto outbound `fetch` automatically once
@@ -25,13 +26,15 @@ export const BAGGAGE_KEY_CLIENT_NAME = "client.name";
25
26
  export const BAGGAGE_KEY_CLIENT_SURFACE = "client.surface";
26
27
  export const BAGGAGE_KEY_CLIENT_VERSION = "client.version";
27
28
  export const BAGGAGE_KEY_CONSENT_ANALYTICS = "consent.analytics";
29
+ export const BAGGAGE_KEY_WORKSPACE_ID = "workspace_id";
28
30
  /** The fixed ``client.name`` we tag every CLI invocation with. The plan
29
31
  * pins this string — backend dashboards filter on it. */
30
32
  export const CLIENT_NAME = "ish-cli";
31
33
  /**
32
34
  * Stamp the active OTel baggage with ``client.name`` / ``client.surface``
33
- * / ``client.version`` and stash a process-local copy for the
34
- * manual-injection ``withBaggage`` helper.
35
+ * / ``client.version`` (always) and ``workspace_id`` (when the command
36
+ * carries one), then stash a process-local copy for the manual-injection
37
+ * ``withBaggage`` helper.
35
38
  *
36
39
  * Idempotent — subsequent calls overwrite the previous surface, which is
37
40
  * what we want when Commander invokes ``preAction`` once per resolved
@@ -51,17 +54,20 @@ export const CLIENT_NAME = "ish-cli";
51
54
  * Safe to call even with no OTel SDK registered — `@opentelemetry/api`
52
55
  * ships no-op fallbacks.
53
56
  */
54
- export function setSurfaceBaggage(commandName) {
57
+ export function setSurfaceBaggage(input) {
55
58
  const existing = propagation.getActiveBaggage() ?? propagation.createBaggage();
56
- const next = existing
59
+ let next = existing
57
60
  .setEntry(BAGGAGE_KEY_CLIENT_NAME, { value: CLIENT_NAME })
58
- .setEntry(BAGGAGE_KEY_CLIENT_SURFACE, { value: commandName })
61
+ .setEntry(BAGGAGE_KEY_CLIENT_SURFACE, { value: input.surface })
59
62
  .setEntry(BAGGAGE_KEY_CLIENT_VERSION, { value: pkg.version })
60
63
  // CLI runs on a developer's machine with no client-side consent UI.
61
64
  // Emit "unknown" so the backend resolver falls through to the
62
65
  // authenticated user's persisted consent rather than default-denying
63
66
  // via baggage absence.
64
67
  .setEntry(BAGGAGE_KEY_CONSENT_ANALYTICS, { value: "unknown" });
68
+ if (input.workspaceId) {
69
+ next = next.setEntry(BAGGAGE_KEY_WORKSPACE_ID, { value: input.workspaceId });
70
+ }
65
71
  _activeBaggage = next;
66
72
  }
67
73
  /** Process-local copy of the active baggage for the manual-injection path.
@@ -23,6 +23,7 @@ export interface PersonResolveOpts {
23
23
  /** Profile IDs to exclude from the fetched pool (e.g. participants already on an ask). */
24
24
  excludeProfileIds?: Set<string>;
25
25
  }
26
+ export declare function normalizeVisibility(raw: string | undefined): string | undefined;
26
27
  /** True when any person-selection flag is set on the command. */
27
28
  export declare function hasPersonFlags(flags: PersonFilterOpts): boolean;
28
29
  /**