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
@@ -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";
@@ -60,19 +60,19 @@ async function checkTool(name, description, required, versionCommand, hint) {
60
60
  }
61
61
  /**
62
62
  * Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
63
- * binary is reachable. Surfaces a hint when the configured multiplexer can't run.
63
+ * binary is reachable. Detection is auto each adapter's `isActive()` checks
64
+ * its own runtime env (`$TMUX`, `$CMUX_SURFACE_ID`).
64
65
  */
65
66
  async function checkMultiplexer() {
66
67
  const mux = getMultiplexer();
67
- const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
68
- const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
68
+ const description = `Multiplexer (active: ${mux.kind})`;
69
69
  if (mux.kind === "none") {
70
70
  return {
71
71
  name: "multiplexer",
72
72
  description,
73
73
  required: false,
74
74
  installed: false,
75
- hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
75
+ hint: "Run inside tmux or cmux to enable session window renaming. Install tmux: brew install tmux",
76
76
  };
77
77
  }
78
78
  if (mux.kind === "tmux") {
@@ -104,7 +104,7 @@ async function checkMultiplexer() {
104
104
  description,
105
105
  required: false,
106
106
  installed: false,
107
- hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
107
+ hint: "Install cmux.app from https://cmux.com (cmux is macOS-only).",
108
108
  };
109
109
  }
110
110
  const version = await tryExec("cmux --version 2>/dev/null");
@@ -122,9 +122,7 @@ async function checkMultiplexer() {
122
122
  installed: !!ping,
123
123
  version: version || "unknown",
124
124
  path,
125
- hint: !ping
126
- ? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux."
127
- : undefined,
125
+ hint: !ping ? "cmux app not reachable — open cmux.app." : undefined,
128
126
  };
129
127
  }
130
128
  /**
@@ -135,8 +133,9 @@ async function checkMultiplexer() {
135
133
  */
