exponential-cli 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,6 +140,7 @@ and live under `goals kr`.
140
140
  exponential goals list --workspace clear --period Q3-2026
141
141
  exponential goals list --workspace clear --tree
142
142
  exponential goals list --all-workspaces # "what am I neglecting" is cross-workspace
143
+ exponential goals list --project <cuid> # objectives (yours) linked to a project
143
144
 
144
145
  exponential goals get 46
145
146
  exponential goals create --workspace clear --title "Ship the CLI" --period Q3-2026
@@ -312,6 +313,27 @@ exponential projects update --id <cuid> --product none
312
313
  exponential projects delete --id <cuid> [--force]
313
314
  ```
314
315
 
316
+ ### Products, tickets, epics and user stories
317
+
318
+ ```bash
319
+ # Products take a slug or a CUID
320
+ exponential products list --workspace clear
321
+ exponential products update <slug|cuid> --workspace clear --description "..." [--fun-ticket-ids|--no-fun-ticket-ids]
322
+ # Deleting a product cascades its features, tickets and epics: refuses unless --force
323
+ exponential products delete <slug|cuid> --workspace clear [--force]
324
+
325
+ # Free-text ticket search within a product: title, shortId or ticket number
326
+ exponential tickets search "login redirect" --product <slug|cuid> --workspace clear [--limit 50]
327
+
328
+ # Epics: their tickets and actions are unlinked, not deleted
329
+ exponential epics delete --id <cuid>
330
+
331
+ # A feature's native user stories
332
+ exponential features stories list --feature <cuid>
333
+ exponential features stories update --id <story-cuid> --i-want "..." [--scope <scope-cuid>|null]
334
+ exponential features stories rm --id <story-cuid>
335
+ ```
336
+
315
337
  ### Workspaces
316
338
 
317
339
  ```bash
package/dist/index.js CHANGED
@@ -1187,6 +1187,41 @@ Tickets (${tickets.length} total)`));
1187
1187
  }
1188
1188
  console.log();
1189
1189
  }
1190
+ function transformTicketSearchHit(hit) {
1191
+ return {
1192
+ id: hit.id,
1193
+ number: hit.number,
1194
+ shortId: hit.shortId,
1195
+ title: hit.title,
1196
+ status: hit.status,
1197
+ priority: hit.priority,
1198
+ assignee: hit.assignee ?? null
1199
+ };
1200
+ }
1201
+ function outputTicketSearchJson(hits, query) {
1202
+ console.log(JSON.stringify({
1203
+ query,
1204
+ tickets: hits.map(transformTicketSearchHit),
1205
+ total: hits.length
1206
+ }, null, 2));
1207
+ }
1208
+ function outputTicketSearchPretty(hits, query) {
1209
+ if (hits.length === 0) {
1210
+ console.log(chalk3.gray(`No tickets match "${query}".`));
1211
+ return;
1212
+ }
1213
+ console.log(chalk3.bold(`
1214
+ Tickets matching "${query}" (${hits.length})`));
1215
+ console.log(chalk3.gray("\u2500".repeat(50)));
1216
+ for (const t of hits) {
1217
+ const id = t.shortId ?? (t.number != null ? `#${t.number}` : "?");
1218
+ const statusBadge2 = chalk3[getTicketStatusColor(t.status)](`[${t.status}]`);
1219
+ const assignee = t.assignee ? chalk3.gray(` \u2014 ${t.assignee.name ?? t.assignee.id}`) : "";
1220
+ console.log(` ${statusBadge2} ${chalk3.bold(id)} ${t.title}${assignee}`);
1221
+ console.log(chalk3.gray(` ID: ${t.id}`));
1222
+ }
1223
+ console.log();
1224
+ }
1190
1225
  function transformTicket(ticket) {
1191
1226
  const detail = ticket;
1192
1227
  return {
@@ -3959,6 +3994,23 @@ function createEpicsCommand() {
3959
3994
  }
3960
3995
  }
3961
3996
  );
