@harborppm/cli 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,10 +64,16 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
64
64
  ```
65
65
  harbor harbors the harbors you belong to
66
66
  harbor use HARBOR|none the harbor to work in when you're in several
67
+ harbor programs programs and their projects
68
+ harbor projects [words] [--program P] projects with health and open work
67
69
  harbor mine what's on you, late first
68
70
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
69
71
  harbor show TASK one task in full
70
72
 
73
+ harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
74
+ [--ends DATE]
75
+ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
76
+ [--ends DATE] [--budget N]
71
77
  harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
72
78
  [--priority low|medium|high|urgent] [--status S] [--over-limit]
73
79
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
@@ -81,7 +87,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
81
87
  [--due DATE]
82
88
  ```
83
89
 
84
- Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
90
+ Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
85
91
 
86
92
  When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app shares the same choice: it opens the harbor `harbor use` set, asks which one the first time if none is set, and a harbor you switch to in the app becomes the one the commands use.
87
93
 
package/bin/harbor.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { VERSION, run } from "../lib/cli.mjs";
3
3
  import { HarborError } from "../lib/mcp.mjs";
4
- import { plain } from "../lib/plain.mjs";
4
+ import { jsonText, plain } from "../lib/plain.mjs";
5
5
  import { updateNotice } from "../lib/update.mjs";
6
6
 
7
7
  const argv = process.argv.slice(2);
@@ -24,10 +24,12 @@ run(argv).then(
24
24
  err instanceof HarborError ||
25
25
  err?.name === "HarborError" ||
26
26
  /^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
27
- // Harbor's refusals can quote a task's title; print them plain.
28
- process.stderr.write(
29
- `${plain(known ? err.message : `Something went wrong: ${err?.message ?? err}`)}\n`,
30
- );
27
+ const message = known ? err.message : `Something went wrong: ${err?.message ?? err}`;
28
+ // With --json a script or agent reads the refusal as data, on stdout like
29
+ // a result; jsonText keeps it exact. Otherwise Harbor's refusals can quote
30
+ // a task's title, so they're printed plain.
31
+ if (argv.includes("--json")) process.stdout.write(`${jsonText({ error: message })}\n`);
32
+ else process.stderr.write(`${plain(message)}\n`);
31
33
  await tell();
32
34
  process.exit(1);
33
35
  },
package/dist/app.mjs CHANGED
@@ -942,7 +942,7 @@ function stepsFor(kind, data, ctx, me) {
942
942
  key: "name",
943
943
  label: "Name",
944
944
  kind: "text",
945
- placeholder: "Fall catalogue shoot"
945
+ placeholder: "e.g. Fall catalogue shoot"
946
946
  },
