@harborppm/cli 0.5.1 → 0.7.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
@@ -10,6 +10,18 @@ harbor mine # or one command at a time
10
10
 
11
11
  Needs Node 22 or later.
12
12
 
13
+ ## New to Harbor?
14
+
15
+ You can start from the terminal. Harbor is free to start.
16
+
17
+ ```sh
18
+ npm install -g @harborppm/cli
19
+ harbor signup # make your account in the browser (email or Google), then come back
20
+ harbor # name your harbor and its first project, and you're in
21
+ ```
22
+
23
+ `harbor signup` opens Harbor's page on **Create**; confirm your email from the letter it sends when you get a moment. The first time you run `harbor` with no harbor yet, it asks for a name (Enter takes a suggestion) and, if you like, a first project, then opens on it. Joining a team's harbor instead? Open the invite they sent you, and `harbor` opens on their harbor.
24
+
13
25
  ## The full-screen app
14
26
 
15
27
  `harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
@@ -43,7 +55,7 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
43
55
 
44
56
  ## Signing in
45
57
 
46
- `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.
58
+ `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.
47
59
 
48
60
  `harbor logout` forgets it on this machine. To end its access everywhere, disconnect **Harbor CLI** under **Connected apps** in Harbor's menu.
49
61
 
@@ -51,22 +63,27 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
51
63
 
52
64
  ```
53
65
  harbor harbors the harbors you belong to
66
+ harbor use HARBOR|none the harbor commands mean when you're in several
54
67
  harbor mine what's on you, late first
55
- harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
68
+ harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
56
69
  harbor show TASK one task in full
57
70
 
58
71
  harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
59
- [--priority low|medium|high|urgent] [--status S]
60
- harbor start TASK | review TASK | done TASK [--confirm]
72
+ [--priority low|medium|high|urgent] [--status S] [--over-limit]
73
+ harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
61
74
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
62
- [--priority P] [--status S]
75
+ [--priority P] [--status S] [--confirm] [--over-limit]
63
76
  harbor comment TASK "TEXT"
