ticketlens 0.9.3 → 0.9.8

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
@@ -125,6 +125,29 @@ Confluence pages linked to the ticket via Jira Remote Links are fetched automati
125
125
 
126
126
  ---
127
127
 
128
+ ### Brief Templates
129
+
130
+ Control which sections appear in a brief without changing the default output for everyone else.
131
+
132
+ ```bash
133
+ ticketlens CNV1-2 --template=quick # Meta + 2 comments only
134
+ ticketlens CNV1-2 --template=code-review # Meta + description + linked + code refs
135
+ ticketlens CNV1-2 --template=full # All sections (same as default)
136
+ ticketlens CNV1-2 --template=my-team-template # Custom team template [Team]
137
+ ```
138
+
139
+ Three system templates ship out of the box:
140
+
141
+ | Slug | Sections | Best for |
142
+ |------|----------|---------|
143
+ | `full` | Everything (default) | LLM context, deep planning |
144
+ | `quick` | Meta + 2 comments | Standup, daily triage |
145
+ | `code-review` | Meta + description + linked + code refs | PR review |
146
+
147
+ **Custom templates [Team]** — create your own in the Console under **Admin → Brief Templates**. Pick which sections appear and cap comment count. The slug you set is what you pass to `--template=`.
148
+
149
+ ---
150
+
128
151
  ### Triage
129
152
 
130
153
  ```bash
@@ -483,13 +506,16 @@ ticketlens update-skill --path=~/.gemini/commands # sync to a different AI assi
483
506
  **Usage in Claude Code:**
484
507
 
485
508
  ```
486
- /jtb CNV1-2 # Fetch ticket + linked issues → plan mode
487
- /jtb CNV1-2 --depth=0 # Target ticket only (fast)
488
- /jtb CNV1-2 --depth=2 # Deep: full linked-issue graph
489
- /jtb CNV1-2 --profile=acme # Force a specific profile
490
- /jtb CNV1-2 --no-attachments # Skip attachment download
491
- /jtb CNV1-2 --no-cache # Re-fetch from Jira
492
- /jtb triage # Scan your assigned tickets
509
+ /jtb CNV1-2 # Fetch ticket + linked issues → plan mode
510
+ /jtb CNV1-2 --depth=0 # Target ticket only (fast)
511
+ /jtb CNV1-2 --depth=2 # Deep: full linked-issue graph
512
+ /jtb CNV1-2 --profile=acme # Force a specific profile
513
+ /jtb CNV1-2 --no-attachments # Skip attachment download
514
+ /jtb CNV1-2 --no-cache # Re-fetch from Jira
515
+ /jtb CNV1-2 --template=quick # Apply quick template (meta + 2 comments)
516
+ /jtb CNV1-2 --template=code-review # Apply code-review template
517
+ /jtb CNV1-2 --template=my-slug # Apply a custom team template [Team]
518
+ /jtb triage # Scan your assigned tickets
493
519
  ```
494
520
 
495
521
  Attachments are listed in the brief as absolute paths. Claude Code reads images (multimodal), PDFs, and text files before planning. Files over 10 MB are skipped.
@@ -532,6 +558,7 @@ ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLen
532
558
  ticketlens CNV1-2 --template=quick # Apply quick template (meta + 2 comments only)
533
559
  ticketlens CNV1-2 --template=code-review # Apply code-review template (meta + desc + linked + code refs)
534
560
  ticketlens CNV1-2 --template=full # Apply full template (all sections, default)
561
+ ticketlens CNV1-2 --template=my-team-template # Apply a custom team template [Team]
535
562
  ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
536
563
 
537
564
  # Pipe plain output to clipboard, LLM, or file
@@ -632,6 +659,18 @@ ticketlens install-hooks --uninstall # Remove installed hooks [Pro]
632
659
  ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
633
660
  ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard [Pro]
634
661
 
662
+ # ── AI provider keys (BYOK) ───────────────────────────────────────────────────
663
+ ticketlens cloud-keys list # List configured AI providers
664
+ ticketlens cloud-keys add groq gsk_xxxx # Add Groq key (free tier)
665
+ ticketlens cloud-keys add anthropic sk-ant-xxxx # Add Anthropic key
666
+ ticketlens cloud-keys add openai sk-xxxx # Add OpenAI key
667
+ ticketlens cloud-keys add groq gsk_xxxx --timeout=10 # Add with custom timeout
668
+ ticketlens cloud-keys test groq # Test a provider key
669
+ ticketlens cloud-keys remove groq # Remove a provider
670
+ ticketlens cloud-keys priority groq 1 # Set provider priority
671
+ ticketlens cloud-keys timeout anthropic 15 # Set per-request timeout
672
+ ticketlens cloud-keys --help # Subcommand help
673
+
635
674
  # ── Login ─────────────────────────────────────────────────────────────────────
636
675
  ticketlens login # Browser flow — opens Console, token saved automatically
637
676
  ticketlens login --manual # Paste flow — for CI/headless environments
@@ -682,7 +721,7 @@ ticketlens schedule # Set up a scheduled daily digest
682
721
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
683
722
  ```
684
723
 
685
- **`--summarize`** generates a 3-sentence AI summary of the ticket. The AI receives the full ticket context: description, comments, linked Confluence pages, and any text-readable attachments.
724
+ **`--summarize`** generates a 3-sentence AI summary of the ticket. The AI receives the full ticket context: description, comments, linked Confluence pages, and any text-readable attachments. The summary is cached alongside the brief (same 4h TTL) — repeat runs return instantly from cache. Use `--no-cache` to force a fresh AI call.
686
725
 
