@harborppm/cli 0.9.3 → 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) => {
@@ -5787,7 +5787,7 @@ function DueField({ initial, today, onSubmit, onCancel }) {
5787
5787
  children: /* @__PURE__ */ jsx(TextField, {
5788
5788
  label: "Due",
5789
5789
  initial,
5790
- placeholder: "2026-10-14, today, tomorrow, +3, none",
5790
+ placeholder: "YYYY-MM-DD, today, tomorrow, +3, none",
5791
5791
  hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
5792
5792
  onCancel,
5793
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.3",
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",