santree 0.7.3 → 0.7.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.
@@ -10,7 +10,9 @@ const require = createRequire(import.meta.url);
10
10
  const { version } = require("../../package.json");
11
11
  import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
12
12
  import { run, spawnAsync } from "../lib/exec.js";
13
- import { resolveAgentBinary, fillCommitMessage, askTicketQuestion } from "../lib/ai.js";
13
+ import { printCdHint } from "../lib/cd-hint.js";
14
+ import { resolveAgentBinary, resolveClaudeBinary, fillCommitMessage, askTicketQuestion, } from "../lib/ai.js";
15
+ import { readTriageInvestigateConfig, isTriageInvestigateConfigured, buildInvestigatePrompt, buildInvestigateCommand, } from "../lib/triage-config.js";
14
16
  import { getInstalledClaudeVersion } from "../lib/version.js";
15
17
  import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
16
18
  import { getMultiplexer } from "../lib/multiplexer/index.js";
@@ -325,6 +327,11 @@ export default function Dashboard() {
325
327
  // Triage tab appears at all. Recomputed on every data refresh — feature
326
328
  // detection via `tracker.supportsTriage`, never a kind check.
327
329
  const [supportsTriage, setSupportsTriage] = useState(false);
330
+ // Whether `.santree/metadata.json` configures an "investigate triage ticket"
331
+ // skill/prompt (`_triage.skill_name` / `_triage.prompt`). Drives whether the
332
+ // Triage `[i]` action renders enabled (cyan) or greyed. Read fresh on every
333
+ // data refresh so manual edits to metadata.json show up within a cycle.
334
+ const [triageInvestigateConfigured, setTriageInvestigateConfigured] = useState(false);
328
335
  const refreshTimerRef = useRef(null);
329
336
  const repoRootRef = useRef(null);
330
337
  const stateRef = useRef(state);
@@ -440,6 +447,7 @@ export default function Dashboard() {
440
447
  setHasWorkspaceFile(hasWs);
441
448
  const tracker = getIssueTracker(repoRoot);
442
449
  setSupportsTriage(tracker.supportsTriage === true);
450
+ setTriageInvestigateConfigured(isTriageInvestigateConfigured(readTriageInvestigateConfig(repoRoot)));
443
451
  dispatch({ type: "SET_DATA", ...data });
444
452
  dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
445
453
  // Triage on-call rotations (Linear). Best-effort, non-blocking — never
@@ -1042,7 +1050,9 @@ export default function Dashboard() {
1042
1050
  ? `cd ${shellEscape(sessionCwd)} && ${bin} --resume ${sessionId}`
1043
1051
  : null;
1044
1052
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
1045
- const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
1053
+ const workCmd = mode === "plan"
1054
+ ? `santree worktree work --plan${contextArg}`
1055
+ : `santree worktree work${contextArg}`;
1046
1056
  const cmd = resumeCmd ?? workCmd;
1047
1057
  const mux = getMultiplexer();
1048
1058
  const selected = await mux.selectWindow(windowName);
@@ -1084,7 +1094,7 @@ export default function Dashboard() {
1084
1094
  });
1085
1095
  }
1086
1096
  }
1087
- // Delayed refresh to pick up session ID created by `st worktree work`
1097
+ // Delayed refresh to pick up session ID created by `santree worktree work`
1088
1098
  setTimeout(() => refresh(), 3000);
1089
1099
  }, [refresh]);
1090
1100
  const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
