@harborppm/cli 0.13.0 → 0.14.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 CHANGED
@@ -59,9 +59,19 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
59
59
 
60
60
  The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
61
61
 
62
+ ## In your shell prompt
63
+
64
+ `harbor prompt` prints a few characters for your prompt: `⚓ 2 today · 1 late`, with late work in coral, and nothing at all when you're clear. It never waits on the network: it shows the counts it kept and fetches new ones in the background when they're more than five minutes old, or after you change a task. `harbor prompt --init zsh` (or `bash`, `fish`, `starship`) prints the lines to add to your shell's setup, so `harbor prompt --init starship >> ~/.config/starship.toml` does it in one go:
65
+
66
+ ```zsh
67
+ # ~/.zshrc
68
+ setopt prompt_subst
69
+ RPROMPT='$(harbor prompt --shell zsh)'
70
+ ```
71
+
62
72
  ## Staying up to date
63
73
 
64
- npm doesn't update a global install by itself, so once a day `harbor` asks npm whether a newer version is out, alongside whatever command you ran, and says so in one line when there is (`npm install -g @harborppm/cli@latest` updates it). It says nothing in scripts, pipes or CI; set `HARBOR_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to turn it off.
74
+ 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 --prefer-online` 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.
65
75
 
66
76
  ## Text from others
67
77
 
package/bin/harbor.mjs CHANGED
@@ -6,9 +6,11 @@ import { updateNotice } from "../lib/update.mjs";
6
6
 
7
7
  const argv = process.argv.slice(2);
8
8
  // Asked alongside the command, so it costs nothing extra (see update.mjs).
9
- const notice = argv.includes("--json")
10
- ? Promise.resolve(null)
11
- : updateNotice({ version: VERSION }).catch(() => null);
9
+ // Not for the shell prompt, which draws on every Enter and must never wait.
10
+ const notice =
11
+ argv.includes("--json") || argv[0] === "prompt"
12
+ ? Promise.resolve(null)
13
+ : updateNotice({ version: VERSION }).catch(() => null);
12
14
  const tell = async () => {
13
15
  const line = await notice;
14
16
  if (line) process.stderr.write(`\n${line}\n`);
package/lib/cli.mjs CHANGED
@@ -29,6 +29,18 @@ import {
29
29
  import { colourDepth, logoLines } from "./logo.mjs";
30
30
  import { HarborError, createClient } from "./mcp.mjs";
31
31
  import { jsonText, plain } from "./plain.mjs";
32
+ import {
33
+ FETCH_LIMIT,
34
+ SHELLS,
35
+ initSnippet,
36
+ isFresh,
37
+ promptCounts,
38
+ readPromptCache,
39
+ refreshInBackground,
40
+ segment,
41
+ stalePromptCache,
42
+ writePromptCache,
43
+ } from "./prompt.mjs";
32
44
 
33
45
  export const HELP = `harbor: Harbor from the terminal
34
46
 
