santree 0.5.3 → 0.5.5

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 (80) hide show
  1. package/README.md +156 -46
  2. package/dist/commands/dashboard.d.ts +1 -1
  3. package/dist/commands/dashboard.js +22 -18
  4. package/dist/commands/doctor.js +97 -76
  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/english-tutor/index.d.ts +1 -0
  10. package/dist/commands/helpers/english-tutor/index.js +1 -0
  11. package/dist/commands/helpers/english-tutor/install.d.ts +8 -0
  12. package/dist/commands/helpers/english-tutor/install.js +24 -0
  13. package/dist/commands/helpers/english-tutor/prompt.d.ts +2 -0
  14. package/dist/commands/helpers/english-tutor/prompt.js +16 -0
  15. package/dist/commands/helpers/english-tutor/session-start.d.ts +2 -0
  16. package/dist/commands/helpers/english-tutor/session-start.js +34 -0
  17. package/dist/commands/helpers/english-tutor/uninstall.d.ts +2 -0
  18. package/dist/commands/helpers/english-tutor/uninstall.js +15 -0
  19. package/dist/commands/helpers/template.d.ts +1 -0
  20. package/dist/commands/helpers/template.js +13 -10
  21. package/dist/commands/issue/index.d.ts +1 -0
  22. package/dist/commands/issue/index.js +1 -0
  23. package/dist/commands/issue/open.d.ts +2 -0
  24. package/dist/commands/{linear → issue}/open.js +13 -11
  25. package/dist/commands/issue/switch.d.ts +11 -0
  26. package/dist/commands/issue/switch.js +38 -0
  27. package/dist/commands/linear/auth.js +23 -10
  28. package/dist/commands/linear/switch.js +7 -3
  29. package/dist/commands/pr/create.js +7 -5
  30. package/dist/commands/worktree/create.js +4 -6
  31. package/dist/commands/worktree/work.js +1 -1
  32. package/dist/lib/ai.d.ts +8 -6
  33. package/dist/lib/ai.js +29 -15
  34. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  35. package/dist/lib/dashboard/DetailPanel.js +6 -3
  36. package/dist/lib/dashboard/data.js +17 -9
  37. package/dist/lib/dashboard/types.d.ts +3 -16
  38. package/dist/lib/english-tutor.d.ts +13 -0
  39. package/dist/lib/english-tutor.js +125 -0
  40. package/dist/lib/git.d.ts +16 -33
  41. package/dist/lib/git.js +20 -74
  42. package/dist/lib/metadata.d.ts +3 -0
  43. package/dist/lib/metadata.js +27 -0
  44. package/dist/lib/multiplexer/cmux.js +1 -1
  45. package/dist/lib/multiplexer/index.js +5 -12
  46. package/dist/lib/multiplexer/types.d.ts +1 -1
  47. package/dist/lib/prompts.d.ts +4 -3
  48. package/dist/lib/prompts.js +4 -3
  49. package/dist/lib/session-signal.d.ts +2 -3
  50. package/dist/lib/session-signal.js +3 -29
  51. package/dist/lib/trackers/auth-store.d.ts +16 -0
  52. package/dist/lib/trackers/auth-store.js +57 -0
  53. package/dist/lib/trackers/config.d.ts +8 -0
  54. package/dist/lib/trackers/config.js +21 -0
  55. package/dist/lib/trackers/github/api.d.ts +3 -0
  56. package/dist/lib/trackers/github/api.js +90 -0
  57. package/dist/lib/trackers/github/auth.d.ts +5 -0
  58. package/dist/lib/trackers/github/auth.js +27 -0
  59. package/dist/lib/trackers/github/images.d.ts +2 -0
  60. package/dist/lib/trackers/github/images.js +42 -0
  61. package/dist/lib/trackers/github/index.d.ts +2 -0
  62. package/dist/lib/trackers/github/index.js +78 -0
  63. package/dist/lib/trackers/index.d.ts +12 -0
  64. package/dist/lib/trackers/index.js +34 -0
  65. package/dist/lib/trackers/linear/api.d.ts +4 -0
  66. package/dist/lib/trackers/linear/api.js +128 -0
  67. package/dist/lib/trackers/linear/auth.d.ts +11 -0
  68. package/dist/lib/trackers/linear/auth.js +206 -0
  69. package/dist/lib/trackers/linear/images.d.ts +2 -0
  70. package/dist/lib/trackers/linear/images.js +44 -0
  71. package/dist/lib/trackers/linear/index.d.ts +3 -0
  72. package/dist/lib/trackers/linear/index.js +100 -0
  73. package/dist/lib/trackers/types.d.ts +52 -0
  74. package/dist/lib/trackers/types.js +1 -0
  75. package/package.json +1 -1
  76. package/prompts/english-tutor-prompt.njk +15 -0
  77. package/prompts/ticket.njk +3 -3
  78. package/dist/commands/linear/open.d.ts +0 -2
  79. package/dist/lib/linear.d.ts +0 -83
  80. package/dist/lib/linear.js +0 -482
