ticketlens 0.1.24 → 0.2.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.
package/README.md CHANGED
@@ -136,6 +136,7 @@ ticketlens triage --sprint="Sprint 12" # Filter by sprint [Team]
136
136
  ticketlens triage --export=csv # Export results to CSV [Team]
137
137
  ticketlens triage --export=json # Export results to JSON [Team]
138
138
  ticketlens triage --push # Push snapshot to Console queue [Team]
139
+ ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
139
140
  ticketlens triage --digest # POST scored results to digest endpoint [Pro]
140
141
  ticketlens triage --plain # Plain markdown — pipe to file or LLM
141
142
  ticketlens triage --static # Static table, no interactive mode
@@ -375,6 +376,7 @@ ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
375
376
  ticketlens triage --export=csv # Export to CSV [Team]
376
377
  ticketlens triage --export=json # Export to JSON [Team]
377
378
  ticketlens triage --push # Push snapshot to Console queue [Team]
379
+ ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
378
380
  ticketlens triage --digest # POST results to digest endpoint [Pro]
379
381
  ticketlens triage --profile=acme --stale=3 --static # Combine flags
380
382
 
@@ -447,7 +449,7 @@ ticketlens cache clear --help # Cache clear help
447
449
 
448
450
  Start free, upgrade when you need it — `ticketlens activate <key>`
449
451
 
450
- ### Pro — $8/mo
452
+ ### Pro — $9/mo
451
453
 
452
454
  <div align="center">
453
455
  <img src="docs/demos/pro-triage.gif" alt="ticketlens --summarize AI summary demo" width="700" />
@@ -465,13 +467,27 @@ ticketlens schedule # Set up a scheduled daily digest
465
467
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
466
468
  ```
467
469
 
468
- **`--handoff`** synthesizes the comment thread into a structured one-pager for the developer picking up the ticket. No context-reading required — the AI reads the full comment history and returns:
470
+ **`--summarize`** generates a 3-sentence AI summary of the ticket. The AI receives the full ticket context: description, comments, linked Confluence pages, and any text-readable attachments.
471
+
472
+ **`--handoff`** synthesizes the ticket into a structured one-pager for the developer picking up the work. The AI receives the same full context and returns:
469
473
 
470
474
  - **What was attempted** — concrete work already done
471
475
  - **Current blockers** — unresolved issues
472
476
  - **Open questions** — decisions not yet made
473
477
  - **Recommendation** — where to start
474
478
 
479
+ **What the AI can read:**
480
+
481
+ | Content | Included |
482
+ |---|---|
483
+ | Description | ✅ Always |
484
+ | Comments | ✅ Always |
485
+ | Linked Confluence pages | ✅ Jira only, same-origin |
486
+ | Text files (`.txt`, `.md`, `.log`, `.csv`, `.json`, `.yaml`, etc.) | ✅ Up to 4 KB per file, 12 KB total |
487
+ | Screenshots (`.png`, `.jpg`, `.gif`, etc.) | ❌ Binary — images require multimodal API |
488
+ | PDFs | ❌ Binary — no parser included (zero-dependency) |
489
+ | Office documents (`.docx`, `.xlsx`) | ❌ Binary — no parser included |
490
+
475
491
  Add one of the following to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API:
476
492
 
477
493
  | Key | Provider | Cost |
@@ -501,7 +517,7 @@ ticketlens CNV1-2 --handoff --provider=openai
501
517
 
502
518
  Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to `4h`, `1d`, `7d`, `30d`, or `0` (disable) via `ticketlens config`. Free tier is fixed at 4h.
503
519
 
504
- ### Team — $15/seat/mo
520
+ ### Team — $19/seat/mo
505
521
 
506
522
  <div align="center">
507
523
  <img src="docs/demos/teams-digest.gif" alt="ticketlens triage --plain digest pipeline demo" width="700" />
@@ -513,10 +529,13 @@ ticketlens triage --sprint="Sprint 12" # Filter by sprint name
513
529
  ticketlens triage --export=csv # Export triage to CSV for standups and reports
514
530
  ticketlens triage --export=json # Machine-readable export for dashboards
515
531
  ticketlens triage --push # Push snapshot to the Console queue
532
+ ticketlens triage --share # Generate a 24h share URL — paste into Slack, no login needed for recipients
516
533
  ```
517
534
 
518
535
  `--push` syncs the scored snapshot to the TicketLens Console after each triage run. The queue page at `/console/queue` shows the latest snapshot for every team profile — no manual refresh needed.
519
536
 
537
+ `--share` generates a signed URL valid for 24 hours. Recipients open it in any browser — no account, no install. The asymmetry is the product: you run one command, everyone sees the same snapshot.
538
+
520
539
  Automate a morning digest with cron — no open terminal required:
521
540
 
