santree 0.3.0 → 0.5.0
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 +55 -2
- package/dist/commands/dashboard.js +538 -188
- package/dist/commands/doctor.js +164 -13
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/commands/worktree/diff.d.ts +13 -0
- package/dist/commands/worktree/diff.js +76 -0
- package/dist/lib/ai.d.ts +12 -2
- package/dist/lib/ai.js +48 -14
- package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/DetailPanel.js +235 -89
- package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
- package/dist/lib/dashboard/DiffOverlay.js +243 -0
- package/dist/lib/dashboard/IssueList.d.ts +20 -3
- package/dist/lib/dashboard/IssueList.js +74 -103
- package/dist/lib/dashboard/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
- package/dist/lib/dashboard/ReviewList.d.ts +3 -1
- package/dist/lib/dashboard/ReviewList.js +3 -3
- package/dist/lib/dashboard/data.js +14 -8
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/theme.d.ts +24 -0
- package/dist/lib/dashboard/theme.js +113 -0
- package/dist/lib/dashboard/types.d.ts +52 -1
- package/dist/lib/dashboard/types.js +81 -0
- package/dist/lib/git.d.ts +26 -4
- package/dist/lib/git.js +45 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
package/dist/commands/doctor.js
CHANGED
|
@@ -11,6 +11,9 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
const { version } = require("../../package.json");
|
|
12
12
|
import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.js";
|
|
13
13
|
import { getAuthStatus, getValidTokens } from "../lib/linear.js";
|
|
14
|
+
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
15
|
+
import { resolveClaudeBinary } from "../lib/ai.js";
|
|
16
|
+
import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, SANTREE_PACKAGE, getLatestVersionFor, isUpdateAvailable, detectPackageManager, getInstallCommandFor, } from "../lib/version.js";
|
|
14
17
|
const execAsync = promisify(exec);
|
|
15
18
|
export const description = "Check system requirements and integrations";
|
|
16
19
|
/**
|
|
@@ -55,6 +58,106 @@ async function checkTool(name, description, required, versionCommand, hint) {
|
|
|
55
58
|
path,
|
|
56
59
|
};
|
|
57
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
|
|
63
|
+
* binary is reachable. Surfaces a hint when the configured multiplexer can't run.
|
|
64
|
+
*/
|
|
65
|
+
async function checkMultiplexer() {
|
|
66
|
+
const mux = getMultiplexer();
|
|
67
|
+
const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
|
|
68
|
+
const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
|
|
69
|
+
if (mux.kind === "none") {
|
|
70
|
+
return {
|
|
71
|
+
name: "multiplexer",
|
|
72
|
+
description,
|
|
73
|
+
required: false,
|
|
74
|
+
installed: false,
|
|
75
|
+
hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (mux.kind === "tmux") {
|
|
79
|
+
const path = await getPath("tmux");
|
|
80
|
+
if (!path) {
|
|
81
|
+
return {
|
|
82
|
+
name: "tmux",
|
|
83
|
+
description,
|
|
84
|
+
required: false,
|
|
85
|
+
installed: false,
|
|
86
|
+
hint: "Install: brew install tmux",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const version = await tryExec("tmux -V");
|
|
90
|
+
return {
|
|
91
|
+
name: "tmux",
|
|
92
|
+
description,
|
|
93
|
+
required: false,
|
|
94
|
+
installed: true,
|
|
95
|
+
version: version || "unknown",
|
|
96
|
+
path,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// cmux
|
|
100
|
+
const path = await getPath("cmux");
|
|
101
|
+
if (!path) {
|
|
102
|
+
return {
|
|
103
|
+
name: "cmux",
|
|
104
|
+
description,
|
|
105
|
+
required: false,
|
|
106
|
+
installed: false,
|
|
107
|
+
hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const version = await tryExec("cmux --version 2>/dev/null");
|
|
111
|
+
const ping = await tryExec("cmux ping 2>/dev/null");
|
|
112
|
+
// Note: cmux #1472 (programmatic workspaces with dead PTYs) is a real
|
|
113
|
+
// limitation but only surfaces when a specific dashboard flow tries to
|
|
114
|
+
// auto-execute a command in a freshly-created workspace. Showing it on
|
|
115
|
+
// every doctor run made cmux look broken when it isn't — the limitation
|
|
116
|
+
// is documented in CLAUDE.md and the README. We only flag a hint here
|
|
117
|
+
// when cmux is actually unreachable.
|
|
118
|
+
return {
|
|
119
|
+
name: "cmux",
|
|
120
|
+
description,
|
|
121
|
+
required: false,
|
|
122
|
+
installed: !!ping,
|
|
123
|
+
version: version || "unknown",
|
|
124
|
+
path,
|
|
125
|
+
hint: !ping
|
|
126
|
+
? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux."
|
|
127
|
+
: undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Checks the Claude CLI, preferring cmux's bundled binary when running inside
|
|
132
|
+
* cmux. The standard `checkTool` uses `which claude` which can't locate the
|
|
133
|
+
* cmux shim at /Applications/cmux.app/Contents/Resources/bin/claude — that
|
|
134
|
+
* binary isn't on PATH. See manaflow-ai/cmux#2048.
|
|
135
|
+
*/
|
|
136
|
+
async function checkClaude() {
|
|
137
|
+
const resolved = resolveClaudeBinary();
|
|
138
|
+
const inCmux = getMultiplexer().kind === "cmux";
|
|
139
|
+
const description = inCmux ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
|
|
140
|
+
if (!resolved) {
|
|
141
|
+
return {
|
|
142
|
+
name: "claude",
|
|
143
|
+
description,
|
|
144
|
+
required: true,
|
|
145
|
+
installed: false,
|
|
146
|
+
hint: inCmux
|
|
147
|
+
? "Open cmux.app to install its bundled Claude, or install standalone: npm install -g @anthropic-ai/claude-code"
|
|
148
|
+
: "Install: npm install -g @anthropic-ai/claude-code",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const version = await tryExec(`"${resolved}" --version 2>/dev/null | head -1`);
|
|
152
|
+
return {
|
|
153
|
+
name: "claude",
|
|
154
|
+
description,
|
|
155
|
+
required: true,
|
|
156
|
+
installed: true,
|
|
157
|
+
version: version || "unknown",
|
|
158
|
+
path: resolved,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
58
161
|
/**
|
|
59
162
|
* Checks GitHub CLI auth status.
|
|
60
163
|
* Uses `gh api user` which works across all gh versions.
|
|
@@ -346,7 +449,7 @@ function StatusIcon({ ok, required }) {
|
|
|
346
449
|
return required ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "yellow", children: "\u25CB" });
|
|
347
450
|
}
|
|
348
451
|
function ToolRow({ tool }) {
|
|
349
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
|
|
452
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.latestVersion && tool.version && (_jsxs(Text, { color: isUpdateAvailable(tool.version, tool.latestVersion) ? "yellow" : undefined, dimColor: !isUpdateAvailable(tool.version, tool.latestVersion), children: ["Latest: ", tool.latestVersion, isUpdateAvailable(tool.version, tool.latestVersion) ? " ⬆ update available" : ""] })), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.updateHint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.updateHint] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
|
|
350
453
|
}
|
|
351
454
|
function LinearRow({ linear }) {
|
|
352
455
|
const isOk = linear.authenticated && linear.tokenValid && linear.repoLinked;
|
|
@@ -396,30 +499,78 @@ export default function Doctor() {
|
|
|
396
499
|
const [loading, setLoading] = useState(true);
|
|
397
500
|
useEffect(() => {
|
|
398
501
|
async function runChecks() {
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
502
|
+
const pm = detectPackageManager();
|
|
503
|
+
const [results, latestSantree, latestClaude] = await Promise.all([
|
|
504
|
+
Promise.all([
|
|
505
|
+
checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
|
|
506
|
+
checkGhAuth(),
|
|
507
|
+
checkMultiplexer(),
|
|
508
|
+
checkClaude(),
|
|
509
|
+
]),
|
|
510
|
+
getLatestVersionFor(SANTREE_PACKAGE),
|
|
511
|
+
getLatestVersionFor(CLAUDE_CODE_PACKAGE),
|
|
404
512
|
]);
|
|
405
|
-
//
|
|
513
|
+
// Synthetic row for santree itself — surfaces update status.
|
|
514
|
+
const santreeRow = {
|
|
515
|
+
name: "santree",
|
|
516
|
+
description: "Santree CLI (this app)",
|
|
517
|
+
required: true,
|
|
518
|
+
installed: true,
|
|
519
|
+
version: CURRENT_VERSION,
|
|
520
|
+
latestVersion: latestSantree ?? undefined,
|
|
521
|
+
updateHint: latestSantree && isUpdateAvailable(CURRENT_VERSION, latestSantree)
|
|
522
|
+
? "Run: santree update"
|
|
523
|
+
: undefined,
|
|
524
|
+
};
|
|
525
|
+
results.unshift(santreeRow);
|
|
526
|
+
// Augment the claude row with latest-version info from npm registry.
|
|
527
|
+
// When the resolved binary is the cmux-bundled one, npm install can't
|
|
528
|
+
// update it — the bundled binary is shipped inside cmux.app. Show a
|
|
529
|
+
// cmux-aware hint instead of the generic npm command.
|
|
530
|
+
const claudeRow = results.find((r) => r.name === "claude");
|
|
531
|
+
if (claudeRow && claudeRow.installed && latestClaude) {
|
|
532
|
+
claudeRow.latestVersion = latestClaude;
|
|
533
|
+
if (claudeRow.version && isUpdateAvailable(claudeRow.version, latestClaude)) {
|
|
534
|
+
const isCmuxBundled = !!claudeRow.path?.includes("/cmux.app/");
|
|
535
|
+
if (isCmuxBundled) {
|
|
536
|
+
claudeRow.updateHint = "Bundled with cmux — update cmux.app to get the latest Claude.";
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
const cmd = getInstallCommandFor(pm, `${CLAUDE_CODE_PACKAGE}@latest`);
|
|
540
|
+
claudeRow.updateHint = `Run: ${cmd.display}`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Optional: a syntax-highlighted diff pager — used by `st worktree diff`
|
|
545
|
+
// and the dashboard `v` overlay when SANTREE_DIFF_TOOL is set. Any
|
|
546
|
+
// pager works (delta, diff-so-fancy, …); without one set, git's
|
|
547
|
+
// default pager runs. Delta is the most popular choice so we check
|
|
548
|
+
// for it as a convenience, but it is never a hard dependency.
|
|
549
|
+
const deltaCheck = await checkTool("delta", "Recommended diff pager — any pager works", false, "delta --version | head -1", "Optional — git's default pager works too. Set SANTREE_DIFF_TOOL or `git config core.pager <tool>`. To install delta: brew install git-delta");
|
|
550
|
+
results.push(deltaCheck);
|
|
551
|
+
// Optional: a `.code-workspace`-aware editor (VSCode or Cursor).
|
|
552
|
+
// Santree itself works with any editor via $SANTREE_EDITOR — this
|
|
553
|
+
// check exists only because the dashboard's `E workspace` shortcut
|
|
554
|
+
// needs an editor that understands `.code-workspace` files. Missing
|
|
555
|
+
// here just means the shortcut is hidden; everything else still works.
|
|
556
|
+
const workspaceEditorDesc = "Workspace editor (`E workspace` shortcut)";
|
|
406
557
|
const [codeCheck, cursorCheck] = await Promise.all([
|
|
407
|
-
checkTool("code",
|
|
408
|
-
checkTool("cursor",
|
|
558
|
+
checkTool("code", workspaceEditorDesc, false, "code --version | head -1", ""),
|
|
559
|
+
checkTool("cursor", workspaceEditorDesc, false, "cursor --version | head -1", ""),
|
|
409
560
|
]);
|
|
410
561
|
if (codeCheck.installed) {
|
|
411
|
-
results.push(
|
|
562
|
+
results.push(codeCheck);
|
|
412
563
|
}
|
|
413
564
|
else if (cursorCheck.installed) {
|
|
414
|
-
results.push(
|
|
565
|
+
results.push(cursorCheck);
|
|
415
566
|
}
|
|
416
567
|
else {
|
|
417
568
|
results.push({
|
|
418
569
|
name: "code/cursor",
|
|
419
|
-
description:
|
|
570
|
+
description: workspaceEditorDesc,
|
|
420
571
|
required: false,
|
|
421
572
|
installed: false,
|
|
422
|
-
hint: "
|
|
573
|
+
hint: "Optional — santree works with any $SANTREE_EDITOR. Only needed for the dashboard's `.code-workspace` shortcut.",
|
|
423
574
|
});
|
|
424
575
|
}
|
|
425
576
|
const linearResult = await checkLinearAuth();
|
|
@@ -132,9 +132,17 @@ function getGitChanges(cwd) {
|
|
|
132
132
|
untracked: countLines(git(cwd, "ls-files --others --exclude-standard")),
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
-
// Build a progress bar for context usage
|
|
135
|
+
// Build a progress bar for context usage.
|
|
136
|
+
//
|
|
137
|
+
// We deliberately inflate the displayed percentage by 20% (clamped to 100) so
|
|
138
|
+
// the bar fills up faster than the model's actual context window. The point is
|
|
139
|
+
// to nudge toward more-frequent /compact: the color thresholds (60%/80%) trip
|
|
140
|
+
// earlier, so the yellow/red warnings show up while there's still real headroom
|
|
141
|
+
// left to compact gracefully instead of after the model has already started
|
|
142
|
+
// dropping content.
|
|
143
|
+
const CONTEXT_DISPLAY_MULTIPLIER = 1.2;
|
|
136
144
|
function formatContextUsage(usedPercentage) {
|
|
137
|
-
const used = Math.round(usedPercentage);
|
|
145
|
+
const used = Math.min(100, Math.round(usedPercentage * CONTEXT_DISPLAY_MULTIPLIER));
|
|
138
146
|
const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
|
|
139
147
|
const width = 20;
|
|
140
148
|
const filled = Math.round((used * width) / 100);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
export declare const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
initial: z.ZodOptional<z.ZodString>;
|
|
5
|
+
from: z.ZodOptional<z.ZodString>;
|
|
6
|
+
ext: z.ZodDefault<z.ZodString>;
|
|
7
|
+
editor: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
type Props = {
|
|
10
|
+
options: z.infer<typeof options>;
|
|
11
|
+
};
|
|
12
|
+
export default function TextEditor({ options: opts }: Props): null;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useApp } from "ink";
|
|
3
|
+
import { option } from "pastel";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
export const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
|
|
10
|
+
export const options = z.object({
|
|
11
|
+
initial: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe(option({ description: "Pre-fill the editor buffer with this text" })),
|
|
15
|
+
from: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(option({ description: "Pre-fill the editor buffer with the contents of this file" })),
|
|
19
|
+
ext: z
|
|
20
|
+
.string()
|
|
21
|
+
.default("md")
|
|
22
|
+
.describe(option({ description: "Temp file extension (default: md)" })),
|
|
23
|
+
editor: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe(option({ description: "Override the editor command (default: $VISUAL || $EDITOR || vim)" })),
|
|
27
|
+
});
|
|
28
|
+
function resolveEditor(override) {
|
|
29
|
+
const raw = override ?? process.env["VISUAL"] ?? process.env["EDITOR"] ?? "vim";
|
|
30
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
31
|
+
const cmd = parts[0] ?? "vim";
|
|
32
|
+
return { cmd, args: parts.slice(1) };
|
|
33
|
+
}
|
|
34
|
+
// Render null and write all UI feedback to stderr so stdout stays clean for
|
|
35
|
+
// shell capture: `file=$(st helpers text-editor) && st worktree work --context-file "$file"`.
|
|
36
|
+
export default function TextEditor({ options: opts }) {
|
|
37
|
+
const { exit } = useApp();
|
|
38
|
+
const hasRun = useRef(false);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (hasRun.current)
|
|
41
|
+
return;
|
|
42
|
+
hasRun.current = true;
|
|
43
|
+
const ext = opts.ext.replace(/^\./, "");
|
|
44
|
+
const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext}`);
|
|
45
|
+
const seed = (() => {
|
|
46
|
+
if (opts.from) {
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(opts.from, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return opts.initial ?? "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return opts.initial ?? "";
|
|
55
|
+
})();
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(filePath, seed);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
process.stderr.write(`Failed to create temp file: ${err.message}\n`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
exit();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Ink put stdin in raw mode on mount; release it for the editor.
|
|
66
|
+
const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
|
|
67
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
68
|
+
try {
|
|
69
|
+
process.stdin.setRawMode(false);
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
const { cmd, args } = resolveEditor(opts.editor);
|
|
74
|
+
const result = spawnSync(cmd, [...args, filePath], { stdio: "inherit" });
|
|
75
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
76
|
+
try {
|
|
77
|
+
process.stdin.setRawMode(wasRaw);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
if (result.error || result.status !== 0) {
|
|
82
|
+
process.stderr.write(result.error
|
|
83
|
+
? `Failed to launch editor '${cmd}': ${result.error.message}\n`
|
|
84
|
+
: `Editor '${cmd}' exited with status ${result.status}\n`);
|
|
85
|
+
try {
|
|
86
|
+
fs.unlinkSync(filePath);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
exit();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let content = "";
|
|
94
|
+
try {
|
|
95
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
process.stderr.write(`Failed to read temp file: ${err.message}\n`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
exit();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Empty buffer => treat as cancel (matches `git commit` behavior)
|
|
104
|
+
if (content.trim().length === 0) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(filePath);
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
process.stderr.write("Cancelled (empty buffer)\n");
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
exit();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
process.stdout.write(`${filePath}\n`);
|
|
115
|
+
exit();
|
|
116
|
+
}, [opts, exit]);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const description = "Update santree to the latest version on npm";
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
pm: z.ZodOptional<z.ZodEnum<{
|
|
6
|
+
npm: "npm";
|
|
7
|
+
pnpm: "pnpm";
|
|
8
|
+
yarn: "yarn";
|
|
9
|
+
}>>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
type Props = {
|
|
12
|
+
options: z.infer<typeof options>;
|
|
13
|
+
};
|
|
14
|
+
export default function Update({ options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text, Box, useApp } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getLatestVersionFor, getInstalledClaudeVersion, isUpdateAvailable, detectPackageManager, getInstallCommand, getInstallCommandFor, } from "../lib/version.js";
|
|
7
|
+
import { spawnAsync } from "../lib/exec.js";
|
|
8
|
+
export const description = "Update santree to the latest version on npm";
|
|
9
|
+
export const options = z.object({
|
|
10
|
+
force: z.boolean().optional().describe("Reinstall even if already on the latest version"),
|
|
11
|
+
pm: z
|
|
12
|
+
.enum(["npm", "pnpm", "yarn"])
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Override package manager auto-detection"),
|
|
15
|
+
});
|
|
16
|
+
const TAIL_LINES = 8;
|
|
17
|
+
function tail(text, n) {
|
|
18
|
+
return text
|
|
19
|
+
.split("\n")
|
|
20
|
+
.filter((l) => l.length > 0)
|
|
21
|
+
.slice(-n);
|
|
22
|
+
}
|
|
23
|
+
export default function Update({ options }) {
|
|
24
|
+
const { exit } = useApp();
|
|
25
|
+
const [status, setStatus] = useState("checking");
|
|
26
|
+
const [latest, setLatest] = useState(null);
|
|
27
|
+
const [pm, setPm] = useState("npm");
|
|
28
|
+
const [installCmd, setInstallCmd] = useState("");
|
|
29
|
+
const [output, setOutput] = useState("");
|
|
30
|
+
const [error, setError] = useState(null);
|
|
31
|
+
const [claude, setClaude] = useState(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
(async () => {
|
|
34
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
35
|
+
// Check santree + claude versions in parallel.
|
|
36
|
+
const [latestVersion, latestClaude] = await Promise.all([
|
|
37
|
+
getLatestVersion({ force: true }),
|
|
38
|
+
getLatestVersionFor(CLAUDE_CODE_PACKAGE, { force: true }),
|
|
39
|
+
]);
|
|
40
|
+
setLatest(latestVersion);
|
|
41
|
+
setClaude({ installed: getInstalledClaudeVersion(), latest: latestClaude });
|
|
42
|
+
const detectedPm = options.pm ?? detectPackageManager();
|
|
43
|
+
setPm(detectedPm);
|
|
44
|
+
const cmd = getInstallCommand(detectedPm);
|
|
45
|
+
setInstallCmd(cmd.display);
|
|
46
|
+
if (!latestVersion) {
|
|
47
|
+
setStatus("error");
|
|
48
|
+
setError("Could not reach the npm registry. Check your connection.");
|
|
49
|
+
setTimeout(() => exit(), 100);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!options.force && !isUpdateAvailable(CURRENT_VERSION, latestVersion)) {
|
|
53
|
+
setStatus("up-to-date");
|
|
54
|
+
setTimeout(() => exit(), 100);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setStatus("installing");
|
|
58
|
+
const result = await spawnAsync(cmd.cmd, cmd.args, {
|
|
59
|
+
onOutput: (data) => setOutput(data),
|
|
60
|
+
});
|
|
61
|
+
if (result.code === 0) {
|
|
62
|
+
setStatus("done");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
setStatus("error");
|
|
66
|
+
setError(`${cmd.display} exited with code ${result.code}`);
|
|
67
|
+
}
|
|
68
|
+
setTimeout(() => exit(), 100);
|
|
69
|
+
})();
|
|
70
|
+
}, []);
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Santree Update" }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "current:" }), _jsxs(Text, { children: ["v", CURRENT_VERSION] })] }), latest && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "latest: " }), _jsxs(Text, { color: isUpdateAvailable(CURRENT_VERSION, latest) ? "yellow" : "green", children: ["v", latest] })] })), installCmd && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "via: " }), _jsxs(Text, { children: [installCmd, " ", _jsxs(Text, { dimColor: true, children: ["(detected: ", pm, ")"] })] })] }))] }), _jsxs(Box, { marginTop: 1, children: [status === "checking" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "Checking npm registry..." })] })), status === "installing" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: ["Running ", installCmd, "..."] })] })), status === "up-to-date" && (_jsx(Text, { color: "green", bold: true, children: "\u2713 Already on the latest version" })), status === "done" && latest && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Updated to v", latest] })), status === "error" && error && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] }), (status === "installing" || status === "error") && output && (_jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: tail(output, TAIL_LINES).map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) })), claude && claude.installed && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: claude.latest && isUpdateAvailable(claude.installed, claude.latest) ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: ["\u2B06 Claude Code ", claude.installed, " \u2192 ", claude.latest, " available"] }), _jsxs(Text, { dimColor: true, children: ["Run: ", getInstallCommandFor(pm, `${CLAUDE_CODE_PACKAGE}@latest`).display] })] })) : claude.latest ? (_jsxs(Text, { dimColor: true, children: ["\u2713 Claude Code ", claude.installed, " is up to date"] })) : null }))] }));
|
|
72
|
+
}
|
|
@@ -5,6 +5,7 @@ export declare const options: z.ZodObject<{
|
|
|
5
5
|
work: z.ZodOptional<z.ZodBoolean>;
|
|
6
6
|
plan: z.ZodOptional<z.ZodBoolean>;
|
|
7
7
|
"no-pull": z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
window: z.ZodOptional<z.ZodBoolean>;
|
|
8
9
|
tmux: z.ZodOptional<z.ZodBoolean>;
|
|
9
10
|
name: z.ZodOptional<z.ZodString>;
|
|
10
11
|
}, z.core.$strip>;
|
|
@@ -3,36 +3,21 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { Text, Box } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { execSync } from "child_process";
|
|
7
6
|
import * as fs from "fs";
|
|
8
7
|
import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../../lib/git.js";
|
|
9
8
|
import { spawnAsync } from "../../lib/exec.js";
|
|
9
|
+
import { getMultiplexer } from "../../lib/multiplexer/index.js";
|
|
10
10
|
export const description = "Create a new worktree from a branch";
|
|
11
11
|
export const options = z.object({
|
|
12
12
|
base: z.string().optional().describe("Base branch to create from"),
|
|
13
13
|
work: z.boolean().optional().describe("Launch Claude after creating"),
|
|
14
14
|
plan: z.boolean().optional().describe("With --work, only plan"),
|
|
15
15
|
"no-pull": z.boolean().optional().describe("Skip pulling latest changes"),
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
window: z.boolean().optional().describe("Create a new multiplexer window/workspace (tmux/cmux)"),
|
|
17
|
+
tmux: z.boolean().optional().describe("Alias for --window (deprecated)"),
|
|
18
|
+
name: z.string().optional().describe("Custom window/workspace name"),
|
|
18
19
|
});
|
|
19
20
|
export const args = z.tuple([z.string().optional().describe("Branch name")]);
|
|
20
|
-
function isInTmux() {
|
|
21
|
-
return !!process.env.TMUX;
|
|
22
|
-
}
|
|
23
|
-
function createTmuxWindow(name, path, runCommand) {
|
|
24
|
-
try {
|
|
25
|
-
execSync(`tmux new-window -n "${name}" -c "${path}"`, { stdio: "ignore" });
|
|
26
|
-
// If a command is provided, send it to the new window
|
|
27
|
-
if (runCommand) {
|
|
28
|
-
execSync(`tmux send-keys -t "${name}" "${runCommand}" Enter`, { stdio: "ignore" });
|
|
29
|
-
}
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
21
|
function getWindowName(branchName, customName) {
|
|
37
22
|
if (customName)
|
|
38
23
|
return customName;
|
|
@@ -50,35 +35,38 @@ export default function Create({ options, args }) {
|
|
|
50
35
|
const [message, setMessage] = useState("");
|
|
51
36
|
const [worktreePath, setWorktreePath] = useState("");
|
|
52
37
|
const [baseBranch, setBaseBranch] = useState(null);
|
|
53
|
-
const [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
38
|
+
const [muxWindowName, setMuxWindowName] = useState(null);
|
|
39
|
+
const [muxKind, setMuxKind] = useState(null);
|
|
40
|
+
async function finalize(path, branch) {
|
|
41
|
+
const wantsWindow = options.window || options.tmux;
|
|
42
|
+
if (wantsWindow) {
|
|
43
|
+
const mux = getMultiplexer();
|
|
44
|
+
if (!mux.isActive()) {
|
|
45
|
+
setMessage("Worktree created, but no active multiplexer");
|
|
59
46
|
setStatus("done");
|
|
60
47
|
console.log(`SANTREE_CD:${path}`);
|
|
61
48
|
return;
|
|
62
49
|
}
|
|
63
|
-
setStatus("
|
|
64
|
-
setMessage(
|
|
50
|
+
setStatus("spawning-window");
|
|
51
|
+
setMessage(`Creating ${mux.kind} window...`);
|
|
65
52
|
const windowName = getWindowName(branch, options.name);
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
setMuxWindowName(windowName);
|
|
54
|
+
setMuxKind(mux.kind);
|
|
68
55
|
let runCommand;
|
|
69
56
|
if (options.work) {
|
|
70
57
|
runCommand = options.plan ? "st worktree work --plan" : "st worktree work";
|
|
71
58
|
}
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
setMessage(`Worktree created, but failed to create ${mux.kind} window${result.message ? `: ${result.message}` : ""}`);
|
|
74
62
|
setStatus("done");
|
|
75
63
|
console.log(`SANTREE_CD:${path}`);
|
|
76
64
|
return;
|
|
77
65
|
}
|
|
78
66
|
setStatus("done");
|
|
79
67
|
const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
|
|
80
|
-
setMessage(`Worktree and
|
|
81
|
-
// Don't output SANTREE_CD when
|
|
68
|
+
setMessage(`Worktree and ${mux.kind} window created!${workInfo}`);
|
|
69
|
+
// Don't output SANTREE_CD when a window is created — user is already in the new window
|
|
82
70
|
return;
|
|
83
71
|
}
|
|
84
72
|
setStatus("done");
|
|
@@ -133,7 +121,7 @@ export default function Create({ options, args }) {
|
|
|
133
121
|
}
|
|
134
122
|
catch {
|
|
135
123
|
setMessage("Warning: Init script exists but is not executable");
|
|
136
|
-
finalize(result.path, branch);
|
|
124
|
+
await finalize(result.path, branch);
|
|
137
125
|
return;
|
|
138
126
|
}
|
|
139
127
|
const initResult = await spawnAsync(initScript, [], {
|
|
@@ -147,10 +135,10 @@ export default function Create({ options, args }) {
|
|
|
147
135
|
if (initResult.code !== 0) {
|
|
148
136
|
setMessage(`Warning: Init script exited with code ${initResult.code}`);
|
|
149
137
|
}
|
|
150
|
-
finalize(result.path, branch);
|
|
138
|
+
await finalize(result.path, branch);
|
|
151
139
|
}
|
|
152
140
|
else {
|
|
153
|
-
finalize(result.path, branch);
|
|
141
|
+
await finalize(result.path, branch);
|
|
154
142
|
}
|
|
155
143
|
}
|
|
156
144
|
else {
|
|
@@ -165,9 +153,13 @@ export default function Create({ options, args }) {
|
|
|
165
153
|
options.work,
|
|
166
154
|
options.plan,
|
|
167
155
|
options["no-pull"],
|
|
156
|
+
options.window,
|
|
168
157
|
options.tmux,
|
|
169
158
|
options.name,
|
|
170
159
|
]);
|
|
171
|
-
const isLoading = status === "pulling" ||
|
|
172
|
-
|
|
160
|
+
const isLoading = status === "pulling" ||
|
|
161
|
+
status === "creating" ||
|
|
162
|
+
status === "init-script" ||
|
|
163
|
+
status === "spawning-window";
|
|
164
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && (_jsxs(Text, { dimColor: true, children: [" ", muxKind ?? "tmux", " window: ", muxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
173
165
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const description = "View worktree diff against its base branch (uses delta if installed)";
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
staged: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
unstaged: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
commits: z.ZodOptional<z.ZodBoolean>;
|
|
7
|
+
base: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
type Props = {
|
|
10
|
+
options: z.infer<typeof options>;
|
|
11
|
+
};
|
|
12
|
+
export default function Diff({ options: opts }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
13
|
+
export {};
|