3997
+ epics.command("delete").description(
3998
+ "Delete an epic. Its tickets and actions are unlinked from it (epicId is cleared), not deleted. Only workspace owners/admins and the epic owner can delete."
3999
+ ).requiredOption("--id <id>", "Epic CUID").action(async (options, cmd) => {
4000
+ const globalOpts = cmd.optsWithGlobals();
4001
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
4002
+ try {
4003
+ const client = getClient();
4004
+ await client.epics.delete(options.id);
4005
+ if (useJson) {
4006
+ console.log(JSON.stringify({ deleted: true, id: options.id }, null, 2));
4007
+ } else {
4008
+ console.log("\n\u2713 Epic deleted");
4009
+ }
4010
+ } catch (error) {
4011
+ handleError(error, useJson);
4012
+ }
4013
+ });
3962
4014
  return epics;
3963
4015
  }
3964
4016
 
@@ -4069,6 +4121,42 @@ function createStoriesCommand() {
4069
4121
  }
4070
4122
  }
4071
4123
  );
4124
+ stories.command("update").description("Update a user story. Only the fields you pass are written.").requiredOption("--id <id>", "User story CUID").option("--as-a <text>", '"As a ..." actor').option("--i-want <text>", '"I want ..." capability').option("--so-that <text>", '"So that ..." outcome').option("--acceptance <text>", "Acceptance criteria").option("--scope <id>", 'Feature scope CUID to group the story under (or "null" to ungroup)').action(
4125
+ async (options, cmd) => {
4126
+ const globalOpts = cmd.optsWithGlobals();
4127
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
4128
+ try {
4129
+ const client = getClient();
4130
+ const story = await client.userStories.update({
4131
+ id: options.id,
4132
+ asA: options.asA,
4133
+ iWant: options.iWant,
4134
+ soThat: options.soThat,
4135
+ acceptanceCriteria: options.acceptance,
4136
+ scopeId: options.scope === "null" ? null : options.scope
4137
+ });
4138
+ if (useJson) outputUserStoryJson(story);
4139
+ else {
4140
+ console.log("\n\u2713 User story updated");
4141
+ outputUserStoryPretty(story);
4142
+ }
4143
+ } catch (error) {
4144
+ handleError(error, useJson);
4145
+ }
4146
+ }
4147
+ );
4148
+ stories.command("rm").description("Delete a user story").requiredOption("--id <id>", "User story CUID").action(async (options, cmd) => {
4149
+ const globalOpts = cmd.optsWithGlobals();
4150
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
4151
+ try {
4152
+ const client = getClient();
4153
+ const result = await client.userStories.delete(options.id);
4154
+ if (useJson) console.log(JSON.stringify(result, null, 2));
4155
+ else console.log("\n\u2713 User story deleted");
4156
+ } catch (error) {
4157
+ handleError(error, useJson);
4158
+ }
4159
+ });
4072
4160
  return stories;
4073
4161
  }
4074
4162
 
@@ -5635,6 +5723,84 @@ function createProductsCommand() {
5635
5723
  }
5636
5724
  }
5637
5725
  );
