@harborppm/cli 0.9.3 → 0.10.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 +22 -4
- package/bin/harbor.mjs +7 -5
- package/dist/app.mjs +6 -6
- package/lib/cli.mjs +84 -1
- package/lib/format.mjs +52 -0
- 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,24 @@ 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
|
+
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
|
+
|
|
46
60
|
## Staying up to date
|
|
47
61
|
|
|
48
62
|
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 +65,6 @@ npm doesn't update a global install by itself, so once a day `harbor` asks npm w
|
|
|
51
65
|
|
|
52
66
|
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
67
|
|
|
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
68
|
## Signing in
|
|
57
69
|
|
|
58
70
|
`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.
|
|
@@ -64,10 +76,16 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
64
76
|
```
|
|
65
77
|
harbor harbors the harbors you belong to
|
|
66
78
|
harbor use HARBOR|none the harbor to work in when you're in several
|
|
79
|
+
harbor programs programs and their projects
|
|
80
|
+
harbor projects [words] [--program P] projects with health and open work
|
|
67
81
|
harbor mine what's on you, late first
|
|
68
82
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
69
83
|
harbor show TASK one task in full
|
|
70
84
|
|
|
85
|
+
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
86
|
+
[--ends DATE]
|
|
87
|
+
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
88
|
+
[--ends DATE] [--budget N]
|
|
71
89
|
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
72
90
|
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
73
91
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
@@ -81,7 +99,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
81
99
|
[--due DATE]
|
|
82
100
|
```
|
|
83
101
|
|
|
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.
|
|
102
|
+
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
103
|
|
|
86
104
|
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
105
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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: "
|
|
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" ? "
|
|
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: "
|
|
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 {
|
|
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.";
|