ticketlens 0.2.0 → 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 CHANGED
@@ -291,6 +291,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
291
291
 
292
292
  ---
293
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
+
294
313
  ### /jtb — Jira TicketBrief for Claude Code
295
314
 
296
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.
@@ -301,11 +320,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
301
320
 
302
321
  ```bash
303
322
  npm install -g ticketlens && ticketlens init
304
- cp $(npm root -g)/ticketlens/skills/jtb/SKILL.md ~/.claude/commands/jtb.md
323
+ ticketlens update-skill # copies /jtb skill into ~/.claude/commands/jtb.md
305
324
  # Restart Claude Code, then:
306
325
  # /jtb CNV1-2
307
326
  ```
308
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
+
309
342
  **Usage in Claude Code:**
310
343
 
311
344
  ```
@@ -432,6 +465,11 @@ ticketlens login --manual # Paste flow — for CI/headless e
432
465
  ticketlens license # Show license tier and status
433
466
  ticketlens activate <LICENSE-KEY> # Activate a license key
434
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
+
435
473
  # ── Help and version ──────────────────────────────────────────────────────────
436
474
  ticketlens --help # Main help
437
475
  ticketlens --version # Show installed version
@@ -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 => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.2.0",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.2.0 -->
1
+ <!-- jtb-skill-version: 0.2.1 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -354,7 +354,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
354
354
 
355
355
  if (pushFlag) {
356
356
  const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
357
+ const { scanCurrentBranch } = await import('./lib/branch-scanner.mjs');
357
358
  const pushFn = opts.pushFn ?? pushTriageSnapshot;
359
+ const scanFn = opts.scanFn ?? scanCurrentBranch;
358
360
  const licenseKey = readLicense(configDir)?.key ?? null;
359
361
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
360
362
  await pushFn({
@@ -363,6 +365,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
363
365
  profile: profileName ?? 'default',
364
366
  baseUrl: conn.baseUrl,
365
367
  licenseKey,
368
+ gitBranches: scanFn(),
366
369
  fetcher,
367
370
  print: printFn,
368
371
  });
@@ -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
+ }
@@ -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 pkgPath = new URL('../../../../package.json', import.meta.url);
19
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
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';
@@ -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 {