ticketlens 0.9.2 → 0.9.7
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 +68 -17
- package/bin/ticketlens.mjs +122 -0
- package/package.json +4 -2
- package/scripts/publish.mjs +69 -0
- package/skills/jtb/SKILL.md +9 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +7 -0
- package/skills/jtb/scripts/fetch-ticket.mjs +60 -11
- package/skills/jtb/scripts/lib/activity-counter.mjs +78 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/brief-cache.mjs +31 -0
- package/skills/jtb/scripts/lib/cloud-keys.mjs +100 -0
- package/skills/jtb/scripts/lib/help.mjs +49 -4
- package/skills/jtb/scripts/lib/summarizer.mjs +11 -6
- package/skills/jtb/scripts/lib/sync.mjs +2 -3
- package/skills/jtb/scripts/lib/triage-push.mjs +3 -0
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
|
|
487
|
-
/jtb CNV1-2 --depth=0
|
|
488
|
-
/jtb CNV1-2 --depth=2
|
|
489
|
-
/jtb CNV1-2 --profile=acme
|
|
490
|
-
/jtb CNV1-2 --no-attachments
|
|
491
|
-
/jtb CNV1-2 --no-cache
|
|
492
|
-
/jtb
|
|
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
|
|
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
|
-
|
|
756
|
+
Or use `--cloud` to route through the TicketLens API without managing keys yourself.
|
|
757
|
+
|
|
758
|
+
| Provider | Cost | Sign up |
|
|
709
759
|
|---|---|---|
|
|
710
|
-
|
|
|
711
|
-
|
|
|
712
|
-
|
|
|
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
|
-
**
|
|
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
|
|
718
|
-
ticketlens
|
|
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>
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
|
+
"version": "0.9.7",
|
|
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": {
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "node --test skills/jtb/scripts/test/*.test.mjs",
|
|
19
19
|
"postinstall": "node scripts/postinstall.mjs",
|
|
20
|
-
"prepublishOnly": "node scripts/preflight.mjs"
|
|
20
|
+
"prepublishOnly": "node scripts/preflight.mjs",
|
|
21
|
+
"publish:beta": "node scripts/publish.mjs --tag=beta",
|
|
22
|
+
"publish:latest": "node scripts/publish.mjs --tag=latest"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|
|
23
25
|
"jira",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pack and publish ticketlens to npm.
|
|
4
|
+
*
|
|
5
|
+
* Swaps DEFAULT_API_BASE to the production URL, runs npm pack, reverts the
|
|
6
|
+
* source file (always — even on failure), then publishes the tarball.
|
|
7
|
+
* Source code always stays at the local dev URL; only the tarball gets prod.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/publish.mjs # --tag beta, prod URL default
|
|
11
|
+
* node scripts/publish.mjs --tag=latest # only when hosting is live
|
|
12
|
+
* node scripts/publish.mjs --prod-url=https://... # override prod URL
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const ROOT = resolve(__dir, '..');
|
|
22
|
+
const API_UTILS = join(ROOT, 'skills/jtb/scripts/lib/api-utils.mjs');
|
|
23
|
+
const PROD_URL = 'https://api.ticketlens.app';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const tag = args.find(a => a.startsWith('--tag='))?.split('=')[1] ?? 'beta';
|
|
27
|
+
const prodUrl = args.find(a => a.startsWith('--prod-url='))?.split('=')[1] ?? PROD_URL;
|
|
28
|
+
|
|
29
|
+
const original = readFileSync(API_UTILS, 'utf8');
|
|
30
|
+
const swapped = original.replace(
|
|
31
|
+
/export const DEFAULT_API_BASE\s*=\s*'[^']+'/,
|
|
32
|
+
`export const DEFAULT_API_BASE = '${prodUrl}'`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (original === swapped) {
|
|
36
|
+
process.stderr.write('Error: DEFAULT_API_BASE not found in api-utils.mjs\n');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let tarball = null;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
log(`Swapping DEFAULT_API_BASE → ${prodUrl}`);
|
|
44
|
+
writeFileSync(API_UTILS, swapped, 'utf8');
|
|
45
|
+
|
|
46
|
+
log('Running npm pack...');
|
|
47
|
+
const raw = execSync('npm pack --json', { cwd: ROOT }).toString().trim();
|
|
48
|
+
const info = JSON.parse(raw);
|
|
49
|
+
tarball = Array.isArray(info) ? info[0]?.filename : info?.filename;
|
|
50
|
+
|
|
51
|
+
if (!tarball) throw new Error('npm pack did not return a filename');
|
|
52
|
+
log(`Packed: ${tarball}`);
|
|
53
|
+
|
|
54
|
+
} finally {
|
|
55
|
+
log('Reverting DEFAULT_API_BASE → local dev URL');
|
|
56
|
+
writeFileSync(API_UTILS, original, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
log(`Publishing ${tarball} --tag ${tag}...`);
|
|
61
|
+
execSync(`npm publish "${tarball}" --tag ${tag}`, { cwd: ROOT, stdio: 'inherit' });
|
|
62
|
+
log(`Done — ticketlens published with tag: ${tag}`);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(join(ROOT, tarball), { force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function log(msg) {
|
|
68
|
+
process.stdout.write(`[publish] ${msg}\n`);
|
|
69
|
+
}
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.9.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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 = '
|
|
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
|
+
}
|
|
@@ -50,6 +50,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
50
50
|
` ${s.brand('ticketlens')} license Show license status`,
|
|
51
51
|
` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
|
|
52
52
|
` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
|
|
53
|
+
` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
|
|
53
54
|
` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
|
|
54
55
|
'',
|
|
55
56
|
` ${s.bold('FETCH OPTIONS')}`,
|
|
@@ -111,11 +112,12 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
111
112
|
` ${s.dim('Or use env vars:')} JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN`,
|
|
112
113
|
` ${s.dim(' ')} TICKETLENS_API_URL ${s.dim('(override API host for local dev)')}`,
|
|
113
114
|
'',
|
|
114
|
-
` ${s.dim('BYOK AI keys')} ${s.dim('(
|
|
115
|
-
` ${s.dim(' ')}
|
|
116
|
-
` ${s.dim(' ')}
|
|
117
|
-
` ${s.dim(' ')}
|
|
115
|
+
` ${s.dim('BYOK AI keys')} ${s.dim('(stored encrypted on your account):')}`,
|
|
116
|
+
` ${s.dim(' ')} ticketlens cloud-keys add groq ${s.dim('<key>')}`,
|
|
117
|
+
` ${s.dim(' ')} ticketlens cloud-keys add anthropic ${s.dim('<key>')}`,
|
|
118
|
+
` ${s.dim(' ')} ticketlens cloud-keys add openai ${s.dim('<key>')}`,
|
|
118
119
|
` ${s.dim(' ')} ${s.dim('Set default:')} ticketlens config set aiProvider ${s.dim('<anthropic|openai|groq>')}`,
|
|
120
|
+
` ${s.dim(' ')} ${s.dim('Or manage keys at:')} ${s.dim('Console → Admin → AI Settings')}`,
|
|
119
121
|
'',
|
|
120
122
|
'',
|
|
121
123
|
];
|
|
@@ -752,3 +754,46 @@ export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
|
752
754
|
];
|
|
753
755
|
stream.write(lines.join('\n') + '\n');
|
|
754
756
|
}
|
|
757
|
+
|
|
758
|
+
export function printCloudKeysHelp({ stream = process.stdout } = {}) {
|
|
759
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
760
|
+
const lines = [
|
|
761
|
+
'',
|
|
762
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('cloud-keys')} ${s.dim('<subcommand> [options]')}`,
|
|
763
|
+
'',
|
|
764
|
+
` Manage per-account AI provider keys stored encrypted on the TicketLens backend.`,
|
|
765
|
+
` Requires ${s.bold('ticketlens login')}. Keys are scoped to your account and never shared.`,
|
|
766
|
+
'',
|
|
767
|
+
` ${s.bold('SUBCOMMANDS')}`,
|
|
768
|
+
'',
|
|
769
|
+
` ${s.brand('list')} List configured providers`,
|
|
770
|
+
` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
|
|
771
|
+
` ${s.brand('remove')} ${s.dim('<provider>')} Remove a provider's key`,
|
|
772
|
+
` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
|
|
773
|
+
` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
|
|
774
|
+
` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
|
|
775
|
+
'',
|
|
776
|
+
` ${s.bold('OPTIONS')}`,
|
|
777
|
+
'',
|
|
778
|
+
` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
|
|
779
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
780
|
+
'',
|
|
781
|
+
` ${s.bold('PROVIDERS')}`,
|
|
782
|
+
'',
|
|
783
|
+
` groq Llama 3.x — free tier at ${s.dim('console.groq.com')}`,
|
|
784
|
+
` anthropic Claude — ${s.dim('console.anthropic.com')}`,
|
|
785
|
+
` openai GPT-4o — ${s.dim('platform.openai.com')}`,
|
|
786
|
+
'',
|
|
787
|
+
` ${s.bold('EXAMPLES')}`,
|
|
788
|
+
'',
|
|
789
|
+
` ${s.dim('$')} ticketlens cloud-keys list`,
|
|
790
|
+
` ${s.dim('$')} ticketlens cloud-keys add groq gsk_xxxxxxxxxxxxxxxxxxxx`,
|
|
791
|
+
` ${s.dim('$')} ticketlens cloud-keys add groq gsk_xxx --timeout=10`,
|
|
792
|
+
` ${s.dim('$')} ticketlens cloud-keys test groq`,
|
|
793
|
+
` ${s.dim('$')} ticketlens cloud-keys remove groq`,
|
|
794
|
+
` ${s.dim('$')} ticketlens cloud-keys priority groq 1`,
|
|
795
|
+
` ${s.dim('$')} ticketlens cloud-keys timeout anthropic 15`,
|
|
796
|
+
'',
|
|
797
|
+
];
|
|
798
|
+
stream.write(lines.join('\n') + '\n');
|
|
799
|
+
}
|
|
@@ -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.
|
|
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,
|
|
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,
|
|
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,
|
|
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 ${
|
|
144
|
+
'Authorization': `Bearer ${cliToken}`,
|
|
144
145
|
},
|
|
145
146
|
body: JSON.stringify({ brief }),
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
if (!res.ok) {
|
|
149
|
-
|
|
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
|
|
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 {
|