@drakulavich/zapara 0.6.0 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-09-23
9
+
10
+ ### Added
11
+ - In a terminal, `zapara card` asks `open it? [Y/n]` and opens the picture in
12
+ the default viewer on Enter. Pipes, scripts and Windows are never asked.
13
+
14
+ ### Changed
15
+ - `zapara card` writes to `~/Downloads/zapara-card.png` instead of the current
16
+ directory, and says `wrote zapara-card.png to Downloads`. Without that
17
+ folder it asks for `--out`.
18
+
8
19
  ## [0.6.0] - 2026-09-22
9
20
 
10
21
  ### Changed
package/README.md CHANGED
@@ -96,19 +96,16 @@ The grid is a fixed 98 columns wide, 100 with its hour header, and does not refl
96
96
  `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. It carries no dates and no hour totals, so it does not read as a timesheet.
97
97
 
98
98
  ```bash
99
- zapara card # writes zapara-card.png in the current directory
99
+ zapara card # writes zapara-card.png to ~/Downloads
100
100
  zapara card --out card.webp # WebP instead; --out card.html writes the page itself
101
101
  ```
102
102
 
103
- On macOS, one more command puts the picture on the clipboard, ready to paste into a chat:
104
-
105
- ```bash
106
- zapara card && osascript -e 'set the clipboard to (read (POSIX file "zapara-card.png") as «class PNGf»)'
107
- ```
103
+ In a terminal it then asks `open it? [Y/n]`: Enter opens the picture in the default viewer, where ⌘C copies it for a chat. A pipe or a script is never asked.
108
104
 
109
105
  ```
110
106
  The Marathoner: Longest streak 7h53m without a break, 68% of your hours calm.
111
- wrote zapara-card.png
107
+ wrote zapara-card.png to Downloads
108
+ open it? [Y/n]
112
109
  ```
113
110
 
114
111
  <p align="center"><img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
@@ -124,7 +121,7 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
124
121
  | `zapara --from 2026-09-01 --to 2026-09-14` | Any window, both days inclusive, at most 90 days. `--to` alone is 7 days ending there, `--days 30 --to 2026-09-14` is 30 days ending there. |
125
122
  | `zapara today` | Today, one row per hour that had activity. `yesterday` likewise. |
126
123
  | `zapara 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
127
- | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in the current directory. |
124
+ | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in `~/Downloads`. |
128
125
  | `zapara card --days 30 --out me.webp` | Any window from 1 to 90 days; `.png`, `.webp` or `.html` by extension. `--json` prints the card's data instead. |
129
126
  | `zapara status` | Writes today's load to `~/.claude/zapara/status.json` for a status line to read, and prints the same line. See [Status line](#status-line). |
130
127
 
@@ -134,16 +131,16 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
134
131
  | `--explain` | With a day: the six weighted parts behind each index. |
135
132
  | `--json` | Print the whole window as one JSON document instead of a table. |
136
133
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
137
- | `--out <path>` | Where `card` writes; the extension picks the format. |
134
+ | `--out <path>` | Where `card` writes instead of `~/Downloads`; the extension picks the format. |
138
135
  | `--no-color` | Plain glyphs and peaks with no ANSI codes. `NO_COLOR` in the environment does the same. |
139
136
  | `-h`, `--help` | Usage, exit 0. |
140
137
  | `-V`, `--version` | The version from `package.json`, exit 0. |
141
138
 
142
139
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
143
140
 
144
- The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; no flag forces text in a pipe yet. `card` and `status` write their file and print their lines whether piped or not. `card --json` is the exception: it prints the card's data and writes no file. `--json` changes nothing for `status`, whose line is already JSON and whose file is written either way.
141
+ The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; no flag forces text in a pipe yet. `card` and `status` write their file and print their lines whether piped or not. `card` asks to open the picture only when stdin and stdout are both a terminal, and never on Windows. `card --json` is the exception: it prints the card's data and writes no file. `--json` changes nothing for `status`, whose line is already JSON and whose file is written either way.
145
142
 
146
- A run that works exits 0, and so does a window with no data, which prints an empty grid. Exit 1 is a failure zapara can name, printed as one line to stderr that never contains a path: the projects directory missing or unreadable, `status` unable to write its file, `card` unable to write its picture or to find a browser engine, and whatever else goes wrong below the command line. Exit 2 is a usage error, such as a bad date, an unknown flag or a value flag given twice; it prints one line and a hint to `--help`.
143
+ A run that works exits 0, and so does a window with no data, which prints an empty grid. Exit 1 is a failure zapara can name, printed as one line to stderr that never contains a path: the projects directory missing or unreadable, `status` unable to write its file, `card` unable to write its picture or to find a browser engine, `card` without `--out` on a machine with no `~/Downloads` folder, and whatever else goes wrong below the command line. Exit 2 is a usage error, such as a bad date, an unknown flag or a value flag given twice; it prints one line and a hint to `--help`.
147
144
 
148
145
  ## Status line
149
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/image.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The only module that reads the card assets, opens a Bun.WebView or Bun.Image,
2
- // and writes the card.
2
+ // writes the card, and starts another program: the opener that shows it.
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
4
5
  import type { CardAssets } from "./cardhtml.ts";
5
6
 
