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.
@@ -0,0 +1,131 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ const BLOCK_START = "# >>> santree >>>";
4
+ const BLOCK_END = "# <<< santree <<<";
5
+ const BLOCK_HEADER = "# Managed by `santree setup` — safe to re-run; edits here may be overwritten.";
6
+ /**
7
+ * Resolve the rc file the managed block should live in, honoring ZDOTDIR (zsh)
8
+ * so users with an XDG-based setup (~/.config/zsh, no ~/.zshrc) get the block in
9
+ * the right place.
10
+ */
11
+ export function resolveShellConfig(shellEnv = process.env.SHELL || "") {
12
+ const home = process.env.HOME || "";
13
+ const base = path.basename(shellEnv);
14
+ if (base.includes("zsh")) {
15
+ const dir = process.env.ZDOTDIR || home;
16
+ return {
17
+ shell: "zsh",
18
+ rcPath: path.join(dir, ".zshrc"),
19
+ scanPaths: [
20
+ path.join(dir, ".zshrc"),
21
+ path.join(dir, ".zshenv"),
22
+ path.join(home, ".zshenv"),
23
+ path.join(home, ".zprofile"),
24
+ ],
25
+ };
26
+ }
27
+ if (base.includes("bash")) {
28
+ return {
29
+ shell: "bash",
30
+ rcPath: path.join(home, ".bashrc"),
31
+ scanPaths: [
32
+ path.join(home, ".bashrc"),
33
+ path.join(home, ".bash_profile"),
34
+ path.join(home, ".profile"),
35
+ ],
36
+ };
37
+ }
38
+ // Fall back to zsh conventions (the default macOS shell).
39
+ const dir = process.env.ZDOTDIR || home;
40
+ return {
41
+ shell: "unknown",
42
+ rcPath: path.join(dir, ".zshrc"),
43
+ scanPaths: [path.join(dir, ".zshrc"), path.join(dir, ".zshenv"), path.join(home, ".zshenv")],
44
+ };
45
+ }
46
+ function tagFor(key) {
47
+ return `# santree:${key}`;
48
+ }
49
+ function readFileSafe(filePath) {
50
+ try {
51
+ return fs.readFileSync(filePath, "utf-8");
52
+ }
53
+ catch {
54
+ return "";
55
+ }
56
+ }
57
+ function splitBlock(content) {
58
+ const startIdx = content.indexOf(BLOCK_START);
59
+ if (startIdx === -1) {
60
+ return { before: content, body: [], after: "", exists: false };
61
+ }
62
+ const endMarkerIdx = content.indexOf(BLOCK_END, startIdx);
63
+ if (endMarkerIdx === -1) {
64
+ // Malformed (start without end) — treat everything from start as the block.
65
+ const before = content.slice(0, startIdx);
66
+ return { before, body: [], after: "", exists: true };
67
+ }
68
+ const before = content.slice(0, startIdx);
69
+ const after = content.slice(endMarkerIdx + BLOCK_END.length);
70
+ const inner = content.slice(startIdx + BLOCK_START.length, endMarkerIdx);
71
+ const body = inner
72
+ .split("\n")
73
+ .map((l) => l.trimEnd())
74
+ .filter((l) => l.length > 0 && l !== BLOCK_HEADER);
75
+ return { before, body, after, exists: true };
76
+ }
77
+ function renderBlock(body) {
78
+ return [BLOCK_START, BLOCK_HEADER, ...body, BLOCK_END].join("\n");
79
+ }
80
+ /**
81
+ * Upsert a single tagged line into the managed block, creating the block (and
82
+ * the rc file) if needed. The line for `key` is replaced if present, else
83
+ * appended. `line` is the raw shell statement WITHOUT the trailing tag comment.
84
+ */
85
+ export function upsertManagedLine(rcPath, key, line) {
86
+ const content = readFileSafe(rcPath);
87
+ const { before, body, after, exists } = splitBlock(content);
88
+ const tag = tagFor(key);
89
+ const tagged = `${line} ${tag}`;
90
+ const idx = body.findIndex((l) => l.trimEnd().endsWith(tag));
91
+ const nextBody = idx === -1 ? [...body, tagged] : body.map((l, i) => (i === idx ? tagged : l));
92
+ let output;
93
+ if (exists) {
94
+ output = before + renderBlock(nextBody) + after;
95
+ }
96
+ else {
97
+ // Append a fresh block, keeping a single trailing newline.
98
+ const trimmed = content.replace(/\n+$/, "");
99
+ const prefix = trimmed.length > 0 ? trimmed + "\n\n" : "";
100
+ output = prefix + renderBlock(nextBody) + "\n";
101
+ }
102
+ const dir = path.dirname(rcPath);
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ fs.writeFileSync(rcPath, output.endsWith("\n") ? output : output + "\n");
105
+ }
106
+ /**
107
+ * Detect whether `name` is already exported by the user's shell config (any of
108
+ * `scanPaths`) or the current environment. Used so setup doesn't clobber an
109
+ * export the user already maintains by hand (e.g. SANTREE_EDITOR in .zshenv).
110
+ * Ignores santree's own managed block so a prior setup run isn't mistaken for a
111
+ * hand-maintained export.
112
+ */
113
+ export function isEnvVarSet(name, cfg) {
114
+ if (process.env[name])
115
+ return true;
116
+ const re = new RegExp(`^\\s*export\\s+${name}=`, "m");
117
+ for (const p of cfg.scanPaths) {
118
+ const content = readFileSafe(p);
119
+ if (!content)
120
+ continue;
121
+ // Strip our own managed block before scanning hand-maintained exports.
122
+ const { before, after } = splitBlock(content);
123
+ const userContent = p === cfg.rcPath ? before + after : content;
124
+ if (re.test(userContent))
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ export function envKey(name) {
130
+ return `env:${name}`;
131
+ }
@@ -0,0 +1,35 @@
1
+ import { type ShellConfig } from "./shell-config.js";
2
+ export type DetectState = "ok" | "actionable" | "unavailable";
3
+ export interface StepOption {
4
+ value: string;
5
+ label: string;
6
+ }
7
+ export interface StepResult {
8
+ ok: boolean;
9
+ message: string;
10
+ }
11
+ export interface SetupStep {
12
+ id: string;
13
+ title: string;
14
+ detail: string;
15
+ scope: "global" | "repo";
16
+ /** "spawn" steps take over the terminal (installs/logins); UI flags them. */
17
+ kind: "file" | "spawn";
18
+ recommended: boolean;
19
+ detect: DetectState;
20
+ /** Pick-one sub-prompt collected before apply; absent = no choice needed. */
21
+ options?: StepOption[];
22
+ optionPrompt?: string;
23
+ apply: (choice: string | undefined) => StepResult | Promise<StepResult>;
24
+ }
25
+ export interface SetupContext {
26
+ repoRoot: string | null;
27
+ shell: ShellConfig;
28
+ dryRun: boolean;
29
+ }
30
+ export declare function buildContext(dryRun: boolean): SetupContext;
31
+ /**
32
+ * Build the full step catalog with `detect` + `options` resolved against the
33
+ * current machine. The wizard filters to `actionable` steps for the checklist.
34
+ */
35
+ export declare function buildSteps(ctx: SetupContext): SetupStep[];
@@ -0,0 +1,328 @@
1
+ import * as fs from "fs";
2
+ import { run } from "../exec.js";
3
+ import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../git.js";
4
+ import { isRepoTrackerConfigured } from "../trackers/index.js";
5
+ import { getMultiplexer } from "../multiplexer/index.js";
6
+ import { resolveClaudeBinary } from "../ai.js";
7
+ import { CLAUDE_CODE_PACKAGE, detectPackageManager, getInstallCommandFor } from "../version.js";
8
+ import { installHooks as installSessionSignalHooks } from "../session-signal.js";
9
+ import { isStatuslineConfigured, configureStatusline, isRemoteControlEnabled, enableRemoteControl, isSessionSignalConfigured, } from "../claude-config.js";
10
+ import { resolveShellConfig, upsertManagedLine, isEnvVarSet, envKey, } from "./shell-config.js";
11
+ import { detectDiffPagers, detectEditors, getInstaller, which } from "./tools.js";
12
+ import { missingIgnoreEntries, addIgnoreEntries, SANTREE_IGNORE_ENTRIES, } from "./gitignore.js";
13
+ import { spawnTTY, santreeSelfArgv } from "./apply.js";
14
+ export function buildContext(dryRun) {
15
+ return {
16
+ repoRoot: findMainRepoRoot(),
17
+ shell: resolveShellConfig(),
18
+ dryRun,
19
+ };
20
+ }
21
+ /**
22
+ * Build the full step catalog with `detect` + `options` resolved against the
23
+ * current machine. The wizard filters to `actionable` steps for the checklist.
24
+ */
25
+ export function buildSteps(ctx) {
26
+ const steps = [];
27
+ const { shell, repoRoot, dryRun } = ctx;
28
+ const installer = getInstaller();
29
+ // ── Editor (SANTREE_EDITOR) ─────────────────────────────────────────────────
30
+ {
31
+ const editors = detectEditors();
32
+ const set = isEnvVarSet("SANTREE_EDITOR", shell);
33
+ const detect = set ? "ok" : editors.length > 0 ? "actionable" : "unavailable";
34
+ steps.push({
35
+ id: "editor",
36
+ title: "Default editor (SANTREE_EDITOR)",
37
+ detail: "Editor opened by the dashboard's [e] action",
38
+ scope: "global",
39
+ kind: "file",
40
+ recommended: true,
41
+ detect,
42
+ options: editors.map((e) => ({ value: e.command, label: e.command })),
43
+ optionPrompt: "Pick your editor:",
44
+ apply: (choice) => {
45
+ const editor = choice || editors[0]?.command || "vim";
46
+ if (dryRun)
47
+ return { ok: true, message: `Would set SANTREE_EDITOR=${editor}` };
48
+ upsertManagedLine(shell.rcPath, envKey("SANTREE_EDITOR"), `export SANTREE_EDITOR="${editor}"`);
49
+ return { ok: true, message: `Set SANTREE_EDITOR=${editor} in ${shell.rcPath}` };
50
+ },
51
+ });
52
+ }
53
+ // ── Diff tool (SANTREE_DIFF_TOOL) ───────────────────────────────────────────
54
+ {
55
+ const pagers = detectDiffPagers();
56
+ const hasDelta = pagers.some((p) => p.command === "delta");
57
+ const options = pagers.map((p) => ({ value: p.command, label: p.command }));
58
+ if (installer && !hasDelta) {
59
+ options.push({
60
+ value: "install:delta",
61
+ label: `git-delta — install via ${installer.name}`,
62
+ });
63
+ }
64
+ const set = isEnvVarSet("SANTREE_DIFF_TOOL", shell);
65
+ const detect = set ? "ok" : options.length > 0 ? "actionable" : "unavailable";
66
+ steps.push({
67
+ id: "diff-tool",
68
+ title: "Diff tool (SANTREE_DIFF_TOOL)",
69
+ detail: "Pretty diffs in `worktree diff` and the dashboard [v] overlay",
70
+ scope: "global",
71
+ kind: installer && !hasDelta ? "spawn" : "file",
72
+ recommended: true,
73
+ detect,
74
+ options,
75
+ optionPrompt: "Pick a diff pager:",
76
+ apply: (choice) => {
77
+ const pick = choice || options[0]?.value;
78
+ if (!pick)
79
+ return { ok: false, message: "No diff pager available" };
80
+ let tool = pick;
81
+ if (pick.startsWith("install:")) {
82
+ tool = pick.slice("install:".length);
83
+ if (dryRun) {
84
+ return {
85
+ ok: true,
86
+ message: `Would ${installer.installArgv("git-delta").join(" ")} then set SANTREE_DIFF_TOOL=${tool}`,
87
+ };
88
+ }
89
+ const argv = installer.installArgv("git-delta");
90
+ const code = spawnTTY(argv[0], argv.slice(1));
91
+ if (code !== 0)
92
+ return { ok: false, message: "git-delta install failed — SANTREE_DIFF_TOOL not set" };
93
+ }
94
+ if (dryRun)
95
+ return { ok: true, message: `Would set SANTREE_DIFF_TOOL=${tool}` };
96
+ upsertManagedLine(shell.rcPath, envKey("SANTREE_DIFF_TOOL"), `export SANTREE_DIFF_TOOL="${tool}"`);
97
+ return { ok: true, message: `Set SANTREE_DIFF_TOOL=${tool} in ${shell.rcPath}` };
98
+ },
99
+ });
100
+ }
101
+ // ── Statusline ──────────────────────────────────────────────────────────────
102
+ steps.push({
103
+ id: "statusline",
104
+ title: "Claude Code statusline",
105
+ detail: "Worktree-aware statusline (branch, context usage, session state)",
106
+ scope: "global",
107
+ kind: "file",
108
+ recommended: true,
109
+ detect: isStatuslineConfigured() ? "ok" : "actionable",
110
+ apply: () => {
111
+ if (dryRun)
112
+ return { ok: true, message: "Would configure statusLine in ~/.claude/settings.json" };
113
+ const p = configureStatusline();
114
+ return { ok: true, message: `Configured statusline in ${p}` };
115
+ },
116
+ });
117
+ // ── Session-signal hooks ────────────────────────────────────────────────────
118
+ steps.push({
119
+ id: "session-signal",
120
+ title: "Session-state signal hooks",
121
+ detail: "Surface Claude session state (waiting/active/idle) in the dashboard",
122
+ scope: "global",
123
+ kind: "file",
124
+ recommended: true,
125
+ detect: isSessionSignalConfigured() ? "ok" : "actionable",
126
+ apply: () => {
127
+ if (dryRun)
128
+ return {
129
+ ok: true,
130
+ message: "Would install session-signal hooks in ~/.claude/settings.json",
131
+ };
132
+ const p = installSessionSignalHooks();
133
+ return { ok: true, message: `Installed session-signal hooks in ${p}` };
134
+ },
135
+ });
136
+ // ── Remote control ──────────────────────────────────────────────────────────
137
+ steps.push({
138
+ id: "remote-control",
139
+ title: "Claude Code remote control",
140
+ detail: "Enable remote control at startup (drive sessions from the dashboard)",
141
+ scope: "global",
142
+ kind: "file",
143
+ recommended: false,
144
+ detect: isRemoteControlEnabled() ? "ok" : "actionable",
145
+ apply: () => {
146
+ if (dryRun)
147
+ return { ok: true, message: "Would set remoteControlAtStartup in ~/.claude.json" };
148
+ const p = enableRemoteControl();
149
+ return { ok: true, message: `Enabled remote control in ${p}` };
150
+ },
151
+ });
152
+ // ── GitHub CLI install + auth ───────────────────────────────────────────────
153
+ {
154
+ const ghInstalled = !!which("gh");
155
+ // `gh auth status` prints to stderr — redirect so it doesn't leak into the wizard.
156
+ const ghAuthed = ghInstalled && run("gh auth status >/dev/null 2>&1") !== null;
157
+ const detect = ghAuthed
158
+ ? "ok"
159
+ : ghInstalled || installer
160
+ ? "actionable"
161
+ : "unavailable";
162
+ steps.push({
163
+ id: "gh",
164
+ title: "GitHub CLI (gh) auth",
165
+ detail: "Required for PR create / review / checks",
166
+ scope: "global",
167
+ kind: "spawn",
168
+ recommended: true,
169
+ detect,
170
+ apply: () => {
171
+ if (dryRun) {
172
+ const parts = !ghInstalled && installer ? `${installer.installArgv("gh").join(" ")} then ` : "";
173
+ return { ok: true, message: `Would ${parts}gh auth login` };
174
+ }
175
+ if (!ghInstalled) {
176
+ if (!installer)
177
+ return { ok: false, message: "Install gh manually: see https://cli.github.com" };
178
+ const argv = installer.installArgv("gh");
179
+ const code = spawnTTY(argv[0], argv.slice(1));
180
+ if (code !== 0)
181
+ return { ok: false, message: "gh install failed" };
182
+ }
183
+ const code = spawnTTY("gh", ["auth", "login"]);
184
+ return code === 0
185
+ ? { ok: true, message: "gh authenticated" }
186
+ : { ok: false, message: "gh auth login did not complete" };
187
+ },
188
+ });
189
+ }
190
+ // ── Claude Code CLI ─────────────────────────────────────────────────────────
191
+ {
192
+ const installed = !!resolveClaudeBinary();
193
+ steps.push({
194
+ id: "claude",
195
+ title: "Claude Code CLI",
196
+ detail: "Powers `worktree work`, `pr fix`, `pr review`",
197
+ scope: "global",
198
+ kind: "spawn",
199
+ recommended: true,
200
+ detect: installed ? "ok" : "actionable",
201
+ apply: () => {
202
+ const cmd = getInstallCommandFor(detectPackageManager(), CLAUDE_CODE_PACKAGE);
203
+ if (dryRun)
204
+ return { ok: true, message: `Would run: ${cmd.display}` };
205
+ const code = spawnTTY(cmd.cmd, cmd.args);
206
+ return code === 0
207
+ ? { ok: true, message: "Claude Code CLI installed" }
208
+ : { ok: false, message: `Install failed — run manually: ${cmd.display}` };
209
+ },
210
+ });
211
+ }
212
+ // ── tmux (multiplexer) ──────────────────────────────────────────────────────
213
+ {
214
+ const active = getMultiplexer().kind !== "none";
215
+ const detect = active ? "ok" : installer ? "actionable" : "unavailable";
216
+ steps.push({
217
+ id: "tmux",
218
+ title: "tmux (terminal multiplexer)",
219
+ detail: "Enables new-window flows: work / fix / review / investigate",
220
+ scope: "global",
221
+ kind: "spawn",
222
+ recommended: false,
223
+ detect,
224
+ apply: () => {
225
+ if (!installer)
226
+ return { ok: false, message: "Install tmux manually: brew install tmux" };
227
+ if (dryRun)
228
+ return { ok: true, message: `Would ${installer.installArgv("tmux").join(" ")}` };
229
+ const argv = installer.installArgv("tmux");
230
+ const code = spawnTTY(argv[0], argv.slice(1));
231
+ return code === 0
232
+ ? { ok: true, message: "tmux installed (start a tmux session to use new-window flows)" }
233
+ : { ok: false, message: "tmux install failed" };
234
+ },
235
+ });
236
+ }
237
+ // ── Repo-scoped steps ───────────────────────────────────────────────────────
238
+ if (repoRoot) {
239
+ const santreeDir = getSantreeDir(repoRoot);
240
+ const initSh = getInitScriptPath(repoRoot);
241
+ // .santree scaffold
242
+ {
243
+ const folderOk = fs.existsSync(santreeDir);
244
+ const initOk = fs.existsSync(initSh);
245
+ let execOk = false;
246
+ if (initOk) {
247
+ try {
248
+ fs.accessSync(initSh, fs.constants.X_OK);
249
+ execOk = true;
250
+ }
251
+ catch { }
252
+ }
253
+ steps.push({
254
+ id: "scaffold",
255
+ title: ".santree scaffold",
256
+ detail: "Create .santree/ and an executable init.sh (runs on worktree create)",
257
+ scope: "repo",
258
+ kind: "file",
259
+ recommended: true,
260
+ detect: folderOk && initOk && execOk ? "ok" : "actionable",
261
+ apply: () => {
262
+ if (dryRun)
263
+ return { ok: true, message: `Would create ${santreeDir} and an executable init.sh` };
264
+ fs.mkdirSync(santreeDir, { recursive: true });
265
+ if (!fs.existsSync(initSh)) {
266
+ fs.writeFileSync(initSh, "#!/usr/bin/env bash\n# Runs after `santree worktree create`. Add setup steps here.\n");
267
+ }
268
+ fs.chmodSync(initSh, 0o755);
269
+ return { ok: true, message: `Scaffolded ${santreeDir}` };
270
+ },
271
+ });
272
+ }
273
+ // Ignore santree files
274
+ {
275
+ const missing = missingIgnoreEntries(repoRoot);
276
+ steps.push({
277
+ id: "gitignore",
278
+ title: "Ignore santree's machine files",
279
+ detail: `Ignore ${SANTREE_IGNORE_ENTRIES.join(", ")} (keeps .santree/issues/ tracked)`,
280
+ scope: "repo",
281
+ kind: "file",
282
+ recommended: true,
283
+ detect: missing.length === 0 ? "ok" : "actionable",
284
+ options: [
285
+ { value: "gitignore", label: ".gitignore — shared with your team" },
286
+ { value: "exclude", label: ".git/info/exclude — local to your clone" },
287
+ ],
288
+ optionPrompt: "Where should the ignore rules go?",
289
+ apply: (choice) => {
290
+ const target = (choice === "exclude" ? "exclude" : "gitignore");
291
+ const where = target === "exclude" ? ".git/info/exclude" : ".gitignore";
292
+ if (dryRun)
293
+ return {
294
+ ok: true,
295
+ message: `Would add ${missing.length} entr${missing.length === 1 ? "y" : "ies"} to ${where}`,
296
+ };
297
+ const added = addIgnoreEntries(repoRoot, target);
298
+ return {
299
+ ok: true,
300
+ message: `Added ${added.length} entr${added.length === 1 ? "y" : "ies"} to ${where}`,
301
+ };
302
+ },
303
+ });
304
+ }
305
+ // Issue tracker
306
+ {
307
+ steps.push({
308
+ id: "tracker",
309
+ title: "Issue tracker",
310
+ detail: "Pick + authenticate Linear / GitHub / Local for this repo",
311
+ scope: "repo",
312
+ kind: "spawn",
313
+ recommended: true,
314
+ detect: isRepoTrackerConfigured(repoRoot) ? "ok" : "actionable",
315
+ apply: () => {
316
+ if (dryRun)
317
+ return { ok: true, message: "Would run: santree issue setup" };
318
+ const { cmd, args } = santreeSelfArgv(["issue", "setup"]);
319
+ const code = spawnTTY(cmd, args, { cwd: repoRoot });
320
+ return code === 0
321
+ ? { ok: true, message: "Issue tracker configured" }
322
+ : { ok: false, message: "Tracker setup did not complete" };
323
+ },
324
+ });
325
+ }
326
+ }
327
+ return steps;
328
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tool detection + installation for `santree setup`.
3
+ *
4
+ * macOS (Homebrew) is the only auto-install path today. The `PlatformInstaller`
5
+ * seam keeps the door open for apt/dnf/etc. without touching call sites — add a
6
+ * resolver branch in `getInstaller()` and the steps keep working.
7
+ */
8
+ export declare function isMacOS(): boolean;
9
+ /** Resolve a command on PATH, or null if absent. */
10
+ export declare function which(cmd: string): string | null;
11
+ export interface DetectedTool {
12
+ command: string;
13
+ path: string;
14
+ }
15
+ export declare function detectDiffPagers(): DetectedTool[];
16
+ export declare function detectEditors(): DetectedTool[];
17
+ export interface PlatformInstaller {
18
+ /** Human label, e.g. "Homebrew". */
19
+ name: string;
20
+ /** argv for installing `pkg` (cmd + args), suitable for a TTY spawn. */
21
+ installArgv(pkg: string): string[];
22
+ }
23
+ export declare function getInstaller(): PlatformInstaller | null;
@@ -0,0 +1,47 @@
1
+ import * as os from "os";
2
+ import { run } from "../exec.js";
3
+ /**
4
+ * Tool detection + installation for `santree setup`.
5
+ *
6
+ * macOS (Homebrew) is the only auto-install path today. The `PlatformInstaller`
7
+ * seam keeps the door open for apt/dnf/etc. without touching call sites — add a
8
+ * resolver branch in `getInstaller()` and the steps keep working.
9
+ */
10
+ export function isMacOS() {
11
+ return os.platform() === "darwin";
12
+ }
13
+ /** Resolve a command on PATH, or null if absent. */
14
+ export function which(cmd) {
15
+ return run(`which ${cmd}`);
16
+ }
17
+ /** Known unified-diff pagers, best first. The first one on PATH wins. */
18
+ const DIFF_PAGERS = ["delta", "diff-so-fancy", "diff-highlight", "ydiff", "riff"];
19
+ export function detectDiffPagers() {
20
+ const found = [];
21
+ for (const cmd of DIFF_PAGERS) {
22
+ const p = which(cmd);
23
+ if (p)
24
+ found.push({ command: cmd, path: p });
25
+ }
26
+ return found;
27
+ }
28
+ /** Editors we know how to launch, in rough order of popularity. */
29
+ const KNOWN_EDITORS = ["code", "cursor", "zed", "subl", "windsurf", "nvim", "vim", "nano", "emacs"];
30
+ export function detectEditors() {
31
+ const found = [];
32
+ for (const cmd of KNOWN_EDITORS) {
33
+ const p = which(cmd);
34
+ if (p)
35
+ found.push({ command: cmd, path: p });
36
+ }
37
+ return found;
38
+ }
39
+ export function getInstaller() {
40
+ if (isMacOS() && which("brew")) {
41
+ return {
42
+ name: "Homebrew",
43
+ installArgv: (pkg) => ["brew", "install", pkg],
44
+ };
45
+ }
46
+ return null;
47
+ }
@@ -1,9 +1,11 @@
1
1
  interface Props {
2
2
  text?: string;
3
+ /** Optional version line (e.g. santree's running version), shown under `text`. */
4
+ version?: string;
3
5
  }
4
6
  /** Animated 3D squirrel for loading states. SDF ray-marched at module
5
7
  * load with surface-normal lighting, soft shadows, AO, and a rainbow
6
8
  * Y-gradient. Body spin + tail wag + breathing/sniff/ear-flick layers
7
9
  * combine over independent phases. */
8
- export default function SquirrelLoader({ text }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export default function SquirrelLoader({ text, version }: Props): import("react/jsx-runtime").JSX.Element;
9
11
  export {};
@@ -292,7 +292,7 @@ function colorForCell(y, lit) {
292
292
  * load with surface-normal lighting, soft shadows, AO, and a rainbow
293
293
  * Y-gradient. Body spin + tail wag + breathing/sniff/ear-flick layers
294
294
  * combine over independent phases. */
295
- export default function SquirrelLoader({ text }) {
295
+ export default function SquirrelLoader({ text, version }) {
296
296
  const [frame, setFrame] = useState(0);
297
297
  useEffect(() => {
298
298
  const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_MS);
@@ -318,5 +318,5 @@ export default function SquirrelLoader({ text }) {
318
318
  return spans;
319
319
  });
320
320
  }, [frame]);
321
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [rows.map((spans, i) => (_jsx(Box, { children: _jsx(Text, { children: spans.map((s, j) => (_jsx(Text, { color: s.color, children: s.text }, j))) }) }, i))), text && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }))] }));
321
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [rows.map((spans, i) => (_jsx(Box, { children: _jsx(Text, { children: spans.map((s, j) => (_jsx(Text, { color: s.color, children: s.text }, j))) }) }, i))), text && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) })), version && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["v", version] }) }))] }));
322
322
  }