@@ -50,6 +62,10 @@ export const HELP = `harbor: Harbor from the terminal
50
62
  harbor show TASK one task (TASK: a key like RR-42)
51
63
  harbor open [TASK|PROJECT] open it on the website (or Today)
52
64
  harbor invite EMAIL [--viewer] invite someone to your harbor
65
+ harbor prompt [--shell SHELL] [--refresh] due today and late, for your
66
+ shell prompt (never waits)
67
+ harbor prompt --init SHELL how to add it: zsh, bash, fish
68
+ or starship
53
69
 
54
70
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
55
71
  [--ends DATE]
@@ -497,6 +513,62 @@ export async function run(argv, io = {}) {
497
513
  return 0;
498
514
  }
499
515
 
516
+ if (command === "prompt") {
517
+ const { values, positionals } = parseArgs(rest, {
518
+ flags: ["refresh", "json"],
519
+ options: ["shell", "init", "harbor"],
520
+ });
521
+ none(positionals);
522
+ if (values.init !== undefined) {
523
+ const snippet = initSnippet(values.init);
524
+ if (!snippet) throw new HarborError(`Pick ${SHELLS.join(", ")} (not "${values.init}").`);
525
+ out(
526
+ // Comments, so the whole of it can be appended straight to the file.
527
+ `${snippet}\n# Shows what's due today and late, from the prompt after next;\n# nothing when you're clear. harbor prompt --refresh checks now.`,
528
+ );
529
+ return 0;
530
+ }
531
+ const shell = values.shell;
532
+ if (shell !== undefined && !SHELLS.includes(shell))
533
+ throw new HarborError(`--shell is ${SHELLS.join(", ")} (not "${shell}").`);
534
+ const creds = await readCredentials(env);
535
+ const harbor = values.harbor ?? creds?.harbor ?? null;
536
+ const today = todayLocal(io.now ? new Date(io.now()) : new Date());
537
+ // A prompt with nothing to say gets nothing, not an empty line; a
538
+ // --refresh run by hand says what it found.
539
+ const show = (counts, at, byHand = false) => {
540
+ if (values.json) return out(jsonText({ ...(counts ?? { today: null, late: null }), at }));
541
+ const text = segment(counts ?? {}, shell);
542
+ if (text) out(text);
543
+ else if (byHand) out("Nothing due today or late.");
544
+ };
545
+ if (!creds) {
546
+ if (values.refresh) throw new HarborError("Not signed in. Run harbor login.");
547
+ show(null, null);
548
+ return 0;
549
+ }
550
+ if (values.refresh) {
551
+ const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
552
+ const work = await client.call("my_work", {
553
+ harbor: harbor ?? undefined,
554
+ limit: FETCH_LIMIT,
555
+ });
556
+ const counts = promptCounts(work, today);
557
+ const at = io.now?.() ?? Date.now();
558
+ await writePromptCache({ at, day: today, harbor, counts }, env);
559
+ show(counts, at, !shell);
560
+ return 0;
561
+ }
562
+ // The prompt itself: what's kept, at once, and fresh counts fetched for next time.
563
+ const cache = await readPromptCache(env);
564
+ const now = io.now?.() ?? Date.now();
565
+ if (!isFresh(cache, { now, today, harbor }))
566
+ await (io.refreshPrompt ?? refreshInBackground)(env, now);
567
+ const usable = cache && cache.day === today && (cache.harbor ?? null) === harbor;
568
+ show(usable ? cache.counts : null, usable ? cache.at : null);
569
+ return 0;
570
+ }
571
+
500
572
  // A mistyped command says so, signed in or not.
501
573
  if (commandHelp(command) === null) {
502
574
  throw new HarborError(`No command "${command}". Run harbor help.`);
@@ -516,6 +588,8 @@ export async function run(argv, io = {}) {
516
588
  if (err instanceof HarborError) throw new HarborError(hint(err.message));
517
589
  throw err;
518
590
  });
591
+ // The prompt's counts are behind now; its next draw fetches new ones.
592
+ if (name === "create_task" || name === "update_task") await stalePromptCache(env);
519
593
  out(asJson ? jsonText(result) : plain(show(result)));
520
594
  return 0;
521
595
  };
