@harborppm/cli 0.10.1 → 0.11.1

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
@@ -55,6 +55,8 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
55
55
  - **GitHub**: a pull request that names a task's key (RR-42) shows on the task and moves it along.
56
56
  - **Claude and other AI apps**, connected over MCP from [harborppm.com/ai](https://harborppm.com/ai?utm_source=npm&utm_medium=readme&utm_campaign=cli).
57
57
 
58
+ `harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite.
59
+
58
60
  The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
59
61
 
60
62
  ## Staying up to date
@@ -80,7 +82,9 @@ harbor programs programs and their projects
80
82
  harbor projects [words] [--program P] projects with health and open work
81
83
  harbor mine what's on you, late first
82
84
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
83
- harbor show TASK one task in full
85
+ harbor show TASK one task in full, with its web link
86
+ harbor open [TASK|PROJECT] open it on the website (or Today)
87
+ harbor invite EMAIL [--viewer] invite someone to your harbor by email
84
88
 
85
89
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
86
90
  [--ends DATE]
package/dist/app.mjs CHANGED
@@ -3285,6 +3285,19 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
3285
3285
  };
3286
3286
  }
3287
3287
 
3288
+ //#endregion
3289
+ //#region src/lib/selectors.ts
3290
+ /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
3291
+ function splitDone(items) {
3292
+ const open = [];
3293
+ const done = [];
3294
+ for (const item of items) (item.health === "done" ? done : open).push(item);
3295
+ return {
3296
+ open,
3297
+ done
3298
+ };
3299
+ }
3300
+
3288
3301
  //#endregion
3289
3302
  //#region src/tui/views/lists.tsx
3290
3303
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
@@ -3411,7 +3424,10 @@ function ProgramsView({ active, width, height, go }) {
3411
3424
  const portfolios = useHarbor((s) => s.portfolios);
3412
3425
  const milestones = useHarbor((s) => s.milestones);
3413
3426
  const { today } = useDeps();
3414
- const rows = useMemo(() => programs.map((program) => {
3427
+ const [showDone, setShowDone] = useState(false);
3428
+ const split = useMemo(() => splitDone(programs), [programs]);
3429
+ const listed = useMemo(() => showDone ? [...split.open, ...split.done] : split.open, [split, showDone]);
3430
+ const rows = useMemo(() => listed.map((program) => {
3415
3431
  const portfolio = portfolios.find((p) => p.id === program.portfolioId);
3416
3432
  const late = milestones.filter((m) => m.programId === program.id && !m.done && m.dueDate < today()).length;
3417
3433
  return {
@@ -3427,16 +3443,17 @@ function ProgramsView({ active, width, height, go }) {
3427
3443
  ].filter(Boolean).join(" · ")
3428
3444
  };
3429
3445
  }), [
3430
- programs,
3446
+ listed,
3431
3447
  portfolios,
3432
3448
  milestones,
3433
3449
  today
3434
3450
  ]);
3451
+ const done = split.done.length;
3435
3452
  return /* @__PURE__ */ jsx(RowList, {
3436
3453
  title: "Programs",
3437
- right: `${count(programs.length, "program")} · n new · e edit`,
3454
+ right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new · e edit`,
3438
3455
  rows,
3439
- empty: "No programs yet. n adds one.",
3456
+ empty: done ? "Every program is done. D shows them." : "No programs yet. n adds one.",
3440
3457
  active,
3441
3458
  width,
3442
3459
  height,
@@ -3446,7 +3463,8 @@ function ProgramsView({ active, width, height, go }) {
3446
3463
  const portfolioId = r ? programs.find((p) => p.id === r.id)?.portfolioId : void 0;
3447
3464
  go.create("program", portfolioId ? { portfolioId } : {});
3448
3465
  },
3449
- e: (r) => r && go.openPanel("program", r.id)
3466
+ e: (r) => r && go.openPanel("program", r.id),
3467
+ D: () => done > 0 && setShowDone((v) => !v)
3450
3468
  }
3451
3469
  });
3452
3470
  }
@@ -4689,19 +4707,6 @@ function PersonPanel({ id, width, height, go, onClose }) {
4689
4707
  });
