mintree 0.1.2
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 +188 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +849 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +327 -0
- package/dist/commands/helpers/index.d.ts +1 -0
- package/dist/commands/helpers/index.js +1 -0
- package/dist/commands/helpers/session-signal/end.d.ts +2 -0
- package/dist/commands/helpers/session-signal/end.js +9 -0
- package/dist/commands/helpers/session-signal/index.d.ts +1 -0
- package/dist/commands/helpers/session-signal/index.js +1 -0
- package/dist/commands/helpers/session-signal/install.d.ts +2 -0
- package/dist/commands/helpers/session-signal/install.js +25 -0
- package/dist/commands/helpers/session-signal/notification.d.ts +2 -0
- package/dist/commands/helpers/session-signal/notification.js +9 -0
- package/dist/commands/helpers/session-signal/prompt.d.ts +2 -0
- package/dist/commands/helpers/session-signal/prompt.js +9 -0
- package/dist/commands/helpers/session-signal/stop.d.ts +2 -0
- package/dist/commands/helpers/session-signal/stop.js +9 -0
- package/dist/commands/helpers/shell-init.d.ts +11 -0
- package/dist/commands/helpers/shell-init.js +111 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +6 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/worktree/clean.d.ts +11 -0
- package/dist/commands/worktree/clean.js +206 -0
- package/dist/commands/worktree/create.d.ts +18 -0
- package/dist/commands/worktree/create.js +93 -0
- package/dist/commands/worktree/index.d.ts +1 -0
- package/dist/commands/worktree/index.js +1 -0
- package/dist/commands/worktree/list.d.ts +10 -0
- package/dist/commands/worktree/list.js +143 -0
- package/dist/commands/worktree/remove.d.ts +12 -0
- package/dist/commands/worktree/remove.js +46 -0
- package/dist/commands/worktree/work.d.ts +15 -0
- package/dist/commands/worktree/work.js +192 -0
- package/dist/lib/branch.d.ts +26 -0
- package/dist/lib/branch.js +57 -0
- package/dist/lib/claude.d.ts +26 -0
- package/dist/lib/claude.js +67 -0
- package/dist/lib/dashboard.d.ts +50 -0
- package/dist/lib/dashboard.js +139 -0
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +15 -0
- package/dist/lib/git.d.ts +110 -0
- package/dist/lib/git.js +320 -0
- package/dist/lib/github.d.ts +7 -0
- package/dist/lib/github.js +15 -0
- package/dist/lib/markers.d.ts +21 -0
- package/dist/lib/markers.js +43 -0
- package/dist/lib/metadata.d.ts +18 -0
- package/dist/lib/metadata.js +44 -0
- package/dist/lib/session-signal.d.ts +63 -0
- package/dist/lib/session-signal.js +160 -0
- package/dist/lib/worktreeCreate.d.ts +36 -0
- package/dist/lib/worktreeCreate.js +184 -0
- package/dist/lib/worktreeRemove.d.ts +21 -0
- package/dist/lib/worktreeRemove.js +84 -0
- package/package.json +63 -0
- package/shell/init.bash +106 -0
- package/shell/init.zsh +125 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { argument, option } from "pastel";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { runRemove } from "../../lib/worktreeRemove.js";
|
|
8
|
+
export const description = "Remove a worktree (the branch and metadata are preserved so you can re-attach later)";
|
|
9
|
+
export const args = z.tuple([
|
|
10
|
+
z.string().describe(argument({
|
|
11
|
+
name: "branch",
|
|
12
|
+
description: "Branch whose worktree should be removed (in the same `<type>/<issue>-<desc>` format)",
|
|
13
|
+
})),
|
|
14
|
+
]);
|
|
15
|
+
export const options = z.object({
|
|
16
|
+
force: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.default(false)
|
|
19
|
+
.describe(option({
|
|
20
|
+
description: "Remove even if the worktree has uncommitted changes",
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
export default function Remove({ args, options }) {
|
|
24
|
+
const [branch] = args;
|
|
25
|
+
const [result, setResult] = useState(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
try {
|
|
29
|
+
setResult(runRemove(branch, options.force));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
setResult({
|
|
33
|
+
ok: false,
|
|
34
|
+
message: err instanceof Error ? err.message : String(err),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}, 0);
|
|
38
|
+
}, [branch, options.force]);
|
|
39
|
+
if (!result) {
|
|
40
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" Removing worktree for ", branch, "..."] })] }));
|
|
41
|
+
}
|
|
42
|
+
if (!result.ok) {
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
44
|
+
}
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed", " ", _jsxs(Text, { dimColor: true, children: ["(", result.worktreePath, ")"] })] }), result.wasDirty && (_jsx(Text, { color: "yellow", children: "\u21B3 forced past uncommitted changes" }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Branch ", _jsx(Text, { color: "cyan", children: result.branch }), " was preserved (use `git branch -D", " ", result.branch, "` to delete it)."] }), _jsx(Text, { dimColor: true, children: "Issue metadata (incl. session_id) was preserved for re-attach." })] })] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const description = "Launch Claude in the current worktree (creates or resumes a session)";
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
prompt: z.ZodOptional<z.ZodString>;
|
|
5
|
+
promptFile: z.ZodOptional<z.ZodString>;
|
|
6
|
+
permissionMode: z.ZodDefault<z.ZodEnum<{
|
|
7
|
+
default: "default";
|
|
8
|
+
auto: "auto";
|
|
9
|
+
}>>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
type Props = {
|
|
12
|
+
options: z.infer<typeof options>;
|
|
13
|
+
};
|
|
14
|
+
export default function Work({ options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { option } from "pastel";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { readFileSync, unlinkSync } from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { parseBranch, isParseError } from "../../lib/branch.js";
|
|
11
|
+
import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
|
|
12
|
+
import { getSessionId, setSessionId } from "../../lib/metadata.js";
|
|
13
|
+
import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
|
|
14
|
+
export const description = "Launch Claude in the current worktree (creates or resumes a session)";
|
|
15
|
+
export const options = z.object({
|
|
16
|
+
prompt: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe(option({
|
|
20
|
+
description: "Initial prompt injected as the first user message (literal, no templating)",
|
|
21
|
+
})),
|
|
22
|
+
promptFile: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(option({
|
|
26
|
+
description: "Read prompt from this file (deleted after read). Used by `worktree create --work` to bridge text from the create marker. Mutually exclusive with --prompt.",
|
|
27
|
+
})),
|
|
28
|
+
permissionMode: z
|
|
29
|
+
.enum(PERMISSION_MODES)
|
|
30
|
+
.default("default")
|
|
31
|
+
.describe(option({
|
|
32
|
+
description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")})`,
|
|
33
|
+
alias: "m",
|
|
34
|
+
})),
|
|
35
|
+
});
|
|
36
|
+
function resolve(cwd) {
|
|
37
|
+
const repoRoot = findMainRepoRoot(cwd);
|
|
38
|
+
if (!repoRoot) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
message: "Not in a git repository.",
|
|
42
|
+
hint: "Run `mintree worktree work` from inside a mintree worktree.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!pathExists(getMintreeDir(repoRoot))) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
message: ".mintree/ not found in this repo.",
|
|
49
|
+
hint: "Run `mintree init` first.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const worktreesDir = path.resolve(getWorktreesDir(repoRoot));
|
|
53
|
+
const cwdAbs = path.resolve(cwd);
|
|
54
|
+
const insideMintreeWorktree = cwdAbs === worktreesDir
|
|
55
|
+
? false
|
|
56
|
+
: cwdAbs.startsWith(worktreesDir + path.sep);
|
|
57
|
+
if (!insideMintreeWorktree) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
message: "This directory isn't a mintree worktree.",
|
|
61
|
+
hint: "Run `mintree worktree work` from inside `.mintree/worktrees/<issue>-<desc>`.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const branch = getCurrentBranch(cwdAbs);
|
|
65
|
+
if (!branch) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
message: "Could not determine the current branch (detached HEAD?)",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const parsed = parseBranch(branch);
|
|
72
|
+
if (isParseError(parsed)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
message: `Branch '${branch}' does not match the mintree convention.`,
|
|
76
|
+
hint: parsed.hint,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// The worktree path that git knows about is the *root* of this checkout.
|
|
80
|
+
// Walk up from cwd until we land directly under .mintree/worktrees/<name>.
|
|
81
|
+
const segmentBeneathWorktreesDir = cwdAbs.slice(worktreesDir.length + 1).split(path.sep)[0];
|
|
82
|
+
const worktreePath = segmentBeneathWorktreesDir
|
|
83
|
+
? path.join(worktreesDir, segmentBeneathWorktreesDir)
|
|
84
|
+
: cwdAbs;
|
|
85
|
+
const existing = getSessionId(repoRoot, parsed.issueId);
|
|
86
|
+
let sessionId;
|
|
87
|
+
let resume;
|
|
88
|
+
if (existing) {
|
|
89
|
+
sessionId = existing;
|
|
90
|
+
resume = true;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
sessionId = randomUUID();
|
|
94
|
+
setSessionId(repoRoot, parsed.issueId, sessionId);
|
|
95
|
+
resume = false;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
data: {
|
|
100
|
+
repoRoot,
|
|
101
|
+
worktreePath,
|
|
102
|
+
branch,
|
|
103
|
+
issueId: parsed.issueId,
|
|
104
|
+
sessionId,
|
|
105
|
+
resume,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export default function Work({ options }) {
|
|
110
|
+
const [state, setState] = useState({ phase: "loading" });
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
// Defer one tick so the spinner gets to render before sync work starts.
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
if (options.prompt && options.promptFile) {
|
|
115
|
+
setState({
|
|
116
|
+
phase: "error",
|
|
117
|
+
message: "--prompt and --prompt-file are mutually exclusive.",
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const result = resolve(process.cwd());
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
setState({ phase: "error", message: result.message, hint: result.hint });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
setState({ phase: "launching", resolved: result.data });
|
|
127
|
+
}, 0);
|
|
128
|
+
}, []);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (state.phase !== "launching")
|
|
131
|
+
return;
|
|
132
|
+
const { resolved } = state;
|
|
133
|
+
// --prompt-file handling: read once, delete the file. The file is the
|
|
134
|
+
// transport between `worktree create --work --prompt` and us — both
|
|
135
|
+
// sides own its cleanup so even a crash mid-handoff doesn't leave junk
|
|
136
|
+
// in /tmp forever (OS sweeps tmpdir eventually anyway).
|
|
137
|
+
let effectivePrompt = options.prompt;
|
|
138
|
+
if (options.promptFile) {
|
|
139
|
+
try {
|
|
140
|
+
effectivePrompt = readFileSync(options.promptFile, "utf-8");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Missing/unreadable — fall through with no prompt.
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(options.promptFile);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Cleanup failure is non-fatal.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const child = launchClaude({
|
|
154
|
+
permissionMode: options.permissionMode,
|
|
155
|
+
sessionId: resolved.sessionId,
|
|
156
|
+
resume: resolved.resume,
|
|
157
|
+
prompt: effectivePrompt,
|
|
158
|
+
cwd: resolved.worktreePath,
|
|
159
|
+
});
|
|
160
|
+
child.on("error", (err) => {
|
|
161
|
+
setState({
|
|
162
|
+
phase: "error",
|
|
163
|
+
message: `Failed to launch claude: ${err.message}`,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
child.on("close", (code) => {
|
|
167
|
+
process.exit(code ?? 0);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
setState({
|
|
172
|
+
phase: "error",
|
|
173
|
+
message: err instanceof Error ? err.message : String(err),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}, [state.phase, options.permissionMode, options.prompt]);
|
|
177
|
+
if (state.phase === "loading") {
|
|
178
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Resolving worktree..." })] }));
|
|
179
|
+
}
|
|
180
|
+
if (state.phase === "error") {
|
|
181
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) }))] }));
|
|
182
|
+
}
|
|
183
|
+
const { resolved } = state;
|
|
184
|
+
const sessionShort = resolved.sessionId.slice(0, 8);
|
|
185
|
+
const action = resolved.resume ? "resuming" : "starting";
|
|
186
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: options.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
|
|
187
|
+
}
|
|
188
|
+
function truncate(s, max) {
|
|
189
|
+
if (s.length <= max)
|
|
190
|
+
return s;
|
|
191
|
+
return s.slice(0, max - 1) + "…";
|
|
192
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch convention enforced by mintree:
|
|
3
|
+
*
|
|
4
|
+
* <type>/<issue>-<kebab-desc>
|
|
5
|
+
*
|
|
6
|
+
* `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
|
|
7
|
+
* issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
|
|
8
|
+
*
|
|
9
|
+
* Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
|
|
10
|
+
* Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
|
|
11
|
+
*/
|
|
12
|
+
export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "refactor", "test", "build", "ci", "perf", "style", "revert"];
|
|
13
|
+
export type BranchType = (typeof ALLOWED_TYPES)[number];
|
|
14
|
+
export type ParsedBranch = {
|
|
15
|
+
branch: string;
|
|
16
|
+
type: BranchType;
|
|
17
|
+
issueId: string;
|
|
18
|
+
desc: string;
|
|
19
|
+
worktreeDirName: string;
|
|
20
|
+
};
|
|
21
|
+
export type ParseError = {
|
|
22
|
+
error: string;
|
|
23
|
+
hint: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function parseBranch(branch: string): ParsedBranch | ParseError;
|
|
26
|
+
export declare function isParseError(result: ParsedBranch | ParseError): result is ParseError;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch convention enforced by mintree:
|
|
3
|
+
*
|
|
4
|
+
* <type>/<issue>-<kebab-desc>
|
|
5
|
+
*
|
|
6
|
+
* `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
|
|
7
|
+
* issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
|
|
8
|
+
*
|
|
9
|
+
* Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
|
|
10
|
+
* Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
|
|
11
|
+
*/
|
|
12
|
+
export const ALLOWED_TYPES = [
|
|
13
|
+
"feat",
|
|
14
|
+
"fix",
|
|
15
|
+
"docs",
|
|
16
|
+
"chore",
|
|
17
|
+
"refactor",
|
|
18
|
+
"test",
|
|
19
|
+
"build",
|
|
20
|
+
"ci",
|
|
21
|
+
"perf",
|
|
22
|
+
"style",
|
|
23
|
+
"revert",
|
|
24
|
+
];
|
|
25
|
+
const BRANCH_REGEX = /^([a-z]+)\/(\d+)-([a-z0-9][a-z0-9-]*)$/;
|
|
26
|
+
export function parseBranch(branch) {
|
|
27
|
+
const match = BRANCH_REGEX.exec(branch);
|
|
28
|
+
if (!match) {
|
|
29
|
+
return {
|
|
30
|
+
error: `Invalid branch name: ${branch}`,
|
|
31
|
+
hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const [, type, issueId, desc] = match;
|
|
35
|
+
if (!type || !issueId || !desc) {
|
|
36
|
+
return {
|
|
37
|
+
error: `Invalid branch name: ${branch}`,
|
|
38
|
+
hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!ALLOWED_TYPES.includes(type)) {
|
|
42
|
+
return {
|
|
43
|
+
error: `Unknown branch type \`${type}\``,
|
|
44
|
+
hint: `Allowed types: ${ALLOWED_TYPES.join(", ")}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
branch,
|
|
49
|
+
type: type,
|
|
50
|
+
issueId,
|
|
51
|
+
desc,
|
|
52
|
+
worktreeDirName: `${issueId}-${desc}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function isParseError(result) {
|
|
56
|
+
return "error" in result;
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ChildProcess } from "child_process";
|
|
2
|
+
export declare const PERMISSION_MODES: readonly ["default", "auto"];
|
|
3
|
+
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the absolute path of the Claude Code CLI binary, or null if not on
|
|
6
|
+
* PATH. Falls back to ~/.claude/local/claude (the Anthropic installer
|
|
7
|
+
* location) when PATH lookup fails — this is the single most common reason a
|
|
8
|
+
* Node child sees "claude not found" while the user sees it on the shell.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveClaudeBinary(): string | null;
|
|
11
|
+
export type LaunchClaudeOptions = {
|
|
12
|
+
permissionMode: PermissionMode;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
resume: boolean;
|
|
15
|
+
prompt?: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
|
|
20
|
+
* The session is started fresh with `--session-id` or resumed with `--resume`
|
|
21
|
+
* depending on `resume`. Returns the ChildProcess so the caller can wire
|
|
22
|
+
* exit/error handlers.
|
|
23
|
+
*
|
|
24
|
+
* Throws if the claude binary is not resolvable.
|
|
25
|
+
*/
|
|
26
|
+
export declare function launchClaude(options: LaunchClaudeOptions): ChildProcess;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { existsSync, writeFileSync } from "fs";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
export const PERMISSION_MODES = ["default", "auto"];
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the absolute path of the Claude Code CLI binary, or null if not on
|
|
8
|
+
* PATH. Falls back to ~/.claude/local/claude (the Anthropic installer
|
|
9
|
+
* location) when PATH lookup fails — this is the single most common reason a
|
|
10
|
+
* Node child sees "claude not found" while the user sees it on the shell.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveClaudeBinary() {
|
|
13
|
+
try {
|
|
14
|
+
const out = execSync("which claude", { stdio: ["ignore", "pipe", "ignore"] })
|
|
15
|
+
.toString()
|
|
16
|
+
.trim();
|
|
17
|
+
if (out)
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// fall through
|
|
22
|
+
}
|
|
23
|
+
const local = join(homedir(), ".claude", "local", "claude");
|
|
24
|
+
if (existsSync(local))
|
|
25
|
+
return local;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// macOS ARG_MAX is 256KB; leave room for env vars.
|
|
29
|
+
const ARG_MAX_SAFE = 200 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* If `prompt` fits in argv, returns it as-is. Otherwise writes it to a temp
|
|
32
|
+
* file and returns a short instruction the agent can follow to read it. This
|
|
33
|
+
* keeps the launch flow safe against very long prompts without forcing
|
|
34
|
+
* callers to handle the spill case.
|
|
35
|
+
*/
|
|
36
|
+
function promptArg(prompt) {
|
|
37
|
+
if (Buffer.byteLength(prompt) <= ARG_MAX_SAFE)
|
|
38
|
+
return prompt;
|
|
39
|
+
const filePath = join(tmpdir(), `mintree-prompt-${Date.now()}.md`);
|
|
40
|
+
writeFileSync(filePath, prompt);
|
|
41
|
+
return `Read ${filePath} and follow the instructions inside.`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
|
|
45
|
+
* The session is started fresh with `--session-id` or resumed with `--resume`
|
|
46
|
+
* depending on `resume`. Returns the ChildProcess so the caller can wire
|
|
47
|
+
* exit/error handlers.
|
|
48
|
+
*
|
|
49
|
+
* Throws if the claude binary is not resolvable.
|
|
50
|
+
*/
|
|
51
|
+
export function launchClaude(options) {
|
|
52
|
+
const bin = resolveClaudeBinary();
|
|
53
|
+
if (!bin) {
|
|
54
|
+
throw new Error("Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code");
|
|
55
|
+
}
|
|
56
|
+
const args = ["--permission-mode", options.permissionMode];
|
|
57
|
+
if (options.resume) {
|
|
58
|
+
args.push("--resume", options.sessionId);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
args.push("--session-id", options.sessionId);
|
|
62
|
+
}
|
|
63
|
+
if (options.prompt && options.prompt.length > 0) {
|
|
64
|
+
args.push("--", promptArg(options.prompt));
|
|
65
|
+
}
|
|
66
|
+
return spawn(bin, args, { stdio: "inherit", cwd: options.cwd });
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type AheadBehind } from "./git.js";
|
|
2
|
+
export type GhIssue = {
|
|
3
|
+
number: number;
|
|
4
|
+
title: string;
|
|
5
|
+
state: string;
|
|
6
|
+
url: string;
|
|
7
|
+
labels: {
|
|
8
|
+
name: string;
|
|
9
|
+
}[];
|
|
10
|
+
body: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
};
|
|
14
|
+
export type WorktreeInfo = {
|
|
15
|
+
path: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
dirty: boolean;
|
|
18
|
+
ab: AheadBehind | null;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
};
|
|
21
|
+
export type SessionStateValue = "active" | "idle" | "waiting" | "exited";
|
|
22
|
+
export type SessionStateInfo = {
|
|
23
|
+
state: SessionStateValue;
|
|
24
|
+
at: string;
|
|
25
|
+
message: string | null;
|
|
26
|
+
};
|
|
27
|
+
export type PrInfo = {
|
|
28
|
+
number: number;
|
|
29
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
30
|
+
url: string;
|
|
31
|
+
};
|
|
32
|
+
export type DashboardIssue = {
|
|
33
|
+
issue: GhIssue;
|
|
34
|
+
worktree: WorktreeInfo | null;
|
|
35
|
+
session: SessionStateInfo | null;
|
|
36
|
+
pr: PrInfo | null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Fetches open issues assigned to the authenticated GitHub user for the
|
|
40
|
+
* current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
|
|
41
|
+
* isn't a GitHub repo, or the API call fails — the caller surfaces the
|
|
42
|
+
* appropriate hint.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchAssignedIssues(): Promise<GhIssue[] | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Top-level loader: enriches each assigned issue with its worktree and
|
|
47
|
+
* session snapshot. Designed to be called on dashboard mount and on every
|
|
48
|
+
* `r` refresh — cheap because all the per-worktree probes are local.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadDashboard(repoRoot: string): Promise<DashboardIssue[] | null>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { tryExec } from "./exec.js";
|
|
4
|
+
import { listWorktrees, getWorktreesDir, isDirty, getAheadBehind, } from "./git.js";
|
|
5
|
+
import { readMetadata } from "./metadata.js";
|
|
6
|
+
const ISSUE_LIST_LIMIT = 50;
|
|
7
|
+
/**
|
|
8
|
+
* Fetches open issues assigned to the authenticated GitHub user for the
|
|
9
|
+
* current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
|
|
10
|
+
* isn't a GitHub repo, or the API call fails — the caller surfaces the
|
|
11
|
+
* appropriate hint.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchAssignedIssues() {
|
|
14
|
+
const json = await tryExec(`gh issue list --assignee @me --state open --json number,title,state,url,labels,body,createdAt,updatedAt --limit ${ISSUE_LIST_LIMIT} 2>/dev/null`);
|
|
15
|
+
if (!json)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(json);
|
|
19
|
+
if (!Array.isArray(parsed))
|
|
20
|
+
return null;
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Builds a map from issue id (number, as string) to the matching mintree
|
|
29
|
+
* worktree, parsing each branch with the same `<type>/<issue>-<desc>` regex
|
|
30
|
+
* the create command enforces. Worktrees with branches that don't follow
|
|
31
|
+
* the convention are simply skipped.
|
|
32
|
+
*/
|
|
33
|
+
function buildWorktreeIndex(repoRoot) {
|
|
34
|
+
const worktreesRoot = path.resolve(getWorktreesDir(repoRoot));
|
|
35
|
+
const branchRegex = /^[a-z]+\/(\d+)-/;
|
|
36
|
+
const index = new Map();
|
|
37
|
+
for (const w of listWorktrees(repoRoot)) {
|
|
38
|
+
if (!w.branch)
|
|
39
|
+
continue;
|
|
40
|
+
const wAbs = path.resolve(w.path);
|
|
41
|
+
if (wAbs !== worktreesRoot && !wAbs.startsWith(worktreesRoot + path.sep))
|
|
42
|
+
continue;
|
|
43
|
+
const m = w.branch.match(branchRegex);
|
|
44
|
+
const issueId = m && m[1] ? m[1] : null;
|
|
45
|
+
if (!issueId)
|
|
46
|
+
continue;
|
|
47
|
+
index.set(issueId, {
|
|
48
|
+
path: w.path,
|
|
49
|
+
branch: w.branch,
|
|
50
|
+
dirty: isDirty(w.path),
|
|
51
|
+
ab: getAheadBehind(w.path),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return index;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reads the live state file written by the session-signal hooks. Returns null
|
|
58
|
+
* when the file doesn't exist, can't be parsed, or holds an unrecognised state
|
|
59
|
+
* value — the dashboard treats those as "no live session".
|
|
60
|
+
*/
|
|
61
|
+
function readSessionState(repoRoot, issueId) {
|
|
62
|
+
const file = path.join(repoRoot, ".mintree", "session-states", `${issueId}.json`);
|
|
63
|
+
if (!fs.existsSync(file))
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
67
|
+
const state = data?.state;
|
|
68
|
+
if (!isSessionState(state))
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
state,
|
|
72
|
+
at: typeof data.at === "string" ? data.at : "",
|
|
73
|
+
message: typeof data.message === "string" ? data.message : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isSessionState(v) {
|
|
81
|
+
return v === "active" || v === "idle" || v === "waiting" || v === "exited";
|
|
82
|
+
}
|
|
83
|
+
function shQuote(value) {
|
|
84
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Looks up the most recent PR for a branch (any state). Returns null when
|
|
88
|
+
* there's no PR or `gh` can't reach the API. Used to populate the detail
|
|
89
|
+
* pane's "Pull Request" section.
|
|
90
|
+
*/
|
|
91
|
+
async function fetchPrForBranch(branch) {
|
|
92
|
+
const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json number,state,url --limit 1 2>/dev/null`);
|
|
93
|
+
if (!out)
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const arr = JSON.parse(out);
|
|
97
|
+
if (Array.isArray(arr) && arr.length > 0 && arr[0])
|
|
98
|
+
return arr[0];
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// fall through
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Top-level loader: enriches each assigned issue with its worktree and
|
|
107
|
+
* session snapshot. Designed to be called on dashboard mount and on every
|
|
108
|
+
* `r` refresh — cheap because all the per-worktree probes are local.
|
|
109
|
+
*/
|
|
110
|
+
export async function loadDashboard(repoRoot) {
|
|
111
|
+
const issues = await fetchAssignedIssues();
|
|
112
|
+
if (!issues)
|
|
113
|
+
return null;
|
|
114
|
+
const worktreesByIssue = buildWorktreeIndex(repoRoot);
|
|
115
|
+
const metadata = readMetadata(repoRoot);
|
|
116
|
+
// Fetch PRs in parallel for branches that actually have a worktree —
|
|
117
|
+
// issues without one wouldn't have a branch on this user's repo, so we
|
|
118
|
+
// skip the per-issue gh call for them.
|
|
119
|
+
const prByBranch = new Map();
|
|
120
|
+
const prFetches = Array.from(worktreesByIssue.values()).map(async (w) => {
|
|
121
|
+
const pr = await fetchPrForBranch(w.branch);
|
|
122
|
+
if (pr)
|
|
123
|
+
prByBranch.set(w.branch, pr);
|
|
124
|
+
});
|
|
125
|
+
await Promise.all(prFetches);
|
|
126
|
+
return issues.map(issue => {
|
|
127
|
+
const issueId = String(issue.number);
|
|
128
|
+
const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
|
|
129
|
+
const sessionId = metadata.issues[issueId]?.session_id;
|
|
130
|
+
const worktree = worktreeRaw ? { ...worktreeRaw, sessionId } : null;
|
|
131
|
+
const pr = worktree ? (prByBranch.get(worktree.branch) ?? null) : null;
|
|
132
|
+
return {
|
|
133
|
+
issue,
|
|
134
|
+
worktree,
|
|
135
|
+
session: readSessionState(repoRoot, issueId),
|
|
136
|
+
pr,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
package/dist/lib/exec.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
export async function tryExec(command) {
|
|
5
|
+
try {
|
|
6
|
+
const { stdout } = await execAsync(command);
|
|
7
|
+
return stdout.trim();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function getPath(command) {
|
|
14
|
+
return tryExec(`which ${command}`);
|
|
15
|
+
}
|