tuiboard 0.8.3 → 0.9.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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Write a new board file.
3
+ *
4
+ * The file is a plain Obsidian Kanban board: YAML frontmatter carrying
5
+ * `kanban-plugin: board`, then one `## Column` heading per column. Those two
6
+ * frontmatter lines are what make Obsidian render the file as a board instead
7
+ * of as a wall of text — which matters because these files are read on a
8
+ * phone as often as in this program.
9
+ *
10
+ * No `%% kanban:settings %%` trailer is written. Obsidian adds its own on the
11
+ * first setting change, and inventing one here would mean guessing at a
12
+ * format this project does not control.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+
18
+ export interface CreateBoardOptions {
19
+ /** Column headings, in order. At least one. */
20
+ columns: readonly string[];
21
+ }
22
+
23
+ /** Columns offered when the caller has no opinion. */
24
+ export const DEFAULT_COLUMNS = ["Todo", "Doing", "Done"] as const;
25
+
26
+ export function createBoardFile(path: string, { columns }: CreateBoardOptions): void {
27
+ const names = columns.map((c) => c.trim()).filter(Boolean);
28
+ if (names.length === 0) {
29
+ // A board with no columns has nowhere to put a task, and the TUI cannot
30
+ // yet add one. Refusing here beats handing back something unusable.
31
+ throw new Error("a board needs at least one column");
32
+ }
33
+
34
+ // Never overwrite: the target may be a file someone else wrote, and the
35
+ // caller's next best move — adopting it instead — is only possible if it
36
+ // still exists.
37
+ if (existsSync(path)) {
38
+ throw new Error(`${path} already exists`);
39
+ }
40
+
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ writeFileSync(path, render(names), "utf-8");
43
+ }
44
+
45
+ /**
46
+ * The blank-line placement matches what Obsidian Kanban itself writes, so a
47
+ * board created here and one created there are the same document.
48
+ */
49
+ function render(columns: readonly string[]): string {
50
+ const frontmatter = ["---", "", "kanban-plugin: board", "", "---", ""].join("\n");
51
+ const body = columns.map((name) => `\n## ${name}\n`).join("");
52
+ // The trailing blank line is what `serializeBoard` produces for a board
53
+ // whose last column is empty. Matching it means a file created here and the
54
+ // same file after tuiboard writes to it are byte-identical — so a fresh
55
+ // board never shows up as a spurious diff in the vault's git history.
56
+ return `${frontmatter}${body}\n`;
57
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Find the boards already sitting in a directory.
3
+ *
4
+ * This is what the "I already have files" path of onboarding offers: point at
5
+ * a folder, and see which of its markdown files are actually task boards.
6
+ *
7
+ * The recognition rule is deliberately the same one `loadConfig()` uses for
8
+ * its zero-config fallback — a `.md` file containing a `- [ ]` or `- [x]`
9
+ * line. Two rules would mean a file adopted by one path and ignored by the
10
+ * other, so the loader delegates here rather than keeping its own copy.
11
+ */
12
+
13
+ import { readFileSync, readdirSync, statSync } from "node:fs";
14
+ import { basename, extname, join, resolve } from "node:path";
15
+
16
+ /** How much of a file is read to decide whether it is a board. */
17
+ const SNIFF_BYTES = 4096;
18
+
19
+ const RE_TASK = /^- \[[ xX]\] /m;
20
+ const RE_TASK_GLOBAL = /^- \[[ xX]\] /gm;
21
+ /** Obsidian Kanban's marker — what tuiboard itself writes into a new board. */
22
+ const RE_KANBAN = /^kanban-plugin:\s*board\s*$/m;
23
+
24
+ export interface BoardCandidate {
25
+ /** Absolute path to the markdown file. */
26
+ path: string;
27
+ /** Filename without extension — what the board would be called. */
28
+ suggestedName: string;
29
+ /** Tasks found in the file, open and done. */
30
+ taskCount: number;
31
+ /** True when this file is already registered as a board. */
32
+ alreadyInConfig: boolean;
33
+ }
34
+
35
+ export interface ScanOptions {
36
+ /** Board paths already registered, so candidates can be marked. */
37
+ existingPaths?: readonly string[];
38
+ }
39
+
40
+ /**
41
+ * True when the file looks like a task board.
42
+ *
43
+ * Two ways to qualify, and the second is not optional: a board tuiboard has
44
+ * just created holds no tasks yet, so a checkbox-only rule would make the
45
+ * program blind to its own output until someone typed into it. The Kanban
46
+ * frontmatter marker settles those.
47
+ */
48
+ export function isBoardFile(path: string): boolean {
49
+ if (extname(path).toLowerCase() !== ".md") return false;
50
+ try {
51
+ if (!statSync(path).isFile()) return false;
52
+ const head = readFileSync(path, "utf-8").slice(0, SNIFF_BYTES);
53
+ return RE_TASK.test(head) || RE_KANBAN.test(head);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * List the board files in `dir`, sorted by name.
61
+ *
62
+ * A missing or unreadable directory yields an empty list rather than an
63
+ * error: onboarding asks the user to type a path, and a typo should redraw
64
+ * the screen with "nothing here", not end the session.
65
+ */
66
+ export function scanDirectory(dir: string, { existingPaths = [] }: ScanOptions = {}): BoardCandidate[] {
67
+ let entries: string[];
68
+ try {
69
+ entries = readdirSync(dir);
70
+ } catch {
71
+ return [];
72
+ }
73
+
74
+ const known = new Set(existingPaths.map((p) => resolve(p)));
75
+
76
+ return entries
77
+ .map((name) => join(dir, name))
78
+ .filter(isBoardFile)
79
+ .sort()
80
+ .map((path) => ({
81
+ path,
82
+ suggestedName: basename(path, extname(path)),
83
+ taskCount: countTasks(path),
84
+ alreadyInConfig: known.has(resolve(path)),
85
+ }));
86
+ }
87
+
88
+ /** Tasks in the whole file — the sniff window is only for recognition. */
89
+ function countTasks(path: string): number {
90
+ try {
91
+ return readFileSync(path, "utf-8").match(RE_TASK_GLOBAL)?.length ?? 0;
92
+ } catch {
93
+ return 0;
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where a new board file should be born.
3
+ *
4
+ * The answer that serves the user best is "next to the boards you already
5
+ * have": that is usually a synced, versioned folder — a vault — so a board
6
+ * created here inherits replication, git history and Obsidian rendering
7
+ * without anyone configuring anything.
8
+ *
9
+ * When there is nothing to learn from — no boards yet, or boards scattered
10
+ * across unrelated folders — the fallback is an XDG data directory the app
11
+ * owns. Someone who installed tuiboard five minutes ago and has no vault
12
+ * still gets a working board.
13
+ *
14
+ * This only ever produces a *proposal*. The path is shown and editable before
15
+ * anything is written, so a wrong guess costs a keystroke, not a lost file.
16
+ */
17
+
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+
21
+ import type { Config } from "~/config/loader";
22
+
23
+ /** The app-owned directory, honouring XDG_DATA_HOME when set. */
24
+ export function defaultBoardsDir(): string {
25
+ const xdg = process.env.XDG_DATA_HOME;
26
+ const base = xdg && xdg.trim() ? xdg : join(homedir(), ".local", "share");
27
+ return join(base, "tuiboard", "boards");
28
+ }
29
+
30
+ export function suggestBoardsDir(config: Pick<Config, "boards">): string {
31
+ const dirs = new Set((config.boards ?? []).map((b) => dirname(resolve(b.path))));
32
+ if (dirs.size === 1) return [...dirs][0]!;
33
+ return defaultBoardsDir();
34
+ }
package/src/cli/args.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * value flags) would warrant a real CLI library — YAGNI here.
5
5
  */
6
6
 
7
- export type ViewKind = "board" | "timeline" | "agents";
7
+ export type ViewKind = "board" | "planner" | "timeline" | "agents";
8
8
 
9
- const VALID_VIEWS: readonly ViewKind[] = ["board", "timeline", "agents"];
9
+ const VALID_VIEWS: readonly ViewKind[] = ["board", "planner", "timeline", "agents"];
10
10
 
11
11
  export interface ParsedArgs {
12
12
  /** Undefined means: render the default Dashboard (all 4 zones). */
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Board lifecycle from the command line.
3
+ *
4
+ * tuiboard board add --path <file.md> [--name <n>] [--columns "A,B,C"] [--dry-run]
5
+ * tuiboard board scan <dir>
6
+ * tuiboard board list
7
+ *
8
+ * Same three operations the TUI's `+` performs, over the same `src/boards/`
9
+ * functions. That is deliberate: if the modal and this file share everything
10
+ * but their input, the subsystem is genuinely decoupled from the renderer —
11
+ * and a board can be created from a script, a bar widget, or an agent.
12
+ *
13
+ * `add` creates the file when it is missing and registers it either way, so
14
+ * adopting an existing board and making a new one are the same command.
15
+ */
16
+
17
+ import { existsSync } from "node:fs";
18
+ import { basename, extname, resolve } from "node:path";
19
+
20
+ import { addBoardToConfig } from "~/boards/config-writer";
21
+ import { createBoardFile, DEFAULT_COLUMNS } from "~/boards/create";
22
+ import { scanDirectory } from "~/boards/scan";
23
+ import { suggestBoardsDir } from "~/boards/suggest";
24
+ import { loadConfig } from "~/config/loader";
25
+
26
+ interface Args {
27
+ path?: string;
28
+ name?: string;
29
+ columns?: string[];
30
+ dryRun: boolean;
31
+ rest: string[];
32
+ }
33
+
34
+ function parse(argv: readonly string[]): Args {
35
+ const a: Args = { dryRun: false, rest: [] };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i]!;
38
+ const take = () => argv[++i];
39
+ if (arg === "--path") a.path = take();
40
+ else if (arg === "--name") a.name = take();
41
+ else if (arg === "--columns") a.columns = splitColumns(take());
42
+ else if (arg === "--dry-run") a.dryRun = true;
43
+ else if (arg.startsWith("--path=")) a.path = arg.slice(7);
44
+ else if (arg.startsWith("--name=")) a.name = arg.slice(7);
45
+ else if (arg.startsWith("--columns=")) a.columns = splitColumns(arg.slice(10));
46
+ else if (arg.startsWith("--")) throw new Error(`unknown argument "${arg}"`);
47
+ else a.rest.push(arg);
48
+ }
49
+ return a;
50
+ }
51
+
52
+ function splitColumns(value: string | undefined): string[] {
53
+ return (value ?? "")
54
+ .split(",")
55
+ .map((c) => c.trim())
56
+ .filter(Boolean);
57
+ }
58
+
59
+ export async function runBoard(argv: readonly string[]): Promise<number> {
60
+ const sub = argv[0];
61
+ if (sub !== "add" && sub !== "scan" && sub !== "list") {
62
+ console.error("usage: tuiboard board <add|scan|list> [options]");
63
+ return 2;
64
+ }
65
+
66
+ let a: Args;
67
+ try {
68
+ a = parse(argv.slice(1));
69
+ } catch (e) {
70
+ console.error(`tuiboard board: ${(e as Error).message}`);
71
+ return 2;
72
+ }
73
+
74
+ try {
75
+ if (sub === "list") {
76
+ const config = loadConfig();
77
+ if (config.boards.length === 0) {
78
+ console.log("No boards configured. Add one with `tuiboard board add --path <file.md>`.");
79
+ return 0;
80
+ }
81
+ for (const b of config.boards) {
82
+ const name = b.name ?? basename(b.path, extname(b.path));
83
+ const missing = existsSync(b.path) ? "" : " (file missing)";
84
+ console.log(`${name}\t${b.path}${missing}`);
85
+ }
86
+ console.log(`\nNew boards would be created in: ${suggestBoardsDir(config)}`);
87
+ return 0;
88
+ }
89
+
90
+ if (sub === "scan") {
91
+ const dir = resolve(a.rest[0] ?? process.cwd());
92
+ const existingPaths = loadConfig().boards.map((b) => b.path);
93
+ const found = scanDirectory(dir, { existingPaths });
94
+ if (found.length === 0) {
95
+ console.log(`No board files in ${dir}.`);
96
+ return 0;
97
+ }
98
+ for (const c of found) {
99
+ const mark = c.alreadyInConfig ? "already configured" : "not configured";
100
+ const tasks = `${c.taskCount} task${c.taskCount === 1 ? "" : "s"}`;
101
+ console.log(`${c.suggestedName}\t${tasks}\t${mark}\t${c.path}`);
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ // add
107
+ if (!a.path) {
108
+ console.error("tuiboard board add: --path is required");
109
+ return 2;
110
+ }
111
+ const path = resolve(a.path);
112
+ const name = a.name ?? basename(path, extname(path));
113
+ const columns = a.columns ?? [...DEFAULT_COLUMNS];
114
+ const exists = existsSync(path);
115
+
116
+ if (a.dryRun) {
117
+ console.log(
118
+ exists
119
+ ? `[dry-run] would adopt existing board ${path} as "${name}"`
120
+ : `[dry-run] would create ${path} with columns ${columns.join(", ")} as "${name}"`,
121
+ );
122
+ return 0;
123
+ }
124
+
125
+ // File first, config second: a file with no config entry is a board to
126
+ // adopt next time, while a config entry pointing at nothing is a broken
127
+ // launch. See docs/superpowers/specs/2026-09-01-board-lifecycle-design.md.
128
+ if (!exists) createBoardFile(path, { columns });
129
+
130
+ try {
131
+ const result = addBoardToConfig({ path, name });
132
+ console.log(
133
+ `${exists ? "adopted" : "created"} ${path} as "${name}" in ${result.configPath}` +
134
+ (result.created ? " (config created)" : ""),
135
+ );
136
+ return 0;
137
+ } catch (e) {
138
+ // Partial success is reported as such: the file is on disk either way.
139
+ if (!exists) {
140
+ console.error(
141
+ `created ${path}, but it was not registered: ${(e as Error).message}`,
142
+ );
143
+ return 1;
144
+ }
145
+ throw e;
146
+ }
147
+ } catch (e) {
148
+ console.error(`tuiboard board: ${(e as Error).message}`);
149
+ return 1;
150
+ }
151
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Headless JSON snapshot of the configured boards.
3
+ *
4
+ * Usage:
5
+ * tuiboard summary # compact JSON on stdout
6
+ * tuiboard summary --pretty # indented, for humans
7
+ * tuiboard summary --next 8 # how many upcoming tasks per board (default 5)
8
+ *
9
+ * Written for status bars, widgets and scripts: no TUI, no OpenTUI preload.
10
+ * It deliberately reuses `loadConfig` and `parseBoard` rather than re-reading
11
+ * the markdown itself, so these numbers can never drift from what the
12
+ * dashboard shows — the parser stays the single source of truth.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+
17
+ import { isHiddenColumn, loadConfig } from "~/config/loader";
18
+ import { isTask, parseBoard } from "~/parser/markdown";
19
+ import { buildPlannerItems, type PlannerSection } from "~/store/planner-panel";
20
+ import type { Board, PriorityLevel, Task } from "~/types";
21
+
22
+ /** Lower sorts first, so "highest" leads an ascending sort. */
23
+ const PRIORITY_RANK: Record<PriorityLevel, number> = {
24
+ highest: 0,
25
+ high: 1,
26
+ medium: 2,
27
+ low: 3,
28
+ lowest: 4,
29
+ none: 5,
30
+ };
31
+
32
+ export interface SummaryTask {
33
+ title: string;
34
+ board: string;
35
+ column: string;
36
+ priority: PriorityLevel;
37
+ due?: string;
38
+ scheduled?: string;
39
+ assignee?: string;
40
+ tags: string[];
41
+ /** Negative when overdue, 0 today, positive in the future, null when undated. */
42
+ daysUntil: number | null;
43
+ }
44
+
45
+ export interface SummaryColumn {
46
+ name: string;
47
+ open: number;
48
+ }
49
+
50
+ export interface SummaryBoard {
51
+ name: string;
52
+ path: string;
53
+ open: number;
54
+ done: number;
55
+ overdue: number;
56
+ today: number;
57
+ columns: SummaryColumn[];
58
+ next: SummaryTask[];
59
+ /** Parser complaints, so a malformed board is visible instead of silently empty. */
60
+ diagnostics: number;
61
+ }
62
+
63
+ /** One row of the Today/Tomorrow planner, flattened for consumers. */
64
+ export interface PlannerEntry {
65
+ title: string;
66
+ board: string;
67
+ column: string;
68
+ /** "agenda" = time-blocked, "priority" = unscheduled priority, "rest" = everything else. */
69
+ bucket: string;
70
+ priority: PriorityLevel;
71
+ due?: string;
72
+ scheduled?: string;
73
+ timeBlock?: string;
74
+ assignee?: string;
75
+ /**
76
+ * Whether the task is already ticked. Today/Tomorrow keep completed tasks —
77
+ * the day's plan is a record of the day, not just of what is left — so a
78
+ * consumer that omits this renders a done task identically to an open one.
79
+ */
80
+ done: boolean;
81
+ /** Completion date, when the task carries one (✅ YYYY-MM-DD). */
82
+ doneDate?: string;
83
+ }
84
+
85
+ export interface Summary {
86
+ generatedAt: string;
87
+ totals: { open: number; done: number; overdue: number; today: number };
88
+ boards: SummaryBoard[];
89
+ /**
90
+ * The same Today/Tomorrow aggregation the TUI renders in its left column.
91
+ * Built with buildPlannerItems() rather than re-derived here, so a bar
92
+ * widget and the dashboard can never disagree about what is due.
93
+ */
94
+ planner: Record<PlannerSection, PlannerEntry[]>;
95
+ }
96
+
97
+ /** Local calendar date as YYYY-MM-DD — never UTC, or "today" flips at the wrong hour. */
98
+ function localToday(now = new Date()): string {
99
+ const pad = (n: number) => String(n).padStart(2, "0");
100
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
101
+ }
102
+
103
+ /** Whole days between two YYYY-MM-DD dates, midday-anchored to dodge DST. */
104
+ function daysBetween(from: string, to: string): number {
105
+ const at = (iso: string) => {
106
+ const [y, m, d] = iso.split("-").map(Number);
107
+ return new Date(y!, (m ?? 1) - 1, d ?? 1, 12, 0, 0).getTime();
108
+ };
109
+ return Math.round((at(to) - at(from)) / 86_400_000);
110
+ }
111
+
112
+ /** "09:30-11:00" from minutes-since-midnight, or undefined when unblocked. */
113
+ function formatTimeBlock(tb: Task["timeBlock"]): string | undefined {
114
+ if (!tb) return undefined;
115
+ const hhmm = (m: number) =>
116
+ String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0");
117
+ return hhmm(tb.startMin) + "-" + hhmm(tb.endMin);
118
+ }
119
+
120
+ /** The date a task is judged by: an explicit due date wins over a scheduled one. */
121
+ function effectiveDate(task: Task): string | undefined {
122
+ return task.due ?? task.scheduled;
123
+ }
124
+
125
+ export function buildSummary(options: { next?: number; today?: string } = {}): Summary {
126
+ const nextCount = options.next ?? 5;
127
+ const today = options.today ?? localToday();
128
+ const config = loadConfig();
129
+
130
+ const boards: SummaryBoard[] = [];
131
+ const parsed: Board[] = [];
132
+ const totals = { open: 0, done: 0, overdue: 0, today: 0 };
133
+
134
+ for (const ref of config.boards) {
135
+ let content: string;
136
+ try {
137
+ content = readFileSync(ref.path, "utf-8");
138
+ } catch {
139
+ // A board listed in config but missing on disk is worth surfacing, not
140
+ // crashing over: a widget polling every 30s shouldn't die on a moved file.
141
+ boards.push({
142
+ name: ref.name ?? ref.path,
143
+ path: ref.path,
144
+ open: 0,
145
+ done: 0,
146
+ overdue: 0,
147
+ today: 0,
148
+ columns: [],
149
+ next: [],
150
+ diagnostics: -1,
151
+ });
152
+ continue;
153
+ }
154
+
155
+ const { board, diagnostics } = parseBoard(content, { filepath: ref.path });
156
+ const boardName = ref.name ?? board.name;
157
+ parsed.push(board);
158
+
159
+ const columns: SummaryColumn[] = [];
160
+ const openTasks: SummaryTask[] = [];
161
+ let open = 0;
162
+ let done = 0;
163
+ let overdue = 0;
164
+ let dueToday = 0;
165
+
166
+ for (const column of board.columns) {
167
+ const hidden = isHiddenColumn(config, column.name);
168
+ let columnOpen = 0;
169
+
170
+ for (const child of column.children) {
171
+ if (!isTask(child)) continue;
172
+ const task = child;
173
+
174
+ // Anything parked in Done/Archive counts as done wherever its checkbox
175
+ // sits — the column is the workflow truth, not the `- [x]` marker.
176
+ if (hidden || task.done) {
177
+ done++;
178
+ continue;
179
+ }
180
+
181
+ open++;
182
+ columnOpen++;
183
+
184
+ const date = effectiveDate(task);
185
+ const daysUntil = date ? daysBetween(today, date) : null;
186
+ if (daysUntil !== null && daysUntil < 0) overdue++;
187
+ if (daysUntil === 0) dueToday++;
188
+
189
+ openTasks.push({
190
+ title: task.displayTitle,
191
+ board: boardName,
192
+ column: column.name,
193
+ priority: task.priority,
194
+ due: task.due,
195
+ scheduled: task.scheduled,
196
+ assignee: task.assignee,
197
+ tags: task.tags,
198
+ daysUntil,
199
+ });
200
+ }
201
+
202
+ if (!hidden) columns.push({ name: column.name, open: columnOpen });
203
+ }
204
+
205
+ // Soonest first; undated tasks last; ties broken by priority then title so
206
+ // the order is stable between polls and the widget doesn't flicker.
207
+ openTasks.sort((a, b) => {
208
+ const ad = a.daysUntil ?? Number.POSITIVE_INFINITY;
209
+ const bd = b.daysUntil ?? Number.POSITIVE_INFINITY;
210
+ if (ad !== bd) return ad - bd;
211
+ const ap = PRIORITY_RANK[a.priority];
212
+ const bp = PRIORITY_RANK[b.priority];
213
+ if (ap !== bp) return ap - bp;
214
+ return a.title.localeCompare(b.title);
215
+ });
216
+
217
+ totals.open += open;
218
+ totals.done += done;
219
+ totals.overdue += overdue;
220
+ totals.today += dueToday;
221
+
222
+ boards.push({
223
+ name: boardName,
224
+ path: ref.path,
225
+ open,
226
+ done,
227
+ overdue,
228
+ today: dueToday,
229
+ columns,
230
+ next: openTasks.slice(0, nextCount),
231
+ diagnostics: diagnostics.length,
232
+ });
233
+ }
234
+
235
+ const planner: Record<PlannerSection, PlannerEntry[]> = {
236
+ overdue: [],
237
+ today: [],
238
+ tomorrow: [],
239
+ };
240
+ for (const item of buildPlannerItems(parsed)) {
241
+ planner[item.section].push({
242
+ title: item.task.displayTitle,
243
+ board: item.boardName,
244
+ column: item.columnName,
245
+ bucket: item.bucket,
246
+ priority: item.task.priority,
247
+ due: item.task.due,
248
+ scheduled: item.task.scheduled,
249
+ timeBlock: formatTimeBlock(item.task.timeBlock),
250
+ assignee: item.task.assignee,
251
+ done: item.task.done,
252
+ doneDate: item.task.doneDate,
253
+ });
254
+ }
255
+
256
+ return { generatedAt: new Date().toISOString(), totals, boards, planner };
257
+ }
258
+
259
+ export async function runSummary(argv: readonly string[]): Promise<number> {
260
+ let pretty = false;
261
+ let next: number | undefined;
262
+
263
+ for (let i = 0; i < argv.length; i++) {
264
+ const arg = argv[i]!;
265
+ if (arg === "--pretty") pretty = true;
266
+ else if (arg === "--next") next = Number(argv[++i]);
267
+ else if (arg.startsWith("--next=")) next = Number(arg.slice("--next=".length));
268
+ else {
269
+ console.error(`tuiboard summary: unknown argument "${arg}"`);
270
+ return 2;
271
+ }
272
+ }
273
+
274
+ if (next !== undefined && (!Number.isFinite(next) || next < 0)) {
275
+ console.error("tuiboard summary: --next needs a non-negative number");
276
+ return 2;
277
+ }
278
+
279
+ const summary = buildSummary({ next });
280
+ process.stdout.write(
281
+ (pretty ? JSON.stringify(summary, null, 2) : JSON.stringify(summary)) + "\n",
282
+ );
283
+ return 0;
284
+ }