ticketlens 0.1.23 → 0.1.25
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 +33 -9
- package/bin/ticketlens.mjs +22 -1
- package/package.json +1 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +27 -6
- package/skills/jtb/scripts/lib/handoff-assembler.mjs +68 -7
- package/skills/jtb/scripts/lib/help.mjs +20 -1
- package/skills/jtb/scripts/lib/profile-resolver.mjs +14 -0
- package/skills/jtb/scripts/lib/summarizer.mjs +49 -10
package/README.md
CHANGED
|
@@ -97,10 +97,11 @@ ticketlens CNV1-2 --no-cache # Bypass cache, re-fetch from Jira
|
|
|
97
97
|
ticketlens CNV1-2 --no-attachments # Skip attachment download and Confluence page fetching
|
|
98
98
|
ticketlens CNV1-2 --check # Append local VCS diff + Claude Code review instructions
|
|
99
99
|
ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
100
|
-
ticketlens CNV1-2 --summarize
|
|
101
|
-
ticketlens CNV1-2 --summarize --
|
|
102
|
-
ticketlens CNV1-2 --
|
|
103
|
-
ticketlens CNV1-2 --handoff
|
|
100
|
+
ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK) [Pro]
|
|
101
|
+
ticketlens CNV1-2 --summarize --provider=groq # Force a specific AI provider [Pro]
|
|
102
|
+
ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens API [Pro]
|
|
103
|
+
ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
|
|
104
|
+
ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
| `--depth` | Scope |
|
|
@@ -328,6 +329,7 @@ ticketlens init # Guided wizard (recommended)
|
|
|
328
329
|
ticketlens switch # Switch between configured profiles
|
|
329
330
|
ticketlens config # Edit the active profile
|
|
330
331
|
ticketlens config --profile=acme # Edit a specific profile
|
|
332
|
+
ticketlens config set aiProvider groq # Set default AI provider (groq|openai|anthropic)
|
|
331
333
|
ticketlens profiles # List all configured profiles
|
|
332
334
|
ticketlens ls # Alias for profiles
|
|
333
335
|
ticketlens profiles --plain # Tab-separated (scripts / pipes)
|
|
@@ -347,10 +349,11 @@ ticketlens CNV1-2 --no-attachments # Skip attachment download entirely
|
|
|
347
349
|
ticketlens CNV1-2 --no-cache # Skip brief cache + force re-download
|
|
348
350
|
ticketlens CNV1-2 --check # Append local VCS diff + Claude Code review instructions
|
|
349
351
|
ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
350
|
-
ticketlens CNV1-2 --summarize
|
|
351
|
-
ticketlens CNV1-2 --summarize --
|
|
352
|
-
ticketlens CNV1-2 --
|
|
353
|
-
ticketlens CNV1-2 --handoff
|
|
352
|
+
ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK) [Pro]
|
|
353
|
+
ticketlens CNV1-2 --summarize --provider=groq # Force Groq (Llama 3.1, free tier) [Pro]
|
|
354
|
+
ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API [Pro]
|
|
355
|
+
ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
|
|
356
|
+
ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
|
|
354
357
|
ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
|
|
355
358
|
|
|
356
359
|
# Pipe plain output to clipboard, LLM, or file
|
|
@@ -469,7 +472,28 @@ ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
|
469
472
|
- **Open questions** — decisions not yet made
|
|
470
473
|
- **Recommendation** — where to start
|
|
471
474
|
|
|
472
|
-
Add
|
|
475
|
+
Add one of the following to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API:
|
|
476
|
+
|
|
477
|
+
| Key | Provider | Cost |
|
|
478
|
+
|---|---|---|
|
|
479
|
+
| `anthropicApiKey` | Anthropic (Claude Haiku) | Paid |
|
|
480
|
+
| `openaiApiKey` | OpenAI (GPT-4o mini) | Paid |
|
|
481
|
+
| `groqApiKey` | Groq (Llama 3.1 8B) | **Free tier** — [console.groq.com](https://console.groq.com) |
|
|
482
|
+
|
|
483
|
+
**Selecting a provider:** By default, the first available key is used (Anthropic → OpenAI → Groq). To set a persistent default:
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
ticketlens config set aiProvider groq # always use Groq
|
|
487
|
+
ticketlens config set aiProvider openai # always use OpenAI
|
|
488
|
+
ticketlens config set aiProvider anthropic # always use Anthropic
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Override per-command with `--provider=`:
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
ticketlens CNV1-2 --summarize --provider=groq
|
|
495
|
+
ticketlens CNV1-2 --handoff --provider=openai
|
|
496
|
+
```
|
|
473
497
|
|
|
474
498
|
<div align="center">
|
|
475
499
|
<img src="docs/demos/pro-triage.gif" alt="ticketlens triage --stale=3 demo" width="700" />
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { run as runInit } from '../skills/jtb/scripts/lib/init-wizard.mjs';
|
|
|
15
15
|
import { runSwitch } from '../skills/jtb/scripts/lib/profile-switcher.mjs';
|
|
16
16
|
import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
|
|
17
17
|
import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
|
|
18
|
-
import { deleteProfile, loadProfiles } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
18
|
+
import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
19
19
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
20
20
|
import {
|
|
21
21
|
printHelp, printProfiles,
|
|
@@ -70,6 +70,27 @@ switch (command) {
|
|
|
70
70
|
|
|
71
71
|
case 'config': {
|
|
72
72
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printConfigHelp(); break; }
|
|
73
|
+
|
|
74
|
+
if (cmdArgs[0] === 'set' && cmdArgs[1] === 'aiProvider') {
|
|
75
|
+
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
76
|
+
const value = cmdArgs[2];
|
|
77
|
+
if (!value) {
|
|
78
|
+
process.stderr.write(`${s.red('✖')} Missing value.\n Usage: ticketlens config set aiProvider <anthropic|openai|groq>\n`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
const validProviders = ['anthropic', 'openai', 'groq'];
|
|
83
|
+
if (!validProviders.includes(value)) {
|
|
84
|
+
process.stderr.write(`${s.red('✖')} Unknown provider "${value}". Valid: ${validProviders.join(', ')}\n`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
saveCredentialKey('aiProvider', value);
|
|
89
|
+
process.stdout.write(` ${s.green('✔')} AI provider set to ${s.bold(s.cyan(value))}\n`);
|
|
90
|
+
process.stdout.write(` ${s.dim('Applied when running --summarize or --handoff without --provider=')}\n`);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
const profileArg = cmdArgs.find(a => a.startsWith('--profile='));
|
|
74
95
|
const profileName = profileArg ? profileArg.split('=')[1] : undefined;
|
|
75
96
|
runConfig({ profileName }).catch(err => {
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { classifyError } from './lib/error-classifier.mjs';
|
|
|
19
19
|
import { promptProfileSelect, promptProfileMismatch, promptSwitchProfile, promptMultipleMatches } from './lib/profile-picker.mjs';
|
|
20
20
|
import { promptSelect } from './lib/select-prompt.mjs';
|
|
21
21
|
import { printFetchHelp } from './lib/help.mjs';
|
|
22
|
+
import { readTextAttachments } from './lib/handoff-assembler.mjs';
|
|
22
23
|
import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
23
24
|
import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
|
|
24
25
|
import { downloadAttachments } from './lib/attachment-downloader.mjs';
|
|
@@ -98,11 +99,28 @@ function saveCloudConsent(configDir, profileName) {
|
|
|
98
99
|
} catch { /* non-fatal */ }
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
function resolveAiProvider(args, credentials) {
|
|
103
|
+
const flagArg = args.find(a => a.startsWith('--provider='));
|
|
104
|
+
if (flagArg) return flagArg.split('=')[1];
|
|
105
|
+
return credentials?.aiProvider ?? undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Append text-readable attachment content to a brief for AI consumption only.
|
|
110
|
+
* The returned string is only sent to the AI — the displayed brief is unchanged.
|
|
111
|
+
*/
|
|
112
|
+
function augmentBriefForAi(brief, localAttachments) {
|
|
113
|
+
const textFiles = readTextAttachments(localAttachments);
|
|
114
|
+
if (textFiles.length === 0) return brief;
|
|
115
|
+
const sections = textFiles.map(({ filename, content }) => `=== Attachment: ${filename} ===\n${content}`);
|
|
116
|
+
return brief + `\n\n--- Attached Documents (${textFiles.length} text-readable) ---\n\n${sections.join('\n\n')}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
/**
|
|
102
120
|
* Apply --summarize to a brief string.
|
|
103
121
|
* Returns the modified brief, or null if the caller should exit (license gate / consent refused).
|
|
104
122
|
*/
|
|
105
|
-
async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn) {
|
|
123
|
+
async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, ticket) {
|
|
106
124
|
if (!licensedFn('pro', configDir)) {
|
|
107
125
|
upgradeFn('pro', '--summarize');
|
|
108
126
|
process.exitCode = 1;
|
|
@@ -135,7 +153,9 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
|
|
|
135
153
|
});
|
|
136
154
|
const credentials = opts.credentials ?? loadCredentials(configDir);
|
|
137
155
|
const licenseKey = readLicense(configDir)?.key;
|
|
138
|
-
const
|
|
156
|
+
const provider = opts.provider ?? resolveAiProvider(args, credentials);
|
|
157
|
+
const aiInput = augmentBriefForAi(brief, ticket?.localAttachments);
|
|
158
|
+
const summary = await summarizerFn({ brief: aiInput, mode, credentials, licenseKey, provider });
|
|
139
159
|
const divider = '─'.repeat(60);
|
|
140
160
|
return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
|
|
141
161
|
} catch (err) {
|
|
@@ -166,8 +186,9 @@ async function applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn
|
|
|
166
186
|
});
|
|
167
187
|
const credentials = opts.credentials ?? loadCredentials(configDir);
|
|
168
188
|
const licenseKey = readLicense(configDir)?.key;
|
|
189
|
+
const provider = opts.provider ?? resolveAiProvider(args, credentials);
|
|
169
190
|
const input = buildHandoffInput(ticket);
|
|
170
|
-
const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512 });
|
|
191
|
+
const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512, provider });
|
|
171
192
|
return `## Handoff Brief — ${ticket.key}\n\n${body}\n`;
|
|
172
193
|
} catch (err) {
|
|
173
194
|
const onErrorFn = opts.onError ?? ((msg) => process.stderr.write(msg + '\n'));
|
|
@@ -815,7 +836,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
815
836
|
|
|
816
837
|
const validatedArgs = await handleUnknownFlags(
|
|
817
838
|
args,
|
|
818
|
-
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff'],
|
|
839
|
+
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider='],
|
|
819
840
|
{ hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
|
|
820
841
|
);
|
|
821
842
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
@@ -947,7 +968,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
947
968
|
if (args.includes('--check')) brief = applyCheck(brief, opts);
|
|
948
969
|
|
|
949
970
|
if (args.includes('--summarize')) {
|
|
950
|
-
brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn);
|
|
971
|
+
brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, cached.ticket);
|
|
951
972
|
if (brief === null) return;
|
|
952
973
|
}
|
|
953
974
|
|
|
@@ -1133,7 +1154,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1133
1154
|
if (args.includes('--check')) output = applyCheck(output, opts);
|
|
1134
1155
|
|
|
1135
1156
|
if (args.includes('--summarize')) {
|
|
1136
|
-
output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn);
|
|
1157
|
+
output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn, ticket);
|
|
1137
1158
|
if (output === null) return;
|
|
1138
1159
|
}
|
|
1139
1160
|
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HANDOFF_PROMPT = `Build a structured handoff brief from this Jira ticket.
|
|
2
5
|
The developer receiving this ticket has never seen it before — they need to get up to speed immediately.
|
|
6
|
+
Use the description, comments, attached documents, and linked Confluence pages as context.
|
|
3
7
|
|
|
4
8
|
Respond in exactly this format (use the exact headings):
|
|
5
9
|
|
|
6
10
|
### What was attempted
|
|
7
|
-
[2–5 bullet points of concrete work already done
|
|
11
|
+
[2–5 bullet points of concrete work already done. Be specific — mention code paths, methods, or files if referenced.]
|
|
8
12
|
|
|
9
13
|
### Current blockers
|
|
10
|
-
[Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the
|
|
14
|
+
[Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the path is clear.]
|
|
11
15
|
|
|
12
16
|
### Open questions
|
|
13
17
|
[Bullet points of unanswered questions or decisions not yet made. Write "None identified" if everything is resolved.]
|
|
@@ -16,16 +20,46 @@ Respond in exactly this format (use the exact headings):
|
|
|
16
20
|
[1–2 sentences on the best starting point for the incoming developer.]
|
|
17
21
|
|
|
18
22
|
Rules:
|
|
19
|
-
- Be specific and factual. Reference actual details from the
|
|
20
|
-
- Do not invent anything not present in the
|
|
23
|
+
- Be specific and factual. Reference actual details from the ticket content.
|
|
24
|
+
- Do not invent anything not present in the provided context.
|
|
21
25
|
- Keep each bullet under 20 words.
|
|
22
|
-
- If there are no comments,
|
|
26
|
+
- If there are no comments, base your analysis on the description and attached documents.
|
|
23
27
|
|
|
24
28
|
`;
|
|
25
29
|
|
|
30
|
+
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.log', '.csv', '.json', '.xml', '.html', '.htm', '.rst', '.yaml', '.yml']);
|
|
31
|
+
const MAX_CHARS_PER_FILE = 4000;
|
|
32
|
+
const MAX_TOTAL_CHARS = 12000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read text content from downloaded local attachments.
|
|
36
|
+
* Skips binary files (images, PDFs, Office docs). Caps content per file and in total.
|
|
37
|
+
* @param {Array} localAttachments - from ticket.localAttachments
|
|
38
|
+
* @returns {Array<{filename: string, content: string}>}
|
|
39
|
+
*/
|
|
40
|
+
export function readTextAttachments(localAttachments = []) {
|
|
41
|
+
let totalChars = 0;
|
|
42
|
+
const result = [];
|
|
43
|
+
for (const att of localAttachments) {
|
|
44
|
+
if (!att.localPath || att.skipReason === 'error') continue;
|
|
45
|
+
if (!TEXT_EXTENSIONS.has(extname(att.filename).toLowerCase())) continue;
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(att.localPath, 'utf8');
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (!trimmed) continue;
|
|
50
|
+
const remaining = MAX_TOTAL_CHARS - totalChars;
|
|
51
|
+
if (remaining <= 0) break;
|
|
52
|
+
const content = trimmed.slice(0, Math.min(MAX_CHARS_PER_FILE, remaining));
|
|
53
|
+
totalChars += content.length;
|
|
54
|
+
result.push({ filename: att.filename, content });
|
|
55
|
+
} catch { /* unreadable — skip */ }
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Build the text input sent to the AI for handoff analysis.
|
|
28
|
-
*
|
|
62
|
+
* Includes description, Confluence pages, text-readable attachments, and comment thread.
|
|
29
63
|
*
|
|
30
64
|
* @param {object} ticket - Normalized ticket object from jira-client.normalizeTicket
|
|
31
65
|
* @returns {string}
|
|
@@ -40,6 +74,33 @@ export function buildHandoffInput(ticket) {
|
|
|
40
74
|
if (ticket.reporter) lines.push(`Reporter: ${ticket.reporter}`);
|
|
41
75
|
lines.push('');
|
|
42
76
|
|
|
77
|
+
if (ticket.description) {
|
|
78
|
+
lines.push('--- Description ---');
|
|
79
|
+
lines.push(ticket.description.replace(/\r/g, ''));
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (ticket.confluencePages?.length > 0) {
|
|
84
|
+
lines.push(`--- Confluence Pages (${ticket.confluencePages.length}) ---`);
|
|
85
|
+
for (const p of ticket.confluencePages) {
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push(`### ${p.title ?? p.url}`);
|
|
88
|
+
if (p.text) lines.push(p.text);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const textAttachments = readTextAttachments(ticket.localAttachments);
|
|
94
|
+
if (textAttachments.length > 0) {
|
|
95
|
+
lines.push(`--- Attached Documents (${textAttachments.length} text-readable) ---`);
|
|
96
|
+
for (const { filename, content } of textAttachments) {
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(`=== ${filename} ===`);
|
|
99
|
+
lines.push(content);
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
}
|
|
103
|
+
|
|
43
104
|
const comments = ticket.comments ?? [];
|
|
44
105
|
lines.push(`--- Comments (${comments.length} total) ---`);
|
|
45
106
|
|
|
@@ -98,6 +98,11 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
98
98
|
` ${s.dim('Or use env vars:')} JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN`,
|
|
99
99
|
` ${s.dim(' ')} TICKETLENS_API_URL ${s.dim('(override API host for local dev)')}`,
|
|
100
100
|
'',
|
|
101
|
+
` ${s.dim('BYOK AI keys')} ${s.dim('(add to credentials.json):')}`,
|
|
102
|
+
` ${s.dim(' ')} anthropicApiKey ${s.dim('→ Claude (paid)')}`,
|
|
103
|
+
` ${s.dim(' ')} openaiApiKey ${s.dim('→ GPT-4o mini (paid)')}`,
|
|
104
|
+
` ${s.dim(' ')} groqApiKey ${s.dim('→ Llama 3.1 (free tier — console.groq.com)')}`,
|
|
105
|
+
'',
|
|
101
106
|
'',
|
|
102
107
|
];
|
|
103
108
|
|
|
@@ -131,6 +136,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
131
136
|
` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
132
137
|
` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
133
138
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
139
|
+
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
134
140
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
135
141
|
'',
|
|
136
142
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -140,6 +146,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
140
146
|
` ${s.dim('$')} ticketlens PROJ-123 --profile=acme --depth=2`,
|
|
141
147
|
` ${s.dim('$')} ticketlens PROJ-123 --handoff`,
|
|
142
148
|
` ${s.dim('$')} ticketlens PROJ-123 --handoff --cloud`,
|
|
149
|
+
` ${s.dim('$')} ticketlens PROJ-123 --summarize --provider=groq`,
|
|
143
150
|
'',
|
|
144
151
|
];
|
|
145
152
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -484,10 +491,21 @@ export function printConfigHelp({ stream = process.stdout } = {}) {
|
|
|
484
491
|
const lines = [
|
|
485
492
|
'',
|
|
486
493
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('config')} ${s.dim('[--profile=NAME]')}`,
|
|
494
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('config set aiProvider')} ${s.dim('<anthropic|openai|groq>')}`,
|
|
487
495
|
'',
|
|
488
496
|
` Edit settings for an existing profile using an interactive wizard.`,
|
|
489
497
|
` Without ${s.cyan('--profile')}, edits the currently active profile.`,
|
|
490
498
|
'',
|
|
499
|
+
` Use ${s.cyan('config set aiProvider')} to set a persistent default AI provider`,
|
|
500
|
+
` for ${s.cyan('--summarize')} and ${s.cyan('--handoff')}. Overridden per-command with ${s.cyan('--provider=')}.`,
|
|
501
|
+
'',
|
|
502
|
+
` ${s.bold('SUBCOMMANDS')}`,
|
|
503
|
+
'',
|
|
504
|
+
` ${s.brand('set aiProvider')} ${s.dim('<PROVIDER>')} Persist default AI provider`,
|
|
505
|
+
` ${s.dim('anthropic')} = Claude Haiku ${s.dim('(paid)')}`,
|
|
506
|
+
` ${s.dim('openai')} = GPT-4o mini ${s.dim('(paid)')}`,
|
|
507
|
+
` ${s.dim('groq')} = Llama 3.1 ${s.dim('(free tier)')}`,
|
|
508
|
+
'',
|
|
491
509
|
` ${s.bold('OPTIONS')}`,
|
|
492
510
|
'',
|
|
493
511
|
` ${s.brand('--profile')}=${s.dim('NAME')} Profile to configure`,
|
|
@@ -497,7 +515,8 @@ export function printConfigHelp({ stream = process.stdout } = {}) {
|
|
|
497
515
|
'',
|
|
498
516
|
` ${s.dim('$')} ticketlens config`,
|
|
499
517
|
` ${s.dim('$')} ticketlens config --profile=work`,
|
|
500
|
-
` ${s.dim('$')} ticketlens config
|
|
518
|
+
` ${s.dim('$')} ticketlens config set aiProvider groq`,
|
|
519
|
+
` ${s.dim('$')} ticketlens config set aiProvider anthropic`,
|
|
501
520
|
'',
|
|
502
521
|
];
|
|
503
522
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -120,6 +120,20 @@ export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
120
120
|
return data;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
|
|
124
|
+
mkdirSync(configDir, { recursive: true });
|
|
125
|
+
const credPath = join(configDir, 'credentials.json');
|
|
126
|
+
const creds = loadCredentials(configDir);
|
|
127
|
+
if (value === null || value === undefined) {
|
|
128
|
+
delete creds[key];
|
|
129
|
+
} else {
|
|
130
|
+
creds[key] = value;
|
|
131
|
+
}
|
|
132
|
+
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
|
|
133
|
+
chmodSync(credPath, 0o600);
|
|
134
|
+
invalidateProfilesCache(configDir);
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
export function expandTilde(p) {
|
|
124
138
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
125
139
|
if (p === '~') return homedir();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
|
2
2
|
const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
|
|
3
|
+
const GROQ_URL = 'https://api.groq.com/openai/v1/chat/completions';
|
|
3
4
|
const CLOUD_URL = 'https://api.ticketlens.dev/v1/summarize';
|
|
4
5
|
const DEFAULT_PROMPT = 'Summarize this Jira ticket in 3 sentences. Focus on what matters most for implementation. Be concrete.\n\n';
|
|
5
6
|
const DEFAULT_MAX_TOKENS = 256;
|
|
@@ -9,34 +10,45 @@ const DEFAULT_MAX_TOKENS = 256;
|
|
|
9
10
|
* @param {object} opts
|
|
10
11
|
* @param {string} opts.brief - Markdown brief text
|
|
11
12
|
* @param {'byok'|'cloud'} opts.mode
|
|
12
|
-
* @param {object} [opts.credentials] - { anthropicApiKey?, openaiApiKey? }
|
|
13
|
+
* @param {object} [opts.credentials] - { anthropicApiKey?, openaiApiKey?, groqApiKey? }
|
|
13
14
|
* @param {string} [opts.licenseKey] - Required for cloud mode
|
|
14
15
|
* @param {Function} [opts.fetcher] - Injectable for tests (defaults to globalThis.fetch)
|
|
15
16
|
* @param {number} [opts.timeoutMs]
|
|
16
17
|
* @returns {Promise<string>} Summary text
|
|
17
18
|
*/
|
|
18
|
-
export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000, prompt, maxTokens }) {
|
|
19
|
+
export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000, prompt, maxTokens, provider }) {
|
|
19
20
|
const effectivePrompt = prompt ?? DEFAULT_PROMPT;
|
|
20
21
|
const effectiveMaxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
21
22
|
if (mode === 'byok') {
|
|
22
|
-
return byok({ brief, credentials, fetcher, timeoutMs, prompt: effectivePrompt, maxTokens: effectiveMaxTokens });
|
|
23
|
+
return byok({ brief, credentials, fetcher, timeoutMs, prompt: effectivePrompt, maxTokens: effectiveMaxTokens, provider });
|
|
23
24
|
}
|
|
24
25
|
return cloud({ brief, licenseKey, fetcher, timeoutMs });
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
async function byok({ brief, credentials, fetcher, timeoutMs, prompt, maxTokens }) {
|
|
28
|
+
async function byok({ brief, credentials, fetcher, timeoutMs, prompt, maxTokens, provider }) {
|
|
28
29
|
const anthropicKey = credentials?.anthropicApiKey;
|
|
29
30
|
const openaiKey = credentials?.openaiApiKey;
|
|
31
|
+
const groqKey = credentials?.groqApiKey;
|
|
30
32
|
|
|
31
|
-
if (
|
|
32
|
-
throw new Error('No
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (anthropicKey) {
|
|
33
|
+
if (provider === 'anthropic') {
|
|
34
|
+
if (!anthropicKey) throw new Error('No ANTHROPIC_API_KEY found. Add it to ~/.ticketlens/credentials.json');
|
|
36
35
|
return callAnthropic({ brief, apiKey: anthropicKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
37
36
|
}
|
|
37
|
+
if (provider === 'openai') {
|
|
38
|
+
if (!openaiKey) throw new Error('No OPENAI_API_KEY found. Add it to ~/.ticketlens/credentials.json');
|
|
39
|
+
return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
40
|
+
}
|
|
41
|
+
if (provider === 'groq') {
|
|
42
|
+
if (!groqKey) throw new Error('No GROQ_API_KEY found. Add it to ~/.ticketlens/credentials.json');
|
|
43
|
+
return callGroq({ brief, apiKey: groqKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
44
|
+
}
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
if (!anthropicKey && !openaiKey && !groqKey) {
|
|
47
|
+
throw new Error('No API key found. Add ANTHROPIC_API_KEY, OPENAI_API_KEY, or GROQ_API_KEY to ~/.ticketlens/credentials.json');
|
|
48
|
+
}
|
|
49
|
+
if (anthropicKey) return callAnthropic({ brief, apiKey: anthropicKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
50
|
+
if (openaiKey) return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
51
|
+
return callGroq({ brief, apiKey: groqKey, fetcher, timeoutMs, prompt, maxTokens });
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
async function callAnthropic({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }) {
|
|
@@ -94,6 +106,33 @@ async function callOpenAi({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens
|
|
|
94
106
|
return data.choices[0].message.content;
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
async function callGroq({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }) {
|
|
110
|
+
const res = await fetcher(GROQ_URL, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
model: 'llama-3.1-8b-instant',
|
|
119
|
+
max_tokens: maxTokens,
|
|
120
|
+
messages: [{ role: 'user', content: prompt + brief }],
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
let detail = '';
|
|
126
|
+
try { detail = (await res.json()).error?.message || ''; } catch {}
|
|
127
|
+
const err = new Error(`Groq API error ${res.status}${detail ? ': ' + detail : ''}`);
|
|
128
|
+
err.status = res.status;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = await res.json();
|
|
133
|
+
return data.choices[0].message.content;
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
async function cloud({ brief, licenseKey, fetcher, timeoutMs }) {
|
|
98
137
|
const res = await fetcher(CLOUD_URL, {
|
|
99
138
|
method: 'POST',
|