ticketlens 0.10.3 → 0.10.18

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
@@ -55,6 +55,8 @@ npx ticketlens init
55
55
  npx ticketlens CNV1-2
56
56
  ```
57
57
 
58
+ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
59
+
58
60
  **Prerequisites:** Node.js >=20
59
61
 
60
62
  ---
@@ -7,8 +7,8 @@
7
7
  * ticketlens triage [--stale=N] [--status=X,Y] [--profile=NAME]
8
8
  */
9
9
 
10
- import { createRequire } from 'node:module';
11
10
  import { parseCommand } from '../skills/jtb/scripts/lib/cli.mjs';
11
+ import { renderWordmark } from '../skills/jtb/scripts/lib/wordmark.mjs';
12
12
  import { run as runFetch } from '../skills/jtb/scripts/fetch-ticket.mjs';
13
13
  import { run as runTriage } from '../skills/jtb/scripts/fetch-my-tickets.mjs';
14
14
  import { run as runInit } from '../skills/jtb/scripts/lib/init-wizard.mjs';
@@ -29,14 +29,13 @@ import {
29
29
  } from '../skills/jtb/scripts/lib/help.mjs';
30
30
  import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
31
31
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
32
- import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
33
- import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
34
- import { syncProfiles, getApiBase, getConsoleBase } from '../skills/jtb/scripts/lib/sync.mjs';
35
- import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
32
+ import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
33
+ import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
34
+ import { syncProfiles, reportSyncResult, getApiBase } from '../skills/jtb/scripts/lib/sync.mjs';
36
35
  import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
37
36
  import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
38
37
  import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
39
- import { applyTeamConfigOnLogin, checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
38
+ import { checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
40
39
 
41
40
  const TRACKED_COMMANDS = new Set([
42
41
  'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
@@ -184,7 +183,24 @@ switch (command) {
184
183
 
185
184
  const profileArg = cmdArgs.find(a => a.startsWith('--profile='));
186
185
  const profileName = profileArg ? profileArg.split('=')[1] : undefined;
187
- runConfig({ profileName }).catch(err => {
186
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !cmdArgs.includes('--no-input');
187
+
188
+ (async () => {
189
+ if (isInteractive) {
190
+ const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
191
+ // Ready + no explicit --profile= honors the hub's own "Exit" sublabel
192
+ // promise ("you can rerun this any time: ticketlens config"). An
193
+ // explicit --profile= is a targeted, scriptable invocation — bypass
194
+ // the hub and edit that profile directly, unchanged from today.
195
+ if (detectSetupState().status !== 'ready' || !profileName) {
196
+ const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
197
+ await runOnboarding({ stream: process.stderr });
198
+ return;
199
+ }
200
+ }
201
+
202
+ await runConfig({ profileName });
203
+ })().catch(err => {
188
204
  process.stderr.write(`Error: ${err.message}\n`);
189
205
  process.exitCode = 1;
190
206
  });
@@ -329,12 +345,9 @@ switch (command) {
329
345
  });
330
346
  break;
331
347
 
332
- case 'version': {
333
- const require = createRequire(import.meta.url);
334
- const pkg = require('../package.json');
335
- process.stdout.write(`ticketlens v${pkg.version}\n`);
348
+ case 'version':
349
+ process.stdout.write(renderWordmark({ stream: process.stdout }));
336
350
  break;
337
- }
338
351
 
339
352
  case 'schedule': {
340
353
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printScheduleHelp(); break; }
@@ -430,78 +443,7 @@ switch (command) {
430
443
  const useManual = cmdArgs.includes('--manual');
431
444
 
432
445
  (async () => {
433
- const s = createStyler({ isTTY: process.stderr.isTTY });
434
-
435
- let token;
436
-
437
- if (useManual) {
438
- // ── manual paste flow (CI / headless environments) ──────────────────
439
- process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
440
- process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
441
- process.stderr.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
442
- process.stderr.write(` ${s.dim('then paste it below.')}\n\n`);
443
-
444
- token = await promptSecret(`CLI Token ${s.dim('(tl_…)')}:`, { stream: process.stderr });
445
- if (!token.startsWith('tl_')) {
446
- process.stderr.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
447
- process.exitCode = 1;
448
- return;
449
- }
450
- } else {
451
- // ── browser flow (default) ────────────────────────────────────────
452
- process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
453
- process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
454
- process.stderr.write(` Opening browser to authorize…\n\n`);
455
- process.stderr.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
456
-
457
- try {
458
- token = await browserLogin();
459
- } catch (err) {
460
- const cancelled = err.message === 'Authorization cancelled';
461
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
462
- if (!cancelled) {
463
- process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
464
- }
465
- process.exitCode = cancelled ? 0 : 1;
466
- return;
467
- }
468
- }
469
-
470
- // ── verify token against API (both flows) ─────────────────────────
471
- process.stderr.write(`\n ${s.dim('○ Verifying token…')}\n`);
472
- let res;
473
- try {
474
- res = await fetch(`${getApiBase()}/v1/profiles`, {
475
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
476
- signal: AbortSignal.timeout(15000),
477
- });
478
- } catch {
479
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
480
- process.exitCode = 1;
481
- return;
482
- }
483
-
484
- if (res.status === 401) {
485
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Invalid token — check the value and try again.\n`);
486
- process.exitCode = 1;
487
- return;
488
- }
489
- if (!res.ok) {
490
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Server returned ${res.status}. Try again later.\n`);
491
- process.exitCode = 1;
492
- return;
493
- }
494
-
495
- saveCliToken(token);
496
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
497
-
498
- // Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
499
- const tcLogin = await applyTeamConfigOnLogin().catch(() => null);
500
- if (tcLogin?.ok) {
501
- process.stderr.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
502
- }
503
-
504
- process.stderr.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
446
+ await runLogin({ manual: useManual });
505
447
  })().catch(err => {
506
448
  process.stderr.write(`Error: ${err.message}\n`);
507
449
  process.exitCode = 1;
@@ -525,42 +467,14 @@ switch (command) {
525
467
 
526
468
  const result = await syncProfiles();
527
469
 
528
- if (result.error === 'no-token') {
529
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Not logged in. Run ${s.cyan('ticketlens login')} first.\n\n`);
530
- process.exitCode = 1;
531
- return;
532
- }
533
- if (result.error === 'unauthorized') {
534
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Token expired or revoked. Run ${s.cyan('ticketlens login')} to re-authenticate.\n\n`);
535
- process.exitCode = 1;
536
- return;
537
- }
470
+ process.stderr.write('\x1b[A\r\x1b[2K');
471
+ reportSyncResult(result, { stream: process.stderr });
538
472
  if (result.error) {
539
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Sync failed: ${result.error}\n\n`);
473
+ process.stderr.write('\n');
540
474
  process.exitCode = 1;
