ticketlens 0.9.0 → 0.9.2

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
@@ -102,6 +102,7 @@ ticketlens CNV1-2 --summarize --provider=groq # Force a specific AI provider [P
102
102
  ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens API [Pro]
103
103
  ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
104
104
  ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
105
+ ticketlens CNV1-2 --template=quick # Apply a brief template (full|quick|code-review, or custom [Team])
105
106
  ```
106
107
 
107
108
  | `--depth` | Scope |
@@ -528,6 +529,9 @@ ticketlens CNV1-2 --summarize --provider=groq # Force Groq (Llama 3.1, free ti
528
529
  ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API [Pro]
529
530
  ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
530
531
  ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
532
+ ticketlens CNV1-2 --template=quick # Apply quick template (meta + 2 comments only)
533
+ ticketlens CNV1-2 --template=code-review # Apply code-review template (meta + desc + linked + code refs)
534
+ ticketlens CNV1-2 --template=full # Apply full template (all sections, default)
531
535
  ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
532
536
 
533
537
  # Pipe plain output to clipboard, LLM, or file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.9.0 -->
1
+ <!-- jtb-skill-version: 0.9.1 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -23,6 +23,9 @@ Fetches a Jira ticket and produces a structured brief with code references, then
23
23
  /jtb PROD-1234 --summarize # AI summary of the brief (Pro)
24
24
  /jtb PROD-1234 --summarize --cloud # summary via TicketLens cloud (Pro)
25
25
  /jtb PROD-1234 --handoff # structured handoff brief from comments (Pro)
26
+ /jtb PROD-1234 --template=quick # apply quick template (meta + 2 comments only)
27
+ /jtb PROD-1234 --template=code-review # apply code-review template (meta + desc + linked + code refs)
28
+ /jtb PROD-1234 --template=full # apply full template (all sections, default)
26
29
  /jtb triage # scan your assigned tickets for attention
27
30
  /jtb triage --stale=3 # custom aging threshold (days)
28
31
  /jtb triage --status=CR,QA # only check specific statuses
@@ -24,6 +24,8 @@ import { handleUnknownFlags } from './lib/arg-validator.mjs';
24
24
  import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
25
25
  import { downloadAttachments } from './lib/attachment-downloader.mjs';
26
26
  import { readBriefCache, writeBriefCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
27
+ import { readCliToken } from './lib/cli-auth.mjs';
28
+ import { resolveTemplate } from './lib/template-resolver.mjs';
27
29
  import { parseAge } from './lib/cache-manager.mjs';
28
30
  import { createStyler } from './lib/ansi.mjs';
29
31
  import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
@@ -836,12 +838,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
836
838
 
837
839
  const validatedArgs = await handleUnknownFlags(
838
840
  args,
839
- ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider='],
841
+ ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider=', '--template='],
840
842
  { hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
841
843
  );
842
844
  if (validatedArgs === null) { process.exitCode = 1; return; }
843
845
  args = validatedArgs;
844
846
 
847
+ // Resolve --template= before cache check so both paths share the same sections.
848
+ const templateArg = args.find(a => a.startsWith('--template='));
849
+ let templateSections = null;
850
+ if (templateArg) {
851
+ const templateSlug = templateArg.split('=')[1];
852
+ try {
853
+ const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
854
+ templateSections = tpl.sections;
855
+ } catch (err) {
856
+ process.stderr.write(`Error: ${err.message}\n`);
857
+ process.exitCode = 1;
858
+ return;
859
+ }
860
+ }
861
+
845
862
  // When multiple profiles share the same ticket prefix and we're in a TTY,
846
863
  // ask the user to choose rather than silently picking the first match.
847
864
  if (!profileName && process.stderr.isTTY && process.stdin.setRawMode) {
@@ -942,7 +959,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
942
959
 
943
960
  // Apply --budget pruning on the plain brief before styling (Pro only).
944
961
  // When --budget is active, always output plain text (pruning operates on unescaped chars).
945
- let plainBrief = assembleBrief(cached.ticket, codeRefs);
962
+ let plainBrief = assembleBrief(cached.ticket, codeRefs, templateSections);
946
963
  const budgetArgCached = args.find(a => a.startsWith('--budget='));
947
964
  if (budgetArgCached) {
948
965
  const budgetN = parseInt(budgetArgCached.split('=')[1], 10);
@@ -956,7 +973,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
956
973
  }
957
974
  let brief = (budgetArgCached && licensedFn('pro', configDir))
958
975
  ? plainBrief
959
- : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true }) : plainBrief);
976
+ : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true, templateSections }) : plainBrief);
960
977
 
961
978
  if (args.includes('--handoff')) {
962
979
  const handoffResult = await applyHandoff(cached.ticket, args, opts, configDir, licensedFn, upgradeFn);
@@ -1128,7 +1145,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1128
1145
 
1129
1146
  // Apply --budget pruning on the plain brief before styling (Pro only).
1130
1147
  // When --budget is active, always output plain text (pruning operates on unescaped chars).
1131
- let plainOutput = assembleBrief(ticket, codeRefs);
1148
+ let plainOutput = assembleBrief(ticket, codeRefs, templateSections);
1132
1149
  const budgetArg = args.find(a => a.startsWith('--budget='));
1133
1150
  if (budgetArg) {
1134
1151
  const budgetN = parseInt(budgetArg.split('=')[1], 10);
@@ -1142,7 +1159,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1142
1159
  }
1143
1160
  let output = (budgetArg && licensedFn('pro', configDir))
1144
1161
  ? plainOutput
1145
- : (useStyled ? styleBrief(ticket, codeRefs, { styled: true }) : plainOutput);
1162
+ : (useStyled ? styleBrief(ticket, codeRefs, { styled: true, templateSections }) : plainOutput);
1146
1163
 
1147
1164
  if (args.includes('--handoff')) {
1148
1165
  const handoffResult = await applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn);
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'http://ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
 
8
8
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
9
  // with an optional port — all treated as local-only addresses.
@@ -6,7 +6,8 @@ import { formatTable } from './table-formatter.mjs';
6
6
  import { formatSize } from './attachment-downloader.mjs';
7
7
  import { timeAgo, truncate, stripCr } from './config.mjs';
8
8
 
9
- export function assembleBrief(ticket, codeRefs = null) {
9
+ export function assembleBrief(ticket, codeRefs = null, templateSections = null) {
10
+ const s = templateSections;
10
11
  const sections = [];
11
12
  sections.push(`# ${ticket.key}: ${ticket.summary}`);
