ticketlens 0.21.9 → 0.21.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -260,8 +260,8 @@ Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedi
260
260
  ### Compliance Ledger
261
261
 
262
262
  ```bash
263
- ticketlens ledger # View the local compliance audit ledger [Pro]
264
- ticketlens ledger --plain # Plain markdown output
263
+ ticketlens ledger # View the local compliance audit ledger, JSON + signature [Pro]
264
+ ticketlens ledger --format=csv # Flat CSV export, no signature [Pro]
265
265
  ```
266
266
 
267
267
  Displays the append-only local ledger of all compliance checks run on this machine. Useful for SOC 2 / HIPAA audit trails. Requires a Pro license.
@@ -365,9 +365,11 @@ Cache locations:
365
365
  ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
366
366
  ticketlens schedule --stop # Cancel the scheduled digest
367
367
  ticketlens schedule --status # Show current schedule
368
- ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
368
+ ticketlens schedule --local --time=07:00 --save=./triage.txt # Local-only cron/LaunchAgent, no Console auth [Pro]
369
369
  ```
370
370
 
371
+ Not logged in? `ticketlens schedule` falls back to local-only mode automatically — same as passing `--local` explicitly.
372
+
371
373
  Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
372
374
 
373
375
  ---
@@ -595,6 +597,7 @@ ticketlens CNV1-2 --template=quick # Apply quick template (meta + 2
595
597
  ticketlens CNV1-2 --template=code-review # Apply code-review template (meta + desc + linked + code refs)
596
598
  ticketlens CNV1-2 --template=full # Apply full template (all sections, default)
597
599
  ticketlens CNV1-2 --template=my-team-template # Apply a custom team template [Team]
600
+ ticketlens CNV1-2 --budget=8000 # Trim brief to fit a token budget [Pro]
598
601
  ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
599
602
 
600
603
  # Pipe plain output to clipboard, LLM, or file
@@ -675,7 +678,7 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
675
678
  ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
676
679
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
677
680
  ticketlens schedule --status # Show current schedule [Pro]
678
- ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
681
+ ticketlens schedule --local --time=07:00 --save=./triage.txt # Local-only cron/LaunchAgent, no Console auth [Pro]
679
682
 
680
683
  # ── History ───────────────────────────────────────────────────────────────────
681
684
  ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
@@ -24,6 +24,7 @@ import {
24
24
  printProfilesHelp, printScheduleHelp,
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
26
  printReviewHelp, printStandupHelp, printUpdateSkillHelp,
27
+ printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
27
28
  printCollisionsHelp, printStatsHelp,
28
29
  printCloudKeysHelp,
29
30
  printNoteHelp, printRecallHelp,
@@ -394,10 +395,28 @@ switch (command) {
394
395
  break;
395
396
  }
396
397
 
397
- const { runScheduleWizard } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
398
+ const cliTokenForSchedule = readCliToken();
399
+ const { shouldUseLocalSchedule, parseLocalScheduleFlags, runScheduleLocal, runScheduleWizard } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
400
+
401
+ if (shouldUseLocalSchedule({ cmdArgs, cliToken: cliTokenForSchedule })) {
402
+ const { time, outputFile } = parseLocalScheduleFlags(cmdArgs);
403
+ if (!time || !outputFile) {
404
+ process.stderr.write(` Local scheduling requires --time and --save.\n`);
405
+ process.stderr.write(` Usage: ticketlens schedule --local --time=HH:MM --save=FILE\n`);
406
+ if (!cliTokenForSchedule) {
407
+ process.stderr.write(` Or run \`ticketlens login\` first to use the Console-backed wizard.\n`);
408
+ }
409
+ process.exitCode = 1;
410
+ break;
411
+ }
412
+ const localResult = await runScheduleLocal({ answers: { time, outputFile } });
413
+ if (!localResult.ok) { process.exitCode = 1; }
414
+ break;
415
+ }
416
+
398
417
  const { promptScheduleAnswers } = await import('../skills/jtb/scripts/lib/prompt-helpers.mjs');
399
418
  const answers = await promptScheduleAnswers(cmdArgs);
400
- const result = await runScheduleWizard({ answers, cliToken: readCliToken() });
419
+ const result = await runScheduleWizard({ answers, cliToken: cliTokenForSchedule });
401
420
  if (!result.ok) { process.exitCode = 1; break; }
402
421
 
403
422
  const s = createStyler({ isTTY: process.stdout.isTTY });
@@ -410,6 +429,7 @@ switch (command) {
410
429
  }