@@ -1093,8 +1103,8 @@ export default function Dashboard() {
1093
1103
  const windowName = ticketId;
1094
1104
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
1095
1105
  const workCmd = mode === "plan"
1096
- ? `st worktree work --plan${contextArg}`
1097
- : `st worktree work${contextArg}`;
1106
+ ? `santree worktree work --plan${contextArg}`
1107
+ : `santree worktree work${contextArg}`;
1098
1108
  const created = await mux.createWindow({
1099
1109
  name: windowName,
1100
1110
  cwd: worktreePath,
@@ -1116,10 +1126,10 @@ export default function Dashboard() {
1116
1126
  }
1117
1127
  else {
1118
1128
  leaveAltScreen();
1119
- console.log(`SANTREE_CD:${worktreePath}`);
1120
- console.log(`SANTREE_WORK:${mode}`);
1121
- if (contextFile)
1122
- console.log(`SANTREE_WORK_CONTEXT:${contextFile}`);
1129
+ printCdHint({
1130
+ path: worktreePath,
1131
+ work: { mode, contextFile: contextFile || undefined },
1132
+ });
1123
1133
  exit();
1124
1134
  }
1125
1135
  }, [exit, refresh]);
@@ -1270,10 +1280,10 @@ export default function Dashboard() {
1270
1280
  }
1271
1281
  else {
1272
1282
  leaveAltScreen();
1273
- console.log(`SANTREE_CD:${di.worktree.path}`);
1274
- console.log(`SANTREE_WORK:${mode}`);
1275
- if (contextFile)
1276
- console.log(`SANTREE_WORK_CONTEXT:${contextFile}`);
1283
+ printCdHint({
1284
+ path: di.worktree.path,
1285
+ work: { mode, contextFile: contextFile || undefined },
1286
+ });
1277
1287
  exit();
1278
1288
  }
1279
1289
  }
