@harborppm/cli 0.18.1 → 0.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/dist/app.mjs CHANGED
@@ -113,17 +113,16 @@ const MINUTE = 6e4;
113
113
  /**
114
114
  * How long ago (or until) `date` is, in the one unit that fits: "5 minutes
115
115
  * ago", "in 2 days", "1 year ago". Rounds to the nearest whole unit, and
116
- * matches date-fns's formatDistanceToNowStrict with addSuffix.
116
+ * matches date-fns's formatDistanceToNowStrict with addSuffix, except that
117
+ * anything under a minute either way is "just now", never "0 seconds ago".
117
118
  */
118
119
  function timeAgo(date, now = clockNow()) {
119
120
  const diff = date.getTime() - now.getTime();
120
121
  const minutes = Math.abs(diff) / MINUTE;
121
122
  let n;
122
123
  let unit;
123
- if (minutes < 1) {
124
- n = Math.round(Math.abs(diff) / 1e3);
125
- unit = "second";
126
- } else if (minutes < 60) {
124
+ if (minutes < 1) return "just now";
125
+ else if (minutes < 60) {
127
126
  n = Math.round(minutes);
128
127
  unit = "minute";
129
128
  } else if (minutes < 1440) {
@@ -826,7 +825,7 @@ function scoreBand(score) {
826
825
  function normalizeRaidItem(r) {
827
826
  const kind = r.kind === "issue" || r.kind === "decision" ? r.kind : "risk";
828
827
  const now = (/* @__PURE__ */ new Date()).toISOString();
829
- const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.severity ? impactFromSeverity(r.severity) : 3);
828
+ const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.impact === void 0 && r.severity ? impactFromSeverity(r.severity) : null);
830
829
  const probability = kind === "risk" ? clampScale(r.probability) : null;
831
830
  const allowed = STATUSES[kind];
832
831
  const status = r.status && allowed.includes(r.status) ? r.status : allowed[0];
@@ -964,7 +963,9 @@ function stepsFor(kind, data, ctx, me) {
964
963
  {
965
964
  key: "endDate",
966
965
  label: "Ends",
967
- kind: "date"
966
+ kind: "date",
967
+ optional: true,
968
+ skipSays: "for 60 days from today"
968
969
  },
969
970
  {
970
971
  key: "budget",
@@ -2333,15 +2334,22 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
2333
2334
  bold: true,
2334
2335
  children: label
2335
2336
  }),
2336
- /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { children: [value || /* @__PURE__ */ jsx(Text, {
2337
- color: theme.muted,
2338
- children: placeholder ?? ""
2339
- }), /* @__PURE__ */ jsx(Text, {
2337
+ /* @__PURE__ */ jsx(Box, { children: value ? /* @__PURE__ */ jsxs(Text, { children: [value, /* @__PURE__ */ jsx(Text, {
2340
2338
  inverse: true,
2341
2339
  children: " "
2342
- })] }) }),
2340
+ })] }) : /* @__PURE__ */ jsxs(Text, {
2341
+ wrap: "truncate-end",
2342
+ children: [/* @__PURE__ */ jsx(Text, {
2343
+ inverse: true,
2344
+ children: " "
2345
+ }), /* @__PURE__ */ jsx(Text, {
2346
+ color: theme.muted,
2347
+ children: placeholder ?? ""
2348
+ })]
2349
+ }) }),
2343
2350
  /* @__PURE__ */ jsx(Text, {
2344
2351
  color: theme.muted,
2352
+ wrap: "truncate-end",
2345
2353
  children: hint ?? "Enter to keep · Esc to leave it"
2346
2354
  })
2347
2355
  ]