6
7
  const ASSETS = new URL("../assets/", import.meta.url);
@@ -72,3 +73,12 @@ async function write(out: string, data: string | Uint8Array): Promise<void> {
72
73
  throw new Error(WRITE_LINE);
73
74
  }
74
75
  }
76
+
77
+ // Absolute, so `-card.html` is never an option. `sh … &` with SIGHUP ignored:
78
+ // a detached Bun.spawn child, or one in a terminal zapara leads, dies with zapara.
79
+ export function openCard(path: string): void {
80
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
81
+ try {
82
+ Bun.spawnSync(["sh", "-c", 'trap "" HUP; "$0" "$@" </dev/null >/dev/null 2>&1 &', opener, resolve(path)], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
83
+ } catch {}
84
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  // Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { cardData, sentenceText } from "./card.ts";
7
7
  import { cardHtml } from "./cardhtml.ts";
8
8
  import { localDate } from "./derive.ts";
9
- import { loadAssets, renderCard } from "./image.ts";
9
+ import { loadAssets, openCard, renderCard } from "./image.ts";
10
10
  import { renderDay, renderJson, renderWeek } from "./render.ts";
11
11
  import { report } from "./report.ts";
12
12
  import { renderStatus, statusOf } from "./status.ts";
@@ -35,7 +35,8 @@ window:
35
35
 
36
36
  options:
37
37
  --explain with a day: the six weighted parts behind each index
38
- --out <path> with card: .png, .webp or .html (default zapara-card.png)
38
+ --out <path> with card: .png, .webp or .html
39
+ (default ~/Downloads/zapara-card.png)
39
40
  --json the same data as JSON; a pipe gets JSON without asking
40
41
  --projects <dir> read this directory instead of ~/.claude/projects
41
42
  --no-color no ANSI colors; NO_COLOR does the same
@@ -44,7 +45,7 @@ options:
44
45
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
45
46
  const HINT = "run 'zapara --help' for usage";
46
47
 
47
- type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
48
+ type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string | null; projects: string; color: boolean };
48
49
 
49
50
  class UsageError extends Error {}
50
51
  // Thrown only at a flag position, never for a token consumed as another flag's
@@ -95,12 +96,11 @@ function windowOf(days: string | null, from: string | null, to: string | null, d
95
96
  }
96
97
 
97
98
  function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
98
- const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
99
+ const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: null, projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
99
100
  let days: string | null = null;
100
101
  let from: string | null = null;
101
102
  let to: string | null = null;
102
103
  let jsonFlag = false;
103
- let outGiven = false;
104
104
  const positional: string[] = [];
105
105
  // A value flag given twice is a usage error; bare flags are idempotent and untracked.
106
106
  const seen = new Set<string>();
@@ -136,7 +136,7 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
136
136
  case "--from": from = value(); break;
137
137
  case "--to": to = value(); break;
138
138
  case "--days": days = value(true); break;
139
- case "--out": a.out = value(); outGiven = true; break;
139
+ case "--out": a.out = value(); break;
140
140
  default:
141
141
  if (arg.startsWith("-")) throw new UsageError(`unknown flag${named(arg)}`);
142
142
  positional.push(arg);
@@ -158,10 +158,12 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
158
158
  // The card is a file either way, so only an explicit --json switches it.
159
159
  a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
160
160
  if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
161
- if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
162
- // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
163
- if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
164
- if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
161
+ if (a.command !== "card" && a.out !== null) throw new UsageError("--out applies to card only");
162
+ if (a.out !== null) {
163
+ // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
164
+ if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
165
+ if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
166
+ }
165
167
  return a;
166
168
  }
167
169
 
@@ -188,6 +190,16 @@ async function status(a: Args, now: Date): Promise<number> {
188
190
  return 0;
189
191
  }
190
192
 
193
+ // The label names the folder, not the path: the CLI never prints a derived path.
194
+ function cardTarget(out: string | null): { path: string; label: string } {
195
+ if (out !== null) return { path: out, label: out };
196
+ const dir = join(homedir(), "Downloads");
197
+ let isDir = false;
198
+ try { isDir = statSync(dir).isDirectory(); } catch {}
199
+ if (!isDir) throw new Error("no Downloads folder: pass --out <path>");
200
+ return { path: join(dir, "zapara-card.png"), label: "zapara-card.png to Downloads" };
201
+ }
202
+
191
203
  async function card(a: Args): Promise<number> {
192
204
  const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
193
205
  const data = cardData(days, { days: a.days });
@@ -203,8 +215,15 @@ async function card(a: Args): Promise<number> {
203
215
  console.log(JSON.stringify(json, null, 2));
204
216
  return 0;
205
217
  }
206
- await renderCard(cardHtml(data, await loadAssets()), a.out);
207
- console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${a.out}`);
218
+ const target = cardTarget(a.out);
219
+ await renderCard(cardHtml(data, await loadAssets()), target.path);
220
+ console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${target.label}`);
221
+ if (process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32") {
222
+ process.stdout.write("open it? [Y/n] ");
223
+ let answer: string | null = null;
224
+ for await (const line of console) { answer = line; break; }
225
+ if (answer !== null && /^(y|yes)?$/i.test(answer.trim())) openCard(target.path);
226
+ }
208
227
  return 0;
209
228
  }
210
229