@@ -2565,7 +2575,7 @@ export default function Dashboard() {
2565
2575
  const created = await mux.createWindow({
2566
2576
  name: windowName,
2567
2577
  cwd,
2568
- command: "st pr review",
2578
+ command: "santree pr review",
2569
2579
  });
2570
2580
  dispatch({
2571
2581
  type: "SET_ACTION_MESSAGE",
@@ -2577,7 +2587,7 @@ export default function Dashboard() {
2577
2587
  }
2578
2588
  else {
2579
2589
  leaveAltScreen();
2580
- console.log(`SANTREE_CD:${ri.worktree.path}`);
2590
+ printCdHint({ path: ri.worktree.path });
2581
2591
  exit();
2582
2592
  }
2583
2593
  return;
@@ -2658,6 +2668,43 @@ export default function Dashboard() {
2658
2668
  dispatch({ type: "TRIAGE_ASK_OPEN", ticketId: di.issue.identifier });
2659
2669
  return;
2660
2670
  }
2671
+ // Investigate — hand the ticket to a user-configured skill/prompt in a
2672
+ // new multiplexer window. Config lives in `.santree/metadata.json`
2673
+ // (`_triage.skill_name` or `_triage.prompt`); read fresh so manual
2674
+ // edits take effect without waiting for a refresh.
2675
+ if (input === "i") {
2676
+ const root = repoRootRef.current;
2677
+ if (!root)
2678
+ return;
2679
+ const prompt = buildInvestigatePrompt(readTriageInvestigateConfig(root), di.issue.identifier);
2680
+ if (!prompt) {
2681
+ dispatch({
2682
+ type: "SET_ACTION_MESSAGE",
2683
+ message: "Set _triage.skill_name (or _triage.prompt) in .santree/metadata.json to enable investigate",
2684
+ });
2685
+ return;
2686
+ }
2687
+ const mux = getMultiplexer();
2688
+ if (!mux.isActive()) {
2689
+ dispatch({
2690
+ type: "SET_ACTION_MESSAGE",
2691
+ message: "Investigate needs a multiplexer (tmux or cmux)",
2692
+ });
2693
+ return;
2694
+ }
2695
+ const command = buildInvestigateCommand(resolveClaudeBinary() ?? "claude", prompt);
2696
+ const windowName = `investigate-${di.issue.identifier}`;
2697
+ void (async () => {
2698
+ const created = await mux.createWindow({ name: windowName, cwd: root, command });
2699
+ dispatch({
2700
+ type: "SET_ACTION_MESSAGE",
2701
+ message: created.ok
2702
+ ? "Launched investigation in new window"
2703
+ : `Failed to launch investigation${created.message ? `: ${created.message}` : ""}`,
2704
+ });
2705
+ })();
2706
+ return;
2707
+ }
2661
2708
  if (input === "o") {
2662
2709
  if (!di.issue.url) {
2663
2710
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
@@ -2805,7 +2852,7 @@ export default function Dashboard() {
2805
2852
  const selected = await mux.selectWindow(windowName);
2806
2853
  if (selected.ok)
2807
2854
  return;
2808
- const cmd = resumeCmd ?? "st worktree work";
2855
+ const cmd = resumeCmd ?? "santree worktree work";
2809
2856
  const created = await mux.createWindow({
2810
2857
  name: windowName,
2811
2858
  cwd: worktreePath,
@@ -2821,7 +2868,7 @@ export default function Dashboard() {
2821
2868
  }
2822
2869
  else {
2823
2870
  leaveAltScreen();
2824
- console.log(`SANTREE_CD:${di.worktree.path}`);
2871
+ printCdHint({ path: di.worktree.path });
2825
2872
  exit();
2826
2873
  }
2827
2874
  return;
@@ -2880,7 +2927,7 @@ export default function Dashboard() {
2880
2927
  const created = await mux.createWindow({
2881
2928
  name: windowName,
2882
2929
  cwd,
2883
- command: "st pr review",
2930
+ command: "santree pr review",
2884
2931
  });
2885
2932
  dispatch({
2886
2933
  type: "SET_ACTION_MESSAGE",
@@ -2892,7 +2939,7 @@ export default function Dashboard() {
2892
2939
  }
2893
2940
  else {
2894
2941
  leaveAltScreen();
2895
- console.log(`SANTREE_CD:${di.worktree.path}`);
2942
+ printCdHint({ path: di.worktree.path });
2896
2943
  exit();
2897
2944
  }
2898
2945
  return;
@@ -2949,7 +2996,7 @@ export default function Dashboard() {
2949
2996
  const created = await mux.createWindow({
2950
2997
  name: windowName,
2951
2998
  cwd,
2952
- command: "st pr fix",
2999
+ command: "santree pr fix",
2953
3000
  });
2954
3001
  dispatch({
2955
3002
  type: "SET_ACTION_MESSAGE",
@@ -2961,7 +3008,7 @@ export default function Dashboard() {
2961
3008
  }
2962
3009
  else {
2963
3010
  leaveAltScreen();
2964
- console.log(`SANTREE_CD:${di.worktree.path}`);
3011
+ printCdHint({ path: di.worktree.path });
2965
3012
  exit();
2966
3013
  }
2967
3014
  return;
@@ -3008,7 +3055,7 @@ export default function Dashboard() {
3008
3055
  });
3009
3056
  // ── Render ─────────────────────────────────────────────────────────
3010
3057
  if (state.loading) {
3011
- return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: _jsx(SquirrelLoader, { text: "Loading dashboard..." }) }) }));
3058
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: _jsx(SquirrelLoader, { text: "Loading dashboard...", version: version }) }) }));
3012
3059
  }
3013
3060
  if (state.error) {
3014
3061
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
@@ -3108,14 +3155,14 @@ export default function Dashboard() {
3108
3155
  ? state.treeDetailScrollOffset
3109
3156
  : state.activeTab === "triage"
3110
3157
  ? state.triageDetailScrollOffset
3111
- : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true }) })] })) })] })] }));
3158
+ : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, prSyncing: !!selectedIssue && !!state.pendingPrs[selectedIssue.issue.identifier], triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true, triageInvestigateConfigured: triageInvestigateConfigured }) })] })) })] })] }));
3112
3159
  }