@@ -0,0 +1,21 @@
1
+ /** Per-repo "investigate triage ticket" config, read from
2
+ * `.santree/metadata.json` (`_triage.skill_name` / `_triage.prompt`).
3
+ * metadata.json is gitignored, so this is per-machine — each dev points it at
4
+ * their own skill or prompt without committing it. */
5
+ export interface TriageInvestigateConfig {
6
+ /** A Claude slash-command/skill name. Invoked as `/<skill_name> <ticketId>`. */
7
+ skillName: string | null;
8
+ /** A free-form prompt template; `{ticket_id}` is substituted with the ticket. */
9
+ prompt: string | null;
10
+ }
11
+ export declare function readTriageInvestigateConfig(repoRoot: string): TriageInvestigateConfig;
12
+ export declare function isTriageInvestigateConfigured(cfg: TriageInvestigateConfig): boolean;
13
+ /** Builds the prompt handed to Claude for investigating a ticket. Skill name
14
+ * wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
15
+ * template with every `{ticket_id}` replaced. Returns null when unconfigured. */
16
+ export declare function buildInvestigatePrompt(cfg: TriageInvestigateConfig, ticketId: string): string | null;
17
+ /** Builds the shell command line launched in the new multiplexer window:
18
+ * `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
19
+ * its send-keys step; this layer just needs the prompt to survive as a single
20
+ * argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
21
+ export declare function buildInvestigateCommand(claudeBin: string, prompt: string): string;
@@ -0,0 +1,32 @@
1
+ import { readAllMetadata } from "./metadata.js";
2
+ export function readTriageInvestigateConfig(repoRoot) {
3
+ const all = readAllMetadata(repoRoot);
4
+ const t = all["_triage"];
5
+ const skillName = typeof t?.skill_name === "string" && t.skill_name.trim() ? t.skill_name.trim() : null;
6
+ const prompt = typeof t?.prompt === "string" && t.prompt.trim() ? t.prompt.trim() : null;
7
+ return { skillName, prompt };
8
+ }
9
+ export function isTriageInvestigateConfigured(cfg) {
10
+ return cfg.skillName !== null || cfg.prompt !== null;
11
+ }
12
+ /** Builds the prompt handed to Claude for investigating a ticket. Skill name
13
+ * wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
14
+ * template with every `{ticket_id}` replaced. Returns null when unconfigured. */
15
+ export function buildInvestigatePrompt(cfg, ticketId) {
16
+ if (cfg.skillName)
17
+ return `/${cfg.skillName} ${ticketId}`;
18
+ if (cfg.prompt)
19
+ return cfg.prompt.replace(/\{ticket_id\}/g, ticketId);
20
+ return null;
21
+ }
22
+ /** POSIX single-quote a shell argument. */
23
+ function shellSingleQuote(s) {
24
+ return `'${s.replace(/'/g, `'\\''`)}'`;
25
+ }
26
+ /** Builds the shell command line launched in the new multiplexer window:
27
+ * `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
28
+ * its send-keys step; this layer just needs the prompt to survive as a single
29
+ * argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
30
+ export function buildInvestigateCommand(claudeBin, prompt) {
31
+ return `${claudeBin} ${shellSingleQuote(prompt)}`;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -40,7 +40,6 @@
40
40
  },
41
41
  "files": [
42
42
  "dist",
43
- "shell",
44
43
  "prompts"
45
44
  ],
46
45
  "dependencies": {