@@ -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({
@@ -36,7 +36,6 @@ export default function Create({ options, args }) {
36
36
  const [worktreePath, setWorktreePath] = useState("");
37
37
  const [baseBranch, setBaseBranch] = useState(null);
38
38
  const [muxWindowName, setMuxWindowName] = useState(null);
39
- const [muxKind, setMuxKind] = useState(null);
40
39
  async function finalize(path, branch) {
41
40
  const wantsWindow = options.window || options.tmux;
42
41
  if (wantsWindow) {
@@ -48,24 +47,23 @@ export default function Create({ options, args }) {
48
47
  return;
49
48
  }
50
49
  setStatus("spawning-window");
51
- setMessage(`Creating ${mux.kind} window...`);
50
+ setMessage("Creating window...");
52
51
  const windowName = getWindowName(branch, options.name);
53
52
  setMuxWindowName(windowName);
54
- setMuxKind(mux.kind);
55
53
  let runCommand;
56
54
  if (options.work) {
57
55
  runCommand = options.plan ? "st worktree work --plan" : "st worktree work";
58
56
  }
59
57
  const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
60
58
  if (!result.ok) {
61
- setMessage(`Worktree created, but failed to create ${mux.kind} window${result.message ? `: ${result.message}` : ""}`);
59
+ setMessage(`Worktree created, but failed to create window${result.message ? `: ${result.message}` : ""}`);
62
60
  setStatus("done");
63
61
  console.log(`SANTREE_CD:${path}`);
64
62
  return;
65
63
  }
66
64
  setStatus("done");
67
65
  const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
68
- setMessage(`Worktree and ${mux.kind} window created!${workInfo}`);
66
+ setMessage(`Worktree and window created!${workInfo}`);
69
67
  // Don't output SANTREE_CD when a window is created — user is already in the new window
70
68
  return;
71
69
  }
@@ -161,5 +159,5 @@ export default function Create({ options, args }) {
161
159
  status === "creating" ||
162
160
  status === "init-script" ||
163
161
  status === "spawning-window";
164
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && (_jsxs(Text, { dimColor: true, children: [" ", muxKind ?? "tmux", " window: ", muxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
162
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && _jsxs(Text, { dimColor: true, children: [" window: ", muxWindowName] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
165
163
  }
@@ -99,5 +99,5 @@ export default function Work({ options }) {
99
99
  setError(err instanceof Error ? err.message : "Failed to launch agent");
100
100
  }
101
101
  }, [status, aiContext, mode]);
102
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching ticket from Linear..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
102
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching issue from tracker..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
103
103
  }
package/dist/lib/ai.d.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { type ChildProcess } from "child_process";
2
- import { cleanupImages, type LinearIssue } from "./linear.js";
2
+ import type { Issue } from "./trackers/types.js";
3
3
  export interface AIContext {
4
4
  repoRoot: string;
5
5
  mainRoot: string;
6
6
  branch: string;
7
7
  ticketId: string | null;
8
- ticket: LinearIssue | null;
8
+ ticket: Issue | null;
9
+ trackerName: string;
10
+ issueNoun: string;
9
11
  }
10
12
  /**
11
- * Resolves repo, branch, ticket ID, and fetches the Linear ticket.
12
- * Returns an error string if any required context is missing.
13
+ * Resolves repo, branch, issue identifier, and fetches the issue from the
14
+ * active tracker (Linear or GitHub Issues selected by repo config).
13
15
  */
14
16
  export declare function resolveAIContext(): Promise<{
15
17
  ok: true;
@@ -74,6 +76,6 @@ export declare function runAgent(prompt: string, opts?: {
74
76
  allowedTools?: string[];
75
77
  }): RunAgentResult;
76
78
  /**
77
- * Cleanup images downloaded for a ticket.
79
+ * Clean up cached image downloads for an issue identifier on the active tracker.
78
80
  */
79
- export { cleanupImages };
81
+ export declare function cleanupImages(ticketId: string): void;
package/dist/lib/ai.js CHANGED
@@ -2,15 +2,14 @@ import { execSync, spawn, spawnSync } from "child_process";
2
2
  import { existsSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir, tmpdir } from "os";
5
- import { getMultiplexer } from "./multiplexer/index.js";
6
- import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot, getBaseBranch, } from "./git.js";
5
+ import { getCurrentBranch, findRepoRoot, findMainRepoRoot, getBaseBranch } from "./git.js";
7
6
  import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
8
- import { getTicketContent, cleanupImages } from "./linear.js";
7
+ import { getIssueTracker } from "./trackers/index.js";
9
8
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRReviewCommentsAsync, getPRConversationCommentsAsync, getFailedCheckDetailsAsync, } from "./github.js";
10
9
  import { runAsync } from "./exec.js";
11
10
  /**
12
- * Resolves repo, branch, ticket ID, and fetches the Linear ticket.
13
- * Returns an error string if any required context is missing.
11
+ * Resolves repo, branch, issue identifier, and fetches the issue from the
12
+ * active tracker (Linear or GitHub Issues selected by repo config).
14
13
  */
15
14
  export async function resolveAIContext() {
16
15
  const repoRoot = findRepoRoot();
@@ -21,18 +20,28 @@ export async function resolveAIContext() {
21
20
  if (!branch) {
22
21
  return { ok: false, error: "Could not determine current branch" };
23
22
  }
24
- const ticketId = extractTicketId(branch);
23
+ const mainRoot = findMainRepoRoot() ?? repoRoot;
24
+ const tracker = getIssueTracker(mainRoot);
25
+ const ticketId = tracker.extractIdFromBranch(branch);
25
26
  if (!ticketId) {
26
27
  return {
27
28
  ok: false,
28
- error: "Could not extract ticket ID from branch name. Expected format: user/TEAM-123-description",
29
+ error: `Could not extract ${tracker.issueNoun} ID from branch name '${branch}'.`,
29
30
  };
30
31
  }
31
- const mainRoot = findMainRepoRoot() ?? repoRoot;
32
- const ticket = await getTicketContent(ticketId, mainRoot);
32
+ const result = await tracker.getIssue(ticketId, mainRoot);
33
+ const ticket = result.ok ? result.value : null;
33
34
  return {
34
35
  ok: true,
35
- context: { repoRoot, mainRoot, branch, ticketId, ticket },
36
+ context: {
37
+ repoRoot,
38
+ mainRoot,
39
+ branch,
40
+ ticketId,
41
+ ticket,
42
+ trackerName: tracker.displayName,
43
+ issueNoun: tracker.issueNoun,
44
+ },
36
45
  };
37
46
  }
38
47
  /**
@@ -41,7 +50,7 @@ export async function resolveAIContext() {
41
50
  export function buildPromptContext(ctx, extra) {
42
51
  return {
43
52
  ticket_id: ctx.ticketId ?? undefined,
44
- ticket_content: ctx.ticket ? renderTicket(ctx.ticket) : undefined,
53
+ ticket_content: ctx.ticket ? renderTicket(ctx.ticket, ctx.trackerName) : undefined,
45
54
  ...extra,
46
55
  };
47
56
  }
@@ -122,8 +131,10 @@ const CMUX_CLAUDE_PATH = "/Applications/cmux.app/Contents/Resources/bin/claude";
122
131
  */
123
132
  export function resolveClaudeBinary() {
124
133
  // Inside cmux, the bundled binary is the only one wired to the active
125
- // workspace. Always prefer it when present.
126
- if (getMultiplexer().kind === "cmux" && existsSync(CMUX_CLAUDE_PATH)) {
134
+ // workspace. Gate on `CMUX_SURFACE_ID` (real cmux runtime) — outside a live
135
+ // workspace the bundled binary has no auth context and exits with
136
+ // "Invalid API key".
137
+ if (process.env["CMUX_SURFACE_ID"] && existsSync(CMUX_CLAUDE_PATH)) {
127
138
  return CMUX_CLAUDE_PATH;
128
139
  }
129
140
  // PATH lookup
@@ -216,6 +227,9 @@ export function runAgent(prompt, opts) {
216
227
  };
217
228
  }
218
229
  /**
219
- * Cleanup images downloaded for a ticket.
230
+ * Clean up cached image downloads for an issue identifier on the active tracker.
220
231
  */
221
- export { cleanupImages };
232
+ export function cleanupImages(ticketId) {
233
+ const repoRoot = findMainRepoRoot();
234
+ getIssueTracker(repoRoot).cleanupCache(ticketId);
235
+ }
@@ -14,7 +14,10 @@ export type IssueActionItem = {
14
14
  };
15
15
  /** Returns the context-sensitive action key list for the selected issue.
16
16
  * Lifted out of the panel so the dashboard can render it on the same row as
17
- * the global command bar (so left- and right-pane key hints align). */
18
- export declare function buildIssueActions(di: DashboardIssue): IssueActionItem[];
17
+ * the global command bar (so left- and right-pane key hints align). The
18
+ * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
19
+ * surfaced as the open-in-browser action label so the panel doesn't hardcode
20
+ * a vendor name. */
21
+ export declare function buildIssueActions(di: DashboardIssue, trackerName: string): IssueActionItem[];
19
22
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
20
23
  export {};
@@ -51,8 +51,11 @@ function fileColor(xy) {
51
51
  }
52
52
  /** Returns the context-sensitive action key list for the selected issue.
53
53
  * Lifted out of the panel so the dashboard can render it on the same row as
54
- * the global command bar (so left- and right-pane key hints align). */
55
- export function buildIssueActions(di) {
54
+ * the global command bar (so left- and right-pane key hints align). The
55
+ * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
56
+ * surfaced as the open-in-browser action label so the panel doesn't hardcode
57
+ * a vendor name. */
58
+ export function buildIssueActions(di, trackerName) {
56
59
  const { worktree, pr, issue } = di;
57
60
  const items = [];
58
61
  if (worktree?.sessionId) {
@@ -82,7 +85,7 @@ export function buildIssueActions(di) {
82
85
  items.push({ key: "r", label: "Review", color: "cyan" });
83
86
  }
84
87
  if (issue.url) {
85
- items.push({ key: "o", label: "Linear", color: "gray" });
88
+ items.push({ key: "o", label: trackerName, color: "gray" });
86
89
  }
87
90
  if (pr)
88
91
  items.push({ key: "p", label: "Open PR", color: "gray" });
@@ -1,14 +1,18 @@
1
1
  import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, getDiffShortstatAsync, } from "../git.js";
2
2
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
3
- import { fetchAssignedIssues } from "../linear.js";
3
+ import { getIssueTracker } from "../trackers/index.js";
4
4
  export async function loadDashboardData(repoRoot) {
5
5
  // Fetch issues and worktrees in parallel
6
- const [issues, worktrees] = await Promise.all([
7
- fetchAssignedIssues(repoRoot),
6
+ const tracker = getIssueTracker(repoRoot);
7
+ const [listResult, worktrees] = await Promise.all([
8
+ tracker.listAssigned(repoRoot),
8
9
  Promise.resolve(listWorktrees()),
9
10
  ]);
10
- if (!issues)
11
- throw new Error("Failed to authenticate with Linear. Run: santree linear auth");
11
+ if (!listResult.ok) {
12
+ const status = await tracker.getAuthStatus(repoRoot);
13
+ throw new Error(listResult.message ?? status.hint ?? `Failed to authenticate with ${tracker.displayName}`);
14
+ }
15
+ const issues = listResult.value;
12
16
  // Build worktree map: ticketId -> worktree info
13
17
  const wtMap = new Map();
14
18
  for (const wt of worktrees) {
@@ -92,10 +96,14 @@ export async function loadDashboardData(repoRoot) {
92
96
  getPRReviewsAsync(pr.number),
93
97
  ]);
94
98
  }
95
- // Derive a readable title from branch name: strip prefix and ticket ID
96
- const titleFromBranch = wt.branch
97
- .replace(/^[^/]+\//, "") // strip prefix (e.g. "feature/")
98
- .replace(/^[A-Z]+-\d+-?/, "") // strip ticket ID
99
+ // Derive a readable title from branch name: strip prefix and the
100
+ // tracker-format ID literal (e.g. "TEAM-123-" or "123-"). The ID
101
+ // shape comes from the tracker's parser so this works for both
102
+ // Linear and GitHub branches.
103
+ const idLiteral = tid ? new RegExp(`^${tid}-?`) : null;
104
+ const titleFromBranch = (idLiteral
105
+ ? wt.branch.replace(/^[^/]+\//, "").replace(idLiteral, "")
106
+ : wt.branch.replace(/^[^/]+\//, ""))
99
107
  .replace(/-/g, " ")
100
108
  .trim() || tid;
101
109
  let sessState = readSessionState(repoRoot, tid);
@@ -1,19 +1,6 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
- export interface LinearAssignedIssue {
3
- identifier: string;
4
- title: string;
5
- description: string | null;
6
- url: string;
7
- priority: number;
8
- priorityLabel: string;
9
- state: {
10
- name: string;
11
- type: string;
12
- };
13
- labels: string[];
14
- projectId: string | null;
15
- projectName: string | null;
16
- }
2
+ import type { AssignedIssue } from "../trackers/types.js";
3
+ export type { AssignedIssue } from "../trackers/types.js";
17
4
  export interface WorktreeInfo {
18
5
  path: string;
19
6
  branch: string;
@@ -30,7 +17,7 @@ export interface WorktreeInfo {
30
17
  } | null;
31
18
  }
32
19
  export interface DashboardIssue {
33
- issue: LinearAssignedIssue;
20
+ issue: AssignedIssue;
34
21
  worktree: WorktreeInfo | null;
35
22
  pr: PRInfo | null;
36
23
  checks: PRCheck[] | null;
@@ -0,0 +1,13 @@
1
+ export declare function getLogPath(): string;
2
+ export declare function getHooksJson(): Record<string, unknown>;
3
+ export declare function getPermissionEntry(): string;
4
+ export declare function installHooks(): {
5
+ settingsPath: string;
6
+ logPath: string;
7
+ };
8
+ /**
9
+ * Remove hooks and permission entry. Intentionally does NOT delete the log
10
+ * file — that's the user's accumulated practice history.
11
+ */
12
+ export declare function uninstallHooks(): string;
13
+ export declare function getInstallSnippet(): Record<string, unknown>;