@vigent/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Which agent engines are available, and are they signed in?
3
+ *
4
+ * pi runs inside vigent. Claude Code, Codex and OpenCode are the CLIs you
5
+ * may already have installed; vigent runs them as they are, so each one uses
6
+ * its own login (vigent never reads their credentials). Detection only runs
7
+ * their version and login-status commands.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ export const ENGINE_IDS = ["pi", "claude", "codex", "opencode"];
14
+ export const ENGINE_NAMES = {
15
+ pi: "pi",
16
+ claude: "Claude Code",
17
+ codex: "Codex",
18
+ opencode: "OpenCode",
19
+ };
20
+ export const ENGINE_BINS = {
21
+ claude: "claude",
22
+ codex: "codex",
23
+ opencode: "opencode",
24
+ };
25
+ const INSTALL = {
26
+ claude: "npm install -g @anthropic-ai/claude-code",
27
+ codex: "npm install -g @openai/codex",
28
+ opencode: "npm install -g opencode-ai",
29
+ };
30
+ const PI_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "OPENROUTER_API_KEY"];
31
+ export function isEngineId(s) {
32
+ return ENGINE_IDS.includes(s);
33
+ }
34
+ /** Is this command on PATH? (a quick check, without running it) */
35
+ export function onPath(bin, env = process.env) {
36
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
37
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
38
+ if (!dir)
39
+ continue;
40
+ for (const ext of exts) {
41
+ try {
42
+ fs.accessSync(path.join(dir, bin + ext), fs.constants.X_OK);
43
+ return true;
44
+ }
45
+ catch {
46
+ // not here
47
+ }
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ /** pi can run: an API key in the environment, or a saved pi login. */
53
+ export function piConnected(env = process.env) {
54
+ return detectPi(env).connected;
55
+ }
56
+ /**
57
+ * The engine new agents use when none is given: pi if it has a key, else the
58
+ * first signed-in CLI (Claude Code, Codex, OpenCode). Before the sign-in checks
59
+ * finish, the first of those that is installed.
60
+ */
61
+ export function defaultEngine(engines, env = process.env) {
62
+ if (engines) {
63
+ const ready = engines.find((e) => e.connected);
64
+ if (ready)
65
+ return ready.id;
66
+ const installed = engines.find((e) => e.id !== "pi" && e.installed);
67
+ return installed?.id ?? "pi";
68
+ }
69
+ if (piConnected(env))
70
+ return "pi";
71
+ for (const id of ["claude", "codex", "opencode"])
72
+ if (onPath(ENGINE_BINS[id], env))
73
+ return id;
74
+ return "pi";
75
+ }
76
+ /** Run a command with a timeout; never throws. */
77
+ function run(cmd, args, timeoutMs, env) {
78
+ return new Promise((resolve) => {
79
+ execFile(cmd, args, { timeout: timeoutMs, env, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
80
+ const code = err ? (err.code === "ENOENT" ? null : (err.code ?? 1)) : 0;
81
+ resolve({ ok: !err, code: typeof code === "number" ? code : null, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
82
+ });
83
+ });
84
+ }
85
+ function strip(s) {
86
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
87
+ }
88
+ function firstVersion(s) {
89
+ return /\d+\.\d+\.\d+[\w.-]*/.exec(s)?.[0];
90
+ }
91
+ function detectPi(env) {
92
+ const keys = PI_KEYS.filter((k) => env[k]);
93
+ let stored = false;
94
+ try {
95
+ const auth = JSON.parse(fs.readFileSync(path.join(env.HOME ?? os.homedir(), ".pi", "agent", "auth.json"), "utf8"));
96
+ stored = Object.keys(auth ?? {}).length > 0;
97
+ }
98
+ catch {
99
+ // no pi login stored
100
+ }
101
+ const connected = keys.length > 0 || stored;
102
+ return {
103
+ id: "pi",
104
+ name: ENGINE_NAMES.pi,
105
+ installed: true,
106
+ connected,
107
+ detail: connected
108
+ ? `built in · ${keys.length > 0 ? `using ${keys.join(", ")}` : "using pi's saved login"}`
109
+ : "built in · no API key found",
110
+ connect: connected ? undefined : "export ANTHROPIC_API_KEY=… (or OPENAI_API_KEY, GEMINI_API_KEY)",
111
+ };
112
+ }
113
+ async function detectClaude(env, timeoutMs) {
114
+ const base = { id: "claude", name: ENGINE_NAMES.claude, install: INSTALL.claude, connect: "claude auth login" };
115
+ const v = await run("claude", ["--version"], timeoutMs, env);
116
+ if (v.code === null)
117
+ return { ...base, installed: false, connected: false, detail: "not installed" };
118
+ const version = firstVersion(v.stdout);
119
+ const s = await run("claude", ["auth", "status", "--json"], timeoutMs, env);
120
+ try {
121
+ const st = JSON.parse(s.stdout);
122
+ if (st.loggedIn) {
123
+ const how = st.authMethod === "api_key" || st.apiKeySource ? "API key" : "Claude account";
124
+ return { ...base, installed: true, connected: true, version, detail: `signed in (${how})`, connect: undefined };
125
+ }
126
+ return { ...base, installed: true, connected: false, version, detail: "not signed in" };
127
+ }
128
+ catch {
129
+ if (env.ANTHROPIC_API_KEY)
130
+ return { ...base, installed: true, connected: true, version, detail: "using ANTHROPIC_API_KEY", connect: undefined };
131
+ return { ...base, installed: true, connected: false, version, detail: "couldn't read sign-in status" };
132
+ }
133
+ }
134
+ async function detectCodex(env, timeoutMs) {
135
+ const base = { id: "codex", name: ENGINE_NAMES.codex, install: INSTALL.codex, connect: "codex login" };
136
+ const v = await run("codex", ["--version"], timeoutMs, env);
137
+ if (v.code === null)
138
+ return { ...base, installed: false, connected: false, detail: "not installed" };
139
+ const version = firstVersion(v.stdout);
140
+ const s = await run("codex", ["login", "status"], timeoutMs, env);
141
+ const text = strip(`${s.stdout}\n${s.stderr}`).trim();
142
+ if (s.ok && /logged in/i.test(text) && !/not logged in/i.test(text)) {
143
+ const line = text.split("\n").find((l) => /logged in/i.test(l)) ?? "signed in";
144
+ return { ...base, installed: true, connected: true, version, detail: line.trim().replace(/^logged in/i, "signed in"), connect: undefined };
145
+ }
146
+ if (env.OPENAI_API_KEY)
147
+ return { ...base, installed: true, connected: false, version, detail: "not signed in (OPENAI_API_KEY is set: printenv OPENAI_API_KEY | codex login --with-api-key)" };
148
+ return { ...base, installed: true, connected: false, version, detail: "not signed in" };
149
+ }
150
+ async function detectOpenCode(env, timeoutMs) {
151
+ const base = { id: "opencode", name: ENGINE_NAMES.opencode, install: INSTALL.opencode, connect: "opencode auth login" };
152
+ const v = await run("opencode", ["--version"], timeoutMs, env);
153
+ if (v.code === null)
154
+ return { ...base, installed: false, connected: false, version: undefined, detail: "not installed" };
155
+ const version = firstVersion(v.stdout);
156
+ const s = await run("opencode", ["auth", "list"], timeoutMs, env);
157
+ const text = strip(`${s.stdout}\n${s.stderr}`);
158
+ const creds = Number(/(\d+)\s+credentials?/.exec(text)?.[1] ?? 0);
159
+ const envVars = Number(/(\d+)\s+environment variables?/.exec(text)?.[1] ?? 0);
160
+ if (creds > 0 || envVars > 0) {
161
+ const parts = [creds > 0 ? `${creds} saved login${creds === 1 ? "" : "s"}` : "", envVars > 0 ? `${envVars} provider key${envVars === 1 ? "" : "s"} in the environment` : ""].filter(Boolean);
162
+ return { ...base, installed: true, connected: true, version, detail: parts.join(", "), connect: undefined };
163
+ }
164
+ return { ...base, installed: true, connected: false, version, detail: "no providers connected" };
165
+ }
166
+ /**
167
+ * What's known instantly, without running anything: which CLIs are on PATH,
168
+ * and pi's keys. Shown at startup while detectEngines() asks the CLIs.
169
+ */
170
+ export function quickEngines(env = process.env) {
171
+ return [
172
+ detectPi(env),
173
+ ...["claude", "codex", "opencode"].map((id) => {
174
+ const installed = onPath(ENGINE_BINS[id], env);
175
+ return {
176
+ id,
177
+ name: ENGINE_NAMES[id],
178
+ installed,
179
+ connected: false,
180
+ checking: installed,
181
+ detail: installed ? "checking sign-in…" : "not installed",
182
+ install: INSTALL[id],
183
+ };
184
+ }),
185
+ ];
186
+ }
187
+ /** Check every engine in parallel. Each CLI check is bounded by `timeoutMs`. */
188
+ export async function detectEngines(env = process.env, timeoutMs = 8000) {
189
+ return Promise.all([
190
+ Promise.resolve(detectPi(env)),
191
+ detectClaude(env, timeoutMs),
192
+ detectCodex(env, timeoutMs),
193
+ detectOpenCode(env, timeoutMs),
194
+ ]);
195
+ }
package/dist/export.js ADDED
@@ -0,0 +1,29 @@
1
+ export function transcriptMarkdown(s, now = new Date()) {
2
+ const out = [`# ${s.name}`, ""];
3
+ const meta = [`model: ${s.model}`];
4
+ if (s.worktree)
5
+ meta.push(`branch: \`${s.worktree.branch}\``);
6
+ if (s.worktree?.stat)
7
+ meta.push(`changes: +${s.worktree.stat.added} −${s.worktree.stat.removed}`);
8
+ meta.push(`exported: ${now.toISOString().replace("T", " ").slice(0, 16)}`);
9
+ out.push(meta.join(" · "), "");
10
+ for (const item of s.items) {
11
+ switch (item.kind) {
12
+ case "user":
13
+ out.push(`## You`, "", item.text, "");
14
+ break;
15
+ case "agent":
16
+ out.push(`## Agent`, "", item.text, "");
17
+ break;
18
+ case "tool": {
19
+ const t = item.tool;
20
+ out.push(`<details><summary><code>${t.name}</code> ${t.summary}</summary>`, "", "```", t.output.trimEnd(), "```", "", "</details>", "");
21
+ break;
22
+ }
23
+ case "info":
24
+ out.push(`> ${item.text}`, "");
25
+ break;
26
+ }
27
+ }
28
+ return `${out.join("\n").trimEnd()}\n`;
29
+ }
package/dist/float.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * A scrollable floating window: read-only text (`:help`, `:map`, `:diff`),
3
+ * optionally with selectable rows (`:dash`) and jump points (`]c` / `[c`
4
+ * between diff hunks).
5
+ */
6
+ import { parseKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+ import { RESET } from "./theme.js";
8
+ export class TextFloat {
9
+ painter;
10
+ icons;
11
+ title;
12
+ lines;
13
+ height;
14
+ onClose;
15
+ requestRender;
16
+ opts;
17
+ top = 0;
18
+ selected = 0;
19
+ pending = "";
20
+ constructor(painter, icons, title, lines, height, onClose, requestRender, opts = {}) {
21
+ this.painter = painter;
22
+ this.icons = icons;
23
+ this.title = title;
24
+ this.lines = lines;
25
+ this.height = height;
26
+ this.onClose = onClose;
27
+ this.requestRender = requestRender;
28
+ this.opts = opts;
29
+ const sel = opts.selectable ?? [];
30
+ if (sel.length > 0) {
31
+ this.selected = Math.max(0, opts.initial !== undefined ? sel.indexOf(opts.initial) : 0);
32
+ this.reveal(sel[this.selected]);
33
+ }
34
+ }
35
+ reveal(line) {
36
+ const n = this.visible();
37
+ if (line < this.top)
38
+ this.top = line;
39
+ else if (line >= this.top + n)
40
+ this.top = Math.min(Math.max(0, this.lines.length - n), line - n + 2);
41
+ }
42
+ select(delta) {
43
+ const sel = this.opts.selectable ?? [];
44
+ if (sel.length === 0)
45
+ return false;
46
+ this.selected = Math.max(0, Math.min(sel.length - 1, this.selected + delta));
47
+ this.reveal(sel[this.selected]);
48
+ return true;
49
+ }
50
+ jump(dir) {
51
+ const jumps = this.opts.jumps ?? [];
52
+ const target = dir > 0 ? jumps.find((j) => j > this.top) : [...jumps].reverse().find((j) => j < this.top);
53
+ if (target !== undefined)
54
+ this.top = Math.min(target, Math.max(0, this.lines.length - this.visible()));
55
+ }
56
+ visible() {
57
+ return Math.max(3, Math.min(this.lines.length, this.height() - 2));
58
+ }
59
+ scroll(delta) {
60
+ const max = Math.max(0, this.lines.length - this.visible());
61
+ this.top = Math.max(0, Math.min(max, this.top + delta));
62
+ }
63
+ handleInput(data) {
64
+ const key = parseKey(data) ?? data;
65
+ if (this.pending) {
66
+ const p = this.pending;
67
+ this.pending = "";
68
+ if ((p === "]" || p === "[") && key === "c")
69
+ this.jump(p === "]" ? 1 : -1);
70
+ this.requestRender();
71
+ return;
72
+ }
73
+ switch (key) {
74
+ case "enter":
75
+ case "return": {
76
+ const sel = this.opts.selectable ?? [];
77
+ this.onClose();
78
+ if (sel.length > 0)
79
+ this.opts.onSelect?.(sel[this.selected]);
80
+ return;
81
+ }
82
+ case "escape":
83
+ case "q":
84
+ case "ctrl+c":
85
+ this.onClose();
86
+ return;
87
+ case "]":
88
+ case "[":
89
+ this.pending = key;
90
+ return;
91
+ case "s": {
92
+ const alt = this.opts.alternate;
93
+ if (!alt)
94
+ break;
95
+ this.opts.alternate = { title: this.title, lines: this.lines, jumps: this.opts.jumps };
96
+ this.title = alt.title;
97
+ this.lines = alt.lines;
98
+ this.opts.jumps = alt.jumps;
99
+ this.top = 0;
100
+ break;
101
+ }
102
+ case "j":
103
+ case "down":
104
+ if (!this.select(1))
105
+ this.scroll(1);
106
+ break;
107
+ case "k":
108
+ case "up":
109
+ if (!this.select(-1))
110
+ this.scroll(-1);
111
+ break;
112
+ case "ctrl+d":
113
+ case "pageDown":
114
+ case "space":
115
+ this.scroll(Math.floor(this.visible() / 2));
116
+ break;
117
+ case "ctrl+u":
118
+ case "pageUp":
119
+ this.scroll(-Math.floor(this.visible() / 2));
120
+ break;
121
+ case "g":
122
+ this.top = 0;
123
+ break;
124
+ case "G":
125
+ case "shift+g":
126
+ this.scroll(this.lines.length);
127
+ break;
128
+ }
129
+ this.requestRender();
130
+ }
131
+ invalidate() { }
132
+ render(width) {
133
+ const P = this.painter;
134
+ const I = this.icons;
135
+ const inner = Math.max(10, width - 2);
136
+ const bg = P.attr("NormalFloat").bg;
137
+ const border = (s) => P.on("FloatBorder", bg) + s + RESET;
138
+ const title = ` ${this.title} `;
139
+ const out = [
140
+ border(I.tl + I.h) +
141
+ P.on("FloatTitle", bg) +
142
+ title +
143
+ RESET +
144
+ border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr),
145
+ ];
146
+ const n = this.visible();
147
+ const selectedLine = (this.opts.selectable ?? [])[this.selected];
148
+ const selBg = P.attr("PmenuSel").bg ?? P.attr("Visual").bg;
149
+ this.lines.slice(this.top, this.top + n).forEach((line, i) => {
150
+ const isSelected = this.top + i === selectedLine;
151
+ const rowBg = isSelected ? selBg : bg;
152
+ let text = "";
153
+ let used = 0;
154
+ for (const [group, seg] of line) {
155
+ const t = truncateToWidth(seg, Math.max(0, inner - 1 - used), "…");
156
+ // the selected row uses the theme's PmenuSel colors so it stays readable
157
+ text += RESET + P.on(isSelected ? "PmenuSel" : group, rowBg) + t;
158
+ used += visibleWidth(t);
159
+ }
160
+ out.push(border(I.v) +
161
+ P.on("NormalFloat", rowBg) +
162
+ " " +
163
+ text +
164
+ P.on("NormalFloat", rowBg) +
165
+ " ".repeat(Math.max(0, inner - 1 - used)) +
166
+ RESET +
167
+ border(I.v));
168
+ });
169
+ const more = this.lines.length > n ? ` ${this.top + 1}-${Math.min(this.lines.length, this.top + n)}/${this.lines.length} ` : "";
170
+ const hint = " q close ";
171
+ out.push(border(I.bl + I.h) +
172
+ P.on("Comment", bg) +
173
+ hint +
174
+ RESET +
175
+ border(I.h.repeat(Math.max(0, inner - 2 - visibleWidth(hint) - visibleWidth(more)))) +
176
+ P.on("Comment", bg) +
177
+ more +
178
+ RESET +
179
+ border(I.h + I.br));
180
+ return out;
181
+ }
182
+ }
package/dist/import.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Import vim / nvim colorschemes as vigent themes.
3
+ *
4
+ * vigent theme import the colorscheme your nvim/vim config uses
5
+ * vigent theme import tokyonight a colorscheme by name (plugins load normally)
6
+ * vigent theme import ./molokai.vim a colorscheme file (works without an editor)
7
+ *
8
+ * The editor runs headless with your normal config, loads the colorscheme,
9
+ * and dumps every resolved highlight group. If that fails it retries without
10
+ * your config, then with the other editor.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { resolveVimColor } from "./color.js";
17
+ import { themesDir } from "./theme.js";
18
+ import { parseHighlightCommand, parseVimColorscheme } from "./vimcolors.js";
19
+ const NVIM_DUMP = `
20
+ local result = { name = vim.g.colors_name, background = vim.o.background }
21
+ local ok, groups = pcall(function()
22
+ local out = {}
23
+ for name, hl in pairs(vim.api.nvim_get_hl(0, {})) do
24
+ local r = hl
25
+ if hl.link then r = vim.api.nvim_get_hl(0, { name = name, link = false }) end
26
+ local e = {}
27
+ if r.fg then e.fg = string.format("#%06x", r.fg) elseif r.ctermfg then e.ctermfg = r.ctermfg end
28
+ if r.bg then e.bg = string.format("#%06x", r.bg) elseif r.ctermbg then e.ctermbg = r.ctermbg end
29
+ for _, k in ipairs({ "bold", "italic", "underline", "undercurl", "reverse", "standout" }) do
30
+ if r[k] then e[k] = true end
31
+ end
32
+ if next(e) ~= nil then out[name] = e end
33
+ end
34
+ return out
35
+ end)
36
+ if ok then result.groups = groups else result.text = vim.fn.execute("highlight") end
37
+ local encode = (vim.json and vim.json.encode) or vim.fn.json_encode
38
+ local f = assert(io.open(os.getenv("VIGENT_OUT"), "w"))
39
+ f:write(encode(result))
40
+ f:close()
41
+ `;
42
+ const VIM_DUMP = `
43
+ let s:out = {'name': get(g:, 'colors_name', ''), 'background': &background}
44
+ if exists('*hlget')
45
+ let s:groups = {}
46
+ for s:h in hlget()
47
+ let s:r = get(hlget(s:h.name, v:true), 0, {})
48
+ let s:e = {}
49
+ for [s:key, s:dst] in [['guifg', 'fg'], ['guibg', 'bg']]
50
+ if has_key(s:r, s:key)
51
+ let s:c = s:r[s:key]
52
+ if s:c !~ '^#' && exists('v:colornames')
53
+ let s:c = get(v:colornames, tolower(s:c), s:c)
54
+ endif
55
+ let s:e[s:dst] = s:c
56
+ endif
57
+ endfor
58
+ if !has_key(s:e, 'fg') && has_key(s:r, 'ctermfg') | let s:e.ctermfg = s:r.ctermfg | endif
59
+ if !has_key(s:e, 'bg') && has_key(s:r, 'ctermbg') | let s:e.ctermbg = s:r.ctermbg | endif
60
+ let s:attrs = get(s:r, 'gui', get(s:r, 'cterm', {}))
61
+ for s:k in ['bold', 'italic', 'underline', 'undercurl', 'reverse', 'inverse', 'standout']
62
+ if get(s:attrs, s:k, v:false) | let s:e[s:k] = v:true | endif
63
+ endfor
64
+ if !empty(s:e) | let s:groups[s:h.name] = s:e | endif
65
+ endfor
66
+ let s:out.groups = s:groups
67
+ else
68
+ let s:out.text = execute('highlight')
69
+ endif
70
+ call writefile([json_encode(s:out)], $VIGENT_OUT)
71
+ `;
72
+ export function hasEditor(editor) {
73
+ const r = spawnSync(editor, ["--version"], { stdio: "ignore", timeout: 5000 });
74
+ return r.status === 0;
75
+ }
76
+ function isPath(source) {
77
+ return /[/\\]/.test(source) || /\.(vim|lua)$/.test(source);
78
+ }
79
+ /** Escape a path for use in a vim command line. */
80
+ function vimEscape(p) {
81
+ return p.replace(/[ \\|"%#]/g, "\\$&");
82
+ }
83
+ function loadCommand(source) {
84
+ if (!source)
85
+ return undefined;
86
+ if (isPath(source))
87
+ return `source ${vimEscape(path.resolve(source))}`;
88
+ if (!/^[\w.\-+]+$/.test(source))
89
+ throw new Error(`invalid colorscheme name '${source}'`);
90
+ return `colorscheme ${source}`;
91
+ }
92
+ function vimrcArgs() {
93
+ const home = os.homedir();
94
+ for (const f of [path.join(home, ".vimrc"), path.join(home, ".vim", "vimrc")]) {
95
+ if (fs.existsSync(f))
96
+ return ["-u", f];
97
+ }
98
+ return ["-u", "NONE"];
99
+ }
100
+ function runEditor(editor, opts, clean) {
101
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vigent-import-"));
102
+ const out = path.join(tmp, "dump.json");
103
+ const script = path.join(tmp, editor === "nvim" ? "dump.lua" : "dump.vim");
104
+ fs.writeFileSync(script, editor === "nvim" ? NVIM_DUMP : VIM_DUMP);
105
+ const cmds = ["set termguicolors"];
106
+ if (opts.background)
107
+ cmds.push(`set background=${opts.background}`);
108
+ const load = loadCommand(opts.source);
109
+ if (load)
110
+ cmds.push(load);
111
+ cmds.push(editor === "nvim" ? `luafile ${vimEscape(script)}` : `source ${vimEscape(script)}`);
112
+ cmds.push("qa!");
113
+ const args = editor === "nvim"
114
+ ? ["--headless", ...(clean ? ["--clean"] : []), ...cmds.flatMap((c) => ["-c", c])]
115
+ : [
116
+ "-es", "-N", "-i", "NONE",
117
+ ...(clean ? ["-u", "NONE"] : vimrcArgs()),
118
+ "-c", "silent! packloadall",
119
+ ...cmds.flatMap((c) => ["-c", c]),
120
+ ];
121
+ try {
122
+ spawnSync(editor, args, {
123
+ env: { ...process.env, VIGENT_OUT: out },
124
+ stdio: ["ignore", "ignore", "pipe"],
125
+ timeout: 60_000,
126
+ });
127
+ if (!fs.existsSync(out))
128
+ return undefined;
129
+ return JSON.parse(fs.readFileSync(out, "utf8"));
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ finally {
135
+ fs.rmSync(tmp, { recursive: true, force: true });
136
+ }
137
+ }
138
+ /** The name a colorscheme file sets (g:colors_name), if it says. */
139
+ function declaredName(file) {
140
+ try {
141
+ const text = fs.readFileSync(file, "utf8");
142
+ return /colors_name\s*=\s*['"]([^'"]+)['"]/.exec(text)?.[1];
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /** Did the editor actually switch to the requested colorscheme? (a failed load keeps the old one) */
149
+ function loadedRequested(dump, source) {
150
+ if (!dump.groups && !dump.text)
151
+ return false;
152
+ if (!source)
153
+ return true;
154
+ if (isPath(source)) {
155
+ const expected = declaredName(source);
156
+ return expected === undefined || dump.name === expected;
157
+ }
158
+ return dump.name === source;
159
+ }
160
+ function dumpToGroups(dump) {
161
+ const groups = {};
162
+ if (dump.groups) {
163
+ for (const [name, e] of Object.entries(dump.groups)) {
164
+ const attr = {};
165
+ const color = (v, cterm) => v === undefined || v === "" ? undefined : resolveVimColor(String(v), cterm);
166
+ attr.fg = color(e.fg, false) ?? color(e.ctermfg, true);
167
+ attr.bg = color(e.bg, false) ?? color(e.ctermbg, true);
168
+ if (e.bold)
169
+ attr.bold = true;
170
+ if (e.italic)
171
+ attr.italic = true;
172
+ if (e.underline || e.undercurl)
173
+ attr.underline = true;
174
+ if (e.reverse || e.inverse || e.standout)
175
+ attr.reverse = true;
176
+ if (attr.fg === undefined)
177
+ delete attr.fg;
178
+ if (attr.bg === undefined)
179
+ delete attr.bg;
180
+ if (Object.keys(attr).length > 0)
181
+ groups[name] = attr;
182
+ }
183
+ return groups;
184
+ }
185
+ return parseHighlightListing(dump.text ?? "");
186
+ }
187
+ /** Parse the output of `:highlight` (used for editors without nvim_get_hl / hlget). */
188
+ export function parseHighlightListing(text) {
189
+ const groups = {};
190
+ const entries = text.split("\n").reduce((acc, line) => {
191
+ if (/^\s/.test(line) && acc.length > 0)
192
+ acc[acc.length - 1] += ` ${line.trim()}`;
193
+ else if (line.trim())
194
+ acc.push(line.trim());
195
+ return acc;
196
+ }, []);
197
+ for (const entry of entries) {
198
+ const m = /^(\S+)\s+xxx\s+(.*)$/.exec(entry);
199
+ if (!m)
200
+ continue;
201
+ const link = /links to (\S+)/.exec(m[2]);
202
+ const own = m[2].replace(/links to \S+/, "").trim();
203
+ const cmd = own ? parseHighlightCommand(`${m[1]} ${own}`) : undefined;
204
+ if (cmd?.kind === "set" && Object.keys(cmd.attr).length > 0)
205
+ groups[m[1]] = cmd.attr;
206
+ else if (link)
207
+ groups[m[1]] = { link: link[1] };
208
+ }
209
+ return groups;
210
+ }
211
+ export function importTheme(opts = {}) {
212
+ const editors = opts.editor ? [opts.editor] : ["nvim", "vim"];
213
+ const available = editors.filter(hasEditor);
214
+ let dump;
215
+ let via = "";
216
+ for (const editor of available) {
217
+ const modes = opts.clean ? [true] : opts.source ? [false, true] : [false];
218
+ for (const clean of modes) {
219
+ const d = runEditor(editor, opts, clean);
220
+ if (d && loadedRequested(d, opts.source)) {
221
+ dump = d;
222
+ via = `${editor}${clean ? " --clean" : ""}`;
223
+ break;
224
+ }
225
+ }
226
+ if (dump)
227
+ break;
228
+ }
229
+ let groups;
230
+ let name;
231
+ let background;
232
+ if (dump) {
233
+ groups = dumpToGroups(dump);
234
+ name = opts.as ?? (dump.name || (opts.source ? path.basename(opts.source).replace(/\.(vim|lua)$/, "") : "current"));
235
+ background = dump.background;
236
+ }
237
+ else if (opts.source && isPath(opts.source) && opts.source.endsWith(".vim") && fs.existsSync(opts.source)) {
238
+ const parsed = parseVimColorscheme(fs.readFileSync(opts.source, "utf8"), opts.background);
239
+ groups = parsed.groups;
240
+ name = opts.as ?? parsed.name ?? path.basename(opts.source, ".vim");
241
+ background = parsed.background;
242
+ via = "static parse";
243
+ }
244
+ else if (available.length === 0) {
245
+ throw new Error("neither nvim nor vim was found on PATH. Install one, or import a .vim file directly: vigent theme import ./theme.vim");
246
+ }
247
+ else if (opts.source) {
248
+ throw new Error(`${available.join(" and ")} could not load colorscheme '${opts.source}'`);
249
+ }
250
+ else {
251
+ throw new Error(`could not read the current colorscheme from ${available.join(" or ")}`);
252
+ }
253
+ if (!/^[\w.\-+]+$/.test(name))
254
+ name = name.replace(/[^\w.\-+]+/g, "-");
255
+ const dir = opts.outDir ?? themesDir();
256
+ fs.mkdirSync(dir, { recursive: true });
257
+ const file = path.join(dir, `${name}.json`);
258
+ fs.writeFileSync(file, `${JSON.stringify({ name, background, source: via, groups }, null, "\t")}\n`);
259
+ return { name, file, groups: Object.keys(groups).length, via };
260
+ }