ticketlens 0.1.23 → 0.1.24

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
@@ -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 # AI summary via your own API key (BYOK) [Pro]
101
- ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens API [Pro]
102
- ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
103
- ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
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 # AI summary via your own API key (BYOK) [Pro]
351
- ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API [Pro]
352
- ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
353
- ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
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 `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API.
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" />
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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": {
@@ -98,6 +98,12 @@ function saveCloudConsent(configDir, profileName) {
98
98
  } catch { /* non-fatal */ }
99
99
  }
100
100
 
101
+ function resolveAiProvider(args, credentials) {
102
+ const flagArg = args.find(a => a.startsWith('--provider='));
103
+ if (flagArg) return flagArg.split('=')[1];
104
+ return credentials?.aiProvider ?? undefined;
105
+ }
106
+
101
107
  /**
102
108
  * Apply --summarize to a brief string.
103
109
  * Returns the modified brief, or null if the caller should exit (license gate / consent refused).
@@ -135,7 +141,8 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
135
141
  });
136
142
  const credentials = opts.credentials ?? loadCredentials(configDir);
137
143
  const licenseKey = readLicense(configDir)?.key;
138
- const summary = await summarizerFn({ brief, mode, credentials, licenseKey });
144
+ const provider = opts.provider ?? resolveAiProvider(args, credentials);
145
+ const summary = await summarizerFn({ brief, mode, credentials, licenseKey, provider });
139
146
  const divider = '─'.repeat(60);
140
147
  return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
141
148
  } catch (err) {
@@ -166,8 +173,9 @@ async function applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn
166
173
  });
167
174
  const credentials = opts.credentials ?? loadCredentials(configDir);
168
175
  const licenseKey = readLicense(configDir)?.key;
176
+ const provider = opts.provider ?? resolveAiProvider(args, credentials);
169
177
  const input = buildHandoffInput(ticket);
170
- const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512 });
178
+ const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512, provider });
171
179
  return `## Handoff Brief — ${ticket.key}\n\n${body}\n`;
172
180
  } catch (err) {
173
181
  const onErrorFn = opts.onError ?? ((msg) => process.stderr.write(msg + '\n'));
@@ -815,7 +823,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
815
823
 
816
824
  const validatedArgs = await handleUnknownFlags(
817
825
  args,
818
- ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff'],
826
+ ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider='],
819
827
  { hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
820
828
  );
821
829
  if (validatedArgs === null) { process.exitCode = 1; return; }
@@ -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 --profile=acme`,
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 (!anthropicKey && !openaiKey) {
32
- throw new Error('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY to ~/.ticketlens/credentials.json');
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
- return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs, prompt, maxTokens });
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',