3113
3160
  /**
3114
3161
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
3115
3162
  * lifted out of the detail panels so they sit on the same row as the global
3116
3163
  * command bar. Empty when nothing is selected.
3117
3164
  */
3118
- function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, }) {
3165
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, triageInvestigateConfigured, }) {
3119
3166
  // During the diff overlay, none of the per-issue actions apply (View diff
3120
3167
  // is what got us here, Commit/PR/etc. need the detail panel context). Keep
3121
3168
  // the row blank so the diff-specific CommandBar reads cleanly.
@@ -3126,7 +3173,11 @@ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerN
3126
3173
  ? buildReviewActions(selectedReview)
3127
3174
  : []
3128
3175
  : selectedIssue
3129
- ? buildIssueActions(selectedIssue, trackerName, { tab: activeTab, canMutate })
3176
+ ? buildIssueActions(selectedIssue, trackerName, {
3177
+ tab: activeTab,
3178
+ canMutate,
3179
+ triageInvestigateConfigured,
3180
+ })
3130
3181
  : [];
3131
3182
  if (items.length === 0)
3132
3183
  return _jsx(Text, { children: " " });
@@ -13,6 +13,7 @@ import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.j
13
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
+ import { isStatuslineConfigured, getStatuslineCommand, isRemoteControlEnabled, missingSessionSignalHooks, } from "../lib/claude-config.js";
16
17
  import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, SANTREE_PACKAGE, getLatestVersionFor, isUpdateAvailable, detectPackageManager, getInstallCommandFor, } from "../lib/version.js";
17
18
  const execAsync = promisify(exec);
18
19
  export const description = "Check system requirements and integrations";
@@ -214,16 +215,6 @@ async function checkTrackerAuth() {
214
215
  hint: status.hint,
215
216
  };
216
217
  }
217
- /**
218
- * Checks if the shell integration is set up by looking for the
219
- * SANTREE_SHELL_INTEGRATION environment variable exported by the shell scripts.
220
- */
221
- function checkShellIntegration() {
222
- const shell = process.env.SHELL || "";
223
- const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : null;
224
- const configured = process.env.SANTREE_SHELL_INTEGRATION === "1";
225
- return { configured, shell: shellName };
226
- }
227
218
  /**
228
219
  * Checks if Claude Code Remote Control is enabled for all sessions.
229
220
  * Remote Control lets you continue local sessions from any device.
@@ -233,23 +224,12 @@ function checkShellIntegration() {
233
224
  * See: https://code.claude.com/docs/en/settings#settings-files
234
225
  */
