ticketlens 0.1.25 → 0.2.1
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 +61 -4
- package/bin/ticketlens.mjs +17 -1
- package/package.json +5 -2
- package/scripts/dev-license.mjs +61 -0
- package/scripts/postinstall.mjs +62 -0
- package/skills/jtb/SKILL.md +246 -0
- package/skills/jtb/scripts/fetch-my-tickets.mjs +29 -8
- package/skills/jtb/scripts/lib/branch-scanner.mjs +56 -0
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/collision-reporter.mjs +45 -0
- package/skills/jtb/scripts/lib/config.mjs +9 -5
- package/skills/jtb/scripts/lib/help.mjs +43 -0
- package/skills/jtb/scripts/lib/run-collisions.mjs +64 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +3 -0
- package/skills/jtb/scripts/lib/triage-share.mjs +89 -0
- package/skills/jtb/scripts/lib/update-skill.mjs +116 -0
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
|
|
@@ -290,6 +291,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
|
|
|
290
291
|
|
|
291
292
|
---
|
|
292
293
|
|
|
294
|
+
### Update Skill
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
ticketlens update-skill # Sync /jtb skill to all detected AI assistants
|
|
298
|
+
ticketlens update-skill --dry-run # Preview what would be updated (no writes)
|
|
299
|
+
ticketlens update-skill --path=~/.gemini/commands # Sync to a specific assistant directory
|
|
300
|
+
ticketlens update-skill --quiet # Suppress output (useful in scripts)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Copies the latest `SKILL.md` to every AI assistant command directory where `/jtb` is already installed. Runs automatically on `npm install -g ticketlens` — for most users, upgrading the CLI is enough. Use `--dry-run` to confirm what would change before writing.
|
|
304
|
+
|
|
305
|
+
Supported assistants detected automatically:
|
|
306
|
+
- Claude Code — `~/.claude/commands/jtb.md`
|
|
307
|
+
- Claude Code (work) — `~/.claude-work/commands/jtb.md`
|
|
308
|
+
- Gemini CLI — `~/.gemini/commands/jtb.md`
|
|
309
|
+
- Copilot CLI — `~/.copilot-cli/commands/jtb.md`
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
293
313
|
### /jtb — Jira TicketBrief for Claude Code
|
|
294
314
|
|
|
295
315
|
`/jtb` is a Claude Code slash command that fetches full ticket context and drops a structured implementation brief directly into your session, then enters plan mode.
|
|
@@ -300,11 +320,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
|
|
|
300
320
|
|
|
301
321
|
```bash
|
|
302
322
|
npm install -g ticketlens && ticketlens init
|
|
303
|
-
|
|
323
|
+
ticketlens update-skill # copies /jtb skill into ~/.claude/commands/jtb.md
|
|
304
324
|
# Restart Claude Code, then:
|
|
305
325
|
# /jtb CNV1-2
|
|
306
326
|
```
|
|
307
327
|
|
|
328
|
+
**Keeping the skill up to date:**
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
npm install -g ticketlens@latest # update the CLI
|
|
332
|
+
ticketlens update-skill # sync the /jtb skill to the new version
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`update-skill` runs automatically on `npm install -g`, so for most users the second step is handled. If you manage Claude Code across multiple machines or accounts, run it manually after updating.
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
ticketlens update-skill --dry-run # preview what would change
|
|
339
|
+
ticketlens update-skill --path=~/.gemini/commands # sync to a different AI assistant
|
|
340
|
+
```
|
|
341
|
+
|
|
308
342
|
**Usage in Claude Code:**
|
|
309
343
|
|
|
310
344
|
```
|
|
@@ -375,6 +409,7 @@ ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
|
|
|
375
409
|
ticketlens triage --export=csv # Export to CSV [Team]
|
|
376
410
|
ticketlens triage --export=json # Export to JSON [Team]
|
|
377
411
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
412
|
+
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
378
413
|
ticketlens triage --digest # POST results to digest endpoint [Pro]
|
|
379
414
|
ticketlens triage --profile=acme --stale=3 --static # Combine flags
|
|
380
415
|
|
|
@@ -430,6 +465,11 @@ ticketlens login --manual # Paste flow — for CI/headless e
|
|
|
430
465
|
ticketlens license # Show license tier and status
|
|
431
466
|
ticketlens activate <LICENSE-KEY> # Activate a license key
|
|
432
467
|
|
|
468
|
+
# ── Skill maintenance ─────────────────────────────────────────────────────────
|
|
469
|
+
ticketlens update-skill # Sync /jtb skill to all detected AI assistants
|
|
470
|
+
ticketlens update-skill --dry-run # Preview what would be updated
|
|
471
|
+
ticketlens update-skill --path=~/.gemini/commands # Sync to a custom assistant directory
|
|
472
|
+
|
|
433
473
|
# ── Help and version ──────────────────────────────────────────────────────────
|
|
434
474
|
ticketlens --help # Main help
|
|
435
475
|
ticketlens --version # Show installed version
|
|
@@ -447,7 +487,7 @@ ticketlens cache clear --help # Cache clear help
|
|
|
447
487
|
|
|
448
488
|
Start free, upgrade when you need it — `ticketlens activate <key>`
|
|
449
489
|
|
|
450
|
-
### Pro — $
|
|
490
|
+
### Pro — $9/mo
|
|
451
491
|
|
|
452
492
|
<div align="center">
|
|
453
493
|
<img src="docs/demos/pro-triage.gif" alt="ticketlens --summarize AI summary demo" width="700" />
|
|
@@ -465,13 +505,27 @@ ticketlens schedule # Set up a scheduled daily digest
|
|
|
465
505
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
466
506
|
```
|
|
467
507
|
|
|
468
|
-
**`--
|
|
508
|
+
**`--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.
|
|
509
|
+
|
|
510
|
+
**`--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
511
|
|
|
470
512
|
- **What was attempted** — concrete work already done
|
|
471
513
|
- **Current blockers** — unresolved issues
|
|
472
514
|
- **Open questions** — decisions not yet made
|
|
473
515
|
- **Recommendation** — where to start
|
|
474
516
|
|
|
517
|
+
**What the AI can read:**
|
|
518
|
+
|
|
519
|
+
| Content | Included |
|
|
520
|
+
|---|---|
|
|
521
|
+
| Description | ✅ Always |
|
|
522
|
+
| Comments | ✅ Always |
|
|
523
|
+
| Linked Confluence pages | ✅ Jira only, same-origin |
|
|
524
|
+
| Text files (`.txt`, `.md`, `.log`, `.csv`, `.json`, `.yaml`, etc.) | ✅ Up to 4 KB per file, 12 KB total |
|
|
525
|
+
| Screenshots (`.png`, `.jpg`, `.gif`, etc.) | ❌ Binary — images require multimodal API |
|
|
526
|
+
| PDFs | ❌ Binary — no parser included (zero-dependency) |
|
|
527
|
+
| Office documents (`.docx`, `.xlsx`) | ❌ Binary — no parser included |
|
|
528
|
+
|
|
475
529
|
Add one of the following to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API:
|
|
476
530
|
|
|
477
531
|
| Key | Provider | Cost |
|
|
@@ -501,7 +555,7 @@ ticketlens CNV1-2 --handoff --provider=openai
|
|
|
501
555
|
|
|
502
556
|
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
557
|
|
|
504
|
-
### Team — $
|
|
558
|
+
### Team — $19/seat/mo
|
|
505
559
|
|
|
506
560
|
<div align="center">
|
|
507
561
|
<img src="docs/demos/teams-digest.gif" alt="ticketlens triage --plain digest pipeline demo" width="700" />
|
|
@@ -513,10 +567,13 @@ ticketlens triage --sprint="Sprint 12" # Filter by sprint name
|
|
|
513
567
|
ticketlens triage --export=csv # Export triage to CSV for standups and reports
|
|
514
568
|
ticketlens triage --export=json # Machine-readable export for dashboards
|
|
515
569
|
ticketlens triage --push # Push snapshot to the Console queue
|
|
570
|
+
ticketlens triage --share # Generate a 24h share URL — paste into Slack, no login needed for recipients
|
|
516
571
|
```
|
|
517
572
|
|
|
518
573
|
`--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
574
|
|
|
575
|
+
`--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.
|
|
576
|
+
|
|
520
577
|
Automate a morning digest with cron — no open terminal required:
|
|
521
578
|
|
|
522
579
|
```bash
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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';
|
|
@@ -52,6 +52,15 @@ switch (command) {
|
|
|
52
52
|
});
|
|
53
53
|
break;
|
|
54
54
|
|
|
55
|
+
case 'collisions': {
|
|
56
|
+
const { runCollisions } = await import('../skills/jtb/scripts/lib/run-collisions.mjs');
|
|
57
|
+
runCollisions(cmdArgs).catch(err => {
|
|
58
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
case 'init':
|
|
56
65
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
57
66
|
runInit().catch(err => {
|
|
@@ -314,6 +323,13 @@ switch (command) {
|
|
|
314
323
|
});
|
|
315
324
|
break;
|
|
316
325
|
|
|
326
|
+
case 'update-skill': {
|
|
327
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateSkillHelp(); break; }
|
|
328
|
+
const { updateSkill } = await import('../skills/jtb/scripts/lib/update-skill.mjs');
|
|
329
|
+
await updateSkill(cmdArgs);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
317
333
|
case 'login': {
|
|
318
334
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLoginHelp(); break; }
|
|
319
335
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.1 -->
|
|
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,21 +342,41 @@ 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
|
-
|
|
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');
|
|
357
|
+
const { scanCurrentBranch } = await import('./lib/branch-scanner.mjs');
|
|
355
358
|
const pushFn = opts.pushFn ?? pushTriageSnapshot;
|
|
359
|
+
const scanFn = opts.scanFn ?? scanCurrentBranch;
|
|
356
360
|
const licenseKey = readLicense(configDir)?.key ?? null;
|
|
357
361
|
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
358
362
|
await pushFn({
|
|
363
|
+
sorted,
|
|
364
|
+
rawTicketMap,
|
|
365
|
+
profile: profileName ?? 'default',
|
|
366
|
+
baseUrl: conn.baseUrl,
|
|
367
|
+
licenseKey,
|
|
368
|
+
gitBranches: scanFn(),
|
|
369
|
+
fetcher,
|
|
370
|
+
print: printFn,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (shareFlag) {
|
|
375
|
+
const { shareTriageSnapshot } = await import('./lib/triage-share.mjs');
|
|
376
|
+
const shareFn = opts.shareFn ?? shareTriageSnapshot;
|
|
377
|
+
const licenseKey = readLicense(configDir)?.key ?? null;
|
|
378
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
379
|
+
await shareFn({
|
|
359
380
|
sorted,
|
|
360
381
|
rawTicketMap,
|
|
361
382
|
profile: profileName ?? 'default',
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TICKET_KEY_RE = /([A-Z][A-Z0-9]+-\d+)/g;
|
|
6
|
+
const SPAWN_OPTS = { encoding: 'utf8', timeout: 10_000 };
|
|
7
|
+
const MAX_FILES = 200;
|
|
8
|
+
const BASE_CANDIDATES = ['origin/main', 'origin/master', 'origin/develop', 'main', 'master'];
|
|
9
|
+
|
|
10
|
+
function run(execFn, args, cwd) {
|
|
11
|
+
const result = execFn('git', args, { ...SPAWN_OPTS, cwd, stdio: 'pipe' });
|
|
12
|
+
return result.status === 0 ? (result.stdout ?? '') : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractTicketKeys(text) {
|
|
16
|
+
return [...new Set([...text.matchAll(TICKET_KEY_RE)].map(m => m[1]))];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function detectBase(execFn, cwd) {
|
|
20
|
+
for (const candidate of BASE_CANDIDATES) {
|
|
21
|
+
if (run(execFn, ['rev-parse', '--verify', candidate], cwd) !== null) return candidate;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scans the current git branch for changed files and linked ticket keys.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {string} [opts.cwd] Working directory (default: process.cwd())
|
|
31
|
+
* @param {Function} [opts.execFn] spawnSync replacement (injectable for tests)
|
|
32
|
+
* @param {Function} [opts.fsCheck] existsSync replacement (injectable for tests)
|
|
33
|
+
* @returns {Array<{branch:string, base:string|null, tickets:string[], files:string[]}>|null}
|
|
34
|
+
* One-element array for the current branch, or null if not in a git repo / detached HEAD.
|
|
35
|
+
*/
|
|
36
|
+
export function scanCurrentBranch({ cwd = process.cwd(), execFn = spawnSync, fsCheck = existsSync } = {}) {
|
|
37
|
+
if (!fsCheck(join(cwd, '.git'))) return null;
|
|
38
|
+
|
|
39
|
+
const branch = run(execFn, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)?.trim();
|
|
40
|
+
if (!branch || branch === 'HEAD') return null;
|
|
41
|
+
|
|
42
|
+
const base = detectBase(execFn, cwd);
|
|
43
|
+
|
|
44
|
+
let files = [];
|
|
45
|
+
if (base) {
|
|
46
|
+
const diffOut = run(execFn, ['diff', '--name-only', `${base}..HEAD`], cwd);
|
|
47
|
+
if (diffOut) {
|
|
48
|
+
files = diffOut.trim().split('\n').filter(Boolean).slice(0, MAX_FILES);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const recentLog = run(execFn, ['log', '--oneline', '-20'], cwd) ?? '';
|
|
53
|
+
const tickets = extractTicketKeys(`${branch} ${recentLog}`);
|
|
54
|
+
|
|
55
|
+
return [{ branch, base, tickets, files }];
|
|
56
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const INDENT = ' ';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats a collision list from the /v1/triage/collisions API response.
|
|
5
|
+
*
|
|
6
|
+
* @param {object[]} collisions
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {boolean} [opts.plain] Suppress ANSI (forced when not TTY)
|
|
9
|
+
* @param {boolean} [opts.isTTY] Enable ANSI escape sequences
|
|
10
|
+
* @param {boolean} [opts.json] Emit raw JSON instead of text
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function formatCollisions(collisions, { plain = false, isTTY = false, json = false } = {}) {
|
|
14
|
+
if (json) return JSON.stringify(collisions, null, 2) + '\n';
|
|
15
|
+
|
|
16
|
+
const useAnsi = isTTY && !plain;
|
|
17
|
+
const warn = useAnsi ? '\x1b[33m⚠\x1b[0m' : '⚠';
|
|
18
|
+
const ok = useAnsi ? '\x1b[32m✓\x1b[0m' : '✓';
|
|
19
|
+
const bold = useAnsi ? (s) => `\x1b[1m${s}\x1b[0m` : (s) => s;
|
|
20
|
+
const dim = useAnsi ? (s) => `\x1b[2m${s}\x1b[0m` : (s) => s;
|
|
21
|
+
|
|
22
|
+
if (collisions.length === 0) {
|
|
23
|
+
return `${ok} No branch collisions detected.\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const n = collisions.length;
|
|
27
|
+
const lines = [`${warn} ${bold(`${n} collision${n === 1 ? '' : 's'} detected`)}\n`];
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < n; i++) {
|
|
30
|
+
const c = collisions[i];
|
|
31
|
+
const yourKeys = c.your_tickets?.length ? c.your_tickets.join(', ') : '—';
|
|
32
|
+
const theirKeys = c.their_tickets?.length ? c.their_tickets.join(', ') : '—';
|
|
33
|
+
const count = c.shared_files?.length ?? 0;
|
|
34
|
+
|
|
35
|
+
lines.push(
|
|
36
|
+
` [${i + 1}] ${bold('You')} ${dim(`(${c.your_branch})`)} ↔ ${bold(c.teammate)} ${dim(`(${c.their_branch})`)}`,
|
|
37
|
+
` Tickets: ${yourKeys} ↔ ${theirKeys}`,
|
|
38
|
+
` Shared files (${count}):`,
|
|
39
|
+
...(c.shared_files ?? []).map(f => `${INDENT}${f}`),
|
|
40
|
+
'',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
* Single source of truth — import from here, do not redefine locally.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
6
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
|
|
10
11
|
/** Canonical config directory: ~/.ticketlens */
|
|
11
12
|
export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
12
13
|
|
|
13
|
-
/** Read package.json version once per process, memoized.
|
|
14
|
+
/** Read package.json version once per process, memoized.
|
|
15
|
+
* Uses realpathSync to resolve symlinks before navigating, so the correct
|
|
16
|
+
* package.json is found whether the module is loaded via a symlink or directly.
|
|
17
|
+
*/
|
|
14
18
|
let _version;
|
|
15
19
|
export function getVersion() {
|
|
16
20
|
if (_version) return _version;
|
|
17
21
|
try {
|
|
18
|
-
const
|
|
19
|
-
const pkg = JSON.parse(readFileSync(
|
|
22
|
+
const realDir = dirname(realpathSync(fileURLToPath(import.meta.url)));
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
|
|
20
24
|
_version = pkg.version || '0.0.0';
|
|
21
25
|
} catch {
|
|
22
26
|
_version = '0.0.0';
|
|
@@ -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,64 @@
|
|
|
1
|
+
import { readLicense } from './license.mjs';
|
|
2
|
+
import { formatCollisions } from './collision-reporter.mjs';
|
|
3
|
+
|
|
4
|
+
const COLLISIONS_PATH = '/v1/triage/collisions';
|
|
5
|
+
const DEFAULT_API_BASE = 'http://ticketlens.test';
|
|
6
|
+
|
|
7
|
+
function apiBase() {
|
|
8
|
+
return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetches cross-team branch collision data from the TicketLens API and prints results.
|
|
13
|
+
*
|
|
14
|
+
* @param {string[]} args CLI argument array (e.g. ['--json', '--plain'])
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {Function} [opts.fetcher] Injectable fetch
|
|
17
|
+
* @param {Function} [opts.print] Output function
|
|
18
|
+
* @param {Function} [opts.readLicenseFn] Injectable license reader
|
|
19
|
+
* @returns {Promise<{ ok: boolean, status?: number }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function runCollisions(args = [], opts = {}) {
|
|
22
|
+
const jsonFlag = args.includes('--json');
|
|
23
|
+
const plainFlag = args.includes('--plain');
|
|
24
|
+
const fetcher = opts.fetcher ?? globalThis.fetch;
|
|
25
|
+
const print = opts.print ?? ((s) => process.stdout.write(s));
|
|
26
|
+
const readLicenseFn = opts.readLicenseFn ?? (() => readLicense());
|
|
27
|
+
|
|
28
|
+
const licenseKey = readLicenseFn()?.key ?? null;
|
|
29
|
+
if (!licenseKey) {
|
|
30
|
+
print('✗ collisions requires an active Team license (ticketlens activate <key>)\n');
|
|
31
|
+
return { ok: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetcher(`${apiBase()}${COLLISIONS_PATH}`, {
|
|
36
|
+
headers: { Authorization: `Bearer ${licenseKey}` },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
if (res.status === 401) { print('✗ Invalid license key\n'); return { ok: false, status: 401 }; }
|
|
41
|
+
if (res.status === 403) { print('✗ collisions requires a Team license\n'); return { ok: false, status: 403 }; }
|
|
42
|
+
print(`⚠ Failed to fetch collisions (${res.status})\n`);
|
|
43
|
+
return { ok: false, status: res.status };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { collisions = [], message } = await res.json();
|
|
47
|
+
|
|
48
|
+
if (message && collisions.length === 0) {
|
|
49
|
+
print(message + '\n');
|
|
50
|
+
return { ok: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
print(formatCollisions(collisions, {
|
|
54
|
+
json: jsonFlag,
|
|
55
|
+
plain: plainFlag,
|
|
56
|
+
isTTY: process.stdout.isTTY ?? false,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
return { ok: true };
|
|
60
|
+
} catch {
|
|
61
|
+
print('⚠ Failed to fetch collisions (network error)\n');
|
|
62
|
+
return { ok: false };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -44,6 +44,7 @@ function buildTicketPayload(scored, rawMap, baseUrl) {
|
|
|
44
44
|
* @param {string} [opts.baseUrl] - Jira base URL for URL construction
|
|
45
45
|
* @param {string} [opts.licenseKey] - Bearer token for the API
|
|
46
46
|
* @param {string} [opts.capturedAt] - ISO 8601 timestamp (defaults to now)
|
|
47
|
+
* @param {Array} [opts.gitBranches] - Branch metadata from scanCurrentBranch (null = not in git repo)
|
|
47
48
|
* @param {Function} [opts.fetcher] - Injectable fetch (default: globalThis.fetch)
|
|
48
49
|
* @param {Function} [opts.print] - Output fn (default: process.stdout.write)
|
|
49
50
|
* @returns {Promise<{ ok: boolean, status?: number }>}
|
|
@@ -55,6 +56,7 @@ export async function pushTriageSnapshot({
|
|
|
55
56
|
baseUrl,
|
|
56
57
|
licenseKey,
|
|
57
58
|
capturedAt,
|
|
59
|
+
gitBranches,
|
|
58
60
|
fetcher = globalThis.fetch,
|
|
59
61
|
print = (s) => process.stdout.write(s),
|
|
60
62
|
} = {}) {
|
|
@@ -67,6 +69,7 @@ export async function pushTriageSnapshot({
|
|
|
67
69
|
profile: String(profile ?? 'default').slice(0, 100),
|
|
68
70
|
captured_at: capturedAt ?? new Date().toISOString(),
|
|
69
71
|
tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
|
|
72
|
+
...(gitBranches != null && { git_branches: gitBranches }),
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
try {
|
|
@@ -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
|
+
}
|