@@ -2654,7 +2662,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
2654
2662
  }, step.key) : /* @__PURE__ */ jsx(TextField, {
2655
2663
  label,
2656
2664
  placeholder: step.kind === "date" ? "YYYY-MM-DD, today, +30" : step.kind === "text" ? step.placeholder : void 0,
2657
- hint: error ?? (step.optional ? "Enter to skip · Esc to leave it" : void 0),
2665
+ hint: error ?? (step.optional ? `Enter ${step.kind === "date" && step.skipSays ? step.skipSays : "to skip"} · Esc to leave it` : void 0),
2658
2666
  onCancel,
2659
2667
  onSubmit: (text) => {
2660
2668
  const t = text.trim();
@@ -5204,37 +5212,45 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
5204
5212
  children: [
5205
5213
  /* @__PURE__ */ jsxs(Box, {
5206
5214
  justifyContent: "space-between",
5207
- children: [/* @__PURE__ */ jsxs(Text, {
5208
- wrap: "truncate-end",
5209
- children: [
5210
- /* @__PURE__ */ jsx(Text, {
5211
- bold: true,
5212
- children: program.name
5213
- }),
5214
- " ",
5215
- /* @__PURE__ */ jsx(HealthMark, {
5216
- health: program.health,
5217
- label: true
5218
- }),
5219
- program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
5220
- color: theme.muted,
5221
- children: [
5222
- " (by hand: ",
5223
- program.healthNote || "no note",
5224
- ")"
5225
- ]
5226
- }) : null
5227
- ]
5228
- }), /* @__PURE__ */ jsxs(Text, {
5229
- color: theme.muted,
5230
- children: [
5231
- portfolio?.name,
5232
- lead ? ` · ${lead.name}` : "",
5233
- " · ",
5234
- shortDay(program.startDate, today()),
5235
- "–",
5236
- shortDay(program.endDate, today())
5237
- ]
5215
+ columnGap: 2,
5216
+ children: [/* @__PURE__ */ jsx(Box, {
5217
+ flexShrink: 1,
5218
+ children: /* @__PURE__ */ jsxs(Text, {
5219
+ wrap: "truncate-end",
5220
+ children: [
5221
+ /* @__PURE__ */ jsx(Text, {
5222
+ bold: true,
5223
+ children: program.name
5224
+ }),
5225
+ " ",
5226
+ /* @__PURE__ */ jsx(HealthMark, {
5227
+ health: program.health,
5228
+ label: true
5229
+ }),
5230
+ program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
5231
+ color: theme.muted,
5232
+ children: [
5233
+ " (by hand: ",
5234
+ program.healthNote || "no note",
5235
+ ")"
5236
+ ]
5237
+ }) : null
5238
+ ]
5239
+ })
5240
+ }), /* @__PURE__ */ jsx(Box, {
5241
+ flexShrink: 2,
5242
+ children: /* @__PURE__ */ jsxs(Text, {
5243
+ color: theme.muted,
5244
+ wrap: "truncate-end",
5245
+ children: [
5246
+ portfolio?.name,
5247
+ lead ? ` · ${lead.name}` : "",
5248
+ " · ",
5249
+ shortDay(program.startDate, today()),
5250
+ "–",
5251
+ shortDay(program.endDate, today())
5252
+ ]
5253
+ })
5238
5254
  })]
5239
5255
  }),
5240
5256
  /* @__PURE__ */ jsxs(Text, {
@@ -5798,37 +5814,47 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5798
5814
  borderColor: on ? theme.accent : theme.border,
5799
5815
  paddingX: 1,
5800
5816
  children: [
5801
- /* @__PURE__ */ jsxs(Box, { children: [
5802
- i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Text, {
5803
- color: theme.muted,
5804
- children: "‹ "
5805
- }) : null,
5806
- /* @__PURE__ */ jsx(Box, {
5807
- flexGrow: 1,
5808
- flexShrink: 1,
5809
- children: /* @__PURE__ */ jsxs(Text, {
5810
- bold: true,
5811
- color: on ? theme.accent : void 0,
5812
- wrap: "truncate-end",
5813
- children: [
5814
- c.label.toUpperCase(),
5815
- " ",
5816
- /* @__PURE__ */ jsxs(Text, {
5817
- color: over ? theme.bad : theme.muted,
5818
- children: [
5819
- c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
5820
- c.all,
5821
- c.limit != null ? `/${c.limit}` : ""
5822
- ]
5823
- })
5824
- ]
5825
- })
5826
- }),
5827
- i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Text, {
5828
- color: theme.muted,
5829
- children: " ›"
5830
- }) : null
5831
- ] }),
5817
+ /* @__PURE__ */ jsxs(Box, {
5818
+ height: 1,
5819
+ overflow: "hidden",
5820
+ children: [
5821
+ i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Box, {
5822
+ flexShrink: 0,
5823
+ children: /* @__PURE__ */ jsx(Text, {
5824
+ color: theme.muted,
5825
+ children: "‹ "
5826
+ })
5827
+ }) : null,
5828
+ /* @__PURE__ */ jsx(Box, {
5829
+ flexGrow: 1,
5830
+ flexShrink: 1,
5831
+ children: /* @__PURE__ */ jsxs(Text, {
5832
+ bold: true,
5833
+ color: on ? theme.accent : void 0,
5834
+ wrap: "truncate-end",
5835
+ children: [
5836
+ c.label.toUpperCase(),
5837
+ " ",
5838
+ /* @__PURE__ */ jsxs(Text, {
5839
+ color: over ? theme.bad : theme.muted,
5840
+ children: [
5841
+ c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
5842
+ c.all,
5843
+ c.limit != null ? `/${c.limit}` : ""
5844
+ ]
5845
+ })
5846
+ ]
5847
+ })
5848
+ }),
5849
+ i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Box, {
5850
+ flexShrink: 0,
5851
+ children: /* @__PURE__ */ jsx(Text, {
5852
+ color: theme.muted,
5853
+ children: " ›"
5854
+ })
5855
+ }) : null
5856
+ ]
5857
+ }),
5832
5858
  c.tasks.length === 0 ? /* @__PURE__ */ jsx(Text, {
5833
5859
  color: theme.muted,
5834
5860
  children: "—"
@@ -6743,6 +6769,7 @@ function TodayView({ active, width, height, go }) {
6743
6769
  else if (key.pageUp) setAt((i) => step(i, -10, mine.length));
6744
6770
  else if (key.return && selected) go.openTask(selected.id);
6745
6771
  else if (input === "x" && selected) moveTask(store, go, selected, "done");
6772
+ else if (input === "n" && projects.length === 0) go.create("project");
6746
6773
  else if (input === "n") go.newTask(selected?.projectId ?? null);
6747
6774
  else if (input === "f" && selected) {
6748
6775
  if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
@@ -6800,7 +6827,7 @@ function TodayView({ active, width, height, go }) {
6800
6827
  }),
6801
6828
  mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
6802
6829
  color: theme.muted,
6803
- children: "Nothing open is yours. n adds a task."
6830
+ children: projects.length ? "Nothing open is yours. n adds a task." : "Nothing open is yours. n starts your first project."
6804
6831
  }) : view.items.map((t, i) => /* @__PURE__ */ jsx(TaskLine, {
6805
6832
  task: t,
6806
6833
  projects,
@@ -9090,15 +9117,17 @@ function riskTitle(answer) {
9090
9117
  title = title.replace(/\.$/, "");
9091
9118
  return title;
9092
9119
  }
9093
- /** The risk an answer becomes: on its project, open, unrated, with the whole answer as what could happen. */
9094
- function riskFromAnswer(answer, project, portfolioId) {
9120
+ /**
9121
+ * The risk an answer becomes: on its project, open, unrated, with the whole
9122
+ * answer as what could happen. Scoped to the project alone, like a risk
9123
+ * logged on the project page; the program and portfolio roll it up from there.
9124
+ */
9125
+ function riskFromAnswer(answer, project) {
9095
9126
  return {
9096
9127
  kind: "risk",
9097
9128
  title: riskTitle(answer.body),
9098
9129
  description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
9099
9130
  projectId: project.id,
9100
- programId: project.programId,
9101
- portfolioId,
9102
9131
  status: "open",
9103
9132
  ownerId: null,
9104
9133
  dueDate: null
@@ -9304,8 +9333,7 @@ function milestonesRaidSlice({ set, get, write }) {
9304
9333
  if (!answer || answer.raidId) return answer?.raidId ?? null;
9305
9334
  const project = get().projects.find((p) => p.id === answer.projectId);
9306
9335
  if (!project) return null;
9307
- const program = get().programs.find((p) => p.id === project.programId);
9308
- const raidId = get().addRaidItem(riskFromAnswer(answer, project, program?.portfolioId ?? null));
9336
+ const raidId = get().addRaidItem(riskFromAnswer(answer, project));
9309
9337
  write({ reflections: (get().reflections ?? []).map((r) => r.id === id ? {
9310
9338
  ...r,
9311
9339
  raidId,
@@ -10328,11 +10356,17 @@ function createUiStore() {
10328
10356
 
10329
10357
  //#endregion
10330
10358
  //#region src/lib/empty.ts
10331
- /** What a new harbor is called until its owner names it: "Sam's harbor". */
10332
- function suggestedHarborName(displayName, email) {
10359
+ /**
10360
+ * A name worth offering in the harbor-name field: "Sam's harbor", or "" when
10361
+ * nobody is known yet, so the field stays empty and shows its placeholder.
10362
+ */
10363
+ function harborNameFor(displayName, email) {
10333
10364
  const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
10334
- if (!first) return "My harbor";
10335
- return `${first}'s harbor`;
10365
+ return first ? `${first}'s harbor` : "";
10366
+ }
10367
+ /** What a new harbor is called until its owner names it: "Sam's harbor", else "My harbor". */
10368
+ function suggestedHarborName(displayName, email) {
10369
+ return harborNameFor(displayName, email) || "My harbor";
10336
10370
  }
10337
10371
 
10338
10372
  //#endregion
package/lib/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  writeSettings,
18
18
  } from "./config.mjs";
19
19
  import {
20
+ STATUS,
20
21
  harbors,
21
22
  myWork,
22
23
  premortemText,
@@ -91,10 +92,11 @@ export const HELP = `harbor: Harbor from the terminal
91
92
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
92
93
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
93
94
  the register, worst first
95
+ (--closed adds closed ones)
94
96
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
95
97
  a project's pre-mortem: start,
96
98
  answer or read it
97
- harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
99
+ harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
98
100
  your line in it
99
101
 
100
102
  Every command takes --json, and --harbor NAME when you're in more than one
@@ -234,7 +236,9 @@ export function hint(message) {
234
236
  .replace(
235
237
  /^Invalid option: expected one of (.*)$/,
236
238
  (_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
237
- );
239
+ )
240
+ // The CLI reads today, tomorrow and +N before Harbor sees the date.
241
+ .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow or +N days");
238
242
  if (!seen.has(flag)) seen.set(flag, said);
239
243
  }
240
244
  return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
@@ -288,13 +292,15 @@ const DETAIL = {
288
292
  ' harbor premortem PROJECT "ANSWER" --start',
289
293
  " writes: starts one and adds your answer",
290
294
  "",
291
- " PROJECT is a name or an id. When two projects share a name, give the id.",
295
+ " PROJECT is a name, a key like RR, or an id. When two projects share a",
296
+ " name, give the key.",
292
297
  ],
293
298
  voyage: [
294
299
  " harbor voyage PROJECT reads a Done project's voyage log",
295
300
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
296
301
  "",
297
- " PROJECT is a name or an id. When two projects share a name, give the id.",
302
+ " PROJECT is a name, a key like RR, or an id. When two projects share a",
303
+ " name, give the key.",
298
304
  ],
299
305
  };
300
306
 
@@ -307,11 +313,12 @@ export function commandHelp(command) {
307
313
  const names = words.filter(
308
314
  (_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
309
315
  );
310
- if (words[0] !== "harbor" || !names.includes(command)) continue;
316
+ // A command line names one (the footer's "harbor (or set one…" doesn't).
317
+ if (words[0] !== "harbor" || !/^[a-z-]+$/.test(words[1] ?? "") || !names.includes(command))
318
+ continue;
311
319
  picked.push(lines[i]);
312
- // Its options carry on below it, further in.
313
- while (lines[i + 1]?.startsWith(" ") && /^\s+\[|^\s+--/.test(lines[i + 1]))
314
- picked.push(lines[++i]);
320
+ // Its options and the rest of its description carry on below it, further in.
321
+ while (/^ {6,}\S/.test(lines[i + 1] ?? "")) picked.push(lines[++i]);
315
322
  }
316
323
  if (DETAIL[command]) picked.splice(0, picked.length, ...DETAIL[command]);
317
324
  if (picked.length === 0) return null;
@@ -321,6 +328,40 @@ export function commandHelp(command) {
321
328
  return [...picked, ...common].join("\n");
322
329
  }
323
330
 
331
+ /** Every command the help names, for spotting a typo. */
332
+ /** "zsh, bash or fish". */
333
+ const either = (list) => `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
334
+
335
+ const COMMANDS = [
336
+ ...new Set(
337
+ HELP.split("\n").flatMap((line) =>
338
+ [...line.matchAll(/(?:^\s+harbor|\|) ([a-z][a-z-]+)/g)].map((m) => m[1]),
339
+ ),
340
+ ),
341
+ ];
342
+
343
+ /** How many single-letter changes (or swaps of neighbours) turn a into b. */
344
+ function distance(a, b) {
345
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
346
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
347
+ for (let i = 1; i <= a.length; i++)
348
+ for (let j = 1; j <= b.length; j++) {
349
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
350
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
351
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
352
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
353
+ }
354
+ return d[a.length][b.length];
355
+ }
356
+
357
+ /** A mistyped command, with the one it was probably meant to be. */
358
+ export function noCommand(name) {
359
+ if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
360
+ const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
361
+ const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
362
+ return `No command "${name}".${guess} Run harbor help.`;
363
+ }
364
+
324
365
  /** Room for --json and --harbor before the command too: `harbor --json mine`. */
325
366
  function commandFirst(argv) {
326
367
  const front = [];
@@ -386,9 +427,9 @@ export async function run(argv, io = {}) {
386
427
 
387
428
  if (command === "help" || command === "--help" || command === "-h") {
388
429
  // `harbor help mine`: that command's help, as `harbor mine --help` gives it.
389
- if (rest[0]) {
430
+ if (rest[0] && rest[0] !== "help") {
390
431
  const help = commandHelp(rest[0]);
391
- if (help === null) throw new HarborError(`No command "${rest[0]}". Run harbor help.`);
432
+ if (help === null) throw new HarborError(noCommand(rest[0]));
392
433
  out(help);
393
434
  return 0;
394
435
  }
@@ -413,7 +454,7 @@ export async function run(argv, io = {}) {
413
454
  if (command && asksHelp(rest)) {
414
455
  const help = commandHelp(command === "app" ? "" : command);
415
456
  if (help === null && command !== "app") {
416
- throw new HarborError(`No command "${command}". Run harbor help.`);
457
+ throw new HarborError(noCommand(command));
417
458
  }
418
459
  out(help ?? HELP);
419
460
  return 0;
@@ -515,7 +556,20 @@ export async function run(argv, io = {}) {
515
556
  await writeCredentials(creds, env);
516
557
  const canWrite = creds.scope.split(" ").includes("harbor:write");
517
558
  const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
518
- const next = signUp ? "Next: run harbor, and it helps you make your first harbor." : null;
559
+ // Someone who made their account on the sign-in page has no harbor yet either.
560
+ const fresh =
561
+ signUp ||
562
+ (await createClient({
563
+ creds,
564
+ // A renewed sign-in keeps the harbor set with harbor use.
565
+ save: (c) =>
566
+ writeCredentials({ ...c, harbor: creds.harbor, harborName: creds.harborName }, env),
567
+ fetcher,
568
+ })
569
+ .call("list_harbors", {})
570
+ .then((r) => (r.harbors ?? []).length === 0)
571
+ .catch(() => false));
572
+ const next = fresh ? "Next: run harbor, and it helps you make your first harbor." : null;
519
573
  const welcome = banner(io, env, ({ bold, dim }) => [
520
574
  bold("Welcome aboard."),
521
575
  said,
@@ -565,7 +619,7 @@ export async function run(argv, io = {}) {
565
619
  none(positionals);
566
620
  if (values.init !== undefined) {
567
621
  const snippet = initSnippet(values.init);
568
- if (!snippet) throw new HarborError(`Pick ${SHELLS.join(", ")} (not "${values.init}").`);
622
+ if (!snippet) throw new HarborError(`Pick ${either(SHELLS)} (not "${values.init}").`);
569
623
  out(
570
624
  // Comments, so the whole of it can be appended straight to the file.
571
625
  `${snippet}\n# Shows what's due today and late, from the prompt after next;\n# nothing when you're clear. harbor prompt --refresh checks now.`,
@@ -574,7 +628,7 @@ export async function run(argv, io = {}) {
574
628
  }
575
629
  const shell = values.shell;
576
630
  if (shell !== undefined && !SHELLS.includes(shell))
577
- throw new HarborError(`--shell is ${SHELLS.join(", ")} (not "${shell}").`);
631
+ throw new HarborError(`--shell is ${either(SHELLS)} (not "${shell}").`);
578
632
  const creds = await readCredentials(env);
579
633
  const harbor = values.harbor ?? creds?.harbor ?? null;
580
634
  const today = todayLocal(io.now ? new Date(io.now()) : new Date());
@@ -615,7 +669,7 @@ export async function run(argv, io = {}) {
615
669
 
616
670
  // A mistyped command says so, signed in or not.
617
671
  if (commandHelp(command) === null) {
618
- throw new HarborError(`No command "${command}". Run harbor help.`);
672
+ throw new HarborError(noCommand(command));
619
673
  }
620
674
  const creds = await readCredentials(env);
621
675
  if (!creds)
@@ -782,13 +836,14 @@ export async function run(argv, io = {}) {
782
836
  }
783
837
  case "show": {
784
838
  const { positionals, harbor } = parse(spec());
785
- return call("get_task", { harbor, task: one(positionals, "task id") }, (t) => {
839
+ return call("get_task", { harbor, task: one(positionals, "task key, like RR-42") }, (t) => {
786
840
  const lines = [
787
841
  taskLine(
788
842
  { ...t, waitingOn: t.waitingOn.filter((w) => w.status !== "done").length },
789
843
  { ...opts, width: undefined },
790
844
  ),
791
845
  ];
846
+ if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
792
847
  if (t.description) lines.push("", t.description);
793
848
  if (t.checklist.length)
794
849
  lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
@@ -796,13 +851,13 @@ export async function run(argv, io = {}) {
796
851
  lines.push(
797
852
  "",
798
853
  "Waits on:",
799
- ...t.waitingOn.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
854
+ ...t.waitingOn.map((w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`),
800
855
  );
801
856
  if (t.waitedOnBy.length)
802
857
  lines.push(
803
858
  "",
804
859
  "Holds up:",
805
- ...t.waitedOnBy.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
860
+ ...t.waitedOnBy.map((w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`),
806
861
  );
807
862
  if (t.comments.length)
808
863
  lines.push(
@@ -857,7 +912,7 @@ export async function run(argv, io = {}) {
857
912
  out(jsonText(r));
858
913
  return 0;
859
914
  }
860
- const as = r.role === "viewer" ? "a viewer (they can look, not change)" : "a member";
915
+ const as = r.role === "viewer" ? "someone who can view (not change)" : "crew who can edit";
861
916
  out(
862
917
  plain(
863
918
  r.sent
@@ -924,7 +979,7 @@ export async function run(argv, io = {}) {
924
979
  "update_task",
925
980
  {
926
981
  harbor,
927
- task: one(positionals, "task id"),
982
+ task: one(positionals, "task key, like RR-42"),
928
983
  status,
929
984
  confirmed: values.confirm || undefined,
930
985
  overLimit: values["over-limit"] || undefined,
@@ -950,7 +1005,7 @@ export async function run(argv, io = {}) {
950
1005
  "update_task",
951
1006
  {
952
1007
  harbor,
953
- task: one(positionals, "task id"),
1008
+ task: one(positionals, "task key, like RR-42"),
954
1009
  title: values.title,
955
1010
  assignee: values.assignee,
956
1011
  due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
@@ -983,7 +1038,7 @@ export async function run(argv, io = {}) {
983
1038
  case "comment": {
984
1039
  const { positionals, harbor } = parse(spec());
985
1040
  const [task, ...words] = positionals;
986
- if (!task) throw new HarborError("Give a task id, then the comment.");
1041
+ if (!task) throw new HarborError("Give a task key, like RR-42, then the comment.");
987
1042
  return call(
988
1043
  "add_comment",
989
1044
  { harbor, task, body: text(words, "the comment") },
package/lib/format.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
2
2
 
3
- const STATUS = {
3
+ export const STATUS = {
4
4
  backlog: "backlog",
5
5
  todo: "to do",
6
6
  doing: "doing",
@@ -55,7 +55,9 @@ export function myWork(w, opts) {
55
55
  out.push("", "Yours in the register:");
56
56
  for (const r of w.raid)
57
57
  // No command takes a risk's id, so it isn't shown.
58
- out.push(` ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
58
+ out.push(
59
+ ` ${r.kind}: ${r.title}${r.due ? ` (${r.kind === "Decision" ? "decide" : "review"} by ${r.due})` : ""}`,
60
+ );
59
61
  }
60
62
  return out.join("\n");
61
63
  }
@@ -127,6 +129,7 @@ export function projectList(r, { done = false } = {}) {
127
129
  .map((p) => {
128
130
  const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
129
131
  const bits = [
132
+ p.key,
130
133
  p.program,
131
134
  health(p.health),
132
135
  `${p.tasks.total - p.tasks.done} open${late}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",