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,63 @@
1
+ /**
2
+ * Shared constants and utilities used across multiple TicketLens modules.
3
+ * Single source of truth — import from here, do not redefine locally.
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+
10
+ /** Canonical config directory: ~/.ticketlens */
11
+ export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
12
+
13
+ /** Read package.json version once per process, memoized. */
14
+ let _version;
15
+ export function getVersion() {
16
+ if (_version) return _version;
17
+ try {
18
+ const pkgPath = new URL('../../../../package.json', import.meta.url);
19
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
20
+ _version = pkg.version || '0.0.0';
21
+ } catch {
22
+ _version = '0.0.0';
23
+ }
24
+ return _version;
25
+ }
26
+
27
+ /** Human-readable relative time from an ISO date string. */
28
+ export function timeAgo(dateStr) {
29
+ if (!dateStr) return '';
30
+ const diff = Date.now() - new Date(dateStr).getTime();
31
+ const mins = Math.floor(diff / 60000);
32
+ if (mins < 60) return `${mins}m ago`;
33
+ const hours = Math.floor(mins / 60);
34
+ if (hours < 24) return `${hours}h ago`;
35
+ const days = Math.floor(hours / 24);
36
+ return `${days}d ago`;
37
+ }
38
+
39
+ /** Collapse newlines and truncate to max visible characters. */
40
+ export function truncate(str, max) {
41
+ if (!str) return '';
42
+ const oneLine = str.replace(/[\r\n]+/g, ' ').trim();
43
+ if (oneLine.length <= max) return oneLine;
44
+ return oneLine.slice(0, max - 3) + '...';
45
+ }
46
+
47
+ /** Strip carriage returns, preserving all other whitespace. */
48
+ export function stripCr(str) {
49
+ if (!str) return '';
50
+ return str.replace(/\r/g, '');
51
+ }
52
+
53
+ /**
54
+ * Build the env-like object expected by jira-client functions.
55
+ * @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn
56
+ * @returns {{ JIRA_BASE_URL: string, JIRA_PAT?: string, JIRA_EMAIL?: string, JIRA_API_TOKEN?: string }}
57
+ */
58
+ export function buildJiraEnv(conn) {
59
+ return {
60
+ JIRA_BASE_URL: conn.baseUrl,
61
+ ...(conn.pat ? { JIRA_PAT: conn.pat } : { JIRA_EMAIL: conn.email, JIRA_API_TOKEN: conn.apiToken }),
62
+ };
63
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Maps a list of requirements to FOUND / NOT_FOUND / PARTIAL status
3
+ * against a git diff string, using keyword heuristics.
4
+ */
5
+
6
+ const STOP_WORDS = new Set([
7
+ 'the','a','an','is','are','be','was','were','been','being',
8
+ 'have','has','had','do','does','did','will','would','could','should',
9
+ 'may','might','must','shall','and','or','but','in','on','at','to',
10
+ 'for','of','with','by','from','as','it','its','that','this','these',
11
+ 'those','not','no','if','when','then','given','user','system','api',
12
+ ]);
13
+
14
+ function keywords(text) {
15
+ return text
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9\s]/g, ' ')
18
+ .split(/\s+/)
19
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w));
20
+ }
21
+
22
+ function defaultAnalyzer(requirement, diff) {
23
+ if (!diff) return 'NOT_FOUND';
24
+ const kws = keywords(requirement);
25
+ if (kws.length === 0) return 'NOT_FOUND';
26
+ const diffLower = diff.toLowerCase();
27
+ const matched = kws.filter(k => diffLower.includes(k));
28
+ if (matched.length === 0) return 'NOT_FOUND';
29
+ if (matched.length >= 3 || matched.length >= kws.length * 0.6) return 'FOUND';
30
+ return 'PARTIAL';
31
+ }
32
+
33
+ function defaultEvidence(requirement, diff) {
34
+ if (!diff) return null;
35
+ const kws = keywords(requirement);
36
+ const lines = diff.split('\n');
37
+ for (const kw of kws) {
38
+ const match = lines.find(l => l.toLowerCase().includes(kw));
39
+ if (match) return match.trim().slice(0, 80);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export function analyzeDiff(requirements, diff, opts = {}) {
45
+ if (!requirements || requirements.length === 0) {
46
+ return { results: [], coveragePercent: 0 };
47
+ }
48
+
49
+ const analyzerFn = opts.analyzerFn ?? defaultAnalyzer;
50
+
51
+ const results = requirements.map(requirement => {
52
+ const status = analyzerFn(requirement, diff);
53
+ const evidence = status !== 'NOT_FOUND' ? defaultEvidence(requirement, diff) : null;
54
+ return { requirement, status, evidence };
55
+ });
56
+
57
+ const score = results.reduce((sum, r) => {
58
+ if (r.status === 'FOUND') return sum + 1;
59
+ if (r.status === 'PARTIAL') return sum + 0.5;
60
+ return sum;
61
+ }, 0);
62
+
63
+ const coveragePercent = Math.round((score / results.length) * 100);
64
+
65
+ return { results, coveragePercent };
66
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Spec drift detection: compares key ticket fields against a stored snapshot
3
+ * to detect when a ticket's status, description, or requirements have changed.
4
+ */
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import * as fsDefault from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
+ import { extractRequirements } from './requirement-extractor.mjs';
12
+
13
+ /**
14
+ * Validate that a path component does not contain traversal sequences.
15
+ * @param {string} value
16
+ * @param {'profile'|'ticket key'} label
17
+ */
18
+ function assertSafe(value, label) {
19
+ if (value.includes('/') || value.includes('\\') || value.includes('..')) {
20
+ throw new Error(`Invalid ${label}`);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Get the current git branch name.
26
+ * @param {{ execFn?: Function, cwd?: string }} opts
27
+ * @returns {string} Branch name, 'DETACHED' for detached HEAD, '' if not in a git repo.
28
+ */
29
+ export function getCurrentBranch({ execFn = spawnSync, cwd } = {}) {
30
+ const spawnOpts = { encoding: 'utf8' };
31
+ if (cwd) spawnOpts.cwd = cwd;
32
+ const result = execFn('git', ['rev-parse', '--abbrev-ref', 'HEAD'], spawnOpts);
33
+ if (result.status !== 0) return '';
34
+ const branch = (result.stdout ?? '').trim();
35
+ return branch === 'HEAD' ? 'DETACHED' : branch;
36
+ }
37
+
38
+ /**
39
+ * Read a stored snapshot for the given ticket + profile.
40
+ * @param {string} ticketKey
41
+ * @param {{ profile?: string, configDir?: string, fsModule?: object }} opts
42
+ * @returns {object|null}
43
+ */
44
+ export function readSnapshot(ticketKey, { profile = 'default', configDir = DEFAULT_CONFIG_DIR, fsModule = fsDefault } = {}) {
45
+ assertSafe(profile, 'profile name');
46
+ assertSafe(ticketKey, 'ticket key');
47
+ const filePath = join(configDir, 'drift', profile, `${ticketKey}.json`);
48
+ try {
49
+ const raw = fsModule.readFileSync(filePath, 'utf8');
50
+ return JSON.parse(raw);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Write a snapshot for the given ticket + profile.
58
+ * @param {string} ticketKey
59
+ * @param {object} ticket Raw Jira ticket object from fetchTicket()
60
+ * @param {{ profile?: string, configDir?: string, fsModule?: object, branch?: string }} opts
61
+ */
62
+ export function writeSnapshot(ticketKey, ticket, { profile = 'default', configDir = DEFAULT_CONFIG_DIR, fsModule = fsDefault, branch = '' } = {}) {
63
+ assertSafe(profile, 'profile name');
64
+ assertSafe(ticketKey, 'ticket key');
65
+ const dir = join(configDir, 'drift', profile);
66
+ fsModule.mkdirSync(dir, { recursive: true });
67
+ const desc = ticket.fields?.description ?? '';
68
+ const descriptionHash = createHash('sha256').update(desc).digest('hex');
69
+ const requirements = extractRequirements(desc);
70
+ const snapshot = {
71
+ fetchedAt: new Date().toISOString(),
72
+ branch,
73
+ status: ticket.fields?.status?.name ?? '',
74
+ descriptionHash,
75
+ requirements,
76
+ };
77
+ fsModule.writeFileSync(join(dir, `${ticketKey}.json`), JSON.stringify(snapshot, null, 2), 'utf8');
78
+ }
79
+
80
+ /**
81
+ * Compare current ticket fields against a prior snapshot.
82
+ * @param {{ status: string, descriptionHash: string, requirements: string[] }} current
83
+ * @param {object|null} prior Snapshot object, or null if no prior snapshot exists.
84
+ * @returns {{ drifted: boolean, changes: string[] }}
85
+ */
86
+ export function detectDrift(current, prior) {
87
+ if (!prior) return { drifted: false, changes: [] };
88
+
89
+ const changes = [];
90
+
91
+ if (current.status !== prior.status) {
92
+ changes.push(`status: "${prior.status}" \u2192 "${current.status}"`);
93
+ }
94
+
95
+ if (current.descriptionHash !== prior.descriptionHash) {
96
+ changes.push('description changed');
97
+ }
98
+
99
+ const priorReqCount = Array.isArray(prior.requirements) ? prior.requirements.length : 0;
100
+ const currentReqCount = Array.isArray(current.requirements) ? current.requirements.length : 0;
101
+ if (currentReqCount !== priorReqCount) {
102
+ changes.push(`requirements: ${priorReqCount} \u2192 ${currentReqCount}`);
103
+ }
104
+
105
+ return { drifted: changes.length > 0, changes };
106
+ }
107
+
108
+ /**
109
+ * Format a drift warning string for output to stderr.
110
+ * @param {string} ticketKey
111
+ * @param {string[]} changes
112
+ * @returns {string}
113
+ */
114
+ export function formatDriftWarning(ticketKey, changes) {
115
+ const lines = [` \u26a0 ${ticketKey} spec drift detected:`];
116
+ for (const change of changes) {
117
+ lines.push(` \u2022 ${change}`);
118
+ }
119
+ return lines.join('\n') + '\n';
120
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Classifies fetch/HTTP errors into user-friendly messages with actionable hints.
3
+ * Extracts cause codes from Node.js fetch errors and HTTP status from Jira API errors.
4
+ */
5
+
6
+ function getNetworkCode(err) {
7
+ // Node fetch wraps the real error in err.cause
8
+ const cause = err.cause || err;
9
+ return cause.code || cause.errno || null;
10
+ }
11
+
12
+ function getHostname(baseUrl) {
13
+ try { return new URL(baseUrl).hostname; } catch { return baseUrl; }
14
+ }
15
+
16
+ export function classifyError(err, { baseUrl, profileName } = {}) {
17
+ const host = baseUrl ? getHostname(baseUrl) : 'the Jira server';
18
+ const profile = profileName || 'your profile';
19
+ const code = getNetworkCode(err);
20
+
21
+ // Network-level errors (fetch threw before getting an HTTP response)
22
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
23
+ return {
24
+ message: `DNS lookup failed for ${host}`,
25
+ hint: 'Check your internet connection. If this is a private server, make sure your VPN is connected.',
26
+ };
27
+ }
28
+
29
+ if (code === 'ECONNREFUSED') {
30
+ return {
31
+ message: `Connection refused by ${host}`,
32
+ hint: 'The server is not accepting connections. Check if the Jira instance is running and the URL is correct.',
33
+ };
34
+ }
35
+
36
+ if (code === 'ECONNRESET' || code === 'ECONNABORTED') {
37
+ return {
38
+ message: `Connection to ${host} was reset`,
39
+ hint: 'This often happens when a VPN drops or a firewall blocks the connection. Check your VPN status.',
40
+ };
41
+ }
42
+
43
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
44
+ return {
45
+ message: `Connection to ${host} timed out`,
46
+ hint: `The server didn't respond. If ${host} requires a VPN, make sure it's connected.`,
47
+ };
48
+ }
49
+
50
+ // AbortSignal.timeout() fires a DOMException with name 'TimeoutError' as err.cause
51
+ const causeName = (err.cause || err).name;
52
+ if (causeName === 'TimeoutError') {
53
+ return {
54
+ message: `Connection to ${host} timed out`,
55
+ hint: `The server didn't respond within the allowed time. If ${host} requires a VPN, make sure it's connected.`,
56
+ };
57
+ }
58
+
59
+ if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'CERT_HAS_EXPIRED' ||
60
+ code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'ERR_TLS_CERT_ALTNAME_INVALID' ||
61
+ code === 'SELF_SIGNED_CERT_IN_CHAIN') {
62
+ return {
63
+ message: `SSL certificate error for ${host}`,
64
+ hint: 'The server has an invalid or self-signed certificate. Contact your Jira admin.',
65
+ };
66
+ }
67
+
68
+ // HTTP-level errors (got a response, but it was an error status)
69
+ const status = err.status || err.statusCode;
70
+
71
+ if (status === 401) {
72
+ return {
73
+ message: `Authentication failed for ${profile}`,
74
+ hint: 'Your credentials may have expired. Check your API token or PAT in ~/.ticketlens/credentials.json.',
75
+ };
76
+ }
77
+
78
+ if (status === 403) {
79
+ return {
80
+ message: `Access denied on ${host}`,
81
+ hint: 'Your account does not have permission. Check your Jira access or ask an admin.',
82
+ };
83
+ }
84
+
85
+ if (status === 404) {
86
+ return {
87
+ message: `Not found on ${host}`,
88
+ hint: 'The ticket or endpoint does not exist. Check the ticket key and the Jira base URL in your profile.',
89
+ };
90
+ }
91
+
92
+ if (status === 429) {
93
+ return {
94
+ message: `Rate limited by ${host}`,
95
+ hint: 'Too many requests. Wait a moment and try again.',
96
+ };
97
+ }
98
+
99
+ if (status && status >= 500) {
100
+ return {
101
+ message: `${host} returned a server error (${status})`,
102
+ hint: 'The Jira instance is having issues. Try again in a few minutes.',
103
+ };
104
+ }
105
+
106
+ // Generic fetch failure (e.g. "fetch failed" with no useful cause)
107
+ if (/fetch failed/i.test(err.message) || code === 'UND_ERR_SOCKET') {
108
+ return {
109
+ message: `Could not reach ${host}`,
110
+ hint: 'Check your internet connection. If this is a private server, make sure your VPN is connected.',
111
+ };
112
+ }
113
+
114
+ // Fallback: return the original message
115
+ return {
116
+ message: err.message,
117
+ hint: null,
118
+ };
119
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Styled help output for TicketLens CLI.
3
+ */
4
+
5
+ import { createStyler } from './ansi.mjs';
6
+ import { getVersion } from './config.mjs';
7
+
8
+ export function printHelp({ stream = process.stdout } = {}) {
9
+ const isTTY = stream.isTTY;
10
+ const s = createStyler({ isTTY });
11
+ const v = getVersion();
12
+
13
+ // Column targets (visible chars):
14
+ // USAGE: command portion = 36, so descriptions always start at the same column
15
+ // OPTIONS: flag portion = 19, so descriptions always start at the same column
16
+ //
17
+ // Spaces after each item are computed as: target - visibleWidth(item)
18
+ // ANSI codes (s.cyan, s.dim) add invisible bytes — they do NOT affect visible width.
19
+
20
+ const lines = [
21
+ '',
22
+ ` ${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${v}`)}`,
23
+ ` ${s.dim('Stop tab-switching. Start building.')}`,
24
+ '',
25
+ ` ${s.bold('USAGE')}`,
26
+ '',
27
+ // visible widths: "ticketlens init"=15, "switch"=17, "config [--profile=NAME]"=34,
28
+ // "profiles"=19, "<TICKET-KEY> [options]"=33, "triage [options]"=27,
29
+ // "activate <KEY>"=25, "license"=18, "cache [size|clear]"=29 → target=36
30
+ // Groups: Setup ─── Daily use ─── Account / Maintenance
31
+ ` ${s.brand('ticketlens')} init Configure Jira connections`,
32
+ ` ${s.brand('ticketlens')} switch Switch active profile`,
33
+ ` ${s.brand('ticketlens')} config ${s.dim('[--profile=NAME]')} Edit profile settings`,
34
+ ` ${s.brand('ticketlens')} profiles List all configured profiles ${s.dim('(alias: ls)')}`,
35
+ '',
36
+ ` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
37
+ ` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
38
+ ` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
39
+ ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
40
+ '',
41
+ ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
42
+ ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
43
+ ` ${s.brand('ticketlens')} license Show license status`,
44
+ ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
45
+ ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
46
+ '',
47
+ ` ${s.bold('FETCH OPTIONS')}`,
48
+ '',
49
+ // visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
50
+ // "--no-attachments"=16, "--no-cache"=10 → target=19
51
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
52
+ ` ${s.brand('--depth')}=${s.dim('N')} Traversal depth ${s.dim('(0=ticket only, 1=+linked, 2=deep)')}`,
53
+ ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
54
+ ` ${s.brand('--styled')} Force ANSI-styled output`,
55
+ ` ${s.brand('--no-attachments')} Skip downloading attachments`,
56
+ ` ${s.brand('--no-cache')} Re-download attachments even if cached`,
57
+ ` ${s.brand('--check')} Append VCS diff + review instructions for Claude Code`,
58
+ ` ${s.brand('--compliance')} Check ticket requirements against local diff ${s.dim('[Pro/Free 3/mo]')}`,
59
+ ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
60
+ ` ${s.brand('--cloud')} Route summary through TicketLens API ${s.dim('[Pro]')}`,
61
+ '',
62
+ ` ${s.bold('TRIAGE OPTIONS')}`,
63
+ '',
64
+ // visible widths: "--profile=NAME"=14, "--stale=N"=9, "--status=X,Y"=12,
65
+ // "--assignee=NAME"=15, "--sprint=NAME"=13, "--static"=8, "--plain"=7 → target=19
66
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
67
+ ` ${s.brand('--stale')}=${s.dim('N')} Aging threshold in days ${s.dim('(default: 5)')}`,
68
+ ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
69
+ ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
70
+ ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
71
+ ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
72
+ ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
73
+ ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
74
+ ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
75
+ '',
76
+ ` ${s.bold('EXAMPLES')}`,
77
+ '',
78
+ ` ${s.dim('$')} ticketlens PROJ-123`,
79
+ ` ${s.dim('$')} ticketlens get PROJ-123 --depth=0 --profile=myteam`,
80
+ ` ${s.dim('$')} ticketlens triage`,
81
+ ` ${s.dim('$')} ticketlens triage --profile=acme --stale=3`,
82
+ ` ${s.dim('$')} ticketlens triage --static`,
83
+ '',
84
+ ` ${s.bold('CONFIGURATION')}`,
85
+ '',
86
+ ` ${s.dim('Profiles:')} ~/.ticketlens/profiles.json`,
87
+ ` ${s.dim('Credentials:')} ~/.ticketlens/credentials.json`,
88
+ ` ${s.dim('License:')} ~/.ticketlens/license.json`,
89
+ '',
90
+ ` ${s.dim('Or use env vars:')} JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN`,
91
+ '',
92
+ '',
93
+ ];
94
+
95
+ stream.write(lines.join('\n') + '\n');
96
+ }
97
+
98
+ export function printFetchHelp({ stream = process.stdout } = {}) {
99
+ const s = createStyler({ isTTY: stream.isTTY });
100
+ const lines = [
101
+ '',
102
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('<TICKET-KEY>')} ${s.dim('[options]')}`,
103
+ '',
104
+ ` Fetch a Jira ticket's full context: description, comments,`,
105
+ ` linked issues, and code references.`,
106
+ '',
107
+ ` ${s.bold('OPTIONS')}`,
108
+ '',
109
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
110
+ // visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
111
+ // "--no-attachments"=16, "--no-cache"=10, "-h, --help"=10 → target=19
112
+ ` ${s.brand('--depth')}=${s.dim('N')} Traversal depth ${s.dim('(default: 1)')}`,
113
+ ` ${s.dim('0 = target ticket only')}`,
114
+ ` ${s.dim('1 = + linked ticket details')}`,
115
+ ` ${s.dim('2 = + linked-of-linked')}`,
116
+ ` ${s.brand('--plain')} Plain markdown output`,
117
+ ` ${s.brand('--styled')} Force ANSI-styled output`,
118
+ ` ${s.brand('--no-attachments')} Skip downloading attachments`,
119
+ ` ${s.brand('--no-cache')} Re-download attachments even if cached`,
120
+ ` ${s.brand('--check')} Append VCS diff + review instructions for Claude Code`,
121
+ ` ${s.brand('--compliance')} Check ticket requirements against local diff ${s.dim('[Pro/Free 3/mo]')}`,
122
+ ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
123
+ ` ${s.brand('--cloud')} Route summary through TicketLens API ${s.dim('[Pro]')}`,
124
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
125
+ '',
126
+ ` ${s.bold('EXAMPLES')}`,
127
+ '',
128
+ ` ${s.dim('$')} ticketlens PROJ-123`,
129
+ ` ${s.dim('$')} ticketlens PROJ-123 --depth=0`,
130
+ ` ${s.dim('$')} ticketlens PROJ-123 --profile=acme --depth=2`,
131
+ '',
132
+ ];
133
+ stream.write(lines.join('\n') + '\n');
134
+ }
135
+
136
+ const ANSI_RE_HELP = /\x1b\[[0-9;]*m/g;
137
+ function padRightVis(str, len) {
138
+ const vis = str.replace(ANSI_RE_HELP, '').length;
139
+ return str + ' '.repeat(Math.max(0, len - vis));
140
+ }
141
+
142
+ export function printProfiles({ stream = process.stdout, config, plain = false } = {}) {
143
+ const isTTY = !plain && stream.isTTY;
144
+ const s = createStyler({ isTTY });
145
+ const profiles = config?.profiles || {};
146
+ const names = Object.keys(profiles);
147
+
148
+ if (names.length === 0) {
149
+ stream.write(`\n No profiles configured.\n Run ${s.cyan('ticketlens init')} to set one up.\n\n`);
150
+ return;
151
+ }
152
+
153
+ // Active = explicitly set default, else first profile in file
154
+ const active = config?.default || names[0];
155
+ const defaultIsExplicit = !!config?.default;
156
+
157
+ const getData = (name) => {
158
+ const p = profiles[name];
159
+ return {
160
+ prefixes: (p.ticketPrefixes || []).join(', ') || '—',
161
+ statuses: (p.triageStatuses || []).join(', ') || '—',
162
+ url: p.baseUrl || '',
163
+ };
164
+ };
165
+
166
+ if (plain) {
167
+ for (const name of names) {
168
+ const { url, prefixes, statuses } = getData(name);
169
+ stream.write(`${name}\t${name === active ? 'active' : 'inactive'}\t${url}\t${prefixes}\t${statuses}\n`);
170
+ }
171
+ return;
172
+ }
173
+
174
+ const MAX_STATUS_W = 45;
175
+ const nameW = Math.max('Profile'.length, ...names.map(n => n.length));
176
+ const urlW = Math.max('URL'.length, ...names.map(n => getData(n).url.length));
177
+ const prefW = Math.max('Prefixes'.length, ...names.map(n => getData(n).prefixes.length));
178
+
179
+ // Header + separator (4 chars before name = 2 leading + indicator + space)
180
+ const hdr = ` ${padRightVis('Profile', nameW + 2)}${padRightVis('URL', urlW + 2)}${padRightVis('Prefixes', prefW + 2)}Statuses`;
181
+ const sep = ` ${'─'.repeat(nameW).padEnd(nameW + 2)}${'─'.repeat(urlW).padEnd(urlW + 2)}${'─'.repeat(prefW).padEnd(prefW + 2)}${'─'.repeat('Statuses'.length)}`;
182
+
183
+ const lines = ['', s.dim(hdr), s.dim(sep)];
184
+
185
+ for (const name of names) {
186
+ const { url, prefixes, statuses } = getData(name);
187
+ const isActive = name === active;
188
+ const indicator = isActive ? s.green('●') : s.dim('○');
189
+ const nameStyled = isActive ? s.bold(s.cyan(name)) : name;
190
+ const statusDisplay = statuses.length > MAX_STATUS_W
191
+ ? statuses.slice(0, MAX_STATUS_W - 1) + '…'
192
+ : statuses;
193
+ lines.push(
194
+ ` ${indicator} ` +
195
+ padRightVis(nameStyled, nameW + 2) +
196
+ url.padEnd(urlW + 2) +
197
+ prefixes.padEnd(prefW + 2) +
198
+ statusDisplay
199
+ );
200
+ }
201
+ lines.push('');
202
+
203
+ const activeNote = defaultIsExplicit
204
+ ? `${s.dim('Active:')} ${s.cyan(active)}`
205
+ : `${s.dim('Active:')} ${s.cyan(active)} ${s.dim('(first — run ticketlens switch to set default)')}`;
206
+ lines.push(` ${activeNote} ${s.dim('· ticketlens switch · ticketlens config --profile=NAME')}`);
207
+ lines.push('');
208
+
209
+ stream.write(lines.join('\n') + '\n');
210
+ }
211
+
212
+ export function printTriageHelp({ stream = process.stdout } = {}) {
213
+ const s = createStyler({ isTTY: stream.isTTY });
214
+ const lines = [
215
+ '',
216
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('triage')} ${s.dim('[options]')}`,
217
+ '',
218
+ ` Scan your assigned Jira tickets and surface what needs attention.`,
219
+ ` Opens an interactive navigator in TTY mode.`,
220
+ '',
221
+ ` ${s.bold('OPTIONS')}`,
222
+ '',
223
+ // visible widths: "--profile=NAME"=14, "--stale=N"=9, "--status=X,Y"=12,
224
+ // "--assignee=NAME"=15, "--sprint=NAME"=13, "--static"=8, "--plain"=7, "-h, --help"=10 → target=19
225
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
226
+ ` ${s.brand('--stale')}=${s.dim('N')} Aging threshold in days ${s.dim('(default: 5)')}`,
227
+ ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
228
+ ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
229
+ ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
230
+ ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
231
+ ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
232
+ ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
233
+ ` ${s.brand('--plain')} Plain markdown output`,
234
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
235
+ '',
236
+ ` ${s.bold('EXAMPLES')}`,
237
+ '',
238
+ ` ${s.dim('$')} ticketlens triage`,
239
+ ` ${s.dim('$')} ticketlens triage --profile=acme`,
240
+ ` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
241
+ ` ${s.dim('$')} ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12"`,
242
+ ` ${s.dim('$')} ticketlens triage --static`,
243
+ '',
244
+ ` ${s.bold('INTERACTIVE MODE')}`,
245
+ '',
246
+ ` ${s.dim('↑/↓')} Navigate tickets`,
247
+ ` ${s.dim('Enter')} Open ticket in browser`,
248
+ ` ${s.dim('p')} Switch profile`,
249
+ ` ${s.dim('q/Esc')} Exit`,
250
+ '',
251
+ ];
252
+ stream.write(lines.join('\n') + '\n');
253
+ }