947
947
  ...ctx.programId ? [] : [{
948
948
  key: "programId",
@@ -977,7 +977,7 @@ function stepsFor(kind, data, ctx, me) {
977
977
  key: "name",
978
978
  label: "Name",
979
979
  kind: "text",
980
- placeholder: "Spring range"
980
+ placeholder: "e.g. Spring range"
981
981
  },
982
982
  ...ctx.portfolioId ? [] : [{
983
983
  key: "portfolioId",
@@ -1005,7 +1005,7 @@ function stepsFor(kind, data, ctx, me) {
1005
1005
  key: "name",
1006
1006
  label: "Name",
1007
1007
  kind: "text",
1008
- placeholder: "Samples approved"
1008
+ placeholder: "e.g. Samples approved"
1009
1009
  }, {
1010
1010
  key: "dueDate",
1011
1011
  label: "Due",
@@ -2349,7 +2349,7 @@ function Editor({ label, edit, done }) {
2349
2349
  if (edit.kind === "date") return /* @__PURE__ */ jsx(TextField, {
2350
2350
  label,
2351
2351
  initial: edit.initial ?? "",
2352
- placeholder: "2026-10-14, today, tomorrow, +3, none",
2352
+ placeholder: "YYYY-MM-DD, today, tomorrow, +3, none",
2353
2353
  hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
2354
2354
  onCancel: done,
2355
2355
  onSubmit: (text) => {
@@ -2505,7 +2505,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
2505
2505
  onPick: next
2506
2506
  }, step.key) : /* @__PURE__ */ jsx(TextField, {
2507
2507
  label,
2508
- placeholder: step.kind === "date" ? "2026-10-14, today, +30" : step.kind === "text" ? step.placeholder : void 0,
2508
+ placeholder: step.kind === "date" ? "YYYY-MM-DD, today, +30" : step.kind === "text" ? step.placeholder : void 0,
2509
2509
  hint: error ?? (step.optional ? "Enter to skip · Esc to leave it" : void 0),
2510
2510
  onCancel,
2511
2511
  onSubmit: (text) => {
@@ -3068,11 +3068,12 @@ function inboxOf(data, personId, now = clockNow()) {
3068
3068
  return who ? firstName(who.name) : "Someone";
3069
3069
  }))];
3070
3070
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
3071
+ const upstream = openBlockerNames(blocker, data.tasks);
3071
3072
  pings.push({
3072
3073
  id: waiting.length === 1 ? `wait:${waiting[0].id}` : `wait:${blocker.id}:${shortHash(waiting.map((t) => t.id).sort().join(","))}`,
3073
3074
  kind: "waiting",
3074
3075
  title: `${listNames(names)} ${names.length === 1 ? "is" : "are"} waiting on you`,
3075
- detail: `${blocked} ${waiting.length === 1 ? "is" : "are"} blocked by “${blocker.title}”${project ? ` · ${project.name}` : ""}`,
3076
+ detail: `${blocked} ${waiting.length === 1 ? "is" : "are"} blocked by “${blocker.title}”${project ? ` · ${project.name}` : ""}${upstream ? `. Yours waits on ${upstream} first.` : ""}`,
3076
3077
  at: waiting.reduce((latest, t) => t.createdAt > latest ? t.createdAt : latest, ""),
3077
3078
  taskId: blocker.id,
3078
3079
  personId: waiting[0].assigneeId,
@@ -3887,6 +3888,7 @@ const HELP = [
3887
3888
  ["u / r", "undo your last change / retry saving"],
3888
3889
  ["v", "on a project: board, list, budget"],
3889
3890
  ["m / R", "new milestone on a program / log a risk"],
3891
+ ["D", "show or hide Done projects and programs"],
3890
3892
  ["c / a", "on a task: comment / add a checklist step"],
3891
3893
  ["q", "quit (saves first)"]
3892
3894
  ];
@@ -4687,11 +4689,25 @@ function PersonPanel({ id, width, height, go, onClose }) {
4687
4689
  });
4688
4690
  }
4689
4691
 
4692
+ //#endregion
4693
+ //#region src/lib/selectors.ts
4694
+ /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
4695
+ function splitDone(items) {
4696
+ const open = [];
4697
+ const done = [];
4698
+ for (const item of items) (item.health === "done" ? done : open).push(item);
4699
+ return {
4700
+ open,
4701
+ done
4702
+ };
4703
+ }
4704
+
4690
4705
  //#endregion
4691
4706
  //#region src/tui/views/program.tsx
4692
4707
  /**
4693
4708
  * One program: its health (and why, when set by hand), next move, its
4694
- * projects and its milestones. ←→ between the two lists.
4709
+ * projects and its milestones. ←→ between the two lists. Projects set to
4710
+ * Done stay out of the list until D shows them.
4695
4711
  */
4696
4712
  function ProgramView({ programId, active, width, height, go, onBack }) {
4697
4713
  const { store, today } = useDeps();
@@ -4701,7 +4717,9 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4701
4717
  const milestones = useHarbor((s) => s.milestones);
4702
4718
  const people = useHarbor((s) => s.people);
4703
4719
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
4704
- const mine = useMemo(() => projects.filter((p) => p.programId === programId), [projects, programId]);
4720
+ const [showDone, setShowDone] = useState(false);
4721
+ const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
4722
+ const mine = showDone ? [...split.open, ...split.done] : split.open;
4705
4723
  const marks = useMemo(() => milestonesOf({ milestones }, programId, today()), [
4706
4724
  milestones,
4707
4725
  programId,
@@ -4720,6 +4738,10 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4720
4738
  if (input === "n") return go.create("project", { programId });
4721
4739
  if (input === "m") return go.create("milestone", { programId });
4722
4740
  if (input === "R") return go.create("raid", {});
4741
+ if (input === "D" && split.done.length > 0) {
4742
+ setSide("projects");
4743
+ return setShowDone((v) => !v);
4744
+ }
4723
4745
  if (side === "projects") {
4724
4746
  if (key.upArrow || input === "k") setPAt((i) => step(i, -1, mine.length));
4725
4747
  else if (key.downArrow || input === "j") setPAt((i) => step(i, 1, mine.length));
@@ -4794,12 +4816,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4794
4816
  marginRight: 1,
4795
4817
  children: [
4796
4818
  /* @__PURE__ */ jsx(Heading, {
4797
- right: `${mine.length}`,
4819
+ right: split.done.length > 0 ? `${mine.length} · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : `${mine.length}`,
4798
4820
  children: side === "projects" ? "› Projects" : " Projects"
4799
4821
  }),
4800
4822
  mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
4801
4823
  color: theme.muted,
4802
- children: "None yet. n adds one."
4824
+ children: split.done.length > 0 ? "Every project here is done. D shows them." : "None yet. n adds one."
4803
4825
  }) : null,
4804
4826
  windowOf(mine, pAt, rows).items.map((p) => {
4805
4827
  const on = active && side === "projects" && p.id === project?.id;
@@ -4873,18 +4895,21 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4873
4895
  ]
4874
4896
  });
4875
4897
  }
4876
- /** One portfolio: its health and its programs, each with theirs. */
4898
+ /** One portfolio: its health and its programs, each with theirs; Done ones wait for D. */
4877
4899
  function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4878
4900
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
4879
4901
  const programs = useHarbor((s) => s.programs);
4880
4902
  const projects = useHarbor((s) => s.projects);
4881
- const list = useMemo(() => programs.filter((p) => p.portfolioId === portfolioId), [programs, portfolioId]);
4903
+ const [showDone, setShowDone] = useState(false);
4904
+ const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
4905
+ const list = showDone ? [...split.open, ...split.done] : split.open;
4882
4906
  const [at, setAt] = useState(0);
4883
4907
  const program = list[Math.min(at, list.length - 1)];
4884
4908
  useInput((input, key) => {
4885
4909
  if (key.escape || key.backspace) return onBack();
4886
4910
  if (input === "e") return go.openPanel("portfolio", portfolioId);
4887
4911
  if (input === "n") return go.create("program", { portfolioId });
4912
+ if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
4888
4913
  if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
4889
4914
  else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
4890
4915
  else if (key.return && program) go.openProgram(program.id);
@@ -4928,12 +4953,12 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4928
4953
  }),
4929
4954
  /* @__PURE__ */ jsx(Text, { children: " " }),
4930
4955
  /* @__PURE__ */ jsx(Heading, {
4931
- right: `${list.length} program${list.length === 1 ? "" : "s"}`,
4956
+ right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
4932
4957
  children: "Programs"
4933
4958
  }),
4934
4959
  list.length === 0 ? /* @__PURE__ */ jsx(Text, {
4935
4960
  color: theme.muted,
4936
- children: "None yet. n adds one."
4961
+ children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
4937
4962
  }) : null,
4938
4963
  windowOf(list, at, height - 5).items.map((p) => {
4939
4964
  const on = active && p.id === program?.id;
@@ -5762,7 +5787,7 @@ function DueField({ initial, today, onSubmit, onCancel }) {
5762
5787
  children: /* @__PURE__ */ jsx(TextField, {
5763
5788
  label: "Due",
5764
5789
  initial,
5765
- placeholder: "2026-10-14, today, tomorrow, +3, none",
5790
+ placeholder: "YYYY-MM-DD, today, tomorrow, +3, none",
5766
5791
  hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
5767
5792
  onCancel,
5768
5793
  onSubmit: (text) => {
package/lib/cli.mjs CHANGED
@@ -16,7 +16,15 @@ import {
16
16
  writeCredentials,
17
17
  writeSettings,
18
18
  } from "./config.mjs";
19
- import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
19
+ import {
20
+ harbors,
21
+ myWork,
22
+ programTree,
23
+ projectList,
24
+ taskLine,
25
+ taskList,
26
+ todayLocal,
27
+ } from "./format.mjs";
20
28
  import { colourDepth, logoLines } from "./logo.mjs";
21
29
  import { HarborError, createClient } from "./mcp.mjs";
22
30
  import { jsonText, plain } from "./plain.mjs";
@@ -32,11 +40,17 @@ export const HELP = `harbor: Harbor from the terminal
32
40
  harbor theme [auto|light|dark] the app's colours
33
41
  harbor --version which version this is
34
42
 
43
+ harbor programs programs and their projects
44
+ harbor projects [words] [--program P] projects with health and open work
35
45
  harbor mine what's on you, late first
36
46
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
37
47
  [--late] [--status S,S]
38
48
  harbor show TASK one task (TASK: a key like RR-42)
39
49
 
50
+ harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
51
+ [--ends DATE]
52
+ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
53
+ [--ends DATE] [--budget N]
40
54
  harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
41
55
  [--priority low|medium|high|urgent] [--status S]
42
56
  [--over-limit]
@@ -147,6 +161,12 @@ const FLAG = {
147
161
  title: "--title",
148
162
  project: "--project",
149
163
  program: "--program",
164
+ portfolio: "--portfolio",
165
+ lead: "--lead",
166
+ starts: "--starts",
167
+ ends: "--ends",
168
+ budget: "--budget",
169
+ name: "the name",
150
170
  owner: "--owner",
151
171
  impact: "--impact",
152
172
  probability: "--probability",
@@ -502,6 +522,69 @@ export async function run(argv, io = {}) {
502
522
  out(plain(`Commands now use ${hits[0].name}.`));
503
523
  return 0;
504
524
  }
525
+ case "programs": {
526
+ const { positionals, harbor } = parse(spec());
527
+ none(positionals);
528
+ return call("harbor_overview", { harbor }, programTree);
529
+ }
530
+ case "projects": {
531
+ const { positionals, values, harbor } = parse(spec([], ["program"]));
532
+ return call(
533
+ "list_projects",
534
+ {
535
+ harbor,
536
+ query: positionals.join(" ") || undefined,
537
+ program: values.program,
538
+ limit: 100,
539
+ },
540
+ projectList,
541
+ );
542
+ }
543
+ case "program": {
544
+ const { positionals, values, harbor } = parse(
545
+ spec([], ["portfolio", "lead", "starts", "ends"]),
546
+ );
547
+ return call(
548
+ "create_program",
549
+ {
550
+ harbor,
551
+ name: text(positionals, "the program's name"),
552
+ portfolio: values.portfolio,
553
+ lead: values.lead,
554
+ starts: dueDate(values.starts),
555
+ ends: dueDate(values.ends),
556
+ },
557
+ (r) =>
558
+ `Opened the program ${r.created.name} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}).`,
559
+ );
560
+ }
561
+ case "project": {
562
+ const { positionals, values, harbor } = parse(
563
+ spec([], ["program", "lead", "starts", "ends", "budget"]),
564
+ );
565
+ if (!values.program) throw new HarborError("Say which program: --program NAME.");
566
+ let budget;
567
+ if (values.budget !== undefined) {
568
+ budget = Number(values.budget.replace(/[,_$£€]/g, ""));
569
+ if (!Number.isFinite(budget) || budget < 0) {
570
+ throw new HarborError("--budget is a number, like 25000.");
571
+ }
572
+ }
573
+ return call(
574
+ "create_project",
575
+ {
576
+ harbor,
577
+ name: text(positionals, "the project's name"),
578
+ program: values.program,
579
+ lead: values.lead,
580
+ starts: dueDate(values.starts),
581
+ ends: dueDate(values.ends),
582
+ budget,
583
+ },
584
+ (r) =>
585
+ `Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}). Add work with harbor add "TITLE" --project "${r.created.name}".`,
586
+ );
587
+ }
505
588
  case "mine": {
506
589
  const { positionals, harbor } = parse(spec());
507
590
  none(positionals);
package/lib/format.mjs CHANGED
@@ -69,6 +69,58 @@ export function taskList(r, opts) {
69
69
  return r.tasks.map((t) => taskLine(t, opts)).join("\n") + more;
70
70
  }
71
71
 
72
+ const HEALTH = { "on-track": "on track", "at-risk": "at risk", "off-track": "off track" };
73
+ const health = (h) => HEALTH[h] ?? h ?? null;
74
+
75
+ /** Each portfolio's programs and their projects, indented, as harbor_overview gives them. */
76
+ export function programTree(r) {
77
+ const portfolios = r.portfolios ?? [];
78
+ if (!portfolios.some((pf) => pf.programs.length)) {
79
+ return 'No programs yet. Open one with harbor program "NAME".';
80
+ }
81
+ const out = [];
82
+ for (const pf of portfolios) {
83
+ if (portfolios.length > 1) out.push(pf.name);
84
+ const pad = portfolios.length > 1 ? " " : "";
85
+ for (const pg of pf.programs) {
86
+ const bits = [health(pg.health), pg.ends ? `ends ${pg.ends}` : null].filter(Boolean);
87
+ out.push(`${pad}${pg.name} (${bits.join(" · ")})`);
88
+ for (const pj of pg.projects) {
89
+ const work = [
90
+ health(pj.health),
91
+ `${pj.openTasks} open`,
92
+ pj.lateTasks ? `${pj.lateTasks} late` : null,
93
+ pj.ends ? `ends ${pj.ends}` : null,
94
+ ].filter(Boolean);
95
+ out.push(`${pad} ${pj.name} (${work.join(" · ")})`);
96
+ }
97
+ if (!pg.projects.length) out.push(`${pad} no projects yet`);
98
+ }
99
+ }
100
+ return out.join("\n");
101
+ }
102
+
103
+ /** Projects one per line, name first so it can be passed to --project. */
104
+ export function projectList(r) {
105
+ if (!r.projects?.length) return "No projects match.";
106
+ const more = r.total > r.projects.length ? `\n…and ${r.total - r.projects.length} more.` : "";
107
+ return (
108
+ r.projects
109
+ .map((p) => {
110
+ const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
111
+ const bits = [
112
+ p.program,
113
+ health(p.health),
114
+ `${p.tasks.total - p.tasks.done} open${late}`,
115
+ p.owner,
116
+ p.ends ? `ends ${p.ends}` : null,
117
+ ].filter(Boolean);
118
+ return `${p.name} (${bits.join(" · ")})`;
119
+ })
120
+ .join("\n") + more
121
+ );
122
+ }
123
+
72
124
  export function harbors(r) {
73
125
  const list = r.harbors ?? [];
74
126
  if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.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",