santree 0.7.4 → 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.
@@ -1,23 +1,15 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
2
  import { Text, Box } from "ink";
4
3
  import { z } from "zod";
5
4
  import { getWorktreePath } from "../../lib/git.js";
5
+ import { formatCdCommand } from "../../lib/cd-hint.js";
6
6
  export const description = "Switch to another worktree";
7
7
  export const args = z.tuple([z.string().describe("Branch name to switch to")]);
8
8
  export default function Switch({ args }) {
9
9
  const [branchName] = args;
10
- const hasOutputRef = useRef(false);
11
10
  // Find worktree path synchronously
12
11
  const worktreePath = getWorktreePath(branchName);
13
- // Output SANTREE_CD once (before Ink fully renders)
14
- useEffect(() => {
15
- if (worktreePath && !hasOutputRef.current) {
16
- hasOutputRef.current = true;
17
- process.stdout.write(`SANTREE_CD:${worktreePath}\n`);
18
- }
19
- }, [worktreePath]);
20
12
  const status = worktreePath ? "done" : "error";
21
13
  const error = worktreePath ? null : `Worktree not found for branch: ${branchName}`;
22
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD00 Switch" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "green", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: worktreePath })] }))] }), _jsxs(Box, { marginTop: 1, children: [status === "done" && (_jsx(Text, { color: "green", bold: true, children: "\u2713 Switching to worktree" })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
14
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD00 Switch" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "green", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: worktreePath })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [status === "done" && worktreePath && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "\u2192 Run this to enter the worktree:" }), _jsxs(Text, { color: "cyan", children: [" ", formatCdCommand({ path: worktreePath })] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
23
15
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * santree is a plain binary — a child process can't change its parent shell's
3
+ * working directory. So when a command wants you to land in a worktree, it
4
+ * prints the command for you to run. (Dashboard flows with tmux/cmux open a new
5
+ * window in the right directory instead and never need this.)
6
+ */
7
+ export interface CdHint {
8
+ path: string;
9
+ /** Append a `worktree work` launch after the cd. */
10
+ work?: {
11
+ mode: "plan" | "implement" | string;
12
+ contextFile?: string;
13
+ };
14
+ }
15
+ /** The shell command to enter a worktree (and optionally start work). */
16
+ export declare function formatCdCommand(hint: CdHint): string;
17
+ /** Print the cd hint to stdout (for non-Ink contexts, e.g. after the dashboard exits). */
18
+ export declare function printCdHint(hint: CdHint): void;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * santree is a plain binary — a child process can't change its parent shell's
3
+ * working directory. So when a command wants you to land in a worktree, it
4
+ * prints the command for you to run. (Dashboard flows with tmux/cmux open a new
5
+ * window in the right directory instead and never need this.)
6
+ */
7
+ /** Single-quote a value for safe copy-paste into a POSIX shell. */
8
+ function sq(s) {
9
+ return `'${s.replace(/'/g, `'\\''`)}'`;
10
+ }
11
+ /** The shell command to enter a worktree (and optionally start work). */
12
+ export function formatCdCommand(hint) {
13
+ let cmd = `cd ${sq(hint.path)}`;
14
+ if (hint.work) {
15
+ let work = "santree worktree work";
16
+ if (hint.work.mode === "plan")
17
+ work += " --plan";
18
+ if (hint.work.contextFile)
19
+ work += ` --context-file ${sq(hint.work.contextFile)}`;
20
+ cmd += ` && ${work}`;
21
+ }
22
+ return cmd;
23
+ }
24
+ /** Print the cd hint to stdout (for non-Ink contexts, e.g. after the dashboard exits). */
25
+ export function printCdHint(hint) {
26
+ console.log(`\n→ Run this to enter the worktree:\n ${formatCdCommand(hint)}`);
27
+ }
@@ -0,0 +1,12 @@
1
+ export declare function claudeSettingsPath(): string;
2
+ export declare function claudeConfigPath(): string;
3
+ export declare function readJsonSafe(filePath: string): Record<string, any>;
4
+ export declare function getStatuslineCommand(): string | undefined;
5
+ export declare function isStatuslineConfigured(): boolean;
6
+ export declare function configureStatusline(): string;
7
+ export declare function isRemoteControlEnabled(): boolean;
8
+ export declare function enableRemoteControl(): string;
9
+ export declare const SESSION_SIGNAL_EVENTS: string[];
10
+ /** Returns the session-signal events that are NOT yet wired in settings.json. */
11
+ export declare function missingSessionSignalHooks(): string[];
12
+ export declare function isSessionSignalConfigured(): boolean;
@@ -0,0 +1,86 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ /**
4
+ * Shared read/detect/configure helpers for the two Claude Code config files
5
+ * santree touches:
6
+ * - ~/.claude/settings.json (statusline + hooks)
7
+ * - ~/.claude.json (remote control)
8
+ *
9
+ * Both `santree doctor` (detect) and `santree setup` (configure) go through
10
+ * here so the two commands can never disagree about what "configured" means.
11
+ */
12
+ const STATUSLINE_COMMAND = "santree helpers statusline";
13
+ export function claudeSettingsPath() {
14
+ return path.join(process.env.HOME || "", ".claude", "settings.json");
15
+ }
16
+ export function claudeConfigPath() {
17
+ return path.join(process.env.HOME || "", ".claude.json");
18
+ }
19
+ export function readJsonSafe(filePath) {
20
+ try {
21
+ if (fs.existsSync(filePath)) {
22
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
23
+ }
24
+ }
25
+ catch {
26
+ // Invalid/unreadable — caller decides whether to start fresh.
27
+ }
28
+ return {};
29
+ }
30
+ function writeJson(filePath, data) {
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
33
+ }
34
+ // ── Statusline ───────────────────────────────────────────────────────────────
35
+ export function getStatuslineCommand() {
36
+ const settings = readJsonSafe(claudeSettingsPath());
37
+ const cmd = settings.statusLine?.command;
38
+ return cmd ? String(cmd) : undefined;
39
+ }
40
+ export function isStatuslineConfigured() {
41
+ const cmd = getStatuslineCommand();
42
+ return (!!cmd && (cmd.includes("santree statusline") || cmd.includes("santree helpers statusline")));
43
+ }
44
+ export function configureStatusline() {
45
+ const settingsPath = claudeSettingsPath();
46
+ const settings = readJsonSafe(settingsPath);
47
+ settings.statusLine = { type: "command", command: STATUSLINE_COMMAND };
48
+ writeJson(settingsPath, settings);
49
+ return settingsPath;
50
+ }
51
+ // ── Remote control ────────────────────────────────────────────────────────────
52
+ export function isRemoteControlEnabled() {
53
+ return readJsonSafe(claudeConfigPath()).remoteControlAtStartup === true;
54
+ }
55
+ export function enableRemoteControl() {
56
+ const configPath = claudeConfigPath();
57
+ const config = readJsonSafe(configPath);
58
+ config.remoteControlAtStartup = true;
59
+ writeJson(configPath, config);
60
+ return configPath;
61
+ }
62
+ // ── Session-signal hooks ──────────────────────────────────────────────────────
63
+ export const SESSION_SIGNAL_EVENTS = ["Notification", "Stop", "UserPromptSubmit", "SessionEnd"];
64
+ /** Returns the session-signal events that are NOT yet wired in settings.json. */
65
+ export function missingSessionSignalHooks() {
66
+ const settings = readJsonSafe(claudeSettingsPath());
67
+ const hooks = settings.hooks || {};
68
+ const missing = [];
69
+ for (const event of SESSION_SIGNAL_EVENTS) {
70
+ const eventHooks = hooks[event];
71
+ if (!Array.isArray(eventHooks)) {
72
+ missing.push(event);
73
+ continue;
74
+ }
75
+ const found = eventHooks.some((entry) => {
76
+ const inner = entry.hooks || [];
77
+ return inner.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
78
+ });
79
+ if (!found)
80
+ missing.push(event);
81
+ }
82
+ return missing;
83
+ }
84
+ export function isSessionSignalConfigured() {
85
+ return missingSessionSignalHooks().length === 0;
86
+ }
@@ -8,6 +8,9 @@ interface Props {
8
8
  creationLogs: string;
9
9
  /** Deletion progress for the selected worktree, when one is being removed. */
10
10
  deleteStatus?: DeleteStatus;
11
+ /** True while a just-created PR is shown optimistically, before a refresh
12
+ * confirms it (renders a "syncing…" hint next to the PR). */
13
+ prSyncing?: boolean;
11
14
  /** Triage mode: hide worktree/PR/checks sections (they never apply to an
12
15
  * inbox issue) and show the discussion instead. */
13
16
  triage?: boolean;
@@ -39,5 +42,5 @@ export declare function buildIssueActions(di: DashboardIssue, trackerName: strin
39
42
  canMutate?: boolean;
40
43
  triageInvestigateConfigured?: boolean;
41
44
  }): IssueActionItem[];
42
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
45
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
43
46
  export {};
@@ -158,7 +158,7 @@ function sectionHeader(icon, label, iconColor = "cyan") {
158
158
  ],
159
159
  };
160
160
  }
161
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage = false, comments, onCall, }) {
161
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing = false, triage = false, comments, onCall, }) {
162
162
  // Show deletion progress when the selected worktree is being removed.
163
163
  if (issue && deleteStatus) {
164
164
  const logLines = deleteStatus.logs.split("\n");
@@ -557,11 +557,21 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
557
557
  { text: " " },
558
558
  { text: pr.state, color: prColor },
559
559
  { text: draft, dim: true },
560
+ ...(prSyncing ? [{ text: " · syncing…", color: "yellow" }] : []),
560
561
  ],
561
562
  });
562
563
  if (pr.url) {
563
564
  lines.push({ text: ` ${pr.url}`, dim: true });
564
565
  }
566
+ if (prSyncing) {
567
+ // Just created from the dashboard — number/url are known, but checks
568
+ // and reviews aren't fetched yet. Show a clear loading line for this
569
+ // one PR (other PRs keep their already-loaded data).
570
+ lines.push({
571
+ text: "",
572
+ segments: [{ text: " ⟳ loading checks & reviews…", color: "yellow" }],
573
+ });
574
+ }
565
575
  }
566
576
  else if (!isMain) {
567
577
  lines.push(sectionHeader("◉", "Pull Request"));
@@ -163,6 +163,12 @@ export interface DashboardState {
163
163
  creationError: string | null;
164
164
  /** In-flight (and just-finished) worktree deletions, keyed by ticket id. */
165
165
  deletingTickets: Record<string, DeleteStatus>;
166
+ /**
167
+ * Optimistic PRs just created from the dashboard, keyed by ticket id. They
168
+ * make the "Open PR" action appear immediately (before the next refresh
169
+ * surfaces the real PR), and are reconciled away by SET_DATA.
170
+ */
171
+ pendingPrs: Record<string, PRInfo>;
166
172
  commitPhase: CommitPhase;
167
173
  commitMessage: string;
168
174
  commitError: string | null;
@@ -48,6 +48,7 @@ export const initialState = {
48
48
  creationLogs: "",
49
49
  creationError: null,
50
50
  deletingTickets: {},
51
+ pendingPrs: {},
51
52
  commitPhase: "idle",
52
53
  commitMessage: "",
53
54
  commitError: null,
@@ -117,12 +118,27 @@ export function reducer(state, action) {
117
118
  // keep their row, so their entries survive and stay visible.
118
119
  const presentTreeIds = new Set(action.flatTrees.map((d) => d.issue.identifier));
119
120
  const deletingTickets = Object.fromEntries(Object.entries(state.deletingTickets).filter(([id]) => presentTreeIds.has(id)));
121
+ // Reconcile optimistic PRs: keep the placeholder until the refresh
122
+ // actually surfaces the PR (GitHub may not have indexed it yet), then
123
+ // drop it once real PR data — or the worktree itself — is gone.
124
+ let reconciledTrees = action.flatTrees;
125
+ const pendingPrs = {};
126
+ for (const [id, optimistic] of Object.entries(state.pendingPrs)) {
127
+ const idx = reconciledTrees.findIndex((d) => d.issue.identifier === id);
128
+ if (idx < 0)
129
+ continue; // worktree gone → drop
130
+ if (reconciledTrees[idx].pr)
131
+ continue; // real PR arrived → reconciled
132
+ reconciledTrees = reconciledTrees.map((d, i) => (i === idx ? { ...d, pr: optimistic } : d));
133
+ pendingPrs[id] = optimistic; // still pending → keep showing it
134
+ }
120
135
  return {
121
136
  ...state,
122
137
  groups: action.groups,
123
138
  flatIssues: action.flatIssues,
124
139
  treeGroups: action.treeGroups,
125
- flatTrees: action.flatTrees,
140
+ flatTrees: reconciledTrees,
141
+ pendingPrs,
126
142
  triageGroups: action.triageGroups,
127
143
  flatTriage: action.flatTriage,
128
144
  deletingTickets,
@@ -408,14 +424,33 @@ export function reducer(state, action) {
408
424
  return { ...state, prCreateDraft: !state.prCreateDraft };
409
425
  case "PR_CREATE_EDIT":
410
426
  return { ...state, prCreatePhase: "review" };
411
- case "PR_CREATE_DONE":
427
+ case "PR_CREATE_DONE": {
428
+ // Optimistically attach the new PR to its tree row so the "Open PR"
429
+ // action appears immediately, instead of waiting for the next refresh
430
+ // to surface it via `gh pr view`.
431
+ const ticketId = state.prCreateTicketId;
432
+ let flatTrees = state.flatTrees;
433
+ let pendingPrs = state.pendingPrs;
434
+ if (ticketId) {
435
+ const optimistic = {
436
+ number: action.url.match(/\/pull\/(\d+)/)?.[1] ?? "?",
437
+ state: "OPEN",
438
+ isDraft: state.prCreateDraft,
439
+ url: action.url,
440
+ };
441
+ flatTrees = state.flatTrees.map((d) => d.issue.identifier === ticketId && !d.pr ? { ...d, pr: optimistic } : d);
442
+ pendingPrs = { ...state.pendingPrs, [ticketId]: optimistic };
443
+ }
412
444
  return {
413
445
  ...state,
414
446
  prCreatePhase: "done",
415
447
  prCreateUrl: action.url,
416
448
  prCreateBody: null,
417
449
  prCreateTitle: null,
450
+ flatTrees,
451
+ pendingPrs,
418
452
  };
453
+ }
419
454
  case "PR_CREATE_CANCEL":
420
455
  return {
421
456
  ...state,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * TTY hand-off for setup steps that run an interactive subprocess (brew install,
3
+ * gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
4
+ * drop raw mode so the child owns the terminal, run it synchronously (Ink can't
5
+ * repaint while the event loop is blocked), then restore raw mode.
6
+ *
7
+ * Returns the child's exit code (1 on spawn failure).
8
+ */
9
+ export declare function spawnTTY(cmd: string, args: string[], opts?: {
10
+ cwd?: string;
11
+ }): number;
12
+ /** argv to re-invoke this same santree binary (robust to global vs local installs). */
13
+ export declare function santreeSelfArgv(args: string[]): {
14
+ cmd: string;
15
+ args: string[];
16
+ };
@@ -0,0 +1,35 @@
1
+ import { spawnSync } from "child_process";
2
+ /**
3
+ * TTY hand-off for setup steps that run an interactive subprocess (brew install,
4
+ * gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
5
+ * drop raw mode so the child owns the terminal, run it synchronously (Ink can't
6
+ * repaint while the event loop is blocked), then restore raw mode.
7
+ *
8
+ * Returns the child's exit code (1 on spawn failure).
9
+ */
10
+ export function spawnTTY(cmd, args, opts) {
11
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
12
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
13
+ try {
14
+ process.stdin.setRawMode(false);
15
+ }
16
+ catch { }
17
+ }
18
+ const result = spawnSync(cmd, args, { stdio: "inherit", cwd: opts?.cwd });
19
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
20
+ try {
21
+ process.stdin.setRawMode(wasRaw);
22
+ }
23
+ catch { }
24
+ }
25
+ if (result.error)
26
+ return 1;
27
+ return result.status ?? 1;
28
+ }
29
+ /** argv to re-invoke this same santree binary (robust to global vs local installs). */
30
+ export function santreeSelfArgv(args) {
31
+ const entry = process.argv[1];
32
+ if (entry)
33
+ return { cmd: process.execPath, args: [entry, ...args] };
34
+ return { cmd: "santree", args };
35
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ignore-rule management for santree's per-repo files.
3
+ *
4
+ * We add SPECIFIC entries (not a blanket `.santree/`) so `.santree/issues/`
5
+ * stays version-controlled for the Local tracker. Trailing slashes mark
6
+ * directories (see santree's own .gitignore convention).
7
+ */
8
+ export declare const SANTREE_IGNORE_ENTRIES: string[];
9
+ export type IgnoreTarget = "gitignore" | "exclude";
10
+ export declare function ignoreTargetPath(repoRoot: string, target: IgnoreTarget): string;
11
+ /** Entries that are not yet ignored by git (so we don't write duplicates). */
12
+ export declare function missingIgnoreEntries(repoRoot: string): string[];
13
+ /**
14
+ * Append the still-missing santree entries to the chosen target file. Returns
15
+ * the entries actually written (skips any already present as literal lines).
16
+ */
17
+ export declare function addIgnoreEntries(repoRoot: string, target: IgnoreTarget): string[];
@@ -0,0 +1,57 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execSync } from "child_process";
4
+ import { run } from "../exec.js";
5
+ /**
6
+ * Ignore-rule management for santree's per-repo files.
7
+ *
8
+ * We add SPECIFIC entries (not a blanket `.santree/`) so `.santree/issues/`
9
+ * stays version-controlled for the Local tracker. Trailing slashes mark
10
+ * directories (see santree's own .gitignore convention).
11
+ */
12
+ export const SANTREE_IGNORE_ENTRIES = [
13
+ ".santree/worktrees/",
14
+ ".santree/metadata.json",
15
+ ".santree/session-states/",
16
+ ];
17
+ export function ignoreTargetPath(repoRoot, target) {
18
+ if (target === "exclude") {
19
+ // Resolve the real excludes file (handles non-standard git dirs).
20
+ const rel = run("git rev-parse --git-path info/exclude", { cwd: repoRoot });
21
+ return rel ? path.resolve(repoRoot, rel) : path.join(repoRoot, ".git", "info", "exclude");
22
+ }
23
+ return path.join(repoRoot, ".gitignore");
24
+ }
25
+ function isGitIgnored(relPath, repoRoot) {
26
+ try {
27
+ execSync(`git check-ignore -q "${relPath}"`, { cwd: repoRoot, stdio: "ignore" });
28
+ return true; // exit 0 = ignored
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /** Entries that are not yet ignored by git (so we don't write duplicates). */
35
+ export function missingIgnoreEntries(repoRoot) {
36
+ return SANTREE_IGNORE_ENTRIES.filter((e) => !isGitIgnored(e, repoRoot));
37
+ }
38
+ /**
39
+ * Append the still-missing santree entries to the chosen target file. Returns
40
+ * the entries actually written (skips any already present as literal lines).
41
+ */
42
+ export function addIgnoreEntries(repoRoot, target) {
43
+ const filePath = ignoreTargetPath(repoRoot, target);
44
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
45
+ const existingLines = new Set(existing
46
+ .split("\n")
47
+ .map((l) => l.trim())
48
+ .filter(Boolean));
49
+ const toAdd = missingIgnoreEntries(repoRoot).filter((e) => !existingLines.has(e));
50
+ if (toAdd.length === 0)
51
+ return [];
52
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
54
+ const header = existing.includes("# santree") ? "" : "\n# santree\n";
55
+ fs.appendFileSync(filePath, prefix + header + toAdd.join("\n") + "\n");
56
+ return toAdd;
57
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shell-config detection + idempotent editing for `santree setup`.
3
+ *
4
+ * Everything santree writes to a user's shell rc lives inside a single managed
5
+ * block (nvm/conda style) so re-running setup replaces the block rather than
6
+ * appending duplicates. Individual lines inside the block are tagged with a
7
+ * trailing `# santree:<key>` comment so each setup step can upsert just its own
8
+ * line without disturbing the others.
9
+ */
10
+ export type ShellName = "zsh" | "bash" | "unknown";
11
+ export interface ShellConfig {
12
+ shell: ShellName;
13
+ /** rc file the managed block is written to (interactive shell init). */
14
+ rcPath: string;
15
+ /** Files scanned to detect pre-existing `export FOO=...` lines (rc + env files). */
16
+ scanPaths: string[];
17
+ }
18
+ /**
19
+ * Resolve the rc file the managed block should live in, honoring ZDOTDIR (zsh)
20
+ * so users with an XDG-based setup (~/.config/zsh, no ~/.zshrc) get the block in
21
+ * the right place.
22
+ */
23
+ export declare function resolveShellConfig(shellEnv?: string): ShellConfig;
24
+ /**
25
+ * Upsert a single tagged line into the managed block, creating the block (and
26
+ * the rc file) if needed. The line for `key` is replaced if present, else
27
+ * appended. `line` is the raw shell statement WITHOUT the trailing tag comment.
28
+ */
29
+ export declare function upsertManagedLine(rcPath: string, key: string, line: string): void;
30
+ /**
31
+ * Detect whether `name` is already exported by the user's shell config (any of
32
+ * `scanPaths`) or the current environment. Used so setup doesn't clobber an
33
+ * export the user already maintains by hand (e.g. SANTREE_EDITOR in .zshenv).
34
+ * Ignores santree's own managed block so a prior setup run isn't mistaken for a
35
+ * hand-maintained export.
36
+ */
37
+ export declare function isEnvVarSet(name: string, cfg: ShellConfig): boolean;
38
+ export declare function envKey(name: string): string;
@@ -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[];