4690
4708
  }
4691
4709
 
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
-
4705
4710
  //#endregion
4706
4711
  //#region src/tui/views/program.tsx
4707
4712
  /**
package/lib/cli.mjs CHANGED
@@ -40,12 +40,15 @@ export const HELP = `harbor: Harbor from the terminal
40
40
  harbor theme [auto|light|dark] the app's colours
41
41
  harbor --version which version this is
42
42
 
43
- harbor programs programs and their projects
44
- harbor projects [words] [--program P] projects with health and open work
43
+ harbor programs [--done] programs and their projects
44
+ harbor projects [words] [--program P] [--done]
45
+ projects with health and open work
45
46
  harbor mine what's on you, late first
46
47
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
47
48
  [--late] [--status S,S]
48
49
  harbor show TASK one task (TASK: a key like RR-42)
50
+ harbor open [TASK|PROJECT] open it on the website (or Today)
51
+ harbor invite EMAIL [--viewer] invite someone to your harbor
49
52
 
50
53
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
51
54
  [--ends DATE]
@@ -270,6 +273,41 @@ const none = (positionals) => {
270
273
  if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
271
274
  };
272
275
 
276
+ /** The harbor a --harbor key (or `harbor use`) names, the only one, or null when there are several. */
277
+ async function harborNamed(client, key) {
278
+ const { harbors } = await client.call("list_harbors", {});
279
+ if (!key) return harbors.length === 1 ? harbors[0] : null;
280
+ const k = key.toLowerCase();
281
+ const hits = harbors.filter((h) => h.id === key || h.name.trim().toLowerCase() === k);
282
+ if (hits.length === 1) return hits[0];
283
+ throw new HarborError(`No harbor matches "${key}". Run harbor harbors.`);
284
+ }
285
+
286
+ const TASK_KEY = /^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/;
287
+
288
+ /**
289
+ * A task by its key, else a project by name, else a task by id: its web
290
+ * link and what to call it. Harbor's own refusal when nothing matches.
291
+ */
292
+ async function findOpenable(client, harbor, wanted) {
293
+ const task = async () => {
294
+ const t = await client.call("get_task", { harbor, task: wanted });
295
+ return { url: t.url, what: t.key ? `${t.key} ${t.title}` : t.title };
296
+ };
297
+ const wrap = (err) => {
298
+ if (err instanceof HarborError) throw new HarborError(hint(err.message));
299
+ throw err;
300
+ };
301
+ if (TASK_KEY.test(wanted)) return task().catch(wrap);
302
+ try {
303
+ const p = await client.call("get_project", { harbor, project: wanted });
304
+ return { url: p.url, what: p.name };
305
+ } catch (err) {
306
+ if (!(err instanceof HarborError) || !/^No project matches/.test(err.message)) wrap(err);
307
+ return task().catch(() => wrap(err));
308
+ }
309
+ }
310
+
273
311
  export async function run(argv, io = {}) {
274
312
  const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
275
313
  const env = io.env ?? process.env;
@@ -523,12 +561,12 @@ export async function run(argv, io = {}) {
523
561
  return 0;
524
562
  }
525
563
  case "programs": {
526
- const { positionals, harbor } = parse(spec());
564
+ const { positionals, values, harbor } = parse(spec(["done"]));
527
565
  none(positionals);
528
- return call("harbor_overview", { harbor }, programTree);
566
+ return call("harbor_overview", { harbor }, (r) => programTree(r, { done: values.done }));
529
567
  }
530
568
  case "projects": {
531
- const { positionals, values, harbor } = parse(spec([], ["program"]));
569
+ const { positionals, values, harbor } = parse(spec(["done"], ["program"]));
532
570
  return call(
533
571
  "list_projects",
534
572
  {
@@ -537,7 +575,7 @@ export async function run(argv, io = {}) {
537
575
  program: values.program,
538
576
  limit: 100,
539
577
  },
540
- projectList,
578
+ (r) => projectList(r, { done: values.done }),
541
579
  );
542
580
  }
543
581
  case "program": {
@@ -641,9 +679,64 @@ export async function run(argv, io = {}) {
641
679
  "",
642
680
  ...t.comments.map((c) => `${c.author ?? "Someone"}, ${c.at.slice(0, 10)}: ${c.body}`),
643
681
  );
682
+ if (t.url) lines.push("", `On the web: ${t.url}`);
644
683
  return lines.join("\n");
645
684
  });
646
685
  }
