@drazenbebic/wdid 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +94 -3
  2. package/dist/index.js +951 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,17 @@
1
- # wdid
1
+ <div align="center">
2
+ <picture>
3
+ <source
4
+ srcset="./logos/wdid_logo_white_transparent.png"
5
+ media="(prefers-color-scheme: dark)"
6
+ width="820" height="332"
7
+ />
8
+ <img
9
+ src="./logos/wdid_logo_black_transparent.png"
10
+ alt="What did I do Logo"
11
+ width="820" height="332"
12
+ />
13
+ </picture>
14
+ </div>
2
15
 
3
16
  > What did I do? — a small CLI that summarizes your git activity as a tidy table, so you can fill in your timesheet without trying to remember Tuesday.
4
17
 
@@ -15,10 +28,13 @@ This puts a `wdid` binary on your `PATH`.
15
28
  ## Usage
16
29
 
17
30
  ```sh
18
- wdid # all commits authored by you, across all branches
31
+ wdid # show help (no args = nothing to do)
19
32
  wdid today # commits from today
33
+ wdid yesterday # commits from yesterday
20
34
  wdid 2026-05-27 # commits from a specific day (YYYY-MM-DD)
35
+ wdid 2026-05 # commits from a specific month (YYYY-MM)
21
36
  wdid --from 2026-05-01 --to 2026-05-07 # a date range
37
+ wdid --all # all commits, no date filter
22
38
  wdid --author "Jane Doe" # someone else's commits
23
39
  wdid --repo ../api ../web # query multiple repos at once
24
40
  ```
@@ -62,7 +78,8 @@ With `--group-by-day`, the date moves into a section heading instead of repeatin
62
78
 
63
79
  | Option | Description |
64
80
  | -------------------------- | -------------------------------------------------------------------------------------------- |
65
- | `[date]` | A `YYYY-MM-DD` date or the literal `today`. Omit to show all history. |
81
+ | `[date]` | A `YYYY-MM-DD` date, a `YYYY-MM` month, or the literal `today` / `yesterday`. |
82
+ | `--all` | Show all history (no date filter). Required to opt into the unfiltered view explicitly. |
66
83
  | `--from <date>` | Start date (inclusive). |
67
84
  | `--to <date>` | End date (inclusive). |
68
85
  | `--author <name>` | Override the git author. Defaults to `git config user.name` (or `defaultAuthor` in config). |
