santree 0.7.4 → 0.7.6
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.
- package/README.md +2 -3
- package/dist/commands/config.d.ts +12 -0
- package/dist/commands/config.js +444 -0
- package/dist/commands/dashboard.js +187 -80
- package/dist/commands/helpers/statusline.js +0 -30
- package/dist/commands/issue/index.d.ts +1 -1
- package/dist/commands/issue/index.js +1 -1
- package/dist/commands/pr/context.d.ts +7 -0
- package/dist/commands/pr/context.js +36 -0
- package/dist/commands/pr/fix.d.ts +11 -2
- package/dist/commands/pr/fix.js +64 -12
- package/dist/commands/worktree/create.js +17 -11
- package/dist/commands/worktree/open.js +3 -2
- package/dist/commands/worktree/switch.js +3 -11
- package/dist/lib/ai.d.ts +11 -0
- package/dist/lib/ai.js +71 -2
- package/dist/lib/cd-hint.d.ts +18 -0
- package/dist/lib/cd-hint.js +27 -0
- package/dist/lib/claude-config.d.ts +26 -0
- package/dist/lib/claude-config.js +121 -0
- package/dist/lib/config/TrackerPicker.d.ts +10 -0
- package/dist/lib/config/TrackerPicker.js +113 -0
- package/dist/lib/config/diagnostics.d.ts +37 -0
- package/dist/lib/config/diagnostics.js +190 -0
- package/dist/lib/config-store.d.ts +23 -0
- package/dist/lib/config-store.js +68 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +7 -2
- package/dist/lib/dashboard/DetailPanel.js +54 -37
- package/dist/lib/dashboard/IssueList.d.ts +5 -1
- package/dist/lib/dashboard/IssueList.js +24 -5
- package/dist/lib/dashboard/MultilineTextArea.js +1 -1
- package/dist/lib/dashboard/Overlays.js +3 -4
- package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
- package/dist/lib/dashboard/ReviewList.js +6 -3
- package/dist/lib/dashboard/data.js +7 -34
- package/dist/lib/dashboard/external-editor.d.ts +1 -1
- package/dist/lib/dashboard/external-editor.js +3 -2
- package/dist/lib/dashboard/types.d.ts +19 -2
- package/dist/lib/dashboard/types.js +68 -2
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +11 -1
- package/dist/lib/fix-loop.d.ts +45 -0
- package/dist/lib/fix-loop.js +83 -0
- package/dist/lib/git.d.ts +3 -29
- package/dist/lib/git.js +6 -62
- package/dist/lib/github.d.ts +70 -0
- package/dist/lib/github.js +128 -0
- package/dist/lib/multiplexer/cmux.js +143 -17
- package/dist/lib/multiplexer/none.js +3 -3
- package/dist/lib/multiplexer/tmux.js +5 -29
- package/dist/lib/multiplexer/types.d.ts +34 -1
- package/dist/lib/open-url.d.ts +3 -2
- package/dist/lib/open-url.js +5 -4
- package/dist/lib/prompts.d.ts +35 -1
- package/dist/lib/prompts.js +7 -0
- package/dist/lib/setup/apply.d.ts +16 -0
- package/dist/lib/setup/apply.js +35 -0
- package/dist/lib/setup/gitignore.d.ts +24 -0
- package/dist/lib/setup/gitignore.js +95 -0
- package/dist/lib/setup/steps.d.ts +39 -0
- package/dist/lib/setup/steps.js +333 -0
- package/dist/lib/setup/tools.d.ts +23 -0
- package/dist/lib/setup/tools.js +47 -0
- package/dist/lib/squirrel-loader.d.ts +3 -1
- package/dist/lib/squirrel-loader.js +2 -2
- package/dist/lib/trackers/github/index.js +2 -2
- package/dist/lib/trackers/local/store.js +1 -1
- package/package.json +1 -2
- package/prompts/fix-context.njk +79 -0
- package/prompts/fix-loop.njk +18 -0
- package/prompts/pr.njk +1 -1
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js +0 -618
- package/dist/commands/github/auth.d.ts +0 -2
- package/dist/commands/github/auth.js +0 -56
- package/dist/commands/github/index.d.ts +0 -1
- package/dist/commands/github/index.js +0 -1
- package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
- package/dist/commands/helpers/english-tutor/index.js +0 -1
- package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
- package/dist/commands/helpers/english-tutor/install.js +0 -24
- package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/prompt.js +0 -16
- package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/session-start.js +0 -34
- package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
- package/dist/commands/helpers/session-signal/end.d.ts +0 -2
- package/dist/commands/helpers/session-signal/end.js +0 -13
- package/dist/commands/helpers/session-signal/index.d.ts +0 -1
- package/dist/commands/helpers/session-signal/index.js +0 -1
- package/dist/commands/helpers/session-signal/install.d.ts +0 -8
- package/dist/commands/helpers/session-signal/install.js +0 -24
- package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
- package/dist/commands/helpers/session-signal/notification.js +0 -13
- package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
- package/dist/commands/helpers/session-signal/prompt.js +0 -13
- package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
- package/dist/commands/helpers/session-signal/stop.js +0 -13
- package/dist/commands/helpers/shell-init.d.ts +0 -11
- package/dist/commands/helpers/shell-init.js +0 -122
- package/dist/commands/issue/setup.d.ts +0 -2
- package/dist/commands/issue/setup.js +0 -108
- package/dist/commands/issue/switch.d.ts +0 -12
- package/dist/commands/issue/switch.js +0 -38
- package/dist/commands/linear/switch.d.ts +0 -2
- package/dist/commands/linear/switch.js +0 -75
- package/dist/lib/english-tutor.d.ts +0 -13
- package/dist/lib/english-tutor.js +0 -125
- package/dist/lib/session-signal.d.ts +0 -16
- package/dist/lib/session-signal.js +0 -104
- package/prompts/english-tutor-prompt.njk +0 -15
- package/shell/init.bash.njk +0 -129
- package/shell/init.zsh.njk +0 -204
package/dist/lib/open-url.d.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* else → `xdg-open <url>`
|
|
5
5
|
*
|
|
6
6
|
* Returns true on apparent success, false on failure. Callers decide how to
|
|
7
|
-
* surface failures (e.g. a dashboard action message). Uses
|
|
8
|
-
*
|
|
7
|
+
* surface failures (e.g. a dashboard action message). Uses execFileSync (array
|
|
8
|
+
* args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
|
|
9
|
+
* `stdio: "ignore"` keeps output out of the dashboard's alt screen.
|
|
9
10
|
*/
|
|
10
11
|
export declare function openUrl(url: string): boolean;
|
package/dist/lib/open-url.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
2
|
/**
|
|
3
3
|
* Open a URL in the platform's default browser.
|
|
4
4
|
* macOS → `open <url>`
|
|
5
5
|
* else → `xdg-open <url>`
|
|
6
6
|
*
|
|
7
7
|
* Returns true on apparent success, false on failure. Callers decide how to
|
|
8
|
-
* surface failures (e.g. a dashboard action message). Uses
|
|
9
|
-
*
|
|
8
|
+
* surface failures (e.g. a dashboard action message). Uses execFileSync (array
|
|
9
|
+
* args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
|
|
10
|
+
* `stdio: "ignore"` keeps output out of the dashboard's alt screen.
|
|
10
11
|
*/
|
|
11
12
|
export function openUrl(url) {
|
|
12
13
|
try {
|
|
13
14
|
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
14
|
-
|
|
15
|
+
execFileSync(cmd, [url], { stdio: "ignore" });
|
|
15
16
|
return true;
|
|
16
17
|
}
|
|
17
18
|
catch {
|
package/dist/lib/prompts.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Issue } from "./trackers/types.js";
|
|
2
|
-
import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversationComment } from "./github.js";
|
|
2
|
+
import type { PRCheck, PRReview, PRReviewComment, PRReviewThread, FailedCheckDetail, PRConversationComment } from "./github.js";
|
|
3
3
|
/**
|
|
4
4
|
* Render a nunjucks template from the prompts/ directory.
|
|
5
5
|
* @param template - Template name without extension (e.g. "fill-pr")
|
|
@@ -35,3 +35,37 @@ export interface PRData {
|
|
|
35
35
|
* Render PR feedback data into formatted markdown using the pr template.
|
|
36
36
|
*/
|
|
37
37
|
export declare function renderPR(data: PRData): string;
|
|
38
|
+
/** A failed check annotated with whether the fix loop should attempt it. */
|
|
39
|
+
export interface FixContextCheck extends FailedCheckDetail {
|
|
40
|
+
fixable: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface FixContextData {
|
|
43
|
+
pr_number: string;
|
|
44
|
+
pr_url: string;
|
|
45
|
+
branch: string;
|
|
46
|
+
base_branch: string;
|
|
47
|
+
conflicts: boolean;
|
|
48
|
+
mergeable: string;
|
|
49
|
+
merge_state: string;
|
|
50
|
+
failed_checks: FixContextCheck[];
|
|
51
|
+
fixable_count: number;
|
|
52
|
+
manual_count: number;
|
|
53
|
+
pending_count: number;
|
|
54
|
+
reviews: PRReview[] | null;
|
|
55
|
+
/** Unresolved review threads the viewer has 👍-approved for auto-apply. */
|
|
56
|
+
approved_comments: PRReviewThread[];
|
|
57
|
+
/**
|
|
58
|
+
* The single recommended action for this iteration, computed from state so
|
|
59
|
+
* the loop doesn't have to: `merge` (conflicts) | `work` (fixable CI and/or
|
|
60
|
+
* approved comments) | `wait` (nothing to do, CI still running) |
|
|
61
|
+
* `stop-stuck` (only manual failures left) | `stop-clean` (all done).
|
|
62
|
+
*/
|
|
63
|
+
directive: "merge" | "work" | "wait" | "stop-stuck" | "stop-clean";
|
|
64
|
+
/** Absolute santree invocation, for the `--signal` / resolve commands. */
|
|
65
|
+
santree_cmd: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Render the compact per-iteration brief the auto-fix loop consumes: conflict
|
|
69
|
+
* status, failing checks tagged fixable/manual, and open review asks.
|
|
70
|
+
*/
|
|
71
|
+
export declare function renderFixContext(data: FixContextData): string;
|
package/dist/lib/prompts.js
CHANGED
|
@@ -51,3 +51,10 @@ export function renderDiff(data) {
|
|
|
51
51
|
export function renderPR(data) {
|
|
52
52
|
return promptsEnv.render("pr.njk", data);
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Render the compact per-iteration brief the auto-fix loop consumes: conflict
|
|
56
|
+
* status, failing checks tagged fixable/manual, and open review asks.
|
|
57
|
+
*/
|
|
58
|
+
export function renderFixContext(data) {
|
|
59
|
+
return promptsEnv.render("fix-context.njk", data);
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTY hand-off for config steps that run an interactive subprocess (brew install,
|
|
3
|
+
* gh auth login, Claude install). 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 config steps that run an interactive subprocess (brew install,
|
|
4
|
+
* gh auth login, Claude install). 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,24 @@
|
|
|
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[];
|
|
18
|
+
/**
|
|
19
|
+
* Remove santree's ignore entries (and an orphaned `# santree` header) from both
|
|
20
|
+
* candidate files. Returns the entries actually removed. Leaves every other line
|
|
21
|
+
* — including user-authored ignores — untouched, and collapses the blank lines
|
|
22
|
+
* left behind so the file doesn't accumulate gaps across enable/disable cycles.
|
|
23
|
+
*/
|
|
24
|
+
export declare function removeIgnoreEntries(repoRoot: string): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
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/fix-loops/",
|
|
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
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Remove santree's ignore entries (and an orphaned `# santree` header) from both
|
|
60
|
+
* candidate files. Returns the entries actually removed. Leaves every other line
|
|
61
|
+
* — including user-authored ignores — untouched, and collapses the blank lines
|
|
62
|
+
* left behind so the file doesn't accumulate gaps across enable/disable cycles.
|
|
63
|
+
*/
|
|
64
|
+
export function removeIgnoreEntries(repoRoot) {
|
|
65
|
+
const removed = [];
|
|
66
|
+
for (const target of ["gitignore", "exclude"]) {
|
|
67
|
+
const filePath = ignoreTargetPath(repoRoot, target);
|
|
68
|
+
if (!fs.existsSync(filePath))
|
|
69
|
+
continue;
|
|
70
|
+
const original = fs.readFileSync(filePath, "utf-8");
|
|
71
|
+
let changed = false;
|
|
72
|
+
const kept = original.split("\n").filter((line) => {
|
|
73
|
+
const t = line.trim();
|
|
74
|
+
if (SANTREE_IGNORE_ENTRIES.includes(t)) {
|
|
75
|
+
removed.push(t);
|
|
76
|
+
changed = true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (t === "# santree") {
|
|
80
|
+
// santree's header only ever sits above its own (now-removed) entries.
|
|
81
|
+
changed = true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
if (!changed)
|
|
87
|
+
continue;
|
|
88
|
+
const cleaned = kept
|
|
89
|
+
.join("\n")
|
|
90
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
91
|
+
.replace(/^\n+/, "");
|
|
92
|
+
fs.writeFileSync(filePath, cleaned.length === 0 ? "" : cleaned.replace(/\n*$/, "\n"));
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type DetectState = "ok" | "actionable" | "unavailable";
|
|
2
|
+
export interface StepOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export interface StepResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SetupStep {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
detail: string;
|
|
14
|
+
scope: "global" | "repo";
|
|
15
|
+
/** "spawn" steps take over the terminal (installs/logins); UI flags them. */
|
|
16
|
+
kind: "file" | "spawn";
|
|
17
|
+
recommended: boolean;
|
|
18
|
+
detect: DetectState;
|
|
19
|
+
/** Pick-one sub-prompt collected before apply; absent = no choice needed. */
|
|
20
|
+
options?: StepOption[];
|
|
21
|
+
optionPrompt?: string;
|
|
22
|
+
apply: (choice: string | undefined) => StepResult | Promise<StepResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Reverse `apply`, returning the step to its un-configured state. Present only
|
|
25
|
+
* on steps santree fully owns and can cleanly undo (statusline, remote control,
|
|
26
|
+
* gitignore). Steps without it have no "off" in the panel.
|
|
27
|
+
*/
|
|
28
|
+
unapply?: (choice: string | undefined) => StepResult | Promise<StepResult>;
|
|
29
|
+
}
|
|
30
|
+
export interface SetupContext {
|
|
31
|
+
repoRoot: string | null;
|
|
32
|
+
dryRun: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function buildContext(dryRun: boolean): SetupContext;
|
|
35
|
+
/**
|
|
36
|
+
* Build the full step catalog with `detect` + `options` resolved against the
|
|
37
|
+
* current machine. The wizard filters to `actionable` steps for the checklist.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildSteps(ctx: SetupContext): SetupStep[];
|
|
@@ -0,0 +1,333 @@
|
|
|
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 { isStatuslineConfigured, configureStatusline, removeStatusline, isRemoteControlEnabled, enableRemoteControl, disableRemoteControl, } from "../claude-config.js";
|
|
9
|
+
import { setConfigValue, getConfiguredEditor, getConfiguredDiffTool, configStorePath, } from "../config-store.js";
|
|
10
|
+
import { detectDiffPagers, detectEditors, getInstaller, which } from "./tools.js";
|
|
11
|
+
import { missingIgnoreEntries, addIgnoreEntries, removeIgnoreEntries, SANTREE_IGNORE_ENTRIES, } from "./gitignore.js";
|
|
12
|
+
import { spawnTTY } from "./apply.js";
|
|
13
|
+
export function buildContext(dryRun) {
|
|
14
|
+
return {
|
|
15
|
+
repoRoot: findMainRepoRoot(),
|
|
16
|
+
dryRun,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the full step catalog with `detect` + `options` resolved against the
|
|
21
|
+
* current machine. The wizard filters to `actionable` steps for the checklist.
|
|
22
|
+
*/
|
|
23
|
+
export function buildSteps(ctx) {
|
|
24
|
+
const steps = [];
|
|
25
|
+
const { repoRoot, dryRun } = ctx;
|
|
26
|
+
const installer = getInstaller();
|
|
27
|
+
// ── Editor ──────────────────────────────────────────────────────────────────
|
|
28
|
+
{
|
|
29
|
+
const editors = detectEditors();
|
|
30
|
+
const detect = getConfiguredEditor()
|
|
31
|
+
? "ok"
|
|
32
|
+
: editors.length > 0
|
|
33
|
+
? "actionable"
|
|
34
|
+
: "unavailable";
|
|
35
|
+
steps.push({
|
|
36
|
+
id: "editor",
|
|
37
|
+
title: "Default editor",
|
|
38
|
+
detail: "Editor opened by the dashboard's [e] action (stored in santree's config)",
|
|
39
|
+
scope: "global",
|
|
40
|
+
kind: "file",
|
|
41
|
+
recommended: true,
|
|
42
|
+
detect,
|
|
43
|
+
options: editors.map((e) => ({ value: e.command, label: e.command })),
|
|
44
|
+
optionPrompt: "Pick your editor:",
|
|
45
|
+
apply: (choice) => {
|
|
46
|
+
const editor = choice || editors[0]?.command || "vim";
|
|
47
|
+
if (dryRun)
|
|
48
|
+
return { ok: true, message: `Would set editor=${editor}` };
|
|
49
|
+
setConfigValue("editor", editor);
|
|
50
|
+
return { ok: true, message: `Set editor to ${editor} in ${configStorePath()}` };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// ── Diff tool ───────────────────────────────────────────────────────────────
|
|
55
|
+
{
|
|
56
|
+
const pagers = detectDiffPagers();
|
|
57
|
+
const hasDelta = pagers.some((p) => p.command === "delta");
|
|
58
|
+
const options = pagers.map((p) => ({ value: p.command, label: p.command }));
|
|
59
|
+
if (installer && !hasDelta) {
|
|
60
|
+
options.push({
|
|
61
|
+
value: "install:delta",
|
|
62
|
+
label: `git-delta — install via ${installer.name}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const detect = getConfiguredDiffTool()
|
|
66
|
+
? "ok"
|
|
67
|
+
: options.length > 0
|
|
68
|
+
? "actionable"
|
|
69
|
+
: "unavailable";
|
|
70
|
+
steps.push({
|
|
71
|
+
id: "diff-tool",
|
|
72
|
+
title: "Diff tool",
|
|
73
|
+
detail: "Pretty diffs in `worktree diff` and the dashboard [v] overlay (stored in santree's config)",
|
|
74
|
+
scope: "global",
|
|
75
|
+
kind: installer && !hasDelta ? "spawn" : "file",
|
|
76
|
+
recommended: true,
|
|
77
|
+
detect,
|
|
78
|
+
options,
|
|
79
|
+
optionPrompt: "Pick a diff pager:",
|
|
80
|
+
apply: (choice) => {
|
|
81
|
+
const pick = choice || options[0]?.value;
|
|
82
|
+
if (!pick)
|
|
83
|
+
return { ok: false, message: "No diff pager available" };
|
|
84
|
+
let tool = pick;
|
|
85
|
+
if (pick.startsWith("install:")) {
|
|
86
|
+
tool = pick.slice("install:".length);
|
|
87
|
+
// `install:` options are only offered when an installer exists, but
|
|
88
|
+
// guard explicitly so a broken invariant degrades instead of NPEs.
|
|
89
|
+
if (!installer)
|
|
90
|
+
return { ok: false, message: "No installer available for git-delta" };
|
|
91
|
+
if (dryRun) {
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
message: `Would ${installer.installArgv("git-delta").join(" ")} then set diffTool=${tool}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const argv = installer.installArgv("git-delta");
|
|
98
|
+
const code = spawnTTY(argv[0], argv.slice(1));
|
|
99
|
+
if (code !== 0)
|
|
100
|
+
return { ok: false, message: "git-delta install failed — diff tool not set" };
|
|
101
|
+
}
|
|
102
|
+
if (dryRun)
|
|
103
|
+
return { ok: true, message: `Would set diffTool=${tool}` };
|
|
104
|
+
setConfigValue("diffTool", tool);
|
|
105
|
+
return { ok: true, message: `Set diff tool to ${tool} in ${configStorePath()}` };
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// ── Statusline ──────────────────────────────────────────────────────────────
|
|
110
|
+
steps.push({
|
|
111
|
+
id: "statusline",
|
|
112
|
+
title: "Statusline",
|
|
113
|
+
detail: "Worktree-aware statusline (branch, git changes, context usage)",
|
|
114
|
+
scope: "global",
|
|
115
|
+
kind: "file",
|
|
116
|
+
recommended: true,
|
|
117
|
+
detect: isStatuslineConfigured() ? "ok" : "actionable",
|
|
118
|
+
apply: () => {
|
|
119
|
+
if (dryRun)
|
|
120
|
+
return { ok: true, message: "Would configure statusLine in ~/.claude/settings.json" };
|
|
121
|
+
const p = configureStatusline();
|
|
122
|
+
return { ok: true, message: `Configured statusline in ${p}` };
|
|
123
|
+
},
|
|
124
|
+
unapply: () => {
|
|
125
|
+
if (dryRun)
|
|
126
|
+
return { ok: true, message: "Would remove statusLine from ~/.claude/settings.json" };
|
|
127
|
+
const p = removeStatusline();
|
|
128
|
+
return { ok: true, message: `Removed statusline from ${p}` };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
// ── Remote control ──────────────────────────────────────────────────────────
|
|
132
|
+
steps.push({
|
|
133
|
+
id: "remote-control",
|
|
134
|
+
title: "Remote control",
|
|
135
|
+
detail: "Enable remote control at startup (drive sessions from the dashboard)",
|
|
136
|
+
scope: "global",
|
|
137
|
+
kind: "file",
|
|
138
|
+
recommended: false,
|
|
139
|
+
detect: isRemoteControlEnabled() ? "ok" : "actionable",
|
|
140
|
+
apply: () => {
|
|
141
|
+
if (dryRun)
|
|
142
|
+
return { ok: true, message: "Would set remoteControlAtStartup in ~/.claude.json" };
|
|
143
|
+
const p = enableRemoteControl();
|
|
144
|
+
return { ok: true, message: `Enabled remote control in ${p}` };
|
|
145
|
+
},
|
|
146
|
+
unapply: () => {
|
|
147
|
+
if (dryRun)
|
|
148
|
+
return { ok: true, message: "Would unset remoteControlAtStartup in ~/.claude.json" };
|
|
149
|
+
const p = disableRemoteControl();
|
|
150
|
+
return { ok: true, message: `Disabled remote control in ${p}` };
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
// ── GitHub CLI install + auth ───────────────────────────────────────────────
|
|
154
|
+
{
|
|
155
|
+
const ghInstalled = !!which("gh");
|
|
156
|
+
// `gh auth status` prints to stderr — redirect so it doesn't leak into the wizard.
|
|
157
|
+
const ghAuthed = ghInstalled && run("gh auth status >/dev/null 2>&1") !== null;
|
|
158
|
+
const detect = ghAuthed
|
|
159
|
+
? "ok"
|
|
160
|
+
: ghInstalled || installer
|
|
161
|
+
? "actionable"
|
|
162
|
+
: "unavailable";
|
|
163
|
+
steps.push({
|
|
164
|
+
id: "gh",
|
|
165
|
+
title: "GitHub CLI",
|
|
166
|
+
detail: "Required for PR create / review / checks",
|
|
167
|
+
scope: "global",
|
|
168
|
+
kind: "spawn",
|
|
169
|
+
recommended: true,
|
|
170
|
+
detect,
|
|
171
|
+
apply: () => {
|
|
172
|
+
if (dryRun) {
|
|
173
|
+
const parts = !ghInstalled && installer ? `${installer.installArgv("gh").join(" ")} then ` : "";
|
|
174
|
+
return { ok: true, message: `Would ${parts}gh auth login` };
|
|
175
|
+
}
|
|
176
|
+
if (!ghInstalled) {
|
|
177
|
+
if (!installer)
|
|
178
|
+
return { ok: false, message: "Install gh manually: see https://cli.github.com" };
|
|
179
|
+
const argv = installer.installArgv("gh");
|
|
180
|
+
const code = spawnTTY(argv[0], argv.slice(1));
|
|
181
|
+
if (code !== 0)
|
|
182
|
+
return { ok: false, message: "gh install failed" };
|
|
183
|
+
}
|
|
184
|
+
const code = spawnTTY("gh", ["auth", "login"]);
|
|
185
|
+
return code === 0
|
|
186
|
+
? { ok: true, message: "gh authenticated" }
|
|
187
|
+
: { ok: false, message: "gh auth login did not complete" };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// ── Claude Code CLI ─────────────────────────────────────────────────────────
|
|
192
|
+
{
|
|
193
|
+
const installed = !!resolveClaudeBinary();
|
|
194
|
+
steps.push({
|
|
195
|
+
id: "claude",
|
|
196
|
+
title: "Claude Code",
|
|
197
|
+
detail: "Powers `worktree work`, `pr fix`, `pr review`",
|
|
198
|
+
scope: "global",
|
|
199
|
+
kind: "spawn",
|
|
200
|
+
recommended: true,
|
|
201
|
+
detect: installed ? "ok" : "actionable",
|
|
202
|
+
apply: () => {
|
|
203
|
+
const cmd = getInstallCommandFor(detectPackageManager(), CLAUDE_CODE_PACKAGE);
|
|
204
|
+
if (dryRun)
|
|
205
|
+
return { ok: true, message: `Would run: ${cmd.display}` };
|
|
206
|
+
const code = spawnTTY(cmd.cmd, cmd.args);
|
|
207
|
+
return code === 0
|
|
208
|
+
? { ok: true, message: "Claude Code CLI installed" }
|
|
209
|
+
: { ok: false, message: `Install failed — run manually: ${cmd.display}` };
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// ── tmux (multiplexer) ──────────────────────────────────────────────────────
|
|
214
|
+
{
|
|
215
|
+
const active = getMultiplexer().kind !== "none";
|
|
216
|
+
const detect = active ? "ok" : installer ? "actionable" : "unavailable";
|
|
217
|
+
steps.push({
|
|
218
|
+
id: "tmux",
|
|
219
|
+
title: "tmux",
|
|
220
|
+
detail: "Enables new-window flows: work / fix / review / investigate",
|
|
221
|
+
scope: "global",
|
|
222
|
+
kind: "spawn",
|
|
223
|
+
recommended: false,
|
|
224
|
+
detect,
|
|
225
|
+
apply: () => {
|
|
226
|
+
if (!installer)
|
|
227
|
+
return { ok: false, message: "Install tmux manually: brew install tmux" };
|
|
228
|
+
if (dryRun)
|
|
229
|
+
return { ok: true, message: `Would ${installer.installArgv("tmux").join(" ")}` };
|
|
230
|
+
const argv = installer.installArgv("tmux");
|
|
231
|
+
const code = spawnTTY(argv[0], argv.slice(1));
|
|
232
|
+
return code === 0
|
|
233
|
+
? { ok: true, message: "tmux installed (start a tmux session to use new-window flows)" }
|
|
234
|
+
: { ok: false, message: "tmux install failed" };
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// ── Repo-scoped steps ───────────────────────────────────────────────────────
|
|
239
|
+
if (repoRoot) {
|
|
240
|
+
const santreeDir = getSantreeDir(repoRoot);
|
|
241
|
+
const initSh = getInitScriptPath(repoRoot);
|
|
242
|
+
// .santree scaffold
|
|
243
|
+
{
|
|
244
|
+
const folderOk = fs.existsSync(santreeDir);
|
|
245
|
+
const initOk = fs.existsSync(initSh);
|
|
246
|
+
let execOk = false;
|
|
247
|
+
if (initOk) {
|
|
248
|
+
try {
|
|
249
|
+
fs.accessSync(initSh, fs.constants.X_OK);
|
|
250
|
+
execOk = true;
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
}
|
|
254
|
+
steps.push({
|
|
255
|
+
id: "scaffold",
|
|
256
|
+
title: "Repo scaffold",
|
|
257
|
+
detail: "Create .santree/ and an executable init.sh (runs on worktree create)",
|
|
258
|
+
scope: "repo",
|
|
259
|
+
kind: "file",
|
|
260
|
+
recommended: true,
|
|
261
|
+
detect: folderOk && initOk && execOk ? "ok" : "actionable",
|
|
262
|
+
apply: () => {
|
|
263
|
+
if (dryRun)
|
|
264
|
+
return { ok: true, message: `Would create ${santreeDir} and an executable init.sh` };
|
|
265
|
+
fs.mkdirSync(santreeDir, { recursive: true });
|
|
266
|
+
if (!fs.existsSync(initSh)) {
|
|
267
|
+
fs.writeFileSync(initSh, "#!/usr/bin/env bash\n# Runs after `santree worktree create`. Add setup steps here.\n");
|
|
268
|
+
}
|
|
269
|
+
fs.chmodSync(initSh, 0o755);
|
|
270
|
+
return { ok: true, message: `Scaffolded ${santreeDir}` };
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// Ignore santree files
|
|
275
|
+
{
|
|
276
|
+
const missing = missingIgnoreEntries(repoRoot);
|
|
277
|
+
steps.push({
|
|
278
|
+
id: "gitignore",
|
|
279
|
+
title: "Gitignore entries",
|
|
280
|
+
detail: `Ignore ${SANTREE_IGNORE_ENTRIES.join(", ")} (keeps .santree/issues/ tracked)`,
|
|
281
|
+
scope: "repo",
|
|
282
|
+
kind: "file",
|
|
283
|
+
recommended: true,
|
|
284
|
+
detect: missing.length === 0 ? "ok" : "actionable",
|
|
285
|
+
options: [
|
|
286
|
+
{ value: "gitignore", label: ".gitignore — shared with your team" },
|
|
287
|
+
{ value: "exclude", label: ".git/info/exclude — local to your clone" },
|
|
288
|
+
],
|
|
289
|
+
optionPrompt: "Where should the ignore rules go?",
|
|
290
|
+
apply: (choice) => {
|
|
291
|
+
const target = (choice === "exclude" ? "exclude" : "gitignore");
|
|
292
|
+
const where = target === "exclude" ? ".git/info/exclude" : ".gitignore";
|
|
293
|
+
if (dryRun)
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
message: `Would add ${missing.length} entr${missing.length === 1 ? "y" : "ies"} to ${where}`,
|
|
297
|
+
};
|
|
298
|
+
const added = addIgnoreEntries(repoRoot, target);
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
message: `Added ${added.length} entr${added.length === 1 ? "y" : "ies"} to ${where}`,
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
unapply: () => {
|
|
305
|
+
if (dryRun)
|
|
306
|
+
return { ok: true, message: "Would remove santree's ignore entries" };
|
|
307
|
+
const removed = removeIgnoreEntries(repoRoot);
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
message: removed.length
|
|
311
|
+
? `Removed ${removed.length} entr${removed.length === 1 ? "y" : "ies"}`
|
|
312
|
+
: "No santree ignore entries to remove",
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// Issue tracker
|
|
318
|
+
{
|
|
319
|
+
steps.push({
|
|
320
|
+
id: "tracker",
|
|
321
|
+
title: "Issue tracker",
|
|
322
|
+
detail: "Pick + authenticate Linear / GitHub / Local for this repo",
|
|
323
|
+
scope: "repo",
|
|
324
|
+
kind: "file",
|
|
325
|
+
recommended: true,
|
|
326
|
+
detect: isRepoTrackerConfigured(repoRoot) ? "ok" : "actionable",
|
|
327
|
+
// Driven by the config panel's inline TrackerPicker; apply is unused.
|
|
328
|
+
apply: () => ({ ok: true, message: "Pick a tracker from the config panel" }),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return steps;
|
|
333
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool detection + installation for `santree config`.
|
|
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;
|