@harborppm/cli 0.10.0 → 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 +20 -4
- package/lib/cli.mjs +92 -0
- package/lib/mcp.mjs +20 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Needs Node 22 or later.
|
|
|
12
12
|
|
|
13
13
|
## New to Harbor?
|
|
14
14
|
|
|
15
|
-
You can start from the terminal.
|
|
15
|
+
You can start from the terminal. A harbor of your own is free for 30 days, no card; after that it's $49 a month for the whole crew, not per seat.
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
npm install -g @harborppm/cli
|
|
@@ -39,10 +39,26 @@ A task changes in place too: title, status, owner, due date (`2026-10-14`, `toda
|
|
|
39
39
|
|
|
40
40
|
Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
|
|
41
41
|
|
|
42
|
+
It runs on the same engine as the website: health, task keys, limits and undo work the same way, changes save as you go (the header says when, and holds them while you're offline), and changes anyone makes on the website or elsewhere show up within a couple of seconds. It needs full access, which `harbor login` asks for by name; an older sign-in is asked again the first time.
|
|
43
|
+
|
|
42
44
|
It has colours for dark terminals and for light ones, and picks by asking the terminal for its background (or reading `COLORFGBG`); a terminal that doesn't say gets the dark ones. `harbor theme light` or `harbor theme dark` settles it, `harbor theme auto` goes back to asking, and `HARBOR_THEME` overrides it for one run.
|
|
43
45
|
|
|
44
46
|
`harbor help` shows the mark too, in a terminal with colour; piped, or with `NO_COLOR` set, it's plain text. `harbor --version` says which version you have.
|
|
45
47
|
|
|
48
|
+
## On the website
|
|
49
|
+
|
|
50
|
+
Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_source=npm&utm_medium=readme&utm_campaign=cli), signed in with the same account, so the people you work with don't need a terminal. The website adds what a team shares:
|
|
51
|
+
|
|
52
|
+
- **Your crew**: invite people by email under **People**; they see the same boards, risks and roadmap, and their changes reach your terminal within seconds.
|
|
53
|
+
- **Status for others**: the weekly brief, a one-click status report, and read-only links for stakeholders.
|
|
54
|
+
- **Intake forms**: a public form that drops requests onto a project's board.
|
|
55
|
+
- **GitHub**: a pull request that names a task's key (RR-42) shows on the task and moves it along.
|
|
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
|
+
|
|
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
|
+
|
|
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`.
|
|
61
|
+
|
|
46
62
|
## Staying up to date
|
|
47
63
|
|
|
48
64
|
npm doesn't update a global install by itself, so once a day `harbor` asks npm whether a newer version is out, alongside whatever command you ran, and says so in one line when there is (`npm install -g @harborppm/cli@latest` updates it). It says nothing in scripts, pipes or CI; set `HARBOR_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to turn it off.
|
|
@@ -51,8 +67,6 @@ npm doesn't update a global install by itself, so once a day `harbor` asks npm w
|
|
|
51
67
|
|
|
52
68
|
Task titles, names and notes can come from an intake form, a GitHub pull request or anyone in a harbor. Before the commands or the app show them, `harbor` takes out the control characters a terminal would act on (escape sequences, backspaces, text-direction overrides), so a title can't set your clipboard, retitle the window or hide what's around it. `--json` keeps the data exact, with those characters written as `\u` escapes.
|
|
53
69
|
|
|
54
|
-
It runs on the same engine as the website: health, task keys, limits and undo work the same way, changes save as you go (the header says when, and holds them while you're offline), and changes anyone makes on the website or elsewhere show up within a couple of seconds. It needs full access, which `harbor login` asks for by name; an older sign-in is asked again the first time.
|
|
55
|
-
|
|
56
70
|
## Signing in
|
|
57
71
|
|
|
58
72
|
`harbor signup` is for a first visit: the same sign-in, with the page opened on making an account. `harbor login` registers the CLI with Harbor and opens the same page Claude and other apps use: it names "Harbor CLI" and asks what to allow. Choose **Allow reading only** (or run `harbor login --read-only`) if it shouldn't change anything. The sign-in is saved in `~/.config/harbor/credentials.json`, readable only by you, and refreshes itself for 30 days.
|
|
@@ -68,7 +82,9 @@ harbor programs programs and their projects
|
|
|
68
82
|
harbor projects [words] [--program P] projects with health and open work
|
|
69
83
|
harbor mine what's on you, late first
|
|
70
84
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
71
|
-
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
|
|
72
88
|
|
|
73
89
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
74
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(
|
|
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
|
}
|