ticketlens 0.4.4 → 0.4.6

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
@@ -350,8 +350,12 @@ ticketlens sync # Pull your latest tracker profiles from the Console
350
350
 
351
351
  Use `--manual` when there is no GUI (CI runners, SSH sessions, containers).
352
352
 
353
+ `ticketlens logout` removes the stored Console auth token and disconnects this machine from your TicketLens account. Local Jira profiles and credentials are kept intact — re-run `ticketlens login` to reconnect.
354
+
353
355
  `ticketlens sync` pulls any tracker profiles you have configured in the Console and writes them locally, keeping your CLI in sync with your team settings without re-running `init`.
354
356
 
357
+ > **Console features** — `ticketlens triage --push`, `--share`, `ticketlens collisions`, and `ticketlens schedule` all require an active Console session. Run `ticketlens login` once and the token is stored automatically.
358
+
355
359
  ---
356
360
 
357
361
  ### License
@@ -262,13 +262,13 @@ switch (command) {
262
262
 
263
263
  if (subCmd === '--stop') {
264
264
  const { runScheduleStop } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
265
- await runScheduleStop({ licenseKey: readLicense()?.key });
265
+ await runScheduleStop({ cliToken: readCliToken() });
266
266
  break;
267
267
  }
268
268
 
269
269
  if (subCmd === '--status') {
270
270
  const { runScheduleStatus } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
271
- await runScheduleStatus({ licenseKey: readLicense()?.key });
271
+ await runScheduleStatus({ cliToken: readCliToken() });
272
272
  break;
273
273
  }
274
274
 
@@ -281,10 +281,12 @@ switch (command) {
281
281
  const { runScheduleWizard } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
282
282
  const { promptScheduleAnswers } = await import('../skills/jtb/scripts/lib/prompt-helpers.mjs');
283
283
  const answers = await promptScheduleAnswers(cmdArgs);
284
- const result = await runScheduleWizard({ answers, licenseKey: readLicense()?.key });
284
+ const result = await runScheduleWizard({ answers, cliToken: readCliToken() });
285
+ if (!result.ok) { process.exitCode = 1; break; }
285
286
 
286
- process.stdout.write(`✔ Digest scheduled for ${answers.time} ${answers.timezone}\n`);
287
- process.stdout.write(` Next delivery: ${result.nextDelivery}\n`);
287
+ const s = createStyler({ isTTY: process.stdout.isTTY });
288
+ process.stdout.write(` ${s.green('✔')} Digest scheduled for ${s.cyan(answers.time)} ${answers.timezone}\n`);
289
+ process.stdout.write(` Next delivery: ${s.cyan(result.nextDelivery)}\n`);
288
290
  break;
289
291
  }
290
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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": {
@@ -18,6 +18,7 @@ import { promptProfileSelect } from './lib/profile-picker.mjs';
18
18
  import { printTriageHelp } from './lib/help.mjs';
19
19
  import { handleUnknownFlags } from './lib/arg-validator.mjs';
20
20
  import { isLicensed, showUpgradePrompt, revalidateIfStale, readLicense } from './lib/license.mjs';
21
+ import { readCliToken } from './lib/cli-auth.mjs';
21
22
 
22
23
  const DEFAULT_STATUSES = ['In Progress', 'Code Review', 'QA'];
23
24
 
@@ -334,37 +335,20 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
334
335
  return;
335
336
  }
336
337
 