522
541
  ```bash
@@ -23,7 +23,7 @@ import {
23
23
  printActivateHelp, printLicenseHelp, printDeleteHelp,
24
24
  printProfilesHelp, printScheduleHelp,
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
- printReviewHelp, printStandupHelp,
26
+ printReviewHelp, printStandupHelp, printUpdateSkillHelp,
27
27
  } from '../skills/jtb/scripts/lib/help.mjs';
28
28
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
29
29
  import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
@@ -314,6 +314,13 @@ switch (command) {
314
314
  });
315
315
  break;
316
316
 
317
+ case 'update-skill': {
318
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateSkillHelp(); break; }
319
+ const { updateSkill } = await import('../skills/jtb/scripts/lib/update-skill.mjs');
320
+ await updateSkill(cmdArgs);
321
+ break;
322
+ }
323
+
317
324
  case 'login': {
318
325
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLoginHelp(); break; }
319
326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.24",
3
+ "version": "0.2.0",
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": {
@@ -8,12 +8,15 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
+ "scripts/",
12
+ "skills/jtb/SKILL.md",
11
13
  "skills/jtb/scripts/lib/",
12
14
  "skills/jtb/scripts/fetch-ticket.mjs",
13
15
  "skills/jtb/scripts/fetch-my-tickets.mjs"
14
16
  ],
15
17
  "scripts": {
16
- "test": "node --test skills/jtb/scripts/test/*.test.mjs"
18
+ "test": "node --test skills/jtb/scripts/test/*.test.mjs",
19
+ "postinstall": "node scripts/postinstall.mjs"
17
20
  },
18
21
  "keywords": [
19
22
  "jira",
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dev helper — set the local TicketLens license tier for testing.
4
+ *
5
+ * Usage:
6
+ * node scripts/dev-license.mjs # show current tier
7
+ * node scripts/dev-license.mjs free # remove license (free tier)
8
+ * node scripts/dev-license.mjs pro # write Pro license
9
+ * node scripts/dev-license.mjs team # write Team license
10
+ */
11
+
12
+ import { unlinkSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { readLicense, writeLicense } from '../skills/jtb/scripts/lib/license.mjs';
15
+ import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
16
+
17
+ const TIERS = ['free', 'pro', 'team'];
18
+ const tier = process.argv[2]?.toLowerCase();
19
+
20
+ if (!tier) {
21
+ const current = readLicense();
22
+ if (!current) {
23
+ console.log('Current tier: free (no license file)');
24
+ } else {
25
+ console.log(`Current tier: ${current.tier}`);
26
+ console.log(` key: ${current.key}`);
27
+ console.log(` email: ${current.email}`);
28
+ console.log(` validatedAt: ${current.validatedAt}`);
29
+ console.log(` expiresAt: ${current.expiresAt ?? 'none'}`);
30
+ }
31
+ process.exit(0);
32
+ }
33
+
34
+ if (!TIERS.includes(tier)) {
35
+ console.error(`Unknown tier: "${tier}". Valid values: ${TIERS.join(', ')}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ if (tier === 'free') {
40
+ const licensePath = join(DEFAULT_CONFIG_DIR, 'license.json');
41
+ try {
42
+ unlinkSync(licensePath);
43
+ console.log('License removed — now running as free tier.');
44
+ } catch {
45
+ console.log('Already free tier (no license file found).');
46
+ }
47
+ process.exit(0);
48
+ }
49
+
50
+ writeLicense({
51
+ key: `dev-${tier}`,
52
+ tier,
53
+ email: `dev-${tier}@test.local`,
54
+ provider: 'dev',
55
+ instanceId: 'dev-local',
56
+ validatedAt: new Date().toISOString(),
57
+ });
58
+
59
+ console.log(`License set to: ${tier}`);
60
+ console.log(` key: dev-${tier}`);
61
+ console.log(` email: dev-${tier}@test.local`);
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runs automatically after `npm install -g ticketlens`.
4
+ * Copies the latest SKILL.md into every detected AI assistant command directory.
5
+ * Silent on failure — never breaks the install.
6
+ */
7
+
8
+ import { copyFileSync, existsSync, readFileSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { homedir } from 'node:os';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const SKILL_SRC = join(__dirname, '..', 'skills', 'jtb', 'SKILL.md');
15
+
16
+ if (!existsSync(SKILL_SRC)) process.exit(0);
17
+
18
+ const HOME = homedir();
19
+
20
+ // Known command directories per AI assistant.
21
+ // Each entry: { label, path } — path must exist and contain jtb.md to be updated.
22
+ const TARGETS = [
23
+ { label: 'Claude Code', path: join(HOME, '.claude', 'commands', 'jtb.md') },
24
+ { label: 'Claude Code (work)', path: join(HOME, '.claude-work', 'commands', 'jtb.md') },
25
+ { label: 'Gemini CLI', path: join(HOME, '.gemini', 'commands', 'jtb.md') },
26
+ { label: 'Copilot CLI', path: join(HOME, '.copilot-cli', 'commands', 'jtb.md') },
27
+ ];
28
+
29
+ function skillVersion(filePath) {
30
+ try {
31
+ const line = readFileSync(filePath, 'utf8').split('\n')[0];
32
+ const m = line.match(/jtb-skill-version:\s*([\d.]+)/);
33
+ return m ? m[1] : 'unknown';
34
+ } catch {
35
+ return 'unknown';
36
+ }
37
+ }
38
+
39
+ let updated = 0;
40
+ let skipped = 0;
41
+
42
+ for (const { label, path } of TARGETS) {
43
+ if (!existsSync(path)) { skipped++; continue; }
44
+ try {
45
+ const before = skillVersion(path);
46
+ copyFileSync(SKILL_SRC, path);
47
+ const after = skillVersion(SKILL_SRC);
48
+ if (before === after) {
49
+ console.log(` ✔ ${label}: already at v${after}`);
50
+ } else {
51
+ console.log(` ✔ ${label}: updated v${before} → v${after}`);
52
+ }
53
+ updated++;
54
+ } catch (err) {
55
+ console.warn(` ⚠ ${label}: could not update — ${err.message}`);
56
+ }
57
+ }
58
+
59
+ if (updated === 0 && skipped === TARGETS.length) {
60
+ console.log(' ℹ /jtb skill not installed in any known location.');
61
+ console.log(' To install: ticketlens update-skill');
62
+ }
@@ -0,0 +1,246 @@
1
+ <!-- jtb-skill-version: 0.2.0 -->
2
+ ---
3
+ name: jtb
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.
5
+ ---
6
+
7
+ # Jira TicketBrief
8
+
9
+ Fetches a Jira ticket and produces a structured brief with code references, then enters plan mode.
10
+
11
+ ## Quick Start
12
+
13
+ ```
14
+ /jtb PROD-1234 # fetch a ticket brief
15
+ /jtb PROD-1234 --depth=0 # target ticket only (fast)
16
+ /jtb PROD-1234 --depth=2 # include linked-of-linked tickets
17
+ /jtb PROD-1234 --profile=acme # force a specific connection profile
18
+ /jtb PROD-1234 --no-cache # re-fetch from Jira (bypass local cache)
19
+ /jtb PROD-1234 --no-attachments # skip attachment download
20
+ /jtb PROD-1234 --plain # plain text output (no ANSI colours)
21
+ /jtb PROD-1234 --check # coverage review: ACs vs local diff
22
+ /jtb PROD-1234 --compliance # formal compliance check (tier-gated)
23
+ /jtb PROD-1234 --summarize # AI summary of the brief (Pro)
24
+ /jtb PROD-1234 --summarize --cloud # summary via TicketLens cloud (Pro)
25
+ /jtb PROD-1234 --handoff # structured handoff brief from comments (Pro)
26
+ /jtb triage # scan your assigned tickets for attention
27
+ /jtb triage --stale=3 # custom aging threshold (days)
28
+ /jtb triage --status=CR,QA # only check specific statuses
29
+ /jtb triage --profile=acme # explicit profile override
30
+ ```
31
+
32
+ ## Prerequisites
33
+
34
+ TicketLens supports two connection methods — check in this order:
35
+
36
+ **1. Profile config (recommended):** If `~/.ticketlens/profiles.json` exists, no env vars
37
+ are needed. Profile resolution is automatic (by ticket prefix, project path, or `--profile`).
38
+ Setup via `ticketlens init`.
39
+
40
+ **2. Env var fallback:** If no profile config exists, these must be set:
41
+ - `JIRA_BASE_URL` — e.g. `https://yourteam.atlassian.net`
42
+ - **Cloud:** `JIRA_EMAIL` + `JIRA_API_TOKEN`
43
+ - **Server/DC:** `JIRA_PAT`
44
+
45
+ If neither profiles nor env vars are configured, tell the user:
46
+ "No Jira connection found. Run `ticketlens init` to set up your connection,
47
+ or set JIRA_BASE_URL + auth credentials as environment variables."
48
+
49
+ ## Workflow
50
+
51
+ ### Triage subcommand
52
+
53
+ If the first argument is `triage`:
54
+
55
+ Run:
56
+ ```bash
57
+ node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
58
+ ```
59
+
60
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
61
+
62
+ **IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
63
+
64
+ ---
65
+
66
+ ### Fetch ticket workflow (default)
67
+
68
+ ### Step 1: Validate environment
69
+
70
+ Follow the Prerequisites section above:
71
+ - If `~/.ticketlens/profiles.json` exists → proceed to Step 2. No env vars needed.
72
+ - If no profile exists → check `JIRA_BASE_URL` and auth vars. If missing, list them and stop.
73
+ - If neither is configured → tell the user: "No Jira connection found. Run `ticketlens init` to set up your connection, or set `JIRA_BASE_URL` + auth credentials as environment variables."
74
+
75
+ ### Step 2: Fetch the ticket
76
+
77
+ Run:
78
+ ```bash
79
+ node ~/.agents/skills/jtb/scripts/fetch-ticket.mjs "$TICKET_KEY" $EXTRA_ARGS
80
+ ```
81
+
82
+ Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
83
+
84
+ The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
85
+
86
+ ### Step 2b: Read attached files
87
+
88
+ Check if the TicketBrief contains an `## Attachments` section. If it does, for each line containing a backtick-quoted absolute path, call the Read tool on that path based on file type:
89
+
90
+ - **Images** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`): Read it — Claude receives it as multimodal visual context.
91
+ - **PDFs** (`.pdf`): Read it — Claude receives the extracted text content.
92
+ - **Text files** (`.txt`, `.csv`, `.md`, `.log`): Read it — Claude receives the raw text.
93
+ - **Other files** (`.zip`, `.docx`, `.xlsx`, etc.): Note they exist at the listed path but do not attempt to read them.
94
+
95
+ Read all eligible files before proceeding. Do not describe images unprompted — hold them in context for Step 5.
96
+
97
+ If there is no `## Attachments` section, skip this step.
98
+
99
+ ---
100
+
101
+ ### Step 3: Detect VCS and enrich
102
+
103
+ Detect the VCS in the current working directory and run enrichment commands:
104
+
105
+ **Git:**
106
+ ```bash
107
+ git log --all --grep="$TICKET_KEY" --oneline --max-count=20
108
+ git branch -a | grep "$TICKET_KEY"
109
+ ```
110
+
111
+ **SVN:**
112
+ ```bash
113
+ svn log --limit 50 | grep -A5 "$TICKET_KEY"
114
+ svn ls ^/branches | grep "$TICKET_KEY"
115
+ ```
116
+
117
+ **Hg:**
118
+ ```bash
119
+ hg log -k "$TICKET_KEY" --limit 20
120
+ hg branches | grep "$TICKET_KEY"
121
+ ```
122
+
123
+ If no VCS is detected, skip this step.
124
+
125
+ ### Step 4: Resolve code references
126
+
127
+ From the TicketBrief output, look at the **Code References** section:
128
+
129
+ - For each **file path**: use Glob to check if it exists in the current repo
130
+ - For each **class name**: use Grep to find its definition (`class ClassName`)
131
+ - For each **branch**: note if it was found in step 3
132
+ - For each **SHA/revision**: note if it appeared in the VCS log
133
+
134
+ ### Step 5: Plan the implementation
135
+
136
+ Enter plan mode with all gathered context:
137
+ - The TicketBrief markdown
138
+ - VCS commits and branches related to the ticket
139
+ - Which referenced files/classes exist locally
140
+ - Linked ticket summaries and their comments
141
+
142
+ Present a clear implementation plan for the user to approve.
143
+
144
+ ---
145
+
146
+ ## --check: Acceptance Criteria Coverage Review
147
+
148
+ When `--check` is appended to any ticket fetch (`/jtb PROJ-123 --check`):
149
+
150
+ ### With VCS (git/svn/hg detected)
151
+ 1. The brief includes a `--- DIFF ---` section with the current local diff
152
+ 2. After reading the brief, evaluate coverage:
153
+ - Identify acceptance criteria from the ticket description and comments
154
+ - For each AC, check whether the diff addresses it
155
+ - Report: ✔ FOUND (with file:line reference) or ✗ NOT FOUND
156
+ - Show: `Coverage: N/M (X%) — N items outstanding`
157
+
158
+ ### Without VCS (no git/svn/hg in cwd)
159
+ Use this evaluation order:
160
+ 1. **Session context** — review files you Read/Edited this session; compare against ACs
161
+ 2. **claude-mem** — if available, call `get_observations` searching for `{ticketKey}` to find prior session work
162
+ 3. **context7** — if available, validate that changed files use correct library/framework APIs
163
+ 4. **fs.stat() fallback** — read files modified in the last 4 hours in cwd; compare against ACs
164
+ 5. **Manual checklist** — if none of the above apply, list the ACs for the developer to review manually
165
+
166
+ ### Privacy
167
+ `--check` never sends data anywhere. The diff stays local. Claude Code provides the intelligence using its existing session context.
168
+
169
+ ---
170
+
171
+ ## --compliance: Formal Compliance Check
172
+
173
+ When `--compliance` is appended to any ticket fetch (`/jtb PROJ-123 --compliance`):
174
+
175
+ **Tier gate:** Free tier allows 3 compliance checks per month. Pro tier is unlimited.
176
+ If the user is on Free and has exhausted their quota, show the upgrade prompt returned by the script and stop.
177
+
178
+ ### With VCS (git/svn/hg detected)
179
+ 1. The brief includes a `--- DIFF ---` section with the current local diff
180
+ 2. After reading the brief, evaluate each requirement formally:
181
+ - Extract every stated requirement, acceptance criterion, and definition-of-done item from the ticket description and all comments
182
+ - For each requirement, assess whether the diff satisfies it:
183
+ - `✔ COMPLIANT` — fully addressed, cite file:line
184
+ - `✖ NON-COMPLIANT` — not addressed at all
185
+ - `~ PARTIAL` — partially addressed, describe the gap
186
+ - Show a compliance summary: `Compliance: N/M (X%) — N items non-compliant, N partial`
187
+ - List all non-compliant and partial items with actionable notes
188
+
189
+ ### Without VCS (no git/svn/hg in cwd)
190
+ Use this evaluation order:
191
+ 1. **Session context** — review files you Read/Edited this session; compare against requirements
192
+ 2. **claude-mem** — if available, call `get_observations` searching for `{ticketKey}`
193
+ 3. **Manual checklist** — list each requirement for the developer to verify manually
194
+
195
+ ### Privacy
196
+ `--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
197
+
198
+ ---
199
+
200
+ ## Advanced Options
201
+
202
+ These flags are available on any ticket fetch and can be combined.
203
+
204
+ ### --summarize (Pro)
205
+
206
+ Generates an AI-powered summary of the full brief, collapsing verbose descriptions into a concise implementation overview. Useful for large tickets with many comments.
207
+
208
+ ```
209
+ /jtb PROD-1234 --summarize # BYOK — uses your own API key
210
+ /jtb PROD-1234 --summarize --cloud # uses TicketLens cloud summariser
211
+ /jtb PROD-1234 --summarize --provider=openai # use a specific AI provider
212
+ /jtb PROD-1234 --summarize --budget=2000 # limit output to ~2000 tokens
213
+ ```
214
+
215
+ - **BYOK (default):** reads your AI API key from `~/.ticketlens/credentials.json`. First use will prompt for consent.
216
+ - **`--cloud`:** routes through TicketLens cloud API (no local key needed, requires Pro).
217
+ - **`--provider=NAME`:** override the AI provider. Supported values depend on your credentials (e.g. `claude`, `openai`).
218
+ - **`--budget=N`:** prune the brief to approximately N tokens before summarising. Forces plain-text output.
219
+
220
+ ### --handoff (Pro)
221
+
222
+ Generates a structured handoff brief synthesised from the ticket's full comment thread. Designed for developer-to-developer handoffs — includes open questions, current state, and next steps.
223
+
224
+ ```
225
+ /jtb PROD-1234 --handoff
226
+ /jtb PROD-1234 --handoff --cloud
227
+ ```
228
+
229
+ Output is a concise markdown document, not a full TicketBrief. No plan mode — output is displayed and the workflow stops.
230
+
231
+ ### --plain / --styled
232
+
233
+ Control output formatting:
234
+
235
+ - **`--plain`** — strip all ANSI colour codes. Useful when piping to a file or another tool.
236
+ - **`--styled`** — force ANSI-styled output even in non-TTY contexts (e.g. when piped).
237
+
238
+ Default: styled when stdout is a TTY, plain otherwise.
239
+
240
+ ### --no-cache
241
+
242
+ Forces a fresh fetch from Jira, bypassing the local brief cache (4-hour TTL by default). Use when the ticket was recently updated and the cached brief is stale.
243
+
244
+ ### --no-attachments
245
+
246
+ Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments.
@@ -74,7 +74,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
74
74
 
75
75
  const validatedArgs = await handleUnknownFlags(
76
76
  args,
77
- ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push'],
77
+ ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share'],
78
78
  { hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
79
79
  );
80
80
  if (validatedArgs === null) { process.exitCode = 1; return; }
@@ -92,6 +92,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
92
92
  const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
93
93
  const digestFlag = args.includes('--digest');
94
94
  const pushFlag = args.includes('--push');
95
+ const shareFlag = args.includes('--share');
95
96
 
96
97
  if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
97
98
  process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
@@ -341,15 +342,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
341
342
  const cleanArgs = args.filter(a => !a.startsWith('--profile=') && !a.startsWith('--project='));
342
343
  return run(cleanArgs, { ...opts, env, fetcher, configDir });
343
344
  }
344
- return;
345
+ // Fall through to push/share if those flags were passed
346
+ if (!pushFlag && !shareFlag) return;
347
+ } else {
348
+ const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
349
+ const summary = useStyled
350
+ ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
351
+ : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
352
+ process.stdout.write(summary + '\n');
345
353
  }
346
354
 
347
- const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
348
- const summary = useStyled
349
- ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
350
- : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
351
- process.stdout.write(summary + '\n');
352
-
353
355
  if (pushFlag) {
354
356
  const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
355
357
  const pushFn = opts.pushFn ?? pushTriageSnapshot;
@@ -365,6 +367,22 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
365
367
  print: printFn,
366
368
  });
367
369
  }
370
+
371
+ if (shareFlag) {
372
+ const { shareTriageSnapshot } = await import('./lib/triage-share.mjs');
373
+ const shareFn = opts.shareFn ?? shareTriageSnapshot;
374
+ const licenseKey = readLicense(configDir)?.key ?? null;
375
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
376
+ await shareFn({
377
+ sorted,
378
+ rawTicketMap,
379
+ profile: profileName ?? 'default',
380
+ baseUrl: conn.baseUrl,
381
+ licenseKey,
382
+ fetcher,
383
+ print: printFn,
384
+ });
385
+ }
368
386
  }
369
387
 
370
388
  // Run if invoked directly
@@ -19,6 +19,7 @@ import { classifyError } from './lib/error-classifier.mjs';
19
19
  import { promptProfileSelect, promptProfileMismatch, promptSwitchProfile, promptMultipleMatches } from './lib/profile-picker.mjs';
20
20
  import { promptSelect } from './lib/select-prompt.mjs';
21
21
  import { printFetchHelp } from './lib/help.mjs';
22
+ import { readTextAttachments } from './lib/handoff-assembler.mjs';
22
23
  import { handleUnknownFlags } from './lib/arg-validator.mjs';
23
24
  import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
24
25
  import { downloadAttachments } from './lib/attachment-downloader.mjs';
@@ -104,11 +105,22 @@ function resolveAiProvider(args, credentials) {
104
105
  return credentials?.aiProvider ?? undefined;
105
106
  }
106
107
 
108
+ /**
109
+ * Append text-readable attachment content to a brief for AI consumption only.
110
+ * The returned string is only sent to the AI — the displayed brief is unchanged.
111
+ */
112
+ function augmentBriefForAi(brief, localAttachments) {
113
+ const textFiles = readTextAttachments(localAttachments);
114
+ if (textFiles.length === 0) return brief;
115
+ const sections = textFiles.map(({ filename, content }) => `=== Attachment: ${filename} ===\n${content}`);
116
+ return brief + `\n\n--- Attached Documents (${textFiles.length} text-readable) ---\n\n${sections.join('\n\n')}`;
117
+ }
118
+
107
119
  /**
108
120
  * Apply --summarize to a brief string.
109
121
  * Returns the modified brief, or null if the caller should exit (license gate / consent refused).
110
122
  */
111
- async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn) {
123
+ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, ticket) {
112
124
  if (!licensedFn('pro', configDir)) {
113
125
  upgradeFn('pro', '--summarize');
114
126
  process.exitCode = 1;
@@ -142,7 +154,8 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
142
154
  const credentials = opts.credentials ?? loadCredentials(configDir);
143
155
  const licenseKey = readLicense(configDir)?.key;
144
156
  const provider = opts.provider ?? resolveAiProvider(args, credentials);
145
- const summary = await summarizerFn({ brief, mode, credentials, licenseKey, provider });
157
+ const aiInput = augmentBriefForAi(brief, ticket?.localAttachments);
158
+ const summary = await summarizerFn({ brief: aiInput, mode, credentials, licenseKey, provider });
146
159
  const divider = '─'.repeat(60);
147
160
  return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
148
161
  } catch (err) {
@@ -955,7 +968,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
955
968
  if (args.includes('--check')) brief = applyCheck(brief, opts);
956
969
 
957
970
  if (args.includes('--summarize')) {
958
- brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn);
971
+ brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, cached.ticket);
959
972
  if (brief === null) return;
960
973
  }
961
974
 
@@ -1141,7 +1154,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1141
1154
  if (args.includes('--check')) output = applyCheck(output, opts);
1142
1155
 
1143
1156
  if (args.includes('--summarize')) {
1144
- output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn);
1157
+ output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn, ticket);
1145
1158
  if (output === null) return;
1146
1159
  }
1147
1160
 
@@ -102,6 +102,10 @@ export function parseCommand(args) {
102
102
  return { command: 'sync', args: args.slice(1) };
103
103
  }
104
104
 
105
+ if (first === 'update-skill') {
106
+ return { command: 'update-skill', args: args.slice(1) };
107
+ }
108
+
105
109
  // Anything that looks like a ticket key or any non-flag arg → fetch
106
110
  return { command: 'fetch', args };
107
111
  }
@@ -1,13 +1,17 @@
1
- export const HANDOFF_PROMPT = `Build a structured handoff brief from this Jira ticket's comment thread.
1
+ import { readFileSync } from 'node:fs';
2
+ import { extname } from 'node:path';
3
+
4
+ export const HANDOFF_PROMPT = `Build a structured handoff brief from this Jira ticket.
2
5
  The developer receiving this ticket has never seen it before — they need to get up to speed immediately.
6
+ Use the description, comments, attached documents, and linked Confluence pages as context.
3
7
 
4
8
  Respond in exactly this format (use the exact headings):
5
9
 
6
10
  ### What was attempted
7
- [2–5 bullet points of concrete work already done, based on the comments. Be specific — mention code paths, methods, or files if the comments reference them.]
11
+ [2–5 bullet points of concrete work already done. Be specific — mention code paths, methods, or files if referenced.]
8
12
 
9
13
  ### Current blockers
10
- [Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the comments suggest the path is clear.]
14
+ [Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the path is clear.]
11
15
 
12
16
  ### Open questions
13
17
  [Bullet points of unanswered questions or decisions not yet made. Write "None identified" if everything is resolved.]
@@ -16,16 +20,46 @@ Respond in exactly this format (use the exact headings):
16
20
  [1–2 sentences on the best starting point for the incoming developer.]
17
21
 
18
22
  Rules:
19
- - Be specific and factual. Reference actual details from the comments.
20
- - Do not invent anything not present in the comments.
23
+ - Be specific and factual. Reference actual details from the ticket content.
24
+ - Do not invent anything not present in the provided context.
21
25
  - Keep each bullet under 20 words.
22
- - If there are no comments, state that clearly in each section.
26
+ - If there are no comments, base your analysis on the description and attached documents.
23
27
 
24
28
  `;
25
29
 
30
+ const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.log', '.csv', '.json', '.xml', '.html', '.htm', '.rst', '.yaml', '.yml']);
31
+ const MAX_CHARS_PER_FILE = 4000;
32
+ const MAX_TOTAL_CHARS = 12000;
33
+
34
+ /**
35
+ * Read text content from downloaded local attachments.
36
+ * Skips binary files (images, PDFs, Office docs). Caps content per file and in total.
37
+ * @param {Array} localAttachments - from ticket.localAttachments
38
+ * @returns {Array<{filename: string, content: string}>}
39
+ */
40
+ export function readTextAttachments(localAttachments = []) {
41
+ let totalChars = 0;
42
+ const result = [];
43
+ for (const att of localAttachments) {
44
+ if (!att.localPath || att.skipReason === 'error') continue;
45
+ if (!TEXT_EXTENSIONS.has(extname(att.filename).toLowerCase())) continue;
46
+ try {
47
+ const raw = readFileSync(att.localPath, 'utf8');
48
+ const trimmed = raw.trim();
49
+ if (!trimmed) continue;
50
+ const remaining = MAX_TOTAL_CHARS - totalChars;
51
+ if (remaining <= 0) break;
52
+ const content = trimmed.slice(0, Math.min(MAX_CHARS_PER_FILE, remaining));
53
+ totalChars += content.length;
54
+ result.push({ filename: att.filename, content });
55
+ } catch { /* unreadable — skip */ }
56
+ }
57
+ return result;
58
+ }
59
+
26
60
  /**
27
61
  * Build the text input sent to the AI for handoff analysis.
28
- * Contains the ticket header and full comment thread.
62
+ * Includes description, Confluence pages, text-readable attachments, and comment thread.
29
63
  *
30
64
  * @param {object} ticket - Normalized ticket object from jira-client.normalizeTicket
31
65
  * @returns {string}
@@ -40,6 +74,33 @@ export function buildHandoffInput(ticket) {
40
74
  if (ticket.reporter) lines.push(`Reporter: ${ticket.reporter}`);
41
75
  lines.push('');
42
76
 
77
+ if (ticket.description) {
78
+ lines.push('--- Description ---');
79
+ lines.push(ticket.description.replace(/\r/g, ''));
80
+ lines.push('');
81
+ }
82
+
83
+ if (ticket.confluencePages?.length > 0) {
84
+ lines.push(`--- Confluence Pages (${ticket.confluencePages.length}) ---`);
85
+ for (const p of ticket.confluencePages) {
86
+ lines.push('');
87
+ lines.push(`### ${p.title ?? p.url}`);
88
+ if (p.text) lines.push(p.text);
89
+ }
90
+ lines.push('');
91
+ }
92
+
93
+ const textAttachments = readTextAttachments(ticket.localAttachments);
94
+ if (textAttachments.length > 0) {
95
+ lines.push(`--- Attached Documents (${textAttachments.length} text-readable) ---`);
96
+ for (const { filename, content } of textAttachments) {
97
+ lines.push('');
98
+ lines.push(`=== ${filename} ===`);
99
+ lines.push(content);
100
+ }
101
+ lines.push('');
102
+ }
103
+
43
104
  const comments = ticket.comments ?? [];
44
105
  lines.push(`--- Comments (${comments.length} total) ---`);
45
106
 
@@ -47,6 +47,7 @@ export function printHelp({ stream = process.stdout } = {}) {
47
47
  ` ${s.brand('ticketlens')} license Show license status`,
48
48
  ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
49
49
  ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
50
+ ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
50
51
  '',
51
52
  ` ${s.bold('FETCH OPTIONS')}`,
52
53
  '',
@@ -63,6 +64,7 @@ export function printHelp({ stream = process.stdout } = {}) {
63
64
  ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
64
65
  ` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
65
66
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
67
+ ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
66
68
  '',
67
69
  ` ${s.bold('TRIAGE OPTIONS')}`,
68
70
  '',
@@ -74,6 +76,8 @@ export function printHelp({ stream = process.stdout } = {}) {
74
76
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
75
77
  ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
76
78
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
79
+ ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
80
+ ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
77
81
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
78
82
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
79
83
  ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
@@ -102,6 +106,7 @@ export function printHelp({ stream = process.stdout } = {}) {
102
106
  ` ${s.dim(' ')} anthropicApiKey ${s.dim('→ Claude (paid)')}`,
103
107
  ` ${s.dim(' ')} openaiApiKey ${s.dim('→ GPT-4o mini (paid)')}`,
104
108
  ` ${s.dim(' ')} groqApiKey ${s.dim('→ Llama 3.1 (free tier — console.groq.com)')}`,
109
+ ` ${s.dim(' ')} ${s.dim('Set default:')} ticketlens config set aiProvider ${s.dim('<anthropic|openai|groq>')}`,
105
110
  '',
106
111
  '',
107
112
  ];
@@ -541,6 +546,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
541
546
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
542
547
  ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
543
548
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
549
+ ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
550
+ ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
544
551
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
545
552
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
546
553
  ` ${s.brand('--plain')} Plain markdown output`,
@@ -600,6 +607,42 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
600
607
  stream.write(lines.join('\n') + '\n');
601
608
  }
602
609
 
610
+ export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
611
+ const s = createStyler({ isTTY: stream.isTTY });
612
+ const lines = [
613
+ '',
614
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('update-skill')} ${s.dim('[--dry-run] [--path=DIR] [--quiet]')}`,
615
+ '',
616
+ ` Copy the latest /jtb SKILL.md into every detected AI assistant command directory.`,
617
+ ` Runs automatically on ${s.dim('npm install -g ticketlens')} for existing installs.`,
618
+ '',
619
+ ` ${s.bold('SUPPORTED ASSISTANTS')}`,
620
+ '',
621
+ ` Claude Code ${s.dim('~/.claude/commands/jtb.md')}`,
622
+ ` Claude Code (work) ${s.dim('~/.claude-work/commands/jtb.md')}`,
623
+ ` Gemini CLI ${s.dim('~/.gemini/commands/jtb.md')}`,
624
+ ` Copilot CLI ${s.dim('~/.copilot-cli/commands/jtb.md')}`,
625
+ '',
626
+ ` Only targets where ${s.dim('jtb.md')} already exists are updated. Use ${s.dim('--path')} to install`,
627
+ ` into a new location (the directory must exist).`,
628
+ '',
629
+ ` ${s.bold('OPTIONS')}`,
630
+ '',
631
+ ` ${s.brand('--dry-run')} Show what would change without writing any files`,
632
+ ` ${s.brand('--path')}=${s.dim('DIR')} Write to a specific commands directory instead`,
633
+ ` ${s.brand('--quiet')} Suppress all output except errors`,
634
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
635
+ '',
636
+ ` ${s.bold('EXAMPLES')}`,
637
+ '',
638
+ ` ${s.dim('$')} ticketlens update-skill`,
639
+ ` ${s.dim('$')} ticketlens update-skill --dry-run`,
640
+ ` ${s.dim('$')} ticketlens update-skill --path=~/.config/my-ai/commands`,
641
+ '',
642
+ ];
643
+ stream.write(lines.join('\n') + '\n');
644
+ }
645
+
603
646
  export function printStandupHelp({ stream = process.stdout } = {}) {
604
647
  const s = createStyler({ isTTY: stream.isTTY });
605
648
  const lines = [
@@ -0,0 +1,89 @@
1
+ /**
2
+ * POST triage snapshot to /v1/triage/share and return a 24h signed URL.
3
+ * Errors are non-fatal — share failure must never suppress triage output.
4
+ */
5
+
6
+ const SHARE_PATH = '/v1/triage/share';
7
+ const DEFAULT_API_BASE = 'http://ticketlens.test';
8
+
9
+ function apiBase() {
10
+ return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
11
+ }
12
+
13
+ function buildTicketPayload(scored, rawMap, baseUrl) {
14
+ const raw = rawMap?.get(scored.ticketKey);
15
+ return {
16
+ key: scored.ticketKey,
17
+ summary: scored.summary ?? null,
18
+ status: scored.status ?? null,
19
+ assignee: raw?.assignee ?? null,
20
+ attention_score: null,
21
+ flags: scored.urgency === 'clear' ? [] : [scored.urgency],
22
+ compliance_coverage: null,
23
+ compliance_status: 'unknown',
24
+ url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
25
+ last_updated: raw?.updated ?? null,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * @param {object} opts
31
+ * @param {object[]} [opts.sorted] - Scored tickets
32
+ * @param {Map} [opts.rawTicketMap] - Map<key, normalizedTicket>
33
+ * @param {string} [opts.profile] - Resolved profile name (max 100 chars)
34
+ * @param {string} [opts.baseUrl] - Jira base URL
35
+ * @param {string} [opts.licenseKey] - Bearer token
36
+ * @param {string} [opts.capturedAt] - ISO 8601 timestamp
37
+ * @param {Function} [opts.fetcher] - Injectable fetch
38
+ * @param {Function} [opts.print] - Output fn
39
+ * @returns {Promise<{ ok: boolean, status?: number }>}
40
+ */
41
+ export async function shareTriageSnapshot({
42
+ sorted = [],
43
+ rawTicketMap = new Map(),
44
+ profile,
45
+ baseUrl,
46
+ licenseKey,
47
+ capturedAt,
48
+ fetcher = globalThis.fetch,
49
+ print = (s) => process.stdout.write(s),
50
+ } = {}) {
51
+ if (!licenseKey) {
52
+ print('✗ --share requires an active Team license (ticketlens activate <key>)\n');
53
+ return { ok: false };
54
+ }
55
+
56
+ const payload = {
57
+ profile: String(profile ?? 'default').slice(0, 100),
58
+ captured_at: capturedAt ?? new Date().toISOString(),
59
+ tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
60
+ };
61
+
62
+ try {
63
+ const res = await fetcher(`${apiBase()}${SHARE_PATH}`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ 'Authorization': `Bearer ${licenseKey}`,
68
+ },
69
+ body: JSON.stringify(payload),
70
+ });
71
+
72
+ if (res.ok) {
73
+ const data = await res.json();
74
+ print(`✓ Share link (expires in 24h):\n ${data.url}\n`);
75
+ return { ok: true, status: res.status };
76
+ }
77
+
78
+ if (res.status === 403) {
79
+ print('✗ --share requires a Team license\n');
80
+ return { ok: false, status: res.status };
81
+ }
82
+
83
+ print(`⚠ Share failed (${res.status}) — triage output unaffected\n`);
84
+ return { ok: false, status: res.status };
85
+ } catch {
86
+ print('⚠ Share failed (network error) — triage output unaffected\n');
87
+ return { ok: false };
88
+ }
89
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * update-skill — copies SKILL.md to known AI assistant command directories.
3
+ *
4
+ * Usage:
5
+ * ticketlens update-skill # update all detected installs
6
+ * ticketlens update-skill --dry-run # show what would change, no writes
7
+ * ticketlens update-skill --path=/some/dir # write to a custom target directory
8
+ * ticketlens update-skill --quiet # suppress non-error output
9
+ */
10
+
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { homedir } from 'node:os';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const SKILL_SRC = join(__dirname, '..', '..', 'SKILL.md');
18
+
19
+ const HOME = homedir();
20
+
21
+ const DEFAULT_TARGETS = [
22
+ { label: 'Claude Code', path: join(HOME, '.claude', 'commands') },
23
+ { label: 'Claude Code (work)', path: join(HOME, '.claude-work', 'commands') },
24
+ { label: 'Gemini CLI', path: join(HOME, '.gemini', 'commands') },
25
+ { label: 'Copilot CLI', path: join(HOME, '.copilot-cli', 'commands') },
26
+ ];
27
+
28
+ function skillVersion(filePath) {
29
+ try {
30
+ const line = readFileSync(filePath, 'utf8').split('\n')[0];
31
+ const m = line.match(/jtb-skill-version:\s*([\d.]+)/);
32
+ return m ? m[1] : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function updateSkill(args = []) {
39
+ const dryRun = args.includes('--dry-run');
40
+ const quiet = args.includes('--quiet');
41
+ const pathArg = args.find(a => a.startsWith('--path='));
42
+
43
+ const log = (...msg) => { if (!quiet) process.stdout.write(msg.join(' ') + '\n'); };
44
+ const err = (...msg) => process.stderr.write(msg.join(' ') + '\n');
45
+
46
+ if (!existsSync(SKILL_SRC)) {
47
+ err(`✖ SKILL.md not found at ${SKILL_SRC}`);
48
+ err(' Reinstall TicketLens: npm install -g ticketlens@latest');
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ const srcVersion = skillVersion(SKILL_SRC) ?? 'unknown';
54
+ if (dryRun) log(`Dry run — skill source: v${srcVersion} (${SKILL_SRC})\n`);
55
+
56
+ let targets;
57
+ if (pathArg) {
58
+ const dir = pathArg.slice('--path='.length);
59
+ targets = [{ label: 'Custom path', path: dir }];
60
+ } else {
61
+ targets = DEFAULT_TARGETS;
62
+ }
63
+
64
+ let updated = 0;
65
+ let skipped = 0;
66
+ let notFound = 0;
67
+
68
+ for (const { label, path: dir } of targets) {
69
+ const dest = join(dir, 'jtb.md');
70
+
71
+ if (!existsSync(dir)) { notFound++; continue; }
72
+ if (!existsSync(dest)) { notFound++; continue; }
73
+
74
+ const destVersion = skillVersion(dest);
75
+
76
+ if (!dryRun && destVersion === srcVersion) {
77
+ log(` ✔ ${label}: already at v${srcVersion}`);
78
+ skipped++;
79
+ continue;
80
+ }
81
+
82
+ if (dryRun) {
83
+ const fromVer = destVersion ?? 'unversioned';
84
+ log(` → ${label}: ${dest}`);
85
+ log(` ${fromVer} → ${srcVersion} (would update)`);
86
+ continue;
87
+ }
88
+
89
+ try {
90
+ copyFileSync(SKILL_SRC, dest);
91
+ const fromVer = destVersion ?? 'unversioned';
92
+ log(` ✔ ${label}: updated v${fromVer} → v${srcVersion}`);
93
+ updated++;
94
+ } catch (copyErr) {
95
+ err(` ✖ ${label}: ${copyErr.message}`);
96
+ }
97
+ }
98
+
99
+ if (!dryRun) {
100
+ if (updated === 0 && notFound === targets.length) {
101
+ log('\n /jtb is not installed in any known AI assistant.');
102
+ log('\n To install for Claude Code:');
103
+ log(' mkdir -p ~/.claude/commands');
104
+ log(` cp "${SKILL_SRC}" ~/.claude/commands/jtb.md`);
105
+ log('\n Then restart your Claude Code session and use /jtb TICKET-KEY.');
106
+ return;
107
+ }
108
+ if (updated > 0) {
109
+ log(`\n ${updated} installation(s) updated to v${srcVersion}.`);
110
+ log(' Restart your AI assistant session to pick up the new skill.');
111
+ }
112
+ if (skipped > 0 && !quiet) {
113
+ log(` ${skipped} installation(s) already up to date.`);
114
+ }
115
+ }
116
+ }