5726
+ products.command("update <slugOrId>").description("Update a product. Only the fields you pass are written.").option("-n, --name <name>", "New name").option("-d, --description <text>", "New description").option("--icon <icon>", "Icon shortcode").option("--color <color>", "Color").option("--fun-ticket-ids", "Use fun ticket ids (e.g. prime.toucan) for new tickets").option("--no-fun-ticket-ids", "Use numbered ticket ids for new tickets").option("--workspace <slug|id>", "Workspace (required when using a slug)").action(
5727
+ async (slugOrId, options, cmd) => {
5728
+ const globalOpts = cmd.optsWithGlobals();
5729
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
5730
+ try {
5731
+ const client = getClient();
5732
+ const workspaceId = await resolveWorkspaceId(client, options.workspace);
5733
+ const existing = await client.products.resolve(workspaceId, slugOrId);
5734
+ const product = await client.products.update({
5735
+ id: existing.id,
5736
+ name: options.name,
5737
+ description: options.description,
5738
+ icon: options.icon,
5739
+ color: options.color,
5740
+ funTicketIds: options.funTicketIds
5741
+ });
5742
+ if (useJson) outputProductJson(product);
5743
+ else {
5744
+ console.log("\n\u2713 Product updated");
5745
+ outputProductPretty(product);
5746
+ }
5747
+ } catch (error) {
5748
+ handleError(error, useJson);
5749
+ }
5750
+ }
5751
+ );
5752
+ products.command("delete <slugOrId>").description(
5753
+ "Delete a product. Deleting a product deletes everything under it \u2014 its features, tickets, epics, research and retrospectives cascade \u2014 so this refuses while it still has any of them, unless --force."
5754
+ ).option("--force", "Delete anyway, taking everything under it with it (destructive)").option("--workspace <slug|id>", "Workspace (required when using a slug)").action(
5755
+ async (slugOrId, options, cmd) => {
5756
+ const globalOpts = cmd.optsWithGlobals();
5757
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
5758
+ try {
5759
+ const client = getClient();
5760
+ const workspaceId = await resolveWorkspaceId(client, options.workspace);
5761
+ const product = await client.products.resolve(workspaceId, slugOrId);
5762
+ const counts = product._count ?? {};
5763
+ const blockers = [
5764
+ counts.features ? `${counts.features} feature(s)` : null,
5765
+ counts.tickets ? `${counts.tickets} ticket(s)` : null,
5766
+ counts.researches ? `${counts.researches} research item(s)` : null,
5767
+ counts.retrospectives ? `${counts.retrospectives} retrospective(s)` : null
5768
+ ].filter(Boolean);
5769
+ if (blockers.length > 0 && !options.force) {
5770
+ const payload2 = {
5771
+ deleted: false,
5772
+ id: product.id,
5773
+ slug: product.slug,
5774
+ reason: `has ${blockers.join(", ")}`,
5775
+ counts
5776
+ };
5777
+ if (useJson) console.log(JSON.stringify(payload2, null, 2));
5778
+ else {
5779
+ console.error(
5780
+ `Product "${product.name}" has ${blockers.join(", ")}. Deleting it deletes them too.
5781
+ Re-run with --force to delete it anyway.`
5782
+ );
5783
+ }
5784
+ process.exitCode = 1;
5785
+ return;
5786
+ }
5787
+ await client.products.delete(product.id);
5788
+ const payload = {
5789
+ deleted: true,
5790
+ id: product.id,
5791
+ slug: product.slug,
5792
+ counts
5793
+ };
5794
+ if (useJson) console.log(JSON.stringify(payload, null, 2));
5795
+ else {
5796
+ console.log("\n\u2713 Product deleted");
5797
+ if (blockers.length) console.log(` ${blockers.join(", ")} went with it`);
5798
+ }
5799
+ } catch (error) {
5800
+ handleError(error, useJson);
5801
+ }
5802
+ }
5803
+ );
5638
5804
  return products;
5639
5805
  }
5640
5806
 
@@ -5995,6 +6161,49 @@ function createTicketsCommand() {
5995
6161
  }
5996
6162
  }
5997
6163
  );