337
- // Interactive mode: TTY + not --plain + not --static
338
- const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
339
- if (wantInteractive && process.stdin.setRawMode) {
340
- const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
341
- if (result === 'switch') {
342
- const cleanArgs = args.filter(a => !a.startsWith('--profile=') && !a.startsWith('--project='));
343
- return run(cleanArgs, { ...opts, env, fetcher, configDir });
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');
353
- }
354
-
338
+ // push/share run first so their result is visible before (or instead of) the TUI
355
339
  if (pushFlag) {
356
340
  const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
357
341
  const { scanCurrentBranch } = await import('./lib/branch-scanner.mjs');
358
342
  const pushFn = opts.pushFn ?? pushTriageSnapshot;
359
343
  const scanFn = opts.scanFn ?? scanCurrentBranch;
360
- const licenseKey = readLicense(configDir)?.key ?? null;
344
+ const cliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
361
345
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
362
346
  await pushFn({
363
347
  sorted,
364
348
  rawTicketMap,
365
349
  profile: profileName ?? 'default',
366
350
  baseUrl: conn.baseUrl,
367
- licenseKey,
351
+ cliToken,
368
352
  gitBranches: scanFn(),
369
353
  fetcher,
370
354
  print: printFn,
@@ -374,18 +358,35 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
374
358
  if (shareFlag) {
375
359
  const { shareTriageSnapshot } = await import('./lib/triage-share.mjs');
376
360
  const shareFn = opts.shareFn ?? shareTriageSnapshot;
377
- const licenseKey = readLicense(configDir)?.key ?? null;
361
+ const cliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
378
362
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
379
363
  await shareFn({
380
364
  sorted,
381
365
  rawTicketMap,
382
366
  profile: profileName ?? 'default',
383
367
  baseUrl: conn.baseUrl,
384
- licenseKey,
368
+ cliToken,
385
369
  fetcher,
386
370
  print: printFn,
387
371
  });
388
372
  }
373
+
374
+ // Interactive mode: TTY + not --plain + not --static
375
+ const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
376
+ if (wantInteractive && process.stdin.setRawMode) {
377
+ const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
378
+ if (result === 'switch') {
379
+ const cleanArgs = args.filter(a => !a.startsWith('--profile=') && !a.startsWith('--project='));
380
+ return run(cleanArgs, { ...opts, env, fetcher, configDir });
381
+ }
382
+ return;
383
+ }
384
+
385
+ const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
386
+ const summary = useStyled
387
+ ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
388
+ : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
389
+ process.stdout.write(summary + '\n');
389
390
  }
390
391
 
391
392
  // Run if invoked directly
@@ -1,7 +1,8 @@
1
- import { readLicense } from './license.mjs';
1
+ import { readCliToken } from './cli-auth.mjs';
2
2
  import { formatCollisions } from './collision-reporter.mjs';
3
-
3
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
4
4
  import { apiBase, warnIfInsecure } from './api-utils.mjs';
5
+ import { red, yellow, bold, cyan } from './ansi.mjs';
5
6
 
6
7
  const COLLISIONS_PATH = '/v1/triage/collisions';
7
8
 
@@ -10,42 +11,44 @@ const COLLISIONS_PATH = '/v1/triage/collisions';
10
11
  *
11
12
  * @param {string[]} args CLI argument array (e.g. ['--json', '--plain'])
12
13
  * @param {object} [opts]
13
- * @param {Function} [opts.fetcher] Injectable fetch
14
- * @param {Function} [opts.print] Output function
15
- * @param {Function} [opts.readLicenseFn] Injectable license reader
14
+ * @param {Function} [opts.fetcher] Injectable fetch
15
+ * @param {Function} [opts.print] Output function
16
+ * @param {Function} [opts.readCliTokenFn] Injectable CLI token reader
17
+ * @param {string} [opts.configDir] Config directory override
16
18
  * @returns {Promise<{ ok: boolean, status?: number }>}
17
19
  */
18
20
  export async function runCollisions(args = [], opts = {}) {
19
21
  const jsonFlag = args.includes('--json');
20
22
  const plainFlag = args.includes('--plain');
21
- const fetcher = opts.fetcher ?? globalThis.fetch;
22
- const print = opts.print ?? ((s) => process.stdout.write(s));
23
- const warn = opts.warn ?? ((s) => process.stderr.write(s));
24
- const readLicenseFn = opts.readLicenseFn ?? (() => readLicense());
23
+ const fetcher = opts.fetcher ?? globalThis.fetch;
24
+ const print = opts.print ?? ((s) => process.stdout.write(s));
25
+ const warn = opts.warn ?? ((s) => process.stderr.write(s));
26
+ const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
27
+ const readCliTokenFn = opts.readCliTokenFn ?? ((dir) => readCliToken(dir));
25
28
  warnIfInsecure(apiBase(), warn);
26
29
 
27
- const licenseKey = readLicenseFn()?.key ?? null;
28
- if (!licenseKey) {
29
- print('✗ collisions requires an active Team license (ticketlens activate <key>)\n');
30
+ const cliToken = readCliTokenFn(configDir) ?? null;
31
+ if (!cliToken) {
32
+ print(` ${red('✗')} ${bold('collisions')} requires Console access. Run ${cyan('ticketlens login')} first.\n`);
30
33
  return { ok: false };
31
34
  }
32
35
 
33
36
  try {
34
37
  const res = await fetcher(`${apiBase()}${COLLISIONS_PATH}`, {
35
- headers: { Authorization: `Bearer ${licenseKey}` },
38
+ headers: { Authorization: `Bearer ${cliToken}` },
36
39
  signal: AbortSignal.timeout(15_000),
37
40
  });
38
41
 
39
42
  if (!res.ok) {
40
43
  if (res.status === 401) {
41
- print('✗ Invalid license key\n');
44
+ print(` ${red('✗')} Session expired. Run ${cyan('ticketlens login')} to reconnect.\n`);
42
45
  return { ok: false, status: 401 };
43
46
  }
44
47
  if (res.status === 403) {
45
- print('✗ collisions requires a Team license\n');
48
+ print(` ${red('✗')} ${bold('collisions')} requires a Team license\n`);
46
49
  return { ok: false, status: 403 };
47
50
  }
48
- print(`⚠ Failed to fetch collisions (${res.status})\n`);
51
+ print(` ${yellow('⚠')} Failed to fetch collisions (${res.status})\n`);
49
52
  return { ok: false, status: res.status };
50
53
  }
51
54
 
@@ -64,7 +67,7 @@ export async function runCollisions(args = [], opts = {}) {
64
67
 
65
68
  return { ok: true };
66
69
  } catch {
67
- print('⚠ Failed to fetch collisions (network error)\n');
70
+ print(` ${yellow('⚠')} Failed to fetch collisions (network error)\n`);
68
71
  return { ok: false };
69
72
  }
70
73
  }
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir, platform as osPlatform } from 'node:os';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { red, green, cyan } from './ansi.mjs';
5
6
 
6
7
  const SCHEDULE_URL = 'https://api.ticketlens.dev/v1/schedule';
7
8
 
@@ -52,12 +53,17 @@ export function buildCronLine({ hour, minute, ticketlensBin }) {
52
53
  export async function runScheduleWizard({
53
54
  answers,
54
55
  fetcher = globalThis.fetch,
55
- licenseKey,
56
+ cliToken,
56
57
  configDir,
57
58
  platform = osPlatform(),
58
59
  writeLocalJob = defaultWriteLocalJob,
59
60
  timeoutMs = 10_000,
61
+ print = s => process.stdout.write(s),
60
62
  }) {
63
+ if (!cliToken) {
64
+ print(` ${red('✗')} schedule requires Console access. Run ${cyan('ticketlens login')} first.\n`);
65
+ return { ok: false };
66
+ }
61
67
  const { time, email, timezone } = answers;
62
68
  const [hourStr, minuteStr] = time.split(':');
63
69
  const hour = parseInt(hourStr, 10);
@@ -68,7 +74,7 @@ export async function runScheduleWizard({
68
74
  signal: AbortSignal.timeout(timeoutMs),
69
75
  headers: {
70
76
  'Content-Type': 'application/json',
71
- 'Authorization': `Bearer ${licenseKey}`,
77
+ 'Authorization': `Bearer ${cliToken}`,
72
78
  },
73
79
  body: JSON.stringify({ email, timezone, deliverAt: time }),
74
80
  });
@@ -91,11 +97,20 @@ export async function runScheduleWizard({
91
97
  return data;
92
98
  }
93
99
 
94
- export async function runScheduleStop({ fetcher = globalThis.fetch, licenseKey, platform = osPlatform() }) {
100
+ export async function runScheduleStop({
101
+ fetcher = globalThis.fetch,
102
+ cliToken,
103
+ platform = osPlatform(),
104
+ print = s => process.stdout.write(s),
105
+ }) {
106
+ if (!cliToken) {
107
+ print(` ${red('✗')} schedule requires Console access. Run ${cyan('ticketlens login')} first.\n`);
108
+ return;
109
+ }
95
110
  const res = await fetcher(SCHEDULE_URL, {
96
111
  method: 'DELETE',
97
112
  signal: AbortSignal.timeout(10_000),
98
- headers: { 'Authorization': `Bearer ${licenseKey}` },
113
+ headers: { 'Authorization': `Bearer ${cliToken}` },
99
114
  });
100
115
  if (!res.ok) throw new Error(`Schedule API error ${res.status}`);
101
116
 
@@ -111,23 +126,31 @@ export async function runScheduleStop({ fetcher = globalThis.fetch, licenseKey,
111
126
  spawnSync('crontab', [tmp], { encoding: 'utf8' });
112
127
  }
113
128
 
114
- process.stdout.write('✔ Digest schedule removed.\n');
129
+ print(` ${green('✔')} Digest schedule removed.\n`);
115
130
  }
116
131
 
117
- export async function runScheduleStatus({ fetcher = globalThis.fetch, licenseKey }) {
132
+ export async function runScheduleStatus({
133
+ fetcher = globalThis.fetch,
134
+ cliToken,
135
+ print = s => process.stdout.write(s),
136
+ }) {
137
+ if (!cliToken) {
138
+ print(` ${red('✗')} schedule requires Console access. Run ${cyan('ticketlens login')} first.\n`);
139
+ return;
140
+ }
118
141
  const res = await fetcher(SCHEDULE_URL, {
119
142
  method: 'GET',
120
143
  signal: AbortSignal.timeout(10_000),
121
- headers: { 'Authorization': `Bearer ${licenseKey}` },
144
+ headers: { 'Authorization': `Bearer ${cliToken}` },
122
145
  });
123
146
  if (!res.ok) {
124
147
  process.stdout.write('No active digest schedule found.\n');
125
148
  return;
126
149
  }
127
150
  const data = await res.json();
128
- process.stdout.write(`Digest schedule: ${data.deliverAt} ${data.timezone}\n`);
129
- process.stdout.write(`Last delivered: ${data.lastDeliveredAt ?? 'never'}\n`);
130
- process.stdout.write(`Next delivery: ${data.nextDelivery}\n`);
151
+ print(` ${green('✔')} Digest schedule: ${cyan(data.deliverAt)} ${data.timezone}\n`);
152
+ print(` Last delivered: ${data.lastDeliveredAt ?? 'never'}\n`);
153
+ print(` Next delivery: ${cyan(data.nextDelivery)}\n`);
131
154
  }
132
155
 
133
156
  function resolveTicketlensBin() {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared ticket payload builder used by triage-push and triage-share.
3
+ */
4
+
5
+ export function buildTicketPayload(scored, rawMap, baseUrl) {
6
+ const raw = rawMap?.get(scored.ticketKey);
7
+ return {
8
+ key: scored.ticketKey,
9
+ summary: scored.summary ?? null,
10
+ status: scored.status ?? null,
11
+ assignee: raw?.assignee ?? null,
12
+ attention_score: null,
13
+ flags: scored.urgency === 'clear' ? [] : [scored.urgency],
14
+ compliance_coverage: null,
15
+ compliance_status: 'unknown',
16
+ url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
17
+ last_updated: raw?.updated ?? null,
18
+ };
19
+ }
@@ -6,8 +6,9 @@
6
6
  import { readLedger } from './ledger.mjs';
7
7
  import { isLicensed } from './license.mjs';
8
8
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
-
10
9
  import { apiBase, warnIfInsecure } from './api-utils.mjs';
10
+ import { buildTicketPayload } from './ticket-payload.mjs';
11
+ import { red, green, yellow, bold, cyan } from './ansi.mjs';
11
12
 
12
13
  const PUSH_PATH = '/v1/triage/push';
13
14
 
@@ -16,27 +17,10 @@ const PUSH_PATH = '/v1/triage/push';
16
17
  // https://api.ticketlens.com → https://app.ticketlens.com/console/queue
17
18
  export function queueUrl(base) {
18
19
  const noApi = base.replace(/^(https?:\/\/)api\./, '$1');
19
- // Production: ticketlens.com → app.ticketlens.com
20
- const console = /ticketlens\.com/.test(noApi)
20
+ const consoleBase = /ticketlens\.com/.test(noApi)
21
21
  ? noApi.replace('://', '://app.')
22
22
  : noApi;
23
- return console + '/console/queue';
24
- }
25
-
26
- function buildTicketPayload(scored, rawMap, baseUrl) {
27
- const raw = rawMap?.get(scored.ticketKey);
28
- return {
29
- key: scored.ticketKey,
30
- summary: scored.summary ?? null,
31
- status: scored.status ?? null,
32
- assignee: raw?.assignee ?? null,
33
- attention_score: null,
34
- flags: scored.urgency === 'clear' ? [] : [scored.urgency],
35
- compliance_coverage: null,
36
- compliance_status: 'unknown',
37
- url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
38
- last_updated: raw?.updated ?? null,
39
- };
23
+ return consoleBase + '/console/queue';
40
24
  }
41
25
 
42
26
  /**
@@ -47,7 +31,7 @@ function buildTicketPayload(scored, rawMap, baseUrl) {
47
31
  * @param {Map} [opts.rawTicketMap] - Map<key, normalizedTicket>
48
32
  * @param {string} [opts.profile] - Resolved profile name (max 100 chars)
49
33
  * @param {string} [opts.baseUrl] - Jira base URL for URL construction
50
- * @param {string} [opts.licenseKey] - Bearer token for the API
34
+ * @param {string} [opts.cliToken] - CLI session token for the API
51
35
  * @param {string} [opts.capturedAt] - ISO 8601 timestamp (defaults to now)
52
36
  * @param {Array} [opts.gitBranches] - Branch metadata from scanCurrentBranch (null = not in git repo)
53
37
  * @param {Function} [opts.fetcher] - Injectable fetch (default: globalThis.fetch)
@@ -59,7 +43,7 @@ export async function pushTriageSnapshot({
59
43
  rawTicketMap = new Map(),
60
44
  profile,
61
45
  baseUrl,
62
- licenseKey,
46
+ cliToken,
63
47
  capturedAt,
64
48
  gitBranches,
65
49
  fetcher = globalThis.fetch,
@@ -70,8 +54,8 @@ export async function pushTriageSnapshot({
70
54
  configDir = DEFAULT_CONFIG_DIR,
71
55
  } = {}) {
72
56
  warnIfInsecure(apiBase(), warn);
73
- if (!licenseKey) {
74
- print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
57
+ if (!cliToken) {
58
+ print(` ${red('✗')} ${bold('--push')} requires Console access. Run ${cyan('ticketlens login')} first.\n`);
75
59
  return { ok: false };
76
60
  }
77
61
 
@@ -110,26 +94,31 @@ export async function pushTriageSnapshot({
110
94
  method: 'POST',
111
95
  headers: {
112
96
  'Content-Type': 'application/json',
113
- 'Authorization': `Bearer ${licenseKey}`,
97
+ 'Authorization': `Bearer ${cliToken}`,
114
98
  },
115
99
  body: JSON.stringify(payload),
116
100
  signal: AbortSignal.timeout(15_000),
117
101
  });
118
102
 
119
103
  if (res.ok) {
120
- print(`✓ Queue updated — view at ${queueUrl(apiBase())}\n`);
104
+ print(` ${green('✓')} Queue updated — view at ${queueUrl(apiBase())}\n`);
121
105
  return { ok: true, status: res.status };
122
106
  }
123
107
 
108
+ if (res.status === 401) {
109
+ print(` ${red('✗')} Session expired. Run ${cyan('ticketlens login')} to reconnect.\n`);
110
+ return { ok: false, status: res.status };
111
+ }
112
+
124
113
  if (res.status === 403) {
125
- print('✗ --push requires a Team license\n');
114
+ print(` ${red('✗')} ${bold('--push')} requires a Team license\n`);
126
115
  return { ok: false, status: res.status };
127
116
  }
128
117
 
129
- print(`⚠ Push failed (${res.status}) — triage output unaffected\n`);
118
+ print(` ${yellow('⚠')} Push failed (${res.status}) — triage output unaffected\n`);
130
119
  return { ok: false, status: res.status };
131
120
  } catch {
132
- print('⚠ Push failed (network error) — triage output unaffected\n');
121
+ print(` ${yellow('⚠')} Push failed (network error) — triage output unaffected\n`);
133
122
  return { ok: false };
134
123
  }
135
124
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { apiBase, warnIfInsecure } from './api-utils.mjs';
7
+ import { red, green, yellow, bold, cyan } from './ansi.mjs';
7
8
 
8
9
  const SHARE_PATH = '/v1/triage/share';
9
10
 
@@ -29,7 +30,7 @@ function buildTicketPayload(scored, rawMap, baseUrl) {
29
30
  * @param {Map} [opts.rawTicketMap] - Map<key, normalizedTicket>
30
31
  * @param {string} [opts.profile] - Resolved profile name (max 100 chars)
31
32
  * @param {string} [opts.baseUrl] - Jira base URL
32
- * @param {string} [opts.licenseKey] - Bearer token
33
+ * @param {string} [opts.cliToken] - CLI session token
33
34
  * @param {string} [opts.capturedAt] - ISO 8601 timestamp
34
35
  * @param {Function} [opts.fetcher] - Injectable fetch
35
36
  * @param {Function} [opts.print] - Output fn
@@ -40,15 +41,15 @@ export async function shareTriageSnapshot({
40
41
  rawTicketMap = new Map(),
41
42
  profile,
42
43
  baseUrl,
43
- licenseKey,
44
+ cliToken,
44
45
  capturedAt,
45
46
  fetcher = globalThis.fetch,
46
47
  print = (s) => process.stdout.write(s),
47
48
  warn = (s) => process.stderr.write(s),
48
49
  } = {}) {
49
50
  warnIfInsecure(apiBase(), warn);
50
- if (!licenseKey) {
51
- print('✗ --share requires an active Team license (ticketlens activate <key>)\n');
51
+ if (!cliToken) {
52
+ print(` ${red('✗')} ${bold('--share')} requires Console access. Run ${cyan('ticketlens login')} first.\n`);
52
53
  return { ok: false };
53
54
  }
54
55
 
@@ -63,7 +64,7 @@ export async function shareTriageSnapshot({
63
64
  method: 'POST',
64
65
  headers: {
65
66
  'Content-Type': 'application/json',
66
- 'Authorization': `Bearer ${licenseKey}`,
67
+ 'Authorization': `Bearer ${cliToken}`,
67
68
  },
68
69
  body: JSON.stringify(payload),
69
70
  signal: AbortSignal.timeout(15_000),
@@ -71,19 +72,24 @@ export async function shareTriageSnapshot({
71
72
 
72
73
  if (res.ok) {
73
74
  const data = await res.json();
74
- print(`✓ Share link (expires in 24h):\n ${data.url}\n`);
75
+ print(` ${green('✓')} Share link (expires in 24h):\n ${data.url}\n`);
75
76
  return { ok: true, status: res.status };
76
77
  }
77
78
 
79
+ if (res.status === 401) {
80
+ print(` ${red('✗')} Session expired. Run ${cyan('ticketlens login')} to reconnect.\n`);
81
+ return { ok: false, status: res.status };
82
+ }
83
+
78
84
  if (res.status === 403) {
79
- print('✗ --share requires a Team license\n');
85
+ print(` ${red('✗')} ${bold('--share')} requires a Team license\n`);
80
86
  return { ok: false, status: res.status };
81
87
  }
82
88
 
83
- print(`⚠ Share failed (${res.status}) — triage output unaffected\n`);
89
+ print(` ${yellow('⚠')} Share failed (${res.status}) — triage output unaffected\n`);
84
90
  return { ok: false, status: res.status };
85
91
  } catch {
86
- print('⚠ Share failed (network error) — triage output unaffected\n');
92
+ print(` ${yellow('⚠')} Share failed (network error) — triage output unaffected\n`);
87
93
  return { ok: false };
88
94
  }
89
95
  }