@harborppm/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +52 -2
- package/bin/harbor.mjs +16 -0
- package/lib/args.mjs +34 -0
- package/lib/auth.mjs +170 -0
- package/lib/cli.mjs +314 -0
- package/lib/config.mjs +37 -0
- package/lib/format.mjs +62 -0
- package/lib/mcp.mjs +60 -0
- package/package.json +21 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harbor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Harbor CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Harbor](https://harborppm.com) from the terminal: what's on you, add and finish tasks, comment, check in, log risks.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @harborppm/cli login # opens your browser once
|
|
7
|
+
npx @harborppm/cli mine
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or install it: `npm install -g @harborppm/cli`, then `harbor mine`. Needs Node 20 or later.
|
|
11
|
+
|
|
12
|
+
## Signing in
|
|
13
|
+
|
|
14
|
+
`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.
|
|
15
|
+
|
|
16
|
+
`harbor logout` forgets it on this machine. To end its access everywhere, disconnect **Harbor CLI** under **Connected apps** in Harbor's menu.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
harbor harbors the harbors you belong to
|
|
22
|
+
harbor mine what's on you, late first
|
|
23
|
+
harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
|
|
24
|
+
harbor show TASK one task in full
|
|
25
|
+
|
|
26
|
+
harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
|
|
27
|
+
[--priority low|medium|high|urgent] [--status S]
|
|
28
|
+
harbor start TASK | review TASK | done TASK [--confirm]
|
|
29
|
+
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
30
|
+
[--priority P] [--status S]
|
|
31
|
+
harbor comment TASK "TEXT"
|
|
32
|
+
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
33
|
+
harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
|
|
34
|
+
harbor issue "TITLE" [--project P]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
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`).
|
|
40
|
+
|
|
41
|
+
Every change shows in Harbor's History as yours, "via Harbor CLI". Nothing the CLI does deletes anything.
|
|
42
|
+
|
|
43
|
+
## Another server
|
|
44
|
+
|
|
45
|
+
`harbor login --server https://your-harbor.example` (or `HARBOR_SERVER`). `HARBOR_CONFIG_DIR` moves the saved sign-in.
|
|
46
|
+
|
|
47
|
+
## Developing
|
|
48
|
+
|
|
49
|
+
No dependencies. `npm test` runs the CLI's tests against a stand-in Harbor; `e2e/cli.test.mjs` in the repository drives it against a real one.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT (see `LICENSE`). The rest of the Harbor repository is private.
|
package/bin/harbor.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "../lib/cli.mjs";
|
|
3
|
+
import { HarborError } from "../lib/mcp.mjs";
|
|
4
|
+
|
|
5
|
+
run(process.argv.slice(2)).then(
|
|
6
|
+
(code) => process.exit(code),
|
|
7
|
+
(err) => {
|
|
8
|
+
const known =
|
|
9
|
+
err instanceof HarborError ||
|
|
10
|
+
/^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
|
|
11
|
+
process.stderr.write(
|
|
12
|
+
`${known ? err.message : `Something went wrong: ${err?.message ?? err}`}\n`,
|
|
13
|
+
);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
},
|
|
16
|
+
);
|
package/lib/args.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small argument reader: positionals, `--flag`, `--key value` and
|
|
3
|
+
* `--key=value`. Unknown flags are an error, so a typo doesn't quietly do
|
|
4
|
+
* something else.
|
|
5
|
+
*/
|
|
6
|
+
export function parseArgs(argv, { flags = [], options = [] } = {}) {
|
|
7
|
+
const positionals = [];
|
|
8
|
+
const values = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (arg === "--") {
|
|
12
|
+
positionals.push(...argv.slice(i + 1));
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
if (!arg.startsWith("--") || arg === "--") {
|
|
16
|
+
positionals.push(arg);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const [key, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
20
|
+
if (flags.includes(key)) {
|
|
21
|
+
if (inline !== undefined) throw new Error(`--${key} takes no value.`);
|
|
22
|
+
values[key] = true;
|
|
23
|
+
} else if (options.includes(key)) {
|
|
24
|
+
const value = inline ?? argv[++i];
|
|
25
|
+
if (value === undefined || (inline === undefined && value.startsWith("--"))) {
|
|
26
|
+
throw new Error(`--${key} needs a value.`);
|
|
27
|
+
}
|
|
28
|
+
values[key] = value;
|
|
29
|
+
} else {
|
|
30
|
+
throw new Error(`Unknown option --${key}.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { positionals, values };
|
|
34
|
+
}
|
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in: OAuth 2.1 with PKCE against Harbor's authorization server, as
|
|
3
|
+
* any MCP app does. The CLI registers itself, opens the browser at Harbor's
|
|
4
|
+
* asking page and listens on 127.0.0.1 for the answer.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
|
|
9
|
+
export const CLIENT_NAME = "Harbor CLI";
|
|
10
|
+
export const WRITE_SCOPE = "harbor:write";
|
|
11
|
+
|
|
12
|
+
const b64url = (buf) => Buffer.from(buf).toString("base64url");
|
|
13
|
+
|
|
14
|
+
export function pkcePair() {
|
|
15
|
+
const verifier = b64url(randomBytes(32));
|
|
16
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
17
|
+
return { verifier, challenge };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function json(res, what) {
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
let body = null;
|
|
23
|
+
try {
|
|
24
|
+
body = JSON.parse(text);
|
|
25
|
+
} catch {
|
|
26
|
+
// Not JSON: say what came back below.
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const why = body?.error_description || body?.error || text.slice(0, 200) || res.statusText;
|
|
30
|
+
throw new Error(`${what} failed (${res.status}): ${why}`);
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The server's OAuth endpoints, from its discovery document. */
|
|
36
|
+
export async function discover(server, fetcher = fetch) {
|
|
37
|
+
const res = await fetcher(new URL("/.well-known/oauth-authorization-server", server));
|
|
38
|
+
return json(res, "Finding Harbor's sign-in");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wait for the browser to come back to http://127.0.0.1:<port>/callback.
|
|
43
|
+
* Resolves with { redirectUri, code: Promise<string> } once listening.
|
|
44
|
+
*/
|
|
45
|
+
export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
|
|
46
|
+
let settle;
|
|
47
|
+
const code = new Promise((resolve, reject) => (settle = { resolve, reject }));
|
|
48
|
+
const server = createServer((req, res) => {
|
|
49
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
50
|
+
if (url.pathname !== "/callback") {
|
|
51
|
+
res.writeHead(404).end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const error = url.searchParams.get("error");
|
|
55
|
+
const ok = !error && url.searchParams.get("state") === state && url.searchParams.get("code");
|
|
56
|
+
res.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
|
|
57
|
+
res.end(
|
|
58
|
+
`<!doctype html><meta charset="utf-8"><title>Harbor CLI</title><body style="font:16px system-ui;margin:3rem">` +
|
|
59
|
+
(ok
|
|
60
|
+
? "<h1>You're signed in</h1><p>Go back to your terminal.</p>"
|
|
61
|
+
: `<h1>Not signed in</h1><p>${error === "access_denied" ? "You declined." : "Something didn't match. Run harbor login again."}</p>`) +
|
|
62
|
+
"</body>",
|
|
63
|
+
);
|
|
64
|
+
if (ok) settle.resolve(url.searchParams.get("code"));
|
|
65
|
+
else
|
|
66
|
+
settle.reject(
|
|
67
|
+
new Error(
|
|
68
|
+
error === "access_denied"
|
|
69
|
+
? "You declined in the browser."
|
|
70
|
+
: "The sign-in didn't come back as expected.",
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
75
|
+
const { port } = server.address();
|
|
76
|
+
const timer = setTimeout(
|
|
77
|
+
() => settle.reject(new Error("No answer from the browser in 5 minutes.")),
|
|
78
|
+
timeoutMs,
|
|
79
|
+
);
|
|
80
|
+
const done = code.finally(() => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
server.close();
|
|
83
|
+
});
|
|
84
|
+
done.catch(() => {});
|
|
85
|
+
return { redirectUri: `http://127.0.0.1:${port}/callback`, code: done };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The whole sign-in. `open(url)` shows the asking page (a browser, or a test
|
|
90
|
+
* driving one). Returns credentials to save.
|
|
91
|
+
*/
|
|
92
|
+
export async function login({ server, readOnly = false, open, fetcher = fetch, now = Date.now }) {
|
|
93
|
+
const meta = await discover(server, fetcher);
|
|
94
|
+
const state = b64url(randomBytes(16));
|
|
95
|
+
const { verifier, challenge } = pkcePair();
|
|
96
|
+
const { redirectUri, code } = await listenForCode(state);
|
|
97
|
+
|
|
98
|
+
const client = await json(
|
|
99
|
+
await fetcher(meta.registration_endpoint, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
client_name: CLIENT_NAME,
|
|
104
|
+
redirect_uris: [redirectUri],
|
|
105
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
106
|
+
response_types: ["code"],
|
|
107
|
+
token_endpoint_auth_method: "none",
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
"Registering the CLI",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const authorize = new URL(meta.authorization_endpoint);
|
|
114
|
+
authorize.search = new URLSearchParams({
|
|
115
|
+
response_type: "code",
|
|
116
|
+
client_id: client.client_id,
|
|
117
|
+
redirect_uri: redirectUri,
|
|
118
|
+
state,
|
|
119
|
+
code_challenge: challenge,
|
|
120
|
+
code_challenge_method: "S256",
|
|
121
|
+
scope: readOnly ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE}`,
|
|
122
|
+
}).toString();
|
|
123
|
+
await open(authorize.toString());
|
|
124
|
+
|
|
125
|
+
const tokens = await json(
|
|
126
|
+
await fetcher(meta.token_endpoint, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
129
|
+
body: new URLSearchParams({
|
|
130
|
+
grant_type: "authorization_code",
|
|
131
|
+
code: await code,
|
|
132
|
+
redirect_uri: redirectUri,
|
|
133
|
+
client_id: client.client_id,
|
|
134
|
+
code_verifier: verifier,
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
"Finishing sign-in",
|
|
138
|
+
);
|
|
139
|
+
return toCredentials(server, meta.token_endpoint, client.client_id, tokens, now());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function toCredentials(server, tokenEndpoint, clientId, tokens, at) {
|
|
143
|
+
return {
|
|
144
|
+
server,
|
|
145
|
+
tokenEndpoint,
|
|
146
|
+
clientId,
|
|
147
|
+
accessToken: tokens.access_token,
|
|
148
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
149
|
+
expiresAt: at + (tokens.expires_in ?? 3600) * 1000,
|
|
150
|
+
scope: tokens.scope ?? "",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** New tokens from the refresh token; the old one stops working. */
|
|
155
|
+
export async function refresh(creds, { fetcher = fetch, now = Date.now } = {}) {
|
|
156
|
+
if (!creds.refreshToken) throw new Error("Signed out. Run harbor login.");
|
|
157
|
+
const tokens = await json(
|
|
158
|
+
await fetcher(creds.tokenEndpoint, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
161
|
+
body: new URLSearchParams({
|
|
162
|
+
grant_type: "refresh_token",
|
|
163
|
+
refresh_token: creds.refreshToken,
|
|
164
|
+
client_id: creds.clientId,
|
|
165
|
+
}),
|
|
166
|
+
}),
|
|
167
|
+
"Refreshing your sign-in",
|
|
168
|
+
);
|
|
169
|
+
return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
|
|
170
|
+
}
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commands. `run(argv, io)` is the whole CLI with its outside world
|
|
3
|
+
* passed in (output, the browser, fetch, where credentials live), so tests
|
|
4
|
+
* drive it without a terminal.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { parseArgs } from "./args.mjs";
|
|
8
|
+
import { login } from "./auth.mjs";
|
|
9
|
+
import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
|
|
10
|
+
import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
|
|
11
|
+
import { HarborError, createClient } from "./mcp.mjs";
|
|
12
|
+
|
|
13
|
+
export const HELP = `harbor: Harbor from the terminal
|
|
14
|
+
|
|
15
|
+
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
16
|
+
harbor logout forget the sign-in on this machine
|
|
17
|
+
harbor harbors the harbors you belong to
|
|
18
|
+
|
|
19
|
+
harbor mine what's on you, late first
|
|
20
|
+
harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
|
|
21
|
+
harbor show TASK one task in full (TASK: its key, RR-42, or id)
|
|
22
|
+
|
|
23
|
+
harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
|
|
24
|
+
[--priority low|medium|high|urgent] [--status S]
|
|
25
|
+
harbor start TASK | review TASK | done TASK [--confirm]
|
|
26
|
+
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
27
|
+
[--priority P] [--status S]
|
|
28
|
+
harbor comment TASK "TEXT"
|
|
29
|
+
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
30
|
+
harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
|
|
31
|
+
harbor issue "TITLE" [--project P]
|
|
32
|
+
|
|
33
|
+
Every command takes --harbor NAME (when you're in more than one) and --json.
|
|
34
|
+
Statuses: backlog, todo, doing, review, done.
|
|
35
|
+
Changes show in Harbor's History as yours, "via Harbor CLI". Nothing here deletes.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const COMMON = { flags: ["json", "help"], options: ["harbor"] };
|
|
39
|
+
const spec = (flags = [], options = []) => ({
|
|
40
|
+
flags: [...COMMON.flags, ...flags],
|
|
41
|
+
options: [...COMMON.options, ...options],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function openBrowser(url) {
|
|
45
|
+
const [cmd, args] =
|
|
46
|
+
process.platform === "darwin"
|
|
47
|
+
? ["open", [url]]
|
|
48
|
+
: process.platform === "win32"
|
|
49
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
50
|
+
: ["xdg-open", [url]];
|
|
51
|
+
try {
|
|
52
|
+
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
53
|
+
.on("error", () => {})
|
|
54
|
+
.unref();
|
|
55
|
+
} catch {
|
|
56
|
+
// No browser to open: the printed address is enough.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const one = (positionals, what) => {
|
|
61
|
+
if (positionals.length !== 1) throw new HarborError(`Give one ${what}.`);
|
|
62
|
+
return positionals[0];
|
|
63
|
+
};
|
|
64
|
+
const text = (positionals, what) => {
|
|
65
|
+
const s = positionals.join(" ").trim();
|
|
66
|
+
if (!s) throw new HarborError(`Say ${what}.`);
|
|
67
|
+
return s;
|
|
68
|
+
};
|
|
69
|
+
const level = (v, name) => {
|
|
70
|
+
if (v === undefined) return undefined;
|
|
71
|
+
const n = Number(v);
|
|
72
|
+
if (!Number.isInteger(n) || n < 1 || n > 5) throw new HarborError(`--${name} is 1 to 5.`);
|
|
73
|
+
return n;
|
|
74
|
+
};
|
|
75
|
+
const nullable = (v) => (v === "none" ? null : v);
|
|
76
|
+
|
|
77
|
+
/** Harbor's refusals are worded for agents; say which flag answers them. */
|
|
78
|
+
export function hint(message) {
|
|
79
|
+
const said = message.replace(/\s*Ask the person;.*$/s, "");
|
|
80
|
+
if (/confirmed: true/.test(message))
|
|
81
|
+
return `${said}\nTo finish it anyway, run it again with --confirm.`;
|
|
82
|
+
if (/overLimit: true/.test(message))
|
|
83
|
+
return `${said}\nTo go past the limit, run it again with --over-limit.`;
|
|
84
|
+
return message;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function run(argv, io = {}) {
|
|
88
|
+
const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
|
|
89
|
+
const env = io.env ?? process.env;
|
|
90
|
+
const fetcher = io.fetch ?? fetch;
|
|
91
|
+
const [command, ...rest] = argv;
|
|
92
|
+
|
|
93
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
94
|
+
out(HELP);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (command === "login") {
|
|
99
|
+
const { values } = parseArgs(rest, { flags: ["read-only"], options: ["server"] });
|
|
100
|
+
const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
|
|
101
|
+
const creds = await login({
|
|
102
|
+
server,
|
|
103
|
+
readOnly: Boolean(values["read-only"]),
|
|
104
|
+
fetcher,
|
|
105
|
+
open:
|
|
106
|
+
io.open ??
|
|
107
|
+
((url) => {
|
|
108
|
+
out(`Opening your browser to sign in. If it doesn't open, go to:\n\n ${url}\n`);
|
|
109
|
+
openBrowser(url);
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
await writeCredentials(creds, env);
|
|
113
|
+
const canWrite = creds.scope.split(" ").includes("harbor:write");
|
|
114
|
+
out(`Signed in to ${server}${canWrite ? "." : ", reading only."}`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (command === "logout") {
|
|
119
|
+
await forgetCredentials(env);
|
|
120
|
+
out(
|
|
121
|
+
"Signed out on this machine. To end its access everywhere, disconnect Harbor CLI under Connected apps in Harbor's menu.",
|
|
122
|
+
);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const creds = await readCredentials(env);
|
|
127
|
+
if (!creds) throw new HarborError("Not signed in. Run harbor login.");
|
|
128
|
+
const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
|
|
129
|
+
const opts = { today: todayLocal() };
|
|
130
|
+
|
|
131
|
+
const call = async (name, args, show) => {
|
|
132
|
+
const result = await client.call(name, args).catch((err) => {
|
|
133
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
134
|
+
throw err;
|
|
135
|
+
});
|
|
136
|
+
out(asJson ? JSON.stringify(result, null, 2) : show(result));
|
|
137
|
+
return 0;
|
|
138
|
+
};
|
|
139
|
+
let asJson = false;
|
|
140
|
+
const parse = (s) => {
|
|
141
|
+
const p = parseArgs(rest, s);
|
|
142
|
+
asJson = Boolean(p.values.json);
|
|
143
|
+
return { ...p, harbor: p.values.harbor };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
switch (command) {
|
|
147
|
+
case "harbors": {
|
|
148
|
+
parse(spec());
|
|
149
|
+
return call("list_harbors", {}, harbors);
|
|
150
|
+
}
|
|
151
|
+
case "mine": {
|
|
152
|
+
const { harbor } = parse(spec());
|
|
153
|
+
return call("my_work", { harbor }, (r) => myWork(r, opts));
|
|
154
|
+
}
|
|
155
|
+
case "tasks": {
|
|
156
|
+
const { positionals, values, harbor } = parse(
|
|
157
|
+
spec(["late"], ["project", "assignee", "status"]),
|
|
158
|
+
);
|
|
159
|
+
return call(
|
|
160
|
+
"search_tasks",
|
|
161
|
+
{
|
|
162
|
+
harbor,
|
|
163
|
+
query: positionals.join(" ") || undefined,
|
|
164
|
+
project: values.project,
|
|
165
|
+
assignee: values.assignee,
|
|
166
|
+
status: values.status ? values.status.split(",") : undefined,
|
|
167
|
+
overdue: values.late || undefined,
|
|
168
|
+
},
|
|
169
|
+
(r) => taskList(r, opts),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
case "show": {
|
|
173
|
+
const { positionals, harbor } = parse(spec());
|
|
174
|
+
return call("get_task", { harbor, task: one(positionals, "task id") }, (t) => {
|
|
175
|
+
const lines = [
|
|
176
|
+
taskLine(
|
|
177
|
+
{ ...t, waitingOn: t.waitingOn.filter((w) => w.status !== "done").length },
|
|
178
|
+
opts,
|
|
179
|
+
),
|
|
180
|
+
];
|
|
181
|
+
if (t.description) lines.push("", t.description);
|
|
182
|
+
if (t.checklist.length)
|
|
183
|
+
lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
|
|
184
|
+
if (t.waitingOn.length)
|
|
185
|
+
lines.push(
|
|
186
|
+
"",
|
|
187
|
+
"Waits on:",
|
|
188
|
+
...t.waitingOn.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
|
|
189
|
+
);
|
|
190
|
+
if (t.waitedOnBy.length)
|
|
191
|
+
lines.push(
|
|
192
|
+
"",
|
|
193
|
+
"Holds up:",
|
|
194
|
+
...t.waitedOnBy.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
|
|
195
|
+
);
|
|
196
|
+
if (t.comments.length)
|
|
197
|
+
lines.push(
|
|
198
|
+
"",
|
|
199
|
+
...t.comments.map((c) => `${c.author ?? "Someone"}, ${c.at.slice(0, 10)}: ${c.body}`),
|
|
200
|
+
);
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
case "add": {
|
|
205
|
+
const { positionals, values, harbor } = parse(
|
|
206
|
+
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
207
|
+
);
|
|
208
|
+
if (!values.project) throw new HarborError("Say which project: --project NAME.");
|
|
209
|
+
return call(
|
|
210
|
+
"create_task",
|
|
211
|
+
{
|
|
212
|
+
harbor,
|
|
213
|
+
project: values.project,
|
|
214
|
+
title: text(positionals, "the task"),
|
|
215
|
+
assignee: values.assignee,
|
|
216
|
+
due: values.due,
|
|
217
|
+
priority: values.priority,
|
|
218
|
+
status: values.status,
|
|
219
|
+
overLimit: values["over-limit"] || undefined,
|
|
220
|
+
},
|
|
221
|
+
(r) => `Added: ${taskLine(r.created, opts)}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
case "start":
|
|
225
|
+
case "review":
|
|
226
|
+
case "done": {
|
|
227
|
+
const { positionals, values, harbor } = parse(spec(["confirm", "over-limit"]));
|
|
228
|
+
const status = { start: "doing", review: "review", done: "done" }[command];
|
|
229
|
+
return call(
|
|
230
|
+
"update_task",
|
|
231
|
+
{
|
|
232
|
+
harbor,
|
|
233
|
+
task: one(positionals, "task id"),
|
|
234
|
+
status,
|
|
235
|
+
confirmed: values.confirm || undefined,
|
|
236
|
+
overLimit: values["over-limit"] || undefined,
|
|
237
|
+
},
|
|
238
|
+
(r) =>
|
|
239
|
+
[
|
|
240
|
+
`${r.updated.title}: ${{ doing: "started", review: "in review", done: "done" }[status]}.`,
|
|
241
|
+
r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
|
|
242
|
+
]
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.join("\n"),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
case "edit": {
|
|
248
|
+
const { positionals, values, harbor } = parse(
|
|
249
|
+
spec(["confirm", "over-limit"], ["title", "assignee", "due", "priority", "status"]),
|
|
250
|
+
);
|
|
251
|
+
return call(
|
|
252
|
+
"update_task",
|
|
253
|
+
{
|
|
254
|
+
harbor,
|
|
255
|
+
task: one(positionals, "task id"),
|
|
256
|
+
title: values.title,
|
|
257
|
+
assignee: values.assignee,
|
|
258
|
+
due: values.due === undefined ? undefined : nullable(values.due),
|
|
259
|
+
priority: values.priority,
|
|
260
|
+
status: values.status,
|
|
261
|
+
confirmed: values.confirm || undefined,
|
|
262
|
+
overLimit: values["over-limit"] || undefined,
|
|
263
|
+
},
|
|
264
|
+
(r) => `Updated: ${taskLine(r.updated, opts)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
case "comment": {
|
|
268
|
+
const { positionals, harbor } = parse(spec());
|
|
269
|
+
const [task, ...words] = positionals;
|
|
270
|
+
if (!task) throw new HarborError("Give a task id, then the comment.");
|
|
271
|
+
return call(
|
|
272
|
+
"add_comment",
|
|
273
|
+
{ harbor, task, body: text(words, "the comment") },
|
|
274
|
+
(r) =>
|
|
275
|
+
`Comment added to ${r.commented.task}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
case "checkin": {
|
|
279
|
+
const { positionals, values, harbor } = parse(spec(["on-blocker"]));
|
|
280
|
+
return call(
|
|
281
|
+
"check_in",
|
|
282
|
+
{
|
|
283
|
+
harbor,
|
|
284
|
+
note: text(positionals, "what you're doing"),
|
|
285
|
+
onBlocker: Boolean(values["on-blocker"]),
|
|
286
|
+
},
|
|
287
|
+
() => "Checked in for today.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
case "risk":
|
|
291
|
+
case "issue": {
|
|
292
|
+
const { positionals, values, harbor } = parse(
|
|
293
|
+
spec([], ["project", "program", "owner", "impact", "probability", "due"]),
|
|
294
|
+
);
|
|
295
|
+
return call(
|
|
296
|
+
"log_raid",
|
|
297
|
+
{
|
|
298
|
+
harbor,
|
|
299
|
+
kind: command,
|
|
300
|
+
title: text(positionals, `the ${command}`),
|
|
301
|
+
project: values.project,
|
|
302
|
+
program: values.program,
|
|
303
|
+
owner: values.owner,
|
|
304
|
+
impact: level(values.impact, "impact"),
|
|
305
|
+
probability: command === "risk" ? level(values.probability, "probability") : undefined,
|
|
306
|
+
due: values.due,
|
|
307
|
+
},
|
|
308
|
+
(r) => `Logged the ${command} (${r.raised.id}).`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
default:
|
|
312
|
+
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the CLI keeps its sign-in: one JSON file per machine, readable only
|
|
3
|
+
* by its owner. HARBOR_CONFIG_DIR moves it (tests, several accounts).
|
|
4
|
+
*/
|
|
5
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SERVER = "https://harborppm.com";
|
|
10
|
+
|
|
11
|
+
export function configDir(env = process.env) {
|
|
12
|
+
if (env.HARBOR_CONFIG_DIR) return env.HARBOR_CONFIG_DIR;
|
|
13
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
14
|
+
return join(base, "harbor");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const file = (env) => join(configDir(env), "credentials.json");
|
|
18
|
+
|
|
19
|
+
/** The saved sign-in, or null when there isn't one. */
|
|
20
|
+
export async function readCredentials(env = process.env) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(file(env), "utf8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeCredentials(creds, env = process.env) {
|
|
29
|
+
await mkdir(configDir(env), { recursive: true, mode: 0o700 });
|
|
30
|
+
await writeFile(file(env), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
31
|
+
// writeFile keeps an existing file's mode; make sure it's private.
|
|
32
|
+
await chmod(file(env), 0o600);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function forgetCredentials(env = process.env) {
|
|
36
|
+
await rm(file(env), { force: true });
|
|
37
|
+
}
|
package/lib/format.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
|
|
2
|
+
|
|
3
|
+
const STATUS = {
|
|
4
|
+
backlog: "backlog",
|
|
5
|
+
todo: "to do",
|
|
6
|
+
doing: "doing",
|
|
7
|
+
review: "review",
|
|
8
|
+
done: "done",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** `mine` leaves out whose each task is: every one is yours. */
|
|
12
|
+
export function taskLine(t, { today, mine = false } = {}) {
|
|
13
|
+
const late = t.due && today && t.due < today && t.status !== "done";
|
|
14
|
+
const bits = [
|
|
15
|
+
STATUS[t.status] ?? t.status,
|
|
16
|
+
t.due ? (late ? `late, due ${t.due}` : `due ${t.due}`) : null,
|
|
17
|
+
t.priority && t.priority !== "medium" ? t.priority : null,
|
|
18
|
+
mine ? null : (t.assignee ?? null),
|
|
19
|
+
t.project ?? null,
|
|
20
|
+
t.waitingOn ? `waits on ${t.waitingOn}` : null,
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
return `${t.key ?? t.id} ${t.title} (${bits.join(" · ")})`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function myWork(w, opts) {
|
|
26
|
+
if (!w.you) return w.note ?? "Nothing is yours in this harbor yet.";
|
|
27
|
+
const out = [`${w.you}: ${w.openTasks} open${w.late ? `, ${w.late} late` : ""}`];
|
|
28
|
+
if (w.tasks?.length) out.push("", ...w.tasks.map((t) => taskLine(t, { ...opts, mine: true })));
|
|
29
|
+
if (w.waitingOnYou?.length) {
|
|
30
|
+
out.push("", "Waiting on you:");
|
|
31
|
+
for (const x of w.waitingOnYou)
|
|
32
|
+
out.push(
|
|
33
|
+
` ${x.theirs.title} (${x.theirs.assignee ?? "unassigned"}) waits on ${x.yourTask.title}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (w.raid?.length) {
|
|
37
|
+
out.push("", "Yours in the register:");
|
|
38
|
+
for (const r of w.raid)
|
|
39
|
+
out.push(` ${r.id} ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
return out.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function taskList(r, opts) {
|
|
45
|
+
if (!r.tasks?.length) return "No tasks match.";
|
|
46
|
+
const more =
|
|
47
|
+
r.total > r.tasks.length
|
|
48
|
+
? `\n…and ${r.total - r.tasks.length} more. Narrow it with a word or --project.`
|
|
49
|
+
: "";
|
|
50
|
+
return r.tasks.map((t) => taskLine(t, opts)).join("\n") + more;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function harbors(r) {
|
|
54
|
+
const list = r.harbors ?? [];
|
|
55
|
+
if (!list.length) return "You aren't in any harbor yet.";
|
|
56
|
+
return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function todayLocal(d = new Date()) {
|
|
60
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
61
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
62
|
+
}
|
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling Harbor's tools: one JSON-RPC `tools/call` per command, over the
|
|
3
|
+
* same /api/mcp endpoint Claude uses. Tokens last an hour; an expired one is
|
|
4
|
+
* refreshed once and the call retried.
|
|
5
|
+
*/
|
|
6
|
+
import { refresh } from "./auth.mjs";
|
|
7
|
+
|
|
8
|
+
export class HarborError extends Error {}
|
|
9
|
+
|
|
10
|
+
const SKEW_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
|
|
13
|
+
let current = creds;
|
|
14
|
+
let id = 0;
|
|
15
|
+
|
|
16
|
+
async function renew() {
|
|
17
|
+
current = await refresh(current, { fetcher, now });
|
|
18
|
+
await save(current);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function post(body) {
|
|
22
|
+
return fetcher(new URL("/api/mcp", current.server), {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
accept: "application/json, text/event-stream",
|
|
27
|
+
authorization: `Bearer ${current.accessToken}`,
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
/** The tool's structured result; a HarborError with Harbor's own words when it says no. */
|
|
35
|
+
async call(name, args = {}) {
|
|
36
|
+
if (current.expiresAt - SKEW_MS < now()) await renew();
|
|
37
|
+
const body = {
|
|
38
|
+
jsonrpc: "2.0",
|
|
39
|
+
id: ++id,
|
|
40
|
+
method: "tools/call",
|
|
41
|
+
params: { name, arguments: args },
|
|
42
|
+
};
|
|
43
|
+
let res = await post(body);
|
|
44
|
+
if (res.status === 401) {
|
|
45
|
+
await renew();
|
|
46
|
+
res = await post(body);
|
|
47
|
+
}
|
|
48
|
+
if (res.status === 401) throw new HarborError("Signed out. Run harbor login.");
|
|
49
|
+
if (res.status === 429)
|
|
50
|
+
throw new HarborError("Harbor asked to slow down. Try again in a minute.");
|
|
51
|
+
if (!res.ok) throw new HarborError(`Harbor answered ${res.status}.`);
|
|
52
|
+
const msg = await res.json();
|
|
53
|
+
if (msg.error) throw new HarborError(msg.error.message);
|
|
54
|
+
const result = msg.result ?? {};
|
|
55
|
+
if (result.isError)
|
|
56
|
+
throw new HarborError(result.content?.map((c) => c.text).join("\n") || "Harbor said no.");
|
|
57
|
+
return result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harborppm/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Harbor from the terminal: what's on you, add and finish tasks, comment, check in, log risks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"harbor": "bin/harbor.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://harborppm.com/ai",
|
|
22
|
+
"license": "MIT"
|
|
23
|
+
}
|