@profullstack/timer 0.1.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/src/cli.mjs ADDED
@@ -0,0 +1,757 @@
1
+ // The command surface.
2
+ //
3
+ // One table, one dispatcher. Each command declares the flags it takes, which
4
+ // is what lets `--help` and the arg parser stay in agreement — a flag that is
5
+ // documented but not declared would silently land in `unknown` and be ignored,
6
+ // and that is exactly the failure an agent cannot see.
7
+ import fs from "node:fs";
8
+
9
+ import { GLOBAL_ALIASES, GLOBAL_BOOLEANS, GLOBAL_VALUES, parseArgs } from "./args.mjs";
10
+ import { dataFile, timerHome } from "./paths.mjs";
11
+ import { read, update } from "./store.mjs";
12
+ import { csv, emit, emitJson, paint, table, warn } from "./output.mjs";
13
+ import {
14
+ GROUP_KEYS,
15
+ boundsFromDuration,
16
+ closeEntry,
17
+ findById,
18
+ isRunning,
19
+ makeEntry,
20
+ seconds,
21
+ select,
22
+ summarize,
23
+ totals,
24
+ } from "./entries.mjs";
25
+ import { formatDuration, hours, parseMoment, resolveWindow, shortStamp } from "./time.mjs";
26
+
27
+ export const VERSION = "0.1.0";
28
+
29
+ /** A bad command line — worth a different exit code than a failed operation. */
30
+ export class UsageError extends Error {
31
+ constructor(message) { super(message); this.name = "UsageError"; this.exitCode = 2; }
32
+ }
33
+ /** The thing you named is not there. */
34
+ export class NotFoundError extends Error {
35
+ constructor(message) { super(message); this.name = "NotFoundError"; this.exitCode = 3; }
36
+ }
37
+
38
+ // Selection flags are shared by every command that reads a range of entries,
39
+ // so they are declared once and spliced in below.
40
+ const SELECT_BOOLEANS = ["today", "yesterday", "week", "month", "year", "billable", "running", "done"];
41
+ const SELECT_VALUES = ["project", "task", "agent", "since", "until", "period"];
42
+ const SELECT_MULTI = ["tag", "id"];
43
+
44
+ function selectionFrom(flags, entries) {
45
+ const { since, until } = resolveWindow(flags);
46
+ return select(entries, {
47
+ project: flags.project,
48
+ task: flags.task,
49
+ tag: flags.tag,
50
+ agent: flags.agent,
51
+ since,
52
+ until,
53
+ billable: "billable" in flags ? flags.billable : undefined,
54
+ ids: flags.id,
55
+ runningOnly: flags.running,
56
+ finishedOnly: flags.done,
57
+ });
58
+ }
59
+
60
+ /** The public shape of an entry — what --json prints and what billing reads. */
61
+ function serialize(entry, now = new Date()) {
62
+ const secs = seconds(entry, now);
63
+ return {
64
+ id: entry.id,
65
+ project: entry.project,
66
+ task: entry.task,
67
+ tags: entry.tags,
68
+ start: entry.start,
69
+ end: entry.end,
70
+ running: isRunning(entry),
71
+ seconds: secs,
72
+ hours: hours(secs),
73
+ billable: entry.billable,
74
+ rate: entry.rate,
75
+ agent: entry.agent,
76
+ notes: entry.notes,
77
+ meta: entry.meta,
78
+ };
79
+ }
80
+
81
+ const ENTRY_COLUMNS = [
82
+ { header: "ID", get: (e) => e.id },
83
+ { header: "STARTED", get: (e) => shortStamp(e.start) },
84
+ { header: "PROJECT", get: (e) => e.project },
85
+ { header: "TASK", get: (e) => e.task || "-" },
86
+ { header: "TIME", get: (e) => formatDuration(e.seconds), align: "right" },
87
+ { header: "TAGS", get: (e) => (e.tags.length ? e.tags.join(",") : "-") },
88
+ { header: "", get: (e) => (e.running ? "running" : (e.billable ? "" : "unbillable")) },
89
+ ];
90
+
91
+ function printEntries(rows, flags) {
92
+ if (flags.json) return emitJson({ entries: rows, totals: sumOf(rows) });
93
+ if (!rows.length) { if (!flags.quiet) warn("no entries match"); return; }
94
+ emit(table(rows, ENTRY_COLUMNS, { dimCols: ["ID", "TAGS"] }));
95
+ if (!flags.quiet) {
96
+ const t = sumOf(rows);
97
+ emit("");
98
+ emit(`${rows.length} ${rows.length === 1 ? "entry" : "entries"} ${paint("bold", formatDuration(t.seconds))}`
99
+ + (t.billableSeconds !== t.seconds ? ` (${formatDuration(t.billableSeconds)} billable)` : "")
100
+ + ` ${paint("dim", `${hours(t.billableSeconds)}h billable`)}`);
101
+ }
102
+ }
103
+
104
+ function sumOf(rows) {
105
+ const secs = rows.reduce((n, r) => n + r.seconds, 0);
106
+ const billable = rows.filter((r) => r.billable).reduce((n, r) => n + r.seconds, 0);
107
+ return { entries: rows.length, seconds: secs, hours: hours(secs), billableSeconds: billable, billableHours: hours(billable) };
108
+ }
109
+
110
+ function requireProject(positional, flags) {
111
+ const project = flags.project || positional[0];
112
+ if (!project) throw new UsageError("which project? e.g. timer start acme");
113
+ return project;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+
118
+ const COMMANDS = [
119
+ {
120
+ name: "start",
121
+ aliases: ["begin", "in"],
122
+ args: "<project> [task words…]",
123
+ summary: "start the clock on a project",
124
+ booleans: ["billable", "switch"],
125
+ values: ["task", "note", "agent", "rate", "at", "project", "meta"],
126
+ multi: ["tag"],
127
+ detail: [
128
+ "Everything after the project name is taken as the task, so you can type",
129
+ " timer start acme fix the login redirect",
130
+ "without quoting. --at accepts 09:15, -20m or an ISO instant, for the clock",
131
+ "you meant to start earlier.",
132
+ "",
133
+ "Several clocks may run at once — that is deliberate, because parallel agents",
134
+ "each track their own work. Use --switch to stop the others first.",
135
+ ],
136
+ run({ positional, flags, file }) {
137
+ const project = requireProject(positional, flags);
138
+ const task = flags.task || positional.slice(flags.project ? 0 : 1).join(" ");
139
+ const at = flags.at ? parseMoment(flags.at) : new Date().toISOString();
140
+ if (flags.at && !at) throw new UsageError(`--at: cannot read "${flags.at}" as a time`);
141
+ let meta = {};
142
+ if (flags.meta) {
143
+ try { meta = JSON.parse(flags.meta); } catch { throw new UsageError("--meta must be a JSON object"); }
144
+ }
145
+ const result = update((store) => {
146
+ const stopped = [];
147
+ if (flags.switch) {
148
+ for (const e of store.entries.filter(isRunning)) { closeEntry(e, at); stopped.push(e.id); }
149
+ }
150
+ const entry = makeEntry({
151
+ project,
152
+ task,
153
+ tags: flags.tag || [],
154
+ start: at,
155
+ notes: flags.note || "",
156
+ agent: flags.agent || process.env.TIMER_AGENT || null,
157
+ rate: flags.rate,
158
+ billable: flags.billable !== false,
159
+ meta,
160
+ });
161
+ store.entries.push(entry);
162
+ const others = store.entries.filter((e) => isRunning(e) && e.id !== entry.id);
163
+ return { entry, stopped, others: others.map((e) => e.id) };
164
+ }, { file });
165
+
166
+ if (flags.json) return emitJson({ started: serialize(result.entry), stopped: result.stopped, alsoRunning: result.others });
167
+ if (!flags.quiet) {
168
+ emit(`${paint("green", "started")} ${result.entry.project}`
169
+ + (result.entry.task ? ` — ${result.entry.task}` : "")
170
+ + ` ${paint("dim", result.entry.id)}`);
171
+ if (result.stopped.length) emit(paint("dim", `stopped ${result.stopped.length} other clock(s)`));
172
+ else if (result.others.length) warn(`note: ${result.others.length} other clock(s) still running — timer status`);
173
+ }
174
+ },
175
+ },
176
+ {
177
+ name: "stop",
178
+ aliases: ["out"],
179
+ args: "[id]",
180
+ summary: "stop a running clock",
181
+ booleans: ["all"],
182
+ values: ["at", "note", "project"],
183
+ multi: ["id"],
184
+ detail: [
185
+ "With no argument it stops the most recently started clock. --all stops every",
186
+ "running clock, --project stops the ones on that project, and an id (or any",
187
+ "unambiguous prefix of one) stops exactly that entry.",
188
+ ],
189
+ run({ positional, flags, file }) {
190
+ const at = flags.at ? parseMoment(flags.at) : new Date().toISOString();
191
+ if (flags.at && !at) throw new UsageError(`--at: cannot read "${flags.at}" as a time`);
192
+ const wanted = [...(flags.id || []), ...positional];
193
+ const result = update((store) => {
194
+ const live = store.entries.filter(isRunning);
195
+ if (!live.length) return { stopped: [] };
196
+ let targets;
197
+ if (wanted.length) {
198
+ targets = wanted.map((id) => {
199
+ const found = findById(store.entries, id);
200
+ if (!found) throw new NotFoundError(`no entry with id "${id}"`);
201
+ if (!isRunning(found)) throw new UsageError(`entry ${found.id} is already stopped`);
202
+ return found;
203
+ });
204
+ } else if (flags.all) {
205
+ targets = live;
206
+ } else if (flags.project) {
207
+ targets = live.filter((e) => e.project.toLowerCase() === String(flags.project).toLowerCase());
208
+ if (!targets.length) throw new NotFoundError(`nothing running on project "${flags.project}"`);
209
+ } else {
210
+ targets = [live.reduce((a, b) => (a.start > b.start ? a : b))];
211
+ }
212
+ for (const t of targets) {
213
+ closeEntry(t, at);
214
+ if (flags.note) t.notes = t.notes ? `${t.notes}\n${flags.note}` : flags.note;
215
+ }
216
+ return { stopped: targets };
217
+ }, { file });
218
+
219
+ if (!result.stopped.length) {
220
+ if (flags.json) return emitJson({ stopped: [], message: "no clock was running" });
221
+ if (!flags.quiet) warn("no clock was running");
222
+ return;
223
+ }
224
+ const rows = result.stopped.map((e) => serialize(e));
225
+ if (flags.json) return emitJson({ stopped: rows, totals: sumOf(rows) });
226
+ if (!flags.quiet) {
227
+ for (const r of rows) {
228
+ emit(`${paint("green", "stopped")} ${r.project}${r.task ? ` — ${r.task}` : ""}`
229
+ + ` ${paint("bold", formatDuration(r.seconds))} ${paint("dim", `${r.hours}h · ${r.id}`)}`);
230
+ }
231
+ }
232
+ },
233
+ },
234
+ {
235
+ name: "status",
236
+ aliases: ["st", "now"],
237
+ summary: "what is running, and today's total",
238
+ booleans: [],
239
+ values: [],
240
+ detail: [
241
+ "Exit status is 0 whether or not a clock is running — 'nothing running' is an",
242
+ "answer, not a failure. Check the `running` array in --json instead.",
243
+ ],
244
+ run({ flags, file }) {
245
+ const store = read(file);
246
+ const now = new Date();
247
+ const live = store.entries.filter(isRunning).map((e) => serialize(e, now));
248
+ const { since, until } = resolveWindow({ today: true }, { now });
249
+ const today = select(store.entries, { since, until }).map((e) => serialize(e, now));
250
+
251
+ if (flags.json) {
252
+ return emitJson({ running: live, today: sumOf(today), dataFile: file });
253
+ }
254
+ if (!live.length) emit(paint("dim", "no clock running"));
255
+ else {
256
+ for (const r of live) {
257
+ emit(`${paint("green", "*")} ${r.project}${r.task ? ` — ${r.task}` : ""}`
258
+ + ` ${paint("bold", formatDuration(r.seconds))}`
259
+ + ` ${paint("dim", `since ${shortStamp(r.start)} · ${r.id}`)}`);
260
+ }
261
+ }
262
+ const t = sumOf(today);
263
+ emit(paint("dim", `today: ${formatDuration(t.seconds)} across ${t.entries} ${t.entries === 1 ? "entry" : "entries"}`
264
+ + (t.billableSeconds !== t.seconds ? ` (${formatDuration(t.billableSeconds)} billable)` : "")));
265
+ },
266
+ },
267
+ {
268
+ name: "log",
269
+ aliases: ["ls", "list", "entries"],
270
+ args: "[project]",
271
+ summary: "list entries",
272
+ booleans: [...SELECT_BOOLEANS, "reverse"],
273
+ values: [...SELECT_VALUES, "limit"],
274
+ multi: SELECT_MULTI,
275
+ detail: [
276
+ "`timer log --json` is the stable contract other tools read — @profullstack/billing",
277
+ "builds invoice line items from exactly this shape.",
278
+ "",
279
+ "Windows: --today, --yesterday, --week (from Monday), --month, --year, or an",
280
+ "explicit --since/--until. A bound compares against the entry's start, and",
281
+ "--until is exclusive, so an entry belongs to the day it began on.",
282
+ ],
283
+ run({ positional, flags, file }) {
284
+ const store = read(file);
285
+ if (positional[0] && !flags.project) flags.project = positional[0];
286
+ let rows = selectionFrom(flags, store.entries).map((e) => serialize(e));
287
+ rows.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
288
+ if (flags.reverse) rows.reverse();
289
+ if (flags.limit) {
290
+ const n = Number(flags.limit);
291
+ if (!Number.isFinite(n) || n <= 0) throw new UsageError("--limit must be a positive number");
292
+ rows = rows.slice(-n);
293
+ }
294
+ printEntries(rows, flags);
295
+ },
296
+ },
297
+ {
298
+ name: "add",
299
+ args: "<project> [task words…]",
300
+ summary: "record time you did not clock",
301
+ booleans: ["billable"],
302
+ values: ["from", "to", "duration", "task", "note", "agent", "rate", "project", "meta"],
303
+ multi: ["tag"],
304
+ detail: [
305
+ "Give any two of --from, --to and --duration; with only --duration the entry",
306
+ "ends now. Times accept 09:15, 2026-08-01, -2h or a full ISO instant.",
307
+ "",
308
+ " timer add acme code review --duration 45m",
309
+ " timer add acme --from 09:00 --to 11:30",
310
+ ],
311
+ run({ positional, flags, file }) {
312
+ const project = requireProject(positional, flags);
313
+ const task = flags.task || positional.slice(flags.project ? 0 : 1).join(" ");
314
+ const now = new Date();
315
+ const from = flags.from ? parseMoment(flags.from, { now }) : null;
316
+ const to = flags.to ? parseMoment(flags.to, { now }) : null;
317
+ if (flags.from && !from) throw new UsageError(`--from: cannot read "${flags.from}" as a time`);
318
+ if (flags.to && !to) throw new UsageError(`--to: cannot read "${flags.to}" as a time`);
319
+ if (!flags.duration && !(from && to)) {
320
+ throw new UsageError("give two of --from, --to and --duration");
321
+ }
322
+ let bounds;
323
+ try {
324
+ bounds = boundsFromDuration({ start: from, end: to, duration: flags.duration, now });
325
+ } catch (err) { throw new UsageError(err.message); }
326
+ if (!bounds.start || !bounds.end) throw new UsageError("give two of --from, --to and --duration");
327
+ if (bounds.end < bounds.start) throw new UsageError("the entry would end before it started");
328
+ let meta = {};
329
+ if (flags.meta) {
330
+ try { meta = JSON.parse(flags.meta); } catch { throw new UsageError("--meta must be a JSON object"); }
331
+ }
332
+ const entry = update((store) => {
333
+ const e = makeEntry({
334
+ project,
335
+ task,
336
+ tags: flags.tag || [],
337
+ start: bounds.start,
338
+ end: bounds.end,
339
+ notes: flags.note || "",
340
+ agent: flags.agent || process.env.TIMER_AGENT || null,
341
+ rate: flags.rate,
342
+ billable: flags.billable !== false,
343
+ meta,
344
+ });
345
+ store.entries.push(e);
346
+ return e;
347
+ }, { file });
348
+ const row = serialize(entry);
349
+ if (flags.json) return emitJson({ added: row });
350
+ if (!flags.quiet) {
351
+ emit(`${paint("green", "added")} ${row.project}${row.task ? ` — ${row.task}` : ""}`
352
+ + ` ${paint("bold", formatDuration(row.seconds))} ${paint("dim", `${row.hours}h · ${row.id}`)}`);
353
+ }
354
+ },
355
+ },
356
+ {
357
+ name: "edit",
358
+ args: "<id>",
359
+ summary: "change an entry",
360
+ booleans: ["billable"],
361
+ values: ["project", "task", "note", "agent", "rate", "from", "to", "duration", "meta"],
362
+ multi: ["tag"],
363
+ detail: ["Only the fields you name change. --tag replaces the whole tag list."],
364
+ run({ positional, flags, file }) {
365
+ const id = positional[0];
366
+ if (!id) throw new UsageError("which entry? e.g. timer edit 4f2a --task 'billing bug'");
367
+ const entry = update((store) => {
368
+ const e = findById(store.entries, id);
369
+ if (!e) throw new NotFoundError(`no entry with id "${id}"`);
370
+ const now = new Date();
371
+ if (flags.project) e.project = flags.project;
372
+ if (flags.task != null) e.task = flags.task;
373
+ if (flags.note != null) e.notes = flags.note;
374
+ if (flags.agent != null) e.agent = flags.agent || null;
375
+ if (flags.rate != null) e.rate = Number(flags.rate);
376
+ if ("billable" in flags) e.billable = Boolean(flags.billable);
377
+ if (flags.tag) e.tags = [...new Set(flags.tag)];
378
+ if (flags.meta) {
379
+ try { e.meta = JSON.parse(flags.meta); } catch { throw new UsageError("--meta must be a JSON object"); }
380
+ }
381
+ if (flags.from) {
382
+ const v = parseMoment(flags.from, { now });
383
+ if (!v) throw new UsageError(`--from: cannot read "${flags.from}" as a time`);
384
+ e.start = v;
385
+ }
386
+ if (flags.to) {
387
+ const v = parseMoment(flags.to, { now });
388
+ if (!v) throw new UsageError(`--to: cannot read "${flags.to}" as a time`);
389
+ e.end = v;
390
+ }
391
+ if (flags.duration) {
392
+ const b = boundsFromDuration({ start: e.start, duration: flags.duration, now });
393
+ e.end = b.end;
394
+ }
395
+ if (e.end && e.end < e.start) throw new UsageError("that would end the entry before it started");
396
+ return e;
397
+ }, { file });
398
+ const row = serialize(entry);
399
+ if (flags.json) return emitJson({ updated: row });
400
+ if (!flags.quiet) emit(`${paint("green", "updated")} ${row.id} ${row.project}${row.task ? ` — ${row.task}` : ""} ${formatDuration(row.seconds)}`);
401
+ },
402
+ },
403
+ {
404
+ name: "rm",
405
+ aliases: ["remove", "delete"],
406
+ args: "<id…>",
407
+ summary: "delete entries",
408
+ booleans: ["force"],
409
+ values: [],
410
+ multi: ["id"],
411
+ run({ positional, flags, file }) {
412
+ const wanted = [...(flags.id || []), ...positional];
413
+ if (!wanted.length) throw new UsageError("which entry? e.g. timer rm 4f2a");
414
+ const removed = update((store) => {
415
+ const gone = [];
416
+ for (const id of wanted) {
417
+ const e = findById(store.entries, id);
418
+ if (!e) throw new NotFoundError(`no entry with id "${id}"`);
419
+ if (isRunning(e) && !flags.force) {
420
+ throw new UsageError(`entry ${e.id} is still running — stop it first, or pass --force`);
421
+ }
422
+ store.entries.splice(store.entries.indexOf(e), 1);
423
+ gone.push(e);
424
+ }
425
+ return gone;
426
+ }, { file });
427
+ const rows = removed.map((e) => serialize(e));
428
+ if (flags.json) return emitJson({ removed: rows });
429
+ if (!flags.quiet) for (const r of rows) emit(`${paint("red", "removed")} ${r.id} ${r.project}${r.task ? ` — ${r.task}` : ""}`);
430
+ },
431
+ },
432
+ {
433
+ name: "resume",
434
+ aliases: ["again"],
435
+ args: "[id]",
436
+ summary: "start a new clock like the last one",
437
+ booleans: ["switch"],
438
+ values: ["project", "at"],
439
+ run({ positional, flags, file }) {
440
+ const at = flags.at ? parseMoment(flags.at) : new Date().toISOString();
441
+ if (flags.at && !at) throw new UsageError(`--at: cannot read "${flags.at}" as a time`);
442
+ const entry = update((store) => {
443
+ let source;
444
+ if (positional[0]) {
445
+ source = findById(store.entries, positional[0]);
446
+ if (!source) throw new NotFoundError(`no entry with id "${positional[0]}"`);
447
+ } else {
448
+ const pool = flags.project
449
+ ? store.entries.filter((e) => e.project.toLowerCase() === String(flags.project).toLowerCase())
450
+ : store.entries;
451
+ if (!pool.length) throw new NotFoundError("nothing to resume yet");
452
+ source = pool.reduce((a, b) => (a.start > b.start ? a : b));
453
+ }
454
+ if (flags.switch) for (const e of store.entries.filter(isRunning)) closeEntry(e, at);
455
+ const fresh = makeEntry({
456
+ project: source.project,
457
+ task: source.task,
458
+ tags: source.tags,
459
+ start: at,
460
+ notes: "",
461
+ agent: source.agent,
462
+ rate: source.rate,
463
+ billable: source.billable,
464
+ meta: source.meta,
465
+ });
466
+ store.entries.push(fresh);
467
+ return fresh;
468
+ }, { file });
469
+ const row = serialize(entry);
470
+ if (flags.json) return emitJson({ started: row });
471
+ if (!flags.quiet) emit(`${paint("green", "resumed")} ${row.project}${row.task ? ` — ${row.task}` : ""} ${paint("dim", row.id)}`);
472
+ },
473
+ },
474
+ {
475
+ name: "note",
476
+ args: "<text…>",
477
+ summary: "append a note to a running clock",
478
+ booleans: [],
479
+ values: ["id"],
480
+ run({ positional, flags, file }) {
481
+ const text = positional.join(" ").trim();
482
+ if (!text) throw new UsageError("what note? e.g. timer note waiting on API keys");
483
+ const entry = update((store) => {
484
+ let target;
485
+ if (flags.id) {
486
+ target = findById(store.entries, flags.id);
487
+ if (!target) throw new NotFoundError(`no entry with id "${flags.id}"`);
488
+ } else {
489
+ const live = store.entries.filter(isRunning);
490
+ if (!live.length) throw new NotFoundError("no clock is running — pass --id to note a stopped entry");
491
+ target = live.reduce((a, b) => (a.start > b.start ? a : b));
492
+ }
493
+ target.notes = target.notes ? `${target.notes}\n${text}` : text;
494
+ return target;
495
+ }, { file });
496
+ if (flags.json) return emitJson({ noted: serialize(entry) });
497
+ if (!flags.quiet) emit(`${paint("green", "noted")} ${paint("dim", entry.id)}`);
498
+ },
499
+ },
500
+ {
501
+ name: "report",
502
+ aliases: ["summary", "sum"],
503
+ summary: "totals, grouped",
504
+ booleans: SELECT_BOOLEANS,
505
+ values: [...SELECT_VALUES, "group"],
506
+ multi: SELECT_MULTI,
507
+ detail: [
508
+ `--group takes ${GROUP_KEYS.join(", ")} (default project).`,
509
+ "",
510
+ " timer report --week --group day",
511
+ " timer report --project acme --month --json",
512
+ ],
513
+ run({ positional, flags, file }) {
514
+ const store = read(file);
515
+ if (positional[0] && !flags.project) flags.project = positional[0];
516
+ const group = flags.group || "project";
517
+ const rows = selectionFrom(flags, store.entries);
518
+ let buckets;
519
+ try { buckets = summarize(rows, { group }); } catch (err) { throw new UsageError(err.message); }
520
+ const t = totals(rows);
521
+ const shaped = buckets.map((b) => ({
522
+ key: b.key,
523
+ entries: b.entries,
524
+ running: b.running,
525
+ seconds: b.seconds,
526
+ hours: hours(b.seconds),
527
+ billableSeconds: b.billableSeconds,
528
+ billableHours: hours(b.billableSeconds),
529
+ }));
530
+ if (flags.json) {
531
+ return emitJson({
532
+ group,
533
+ rows: shaped,
534
+ totals: { ...t, hours: hours(t.seconds), billableHours: hours(t.billableSeconds) },
535
+ });
536
+ }
537
+ if (!shaped.length) { if (!flags.quiet) warn("no entries match"); return; }
538
+ emit(table(shaped, [
539
+ { header: group.toUpperCase(), get: (r) => r.key },
540
+ { header: "ENTRIES", get: (r) => r.entries, align: "right" },
541
+ { header: "TIME", get: (r) => formatDuration(r.seconds), align: "right" },
542
+ { header: "HOURS", get: (r) => r.hours.toFixed(2), align: "right" },
543
+ { header: "BILLABLE", get: (r) => r.billableHours.toFixed(2), align: "right" },
544
+ ]));
545
+ emit("");
546
+ emit(`${paint("bold", formatDuration(t.seconds))} ${paint("dim", `${hours(t.seconds)}h total · ${hours(t.billableSeconds)}h billable`)}`);
547
+ },
548
+ },
549
+ {
550
+ name: "projects",
551
+ summary: "projects seen, with totals",
552
+ booleans: SELECT_BOOLEANS,
553
+ values: SELECT_VALUES,
554
+ multi: SELECT_MULTI,
555
+ run({ flags, file }) {
556
+ const store = read(file);
557
+ const rows = selectionFrom(flags, store.entries);
558
+ const buckets = summarize(rows, { group: "project" }).map((b) => {
559
+ const mine = rows.filter((e) => e.project === b.key);
560
+ const last = mine.reduce((a, e) => (a && a.start > e.start ? a : e), null);
561
+ return {
562
+ project: b.key,
563
+ entries: b.entries,
564
+ running: b.running,
565
+ seconds: b.seconds,
566
+ hours: hours(b.seconds),
567
+ billableHours: hours(b.billableSeconds),
568
+ lastSeen: last ? last.start : null,
569
+ };
570
+ });
571
+ if (flags.json) return emitJson({ projects: buckets });
572
+ if (!buckets.length) { if (!flags.quiet) warn("no projects yet — timer start <project>"); return; }
573
+ emit(table(buckets, [
574
+ { header: "PROJECT", get: (r) => r.project },
575
+ { header: "ENTRIES", get: (r) => r.entries, align: "right" },
576
+ { header: "HOURS", get: (r) => r.hours.toFixed(2), align: "right" },
577
+ { header: "BILLABLE", get: (r) => r.billableHours.toFixed(2), align: "right" },
578
+ { header: "LAST", get: (r) => (r.lastSeen ? shortStamp(r.lastSeen) : "-") },
579
+ { header: "", get: (r) => (r.running ? "running" : "") },
580
+ ]));
581
+ },
582
+ },
583
+ {
584
+ name: "export",
585
+ summary: "dump entries as json, ndjson or csv",
586
+ booleans: SELECT_BOOLEANS,
587
+ values: [...SELECT_VALUES, "format", "out"],
588
+ multi: SELECT_MULTI,
589
+ detail: [
590
+ "Default format is json. --out writes to a file instead of stdout, which is",
591
+ "the one case where a --json run prints a status line (to stderr).",
592
+ ],
593
+ run({ flags, file }) {
594
+ const store = read(file);
595
+ const rows = selectionFrom(flags, store.entries).map((e) => serialize(e));
596
+ rows.sort((a, b) => (a.start < b.start ? -1 : 1));
597
+ const format = (flags.format || "json").toLowerCase();
598
+ let text;
599
+ if (format === "json") text = JSON.stringify({ entries: rows, totals: sumOf(rows) }, null, 2);
600
+ else if (format === "ndjson") text = rows.map((r) => JSON.stringify(r)).join("\n");
601
+ else if (format === "csv") {
602
+ text = csv(rows, [
603
+ { header: "id", get: (r) => r.id },
604
+ { header: "project", get: (r) => r.project },
605
+ { header: "task", get: (r) => r.task },
606
+ { header: "tags", get: (r) => r.tags.join(" ") },
607
+ { header: "start", get: (r) => r.start },
608
+ { header: "end", get: (r) => r.end || "" },
609
+ { header: "hours", get: (r) => r.hours },
610
+ { header: "billable", get: (r) => (r.billable ? "yes" : "no") },
611
+ { header: "rate", get: (r) => (r.rate == null ? "" : r.rate) },
612
+ { header: "agent", get: (r) => r.agent || "" },
613
+ { header: "notes", get: (r) => r.notes },
614
+ ]);
615
+ } else throw new UsageError(`--format: unknown format "${flags.format}" (json, ndjson, csv)`);
616
+
617
+ if (flags.out) {
618
+ fs.writeFileSync(flags.out, `${text}\n`);
619
+ if (!flags.quiet) warn(`wrote ${rows.length} entries to ${flags.out}`);
620
+ return;
621
+ }
622
+ emit(text);
623
+ },
624
+ },
625
+ {
626
+ name: "config",
627
+ aliases: ["where", "paths"],
628
+ summary: "where the timesheet lives",
629
+ booleans: [],
630
+ values: [],
631
+ run({ flags, file }) {
632
+ const exists = fs.existsSync(file);
633
+ const store = exists ? read(file) : { entries: [] };
634
+ if (flags.json) {
635
+ return emitJson({ dataFile: file, home: timerHome(), exists, entries: store.entries.length, version: VERSION });
636
+ }
637
+ emit(`timer ${VERSION}`);
638
+ emit(`data file ${file}${exists ? "" : paint("dim", " (not created yet)")}`);
639
+ emit(`home ${timerHome()}`);
640
+ emit(`entries ${store.entries.length}`);
641
+ emit("");
642
+ emit(paint("dim", "override with TIMER_DATA (a file) or TIMER_HOME / PROFULLSTACK_HOME (a directory)"));
643
+ },
644
+ },
645
+ ];
646
+
647
+ const BY_NAME = new Map();
648
+ for (const cmd of COMMANDS) {
649
+ BY_NAME.set(cmd.name, cmd);
650
+ for (const alias of cmd.aliases || []) BY_NAME.set(alias, cmd);
651
+ }
652
+
653
+ export function findCommand(name) {
654
+ return BY_NAME.get(String(name || "").toLowerCase()) || null;
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+
659
+ function usage() {
660
+ const lines = [
661
+ paint("bold", "timer") + " — track time against projects, for people and for agents",
662
+ "",
663
+ " timer <command> [args] [--json]",
664
+ "",
665
+ ];
666
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
667
+ for (const c of COMMANDS) lines.push(` ${c.name.padEnd(width)} ${c.summary}`);
668
+ lines.push(
669
+ "",
670
+ " timer help <command> flags and examples for one command",
671
+ "",
672
+ paint("dim", " --json on any command prints one JSON document and nothing else."),
673
+ paint("dim", ` timesheet: ${dataFile()}`),
674
+ );
675
+ return lines.join("\n");
676
+ }
677
+
678
+ function commandHelp(cmd) {
679
+ const flagList = [
680
+ ...(cmd.booleans || []).map((f) => `--${f}`),
681
+ ...(cmd.values || []).map((f) => `--${f} <value>`),
682
+ ...(cmd.multi || []).map((f) => `--${f} <value> (repeatable)`),
683
+ ];
684
+ const lines = [
685
+ `${paint("bold", `timer ${cmd.name}`)} ${cmd.args || ""}`.trimEnd(),
686
+ "",
687
+ ` ${cmd.summary}`,
688
+ ];
689
+ if (cmd.aliases?.length) lines.push("", ` aliases: ${cmd.aliases.join(", ")}`);
690
+ if (flagList.length) lines.push("", " flags:", ...flagList.map((f) => ` ${f}`));
691
+ if (cmd.detail?.length) lines.push("", ...cmd.detail.map((l) => (l ? ` ${l}` : "")));
692
+ lines.push("", " global: --json --quiet --data <file> --help --version");
693
+ return lines.join("\n");
694
+ }
695
+
696
+ export function run(argv) {
697
+ // The first non-flag token is the command. Parsing globals first means
698
+ // `timer --json status` and `timer status --json` are the same command.
699
+ const head = parseArgs(argv, {
700
+ booleans: GLOBAL_BOOLEANS,
701
+ values: GLOBAL_VALUES,
702
+ aliases: GLOBAL_ALIASES,
703
+ });
704
+ if (head.flags.version) { emit(VERSION); return 0; }
705
+
706
+ const name = head.positional[0];
707
+ if (!name || name === "help") {
708
+ const topic = name === "help" ? head.positional[1] : null;
709
+ if (topic) {
710
+ const cmd = findCommand(topic);
711
+ if (!cmd) throw new UsageError(`unknown command "${topic}"`);
712
+ emit(commandHelp(cmd));
713
+ return 0;
714
+ }
715
+ emit(usage());
716
+ return 0;
717
+ }
718
+
719
+ const cmd = findCommand(name);
720
+ if (!cmd) {
721
+ throw new UsageError(`unknown command "${name}" — try: timer help`);
722
+ }
723
+ if (head.flags.help) { emit(commandHelp(cmd)); return 0; }
724
+
725
+ const parsed = parseArgs(argv.slice(argv.indexOf(name) + 1), {
726
+ booleans: [...GLOBAL_BOOLEANS, ...(cmd.booleans || [])],
727
+ values: [...GLOBAL_VALUES, ...(cmd.values || [])],
728
+ multi: cmd.multi || [],
729
+ aliases: GLOBAL_ALIASES,
730
+ });
731
+ if (parsed.flags.help) { emit(commandHelp(cmd)); return 0; }
732
+ if (parsed.unknown.length) {
733
+ throw new UsageError(`unknown flag ${parsed.unknown[0]} for "${cmd.name}" — try: timer help ${cmd.name}`);
734
+ }
735
+ const flags = { ...head.flags, ...parsed.flags };
736
+ delete flags.help;
737
+ delete flags.version;
738
+ const file = flags.data || dataFile();
739
+ cmd.run({ positional: parsed.positional, flags, rest: parsed.rest, file });
740
+ return 0;
741
+ }
742
+
743
+ export function main(argv = process.argv.slice(2)) {
744
+ try {
745
+ return run(argv);
746
+ } catch (err) {
747
+ const code = err.exitCode || 1;
748
+ // A failed run must never put a result document on stdout: an agent that
749
+ // parses stdout would read a success shape for a command that failed.
750
+ if (argv.includes("--json") || argv.includes("-j")) {
751
+ process.stderr.write(`${JSON.stringify({ error: err.message, kind: err.name || "Error" }, null, 2)}\n`);
752
+ } else {
753
+ warn(`${paint("red", "timer:")} ${err.message}`);
754
+ }
755
+ return code;
756
+ }
757
+ }