bi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/cli.js +1054 -0
- package/lib/atomic-fs.js +109 -0
- package/lib/commands.js +537 -0
- package/lib/detect.js +27 -0
- package/lib/doctor.js +247 -0
- package/lib/hash.js +18 -0
- package/lib/installers.js +77 -0
- package/lib/json-adapter.js +45 -0
- package/lib/lock.js +55 -0
- package/lib/manifest.js +70 -0
- package/lib/merge-config.js +141 -0
- package/lib/prereq-check.js +14 -0
- package/lib/targets.js +216 -0
- package/lib/toml-adapter.js +79 -0
- package/package.json +57 -0
- package/templates/azure-mcp.json +11 -0
- package/templates/dataverse-mcp.json +11 -0
- package/templates/dbt-mcp.json +14 -0
- package/templates/fabric-mcp.json +11 -0
- package/templates/pac-cli-mcp.json +12 -0
- package/templates/postgres-mcp.json +14 -0
- package/templates/powerbi-mcp.json +11 -0
- package/templates/snowflake-mcp.json +12 -0
- package/templates/sqlserver-mcp.json +14 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as clack from "@clack/prompts";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
loadTemplates,
|
|
11
|
+
mergeWithPrevious,
|
|
12
|
+
getDetectedTargetGroups,
|
|
13
|
+
getCurrentSelections,
|
|
14
|
+
findExternallyManagedEntries,
|
|
15
|
+
computeDiff,
|
|
16
|
+
collectPlaceholders,
|
|
17
|
+
applyPlaceholderValues,
|
|
18
|
+
describePlaceholder,
|
|
19
|
+
applySelections,
|
|
20
|
+
listInstalled,
|
|
21
|
+
parseServerSpec,
|
|
22
|
+
getInstalledEntries,
|
|
23
|
+
reconfigureEntry,
|
|
24
|
+
getEntryDrift,
|
|
25
|
+
exportConfig,
|
|
26
|
+
buildSelectionsFromConfig,
|
|
27
|
+
isProtectedExportPath,
|
|
28
|
+
} from "../lib/commands.js";
|
|
29
|
+
import { isBinaryOnPath } from "../lib/prereq-check.js";
|
|
30
|
+
import { offerInstall } from "../lib/installers.js";
|
|
31
|
+
import { TARGET_DEFINITIONS, resolveTargets } from "../lib/targets.js";
|
|
32
|
+
import { removeServerFromTarget } from "../lib/merge-config.js";
|
|
33
|
+
import { acquireLock } from "../lib/lock.js";
|
|
34
|
+
import { atomicWriteFile } from "../lib/atomic-fs.js";
|
|
35
|
+
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
const SECRET_TOKEN_MARKERS = ["TOKEN", "SECRET", "PASSWORD", "CONNECTION_STRING", "PAT", "KEY", "URI"];
|
|
39
|
+
|
|
40
|
+
export const VALID_COMMANDS = ["init", "configure", "list", "doctor", "reconfigure", "remove", "export"];
|
|
41
|
+
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
|
|
44
|
+
function splitList(value) {
|
|
45
|
+
return value
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter((s) => s.length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pure argv parser -- never prints or exits. The caller decides what to do
|
|
53
|
+
* with an unrecognized command, --help, --version, etc, which keeps this
|
|
54
|
+
* function fully unit-testable without a TTY.
|
|
55
|
+
*/
|
|
56
|
+
// Value-taking flags (--servers, --targets, --out, --from) must not swallow
|
|
57
|
+
// the NEXT flag as their value -- e.g. `--servers --yes` should report an
|
|
58
|
+
// error naming --servers, not silently consume "--yes" as a server id.
|
|
59
|
+
// Returns { value, consumed }: consumed is false when the next token is
|
|
60
|
+
// missing or itself looks like a flag (starts with "--"), in which case the
|
|
61
|
+
// caller must not advance past it.
|
|
62
|
+
function readFlagValue(args, i) {
|
|
63
|
+
const value = args[i + 1];
|
|
64
|
+
if (value === undefined || value.startsWith("--")) {
|
|
65
|
+
return { value: undefined, consumed: false };
|
|
66
|
+
}
|
|
67
|
+
return { value, consumed: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseArgs(argv) {
|
|
71
|
+
const args = argv.slice(2);
|
|
72
|
+
const positional = [];
|
|
73
|
+
const result = {
|
|
74
|
+
command: null,
|
|
75
|
+
dryRun: false,
|
|
76
|
+
yes: false,
|
|
77
|
+
json: false,
|
|
78
|
+
help: false,
|
|
79
|
+
version: false,
|
|
80
|
+
servers: null,
|
|
81
|
+
targets: null,
|
|
82
|
+
prune: false,
|
|
83
|
+
out: null,
|
|
84
|
+
from: null,
|
|
85
|
+
rest: [],
|
|
86
|
+
errors: [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < args.length; i++) {
|
|
90
|
+
const arg = args[i];
|
|
91
|
+
if (arg === "--dry-run") {
|
|
92
|
+
result.dryRun = true;
|
|
93
|
+
} else if (arg === "--yes") {
|
|
94
|
+
result.yes = true;
|
|
95
|
+
} else if (arg === "--prune") {
|
|
96
|
+
result.prune = true;
|
|
97
|
+
} else if (arg === "--json") {
|
|
98
|
+
result.json = true;
|
|
99
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
100
|
+
result.help = true;
|
|
101
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
102
|
+
result.version = true;
|
|
103
|
+
} else if (arg === "--servers") {
|
|
104
|
+
const { value, consumed } = readFlagValue(args, i);
|
|
105
|
+
if (!consumed) {
|
|
106
|
+
result.errors.push("Flag --servers requires a value.");
|
|
107
|
+
result.servers = [];
|
|
108
|
+
} else {
|
|
109
|
+
result.servers = splitList(value);
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
} else if (arg.startsWith("--servers=")) {
|
|
113
|
+
result.servers = splitList(arg.slice("--servers=".length));
|
|
114
|
+
} else if (arg === "--targets") {
|
|
115
|
+
const { value, consumed } = readFlagValue(args, i);
|
|
116
|
+
if (!consumed) {
|
|
117
|
+
result.errors.push("Flag --targets requires a value.");
|
|
118
|
+
result.targets = [];
|
|
119
|
+
} else {
|
|
120
|
+
result.targets = splitList(value);
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
} else if (arg.startsWith("--targets=")) {
|
|
124
|
+
result.targets = splitList(arg.slice("--targets=".length));
|
|
125
|
+
} else if (arg === "--out") {
|
|
126
|
+
const { value, consumed } = readFlagValue(args, i);
|
|
127
|
+
if (!consumed) {
|
|
128
|
+
result.errors.push("Flag --out requires a value.");
|
|
129
|
+
result.out = null;
|
|
130
|
+
} else {
|
|
131
|
+
result.out = value;
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
} else if (arg.startsWith("--out=")) {
|
|
135
|
+
result.out = arg.slice("--out=".length);
|
|
136
|
+
} else if (arg === "--from") {
|
|
137
|
+
const { value, consumed } = readFlagValue(args, i);
|
|
138
|
+
if (!consumed) {
|
|
139
|
+
result.errors.push("Flag --from requires a value.");
|
|
140
|
+
result.from = null;
|
|
141
|
+
} else {
|
|
142
|
+
result.from = value;
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
} else if (arg.startsWith("--from=")) {
|
|
146
|
+
result.from = arg.slice("--from=".length);
|
|
147
|
+
} else if (!arg.startsWith("-")) {
|
|
148
|
+
positional.push(arg);
|
|
149
|
+
}
|
|
150
|
+
// unknown flags are ignored -- the flags above are the full surface area
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
result.command = positional[0] || "init";
|
|
154
|
+
result.rest = positional.slice(1);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function printHelp() {
|
|
159
|
+
const lines = [
|
|
160
|
+
"bi-agent-kit -- installs BI-related MCP server entries into AI coding tool configs",
|
|
161
|
+
"",
|
|
162
|
+
"Usage: bi-agent-kit <command> [flags]",
|
|
163
|
+
"",
|
|
164
|
+
"Commands:",
|
|
165
|
+
" init Interactively pick BI MCP servers and install them into detected configs",
|
|
166
|
+
" configure Same as init, re-run any time to change your selections",
|
|
167
|
+
" list Show what bi-agent-kit has installed, grouped by target config file",
|
|
168
|
+
" doctor Check the environment for common setup problems",
|
|
169
|
+
" reconfigure Re-run the placeholder walkthrough for an already-installed entry",
|
|
170
|
+
" remove <spec...> Remove one or more installed servers (templateId or templateId:instanceName)",
|
|
171
|
+
" export Print a portable JSON export of your installed server selections",
|
|
172
|
+
"",
|
|
173
|
+
"Flags:",
|
|
174
|
+
" --dry-run Show what would change without writing anything",
|
|
175
|
+
" --yes Skip confirmations; never runs installers without an explicit interactive yes",
|
|
176
|
+
" --json Machine-readable output (list, doctor)",
|
|
177
|
+
" --servers <ids> Comma-separated server specs, skips the interactive picker.",
|
|
178
|
+
" Each spec is templateId or templateId:instanceName (e.g. dataverse:dev)",
|
|
179
|
+
" Adds or updates the listed servers without touching any others already installed.",
|
|
180
|
+
" --prune With --servers, remove anything not listed instead of only adding/updating (full-sync semantics)",
|
|
181
|
+
" --targets <ids> Comma-separated target ids (see lib/targets.js), restricts --servers to these targets",
|
|
182
|
+
" --out <file> With export, write the document to a file instead of stdout",
|
|
183
|
+
" --from <file> With init, import a portable export document and install non-interactively",
|
|
184
|
+
" --help, -h Show this help and exit",
|
|
185
|
+
" --version, -v Show the installed version and exit",
|
|
186
|
+
];
|
|
187
|
+
console.log(lines.join("\n"));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function printVersion() {
|
|
191
|
+
const packageJsonPath = path.join(here, "..", "package.json");
|
|
192
|
+
const text = await fs.readFile(packageJsonPath, "utf8");
|
|
193
|
+
const pkg = JSON.parse(text);
|
|
194
|
+
console.log(pkg.version);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatChoiceLabel(group, template) {
|
|
198
|
+
return template.label + " -> " + group.labels.join(", ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function looksSecretLike(token) {
|
|
202
|
+
return SECRET_TOKEN_MARKERS.some((marker) => token.includes(marker));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function labelForTargetIds(targetIds) {
|
|
206
|
+
return (targetIds || [])
|
|
207
|
+
.map((id) => {
|
|
208
|
+
const def = TARGET_DEFINITIONS.find((d) => d.id === id);
|
|
209
|
+
return def ? def.label : id;
|
|
210
|
+
})
|
|
211
|
+
.join(", ");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** templateId for a selection, falling back to serverId for pre-existing manifests / callers. */
|
|
215
|
+
function templateIdFor(selection) {
|
|
216
|
+
return selection.templateId || selection.serverId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function warnIfSecretNotGitignored({ dir, absPath }) {
|
|
220
|
+
const resolvedDir = path.resolve(dir);
|
|
221
|
+
const resolvedAbsPath = path.resolve(absPath);
|
|
222
|
+
const relative = path.relative(resolvedDir, resolvedAbsPath);
|
|
223
|
+
const isProjectScoped = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
224
|
+
if (!isProjectScoped) return;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await execFileAsync("git", ["check-ignore", "-q", resolvedAbsPath], { cwd: resolvedDir });
|
|
228
|
+
// exit 0 -> file is ignored, nothing to warn about
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// git absent, not a repo, or any other execution failure -- stay silent
|
|
231
|
+
if (typeof err.code === "number") {
|
|
232
|
+
clack.log.warn(
|
|
233
|
+
absPath + " now contains a credential and is not gitignored. Add it to .gitignore before committing."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function offerBinaryInstall(binaryName) {
|
|
240
|
+
const result = await offerInstall(binaryName, {
|
|
241
|
+
confirm: (opts) => clack.confirm(opts),
|
|
242
|
+
});
|
|
243
|
+
if (!result.attempted) return false;
|
|
244
|
+
if (result.installed) {
|
|
245
|
+
const nowOnPath = await isBinaryOnPath(binaryName);
|
|
246
|
+
if (nowOnPath) {
|
|
247
|
+
clack.log.success(binaryName + " installed and found on PATH.");
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
clack.log.warn(binaryName + " install command completed, but it was still not found on PATH.");
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
clack.log.error("Failed to install " + binaryName + (result.message ? ": " + result.message : ""));
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function offerPacAuthWalkthrough() {
|
|
258
|
+
const configureNow = await clack.confirm({ message: "Configure a pac auth profile now?" });
|
|
259
|
+
if (clack.isCancel(configureNow) || !configureNow) return;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const { stdout } = await execFileAsync("pac", ["auth", "list"]);
|
|
263
|
+
clack.log.info(stdout.trim().length > 0 ? stdout.trim() : "No pac auth profiles found.");
|
|
264
|
+
} catch (err) {
|
|
265
|
+
clack.log.warn("Could not run pac auth list: " + err.message);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const createNow = await clack.confirm({ message: "Create a new pac auth profile?" });
|
|
269
|
+
if (clack.isCancel(createNow) || !createNow) return;
|
|
270
|
+
|
|
271
|
+
const environmentUrl = await clack.text({
|
|
272
|
+
message: "Environment URL (leave blank to skip)",
|
|
273
|
+
placeholder: "https://yourorg.crm.dynamics.com",
|
|
274
|
+
});
|
|
275
|
+
if (clack.isCancel(environmentUrl)) return;
|
|
276
|
+
|
|
277
|
+
const args = ["auth", "create"];
|
|
278
|
+
if (environmentUrl.trim().length > 0) {
|
|
279
|
+
args.push("--environment", environmentUrl.trim());
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
clack.log.info("A browser window will open for interactive login. Waiting for it to complete...");
|
|
283
|
+
try {
|
|
284
|
+
await execFileAsync("pac", args);
|
|
285
|
+
clack.log.success("pac auth profile created.");
|
|
286
|
+
} catch (err) {
|
|
287
|
+
clack.log.error("pac auth create failed: " + err.message);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function describeExternallyManagedGuidance() {
|
|
292
|
+
return "The entry stays untouched; remove or rename it by hand first if you want bi-agent-kit to manage it.";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Runs the interactive placeholder walkthrough for a template, prompting for
|
|
297
|
+
* every REPLACE_ token found in its config. Kept as a small isolated seam so
|
|
298
|
+
* the add flow, the picker's inline reconfigure, and the reconfigure command
|
|
299
|
+
* all share one implementation.
|
|
300
|
+
*
|
|
301
|
+
* Returns { answers, hasSecret } where answers is ready to hand to
|
|
302
|
+
* applyPlaceholderValues and hasSecret flags whether any answered token
|
|
303
|
+
* looked like a credential (for the gitignore warning).
|
|
304
|
+
*/
|
|
305
|
+
async function promptForPlaceholders(template) {
|
|
306
|
+
const placeholders = collectPlaceholders(template.config);
|
|
307
|
+
const answers = [];
|
|
308
|
+
let hasSecret = false;
|
|
309
|
+
for (const placeholder of placeholders) {
|
|
310
|
+
const answer = await clack.text({
|
|
311
|
+
message: describePlaceholder(placeholder.path, placeholder.token),
|
|
312
|
+
placeholder: placeholder.token,
|
|
313
|
+
});
|
|
314
|
+
if (clack.isCancel(answer)) {
|
|
315
|
+
clack.cancel("Cancelled.");
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
if (answer.trim().length > 0) {
|
|
319
|
+
answers.push({ path: placeholder.path, token: placeholder.token, value: answer.trim() });
|
|
320
|
+
if (looksSecretLike(placeholder.token)) hasSecret = true;
|
|
321
|
+
} else {
|
|
322
|
+
clack.log.warn(
|
|
323
|
+
"Left " +
|
|
324
|
+
placeholder.token +
|
|
325
|
+
" unset -- that literal token stays in the written config, and " +
|
|
326
|
+
template.label +
|
|
327
|
+
" will not work until you edit it."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { answers, hasSecret };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runList({ dir, json }) {
|
|
335
|
+
const installed = await listInstalled({ dir });
|
|
336
|
+
|
|
337
|
+
if (json) {
|
|
338
|
+
console.log(JSON.stringify(installed, null, 2));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
clack.intro("bi-agent-kit");
|
|
343
|
+
if (installed.length === 0) {
|
|
344
|
+
clack.log.info("Nothing installed yet. Run npx bi-agent-kit init to get started.");
|
|
345
|
+
clack.outro("Done.");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const byPath = new Map();
|
|
350
|
+
for (const entry of installed) {
|
|
351
|
+
if (!byPath.has(entry.absPath)) byPath.set(entry.absPath, []);
|
|
352
|
+
byPath.get(entry.absPath).push(entry);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [absPath, entries] of byPath) {
|
|
356
|
+
clack.log.step(labelForTargetIds(entries[0].targetIds) + " (" + absPath + ")");
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
clack.log.info(" " + entry.serverId + " -- installed " + (entry.installedAt || "unknown date"));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
clack.outro("Done.");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runDoctor({ dir, homedir, platform, json }) {
|
|
365
|
+
const { runDoctor: doctor } = await import("../lib/doctor.js");
|
|
366
|
+
const findings = await doctor({ dir, homedir, platform });
|
|
367
|
+
|
|
368
|
+
if (json) {
|
|
369
|
+
console.log(JSON.stringify(findings, null, 2));
|
|
370
|
+
} else {
|
|
371
|
+
clack.intro("bi-agent-kit doctor");
|
|
372
|
+
for (const finding of findings) {
|
|
373
|
+
const message = "[" + finding.area + "] " + finding.message;
|
|
374
|
+
if (finding.level === "error") clack.log.error(message);
|
|
375
|
+
else if (finding.level === "warn") clack.log.warn(message);
|
|
376
|
+
else clack.log.info(message);
|
|
377
|
+
}
|
|
378
|
+
clack.outro("Done.");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const hasError = findings.some((f) => f.level === "error");
|
|
382
|
+
process.exitCode = hasError ? 1 : 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Cross product of requested server specs x detected groups, restricted to
|
|
387
|
+
* groups whose targetIds intersect the requested target ids (if any).
|
|
388
|
+
* Each spec is "templateId" or "templateId:instanceName", parsed via
|
|
389
|
+
* parseServerSpec. Throws with a message listing the valid template ids when
|
|
390
|
+
* given an unknown templateId, or with parseServerSpec's own message when
|
|
391
|
+
* the instance name is invalid.
|
|
392
|
+
*/
|
|
393
|
+
function buildNonInteractiveSelections({ servers, targets, groups, templates }) {
|
|
394
|
+
const validServerIds = templates.map((t) => t.id);
|
|
395
|
+
|
|
396
|
+
const specs = servers.map((raw) => {
|
|
397
|
+
const spec = parseServerSpec(raw);
|
|
398
|
+
if (!validServerIds.includes(spec.templateId)) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
"Unknown server id: " + spec.templateId + ". Valid server ids: " + validServerIds.join(", ")
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return spec;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const validTargetIds = TARGET_DEFINITIONS.map((t) => t.id);
|
|
407
|
+
if (targets) {
|
|
408
|
+
for (const targetId of targets) {
|
|
409
|
+
if (!validTargetIds.includes(targetId)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"Unknown target id: " + targetId + ". Valid target ids: " + validTargetIds.join(", ")
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const matchingGroups = targets
|
|
418
|
+
? groups.filter((group) => group.targetIds.some((id) => targets.includes(id)))
|
|
419
|
+
: groups;
|
|
420
|
+
|
|
421
|
+
const selections = [];
|
|
422
|
+
for (const spec of specs) {
|
|
423
|
+
for (const group of matchingGroups) {
|
|
424
|
+
selections.push({ absPath: group.absPath, serverId: spec.serverId, templateId: spec.templateId });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return selections;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolves which previous selections belong to a given (templateId, absPath)
|
|
432
|
+
* pair. Previous selections are instance-keyed (serverId like "dataverse-dev"),
|
|
433
|
+
* so this matches on the entry's own templateId, falling back to parsing the
|
|
434
|
+
* base template id out of its serverId (bare id, or "templateId-instance")
|
|
435
|
+
* for older manifests that never recorded templateId.
|
|
436
|
+
*/
|
|
437
|
+
export function findPreviousEntriesForPair({ previousSelections, absPath, templateId, templates }) {
|
|
438
|
+
return previousSelections.filter(
|
|
439
|
+
(entry) => entry.absPath === absPath && resolveEntryTemplateId(entry, templates) === templateId
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function resolveEntryTemplateId(entry, templates) {
|
|
444
|
+
if (entry.templateId && templates.some((t) => t.id === entry.templateId)) {
|
|
445
|
+
return entry.templateId;
|
|
446
|
+
}
|
|
447
|
+
if (templates.some((t) => t.id === entry.serverId)) return entry.serverId;
|
|
448
|
+
const match = templates.find((t) => entry.serverId.startsWith(t.id + "-"));
|
|
449
|
+
return match ? match.id : entry.templateId || entry.serverId;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Set of base template ids that have ANY previous entry (base or named
|
|
454
|
+
* instance) on any target, derived via resolveEntryTemplateId. This is what
|
|
455
|
+
* the server picker's initialValues must be built from -- previousSelections
|
|
456
|
+
* are instance-keyed (serverId like "dataverse-dev"), so a naive
|
|
457
|
+
* `previousSelections.map(s => s.serverId)` set never matches the bare
|
|
458
|
+
* template id "dataverse" and the picker shows it unchecked even though an
|
|
459
|
+
* instance is already installed everywhere.
|
|
460
|
+
*/
|
|
461
|
+
export function previousTemplateIds({ previousSelections, templates }) {
|
|
462
|
+
return new Set(previousSelections.map((entry) => resolveEntryTemplateId(entry, templates)));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* For each already-installed (templateId, absPath) pair among the chosen
|
|
467
|
+
* servers/targets, asks the user whether to keep it as is, reconfigure it in
|
|
468
|
+
* place, or add a new named instance alongside it. Previous selections are
|
|
469
|
+
* instance-keyed, so a single pair may have more than one previous entry
|
|
470
|
+
* (e.g. dataverse-dev and dataverse-prod) -- "keep" carries every one of them
|
|
471
|
+
* forward unchanged, "reconfigure" lets the user pick which one (if more than
|
|
472
|
+
* one) and only that one gets a walkthrough, and "add as new instance" carries
|
|
473
|
+
* all existing ones forward and appends a new suffixed selection. A bare
|
|
474
|
+
* base-id selection is only ever pushed when no previous entry exists for the
|
|
475
|
+
* pair. Returns the resulting selections list (skip/new-instance both
|
|
476
|
+
* contribute selections that survive the diff; reconfigure additionally runs
|
|
477
|
+
* its walkthrough immediately and is reported separately since it never
|
|
478
|
+
* appears in `toAdd`).
|
|
479
|
+
*/
|
|
480
|
+
async function resolveInteractiveSelections({ chosenServers, chosenTargets, previousSelections, templates, dryRun }) {
|
|
481
|
+
const raw = [];
|
|
482
|
+
const reconfigured = [];
|
|
483
|
+
|
|
484
|
+
for (const templateId of chosenServers) {
|
|
485
|
+
for (const absPath of chosenTargets) {
|
|
486
|
+
const previousEntries = findPreviousEntriesForPair({ previousSelections, absPath, templateId, templates });
|
|
487
|
+
|
|
488
|
+
if (previousEntries.length === 0) {
|
|
489
|
+
raw.push({ absPath, serverId: templateId, templateId });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const choice = await clack.select({
|
|
494
|
+
message: templateId + " is already installed at " + absPath + ". What would you like to do?",
|
|
495
|
+
options: [
|
|
496
|
+
{ value: "skip", label: "Keep as is" },
|
|
497
|
+
{ value: "reconfigure", label: "Reconfigure values" },
|
|
498
|
+
{ value: "new-instance", label: "Add as a new named instance" },
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
if (clack.isCancel(choice)) {
|
|
502
|
+
clack.cancel("Cancelled.");
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Every existing instance for this pair survives the diff regardless of
|
|
507
|
+
// choice -- "reconfigure" only changes its stored config, and
|
|
508
|
+
// "new-instance" adds one more alongside these.
|
|
509
|
+
for (const entry of previousEntries) {
|
|
510
|
+
raw.push({ absPath, serverId: entry.serverId, templateId: entry.templateId || templateId });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (choice === "skip") {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (choice === "reconfigure") {
|
|
518
|
+
let target = previousEntries[0];
|
|
519
|
+
if (previousEntries.length > 1) {
|
|
520
|
+
const picked = await clack.select({
|
|
521
|
+
message: "Which instance of " + templateId + " would you like to reconfigure?",
|
|
522
|
+
options: previousEntries.map((entry) => ({ value: entry, label: entry.serverId })),
|
|
523
|
+
});
|
|
524
|
+
if (clack.isCancel(picked)) {
|
|
525
|
+
clack.cancel("Cancelled.");
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
target = picked;
|
|
529
|
+
}
|
|
530
|
+
const template = templates.find((t) => t.id === (target.templateId || templateId));
|
|
531
|
+
if (dryRun) {
|
|
532
|
+
reconfigured.push({ absPath, serverId: target.serverId, status: "dry-run" });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
clack.log.step("Reconfiguring " + template.label + " at " + absPath);
|
|
536
|
+
const { answers } = await promptForPlaceholders(template);
|
|
537
|
+
const configOverride = applyPlaceholderValues(template.config, answers);
|
|
538
|
+
reconfigured.push({ absPath, serverId: target.serverId, configOverride });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// new-instance: keep the existing entries, add a second one under a new name
|
|
543
|
+
let instanceName;
|
|
544
|
+
let spec = null;
|
|
545
|
+
while (spec === null) {
|
|
546
|
+
instanceName = await clack.text({ message: "Instance name (letters, numbers, hyphens)" });
|
|
547
|
+
if (clack.isCancel(instanceName)) {
|
|
548
|
+
clack.cancel("Cancelled.");
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
spec = parseServerSpec(templateId + ":" + instanceName);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
clack.log.error(err.message);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
raw.push({ absPath, serverId: spec.serverId, templateId: spec.templateId });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return { raw, reconfigured };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function runInstallFlow(parsed) {
|
|
565
|
+
clack.intro("bi-agent-kit");
|
|
566
|
+
|
|
567
|
+
const dir = process.cwd();
|
|
568
|
+
const homedir = os.homedir();
|
|
569
|
+
const platform = process.platform;
|
|
570
|
+
const { dryRun, yes } = parsed;
|
|
571
|
+
const nonInteractive = parsed.servers !== null || parsed.from !== null;
|
|
572
|
+
|
|
573
|
+
const groups = await getDetectedTargetGroups({ dir, homedir, platform });
|
|
574
|
+
if (groups.length === 0) {
|
|
575
|
+
clack.log.warn("No supported AI tool config files were detected in this project or your home directory.");
|
|
576
|
+
clack.outro("Nothing to do.");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const templates = await loadTemplates();
|
|
581
|
+
const previousSelections = await getCurrentSelections({ dir });
|
|
582
|
+
|
|
583
|
+
const externallyManaged = await findExternallyManagedEntries({ dir, groups, templates });
|
|
584
|
+
const externallyManagedKeys = new Set(externallyManaged.map((e) => e.absPath + "::" + e.serverId));
|
|
585
|
+
if (externallyManaged.length > 0) {
|
|
586
|
+
clack.log.warn(
|
|
587
|
+
"Skipping " +
|
|
588
|
+
externallyManaged.length +
|
|
589
|
+
" entry(ies) already configured outside bi-agent-kit, they will be left as is. " +
|
|
590
|
+
describeExternallyManagedGuidance()
|
|
591
|
+
);
|
|
592
|
+
for (const entry of externallyManaged) {
|
|
593
|
+
clack.log.info(" " + entry.serverId + " at " + entry.absPath);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const missingBinaries = new Set();
|
|
598
|
+
for (const template of templates) {
|
|
599
|
+
if (!template.requiresBinary) continue;
|
|
600
|
+
const found = await isBinaryOnPath(template.requiresBinary);
|
|
601
|
+
if (!found) missingBinaries.add(template.requiresBinary);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const canPromptForInstalls = !dryRun && !nonInteractive && !yes;
|
|
605
|
+
if (canPromptForInstalls) {
|
|
606
|
+
for (const binaryName of Array.from(missingBinaries)) {
|
|
607
|
+
const wantsInstall = await clack.confirm({
|
|
608
|
+
message: binaryName + " was not found on PATH. Install it now?",
|
|
609
|
+
});
|
|
610
|
+
if (clack.isCancel(wantsInstall)) {
|
|
611
|
+
clack.cancel("Cancelled.");
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
if (!wantsInstall) {
|
|
615
|
+
clack.log.warn(binaryName + " was not found on PATH -- servers that require it will be flagged in the picker below.");
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const succeeded = await offerBinaryInstall(binaryName);
|
|
619
|
+
if (succeeded) missingBinaries.delete(binaryName);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
for (const binaryName of missingBinaries) {
|
|
623
|
+
clack.log.warn(binaryName + " was not found on PATH -- servers that require it will be flagged in the picker below.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let newSelections;
|
|
628
|
+
let pickerReconfigured = [];
|
|
629
|
+
|
|
630
|
+
if (nonInteractive) {
|
|
631
|
+
let raw;
|
|
632
|
+
try {
|
|
633
|
+
if (parsed.from) {
|
|
634
|
+
const fromText = await fs.readFile(path.resolve(parsed.from), "utf8");
|
|
635
|
+
const doc = JSON.parse(fromText);
|
|
636
|
+
const { selections: fromSelections, skipped } = buildSelectionsFromConfig({ doc, groups });
|
|
637
|
+
for (const item of skipped) {
|
|
638
|
+
clack.log.warn(
|
|
639
|
+
"Skipping " + item.spec + " for target \"" + item.targetId + "\": not detected on this machine."
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
raw = fromSelections;
|
|
643
|
+
} else {
|
|
644
|
+
raw = buildNonInteractiveSelections({
|
|
645
|
+
servers: parsed.servers,
|
|
646
|
+
targets: parsed.targets,
|
|
647
|
+
groups,
|
|
648
|
+
templates,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
clack.log.error(err.message);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
// scopePaths limits what --prune is allowed to remove: the absPaths of the
|
|
656
|
+
// groups actually in play for this run. With --targets, that is only the
|
|
657
|
+
// restricted subset (so `--targets claude-code --prune` cannot touch a
|
|
658
|
+
// powerbi entry living in .cursor/mcp.json); without --targets it is every
|
|
659
|
+
// detected group, i.e. the previous unscoped full-prune behavior.
|
|
660
|
+
const scopeGroups = parsed.targets
|
|
661
|
+
? groups.filter((group) => group.targetIds.some((id) => parsed.targets.includes(id)))
|
|
662
|
+
: groups;
|
|
663
|
+
const scopePaths = new Set(scopeGroups.map((g) => g.absPath));
|
|
664
|
+
const merged = mergeWithPrevious({
|
|
665
|
+
previousSelections,
|
|
666
|
+
newSelections: raw,
|
|
667
|
+
prune: parsed.prune,
|
|
668
|
+
scopePaths,
|
|
669
|
+
});
|
|
670
|
+
newSelections = merged.filter((s) => !externallyManagedKeys.has(s.absPath + "::" + s.serverId));
|
|
671
|
+
} else {
|
|
672
|
+
const serverOptions = templates.map((template) => ({
|
|
673
|
+
value: template.id,
|
|
674
|
+
label: template.label + (template.description ? " -- " + template.description : ""),
|
|
675
|
+
}));
|
|
676
|
+
const previousTemplateIdSet = previousTemplateIds({ previousSelections, templates });
|
|
677
|
+
const chosenServers = await clack.multiselect({
|
|
678
|
+
message: "Select which BI servers to install",
|
|
679
|
+
options: serverOptions,
|
|
680
|
+
initialValues: templates.map((t) => t.id).filter((id) => previousTemplateIdSet.has(id)),
|
|
681
|
+
required: false,
|
|
682
|
+
});
|
|
683
|
+
if (clack.isCancel(chosenServers)) {
|
|
684
|
+
clack.cancel("Cancelled.");
|
|
685
|
+
process.exit(1);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const previousAbsPaths = new Set(previousSelections.map((s) => s.absPath));
|
|
689
|
+
const targetOptions = groups.map((group) => ({
|
|
690
|
+
value: group.absPath,
|
|
691
|
+
label: labelForTargetIds(group.targetIds) + " (" + group.absPath + ")",
|
|
692
|
+
}));
|
|
693
|
+
const chosenTargets = await clack.multiselect({
|
|
694
|
+
message: "Select which detected config files to install into",
|
|
695
|
+
options: targetOptions,
|
|
696
|
+
initialValues: groups.map((g) => g.absPath).filter((absPath) => previousAbsPaths.has(absPath)),
|
|
697
|
+
required: false,
|
|
698
|
+
});
|
|
699
|
+
if (clack.isCancel(chosenTargets)) {
|
|
700
|
+
clack.cancel("Cancelled.");
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const { raw, reconfigured } = await resolveInteractiveSelections({
|
|
705
|
+
chosenServers,
|
|
706
|
+
chosenTargets,
|
|
707
|
+
previousSelections,
|
|
708
|
+
templates,
|
|
709
|
+
dryRun,
|
|
710
|
+
});
|
|
711
|
+
pickerReconfigured = reconfigured;
|
|
712
|
+
newSelections = raw.filter((s) => !externallyManagedKeys.has(s.absPath + "::" + s.serverId));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const { toAdd } = computeDiff({ previousSelections, newSelections });
|
|
716
|
+
const toAddKeys = new Set(toAdd.map((s) => s.absPath + "::" + s.serverId));
|
|
717
|
+
|
|
718
|
+
const selections = [];
|
|
719
|
+
const secretSelectionKeys = new Set();
|
|
720
|
+
let pacCliJustConfigured = false;
|
|
721
|
+
for (const selection of newSelections) {
|
|
722
|
+
const key = selection.absPath + "::" + selection.serverId;
|
|
723
|
+
if (dryRun || !toAddKeys.has(key)) {
|
|
724
|
+
selections.push(selection);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const template = templates.find((t) => t.id === templateIdFor(selection));
|
|
728
|
+
const placeholders = template ? collectPlaceholders(template.config) : [];
|
|
729
|
+
if (placeholders.length === 0) {
|
|
730
|
+
selections.push(selection);
|
|
731
|
+
if (template && template.id === "pac-cli") pacCliJustConfigured = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (nonInteractive) {
|
|
736
|
+
clack.log.warn(
|
|
737
|
+
template.label +
|
|
738
|
+
" at " +
|
|
739
|
+
selection.absPath +
|
|
740
|
+
" still contains REPLACE_ placeholder values. Edit " +
|
|
741
|
+
selection.absPath +
|
|
742
|
+
" directly, or re-run interactively to fill them in."
|
|
743
|
+
);
|
|
744
|
+
selections.push(selection);
|
|
745
|
+
if (template.id === "pac-cli") pacCliJustConfigured = true;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
clack.log.step("Setup needed for " + template.label + " at " + selection.absPath);
|
|
750
|
+
const { answers, hasSecret } = await promptForPlaceholders(template);
|
|
751
|
+
if (hasSecret) secretSelectionKeys.add(key);
|
|
752
|
+
const configOverride = applyPlaceholderValues(template.config, answers);
|
|
753
|
+
selections.push({ ...selection, configOverride });
|
|
754
|
+
if (template && template.id === "pac-cli") pacCliJustConfigured = true;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const report = await applySelections({ dir, homedir, platform, templates, selections, dryRun });
|
|
758
|
+
|
|
759
|
+
for (const entry of report) {
|
|
760
|
+
clack.log.info(entry.action + " " + entry.serverId + " at " + entry.absPath + ": " + entry.status);
|
|
761
|
+
if (entry.action === "add" && entry.status === "written") {
|
|
762
|
+
const key = entry.absPath + "::" + entry.serverId;
|
|
763
|
+
if (secretSelectionKeys.has(key)) {
|
|
764
|
+
await warnIfSecretNotGitignored({ dir, absPath: entry.absPath });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
for (const item of pickerReconfigured) {
|
|
770
|
+
if (item.status === "dry-run") {
|
|
771
|
+
clack.log.info("reconfigure " + item.serverId + " at " + item.absPath + ": dry-run");
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const status = await reconfigureEntry({
|
|
775
|
+
dir,
|
|
776
|
+
homedir,
|
|
777
|
+
platform,
|
|
778
|
+
templates,
|
|
779
|
+
absPath: item.absPath,
|
|
780
|
+
serverId: item.serverId,
|
|
781
|
+
configOverride: item.configOverride,
|
|
782
|
+
});
|
|
783
|
+
clack.log.info("reconfigure " + item.serverId + " at " + item.absPath + ": " + status.status);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!dryRun && !nonInteractive && !yes) {
|
|
787
|
+
const pacAvailable = await isBinaryOnPath("pac");
|
|
788
|
+
if (pacAvailable && pacCliJustConfigured) {
|
|
789
|
+
await offerPacAuthWalkthrough();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
clack.outro(dryRun ? "Dry run complete, nothing was written." : "Done.");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function runReconfigure(parsed) {
|
|
797
|
+
clack.intro("bi-agent-kit reconfigure");
|
|
798
|
+
|
|
799
|
+
const dir = process.cwd();
|
|
800
|
+
const homedir = os.homedir();
|
|
801
|
+
const platform = process.platform;
|
|
802
|
+
|
|
803
|
+
const templates = await loadTemplates();
|
|
804
|
+
const entries = await getInstalledEntries({ dir, templates });
|
|
805
|
+
|
|
806
|
+
if (entries.length === 0) {
|
|
807
|
+
clack.log.info("Nothing installed yet. Run npx bi-agent-kit init to get started.");
|
|
808
|
+
clack.outro("Done.");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const chosen = await clack.select({
|
|
813
|
+
message: "Which entry would you like to reconfigure?",
|
|
814
|
+
options: entries.map((entry) => ({ value: entry, label: entry.label })),
|
|
815
|
+
});
|
|
816
|
+
if (clack.isCancel(chosen)) {
|
|
817
|
+
clack.cancel("Cancelled.");
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const template = templates.find((t) => t.id === templateIdFor(chosen));
|
|
822
|
+
if (!template) {
|
|
823
|
+
clack.log.error("Could not find the template backing this entry (" + templateIdFor(chosen) + ").");
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (parsed.dryRun) {
|
|
828
|
+
clack.log.info(
|
|
829
|
+
"Would reconfigure " +
|
|
830
|
+
chosen.serverId +
|
|
831
|
+
" at " +
|
|
832
|
+
chosen.absPath +
|
|
833
|
+
(chosen.hasPlaceholders ? " (prompts skipped in dry run)" : " (no placeholders to fill in)")
|
|
834
|
+
);
|
|
835
|
+
clack.outro("Dry run complete, nothing was written.");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const drift = await getEntryDrift({
|
|
840
|
+
dir,
|
|
841
|
+
homedir,
|
|
842
|
+
platform,
|
|
843
|
+
absPath: chosen.absPath,
|
|
844
|
+
serverId: chosen.serverId,
|
|
845
|
+
});
|
|
846
|
+
if (drift) {
|
|
847
|
+
clack.log.warn(
|
|
848
|
+
chosen.serverId + " at " + chosen.absPath +
|
|
849
|
+
" was modified outside bi-agent-kit since it was last written. Reconfiguring will overwrite those changes."
|
|
850
|
+
);
|
|
851
|
+
const proceed = await clack.confirm({ message: "Overwrite the hand-edited entry?" });
|
|
852
|
+
if (clack.isCancel(proceed) || !proceed) {
|
|
853
|
+
clack.log.info("Skipped -- nothing was written.");
|
|
854
|
+
clack.outro("Done.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (!chosen.hasPlaceholders) {
|
|
860
|
+
clack.log.info(template.label + " has no configurable placeholders -- reapplying its default configuration.");
|
|
861
|
+
} else {
|
|
862
|
+
clack.log.step("Reconfiguring " + template.label + " at " + chosen.absPath);
|
|
863
|
+
}
|
|
864
|
+
const { answers } = await promptForPlaceholders(template);
|
|
865
|
+
const configOverride = applyPlaceholderValues(template.config, answers);
|
|
866
|
+
|
|
867
|
+
const status = await reconfigureEntry({
|
|
868
|
+
dir,
|
|
869
|
+
homedir,
|
|
870
|
+
platform,
|
|
871
|
+
templates,
|
|
872
|
+
absPath: chosen.absPath,
|
|
873
|
+
serverId: chosen.serverId,
|
|
874
|
+
configOverride,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
clack.log.info("reconfigure " + chosen.serverId + " at " + chosen.absPath + ": " + status.status);
|
|
878
|
+
clack.outro("Done.");
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Pure predicate for the shared-target confirmation gate: given a group's
|
|
883
|
+
* full targetIds and a --targets filter (or null/undefined when no filter is
|
|
884
|
+
* given), returns the ids that are outside the filter -- i.e. the other
|
|
885
|
+
* tools sharing this one target file that a filtered removal would still
|
|
886
|
+
* affect, since one file has exactly one key regardless of how many logical
|
|
887
|
+
* targets resolve to it. Empty when there is no filter, or every id sharing
|
|
888
|
+
* the file is already covered by it.
|
|
889
|
+
*/
|
|
890
|
+
export function targetsOutsideFilter(groupTargetIds, targetFilter) {
|
|
891
|
+
if (!targetFilter) return [];
|
|
892
|
+
return groupTargetIds.filter((id) => !targetFilter.includes(id));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function runRemove(parsed) {
|
|
896
|
+
clack.intro("bi-agent-kit remove");
|
|
897
|
+
|
|
898
|
+
const dir = process.cwd();
|
|
899
|
+
const homedir = os.homedir();
|
|
900
|
+
const platform = process.platform;
|
|
901
|
+
const specs = parsed.rest || [];
|
|
902
|
+
|
|
903
|
+
if (specs.length === 0) {
|
|
904
|
+
clack.log.warn("No server specs given. Usage: bi-agent-kit remove <spec...>");
|
|
905
|
+
clack.outro("Nothing to do.");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const previousSelections = await getCurrentSelections({ dir });
|
|
910
|
+
const allGroups = resolveTargets(dir, homedir, platform);
|
|
911
|
+
const targetFilter = parsed.targets;
|
|
912
|
+
|
|
913
|
+
const release = parsed.dryRun ? null : await acquireLock(path.join(dir, ".kae-bi-kit.lock"));
|
|
914
|
+
try {
|
|
915
|
+
for (const raw of specs) {
|
|
916
|
+
let spec;
|
|
917
|
+
try {
|
|
918
|
+
spec = parseServerSpec(raw);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
clack.log.error(err.message);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const matches = previousSelections.filter((entry) => {
|
|
925
|
+
if (entry.serverId !== spec.serverId) return false;
|
|
926
|
+
if (!targetFilter) return true;
|
|
927
|
+
const group = allGroups.find((g) => g.absPath === entry.absPath);
|
|
928
|
+
return group ? group.targetIds.some((id) => targetFilter.includes(id)) : false;
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
if (matches.length === 0) {
|
|
932
|
+
clack.log.info(spec.serverId + ": not-installed");
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
for (const entry of matches) {
|
|
937
|
+
if (parsed.dryRun) {
|
|
938
|
+
clack.log.info("Would remove " + entry.serverId + " at " + entry.absPath);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
const group = allGroups.find((g) => g.absPath === entry.absPath);
|
|
942
|
+
if (!group) {
|
|
943
|
+
clack.log.warn(entry.serverId + " at " + entry.absPath + ": target not resolvable on this machine, skipping");
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const outsideTargets = targetsOutsideFilter(group.targetIds, targetFilter);
|
|
948
|
+
if (outsideTargets.length > 0) {
|
|
949
|
+
clack.log.warn(
|
|
950
|
+
entry.serverId + " at " + entry.absPath + " is shared by these tools via one file: " +
|
|
951
|
+
labelForTargetIds(outsideTargets) + ". Removing it affects all of them."
|
|
952
|
+
);
|
|
953
|
+
if (!parsed.yes) {
|
|
954
|
+
const proceed = await clack.confirm({ message: "Remove anyway?" });
|
|
955
|
+
if (clack.isCancel(proceed) || !proceed) {
|
|
956
|
+
clack.log.info("Skipped " + entry.serverId + " at " + entry.absPath);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const result = await removeServerFromTarget({ dir, resolvedTarget: group, serverKey: entry.serverId });
|
|
963
|
+
clack.log.info(entry.serverId + " at " + entry.absPath + ": " + result.status);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} finally {
|
|
967
|
+
if (release) await release();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
clack.outro(parsed.dryRun ? "Dry run complete, nothing was removed." : "Done.");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function runExport(parsed) {
|
|
974
|
+
const dir = process.cwd();
|
|
975
|
+
const templates = await loadTemplates();
|
|
976
|
+
const doc = await exportConfig({ dir, templates });
|
|
977
|
+
const text = JSON.stringify(doc, null, 2) + "\n";
|
|
978
|
+
|
|
979
|
+
if (parsed.out) {
|
|
980
|
+
const resolvedOut = path.resolve(parsed.out);
|
|
981
|
+
const groups = resolveTargets(dir, os.homedir(), process.platform);
|
|
982
|
+
if (isProtectedExportPath({ dir, outPath: resolvedOut, groups })) {
|
|
983
|
+
clack.log.error(
|
|
984
|
+
"Refusing to write export to " + resolvedOut +
|
|
985
|
+
": it would overwrite the manifest, its backup, or a target config file."
|
|
986
|
+
);
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
await atomicWriteFile(resolvedOut, text);
|
|
990
|
+
clack.log.success("Wrote " + parsed.out);
|
|
991
|
+
} else {
|
|
992
|
+
console.log(text);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function main(parsed) {
|
|
997
|
+
if (parsed.help) {
|
|
998
|
+
printHelp();
|
|
999
|
+
process.exit(0);
|
|
1000
|
+
}
|
|
1001
|
+
if (parsed.version) {
|
|
1002
|
+
await printVersion();
|
|
1003
|
+
process.exit(0);
|
|
1004
|
+
}
|
|
1005
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
1006
|
+
for (const message of parsed.errors) {
|
|
1007
|
+
console.error(message);
|
|
1008
|
+
}
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
if (!VALID_COMMANDS.includes(parsed.command)) {
|
|
1012
|
+
console.error(
|
|
1013
|
+
"Unknown command: " + parsed.command + ". Valid commands: " + VALID_COMMANDS.join(", ")
|
|
1014
|
+
);
|
|
1015
|
+
process.exit(1);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (parsed.command === "list") {
|
|
1019
|
+
await runList({ dir: process.cwd(), json: parsed.json });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (parsed.command === "doctor") {
|
|
1024
|
+
await runDoctor({ dir: process.cwd(), homedir: os.homedir(), platform: process.platform, json: parsed.json });
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (parsed.command === "reconfigure") {
|
|
1029
|
+
await runReconfigure(parsed);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (parsed.command === "remove") {
|
|
1034
|
+
await runRemove(parsed);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (parsed.command === "export") {
|
|
1039
|
+
await runExport(parsed);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
await runInstallFlow(parsed);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const parsedArgs = parseArgs(process.argv);
|
|
1047
|
+
const isMainModule =
|
|
1048
|
+
process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
1049
|
+
if (isMainModule) {
|
|
1050
|
+
main(parsedArgs).catch((err) => {
|
|
1051
|
+
clack.log.error(err.message);
|
|
1052
|
+
process.exit(1);
|
|
1053
|
+
});
|
|
1054
|
+
}
|