64
77
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
65
- harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
66
- harbor issue "TITLE" [--project P]
78
+ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
79
+ [--owner NAME] [--due YYYY-MM-DD]
80
+ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
81
+ [--due YYYY-MM-DD]
67
82
  ```
68
83
 
69
- Every command takes `--harbor NAME` when you're in more than one, and `--json` for the raw result. Statuses are backlog, todo, doing, review and done.
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.
85
+
86
+ 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 opens the one `harbor use` set too.
70
87
 
71
88
  Harbor holds back what it would hold back in the app: finishing a task that still waits on another, or going past a column's limit. The CLI says so and names the flag that goes ahead (`--confirm`, `--over-limit`).
72
89
 
package/bin/harbor.mjs CHANGED
@@ -22,6 +22,7 @@ run(argv).then(
22
22
  async (err) => {
23
23
  const known =
24
24
  err instanceof HarborError ||
25
+ err?.name === "HarborError" ||
25
26
  /^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
26
27
  // Harbor's refusals can quote a task's title; print them plain.
27
28
  process.stderr.write(
package/dist/app.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // Built from src/tui by cli/build.mjs. Edit the source, not this file.
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
4
  import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -1707,9 +1708,17 @@ function numberNewTasks(before, after) {
1707
1708
  projects
1708
1709
  };
1709
1710
  const was = byId(before.tasks);
1711
+ const holders = /* @__PURE__ */ new Map();
1712
+ for (const t of after.tasks) {
1713
+ if (!Number.isInteger(t.number)) continue;
1714
+ const k = `${t.projectId}:${t.number}`;
1715
+ holders.set(k, (holders.get(k) ?? 0) + 1);
1716
+ }
1717
+ const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
1710
1718
  const fresh = (t) => {
1711
1719
  const old = was.get(t.id);
1712
- return !old || old.projectId !== t.projectId && old.number === t.number;
1720
+ if (!old) return !keepsNumber(t);
1721
+ return old.projectId !== t.projectId && old.number === t.number;
1713
1722
  };
1714
1723
  if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
1715
1724
  ...after,
@@ -1855,12 +1864,17 @@ function openBlockerNames(task, tasks) {
1855
1864
  function waitingOn(task, tasks) {
1856
1865
  return tasks.filter((t) => t.id !== task.id && waitsOn(t, task.id) && t.status !== "done");
1857
1866
  }
1867
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1868
+ /**
1869
+ * Who a comment mentions: "@" and a first name as a whole word, so @Samantha
1870
+ * isn't Sam and an address (jane@nico.com) mentions nobody.
1871
+ */
1858
1872
  function parseMentions(body, people) {
1859
1873
  const ids = /* @__PURE__ */ new Set();
1860
- const lower = body.toLowerCase();
1861
1874
  for (const person of people) {
1862
- const needle = `@${person.name.split(" ")[0].toLowerCase()}`;
1863
- if (lower.includes(needle)) ids.add(person.id);
1875
+ const first = person.name.trim().split(/\s+/)[0];
1876
+ if (!first) continue;
1877
+ if (new RegExp(`(?<![\\p{L}\\p{N}_])@${escapeRegExp(first)}(?![\\p{L}\\p{N}_-])`, "iu").test(body)) ids.add(person.id);
1864
1878
  }
1865
1879
  return [...ids];
1866
1880
  }
@@ -2907,7 +2921,10 @@ function HistoryView({ active, width, height, go }) {
2907
2921
  }),
2908
2922
  /* @__PURE__ */ jsx(Text, {
2909
2923
  bold: true,
2910
- children: actorOf(row, people)
2924
+ children: actorOf({
2925
+ ...row,
2926
+ via: null
2927
+ }, people)
2911
2928
  }),
2912
2929
  row.via ? /* @__PURE__ */ jsxs(Text, {
2913
2930
  color: theme.muted,
@@ -2994,7 +3011,7 @@ function listNames(names) {
2994
3011
  return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
2995
3012
  }
2996
3013
  function excerpt(body) {
2997
- const t = body.replace(/@\w+/g, "").replace(/\s+/g, " ").trim();
3014
+ const t = body.replace(/(?<![\p{L}\p{N}_])@[\p{L}\p{N}_-]+/gu, "").replace(/\s+/g, " ").trim();
2998
3015
  if (t.length <= 96) return t;
2999
3016
  return `${t.slice(0, 94)}…`;
3000
3017
  }
@@ -8679,7 +8696,8 @@ function plainTransport(t) {
8679
8696
  load: async (workspaceId) => plainDeep(await t.load(workspaceId)),
8680
8697
  save: async (body) => plainDeep(await t.save(body)),
8681
8698
  wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
8682
- history: async (query) => plainDeep(await t.history(query))
8699
+ history: async (query) => plainDeep(await t.history(query)),
8700
+ create: async (name) => plainDeep(await t.create(name))
8683
8701
  };
8684
8702
  }
8685
8703
  /** Harbor said no, or couldn't be reached (`offline`). */
@@ -8737,6 +8755,10 @@ function httpTransport(source, fetcher = fetch) {
8737
8755
  history: (history) => post({
8738
8756
  op: "history",
8739
8757
  history
8758
+ }),
8759
+ create: (name) => post({
8760
+ op: "create",
8761
+ name
8740
8762
  })
8741
8763
  };
8742
8764
  }
@@ -8946,6 +8968,17 @@ function createSync(opts) {
8946
8968
  };
8947
8969
  }
8948
8970
 
8971
+ //#endregion
8972
+ //#region cli/lib/errors.mjs
8973
+ /**
8974
+ * What the CLI says when something stops a command: a HarborError is
8975
+ * already worded for the person and printed as it is; anything else is
8976
+ * unexpected and printed as "Something went wrong".
8977
+ */
8978
+ var HarborError = class extends Error {
8979
+ name = "HarborError";
8980
+ };
8981
+
8949
8982
  //#endregion
8950
8983
  //#region src/tui/theme-detect.ts
8951
8984
  const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
@@ -9003,6 +9036,47 @@ function createUiStore() {
9003
9036
  }));
9004
9037
  }
9005
9038
 
9039
+ //#endregion
9040
+ //#region src/lib/empty.ts
9041
+ /** What a new harbor is called until its owner names it: "Sam's harbor". */
9042
+ function suggestedHarborName(displayName, email) {
9043
+ const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
9044
+ if (!first) return "My harbor";
9045
+ return `${first}'s harbor`;
9046
+ }
9047
+
9048
+ //#endregion
9049
+ //#region src/tui/first-run.ts
9050
+ /**
9051
+ * A new account's first `harbor`: it isn't in a harbor yet, so the terminal
9052
+ * asks for a name (and, if they like, a first project) and makes the same
9053
+ * empty harbor the website's launch page makes, instead of sending them off
9054
+ * to the website.
9055
+ */
9056
+ async function firstHarbor(deps) {
9057
+ const fallback = suggestedHarborName(deps.account.name, deps.account.email);
9058
+ deps.say("You're signed in, but you aren't in a harbor yet, so let's make yours.");
9059
+ deps.say("Joining a team's harbor instead? Open the invite they sent you.\n");
9060
+ const name = (await deps.ask(`Name your harbor (Enter for "${fallback}"): `)).trim() || fallback;
9061
+ const project = (await deps.ask("Name your first project (Enter to skip): ")).trim() || null;
9062
+ const { harbor } = await deps.transport.create(name.slice(0, 120));
9063
+ return {
9064
+ harbor,
9065
+ project: project?.slice(0, 120) ?? null
9066
+ };
9067
+ }
9068
+ /** The first project, in the new harbor's starter program, led by its owner. */
9069
+ function addFirstProject(store, name, accountId, today) {
9070
+ const s = store.getState();
9071
+ const program = s.programs[0];
9072
+ if (!program) return null;
9073
+ const me = s.people.find((p) => p.accountId === accountId) ?? s.people[0];
9074
+ return commitCreate(store, "project", {
9075
+ name,
9076
+ ownerId: me?.id ?? ""
9077
+ }, { programId: program.id }, today);
9078
+ }
9079
+
9006
9080
  //#endregion
