@harborppm/cli 0.10.1 → 0.11.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
@@ -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/lib/cli.mjs CHANGED
@@ -46,6 +46,8 @@ export const HELP = `harbor: Harbor from the terminal
46
46
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
47
47
  [--late] [--status S,S]
48
48
  harbor show TASK one task (TASK: a key like RR-42)
49
+ harbor open [TASK|PROJECT] open it on the website (or Today)
50
+ harbor invite EMAIL [--viewer] invite someone to your harbor
49
51
 
50
52
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
51
53
  [--ends DATE]
@@ -270,6 +272,41 @@ const none = (positionals) => {
270
272
  if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
271
273
  };
272
274
 
275
+ /** The harbor a --harbor key (or `harbor use`) names, the only one, or null when there are several. */
276
+ async function harborNamed(client, key) {
277
+ const { harbors } = await client.call("list_harbors", {});
278
+ if (!key) return harbors.length === 1 ? harbors[0] : null;
279
+ const k = key.toLowerCase();
280
+ const hits = harbors.filter((h) => h.id === key || h.name.trim().toLowerCase() === k);
281
+ if (hits.length === 1) return hits[0];
282
+ throw new HarborError(`No harbor matches "${key}". Run harbor harbors.`);
283
+ }
284
+
285
+ const TASK_KEY = /^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/;
286
+
287
+ /**
288
+ * A task by its key, else a project by name, else a task by id: its web
289
+ * link and what to call it. Harbor's own refusal when nothing matches.
290
+ */
291
+ async function findOpenable(client, harbor, wanted) {
292
+ const task = async () => {
293
+ const t = await client.call("get_task", { harbor, task: wanted });
294
+ return { url: t.url, what: t.key ? `${t.key} ${t.title}` : t.title };
295
+ };
296
+ const wrap = (err) => {
297
+ if (err instanceof HarborError) throw new HarborError(hint(err.message));
298
+ throw err;
299
+ };
300
+ if (TASK_KEY.test(wanted)) return task().catch(wrap);
301
+ try {
302
+ const p = await client.call("get_project", { harbor, project: wanted });
303
+ return { url: p.url, what: p.name };
304
+ } catch (err) {
305
+ if (!(err instanceof HarborError) || !/^No project matches/.test(err.message)) wrap(err);
306
+ return task().catch(() => wrap(err));
307
+ }
308
+ }
309
+
273
310
  export async function run(argv, io = {}) {
274
311
  const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
275
312
  const env = io.env ?? process.env;
@@ -641,9 +678,64 @@ export async function run(argv, io = {}) {
641
678
  "",
642
679
  ...t.comments.map((c) => `${c.author ?? "Someone"}, ${c.at.slice(0, 10)}: ${c.body}`),
643
680
  );
681
+ if (t.url) lines.push("", `On the web: ${t.url}`);
644
682
  return lines.join("\n");
645
683
  });
646
684
  }
685
+ case "open": {
686
+ const { positionals, harbor } = parse(spec());
687
+ const wanted = positionals.join(" ").trim();
688
+ let url;
689
+ let what = "Today";
690
+ if (!wanted) {
691
+ const ref = await harborNamed(client, harbor);
692
+ url = new URL("/open", creds.server);
693
+ if (ref) url.searchParams.set("harbor", ref.id);
694
+ url = url.toString();
695
+ if (ref) what = `Today in ${ref.name}`;
696
+ } else {
697
+ const found = await findOpenable(client, harbor, wanted);
698
+ url = found.url;
699
+ what = found.what;
700
+ }
701
+ if (!url) throw new HarborError("This Harbor doesn't give web links yet.");
702
+ if (asJson) {
703
+ out(jsonText({ url }));
704
+ return 0;
705
+ }
706
+ out(plain(`Opening ${what} in your browser. If it doesn't open, go to:\n\n ${url}`));
707
+ (io.browse ?? openBrowser)(url);
708
+ return 0;
709
+ }
710
+ case "invite": {
711
+ const { positionals, values, harbor } = parse(spec(["viewer"]));
712
+ const email = one(positionals, "email address");
713
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
714
+ throw new HarborError(`"${email}" isn't an email address.`);
715
+ }
716
+ if (!hasFullAccess(creds)) {
717
+ throw new HarborError("Inviting needs full access. Run harbor login, then try again.");
718
+ }
719
+ const r = await client.app({
720
+ op: "invite",
721
+ ...(harbor ? { harbor } : {}),
722
+ email,
723
+ role: values.viewer ? "viewer" : "member",
724
+ });
725
+ if (asJson) {
726
+ out(jsonText(r));
727
+ return 0;
728
+ }
729
+ const as = r.role === "viewer" ? "a viewer (they can look, not change)" : "a member";
730
+ out(
731
+ plain(
732
+ r.sent
733
+ ? `Invited ${r.email} to ${r.harbor} as ${as}. Harbor emailed them the invite; they join on the website or with harbor login.`
734
+ : `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}`,
735
+ ),
736
+ );
737
+ return 0;
738
+ }
647
739
  case "add": {
648
740
  const { positionals, values, harbor } = parse(
649
741
  spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
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.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",