12
13
 
@@ -15,19 +16,25 @@ export function assembleBrief(ticket, codeRefs = null) {
15
16
  if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
16
17
  sections.push(meta.join(' | '));
17
18
 
18
- if (ticket.description) {
19
+ if (ticket.description && (s === null || s.description !== false)) {
19
20
  sections.push(`## Description\n\n${stripCr(ticket.description)}`);
20
21
  }
21
22
 
22
- if (ticket.comments?.length > 0) {
23
- const commentLines = ticket.comments.map(c => {
23
+ const commentsEnabled = s === null || s.comments?.enabled !== false;
24
+ const rawMax = s?.comments?.max;
25
+ const commentsMax = (typeof rawMax === 'number' && rawMax >= 0) ? rawMax : Infinity;
26
+ const visibleComments = commentsEnabled && ticket.comments?.length > 0
27
+ ? ticket.comments.slice(0, commentsMax === Infinity ? ticket.comments.length : commentsMax)
28
+ : [];
29
+ if (visibleComments.length > 0) {
30
+ const commentLines = visibleComments.map(c => {
24
31
  const date = c.created ? c.created.split('T')[0] : 'unknown';
25
32
  return `### **${c.author}** (${date})\n\n${c.body.replace(/\r/g, '')}`;
26
33
  });
27
34
  sections.push(`## Comments\n\n${commentLines.join('\n\n---\n\n')}`);
28
35
  }
29
36
 
30
- if (ticket.linkedTicketDetails?.length > 0) {
37
+ if (ticket.linkedTicketDetails?.length > 0 && (s === null || s.linked !== false)) {
31
38
  const linkedSections = ticket.linkedTicketDetails.map(lt => {
32
39
  const parts = [`### ${lt.key}: ${lt.summary}`, `**Type:** ${lt.type} | **Status:** ${lt.status}`];
33
40
  if (lt.description) parts.push(stripCr(lt.description));
@@ -43,7 +50,7 @@ export function assembleBrief(ticket, codeRefs = null) {
43
50
  sections.push(`## Linked Tickets\n\n${linkedSections.join('\n\n---\n\n')}`);
44
51
  }
45
52
 
46
- if (ticket.confluencePages?.length > 0) {
53
+ if (ticket.confluencePages?.length > 0 && (s === null || s.confluence !== false)) {
47
54
  const pageLines = ticket.confluencePages.map(p => {
48
55
  const parts = [`### ${p.title ?? p.url}`];
49
56
  if (p.text) parts.push(p.text);
@@ -52,7 +59,7 @@ export function assembleBrief(ticket, codeRefs = null) {
52
59
  sections.push(`## Confluence Pages\n\n${pageLines.join('\n\n---\n\n')}`);
53
60
  }
54
61
 
55
- if (codeRefs) {
62
+ if (codeRefs && (s === null || s.code_refs !== false)) {
56
63
  const categories = [
57
64
  ['File Paths', codeRefs.filePaths],
58
65
  ['Methods', codeRefs.methods],
@@ -70,7 +77,7 @@ export function assembleBrief(ticket, codeRefs = null) {
70
77
  }
71
78
  }
72
79
 
73
- if (ticket.attachments?.length > 0) {
80
+ if (ticket.attachments?.length > 0 && (s === null || s.attachments !== false)) {
74
81
  const lines = ticket.attachments.map(a => {
75
82
  const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
76
83
  const sz = formatSize(a.size);
@@ -67,7 +67,8 @@ export function printHelp({ stream = process.stdout } = {}) {
67
67
  ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
68
68
  ` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
69
69
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
70
- ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
70
+ ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
71
+ ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
71
72
  '',
72
73
  ` ${s.bold('TRIAGE OPTIONS')}`,
73
74
  '',
@@ -81,7 +82,7 @@ export function printHelp({ stream = process.stdout } = {}) {
81
82
  ` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
82
83
  ` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
83
84
  ` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
84
- ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
85
+ ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
85
86
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
86
87
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
87
88
  ` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
@@ -149,7 +150,8 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
149
150
  ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
150
151
  ` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
151
152
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
152
- ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
153
+ ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
154
+ ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
153
155
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
154
156
  '',
155
157
  ` ${s.bold('EXAMPLES')}`,
@@ -98,7 +98,8 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
98
98
  }
99
99
 
100
100
  export function styleBrief(ticket, codeRefs = null, opts = {}) {
101
- const { styled = true } = opts;
101
+ const { styled = true, templateSections = null } = opts;
102
+ const ts = templateSections;
102
103
  const s = createStyler({ forceColor: styled, noColor: !styled });
103
104
 
104
105
  const sections = [];
@@ -106,7 +107,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
106
107
  // Header: ticket key + summary
107
108
  sections.push(s.bold(s.brand(`${ticket.key}: ${ticket.summary}`)));
108
109
 
109
- // Metadata line
110
+ // Metadata line (always included)
110
111
  const meta = [
111
112
  `${s.dim('Type:')} ${ticket.type}`,
112
113
  `${s.dim('Status:')} ${statusColor(s, ticket.status)}`,
@@ -118,13 +119,19 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
118
119
  sections.push(meta.join(s.dim(' · ')));
119
120
 
120
121
  // Description
121
- if (ticket.description) {
122
+ if (ticket.description && (ts === null || ts.description !== false)) {
122
123
  sections.push(`${s.bold(s.brand('Description'))}\n${s.dim('─'.repeat(divWidth()))}\n${stripCr(ticket.description)}`);
123
124
  }
124
125
 
125
126
  // Comments
126
- if (ticket.comments?.length > 0) {
127
- const commentLines = ticket.comments.map(c => {
127
+ const commentsEnabled = ts === null || ts.comments?.enabled !== false;
128
+ const rawMax = ts?.comments?.max;
129
+ const commentsMax = (typeof rawMax === 'number' && rawMax >= 0) ? rawMax : Infinity;
130
+ const visibleComments = commentsEnabled && ticket.comments?.length > 0
131
+ ? ticket.comments.slice(0, commentsMax === Infinity ? ticket.comments.length : commentsMax)
132
+ : [];
133
+ if (visibleComments.length > 0) {
134
+ const commentLines = visibleComments.map(c => {
128
135
  const date = c.created ? c.created.split('T')[0] : 'unknown';
129
136
  return `${s.brand(c.author)} ${s.dim(`(${date})`)}\n${stripCr(c.body)}`;
130
137
  });
@@ -132,7 +139,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
132
139
  }
133
140
 
134
141
  // Linked tickets
135
- if (ticket.linkedTicketDetails?.length > 0) {
142
+ if (ticket.linkedTicketDetails?.length > 0 && (ts === null || ts.linked !== false)) {
136
143
  const linkedSections = ticket.linkedTicketDetails.map(lt => {
137
144
  const parts = [`${s.brand(lt.key)}: ${lt.summary}`, `${s.dim('Type:')} ${lt.type} | ${s.dim('Status:')} ${statusColor(s, lt.status)}`];
138
145
  if (lt.description) parts.push(stripCr(lt.description));
@@ -149,7 +156,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
149
156
  }
150
157
 
151
158
  // Confluence pages
152
- if (ticket.confluencePages?.length > 0) {
159
+ if (ticket.confluencePages?.length > 0 && (ts === null || ts.confluence !== false)) {
153
160
  const pageLines = ticket.confluencePages.map(p => {
154
161
  const parts = [s.brand(p.title ?? p.url)];
155
162
  if (p.text) parts.push(p.text);
@@ -159,7 +166,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
159
166
  }
160
167
 
161
168
  // Code references
162
- if (codeRefs) {
169
+ if (codeRefs && (ts === null || ts.code_refs !== false)) {
163
170
  const categories = [
164
171
  ['File Paths', codeRefs.filePaths],
165
172
  ['Methods', codeRefs.methods],
@@ -177,7 +184,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
177
184
  }
178
185
  }
179
186
 
180
- if (ticket.attachments?.length > 0) {
187
+ if (ticket.attachments?.length > 0 && (ts === null || ts.attachments !== false)) {
181
188
  const lines = ticket.attachments.map(a => {
182
189
  const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
183
190
  const sz = formatSize(a.size);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Resolves a brief template by slug.
3
+ * System templates (full / quick / code-review) are hardcoded and work offline.
4
+ * Custom team templates require a CLI token and are fetched from /v1/templates.
5
+ */
6
+
7
+ import { getApiBase } from './sync.mjs';
8
+
9
+ export const SYSTEM_TEMPLATES = [
10
+ {
11
+ slug: 'full',
12
+ name: 'Full Brief',
13
+ is_system: true,
14
+ sections: {
15
+ meta: true,
16
+ description: true,
17
+ comments: { enabled: true, max: 10 },
18
+ linked: true,
19
+ code_refs: true,
20
+ confluence: true,
21
+ attachments: true,
22
+ },
23
+ },
24
+ {
25
+ slug: 'quick',
26
+ name: 'Quick Scan',
27
+ is_system: true,
28
+ sections: {
29
+ meta: true,
30
+ description: false,
31
+ comments: { enabled: true, max: 2 },
32
+ linked: false,
33
+ code_refs: false,
34
+ confluence: false,
35
+ attachments: false,
36
+ },
37
+ },
38
+ {
39
+ slug: 'code-review',
40
+ name: 'Code Review',
41
+ is_system: true,
42
+ sections: {
43
+ meta: true,
44
+ description: true,
45
+ comments: { enabled: false, max: 0 },
46
+ linked: true,
47
+ code_refs: true,
48
+ confluence: false,
49
+ attachments: false,
50
+ },
51
+ },
52
+ ];
53
+
54
+ const SYSTEM_SLUGS = new Set(SYSTEM_TEMPLATES.map(t => t.slug));
55
+
56
+ /**
57
+ * Resolve a template by slug.
58
+ *
59
+ * @param {string} slug
60
+ * @param {object} opts
61
+ * @param {string|null} opts.token CLI token (null = offline)
62
+ * @param {Function} [opts.fetcher] globalThis.fetch override (for testing)
63
+ * @returns {Promise<object>} template with { slug, sections, is_system, ... }
64
+ * @throws {Error} if slug is unknown and cannot be resolved
65
+ */
66
+ export async function resolveTemplate(slug, { token, fetcher = globalThis.fetch } = {}) {
67
+ if (!slug || !/^[a-z0-9_-]{1,64}$/.test(slug)) {
68
+ throw new Error(`Invalid template slug "${slug}". Use lowercase letters, numbers, hyphens, or underscores (max 64 chars).`);
69
+ }
70
+
71
+ const systemMatch = SYSTEM_TEMPLATES.find(t => t.slug === slug);
72
+
73
+ if (!token) {
74
+ if (systemMatch) {
75
+ process.stderr.write(
76
+ ` ○ Template "${slug}" resolved from local system definitions (no login required).\n`,
77
+ );
78
+ return systemMatch;
79
+ }
80
+ throw new Error(
81
+ `Template "${slug}" not found. Custom templates require \`ticketlens login\`.`,
82
+ );
83
+ }
84
+
85
+ // Try the API first.
86
+ let apiTemplates = null;
87
+ try {
88
+ const res = await fetcher(`${getApiBase()}/v1/templates`, {
89
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
90
+ signal: AbortSignal.timeout(10_000),
91
+ });
92
+ if (res.ok) {
93
+ apiTemplates = await res.json();
94
+ } else {
95
+ process.stderr.write(` ⚠ Could not fetch templates (HTTP ${res.status}) — using local system definitions.\n`);
96
+ }
97
+ } catch {
98
+ process.stderr.write(` ⚠ Could not reach templates API — using local system definitions.\n`);
99
+ }
100
+
101
+ if (apiTemplates) {
102
+ if (!Array.isArray(apiTemplates)) {
103
+ process.stderr.write(` ⚠ Unexpected templates API response format — using local system definitions.\n`);
104
+ } else {
105
+ const found = apiTemplates.find(t => t.slug === slug);
106
+ if (found) return found;
107
+ const available = apiTemplates.map(t => t.slug).join(', ');
108
+ throw new Error(
109
+ `Template "${slug}" not found. Available templates: ${available}.\n` +
110
+ ` To create custom templates, go to Admin → Brief Templates in your TicketLens Console.`,
111
+ );
112
+ }
113
+ }
114
+
115
+ // API unreachable — fall back to system templates
116
+ if (systemMatch) {
117
+ process.stderr.write(` ⚠ Using local system template "${slug}" (API unavailable).\n`);
118
+ return systemMatch;
119
+ }
120
+
121
+ throw new Error(
122
+ `Template "${slug}" not found and API is unreachable. Custom templates require \`ticketlens login\`.`,
123
+ );
124
+ }