@harborppm/cli 0.13.0 → 0.14.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
@@ -59,6 +59,16 @@ 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:
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
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` 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.
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,59 @@ 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
+ `${snippet}\n\nYour prompt shows what's due today and late once Harbor has answered,\na moment after the next prompt. harbor prompt --refresh checks now.`,
527
+ );
528
+ return 0;
529
+ }
530
+ const shell = values.shell;
531
+ if (shell !== undefined && !SHELLS.includes(shell))
532
+ throw new HarborError(`--shell is ${SHELLS.join(", ")} (not "${shell}").`);
533
+ const creds = await readCredentials(env);
534
+ const harbor = values.harbor ?? creds?.harbor ?? null;
535
+ const today = todayLocal(io.now ? new Date(io.now()) : new Date());
536
+ const show = (counts, at) =>
537
+ out(
538
+ values.json
539
+ ? jsonText({ ...(counts ?? { today: null, late: null }), at })
540
+ : segment(counts ?? {}, shell),
541
+ );
542
+ if (!creds) {
543
+ if (values.refresh) throw new HarborError("Not signed in. Run harbor login.");
544
+ show(null, null);
545
+ return 0;
546
+ }
547
+ if (values.refresh) {
548
+ const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
549
+ const work = await client.call("my_work", {
550
+ harbor: harbor ?? undefined,
551
+ limit: FETCH_LIMIT,
552
+ });
553
+ const counts = promptCounts(work, today);
554
+ const at = io.now?.() ?? Date.now();
555
+ await writePromptCache({ at, day: today, harbor, counts }, env);
556
+ show(counts, at);
557
+ return 0;
558
+ }
559
+ // The prompt itself: what's kept, at once, and fresh counts fetched for next time.
560
+ const cache = await readPromptCache(env);
561
+ const now = io.now?.() ?? Date.now();
562
+ if (!isFresh(cache, { now, today, harbor }))
563
+ await (io.refreshPrompt ?? refreshInBackground)(env, now);
564
+ const usable = cache && cache.day === today && (cache.harbor ?? null) === harbor;
565
+ show(usable ? cache.counts : null, usable ? cache.at : null);
566
+ return 0;
567
+ }
568
+
500
569
  // A mistyped command says so, signed in or not.
501
570
  if (commandHelp(command) === null) {
502
571
  throw new HarborError(`No command "${command}". Run harbor help.`);
@@ -516,6 +585,8 @@ export async function run(argv, io = {}) {
516
585
  if (err instanceof HarborError) throw new HarborError(hint(err.message));
517
586
  throw err;
518
587
  });
588
+ // The prompt's counts are behind now; its next draw fetches new ones.
589
+ if (name === "create_task" || name === "update_task") await stalePromptCache(env);
519
590
  out(asJson ? jsonText(result) : plain(show(result)));
520
591
  return 0;
521
592
  };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
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",