541
475
  return;
542
476
  }
543
477
 
544
- const { added, updated, unchanged, needsCredentials } = result;
545
- const total = added.length + updated.length + unchanged.length;
546
-
547
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Sync complete`);
548
- if (total === 0) {
549
- process.stderr.write(` — no profiles on console yet.\n`);
550
- } else {
551
- process.stderr.write(`\n`);
552
- if (added.length) process.stderr.write(` ${s.dim('+')} ${added.length} added: ${added.map(n => s.cyan(n)).join(', ')}\n`);
553
- if (updated.length) process.stderr.write(` ${s.dim('↑')} ${updated.length} updated: ${updated.map(n => s.cyan(n)).join(', ')}\n`);
554
- if (unchanged.length) process.stderr.write(` ${s.dim('○')} ${unchanged.length} unchanged\n`);
555
- }
556
-
557
- if (needsCredentials.length > 0) {
558
- process.stderr.write(`\n ${s.yellow('!')} These profiles need credentials before they can be used:\n`);
559
- for (const name of needsCredentials) {
560
- process.stderr.write(` ${s.dim('○')} ${s.cyan(name)} — run: ${s.bold(`ticketlens config --profile=${name}`)}\n`);
561
- }
562
- }
563
-
564
478
  // Flow 3: also pull team Jira config update (Pro/Team); silently skipped for Free
565
479
  const tcSync = await checkTeamJiraConfigUpdate().catch(() => null);
566
480
  if (tcSync?.banner) {
@@ -694,7 +608,23 @@ switch (command) {
694
608
  }
695
609
 
696
610
  case 'help':
697
- default:
698
- printHelp();
611
+ default: {
612
+ const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
613
+
614
+ (async () => {
615
+ if (isInteractive) {
616
+ const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
617
+ if (detectSetupState().status !== 'ready') {
618
+ const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
619
+ await runOnboarding({ stream: process.stderr });
620
+ return;
621
+ }
622
+ }
623
+ printHelp();
624
+ })().catch(err => {
625
+ process.stderr.write(`Error: ${err.message}\n`);
626
+ process.exitCode = 1;
627
+ });
699
628
  break;
629
+ }
700
630
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.10.3",
3
+ "version": "0.10.18",
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": {
7
- "ticketlens": "bin/ticketlens.mjs"
7
+ "ticketlens": "bin/ticketlens.mjs",
8
+ "tl": "bin/ticketlens.mjs"
8
9
  },
9
10
  "files": [
10
11
  "bin/",
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  const SKILL_SRC = join(__dirname, '..', 'skills', 'jtb', 'SKILL.md');
15
+ const OWN_BIN = join(__dirname, '..', 'bin', 'ticketlens.mjs');
15
16
 
16
17
  if (!existsSync(SKILL_SRC)) process.exit(0);
17
18
 
@@ -60,3 +61,36 @@ if (updated === 0 && skipped === TARGETS.length) {
60
61
  console.log(' ℹ /jtb skill not installed in any known location.');
61
62
  console.log(' To install: ticketlens update-skill');
62
63
  }
64
+
65
+ // First-run banner + tl-alias status. Best-effort: npm >=7 hides this output
66
+ // unless the user passes --foreground-scripts, so the guaranteed channel is
67
+ // the first bare `tl`/`ticketlens` run added in a later phase. Never throws —
68
+ // a broken banner must not break the install.
69
+ try {
70
+ const { renderWordmark } = await import('../skills/jtb/scripts/lib/wordmark.mjs');
71
+ const { checkAliasStatus } = await import('../skills/jtb/scripts/lib/alias-status.mjs');
72
+ const { loadProfiles } = await import('../skills/jtb/scripts/lib/profile-resolver.mjs');
73
+
74
+ console.log('\n' + renderWordmark({ stream: process.stdout }));
75
+
76
+ const profiles = loadProfiles();
77
+ const isConfigured = !!(profiles && Object.keys(profiles.profiles || {}).length > 0);
78
+
79
+ const alias = checkAliasStatus({ selfBinPath: OWN_BIN });
80
+ if (alias.status === 'active') {
81
+ console.log(' ✔ Also available as: tl');
82
+ } else if (alias.status === 'shadowed') {
83
+ console.log(` ⚠ 'tl' on this machine points to ${alias.foreignPath} — use 'ticketlens' instead.`);
84
+ }
85
+ // alias.status === 'missing' → npm bin dir not on PATH is a broader problem
86
+ // this script can't fix; saying nothing avoids confusing the user further.
87
+
88
+ if (isConfigured) {
89
+ console.log(' → Try tl triage');
90
+ } else {
91
+ console.log(' → New here? Run tl to launch the guided setup.');
92
+ }
93
+ console.log('');
94
+ } catch {
95
+ // Silent on failure — never breaks the install.
96
+ }
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.10.3 -->
1
+ <!-- jtb-skill-version: 0.10.18 -->
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.
@@ -59,7 +59,7 @@ TicketLens supports two connection methods — check in this order:
59
59
 
60
60
  **1. Profile config (recommended):** If `~/.ticketlens/profiles.json` exists, no env vars
61
61
  are needed. Profile resolution is automatic (by ticket prefix, project path, or `--profile`).
62
- Setup via `ticketlens init`.
62
+ Setup via `ticketlens init` (`tl init` also works — `tl` is shorthand for `ticketlens`).
63
63
 
64
64
  **2. Env var fallback:** If no profile config exists, these must be set:
65
65
  - `JIRA_BASE_URL` — e.g. `https://yourteam.atlassian.net`
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Detects whether the `tl` shorthand resolves to this package's own
3
+ * bin/ticketlens.mjs, a foreign binary shadowing it on PATH, or nothing.
4
+ * Pure filesystem checks only — no subprocess spawning, no network.
5
+ */
6
+
7
+ import { existsSync, accessSync, realpathSync, constants as fsConstants } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+
10
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
11
+
12
+ /**
13
+ * Scan PATH for the first executable matching binName. Never throws —
14
+ * an unreadable or missing directory is skipped, not fatal.
15
+ */
16
+ export function findOnPath(binName, { env = process.env, platform = process.platform } = {}) {
17
+ const isWin = platform === 'win32';
18
+ // PATH delimiter depends on the target platform, not the host running this
19
+ // code — node:path's `delimiter` is host-bound and wrong when platform is
20
+ // passed explicitly (e.g. testing win32 logic from a POSIX host).
21
+ const dirs = (env.PATH || env.Path || '').split(isWin ? ';' : ':').filter(Boolean);
22
+ const candidates = isWin
23
+ ? [...(env.PATHEXT || DEFAULT_PATHEXT).split(';').map((ext) => binName + ext), binName]
24
+ : [binName];
25
+
26
+ for (const dir of dirs) {
27
+ for (const name of candidates) {
28
+ try {
29
+ const full = join(dir, name);
30
+ if (!existsSync(full)) continue;
31
+ // POSIX: skip non-executable matches (a shell would too). Windows PATHEXT
32
+ // already restricts candidates to executable extensions, so no check needed.
33
+ if (!isWin) accessSync(full, fsConstants.X_OK);
34
+ return full;
35
+ } catch {
36
+ continue;
37
+ }
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Compare where `tl` resolves against our own installed ticketlens binary.
45
+ * POSIX: realpath comparison (npm bins are symlinks) against selfBinPath.
46
+ * Windows: npm .cmd/.ps1 shims aren't real symlinks, and selfBinPath (the
47
+ * package's internal target script under node_modules) lives in a different
48
+ * directory than the shims themselves — comparing against it always reads
49
+ * as foreign. Instead, npm always installs every bin entry from one package
50
+ * into the same shim directory together, so confirm ownership by checking
51
+ * whether a `ticketlens` shim sits alongside the found `tl` shim.
52
+ */
53
+ export function checkAliasStatus({ selfBinPath, env = process.env, platform = process.platform } = {}) {
54
+ try {
55
+ const tlPath = findOnPath('tl', { env, platform });
56
+ if (!tlPath) return { status: 'missing' };
57
+
58
+ if (platform === 'win32') {
59
+ const ticketlensPath = findOnPath('ticketlens', { env, platform });
60
+ return ticketlensPath && dirname(tlPath) === dirname(ticketlensPath)
61
+ ? { status: 'active' }
62
+ : { status: 'shadowed', foreignPath: tlPath };
63
+ }
64
+
65
+ const resolvedTl = realpathSync(tlPath);
66
+ const resolvedSelf = realpathSync(selfBinPath);
67
+ return resolvedTl === resolvedSelf
68
+ ? { status: 'active' }
69
+ : { status: 'shadowed', foreignPath: tlPath };
70
+ } catch {
71
+ return { status: 'missing' };
72
+ }
73
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
- export const DEFAULT_SITE_BASE = 'https://ticketlens.dev';
7
+ export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
10
10
  // with an optional port — all treated as local-only addresses.
@@ -8,7 +8,7 @@ export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
8
8
  export function parseCommand(args) {
9
9
  const first = args[0];
10
10
 
11
- if (args.length === 0 || first === '--help' || first === '-h') {
11
+ if (args.length === 0 || first === '--help' || first === '-h' || first === '--no-input') {
12
12
  return { command: 'help', args: [] };
13
13
  }
14
14
 
@@ -11,21 +11,35 @@ import { homedir } from 'node:os';
11
11
  /** Canonical config directory: ~/.ticketlens */
12
12
  export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
13
13
 
14
- /** Read package.json version once per process, memoized.
14
+ /** Read package.json once per process, memoized.
15
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.
16
+ * package.json is found whether the module is loaded via a symlink (e.g.
17
+ * the npm-managed `tl` bin) or directly.
17
18
  */
18
- let _version;
19
- export function getVersion() {
20
- if (_version) return _version;
19
+ let _pkg;
20
+ function _readPkg() {
21
+ if (_pkg) return _pkg;
21
22
  try {
22
23
  const realDir = dirname(realpathSync(fileURLToPath(import.meta.url)));
23
- const pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
24
- _version = pkg.version || '0.0.0';
24
+ _pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
25
25
  } catch {
26
- _version = '0.0.0';
26
+ _pkg = {};
27
27
  }
28
- return _version;
28
+ return _pkg;
29
+ }
30
+
31
+ export function getVersion() {
32
+ return _readPkg().version || '0.0.0';
33
+ }
34
+
35
+ /** Metadata used by the first-run wordmark banner. */
36
+ export function getPackageMeta() {
37
+ const pkg = _readPkg();
38
+ return {
39
+ version: pkg.version || '0.0.0',
40
+ author: pkg.author || 'unknown',
41
+ repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''),
42
+ };
29
43
  }
30
44
 
31
45
  /** Human-readable relative time from an ISO date string. */
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tests every configured profile's connection, one at a time with a spinner
3
+ * per profile — the same pattern init-wizard.mjs/config-wizard.mjs already
4
+ * use for a single profile, just looped across all of them.
5
+ */
6
+
7
+ import { createSession } from './banner.mjs';
8
+ import { classifyError } from './error-classifier.mjs';
9
+ import { resolveAdapter } from './resolve-adapter.mjs';
10
+ import { loadProfiles, loadCredentials } from './profile-resolver.mjs';
11
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
12
+
13
+ export async function testConnections({
14
+ configDir = DEFAULT_CONFIG_DIR,
15
+ stream = process.stderr,
16
+ resolveAdapterFn = resolveAdapter,
17
+ } = {}) {
18
+ const config = loadProfiles(configDir);
19
+ const names = config ? Object.keys(config.profiles) : [];
20
+ const creds = loadCredentials(configDir);
21
+ const results = [];
22
+
23
+ for (const name of names) {
24
+ const profile = config.profiles[name];
25
+ const profileCreds = creds[name] || {};
26
+ const conn = {
27
+ baseUrl: profile.baseUrl,
28
+ auth: profile.auth,
29
+ email: profile.email,
30
+ apiToken: profileCreds.apiToken,
31
+ pat: profileCreds.pat,
32
+ };
33
+
34
+ const session = createSession(
35
+ { baseUrl: conn.baseUrl, profileName: name, email: conn.email || undefined, pat: conn.auth === 'pat' ? conn.pat : undefined },
36
+ { stream },
37
+ );
38
+ session.spin('Testing connection...');
39
+
40
+ try {
41
+ await resolveAdapterFn(conn).fetchCurrentUser();
42
+ session.connected();
43
+ results.push({ name, ok: true });
44
+ } catch (err) {
45
+ session.failed();
46
+ const classified = classifyError(err, { baseUrl: conn.baseUrl, profileName: name });
47
+ session.footer(classified.message, 'error', classified.hint);
48
+ results.push({ name, ok: false, error: classified.message });
49
+ }
50
+ }
51
+
52
+ return { results, failedCount: results.filter(r => !r.ok).length };
53
+ }
@@ -3,12 +3,11 @@
3
3
  */
4
4
 
5
5
  import { createStyler } from './ansi.mjs';
6
- import { getVersion } from './config.mjs';
6
+ import { renderWordmark } from './wordmark.mjs';
7
7
 
8
8
  export function printHelp({ stream = process.stdout } = {}) {
9
9
  const isTTY = stream.isTTY;
10
10
  const s = createStyler({ isTTY });
11
- const v = getVersion();
12
11
 
13
12
  // Column targets (visible chars):
14
13
  // USAGE: command portion = 36, so descriptions always start at the same column
@@ -18,12 +17,11 @@ export function printHelp({ stream = process.stdout } = {}) {
18
17
  // ANSI codes (s.cyan, s.dim) add invisible bytes — they do NOT affect visible width.
19
18
 
20
19
  const lines = [
21
- '',
22
- ` ${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${v}`)}`,
23
- ` ${s.dim('Stop tab-switching. Start building.')}`,
24
20
  '',
25
21
  ` ${s.bold('USAGE')}`,
26
22
  '',
23
+ ` ${s.brand('tl')} ${s.dim('<anything>')} Shorthand for ${s.brand('ticketlens')}`,
24
+ '',
27
25
  // Alignment targets (visible chars before description):
28
26
  // Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
29
27
  // Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
@@ -56,6 +54,10 @@ export function printHelp({ stream = process.stdout } = {}) {
56
54
  ` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
57
55
  ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
58
56
  '',
57
+ ` ${s.bold('GLOBAL OPTIONS')}`,
58
+ '',
59
+ ` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
60
+ '',
59
61
  ` ${s.bold('FETCH OPTIONS')}`,
60
62
  '',
61
63
  // visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
@@ -125,7 +127,7 @@ export function printHelp({ stream = process.stdout } = {}) {
125
127
  '',
126
128
  ];
127
129
 
128
- stream.write(lines.join('\n') + '\n');
130
+ stream.write(renderWordmark({ stream }) + lines.join('\n') + '\n');
129
131
  }
130
132
 
131
133
  export function printFetchHelp({ stream = process.stdout } = {}) {
@@ -14,8 +14,10 @@ import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
14
14
  import { resolveAdapter } from './resolve-adapter.mjs';
15
15
  import { promptSelect } from './select-prompt.mjs';
16
16
  import { runSwitch } from './profile-switcher.mjs';
17
+ import { renderWordmark } from './wordmark.mjs';
18
+ import { printQuickStart } from './quick-start-panel.mjs';
17
19
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
18
- import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
20
+ import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
19
21
 
20
22
  const RETRY_OPTIONS = [
21
23
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -51,7 +53,7 @@ async function probeProtocol(host, { stream, s }) {
51
53
 
52
54
  // ── Main wizard ───────────────────────────────────────────────────────────────
53
55
 
54
- export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
56
+ export async function run({ configDir = DEFAULT_CONFIG_DIR, showBanner = true, showQuickStart = true } = {}) {
55
57
  const stream = process.stderr;
56
58
  const s = createStyler({ isTTY: stream.isTTY });
57
59
 
@@ -65,27 +67,22 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
65
67
  process.on('SIGINT', onSigint);
66
68
 
67
69
  try {
68
- await _run({ configDir, stream, s });
70
+ await _run({ configDir, stream, s, showBanner, showQuickStart });
69
71
  } finally {
70
72
  process.removeListener('SIGINT', onSigint);
71
73
  }
72
74
  }
73
75
 
74
- async function _run({ configDir, stream, s }) {
75
- // Welcome box
76
- const headerLines = [
77
- `${s.bold(s.cyan('◆ TicketLens'))} — Setup Wizard`,
78
- `${s.dim("Let's configure your tracker connection.")}`,
79
- ];
80
- const innerWidth = headerLines.reduce((max, l) => Math.max(max, visLen(l)), 0) + 4;
81
- const bc = s.cyan;
82
- stream.write('\n');
83
- stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
84
- for (const line of headerLines) {
85
- const pad = innerWidth - visLen(line) - 1;
86
- stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
76
+ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
77
+ // Welcome banner — same wordmark as --help/--version, so a brand-new user's
78
+ // very first output looks like the same product (Phase 1's renderWordmark()
79
+ // was built reusable for exactly this; not duplicating the art). Suppressed
80
+ // when invoked from the onboarding hub, which already showed its own banner
81
+ // — otherwise the user sees it twice in the fresh-install flow.
82
+ if (showBanner) {
83
+ stream.write(renderWordmark({ stream }));
84
+ stream.write(`\n${s.dim("Let's configure your tracker connection.")}\n`);
87
85
  }
88
- stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
89
86
 
90
87
  let addedCount = 0;
91
88
  let addAnother = true;
@@ -575,25 +572,9 @@ async function _run({ configDir, stream, s }) {
575
572
  const profileWord = addedCount === 1 ? '1 profile' : `${addedCount} profiles`;
576
573
  stream.write(`\n ${s.green('✔')} ${profileWord} configured.\n\n`);
577
574
 
578
- // Quick-start panel
579
- const cmds = [
580
- ['ticketlens triage', 'Scan your assigned tickets'],
581
- ['ticketlens <TICKET-KEY>', 'Fetch a specific ticket'],
582
- ['ticketlens switch', 'Switch active profile'],
583
- ['ticketlens --help', 'Full command reference'],
584
- ];
585
- const cmdWidth = cmds.reduce((max, [c]) => Math.max(max, c.length), 0);
586
- const cmdRows = cmds.map(([cmd, desc]) =>
587
- ` ${s.bold(s.cyan(cmd.padEnd(cmdWidth)))} ${s.dim(desc)}`
588
- );
589
- const QTITLE = ' Quick start ';
590
- const contentWidth = cmdRows.reduce((max, r) => Math.max(max, visLen(r)), 0);
591
- const qWidth = Math.max(contentWidth + 2, QTITLE.length + 4);
592
- const qPad = (r) => ' ' + r + ' '.repeat(Math.max(0, qWidth - visLen(r) - 1));
593
- const qTitleFill = qWidth - 1 - QTITLE.length;
594
- stream.write(bc('╭') + bc('─') + s.bold(s.cyan(QTITLE)) + bc('─'.repeat(Math.max(0, qTitleFill))) + bc('╮') + '\n');
595
- stream.write(bc('│') + qPad('') + bc('│') + '\n');
596
- for (const r of cmdRows) stream.write(bc('│') + qPad(r) + bc('│') + '\n');
597
- stream.write(bc('│') + qPad('') + bc('│') + '\n');
598
- stream.write(bc('╰') + bc('─'.repeat(qWidth)) + bc('╯') + '\n\n');
575
+ // Suppressed when invoked from the onboarding hub, which owns showing this
576
+ // exactly once after its own menu loop exits — otherwise the user sees it twice.
577
+ if (showQuickStart) {
578
+ printQuickStart({ stream, s });
579
+ }
599
580
  }