235
226
  function checkRemoteControl() {
236
- const home = process.env.HOME || "";
237
- const configPath = path.join(home, ".claude.json");
238
- try {
239
- if (fs.existsSync(configPath)) {
240
- const content = fs.readFileSync(configPath, "utf-8");
241
- const config = JSON.parse(content);
242
- if (config.remoteControlAtStartup === true) {
243
- return { enabled: true };
244
- }
245
- }
246
- }
247
- catch {
248
- // JSON parse error or file read error
227
+ if (isRemoteControlEnabled()) {
228
+ return { enabled: true };
249
229
  }
250
230
  return {
251
231
  enabled: false,
252
- hint: 'Run /config in Claude Code and enable "Enable Remote Control for all sessions"',
232
+ hint: 'Run `santree setup` (or /config in Claude Code "Enable Remote Control for all sessions")',
253
233
  };
254
234
  }
255
235
  /**
@@ -257,30 +237,12 @@ function checkRemoteControl() {
257
237
  * If ~/.claude/settings.json has statusLine pointing to santree
258
238
  */
259
239
  async function checkStatusline() {
260
- const home = process.env.HOME || "";
261
- const claudeSettingsPath = path.join(home, ".claude", "settings.json");
262
- let claudeSettingsConfigured = false;
263
- let currentCommand;
264
- try {
265
- if (fs.existsSync(claudeSettingsPath)) {
266
- const content = fs.readFileSync(claudeSettingsPath, "utf-8");
267
- const settings = JSON.parse(content);
268
- if (settings.statusLine?.command) {
269
- currentCommand = String(settings.statusLine.command);
270
- // Check if it points to santree statusline
271
- claudeSettingsConfigured =
272
- currentCommand.includes("santree statusline") ||
273
- currentCommand.includes("santree helpers statusline");
274
- }
275
- }
276
- }
277
- catch {
278
- // JSON parse error or file read error
279
- }
240
+ const claudeSettingsConfigured = isStatuslineConfigured();
241
+ const currentCommand = getStatuslineCommand();
280
242
  let hint;
281
243
  if (!claudeSettingsConfigured) {
282
244
  hint =
283
- 'Add to ~/.claude/settings.json: "statusLine": { "type": "command", "command": "santree helpers statusline" }';
245
+ 'Run `santree setup`, or add to ~/.claude/settings.json: "statusLine": { "type": "command", "command": "santree helpers statusline" }';
284
246
  }
285
247
  return {
286
248
  claudeSettingsConfigured,
@@ -294,44 +256,14 @@ async function checkStatusline() {
294
256
  * that run "santree helpers session-signal".
295
257
  */
296
258
  function checkSessionSignalHooks() {
297
- const home = process.env.HOME || "";
298
- const claudeSettingsPath = path.join(home, ".claude", "settings.json");
299
- const requiredEvents = ["Notification", "Stop", "UserPromptSubmit", "SessionEnd"];
300
- const missingHooks = [];
301
- try {
302
- if (fs.existsSync(claudeSettingsPath)) {
303
- const content = fs.readFileSync(claudeSettingsPath, "utf-8");
304
- const settings = JSON.parse(content);
305
- const hooks = settings.hooks || {};
306
- for (const event of requiredEvents) {
307
- const eventHooks = hooks[event];
308
- if (!Array.isArray(eventHooks)) {
309
- missingHooks.push(event);
310
- continue;
311
- }
312
- // Check if any hook entry has a nested hook command containing session-signal
313
- const found = eventHooks.some((entry) => {
314
- const innerHooks = entry.hooks || [];
315
- return innerHooks.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
316
- });
317
- if (!found)
318
- missingHooks.push(event);
319
- }
320
- }
321
- else {
322
- missingHooks.push(...requiredEvents);
323
- }
324
- }
325
- catch {
326
- missingHooks.push(...requiredEvents);
327
- }
259
+ const missingHooks = missingSessionSignalHooks();
328
260
  if (missingHooks.length === 0) {
329
261
  return { configured: true, missingHooks: [] };
330
262
  }
331
263
  return {
332
264
  configured: false,
333
265
  missingHooks,
334
- hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
266
+ hint: `Missing: ${missingHooks.join(", ")}. Run: santree setup (or santree helpers session-signal install)`,
335
267
  };
336
268
  }
337
269
  /**
@@ -470,9 +402,6 @@ function TrackerRow({ tracker }) {
470
402
  const isOk = tracker.authenticated && !tracker.hint;
471
403
  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] }) }))] }));
472
404
  }
473
- function ShellRow({ configured, shell }) {
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, ")\""] }) }))] }));
475
- }
476
405
  function StatuslineRow({ status }) {
477
406
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.claudeSettingsConfigured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Claude Statusline" }), _jsx(Text, { dimColor: true, children: " - Custom statusline in Claude Code" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [status.currentCommand ? (_jsxs(Text, { dimColor: true, children: ["Command: ", status.currentCommand] })) : (_jsx(Text, { dimColor: true, children: "Command: not configured" })), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
478
407
  }
@@ -508,7 +437,6 @@ function SantreeSetupRow({ status }) {
508
437
  export default function Doctor() {
509
438
  const [tools, setTools] = useState([]);
510
439
  const [tracker, setTracker] = useState(null);
511
- const [shellStatus, setShellStatus] = useState(null);
512
440
  const [remoteControl, setRemoteControl] = useState(null);
513
441
  const [statusline, setStatusline] = useState(null);
514
442
  const [sessionSignal, setSessionSignal] = useState(null);
@@ -559,7 +487,7 @@ export default function Doctor() {
559
487
  }
560
488
  }
561
489
  }
562
- // Optional: a syntax-highlighting diff pager — used by `st worktree diff`
490
+ // Optional: a syntax-highlighting diff pager — used by `santree worktree diff`
563
491
  // and the dashboard `v` overlay when SANTREE_DIFF_TOOL is set. Any
564
492
  // diff pager works (delta, diff-so-fancy, …); without one set, the
565
493
  // dashboard renders inline with santree's own colorizer and the CLI
@@ -597,7 +525,6 @@ export default function Doctor() {
597
525
  const statuslineResult = await checkStatusline();
598
526
  setTools(results);
599
527
  setTracker(trackerResult);
600
- setShellStatus(checkShellIntegration());
601
528
  setRemoteControl(checkRemoteControl());
602
529
  setStatusline(statuslineResult);
603
530
  setSessionSignal(checkSessionSignalHooks());
@@ -613,6 +540,6 @@ export default function Doctor() {
613
540
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
614
541
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
615
542
  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"] }))] })) })] }));
543
+ const allRequired = requiredMissing.length === 0 && trackerOk;
544
+ 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 }), 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), " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
618
545
  }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Guided setup \u2014 configure editor, diff tool, Claude Code & this repo";
3
+ export declare const options: z.ZodObject<{
4
+ dryRun: z.ZodDefault<z.ZodBoolean>;
5
+ yes: z.ZodDefault<z.ZodBoolean>;
6
+ }, z.core.$strip>;
7
+ type Props = {
8
+ options: z.infer<typeof options>;
9
+ };
10
+ export default function Setup({ options: opts }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};
@@ -0,0 +1,194 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { z } from "zod";
6
+ import { buildContext, buildSteps, } from "../lib/setup/steps.js";
7
+ export const description = "Guided setup — configure editor, diff tool, Claude Code & this repo";
8
+ export const options = z.object({
9
+ dryRun: z.boolean().default(false).describe("Preview what would change without writing anything"),
10
+ yes: z.boolean().default(false).describe("Apply all recommended steps non-interactively"),
11
+ });
12
+ export default function Setup({ options: opts }) {
13
+ const dryRun = opts.dryRun;
14
+ const [phase, setPhase] = useState("detecting");
15
+ const [steps, setSteps] = useState([]);
16
+ const [ctx, setCtx] = useState(null);
17
+ // select phase
18
+ const [cursor, setCursor] = useState(0);
19
+ const [selected, setSelected] = useState(new Set());
20
+ // configure phase
21
+ const [configQueue, setConfigQueue] = useState([]);
22
+ const [configPos, setConfigPos] = useState(0);
23
+ const [optionCursor, setOptionCursor] = useState(0);
24
+ const [choices, setChoices] = useState({});
25
+ // applying phase
26
+ const [applyIdx, setApplyIdx] = useState(0);
27
+ const [applyOrder, setApplyOrder] = useState([]);
28
+ const [applied, setApplied] = useState([]);
29
+ const [running, setRunning] = useState(null);
30
+ // ── Detect ────────────────────────────────────────────────────────────────
31
+ useEffect(() => {
32
+ (async () => {
33
+ await new Promise((r) => setTimeout(r, 80)); // let the spinner paint first
34
+ const c = buildContext(dryRun);
35
+ const all = buildSteps(c).filter((s) => s.detect === "actionable");
36
+ setCtx(c);
37
+ setSteps(all);
38
+ if (all.length === 0) {
39
+ setPhase("done");
40
+ return;
41
+ }
42
+ const rec = new Set();
43
+ all.forEach((s, i) => {
44
+ if (s.recommended)
45
+ rec.add(i);
46
+ });
47
+ setSelected(rec);
48
+ if (opts.yes) {
49
+ beginApply(all, rec, {});
50
+ }
51
+ else {
52
+ setPhase("select");
53
+ }
54
+ })();
55
+ }, []);
56
+ function beginApply(allSteps, sel, picked) {
57
+ const order = [...sel].sort((a, b) => a - b);
58
+ // For steps with options that weren't interactively chosen, default to the
59
+ // first option (used by --yes and single-option steps).
60
+ const merged = { ...picked };
61
+ for (const i of order) {
62
+ const s = allSteps[i];
63
+ if (s.options && s.options.length > 0 && merged[s.id] === undefined) {
64
+ merged[s.id] = s.options[0].value;
65
+ }
66
+ }
67
+ setChoices(merged);
68
+ setApplyOrder(order);
69
+ setApplyIdx(0);
70
+ setApplied([]);
71
+ setPhase("applying");
72
+ }
73
+ // ── Select phase input ──────────────────────────────────────────────────────
74
+ useInput((input, key) => {
75
+ if (key.escape)
76
+ process.exit(0);
77
+ else if (key.upArrow)
78
+ setCursor((c) => Math.max(0, c - 1));
79
+ else if (key.downArrow)
80
+ setCursor((c) => Math.min(steps.length - 1, c + 1));
81
+ else if (input === " ") {
82
+ setSelected((s) => {
83
+ const next = new Set(s);
84
+ if (next.has(cursor))
85
+ next.delete(cursor);
86
+ else
87
+ next.add(cursor);
88
+ return next;
89
+ });
90
+ }
91
+ else if (key.return) {
92
+ // Steps needing an interactive choice (>1 option).
93
+ const queue = [...selected]
94
+ .sort((a, b) => a - b)
95
+ .filter((i) => (steps[i].options?.length ?? 0) > 1);
96
+ if (queue.length > 0) {
97
+ setConfigQueue(queue);
98
+ setConfigPos(0);
99
+ setOptionCursor(0);
100
+ setPhase("configure");
101
+ }
102
+ else {
103
+ beginApply(steps, selected, {});
104
+ }
105
+ }
106
+ }, { isActive: phase === "select" });
107
+ // ── Configure phase input ───────────────────────────────────────────────────
108
+ useInput((_input, key) => {
109
+ if (key.escape) {
110
+ setPhase("select");
111
+ return;
112
+ }
113
+ const stepIdx = configQueue[configPos];
114
+ const step = steps[stepIdx];
115
+ const optCount = step.options.length;
116
+ if (key.upArrow)
117
+ setOptionCursor((c) => Math.max(0, c - 1));
118
+ else if (key.downArrow)
119
+ setOptionCursor((c) => Math.min(optCount - 1, c + 1));
120
+ else if (key.return) {
121
+ const value = step.options[optionCursor].value;
122
+ const nextChoices = { ...choices, [step.id]: value };
123
+ setChoices(nextChoices);
124
+ if (configPos + 1 < configQueue.length) {
125
+ setConfigPos((p) => p + 1);
126
+ setOptionCursor(0);
127
+ }
128
+ else {
129
+ beginApply(steps, selected, nextChoices);
130
+ }
131
+ }
132
+ }, { isActive: phase === "configure" });
133
+ // ── Applying phase ──────────────────────────────────────────────────────────
134
+ useEffect(() => {
135
+ if (phase !== "applying")
136
+ return;
137
+ if (applyIdx >= applyOrder.length) {
138
+ setRunning(null);
139
+ setPhase("done");
140
+ return;
141
+ }
142
+ const step = steps[applyOrder[applyIdx]];
143
+ setRunning(step.title);
144
+ let cancelled = false;
145
+ (async () => {
146
+ await new Promise((r) => setTimeout(r, 60)); // paint "running" frame
147
+ let result;
148
+ try {
149
+ result = await step.apply(choices[step.id]);
150
+ }
151
+ catch (e) {
152
+ result = { ok: false, message: e instanceof Error ? e.message : String(e) };
153
+ }
154
+ if (cancelled)
155
+ return;
156
+ setApplied((prev) => [...prev, { step, result }]);
157
+ setApplyIdx((i) => i + 1);
158
+ })();
159
+ return () => {
160
+ cancelled = true;
161
+ };
162
+ }, [phase, applyIdx]);
163
+ // ── Exit ────────────────────────────────────────────────────────────────────
164
+ useEffect(() => {
165
+ if (phase === "done") {
166
+ const t = setTimeout(() => process.exit(0), 120);
167
+ return () => clearTimeout(t);
168
+ }
169
+ }, [phase]);
170
+ // ── Render ──────────────────────────────────────────────────────────────────
171
+ const title = (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree setup" }), dryRun && _jsx(Text, { color: "yellow", children: " (dry run \u2014 nothing will be written)" })] }));
172
+ if (phase === "detecting") {
173
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [title, _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Detecting your setup\u2026" })] })] }));
174
+ }
175
+ if (phase === "done" && steps.length === 0) {
176
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [title, _jsx(Text, { color: "green", children: "\u2713 Everything's already configured \u2014 you're all set." }), !ctx?.repoRoot && (_jsx(Text, { dimColor: true, children: "Tip: run this again inside a git repo to configure repo-specific bits." }))] }));
177
+ }
178
+ if (phase === "select") {
179
+ let lastScope = null;
180
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [title, _jsx(Text, { children: "What should I set up? (Space toggles, Enter applies)" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: steps.map((s, i) => {
181
+ const head = s.scope !== lastScope;
182
+ lastScope = s.scope;
183
+ const box = selected.has(i) ? "[x]" : "[ ]";
184
+ const isCur = i === cursor;
185
+ return (_jsxs(Box, { flexDirection: "column", children: [head && _jsx(Text, { dimColor: true, children: s.scope === "global" ? "Global" : "This repo" }), _jsxs(Text, { children: [_jsxs(Text, { color: isCur ? "cyan" : undefined, bold: isCur, children: [isCur ? "> " : " ", box] }), " ", s.title, s.kind === "spawn" && _jsx(Text, { color: "yellow", children: " \u2937 runs a command" })] }), isCur && _jsx(Text, { dimColor: true, children: " " + s.detail })] }, s.id));
186
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 move \u00B7 Space toggle \u00B7 Enter apply \u00B7 Esc cancel" }) })] }));
187
+ }
188
+ if (phase === "configure") {
189
+ const step = steps[configQueue[configPos]];
190
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [title, _jsx(Text, { bold: true, children: step.title }), _jsx(Text, { children: step.optionPrompt || "Pick one:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: step.options.map((o, i) => (_jsxs(Text, { children: [_jsx(Text, { color: i === optionCursor ? "cyan" : undefined, bold: i === optionCursor, children: i === optionCursor ? "> " : " " }), o.label] }, o.value))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2191/\u2193 select \u00B7 Enter confirm", configQueue.length > 1 ? ` (${configPos + 1}/${configQueue.length})` : ""] }) })] }));
191
+ }
192
+ // applying / done
193
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [title, applied.map(({ step, result }, i) => (_jsxs(Text, { color: result.ok ? "green" : "red", children: [result.ok ? "✓" : "✗", " ", step.title, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", result.message] })] }, i))), phase === "applying" && running && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", running, "\u2026"] })] })), phase === "done" && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "Done. Run `santree doctor` to verify." }), !dryRun && ctx?.shell && (_jsxs(Text, { dimColor: true, children: ["Restart your shell (or `source ", ctx.shell.rcPath, "`) to load the changes."] }))] }))] }));
194
+ }