687
726
  **`--handoff`** synthesizes the ticket into a structured one-pager for the developer picking up the work. The AI receives the same full context and returns:
688
727
 
@@ -703,20 +742,30 @@ ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
703
742
  | PDFs | ❌ Binary — no parser included (zero-dependency) |
704
743
  | Office documents (`.docx`, `.xlsx`) | ❌ Binary — no parser included |
705
744
 
706
- Add one of the following to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API:
745
+ Add your AI provider keys once via `cloud-keys` — they're stored encrypted on your account and used automatically:
746
+
747
+ ```bash
748
+ ticketlens cloud-keys add groq gsk_xxxx # Groq (Llama 3.x — free tier)
749
+ ticketlens cloud-keys add anthropic sk-ant-xxxx # Anthropic Claude
750
+ ticketlens cloud-keys add openai sk-xxxx # OpenAI GPT-4o mini
751
+ ticketlens cloud-keys list # See configured providers
752
+ ticketlens cloud-keys test groq # Verify a key works
753
+ ticketlens cloud-keys remove groq # Remove a provider
754
+ ```
707
755
 
708
- | Key | Provider | Cost |
756
+ Or use `--cloud` to route through the TicketLens API without managing keys yourself.
757
+
758
+ | Provider | Cost | Sign up |
709
759
  |---|---|---|