9007
9081
  //#region src/tui/main.tsx
9008
9082
  /**
@@ -9045,6 +9119,11 @@ async function startApp(deps, options = {}) {
9045
9119
  instance.unmount();
9046
9120
  throw err;
9047
9121
  }
9122
+ if (deps.firstRun) {
9123
+ const project = deps.firstRun.project;
9124
+ if (project) addFirstProject(store, project, account.id, today());
9125
+ ui.setState({ notice: project ? `Your harbor is ready, with ${project} in it. n adds more, ? lists the keys.` : "Your harbor is ready. n adds a project, ? lists the keys." });
9126
+ }
9048
9127
  instance.rerender(/* @__PURE__ */ jsx(DepsProvider, {
9049
9128
  value: {
9050
9129
  store,
@@ -9063,25 +9142,81 @@ async function startApp(deps, options = {}) {
9063
9142
  ui
9064
9143
  };
9065
9144
  }
9145
+ /**
9146
+ * The harbor --harbor names, as the commands read it: its id, its whole
9147
+ * name, or the one name it best matches.
9148
+ */
9149
+ function harborNamed(harbors, key) {
9150
+ const want = key.trim();
9151
+ const exact = harbors.find((h) => h.id === want) ?? harbors.find((h) => h.name.trim().toLowerCase() === want.toLowerCase());
9152
+ if (exact) return exact;
9153
+ const hits = harbors.map((h) => ({
9154
+ h,
9155
+ s: scoreMatch(want, h.name)
9156
+ })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
9157
+ if (hits.length === 0) throw new HarborError(`No harbor matches "${want}". Run harbor harbors.`);
9158
+ if (hits.length > 1 && hits[0].s === hits[1].s) {
9159
+ const names = hits.map((x) => x.h.name).join(", ");
9160
+ throw new HarborError(`"${want}" matches more than one harbor: ${names}.`);
9161
+ }
9162
+ return hits[0].h;
9163
+ }
9066
9164
  /** `harbor`: sign-in already done, take over the terminal until q. */
9067
9165
  async function runApp(opts) {
9068
9166
  const theme = await pickTheme(opts.theme);
9069
9167
  const transport = httpTransport(opts.source, opts.fetcher);
9070
- const { account, harbors } = await transport.harbors();
9071
- if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
9072
- const wanted = opts.harbor?.toLowerCase();
9073
- const pick = wanted ? harbors.find((h) => h.id === opts.harbor || h.name.toLowerCase() === wanted) : void 0;
9074
- if (wanted && !pick) throw new Error(`No harbor called "${opts.harbor}".`);
9168
+ const answer = await transport.harbors();
9169
+ const account = answer.account;
9170
+ let harbors = answer.harbors;
9171
+ let firstRun;
9172
+ let workspaceId;
9173
+ if (harbors.length === 0) {
9174
+ const terminal = terminalAsk();
9175
+ try {
9176
+ const made = await firstHarbor({
9177
+ ask: terminal.ask,
9178
+ say: (line) => process.stdout.write(`${line}\n`),
9179
+ transport: plainTransport(transport),
9180
+ account: plainDeep(account)
9181
+ });
9182
+ harbors = [made.harbor];
9183
+ workspaceId = made.harbor.id;
9184
+ firstRun = { project: made.project };
9185
+ } finally {
9186
+ terminal.close();
9187
+ }
9188
+ } else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
9075
9189
  const { instance } = await startApp({
9076
9190
  transport,
9077
9191
  account,
9078
9192
  harbors,
9079
- workspaceId: pick?.id,
9193
+ workspaceId,
9080
9194
  splashMs: SPLASH_MS,
9081
- theme
9195
+ theme,
9196
+ firstRun
9082
9197
  }, { alternateScreen: true });
9083
9198
  await instance.waitUntilExit();
9084
9199
  }
9200
+ /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
9201
+ function terminalAsk() {
9202
+ const rl = createInterface({
9203
+ input: process.stdin,
9204
+ output: process.stdout
9205
+ });
9206
+ let stop = () => {};
9207
+ const stopped = new Promise((_, reject) => stop = reject);
9208
+ stopped.catch(() => {});
9209
+ const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
9210
+ rl.on("SIGINT", nothing);
9211
+ rl.on("close", nothing);
9212
+ return {
9213
+ ask: (question) => Promise.race([rl.question(question), stopped]),
9214
+ close: () => {
9215
+ rl.off("close", nothing);
9216
+ rl.close();
9217
+ }
9218
+ };
9219
+ }
9085
9220
 
9086
9221
  //#endregion
9087
- export { SPLASH_MS, TransportError, httpTransport, runApp, startApp };
9222
+ export { SPLASH_MS, TransportError, firstHarbor, harborNamed, httpTransport, runApp, startApp };
package/lib/args.mjs CHANGED
@@ -22,7 +22,11 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
22
22
  values[key] = true;
23
23
  } else if (options.includes(key)) {
24
24
  const value = inline ?? argv[++i];
25
- if (value === undefined || (inline === undefined && value.startsWith("--"))) {
25
+ if (
26
+ value === undefined ||
27
+ value.trim() === "" ||
28
+ (inline === undefined && value.startsWith("--"))
29
+ ) {
26
30
  throw new Error(`--${key} needs a value.`);
27
31
  }
28
32
  values[key] = value;
package/lib/auth.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { createServer } from "node:http";
8
+ import { HarborError, reach } from "./errors.mjs";
8
9
 
9
10
  export const CLIENT_NAME = "Harbor CLI";
10
11
  export const WRITE_SCOPE = "harbor:write";
@@ -36,7 +37,7 @@ async function json(res, what) {
36
37
 
37
38
  /** The server's OAuth endpoints, from its discovery document. */
38
39
  export async function discover(server, fetcher = fetch) {
39
- const res = await fetcher(new URL("/.well-known/oauth-authorization-server", server));
40
+ const res = await reach(fetcher, new URL("/.well-known/oauth-authorization-server", server));
40
41
  return json(res, "Finding Harbor's sign-in");
41
42
  }
42
43
 
@@ -89,16 +90,23 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
89
90
 
90
91
  /**
91
92
  * The whole sign-in. `open(url)` shows the asking page (a browser, or a test
92
- * driving one). Returns credentials to save.
93
+ * driving one). `signUp` opens it on making an account. Returns credentials to save.
93
94
  */
94
- export async function login({ server, readOnly = false, open, fetcher = fetch, now = Date.now }) {
95
+ export async function login({
96
+ server,
97
+ readOnly = false,
98
+ signUp = false,
99
+ open,
100
+ fetcher = fetch,
101
+ now = Date.now,
102
+ }) {
95
103
  const meta = await discover(server, fetcher);
96
104
  const state = b64url(randomBytes(16));
97
105
  const { verifier, challenge } = pkcePair();
98
106
  const { redirectUri, code } = await listenForCode(state);
99
107
 
100
108
  const client = await json(
101
- await fetcher(meta.registration_endpoint, {
109
+ await reach(fetcher, meta.registration_endpoint, {
102
110
  method: "POST",
103
111
  headers: { "content-type": "application/json" },
104
112
  body: JSON.stringify({
@@ -120,14 +128,14 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
120
128
  state,
121
129
  code_challenge: challenge,
122
130
  code_challenge_method: "S256",
123
- scope: readOnly
124
- ? "openid offline_access"
125
- : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
131
+ scope: readOnly ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
132
+ // OpenID's way to say "new here": Harbor's page opens on Create.
133
+ ...(signUp ? { prompt: "create" } : {}),
126
134
  }).toString();
127
135
  await open(authorize.toString());
128
136
 
129
137
  const tokens = await json(
130
- await fetcher(meta.token_endpoint, {
138
+ await reach(fetcher, meta.token_endpoint, {
131
139
  method: "POST",
132
140
  headers: { "content-type": "application/x-www-form-urlencoded" },
133
141
  body: new URLSearchParams({
@@ -157,18 +165,21 @@ export function toCredentials(server, tokenEndpoint, clientId, tokens, at) {
157
165
 
158
166
  /** New tokens from the refresh token; the old one stops working. */
159
167
  export async function refresh(creds, { fetcher = fetch, now = Date.now } = {}) {
160
- if (!creds.refreshToken) throw new Error("Signed out. Run harbor login.");
161
- const tokens = await json(
162
- await fetcher(creds.tokenEndpoint, {
163
- method: "POST",
164
- headers: { "content-type": "application/x-www-form-urlencoded" },
165
- body: new URLSearchParams({
166
- grant_type: "refresh_token",
167
- refresh_token: creds.refreshToken,
168
- client_id: creds.clientId,
169
- }),
168
+ if (!creds.refreshToken) throw new HarborError("Signed out. Run harbor login.");
169
+ const res = await reach(fetcher, creds.tokenEndpoint, {
170
+ method: "POST",
171
+ headers: { "content-type": "application/x-www-form-urlencoded" },
172
+ body: new URLSearchParams({
173
+ grant_type: "refresh_token",
174
+ refresh_token: creds.refreshToken,
175
+ client_id: creds.clientId,
170
176
  }),
171
- "Refreshing your sign-in",
172
- );
177
+ });
178
+ // Refused: the sign-in was disconnected in Harbor, or renewed by another
179
+ // copy of these credentials (a refresh token is good once).
180
+ if (res.status === 400 || res.status === 401) {
181
+ throw new HarborError("Your sign-in on this machine has ended. Run harbor login.");
182
+ }
183
+ const tokens = await json(res, "Refreshing your sign-in");
173
184
  return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
174
185
  }
package/lib/cli.mjs CHANGED
@@ -24,27 +24,32 @@ import { jsonText, plain } from "./plain.mjs";
24
24
  export const HELP = `harbor: Harbor from the terminal
25
25
 
26
26
  harbor the full-screen app: everything, with the keyboard
27
+ harbor signup [--server URL] new to Harbor: make an account (opens your browser)
27
28
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
28
29
  harbor logout forget the sign-in on this machine
29
30
  harbor harbors the harbors you belong to
31
+ harbor use HARBOR|none the harbor commands mean when you're in several
30
32
  harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
31
33
  harbor --version which version this is
32
34
 
33
35
  harbor mine what's on you, late first
34
- harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
36
+ harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
35
37
  harbor show TASK one task in full (TASK: its key, RR-42, or id)
36
38
 
37
39
  harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
38
- [--priority low|medium|high|urgent] [--status S]
39
- harbor start TASK | review TASK | done TASK [--confirm]
40
+ [--priority low|medium|high|urgent] [--status S] [--over-limit]
41
+ harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
40
42
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
41
- [--priority P] [--status S]
43
+ [--priority P] [--status S] [--confirm] [--over-limit]
42
44
  harbor comment TASK "TEXT"
43
45
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
44
- harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
45
- harbor issue "TITLE" [--project P]
46
+ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
47
+ [--owner NAME] [--due YYYY-MM-DD]
48
+ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
49
+ [--due YYYY-MM-DD]
46
50
 
47
- Every command takes --harbor NAME (when you're in more than one) and --json.
51
+ Every command takes --json, and --harbor NAME when you're in more than one harbor
52
+ (or set one with harbor use). harbor COMMAND --help shows just that command.
48
53
  Statuses: backlog, todo, doing, review, done.
49
54
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
50
55
  delete; the full-screen app can, as the website can, and asks first.
@@ -120,21 +125,112 @@ const level = (v, name) => {
120
125
  };
121
126
  const nullable = (v) => (v === "none" ? null : v);
122
127
 
123
- /** Harbor's refusals are worded for agents; say which flag answers them. */
128
+ /** Harbor's tool fields, as the CLI's flags. */
129
+ const FLAG = {
130
+ due: "--due",
131
+ priority: "--priority",
132
+ status: "--status",
133
+ assignee: "--assignee",
134
+ title: "--title",
135
+ project: "--project",
136
+ program: "--program",
137
+ owner: "--owner",
138
+ impact: "--impact",
139
+ probability: "--probability",
140
+ query: "the words",
141
+ task: "the task",
142
+ note: "the check-in",
143
+ body: "the comment",
144
+ };
145
+
146
+ /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
124
147
  export function hint(message) {
125
148
  const said = message.replace(/\s*Ask the person;.*$/s, "");
126
149
  if (/confirmed: true/.test(message))
127
150
  return `${said}\nTo finish it anyway, run it again with --confirm.`;
128
151
  if (/overLimit: true/.test(message))
129
152
  return `${said}\nTo go past the limit, run it again with --over-limit.`;
130
- return message;
153
+ const fit = /^Those arguments don't fit \w+: (.*)$/s.exec(message);
154
+ if (fit) {
155
+ // One line per flag: a bad date fails two checks that say the same thing.
156
+ const seen = new Map();
157
+ for (const issue of fit[1].split("; ")) {
158
+ const [path, ...why] = issue.split(": ");
159
+ const field = path.replace(/\.\d+$/, "");
160
+ const flag = FLAG[field] ?? `--${field}`;
161
+ if (!seen.has(flag)) seen.set(flag, why.join(": "));
162
+ }
163
+ return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
164
+ }
165
+ return message
166
+ .replace(
167
+ /Say which one with harbor\.$/,
168
+ "Say which with --harbor NAME, or set one with harbor use NAME.",
169
+ )
170
+ .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
171
+ .replace(
172
+ /^This connection was allowed to read only\..*$/s,
173
+ "This sign-in can only read. To make changes, run harbor login again.",
174
+ )
175
+ .replace(
176
+ /^Say which project or program it belongs to\.$/,
177
+ "Say where it belongs: --project NAME or --program NAME.",
178
+ )
179
+ .replace(
180
+ /^You don't belong to a harbor yet\..*$/s,
181
+ "You aren't in a harbor yet. Run harbor to make one, or open the invite a team sent you.",
182
+ );
131
183
  }
132
184
 
185
+ /** The lines of the help about one command, or null when it has none. */
186
+ export function commandHelp(command) {
187
+ const lines = HELP.split("\n");
188
+ const picked = [];
189
+ for (let i = 0; i < lines.length; i++) {
190
+ const words = lines[i].trim().split(/\s+/);
191
+ const names = words.filter(
192
+ (_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
193
+ );
194
+ if (words[0] !== "harbor" || !names.includes(command)) continue;
195
+ picked.push(lines[i]);
196
+ // Its options carry on below it, further in.
197
+ while (lines[i + 1]?.startsWith(" ") && /^\s+\[|^\s+--/.test(lines[i + 1]))
198
+ picked.push(lines[++i]);
199
+ }
200
+ if (picked.length === 0) return null;
201
+ const common = ["signup", "login", "logout", "use", "theme", "help", "app"].includes(command)
202
+ ? []
203
+ : ["", " Also takes --json, and --harbor NAME when you're in more than one harbor."];
204
+ return [...picked, ...common].join("\n");
205
+ }
206
+
207
+ /** Room for --json and --harbor before the command too: `harbor --json mine`. */
208
+ function commandFirst(argv) {
209
+ const front = [];
210
+ let i = 0;
211
+ while (argv[i] === "--json" || argv[i] === "--harbor" || argv[i]?.startsWith("--harbor=")) {
212
+ if (argv[i] === "--harbor") front.push(argv[i], argv[i + 1] ?? "");
213
+ else front.push(argv[i]);
214
+ i += argv[i] === "--harbor" ? 2 : 1;
215
+ }
216
+ return front.length && argv[i] ? [argv[i], ...front, ...argv.slice(i + 1)] : argv;
217
+ }
218
+
219
+ /** The command's arguments up to a bare --, where words stop being options. */
220
+ const asksHelp = (rest) => {
221
+ const end = rest.indexOf("--");
222
+ return (end < 0 ? rest : rest.slice(0, end)).some((a) => a === "--help" || a === "-h");
223
+ };
224
+
225
+ const none = (positionals) => {
226
+ if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
227
+ };
228
+
133
229
  export async function run(argv, io = {}) {
134
230
  const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
135
231
  const env = io.env ?? process.env;
136
232
  const fetcher = io.fetch ?? fetch;
137
- const [command, ...rest] = argv;
233
+ const [command, ...rest] = commandFirst(argv);
138
234
 
139
235
  if (command === "help" || command === "--help" || command === "-h") {
140
236
  const top = banner(io, env, ({ bold, dim }) => [
@@ -154,6 +250,16 @@ export async function run(argv, io = {}) {
154
250
  return 0;
155
251
  }
156
252
 
253
+ // `harbor done RR-4 --help` asks how, so nothing runs.
254
+ if (command && asksHelp(rest)) {
255
+ const help = commandHelp(command === "app" ? "" : command);
256
+ if (help === null && command !== "app") {
257
+ throw new HarborError(`No command "${command}". Run harbor help.`);
258
+ }
259
+ out(help ?? HELP);
260
+ return 0;
261
+ }
262
+
157
263
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
158
264
  if (!command || command === "app") {
159
265
  const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
@@ -193,37 +299,57 @@ export async function run(argv, io = {}) {
193
299
  await runApp({
194
300
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
195
301
  fetcher,
196
- harbor: values.harbor,
302
+ harbor: values.harbor ?? creds.harbor ?? undefined,
197
303
  theme: (await readSettings(env)).theme,
198
304
  });
199
305
  return 0;
200
306
  }
201
307
 
202
- if (command === "login") {
203
- const { values } = parseArgs(rest, { flags: ["read-only"], options: ["server"] });
308
+ if (command === "login" || command === "signup") {
309
+ const signUp = command === "signup";
310
+ const { values } = parseArgs(rest, {
311
+ flags: signUp ? [] : ["read-only"],
312
+ options: ["server"],
313
+ });
204
314
  const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
315
+ if (!/^https?:\/\/[^/]/.test(server) || !URL.canParse(server)) {
316
+ throw new HarborError(`--server needs a web address, like ${DEFAULT_SERVER}.`);
317
+ }
318
+ const before = await readCredentials(env);
205
319
  const creds = await login({
206
320
  server,
207
321
  readOnly: Boolean(values["read-only"]),
322
+ signUp,
208
323
  fetcher,
209
324
  open:
210
325
  io.open ??
211
326
  ((url) => {
212
- out(`Opening your browser to sign in. If it doesn't open, go to:\n\n ${url}\n`);
327
+ out(
328
+ signUp
329
+ ? `Opening your browser to make your Harbor account. If it doesn't open, go to:\n\n ${url}\n`
330
+ : `Opening your browser to sign in (new to Harbor? choose Create there). If it doesn't open, go to:\n\n ${url}\n`,
331
+ );
213
332
  openBrowser(url);
214
333
  }),
215
334
  });
335
+ // Signing in again keeps the harbor set with harbor use.
336
+ if (before?.harbor && before.server === server) {
337
+ creds.harbor = before.harbor;
338
+ creds.harborName = before.harborName ?? null;
339
+ }
216
340
  await writeCredentials(creds, env);
217
341
  const canWrite = creds.scope.split(" ").includes("harbor:write");
218
342
  const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
343
+ const next = signUp ? "Next: run harbor, and it helps you make your first harbor." : null;
219
344
  const welcome = banner(io, env, ({ bold, dim }) => [
220
345
  bold("Welcome aboard."),
221
346
  said,
347
+ ...(next ? [next] : []),
222
348
  "",
223
349
  dim("harbor the full-screen app"),
224
350
  dim("harbor help every command"),
225
351
  ]);
226
- out(welcome ?? said);
352
+ out(welcome ?? (next ? `${said}\n${next}` : said));
227
353
  return 0;
228
354
  }
229
355
 
@@ -248,6 +374,7 @@ export async function run(argv, io = {}) {
248
374
  }
249
375
 
250
376
  if (command === "logout") {
377
+ none(parseArgs(rest, {}).positionals);
251
378
  await forgetCredentials(env);
252
379
  out(
253
380
  "Signed out on this machine. To end its access everywhere, disconnect Harbor CLI under Connected apps in Harbor's menu.",
@@ -256,7 +383,8 @@ export async function run(argv, io = {}) {
256
383
  }
257
384
 
258
385
  const creds = await readCredentials(env);
259
- if (!creds) throw new HarborError("Not signed in. Run harbor login.");
386
+ if (!creds)
387
+ throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
260
388
  const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
261
389
  const opts = { today: todayLocal() };
262
390
 
@@ -272,16 +400,56 @@ export async function run(argv, io = {}) {
272
400
  const parse = (s) => {
273
401
  const p = parseArgs(rest, s);
274
402
  asJson = Boolean(p.values.json);
275
- return { ...p, harbor: p.values.harbor };
403
+ return { ...p, harbor: p.values.harbor ?? creds.harbor ?? undefined };
276
404
  };
277
405
 
278
406
  switch (command) {
279
407
  case "harbors": {
280
- parse(spec());
281
- return call("list_harbors", {}, harbors);
408
+ none(parse(spec()).positionals);
409
+ return call("list_harbors", {}, (r) =>
410
+ creds.harbor
411
+ ? `${harbors(r)}\n\nCommands use ${creds.harborName ?? creds.harbor} (harbor use).`
412
+ : harbors(r),
413
+ );
414
+ }
415
+ case "use": {
416
+ const { positionals } = parse({ flags: ["json"], options: [] });
417
+ const wanted = positionals.join(" ").trim();
418
+ if (!wanted) {
419
+ out(
420
+ creds.harbor
421
+ ? plain(`Commands use ${creds.harborName ?? creds.harbor}.`)
422
+ : "No harbor set: commands use your only harbor, or the one --harbor names.",
423
+ );
424
+ return 0;
425
+ }
426
+ if (wanted.toLowerCase() === "none") {
427
+ await writeCredentials({ ...creds, harbor: null, harborName: null }, env);
428
+ out("Commands no longer pick a harbor for you.");
429
+ return 0;
430
+ }
431
+ const list = await client.call("list_harbors", {});
432
+ const key = wanted.toLowerCase();
433
+ const exact = list.harbors.filter(
434
+ (h) => h.id === wanted || h.name.trim().toLowerCase() === key,
435
+ );
436
+ const hits = exact.length
437
+ ? exact
438
+ : list.harbors.filter((h) => h.name.toLowerCase().includes(key));
439
+ if (hits.length === 0)
440
+ throw new HarborError(`No harbor matches "${wanted}". Run harbor harbors.`);
441
+ if (hits.length > 1) {
442
+ throw new HarborError(
443
+ `"${wanted}" matches more than one harbor: ${hits.map((h) => h.name).join(", ")}.`,
444
+ );
445
+ }
446
+ await writeCredentials({ ...creds, harbor: hits[0].id, harborName: hits[0].name }, env);
447
+ out(plain(`Commands now use ${hits[0].name}.`));
448
+ return 0;
282
449
  }
283
450
  case "mine": {
284
- const { harbor } = parse(spec());
451
+ const { positionals, harbor } = parse(spec());
452
+ none(positionals);
285
453
  return call("my_work", { harbor }, (r) => myWork(r, opts));
286
454
  }
287
455
  case "tasks": {
@@ -295,7 +463,12 @@ export async function run(argv, io = {}) {
295
463
  query: positionals.join(" ") || undefined,
296
464
  project: values.project,
297
465
  assignee: values.assignee,
298
- status: values.status ? values.status.split(",") : undefined,
466
+ status: values.status
467
+ ? values.status
468
+ .split(",")
469
+ .map((x) => x.trim())
470
+ .filter(Boolean)
471
+ : undefined,
299
472
  overdue: values.late || undefined,
300
473
  },
301
474
  (r) => taskList(r, opts),
@@ -422,8 +595,16 @@ export async function run(argv, io = {}) {
422
595
  case "risk":
423
596
  case "issue": {
424
597
  const { positionals, values, harbor } = parse(
425
- spec([], ["project", "program", "owner", "impact", "probability", "due"]),
598
+ spec(
599
+ [],
600
+ ["project", "program", "owner", "impact", "due"].concat(
601
+ command === "risk" ? ["probability"] : [],
602
+ ),
603
+ ),
426
604
  );
605
+ if (!values.project && !values.program) {
606
+ throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
607
+ }
427
608
  return call(
428
609
  "log_raid",
429
610
  {
@@ -434,7 +615,7 @@ export async function run(argv, io = {}) {
434
615
  program: values.program,
435
616
  owner: values.owner,
436
617
  impact: level(values.impact, "impact"),
437
- probability: command === "risk" ? level(values.probability, "probability") : undefined,
618
+ probability: level(values.probability, "probability"),
438
619
  due: values.due,
439
620
  },
440
621
  (r) => `Logged the ${command} (${r.raised.id}).`,
package/lib/config.mjs CHANGED
@@ -19,7 +19,12 @@ const file = (env) => join(configDir(env), "credentials.json");
19
19
  /** The saved sign-in, or null when there isn't one. */
20
20
  export async function readCredentials(env = process.env) {
21
21
  try {
22
- return JSON.parse(await readFile(file(env), "utf8"));
22
+ const creds = JSON.parse(await readFile(file(env), "utf8"));
23
+ // A file that isn't a whole sign-in is no sign-in.
24
+ const whole = ["server", "accessToken", "tokenEndpoint"].every(
25
+ (k) => typeof creds?.[k] === "string" && creds[k],
26
+ );
27
+ return whole ? creds : null;
23
28
  } catch {
24
29
  return null;
25
30
  }
package/lib/errors.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What the CLI says when something stops a command: a HarborError is
3
+ * already worded for the person and printed as it is; anything else is
4
+ * unexpected and printed as "Something went wrong".
5
+ */
6
+ export class HarborError extends Error {
7
+ // By name too: the full-screen app is bundled with its own copy of this class.
8
+ name = "HarborError";
9
+ }
10
+
11
+ /**
12
+ * fetch, with "the server isn't there" said plainly rather than as
13
+ * "fetch failed" (no network, a wrong --server, the server down).
14
+ * @param {typeof fetch} fetcher
15
+ * @param {string | URL} url
16
+ * @param {RequestInit} [init]
17
+ */
18
+ export async function reach(fetcher, url, init) {
19
+ try {
20
+ return await fetcher(url, init);
21
+ } catch (err) {
22
+ if (/** @type {Error} */ (err)?.name === "AbortError") throw err;
23
+ const where = new URL(url).origin;
24
+ throw new HarborError(
25
+ `Can't reach ${where}. Check your connection, or the address you signed in with.`,
26
+ );
27
+ }
28
+ }
package/lib/format.mjs CHANGED
@@ -52,7 +52,7 @@ export function taskList(r, opts) {
52
52
 
53
53
  export function harbors(r) {
54
54
  const list = r.harbors ?? [];
55
- if (!list.length) return "You aren't in any harbor yet.";
55
+ if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
56
56
  return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
57
57
  }
58
58
 
package/lib/mcp.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * refreshed once and the call retried.
5
5
  */
6
6
  import { refresh } from "./auth.mjs";
7
+ import { HarborError, reach } from "./errors.mjs";
7
8
 
8
- export class HarborError extends Error {}
9
+ export { HarborError };
9
10
 
10
11
  const SKEW_MS = 60_000;
11
12
 
@@ -19,7 +20,7 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
19
20
  }
20
21
 
21
22
  async function post(body) {
22
- return fetcher(new URL("/api/mcp", current.server), {
23
+ return reach(fetcher, new URL("/api/mcp", current.server), {
23
24
  method: "POST",
24
25
  headers: {
25
26
  "content-type": "application/json",
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
+ "keywords": [
6
+ "harbor",
7
+ "project-management",
8
+ "portfolio-management",
9
+ "ppm",
10
+ "kanban",
11
+ "tasks",
12
+ "cli",
13
+ "tui",
14
+ "terminal",
15
+ "mcp"
16
+ ],
5
17
  "type": "module",
6
18
  "bin": {
7
19
  "harbor": "bin/harbor.mjs"