ticketlens 0.1.0

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Cache management for downloaded Jira attachments.
3
+ * Supports: size inspection, selective clearing with age/profile filters.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { formatSize } from './attachment-downloader.mjs';
9
+ import { createStyler } from './ansi.mjs';
10
+ import { loadProfiles } from './profile-resolver.mjs';
11
+ import { promptSelect } from './select-prompt.mjs';
12
+ import { getBriefCacheEntries, clearBriefCache, briefCacheAge, DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
13
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
14
+
15
+ // Age unit → milliseconds
16
+ const AGE_UNIT_MS = { h: 3600000, d: 86400000, w: 7 * 86400000, m: 30 * 86400000, y: 365 * 86400000 };
17
+
18
+ // Sentinel returned by showProfilePicker when the user presses Esc
19
+ const CANCELLED = Symbol('CANCELLED');
20
+
21
+ /**
22
+ * Parses an age string like "4h", "7d", "2w", "2m", "1y" into milliseconds.
23
+ * Returns null on invalid input.
24
+ */
25
+ export function parseAge(str) {
26
+ if (!str) return null;
27
+ const match = str.match(/^(\d+)(h|d|w|m|y)$/);
28
+ if (!match) return null;
29
+ const [, n, unit] = match;
30
+ return parseInt(n, 10) * AGE_UNIT_MS[unit];
31
+ }
32
+
33
+ /**
34
+ * Returns all cached file entries, optionally filtered by ticket key.
35
+ * Each entry: { ticketKey, filename, localPath, size, mtimeMs }
36
+ */
37
+ export function getCacheEntries(configDir = DEFAULT_CONFIG_DIR, ticketKey = null) {
38
+ const cacheDir = path.join(configDir, 'cache');
39
+ if (!fs.existsSync(cacheDir)) return [];
40
+
41
+ const ticketDirs = ticketKey
42
+ ? [ticketKey]
43
+ : fs.readdirSync(cacheDir).filter(d => {
44
+ try { return fs.statSync(path.join(cacheDir, d)).isDirectory(); } catch { return false; }
45
+ });
46
+
47
+ const entries = [];
48
+ for (const ticket of ticketDirs) {
49
+ const ticketDir = path.join(cacheDir, ticket);
50
+ if (!fs.existsSync(ticketDir)) continue;
51
+
52
+ let files;
53
+ try { files = fs.readdirSync(ticketDir); } catch { continue; }
54
+
55
+ for (const file of files) {
56
+ const filePath = path.join(ticketDir, file);
57
+ try {
58
+ const stat = fs.statSync(filePath);
59
+ if (stat.isFile()) {
60
+ entries.push({ ticketKey: ticket, filename: file, localPath: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
61
+ }
62
+ } catch { /* deleted between readdir and stat */ }
63
+ }
64
+ }
65
+
66
+ return entries;
67
+ }
68
+
69
+ /**
70
+ * Returns total cache size in bytes.
71
+ */
72
+ export function getCacheSize(configDir = DEFAULT_CONFIG_DIR) {
73
+ return getCacheEntries(configDir).reduce((sum, e) => sum + e.size, 0);
74
+ }
75
+
76
+ /**
77
+ * Formats a file modification time as a human-readable age string.
78
+ */
79
+ export function formatAge(mtimeMs) {
80
+ const days = Math.floor((Date.now() - mtimeMs) / (24 * 60 * 60 * 1000));
81
+ if (days === 0) return 'today';
82
+ if (days === 1) return '1 day ago';
83
+ if (days < 30) return `${days} days ago`;
84
+ const months = Math.floor(days / 30);
85
+ if (months === 1) return '1 month ago';
86
+ if (months < 12) return `${months} months ago`;
87
+ const years = Math.floor(days / 365);
88
+ return `${years} year${years > 1 ? 's' : ''} ago`;
89
+ }
90
+
91
+ /**
92
+ * Groups cache entries by profile (inferred from ticket-key prefix → ticketPrefixes map).
93
+ * Returns sorted array: configured profiles alphabetically, unconfigured last.
94
+ * Each element: { name: string|null, entries, size, prefixes: string[] }
95
+ */
96
+ function groupEntriesByProfile(entries, config) {
97
+ const prefixToProfile = {};
98
+ for (const [name, p] of Object.entries(config?.profiles ?? {})) {
99
+ for (const prefix of (p.ticketPrefixes ?? [])) {
100
+ prefixToProfile[prefix] = name;
101
+ }
102
+ }
103
+
104
+ const groupMap = {};
105
+ for (const e of entries) {
106
+ const prefix = e.ticketKey.split('-')[0];
107
+ const name = prefixToProfile[prefix] ?? null;
108
+ const key = name ?? '\x00'; // sort unconfigured last
109
+ if (!groupMap[key]) groupMap[key] = { name, entries: [], size: 0, prefixes: [] };
110
+ groupMap[key].entries.push(e);
111
+ groupMap[key].size += e.size;
112
+ if (!groupMap[key].prefixes.includes(prefix)) groupMap[key].prefixes.push(prefix);
113
+ }
114
+
115
+ return Object.values(groupMap).sort((a, b) => {
116
+ if (a.name === null) return 1;
117
+ if (b.name === null) return -1;
118
+ return a.name.localeCompare(b.name);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Filters entries to only those belonging to the given profile (by ticketPrefixes).
124
+ * Returns all entries if the profile has no ticketPrefixes configured.
125
+ */
126
+ function filterEntriesByProfile(entries, profileName, config) {
127
+ const prefixes = config?.profiles?.[profileName]?.ticketPrefixes ?? [];
128
+ if (prefixes.length === 0) return entries;
129
+ return entries.filter(e => prefixes.includes(e.ticketKey.split('-')[0]));
130
+ }
131
+
132
+ /**
133
+ * Entry point for `ticketlens cache <subcommand> [args]`
134
+ */
135
+ export async function run(args, opts = {}) {
136
+ const {
137
+ configDir = DEFAULT_CONFIG_DIR,
138
+ stdin = process.stdin,
139
+ stdout = process.stdout,
140
+ stderr = process.stderr,
141
+ } = opts;
142
+
143
+ const s = createStyler({ isTTY: stdout.isTTY });
144
+ const sub = args[0];
145
+
146
+ if (!sub || sub === '--help' || sub === '-h') {
147
+ printCacheHelp(stdout, s);
148
+ return;
149
+ }
150
+
151
+ if (sub === 'size') {
152
+ const sizeArgs = args.slice(1);
153
+ if (sizeArgs.includes('--help') || sizeArgs.includes('-h')) {
154
+ printCacheSizeHelp(stdout, s);
155
+ return;
156
+ }
157
+ const sizeProfileArg = sizeArgs.find(a => a.startsWith('--profile='));
158
+ const sizeProfileName = sizeProfileArg ? sizeProfileArg.split('=')[1] : null;
159
+ runSize(configDir, stdout, s, sizeProfileName);
160
+ return;
161
+ }
162
+
163
+ if (sub === 'clear') {
164
+ const clearArgs = args.slice(1);
165
+ if (clearArgs.includes('--help') || clearArgs.includes('-h')) {
166
+ printCacheClearHelp(stdout, s);
167
+ return;
168
+ }
169
+ await runClear(clearArgs, { configDir, stdin, stdout, stderr, s });
170
+ return;
171
+ }
172
+
173
+ stderr.write(`Unknown cache subcommand: "${sub}". Try: ticketlens cache clear | ticketlens cache size\n`);
174
+ process.exitCode = 1;
175
+ }
176
+
177
+ // ─── size ────────────────────────────────────────────────────────────────────
178
+
179
+ function runSize(configDir, stdout, s, profileName = null) {
180
+ let entries = getCacheEntries(configDir);
181
+ const config = loadProfiles(configDir);
182
+
183
+ if (profileName) {
184
+ entries = filterEntriesByProfile(entries, profileName, config);
185
+ }
186
+
187
+ let briefEntries = getBriefCacheEntries(configDir);
188
+ if (profileName) briefEntries = briefEntries.filter(e => e.profileName === profileName);
189
+
190
+ if (entries.length === 0 && briefEntries.length === 0) {
191
+ const hint = profileName ? `profile "${profileName}"` : 'any profile';
192
+ stdout.write(`No cached files found for ${hint}.\n${s.dim('Run ticketlens TICKET-KEY to fetch a ticket.')}\n`);
193
+ return;
194
+ }
195
+
196
+ const lines = [];
197
+
198
+ if (entries.length > 0) {
199
+ const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
200
+ const groups = groupEntriesByProfile(entries, config);
201
+ const ticketCount = new Set(entries.map(e => e.ticketKey)).size;
202
+
203
+ const profileScope = profileName ? ` ${s.dim(`(profile: ${profileName})`)}` : '';
204
+ lines.push(`\n${s.bold('Attachment Cache')}${profileScope} — ${formatSize(totalSize)}, ${plural(entries.length, 'file')} across ${plural(ticketCount, 'ticket')}\n`);
205
+
206
+ for (const group of groups) {
207
+ const profileLabel = group.name ? s.bold(s.cyan(group.name)) : s.dim('(unconfigured)');
208
+ const prefixList = group.prefixes.sort().join(s.dim(' · '));
209
+ const summary = `${formatSize(group.size)}, ${plural(group.entries.length, 'file')}`;
210
+ lines.push(`\n ${profileLabel} ${s.dim(prefixList)} — ${summary}`);
211
+
212
+ const byTicket = {};
213
+ for (const e of group.entries) {
214
+ if (!byTicket[e.ticketKey]) byTicket[e.ticketKey] = { files: 0, size: 0 };
215
+ byTicket[e.ticketKey].files++;
216
+ byTicket[e.ticketKey].size += e.size;
217
+ }
218
+ for (const [ticket, info] of Object.entries(byTicket).sort()) {
219
+ lines.push(` ${s.cyan(ticket)} ${plural(info.files, 'file')}, ${formatSize(info.size)}`);
220
+ }
221
+ }
222
+ }
223
+
224
+ if (briefEntries.length > 0) {
225
+ const briefSize = briefEntries.reduce((sum, e) => sum + e.size, 0);
226
+ lines.push(`\n${s.bold('Brief Cache')} — ${formatSize(briefSize)}, ${plural(briefEntries.length, 'brief')} cached\n`);
227
+ const byProfile = {};
228
+ for (const e of briefEntries) {
229
+ if (!byProfile[e.profileName]) byProfile[e.profileName] = [];
230
+ byProfile[e.profileName].push(e);
231
+ }
232
+ for (const [pName, pEntries] of Object.entries(byProfile).sort()) {
233
+ const profileTtl = config?.profiles?.[pName]?.cacheTtl ?? DEFAULT_BRIEF_TTL;
234
+ lines.push(` ${s.bold(s.cyan(pName))} ${s.dim(`TTL: ${profileTtl}`)}`);
235
+ for (const e of pEntries.sort((a, b) => a.ticketKey.localeCompare(b.ticketKey))) {
236
+ const age = e.fetchedAt ? briefCacheAge(e.fetchedAt) : 'unknown';
237
+ const depthLabel = e.depth != null ? `depth ${e.depth}` : '';
238
+ lines.push(` ${s.cyan(e.ticketKey)} ${s.dim(age)}${depthLabel ? s.dim(` · ${depthLabel}`) : ''}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ lines.push(`\n${s.dim(`Cache location: ${path.join(configDir, 'cache')}`)}`);
244
+ stdout.write(lines.join('\n') + '\n\n');
245
+ }
246
+
247
+ // ─── clear ───────────────────────────────────────────────────────────────────
248
+
249
+ async function runClear(args, { configDir, stdin, stdout, stderr, s }) {
250
+ const ticketKey = args.find(a => !a.startsWith('--'));
251
+ const olderThanArg = args.find(a => a.startsWith('--older-than='));
252
+ const profileArg = args.find(a => a.startsWith('--profile='));
253
+ const forceYes = args.includes('--yes') || args.includes('-y');
254
+
255
+ let olderThanMs = null;
256
+ if (olderThanArg) {
257
+ const ageStr = olderThanArg.split('=')[1];
258
+ olderThanMs = parseAge(ageStr);
259
+ if (olderThanMs === null) {
260
+ stderr.write(`Invalid --older-than value: "${ageStr}" — expected a number followed by h, d, w, m, or y.\nExamples: --older-than=7d --older-than=2w --older-than=1m --older-than=1y\n`);
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+ }
265
+
266
+ let entries = getCacheEntries(configDir, ticketKey ?? null);
267
+ const config = loadProfiles(configDir);
268
+
269
+ // Determine profile filter:
270
+ // 1. --profile=NAME flag → filter by that profile (no picker)
271
+ // 2. No ticket / no profile flag / not --yes / TTY → show interactive picker
272
+ // 3. Otherwise → clear all (existing behaviour)
273
+ let filterProfileName = profileArg ? profileArg.split('=')[1] : null;
274
+ let pickerFiltered = false;
275
+
276
+ if (!ticketKey && !filterProfileName && !forceYes && stdout.isTTY && process.stdin.setRawMode) {
277
+ const groups = groupEntriesByProfile(entries, config);
278
+ if (groups.length > 1) {
279
+ const picked = await showProfilePicker(entries, groups, stdout, s);
280
+ if (picked === CANCELLED) {
281
+ stdout.write(`\n${s.dim('✖')} ${s.dim('Aborted — no files were deleted.')}\n\n`);
282
+ return;
283
+ }
284
+ // Use the picker's pre-grouped entries directly — avoids filterEntriesByProfile
285
+ // falling back to "all entries" when a profile has no ticketPrefixes configured.
286
+ if (picked.entries !== null) {
287
+ entries = picked.entries;
288
+ pickerFiltered = true;
289
+ }
290
+ filterProfileName = picked.profileName;
291
+ }
292
+ }
293
+
294
+ if (filterProfileName && !pickerFiltered) {
295
+ entries = filterEntriesByProfile(entries, filterProfileName, config);
296
+ }
297
+
298
+ if (olderThanMs !== null) {
299
+ const cutoff = Date.now() - olderThanMs;
300
+ entries = entries.filter(e => e.mtimeMs < cutoff);
301
+ }
302
+
303
+ if (entries.length === 0) {
304
+ const scopeParts = [];
305
+ if (filterProfileName) scopeParts.push(`for profile ${s.cyan(filterProfileName)}`);
306
+ else if (ticketKey) scopeParts.push(`for ${s.cyan(ticketKey)}`);
307
+ else scopeParts.push('in the attachment cache');
308
+ const ageNote = olderThanArg ? ` older than ${expandAge(olderThanArg.split('=')[1])}` : '';
309
+ stdout.write(`No cached files${ageNote} found ${scopeParts.join(' ')}.\n`);
310
+ return;
311
+ }
312
+
313
+ // Group by ticket for display
314
+ const byTicket = {};
315
+ for (const e of entries) {
316
+ if (!byTicket[e.ticketKey]) byTicket[e.ticketKey] = [];
317
+ byTicket[e.ticketKey].push(e);
318
+ }
319
+
320
+ const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
321
+ const scopeLabel = filterProfileName ? s.dim(` (${filterProfileName})`) : '';
322
+ stdout.write(`\n${s.bold('Files to delete')}${scopeLabel}${s.bold(':')} ${plural(entries.length, 'file')} across ${plural(Object.keys(byTicket).length, 'ticket')}, ${formatSize(totalSize)} total\n\n`);
323
+
324
+ for (const [ticket, files] of Object.entries(byTicket).sort()) {
325
+ stdout.write(` ${s.cyan(ticket)}\n`);
326
+ for (const f of files) {
327
+ stdout.write(` ${f.filename} ${s.dim(formatSize(f.size) + ', ' + formatAge(f.mtimeMs))}\n`);
328
+ }
329
+ }
330
+ stdout.write('\n');
331
+
332
+ if (!forceYes) {
333
+ const confirmed = await confirm('Delete these files?', stdin, stdout, s);
334
+ if (!confirmed) {
335
+ stdout.write('Aborted — no files were deleted.\n');
336
+ return;
337
+ }
338
+ }
339
+
340
+ let deleted = 0;
341
+ let deletedSize = 0;
342
+ for (const e of entries) {
343
+ try {
344
+ fs.unlinkSync(e.localPath);
345
+ deleted++;
346
+ deletedSize += e.size;
347
+ // Clean up empty ticket dirs
348
+ const dir = path.dirname(e.localPath);
349
+ try {
350
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
351
+ } catch { /* dir not empty or already gone */ }
352
+ } catch { /* already deleted */ }
353
+ }
354
+
355
+ // Also remove brief cache for any affected tickets
356
+ const affectedTickets = [...new Set(entries.map(e => e.ticketKey))];
357
+ for (const key of affectedTickets) {
358
+ // Clear brief cache for all profiles that own this ticket key
359
+ const prefix = key.split('-')[0];
360
+ for (const [pName, p] of Object.entries(config?.profiles ?? {})) {
361
+ if (!p.ticketPrefixes || p.ticketPrefixes.includes(prefix)) {
362
+ clearBriefCache(key, pName, configDir);
363
+ }
364
+ }
365
+ // Also clear the _default profile slot
366
+ clearBriefCache(key, '_default', configDir);
367
+ }
368
+
369
+ stdout.write(`${s.bold('✓')} Deleted ${plural(deleted, 'file')}, freed ${formatSize(deletedSize)}.\n`);
370
+ }
371
+
372
+ // ─── profile picker ───────────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Interactive profile picker for `cache clear`.
376
+ * Shows "All profiles" plus each profile that has cached files.
377
+ * Returns: null (All), profileName string, or CANCELLED symbol.
378
+ */
379
+ async function showProfilePicker(entries, groups, stdout, s) {
380
+ const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
381
+
382
+ stdout.write(`\n ${s.dim('Which profile cache should be cleared?')}\n\n`);
383
+
384
+ const items = [
385
+ {
386
+ label: 'All profiles',
387
+ sublabel: `${formatSize(totalSize)}, ${plural(entries.length, 'file')} — clear everything`,
388
+ },
389
+ ...groups.map(g => ({
390
+ label: g.name ?? '(unconfigured)',
391
+ sublabel: `${formatSize(g.size)}, ${plural(g.entries.length, 'file')} ${g.prefixes.sort().join(' · ')}`,
392
+ })),
393
+ ];
394
+
395
+ const selectedIndex = await promptSelect(items, { stream: stdout });
396
+ if (selectedIndex === null) return CANCELLED;
397
+ if (selectedIndex === 0) return { entries: null, profileName: null }; // All profiles
398
+ const group = groups[selectedIndex - 1];
399
+ return { entries: group.entries, profileName: group.name };
400
+ }
401
+
402
+ // ─── helpers ─────────────────────────────────────────────────────────────────
403
+
404
+ function plural(n, word) {
405
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
406
+ }
407
+
408
+ function expandAge(str) {
409
+ const match = str?.match(/^(\d+)(h|d|w|m|y)$/);
410
+ if (!match) return str;
411
+ const [, n, unit] = match;
412
+ const num = parseInt(n, 10);
413
+ const labels = { h: 'hour', d: 'day', w: 'week', m: 'month', y: 'year' };
414
+ return `${num} ${labels[unit]}${num === 1 ? '' : 's'}`;
415
+ }
416
+
417
+ function confirm(question, stdin, stdout, s) {
418
+ return new Promise(resolve => {
419
+ stdout.write(`${question} ${s.dim('y/N')} `);
420
+
421
+ if (!stdin.isTTY) {
422
+ stdout.write(s.dim('(non-interactive — skipping)\n'));
423
+ resolve(false);
424
+ return;
425
+ }
426
+
427
+ stdin.setRawMode(true);
428
+ stdin.resume();
429
+ stdin.once('data', buf => {
430
+ stdin.setRawMode(false);
431
+ stdin.pause();
432
+ const ch = buf.toString().toLowerCase();
433
+ stdout.write(ch === 'y' ? 'y\n' : 'N\n');
434
+ resolve(ch === 'y');
435
+ });
436
+ });
437
+ }
438
+
439
+ function printCacheSizeHelp(stream, s) {
440
+ stream.write([
441
+ '',
442
+ ` ${s.bold(s.cyan('ticketlens'))} ${s.bold('cache size')} ${s.dim('[--profile=NAME]')}`,
443
+ '',
444
+ ` Show disk usage of locally cached Jira attachments, grouped by profile.`,
445
+ '',
446
+ ` ${s.bold('OPTIONS')}`,
447
+ '',
448
+ ` ${s.cyan('--profile')}=${s.dim('NAME')} Filter output to a single profile`,
449
+ '',
450
+ ` ${s.bold('EXAMPLES')}`,
451
+ '',
452
+ ` ${s.dim('$')} ticketlens cache size`,
453
+ ` ${s.dim('$')} ticketlens cache size --profile=work`,
454
+ '',
455
+ ].join('\n') + '\n');
456
+ }
457
+
458
+ function printCacheClearHelp(stream, s) {
459
+ stream.write([
460
+ '',
461
+ ` ${s.bold(s.cyan('ticketlens'))} ${s.bold('cache clear')} ${s.dim('[TICKET] [options]')}`,
462
+ '',
463
+ ` Remove locally cached Jira attachment files.`,
464
+ ` In TTY mode, shows an interactive profile picker when no filters are given.`,
465
+ '',
466
+ ` ${s.bold('OPTIONS')}`,
467
+ '',
468
+ ` ${s.cyan('--profile')}=${s.dim('NAME')} Filter to one profile's tickets`,
469
+ ` ${s.cyan('--older-than')}=${s.dim('Nh|Nd|Nw|Nm|Ny')} Delete files older than N hours / days / weeks / months / years`,
470
+ ` ${s.cyan('--yes')}, ${s.cyan('-y')} Skip confirmation prompt`,
471
+ ` ${s.cyan('-h')}, ${s.cyan('--help')} Show this help`,
472
+ '',
473
+ ` ${s.bold('EXAMPLES')}`,
474
+ '',
475
+ ` ${s.dim('$')} ticketlens cache clear`,
476
+ ` ${s.dim('$')} ticketlens cache clear --profile=work`,
477
+ ` ${s.dim('$')} ticketlens cache clear PROJ-123`,
478
+ ` ${s.dim('$')} ticketlens cache clear --older-than=7d`,
479
+ ` ${s.dim('$')} ticketlens cache clear --older-than=1m --yes`,
480
+ '',
481
+ ].join('\n') + '\n');
482
+ }
483
+
484
+ function printCacheHelp(stream, s) {
485
+ stream.write([
486
+ '',
487
+ ` ${s.bold(s.cyan('ticketlens'))} ${s.bold('cache')} ${s.dim('<subcommand>')}`,
488
+ '',
489
+ ` Manage locally cached Jira ticket attachments.`,
490
+ '',
491
+ ` ${s.bold('SUBCOMMANDS')}`,
492
+ '',
493
+ ` ${s.cyan('cache size')} Show disk usage, grouped by profile`,
494
+ ` ${s.cyan('cache clear')} Remove cached files (interactive picker in TTY)`,
495
+ '',
496
+ ` Run ${s.cyan('ticketlens cache size --help')} or ${s.cyan('ticketlens cache clear --help')} for details.`,
497
+ '',
498
+ ].join('\n') + '\n');
499
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CLI authentication token — stored locally, never transmitted except to the
3
+ * TicketLens API. The server stores only the sha256 hash; this file holds the
4
+ * plaintext so the CLI can use it as a Bearer token.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+
11
+ const TOKEN_FILE = 'cli-token.json';
12
+
13
+ export function cliTokenPath(configDir = DEFAULT_CONFIG_DIR) {
14
+ return join(configDir, TOKEN_FILE);
15
+ }
16
+
17
+ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
18
+ const p = cliTokenPath(configDir);
19
+ if (!existsSync(p)) return null;
20
+ try {
21
+ const data = JSON.parse(readFileSync(p, 'utf8'));
22
+ return typeof data.token === 'string' ? data.token : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR) {
29
+ mkdirSync(configDir, { recursive: true });
30
+ const p = cliTokenPath(configDir);
31
+ writeFileSync(p, JSON.stringify({ token }, null, 2) + '\n', 'utf8');
32
+ chmodSync(p, 0o600);
33
+ }
34
+
35
+ export function deleteCliToken(configDir = DEFAULT_CONFIG_DIR) {
36
+ const p = cliTokenPath(configDir);
37
+ if (existsSync(p)) {
38
+ writeFileSync(p, JSON.stringify({}, null, 2) + '\n', 'utf8');
39
+ }
40
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CLI command parser for ticketlens.
3
+ * Routes arguments to the appropriate subcommand.
4
+ */
5
+
6
+ export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
7
+
8
+ export function parseCommand(args) {
9
+ const first = args[0];
10
+
11
+ if (args.length === 0 || first === '--help' || first === '-h') {
12
+ return { command: 'help', args: [] };
13
+ }
14
+
15
+ if (args.includes('--version') || args.includes('-v')) {
16
+ return { command: 'version', args: [] };
17
+ }
18
+
19
+ if (first === 'triage') {
20
+ return { command: 'triage', args: args.slice(1) };
21
+ }
22
+
23
+ if (first === 'init') {
24
+ return { command: 'init', args: args.slice(1) };
25
+ }
26
+
27
+ if (first === 'switch') {
28
+ return { command: 'switch', args: args.slice(1) };
29
+ }
30
+
31
+ if (first === 'config') {
32
+ return { command: 'config', args: args.slice(1) };
33
+ }
34
+
35
+ if (first === 'activate') {
36
+ return { command: 'activate', args: args.slice(1) };
37
+ }
38
+
39
+ if (first === 'license') {
40
+ return { command: 'license', args: args.slice(1) };
41
+ }
42
+
43
+ if (first === 'cache') {
44
+ return { command: 'cache', args: args.slice(1) };
45
+ }
46
+
47
+ if (first === 'delete') {
48
+ return { command: 'delete', args: args.slice(1) };
49
+ }
50
+
51
+ if (first === 'profiles' || first === 'ls') {
52
+ return { command: 'profiles', args: args.slice(1) };
53
+ }
54
+
55
+ // "get PROJ-123" — alias for the fetch command
56
+ if (first === 'get') {
57
+ return { command: 'fetch', args: args.slice(1) };
58
+ }
59
+
60
+ // "clear" — shorthand for "cache clear"
61
+ if (first === 'clear') {
62
+ return { command: 'cache', args: ['clear', ...args.slice(1)] };
63
+ }
64
+
65
+ if (first === 'schedule') {
66
+ return { command: 'schedule', args: args.slice(1) };
67
+ }
68
+
69
+ if (first === 'ledger') {
70
+ return { command: 'ledger', args: args.slice(1) };
71
+ }
72
+
73
+ if (first === 'install-hooks') {
74
+ return { command: 'install-hooks', args: args.slice(1) };
75
+ }
76
+
77
+ if (first === 'pr') {
78
+ return { command: 'pr', args: args.slice(1) };
79
+ }
80
+
81
+ if (first === 'compliance') {
82
+ return { command: 'compliance', args: args.slice(1) };
83
+ }
84
+
85
+ // Anything that looks like a ticket key or any non-flag arg → fetch
86
+ return { command: 'fetch', args };
87
+ }