710
- | `anthropicApiKey` | Anthropic (Claude Haiku) | Paid |
711
- | `openaiApiKey` | OpenAI (GPT-4o mini) | Paid |
712
- | `groqApiKey` | Groq (Llama 3.1 8B) | **Free tier** — [console.groq.com](https://console.groq.com) |
760
+ | Groq (Llama 3.x) | **Free tier** | [console.groq.com](https://console.groq.com) |
761
+ | Anthropic (Claude) | Paid | console.anthropic.com |
762
+ | OpenAI (GPT-4o mini) | Paid | platform.openai.com |
713
763
 
714
- **Selecting a provider:** By default, the first available key is used (Anthropic → OpenAI → Groq). To set a persistent default:
764
+ **Provider priority:** Providers are tried in the order you configure them. To set a default fallback order:
715
765
 
716
766
  ```bash
717
- ticketlens config set aiProvider groq # always use Groq
718
- ticketlens config set aiProvider openai # always use OpenAI
719
- ticketlens config set aiProvider anthropic # always use Anthropic
767
+ ticketlens cloud-keys priority groq 1 # try Groq first
768
+ ticketlens cloud-keys priority anthropic 2 # Anthropic second
720
769
  ```
721
770
 
722
771
  Override per-command with `--provider=`:
@@ -726,6 +775,8 @@ ticketlens CNV1-2 --summarize --provider=groq
726
775
  ticketlens CNV1-2 --handoff --provider=openai
727
776
  ```
728
777
 
778
+ Or manage keys in **Console → Admin → AI Settings**.
779
+
729
780
  <div align="center">
730
781
  <img src="docs/demos/pro-triage.gif" alt="ticketlens triage --stale=3 demo" width="700" />
731
782
  </div>
@@ -25,6 +25,7 @@ import {
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
26
  printReviewHelp, printStandupHelp, printUpdateSkillHelp,
27
27
  printCollisionsHelp, printStatsHelp,
28
+ printCloudKeysHelp,
28
29
  } from '../skills/jtb/scripts/lib/help.mjs';
29
30
  import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
30
31
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
@@ -33,10 +34,15 @@ import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
33
34
  import { syncProfiles, getApiBase, getConsoleBase } from '../skills/jtb/scripts/lib/sync.mjs';
34
35
  import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
35
36
  import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
37
+ import { incrementInvocation } from '../skills/jtb/scripts/lib/activity-counter.mjs';
38
+ import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
36
39
 
37
40
  const args = process.argv.slice(2);
38
41
  const { command, args: cmdArgs } = parseCommand(args);
39
42
 
43
+ // Best-effort invocation counter — non-fatal
44
+ try { incrementInvocation(DEFAULT_CONFIG_DIR); } catch { /* non-fatal */ }
45
+
40
46
  // Fire-and-forget: silently refresh license.json at startup if >7 days since last validation
41
47
  revalidateIfStale();
42
48
  // Fire-and-forget: refresh the cached latest npm version once per 24h
@@ -532,6 +538,122 @@ switch (command) {
532
538
  break;
533
539
  }
534
540
 
541
+ case 'cloud-keys': {
542
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCloudKeysHelp(); break; }
543
+
544
+ const { listCloudKeys, addCloudKey, removeCloudKey, setPriority, setTimeout_, testCloudKey } =
545
+ await import('../skills/jtb/scripts/lib/cloud-keys.mjs');
546
+
547
+ const cliToken = readCliToken();
548
+ if (!cliToken) {
549
+ process.stderr.write('Not logged in. Run `ticketlens login` first.\n');
550
+ process.exitCode = 1;
551
+ break;
552
+ }
553
+
554
+ const apiBase = getApiBase();
555
+ const cfg = { cliToken, apiBase };
556
+ const s = createStyler({ isTTY: process.stderr.isTTY });
557
+ const subCmd = cmdArgs[0];
558
+
559
+ (async () => {
560
+ if (!subCmd || subCmd === 'list') {
561
+ const providers = await listCloudKeys(cfg);
562
+ if (providers.length === 0) {
563
+ process.stdout.write('No AI providers configured.\n');
564
+ process.stdout.write(`Add one: ticketlens cloud-keys add <provider> <key>\n`);
565
+ return;
566
+ }
567
+ for (const p of providers) {
568
+ const status = p.enabled ? s.brand('on') : s.dim('off');
569
+ process.stdout.write(
570
+ ` ${p.provider.padEnd(12)} ${p.masked_key.padEnd(28)} priority=${p.priority} timeout=${p.timeout_seconds}s [${status}]\n`
571
+ );
572
+ }
573
+ return;
574
+ }
575
+
576
+ if (subCmd === 'add') {
577
+ const provider = cmdArgs[1];
578
+ const apiKey = cmdArgs[2];
579
+ if (!provider || !apiKey) {
580
+ process.stderr.write('Usage: ticketlens cloud-keys add <provider> <key> [--timeout=N]\n');
581
+ process.exitCode = 1;
582
+ return;
583
+ }
584
+ const timeoutArg = cmdArgs.find(a => a.startsWith('--timeout='));
585
+ const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 5;
586
+ await addCloudKey(cfg, provider, apiKey, timeout);
587
+ process.stdout.write(`${s.brand('✓')} ${provider} key saved.\n`);
588
+ return;
589
+ }
590
+
591
+ if (subCmd === 'remove') {
592
+ const provider = cmdArgs[1];
593
+ if (!provider) {
594
+ process.stderr.write('Usage: ticketlens cloud-keys remove <provider>\n');
595
+ process.exitCode = 1;
596
+ return;
597
+ }
598
+ await removeCloudKey(cfg, provider);
599
+ process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
600
+ return;
601
+ }
602
+
603
+ if (subCmd === 'test') {
604
+ const provider = cmdArgs[1];
605
+ if (!provider) {
606
+ process.stderr.write('Usage: ticketlens cloud-keys test <provider>\n');
607
+ process.exitCode = 1;
608
+ return;
609
+ }
610
+ process.stderr.write(`Testing ${provider}…\n`);
611
+ const result = await testCloudKey(cfg, provider);
612
+ if (result.ok) {
613
+ process.stdout.write(`${s.brand('✓')} ${provider} responded: ${result.response}\n`);
614
+ } else {
615
+ process.stderr.write(`${s.dim('✗')} ${provider} error: ${result.error ?? 'unknown'}\n`);
616
+ process.exitCode = 1;
617
+ }
618
+ return;
619
+ }
620
+
621
+ if (subCmd === 'priority') {
622
+ const provider = cmdArgs[1];
623
+ const priority = parseInt(cmdArgs[2], 10);
624
+ if (!provider || isNaN(priority)) {
625
+ process.stderr.write('Usage: ticketlens cloud-keys priority <provider> <N>\n');
626
+ process.exitCode = 1;
627
+ return;
628
+ }
629
+ await setPriority(cfg, provider, priority);
630
+ process.stdout.write(`${s.brand('✓')} ${provider} priority set to ${priority}.\n`);
631
+ return;
632
+ }
633
+
634
+ if (subCmd === 'timeout') {
635
+ const provider = cmdArgs[1];
636
+ const seconds = parseInt(cmdArgs[2], 10);
637
+ if (!provider || isNaN(seconds)) {
638
+ process.stderr.write('Usage: ticketlens cloud-keys timeout <provider> <seconds>\n');
639
+ process.exitCode = 1;
640
+ return;
641
+ }
642
+ await setTimeout_(cfg, provider, seconds);
643
+ process.stdout.write(`${s.brand('✓')} ${provider} timeout set to ${seconds}s.\n`);
644
+ return;
645
+ }
646
+
647
+ process.stderr.write(`Unknown subcommand: ${subCmd}\n`);
648
+ printCloudKeysHelp({ stream: process.stderr });
649
+ process.exitCode = 1;
650
+ })().catch(err => {
651
+ process.stderr.write(`Error: ${err.message}\n`);
652
+ process.exitCode = 1;
653
+ });
654
+ break;
655
+ }
656
+
535
657
  case 'help':
536
658
  default:
537
659
  printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.9.3",
3
+ "version": "0.9.8",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.9.1 -->
1
+ <!-- jtb-skill-version: 0.9.7 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -43,6 +43,14 @@ Fetches a Jira ticket and produces a structured brief with code references, then
43
43
  /jtb stats --format=json # JSON output for scripting
44
44
  /jtb collisions # show branch collisions with teammates (Team)
45
45
  /jtb collisions --json # machine-readable output
46
+ /jtb cloud-keys list # list configured AI provider keys (Pro)
47
+ /jtb cloud-keys add groq gsk_xxxx # add Groq key (free tier — console.groq.com)
48
+ /jtb cloud-keys add anthropic sk-ant-x # add Anthropic key
49
+ /jtb cloud-keys add openai sk-xxxx # add OpenAI key
50
+ /jtb cloud-keys test groq # verify a provider key works
51
+ /jtb cloud-keys remove groq # remove a provider
52
+ /jtb cloud-keys priority groq 1 # set provider priority (lower = tried first)
53
+ /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
46
54
  ```
47
55
 
48
56
  ## Prerequisites
@@ -10,6 +10,7 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
10
10
  import { styleTriageSummary } from './lib/styled-assembler.mjs';
11
11
  import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
12
12
  import { resolveAdapter } from './lib/resolve-adapter.mjs';
13
+ import { incrementTriageRun, readAndResetActivity } from './lib/activity-counter.mjs';
13
14
  import { writeFileSync, mkdirSync, statSync } from 'node:fs';
14
15
  import { resolve as resolvePath, dirname } from 'node:path';
15
16
  import { createSpinner } from './lib/spinner.mjs';
@@ -423,6 +424,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
423
424
  saveTriageSnapshot(scored, { profile: profileName ?? 'default', configDir });
424
425
  } catch { /* non-fatal */ }
425
426
 
427
+ // Track this triage run (best-effort, non-fatal)
428
+ try { incrementTriageRun(configDir); } catch { /* non-fatal */ }
429
+
426
430
  // --digest: POST scored results to the digest backend endpoint
427
431
  if (digestFlag) {
428
432
  if (!licensedFn('pro', configDir)) {
@@ -480,6 +484,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
480
484
  const scanFn = opts.scanFn ?? scanCurrentBranch;
481
485
  const cliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
482
486
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
487
+ let cliActivity = null;
488
+ try { cliActivity = readAndResetActivity(configDir); } catch { /* non-fatal */ }
483
489
  await pushFn({
484
490
  sorted,
485
491
  rawTicketMap,
@@ -487,6 +493,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
487
493
  baseUrl: conn.baseUrl,
488
494
  cliToken,
489
495
  gitBranches: scanFn(),
496
+ cliActivity,
490
497
  fetcher,
491
498
  print: printFn,
492
499
  });
@@ -23,11 +23,12 @@ import { readTextAttachments } from './lib/handoff-assembler.mjs';
23
23
  import { handleUnknownFlags } from './lib/arg-validator.mjs';
24
24
  import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
25
25
  import { downloadAttachments } from './lib/attachment-downloader.mjs';
26
- import { readBriefCache, writeBriefCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
26
+ import { readBriefCache, writeBriefCache, readSummaryCache, writeSummaryCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
27
27
  import { readCliToken } from './lib/cli-auth.mjs';
28
28
  import { resolveTemplate } from './lib/template-resolver.mjs';
29
29
  import { parseAge } from './lib/cache-manager.mjs';
30
30
  import { createStyler } from './lib/ansi.mjs';
31
+ import { apiBase } from './lib/api-utils.mjs';
31
32
  import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
32
33
  import { detectVcs } from './lib/vcs-detector.mjs';
33
34
  import { runComplianceCheck } from './lib/compliance-checker.mjs';
@@ -118,6 +119,23 @@ function augmentBriefForAi(brief, localAttachments) {
118
119
  return brief + `\n\n--- Attached Documents (${textFiles.length} text-readable) ---\n\n${sections.join('\n\n')}`;
119
120
  }
120
121
 
122
+ const PROVIDER_LABELS = { groq: 'Groq', anthropic: 'Anthropic', openai: 'OpenAI' };
123
+
124
+ async function fetchPrimaryCloudProvider(cliToken, apiUrl) {
125
+ try {
126
+ const res = await fetch(`${apiUrl}/v1/ai-providers`, {
127
+ headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
128
+ signal: AbortSignal.timeout(3_000),
129
+ });
130
+ if (!res.ok) return null;
131
+ const { providers = [] } = await res.json();
132
+ const primary = providers.filter(p => p.enabled).sort((a, b) => a.priority - b.priority)[0];
133
+ return primary?.provider ?? null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
121
139
  /**
122
140
  * Apply --summarize to a brief string.
123
141
  * Returns the modified brief, or null if the caller should exit (license gate / consent refused).
@@ -131,18 +149,48 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
131
149
 
132
150
  const mode = args.includes('--cloud') ? 'cloud' : 'byok';
133
151
  const profileName = conn.profileName ?? 'default';
152
+ const ticketKey = ticket?.key ?? null;
153
+ const noCache = args.includes('--no-cache');
154
+
155
+ // Return cached summary if available (skips consent check and AI call).
156
+ // Bypassed when opts.summarizer is injected (test mode) or --no-cache is set.
157
+ if (!noCache && !opts.summarizer && ticketKey) {
158
+ const cachedSummary = readSummaryCache(ticketKey, profileName, configDir);
159
+ if (cachedSummary) {
160
+ const divider = '─'.repeat(60);
161
+ return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${cachedSummary}\n${divider}\n`;
162
+ }
163
+ }
134
164
 
135
165
  // Cloud consent check (skipped when summarizer is injected, e.g. tests)
136
166
  if (mode === 'cloud' && !opts.summarizer && !hasCloudConsent(configDir, profileName)) {
167
+ const cliTokenForConsent = opts.cliToken ?? readCliToken(configDir);
168
+ const apiUrl = apiBase();
169
+
137
170
  let consentGiven = !process.stdin.isTTY;
138
171
  if (!consentGiven && process.stdout.isTTY) {
139
- process.stdout.write(
140
- '\n Cloud summary sends your ticket content to api.ticketlens.dev for processing.\n' +
141
- ' TicketLens calls Claude and returns a summary. No data stored after the request.\n' +
142
- ' Proceed? (y/N) '
143
- );
172
+ const providerKey = cliTokenForConsent
173
+ ? await fetchPrimaryCloudProvider(cliTokenForConsent, apiUrl)
174
+ : null;
175
+ const providerLabel = PROVIDER_LABELS[providerKey] ?? 'your configured AI service';
176
+
177
+ const s = createStyler({ isTTY: true });
178
+ const sep = s.dim(' ' + '─'.repeat(55));
179
+ const promptText =
180
+ `\n${sep}\n` +
181
+ ` ${s.bold('Cloud Summary')} — consent required once per profile\n` +
182
+ `${sep}\n` +
183
+ ` ${s.dim('Destination')} ${s.cyan(apiUrl)}\n` +
184
+ ` ${s.dim('Processed by')} ${s.yellow(providerLabel)}\n` +
185
+ ` ${s.dim('Retention')} No data stored after this request\n` +
186
+ `${sep}\n\n` +
187
+ ` Allow cloud summarization for "${profileName}" profile? [Y/n] `;
188
+
144
189
  const rl = (await import('node:readline')).createInterface({ input: process.stdin, output: process.stdout });
145
- consentGiven = await new Promise(resolve => rl.question('', ans => { rl.close(); resolve(ans.trim().toLowerCase() === 'y'); }));
190
+ consentGiven = await new Promise(resolve => rl.question(
191
+ promptText,
192
+ ans => { rl.close(); const a = ans.trim().toLowerCase(); resolve(a === '' || a === 'y'); }
193
+ ));
146
194
  }
147
195
  if (!consentGiven) { process.exitCode = 1; return null; }
148
196
  saveCloudConsent(configDir, profileName);
@@ -154,10 +202,11 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
154
202
  return summarize(sumOpts);
155
203
  });
156
204
  const credentials = opts.credentials ?? loadCredentials(configDir);
157
- const licenseKey = readLicense(configDir)?.key;
205
+ const cliToken = opts.cliToken ?? readCliToken(configDir);
158
206
  const provider = opts.provider ?? resolveAiProvider(args, credentials);
159
207
  const aiInput = augmentBriefForAi(brief, ticket?.localAttachments);
160
- const summary = await summarizerFn({ brief: aiInput, mode, credentials, licenseKey, provider });
208
+ const summary = await summarizerFn({ brief: aiInput, mode, credentials, cliToken, provider });
209
+ if (ticketKey && !opts.summarizer) writeSummaryCache(ticketKey, profileName, summary, configDir);
161
210
  const divider = '─'.repeat(60);
162
211
  return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
163
212
  } catch (err) {
@@ -187,10 +236,10 @@ async function applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn
187
236
  return summarize(sumOpts);
188
237
  });
189
238
  const credentials = opts.credentials ?? loadCredentials(configDir);
190
- const licenseKey = readLicense(configDir)?.key;
239
+ const cliToken = opts.cliToken ?? readCliToken(configDir);
191
240
  const provider = opts.provider ?? resolveAiProvider(args, credentials);
192
241
  const input = buildHandoffInput(ticket);
193
- const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512, provider });
242
+ const body = await summarizerFn({ brief: input, mode, credentials, cliToken, prompt: HANDOFF_PROMPT, maxTokens: 512, provider });
194
243
  return `## Handoff Brief — ${ticket.key}\n\n${body}\n`;
195
244
  } catch (err) {
196
245
  const onErrorFn = opts.onError ?? ((msg) => process.stderr.write(msg + '\n'));
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Best-effort CLI activity counter — tracks fetch, triage, and invocation
3
+ * counts between pushes. Stored in ~/.ticketlens/activity.json.
4
+ *
5
+ * Intentional limitations (acceptable for a UX metric, NOT suitable for billing):
6
+ *
7
+ * - Not transactional: if a push succeeds server-side but the network returns
8
+ * an error, counters are not reset and will be included in the next push
9
+ * (double-counting one session's activity is acceptable).
10
+ *
11
+ * - Not safe for concurrent processes: increment() is a read-modify-write with
12
+ * no file lock. Two simultaneous CLI invocations will race and the last write
13
+ * wins, silently dropping one count. This is rare in normal developer usage
14
+ * and acceptable given the zero-npm-deps constraint. Do not rely on these
15
+ * counters for exact accuracy.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ const ACTIVITY_FILE = 'activity.json';
22
+
23
+ function read(configDir) {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(path.join(configDir, ACTIVITY_FILE), 'utf8'));
26
+ } catch {
27
+ return { fetch_count: 0, triage_run_count: 0, invocations: 0 };
28
+ }
29
+ }
30
+
31
+ function write(configDir, data) {
32
+ try {
33
+ fs.mkdirSync(configDir, { recursive: true });
34
+ fs.writeFileSync(
35
+ path.join(configDir, ACTIVITY_FILE),
36
+ JSON.stringify(data, null, 2),
37
+ 'utf8',
38
+ );
39
+ } catch {
40
+ // Non-fatal — activity tracking is best-effort
41
+ }
42
+ }
43
+
44
+ function increment(configDir, field) {
45
+ const data = read(configDir);
46
+ data[field] = (data[field] ?? 0) + 1;
47
+ write(configDir, data);
48
+ }
49
+
50
+ export function incrementFetch(configDir) {
51
+ increment(configDir, 'fetch_count');
52
+ }
53
+
54
+ export function incrementTriageRun(configDir) {
55
+ increment(configDir, 'triage_run_count');
56
+ }
57
+
58
+ export function incrementInvocation(configDir) {
59
+ increment(configDir, 'invocations');
60
+ }
61
+
62
+ /**
63
+ * Returns the current counters and resets them to zero.
64
+ * Call only after a confirmed successful push.
65
+ *
66
+ * @param {string} configDir
67
+ * @returns {{ fetch_count: number, triage_run_count: number, invocations: number }}
68
+ */
69
+ export function readAndResetActivity(configDir) {
70
+ const data = read(configDir);
71
+ const snapshot = {
72
+ fetch_count: data.fetch_count ?? 0,
73
+ triage_run_count: data.triage_run_count ?? 0,
74
+ invocations: data.invocations ?? 0,
75
+ };
76
+ write(configDir, { fetch_count: 0, triage_run_count: 0, invocations: 0 });
77
+ return snapshot;
78
+ }
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
6
+ export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
7
7
 
8
8
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
9
  // with an optional port — all treated as local-only addresses.
@@ -72,6 +72,37 @@ export function writeBriefCache(ticketKey, profileName, depth, ticket, configDir
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * Reads the cached AI summary from an existing brief cache file.
77
+ * Returns null if the brief cache is missing or contains no summary.
78
+ * No TTL check here — the summary is implicitly expired when the brief expires.
79
+ */
80
+ export function readSummaryCache(ticketKey, profileName, configDir = DEFAULT_CONFIG_DIR) {
81
+ const filePath = briefCachePath(ticketKey, profileName, configDir);
82
+ if (!fs.existsSync(filePath)) return null;
83
+ try {
84
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
85
+ return typeof data.summary === 'string' ? data.summary : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Patches an existing brief cache file with an AI summary (read-modify-write).
93
+ * Non-fatal — silently no-ops if the file is missing or unreadable.
94
+ */
95
+ export function writeSummaryCache(ticketKey, profileName, summary, configDir = DEFAULT_CONFIG_DIR) {
96
+ const filePath = briefCachePath(ticketKey, profileName, configDir);
97
+ if (!fs.existsSync(filePath)) return;
98
+ try {
99
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
100
+ fs.writeFileSync(filePath, JSON.stringify({ ...data, summary }));
101
+ } catch {
102
+ // Non-fatal
103
+ }
104
+ }
105
+
75
106
  /**
76
107
  * Removes the brief cache file for a specific ticket + profile.
77
108
  */
@@ -0,0 +1,100 @@
1
+ /**
2
+ * cloud-keys.mjs — manage per-user AI provider keys stored encrypted on the backend.
3
+ *
4
+ * All operations require a CLI token (set via `ticketlens login`).
5
+ */
6
+
7
+ const SUPPORTED_PROVIDERS = ['groq', 'anthropic', 'openai'];
8
+
9
+ function apiBase(config) {
10
+ return (config.apiBase || 'https://api.ticketlens.io').replace(/\/$/, '');
11
+ }
12
+
13
+ async function fetchApi(config, path, method = 'GET', body = null) {
14
+ const cliToken = config.cliToken;
15
+ if (!cliToken) {
16
+ throw new Error('Not logged in. Run `ticketlens login` first.');
17
+ }
18
+
19
+ const opts = {
20
+ method,
21
+ headers: {
22
+ 'Authorization': `Bearer ${cliToken}`,
23
+ 'Content-Type': 'application/json',
24
+ 'Accept': 'application/json',
25
+ },
26
+ };
27
+ if (body !== null) opts.body = JSON.stringify(body);
28
+
29
+ const fetcher = config.fetcher ?? fetch;
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), 15_000);
32
+ let res;
33
+ try {
34
+ res = await fetcher(`${apiBase(config)}${path}`, { ...opts, signal: controller.signal });
35
+ } catch (err) {
36
+ throw new Error(err.name === 'AbortError' ? 'Request timed out.' : err.message);
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+
41
+ if (!res.ok) {
42
+ let msg = `HTTP ${res.status}`;
43
+ try { msg = (await res.json()).error ?? msg; } catch {}
44
+ throw new Error(msg);
45
+ }
46
+
47
+ return res.json();
48
+ }
49
+
50
+ export async function listCloudKeys(config) {
51
+ const data = await fetchApi(config, '/v1/ai-providers');
52
+ return data.providers ?? [];
53
+ }
54
+
55
+ async function findProvider(config, provider) {
56
+ const providers = await listCloudKeys(config);
57
+ const target = providers.find(p => p.provider === provider);
58
+ if (!target) throw new Error(`No "${provider}" provider configured.`);
59
+ return target;
60
+ }
61
+
62
+ export async function addCloudKey(config, provider, apiKey, timeout = 5) {
63
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
64
+ throw new Error(`Unknown provider "${provider}". Supported: ${SUPPORTED_PROVIDERS.join(', ')}.`);
65
+ }
66
+ if (apiKey.length < 10) {
67
+ throw new Error('API key too short. Check your key and try again.');
68
+ }
69
+
70
+ const created = await fetchApi(config, '/v1/ai-providers', 'POST', {
71
+ provider,
72
+ api_key: apiKey,
73
+ });
74
+
75
+ if (timeout !== 5) {
76
+ await fetchApi(config, `/v1/ai-providers/${created.id}`, 'PUT', { timeout_seconds: timeout });
77
+ }
78
+
79
+ return created;
80
+ }
81
+
82
+ export async function removeCloudKey(config, provider) {
83
+ const target = await findProvider(config, provider);
84
+ await fetchApi(config, `/v1/ai-providers/${target.id}`, 'DELETE');
85
+ }
86
+
87
+ export async function setPriority(config, provider, priority) {
88
+ const target = await findProvider(config, provider);
89
+ await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { priority: Number(priority) });
90
+ }
91
+
92
+ export async function setTimeout_(config, provider, seconds) {
93
+ const target = await findProvider(config, provider);
94
+ await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { timeout_seconds: Number(seconds) });
95
+ }
96
+
97
+ export async function testCloudKey(config, provider) {
98
+ const target = await findProvider(config, provider);
99
+ return fetchApi(config, `/v1/ai-providers/${target.id}/test`, 'POST');
100
+ }
@@ -24,8 +24,11 @@ export function printHelp({ stream = process.stdout } = {}) {
24
24
  '',
25
25
  ` ${s.bold('USAGE')}`,
26
26
  '',
27
- // visible widths — target column = 36 visible chars for the command portion
28
- // Groups: Console sync ─── Setup ─── Daily use ─── Account / Maintenance
27
+ // Alignment targets (visible chars before description):
28
+ // Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
29
+ // Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
30
+ // Group 4 (account/maintenance): column 43 (longest: "schedule [--stop|--status]" = 41)
31
+ // cloud-keys [add|remove|list|test] is an outlier at column 50 — kept with 2-space minimum.
29
32
  ` ${s.brand('ticketlens')} login Connect CLI to your TicketLens account`,
30
33
  ` ${s.brand('ticketlens')} logout Remove stored credentials`,
31
34
  ` ${s.brand('ticketlens')} sync Pull tracker profiles from the console`,
@@ -35,9 +38,9 @@ export function printHelp({ stream = process.stdout } = {}) {
35
38
  ` ${s.brand('ticketlens')} config ${s.dim('[--profile=NAME]')} Edit profile settings`,
36
39
  ` ${s.brand('ticketlens')} profiles List all configured profiles ${s.dim('(alias: ls)')}`,
37
40
  '',
38
- ` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
39
- ` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
40
- ` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
41
+ ` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
42
+ ` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
43
+ ` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
41
44
  ` ${s.brand('ticketlens')} collisions ${s.dim('[--json]')} Show branch collisions with teammates ${s.dim('[Team]')}`,
42
45
  ` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
43
46
  ` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
@@ -45,11 +48,12 @@ export function printHelp({ stream = process.stdout } = {}) {
45
48
  ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
46
49
  ` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
47
50
  '',
48
- ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
49
- ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
50
- ` ${s.brand('ticketlens')} license Show license status`,
51
- ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
52
- ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
51
+ ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
52
+ ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
53
+ ` ${s.brand('ticketlens')} license Show license status`,
54
+ ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
55
+ ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
56
+ ` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
53
57
  ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
54
58
  '',
55
59
  ` ${s.bold('FETCH OPTIONS')}`,
@@ -111,11 +115,12 @@ export function printHelp({ stream = process.stdout } = {}) {
111
115
  ` ${s.dim('Or use env vars:')} JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN`,
112
116
  ` ${s.dim(' ')} TICKETLENS_API_URL ${s.dim('(override API host for local dev)')}`,
113
117
  '',
114
- ` ${s.dim('BYOK AI keys')} ${s.dim('(add to credentials.json):')}`,
115
- ` ${s.dim(' ')} anthropicApiKey ${s.dim('→ Claude (paid)')}`,
116
- ` ${s.dim(' ')} openaiApiKey ${s.dim('→ GPT-4o mini (paid)')}`,
117
- ` ${s.dim(' ')} groqApiKey ${s.dim('→ Llama 3.1 (free tier — console.groq.com)')}`,
118
+ ` ${s.dim('BYOK AI keys')} ${s.dim('(stored encrypted on your account):')}`,
119
+ ` ${s.dim(' ')} ticketlens cloud-keys add groq ${s.dim('<key>')}`,
120
+ ` ${s.dim(' ')} ticketlens cloud-keys add anthropic ${s.dim('<key>')}`,
121
+ ` ${s.dim(' ')} ticketlens cloud-keys add openai ${s.dim('<key>')}`,
118
122
  ` ${s.dim(' ')} ${s.dim('Set default:')} ticketlens config set aiProvider ${s.dim('<anthropic|openai|groq>')}`,
123
+ ` ${s.dim(' ')} ${s.dim('Or manage keys at:')} ${s.dim('Console → Admin → AI Settings')}`,
119
124
  '',
120
125
  '',
121
126
  ];
@@ -752,3 +757,46 @@ export function printStandupHelp({ stream = process.stdout } = {}) {
752
757
  ];
753
758
  stream.write(lines.join('\n') + '\n');
754
759
  }
760
+
761
+ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
762
+ const s = createStyler({ isTTY: stream.isTTY });
763
+ const lines = [
764
+ '',
765
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('cloud-keys')} ${s.dim('<subcommand> [options]')}`,
766
+ '',
767
+ ` Manage per-account AI provider keys stored encrypted on the TicketLens backend.`,
768
+ ` Requires ${s.bold('ticketlens login')}. Keys are scoped to your account and never shared.`,
769
+ '',
770
+ ` ${s.bold('SUBCOMMANDS')}`,
771
+ '',
772
+ ` ${s.brand('list')} List configured providers`,
773
+ ` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
774
+ ` ${s.brand('remove')} ${s.dim('<provider>')} Remove a provider's key`,
775
+ ` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
776
+ ` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
777
+ ` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
778
+ '',
779
+ ` ${s.bold('OPTIONS')}`,
780
+ '',
781
+ ` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
782
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
783
+ '',
784
+ ` ${s.bold('PROVIDERS')}`,
785
+ '',
786
+ ` groq Llama 3.x — free tier at ${s.dim('console.groq.com')}`,
787
+ ` anthropic Claude — ${s.dim('console.anthropic.com')}`,
788
+ ` openai GPT-4o — ${s.dim('platform.openai.com')}`,
789
+ '',
790
+ ` ${s.bold('EXAMPLES')}`,
791
+ '',
792
+ ` ${s.dim('$')} ticketlens cloud-keys list`,
793
+ ` ${s.dim('$')} ticketlens cloud-keys add groq gsk_xxxxxxxxxxxxxxxxxxxx`,
794
+ ` ${s.dim('$')} ticketlens cloud-keys add groq gsk_xxx --timeout=10`,
795
+ ` ${s.dim('$')} ticketlens cloud-keys test groq`,
796
+ ` ${s.dim('$')} ticketlens cloud-keys remove groq`,
797
+ ` ${s.dim('$')} ticketlens cloud-keys priority groq 1`,
798
+ ` ${s.dim('$')} ticketlens cloud-keys timeout anthropic 15`,
799
+ '',
800
+ ];
801
+ stream.write(lines.join('\n') + '\n');
802
+ }
@@ -12,18 +12,18 @@ const DEFAULT_MAX_TOKENS = 256;
12
12
  * @param {string} opts.brief - Markdown brief text
13
13
  * @param {'byok'|'cloud'} opts.mode
14
14
  * @param {object} [opts.credentials] - { anthropicApiKey?, openaiApiKey?, groqApiKey? }
15
- * @param {string} [opts.licenseKey] - Required for cloud mode
15
+ * @param {string} [opts.cliToken] - CLI session token (required for cloud mode)
16
16
  * @param {Function} [opts.fetcher] - Injectable for tests (defaults to globalThis.fetch)
17
17
  * @param {number} [opts.timeoutMs]
18
18
  * @returns {Promise<string>} Summary text
19
19
  */
20
- export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000, prompt, maxTokens, provider }) {
20
+ export async function summarize({ brief, mode, credentials = null, cliToken = null, fetcher = globalThis.fetch, timeoutMs = 30_000, prompt, maxTokens, provider }) {
21
21
  const effectivePrompt = prompt ?? DEFAULT_PROMPT;
22
22
  const effectiveMaxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
23
23
  if (mode === 'byok') {
24
24
  return byok({ brief, credentials, fetcher, timeoutMs, prompt: effectivePrompt, maxTokens: effectiveMaxTokens, provider });
25
25
  }
26
- return cloud({ brief, licenseKey, fetcher, timeoutMs });
26
+ return cloud({ brief, cliToken, fetcher, timeoutMs });
27
27
  }
28
28
 
29
29
  async function byok({ brief, credentials, fetcher, timeoutMs, prompt, maxTokens, provider }) {
@@ -134,19 +134,24 @@ async function callGroq({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }
134
134
  return data.choices[0].message.content;
135
135
  }
136
136
 
137
- async function cloud({ brief, licenseKey, fetcher, timeoutMs }) {
137
+ async function cloud({ brief, cliToken, fetcher, timeoutMs }) {
138
+ if (!cliToken) throw new Error('Not logged in. Run `ticketlens login` first.');
138
139
  const res = await fetcher(`${apiBase()}/v1/summarize`, {
139
140
  method: 'POST',
140
141
  signal: AbortSignal.timeout(timeoutMs),
141
142
  headers: {
142
143
  'Content-Type': 'application/json',
143
- 'Authorization': `Bearer ${licenseKey}`,
144
+ 'Authorization': `Bearer ${cliToken}`,
144
145
  },
145
146
  body: JSON.stringify({ brief }),
146
147
  });
147
148
 
148
149
  if (!res.ok) {
149
- const err = new Error(`TicketLens API error ${res.status}`);
150
+ let detail = '';
151
+ try { detail = (await res.json()).error ?? ''; } catch {}
152
+ if (!detail && res.status === 503) detail = 'No AI provider configured on the backend. Run: ticketlens cloud-keys add groq <key>';
153
+ if (!detail && res.status === 500) detail = 'Server error (the backend may need `php artisan migrate` after a recent update)';
154
+ const err = new Error(detail || `TicketLens API error ${res.status}`);
150
155
  err.status = res.status;
151
156
  throw err;
152
157
  }
@@ -13,10 +13,9 @@ import {
13
13
  invalidateProfilesCache,
14
14
  } from './profile-resolver.mjs';
15
15
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
16
+ import { apiBase } from './api-utils.mjs';
16
17
 
17
- const DEFAULT_API_BASE = 'https://api.ticketlens.app';
18
-
19
- export const getApiBase = () => process.env.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
18
+ export const getApiBase = () => apiBase();
20
19
  // Strip the api. subdomain to get the console base URL (e.g. api.ticketlens.app → ticketlens.app)
21
20
  export const getConsoleBase = () => getApiBase().replace('://api.', '://');
22
21
 
@@ -34,6 +34,7 @@ export function queueUrl(base) {
34
34
  * @param {string} [opts.cliToken] - CLI session token for the API
35
35
  * @param {string} [opts.capturedAt] - ISO 8601 timestamp (defaults to now)
36
36
  * @param {Array} [opts.gitBranches] - Branch metadata from scanCurrentBranch (null = not in git repo)
37
+ * @param {object} [opts.cliActivity] - Activity counters since last push {fetch_count, triage_run_count, invocations}
37
38
  * @param {Function} [opts.fetcher] - Injectable fetch (default: globalThis.fetch)
38
39
  * @param {Function} [opts.print] - Output fn (default: process.stdout.write)
39
40
  * @returns {Promise<{ ok: boolean, status?: number }>}
@@ -46,6 +47,7 @@ export async function pushTriageSnapshot({
46
47
  cliToken,
47
48
  capturedAt,
48
49
  gitBranches,
50
+ cliActivity,
49
51
  fetcher = globalThis.fetch,
50
52
  print = (s) => process.stdout.write(s),
51
53
  warn = (s) => process.stderr.write(s),
@@ -87,6 +89,7 @@ export async function pushTriageSnapshot({
87
89
  captured_at: capturedAt ?? new Date().toISOString(),
88
90
  tickets,
89
91
  ...(gitBranches != null && { git_branches: gitBranches }),
92
+ ...(cliActivity != null && { cli_activity: cliActivity }),
90
93
  };
91
94
 
92
95
  try {