686
+ case "open": {
687
+ const { positionals, harbor } = parse(spec());
688
+ const wanted = positionals.join(" ").trim();
689
+ let url;
690
+ let what = "Today";
691
+ if (!wanted) {
692
+ const ref = await harborNamed(client, harbor);
693
+ url = new URL("/open", creds.server);
694
+ if (ref) url.searchParams.set("harbor", ref.id);
695
+ url = url.toString();
696
+ if (ref) what = `Today in ${ref.name}`;
697
+ } else {
698
+ const found = await findOpenable(client, harbor, wanted);
699
+ url = found.url;
700
+ what = found.what;
701
+ }
702
+ if (!url) throw new HarborError("This Harbor doesn't give web links yet.");
703
+ if (asJson) {
704
+ out(jsonText({ url }));
705
+ return 0;
706
+ }
707
+ out(plain(`Opening ${what} in your browser. If it doesn't open, go to:\n\n ${url}`));
708
+ (io.browse ?? openBrowser)(url);
709
+ return 0;
710
+ }
711
+ case "invite": {
712
+ const { positionals, values, harbor } = parse(spec(["viewer"]));
713
+ const email = one(positionals, "email address");
714
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
715
+ throw new HarborError(`"${email}" isn't an email address.`);
716
+ }
717
+ if (!hasFullAccess(creds)) {
718
+ throw new HarborError("Inviting needs full access. Run harbor login, then try again.");
719
+ }
720
+ const r = await client.app({
721
+ op: "invite",
722
+ ...(harbor ? { harbor } : {}),
723
+ email,
724
+ role: values.viewer ? "viewer" : "member",
725
+ });
726
+ if (asJson) {
727
+ out(jsonText(r));
728
+ return 0;
729
+ }
730
+ const as = r.role === "viewer" ? "a viewer (they can look, not change)" : "a member";
731
+ out(
732
+ plain(
733
+ r.sent
734
+ ? `Invited ${r.email} to ${r.harbor} as ${as}. Harbor emailed them the invite; they join on the website or with harbor login.`
735
+ : `Made an invite for ${r.email} to ${r.harbor} as ${as}, but Harbor didn't email it (it sends invites once your own email address is confirmed). Send them this link yourself:\n\n ${r.link}`,
736
+ ),
737
+ );
738
+ return 0;
739
+ }
647
740
  case "add": {
648
741
  const { positionals, values, harbor } = parse(
649
742
  spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
package/lib/format.mjs CHANGED
@@ -72,20 +72,33 @@ export function taskList(r, opts) {
72
72
  const HEALTH = { "on-track": "on track", "at-risk": "at risk", "off-track": "off track" };
73
73
  const health = (h) => HEALTH[h] ?? h ?? null;
74
74
 
75
- /** Each portfolio's programs and their projects, indented, as harbor_overview gives them. */
76
- export function programTree(r) {
75
+ /** "2 done not shown (--done shows them).", or nothing. */
76
+ const hiddenNote = (n, what) =>
77
+ n ? `\n${n} done ${what}${n === 1 ? "" : "s"} not shown (--done shows them).` : "";
78
+
79
+ /**
80
+ * Each portfolio's programs and their projects, indented, as harbor_overview
81
+ * gives them. Programs and projects set to Done stay out unless `done`.
82
+ */
83
+ export function programTree(r, { done = false } = {}) {
77
84
  const portfolios = r.portfolios ?? [];
78
85
  if (!portfolios.some((pf) => pf.programs.length)) {
79
86
  return 'No programs yet. Open one with harbor program "NAME".';
80
87
  }
88
+ const shown = (x) => done || x.health !== "done";
89
+ let hidden = 0;
81
90
  const out = [];
82
91
  for (const pf of portfolios) {
83
- if (portfolios.length > 1) out.push(pf.name);
92
+ const programs = pf.programs.filter(shown);
93
+ hidden += pf.programs.length - programs.length;
94
+ if (portfolios.length > 1 && programs.length) out.push(pf.name);
84
95
  const pad = portfolios.length > 1 ? " " : "";
85
- for (const pg of pf.programs) {
96
+ for (const pg of programs) {
86
97
  const bits = [health(pg.health), pg.ends ? `ends ${pg.ends}` : null].filter(Boolean);
87
98
  out.push(`${pad}${pg.name} (${bits.join(" · ")})`);
88
- for (const pj of pg.projects) {
99
+ const projects = pg.projects.filter(shown);
100
+ hidden += pg.projects.length - projects.length;
101
+ for (const pj of projects) {
89
102
  const work = [
90
103
  health(pj.health),
91
104
  `${pj.openTasks} open`,
@@ -97,15 +110,20 @@ export function programTree(r) {
97
110
  if (!pg.projects.length) out.push(`${pad} no projects yet`);
98
111
  }
99
112
  }
100
- return out.join("\n");
113
+ if (!out.length) return `Every program is done.${hiddenNote(hidden, "item").replace("\n", " ")}`;
114
+ return out.join("\n") + hiddenNote(hidden, "item");
101
115
  }
102
116
 
103
- /** Projects one per line, name first so it can be passed to --project. */
104
- export function projectList(r) {
117
+ /** Projects one per line, name first so it can be passed to --project. Done ones only with `done`. */
118
+ export function projectList(r, { done = false } = {}) {
105
119
  if (!r.projects?.length) return "No projects match.";
120
+ const projects = done ? r.projects : r.projects.filter((p) => p.health !== "done");
121
+ const hidden = r.projects.length - projects.length;
122
+ if (!projects.length)
123
+ return `Every project that matches is done.${hiddenNote(hidden, "project").replace("\n", " ")}`;
106
124
  const more = r.total > r.projects.length ? `\n…and ${r.total - r.projects.length} more.` : "";
107
125
  return (
108
- r.projects
126
+ projects
109
127
  .map((p) => {
110
128
  const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
111
129
  const bits = [
@@ -117,7 +135,9 @@ export function projectList(r) {
117
135
  ].filter(Boolean);
118
136
  return `${p.name} (${bits.join(" · ")})`;
119
137
  })
120
- .join("\n") + more
138
+ .join("\n") +
139
+ hiddenNote(hidden, "project") +
140
+ more
121
141
  );
122
142
  }
123
143
 
package/lib/mcp.mjs CHANGED
@@ -19,8 +19,8 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
19
19
  await save(current);
20
20
  }
21
21
 
22
- async function post(body) {
23
- return reach(fetcher, new URL("/api/mcp", current.server), {
22
+ async function post(body, path = "/api/mcp") {
23
+ return reach(fetcher, new URL(path, current.server), {
24
24
  method: "POST",
25
25
  headers: {
26
26
  "content-type": "application/json",
@@ -57,5 +57,23 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
57
57
  throw new HarborError(result.content?.map((c) => c.text).join("\n") || "Harbor said no.");
58
58
  return result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? "{}");
59
59
  },
60
+
61
+ /**
62
+ * One request to the full-access door the terminal app uses (/api/app):
63
+ * what the MCP tools don't do, like `harbor invite`. Harbor's refusal
64
+ * comes back as a HarborError in its own words.
65
+ */
66
+ async app(body) {
67
+ if (current.expiresAt - SKEW_MS < now()) await renew();
68
+ let res = await post(body, "/api/app");
69
+ if (res.status === 401) {
70
+ await renew();
71
+ res = await post(body, "/api/app");
72
+ }
73
+ if (res.status === 401) throw new HarborError("Signed out. Run harbor login.");
74
+ const answer = await res.json().catch(() => ({}));
75
+ if (!res.ok) throw new HarborError(answer.error ?? `Harbor answered ${res.status}.`);
76
+ return answer;
77
+ },
60
78
  };
61
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
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",