6164
+ tickets.command("search <query>").description(
6165
+ [
6166
+ "Find tickets in a product by free text \u2014 the same picker search the app uses",
6167
+ "when you link a dependency. Matches title (case-insensitive substring), shortId",
6168
+ '(word-order-insensitive, so "toucan.prime" finds prime.toucan) or an exact',
6169
+ "ticket number. Most recently updated first. Returns slim rows (id, number,",
6170
+ "shortId, title, status, priority, assignee); use `tickets get` for the rest."
6171
+ ].join("\n")
6172
+ ).requiredOption("--product <slug|id>", "Product slug or CUID").option("--workspace <slug|id>", "Workspace (required when --product is a slug)").option("--limit <n>", "Max results, 1-50 (default 20)").option("--exclude <id>", "Ticket CUID to leave out of the results").action(
6173
+ async (query, options, cmd) => {
6174
+ const globalOpts = cmd.optsWithGlobals();
6175
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
6176
+ try {
6177
+ const trimmed = query.trim();
6178
+ if (!trimmed) {
6179
+ throw new Error("Search query is empty. Use `tickets list` to list a product's tickets.");
6180
+ }
6181
+ if (trimmed.length > 200) {
6182
+ throw new Error("Search query is too long (max 200 characters).");
6183
+ }
6184
+ let limit;
6185
+ if (options.limit !== void 0) {
6186
+ limit = Number(options.limit);
6187
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
6188
+ throw new Error(`Invalid --limit "${options.limit}". Use a whole number from 1 to 50.`);
6189
+ }
6190
+ }
6191
+ const client = getClient();
6192
+ const workspaceId = await resolveWorkspaceId(client, options.workspace);
6193
+ const productId = await resolveProductId(client, workspaceId, options.product);
6194
+ const hits = await client.tickets.search({
6195
+ productId,
6196
+ query: trimmed,
6197
+ excludeTicketId: options.exclude,
6198
+ limit
6199
+ });
6200
+ if (useJson) outputTicketSearchJson(hits, trimmed);
6201
+ else outputTicketSearchPretty(hits, trimmed);
6202
+ } catch (error) {
6203
+ handleError(error, useJson);
6204
+ }
6205
+ }
6206
+ );
5998
6207
  tickets.command("get <id>").description("Show a ticket with dependencies, actions, and comments").action(async (id, _options, cmd) => {
5999
6208
  const globalOpts = cmd.optsWithGlobals();
6000
6209
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
@@ -7092,13 +7301,34 @@ function createGoalsCommand() {
7092
7301
  );
7093
7302
  goals.command("list").description(
7094
7303
  "List objectives, each with the projects linked to it \u2014 the join that ties an action (which carries a projectId) back to a goal."
7095
- ).option("-w, --workspace <slug|id>", "Workspace slug or ID (defaults to your default workspace)").option("--all-workspaces", "List across every workspace you belong to").option("--period <period>", "Filter by period, e.g. Q3-2026").option("--status <status>", `Filter by status: ${GOAL_STATUSES.join(", ")}`).option("--tree", "Render the parent/child cascade instead of a flat list").action(
7304
+ ).option("-w, --workspace <slug|id>", "Workspace slug or ID (defaults to your default workspace)").option("--all-workspaces", "List across every workspace you belong to").option("--period <period>", "Filter by period, e.g. Q3-2026").option("--status <status>", `Filter by status: ${GOAL_STATUSES.join(", ")}`).option("--tree", "Render the parent/child cascade instead of a flat list").option(
7305
+ "--project <cuid>",
7306
+ "Only objectives linked to this project (yours only \u2014 the server scopes this lookup to objectives you created). Not combinable with --workspace, --all-workspaces or --tree."
7307
+ ).action(
7096
7308
  async (options, cmd) => {
7097
7309
  const globalOpts = cmd.optsWithGlobals();
7098
7310
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
7099
7311
  try {
7100
7312
  const status = validateGoalStatus(options.status);
7101
7313
  const client = getClient();
7314
+ if (options.project) {
7315
+ const conflicting = [
7316
+ options.workspace ? "--workspace" : null,
7317
+ options.allWorkspaces ? "--all-workspaces" : null,
7318
+ options.tree ? "--tree" : null
7319
+ ].filter(Boolean);
7320
+ if (conflicting.length > 0) {
7321
+ throw new Error(
7322
+ `--project can't be combined with ${conflicting.join(", ")}.`
7323
+ );
7324
+ }
7325
+ const list2 = (await client.goals.listByProject(options.project)).filter(
7326
+ (g) => (!options.period || g.period === options.period) && (!status || g.status === status)
7327
+ );
7328
+ if (useJson) outputGoalsJson(list2);
7329
+ else outputGoalsPretty(list2);
7330
+ return;
7331
+ }
7102
7332
  const workspaces = await targetWorkspaces(client, options);
7103
7333
  if (options.tree) {
7104
7334
  const trees = await fanOutByWorkspace(