@@ -113,6 +130,80 @@ CLI flags always win. The first match in this list is used in full (configs do n
113
130
 
114
131
  The column header is picked automatically based on the active format. To override it for a specific preset (e.g. call them "Tasks" instead of "Ticket"), set `ticketColumnLabel` in your config.
115
132
 
133
+ ## Toggl integration
134
+
135
+ `wdid toggl sync [date]` pushes the day's commits to Toggl as time entries. By default, commits with the same ticket are **collapsed into a single entry** (duration scales with commit count) and commits whose subject matches `\bmerge\b` are skipped. Entries stack from a configurable day-start hour — you adjust the exact times in Toggl yourself. The sync is **idempotent**: each entry's description carries one `(wdid <short-sha>)` marker per included commit, and re-running skips commits already pushed.
136
+
137
+ Descriptions are condensed for Toggl: the conventional-commit prefix (`feat:`, `chore(ABC-123):`, `fix!:`, etc.) is stripped, and the ticket — if any — is prepended once. So `chore(EN-4435): remove requestBody` becomes `EN-4435: remove requestBody`. Aggregated entries look like `EN-4435: subject A; subject B; subject C`.
138
+
139
+ ```sh
140
+ wdid toggl sync # push today
141
+ wdid toggl sync 2026-05-27 # push a specific day
142
+ wdid toggl sync today --dry-run # preview without pushing
143
+ wdid toggl sync --workspace 12345 today # override the configured workspace
144
+ wdid toggl sync --from 2026-05-25 --to 2026-05-27 # push a multi-day range (inclusive)
145
+ ```
146
+
147
+ `--from` and `--to` are inclusive and mutually exclusive with the positional `[date]`. Each day is planned independently (its own 09:00 start, its own dedup fetch). On a per-day failure (Toggl 500, missing project, etc.), the sync continues through the remaining days and exits non-zero with a summary so one bad day doesn't strand the rest. The range is capped at 366 days as a guardrail.
148
+
149
+ ### Toggl config
150
+
151
+ Add these alongside the other config fields:
152
+
153
+ ```json
154
+ {
155
+ "togglApiToken": "your-api-token",
156
+ "togglWorkspaceId": 12345,
157
+ "togglProjects": {
158
+ "ABC-": 67890,
159
+ "DEF-": 67891
160
+ },
161
+ "togglDefaultProjectId": 99999,
162
+ "togglDefaultDurationMinutes": 30,
163
+ "togglDayStartHour": 9,
164
+ "togglOneEntryPerTicket": true,
165
+ "togglIgnoreSubjectPattern": "\\bmerge\\b"
166
+ }
167
+ ```
168
+
169
+ | Field | Type | Description |
170
+ | ----------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
171
+ | `togglApiToken` | `string` | Toggl API token (find it in Toggl → Profile → API Token). Prefer the `TOGGL_API_TOKEN` env var. |
172
+ | `togglWorkspaceId` | `number` | Numeric Toggl workspace ID. Required to push. |
173
+ | `togglProjects` | `Record<string, number>` | Map of ticket-prefix → project ID. Longest matching prefix wins. |
174
+ | `togglDefaultProjectId` | `number` | Project ID for commits that don't match any prefix (or have no ticket). |
175
+ | `togglDefaultDurationMinutes` | `number` | Per-commit duration. Default `30`. In per-ticket mode, an entry's total duration is `count × this`. |
176
+ | `togglDayStartHour` | `number` (0–23) | Hour to start stacking entries at. Default `9` (09:00). |
177
+ | `togglOneEntryPerTicket` | `boolean` | When `true` (default), commits sharing a ticket collapse into one entry. Commits without a ticket stay 1:1. |
178
+ | `togglIgnoreSubjectPattern` | `string` (regex) | Subjects matching this pattern (case-insensitive) are skipped. Default `\bmerge\b`. Set to `""` to disable. |
179
+
180
+ ### Auth
181
+
182
+ The API token is resolved in this order: `TOGGL_API_TOKEN` env var > `togglApiToken` in config. The env var path is preferred so you don't have to commit (or remember not to commit) the token.
183
+
184
+ ## Managing config
185
+
186
+ `wdid config` provides four subcommands for the **global** config (`~/.config/wdid/config.json` — honors `XDG_CONFIG_HOME`). Repo-level configs are read but not written by these commands; edit them by hand.
187
+
188
+ ```sh
189
+ wdid config set togglApiToken tok_… # set a scalar field
190
+ wdid config set togglWorkspaceId 12345 # numbers are parsed
191
+ wdid config set togglOneEntryPerTicket false # booleans take "true"/"false"
192
+ wdid config set togglProjects.ABC- 67890 # set a nested record entry
193
+ wdid config get togglApiToken # secrets are masked
194
+ wdid config get togglApiToken --show-secrets # …unless --show-secrets
195
+ wdid config list # all set fields, aligned, secrets masked
196
+ wdid config list --show-secrets # reveal secrets
197
+ wdid config path # absolute path to the config file
198
+ ```
199
+
200
+ Notes:
201
+
202
+ - **Validation runs at `set` time** — `wdid config set togglDayStartHour 99` fails immediately with the schema error, the file is never touched.
203
+ - **Secrets are masked** in `list` / `get` output (`tok_…wa9e0d` style) unless `--show-secrets` is set.
204
+ - **`defaultRepos` is not settable from the CLI** (it's an array). Same for any future array-shaped field. Use `vim $(wdid config path)` to edit those.
205
+ - **To remove a key**, edit the file directly — `unset` isn't included in this slice.
206
+
116
207
  ## Development
117
208
 
118
209
  This project uses [pnpm](https://pnpm.io) (see the `packageManager` field in `package.json`).
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import chalk2 from "chalk";
4
+ import chalk3 from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/git.ts
@@ -102,30 +102,31 @@ async function assertGitRepo(cwd) {
102
102
  );
103
103
  }
104
104
  }
105
+ var GIT_USER_NAME_HINT = 'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author';
105
106
  async function getGitUserName(cwd) {
107
+ let stdout;
106
108
  try {
107
- const { stdout } = await execFileAsync("git", ["config", "user.name"], {
109
+ const result = await execFileAsync("git", ["config", "user.name"], {
108
110
  cwd
109
111
  });
110
- const name = stdout.trim();
111
- if (!name) {
112
- throw new Error("empty");
113
- }
114
- return name;
112
+ stdout = result.stdout;
115
113
  } catch (err) {
116
114
  if (err.code === "ENOENT") {
117
115
  throw new Error("git is not installed or not on PATH", { cause: err });
118
116
  }
119
- throw new Error(
120
- 'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author',
121
- { cause: err }
122
- );
117
+ throw new Error(GIT_USER_NAME_HINT, { cause: err });
123
118
  }
119
+ const name = stdout.trim();
120
+ if (!name) {
121
+ throw new Error(GIT_USER_NAME_HINT);
122
+ }
123
+ return name;
124
124
  }
125
125
  async function getCommits(opts) {
126
126
  await assertGitRepo(opts.cwd);
127
127
  const args = [
128
128
  "log",
129
+ "--exclude=refs/stash",
129
130
  "--all",
130
131
  "--author-date-order",
131
132
  "--regexp-ignore-case",
@@ -171,6 +172,7 @@ async function getCommits(opts) {
171
172
  parsed.map((p) => p.sha).filter((s) => s.length > 0)
172
173
  );
173
174
  return parsed.map((p) => ({
175
+ sha: p.sha,
174
176
  date: p.date,
175
177
  time: p.time,
176
178
  ticket: p.ticket,
@@ -251,6 +253,7 @@ function toJsonKey(label) {
251
253
  function renderJson(entries, ticketColumnLabel = "Ticket") {
252
254
  const key = toJsonKey(ticketColumnLabel);
253
255
  const transformed = entries.map((e) => ({
256
+ sha: e.sha,
254
257
  date: e.date,
255
258
  time: e.time,
256
259
  [key]: e.ticket,
@@ -271,6 +274,12 @@ import { homedir } from "os";
271
274
  import { readFile } from "fs/promises";
272
275
  import { join } from "path";
273
276
  import { cosmiconfig } from "cosmiconfig";
277
+ var TOGGL_DEFAULTS = {
278
+ durationMinutes: 30,
279
+ dayStartHour: 9,
280
+ oneEntryPerTicket: true,
281
+ ignoreSubjectPattern: "\\bmerge\\b"
282
+ };
274
283
  var DEFAULT_COLUMN_LABELS = {
275
284
  jira: "Ticket",
276
285
  github: "Issue",
@@ -371,6 +380,71 @@ function validateConfig(raw) {
371
380
  }
372
381
  cfg.ticketColumnLabel = obj.ticketColumnLabel;
373
382
  }
383
+ if ("togglApiToken" in obj) {
384
+ if (typeof obj.togglApiToken !== "string") {
385
+ throw new Error("togglApiToken must be a string");
386
+ }
387
+ cfg.togglApiToken = obj.togglApiToken;
388
+ }
389
+ if ("togglWorkspaceId" in obj) {
390
+ if (typeof obj.togglWorkspaceId !== "number" || !Number.isInteger(obj.togglWorkspaceId) || obj.togglWorkspaceId < 1) {
391
+ throw new Error("togglWorkspaceId must be a positive integer");
392
+ }
393
+ cfg.togglWorkspaceId = obj.togglWorkspaceId;
394
+ }
395
+ if ("togglProjects" in obj) {
396
+ const raw2 = obj.togglProjects;
397
+ if (typeof raw2 !== "object" || raw2 === null || Array.isArray(raw2)) {
398
+ throw new Error(
399
+ "togglProjects must be an object mapping ticket prefix \u2192 project ID"
400
+ );
401
+ }
402
+ const map = {};
403
+ for (const [prefix, projectId] of Object.entries(raw2)) {
404
+ if (typeof projectId !== "number" || !Number.isInteger(projectId) || projectId < 1) {
405
+ throw new Error(
406
+ `togglProjects["${prefix}"] must be a positive integer`
407
+ );
408
+ }
409
+ map[prefix] = projectId;
410
+ }
411
+ cfg.togglProjects = map;
412
+ }
413
+ if ("togglDefaultProjectId" in obj) {
414
+ if (typeof obj.togglDefaultProjectId !== "number" || !Number.isInteger(obj.togglDefaultProjectId) || obj.togglDefaultProjectId < 1) {
415
+ throw new Error("togglDefaultProjectId must be a positive integer");
416
+ }
417
+ cfg.togglDefaultProjectId = obj.togglDefaultProjectId;
418
+ }
419
+ if ("togglDefaultDurationMinutes" in obj) {
420
+ if (typeof obj.togglDefaultDurationMinutes !== "number" || !Number.isInteger(obj.togglDefaultDurationMinutes) || obj.togglDefaultDurationMinutes < 1) {
421
+ throw new Error("togglDefaultDurationMinutes must be a positive integer");
422
+ }
423
+ cfg.togglDefaultDurationMinutes = obj.togglDefaultDurationMinutes;
424
+ }
425
+ if ("togglDayStartHour" in obj) {
426
+ if (typeof obj.togglDayStartHour !== "number" || !Number.isInteger(obj.togglDayStartHour) || obj.togglDayStartHour < 0 || obj.togglDayStartHour > 23) {
427
+ throw new Error("togglDayStartHour must be an integer between 0 and 23");
428
+ }
429
+ cfg.togglDayStartHour = obj.togglDayStartHour;
430
+ }
431
+ if ("togglOneEntryPerTicket" in obj) {
432
+ if (typeof obj.togglOneEntryPerTicket !== "boolean") {
433
+ throw new Error("togglOneEntryPerTicket must be a boolean");
434
+ }
435
+ cfg.togglOneEntryPerTicket = obj.togglOneEntryPerTicket;
436
+ }
437
+ if ("togglIgnoreSubjectPattern" in obj) {
438
+ if (typeof obj.togglIgnoreSubjectPattern !== "string") {
439
+ throw new Error("togglIgnoreSubjectPattern must be a string");
440
+ }
441
+ if (obj.togglIgnoreSubjectPattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
442
+ throw new Error(
443
+ `togglIgnoreSubjectPattern is ${obj.togglIgnoreSubjectPattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
444
+ );
445
+ }
446
+ cfg.togglIgnoreSubjectPattern = obj.togglIgnoreSubjectPattern;
447
+ }
374
448
  return cfg;
375
449
  }
376
450
  function globalConfigPath() {
@@ -401,6 +475,477 @@ async function loadConfig(cwd) {
401
475
  return {};
402
476
  }
403
477
 
478
+ // src/integrations/toggl.ts
479
+ var TOGGL_API_BASE = "https://api.track.toggl.com/api/v9";
480
+ function shortenSha(sha) {
481
+ return sha.slice(0, 7);
482
+ }
483
+ function findProjectId(ticket, projects, defaultProjectId) {
484
+ if (!ticket) {
485
+ return { projectId: defaultProjectId ?? null, matchedPrefix: null };
486
+ }
487
+ const prefixes = Object.keys(projects).sort((a, b) => b.length - a.length);
488
+ for (const prefix of prefixes) {
489
+ if (ticket.startsWith(prefix)) {
490
+ return { projectId: projects[prefix] ?? null, matchedPrefix: prefix };
491
+ }
492
+ }
493
+ return { projectId: defaultProjectId ?? null, matchedPrefix: null };
494
+ }
495
+ var SHA_MARKER_GLOBAL = /\(wdid ([0-9a-f]{7})\)/g;
496
+ function extractSyncedShasFromDescription(description) {
497
+ const shas = [];
498
+ for (const match of description.matchAll(SHA_MARKER_GLOBAL)) {
499
+ if (match[1]) {
500
+ shas.push(match[1]);
501
+ }
502
+ }
503
+ return shas;
504
+ }
505
+ function groupKey(commit, oneEntryPerTicket) {
506
+ if (oneEntryPerTicket && commit.ticket) {
507
+ return `ticket:${commit.ticket}`;
508
+ }
509
+ return `sha:${commit.sha}`;
510
+ }
511
+ function buildMarker(shortShas) {
512
+ return shortShas.map((s) => `(wdid ${s})`).join(" ");
513
+ }
514
+ var CONVENTIONAL_PREFIX = /^\w+(\([^)]+\))?!?:\s*/;
515
+ function escapeRegExp(s) {
516
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
517
+ }
518
+ function cleanSubjectForToggl(rawSubject, ticket) {
519
+ let cleaned = rawSubject.replace(CONVENTIONAL_PREFIX, "");
520
+ if (ticket) {
521
+ const leadingTicket = new RegExp(`^${escapeRegExp(ticket)}[:\\s]+`);
522
+ cleaned = cleaned.replace(leadingTicket, "");
523
+ }
524
+ return cleaned.trim();
525
+ }
526
+ function planEntries(commits, options) {
527
+ const filtered = options.ignoreSubjectPattern ? commits.filter((c) => !options.ignoreSubjectPattern.test(c.description)) : commits;
528
+ const sorted = [...filtered].sort(
529
+ (a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`)
530
+ );
531
+ const groups = /* @__PURE__ */ new Map();
532
+ for (const commit of sorted) {
533
+ const key = groupKey(commit, options.oneEntryPerTicket);
534
+ const existing = groups.get(key);
535
+ if (existing) {
536
+ existing.push(commit);
537
+ } else {
538
+ groups.set(key, [commit]);
539
+ }
540
+ }
541
+ const defaultDurationSeconds = options.defaultDurationMinutes * 60;
542
+ const dayStart = /* @__PURE__ */ new Date(
543
+ `${options.date}T${String(options.dayStartHour).padStart(2, "0")}:00:00`
544
+ );
545
+ let cursor = dayStart.getTime();
546
+ const plans = [];
547
+ for (const groupCommits of groups.values()) {
548
+ const first = groupCommits[0];
549
+ const ticket = first.ticket;
550
+ const shas = groupCommits.map((c) => c.sha);
551
+ const shortShas = shas.map(shortenSha);
552
+ const cleanedSubjects = groupCommits.map((c) => cleanSubjectForToggl(c.description, ticket)).filter((s) => s.length > 0);
553
+ const joined = cleanedSubjects.join("; ");
554
+ const marker = buildMarker(shortShas);
555
+ const body = ticket ? joined.length > 0 ? `${ticket}: ${joined}` : ticket : joined;
556
+ const description = `${body} ${marker}`.trim();
557
+ const durationSeconds = defaultDurationSeconds * groupCommits.length;
558
+ const { projectId, matchedPrefix } = findProjectId(
559
+ ticket,
560
+ options.projects,
561
+ options.defaultProjectId
562
+ );
563
+ const alreadySynced = shortShas.every(
564
+ (s) => options.existingSyncedShas.has(s)
565
+ );
566
+ plans.push({
567
+ shas,
568
+ shortShas,
569
+ description,
570
+ start: new Date(cursor).toISOString(),
571
+ durationSeconds,
572
+ projectId,
573
+ matchedTicketPrefix: matchedPrefix,
574
+ ticket,
575
+ commitCount: groupCommits.length,
576
+ alreadySynced
577
+ });
578
+ cursor += durationSeconds * 1e3;
579
+ }
580
+ return plans;
581
+ }
582
+ function basicAuth(token) {
583
+ return `Basic ${Buffer.from(`${token}:api_token`).toString("base64")}`;
584
+ }
585
+ function nextDay(date) {
586
+ const d = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
587
+ d.setUTCDate(d.getUTCDate() + 1);
588
+ return d.toISOString().slice(0, 10);
589
+ }
590
+ var MAX_SYNC_RANGE_DAYS = 366;
591
+ function enumerateDates(from, to) {
592
+ const start = /* @__PURE__ */ new Date(`${from}T00:00:00Z`);
593
+ const end = /* @__PURE__ */ new Date(`${to}T00:00:00Z`);
594
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
595
+ throw new Error(`invalid date range "${from}" \u2192 "${to}"`);
596
+ }
597
+ if (end < start) {
598
+ throw new Error(`--to (${to}) must be on or after --from (${from})`);
599
+ }
600
+ const days = Math.round((end.getTime() - start.getTime()) / 864e5) + 1;
601
+ if (days > MAX_SYNC_RANGE_DAYS) {
602
+ throw new Error(
603
+ `date range is ${days} days; limit is ${MAX_SYNC_RANGE_DAYS}`
604
+ );
605
+ }
606
+ const result = [];
607
+ for (const d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
608
+ result.push(d.toISOString().slice(0, 10));
609
+ }
610
+ return result;
611
+ }
612
+ async function fetchSyncedShas(auth, date) {
613
+ const url = `${TOGGL_API_BASE}/me/time_entries?start_date=${date}&end_date=${nextDay(date)}`;
614
+ const response = await fetch(url, {
615
+ headers: { Authorization: basicAuth(auth.apiToken) }
616
+ });
617
+ if (!response.ok) {
618
+ throw new Error(
619
+ `Toggl fetch failed: ${response.status} ${response.statusText}`
620
+ );
621
+ }
622
+ const entries = await response.json();
623
+ const shas = /* @__PURE__ */ new Set();
624
+ for (const e of entries) {
625
+ for (const sha of extractSyncedShasFromDescription(e.description ?? "")) {
626
+ shas.add(sha);
627
+ }
628
+ }
629
+ return shas;
630
+ }
631
+ async function pushEntries(auth, workspaceId, plan) {
632
+ const result = { pushed: 0, skipped: 0, failures: [] };
633
+ for (const entry of plan) {
634
+ if (entry.alreadySynced) {
635
+ result.skipped++;
636
+ continue;
637
+ }
638
+ const response = await fetch(
639
+ `${TOGGL_API_BASE}/workspaces/${workspaceId}/time_entries`,
640
+ {
641
+ method: "POST",
642
+ headers: {
643
+ "Content-Type": "application/json",
644
+ Authorization: basicAuth(auth.apiToken)
645
+ },
646
+ body: JSON.stringify({
647
+ description: entry.description,
648
+ start: entry.start,
649
+ duration: entry.durationSeconds,
650
+ workspace_id: workspaceId,
651
+ project_id: entry.projectId,
652
+ created_with: "wdid"
653
+ })
654
+ }
655
+ );
656
+ if (!response.ok) {
657
+ const body = await response.text();
658
+ result.failures.push({
659
+ plan: entry,
660
+ reason: `${response.status} ${response.statusText} \u2014 ${body.slice(0, 200)}`
661
+ });
662
+ continue;
663
+ }
664
+ result.pushed++;
665
+ }
666
+ return result;
667
+ }
668
+
669
+ // src/config-cli.ts
670
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
671
+ import { dirname } from "path";
672
+ import Table2 from "cli-table3";
673
+ import chalk2 from "chalk";
674
+ var FIELDS = {
675
+ format: {
676
+ type: "enum",
677
+ values: ["jira", "github", "conventional", "custom"],
678
+ default: "jira",
679
+ description: "Ticket extraction preset."
680
+ },
681
+ customPattern: {
682
+ type: "string",
683
+ description: 'Regex for ticket extraction when format is "custom".'
684
+ },
685
+ defaultAuthor: {
686
+ type: "string",
687
+ description: "Default author for git log; falls back to git config user.name."
688
+ },
689
+ defaultRepos: {
690
+ type: "string-array",
691
+ settable: false,
692
+ description: "Repo paths to query when --repo is not given. (~) is expanded."
693
+ },
694
+ ticketColumnLabel: {
695
+ type: "string",
696
+ description: "Override the auto-picked Ticket column header."
697
+ },
698
+ togglApiToken: {
699
+ type: "string",
700
+ secret: true,
701
+ description: "Toggl API token. Prefer the TOGGL_API_TOKEN env var."
702
+ },
703
+ togglWorkspaceId: {
704
+ type: "number",
705
+ description: "Numeric Toggl workspace ID. Required to push."
706
+ },
707
+ togglProjects: {
708
+ type: "number-record",
709
+ nested: true,
710
+ description: "Map of ticket-prefix \u2192 Toggl project ID. Longest prefix wins."
711
+ },
712
+ togglDefaultProjectId: {
713
+ type: "number",
714
+ description: "Project ID for commits without a prefix match."
715
+ },
716
+ togglDefaultDurationMinutes: {
717
+ type: "number",
718
+ default: 30,
719
+ description: "Per-commit duration. Total = count \xD7 this in per-ticket mode."
720
+ },
721
+ togglDayStartHour: {
722
+ type: "number",
723
+ default: 9,
724
+ description: "Hour (0\u201323) to start stacking entries at."
725
+ },
726
+ togglOneEntryPerTicket: {
727
+ type: "boolean",
728
+ default: true,
729
+ description: "Collapse same-ticket commits into one entry. No-ticket commits stay 1:1."
730
+ },
731
+ togglIgnoreSubjectPattern: {
732
+ type: "string",
733
+ default: "\\bmerge\\b",
734
+ description: 'Subjects matching this regex (case-insensitive) are skipped. "" to disable.'
735
+ }
736
+ };
737
+ function parseKey(rawKey) {
738
+ const dotIndex = rawKey.indexOf(".");
739
+ const field = dotIndex === -1 ? rawKey : rawKey.slice(0, dotIndex);
740
+ const subKey = dotIndex === -1 ? void 0 : rawKey.slice(dotIndex + 1);
741
+ const spec = FIELDS[field];
742
+ if (!spec) {
743
+ throw new Error(
744
+ `unknown config key "${field}". Known keys: ${Object.keys(FIELDS).join(", ")}`
745
+ );
746
+ }
747
+ if (subKey !== void 0 && !spec.nested) {
748
+ throw new Error(
749
+ `config key "${field}" does not support dotted access (it is a ${spec.type})`
750
+ );
751
+ }
752
+ if (subKey === "") {
753
+ throw new Error(`empty sub-key in "${rawKey}"`);
754
+ }
755
+ return { field, subKey };
756
+ }
757
+ function parseValue(field, rawValue) {
758
+ const spec = FIELDS[field];
759
+ if (!spec) {
760
+ throw new Error(`unknown config key "${field}"`);
761
+ }
762
+ if (spec.settable === false) {
763
+ throw new Error(
764
+ `"${field}" cannot be set from the CLI \u2014 edit the config file directly`
765
+ );
766
+ }
767
+ switch (spec.type) {
768
+ case "string":
769
+ return rawValue;
770
+ case "enum":
771
+ if (!spec.values?.includes(rawValue)) {
772
+ throw new Error(
773
+ `"${rawValue}" is not a valid value for "${field}" \u2014 must be one of ${spec.values?.join(", ")}`
774
+ );
775
+ }
776
+ return rawValue;
777
+ case "number":
778
+ case "number-record": {
779
+ const n = Number(rawValue);
780
+ if (!Number.isFinite(n)) {
781
+ throw new Error(`"${rawValue}" is not a valid number for "${field}"`);
782
+ }
783
+ return n;
784
+ }
785
+ case "boolean":
786
+ if (rawValue === "true") {
787
+ return true;
788
+ }
789
+ if (rawValue === "false") {
790
+ return false;
791
+ }
792
+ throw new Error(
793
+ `"${rawValue}" is not a valid boolean for "${field}" \u2014 use "true" or "false"`
794
+ );
795
+ case "string-array":
796
+ throw new Error(`"${field}" is an array \u2014 edit the config file directly`);
797
+ default:
798
+ throw new Error(`internal: unhandled field type for "${field}"`);
799
+ }
800
+ }
801
+ function setConfigValue(cfg, rawKey, rawValue) {
802
+ const { field, subKey } = parseKey(rawKey);
803
+ const next = { ...cfg };
804
+ if (subKey !== void 0) {
805
+ const spec = FIELDS[field];
806
+ if (spec.type !== "number-record") {
807
+ throw new Error(
808
+ `internal: nested set requested for non-record field "${field}"`
809
+ );
810
+ }
811
+ const parsed = parseValue(field, rawValue);
812
+ const current = cfg[field] ?? {};
813
+ next[field] = {
814
+ ...current,
815
+ [subKey]: parsed
816
+ };
817
+ } else {
818
+ next[field] = parseValue(field, rawValue);
819
+ }
820
+ return validateConfig(next);
821
+ }
822
+ function getConfigValue(cfg, rawKey) {
823
+ const { field, subKey } = parseKey(rawKey);
824
+ const value = cfg[field];
825
+ if (subKey === void 0) {
826
+ return value;
827
+ }
828
+ if (typeof value !== "object" || value === null) {
829
+ return void 0;
830
+ }
831
+ return value[subKey];
832
+ }
833
+ function maskSecret(value) {
834
+ if (value.length <= 10) {
835
+ return "***";
836
+ }
837
+ return `${value.slice(0, 4)}\u2026${value.slice(-6)}`;
838
+ }
839
+ function formatValue(value, secret, reveal) {
840
+ if (value === void 0) {
841
+ return "(not set)";
842
+ }
843
+ if (secret && !reveal && typeof value === "string") {
844
+ return maskSecret(value);
845
+ }
846
+ if (typeof value === "string") {
847
+ return value;
848
+ }
849
+ return JSON.stringify(value);
850
+ }
851
+ function renderConfigList(cfg, options = {}) {
852
+ const reveal = options.showSecrets ?? false;
853
+ const keys = Object.keys(cfg);
854
+ const presentKeys = keys.filter((k) => cfg[k] !== void 0);
855
+ if (presentKeys.length === 0) {
856
+ return chalk2.gray("(no values set)");
857
+ }
858
+ const table = new Table2({
859
+ head: [chalk2.bold.cyan("Key"), chalk2.bold.cyan("Value")],
860
+ style: { head: [], border: [] },
861
+ wordWrap: true,
862
+ colWidths: [32, 60]
863
+ });
864
+ for (const k of presentKeys) {
865
+ const spec = FIELDS[k];
866
+ const secret = spec?.secret ?? false;
867
+ const formatted = formatValue(cfg[k], secret, reveal);
868
+ const valueCell = secret && !reveal ? chalk2.dim(formatted) : formatted;
869
+ table.push([chalk2.cyan(k), valueCell]);
870
+ }
871
+ return table.toString();
872
+ }
873
+ function formatFieldType(spec) {
874
+ switch (spec.type) {
875
+ case "enum":
876
+ return `enum (${(spec.values ?? []).join(" | ")})`;
877
+ case "string-array":
878
+ return "string[]";
879
+ case "number-record":
880
+ return "Record<string, number>";
881
+ default:
882
+ return spec.type;
883
+ }
884
+ }
885
+ function formatFieldNotes(spec) {
886
+ const tags = [];
887
+ if (spec.secret) {
888
+ tags.push("secret");
889
+ }
890
+ if (spec.nested) {
891
+ tags.push("nested");
892
+ }
893
+ if (spec.settable === false) {
894
+ tags.push("edit file directly");
895
+ }
896
+ return tags.length > 0 ? tags.join(", ") : "";
897
+ }
898
+ function formatFieldDefault(spec) {
899
+ if (spec.default === void 0) {
900
+ return "";
901
+ }
902
+ return typeof spec.default === "string" ? spec.default : JSON.stringify(spec.default);
903
+ }
904
+ function renderConfigKeys() {
905
+ const table = new Table2({
906
+ head: [
907
+ chalk2.bold.cyan("Key"),
908
+ chalk2.bold.cyan("Type"),
909
+ chalk2.bold.cyan("Default"),
910
+ chalk2.bold.cyan("Notes"),
911
+ chalk2.bold.cyan("Description")
912
+ ],
913
+ style: { head: [], border: [] },
914
+ wordWrap: true,
915
+ colWidths: [30, 26, 16, 22, 48]
916
+ });
917
+ for (const [key, spec] of Object.entries(FIELDS)) {
918
+ table.push([
919
+ chalk2.cyan(key),
920
+ formatFieldType(spec),
921
+ chalk2.dim(formatFieldDefault(spec)),
922
+ chalk2.yellow(formatFieldNotes(spec)),
923
+ chalk2.dim(spec.description ?? "")
924
+ ]);
925
+ }
926
+ return table.toString();
927
+ }
928
+ function renderSingleValue(value, field, reveal) {
929
+ const spec = FIELDS[field];
930
+ return formatValue(value, spec?.secret ?? false, reveal);
931
+ }
932
+ async function readGlobalConfig() {
933
+ try {
934
+ const content = await readFile2(globalConfigPath(), "utf-8");
935
+ return validateConfig(JSON.parse(content));
936
+ } catch (err) {
937
+ if (err.code === "ENOENT") {
938
+ return {};
939
+ }
940
+ throw err;
941
+ }
942
+ }
943
+ async function writeGlobalConfig(cfg) {
944
+ const path = globalConfigPath();
945
+ await mkdir(dirname(path), { recursive: true });
946
+ await writeFile(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
947
+ }
948
+
404
949
  // src/index.ts
405
950
  function parseLimit(raw) {
406
951
  if (raw === void 0) {
@@ -428,12 +973,32 @@ var VALID_PRESETS = [
428
973
  function isIsoDate(value) {
429
974
  return /^\d{4}-\d{2}-\d{2}$/.test(value);
430
975
  }
976
+ function isYearMonth(value) {
977
+ return /^\d{4}-(0[1-9]|1[0-2])$/.test(value);
978
+ }
979
+ function resolveYearMonth(value) {
980
+ const year = Number.parseInt(value.slice(0, 4), 10);
981
+ const month = Number.parseInt(value.slice(5, 7), 10);
982
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
983
+ const pad = (n) => String(n).padStart(2, "0");
984
+ return {
985
+ from: `${year}-${pad(month)}-01`,
986
+ to: `${year}-${pad(month)}-${pad(lastDay)}`
987
+ };
988
+ }
431
989
  function resolveDate(input) {
432
990
  if (input === "today") {
433
991
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
434
992
  }
993
+ if (input === "yesterday") {
994
+ const d = /* @__PURE__ */ new Date();
995
+ d.setUTCDate(d.getUTCDate() - 1);
996
+ return d.toISOString().slice(0, 10);
997
+ }
435
998
  if (!isIsoDate(input)) {
436
- throw new Error(`invalid date "${input}" \u2014 expected YYYY-MM-DD or "today"`);
999
+ throw new Error(
1000
+ `invalid date "${input}" \u2014 expected YYYY-MM-DD, "today", or "yesterday"`
1001
+ );
437
1002
  }
438
1003
  return input;
439
1004
  }
@@ -462,9 +1027,15 @@ async function run(dateArg, options) {
462
1027
  let from = options.from ? resolveDate(options.from) : void 0;
463
1028
  let to = options.to ? resolveDate(options.to) : void 0;
464
1029
  if (dateArg) {
465
- const day = resolveDate(dateArg);
466
- from = day;
467
- to = day;
1030
+ if (isYearMonth(dateArg)) {
1031
+ const range = resolveYearMonth(dateArg);
1032
+ from = range.from;
1033
+ to = range.to;
1034
+ } else {
1035
+ const day = resolveDate(dateArg);
1036
+ from = day;
1037
+ to = day;
1038
+ }
468
1039
  }
469
1040
  const limit = parseLimit(options.limit);
470
1041
  const perRepoEntries = await Promise.all(
@@ -490,8 +1061,227 @@ async function run(dateArg, options) {
490
1061
  const rendered = options.groupByDay ? renderTableGroupedByDay(display, ticketColumnLabel) : renderTable(display, ticketColumnLabel);
491
1062
  process.stdout.write(rendered + "\n");
492
1063
  }
1064
+ function resolveTogglSyncDates(dateArg, options) {
1065
+ if (dateArg && (options.from || options.to)) {
1066
+ throw new Error(
1067
+ "cannot combine the positional [date] with --from / --to \u2014 use one or the other"
1068
+ );
1069
+ }
1070
+ if (options.from || options.to) {
1071
+ if (!options.from || !options.to) {
1072
+ throw new Error("--from and --to must both be provided");
1073
+ }
1074
+ return enumerateDates(resolveDate(options.from), resolveDate(options.to));
1075
+ }
1076
+ return [resolveDate(dateArg ?? "today")];
1077
+ }
1078
+ function resolveTogglAuth(config) {
1079
+ const token = process.env.TOGGL_API_TOKEN ?? config.togglApiToken;
1080
+ if (!token) {
1081
+ return null;
1082
+ }
1083
+ return { apiToken: token };
1084
+ }
1085
+ function formatHHMM(iso) {
1086
+ const d = new Date(iso);
1087
+ const pad = (n) => String(n).padStart(2, "0");
1088
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1089
+ }
1090
+ function renderTogglPlan(plan, date) {
1091
+ const newCount = plan.filter((p) => !p.alreadySynced).length;
1092
+ const skipCount = plan.length - newCount;
1093
+ const lines = [];
1094
+ lines.push(
1095
+ chalk3.bold(`Toggl sync \u2014 ${date}`) + chalk3.dim(
1096
+ ` (${newCount} new, ${skipCount} already synced, ${plan.length} total)`
1097
+ )
1098
+ );
1099
+ lines.push("");
1100
+ for (const entry of plan) {
1101
+ const start = formatHHMM(entry.start);
1102
+ const endIso = new Date(
1103
+ new Date(entry.start).getTime() + entry.durationSeconds * 1e3
1104
+ ).toISOString();
1105
+ const end = formatHHMM(endIso);
1106
+ const status = entry.alreadySynced ? chalk3.gray("skip") : chalk3.green("new ");
1107
+ const project = entry.projectId === null ? chalk3.yellow("(no project)") : chalk3.cyan(
1108
+ `[project ${entry.projectId}${entry.matchedTicketPrefix ? ` \u2190 ${entry.matchedTicketPrefix}` : " (default)"}]`
1109
+ );
1110
+ const commitNote = entry.commitCount > 1 ? chalk3.dim(` (${entry.commitCount} commits)`) : "";
1111
+ lines.push(
1112
+ ` ${status} ${chalk3.dim(`${start}\u2013${end}`)} ${entry.description} ${project}${commitNote}`
1113
+ );
1114
+ }
1115
+ return lines.join("\n");
1116
+ }
1117
+ async function syncOneDay(date, ctx) {
1118
+ const result = {
1119
+ date,
1120
+ planned: 0,
1121
+ pushed: 0,
1122
+ skipped: 0,
1123
+ failures: 0
1124
+ };
1125
+ try {
1126
+ const perRepoCommits = await Promise.all(
1127
+ ctx.repos.map(async (cwd) => {
1128
+ const author = ctx.cliAuthor ?? ctx.configAuthor ?? await getGitUserName(cwd);
1129
+ return getCommits({
1130
+ author,
1131
+ from: date,
1132
+ to: date,
1133
+ cwd,
1134
+ pattern: ctx.pattern
1135
+ });
1136
+ })
1137
+ );
1138
+ const commits = perRepoCommits.flat();
1139
+ const existingSyncedShas = ctx.auth ? await fetchSyncedShas(ctx.auth, date) : /* @__PURE__ */ new Set();
1140
+ const plan = planEntries(commits, {
1141
+ date,
1142
+ defaultDurationMinutes: ctx.defaultDurationMinutes,
1143
+ dayStartHour: ctx.dayStartHour,
1144
+ projects: ctx.projects,
1145
+ defaultProjectId: ctx.defaultProjectId,
1146
+ existingSyncedShas,
1147
+ oneEntryPerTicket: ctx.oneEntryPerTicket,
1148
+ ignoreSubjectPattern: ctx.ignoreSubjectPattern
1149
+ });
1150
+ result.planned = plan.length;
1151
+ if (plan.length === 0) {
1152
+ process.stdout.write(
1153
+ chalk3.gray(`No commits to sync for ${date}.`) + "\n"
1154
+ );
1155
+ return result;
1156
+ }
1157
+ process.stdout.write(renderTogglPlan(plan, date) + "\n");
1158
+ if (ctx.dryRun) {
1159
+ return result;
1160
+ }
1161
+ if (!ctx.auth || ctx.workspaceId === null) {
1162
+ throw new Error("internal: auth/workspaceId resolved to null");
1163
+ }
1164
+ const pushResult = await pushEntries(ctx.auth, ctx.workspaceId, plan);
1165
+ result.pushed = pushResult.pushed;
1166
+ result.skipped = pushResult.skipped;
1167
+ result.failures = pushResult.failures.length;
1168
+ process.stdout.write(
1169
+ "\n" + chalk3.bold(
1170
+ ` ${date}: pushed ${pushResult.pushed}, skipped ${pushResult.skipped}`
1171
+ ) + (pushResult.failures.length > 0 ? chalk3.red(`, ${pushResult.failures.length} failed`) : "") + "\n"
1172
+ );
1173
+ for (const failure of pushResult.failures) {
1174
+ process.stderr.write(
1175
+ chalk3.red(
1176
+ ` ${date} failed: ${failure.plan.shortShas.join(",")} \u2014 ${failure.reason}`
1177
+ ) + "\n"
1178
+ );
1179
+ }
1180
+ } catch (err) {
1181
+ const message = err instanceof Error ? err.message : String(err);
1182
+ result.error = message;
1183
+ process.stderr.write(chalk3.red(` ${date}: ${message}`) + "\n");
1184
+ }
1185
+ return result;
1186
+ }
1187
+ async function runTogglSync(dateArg, options) {
1188
+ const config = await loadConfig(process.cwd());
1189
+ const dates = resolveTogglSyncDates(dateArg, options);
1190
+ const workspaceId = (options.workspace ? Number.parseInt(options.workspace, 10) : config.togglWorkspaceId) ?? null;
1191
+ if (!options.dryRun && workspaceId === null) {
1192
+ throw new Error(
1193
+ "togglWorkspaceId is not set \u2014 add it to your config or pass --workspace <id>"
1194
+ );
1195
+ }
1196
+ const auth = resolveTogglAuth(config);
1197
+ if (!options.dryRun && !auth) {
1198
+ throw new Error(
1199
+ "no Toggl API token \u2014 set TOGGL_API_TOKEN or `togglApiToken` in your config"
1200
+ );
1201
+ }
1202
+ const format = config.format ?? "jira";
1203
+ const pattern = getTicketPattern(format, config.customPattern);
1204
+ const configRepos = config.defaultRepos?.map(expandPath) ?? [];
1205
+ const repos = options.repo && options.repo.length > 0 ? options.repo : configRepos.length > 0 ? configRepos : [process.cwd()];
1206
+ const defaultDurationMinutes = config.togglDefaultDurationMinutes ?? TOGGL_DEFAULTS.durationMinutes;
1207
+ const dayStartHour = config.togglDayStartHour ?? TOGGL_DEFAULTS.dayStartHour;
1208
+ const oneEntryPerTicket = config.togglOneEntryPerTicket ?? TOGGL_DEFAULTS.oneEntryPerTicket;
1209
+ const ignoreSubjectSource = config.togglIgnoreSubjectPattern ?? TOGGL_DEFAULTS.ignoreSubjectPattern;
1210
+ const ignoreSubjectPattern = ignoreSubjectSource ? new RegExp(ignoreSubjectSource, "i") : void 0;
1211
+ const ctx = {
1212
+ auth,
1213
+ workspaceId,
1214
+ dryRun: options.dryRun ?? false,
1215
+ repos,
1216
+ cliAuthor: options.author,
1217
+ configAuthor: config.defaultAuthor,
1218
+ pattern,
1219
+ defaultDurationMinutes,
1220
+ dayStartHour,
1221
+ oneEntryPerTicket,
1222
+ ignoreSubjectPattern,
1223
+ projects: config.togglProjects ?? {},
1224
+ defaultProjectId: config.togglDefaultProjectId
1225
+ };
1226
+ const results = [];
1227
+ for (const date of dates) {
1228
+ results.push(await syncOneDay(date, ctx));
1229
+ }
1230
+ if (options.dryRun) {
1231
+ process.stdout.write("\n" + chalk3.dim("(dry-run \u2014 nothing pushed)") + "\n");
1232
+ return;
1233
+ }
1234
+ if (dates.length > 1) {
1235
+ const totalPushed = results.reduce((n, r) => n + r.pushed, 0);
1236
+ const totalSkipped = results.reduce((n, r) => n + r.skipped, 0);
1237
+ const totalFailures = results.reduce((n, r) => n + r.failures, 0);
1238
+ const erroredDays = results.filter((r) => r.error !== void 0).length;
1239
+ process.stdout.write(
1240
+ "\n" + chalk3.bold(
1241
+ `Total across ${dates.length} days: pushed ${totalPushed}, skipped ${totalSkipped}`
1242
+ ) + (totalFailures > 0 ? chalk3.red(`, ${totalFailures} failed`) : "") + (erroredDays > 0 ? chalk3.red(`, ${erroredDays} day(s) errored`) : "") + "\n"
1243
+ );
1244
+ }
1245
+ const anyFailed = results.some((r) => r.error !== void 0 || r.failures > 0);
1246
+ if (anyFailed) {
1247
+ process.exitCode = 1;
1248
+ }
1249
+ }
493
1250
  var program = new Command();
494
- program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.2.0", "-V, --version", "output the version number").argument("[date]", 'a YYYY-MM-DD date or "today"; omit to show all history').option("--from <date>", 'start date (YYYY-MM-DD or "today")').option("--to <date>", 'end date (YYYY-MM-DD or "today")').option(
1251
+ program.enablePositionalOptions();
1252
+ var isoArt = String.raw` ___ ___ ___
1253
+ /\__\ /\ \ ___ /\ \
1254
+ /:/ _/_ /::\ \ /\ \ /::\ \
1255
+ /:/ /\__\ /:/\:\ \ \:\ \ /:/\:\ \
1256
+ /:/ /:/ _/_ /:/ \:\__\ /::\__\ /:/ \:\__\
1257
+ /:/_/:/ /\__\ /:/__/ \:|__| __/:/\/__/ /:/__/ \:|__|
1258
+ \:\/:/ /:/ / \:\ \ /:/ / /\/:/ / \:\ \ /:/ /
1259
+ \::/_/:/ / \:\ /:/ / \::/__/ \:\ /:/ /
1260
+ \:\/:/ / \:\/:/ / \:\__\ \:\/:/ /
1261
+ \::/ / \::/__/ \/__/ \::/__/
1262
+ \/__/ ~~ ~~`;
1263
+ function gradientPaint(text, from, to) {
1264
+ const lines = text.split("\n");
1265
+ const maxY = Math.max(lines.length - 1, 1);
1266
+ const maxX = Math.max(1, ...lines.map((l) => l.length - 1));
1267
+ return lines.map(
1268
+ (line, y) => Array.from(line, (ch, x) => {
1269
+ if (ch === " ") {
1270
+ return ch;
1271
+ }
1272
+ const t = (y / maxY + x / maxX) / 2;
1273
+ const r = Math.round(from[0] + (to[0] - from[0]) * t);
1274
+ const g = Math.round(from[1] + (to[1] - from[1]) * t);
1275
+ const b = Math.round(from[2] + (to[2] - from[2]) * t);
1276
+ return chalk3.rgb(r, g, b)(ch);
1277
+ }).join("")
1278
+ ).join("\n");
1279
+ }
1280
+ var banner = gradientPaint(isoArt, [34, 211, 238], [217, 70, 239]) + " " + chalk3.rgb(217, 70, 239)(`v${"0.4.0"}`) + "\n";
1281
+ program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.4.0", "-V, --version", "output the version number").addHelpText("before", banner).argument(
1282
+ "[date]",
1283
+ 'a YYYY-MM-DD date, YYYY-MM month, "today", or "yesterday"'
1284
+ ).option("--all", "show all history (no date filter)").option("--from <date>", 'start date (YYYY-MM-DD, "today", or "yesterday")').option("--to <date>", 'end date (YYYY-MM-DD, "today", or "yesterday")').option(
495
1285
  "--author <name>",
496
1286
  "override the git author (defaults to git config user.name, then defaultAuthor in config)"
497
1287
  ).option(
@@ -514,7 +1304,7 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
514
1304
  "group rows under a bold date heading per day (time-only in row)"
515
1305
  ).option("--json", "emit a JSON array of commit entries instead of the table").action(async (dateArg, options) => {
516
1306
  if (shouldDisableColor(options)) {
517
- chalk2.level = 0;
1307
+ chalk3.level = 0;
518
1308
  }
519
1309
  try {
520
1310
  await run(dateArg, options);
@@ -524,4 +1314,148 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
524
1314
  process.exitCode = 1;
525
1315
  }
526
1316
  });
1317
+ var togglCmd = program.command("toggl").description("Toggl integration commands").action(() => {
1318
+ togglCmd.help();
1319
+ });
1320
+ togglCmd.addHelpText(
1321
+ "after",
1322
+ `
1323
+ Examples:
1324
+ $ wdid toggl sync push today's commits
1325
+ $ wdid toggl sync yesterday push yesterday's commits
1326
+ $ wdid toggl sync 2026-05-27 push a specific day
1327
+ $ wdid toggl sync today --dry-run preview without pushing
1328
+ $ wdid toggl sync --workspace 12345 today override the workspace
1329
+ $ wdid toggl sync --from 2026-05-25 --to 2026-05-27 push a multi-day range`
1330
+ );
1331
+ togglCmd.command("sync [date]").description(
1332
+ "push the day's commits as Toggl time entries. Default: today. Pass --from/--to for a range."
1333
+ ).option("--dry-run", "preview the plan without pushing").option(
1334
+ "--workspace <id>",
1335
+ "override the configured togglWorkspaceId for this run"
1336
+ ).option(
1337
+ "--repo <path...>",
1338
+ "one or more repo paths to query (overrides defaultRepos in config)"
1339
+ ).option(
1340
+ "--author <name>",
1341
+ "override the git author (defaults to git config user.name, then defaultAuthor in config)"
1342
+ ).option(
1343
+ "--from <date>",
1344
+ "start of a multi-day range (inclusive). Use with --to. Mutually exclusive with [date]."
1345
+ ).option(
1346
+ "--to <date>",
1347
+ "end of a multi-day range (inclusive). Use with --from. Mutually exclusive with [date]."
1348
+ ).action(async (dateArg, options) => {
1349
+ try {
1350
+ await runTogglSync(dateArg, options);
1351
+ } catch (err) {
1352
+ const message = err instanceof Error ? err.message : String(err);
1353
+ process.stderr.write(renderError(message) + "\n");
1354
+ process.exitCode = 1;
1355
+ }
1356
+ });
1357
+ async function runConfigSet(key, value) {
1358
+ const current = await readGlobalConfig();
1359
+ const next = setConfigValue(current, key, value);
1360
+ await writeGlobalConfig(next);
1361
+ const { field } = parseKey(key);
1362
+ const isSecret = FIELDS[field]?.secret ?? false;
1363
+ process.stdout.write(
1364
+ chalk3.green(`set ${key}`) + (isSecret ? chalk3.dim(" (secret \u2014 value hidden)") : "") + "\n"
1365
+ );
1366
+ }
1367
+ async function runConfigGet(key, options) {
1368
+ const cfg = await readGlobalConfig();
1369
+ const value = getConfigValue(cfg, key);
1370
+ const { field } = parseKey(key);
1371
+ process.stdout.write(
1372
+ renderSingleValue(value, field, options.showSecrets ?? false) + "\n"
1373
+ );
1374
+ if (value === void 0) {
1375
+ process.exitCode = 1;
1376
+ }
1377
+ }
1378
+ async function runConfigList(options) {
1379
+ const cfg = await readGlobalConfig();
1380
+ process.stdout.write(
1381
+ renderConfigList(cfg, { showSecrets: options.showSecrets }) + "\n"
1382
+ );
1383
+ }
1384
+ function runConfigPath() {
1385
+ process.stdout.write(globalConfigPath() + "\n");
1386
+ }
1387
+ var configCmd = program.command("config").description("Read and write the global wdid config file").action(() => {
1388
+ configCmd.help();
1389
+ });
1390
+ configCmd.addHelpText(
1391
+ "after",
1392
+ `
1393
+ Examples:
1394
+ $ wdid config keys list every available key
1395
+ $ wdid config set togglApiToken tok_abc123 set a scalar field
1396
+ $ wdid config set togglWorkspaceId 12345 numbers are parsed
1397
+ $ wdid config set togglOneEntryPerTicket false booleans take true/false
1398
+ $ wdid config set togglProjects.ABC- 67890 set a nested record entry
1399
+ $ wdid config get togglApiToken secrets are masked
1400
+ $ wdid config get togglApiToken --show-secrets reveal the secret
1401
+ $ wdid config list show all set fields
1402
+ $ wdid config path print the config file path`
1403
+ );
1404
+ configCmd.command("set <key> <value>").description(
1405
+ "Set a value in the global config (~/.config/wdid/config.json). Use dotted access for nested fields (togglProjects.ABC-)."
1406
+ ).action(async (key, value) => {
1407
+ try {
1408
+ await runConfigSet(key, value);
1409
+ } catch (err) {
1410
+ const message = err instanceof Error ? err.message : String(err);
1411
+ process.stderr.write(renderError(message) + "\n");
1412
+ process.exitCode = 1;
1413
+ }
1414
+ });
1415
+ configCmd.command("get <key>").description(
1416
+ "Print a single config value. Secrets are masked unless --show-secrets is set."
1417
+ ).option("--show-secrets", "reveal secret values in full").action(async (key, options) => {
1418
+ try {
1419
+ await runConfigGet(key, options);
1420
+ } catch (err) {
1421
+ const message = err instanceof Error ? err.message : String(err);
1422
+ process.stderr.write(renderError(message) + "\n");
1423
+ process.exitCode = 1;
1424
+ }
1425
+ });
1426
+ configCmd.command("list").description(
1427
+ "Print all configured values. Secrets are masked unless --show-secrets is set."
1428
+ ).option("--show-secrets", "reveal secret values in full").action(async (options) => {
1429
+ try {
1430
+ await runConfigList(options);
1431
+ } catch (err) {
1432
+ const message = err instanceof Error ? err.message : String(err);
1433
+ process.stderr.write(renderError(message) + "\n");
1434
+ process.exitCode = 1;
1435
+ }
1436
+ });
1437
+ configCmd.command("path").description(
1438
+ "Print the absolute path to the global config file (honors XDG_CONFIG_HOME)."
1439
+ ).action(() => {
1440
+ runConfigPath();
1441
+ });
1442
+ configCmd.command("keys").description("List every config key with its type, default, and description.").action(() => {
1443
+ process.stdout.write(renderConfigKeys() + "\n");
1444
+ });
1445
+ program.addHelpText(
1446
+ "after",
1447
+ `
1448
+ Examples:
1449
+ $ wdid today commits from today
1450
+ $ wdid yesterday commits from yesterday
1451
+ $ wdid 2026-05-27 commits from a specific day
1452
+ $ wdid 2026-05 commits from a whole month
1453
+ $ wdid --from 2026-05-01 --to 2026-05-07 a date range
1454
+ $ wdid --all all history, no filter
1455
+ $ wdid toggl sync today push today's commits to Toggl
1456
+ $ wdid config list show global config`
1457
+ );
1458
+ if (process.argv.length <= 2) {
1459
+ program.help();
1460
+ }
527
1461
  program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drazenbebic/wdid",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "What did I do? — summarize your git activity per day as a tidy table, grouped by JIRA ticket.",
5
5
  "keywords": [
6
6
  "git",