ast-mcp-server 0.7.2 → 0.8.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/CHANGELOG.md +43 -1
- package/README.md +36 -14
- package/SECURITY.md +8 -8
- package/dist/cli.js +18 -14
- package/dist/services/agent-setup.js +248 -27
- package/dist/services/agent-targets.js +209 -63
- package/dist/services/checkbox-state.js +31 -0
- package/dist/services/managed-file.js +709 -0
- package/dist/services/managed-guidance.js +229 -0
- package/dist/services/opencode-config.js +135 -0
- package/dist/services/raw-tty.js +75 -0
- package/dist/services/setup-wizard.js +45 -93
- package/dist/services/skill-installer.js +145 -84
- package/docs/support.md +13 -13
- package/package.json +6 -3
- package/skills/structural-code-editing/SKILL.md +9 -3
- package/skills/structural-code-editing/guidance.md +3 -0
- package/skills/structural-code-editing/releases.json +50 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyManagedFilePlan, canonicalizeFuturePath, captureManagedFilePreimage, createManagedFileApplyContext, hashBytes, verifyManagedFilePostimage, } from "./managed-file.js";
|
|
5
|
+
const BEGIN_MARKER = "<!-- ast-tool:structural-code-editing guidance v1 begin -->";
|
|
6
|
+
const END_MARKER = "<!-- ast-tool:structural-code-editing guidance v1 end -->";
|
|
7
|
+
const MARKER_NAMESPACE = "ast-tool:structural-code-editing guidance";
|
|
8
|
+
function configured(value, fallback, cwd) {
|
|
9
|
+
return path.resolve(cwd, value ?? fallback);
|
|
10
|
+
}
|
|
11
|
+
async function regularFileSize(filePath) {
|
|
12
|
+
try {
|
|
13
|
+
const info = await stat(filePath);
|
|
14
|
+
if (!info.isFile())
|
|
15
|
+
throw new Error(`Guidance source is not a regular file: ${filePath}`);
|
|
16
|
+
return info.size;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function openCodeConfigRoot(environment, home, cwd) {
|
|
25
|
+
if (environment.OPENCODE_CONFIG) {
|
|
26
|
+
return path.dirname(path.resolve(cwd, environment.OPENCODE_CONFIG));
|
|
27
|
+
}
|
|
28
|
+
if (environment.OPENCODE_CONFIG_DIR) {
|
|
29
|
+
return path.resolve(cwd, environment.OPENCODE_CONFIG_DIR);
|
|
30
|
+
}
|
|
31
|
+
return path.join(home, ".config", "opencode");
|
|
32
|
+
}
|
|
33
|
+
function safeGeminiContextFilename(value) {
|
|
34
|
+
const candidate = typeof value === "string"
|
|
35
|
+
? value
|
|
36
|
+
: Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
|
|
37
|
+
? value[0]
|
|
38
|
+
: undefined;
|
|
39
|
+
if (candidate === undefined) {
|
|
40
|
+
throw new Error("Gemini context.fileName routing is ambiguous or unsupported.");
|
|
41
|
+
}
|
|
42
|
+
if (candidate.length === 0 ||
|
|
43
|
+
path.isAbsolute(candidate) ||
|
|
44
|
+
candidate !== path.basename(candidate) ||
|
|
45
|
+
candidate === "." ||
|
|
46
|
+
candidate === "..") {
|
|
47
|
+
throw new Error("Gemini context.fileName must be one safe filename.");
|
|
48
|
+
}
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
async function geminiGuidancePath(home) {
|
|
52
|
+
const root = path.join(home, ".gemini");
|
|
53
|
+
const settingsPath = path.join(root, "settings.json");
|
|
54
|
+
let settingsText;
|
|
55
|
+
try {
|
|
56
|
+
settingsText = await readFile(settingsPath, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
return path.join(root, "GEMINI.md");
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(settingsText);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error("Gemini settings.json is not valid JSON.");
|
|
69
|
+
}
|
|
70
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
71
|
+
throw new Error("Gemini settings.json must contain an object.");
|
|
72
|
+
}
|
|
73
|
+
const context = parsed.context;
|
|
74
|
+
if (context === undefined)
|
|
75
|
+
return path.join(root, "GEMINI.md");
|
|
76
|
+
if (typeof context !== "object" || context === null || Array.isArray(context)) {
|
|
77
|
+
throw new Error("Gemini context settings are unsupported.");
|
|
78
|
+
}
|
|
79
|
+
const fileName = context.fileName;
|
|
80
|
+
if (fileName === undefined)
|
|
81
|
+
return path.join(root, "GEMINI.md");
|
|
82
|
+
return path.join(root, safeGeminiContextFilename(fileName));
|
|
83
|
+
}
|
|
84
|
+
async function resolveDestinations(options) {
|
|
85
|
+
const environment = options.environment ?? process.env;
|
|
86
|
+
const home = options.homeDirectory ?? os.homedir();
|
|
87
|
+
const cwd = options.workingDirectory ?? process.cwd();
|
|
88
|
+
const selected = new Set(options.agents);
|
|
89
|
+
const claudePath = path.join(configured(environment.CLAUDE_CONFIG_DIR, path.join(home, ".claude"), cwd), "CLAUDE.md");
|
|
90
|
+
const result = [];
|
|
91
|
+
for (const agent of options.agents) {
|
|
92
|
+
if (agent === "hermes" || agent === "copilot") {
|
|
93
|
+
result.push({ agent });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (agent === "claude") {
|
|
97
|
+
result.push({ agent, path: claudePath });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (agent === "opencode") {
|
|
101
|
+
const nativePath = path.join(openCodeConfigRoot(environment, home, cwd), "AGENTS.md");
|
|
102
|
+
if ((await regularFileSize(nativePath)) !== undefined) {
|
|
103
|
+
result.push({ agent, path: nativePath });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const fallbackSize = await regularFileSize(claudePath);
|
|
107
|
+
if (fallbackSize !== undefined && selected.has("claude")) {
|
|
108
|
+
result.push({ agent, path: claudePath });
|
|
109
|
+
}
|
|
110
|
+
else if (fallbackSize !== undefined) {
|
|
111
|
+
result.push({ agent, path: nativePath, seed: await readFile(claudePath) });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
result.push({ agent, path: nativePath });
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (agent === "codex") {
|
|
119
|
+
const root = configured(environment.CODEX_HOME, path.join(home, ".codex"), cwd);
|
|
120
|
+
const override = path.join(root, "AGENTS.override.md");
|
|
121
|
+
result.push({
|
|
122
|
+
agent,
|
|
123
|
+
path: (await regularFileSize(override)) ? override : path.join(root, "AGENTS.md"),
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
result.push({ agent, path: await geminiGuidancePath(home) });
|
|
128
|
+
}
|
|
129
|
+
return Promise.all(result.map(async (item) => "path" in item ? { ...item, path: await canonicalizeFuturePath(item.path) } : item));
|
|
130
|
+
}
|
|
131
|
+
function decodeUtf8(content, destination) {
|
|
132
|
+
const bom = content.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))
|
|
133
|
+
? content.subarray(0, 3)
|
|
134
|
+
: Buffer.alloc(0);
|
|
135
|
+
const body = content.subarray(bom.length);
|
|
136
|
+
if (body.includes(0))
|
|
137
|
+
throw new Error(`Guidance destination contains NUL bytes: ${destination}`);
|
|
138
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
139
|
+
return { bom, text };
|
|
140
|
+
}
|
|
141
|
+
function newlineFor(text) {
|
|
142
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
143
|
+
}
|
|
144
|
+
function canonicalBlock(guidance, newline) {
|
|
145
|
+
const normalized = guidance.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trimEnd();
|
|
146
|
+
return [BEGIN_MARKER, normalized, END_MARKER].join(newline);
|
|
147
|
+
}
|
|
148
|
+
function postimageFor(content, guidance, destination) {
|
|
149
|
+
const { bom, text } = decodeUtf8(content, destination);
|
|
150
|
+
const newline = newlineFor(text);
|
|
151
|
+
const block = canonicalBlock(guidance, newline);
|
|
152
|
+
const namespaceCount = text.split(MARKER_NAMESPACE).length - 1;
|
|
153
|
+
const begins = text.split(BEGIN_MARKER).length - 1;
|
|
154
|
+
const ends = text.split(END_MARKER).length - 1;
|
|
155
|
+
if (namespaceCount === 0) {
|
|
156
|
+
const separator = text.length === 0 ? "" : text.endsWith(newline) ? newline : `${newline}${newline}`;
|
|
157
|
+
return Buffer.concat([bom, Buffer.from(`${text}${separator}${block}${newline}`, "utf8")]);
|
|
158
|
+
}
|
|
159
|
+
if (namespaceCount !== 2 || begins !== 1 || ends !== 1) {
|
|
160
|
+
throw new Error(`Guidance destination contains malformed or unsupported managed markers: ${destination}`);
|
|
161
|
+
}
|
|
162
|
+
const begin = text.indexOf(BEGIN_MARKER);
|
|
163
|
+
const end = text.indexOf(END_MARKER);
|
|
164
|
+
if (begin < 0 || end < begin) {
|
|
165
|
+
throw new Error(`Guidance destination contains malformed managed markers: ${destination}`);
|
|
166
|
+
}
|
|
167
|
+
const afterEnd = end + END_MARKER.length;
|
|
168
|
+
return Buffer.concat([
|
|
169
|
+
bom,
|
|
170
|
+
Buffer.from(`${text.slice(0, begin)}${block}${text.slice(afterEnd)}`, "utf8"),
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
export async function planManagedGuidance(options) {
|
|
174
|
+
const guidancePath = path.resolve(options.sourceGuidancePath);
|
|
175
|
+
if ((await regularFileSize(guidancePath)) === undefined) {
|
|
176
|
+
throw new Error(`Bundled guidance is missing: ${guidancePath}`);
|
|
177
|
+
}
|
|
178
|
+
const guidanceBuffer = await readFile(guidancePath);
|
|
179
|
+
const { text: guidance } = decodeUtf8(guidanceBuffer, guidancePath);
|
|
180
|
+
if (guidance.trim().length === 0 || guidance.includes(MARKER_NAMESPACE)) {
|
|
181
|
+
throw new Error("Bundled guidance must be non-empty and marker-free.");
|
|
182
|
+
}
|
|
183
|
+
const destinations = await resolveDestinations(options);
|
|
184
|
+
const filesByPath = new Map();
|
|
185
|
+
const installations = [];
|
|
186
|
+
for (const destination of destinations) {
|
|
187
|
+
if (!("path" in destination)) {
|
|
188
|
+
installations.push({ agent: destination.agent, status: "skill_only" });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let file = filesByPath.get(destination.path);
|
|
192
|
+
if (file === undefined) {
|
|
193
|
+
const captured = await captureManagedFilePreimage(destination.path);
|
|
194
|
+
const snapshot = captured.snapshot;
|
|
195
|
+
const preimage = snapshot.exists ? captured.content : (destination.seed ?? Buffer.alloc(0));
|
|
196
|
+
const postimage = postimageFor(preimage, guidance, destination.path);
|
|
197
|
+
const postimageSha256 = hashBytes(postimage);
|
|
198
|
+
const status = !snapshot.exists
|
|
199
|
+
? "installed"
|
|
200
|
+
: snapshot.sha256 === postimageSha256
|
|
201
|
+
? "unchanged"
|
|
202
|
+
: "updated";
|
|
203
|
+
file = { path: destination.path, snapshot, postimage, postimageSha256, status };
|
|
204
|
+
filesByPath.set(destination.path, file);
|
|
205
|
+
}
|
|
206
|
+
else if (destination.seed !== undefined) {
|
|
207
|
+
const seededPostimage = postimageFor(destination.seed, guidance, destination.path);
|
|
208
|
+
if (!seededPostimage.equals(file.postimage)) {
|
|
209
|
+
throw new Error(`Guidance aliases require incompatible postimages: ${destination.path}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
installations.push({ agent: destination.agent, path: destination.path, status: file.status });
|
|
213
|
+
}
|
|
214
|
+
return { installations, files: [...filesByPath.values()] };
|
|
215
|
+
}
|
|
216
|
+
export async function applyManagedGuidancePlan(plan, onApplied, context = createManagedFileApplyContext(), hooks = {}) {
|
|
217
|
+
const authenticated = [];
|
|
218
|
+
for (const file of plan.files) {
|
|
219
|
+
for (const current of authenticated) {
|
|
220
|
+
await verifyManagedFilePostimage(current, context);
|
|
221
|
+
}
|
|
222
|
+
await applyManagedFilePlan(file, context, hooks);
|
|
223
|
+
authenticated.push(file);
|
|
224
|
+
await onApplied?.(file);
|
|
225
|
+
}
|
|
226
|
+
for (const current of authenticated) {
|
|
227
|
+
await verifyManagedFilePostimage(current, context);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
6
|
+
function hash(value) {
|
|
7
|
+
return createHash("sha256").update(value).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
export function resolveOpenCodeConfigPath(environment, homeDirectory) {
|
|
10
|
+
if (environment.OPENCODE_CONFIG)
|
|
11
|
+
return path.resolve(environment.OPENCODE_CONFIG);
|
|
12
|
+
if (environment.OPENCODE_CONFIG_DIR)
|
|
13
|
+
return path.resolve(environment.OPENCODE_CONFIG_DIR, "opencode.json");
|
|
14
|
+
return path.join(homeDirectory, ".config", "opencode", "opencode.json");
|
|
15
|
+
}
|
|
16
|
+
export async function withIsolatedOpenCodeConfig(environment, homeDirectory, operation) {
|
|
17
|
+
const selectedConfig = resolveOpenCodeConfigPath(environment, homeDirectory);
|
|
18
|
+
const routedDirectory = path.resolve(environment.OPENCODE_CONFIG_DIR ?? path.join(homeDirectory, ".config", "opencode"));
|
|
19
|
+
const routedConfig = path.join(routedDirectory, "opencode.json");
|
|
20
|
+
const readConfig = async (filePath) => {
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(filePath, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === "ENOENT")
|
|
26
|
+
return "{}\n";
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "ast-opencode-preflight-"));
|
|
31
|
+
const temporaryRoutedDirectory = path.join(temporaryRoot, "config-dir");
|
|
32
|
+
const temporaryRoutedConfig = path.join(temporaryRoutedDirectory, "opencode.json");
|
|
33
|
+
const selectedAndRoutedAreSame = selectedConfig === routedConfig;
|
|
34
|
+
const temporarySelectedConfig = selectedAndRoutedAreSame
|
|
35
|
+
? temporaryRoutedConfig
|
|
36
|
+
: path.join(temporaryRoot, "selected", path.basename(selectedConfig));
|
|
37
|
+
try {
|
|
38
|
+
await mkdir(path.dirname(temporarySelectedConfig), { recursive: true });
|
|
39
|
+
await mkdir(temporaryRoutedDirectory, { recursive: true });
|
|
40
|
+
await writeFile(temporarySelectedConfig, await readConfig(selectedConfig), {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
flag: "wx",
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
if (!selectedAndRoutedAreSame) {
|
|
46
|
+
await writeFile(temporaryRoutedConfig, await readConfig(routedConfig), {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
flag: "wx",
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return await operation({
|
|
53
|
+
...environment,
|
|
54
|
+
OPENCODE_CONFIG: temporarySelectedConfig,
|
|
55
|
+
OPENCODE_CONFIG_DIR: temporaryRoutedDirectory,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function planOpenCodeConfig(options) {
|
|
63
|
+
let before = "{}\n";
|
|
64
|
+
let beforeExists = true;
|
|
65
|
+
let mode = 0o600;
|
|
66
|
+
try {
|
|
67
|
+
before = await readFile(options.filePath, "utf8");
|
|
68
|
+
mode = (await stat(options.filePath)).mode & 0o777;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
beforeExists = false;
|
|
73
|
+
else
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
const parseErrors = [];
|
|
77
|
+
const parsed = parse(before, parseErrors);
|
|
78
|
+
if (parseErrors.length > 0) {
|
|
79
|
+
throw new Error("OpenCode configuration is not parseable JSON/JSONC.");
|
|
80
|
+
}
|
|
81
|
+
if (!parsed || typeof parsed !== "object")
|
|
82
|
+
throw new Error("OpenCode configuration must contain an object.");
|
|
83
|
+
const desired = {
|
|
84
|
+
type: "local",
|
|
85
|
+
command: [options.nodeExecutable, options.serverEntryPath],
|
|
86
|
+
enabled: true,
|
|
87
|
+
};
|
|
88
|
+
if (parsed.mcp?.ast !== undefined && JSON.stringify(parsed.mcp.ast) !== JSON.stringify(desired))
|
|
89
|
+
throw new Error("OpenCode configuration conflict at mcp.ast.");
|
|
90
|
+
const content = parsed.mcp?.ast === undefined
|
|
91
|
+
? applyEdits(before, modify(before, ["mcp", "ast"], desired, {
|
|
92
|
+
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
|
93
|
+
}))
|
|
94
|
+
: before;
|
|
95
|
+
return {
|
|
96
|
+
filePath: options.filePath,
|
|
97
|
+
beforeHash: hash(beforeExists ? before : ""),
|
|
98
|
+
beforeExists,
|
|
99
|
+
mode,
|
|
100
|
+
content,
|
|
101
|
+
status: content === before ? "unchanged" : "installed",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function applyOpenCodeConfigPlan(plan) {
|
|
105
|
+
if (plan.status === "unchanged")
|
|
106
|
+
return;
|
|
107
|
+
let current = "";
|
|
108
|
+
let exists = true;
|
|
109
|
+
try {
|
|
110
|
+
current = await readFile(plan.filePath, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === "ENOENT")
|
|
114
|
+
exists = false;
|
|
115
|
+
else
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
if (exists !== plan.beforeExists || hash(current) !== plan.beforeHash)
|
|
119
|
+
throw new Error("OpenCode configuration changed concurrently; no file was replaced.");
|
|
120
|
+
const directory = path.dirname(plan.filePath);
|
|
121
|
+
await mkdir(directory, { recursive: true });
|
|
122
|
+
const temporary = path.join(directory, `.${path.basename(plan.filePath)}.${randomUUID()}.tmp`);
|
|
123
|
+
await writeFile(temporary, plan.content, { encoding: "utf8", flag: "wx", mode: plan.mode });
|
|
124
|
+
try {
|
|
125
|
+
if (!plan.beforeExists) {
|
|
126
|
+
await link(temporary, plan.filePath);
|
|
127
|
+
await rm(temporary);
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
await rename(temporary, plan.filePath);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await rm(temporary, { force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createCheckboxState, reduceCheckboxState, } from "./checkbox-state.js";
|
|
2
|
+
function render(state) {
|
|
3
|
+
const lines = state.choices.map((choice, index) => {
|
|
4
|
+
const focus = index === state.focus ? "❯" : " ";
|
|
5
|
+
const marker = choice.checked ? "◉" : "◯";
|
|
6
|
+
const reason = choice.enabled ? "" : ` — ${choice.reason ?? "unavailable"}`;
|
|
7
|
+
return `${focus} ${marker} ${choice.label}${reason}`;
|
|
8
|
+
});
|
|
9
|
+
return `\u001b[2J\u001b[HSelect agents (↑/↓ move, Space toggle, Enter continue, Esc cancel)\n${lines.join("\n")}${state.validation ? `\n${state.validation}` : ""}`;
|
|
10
|
+
}
|
|
11
|
+
function keyAction(bytes) {
|
|
12
|
+
const value = bytes.toString("utf8");
|
|
13
|
+
if (value === "\u001b[A")
|
|
14
|
+
return "up";
|
|
15
|
+
if (value === "\u001b[B")
|
|
16
|
+
return "down";
|
|
17
|
+
if (value === " ")
|
|
18
|
+
return "toggle";
|
|
19
|
+
if (value === "\r" || value === "\n")
|
|
20
|
+
return "submit";
|
|
21
|
+
if (value === "\u001b" || value === "\u0003")
|
|
22
|
+
return "cancel";
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function runRawCheckbox(options) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const signals = options.signals ?? process;
|
|
28
|
+
const initialRaw = options.input.isRaw === true;
|
|
29
|
+
let state = createCheckboxState(options.choices);
|
|
30
|
+
let settled = false;
|
|
31
|
+
const cleanup = () => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
options.input.off("data", onData);
|
|
36
|
+
options.input.off("error", onError);
|
|
37
|
+
signals.off("SIGINT", onSignal);
|
|
38
|
+
signals.off("SIGTERM", onSignal);
|
|
39
|
+
options.input.setRawMode(initialRaw);
|
|
40
|
+
options.input.pause();
|
|
41
|
+
options.output.write("\u001b[?25h");
|
|
42
|
+
};
|
|
43
|
+
const finish = (selection) => {
|
|
44
|
+
cleanup();
|
|
45
|
+
resolve(selection);
|
|
46
|
+
};
|
|
47
|
+
const onSignal = () => finish([]);
|
|
48
|
+
const onError = () => finish([]);
|
|
49
|
+
const onData = (bytes) => {
|
|
50
|
+
const action = keyAction(bytes);
|
|
51
|
+
if (!action)
|
|
52
|
+
return;
|
|
53
|
+
state = reduceCheckboxState(state, action);
|
|
54
|
+
if (state.cancelled)
|
|
55
|
+
return finish([]);
|
|
56
|
+
if (state.submitted)
|
|
57
|
+
return finish(state.submitted);
|
|
58
|
+
options.output.write(render(state));
|
|
59
|
+
};
|
|
60
|
+
options.input.setRawMode(true);
|
|
61
|
+
options.input.resume();
|
|
62
|
+
options.input.on("data", onData);
|
|
63
|
+
options.input.on("error", onError);
|
|
64
|
+
signals.on("SIGINT", onSignal);
|
|
65
|
+
signals.on("SIGTERM", onSignal);
|
|
66
|
+
try {
|
|
67
|
+
options.output.write("\u001b[?25l");
|
|
68
|
+
options.output.write(render(state));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
cleanup();
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -1,110 +1,62 @@
|
|
|
1
|
-
import { AGENT_IDS, getAgentTarget } from "./agent-targets.js";
|
|
1
|
+
import { AGENT_IDS, getAgentTarget, } from "./agent-targets.js";
|
|
2
|
+
import { runRawCheckbox } from "./raw-tty.js";
|
|
2
3
|
export { AGENT_IDS } from "./agent-targets.js";
|
|
3
4
|
function isAgentId(value) {
|
|
4
5
|
return AGENT_IDS.includes(value);
|
|
5
6
|
}
|
|
6
7
|
export function parseAgentsArgument(value) {
|
|
7
8
|
const normalized = value.trim().toLowerCase();
|
|
8
|
-
if (normalized
|
|
9
|
+
if (!normalized)
|
|
9
10
|
throw new Error("The --agents value cannot be empty.");
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return [...AGENT_IDS];
|
|
13
|
-
}
|
|
11
|
+
if (normalized === "all")
|
|
12
|
+
return { mode: "all" };
|
|
14
13
|
const requested = normalized
|
|
15
14
|
.split(",")
|
|
16
15
|
.map((item) => item.trim())
|
|
17
16
|
.filter(Boolean);
|
|
18
|
-
if (requested.length === 0) {
|
|
19
|
-
throw new Error("The --agents value cannot be empty.");
|
|
20
|
-
}
|
|
21
17
|
const unsupported = requested.find((item) => !isAgentId(item));
|
|
22
|
-
if (unsupported
|
|
23
|
-
throw new Error(`Unsupported agent: ${unsupported}. Expected
|
|
24
|
-
}
|
|
18
|
+
if (unsupported)
|
|
19
|
+
throw new Error(`Unsupported agent: ${unsupported}. Expected ${AGENT_IDS.join(", ")}, or all.`);
|
|
25
20
|
const selected = new Set(requested);
|
|
26
|
-
return AGENT_IDS.filter((
|
|
21
|
+
return AGENT_IDS.filter((id) => selected.has(id));
|
|
27
22
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.split(/[\s,]+/)
|
|
40
|
-
.map((item) => item.trim())
|
|
41
|
-
.filter(Boolean);
|
|
42
|
-
for (const token of tokens) {
|
|
43
|
-
let detection;
|
|
44
|
-
if (/^\d+$/.test(token)) {
|
|
45
|
-
detection = detections[Number(token) - 1];
|
|
46
|
-
}
|
|
47
|
-
else if (isAgentId(token)) {
|
|
48
|
-
detection = detections.find((item) => item.id === token);
|
|
49
|
-
}
|
|
50
|
-
if (detection === undefined) {
|
|
51
|
-
throw new Error(`Invalid selection: ${token}.`);
|
|
52
|
-
}
|
|
53
|
-
if (!detection.installed) {
|
|
54
|
-
throw new Error(`${detection.label} is not installed.`);
|
|
55
|
-
}
|
|
56
|
-
selected.delete(detection.id);
|
|
57
|
-
}
|
|
58
|
-
return AGENT_IDS.filter((agent) => selected.has(agent));
|
|
23
|
+
function usability(detection) {
|
|
24
|
+
if (!detection.installed)
|
|
25
|
+
return {
|
|
26
|
+
usable: false,
|
|
27
|
+
reason: detection.compatibility?.status === "unavailable"
|
|
28
|
+
? detection.compatibility.reason
|
|
29
|
+
: "Not found in PATH.",
|
|
30
|
+
};
|
|
31
|
+
if (!detection.compatibility || detection.compatibility.status === "compatible")
|
|
32
|
+
return { usable: true };
|
|
33
|
+
return { usable: false, reason: detection.compatibility.reason };
|
|
59
34
|
}
|
|
60
|
-
function
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
35
|
+
export function resolveAgentSelection(request, detections) {
|
|
36
|
+
const byId = new Map(detections.map((item) => [item.id, item]));
|
|
37
|
+
const ids = Array.isArray(request) ? request : AGENT_IDS.filter((id) => byId.get(id)?.installed);
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
const detection = byId.get(id);
|
|
40
|
+
const result = detection
|
|
41
|
+
? usability(detection)
|
|
42
|
+
: { usable: false, reason: "Detection result is missing." };
|
|
43
|
+
if (!result.usable)
|
|
44
|
+
throw new Error(`${getAgentTarget(id).label}: ${result.reason}`);
|
|
45
|
+
}
|
|
46
|
+
return AGENT_IDS.filter((id) => ids.includes(id));
|
|
72
47
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
selected = applyAgentDeselections(answer, detections);
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
selectionError = error instanceof Error ? error.message : String(error);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (selected.length === 0) {
|
|
95
|
-
return [];
|
|
96
|
-
}
|
|
97
|
-
let confirmationError;
|
|
98
|
-
while (true) {
|
|
99
|
-
const answer = (await ask(renderConfirmation(selected, confirmationError)))
|
|
100
|
-
.trim()
|
|
101
|
-
.toLowerCase();
|
|
102
|
-
if (answer === "" || answer === "y" || answer === "yes") {
|
|
103
|
-
return selected;
|
|
104
|
-
}
|
|
105
|
-
if (answer === "n" || answer === "no") {
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
confirmationError = "Enter y or n.";
|
|
109
|
-
}
|
|
48
|
+
export async function promptForAgentSelection(detections, options) {
|
|
49
|
+
return runRawCheckbox({
|
|
50
|
+
...options,
|
|
51
|
+
choices: detections.map((item) => {
|
|
52
|
+
const result = usability(item);
|
|
53
|
+
return {
|
|
54
|
+
id: item.id,
|
|
55
|
+
label: item.label,
|
|
56
|
+
enabled: result.usable,
|
|
57
|
+
checked: result.usable,
|
|
58
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
110
62
|
}
|