package/lib/prompt.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `harbor prompt`: a few characters for a shell prompt saying what's due
3
+ * today and what's late. A prompt draws on every Enter, so this never waits
4
+ * on the network and never prints an error: it reads counts kept beside the
5
+ * sign-in, and when they're older than a few minutes it starts
6
+ * `harbor prompt --refresh` in the background to fetch new ones for the next
7
+ * prompt. With nothing due and nothing late it prints nothing.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { configDir } from "./config.mjs";
14
+
15
+ export const FRESH_MS = 5 * 60 * 1000;
16
+ /** A refresh started this recently is still running (or failed): don't start another. */
17
+ const TRYING_MS = 60 * 1000;
18
+ export const SHELLS = ["zsh", "bash", "fish", "starship"];
19
+ /** As many tasks as my_work gives; late and due-today ones come first, so they fit. */
20
+ export const FETCH_LIMIT = 50;
21
+
22
+ const cacheFile = (env) => join(configDir(env), "prompt.json");
23
+ const tryingFile = (env) => join(configDir(env), "prompt.trying");
24
+
25
+ /** Due today and late among your open tasks, by this machine's date. */
26
+ export function promptCounts(work, today) {
27
+ const tasks = work?.tasks ?? [];
28
+ return {
29
+ today: tasks.filter((t) => t.due === today).length,
30
+ late: tasks.filter((t) => t.due && t.due < today).length,
31
+ };
32
+ }
33
+
34
+ // 256-colour codes near Harbor's teal, amber and coral.
35
+ const COLOUR = { mark: 30, today: 179, late: 167 };
36
+
37
+ /**
38
+ * The segment as `shell` wants it: zsh and bash need colour codes marked as
39
+ * taking no room, or the cursor lands in the wrong place; fish takes them
40
+ * as they are; Starship colours its own modules, so it gets plain text, as
41
+ * does no shell at all.
42
+ */
43
+ export function segment({ today, late }, shell) {
44
+ if (!today && !late) return "";
45
+ const parts = [];
46
+ if (today) parts.push(["today", `${today} today`]);
47
+ if (late) parts.push(["late", `${late} late`]);
48
+ if (!shell || shell === "starship") return `⚓ ${parts.map(([, s]) => s).join(" · ")}`;
49
+ const hide =
50
+ shell === "zsh" ? (s) => `%{${s}%}` : shell === "bash" ? (s) => `\x01${s}\x02` : (s) => s;
51
+ const paint = (kind, s) => `${hide(`\x1b[38;5;${COLOUR[kind]}m`)}${s}${hide("\x1b[0m")}`;
52
+ return [paint("mark", "⚓"), ...parts.map(([kind, s]) => paint(kind, s))].join(" ");
53
+ }
54
+
55
+ /** What to put in the shell's startup file. */
56
+ export function initSnippet(shell) {
57
+ switch (shell) {
58
+ case "zsh":
59
+ return [
60
+ "# Add to ~/.zshrc",
61
+ "setopt prompt_subst",
62
+ "RPROMPT='$(harbor prompt --shell zsh)'",
63
+ ].join("\n");
64
+ case "bash":
65
+ return ["# Add to ~/.bashrc", "PS1='$(harbor prompt --shell bash) '\"$PS1\""].join("\n");
66
+ case "fish":
67
+ return [
68
+ "# Add to ~/.config/fish/config.fish",
69
+ "function fish_right_prompt",
70
+ " harbor prompt --shell fish",
71
+ "end",
72
+ ].join("\n");
73
+ case "starship":
74
+ return [
75
+ "# Add to ~/.config/starship.toml",
76
+ "[custom.harbor]",
77
+ 'command = "harbor prompt --shell starship"',
78
+ 'when = "true"',
79
+ 'style = "bold #c44a2c"',
80
+ 'format = "[$output]($style) "',
81
+ ].join("\n");
82
+ default:
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** The kept counts, or null when there are none. */
88
+ export async function readPromptCache(env = process.env) {
89
+ try {
90
+ const c = JSON.parse(await readFile(cacheFile(env), "utf8"));
91
+ return typeof c?.at === "number" && c.counts ? c : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export async function writePromptCache(cache, env = process.env) {
98
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
99
+ const temp = `${cacheFile(env)}.${process.pid}.tmp`;
100
+ await writeFile(temp, JSON.stringify(cache));
101
+ await rename(temp, cacheFile(env));
102
+ }
103
+
104
+ /**
105
+ * Counts saved for one harbor and one day only: a new day, or `harbor use`
106
+ * another harbor, makes them stale even if they were fetched a minute ago.
107
+ */
108
+ export function isFresh(cache, { now, today, harbor }) {
109
+ return Boolean(
110
+ cache &&
111
+ now - cache.at < FRESH_MS &&
112
+ cache.day === today &&
113
+ (cache.harbor ?? null) === (harbor ?? null),
114
+ );
115
+ }
116
+
117
+ /** Marks the kept counts stale, after a command changed tasks. */
118
+ export async function stalePromptCache(env = process.env) {
119
+ const cache = await readPromptCache(env);
120
+ if (cache) await writePromptCache({ ...cache, at: 0 }, env).catch(() => {});
121
+ }
122
+
123
+ /** Starts `harbor prompt --refresh` on its own, unless one started a moment ago. */
124
+ export async function refreshInBackground(env = process.env, now = Date.now()) {
125
+ try {
126
+ const tried = await stat(tryingFile(env)).catch(() => null);
127
+ if (tried && now - tried.mtimeMs < TRYING_MS) return false;
128
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
129
+ await writeFile(tryingFile(env), "");
130
+ const bin = fileURLToPath(new URL("../bin/harbor.mjs", import.meta.url));
131
+ spawn(process.execPath, [bin, "prompt", "--refresh"], {
132
+ stdio: "ignore",
133
+ detached: true,
134
+ env: { ...env, HARBOR_NO_UPDATE_CHECK: "1" },
135
+ }).unref();
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
package/lib/update.mjs CHANGED
@@ -68,6 +68,6 @@ export async function updateNotice({
68
68
  }
69
69
  }
70
70
  return latest && isNewer(latest, version)
71
- ? `Harbor CLI ${latest} is out (you have ${version}): npm install -g @harborppm/cli@latest`
71
+ ? `Harbor CLI ${latest} is out (you have ${version}): npm install -g @harborppm/cli@latest --prefer-online`
72
72
  : null;
73
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",