santree 0.7.5 → 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.
Files changed (109) hide show
  1. package/README.md +2 -3
  2. package/dist/commands/config.d.ts +12 -0
  3. package/dist/commands/config.js +444 -0
  4. package/dist/commands/dashboard.js +165 -61
  5. package/dist/commands/helpers/statusline.js +0 -30
  6. package/dist/commands/issue/index.d.ts +1 -1
  7. package/dist/commands/issue/index.js +1 -1
  8. package/dist/commands/pr/context.d.ts +7 -0
  9. package/dist/commands/pr/context.js +36 -0
  10. package/dist/commands/pr/fix.d.ts +11 -2
  11. package/dist/commands/pr/fix.js +64 -12
  12. package/dist/commands/worktree/create.js +6 -1
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/lib/ai.d.ts +11 -0
  15. package/dist/lib/ai.js +71 -2
  16. package/dist/lib/claude-config.d.ts +18 -4
  17. package/dist/lib/claude-config.js +58 -23
  18. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  19. package/dist/lib/config/TrackerPicker.js +113 -0
  20. package/dist/lib/config/diagnostics.d.ts +37 -0
  21. package/dist/lib/config/diagnostics.js +190 -0
  22. package/dist/lib/config-store.d.ts +23 -0
  23. package/dist/lib/config-store.js +68 -0
  24. package/dist/lib/dashboard/DetailPanel.d.ts +4 -2
  25. package/dist/lib/dashboard/DetailPanel.js +44 -37
  26. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  27. package/dist/lib/dashboard/IssueList.js +24 -5
  28. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  29. package/dist/lib/dashboard/Overlays.js +3 -4
  30. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  31. package/dist/lib/dashboard/ReviewList.js +6 -3
  32. package/dist/lib/dashboard/data.js +7 -34
  33. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  34. package/dist/lib/dashboard/external-editor.js +3 -2
  35. package/dist/lib/dashboard/types.d.ts +13 -2
  36. package/dist/lib/dashboard/types.js +33 -2
  37. package/dist/lib/exec.d.ts +13 -0
  38. package/dist/lib/exec.js +11 -1
  39. package/dist/lib/fix-loop.d.ts +45 -0
  40. package/dist/lib/fix-loop.js +83 -0
  41. package/dist/lib/git.d.ts +3 -29
  42. package/dist/lib/git.js +6 -62
  43. package/dist/lib/github.d.ts +70 -0
  44. package/dist/lib/github.js +128 -0
  45. package/dist/lib/multiplexer/cmux.js +143 -17
  46. package/dist/lib/multiplexer/none.js +3 -3
  47. package/dist/lib/multiplexer/tmux.js +5 -29
  48. package/dist/lib/multiplexer/types.d.ts +34 -1
  49. package/dist/lib/open-url.d.ts +3 -2
  50. package/dist/lib/open-url.js +5 -4
  51. package/dist/lib/prompts.d.ts +35 -1
  52. package/dist/lib/prompts.js +7 -0
  53. package/dist/lib/setup/apply.d.ts +2 -2
  54. package/dist/lib/setup/apply.js +2 -2
  55. package/dist/lib/setup/gitignore.d.ts +7 -0
  56. package/dist/lib/setup/gitignore.js +39 -1
  57. package/dist/lib/setup/steps.d.ts +6 -2
  58. package/dist/lib/setup/steps.js +65 -60
  59. package/dist/lib/setup/tools.d.ts +1 -1
  60. package/dist/lib/setup/tools.js +1 -1
  61. package/dist/lib/trackers/github/index.js +2 -2
  62. package/dist/lib/trackers/local/store.js +1 -1
  63. package/package.json +1 -1
  64. package/prompts/fix-context.njk +79 -0
  65. package/prompts/fix-loop.njk +18 -0
  66. package/prompts/pr.njk +1 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js +0 -545
  69. package/dist/commands/github/auth.d.ts +0 -2
  70. package/dist/commands/github/auth.js +0 -56
  71. package/dist/commands/github/index.d.ts +0 -1
  72. package/dist/commands/github/index.js +0 -1
  73. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  74. package/dist/commands/helpers/english-tutor/index.js +0 -1
  75. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  76. package/dist/commands/helpers/english-tutor/install.js +0 -24
  77. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  78. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  79. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  80. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  81. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  82. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  83. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  84. package/dist/commands/helpers/session-signal/end.js +0 -13
  85. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  86. package/dist/commands/helpers/session-signal/index.js +0 -1
  87. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  88. package/dist/commands/helpers/session-signal/install.js +0 -24
  89. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  90. package/dist/commands/helpers/session-signal/notification.js +0 -13
  91. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  92. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  93. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  94. package/dist/commands/helpers/session-signal/stop.js +0 -13
  95. package/dist/commands/issue/setup.d.ts +0 -2
  96. package/dist/commands/issue/setup.js +0 -108
  97. package/dist/commands/issue/switch.d.ts +0 -12
  98. package/dist/commands/issue/switch.js +0 -38
  99. package/dist/commands/linear/switch.d.ts +0 -2
  100. package/dist/commands/linear/switch.js +0 -75
  101. package/dist/commands/setup.d.ts +0 -11
  102. package/dist/commands/setup.js +0 -194
  103. package/dist/lib/english-tutor.d.ts +0 -13
  104. package/dist/lib/english-tutor.js +0 -125
  105. package/dist/lib/session-signal.d.ts +0 -16
  106. package/dist/lib/session-signal.js +0 -104
  107. package/dist/lib/setup/shell-config.d.ts +0 -38
  108. package/dist/lib/setup/shell-config.js +0 -131
  109. package/prompts/english-tutor-prompt.njk +0 -15