136
134
  async function checkClaude() {
137
135
  const resolved = resolveClaudeBinary();
136
+ const usingBundled = resolved?.startsWith("/Applications/cmux.app/") ?? false;
138
137
  const inCmux = getMultiplexer().kind === "cmux";
139
- const description = inCmux ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
138
+ const description = usingBundled ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
140
139
  if (!resolved) {
141
140
  return {
142
141
  name: "claude",
@@ -197,30 +196,22 @@ async function checkGhAuth() {
197
196
  };
198
197
  }
199
198
  /**
200
- * Checks Linear API authentication status.
199
+ * Checks the active issue tracker's auth state. The tracker (Linear, GitHub)
200
+ * is resolved from the repo's `_tracker` config (or env / auto-detect).
201
+ * Doctor is the one place that legitimately names the active tracker — it's
202
+ * diagnostic context, not a generic UI string.
201
203
  */
202
- async function checkLinearAuth() {
204
+ async function checkTrackerAuth() {
203
205
  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);
206
+ const tracker = getIssueTracker(repoRoot);
207
+ const status = await tracker.getAuthStatus(repoRoot);
213
208
  return {
214
- authenticated: true,
215
- orgSlug: status.orgSlug,
216
- orgName: status.orgName,
217
- tokenValid: valid !== null,
209
+ displayName: tracker.displayName,
210
+ authenticated: status.authenticated,
211
+ accountLabel: status.accountLabel,
212
+ expiresAt: status.expiresAt,
218
213
  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,
214
+ hint: status.hint,
224
215
  };
225
216
  }
226
217
  /**
@@ -334,40 +325,64 @@ function checkSessionSignalHooks() {
334
325
  catch {
335
326
  missingHooks.push(...requiredEvents);
336
327
  }
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
328
+ if (missingHooks.length === 0) {
329
+ return { configured: true, missingHooks: [] };
330
+ }
331
+ return {
332
+ configured: false,
333
+ missingHooks,
334
+ hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
335
+ };
336
+ }
337
+ /**
338
+ * Checks if english-tutor hooks are configured. Verifies the UserPromptSubmit
339
+ * and SessionStart hooks plus the scoped Edit permission for the practice log.
340
+ */
341
+ function checkEnglishTutorHooks() {
342
+ const home = process.env.HOME || "";
343
+ const claudeSettingsPath = path.join(home, ".claude", "settings.json");
344
+ const requiredEvents = ["UserPromptSubmit", "SessionStart"];
345
+ const missingHooks = [];
346
+ let missingPermission = true;
347
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
348
+ const expectedPermission = `Edit(${path.join(configDir, "santree", "english-practice-log.md")})`;
349
+ try {
350
+ if (fs.existsSync(claudeSettingsPath)) {
351
+ const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, "utf-8"));
352
+ const hooks = settings.hooks || {};
353
+ for (const event of requiredEvents) {
354
+ const eventHooks = hooks[event];
355
+ if (!Array.isArray(eventHooks)) {
356
+ missingHooks.push(event);
357
+ continue;
355
358
  }
359
+ const found = eventHooks.some((entry) => {
360
+ const innerHooks = entry.hooks || [];
361
+ return innerHooks.some((h) => typeof h.command === "string" && h.command.includes("english-tutor"));
362
+ });
363
+ if (!found)
364
+ missingHooks.push(event);
356
365
  }
357
- if (exists) {
358
- hookScripts.push({ name, exists, executable });
366
+ const allow = settings.permissions?.allow;
367
+ if (Array.isArray(allow) && allow.includes(expectedPermission)) {
368
+ missingPermission = false;
359
369
  }
360
370
  }
371
+ else {
372
+ missingHooks.push(...requiredEvents);
373
+ }
361
374
  }
362
- if (missingHooks.length === 0) {
363
- return { configured: true, missingHooks: [], hookScripts, hooksDir };
375
+ catch {
376
+ missingHooks.push(...requiredEvents);
377
+ }
378
+ if (missingHooks.length === 0 && !missingPermission) {
379
+ return { configured: true, missingHooks: [], missingPermission: false };
364
380
  }
365
381
  return {
366
382
  configured: false,
367
383
  missingHooks,
368
- hookScripts,
369
- hooksDir,
370
- hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
384
+ missingPermission,
385
+ hint: "Run: santree helpers english-tutor install",
371
386
  };
372
387
  }
373
388
  /**
@@ -451,9 +466,9 @@ function StatusIcon({ ok, required }) {
451
466
  function ToolRow({ tool }) {
452
467
  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
468
  }
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] }) }))] }));
469
+ function TrackerRow({ tracker }) {
470
+ const isOk = tracker.authenticated && !tracker.hint;
471
+ 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
472
  }
458
473
  function ShellRow({ configured, shell }) {
459
474
  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 +480,15 @@ function RemoteControlRow({ status }) {
465
480
  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
481
  }
467
482
  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)))] })] }));
483
+ 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] })] })) })] }));
484
+ }
485
+ function EnglishTutorRow({ status }) {
486
+ const missingParts = [];
487
+ if (status.missingHooks.length > 0)
488
+ missingParts.push(`hooks: ${status.missingHooks.join(", ")}`);
489
+ if (status.missingPermission)
490
+ missingParts.push("log Edit permission");
491
+ 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: "English Tutor Hooks" }), _jsx(Text, { dimColor: true, children: " - Inline grammar corrections" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsx(Text, { dimColor: true, children: "Hooks and log permission configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", missingParts.join("; ") || "—"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })) })] }));
475
492
  }
476
493
  function SantreeSetupRow({ status }) {
477
494
  const isOk = status.santreeFolderExists &&
@@ -490,11 +507,12 @@ function SantreeSetupRow({ status }) {
490
507
  }
491
508
  export default function Doctor() {
492
509
  const [tools, setTools] = useState([]);
493
- const [linear, setLinear] = useState(null);
510
+ const [tracker, setTracker] = useState(null);
494
511
  const [shellStatus, setShellStatus] = useState(null);
495
512
  const [remoteControl, setRemoteControl] = useState(null);
496
513
  const [statusline, setStatusline] = useState(null);
497
514
  const [sessionSignal, setSessionSignal] = useState(null);
515
+ const [englishTutor, setEnglishTutor] = useState(null);
498
516
  const [santreeSetup, setSantreeSetup] = useState(null);
499
517
  const [loading, setLoading] = useState(true);
500
518
  useEffect(() => {
@@ -541,12 +559,14 @@ export default function Doctor() {
541
559
  }
542
560
  }
543
561
  }
544
- // Optional: a syntax-highlighted diff pager — used by `st worktree diff`
562
+ // Optional: a syntax-highlighting diff pager — used by `st worktree diff`
545
563
  // 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");
564
+ // diff pager works (delta, diff-so-fancy, …); without one set, the
565
+ // dashboard renders inline with santree's own colorizer and the CLI
566
+ // falls back to git's default pager. Delta is the most popular
567
+ // choice so we check for it as a convenience, but it is never a
568
+ // hard dependency.
569
+ 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
570
  results.push(deltaCheck);
551
571
  // Optional: a `.code-workspace`-aware editor (VSCode or Cursor).
552
572
  // Santree itself works with any editor via $SANTREE_EDITOR — this
@@ -573,14 +593,15 @@ export default function Doctor() {
573
593
  hint: "Optional — santree works with any $SANTREE_EDITOR. Only needed for the dashboard's `.code-workspace` shortcut.",
574
594
  });
575
595
  }
576
- const linearResult = await checkLinearAuth();
596
+ const trackerResult = await checkTrackerAuth();
577
597
  const statuslineResult = await checkStatusline();
578
598
  setTools(results);
579
- setLinear(linearResult);
599
+ setTracker(trackerResult);
580
600
  setShellStatus(checkShellIntegration());
581
601
  setRemoteControl(checkRemoteControl());
582
602
  setStatusline(statuslineResult);
583
603
  setSessionSignal(checkSessionSignalHooks());
604
+ setEnglishTutor(checkEnglishTutorHooks());
584
605
  setSantreeSetup(checkSantreeSetup());
585
606
  setLoading(false);
586
607
  }
@@ -591,7 +612,7 @@ export default function Doctor() {
591
612
  }
592
613
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
593
614
  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"] }))] })) })] }));
615
+ const trackerOk = tracker?.authenticated && !tracker?.hint;
616
+ const allRequired = requiredMissing.length === 0 && trackerOk && shellStatus?.configured;
617
+ 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 }), englishTutor && _jsx(EnglishTutorRow, { status: englishTutor }), _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
618
  }
@@ -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";
@@ -0,0 +1 @@
1
+ export declare const description = "Nudge Claude to correct your English (Claude Code hooks)";
@@ -0,0 +1 @@
1
+ export const description = "Nudge Claude to correct your English (Claude Code hooks)";
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Install English-tutor hooks into Claude Code settings";
3
+ export declare const options: z.ZodObject<{
4
+ dry: z.ZodOptional<z.ZodBoolean>;
5
+ }, z.core.$strip>;
6
+ export default function Install({ options: opts }: {
7
+ options: z.infer<typeof options>;
8
+ }): null;
@@ -0,0 +1,24 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { z } from "zod";
3
+ import { getInstallSnippet, installHooks } from "../../../lib/english-tutor.js";
4
+ export const description = "Install English-tutor hooks into Claude Code settings";
5
+ export const options = z.object({
6
+ dry: z.boolean().optional().describe("Print the hooks JSON without writing"),
7
+ });
8
+ export default function Install({ options: opts }) {
9
+ const hasRun = useRef(false);
10
+ useEffect(() => {
11
+ if (hasRun.current)
12
+ return;
13
+ hasRun.current = true;
14
+ if (opts?.dry) {
15
+ process.stdout.write(JSON.stringify(getInstallSnippet(), null, 2) + "\n");
16
+ process.exit(0);
17
+ }
18
+ const { settingsPath, logPath } = installHooks();
19
+ process.stdout.write(`English-tutor hooks installed in ${settingsPath}\n`);
20
+ process.stdout.write(`Practice log: ${logPath}\n`);
21
+ process.exit(0);
22
+ }, []);
23
+ return null;
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "English-tutor instruction (UserPromptSubmit hook)";
2
+ export default function Prompt(): null;
@@ -0,0 +1,16 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { renderPrompt } from "../../../lib/prompts.js";
3
+ import { getLogPath } from "../../../lib/english-tutor.js";
4
+ export const description = "English-tutor instruction (UserPromptSubmit hook)";
5
+ export default function Prompt() {
6
+ const hasRun = useRef(false);
7
+ useEffect(() => {
8
+ if (hasRun.current)
9
+ return;
10
+ hasRun.current = true;
11
+ const text = renderPrompt("english-tutor-prompt", { logPath: getLogPath() });
12
+ process.stdout.write(text);
13
+ process.exit(0);
14
+ }, []);
15
+ return null;
16
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Replay practice log (SessionStart hook)";
2
+ export default function SessionStart(): null;
@@ -0,0 +1,34 @@
1
+ import * as fs from "fs";
2
+ import { useEffect, useRef } from "react";
3
+ import { getLogPath } from "../../../lib/english-tutor.js";
4
+ export const description = "Replay practice log (SessionStart hook)";
5
+ export default function SessionStart() {
6
+ const hasRun = useRef(false);
7
+ useEffect(() => {
8
+ if (hasRun.current)
9
+ return;
10
+ hasRun.current = true;
11
+ try {
12
+ const logPath = getLogPath();
13
+ if (fs.existsSync(logPath)) {
14
+ const contents = fs.readFileSync(logPath, "utf-8").trim();
15
+ const entryCount = (contents.match(/^- /gm) || []).length;
16
+ if (contents.length > 0) {
17
+ process.stdout.write(`[ENGLISH TUTOR — PRACTICE LOG REPLAY]\n\n${contents}\n\n--- LOG END ---\n`);
18
+ // Only ask for a summary once there are enough entries to be useful.
19
+ // Below that threshold the log is too sparse to surface real patterns,
20
+ // and a forced summary would just noise up new sessions.
21
+ if (entryCount >= 3) {
22
+ process.stdout.write(`\n[ENGLISH TUTOR — SESSION-START INSTRUCTION]\n` +
23
+ `At the very start of your first response in this session, briefly note any RECURRING patterns from the log above (1-3 short bullets, e.g. "you frequently drop articles", "watch for 'their' vs 'there'"). Skip this entirely if no clear patterns emerge or if the user's first message is purely a coding task. Do not list every entry — only patterns.\n`);
24
+ }
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ // hook must never block the session — silently no-op on read error
30
+ }
31
+ process.exit(0);
32
+ }, []);
33
+ return null;
34
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Remove English-tutor hooks from Claude Code settings";
2
+ export default function Uninstall(): null;
@@ -0,0 +1,15 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { uninstallHooks } from "../../../lib/english-tutor.js";
3
+ export const description = "Remove English-tutor hooks from Claude Code settings";
4
+ export default function Uninstall() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ const settingsPath = uninstallHooks();
11
+ process.stdout.write(`English-tutor hooks removed from ${settingsPath}\n`);
12
+ process.exit(0);
13
+ }, []);
14
+ return null;
15
+ }
@@ -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;