@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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ish ask — Create and run asks (multi-round surveys with variants).
3
3
  */
4
- import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveAsk, collectRepeatable, parseWaitTimeout, resolvePersonIds, addPersonFilterFlags, } from "../lib/command-helpers.js";
4
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveAsk, collectRepeatable, parseWaitTimeout, resolvePersonIds, addPersonFilterFlags, confirmDestructive, } from "../lib/command-helpers.js";
5
5
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
6
  import { loadConfig, saveConfig } from "../config.js";
7
7
  import { formatAskList, formatAskDetail, formatRoundDetail, formatAskResults, output, } from "../lib/output.js";
@@ -927,9 +927,33 @@ Examples:
927
927
  .description("Delete an ask and all its rounds, responses, and participants")
928
928
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
929
929
  .option("--ask <id>", "Ask ID; alternative to positional argument")
930
- .addHelpText("after", "\nExamples:\n $ ish ask delete a-6ec")
930
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
931
+ .addHelpText("after", `
932
+ Examples:
933
+ $ ish ask delete a-6ec # interactive — prompts for confirmation
934
+ $ ish ask delete a-6ec --yes # non-interactive
935
+ $ ish ask delete a-6ec --json --yes`)
931
936
  .action(async (id, opts, cmd) => {
932
937
  await withClient(cmd, async (client, globals) => {
938
+ // Code-review #5: when neither positional id nor --ask is provided,
939
+ // we fall back to the active ask in config. If config has no active
940
+ // ask either, the prompt would say "Delete the active ask..." and
941
+ // then error AFTER confirmation with "No active ask" — confusing.
942
+ // Detect the empty-ref case BEFORE the prompt so the user sees
943
+ // the actionable error first.
944
+ const ref = id ?? opts.ask;
945
+ if (!ref) {
946
+ const config = loadConfig();
947
+ if (!config.ask) {
948
+ throw new Error("No active ask to delete. Pass an ask id (positional or --ask <id>), or run `ish ask use <id>` to set one first.");
949
+ }
950
+ }
951
+ // Pattern G follow-up: confirm BEFORE resolving so a typo'd alias
952
+ // (e.g. `a-fff`) doesn't bypass the guard. Show the raw input in
953
+ // the prompt; the alias resolution happens after the user
954
+ // confirms.
955
+ const displayRef = ref ?? "active ask";
956
+ await confirmDestructive(`Delete ${displayRef === "active ask" ? "the active ask" : `ask ${displayRef}`} (and all its rounds, responses, participants)? This cannot be undone.`, { yes: opts.yes, json: globals.json });
933
957
  const aid = resolveAsk(pickAskRef(id, opts.ask));
934
958
  await client.del(`/asks/${aid}`);
935
959
  // If we just deleted the active ask, clear it from config.
@@ -12,7 +12,15 @@ export function registerConfigCommands(program) {
12
12
  A simulation config tunes how participants behave during a run (model, timing, retries, etc.).
13
13
  Pass \`--config <id>\` to \`ish study run\` to override the default for one dispatch.
14
14
 
15
- Configs are global — not scoped to a workspace. The --workspace flag is rejected. Use aliases (c-...) or UUIDs to identify configs.
15
+ Configs are global — not scoped to a workspace. Passing \`--workspace\` to a
16
+ \`config\` command is silently ignored (the global flag is parsed; the value
17
+ has no effect on which configs are returned). Identify configs by alias
18
+ (\`c-...\`) or UUID.
19
+
20
+ Note: listing simulation configs requires admin privileges. Non-admins get a
21
+ \`forbidden\` (403) envelope on \`ish config list\`. If you need a specific
22
+ config, pass it directly via \`ish study run --config <id>\`; the run-time
23
+ override doesn't require admin access.
16
24
 
17
25
  Run \`ish docs overview\` for the full mental model.`);
18
26
  config
@@ -85,13 +85,12 @@ export function registerDocsCommands(program) {
85
85
  const docs = program
86
86
  .command("docs")
87
87
  .description("Offline docs for agents — mental model, concept pages, search")
88
- .addHelpText("after", `\nSubcommands:
89
- overview Mental model + how to navigate (default)
90
- list All pages with one-line descriptions
91
- get-page <slug> Full markdown for one page
92
- search <query> Keyword search across all pages
93
-
94
- Examples:
88
+ .addHelpText("after",
89
+ // ISSUE-013: dropped the manual "Subcommands:" block — Commander's
90
+ // auto-generated "Commands:" section already lists the verbs. Keeping
91
+ // just Examples avoids the near-duplicate that was visible on
92
+ // `ish docs --help`.
93
+ `\nExamples:
95
94
  $ ish docs # overview
96
95
  $ ish docs list
97
96
  $ ish docs get-page concepts/study
@@ -2,7 +2,7 @@
2
2
  * ish person — Manage people, generation, and source uploads.
3
3
  */
4
4
  import fs from "node:fs";
5
- import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
5
+ import { withClient, readJsonFileOrStdin, resolveWorkspace, confirmDestructive, normalizeVisibility } from "../lib/command-helpers.js";
6
6
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
7
7
  import { formatPersonList, output, } from "../lib/output.js";
8
8
  import { resolveTextContent } from "../lib/upload.js";
@@ -37,6 +37,12 @@ Concept pages: ish docs get-page concepts/person
37
37
  .option("--country <country>", "Filter by country code, e.g. US (repeatable)", collect, [])
38
38
  .option("--min-age <n>", "Minimum age")
39
39
  .option("--max-age <n>", "Maximum age")
40
+ // ISSUE-032: asymmetric — `ask create` and `study run` accept
41
+ // `--visibility workspace|shared|platform` but `person list` didn't,
42
+ // so a user looking for "people scoped to MY workspace" had to
43
+ // download all 13k+ platform people and grep client-side. Same
44
+ // normalizer + flag wording as the sibling commands.
45
+ .option("--visibility <v>", "Filter by visibility: workspace | shared | platform (legacy 'private'/'public' accepted as aliases)")
40
46
  .option("--limit <n>", "Max results (default 50)", "50")
41
47
  .option("--offset <n>", "Offset for pagination", "0")
42
48
  .addHelpText("after", `
@@ -47,6 +53,8 @@ Examples:
47
53
  $ ish person list --occupation founder --occupation designer
48
54
  $ ish person list --gender female --gender male --country US --country GB
49
55
  $ ish person list --type all --json
56
+ $ ish person list --visibility workspace # only workspace-scoped people
57
+ $ ish person list --visibility platform --country US # platform pool by country
50
58
 
51
59
  # Pagination: default --limit is 50, iterate with --offset.
52
60
  $ ish person list --limit 100
@@ -75,6 +83,9 @@ Examples:
75
83
  params.min_age = opts.minAge;
76
84
  if (opts.maxAge)
77
85
  params.max_age = opts.maxAge;
86
+ const normVis = normalizeVisibility(opts.visibility);
87
+ if (normVis)
88
+ params.visibility = normVis;
78
89
  const data = await client.get("/people", params);
79
90
  formatPersonList(data, globals.json, parseInt(opts.limit, 10));
80
91
  // Pattern H1: when there's more data, surface a stderr hint so agents
@@ -104,13 +115,110 @@ Examples:
104
115
  });
105
116
  person
106
117
  .command("create")
107
- .description("Create a person from an exact JSON spec (no LLM)")
108
- .requiredOption("--file <path>", "JSON file with person data")
118
+ .description("Create a person from an exact JSON spec (no LLM). Accepts inline flags or --file.")
119
+ // ISSUE-033: previously --file was the ONLY accepted input — asymmetric
120
+ // with `person update` which has a rich inline-flag surface. First-time
121
+ // users reading `person update --help` reasonably expected the same on
122
+ // create. Now mirrored: --file is optional, inline flags compose into
123
+ // the body, inline flags override --file values.
124
+ .option("--file <path>", "JSON file with person data (or '-' for stdin). Escape hatch for fields not covered by inline flags.")
125
+ .option("--name <text>", "Person name (required if --file omitted)")
126
+ .option("--type <value>", "Person type: ai | human (default: ai when --file omitted)")
127
+ .option("--description <text>", "Person description")
128
+ .option("--bio <text>", "Person bio")
129
+ .option("--occupation <text>", "Occupation")
130
+ .option("--country <code>", "Country code, e.g. US")
131
+ .option("--gender <g>", "Gender, e.g. female")
132
+ .option("--date-of-birth <YYYY-MM-DD>", "Date of birth")
133
+ .option("--education-level <value>", `Education level. One of: ${EDUCATION_LEVELS.join(", ")}`)
134
+ .option("--household <value>", `Household composition (MECE). One of: ${HOUSEHOLDS.join(", ")}. A couple raising children is couple_with_kids, not couple_no_kids; "single" means lives alone with no partner, roommates, parents, or children in the household.`)
135
+ .option("--locale-type <value>", `Self-described neighborhood type. One of: ${LOCALE_TYPES.join(", ")}`)
136
+ .option("--income-level <value>", `Self-identified relative socioeconomic position. One of: ${INCOME_LEVELS.join(", ")}`)
137
+ .option("--employment-status <value>", `Primary daytime activity / labor-force status. One of: ${EMPLOYMENT_STATUSES.join(", ")}`)
138
+ .option("--accessibility-profile <json-or-path>", "AccessibilityProfile v1.0 as an inline JSON string OR a path to a JSON file. Empty object {} is the canonical default. Validated client-side against the spec before submit.")
109
139
  .option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
110
- .addHelpText("after", "\nExamples:\n $ ish person create --file profile.json\n\n Expected JSON: { \"name\": \"...\", \"type\": \"ai\", \"gender\": \"female\", \"country\": \"US\", \"occupation\": \"...\", \"bio\": \"...\" }")
140
+ .addHelpText("after", `
141
+ Examples:
142
+ # Inline (mirrors person update):
143
+ $ ish person create --name "Alice" --type ai --country US
144
+ $ ish person create --name "Bob" --gender male --country GB --occupation founder \\
145
+ --household single --locale-type urban --income-level middle \\
146
+ --employment-status employed_full_time --bio "..."
147
+
148
+ # From file:
149
+ $ ish person create --file profile.json
150
+
151
+ # From stdin:
152
+ $ cat profile.json | ish person create --file -
153
+
154
+ # Compose file + inline (inline overrides):
155
+ $ ish person create --file profile.json --bio "Updated bio for this run"
156
+
157
+ Inline flags compose into the create body. --file is an escape hatch for
158
+ fields not covered by inline flags. When both are provided, inline flags
159
+ override values from --file.
160
+
161
+ Minimum required (when --file omitted): --name (--type defaults to "ai").
162
+
163
+ Household MECE rule: a couple raising children is \`couple_with_kids\`, not
164
+ \`couple_no_kids\`. \`single\` means lives alone with no partner, roommates,
165
+ parents, or children sharing the household.
166
+
167
+ \`--accessibility-profile\` accepts either an inline JSON string OR a path to
168
+ a JSON file. Schema: https://ishlabs.io/spec/accessibility-profile-schema.v1.json`)
111
169
  .action(async (opts, cmd) => {
112
170
  await withClient(cmd, async (client, globals) => {
113
- const body = await readJsonFileOrStdin(opts.file);
171
+ let body = {};
172
+ if (opts.file) {
173
+ body = (await readJsonFileOrStdin(opts.file));
174
+ }
175
+ // Inline flags compose / override the file body (mirrors person update).
176
+ if (opts.name !== undefined)
177
+ body.name = opts.name;
178
+ if (opts.type !== undefined)
179
+ body.type = opts.type;
180
+ if (opts.description !== undefined)
181
+ body.description = opts.description;
182
+ if (opts.bio !== undefined)
183
+ body.bio = opts.bio;
184
+ if (opts.occupation !== undefined)
185
+ body.occupation = opts.occupation;
186
+ if (opts.country !== undefined)
187
+ body.country = opts.country;
188
+ if (opts.gender !== undefined)
189
+ body.gender = opts.gender;
190
+ if (opts.dateOfBirth !== undefined)
191
+ body.date_of_birth = opts.dateOfBirth;
192
+ if (opts.educationLevel !== undefined) {
193
+ body.education_level = assertEnumValue(opts.educationLevel, EDUCATION_LEVELS, "--education-level");
194
+ }
195
+ if (opts.household !== undefined) {
196
+ body.household = assertEnumValue(opts.household, HOUSEHOLDS, "--household");
197
+ }
198
+ if (opts.localeType !== undefined) {
199
+ body.locale_type = assertEnumValue(opts.localeType, LOCALE_TYPES, "--locale-type");
200
+ }
201
+ if (opts.incomeLevel !== undefined) {
202
+ body.income_level = assertEnumValue(opts.incomeLevel, INCOME_LEVELS, "--income-level");
203
+ }
204
+ if (opts.employmentStatus !== undefined) {
205
+ body.employment_status = assertEnumValue(opts.employmentStatus, EMPLOYMENT_STATUSES, "--employment-status");
206
+ }
207
+ if (opts.accessibilityProfile !== undefined) {
208
+ body.accessibility_profile = parseAccessibilityProfileFlag(opts.accessibilityProfile);
209
+ }
210
+ // Type default — keeps the inline-only path ergonomic without
211
+ // requiring `--type ai` on every minimal create.
212
+ if (body.name && body.type === undefined) {
213
+ body.type = "ai";
214
+ }
215
+ // Without --file AND with no inline flags, the body has only
216
+ // product_id — that would 422 server-side with no useful guidance.
217
+ // Provide a clear local error first.
218
+ if (!opts.file && body.name === undefined) {
219
+ throw new Error("Nothing to create. Provide --file <path> or at least --name (plus optional inline flags). " +
220
+ "Run `ish person create --help` for the full inline-flag set.");
221
+ }
114
222
  if (opts.workspace || body.product_id == null) {
115
223
  body.product_id = resolveWorkspace(opts.workspace);
116
224
  }
@@ -135,7 +243,7 @@ Examples:
135
243
  .option("--no-scenarios", "Skip fetching the evidence-grounded scenarios for each generated person")
136
244
  .option("--no-wait", "Don't poll source-processing status. Only relevant when --source is a local path (paths get auto-uploaded and processed).")
137
245
  .option("--source-timeout <seconds>", "Source-processing poll timeout in seconds. Only relevant when --source is a local path.", "300")
138
- .option("--timeout <seconds>", "Generation-job poll timeout in seconds (default 600). The job keeps running server-side past this; re-poll, don't re-enqueue.", "600")
246
+ .option("--timeout <seconds>", "Generation-job poll timeout in seconds (default 180; pass a larger value for long-running jobs). The job keeps running server-side past this; re-poll later or re-run with a longer --timeout — don't re-enqueue, that would duplicate the work.", "180")
139
247
  .addHelpText("after", `
140
248
  Generation is an async job: ish enqueues it, polls until the people plus
141
249
  their evidence-grounded scenarios are ready (~30-60s), then prints them. The
@@ -428,12 +536,18 @@ Schema: https://ishlabs.io/spec/accessibility-profile-schema.v1.json`)
428
536
  .description("Delete a person")
429
537
  .argument("<id>", "Person ID")
430
538
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the person)")
431
- .addHelpText("after", "\nExamples:\n $ ish person delete <id>")
432
- .action(async (id, _opts, cmd) => {
539
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
540
+ .addHelpText("after", `
541
+ Examples:
542
+ $ ish person delete <id> # interactive — prompts for confirmation
543
+ $ ish person delete <id> --yes # non-interactive
544
+ $ ish person delete <id> --json --yes`)
545
+ .action(async (id, opts, cmd) => {
433
546
  await withClient(cmd, async (client, globals) => {
434
547
  const rid = resolveId(id);
548
+ await confirmDestructive(`Delete person ${tagAlias(ALIAS_PREFIX.person, rid)}? This cannot be undone.`, { yes: opts.yes, json: globals.json });
435
549
  await client.del(`/people/${rid}`);
436
- output({ id: rid, alias: tagAlias(ALIAS_PREFIX.person, rid), message: "Profile deleted" }, globals.json, { writePath: true });
550
+ output({ id: rid, alias: tagAlias(ALIAS_PREFIX.person, rid), message: "Person deleted" }, globals.json, { writePath: true });
437
551
  });
438
552
  });
439
553
  person
@@ -16,7 +16,7 @@
16
16
  * of shell history.
17
17
  * - No interactive prompts (CLI is for autonomous agents).
18
18
  */
19
- import { withClient, resolveWorkspace, readFileOrStdin } from "../lib/command-helpers.js";
19
+ import { withClient, resolveWorkspace, readFileOrStdin, confirmDestructive } from "../lib/command-helpers.js";
20
20
  import { output } from "../lib/output.js";
21
21
  const RESERVED_SITE_ACCESS_KEYS = new Set([
22
22
  "BASIC_AUTH_USERNAME",
@@ -127,6 +127,8 @@ Examples:
127
127
  .option("--workspace <id>", "Workspace ID; defaults to active workspace")
128
128
  .addHelpText("after", `
129
129
  Pick exactly one of: positional <value>, --value-file <path>, --value-stdin.
130
+ Bare \`-\` as the positional value is accepted as an alias for --value-stdin
131
+ (matches the \`--value-file -\` convention).
130
132
  Stdin and --value-file are preferred for real keys so the value never lands
131
133
  in shell history.
132
134
 
@@ -138,6 +140,16 @@ Examples:
138
140
  .action(async (key, value, opts, cmd) => {
139
141
  await withClient(cmd, async (client, globals) => {
140
142
  validateKey(key);
143
+ // ISSUE-019: bare `-` as the positional value is a common Unix
144
+ // shorthand for "read from stdin" (matches `--value-file -`'s
145
+ // existing convention). Previously bare `-` was silently stored
146
+ // as the literal string "-" — undocumented + surprising. Treat
147
+ // it as an alias for --value-stdin so the convention matches
148
+ // user expectation. Documented in --help below.
149
+ if (value === "-") {
150
+ value = undefined;
151
+ opts.valueStdin = true;
152
+ }
141
153
  // Exactly one input source.
142
154
  const sources = [
143
155
  value !== undefined,
@@ -212,7 +224,12 @@ Examples:
212
224
  .description("Delete a workspace secret by key")
213
225
  .argument("<key>", "Secret key to delete")
214
226
  .option("--workspace <id>", "Workspace ID; defaults to active workspace")
215
- .addHelpText("after", "\nExamples:\n $ ish secret delete GROQ_API_KEY")
227
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
228
+ .addHelpText("after", `
229
+ Examples:
230
+ $ ish secret delete GROQ_API_KEY # interactive — prompts for confirmation
231
+ $ ish secret delete GROQ_API_KEY --yes # non-interactive
232
+ $ ish secret delete GROQ_API_KEY --json --yes`)
216
233
  .action(async (key, opts, cmd) => {
217
234
  await withClient(cmd, async (client, globals) => {
218
235
  if (!KEY_PATTERN.test(key)) {
@@ -220,6 +237,12 @@ Examples:
220
237
  err.name = "ValidationError";
221
238
  throw err;
222
239
  }
240
+ // Pattern G follow-up: the original Pattern G fix covered
241
+ // workspace/person/source delete (commit 891b22d), then a
242
+ // follow-up added ask delete. `secret delete` was the seventh
243
+ // destructive op and slipped through; rolling the guard in
244
+ // for full consistency.
245
+ await confirmDestructive(`Delete secret "${key}"? This cannot be undone.`, { yes: opts.yes, json: globals.json });
223
246
  const wid = resolveWorkspace(opts.workspace);
224
247
  const all = await client.get(`/products/${wid}/secrets`);
225
248
  const match = all.find((s) => s.key === key);
@@ -2,7 +2,7 @@
2
2
  * ish source — Upload and inspect participant attachments used as generation inputs.
3
3
  *
4
4
  * Attachments (transcripts, audio, images, PDFs) are inputs to `ish person
5
- * generate`. For one-shot generation, `profile generate --source <path>`
5
+ * generate`. For one-shot generation, `ish person generate --source <path>`
6
6
  * auto-uploads. Use these commands to upload once and reuse across multiple
7
7
  * generation runs, or to inspect processing status.
8
8
  *
@@ -2,7 +2,7 @@
2
2
  * ish source — Upload and inspect participant attachments used as generation inputs.
3
3
  *
4
4
  * Attachments (transcripts, audio, images, PDFs) are inputs to `ish person
5
- * generate`. For one-shot generation, `profile generate --source <path>`
5
+ * generate`. For one-shot generation, `ish person generate --source <path>`
6
6
  * auto-uploads. Use these commands to upload once and reuse across multiple
7
7
  * generation runs, or to inspect processing status.
8
8
  *
@@ -10,7 +10,7 @@
10
10
  * existing scripts and agents; internally we call the unified
11
11
  * /people/attachments/* endpoint family (see ADR-0034).
12
12
  */
13
- import { withClient, resolveWorkspace } from "../lib/command-helpers.js";
13
+ import { withClient, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
14
14
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
15
15
  import { normalizeEnumValue } from "../lib/enums.js";
16
16
  import { formatAttachment, output } from "../lib/output.js";
@@ -24,7 +24,7 @@ export function registerSourceCommands(program) {
24
24
  A source — internally a participant attachment — is an input to \`ish person generate\`:
25
25
  transcript, audio, image, or PDF. Use \`source upload\` when you want to reuse the
26
26
  same attachment across multiple generation runs; otherwise pass a local path directly
27
- to \`profile generate --source\` and it auto-uploads.
27
+ to \`person generate --source\` and it auto-uploads.
28
28
 
29
29
  Concept pages: ish docs get-page concepts/source
30
30
  ish docs get-page concepts/profile`);
@@ -86,21 +86,25 @@ Examples:
86
86
  .command("delete")
87
87
  .description("Delete a participant attachment plus its uploaded file")
88
88
  .argument("<id>", "Attachment ID or alias")
89
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
89
90
  .addHelpText("after", `
90
91
  The backend ref-counts attachment deletes: the file row + storage object are
91
92
  removed only when no profile mappings remain. Profiles already generated from
92
93
  this attachment keep their seed mapping until they themselves are deleted.
93
94
 
94
95
  Examples:
95
- $ ish source delete ps-3a4`)
96
- .action(async (id, _opts, cmd) => {
96
+ $ ish source delete ps-3a4 # interactive — prompts for confirmation
97
+ $ ish source delete ps-3a4 --yes # non-interactive
98
+ $ ish source delete ps-3a4 --json --yes`)
99
+ .action(async (id, opts, cmd) => {
97
100
  await withClient(cmd, async (client, globals) => {
98
101
  const rid = resolveId(id);
102
+ await confirmDestructive(`Delete source ${tagAlias(ALIAS_PREFIX.personSource, rid)}? This cannot be undone.`, { yes: opts.yes, json: globals.json });
99
103
  await client.del(`/people/attachments/${rid}`);
100
104
  output({
101
105
  id: rid,
102
106
  alias: tagAlias(ALIAS_PREFIX.personSource, rid),
103
- message: "Attachment deleted",
107
+ message: "Source deleted",
104
108
  }, globals.json, { writePath: true });
105
109
  });
106
110
  });
@@ -544,8 +544,18 @@ Next: configure a run with \`ish iteration create --study <id>\`,
544
544
  const data = await client.post(`/products/${resolvedWs}/studies`, body);
545
545
  if (data.id) {
546
546
  const config = loadConfig();
547
+ const prevStudy = config.study;
547
548
  config.study = data.id;
548
549
  saveConfig(config);
550
+ // Auto-activating the new study is intentional ergonomics (the
551
+ // common next step is `ish iteration create --study <new>`), but
552
+ // it was previously silent — surprised users who had set a
553
+ // different active study just before (ISSUE-030). Always
554
+ // surface the side-effect on stderr.
555
+ if (!globals.json) {
556
+ const verb = prevStudy && prevStudy !== data.id ? "replaced" : "set";
557
+ console.error(`Active study ${verb} to "${data.name || data.id}".`);
558
+ }
549
559
  }
550
560
  const result = data;
551
561
  if (result.id)
@@ -856,6 +866,15 @@ checklists ("steps") ride along when present in the JSON forms
856
866
  json: globals.json,
857
867
  });
858
868
  await client.del(`/studies/${rid}`);
869
+ // If the deleted study was active, clear it (Pattern A — parallel to
870
+ // ask delete and chat endpoint delete which already do this).
871
+ const config = loadConfig();
872
+ if (config.study === rid) {
873
+ delete config.study;
874
+ saveConfig(config);
875
+ if (!globals.json)
876
+ console.error("(Cleared active study.)");
877
+ }
859
878
  output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
860
879
  });
861
880
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ish workspace — Manage workspaces (API: /products).
3
3
  */
4
- import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
4
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
5
5
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
6
  import { loadConfig, saveConfig } from "../config.js";
7
7
  import { formatWorkspaceList, formatWorkspaceDetail, formatSiteAccessStatus, output } from "../lib/output.js";
@@ -134,13 +134,35 @@ existing workspace was returned. On creation, \`reused: false\`.`)
134
134
  });
135
135
  workspace
136
136
  .command("delete")
137
- .description("Delete a workspace")
137
+ .description("Delete a workspace (and ALL nested studies, asks, people, secrets, configs, sources, chat endpoints)")
138
138
  .argument("<id>", "Workspace ID")
139
- .addHelpText("after", "\nExamples:\n $ ish workspace delete <id>")
140
- .action(async (id, _opts, cmd) => {
139
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
140
+ .addHelpText("after", `
141
+ Deleting a workspace is the highest-blast-radius destructive op in the CLI:
142
+ it removes ALL nested studies, asks, people, secrets, configs, sources, and
143
+ chat endpoints. This cannot be undone.
144
+
145
+ Examples:
146
+ $ ish workspace delete <id> # interactive — prompts for confirmation
147
+ $ ish workspace delete <id> --yes # non-interactive
148
+ $ ish workspace delete <id> --json --yes`)
149
+ .action(async (id, opts, cmd) => {
141
150
  await withClient(cmd, async (client, globals) => {
142
151
  const rid = resolveId(id);
152
+ await confirmDestructive(`Delete workspace ${tagAlias(ALIAS_PREFIX.workspace, rid)}? This will delete ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints. This cannot be undone.`, { yes: opts.yes, json: globals.json });
143
153
  await client.del(`/products/${rid}`);
154
+ // If the deleted workspace was active, clear it + its scoped children
155
+ // so subsequent commands don't render orphan refs (Pattern A).
156
+ const config = loadConfig();
157
+ if (config.workspace === rid) {
158
+ delete config.workspace;
159
+ delete config.study;
160
+ delete config.ask;
161
+ delete config.chat_endpoint;
162
+ saveConfig(config);
163
+ if (!globals.json)
164
+ console.error("(Cleared active workspace + study / ask / chat endpoint.)");
165
+ }
144
166
  output({ id: rid, alias: tagAlias(ALIAS_PREFIX.workspace, rid), message: "Workspace deleted" }, globals.json, { writePath: true });
145
167
  });
146
168
  });
@@ -187,9 +209,12 @@ Examples:
187
209
  if (opts.clear) {
188
210
  const config = loadConfig();
189
211
  delete config.workspace;
212
+ // workspace-scoped children: clearing the workspace orphans them all.
190
213
  delete config.study;
214
+ delete config.ask;
215
+ delete config.chat_endpoint;
191
216
  saveConfig(config);
192
- console.error("Cleared active workspace (and study).");
217
+ console.error("Cleared active workspace (and active study / ask / chat endpoint).");
193
218
  return;
194
219
  }
195
220
  if (!id) {
@@ -199,10 +224,20 @@ Examples:
199
224
  const rid = resolveId(id);
200
225
  const data = await client.get(`/products/${rid}`);
201
226
  const config = loadConfig();
227
+ const switched = config.workspace !== rid;
202
228
  config.workspace = rid;
203
- delete config.study; // study belongs to workspace
229
+ if (switched) {
230
+ // Switching workspaces orphans all workspace-scoped active refs;
231
+ // dropping them avoids silent cross-workspace footguns (ISSUE-004).
232
+ delete config.study;
233
+ delete config.ask;
234
+ delete config.chat_endpoint;
235
+ }
204
236
  saveConfig(config);
205
237
  console.error(`Active workspace set to "${data.name || rid}".`);
238
+ if (switched) {
239
+ console.error("(Cleared active study / ask / chat endpoint — they belonged to the previous workspace.)");
240
+ }
206
241
  });
207
242
  });
208
243
  }