@vigent/cli 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/agents.js +65 -0
- package/dist/app.js +2487 -0
- package/dist/builtin-themes.js +352 -0
- package/dist/choices.js +73 -0
- package/dist/cli-engines.js +537 -0
- package/dist/cli.js +440 -0
- package/dist/clipboard.js +56 -0
- package/dist/color.js +126 -0
- package/dist/config-edit.js +61 -0
- package/dist/config.js +406 -0
- package/dist/demo.js +81 -0
- package/dist/diffview.js +94 -0
- package/dist/engines.js +195 -0
- package/dist/export.js +29 -0
- package/dist/float.js +182 -0
- package/dist/import.js +260 -0
- package/dist/keys.js +589 -0
- package/dist/markdown.js +80 -0
- package/dist/palette.js +83 -0
- package/dist/picker.js +301 -0
- package/dist/review.js +158 -0
- package/dist/riftri.js +284 -0
- package/dist/session.js +460 -0
- package/dist/settings.js +174 -0
- package/dist/suggest.js +31 -0
- package/dist/theme-new.js +49 -0
- package/dist/theme.js +487 -0
- package/dist/tree.js +115 -0
- package/dist/version.js +45 -0
- package/dist/vimcolors.js +206 -0
- package/dist/worktree.js +357 -0
- package/package.json +64 -4
package/dist/version.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Which vigent is running: the package version, plus the git commit when it runs from a clone. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
6
|
+
export const VERSION = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0";
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return "0";
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
/** The short commit vigent was built from, when it runs from a git clone (npm link); else undefined. */
|
|
15
|
+
export const COMMIT = (() => {
|
|
16
|
+
if (!fs.existsSync(new URL("../.git", import.meta.url)))
|
|
17
|
+
return undefined;
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
/** "0.2.0 (ed9418d)" from a clone, "0.2.0" from npm. */
|
|
26
|
+
export const VERSION_TEXT = COMMIT ? `${VERSION} (${COMMIT})` : VERSION;
|
|
27
|
+
/**
|
|
28
|
+
* In a clone: is the compiled code older than the source? (`git pull` without
|
|
29
|
+
* `npm install` / `npm run build` leaves dist/ behind, and the new themes and
|
|
30
|
+
* commands seem missing.) Returns the warning to show, or undefined.
|
|
31
|
+
*/
|
|
32
|
+
export function staleBuild(dir = root) {
|
|
33
|
+
const src = `${dir}/src`;
|
|
34
|
+
const built = `${dir}/dist/cli.js`;
|
|
35
|
+
if (!fs.existsSync(`${dir}/.git`) || !fs.existsSync(src) || !fs.existsSync(built))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const builtAt = fs.statSync(built).mtimeMs;
|
|
39
|
+
const newer = fs.readdirSync(src).some((f) => f.endsWith(".ts") && fs.statSync(`${src}/${f}`).mtimeMs > builtAt + 1000);
|
|
40
|
+
return newer ? `this vigent was built before its source last changed: run npm install (or npm run build ) in ${dir.replace(/\/$/, "")}` : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vim `:highlight` syntax support.
|
|
3
|
+
*
|
|
4
|
+
* - parseHighlightCommand(): the arguments of a single `:hi` command
|
|
5
|
+
* (used by the in-app `:hi` command and by the file parser)
|
|
6
|
+
* - parseVimColorscheme(): a static reader for `.vim` colorscheme files, so
|
|
7
|
+
* classic vim themes can be imported without vim or nvim installed
|
|
8
|
+
*/
|
|
9
|
+
import { resolveVimColor } from "./color.js";
|
|
10
|
+
/** Split on whitespace, keeping 'quoted' and "quoted" values together. */
|
|
11
|
+
export function tokenize(s) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const re = /(\S+?=(?:'[^']*'|"[^"]*")|\S+)/g;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(s)))
|
|
16
|
+
out.push(m[1]);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
const ATTR_WORDS = {
|
|
20
|
+
bold: "bold",
|
|
21
|
+
italic: "italic",
|
|
22
|
+
underline: "underline",
|
|
23
|
+
undercurl: "underline",
|
|
24
|
+
underdouble: "underline",
|
|
25
|
+
underdotted: "underline",
|
|
26
|
+
underdashed: "underline",
|
|
27
|
+
reverse: "reverse",
|
|
28
|
+
inverse: "reverse",
|
|
29
|
+
standout: "reverse",
|
|
30
|
+
};
|
|
31
|
+
function applyAttrList(list, attr) {
|
|
32
|
+
for (const word of list.toLowerCase().split(",")) {
|
|
33
|
+
if (word === "none") {
|
|
34
|
+
attr.bold = attr.italic = attr.underline = attr.reverse = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const key = ATTR_WORDS[word];
|
|
38
|
+
if (key)
|
|
39
|
+
attr[key] = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Convert raw vim values to an Attr, preferring GUI colors over cterm ones.
|
|
44
|
+
* With keepOff, attributes turned off by `gui=NONE` stay as `false` so they
|
|
45
|
+
* can switch off a theme's bold/italic when merged as an override.
|
|
46
|
+
*/
|
|
47
|
+
export function rawToAttr(raw, keepOff = false) {
|
|
48
|
+
const attr = {};
|
|
49
|
+
const fg = raw.guifg ? resolveVimColor(raw.guifg) : undefined;
|
|
50
|
+
const bg = raw.guibg ? resolveVimColor(raw.guibg) : undefined;
|
|
51
|
+
attr.fg = fg ?? (raw.ctermfg ? resolveVimColor(raw.ctermfg, true) : undefined);
|
|
52
|
+
attr.bg = bg ?? (raw.ctermbg ? resolveVimColor(raw.ctermbg, true) : undefined);
|
|
53
|
+
const attrs = raw.gui ?? raw.cterm;
|
|
54
|
+
if (attrs)
|
|
55
|
+
applyAttrList(attrs, attr);
|
|
56
|
+
for (const k of Object.keys(attr)) {
|
|
57
|
+
if (attr[k] === undefined || (attr[k] === false && !keepOff))
|
|
58
|
+
delete attr[k];
|
|
59
|
+
}
|
|
60
|
+
return attr;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse the arguments of a `:highlight` command (everything after `hi`).
|
|
64
|
+
* Accepts vim keys (guifg, guibg, gui, ctermfg, ctermbg, cterm) plus the
|
|
65
|
+
* nvim_set_hl-style shorthands fg=, bg=, and bare words like `bold`.
|
|
66
|
+
*/
|
|
67
|
+
export function parseHighlightCommand(args) {
|
|
68
|
+
let tokens = tokenize(args.trim());
|
|
69
|
+
if (tokens[0] === "!")
|
|
70
|
+
tokens = tokens.slice(1);
|
|
71
|
+
if (tokens[0] === "default" || tokens[0] === "def")
|
|
72
|
+
tokens = tokens.slice(1);
|
|
73
|
+
if (tokens.length === 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
if (tokens[0] === "clear")
|
|
76
|
+
return { kind: "clear", group: tokens[1] };
|
|
77
|
+
if (tokens[0] === "link") {
|
|
78
|
+
if (tokens.length < 3)
|
|
79
|
+
return undefined;
|
|
80
|
+
return { kind: "link", from: tokens[1], to: tokens[2] };
|
|
81
|
+
}
|
|
82
|
+
const group = tokens[0];
|
|
83
|
+
if (tokens.length === 1)
|
|
84
|
+
return { kind: "show", group };
|
|
85
|
+
const raw = {};
|
|
86
|
+
const extra = {};
|
|
87
|
+
for (const tok of tokens.slice(1)) {
|
|
88
|
+
const eq = tok.indexOf("=");
|
|
89
|
+
if (eq < 0) {
|
|
90
|
+
applyAttrList(tok, extra);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const key = tok.slice(0, eq).toLowerCase();
|
|
94
|
+
const value = tok.slice(eq + 1).replace(/^["']|["']$/g, "");
|
|
95
|
+
switch (key) {
|
|
96
|
+
case "guifg":
|
|
97
|
+
case "fg":
|
|
98
|
+
raw.guifg = value;
|
|
99
|
+
break;
|
|
100
|
+
case "guibg":
|
|
101
|
+
case "bg":
|
|
102
|
+
raw.guibg = value;
|
|
103
|
+
break;
|
|
104
|
+
case "gui":
|
|
105
|
+
raw.gui = value;
|
|
106
|
+
break;
|
|
107
|
+
case "ctermfg":
|
|
108
|
+
raw.ctermfg = value;
|
|
109
|
+
break;
|
|
110
|
+
case "ctermbg":
|
|
111
|
+
raw.ctermbg = value;
|
|
112
|
+
break;
|
|
113
|
+
case "cterm":
|
|
114
|
+
raw.cterm = value;
|
|
115
|
+
break;
|
|
116
|
+
// guisp, term, font, blend, start, stop: not used by a terminal UI
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { kind: "set", group, attr: { ...rawToAttr(raw, true), ...extra }, raw };
|
|
120
|
+
}
|
|
121
|
+
/** Evaluate an `if` condition. Only `&background` checks are understood; anything else is assumed true. */
|
|
122
|
+
function evalCondition(cond, background) {
|
|
123
|
+
const m = /&(?:background|bg)\s*(==|!=|is|isnot)[#?]?\s*['"](dark|light)['"]/.exec(cond);
|
|
124
|
+
if (!m)
|
|
125
|
+
return true;
|
|
126
|
+
const equal = m[2] === background;
|
|
127
|
+
return m[1] === "==" || m[1] === "is" ? equal : !equal;
|
|
128
|
+
}
|
|
129
|
+
/** Statically read a `.vim` colorscheme file. Lines built at runtime (exe, functions) are skipped. */
|
|
130
|
+
export function parseVimColorscheme(text, background) {
|
|
131
|
+
// join line continuations ("\" at the start of the next line)
|
|
132
|
+
const lines = text.replace(/\r/g, "").replace(/\n\s*\\/g, " ").split("\n");
|
|
133
|
+
const setBg = /^\s*set\s+(?:background|bg)=(dark|light)/m.exec(text);
|
|
134
|
+
const bg = background ?? (setBg ? setBg[1] : "dark");
|
|
135
|
+
const raw = {};
|
|
136
|
+
const links = {};
|
|
137
|
+
const stack = [];
|
|
138
|
+
let name;
|
|
139
|
+
const active = () => (stack.length === 0 ? true : stack[stack.length - 1].active);
|
|
140
|
+
for (const original of lines) {
|
|
141
|
+
const line = original.trim();
|
|
142
|
+
if (line === "" || line.startsWith('"'))
|
|
143
|
+
continue;
|
|
144
|
+
let m;
|
|
145
|
+
if ((m = /^if\s+(.*)$/.exec(line))) {
|
|
146
|
+
const parentActive = active();
|
|
147
|
+
const ok = parentActive && evalCondition(m[1], bg);
|
|
148
|
+
stack.push({ active: ok, taken: ok, parentActive });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if ((m = /^elseif\s+(.*)$/.exec(line))) {
|
|
152
|
+
const f = stack[stack.length - 1];
|
|
153
|
+
if (!f)
|
|
154
|
+
continue;
|
|
155
|
+
const ok = f.parentActive && !f.taken && evalCondition(m[1], bg);
|
|
156
|
+
f.active = ok;
|
|
157
|
+
f.taken ||= ok;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (/^else\b/.test(line)) {
|
|
161
|
+
const f = stack[stack.length - 1];
|
|
162
|
+
if (!f)
|
|
163
|
+
continue;
|
|
164
|
+
f.active = f.parentActive && !f.taken;
|
|
165
|
+
f.taken = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/^endif\b/.test(line)) {
|
|
169
|
+
stack.pop();
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!active())
|
|
173
|
+
continue;
|
|
174
|
+
if (/^fini(sh)?\b/.test(line))
|
|
175
|
+
break;
|
|
176
|
+
if ((m = /^let\s+g:colors_name\s*=\s*['"]([^'"]+)['"]/.exec(line))) {
|
|
177
|
+
name = m[1];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if ((m = /^hi(?:g|gh|ghl|ghli|ghlig|ghligh|ghlight)?(!?)\s+(.*)$/.exec(line))) {
|
|
181
|
+
const cmd = parseHighlightCommand(m[2].replace(/\s+"[^"]*$/, ""));
|
|
182
|
+
if (!cmd)
|
|
183
|
+
continue;
|
|
184
|
+
if (cmd.kind === "link") {
|
|
185
|
+
links[cmd.from] = cmd.to;
|
|
186
|
+
}
|
|
187
|
+
else if (cmd.kind === "set") {
|
|
188
|
+
raw[cmd.group] = { ...raw[cmd.group], ...cmd.raw };
|
|
189
|
+
// a group with its own colors stops following a link, as in vim
|
|
190
|
+
if (cmd.raw.guifg || cmd.raw.guibg || cmd.raw.ctermfg || cmd.raw.ctermbg)
|
|
191
|
+
delete links[cmd.group];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const groups = {};
|
|
196
|
+
for (const [group, r] of Object.entries(raw)) {
|
|
197
|
+
const attr = rawToAttr(r);
|
|
198
|
+
if (Object.keys(attr).length > 0)
|
|
199
|
+
groups[group] = attr;
|
|
200
|
+
}
|
|
201
|
+
for (const [from, to] of Object.entries(links)) {
|
|
202
|
+
if (!groups[from])
|
|
203
|
+
groups[from] = { link: to };
|
|
204
|
+
}
|
|
205
|
+
return { name, background: bg, groups };
|
|
206
|
+
}
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One git worktree per agent.
|
|
3
|
+
*
|
|
4
|
+
* `:new fix flaky checkout test` creates the branch `fix/flaky-checkout-test`
|
|
5
|
+
* from the branch you're on, checks it out in its own worktree next to the
|
|
6
|
+
* repo (../<repo>-worktrees/fix-flaky-checkout-test), and runs the agent
|
|
7
|
+
* there. Agents never touch your checkout or each other's files.
|
|
8
|
+
*
|
|
9
|
+
* With riftri installed (https://riftri.dev, see riftri.ts) the worktree's
|
|
10
|
+
* unchanged files share copy-on-write storage with the other agents', so a
|
|
11
|
+
* new agent costs almost no disk. It's still a real git worktree.
|
|
12
|
+
*
|
|
13
|
+
* Removing is always the safe path: `git worktree remove` (or `riftri
|
|
14
|
+
* worktree remove`) refuses when there are uncommitted changes, and
|
|
15
|
+
* `git branch -d` keeps branches that aren't merged, unless you ask for --force.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { describeFailure, riftriAdd, riftriBinary, riftriGc, riftriOwnership, riftriRemove, RIFTRI_INSTALL } from "./riftri.js";
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
export const DEFAULT_WORKTREE = {
|
|
24
|
+
enabled: true,
|
|
25
|
+
dir: "../{repo}-worktrees",
|
|
26
|
+
branch: "{type}/{name}",
|
|
27
|
+
backend: "auto",
|
|
28
|
+
};
|
|
29
|
+
function git(args, cwd) {
|
|
30
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
31
|
+
}
|
|
32
|
+
function gitOk(args, cwd) {
|
|
33
|
+
try {
|
|
34
|
+
git(args, cwd);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Readable message from a failed git command. */
|
|
42
|
+
function gitError(err) {
|
|
43
|
+
const e = err;
|
|
44
|
+
const text = (e.stderr ? String(e.stderr) : e.message ?? String(err)).trim();
|
|
45
|
+
return text.replace(/^(fatal|error): /gm, "").split("\n")[0];
|
|
46
|
+
}
|
|
47
|
+
/** The repository's top-level folder, or undefined outside a git repo. */
|
|
48
|
+
export function repoRoot(cwd) {
|
|
49
|
+
try {
|
|
50
|
+
return git(["rev-parse", "--show-toplevel"], cwd);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function currentBranch(cwd) {
|
|
57
|
+
try {
|
|
58
|
+
return git(["symbolic-ref", "--short", "-q", "HEAD"], cwd);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return git(["rev-parse", "--short", "HEAD"], cwd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Branch names
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/** Words that name the kind of change, mapped to the conventional branch type. */
|
|
68
|
+
const TYPES = {
|
|
69
|
+
feat: "feat",
|
|
70
|
+
feature: "feat",
|
|
71
|
+
fix: "fix",
|
|
72
|
+
bugfix: "fix",
|
|
73
|
+
hotfix: "fix",
|
|
74
|
+
bug: "fix",
|
|
75
|
+
docs: "docs",
|
|
76
|
+
doc: "docs",
|
|
77
|
+
refactor: "refactor",
|
|
78
|
+
test: "test",
|
|
79
|
+
tests: "test",
|
|
80
|
+
chore: "chore",
|
|
81
|
+
perf: "perf",
|
|
82
|
+
ci: "ci",
|
|
83
|
+
build: "build",
|
|
84
|
+
style: "style",
|
|
85
|
+
};
|
|
86
|
+
/** lowercase, ascii, hyphen-separated, at most `max` characters (cut on a word boundary). */
|
|
87
|
+
export function slugify(text, max = 48) {
|
|
88
|
+
const slug = text
|
|
89
|
+
.normalize("NFKD")
|
|
90
|
+
.replace(/[̀-ͯ]/g, "")
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
93
|
+
.replace(/^-+|-+$/g, "");
|
|
94
|
+
if (slug.length <= max)
|
|
95
|
+
return slug;
|
|
96
|
+
const cut = slug.slice(0, max + 1);
|
|
97
|
+
const boundary = cut.lastIndexOf("-");
|
|
98
|
+
return (boundary > 8 ? cut.slice(0, boundary) : slug.slice(0, max)).replace(/-+$/, "");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A conventional branch name for an agent:
|
|
102
|
+
* "fix flaky checkout test" -> fix/flaky-checkout-test
|
|
103
|
+
* "auth-refactor" -> refactor/auth
|
|
104
|
+
* "docs: readme flags" -> docs/readme-flags
|
|
105
|
+
* "add dark mode" -> feat/add-dark-mode
|
|
106
|
+
* "release/v2-notes" -> release/v2-notes (a name with "/" is used as written)
|
|
107
|
+
*/
|
|
108
|
+
export function branchName(agentName, template = DEFAULT_WORKTREE.branch) {
|
|
109
|
+
if (agentName.includes("/")) {
|
|
110
|
+
const parts = agentName.split("/").map((p) => slugify(p)).filter(Boolean);
|
|
111
|
+
if (parts.length > 0)
|
|
112
|
+
return parts.join("/");
|
|
113
|
+
}
|
|
114
|
+
const words = slugify(agentName, 200).split("-").filter(Boolean);
|
|
115
|
+
let type = "feat";
|
|
116
|
+
if (words.length > 1 && TYPES[words[0]]) {
|
|
117
|
+
type = TYPES[words.shift()];
|
|
118
|
+
}
|
|
119
|
+
else if (words.length > 1 && TYPES[words[words.length - 1]]) {
|
|
120
|
+
type = TYPES[words.pop()];
|
|
121
|
+
}
|
|
122
|
+
const name = slugify(words.join("-")) || "agent";
|
|
123
|
+
return template.replace(/\{type\}/g, type).replace(/\{name\}/g, name);
|
|
124
|
+
}
|
|
125
|
+
function isValidBranch(name, cwd) {
|
|
126
|
+
return gitOk(["check-ref-format", "--branch", name], cwd);
|
|
127
|
+
}
|
|
128
|
+
function branchExists(name, cwd) {
|
|
129
|
+
return gitOk(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], cwd);
|
|
130
|
+
}
|
|
131
|
+
export function worktreesDir(root, config = DEFAULT_WORKTREE) {
|
|
132
|
+
const dir = config.dir.replace(/\{repo\}/g, path.basename(root));
|
|
133
|
+
return path.resolve(root, dir);
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Create / remove / list
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
export function createWorktree(root, agentName, config = DEFAULT_WORKTREE) {
|
|
139
|
+
const base = config.base || currentBranch(root);
|
|
140
|
+
const wanted = branchName(agentName, config.branch);
|
|
141
|
+
if (!isValidBranch(wanted, root))
|
|
142
|
+
throw new Error(`'${wanted}' is not a valid branch name (check [worktree] branch)`);
|
|
143
|
+
const dir = worktreesDir(root, config);
|
|
144
|
+
let branch = wanted;
|
|
145
|
+
let folder = path.join(dir, wanted.replace(/\//g, "-"));
|
|
146
|
+
for (let n = 2; branchExists(branch, root) || fs.existsSync(folder); n++) {
|
|
147
|
+
branch = `${wanted}-${n}`;
|
|
148
|
+
folder = path.join(dir, branch.replace(/\//g, "-"));
|
|
149
|
+
}
|
|
150
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
151
|
+
excludeIfInside(root, dir);
|
|
152
|
+
const info = { branch, path: folder, base, stat: { files: 0, added: 0, removed: 0 } };
|
|
153
|
+
const backend = config.backend ?? "auto";
|
|
154
|
+
if (backend !== "git") {
|
|
155
|
+
if (!riftriBinary()) {
|
|
156
|
+
if (backend === "riftri")
|
|
157
|
+
throw new Error(`[worktree] backend is "riftri", but riftri isn't installed: ${RIFTRI_INSTALL}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const r = riftriAdd(root, folder, branch, base);
|
|
161
|
+
if (r.ok)
|
|
162
|
+
return { ...info, riftri: r.report?.backend ?? "riftri" };
|
|
163
|
+
// riftri declined (unsupported volume, checkout or environment) and changed nothing: git is safe
|
|
164
|
+
if (!r.declined || backend === "riftri")
|
|
165
|
+
throw new Error(`riftri worktree add failed: ${describeFailure(r)}`);
|
|
166
|
+
info.riftriSkipped = r.message;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
git(["worktree", "add", "-b", branch, folder, base], root);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
throw new Error(`git worktree add failed: ${gitError(err)}`);
|
|
174
|
+
}
|
|
175
|
+
return info;
|
|
176
|
+
}
|
|
177
|
+
/** A worktrees folder inside the repo goes in .git/info/exclude (local, never committed) so `git status` stays clean. */
|
|
178
|
+
function excludeIfInside(root, dir) {
|
|
179
|
+
const rel = path.relative(root, dir);
|
|
180
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
181
|
+
return;
|
|
182
|
+
const file = path.resolve(root, git(["rev-parse", "--git-path", "info/exclude"], root));
|
|
183
|
+
const entry = `/${rel.split(path.sep).join("/")}/`;
|
|
184
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
185
|
+
if (current.split("\n").includes(entry))
|
|
186
|
+
return;
|
|
187
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
188
|
+
fs.appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${entry}\n`);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Remove a worktree, then delete its branch if it's merged. Without `force`,
|
|
192
|
+
* git refuses to remove a worktree with uncommitted changes and keeps an
|
|
193
|
+
* unmerged branch, so no work is lost by accident.
|
|
194
|
+
*/
|
|
195
|
+
export function removeWorktree(root, wt, force = false) {
|
|
196
|
+
const dirtyMessage = `${wt.path} has uncommitted changes; use force to remove it anyway`;
|
|
197
|
+
const owner = riftriOwnership(root, wt.path);
|
|
198
|
+
if (owner.error)
|
|
199
|
+
return { removed: false, branchDeleted: false, message: `riftri: ${owner.error}` };
|
|
200
|
+
if (owner.managed) {
|
|
201
|
+
// riftri's removal is journaled; a forced one snapshots the view first
|
|
202
|
+
const r = riftriRemove(root, wt.path, force);
|
|
203
|
+
if (!r.ok) {
|
|
204
|
+
const dirty = /has changes|uncommitted|dirty/i.test(r.message);
|
|
205
|
+
return { removed: false, branchDeleted: false, message: dirty ? dirtyMessage : `riftri worktree remove failed: ${describeFailure(r)}` };
|
|
206
|
+
}
|
|
207
|
+
riftriGc(root); // free the shared base once no agent uses it
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
try {
|
|
211
|
+
git(["worktree", "remove", ...(force ? ["--force"] : []), wt.path], root);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const reason = gitError(err);
|
|
215
|
+
const dirty = /modified or untracked|contains modified/.test(reason);
|
|
216
|
+
return { removed: false, branchDeleted: false, message: dirty ? dirtyMessage : reason };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!wt.branch)
|
|
220
|
+
return { removed: true, branchDeleted: false, message: `removed ${wt.path}` };
|
|
221
|
+
try {
|
|
222
|
+
const merged = force || isSquashMerged(root, wt.branch, wt.base);
|
|
223
|
+
git(["branch", merged ? "-D" : "-d", wt.branch], root);
|
|
224
|
+
return { removed: true, branchDeleted: true, message: `removed ${wt.path} and branch ${wt.branch}` };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return {
|
|
228
|
+
removed: true,
|
|
229
|
+
branchDeleted: false,
|
|
230
|
+
message: `removed ${wt.path}; kept branch ${wt.branch} (it has commits that aren't merged)`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A squash merge makes a new commit, so git doesn't see the branch as merged.
|
|
236
|
+
* Treat it as merged when every file it changed has the same content on base.
|
|
237
|
+
*/
|
|
238
|
+
export function isSquashMerged(root, branch, base) {
|
|
239
|
+
if (!base)
|
|
240
|
+
return false;
|
|
241
|
+
try {
|
|
242
|
+
const fork = git(["merge-base", base, branch], root);
|
|
243
|
+
const files = git(["diff", "--name-only", fork, branch], root).split("\n").filter(Boolean);
|
|
244
|
+
if (files.length === 0)
|
|
245
|
+
return false;
|
|
246
|
+
return gitOk(["diff", "--quiet", base, branch, "--", ...files], root);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Worktrees vigent manages (the ones inside the worktrees folder). */
|
|
253
|
+
export function listWorktrees(root, config = DEFAULT_WORKTREE) {
|
|
254
|
+
const dir = worktreesDir(root, config);
|
|
255
|
+
const out = [];
|
|
256
|
+
let current = {};
|
|
257
|
+
const flush = () => {
|
|
258
|
+
if (current.path && (current.path === dir || current.path.startsWith(dir + path.sep))) {
|
|
259
|
+
out.push({ path: current.path, branch: current.branch, head: current.head ?? "" });
|
|
260
|
+
}
|
|
261
|
+
current = {};
|
|
262
|
+
};
|
|
263
|
+
for (const line of git(["worktree", "list", "--porcelain"], root).split("\n")) {
|
|
264
|
+
if (line.startsWith("worktree ")) {
|
|
265
|
+
flush();
|
|
266
|
+
current.path = line.slice(9);
|
|
267
|
+
}
|
|
268
|
+
else if (line.startsWith("HEAD "))
|
|
269
|
+
current.head = line.slice(5, 12);
|
|
270
|
+
else if (line.startsWith("branch "))
|
|
271
|
+
current.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
272
|
+
}
|
|
273
|
+
flush();
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/** Find a managed worktree by branch, folder name, or path. */
|
|
277
|
+
export function findWorktree(root, query, config = DEFAULT_WORKTREE) {
|
|
278
|
+
const all = listWorktrees(root, config);
|
|
279
|
+
const abs = path.resolve(query);
|
|
280
|
+
return (all.find((w) => w.branch === query) ??
|
|
281
|
+
all.find((w) => path.basename(w.path) === query) ??
|
|
282
|
+
all.find((w) => w.path === abs) ??
|
|
283
|
+
all.find((w) => w.branch === branchName(query, config.branch)));
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Changes
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
async function gitAsync(args, cwd) {
|
|
289
|
+
try {
|
|
290
|
+
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 });
|
|
291
|
+
return stdout;
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
// `git diff --no-index` exits 1 when files differ; its stdout is still the diff
|
|
295
|
+
const e = err;
|
|
296
|
+
if (e.code === 1 && typeof e.stdout === "string")
|
|
297
|
+
return e.stdout;
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** The commit the worktree branched from, so later commits on the base don't show as changes. */
|
|
302
|
+
async function forkPoint(wt) {
|
|
303
|
+
if (!wt.path)
|
|
304
|
+
throw new Error("no worktree");
|
|
305
|
+
if (!wt.base)
|
|
306
|
+
return "HEAD";
|
|
307
|
+
try {
|
|
308
|
+
return (await gitAsync(["merge-base", wt.base, "HEAD"], wt.path)).trim();
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return wt.base;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async function untrackedFiles(cwd) {
|
|
315
|
+
const out = await gitAsync(["ls-files", "--others", "--exclude-standard", "-z"], cwd);
|
|
316
|
+
return out.split("\0").filter(Boolean);
|
|
317
|
+
}
|
|
318
|
+
/** Everything the agent changed since the fork point: commits, uncommitted edits, and new files. */
|
|
319
|
+
export async function diffStat(wt) {
|
|
320
|
+
if (!wt.path)
|
|
321
|
+
return wt.stat ?? { files: 0, added: 0, removed: 0 };
|
|
322
|
+
const from = await forkPoint(wt);
|
|
323
|
+
const stat = { files: 0, added: 0, removed: 0 };
|
|
324
|
+
for (const line of (await gitAsync(["diff", "--numstat", from], wt.path)).split("\n")) {
|
|
325
|
+
const m = /^(\d+|-)\t(\d+|-)\t/.exec(line);
|
|
326
|
+
if (!m)
|
|
327
|
+
continue;
|
|
328
|
+
stat.files++;
|
|
329
|
+
stat.added += m[1] === "-" ? 0 : Number(m[1]);
|
|
330
|
+
stat.removed += m[2] === "-" ? 0 : Number(m[2]);
|
|
331
|
+
}
|
|
332
|
+
for (const file of await untrackedFiles(wt.path)) {
|
|
333
|
+
stat.files++;
|
|
334
|
+
try {
|
|
335
|
+
const buf = fs.readFileSync(path.join(wt.path, file));
|
|
336
|
+
const text = buf.includes(0) ? "" : buf.toString("utf8"); // skip binary files
|
|
337
|
+
if (text)
|
|
338
|
+
stat.added += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// unreadable: count the file, not its lines
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return stat;
|
|
345
|
+
}
|
|
346
|
+
/** Unified diff of everything the agent changed, new files included. */
|
|
347
|
+
export async function diffText(wt, maxLines = 4000) {
|
|
348
|
+
if (!wt.path)
|
|
349
|
+
return "";
|
|
350
|
+
const from = await forkPoint(wt);
|
|
351
|
+
let text = await gitAsync(["diff", "--no-color", from], wt.path);
|
|
352
|
+
for (const file of await untrackedFiles(wt.path)) {
|
|
353
|
+
text += await gitAsync(["diff", "--no-color", "--no-index", "--", "/dev/null", file], wt.path);
|
|
354
|
+
}
|
|
355
|
+
const lines = text.split("\n");
|
|
356
|
+
return lines.length > maxLines ? `${lines.slice(0, maxLines).join("\n")}\n… ${lines.length - maxLines} more lines` : text;
|
|
357
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vigent/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A vim-style terminal app for running coding agents side by side, each in its own git worktree. Built on pi.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vim",
|
|
7
|
+
"neovim",
|
|
8
|
+
"tui",
|
|
9
|
+
"terminal",
|
|
10
|
+
"coding-agent",
|
|
11
|
+
"ai",
|
|
12
|
+
"agents",
|
|
13
|
+
"git-worktree",
|
|
14
|
+
"pi",
|
|
15
|
+
"llm",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/farming-labs/vigent#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/farming-labs/vigent/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/farming-labs/vigent.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "KinfeMichael Tariku",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"vigent": "./dist/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"prepare": "npm run build",
|
|
41
|
+
"start": "node dist/cli.js",
|
|
42
|
+
"demo": "node dist/cli.js --demo",
|
|
43
|
+
"test": "tsc && node --test test/*.test.mjs",
|
|
44
|
+
"prepack": "npm run clean && npm run build",
|
|
45
|
+
"prepublishOnly": "npm test"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@mariozechner/pi-ai": "^0.73.1",
|
|
52
|
+
"@mariozechner/pi-coding-agent": "^0.73.1",
|
|
53
|
+
"@mariozechner/pi-tui": "^0.73.1",
|
|
54
|
+
"smol-toml": "^1.9.1"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@xterm/headless": "^5.5.0",
|
|
58
|
+
"typescript": "^7.0.2"
|
|
59
|
+
},
|
|
60
|
+
"optionalDependencies": {
|
|
61
|
+
"riftri": "^0.6.7"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|