santree 0.5.3 → 0.5.4

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.
Files changed (66) hide show
  1. package/README.md +145 -52
  2. package/dist/commands/dashboard.d.ts +1 -1
  3. package/dist/commands/dashboard.js +22 -18
  4. package/dist/commands/doctor.js +33 -71
  5. package/dist/commands/github/auth.d.ts +2 -0
  6. package/dist/commands/github/auth.js +56 -0
  7. package/dist/commands/github/index.d.ts +1 -0
  8. package/dist/commands/github/index.js +1 -0
  9. package/dist/commands/helpers/template.d.ts +1 -0
  10. package/dist/commands/helpers/template.js +13 -10
  11. package/dist/commands/issue/index.d.ts +1 -0
  12. package/dist/commands/issue/index.js +1 -0
  13. package/dist/commands/issue/open.d.ts +2 -0
  14. package/dist/commands/{linear → issue}/open.js +13 -11
  15. package/dist/commands/issue/switch.d.ts +11 -0
  16. package/dist/commands/issue/switch.js +38 -0
  17. package/dist/commands/linear/auth.js +23 -10
  18. package/dist/commands/linear/switch.js +7 -3
  19. package/dist/commands/pr/create.js +7 -5
  20. package/dist/commands/worktree/create.js +4 -6
  21. package/dist/commands/worktree/work.js +1 -1
  22. package/dist/lib/ai.d.ts +8 -6
  23. package/dist/lib/ai.js +29 -15
  24. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  25. package/dist/lib/dashboard/DetailPanel.js +6 -3
  26. package/dist/lib/dashboard/data.js +17 -9
  27. package/dist/lib/dashboard/types.d.ts +3 -16
  28. package/dist/lib/git.d.ts +16 -33
  29. package/dist/lib/git.js +20 -74
  30. package/dist/lib/metadata.d.ts +3 -0
  31. package/dist/lib/metadata.js +27 -0
  32. package/dist/lib/multiplexer/cmux.js +1 -1
  33. package/dist/lib/multiplexer/types.d.ts +1 -1
  34. package/dist/lib/prompts.d.ts +4 -3
  35. package/dist/lib/prompts.js +4 -3
  36. package/dist/lib/session-signal.d.ts +2 -3
  37. package/dist/lib/session-signal.js +3 -29
  38. package/dist/lib/trackers/auth-store.d.ts +16 -0
  39. package/dist/lib/trackers/auth-store.js +57 -0
  40. package/dist/lib/trackers/config.d.ts +8 -0
  41. package/dist/lib/trackers/config.js +21 -0
  42. package/dist/lib/trackers/github/api.d.ts +3 -0
  43. package/dist/lib/trackers/github/api.js +90 -0
  44. package/dist/lib/trackers/github/auth.d.ts +5 -0
  45. package/dist/lib/trackers/github/auth.js +27 -0
  46. package/dist/lib/trackers/github/images.d.ts +2 -0
  47. package/dist/lib/trackers/github/images.js +42 -0
  48. package/dist/lib/trackers/github/index.d.ts +2 -0
  49. package/dist/lib/trackers/github/index.js +78 -0
  50. package/dist/lib/trackers/index.d.ts +12 -0
  51. package/dist/lib/trackers/index.js +34 -0
  52. package/dist/lib/trackers/linear/api.d.ts +4 -0
  53. package/dist/lib/trackers/linear/api.js +128 -0
  54. package/dist/lib/trackers/linear/auth.d.ts +11 -0
  55. package/dist/lib/trackers/linear/auth.js +206 -0
  56. package/dist/lib/trackers/linear/images.d.ts +2 -0
  57. package/dist/lib/trackers/linear/images.js +44 -0
  58. package/dist/lib/trackers/linear/index.d.ts +3 -0
  59. package/dist/lib/trackers/linear/index.js +100 -0
  60. package/dist/lib/trackers/types.d.ts +52 -0
  61. package/dist/lib/trackers/types.js +1 -0
  62. package/package.json +1 -1
  63. package/prompts/ticket.njk +3 -3
  64. package/dist/commands/linear/open.d.ts +0 -2
  65. package/dist/lib/linear.d.ts +0 -83
  66. package/dist/lib/linear.js +0 -482
@@ -10,7 +10,7 @@ import * as path from "path";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
12
  import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.js";
13
- import { getAuthStatus, getValidTokens } from "../lib/linear.js";
13
+ import { getIssueTracker } from "../lib/trackers/index.js";
14
14
  import { getMultiplexer } from "../lib/multiplexer/index.js";