411
430
 
412
431
  case 'install-hooks':
432
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInstallHooksHelp(); break; }
413
433
  runFetch(['install-hooks', ...cmdArgs]).catch(err => {
414
434
  process.stderr.write(`Error: ${err.message}\n`);
415
435
  process.exitCode = 1;
@@ -417,6 +437,7 @@ switch (command) {
417
437
  break;
418
438
 
419
439
  case 'pr':
440
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printPrHelp(); break; }
420
441
  runFetch(['pr', ...cmdArgs]).catch(err => {
421
442
  process.stderr.write(`Error: ${err.message}\n`);
422
443
  process.exitCode = 1;
@@ -440,6 +461,7 @@ switch (command) {
440
461
  break;
441
462
 
442
463
  case 'ledger':
464
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLedgerHelp(); break; }
443
465
  runFetch(['ledger', ...cmdArgs]).catch(err => {
444
466
  process.stderr.write(`Error: ${err.message}\n`);
445
467
  process.exitCode = 1;
@@ -447,6 +469,7 @@ switch (command) {
447
469
  break;
448
470
 
449
471
  case 'compliance':
472
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printComplianceHelp(); break; }
450
473
  runFetch(['compliance', ...cmdArgs]).catch(err => {
451
474
  process.stderr.write(`Error: ${err.message}\n`);
452
475
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.21.9",
3
+ "version": "0.21.10",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,10 @@ Fetches a Jira ticket and produces a structured brief with code references, then
44
44
  /jtb stats --format=json # JSON output for scripting
45
45
  /jtb collisions # show branch collisions with teammates (Team)
46
46
  /jtb collisions --json # machine-readable output
47
+ /jtb review # code-review context brief from current branch
48
+ /jtb review --branch=main # compare against a specific branch
49
+ /jtb standup # standup summary from git log (last 24h)
50
+ /jtb standup --format=pr # PR body instead of standup bullets
47
51
  /jtb cloud-keys list # list configured AI provider keys (Pro)
48
52
  /jtb cloud-keys add groq gsk_xxxx # add Groq key (free tier — console.groq.com)
49
53
  /jtb cloud-keys add anthropic sk-ant-x # add Anthropic key
@@ -52,7 +56,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
52
56
  /jtb cloud-keys remove groq # remove a provider
53
57
  /jtb cloud-keys priority groq 1 # set provider priority (lower = tried first)
54
58
  /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
55
- /jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
59
+ /jtb note add --title="gotcha text" --ticket=PROD-1234 # save a Recall note (Pro, body from stdin)
56
60
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
57
61
  /jtb recall sync # retry any notes stuck in the local queue (Pro)
58
62
  /jtb recall settings # show effective retry-queue settings, fetched live (Pro)
@@ -493,6 +493,22 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
493
493
 
494
494
  // Early dispatch for non-ticket subcommands
495
495
  if (args[0] === 'install-hooks') {
496
+ if (args.includes('--uninstall')) {
497
+ const { uninstallHook } = await import('./lib/hook-installer.mjs');
498
+ try {
499
+ const result = await uninstallHook({ cwd: process.cwd() });
500
+ if (result.skipped) {
501
+ process.stderr.write(` Hook uninstall skipped: ${result.reason}\n`);
502
+ } else {
503
+ process.stdout.write(` Hook removed: ${result.path}\n`);
504
+ }
505
+ } catch (err) {
506
+ process.stderr.write(` Error removing hook: ${err.message}\n`);
507
+ process.exitCode = 1;
508
+ }
509
+ return;
510
+ }
511
+
496
512
  const { installHook } = await import('./lib/hook-installer.mjs');
497
513
  try {
498
514
  const result = await installHook({ cwd: process.cwd() });
@@ -43,23 +43,30 @@ export function printHelp({ stream = process.stdout } = {}) {
43
43
  ` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
44
44
  ` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
45
45
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
46
+ ` ${s.brand('ticketlens')} install-hooks ${s.dim('[--uninstall]')} Git pre-push compliance gate`,
47
+ ` ${s.brand('ticketlens')} pr ${s.dim('<TICKET-KEY>')} Assemble a PR description from ticket context`,
48
+ ` ${s.brand('ticketlens')} ledger ${s.dim('[--format=json|csv]')} Export your signed usage ledger ${s.dim('[Pro]')}`,
46
49
  ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
47
50
  ` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
48
51
  ` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
49
52
  ` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
50
53
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
54
+ ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Pro]')}`,
55
+ ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Pro]')}`,
51
56
  '',
52
57
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
53
58
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
54
59
  ` ${s.brand('ticketlens')} license Show license status`,
55
60
  ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
56
- ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
61
+ ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status|--local]')} Manage digest schedule ${s.dim('[Pro]')}`,
57
62
  ` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
58
63
  ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
59
64
  '',
60
65
  ` ${s.bold('GLOBAL OPTIONS')}`,
61
66
  '',
62
67
  ` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
68
+ ` ${s.brand('-v')}, ${s.brand('--version')} Show version and exit ${s.dim('(same as ticketlens version)')}`,
69
+ ` ${s.brand('clear')} Alias for ${s.brand('cache clear')}`,
63
70
  '',
64
71
  ` ${s.bold('FETCH OPTIONS')}`,
65
72
  '',
@@ -78,6 +85,7 @@ export function printHelp({ stream = process.stdout } = {}) {
78
85
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
79
86
  ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
80
87
  ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
88
+ ` ${s.brand('--budget')}=${s.dim('N')} Trim brief to fit a token budget ${s.dim('[Pro]')}`,
81
89
  '',
82
90
  ` ${s.bold('TRIAGE OPTIONS')}`,
83
91
  '',
@@ -100,6 +108,7 @@ export function printHelp({ stream = process.stdout } = {}) {
100
108
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
101
109
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
102
110
  ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
111
+ ` ${s.brand('--styled')} Force ANSI-styled table output`,
103
112
  '',
104
113
  ` ${s.bold('EXAMPLES')}`,
105
114
  '',
@@ -444,15 +453,23 @@ export function printScheduleHelp({ stream = process.stdout } = {}) {
444
453
  const s = createStyler({ isTTY: stream.isTTY });
445
454
  const lines = [
446
455
  '',
447
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('schedule')} ${s.dim('[--stop|--status]')} ${s.dim('[Pro]')}`,
456
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('schedule')} ${s.dim('[--stop|--status|--local]')} ${s.dim('[Pro]')}`,
448
457
  '',
449
458
  ` Set up a recurring digest email with your triage results. ${s.dim('[Pro]')}`,
450
459
  ` Runs an interactive wizard to configure day, time, and timezone.`,
460
+ ` Without Console login, falls back to local-only scheduling automatically —`,
461
+ ` no interactive wizard, no digest email, just a cron/LaunchAgent entry that`,
462
+ ` writes triage output to a file.`,
451
463
  '',
452
464
  ` ${s.bold('OPTIONS')}`,
453
465
  '',
454
466
  ` ${s.brand('--stop')} Cancel the active digest schedule`,
455
467
  ` ${s.brand('--status')} Show current schedule configuration`,
468
+ ` ${s.brand('--time')}=${s.dim('HH:MM')} Pre-fill delivery/run time ${s.dim('(skips the interactive prompt)')}`,
469
+ ` ${s.brand('--email')}=${s.dim('ADDR')} Pre-fill delivery email ${s.dim('(Console-backed wizard only)')}`,
470
+ ` ${s.brand('--timezone')}=${s.dim('TZ')} Pre-fill timezone ${s.dim('(Console-backed wizard only)')}`,
471
+ ` ${s.brand('--local')} Local-only cron/LaunchAgent, no Console auth ${s.dim('(auto-detected when logged out)')}`,
472
+ ` ${s.brand('--save')}=${s.dim('FILE')} Output file for local mode ${s.dim('(required with --local)')}`,
456
473
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
457
474
  '',
458
475
  ` ${s.bold('EXAMPLES')}`,
@@ -460,6 +477,7 @@ export function printScheduleHelp({ stream = process.stdout } = {}) {
460
477
  ` ${s.dim('$')} ticketlens schedule`,
461
478
  ` ${s.dim('$')} ticketlens schedule --status`,
462
479
  ` ${s.dim('$')} ticketlens schedule --stop`,
480
+ ` ${s.dim('$')} ticketlens schedule --local --time=07:00 --save=./triage.txt`,
463
481
  '',
464
482
  ];
465
483
  stream.write(lines.join('\n') + '\n');
@@ -710,6 +728,108 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
710
728
  stream.write(lines.join('\n') + '\n');
711
729
  }
712
730
 
731
+ export function printComplianceHelp({ stream = process.stdout } = {}) {
732
+ const s = createStyler({ isTTY: stream.isTTY });
733
+ const lines = [
734
+ '',
735
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('compliance')} ${s.dim('<TICKET-KEY> [--profile=NAME]')} ${s.dim('[Pro/Free 3/mo]')}`,
736
+ '',
737
+ ` Check your current branch's diff against the ticket's requirements.`,
738
+ ` Extracts candidate requirements from the ticket description and diffs`,
739
+ ` them against what the local git diff actually covers, reporting a`,
740
+ ` coverage percentage and a list of uncovered items.`,
741
+ '',
742
+ ` Used internally by the pre-push hook installed via ${s.brand('ticketlens install-hooks')}`,
743
+ ` (reads the threshold from ${s.dim('.ticketlens-hooks.json')}, default 80%).`,
744
+ '',
745
+ ` ${s.bold('OPTIONS')}`,
746
+ '',
747
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
748
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
749
+ '',
750
+ ` ${s.bold('EXAMPLES')}`,
751
+ '',
752
+ ` ${s.dim('$')} ticketlens compliance PROJ-123`,
753
+ ` ${s.dim('$')} ticketlens compliance PROJ-123 --profile=myteam`,
754
+ '',
755
+ ];
756
+ stream.write(lines.join('\n') + '\n');
757
+ }
758
+
759
+ export function printLedgerHelp({ stream = process.stdout } = {}) {
760
+ const s = createStyler({ isTTY: stream.isTTY });
761
+ const lines = [
762
+ '',
763
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('ledger')} ${s.dim('[--format=json|csv]')} ${s.dim('[Pro]')}`,
764
+ '',
765
+ ` Export your local usage ledger — a signed, tamper-evident record of`,
766
+ ` billable actions (AI calls, exports, etc). Verifiable offline via an`,
767
+ ` HMAC-SHA256 signature over {records, exportedAt}, keyed at ledger-key.`,
768
+ '',
769
+ ` ${s.bold('OPTIONS')}`,
770
+ '',
771
+ ` ${s.brand('--format')}=${s.dim('json')} Full record export with signature ${s.dim('(default)')}`,
772
+ ` ${s.brand('--format')}=${s.dim('csv')} Flat CSV, no signature`,
773
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
774
+ '',
775
+ ` ${s.bold('EXAMPLES')}`,
776
+ '',
777
+ ` ${s.dim('$')} ticketlens ledger`,
778
+ ` ${s.dim('$')} ticketlens ledger --format=csv`,
779
+ '',
780
+ ];
781
+ stream.write(lines.join('\n') + '\n');
782
+ }
783
+
784
+ export function printPrHelp({ stream = process.stdout } = {}) {
785
+ const s = createStyler({ isTTY: stream.isTTY });
786
+ const lines = [
787
+ '',
788
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('pr')} ${s.dim('<TICKET-KEY> [--profile=NAME]')}`,
789
+ '',
790
+ ` Assemble a pull-request description from a ticket's context —`,
791
+ ` summary, acceptance criteria, and linked issues, formatted as a`,
792
+ ` ready-to-paste PR body.`,
793
+ '',
794
+ ` ${s.bold('OPTIONS')}`,
795
+ '',
796
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
797
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
798
+ '',
799
+ ` ${s.bold('EXAMPLES')}`,
800
+ '',
801
+ ` ${s.dim('$')} ticketlens pr PROJ-123`,
802
+ ` ${s.dim('$')} ticketlens pr PROJ-123 --profile=myteam`,
803
+ '',
804
+ ];
805
+ stream.write(lines.join('\n') + '\n');
806
+ }
807
+
808
+ export function printInstallHooksHelp({ stream = process.stdout } = {}) {
809
+ const s = createStyler({ isTTY: stream.isTTY });
810
+ const lines = [
811
+ '',
812
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('install-hooks')} ${s.dim('[--uninstall]')}`,
813
+ '',
814
+ ` Installs a git pre-push hook that blocks a push when this branch's`,
815
+ ` compliance coverage (see ${s.brand('ticketlens compliance')}) is below 80%.`,
816
+ ` Appends to an existing pre-push hook rather than overwriting it;`,
817
+ ` calling it again is a no-op if already installed.`,
818
+ '',
819
+ ` ${s.bold('OPTIONS')}`,
820
+ '',
821
+ ` ${s.brand('--uninstall')} Remove the hook, restoring any pre-existing pre-push content`,
822
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
823
+ '',
824
+ ` ${s.bold('EXAMPLES')}`,
825
+ '',
826
+ ` ${s.dim('$')} ticketlens install-hooks`,
827
+ ` ${s.dim('$')} ticketlens install-hooks --uninstall`,
828
+ '',
829
+ ];
830
+ stream.write(lines.join('\n') + '\n');
831
+ }
832
+
713
833
  export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
714
834
  const s = createStyler({ isTTY: stream.isTTY });
715
835
  const lines = [
@@ -79,3 +79,51 @@ export function installHook({ cwd = process.cwd(), threshold = 80, fsModule = fs
79
79
 
80
80
  return { installed: true, path: hookPath };
81
81
  }
82
+
83
+ /**
84
+ * Removes a previously-installed compliance gate, leaving any pre-existing
85
+ * (non-ticketlens) pre-push hook content untouched. installHook() always
86
+ * appends its block last, prefixed with its own `#!/bin/sh` line — this
87
+ * strips from that shebang line (and one preceding blank line from the
88
+ * append's leading '\n', if present) through end of file. If nothing
89
+ * precedes that point, the file is deleted rather than left empty.
90
+ *
91
+ * @param {{ cwd?: string, fsModule?: typeof import('node:fs') }} [opts]
92
+ * @returns {{ uninstalled: true, path: string } | { skipped: true, reason: string }}
93
+ */
94
+ export function uninstallHook({ cwd = process.cwd(), fsModule = fs } = {}) {
95
+ const hookPath = join(cwd, '.git', 'hooks', 'pre-push');
96
+
97
+ let existing;
98
+ try {
99
+ existing = fsModule.readFileSync(hookPath, 'utf8');
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') return { skipped: true, reason: 'No pre-push hook installed.' };
102
+ throw err;
103
+ }
104
+
105
+ if (!existing.includes(GUARD)) {
106
+ return { skipped: true, reason: 'No ticketlens compliance gate found in the pre-push hook.' };
107
+ }
108
+
109
+ const guardIdx = existing.indexOf(GUARD);
110
+ const shebangIdx = existing.lastIndexOf('#!/bin/sh\n', guardIdx);
111
+ let cutStart = shebangIdx;
112
+ if (cutStart > 0 && existing[cutStart - 1] === '\n') cutStart -= 1;
113
+
114
+ const remaining = existing.slice(0, cutStart);
115
+ if (remaining.trim() === '') {
116
+ fsModule.unlinkSync(hookPath);
117
+ } else {
118
+ fsModule.writeFileSync(hookPath, remaining, 'utf8');
119
+ }
120
+
121
+ const configPath = join(cwd, '.ticketlens-hooks.json');
122
+ try {
123
+ fsModule.unlinkSync(configPath);
124
+ } catch (err) {
125
+ if (err.code !== 'ENOENT') throw err;
126
+ }
127
+
128
+ return { uninstalled: true, path: hookPath };
129
+ }
@@ -252,6 +252,33 @@ export function buildLocalCronLine({ hour, minute, ticketlensBin, outputFile })
252
252
  return `${minute} ${hour} * * * ${ticketlensBin} triage '--save=${outputFile}' >> '${outputFile}.err' 2>&1`;
253
253
  }
254
254
 
255
+ /**
256
+ * Decides whether `ticketlens schedule` should use the local-only path
257
+ * (runScheduleLocal) instead of the Console-backed wizard (runScheduleWizard)
258
+ * — explicit `--local`, or auto-detected whenever there's no cliToken at all,
259
+ * so a logged-out user gets a working local schedule instead of a hard
260
+ * "log in first" error.
261
+ *
262
+ * @param {{ cmdArgs?: string[], cliToken?: string|null }} opts
263
+ * @returns {boolean}
264
+ */
265
+ export function shouldUseLocalSchedule({ cmdArgs = [], cliToken } = {}) {
266
+ return cmdArgs.includes('--local') || !cliToken;
267
+ }
268
+
269
+ /**
270
+ * Extracts the --time= and --save= flags local scheduling needs. Both are
271
+ * required by runScheduleLocal itself; this just does the string parsing.
272
+ *
273
+ * @param {string[]} cmdArgs
274
+ * @returns {{ time?: string, outputFile?: string }}
275
+ */
276
+ export function parseLocalScheduleFlags(cmdArgs = []) {
277
+ const time = cmdArgs.find(a => a.startsWith('--time='))?.split('=')[1];
278
+ const outputFile = cmdArgs.find(a => a.startsWith('--save='))?.split('=')[1];
279
+ return { time, outputFile };
280
+ }
281
+
255
282
  /**
256
283
  * Set up a local-only scheduled triage (no Console auth, no cloud push).
257
284
  * Writes a cron/LaunchAgent entry that runs `ticketlens triage --save=FILE`.