@@ -1,15 +1,53 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
6
- export const description = "Fix PR review comments";
7
- export default function Fix() {
8
- const [status, setStatus] = useState("loading");
5
+ import { z } from "zod";
6
+ import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages } from "../../lib/ai.js";
7
+ import { findMainRepoRoot, getCurrentBranch, extractTicketId } from "../../lib/git.js";
8
+ import { getPRInfoAsync } from "../../lib/github.js";
9
+ import { santreeSelfArgv } from "../../lib/setup/apply.js";
10
+ import { startFixLoop, signalFixLoop } from "../../lib/fix-loop.js";
11
+ export const description = "Run the self-driving fix loop: merge conflicts, fixable CI, and 👍-approved review comments";
12
+ export const options = z.object({
13
+ // Accepted for backward compatibility (the dashboard passes it). The fix loop
14
+ // is now the only flow, so this is always on.
15
+ loop: z.boolean().default(true).describe("Deprecated: the fix loop is always on"),
16
+ signal: z
17
+ .string()
18
+ .optional()
19
+ .describe("Internal: update this ticket's fix-loop dashboard marker, then exit"),
20
+ });
21
+ const KNOWN_STATUSES = new Set([
22
+ "running",
23
+ "merging",
24
+ "fixing",
25
+ "waiting-ci",
26
+ "stopped:clean",
27
+ "stopped:stuck",
28
+ ]);
29
+ export default function Fix({ options: opts }) {
30
+ // --signal is a quiet internal marker update — start in its own phase so the
31
+ // full "Fix PR" UI never flashes before the process exits.
32
+ const [status, setStatus] = useState(opts.signal !== undefined ? "signaled" : "loading");
9
33
  const [branch, setBranch] = useState(null);
10
34
  const [ticketId, setTicketId] = useState(null);
11
35
  const [error, setError] = useState(null);
12
36
  useEffect(() => {
37
+ // --signal: lightweight marker update invoked by the loop body. Resolve
38
+ // repo/ticket offline (no tracker fetch) and exit fast.
39
+ if (opts.signal !== undefined) {
40
+ const mainRoot = findMainRepoRoot();
41
+ const br = getCurrentBranch();
42
+ const tid = br ? extractTicketId(br) : null;
43
+ if (mainRoot && tid) {
44
+ const s = (KNOWN_STATUSES.has(opts.signal) ? opts.signal : "running");
45
+ signalFixLoop(mainRoot, tid, s);
46
+ }
47
+ setStatus("signaled");
48
+ setTimeout(() => process.exit(0), 20);
49
+ return;
50
+ }
13
51
  async function init() {
14
52
  await new Promise((r) => setTimeout(r, 100));
15
53
  setStatus("fetching");
@@ -22,18 +60,29 @@ export default function Fix() {
22
60
  const ctx = result.context;
23
61
  setBranch(ctx.branch);
24
62
  setTicketId(ctx.ticketId);
25
- const prFeedback = await fetchAndRenderPR(ctx.branch);
26
- if (!prFeedback) {
63
+ const prInfo = await getPRInfoAsync(ctx.branch);
64
+ if (!prInfo) {
27
65
  setStatus("error");
28
66
  setError(`No pull request found for branch '${ctx.branch}'`);
29
67
  return;
30
68
  }
31
- const diffContent = await fetchAndRenderDiff(ctx.branch);
32
69
  setStatus("launching");
33
- const prompt = renderAIPrompt("fix-pr", ctx, {
34
- pr_feedback: prFeedback,
35
- diff_content: diffContent,
70
+ // The loop refreshes its own context each iteration via `pr context`,
71
+ // so we only build the standing instructions here. Use an absolute
72
+ // santree invocation (the new window's shell may not have it on PATH).
73
+ const self = santreeSelfArgv([]);
74
+ const santreeCmd = [self.cmd, ...self.args].join(" ");
75
+ const body = renderAIPrompt("fix-loop", ctx, {
76
+ branch: ctx.branch,
77
+ santree_cmd: santreeCmd,
36
78
  });
79
+ // No interval → `/loop` self-paces via ScheduleWakeup (keeps context
80
+ // across iterations and can genuinely stop on our conditions). Passing
81
+ // an interval (`/loop 5m …`) instead picks cron mode — fresh context
82
+ // each firing, can't self-terminate — which is wrong for a fix loop.
83
+ const prompt = `/loop ${body}`;
84
+ if (ctx.ticketId)
85
+ startFixLoop(ctx.mainRoot, ctx.ticketId, 5);
37
86
  try {
38
87
  const child = launchAgent(prompt);
39
88
  child.on("error", (err) => {
@@ -54,5 +103,8 @@ export default function Fix() {
54
103
  }
55
104
  init();
56
105
  }, []);
57
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", bold: true, children: " fix PR " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" claude ", `"<fix-pr prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
106
+ if (status === "signaled") {
107
+ return _jsxs(Text, { dimColor: true, children: ["fix-loop signal: ", opts.signal] });
108
+ }
109
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR \u2014 loop" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", bold: true, children: " fix loop " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude (looping every 5 min)..." }), _jsx(Text, { dimColor: true, children: ` claude "/loop <fix-loop for ${ticketId}>"` })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
58
110
  }
@@ -56,7 +56,12 @@ export default function Create({ options, args }) {
56
56
  if (options.work) {
57
57
  runCommand = options.plan ? "santree worktree work --plan" : "santree worktree work";
58
58
  }
59
- const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
59
+ const result = await mux.createWindow({
60
+ name: windowName,
61
+ cwd: path,
62
+ command: runCommand,
63
+ tabName: runCommand ? "work" : undefined,
64
+ });
60
65
  if (!result.ok) {
61
66
  setMessage(`Worktree created, but failed to create window${result.message ? `: ${result.message}` : ""}`);
62
67
  setStatus("done");
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import { z } from "zod";
5
5
  import { findMainRepoRoot } from "../../lib/git.js";
6
+ import { getConfiguredEditor } from "../../lib/config-store.js";
6
7
  import { execSync, spawn } from "child_process";
7
8
  import * as fs from "fs";
8
9
  import * as path from "path";
@@ -38,8 +39,8 @@ export default function Open({ options: opts }) {
38
39
  });
39
40
  return;
40
41
  }
41
- // Resolve editor: --editor flag > SANTREE_EDITOR env > "code" default
42
- const editor = opts.editor || process.env.SANTREE_EDITOR || "code";
42
+ // Resolve editor: --editor flag > santree config editor (or SANTREE_EDITOR) > "code"
43
+ const editor = opts.editor || getConfiguredEditor() || "code";
43
44
  // Validate editor exists in PATH
44
45
  try {
45
46
  execSync(`which ${editor}`, { stdio: "ignore" });
package/dist/lib/ai.d.ts CHANGED
@@ -33,6 +33,17 @@ export declare function renderAIPrompt(template: string, ctx: AIContext, extra?:
33
33
  * Returns rendered markdown or null if no PR exists.
34
34
  */
35
35
  export declare function fetchAndRenderPR(branch: string): Promise<string | null>;
36
+ /**
37
+ * Fetch and render the self-contained per-iteration brief the auto-fix loop
38
+ * consumes — state AND the exact actions to take this iteration. It covers
39
+ * conflict status vs. the base branch, failing checks tagged fixable/manual
40
+ * (logs only for the fixable ones, to keep it tight), the 👍-approved review
41
+ * threads, and a single computed `directive` telling the loop what to do (merge
42
+ * / work / wait / stop). The loop just runs this and obeys. `santreeCmd` is the
43
+ * absolute santree invocation embedded in the `--signal` / resolve commands.
44
+ * Returns null when no PR exists for the branch.
45
+ */
46
+ export declare function fetchAndRenderFixContext(branch: string, santreeCmd: string): Promise<string | null>;
36
47
  /**
37
48
  * Fetch and render diff for a branch against its base branch (async, non-blocking).
38
49
  * Returns rendered markdown.
package/dist/lib/ai.js CHANGED
@@ -3,9 +3,9 @@ import { existsSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir, tmpdir } from "os";
5
5
  import { getCurrentBranch, findRepoRoot, findMainRepoRoot, getBaseBranch } from "./git.js";
6
- import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
6
+ import { renderPrompt, renderTicket, renderDiff, renderPR, renderFixContext, } from "./prompts.js";
7
7
  import { getIssueTracker } from "./trackers/index.js";
8
- import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRReviewCommentsAsync, getPRConversationCommentsAsync, getFailedCheckDetailsAsync, } from "./github.js";
8
+ import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRReviewCommentsAsync, getPRReviewThreadsAsync, getApprovedReviewThreads, getViewerLoginAsync, getPRConversationCommentsAsync, getFailedCheckDetailsAsync, getPRMergeStateAsync, classifyCheck, } from "./github.js";
9
9
  import { runAsync } from "./exec.js";
10
10
  /**
11
11
  * Resolves repo, branch, issue identifier, and fetches the issue from the
@@ -96,6 +96,75 @@ export async function fetchAndRenderPR(branch) {
96
96
  conversation_comments: conversationComments,
97
97
  });
98
98
  }
99
+ /**
100
+ * Fetch and render the self-contained per-iteration brief the auto-fix loop
101
+ * consumes — state AND the exact actions to take this iteration. It covers
102
+ * conflict status vs. the base branch, failing checks tagged fixable/manual
103
+ * (logs only for the fixable ones, to keep it tight), the 👍-approved review
104
+ * threads, and a single computed `directive` telling the loop what to do (merge
105
+ * / work / wait / stop). The loop just runs this and obeys. `santreeCmd` is the
106
+ * absolute santree invocation embedded in the `--signal` / resolve commands.
107
+ * Returns null when no PR exists for the branch.
108
+ */
109
+ export async function fetchAndRenderFixContext(branch, santreeCmd) {
110
+ const prInfo = await getPRInfoAsync(branch);
111
+ if (!prInfo)
112
+ return null;
113
+ const baseBranch = getBaseBranch(branch);
114
+ const [checks, reviews, threads, mergeState, viewer] = await Promise.all([
115
+ getPRChecksAsync(prInfo.number),
116
+ getPRReviewsAsync(prInfo.number),
117
+ getPRReviewThreadsAsync(prInfo.number),
118
+ getPRMergeStateAsync(branch),
119
+ getViewerLoginAsync(),
120
+ ]);
121
+ const failing = (checks ?? []).filter((c) => c.bucket === "fail");
122
+ const failedDetails = await Promise.all(failing.map((c) => getFailedCheckDetailsAsync(c)));
123
+ const failed_checks = failedDetails.map((d) => ({
124
+ ...d,
125
+ fixable: classifyCheck(d) === "fixable",
126
+ }));
127
+ const fixable_count = failed_checks.filter((c) => c.fixable).length;
128
+ const manual_count = failed_checks.filter((c) => !c.fixable).length;
129
+ // Only genuinely-queued/running checks count as pending — `skipping`/`cancel`
130
+ // won't run under the current conditions, so the loop must not wait on them.
131
+ const pending_count = (checks ?? []).filter((c) => c.bucket === "pending").length;
132
+ // Only surface review comments the viewer has 👍-approved on an unresolved
133
+ // thread — resolved threads are skipped, and unapproved ones wait for approval.
134
+ const approved_comments = viewer && threads ? getApprovedReviewThreads(threads, viewer) : [];
135
+ const conflicts = mergeState?.hasConflicts ?? false;
136
+ const mergeable = mergeState?.mergeable ?? "UNKNOWN";
137
+ // The single recommended action, in priority order. Conflicts block CI, so
138
+ // resolve them first; otherwise do any fixable work; otherwise wait for CI (or
139
+ // for GitHub to finish computing mergeability) rather than declaring done;
140
+ // otherwise stop — stuck if only manual failures remain, else clean.
141
+ const directive = conflicts
142
+ ? "merge"
143
+ : fixable_count > 0 || approved_comments.length > 0
144
+ ? "work"
145
+ : pending_count > 0 || mergeable === "UNKNOWN"
146
+ ? "wait"
147
+ : manual_count > 0
148
+ ? "stop-stuck"
149
+ : "stop-clean";
150
+ return renderFixContext({
151
+ pr_number: prInfo.number,
152
+ pr_url: prInfo.url ?? "",
153
+ branch,
154
+ base_branch: baseBranch,
155
+ conflicts,
156
+ mergeable,
157
+ merge_state: mergeState?.mergeStateStatus ?? "UNKNOWN",
158
+ failed_checks,
159
+ fixable_count,
160
+ manual_count,
161
+ pending_count,
162
+ reviews,
163
+ approved_comments,
164
+ directive,
165
+ santree_cmd: santreeCmd,
166
+ });
167
+ }
99
168
  /**
100
169
  * Fetch and render diff for a branch against its base branch (async, non-blocking).
101
170
  * Returns rendered markdown.
@@ -4,9 +4,23 @@ export declare function readJsonSafe(filePath: string): Record<string, any>;
4
4
  export declare function getStatuslineCommand(): string | undefined;
5
5
  export declare function isStatuslineConfigured(): boolean;
6
6
  export declare function configureStatusline(): string;
7
+ /**
8
+ * Remove santree's statusline. Only clears `statusLine` when it's actually
9
+ * pointing at santree — a user's hand-rolled statusline is left untouched.
10
+ */
11
+ export declare function removeStatusline(): string;
7
12
  export declare function isRemoteControlEnabled(): boolean;
8
13
  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;
14
+ /** Revert remote-control to the default (off) by dropping the opt-in flag. */
15
+ export declare function disableRemoteControl(): string;
16
+ /**
17
+ * Strip any leftover session-signal hook entries from settings.json. The
18
+ * session-state feature (and its `santree helpers session-signal` command) was
19
+ * removed, so these hooks now invoke a command that no longer exists and fire
20
+ * on every Claude event. Removing them is always safe: we only touch entries
21
+ * whose command mentions `session-signal`, leaving every other hook on the
22
+ * shared events untouched. Returns the number of hook entries removed so
23
+ * callers can report it. Self-healing: running `santree config` once cleans up
24
+ * an existing install.
25
+ */
26
+ export declare function pruneSessionSignalHooks(): number;
@@ -6,8 +6,9 @@ import * as path from "path";
6
6
  * - ~/.claude/settings.json (statusline + hooks)
7
7
  * - ~/.claude.json (remote control)
8
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.
9
+ * `santree config` (both the `--check` report and the interactive panel) goes
10
+ * through here so detection and configuration can never disagree about what
11
+ * "configured" means.
11
12
  */
12
13
  const STATUSLINE_COMMAND = "santree helpers statusline";
13
14
  export function claudeSettingsPath() {
@@ -48,6 +49,19 @@ export function configureStatusline() {
48
49
  writeJson(settingsPath, settings);
49
50
  return settingsPath;
50
51
  }
52
+ /**
53
+ * Remove santree's statusline. Only clears `statusLine` when it's actually
54
+ * pointing at santree — a user's hand-rolled statusline is left untouched.
55
+ */
56
+ export function removeStatusline() {
57
+ const settingsPath = claudeSettingsPath();
58
+ if (!isStatuslineConfigured())
59
+ return settingsPath;
60
+ const settings = readJsonSafe(settingsPath);
61
+ delete settings.statusLine;
62
+ writeJson(settingsPath, settings);
63
+ return settingsPath;
64
+ }
51
65
  // ── Remote control ────────────────────────────────────────────────────────────
52
66
  export function isRemoteControlEnabled() {
53
67
  return readJsonSafe(claudeConfigPath()).remoteControlAtStartup === true;
@@ -59,28 +73,49 @@ export function enableRemoteControl() {
59
73
  writeJson(configPath, config);
60
74
  return configPath;
61
75
  }
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);
76
+ /** Revert remote-control to the default (off) by dropping the opt-in flag. */
77
+ export function disableRemoteControl() {
78
+ const configPath = claudeConfigPath();
79
+ const config = readJsonSafe(configPath);
80
+ delete config.remoteControlAtStartup;
81
+ writeJson(configPath, config);
82
+ return configPath;
83
+ }
84
+ // ── Legacy cleanup ────────────────────────────────────────────────────────────
85
+ /**
86
+ * Strip any leftover session-signal hook entries from settings.json. The
87
+ * session-state feature (and its `santree helpers session-signal` command) was
88
+ * removed, so these hooks now invoke a command that no longer exists and fire
89
+ * on every Claude event. Removing them is always safe: we only touch entries
90
+ * whose command mentions `session-signal`, leaving every other hook on the
91
+ * shared events untouched. Returns the number of hook entries removed so
92
+ * callers can report it. Self-healing: running `santree config` once cleans up
93
+ * an existing install.
94
+ */
95
+ export function pruneSessionSignalHooks() {
96
+ const settingsPath = claudeSettingsPath();
97
+ const settings = readJsonSafe(settingsPath);
98
+ const hooks = settings.hooks;
99
+ if (!hooks || typeof hooks !== "object")
100
+ return 0;
101
+ let removed = 0;
102
+ for (const event of Object.keys(hooks)) {
103
+ const arr = hooks[event];
104
+ if (!Array.isArray(arr))
73
105
  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"));
106
+ const kept = arr.filter((entry) => {
107
+ const inner = Array.isArray(entry.hooks) ? entry.hooks : [];
108
+ const isSignal = inner.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
109
+ if (isSignal)
110
+ removed++;
111
+ return !isSignal;
78
112
  });
79
- if (!found)
80
- missing.push(event);
113
+ if (kept.length === 0)
114
+ delete hooks[event];
115
+ else
116
+ hooks[event] = kept;
81
117
  }
82
- return missing;
83
- }
84
- export function isSessionSignalConfigured() {
85
- return missingSessionSignalHooks().length === 0;
118
+ if (removed > 0)
119
+ writeJson(settingsPath, settings);
120
+ return removed;
86
121
  }
@@ -0,0 +1,10 @@
1
+ import type { IssueTrackerKind } from "../trackers/types.js";
2
+ interface Props {
3
+ repoRoot: string;
4
+ activeKind: IssueTrackerKind | null;
5
+ activeOrg: string | null;
6
+ onApplied: (message: string) => void;
7
+ onCancel: () => void;
8
+ }
9
+ export default function TrackerPicker({ repoRoot, activeKind, activeOrg, onApplied, onCancel, }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Text, Box, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { setRepoTracker, getIssueTracker } from "../trackers/index.js";
6
+ import { setRepoLinearOrg, startOAuthFlow } from "../trackers/linear/index.js";
7
+ import { readLinearAuthStore } from "../trackers/auth-store.js";
8
+ import { getAuthenticatedUser, getCurrentRepoNwo } from "../trackers/github/auth.js";
9
+ const AUTH_NEW = "__auth_new__";
10
+ const TRACKERS = [
11
+ { kind: "local", label: "Local", hint: "built-in, file-based — no account needed" },
12
+ { kind: "linear", label: "Linear", hint: "OAuth workspace" },
13
+ { kind: "github", label: "GitHub", hint: "GitHub Issues via gh CLI" },
14
+ ];
15
+ export default function TrackerPicker({ repoRoot, activeKind, activeOrg, onApplied, onCancel, }) {
16
+ const [phase, setPhase] = useState("pick-tracker");
17
+ const [error, setError] = useState(null);
18
+ const startIdx = Math.max(0, TRACKERS.findIndex((t) => t.kind === activeKind));
19
+ const [trackerIdx, setTrackerIdx] = useState(startIdx);
20
+ const [orgs, setOrgs] = useState([]);
21
+ const [orgIdx, setOrgIdx] = useState(0);
22
+ function authenticateNewLinear() {
23
+ setPhase("authenticating");
24
+ startOAuthFlow().then((result) => {
25
+ if (!result) {
26
+ setError("Authentication failed or timed out. Please try again.");
27
+ setPhase("error");
28
+ return;
29
+ }
30
+ setRepoLinearOrg(repoRoot, result.orgSlug);
31
+ setRepoTracker(repoRoot, "linear");
32
+ onApplied(`Authenticated as ${result.orgName} (${result.orgSlug})`);
33
+ });
34
+ }
35
+ async function chooseTracker(kind) {
36
+ if (kind === "local") {
37
+ setRepoTracker(repoRoot, "local");
38
+ onApplied(`Active tracker: ${getIssueTracker(repoRoot).displayName}`);
39
+ return;
40
+ }
41
+ if (kind === "linear") {
42
+ const store = readLinearAuthStore();
43
+ const list = Object.entries(store).map(([slug, tokens]) => ({
44
+ slug,
45
+ name: tokens.org_name,
46
+ }));
47
+ if (list.length === 0) {
48
+ authenticateNewLinear();
49
+ return;
50
+ }
51
+ setOrgs(list);
52
+ const cur = list.findIndex((o) => o.slug === activeOrg);
53
+ setOrgIdx(cur >= 0 ? cur : 0);
54
+ setPhase("pick-org");
55
+ return;
56
+ }
57
+ // github
58
+ setPhase("checking");
59
+ const user = await getAuthenticatedUser();
60
+ if (!user) {
61
+ setError("GitHub CLI not authenticated. Run: gh auth login");
62
+ setPhase("error");
63
+ return;
64
+ }
65
+ setRepoTracker(repoRoot, "github");
66
+ const nwo = await getCurrentRepoNwo(repoRoot);
67
+ onApplied(`Active tracker: GitHub (@${user.login}${nwo ? ` · ${nwo}` : ""})`);
68
+ }
69
+ useInput((_input, key) => {
70
+ if (phase === "error") {
71
+ // Any key returns to the tracker menu.
72
+ setError(null);
73
+ setPhase("pick-tracker");
74
+ return;
75
+ }
76
+ if (phase === "pick-tracker") {
77
+ if (key.escape || key.leftArrow)
78
+ onCancel();
79
+ else if (key.upArrow)
80
+ setTrackerIdx((i) => Math.max(0, i - 1));
81
+ else if (key.downArrow)
82
+ setTrackerIdx((i) => Math.min(TRACKERS.length - 1, i + 1));
83
+ else if (key.return)
84
+ void chooseTracker(TRACKERS[trackerIdx].kind);
85
+ }
86
+ else if (phase === "pick-org") {
87
+ const max = orgs.length; // index === orgs.length selects AUTH_NEW
88
+ if (key.leftArrow || key.escape)
89
+ setPhase("pick-tracker");
90
+ else if (key.upArrow)
91
+ setOrgIdx((i) => Math.max(0, i - 1));
92
+ else if (key.downArrow)
93
+ setOrgIdx((i) => Math.min(max, i + 1));
94
+ else if (key.return) {
95
+ if (orgIdx === orgs.length) {
96
+ authenticateNewLinear();
97
+ return;
98
+ }
99
+ const org = orgs[orgIdx];
100
+ setRepoLinearOrg(repoRoot, org.slug);
101
+ setRepoTracker(repoRoot, "linear");
102
+ onApplied(`Linked to ${org.name} (${org.slug})`);
103
+ }
104
+ }
105
+ });
106
+ return (_jsxs(Box, { flexDirection: "column", children: [phase === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking\u2026" })] })), phase === "authenticating" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Authenticating with Linear \u2014 follow the browser prompt\u2026" })] })), phase === "pick-tracker" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select the issue tracker for this repo:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: TRACKERS.map((t, i) => {
107
+ const active = t.kind === activeKind;
108
+ return (_jsxs(Text, { children: [i === trackerIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), t.label, active ? _jsx(Text, { color: "green", children: " \u25CF in use" }) : null, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", t.hint] })] }, t.kind));
109
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 Enter confirm \u00B7 \u2190 / Esc back" }) })] })), phase === "pick-org" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a Linear workspace to link:" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [orgs.map((org, i) => {
110
+ const active = org.slug === activeOrg && activeKind === "linear";
111
+ return (_jsxs(Text, { children: [i === orgIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), org.name, " (", org.slug, ")", active ? _jsx(Text, { color: "green", children: " \u25CF in use" }) : null] }, org.slug));
112
+ }), _jsxs(Text, { children: [orgIdx === orgs.length ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), _jsx(Text, { dimColor: true, children: "+ Authenticate a new workspace\u2026" })] }, AUTH_NEW)] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 \u2190 back \u00B7 Enter confirm" }) })] })), phase === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsx(Text, { dimColor: true, children: "Press any key to go back." })] }))] }));
113
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read-only diagnostics for `santree config`. These are the facts santree can
3
+ * *report* but not change from the panel (tool versions, update availability,
4
+ * auth state). The panel renders them as non-selectable info rows; `--check`
5
+ * prints them flat. Configurable settings live in `lib/setup/steps.ts` instead.
6
+ */
7
+ /** A read-only status row — santree reports it, you can't toggle it here. */
8
+ export interface InfoRow {
9
+ id: string;
10
+ title: string;
11
+ description: string;
12
+ scope: "system" | "global" | "repo";
13
+ required: boolean;
14
+ ok: boolean;
15
+ /** Dim sub-lines (version, path, account, …). */
16
+ lines: string[];
17
+ /** Yellow call-to-action when not ok. */
18
+ hint?: string;
19
+ }
20
+ /**
21
+ * Extra detail attached to a configurable step row by id (version / auth / a
22
+ * yellow update hint). Lets an already-configured step render doctor-style
23
+ * without the step catalog itself doing network I/O.
24
+ */
25
+ export interface StepDetail {
26
+ lines: string[];
27
+ hint?: string;
28
+ }
29
+ export interface DiagnosticsData {
30
+ infoRows: InfoRow[];
31
+ /** Keyed by SetupStep id (gh, claude, tracker). */
32
+ stepDetail: Map<string, StepDetail>;
33
+ /** True when an active multiplexer was detected (tmux/cmux). */
34
+ muxActive: boolean;
35
+ }
36
+ /** Run every read-only check. Network calls (npm registry, gh, tracker) run in parallel. */
37
+ export declare function loadDiagnostics(): Promise<DiagnosticsData>;