15
15
  import { resolveClaudeBinary } from "../lib/ai.js";
16
16
  import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, SANTREE_PACKAGE, getLatestVersionFor, isUpdateAvailable, detectPackageManager, getInstallCommandFor, } from "../lib/version.js";
@@ -135,8 +135,9 @@ async function checkMultiplexer() {
135
135
  */
136
136
  async function checkClaude() {
137
137
  const resolved = resolveClaudeBinary();
138
+ const usingBundled = resolved?.startsWith("/Applications/cmux.app/") ?? false;
138
139
  const inCmux = getMultiplexer().kind === "cmux";
139
- const description = inCmux ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
140
+ const description = usingBundled ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
140
141
  if (!resolved) {
141
142
  return {
142
143
  name: "claude",
@@ -197,30 +198,22 @@ async function checkGhAuth() {
197
198
  };
198
199
  }
199
200
  /**
200
- * Checks Linear API authentication status.
201
+ * Checks the active issue tracker's auth state. The tracker (Linear, GitHub)
202
+ * is resolved from the repo's `_tracker` config (or env / auto-detect).
203
+ * Doctor is the one place that legitimately names the active tracker — it's
204
+ * diagnostic context, not a generic UI string.
201
205
  */
202
- async function checkLinearAuth() {
206
+ async function checkTrackerAuth() {
203
207
  const repoRoot = findMainRepoRoot();
204
- const status = getAuthStatus(repoRoot);
205
- if (!status.authenticated || !status.orgSlug) {
206
- return {
207
- authenticated: false,
208
- hint: "Run: santree linear auth",
209
- };
210
- }
211
- // Try to validate/refresh tokens
212
- const valid = await getValidTokens(status.orgSlug);
208
+ const tracker = getIssueTracker(repoRoot);
209
+ const status = await tracker.getAuthStatus(repoRoot);
213
210
  return {
214
- authenticated: true,
215
- orgSlug: status.orgSlug,
216
- orgName: status.orgName,
217
- tokenValid: valid !== null,
211
+ displayName: tracker.displayName,
212
+ authenticated: status.authenticated,
213
+ accountLabel: status.accountLabel,
214
+ expiresAt: status.expiresAt,
218
215
  repoLinked: status.repoLinked,
219
- hint: !valid
220
- ? "Token expired. Run: santree linear auth"
221
- : !status.repoLinked
222
- ? "Repo not linked. Run: santree linear auth"
223
- : undefined,
216
+ hint: status.hint,
224
217
  };
225
218
  }
226
219
  /**
@@ -334,39 +327,12 @@ function checkSessionSignalHooks() {
334
327
  catch {
335
328
  missingHooks.push(...requiredEvents);
336
329
  }
337
- // Check hook script files in .santree/hooks/
338
- const hookScripts = [];
339
- const mainRepoRoot = findMainRepoRoot();
340
- let hooksDir = null;
341
- if (mainRepoRoot) {
342
- hooksDir = path.join(mainRepoRoot, ".santree", "hooks");
343
- const scriptNames = ["on-waiting.sh", "on-active.sh", "on-idle.sh", "on-exited.sh"];
344
- for (const name of scriptNames) {
345
- const scriptPath = path.join(hooksDir, name);
346
- const exists = fs.existsSync(scriptPath);
347
- let executable = false;
348
- if (exists) {
349
- try {
350
- fs.accessSync(scriptPath, fs.constants.X_OK);
351
- executable = true;
352
- }
353
- catch {
354
- // not executable
355
- }
356
- }
357
- if (exists) {
358
- hookScripts.push({ name, exists, executable });
359
- }
360
- }
361
- }
362
330
  if (missingHooks.length === 0) {
363
- return { configured: true, missingHooks: [], hookScripts, hooksDir };
331
+ return { configured: true, missingHooks: [] };
364
332
  }
365
333
  return {
366
334
  configured: false,
367
335
  missingHooks,
368
- hookScripts,
369
- hooksDir,
370
336
  hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
371
337
  };
372
338
  }
@@ -451,9 +417,9 @@ function StatusIcon({ ok, required }) {
451
417
  function ToolRow({ tool }) {
452
418
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.latestVersion && tool.version && (_jsxs(Text, { color: isUpdateAvailable(tool.version, tool.latestVersion) ? "yellow" : undefined, dimColor: !isUpdateAvailable(tool.version, tool.latestVersion), children: ["Latest: ", tool.latestVersion, isUpdateAvailable(tool.version, tool.latestVersion) ? " ⬆ update available" : ""] })), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.updateHint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.updateHint] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
453
419
  }
454
- function LinearRow({ linear }) {
455
- const isOk = linear.authenticated && linear.tokenValid && linear.repoLinked;
456
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: !!isOk, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Linear API" }), _jsx(Text, { dimColor: true, children: " - Linear ticket integration" })] }), linear.authenticated ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Organization: ", linear.orgName, " (", linear.orgSlug, ")"] }), _jsxs(Text, { dimColor: true, children: ["Token: ", linear.tokenValid ? "valid" : "expired"] }), _jsxs(Text, { dimColor: true, children: ["Repo linked: ", linear.repoLinked ? "yes" : "no"] }), linear.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", linear.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", linear.hint] }) }))] }));
420
+ function TrackerRow({ tracker }) {
421
+ const isOk = tracker.authenticated && !tracker.hint;
422
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: isOk, required: true }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: [tracker.displayName, " API"] }), _jsx(Text, { dimColor: true, children: " - Issue tracker integration" })] }), tracker.authenticated ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [tracker.accountLabel && _jsxs(Text, { dimColor: true, children: ["Account: ", tracker.accountLabel] }), tracker.repoLinked !== undefined && (_jsxs(Text, { dimColor: true, children: ["Repo linked: ", tracker.repoLinked ? "yes" : "no"] })), tracker.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tracker.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tracker.hint] }) }))] }));
457
423
  }
458
424
  function ShellRow({ configured, shell }) {
459
425
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: configured, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Shell Integration" }), _jsx(Text, { dimColor: true, children: " - Enables directory switching" })] }), configured ? (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["Shell: ", shell] }) })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 Add to .", shell, "rc: eval \"$(santree helpers shell-init ", shell, ")\""] }) }))] }));
@@ -465,13 +431,7 @@ function RemoteControlRow({ status }) {
465
431
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.enabled, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Remote Control" }), _jsx(Text, { dimColor: true, children: " - Continue sessions from any device" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Enabled: ", status.enabled ? "yes" : "no"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
466
432
  }
467
433
  function SessionSignalRow({ status }) {
468
- const allScriptNames = ["on-waiting.sh", "on-active.sh", "on-idle.sh", "on-exited.sh"];
469
- const existingNames = new Set(status.hookScripts.map((s) => s.name));
470
- const missingScripts = allScriptNames.filter((n) => !existingNames.has(n));
471
- const nonExecutable = status.hookScripts.filter((s) => !s.executable);
472
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured && nonExecutable.length === 0, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Session Signal Hooks" }), _jsx(Text, { dimColor: true, children: " - Surface session state in dashboard/tmux" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [status.configured ? (_jsx(Text, { dimColor: true, children: "All hooks configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", status.missingHooks.join(", ")] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })), status.hooksDir && (_jsxs(Text, { dimColor: true, children: ["Hook scripts:", " ", status.hookScripts.length > 0
473
- ? status.hookScripts.map((s) => s.name).join(", ")
474
- : "none"] })), status.hooksDir && missingScripts.length > 0 && (_jsxs(Text, { color: "yellow", children: ["\u21B3 Create in ", status.hooksDir, ": ", missingScripts.join(", ")] })), nonExecutable.map((s) => (_jsxs(Text, { color: "yellow", children: ["\u21B3 ", s.name, " is not executable. Run: chmod +x .santree/hooks/", s.name] }, s.name)))] })] }));
434
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Session Signal Hooks" }), _jsx(Text, { dimColor: true, children: " - Surface session state in dashboard/tmux" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsx(Text, { dimColor: true, children: "All hooks configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", status.missingHooks.join(", ")] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })) })] }));
475
435
  }
476
436
  function SantreeSetupRow({ status }) {
477
437
  const isOk = status.santreeFolderExists &&
@@ -490,7 +450,7 @@ function SantreeSetupRow({ status }) {
490
450
  }
491
451
  export default function Doctor() {
492
452
  const [tools, setTools] = useState([]);
493
- const [linear, setLinear] = useState(null);
453
+ const [tracker, setTracker] = useState(null);
494
454
  const [shellStatus, setShellStatus] = useState(null);
495
455
  const [remoteControl, setRemoteControl] = useState(null);
496
456
  const [statusline, setStatusline] = useState(null);
@@ -541,12 +501,14 @@ export default function Doctor() {
541
501
  }
542
502
  }
543
503
  }
544
- // Optional: a syntax-highlighted diff pager — used by `st worktree diff`
504
+ // Optional: a syntax-highlighting diff pager — used by `st worktree diff`
545
505
  // and the dashboard `v` overlay when SANTREE_DIFF_TOOL is set. Any
546
- // pager works (delta, diff-so-fancy, …); without one set, git's
547
- // default pager runs. Delta is the most popular choice so we check
548
- // for it as a convenience, but it is never a hard dependency.
549
- const deltaCheck = await checkTool("delta", "Recommended diff pager any pager works", false, "delta --version | head -1", "Optional — git's default pager works too. Set SANTREE_DIFF_TOOL or `git config core.pager <tool>`. To install delta: brew install git-delta");
506
+ // diff pager works (delta, diff-so-fancy, …); without one set, the
507
+ // dashboard renders inline with santree's own colorizer and the CLI
508
+ // falls back to git's default pager. Delta is the most popular
509
+ // choice so we check for it as a convenience, but it is never a
510
+ // hard dependency.
511
+ const deltaCheck = await checkTool("delta", "Recommended diff pager — any diff pager works", false, "delta --version | head -1", "Optional — santree's built-in colorizer handles the dashboard overlay; the CLI falls back to git's default pager. Set SANTREE_DIFF_TOOL to override. To install delta: brew install git-delta");
550
512
  results.push(deltaCheck);
551
513
  // Optional: a `.code-workspace`-aware editor (VSCode or Cursor).
552
514
  // Santree itself works with any editor via $SANTREE_EDITOR — this
@@ -573,10 +535,10 @@ export default function Doctor() {
573
535
  hint: "Optional — santree works with any $SANTREE_EDITOR. Only needed for the dashboard's `.code-workspace` shortcut.",
574
536
  });
575
537
  }
576
- const linearResult = await checkLinearAuth();
538
+ const trackerResult = await checkTrackerAuth();
577
539
  const statuslineResult = await checkStatusline();
578
540
  setTools(results);
579
- setLinear(linearResult);
541
+ setTracker(trackerResult);
580
542
  setShellStatus(checkShellIntegration());
581
543
  setRemoteControl(checkRemoteControl());
582
544
  setStatusline(statuslineResult);
@@ -591,7 +553,7 @@ export default function Doctor() {
591
553
  }
592
554
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
593
555
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
594
- const linearOk = linear?.authenticated && linear?.tokenValid && linear?.repoLinked;
595
- const allRequired = requiredMissing.length === 0 && linearOk && shellStatus?.configured;
596
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), linear && _jsx(LinearRow, { linear: linear }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), sessionSignal && _jsx(SessionSignalRow, { status: sessionSignal }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (linearOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
556
+ const trackerOk = tracker?.authenticated && !tracker?.hint;
557
+ const allRequired = requiredMissing.length === 0 && trackerOk && shellStatus?.configured;
558
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), tracker && _jsx(TrackerRow, { tracker: tracker }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), sessionSignal && _jsx(SessionSignalRow, { status: sessionSignal }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (trackerOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
597
559
  }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Authenticate with GitHub via the gh CLI";
2
+ export default function GithubAuth(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { exec } from "child_process";
6
+ import { promisify } from "util";
7
+ import { findMainRepoRoot } from "../../lib/git.js";
8
+ import { getAuthenticatedUser } from "../../lib/trackers/github/auth.js";
9
+ import { setRepoTracker } from "../../lib/trackers/index.js";
10
+ const execAsync = promisify(exec);
11
+ export const description = "Authenticate with GitHub via the gh CLI";
12
+ export default function GithubAuth() {
13
+ const [status, setStatus] = useState("checking");
14
+ const [message, setMessage] = useState("");
15
+ const [error, setError] = useState(null);
16
+ useEffect(() => {
17
+ async function run() {
18
+ await new Promise((r) => setTimeout(r, 100));
19
+ const repoRoot = findMainRepoRoot();
20
+ if (!repoRoot) {
21
+ setError("Not inside a git repository");
22
+ setStatus("error");
23
+ return;
24
+ }
25
+ let user = await getAuthenticatedUser();
26
+ if (!user) {
27
+ setStatus("logging-in");
28
+ try {
29
+ await execAsync("gh auth login -p https -h github.com -w", { stdio: "inherit" });
30
+ }
31
+ catch (e) {
32
+ setError(e instanceof Error ? e.message : "gh auth login failed");
33
+ setStatus("error");
34
+ return;
35
+ }
36
+ user = await getAuthenticatedUser();
37
+ if (!user) {
38
+ setError("gh auth login completed but no authenticated user — try `gh auth status`");
39
+ setStatus("error");
40
+ return;
41
+ }
42
+ }
43
+ setRepoTracker(repoRoot, "github");
44
+ setMessage(`Authenticated as @${user.login}; this repo now uses GitHub Issues`);
45
+ setStatus("done");
46
+ }
47
+ run();
48
+ }, []);
49
+ useEffect(() => {
50
+ if (status === "done" || status === "error") {
51
+ const timer = setTimeout(() => process.exit(status === "error" ? 1 : 0), 100);
52
+ return () => clearTimeout(timer);
53
+ }
54
+ }, [status]);
55
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "GitHub Auth" }) }), status === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking gh auth status..." })] })), status === "logging-in" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Running `gh auth login` \u2014 follow the browser prompt..." })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] }));
56
+ }
@@ -0,0 +1 @@
1
+ export declare const description = "GitHub Issues tracker commands";
@@ -0,0 +1 @@
1
+ export const description = "GitHub Issues tracker commands";
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
2
2
  export declare const description = "Render a template to stdout";
3
3
  export declare const args: z.ZodTuple<[z.ZodEnum<{
4
4
  linear: "linear";
5
+ ticket: "ticket";
5
6
  review: "review";
6
7
  pr: "pr";
7
8
  "git-changes": "git-changes";
@@ -6,13 +6,13 @@ import { argument } from "pastel";
6
6
  import { z } from "zod/v4";
7
7
  import { findRepoRoot, findMainRepoRoot, getCurrentBranch, extractTicketId, } from "../../lib/git.js";
8
8
  import { renderTicket } from "../../lib/prompts.js";
9
- import { getTicketContent } from "../../lib/linear.js";
9
+ import { getIssueTracker } from "../../lib/trackers/index.js";
10
10
  import { resolveAIContext, renderAIPrompt, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
11
11
  export const description = "Render a template to stdout";
12
12
  export const args = z.tuple([
13
- z.enum(["linear", "git-changes", "pr", "fix-pr", "review"]).describe(argument({
13
+ z.enum(["ticket", "linear", "git-changes", "pr", "fix-pr", "review"]).describe(argument({
14
14
  name: "type",
15
- description: "Template type (linear, git-changes, pr, fix-pr, or review)",
15
+ description: "Template type (ticket, git-changes, pr, fix-pr, or review)",
16
16
  })),
17
17
  ]);
18
18
  export default function Template({ args }) {
@@ -93,22 +93,24 @@ export default function Template({ args }) {
93
93
  setTimeout(() => exit(), 100);
94
94
  }
95
95
  else {
96
+ const mainRoot = findMainRepoRoot() ?? repoRoot;
97
+ const tracker = getIssueTracker(mainRoot);
96
98
  const ticketId = extractTicketId(branch);
97
99
  if (!ticketId) {
98
100
  setStatus("error");
99
- setMessage("Could not extract ticket ID from branch name. Expected format: user/TEAM-123-description");
101
+ setMessage(`Could not extract ${tracker.issueNoun} ID from branch name '${branch}'.`);
100
102
  setTimeout(() => exit(), 100);
101
103
  return;
102
104
  }
103
- const mainRoot = findMainRepoRoot() ?? repoRoot;
104
- const ticket = await getTicketContent(ticketId, mainRoot);
105
- if (!ticket) {
105
+ const result = await tracker.getIssue(ticketId, mainRoot);
106
+ if (!result.ok) {
107
+ const status = await tracker.getAuthStatus(mainRoot);
106
108
  setStatus("error");
107
- setMessage(`Could not fetch Linear ticket ${ticketId}. Run 'santree linear auth' to authenticate.`);
109
+ setMessage(`Could not fetch ${tracker.issueNoun} ${ticketId}.${status.hint ? ` ${status.hint}` : ""}`);
108
110
  setTimeout(() => exit(), 100);
109
111
  return;
110
112
  }
111
- process.stdout.write(renderTicket(ticket));
113
+ process.stdout.write(renderTicket(result.value, tracker.displayName));
112
114
  setStatus("done");
113
115
  setTimeout(() => exit(), 100);
114
116
  }
@@ -118,7 +120,8 @@ export default function Template({ args }) {
118
120
  if (status === "done")
119
121
  return null;
120
122
  const spinnerTexts = {
121
- linear: "Fetching Linear ticket...",
123
+ ticket: "Fetching issue...",
124
+ linear: "Fetching issue...",
122
125
  "git-changes": "Gathering changes...",
123
126
  pr: "Fetching PR feedback...",
124
127
  "fix-pr": "Building fix-pr prompt...",
@@ -0,0 +1 @@
1
+ export declare const description = "Issue tracker commands (Linear / GitHub)";
@@ -0,0 +1 @@
1
+ export const description = "Issue tracker commands (Linear / GitHub)";
@@ -0,0 +1,2 @@
1
+ export declare const description = "Open the current branch's issue in the browser";
2
+ export default function IssueOpen(): import("react/jsx-runtime").JSX.Element;
@@ -4,11 +4,11 @@ import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { exec } from "child_process";
6
6
  import { promisify } from "util";
7
- import { findMainRepoRoot, getCurrentBranch, extractTicketId } from "../../lib/git.js";
8
- import { getTicketContent } from "../../lib/linear.js";
7
+ import { findMainRepoRoot, getCurrentBranch } from "../../lib/git.js";
8
+ import { getIssueTracker } from "../../lib/trackers/index.js";
9
9
  const execAsync = promisify(exec);
10
- export const description = "Open the current Linear ticket in the browser";
11
- export default function LinearOpen() {
10
+ export const description = "Open the current branch's issue in the browser";
11
+ export default function IssueOpen() {
12
12
  const [status, setStatus] = useState("checking");
13
13
  const [message, setMessage] = useState("");
14
14
  useEffect(() => {
@@ -26,16 +26,18 @@ export default function LinearOpen() {
26
26
  setMessage("Could not determine current branch");
27
27
  return;
28
28
  }
29
- const ticketId = extractTicketId(branch);
29
+ const tracker = getIssueTracker(repoRoot);
30
+ const ticketId = tracker.extractIdFromBranch(branch);
30
31
  if (!ticketId) {
31
32
  setStatus("error");
32
- setMessage("No ticket ID found in branch name (expected pattern like TEAM-123)");
33
+ setMessage(`No ${tracker.issueNoun} ID found in branch '${branch}'`);
33
34
  return;
34
35
  }
35
- const issue = await getTicketContent(ticketId, repoRoot);
36
- if (!issue?.url) {
36
+ const result = await tracker.getIssue(ticketId, repoRoot);
37
+ if (!result.ok || !result.value.url) {
38
+ const auth = await tracker.getAuthStatus(repoRoot);
37
39
  setStatus("error");
38
- setMessage(`Could not fetch ticket ${ticketId}. Check auth with: santree linear auth --status`);
40
+ setMessage(`Could not fetch ${tracker.issueNoun} ${ticketId}.${auth.hint ? ` ${auth.hint}` : ""}`);
39
41
  return;
40
42
  }
41
43
  try {
@@ -44,7 +46,7 @@ export default function LinearOpen() {
44
46
  : process.platform === "win32"
45
47
  ? "start"
46
48
  : "xdg-open";
47
- await execAsync(`${openCmd} "${issue.url}"`);
49
+ await execAsync(`${openCmd} "${result.value.url}"`);
48
50
  setStatus("done");
49
51
  setMessage(`Opened ${ticketId} in browser`);
50
52
  }
@@ -61,5 +63,5 @@ export default function LinearOpen() {
61
63
  return () => clearTimeout(timer);
62
64
  }
63
65
  }, [status]);
64
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [status === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Opening Linear ticket..." })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] }));
66
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [status === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Opening issue..." })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] }));
65
67
  }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod/v4";
2
+ export declare const description = "Switch the active issue tracker for this repo";
3
+ export declare const args: z.ZodTuple<[z.ZodEnum<{
4
+ linear: "linear";
5
+ github: "github";
6
+ }>], null>;
7
+ type Props = {
8
+ args: z.infer<typeof args>;
9
+ };
10
+ export default function IssueSwitch({ args }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box } from "ink";
4
+ import { argument } from "pastel";
5
+ import { z } from "zod/v4";
6
+ import { findMainRepoRoot } from "../../lib/git.js";
7
+ import { setRepoTracker, getIssueTracker } from "../../lib/trackers/index.js";
8
+ export const description = "Switch the active issue tracker for this repo";
9
+ export const args = z.tuple([
10
+ z.enum(["linear", "github"]).describe(argument({
11
+ name: "kind",
12
+ description: "Tracker kind: linear or github",
13
+ })),
14
+ ]);
15
+ export default function IssueSwitch({ args }) {
16
+ const [kind] = args;
17
+ const [status, setStatus] = useState("switching");
18
+ const [message, setMessage] = useState("");
19
+ useEffect(() => {
20
+ const repoRoot = findMainRepoRoot();
21
+ if (!repoRoot) {
22
+ setMessage("Not inside a git repository");
23
+ setStatus("error");
24
+ return;
25
+ }
26
+ setRepoTracker(repoRoot, kind);
27
+ const tracker = getIssueTracker(repoRoot);
28
+ setMessage(`Active tracker for this repo: ${tracker.displayName}`);
29
+ setStatus("done");
30
+ }, [kind]);
31
+ useEffect(() => {
32
+ if (status === "done" || status === "error") {
33
+ const timer = setTimeout(() => process.exit(status === "error" ? 1 : 0), 50);
34
+ return () => clearTimeout(timer);
35
+ }
36
+ }, [status]);
37
+ return (_jsxs(Box, { padding: 1, children: [status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] }));
38
+ }
@@ -3,8 +3,10 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box, useInput } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { findMainRepoRoot, setRepoLinearOrg, getRepoLinearOrg, removeRepoLinearOrg, } from "../../lib/git.js";
7
- import { startOAuthFlow, getAuthStatus, getValidTokens, getTicketContent, readAuthStore, } from "../../lib/linear.js";
6
+ import { findMainRepoRoot } from "../../lib/git.js";
7
+ import { setRepoLinearOrg, getRepoLinearOrg, removeRepoLinearOrg, startOAuthFlow, getValidTokens, linearTracker, } from "../../lib/trackers/linear/index.js";
8
+ import { readLinearAuthStore } from "../../lib/trackers/auth-store.js";
9
+ import { setRepoTracker } from "../../lib/trackers/index.js";
8
10
  import { renderTicket } from "../../lib/prompts.js";
9
11
  export const description = "Authenticate with Linear";
10
12
  export const options = z.object({
@@ -46,6 +48,7 @@ export default function LinearAuth({ options }) {
46
48
  return;
47
49
  }
48
50
  setRepoLinearOrg(repoRoot, result.orgSlug);
51
+ setRepoTracker(repoRoot, "linear");
49
52
  setMessage(`Authenticated as ${result.orgName} (${result.orgSlug})`);
50
53
  setStatus("done");
51
54
  });
@@ -54,6 +57,7 @@ export default function LinearAuth({ options }) {
54
57
  // Link existing org
55
58
  const choice = choices[selected];
56
59
  setRepoLinearOrg(repoRoot, choice.slug);
60
+ setRepoTracker(repoRoot, "linear");
57
61
  setMessage(`Linked repo to ${choice.name} (${choice.slug})`);
58
62
  setStatus("done");
59
63
  }
@@ -67,35 +71,42 @@ export default function LinearAuth({ options }) {
67
71
  setStatus("error");
68
72
  return;
69
73
  }
70
- const issue = await getTicketContent(options.test, repoRoot);
71
- if (!issue) {
74
+ const result = await linearTracker.getIssue(options.test, repoRoot);
75
+ if (!result.ok) {
72
76
  setError(`Could not fetch ticket ${options.test}. Check auth and ticket ID.`);
73
77
  setStatus("error");
74
78
  return;
75
79
  }
76
- setMessage(renderTicket(issue).trim());
80
+ setMessage(renderTicket(result.value, linearTracker.displayName).trim());
77
81
  setStatus("done");
78
82
  return;
79
83
  }
80
84
  if (options.status) {
81
85
  const repoRoot = findMainRepoRoot();
82
- const authStatus = getAuthStatus(repoRoot);
86
+ const authStatus = await linearTracker.getAuthStatus(repoRoot);
83
87
  if (!authStatus.authenticated) {
84
88
  setMessage("Not authenticated with Linear");
85
89
  setStatus("done");
86
90
  return;
87
91
  }
88
- if (authStatus.orgSlug) {
89
- const valid = await getValidTokens(authStatus.orgSlug);
92
+ const orgSlug = repoRoot ? getRepoLinearOrg(repoRoot) : null;
93
+ if (orgSlug) {
94
+ const valid = await getValidTokens(orgSlug);
90
95
  const expiry = valid
91
96
  ? new Date(valid.expires_at).toLocaleString()
92
97
  : "expired (refresh failed)";
93
98
  setMessage([
94
- `Organization: ${authStatus.orgName} (${authStatus.orgSlug})`,
99
+ `Account: ${authStatus.accountLabel ?? orgSlug}`,
95
100
  `Token expires: ${expiry}`,
96
101
  `Repo linked: ${authStatus.repoLinked ? "yes" : "no"}`,
97
102
  ].join("\n"));
98
103
  }
104
+ else {
105
+ setMessage([
106
+ `Account: ${authStatus.accountLabel ?? "unknown"}`,
107
+ `Repo linked: ${authStatus.repoLinked ? "yes" : "no"}`,
108
+ ].join("\n"));
109
+ }
99
110
  setStatus("done");
100
111
  return;
101
112
  }
@@ -142,12 +153,13 @@ export default function LinearAuth({ options }) {
142
153
  return;
143
154
  }
144
155
  setRepoLinearOrg(repoRoot, result.orgSlug);
156
+ setRepoTracker(repoRoot, "linear");
145
157
  setMessage(`Re-authenticated as ${result.orgName} (${result.orgSlug})`);
146
158
  setStatus("done");
147
159
  return;
148
160
  }
149
161
  // Check for existing authenticated orgs
150
- const store = readAuthStore();
162
+ const store = readLinearAuthStore();
151
163
  const orgs = Object.entries(store).map(([slug, tokens]) => ({
152
164
  slug,
153
165
  name: tokens.org_name,
@@ -162,6 +174,7 @@ export default function LinearAuth({ options }) {
162
174
  return;
163
175
  }
164
176
  setRepoLinearOrg(repoRoot, result.orgSlug);
177
+ setRepoTracker(repoRoot, "linear");
165
178
  setMessage(`Authenticated as ${result.orgName} (${result.orgSlug})`);
166
179
  setStatus("done");
167
180
  return;
@@ -2,8 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text, Box, useInput } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- import { findMainRepoRoot, setRepoLinearOrg, getRepoLinearOrg } from "../../lib/git.js";
6
- import { readAuthStore } from "../../lib/linear.js";
5
+ import { findMainRepoRoot } from "../../lib/git.js";
6
+ import { setRepoLinearOrg, getRepoLinearOrg } from "../../lib/trackers/linear/index.js";
7
+ import { readLinearAuthStore } from "../../lib/trackers/auth-store.js";
8
+ import { setRepoTracker } from "../../lib/trackers/index.js";
7
9
  export const description = "Switch Linear workspace for this repo";
8
10
  export default function LinearSwitch() {
9
11
  const [status, setStatus] = useState("checking");
@@ -25,6 +27,7 @@ export default function LinearSwitch() {
25
27
  const choice = choices[selected];
26
28
  const repoRoot = findMainRepoRoot();
27
29
  setRepoLinearOrg(repoRoot, choice.slug);
30
+ setRepoTracker(repoRoot, "linear");
28
31
  setMessage(`Switched to ${choice.name} (${choice.slug})`);
29
32
  setStatus("done");
30
33
  }
@@ -38,7 +41,7 @@ export default function LinearSwitch() {
38
41
  setStatus("error");
39
42
  return;
40
43
  }
41
- const store = readAuthStore();
44
+ const store = readLinearAuthStore();
42
45
  const orgs = Object.entries(store).map(([slug, tokens]) => ({
43
46
  slug,
44
47
  name: tokens.org_name,
@@ -51,6 +54,7 @@ export default function LinearSwitch() {
51
54
  if (orgs.length === 1) {
52
55
  const org = orgs[0];
53
56
  setRepoLinearOrg(repoRoot, org.slug);
57
+ setRepoTracker(repoRoot, "linear");
54
58
  setMessage(`Linked to ${org.name} (${org.slug})`);
55
59
  setStatus("done");
56
60
  return;
@@ -12,7 +12,7 @@ import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getBaseBranch, hasUnc
12
12
  import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, getPRTemplate, } from "../../lib/github.js";
13
13
  import { renderPrompt, renderDiff, renderTicket } from "../../lib/prompts.js";
14
14
  import { runAgent } from "../../lib/ai.js";
15
- import { getTicketContent } from "../../lib/linear.js";
15
+ import { getIssueTracker } from "../../lib/trackers/index.js";
16
16
  const execAsync = promisify(exec);
17
17
  export const description = "Create a GitHub pull request";
18
18
  export const options = z.object({
@@ -63,12 +63,14 @@ export default function PR({ options }) {
63
63
  }
64
64
  const ticketId = extractTicketId(branch);
65
65
  const mainRepoRoot = findMainRepoRoot();
66
- // Fetch ticket content (downloads images for Linear tickets)
66
+ // Fetch issue content from the active tracker (downloads images
67
+ // inline so Claude can read them via --allowedTools Read).
67
68
  let ticketContent;
68
69
  if (ticketId && mainRepoRoot) {
69
- const ticket = await getTicketContent(ticketId, mainRepoRoot);
70
- if (ticket) {
71
- ticketContent = renderTicket(ticket);
70
+ const tracker = getIssueTracker(mainRepoRoot);
71
+ const result = await tracker.getIssue(ticketId, mainRepoRoot);
72
+ if (result.ok) {
73
+ ticketContent = renderTicket(result.value, tracker.displayName);
72
74
  }
73
75
  }
74
76
  const diffContent = renderDiff({