@sofatutor/agent-bridge 0.13.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/README.md +231 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +1648 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1648 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { access, chmod, copyFile, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
9
|
+
import fsExtra from "fs-extra";
|
|
10
|
+
//#region src/lib/config.ts
|
|
11
|
+
const SAFE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
12
|
+
const safeName = z.string().min(1).refine((v) => SAFE_NAME_RE.test(v) && v !== "." && v !== "..", { message: "Only [A-Za-z0-9._-] characters allowed, cannot be . or .." });
|
|
13
|
+
const safeRelativeFolder = z.string().min(1).refine((value) => {
|
|
14
|
+
if (isAbsolute(value) || value.includes("\0")) return false;
|
|
15
|
+
const segments = value.split(/[\\/]/).filter((s) => s.length > 0);
|
|
16
|
+
if (segments.length === 0) return false;
|
|
17
|
+
return segments.every((seg) => seg !== ".." && seg !== "." && /^\.?[A-Za-z0-9._-]+$/.test(seg));
|
|
18
|
+
}, { message: "Must be a relative path using only [A-Za-z0-9._-]" });
|
|
19
|
+
const toolConfigSchema = z.object({
|
|
20
|
+
name: safeName.refine((v) => !v.includes("--"), { message: "Must not contain '--' (reserved for tool-prefix routing)" }),
|
|
21
|
+
folder: safeRelativeFolder
|
|
22
|
+
});
|
|
23
|
+
const sourceConfigSchema = z.object({
|
|
24
|
+
name: safeName,
|
|
25
|
+
source: z.string().min(1).refine((v) => !v.startsWith("-"), { message: "Must not start with '-'" }),
|
|
26
|
+
branch: z.string().refine((v) => /^[A-Za-z0-9._/-]+$/.test(v) && !v.startsWith("-"), { message: "Must match [A-Za-z0-9._/-] and not start with '-'" }).optional()
|
|
27
|
+
});
|
|
28
|
+
const bridgeConfigSchema = z.object({
|
|
29
|
+
version: z.string().optional(),
|
|
30
|
+
domains: z.array(safeName).min(1, "'domains' must be a non-empty array"),
|
|
31
|
+
tools: z.array(toolConfigSchema).min(1, "'tools' must be a non-empty array"),
|
|
32
|
+
sources: z.array(sourceConfigSchema).min(1, "'sources' must be a non-empty array")
|
|
33
|
+
}).superRefine((data, ctx) => {
|
|
34
|
+
const toolNames = /* @__PURE__ */ new Set();
|
|
35
|
+
const toolFolders = /* @__PURE__ */ new Set();
|
|
36
|
+
data.tools.forEach((t, i) => {
|
|
37
|
+
if (toolNames.has(t.name)) ctx.addIssue({
|
|
38
|
+
code: z.ZodIssueCode.custom,
|
|
39
|
+
message: `Duplicate tool name: '${t.name}'`,
|
|
40
|
+
path: [
|
|
41
|
+
"tools",
|
|
42
|
+
i,
|
|
43
|
+
"name"
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
toolNames.add(t.name);
|
|
47
|
+
if (toolFolders.has(t.folder)) ctx.addIssue({
|
|
48
|
+
code: z.ZodIssueCode.custom,
|
|
49
|
+
message: `Duplicate tool folder: '${t.folder}'`,
|
|
50
|
+
path: [
|
|
51
|
+
"tools",
|
|
52
|
+
i,
|
|
53
|
+
"folder"
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
toolFolders.add(t.folder);
|
|
57
|
+
});
|
|
58
|
+
const sourceNames = /* @__PURE__ */ new Set();
|
|
59
|
+
data.sources.forEach((s, i) => {
|
|
60
|
+
if (sourceNames.has(s.name)) ctx.addIssue({
|
|
61
|
+
code: z.ZodIssueCode.custom,
|
|
62
|
+
message: `Duplicate source name: '${s.name}'`,
|
|
63
|
+
path: [
|
|
64
|
+
"sources",
|
|
65
|
+
i,
|
|
66
|
+
"name"
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
sourceNames.add(s.name);
|
|
70
|
+
const isRemote = s.source.startsWith("https://") || s.source.startsWith("http://") || s.source.startsWith("file://") || /^[\w.-]+@[\w.-]+:/.test(s.source);
|
|
71
|
+
if (s.branch && !isRemote) ctx.addIssue({
|
|
72
|
+
code: z.ZodIssueCode.custom,
|
|
73
|
+
message: "'branch' is only valid for remote sources",
|
|
74
|
+
path: [
|
|
75
|
+
"sources",
|
|
76
|
+
i,
|
|
77
|
+
"branch"
|
|
78
|
+
]
|
|
79
|
+
});
|
|
80
|
+
if (!isRemote && !isAbsolute(s.source)) ctx.addIssue({
|
|
81
|
+
code: z.ZodIssueCode.custom,
|
|
82
|
+
message: "Local source paths must be absolute",
|
|
83
|
+
path: [
|
|
84
|
+
"sources",
|
|
85
|
+
i,
|
|
86
|
+
"source"
|
|
87
|
+
]
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const BRIDGE_DIR = ".agent-bridge";
|
|
92
|
+
const CONFIG_FILENAME = "config.yml";
|
|
93
|
+
/**
|
|
94
|
+
* Tombstone written by `opt-out`. It lives inside `.agent-bridge/` so it's
|
|
95
|
+
* gitignored by default (the directory's `.gitignore` ignores everything but
|
|
96
|
+
* `config.yml`), keeping opt-out local to a machine. `init`/`sync` honor it so
|
|
97
|
+
* a `postinstall` guard doesn't silently reinstall Agent Bridge on the next
|
|
98
|
+
* `npm install`. Force-add it (`git add -f`) to commit a repo-wide opt-out.
|
|
99
|
+
*/
|
|
100
|
+
const OPT_OUT_MARKER = join(BRIDGE_DIR, "optout");
|
|
101
|
+
function detectSourceType(source) {
|
|
102
|
+
if (source.startsWith("https://") || source.startsWith("http://") || source.startsWith("file://")) return "git-https";
|
|
103
|
+
if (/^[\w.-]+@[\w.-]+:/.test(source)) return "git-ssh";
|
|
104
|
+
return "local";
|
|
105
|
+
}
|
|
106
|
+
function isRemoteSource(source) {
|
|
107
|
+
const type = detectSourceType(source);
|
|
108
|
+
return type === "git-https" || type === "git-ssh";
|
|
109
|
+
}
|
|
110
|
+
function bridgeDir(repoRoot) {
|
|
111
|
+
return join(repoRoot, BRIDGE_DIR);
|
|
112
|
+
}
|
|
113
|
+
function configPath(repoRoot) {
|
|
114
|
+
return join(repoRoot, BRIDGE_DIR, CONFIG_FILENAME);
|
|
115
|
+
}
|
|
116
|
+
function sourceDir(repoRoot, sourceName) {
|
|
117
|
+
return join(repoRoot, BRIDGE_DIR, sourceName);
|
|
118
|
+
}
|
|
119
|
+
function optOutMarkerPath(repoRoot) {
|
|
120
|
+
return join(repoRoot, OPT_OUT_MARKER);
|
|
121
|
+
}
|
|
122
|
+
/** Whether an opt-out tombstone is present at the repo root. */
|
|
123
|
+
async function isOptedOut(repoRoot) {
|
|
124
|
+
try {
|
|
125
|
+
await access(optOutMarkerPath(repoRoot));
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Write the opt-out tombstone inside `.agent-bridge/`. Recreates the directory
|
|
133
|
+
* (opt-out deletes it) and its `.gitignore` so the marker is ignored by default.
|
|
134
|
+
*/
|
|
135
|
+
async function writeOptOutMarker(repoRoot) {
|
|
136
|
+
const dir = bridgeDir(repoRoot);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
await writeFile(join(dir, ".gitignore"), [
|
|
139
|
+
"# Ignore cloned sources",
|
|
140
|
+
"*",
|
|
141
|
+
"!config.yml",
|
|
142
|
+
"!.gitignore"
|
|
143
|
+
].join("\n") + "\n", "utf-8");
|
|
144
|
+
await writeFile(optOutMarkerPath(repoRoot), "# Agent Bridge opt-out marker. Remove this file (or run `agent-bridge init --force`) to re-enable.\n", "utf-8");
|
|
145
|
+
}
|
|
146
|
+
/** Remove the opt-out tombstone if present (idempotent). */
|
|
147
|
+
async function removeOptOutMarker(repoRoot) {
|
|
148
|
+
await rm(optOutMarkerPath(repoRoot), { force: true });
|
|
149
|
+
}
|
|
150
|
+
async function configExists(repoRoot) {
|
|
151
|
+
try {
|
|
152
|
+
await access(configPath(repoRoot));
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function loadConfig(repoRoot) {
|
|
159
|
+
const raw = await readFile(configPath(repoRoot), "utf-8");
|
|
160
|
+
const data = yaml.load(raw);
|
|
161
|
+
const result = bridgeConfigSchema.safeParse(data);
|
|
162
|
+
if (!result.success) {
|
|
163
|
+
const errors = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
164
|
+
throw new Error(`Invalid config: ${errors.join("; ")}`);
|
|
165
|
+
}
|
|
166
|
+
return result.data;
|
|
167
|
+
}
|
|
168
|
+
async function saveConfig(repoRoot, config) {
|
|
169
|
+
await mkdir(bridgeDir(repoRoot), { recursive: true });
|
|
170
|
+
const content = yaml.dump(config, {
|
|
171
|
+
lineWidth: -1,
|
|
172
|
+
noRefs: true
|
|
173
|
+
});
|
|
174
|
+
await writeFile(configPath(repoRoot), content, "utf-8");
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/lib/git.ts
|
|
178
|
+
function findRepoRoot() {
|
|
179
|
+
try {
|
|
180
|
+
return execSync("git rev-parse --show-toplevel", {
|
|
181
|
+
encoding: "utf-8",
|
|
182
|
+
stdio: "pipe"
|
|
183
|
+
}).trim();
|
|
184
|
+
} catch {
|
|
185
|
+
return process.cwd();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Check if a directory is inside a Git repository.
|
|
190
|
+
*/
|
|
191
|
+
function isInGitRepo(cwd) {
|
|
192
|
+
try {
|
|
193
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
194
|
+
encoding: "utf-8",
|
|
195
|
+
stdio: "pipe",
|
|
196
|
+
cwd
|
|
197
|
+
});
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Get the path to the .git/hooks directory.
|
|
205
|
+
*/
|
|
206
|
+
function getGitHooksDir(repoRoot) {
|
|
207
|
+
return join(repoRoot, ".git", "hooks");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The hook names that Agent Bridge will install.
|
|
211
|
+
*/
|
|
212
|
+
const AGENT_BRIDGE_HOOKS = ["post-checkout", "post-merge"];
|
|
213
|
+
/**
|
|
214
|
+
* Marker comment to identify Agent Bridge hooks.
|
|
215
|
+
*/
|
|
216
|
+
const HOOK_MARKER = "# agent-bridge-hook";
|
|
217
|
+
/**
|
|
218
|
+
* Generate the hook script content.
|
|
219
|
+
* Runs update and sync in the background, logging to `.agent-bridge/hook.log`
|
|
220
|
+
* (trimmed to the last ~200 lines) so failures are diagnosable.
|
|
221
|
+
*/
|
|
222
|
+
function generateHookScript() {
|
|
223
|
+
return `#!/bin/sh
|
|
224
|
+
${HOOK_MARKER}
|
|
225
|
+
# This hook was installed by Agent Bridge.
|
|
226
|
+
# It runs 'agent-bridge update && agent-bridge sync' in the background
|
|
227
|
+
# to keep your AI agent configurations up to date.
|
|
228
|
+
|
|
229
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
230
|
+
LOG_DIR="\${REPO_ROOT:-.}/.agent-bridge"
|
|
231
|
+
LOG_FILE="\${LOG_DIR}/hook.log"
|
|
232
|
+
|
|
233
|
+
mkdir -p "\$LOG_DIR" 2>/dev/null
|
|
234
|
+
|
|
235
|
+
(
|
|
236
|
+
# Wait a moment for git to finish
|
|
237
|
+
sleep 1
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
echo "--- $(date '+%Y-%m-%dT%H:%M:%S%z') agent-bridge hook ---"
|
|
241
|
+
if command -v agent-bridge >/dev/null 2>&1; then
|
|
242
|
+
agent-bridge update && agent-bridge sync
|
|
243
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
244
|
+
npx @sofatutor/agent-bridge update && npx @sofatutor/agent-bridge sync
|
|
245
|
+
else
|
|
246
|
+
echo "agent-bridge not found (install globally or ensure npx is available)"
|
|
247
|
+
fi
|
|
248
|
+
} >>"\$LOG_FILE" 2>&1
|
|
249
|
+
|
|
250
|
+
# Keep the log from growing without bound.
|
|
251
|
+
if [ -f "\$LOG_FILE" ]; then
|
|
252
|
+
tail -n 200 "\$LOG_FILE" >"\$LOG_FILE.tmp" && mv "\$LOG_FILE.tmp" "\$LOG_FILE"
|
|
253
|
+
fi
|
|
254
|
+
) </dev/null >/dev/null 2>&1 &
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Check if a hook file contains the Agent Bridge marker.
|
|
259
|
+
*/
|
|
260
|
+
async function hasAgentBridgeHook(hookPath) {
|
|
261
|
+
try {
|
|
262
|
+
return (await readFile(hookPath, "utf-8")).includes(HOOK_MARKER);
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Check if a hook file exists.
|
|
269
|
+
*/
|
|
270
|
+
async function hookExists(hookPath) {
|
|
271
|
+
try {
|
|
272
|
+
await access(hookPath);
|
|
273
|
+
return true;
|
|
274
|
+
} catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Install Agent Bridge git hooks in the repository.
|
|
280
|
+
*
|
|
281
|
+
* @param repoRoot - The root of the git repository
|
|
282
|
+
* @param force - If true, overwrite existing hooks that don't have the marker
|
|
283
|
+
* @returns Result with installed, skipped, and errored hooks
|
|
284
|
+
*/
|
|
285
|
+
async function installGitHooks(repoRoot, force = false) {
|
|
286
|
+
const result = {
|
|
287
|
+
installed: [],
|
|
288
|
+
skipped: [],
|
|
289
|
+
errors: []
|
|
290
|
+
};
|
|
291
|
+
if (!isInGitRepo(repoRoot)) {
|
|
292
|
+
for (const hook of AGENT_BRIDGE_HOOKS) result.errors.push({
|
|
293
|
+
hook,
|
|
294
|
+
error: "Not a git repository"
|
|
295
|
+
});
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
const hooksDir = getGitHooksDir(repoRoot);
|
|
299
|
+
try {
|
|
300
|
+
await mkdir(hooksDir, { recursive: true });
|
|
301
|
+
} catch (err) {
|
|
302
|
+
for (const hook of AGENT_BRIDGE_HOOKS) result.errors.push({
|
|
303
|
+
hook,
|
|
304
|
+
error: `Failed to create hooks directory: ${err}`
|
|
305
|
+
});
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
const hookContent = generateHookScript();
|
|
309
|
+
for (const hookName of AGENT_BRIDGE_HOOKS) {
|
|
310
|
+
const hookPath = join(hooksDir, hookName);
|
|
311
|
+
try {
|
|
312
|
+
if (await hookExists(hookPath)) if (await hasAgentBridgeHook(hookPath)) {
|
|
313
|
+
await writeFile(hookPath, hookContent, "utf-8");
|
|
314
|
+
await chmod(hookPath, 493);
|
|
315
|
+
result.installed.push(hookName);
|
|
316
|
+
} else if (force) {
|
|
317
|
+
await writeFile(hookPath, hookContent, "utf-8");
|
|
318
|
+
await chmod(hookPath, 493);
|
|
319
|
+
result.installed.push(hookName);
|
|
320
|
+
} else result.skipped.push(hookName);
|
|
321
|
+
else {
|
|
322
|
+
await writeFile(hookPath, hookContent, "utf-8");
|
|
323
|
+
await chmod(hookPath, 493);
|
|
324
|
+
result.installed.push(hookName);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
result.errors.push({
|
|
328
|
+
hook: hookName,
|
|
329
|
+
error: String(err)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Remove Agent Bridge git hooks from the repository.
|
|
337
|
+
* Only removes hooks that have the Agent Bridge marker.
|
|
338
|
+
*/
|
|
339
|
+
async function removeGitHooks(repoRoot) {
|
|
340
|
+
const removed = [];
|
|
341
|
+
if (!isInGitRepo(repoRoot)) return removed;
|
|
342
|
+
const hooksDir = getGitHooksDir(repoRoot);
|
|
343
|
+
for (const hookName of AGENT_BRIDGE_HOOKS) {
|
|
344
|
+
const hookPath = join(hooksDir, hookName);
|
|
345
|
+
try {
|
|
346
|
+
if (await hasAgentBridgeHook(hookPath)) {
|
|
347
|
+
const { unlink } = await import("node:fs/promises");
|
|
348
|
+
await unlink(hookPath);
|
|
349
|
+
removed.push(hookName);
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
}
|
|
353
|
+
return removed;
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/lib/fs.ts
|
|
357
|
+
const { pathExists, remove, outputFile, readFile: fsReadFile, ensureDir } = fsExtra;
|
|
358
|
+
/** Name of the marker file placed inside every synced feature folder. */
|
|
359
|
+
const MARKER_FILENAME = ".agentbridge";
|
|
360
|
+
async function dirExists(p) {
|
|
361
|
+
try {
|
|
362
|
+
return (await stat(p)).isDirectory();
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function fileExists(p) {
|
|
368
|
+
return pathExists(p);
|
|
369
|
+
}
|
|
370
|
+
async function listFilesRecursive(dir) {
|
|
371
|
+
const files = [];
|
|
372
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
if (entry.isSymbolicLink()) continue;
|
|
375
|
+
if (entry.name === ".agentbridge") continue;
|
|
376
|
+
if (entry.isDirectory()) {
|
|
377
|
+
const subFiles = await listFilesRecursive(join(dir, entry.name));
|
|
378
|
+
files.push(...subFiles.map((f) => join(entry.name, f)));
|
|
379
|
+
} else if (entry.isFile()) files.push(entry.name);
|
|
380
|
+
}
|
|
381
|
+
return files;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Copy all files from `srcDir` into `destDir`, preserving nested structure.
|
|
385
|
+
* Overwrites existing files. Creates directories as needed.
|
|
386
|
+
*/
|
|
387
|
+
async function copyDirContents(srcDir, destDir) {
|
|
388
|
+
const files = await listFilesRecursive(srcDir);
|
|
389
|
+
for (const relFile of files) {
|
|
390
|
+
const srcFile = join(srcDir, relFile);
|
|
391
|
+
const destFile = join(destDir, relFile);
|
|
392
|
+
await ensureDir(dirname(destFile));
|
|
393
|
+
await copyFile(srcFile, destFile);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Remove a directory and all its contents.
|
|
398
|
+
*/
|
|
399
|
+
async function removeDir(dir) {
|
|
400
|
+
await remove(dir);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Remove a single file.
|
|
404
|
+
*/
|
|
405
|
+
async function removeFile(filePath) {
|
|
406
|
+
await remove(filePath);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Read the manifest file in a directory. Returns list of managed entries.
|
|
410
|
+
* Entries ending with '/' are folders, others are files.
|
|
411
|
+
*/
|
|
412
|
+
async function readManifest(dir) {
|
|
413
|
+
const manifestPath = join(dir, MARKER_FILENAME);
|
|
414
|
+
try {
|
|
415
|
+
return (await fsReadFile(manifestPath, "utf-8")).split("\n").filter((line) => line.trim().length > 0);
|
|
416
|
+
} catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Check if an entry in the manifest is a folder (ends with /).
|
|
422
|
+
*/
|
|
423
|
+
function isManifestFolder(entry) {
|
|
424
|
+
return entry.endsWith("/");
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Get the base name from a manifest entry (strips trailing / for folders).
|
|
428
|
+
*/
|
|
429
|
+
function manifestEntryName(entry) {
|
|
430
|
+
return entry.endsWith("/") ? entry.slice(0, -1) : entry;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Write a manifest file listing managed entries.
|
|
434
|
+
*/
|
|
435
|
+
async function writeManifest(dir, entries) {
|
|
436
|
+
await outputFile(join(dir, MARKER_FILENAME), entries.length > 0 ? entries.join("\n") + "\n" : "");
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Add an entry to the manifest. Creates manifest if it doesn't exist.
|
|
440
|
+
* Use trailing '/' for folders.
|
|
441
|
+
*/
|
|
442
|
+
async function addToManifest(dir, entry) {
|
|
443
|
+
const existing = await readManifest(dir);
|
|
444
|
+
if (!existing.includes(entry)) {
|
|
445
|
+
existing.push(entry);
|
|
446
|
+
await writeManifest(dir, existing);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Remove an entry from the manifest.
|
|
451
|
+
* Deletes the manifest file entirely if it becomes empty.
|
|
452
|
+
*/
|
|
453
|
+
async function removeFromManifest(dir, entry) {
|
|
454
|
+
const existing = await readManifest(dir);
|
|
455
|
+
const updated = existing.filter((e) => e !== entry);
|
|
456
|
+
if (updated.length !== existing.length) if (updated.length === 0) await remove(join(dir, MARKER_FILENAME));
|
|
457
|
+
else await writeManifest(dir, updated);
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/lib/sources.ts
|
|
461
|
+
/**
|
|
462
|
+
* Marker file written inside every directory Agent Bridge manages under
|
|
463
|
+
* `.agent-bridge/`. Used to gate destructive cleanup so we never delete
|
|
464
|
+
* user-placed content.
|
|
465
|
+
*/
|
|
466
|
+
const SOURCE_MARKER = ".agent-bridge-managed";
|
|
467
|
+
function git(args, cwd) {
|
|
468
|
+
return execFileSync("git", args, {
|
|
469
|
+
cwd,
|
|
470
|
+
encoding: "utf-8",
|
|
471
|
+
stdio: "pipe"
|
|
472
|
+
}).trim();
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Branch names must not contain shell metacharacters or leading dashes
|
|
476
|
+
* (which could be mistaken for git flags). Conservative but safe.
|
|
477
|
+
*/
|
|
478
|
+
function assertSafeBranch(branch, sourceName) {
|
|
479
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith("-")) throw new Error(`Source '${sourceName}': invalid branch name '${branch}'. Branch must match [A-Za-z0-9._/-]+ and not start with '-'.`);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Reject source URLs that begin with '-' to prevent them being interpreted
|
|
483
|
+
* as CLI flags by git.
|
|
484
|
+
*/
|
|
485
|
+
function assertSafeSourceUrl(source, sourceName) {
|
|
486
|
+
if (source.startsWith("-")) throw new Error(`Source '${sourceName}': URL must not start with '-' (got '${source}').`);
|
|
487
|
+
}
|
|
488
|
+
async function writeSourceMarker(dest) {
|
|
489
|
+
await writeFile(join(dest, SOURCE_MARKER), "This directory is managed by agent-bridge. Do not edit manually.\n", "utf-8");
|
|
490
|
+
}
|
|
491
|
+
async function hasSourceMarker(dir) {
|
|
492
|
+
try {
|
|
493
|
+
await access(join(dir, SOURCE_MARKER));
|
|
494
|
+
return true;
|
|
495
|
+
} catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function cloneSource(repoRoot, source) {
|
|
500
|
+
assertSafeSourceUrl(source.source, source.name);
|
|
501
|
+
if (source.branch) assertSafeBranch(source.branch, source.name);
|
|
502
|
+
const dest = sourceDir(repoRoot, source.name);
|
|
503
|
+
await mkdir(bridgeDir(repoRoot), { recursive: true });
|
|
504
|
+
const args = [
|
|
505
|
+
"clone",
|
|
506
|
+
"--depth",
|
|
507
|
+
"1"
|
|
508
|
+
];
|
|
509
|
+
if (source.branch) args.push("--single-branch", "--branch", source.branch);
|
|
510
|
+
args.push("--", source.source, dest);
|
|
511
|
+
execFileSync("git", args, { stdio: "pipe" });
|
|
512
|
+
await writeSourceMarker(dest);
|
|
513
|
+
}
|
|
514
|
+
async function fetchSource(repoRoot, source) {
|
|
515
|
+
assertSafeSourceUrl(source.source, source.name);
|
|
516
|
+
if (source.branch) assertSafeBranch(source.branch, source.name);
|
|
517
|
+
const dest = sourceDir(repoRoot, source.name);
|
|
518
|
+
git([
|
|
519
|
+
"fetch",
|
|
520
|
+
"--prune",
|
|
521
|
+
"origin"
|
|
522
|
+
], dest);
|
|
523
|
+
if (source.branch) {
|
|
524
|
+
if (git([
|
|
525
|
+
"rev-parse",
|
|
526
|
+
"--abbrev-ref",
|
|
527
|
+
"HEAD"
|
|
528
|
+
], dest) !== source.branch) {
|
|
529
|
+
try {
|
|
530
|
+
git([
|
|
531
|
+
"fetch",
|
|
532
|
+
"--depth",
|
|
533
|
+
"1",
|
|
534
|
+
"origin",
|
|
535
|
+
source.branch
|
|
536
|
+
], dest);
|
|
537
|
+
} catch {}
|
|
538
|
+
git(["checkout", source.branch], dest);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
git(["pull", "--ff-only"], dest);
|
|
543
|
+
} catch {}
|
|
544
|
+
await writeSourceMarker(dest);
|
|
545
|
+
}
|
|
546
|
+
function resolveLocalSource(repoRoot, source) {
|
|
547
|
+
const raw = source.source;
|
|
548
|
+
if (isAbsolute(raw)) return raw;
|
|
549
|
+
return resolve(repoRoot, raw);
|
|
550
|
+
}
|
|
551
|
+
function resolveSourcePath(repoRoot, source) {
|
|
552
|
+
if (isRemoteSource(source.source)) return sourceDir(repoRoot, source.name);
|
|
553
|
+
return resolveLocalSource(repoRoot, source);
|
|
554
|
+
}
|
|
555
|
+
async function syncSource(repoRoot, source) {
|
|
556
|
+
if (!isRemoteSource(source.source)) {
|
|
557
|
+
const resolved = resolveLocalSource(repoRoot, source);
|
|
558
|
+
if (!await dirExists(resolved)) return {
|
|
559
|
+
name: source.name,
|
|
560
|
+
action: "local",
|
|
561
|
+
error: `Local source path does not exist: ${resolved}\n Update the path in .agent-bridge/config.yml or run "agent-bridge init" to reconfigure.`
|
|
562
|
+
};
|
|
563
|
+
return {
|
|
564
|
+
name: source.name,
|
|
565
|
+
action: "local"
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (await dirExists(sourceDir(repoRoot, source.name))) try {
|
|
569
|
+
await fetchSource(repoRoot, source);
|
|
570
|
+
return {
|
|
571
|
+
name: source.name,
|
|
572
|
+
action: "updated"
|
|
573
|
+
};
|
|
574
|
+
} catch (err) {
|
|
575
|
+
return {
|
|
576
|
+
name: source.name,
|
|
577
|
+
action: "updated",
|
|
578
|
+
error: err instanceof Error ? err.message : String(err)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
await cloneSource(repoRoot, source);
|
|
583
|
+
return {
|
|
584
|
+
name: source.name,
|
|
585
|
+
action: "cloned"
|
|
586
|
+
};
|
|
587
|
+
} catch (err) {
|
|
588
|
+
return {
|
|
589
|
+
name: source.name,
|
|
590
|
+
action: "cloned",
|
|
591
|
+
error: err instanceof Error ? err.message : String(err)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async function syncAllSources(repoRoot, config) {
|
|
596
|
+
await ensureBridgeGitignore(repoRoot);
|
|
597
|
+
return Promise.all(config.sources.map((source) => syncSource(repoRoot, source)));
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Write a `.gitignore` inside `.agent-bridge/` that ignores cloned source
|
|
601
|
+
* directories (which are nested git repos) while keeping `config.yml` tracked.
|
|
602
|
+
* Without this, git sees the nested repos as gitlinks/submodules and creates
|
|
603
|
+
* phantom dirty-state changes.
|
|
604
|
+
*/
|
|
605
|
+
async function ensureBridgeGitignore(repoRoot) {
|
|
606
|
+
const bridge = bridgeDir(repoRoot);
|
|
607
|
+
await mkdir(bridge, { recursive: true });
|
|
608
|
+
await writeFile(join(bridge, ".gitignore"), [
|
|
609
|
+
"# Ignore cloned sources",
|
|
610
|
+
"*",
|
|
611
|
+
"!config.yml",
|
|
612
|
+
"!.gitignore"
|
|
613
|
+
].join("\n") + "\n", "utf-8");
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Remove cloned source directories under `.agent-bridge/` that are no longer
|
|
617
|
+
* referenced in config. Only directories carrying the Agent Bridge marker
|
|
618
|
+
* file are eligible for deletion — user-placed content is always preserved.
|
|
619
|
+
*
|
|
620
|
+
* For backwards compatibility with clones created before the marker existed,
|
|
621
|
+
* directories containing a `.git` folder are also treated as stale.
|
|
622
|
+
*/
|
|
623
|
+
async function removeStaleSourceDirs(repoRoot, config) {
|
|
624
|
+
const bridge = bridgeDir(repoRoot);
|
|
625
|
+
if (!await dirExists(bridge)) return [];
|
|
626
|
+
const entries = await readdir(bridge, { withFileTypes: true });
|
|
627
|
+
const configuredNames = new Set(config.sources.filter((s) => isRemoteSource(s.source)).map((s) => s.name));
|
|
628
|
+
const removed = [];
|
|
629
|
+
for (const entry of entries) {
|
|
630
|
+
if (!entry.isDirectory()) continue;
|
|
631
|
+
if (configuredNames.has(entry.name)) continue;
|
|
632
|
+
const candidate = join(bridge, entry.name);
|
|
633
|
+
if (await hasSourceMarker(candidate) || await dirExists(join(candidate, ".git"))) {
|
|
634
|
+
await rm(candidate, {
|
|
635
|
+
recursive: true,
|
|
636
|
+
force: true
|
|
637
|
+
});
|
|
638
|
+
removed.push(entry.name);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return removed;
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/lib/version.ts
|
|
645
|
+
const VERSION = "0.13.1";
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/commands/init.ts
|
|
648
|
+
const WELL_KNOWN_TOOLS = [
|
|
649
|
+
{
|
|
650
|
+
value: {
|
|
651
|
+
name: "vscode",
|
|
652
|
+
folder: ".github"
|
|
653
|
+
},
|
|
654
|
+
label: "VS Code (.github/)"
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
value: {
|
|
658
|
+
name: "cursor",
|
|
659
|
+
folder: ".cursor"
|
|
660
|
+
},
|
|
661
|
+
label: "Cursor (.cursor/)"
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
value: {
|
|
665
|
+
name: "claude",
|
|
666
|
+
folder: ".claude"
|
|
667
|
+
},
|
|
668
|
+
label: "Claude (.claude/)"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
value: {
|
|
672
|
+
name: "pi",
|
|
673
|
+
folder: ".pi"
|
|
674
|
+
},
|
|
675
|
+
label: "Pi (.pi/)"
|
|
676
|
+
}
|
|
677
|
+
];
|
|
678
|
+
const WELL_KNOWN_TOOL_MAP = Object.fromEntries(WELL_KNOWN_TOOLS.map((t) => [t.value.name, t.value]));
|
|
679
|
+
const CUSTOM_TOOL_SENTINEL = {
|
|
680
|
+
name: "__custom__",
|
|
681
|
+
folder: "__custom__"
|
|
682
|
+
};
|
|
683
|
+
const DEFAULT_DOMAINS = [
|
|
684
|
+
"backend",
|
|
685
|
+
"frontend",
|
|
686
|
+
"shared"
|
|
687
|
+
];
|
|
688
|
+
/**
|
|
689
|
+
* Derive a short source name from a URL or local path.
|
|
690
|
+
*
|
|
691
|
+
* Examples:
|
|
692
|
+
* https://github.com/org/repo.git → repo
|
|
693
|
+
* git@github.com:org/repo.git → repo
|
|
694
|
+
* file:///tmp/bare.git → bare
|
|
695
|
+
* /path/to/my-folder → my-folder
|
|
696
|
+
*/
|
|
697
|
+
function deriveSourceName(source) {
|
|
698
|
+
let segment = source;
|
|
699
|
+
const sshMatch = segment.match(/^[\w.-]+@[\w.-]+:(.+)$/);
|
|
700
|
+
if (sshMatch) segment = sshMatch[1];
|
|
701
|
+
try {
|
|
702
|
+
segment = new URL(segment).pathname;
|
|
703
|
+
} catch {}
|
|
704
|
+
return (segment.replace(/\/+$/, "").split("/").pop() ?? segment).replace(/\.git$/, "") || "source";
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Parse a comma-separated `--tools` argument into ToolConfig[].
|
|
708
|
+
* Accepts well-known names (cursor, vscode, claude) or `name:folder` pairs.
|
|
709
|
+
*/
|
|
710
|
+
function parseToolsArg(input) {
|
|
711
|
+
return input.split(",").map((t) => {
|
|
712
|
+
const trimmed = t.trim();
|
|
713
|
+
if (!trimmed) throw new Error("Empty tool name in --tools");
|
|
714
|
+
if (WELL_KNOWN_TOOL_MAP[trimmed]) return WELL_KNOWN_TOOL_MAP[trimmed];
|
|
715
|
+
const colonIdx = trimmed.indexOf(":");
|
|
716
|
+
if (colonIdx > 0) return {
|
|
717
|
+
name: trimmed.slice(0, colonIdx),
|
|
718
|
+
folder: trimmed.slice(colonIdx + 1)
|
|
719
|
+
};
|
|
720
|
+
throw new Error(`Unknown tool "${trimmed}". Use a known name (${Object.keys(WELL_KNOWN_TOOL_MAP).join(", ")}) or name:folder format.`);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Parse a single `--source` argument into a SourceConfig.
|
|
725
|
+
* Supports `#branch` suffix for remote sources.
|
|
726
|
+
*/
|
|
727
|
+
function parseSourceArg(input, repoRoot) {
|
|
728
|
+
let source = input.trim();
|
|
729
|
+
let branch;
|
|
730
|
+
const hashIdx = source.lastIndexOf("#");
|
|
731
|
+
if (hashIdx > 0) {
|
|
732
|
+
branch = source.slice(hashIdx + 1);
|
|
733
|
+
source = source.slice(0, hashIdx);
|
|
734
|
+
}
|
|
735
|
+
if (!source) throw new Error("Empty source in --source");
|
|
736
|
+
const entry = {
|
|
737
|
+
name: deriveSourceName(source),
|
|
738
|
+
source
|
|
739
|
+
};
|
|
740
|
+
if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
|
|
741
|
+
if (branch) entry.branch = branch;
|
|
742
|
+
return entry;
|
|
743
|
+
}
|
|
744
|
+
async function initCommand(cwd, opts) {
|
|
745
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
746
|
+
if (await isOptedOut(repoRoot)) if (opts?.force) await removeOptOutMarker(repoRoot);
|
|
747
|
+
else {
|
|
748
|
+
p.log.warn(`${OPT_OUT_MARKER} present — Agent Bridge is opted out. Skipping init. Delete the file or run with --force to re-enable.`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const hasToolsArg = !!opts?.tools;
|
|
752
|
+
const hasSourceArg = !!(opts?.source && opts.source.length > 0);
|
|
753
|
+
if (hasToolsArg !== hasSourceArg) {
|
|
754
|
+
p.log.error("Both --tools and --source are required for non-interactive init.");
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
if (hasToolsArg && hasSourceArg) {
|
|
758
|
+
const domains = opts.domains ? opts.domains.split(",").map((d) => d.trim()).filter(Boolean) : [...DEFAULT_DOMAINS];
|
|
759
|
+
const tools = parseToolsArg(opts.tools);
|
|
760
|
+
const sources = opts.source.map((s) => parseSourceArg(s, repoRoot));
|
|
761
|
+
const seen = /* @__PURE__ */ new Set();
|
|
762
|
+
for (const s of sources) {
|
|
763
|
+
if (seen.has(s.name)) throw new Error(`Duplicate source name "${s.name}" derived from --source arguments`);
|
|
764
|
+
seen.add(s.name);
|
|
765
|
+
}
|
|
766
|
+
const config = {
|
|
767
|
+
version: VERSION,
|
|
768
|
+
domains,
|
|
769
|
+
tools,
|
|
770
|
+
sources
|
|
771
|
+
};
|
|
772
|
+
await saveConfig(repoRoot, config);
|
|
773
|
+
await ensureBridgeGitignore(repoRoot);
|
|
774
|
+
p.log.success("Saved .agent-bridge/config.yml");
|
|
775
|
+
const spinner = p.spinner();
|
|
776
|
+
spinner.start("Fetching remote sources…");
|
|
777
|
+
const fetchErrors = (await syncAllSources(repoRoot, config)).filter((r) => r.error);
|
|
778
|
+
if (fetchErrors.length > 0) {
|
|
779
|
+
spinner.stop("Some sources failed");
|
|
780
|
+
for (const err of fetchErrors) p.log.error(`${err.name}: ${err.error}`);
|
|
781
|
+
} else spinner.stop("All sources ready");
|
|
782
|
+
if (opts.hooks && isInGitRepo(repoRoot)) {
|
|
783
|
+
const hookResult = await installGitHooks(repoRoot, opts.force === true);
|
|
784
|
+
if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
|
|
785
|
+
if (hookResult.skipped.length > 0) p.log.warn(`Skipped hooks: ${hookResult.skipped.join(", ")}`);
|
|
786
|
+
if (hookResult.errors.length > 0) for (const e of hookResult.errors) p.log.error(`Hook ${e.hook}: ${e.error}`);
|
|
787
|
+
}
|
|
788
|
+
p.outro("Done! Run `agent-bridge sync` to sync features.");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
p.intro("Welcome to Agent Bridge — Project Setup");
|
|
792
|
+
if (await configExists(repoRoot)) {
|
|
793
|
+
const existing = await loadConfig(repoRoot);
|
|
794
|
+
p.log.info(`Config already exists with ${existing.sources?.length ?? 0} source(s). Re-running will overwrite.`);
|
|
795
|
+
}
|
|
796
|
+
const domainsInput = await p.text({
|
|
797
|
+
message: "Domains (comma-separated)",
|
|
798
|
+
placeholder: DEFAULT_DOMAINS.join(", "),
|
|
799
|
+
defaultValue: DEFAULT_DOMAINS.join(", "),
|
|
800
|
+
validate: (v) => {
|
|
801
|
+
if (!v.trim()) return "At least one domain is required";
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
if (p.isCancel(domainsInput)) {
|
|
805
|
+
p.cancel("Setup cancelled.");
|
|
806
|
+
process.exit(1);
|
|
807
|
+
}
|
|
808
|
+
const domains = domainsInput.split(",").map((d) => d.trim()).filter(Boolean);
|
|
809
|
+
const selectedTools = await p.multiselect({
|
|
810
|
+
message: "Which tools (IDEs) should receive Agent Bridge files?",
|
|
811
|
+
options: [...WELL_KNOWN_TOOLS, {
|
|
812
|
+
value: CUSTOM_TOOL_SENTINEL,
|
|
813
|
+
label: "Other (add custom tool)"
|
|
814
|
+
}],
|
|
815
|
+
required: true
|
|
816
|
+
});
|
|
817
|
+
if (p.isCancel(selectedTools)) {
|
|
818
|
+
p.cancel("Setup cancelled.");
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
const tools = selectedTools.filter((t) => t.name !== "__custom__");
|
|
822
|
+
if (selectedTools.some((t) => t.name === "__custom__")) {
|
|
823
|
+
let addingCustom = true;
|
|
824
|
+
while (addingCustom) {
|
|
825
|
+
const name = await p.text({
|
|
826
|
+
message: "Custom tool name (used for <tool>-- prefix matching)",
|
|
827
|
+
placeholder: "windsurf",
|
|
828
|
+
defaultValue: "",
|
|
829
|
+
validate: (v) => {
|
|
830
|
+
if (!v.trim()) return "Tool name cannot be empty";
|
|
831
|
+
if (tools.some((t) => t.name === v.trim())) return "Tool name already used";
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
if (p.isCancel(name)) break;
|
|
835
|
+
const folder = await p.text({
|
|
836
|
+
message: `Target folder for "${name}"`,
|
|
837
|
+
placeholder: `.${name}`,
|
|
838
|
+
defaultValue: "",
|
|
839
|
+
validate: (v) => {
|
|
840
|
+
if (!v.trim()) return "Folder cannot be empty";
|
|
841
|
+
if (tools.some((t) => t.folder === v.trim())) return "Folder already used by another tool";
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
if (p.isCancel(folder)) break;
|
|
845
|
+
tools.push({
|
|
846
|
+
name: name.trim(),
|
|
847
|
+
folder: folder.trim()
|
|
848
|
+
});
|
|
849
|
+
const addMore = await p.confirm({
|
|
850
|
+
message: "Add another custom tool?",
|
|
851
|
+
initialValue: false
|
|
852
|
+
});
|
|
853
|
+
if (p.isCancel(addMore) || !addMore) addingCustom = false;
|
|
854
|
+
}
|
|
855
|
+
if (tools.length === 0) {
|
|
856
|
+
p.cancel("At least one tool is required.");
|
|
857
|
+
process.exit(1);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const sources = [];
|
|
861
|
+
const addSource = async () => {
|
|
862
|
+
const source = await p.text({
|
|
863
|
+
message: "Source URL or local path",
|
|
864
|
+
placeholder: "https://github.com/org/repo.git",
|
|
865
|
+
defaultValue: "",
|
|
866
|
+
validate: (v) => {
|
|
867
|
+
if (!v.trim()) return "Source URL/path cannot be empty";
|
|
868
|
+
const derived = deriveSourceName(v.trim());
|
|
869
|
+
if (sources.some((s) => s.name === derived)) return `Source name "${derived}" (derived from URL) already used`;
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
if (p.isCancel(source)) return false;
|
|
873
|
+
const entry = {
|
|
874
|
+
name: deriveSourceName(source.trim()),
|
|
875
|
+
source: source.trim()
|
|
876
|
+
};
|
|
877
|
+
if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
|
|
878
|
+
if (isRemoteSource(entry.source)) {
|
|
879
|
+
const branch = await p.text({
|
|
880
|
+
message: "Branch (leave empty for remote default)",
|
|
881
|
+
placeholder: "main",
|
|
882
|
+
defaultValue: ""
|
|
883
|
+
});
|
|
884
|
+
if (p.isCancel(branch)) return false;
|
|
885
|
+
if (branch.trim()) entry.branch = branch.trim();
|
|
886
|
+
}
|
|
887
|
+
sources.push(entry);
|
|
888
|
+
return true;
|
|
889
|
+
};
|
|
890
|
+
p.log.info("Add at least one source.");
|
|
891
|
+
let addingSource = true;
|
|
892
|
+
while (addingSource) {
|
|
893
|
+
if (!await addSource()) {
|
|
894
|
+
if (sources.length === 0) {
|
|
895
|
+
p.cancel("At least one source is required.");
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
const addMore = await p.confirm({
|
|
901
|
+
message: "Add another source?",
|
|
902
|
+
initialValue: false
|
|
903
|
+
});
|
|
904
|
+
if (p.isCancel(addMore) || !addMore) addingSource = false;
|
|
905
|
+
}
|
|
906
|
+
const config = {
|
|
907
|
+
version: VERSION,
|
|
908
|
+
domains,
|
|
909
|
+
tools,
|
|
910
|
+
sources
|
|
911
|
+
};
|
|
912
|
+
await saveConfig(repoRoot, config);
|
|
913
|
+
await ensureBridgeGitignore(repoRoot);
|
|
914
|
+
p.log.success("Saved .agent-bridge/config.yml");
|
|
915
|
+
const s = p.spinner();
|
|
916
|
+
s.start("Fetching remote sources…");
|
|
917
|
+
const errors = (await syncAllSources(repoRoot, config)).filter((r) => r.error);
|
|
918
|
+
if (errors.length > 0) {
|
|
919
|
+
s.stop("Some sources failed");
|
|
920
|
+
for (const err of errors) p.log.error(`${err.name}: ${err.error}`);
|
|
921
|
+
} else s.stop("All sources ready");
|
|
922
|
+
if (isInGitRepo(repoRoot)) {
|
|
923
|
+
const installHooks = await p.confirm({
|
|
924
|
+
message: "Install git hooks to auto-sync on checkout/merge?",
|
|
925
|
+
initialValue: false
|
|
926
|
+
});
|
|
927
|
+
if (!p.isCancel(installHooks) && installHooks) {
|
|
928
|
+
const hookResult = await installGitHooks(repoRoot, opts?.force === true);
|
|
929
|
+
if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
|
|
930
|
+
if (hookResult.skipped.length > 0) {
|
|
931
|
+
p.log.warn(`Skipped hooks (existing non-Agent-Bridge hooks): ${hookResult.skipped.join(", ")}`);
|
|
932
|
+
p.log.info("Re-run `agent-bridge init --force` to overwrite, or integrate manually.");
|
|
933
|
+
}
|
|
934
|
+
if (hookResult.errors.length > 0) for (const err of hookResult.errors) p.log.error(`Hook ${err.hook}: ${err.error}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
p.outro("Done! Run `agent-bridge sync` to sync features.");
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region src/lib/migrations/index.ts
|
|
941
|
+
const migrations = [];
|
|
942
|
+
/** Parse "1.2.3" or "1.2.3-beta.1" into [major, minor, patch]. */
|
|
943
|
+
function parseSemver(version) {
|
|
944
|
+
const parts = version.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
945
|
+
return [
|
|
946
|
+
parts[0] ?? 0,
|
|
947
|
+
parts[1] ?? 0,
|
|
948
|
+
parts[2] ?? 0
|
|
949
|
+
];
|
|
950
|
+
}
|
|
951
|
+
/** Returns -1 | 0 | 1 comparing a to b (ignores prerelease). */
|
|
952
|
+
function compareSemver(a, b) {
|
|
953
|
+
const [aMaj, aMin, aPat] = parseSemver(a);
|
|
954
|
+
const [bMaj, bMin, bPat] = parseSemver(b);
|
|
955
|
+
if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
|
|
956
|
+
if (aMin !== bMin) return aMin < bMin ? -1 : 1;
|
|
957
|
+
if (aPat !== bPat) return aPat < bPat ? -1 : 1;
|
|
958
|
+
return 0;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Find migrations that should run when upgrading from `fromVersion` to
|
|
962
|
+
* `toVersion`. Returns them sorted in ascending version order.
|
|
963
|
+
*/
|
|
964
|
+
function pendingMigrations(fromVersion, toVersion) {
|
|
965
|
+
return migrations.filter((m) => compareSemver(m.version, fromVersion) > 0 && compareSemver(m.version, toVersion) <= 0).sort((a, b) => compareSemver(a.version, b.version));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Run all pending migrations between the config's version and the
|
|
969
|
+
* currently installed VERSION. Updates and saves the config afterwards.
|
|
970
|
+
*
|
|
971
|
+
* Returns null if no migration was needed.
|
|
972
|
+
*/
|
|
973
|
+
async function runMigrations(repoRoot) {
|
|
974
|
+
let config = await loadConfig(repoRoot);
|
|
975
|
+
const configVersion = config.version ?? "0.0.0";
|
|
976
|
+
const cmp = compareSemver(configVersion, VERSION);
|
|
977
|
+
if (cmp === 0) return null;
|
|
978
|
+
if (cmp > 0) return null;
|
|
979
|
+
const pending = pendingMigrations(configVersion, VERSION);
|
|
980
|
+
const applied = [];
|
|
981
|
+
for (const migration of pending) {
|
|
982
|
+
config = await migration.migrate(repoRoot, config);
|
|
983
|
+
applied.push(migration.version);
|
|
984
|
+
}
|
|
985
|
+
config = {
|
|
986
|
+
...config,
|
|
987
|
+
version: VERSION
|
|
988
|
+
};
|
|
989
|
+
await saveConfig(repoRoot, config);
|
|
990
|
+
return {
|
|
991
|
+
fromVersion: configVersion,
|
|
992
|
+
toVersion: VERSION,
|
|
993
|
+
applied
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
//#endregion
|
|
997
|
+
//#region src/lib/manifest.ts
|
|
998
|
+
const TOOL_PREFIX_SEPARATOR = "--";
|
|
999
|
+
function parseToolPrefix(name) {
|
|
1000
|
+
const idx = name.indexOf(TOOL_PREFIX_SEPARATOR);
|
|
1001
|
+
if (idx > 0) return {
|
|
1002
|
+
toolPrefix: name.substring(0, idx),
|
|
1003
|
+
baseName: name.substring(idx + 2)
|
|
1004
|
+
};
|
|
1005
|
+
return { baseName: name };
|
|
1006
|
+
}
|
|
1007
|
+
function featureMatchesTool(feature, toolName) {
|
|
1008
|
+
if (!feature.toolPrefix) return true;
|
|
1009
|
+
return feature.toolPrefix === toolName;
|
|
1010
|
+
}
|
|
1011
|
+
function featureName(feature) {
|
|
1012
|
+
if (feature.toolPrefix) return parseToolPrefix(feature.name).baseName;
|
|
1013
|
+
return feature.name;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Discover all feature types across all sources and domains.
|
|
1017
|
+
*/
|
|
1018
|
+
async function discoverFeatureTypes(repoRoot, config) {
|
|
1019
|
+
const types = /* @__PURE__ */ new Set();
|
|
1020
|
+
for (const source of config.sources) {
|
|
1021
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1022
|
+
for (const domain of config.domains) {
|
|
1023
|
+
const domainDir = join(srcPath, domain);
|
|
1024
|
+
if (!await dirExists(domainDir)) continue;
|
|
1025
|
+
const entries = await readdir(domainDir, { withFileTypes: true });
|
|
1026
|
+
for (const entry of entries) if (entry.isDirectory()) types.add(entry.name);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return [...types].sort();
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Scan all features across sources × domains × feature types.
|
|
1033
|
+
*
|
|
1034
|
+
* Structure: `<source-path>/<domain>/<feature-type>/<feature>/` (folder-based)
|
|
1035
|
+
* or `<source-path>/<domain>/<feature-type>/<feature.ext>` (file-based)
|
|
1036
|
+
*/
|
|
1037
|
+
async function scanFeatures(repoRoot, config, featureTypes) {
|
|
1038
|
+
const features = [];
|
|
1039
|
+
for (const source of config.sources) {
|
|
1040
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1041
|
+
for (const domain of config.domains) for (const ft of featureTypes) {
|
|
1042
|
+
const { toolPrefix: typeToolPrefix, baseName: baseType } = parseToolPrefix(ft);
|
|
1043
|
+
const ftDir = join(srcPath, domain, ft);
|
|
1044
|
+
if (!await dirExists(ftDir)) continue;
|
|
1045
|
+
const entries = await readdir(ftDir, { withFileTypes: true });
|
|
1046
|
+
for (const entry of entries) {
|
|
1047
|
+
const isFile = entry.isFile();
|
|
1048
|
+
const isDir = entry.isDirectory();
|
|
1049
|
+
if (!isFile && !isDir) continue;
|
|
1050
|
+
const { toolPrefix: itemToolPrefix } = parseToolPrefix(entry.name);
|
|
1051
|
+
const toolPrefix = itemToolPrefix ?? typeToolPrefix;
|
|
1052
|
+
features.push({
|
|
1053
|
+
name: entry.name,
|
|
1054
|
+
type: ft,
|
|
1055
|
+
displayType: baseType,
|
|
1056
|
+
source: source.name,
|
|
1057
|
+
domain,
|
|
1058
|
+
absolutePath: join(ftDir, entry.name),
|
|
1059
|
+
toolPrefix,
|
|
1060
|
+
isFile
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return features;
|
|
1066
|
+
}
|
|
1067
|
+
function detectDuplicates(features) {
|
|
1068
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1069
|
+
for (const f of features) {
|
|
1070
|
+
const linkName = featureName(f);
|
|
1071
|
+
const key = `${f.displayType}/${linkName}`;
|
|
1072
|
+
const group = byKey.get(key) ?? [];
|
|
1073
|
+
group.push(f);
|
|
1074
|
+
byKey.set(key, group);
|
|
1075
|
+
}
|
|
1076
|
+
const conflicts = [];
|
|
1077
|
+
for (const [, group] of byKey) if (group.length > 1) conflicts.push({
|
|
1078
|
+
name: featureName(group[0]),
|
|
1079
|
+
type: group[0].type,
|
|
1080
|
+
paths: group.map((f) => f.absolutePath)
|
|
1081
|
+
});
|
|
1082
|
+
return conflicts;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Well-known root files that live at the domain root and should be synced to the
|
|
1086
|
+
* workspace root. When a source contains `<domain>/AGENTS.md` (etc.), Agent Bridge
|
|
1087
|
+
* copies it to the project root.
|
|
1088
|
+
*/
|
|
1089
|
+
const ROOT_FILES = [
|
|
1090
|
+
"AGENTS.md",
|
|
1091
|
+
"CLAUDE.md",
|
|
1092
|
+
"SYSTEM.md"
|
|
1093
|
+
];
|
|
1094
|
+
/**
|
|
1095
|
+
* Scan all sources × domains for well-known root files.
|
|
1096
|
+
* Returns one entry per found file.
|
|
1097
|
+
*/
|
|
1098
|
+
async function scanRootFiles(repoRoot, config) {
|
|
1099
|
+
const found = [];
|
|
1100
|
+
for (const source of config.sources) {
|
|
1101
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1102
|
+
for (const domain of config.domains) for (const fileName of ROOT_FILES) {
|
|
1103
|
+
const filePath = join(srcPath, domain, fileName);
|
|
1104
|
+
if (await fileExists(filePath)) found.push({
|
|
1105
|
+
fileName,
|
|
1106
|
+
source: source.name,
|
|
1107
|
+
domain,
|
|
1108
|
+
absolutePath: filePath
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return found;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Detect duplicate root files (same filename provided by multiple sources/domains).
|
|
1116
|
+
*/
|
|
1117
|
+
function detectRootFileDuplicates(rootFiles) {
|
|
1118
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1119
|
+
for (const rf of rootFiles) {
|
|
1120
|
+
const group = byName.get(rf.fileName) ?? [];
|
|
1121
|
+
group.push(rf);
|
|
1122
|
+
byName.set(rf.fileName, group);
|
|
1123
|
+
}
|
|
1124
|
+
const duplicates = [];
|
|
1125
|
+
for (const [fileName, group] of byName) if (group.length > 1) duplicates.push({
|
|
1126
|
+
fileName,
|
|
1127
|
+
paths: group.map((rf) => rf.absolutePath)
|
|
1128
|
+
});
|
|
1129
|
+
return duplicates;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Scan all sources × domains for tool-prefixed flat files at the domain level.
|
|
1133
|
+
* A file named `cursor--settings.json` targets the tool "cursor" with
|
|
1134
|
+
* destination filename "settings.json".
|
|
1135
|
+
*/
|
|
1136
|
+
async function scanToolRootEntries(repoRoot, config) {
|
|
1137
|
+
const entries = [];
|
|
1138
|
+
const toolNames = new Set(config.tools.map((t) => t.name));
|
|
1139
|
+
for (const source of config.sources) {
|
|
1140
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1141
|
+
for (const domain of config.domains) {
|
|
1142
|
+
const domainDir = join(srcPath, domain);
|
|
1143
|
+
if (!await dirExists(domainDir)) continue;
|
|
1144
|
+
const domainEntries = await readdir(domainDir, { withFileTypes: true });
|
|
1145
|
+
for (const entry of domainEntries) {
|
|
1146
|
+
if (!entry.isFile()) continue;
|
|
1147
|
+
const { toolPrefix, baseName } = parseToolPrefix(entry.name);
|
|
1148
|
+
if (!toolPrefix || !toolNames.has(toolPrefix)) continue;
|
|
1149
|
+
entries.push({
|
|
1150
|
+
toolName: toolPrefix,
|
|
1151
|
+
name: baseName,
|
|
1152
|
+
source: source.name,
|
|
1153
|
+
domain,
|
|
1154
|
+
absolutePath: join(domainDir, entry.name)
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return entries;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Detect duplicate tool root entries (same tool + name from multiple sources/domains).
|
|
1163
|
+
*/
|
|
1164
|
+
function detectToolRootDuplicates(entries) {
|
|
1165
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1166
|
+
for (const entry of entries) {
|
|
1167
|
+
const key = `${entry.toolName}/${entry.name}`;
|
|
1168
|
+
const group = byKey.get(key) ?? [];
|
|
1169
|
+
group.push(entry);
|
|
1170
|
+
byKey.set(key, group);
|
|
1171
|
+
}
|
|
1172
|
+
const duplicates = [];
|
|
1173
|
+
for (const [, group] of byKey) if (group.length > 1) duplicates.push({
|
|
1174
|
+
toolName: group[0].toolName,
|
|
1175
|
+
name: group[0].name,
|
|
1176
|
+
paths: group.map((e) => e.absolutePath)
|
|
1177
|
+
});
|
|
1178
|
+
return duplicates;
|
|
1179
|
+
}
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/lib/sync.ts
|
|
1182
|
+
/**
|
|
1183
|
+
* Compute the destination path for a feature inside a tool's folder.
|
|
1184
|
+
* For folder-based features: returns the folder path.
|
|
1185
|
+
* For file-based features: returns the file path.
|
|
1186
|
+
*/
|
|
1187
|
+
function featureDestPath(repoRoot, toolFolder, featureType, featureName) {
|
|
1188
|
+
return join(repoRoot, toolFolder, featureType, featureName);
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Check whether a folder-based feature destination conflicts with existing user content.
|
|
1192
|
+
* Returns `true` when the folder exists and is not tracked in the manifest.
|
|
1193
|
+
*/
|
|
1194
|
+
async function checkFolderConflict(featureTypeDir, folderName) {
|
|
1195
|
+
if (!await dirExists(join(featureTypeDir, folderName))) return false;
|
|
1196
|
+
return !(await readManifest(featureTypeDir)).includes(folderName + "/");
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Check whether a file-based feature destination conflicts with existing user content.
|
|
1200
|
+
* Returns `true` when the file exists and is not tracked in the manifest.
|
|
1201
|
+
*/
|
|
1202
|
+
async function checkFileConflict(featureTypeDir, fileName) {
|
|
1203
|
+
if (!await fileExists(join(featureTypeDir, fileName))) return false;
|
|
1204
|
+
return !(await readManifest(featureTypeDir)).includes(fileName);
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Check whether a feature destination conflicts with existing user content.
|
|
1208
|
+
* Handles both folder-based and file-based features.
|
|
1209
|
+
*/
|
|
1210
|
+
async function checkPathConflict(featureTypeDir, featureName, isFile) {
|
|
1211
|
+
if (isFile) return checkFileConflict(featureTypeDir, featureName);
|
|
1212
|
+
else return checkFolderConflict(featureTypeDir, featureName);
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Sync a folder-based feature: clear destination, copy files, add to manifest.
|
|
1216
|
+
*/
|
|
1217
|
+
async function syncFolderFeature(sourcePath, featureTypeDir, folderName) {
|
|
1218
|
+
const destPath = join(featureTypeDir, folderName);
|
|
1219
|
+
const existed = await dirExists(destPath);
|
|
1220
|
+
if (existed) await removeDir(destPath);
|
|
1221
|
+
await mkdir(destPath, { recursive: true });
|
|
1222
|
+
await copyDirContents(sourcePath, destPath);
|
|
1223
|
+
await addToManifest(featureTypeDir, folderName + "/");
|
|
1224
|
+
return existed ? "updated" : "created";
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Sync a file-based feature: copy file, add to manifest.
|
|
1228
|
+
*/
|
|
1229
|
+
async function syncFileFeature(sourcePath, featureTypeDir, fileName) {
|
|
1230
|
+
const destPath = join(featureTypeDir, fileName);
|
|
1231
|
+
const existed = await fileExists(destPath);
|
|
1232
|
+
await mkdir(featureTypeDir, { recursive: true });
|
|
1233
|
+
await copyFile(sourcePath, destPath);
|
|
1234
|
+
await addToManifest(featureTypeDir, fileName);
|
|
1235
|
+
return existed ? "updated" : "created";
|
|
1236
|
+
}
|
|
1237
|
+
async function removeEmptyParents(dirPath, stopAt) {
|
|
1238
|
+
let current = dirPath;
|
|
1239
|
+
while (current !== stopAt && current.startsWith(stopAt)) try {
|
|
1240
|
+
if ((await readdir(current)).length > 0) break;
|
|
1241
|
+
await rmdir(current);
|
|
1242
|
+
current = dirname(current);
|
|
1243
|
+
} catch {
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Recursively collect all managed entries (files and folders) from manifests.
|
|
1249
|
+
* Scans for .agentbridge files and reads their contents.
|
|
1250
|
+
*/
|
|
1251
|
+
async function collectManagedEntries(dir) {
|
|
1252
|
+
if (!await dirExists(dir)) return [];
|
|
1253
|
+
const result = [];
|
|
1254
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1255
|
+
const manifestEntries = await readManifest(dir);
|
|
1256
|
+
for (const entry of manifestEntries) {
|
|
1257
|
+
const isFolder = isManifestFolder(entry);
|
|
1258
|
+
const name = manifestEntryName(entry);
|
|
1259
|
+
result.push({
|
|
1260
|
+
path: join(dir, name),
|
|
1261
|
+
manifestDir: dir,
|
|
1262
|
+
manifestEntry: entry,
|
|
1263
|
+
isFolder
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
for (const entry of entries) {
|
|
1267
|
+
if (!entry.isDirectory()) continue;
|
|
1268
|
+
if (entry.name === ".agentbridge") continue;
|
|
1269
|
+
const sub = await collectManagedEntries(join(dir, entry.name));
|
|
1270
|
+
result.push(...sub);
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
}
|
|
1274
|
+
async function reconcileFeatures(repoRoot, config, features) {
|
|
1275
|
+
let added = 0;
|
|
1276
|
+
let updated = 0;
|
|
1277
|
+
let removed = 0;
|
|
1278
|
+
const errors = [];
|
|
1279
|
+
const expectedFeatures = /* @__PURE__ */ new Map();
|
|
1280
|
+
for (const tool of config.tools) for (const feature of features) {
|
|
1281
|
+
if (!featureMatchesTool(feature, tool.name)) continue;
|
|
1282
|
+
const name = featureName(feature);
|
|
1283
|
+
const featureTypeDir = join(repoRoot, tool.folder, feature.displayType);
|
|
1284
|
+
const destPath = join(featureTypeDir, name);
|
|
1285
|
+
const manifestEntry = feature.isFile ? name : name + "/";
|
|
1286
|
+
expectedFeatures.set(destPath, {
|
|
1287
|
+
sourcePath: feature.absolutePath,
|
|
1288
|
+
featureTypeDir,
|
|
1289
|
+
name,
|
|
1290
|
+
manifestEntry,
|
|
1291
|
+
isFile: feature.isFile
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
for (const tool of config.tools) {
|
|
1295
|
+
const toolDir = join(repoRoot, tool.folder);
|
|
1296
|
+
const managedEntries = await collectManagedEntries(toolDir);
|
|
1297
|
+
for (const entry of managedEntries) {
|
|
1298
|
+
if (expectedFeatures.has(entry.path)) continue;
|
|
1299
|
+
try {
|
|
1300
|
+
if (entry.isFolder) await removeDir(entry.path);
|
|
1301
|
+
else await removeFile(entry.path);
|
|
1302
|
+
await removeFromManifest(entry.manifestDir, entry.manifestEntry);
|
|
1303
|
+
await removeEmptyParents(entry.manifestDir, toolDir);
|
|
1304
|
+
removed++;
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
errors.push({
|
|
1307
|
+
path: entry.path,
|
|
1308
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
for (const [destPath, expected] of expectedFeatures) try {
|
|
1314
|
+
const result = expected.isFile ? await syncFileFeature(expected.sourcePath, expected.featureTypeDir, expected.name) : await syncFolderFeature(expected.sourcePath, expected.featureTypeDir, expected.name);
|
|
1315
|
+
if (result === "created") added++;
|
|
1316
|
+
else if (result === "updated") updated++;
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
errors.push({
|
|
1319
|
+
path: destPath,
|
|
1320
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
added,
|
|
1325
|
+
updated,
|
|
1326
|
+
removed,
|
|
1327
|
+
errors
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const ROOT_FILE_MARKER = "<!-- Managed by Agent Bridge -->";
|
|
1331
|
+
/**
|
|
1332
|
+
* Check if a root file at `destPath` is managed by Agent Bridge.
|
|
1333
|
+
* A file is managed if it starts with the marker comment.
|
|
1334
|
+
*/
|
|
1335
|
+
async function isRootFileManaged(destPath) {
|
|
1336
|
+
if (!await fileExists(destPath)) return false;
|
|
1337
|
+
return (await readFile(destPath, "utf-8")).startsWith(ROOT_FILE_MARKER);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Sync root files: copy source root files to the workspace root, and clean up
|
|
1341
|
+
* managed root files that are no longer provided by any source.
|
|
1342
|
+
*/
|
|
1343
|
+
async function syncRootFiles(repoRoot, rootFiles) {
|
|
1344
|
+
const synced = [];
|
|
1345
|
+
const removed = [];
|
|
1346
|
+
const errors = [];
|
|
1347
|
+
const expected = /* @__PURE__ */ new Map();
|
|
1348
|
+
for (const rf of rootFiles) expected.set(rf.fileName, rf);
|
|
1349
|
+
for (const [fileName, rf] of expected) {
|
|
1350
|
+
const destPath = join(repoRoot, fileName);
|
|
1351
|
+
try {
|
|
1352
|
+
if (await fileExists(destPath)) {
|
|
1353
|
+
if (!await isRootFileManaged(destPath)) continue;
|
|
1354
|
+
}
|
|
1355
|
+
const sourceContent = await readFile(rf.absolutePath, "utf-8");
|
|
1356
|
+
const managedContent = ROOT_FILE_MARKER + "\n" + sourceContent;
|
|
1357
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
1358
|
+
await writeFile(destPath, managedContent, "utf-8");
|
|
1359
|
+
synced.push(fileName);
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
errors.push({
|
|
1362
|
+
path: destPath,
|
|
1363
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
for (const fileName of ROOT_FILES) {
|
|
1368
|
+
if (expected.has(fileName)) continue;
|
|
1369
|
+
const destPath = join(repoRoot, fileName);
|
|
1370
|
+
try {
|
|
1371
|
+
if (await isRootFileManaged(destPath)) {
|
|
1372
|
+
await removeFile(destPath);
|
|
1373
|
+
removed.push(fileName);
|
|
1374
|
+
}
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
errors.push({
|
|
1377
|
+
path: destPath,
|
|
1378
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
synced,
|
|
1384
|
+
removed,
|
|
1385
|
+
errors
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Reconcile tool root entries: sync expected entries and remove orphans.
|
|
1390
|
+
* Tool-prefixed flat files (e.g. `cursor--settings.json`) are copied directly
|
|
1391
|
+
* into the tool's root folder (e.g. `.cursor/settings.json`).
|
|
1392
|
+
*/
|
|
1393
|
+
async function reconcileToolRootEntries(repoRoot, config, entries) {
|
|
1394
|
+
let added = 0;
|
|
1395
|
+
let updated = 0;
|
|
1396
|
+
let removed = 0;
|
|
1397
|
+
const errors = [];
|
|
1398
|
+
const toolFolders = /* @__PURE__ */ new Map();
|
|
1399
|
+
for (const tool of config.tools) toolFolders.set(tool.name, tool.folder);
|
|
1400
|
+
const expectedEntries = /* @__PURE__ */ new Map();
|
|
1401
|
+
for (const entry of entries) {
|
|
1402
|
+
const folder = toolFolders.get(entry.toolName);
|
|
1403
|
+
if (!folder) continue;
|
|
1404
|
+
const toolDir = join(repoRoot, folder);
|
|
1405
|
+
const destPath = join(toolDir, entry.name);
|
|
1406
|
+
expectedEntries.set(destPath, {
|
|
1407
|
+
sourcePath: entry.absolutePath,
|
|
1408
|
+
name: entry.name,
|
|
1409
|
+
toolDir
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
for (const tool of config.tools) {
|
|
1413
|
+
const toolDir = join(repoRoot, tool.folder);
|
|
1414
|
+
const manifest = await readManifest(toolDir);
|
|
1415
|
+
for (const manifestEntry of manifest) {
|
|
1416
|
+
const isFolder = isManifestFolder(manifestEntry);
|
|
1417
|
+
const destPath = join(toolDir, manifestEntryName(manifestEntry));
|
|
1418
|
+
if (expectedEntries.has(destPath)) continue;
|
|
1419
|
+
try {
|
|
1420
|
+
if (isFolder) await removeDir(destPath);
|
|
1421
|
+
else await removeFile(destPath);
|
|
1422
|
+
await removeFromManifest(toolDir, manifestEntry);
|
|
1423
|
+
removed++;
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
errors.push({
|
|
1426
|
+
path: destPath,
|
|
1427
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
for (const [, expected] of expectedEntries) try {
|
|
1433
|
+
const result = await syncFileFeature(expected.sourcePath, expected.toolDir, expected.name);
|
|
1434
|
+
if (result === "created") added++;
|
|
1435
|
+
else if (result === "updated") updated++;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
errors.push({
|
|
1438
|
+
path: join(expected.toolDir, expected.name),
|
|
1439
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
return {
|
|
1443
|
+
added,
|
|
1444
|
+
updated,
|
|
1445
|
+
removed,
|
|
1446
|
+
errors
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
//#endregion
|
|
1450
|
+
//#region src/commands/sync.ts
|
|
1451
|
+
async function syncCommand(cwd, _opts) {
|
|
1452
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
1453
|
+
p.intro("Agent Bridge Sync");
|
|
1454
|
+
if (await isOptedOut(repoRoot)) {
|
|
1455
|
+
p.log.warn(`${OPT_OUT_MARKER} present — Agent Bridge is opted out. Skipping sync.`);
|
|
1456
|
+
p.outro("Skipped (opted out).");
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const s = p.spinner();
|
|
1460
|
+
s.start("Loading configuration…");
|
|
1461
|
+
const config = await loadConfig(repoRoot);
|
|
1462
|
+
const migrationResult = await runMigrations(repoRoot);
|
|
1463
|
+
if (migrationResult) p.log.info(`Config upgraded ${migrationResult.fromVersion} → ${migrationResult.toVersion}` + (migrationResult.applied.length > 0 ? ` (${migrationResult.applied.length} migration(s))` : ""));
|
|
1464
|
+
s.stop("Configuration valid");
|
|
1465
|
+
s.start("Syncing sources…");
|
|
1466
|
+
const sourceResults = await syncAllSources(repoRoot, config);
|
|
1467
|
+
const sourceErrors = sourceResults.filter((r) => r.error);
|
|
1468
|
+
if (sourceErrors.length > 0) {
|
|
1469
|
+
s.stop("Some sources failed");
|
|
1470
|
+
for (const err of sourceErrors) p.log.error(`${err.name}: ${err.error}`);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
const staleRemoved = await removeStaleSourceDirs(repoRoot, config);
|
|
1474
|
+
if (staleRemoved.length > 0) for (const name of staleRemoved) p.log.info(`Removed stale source: ${name}`);
|
|
1475
|
+
for (const r of sourceResults) if (r.action !== "local") p.log.info(`${r.name}: ${r.action}`);
|
|
1476
|
+
s.stop("Sources synced");
|
|
1477
|
+
s.start("Discovering features…");
|
|
1478
|
+
const features = await scanFeatures(repoRoot, config, await discoverFeatureTypes(repoRoot, config));
|
|
1479
|
+
const rootFiles = await scanRootFiles(repoRoot, config);
|
|
1480
|
+
const toolRootEntries = await scanToolRootEntries(repoRoot, config);
|
|
1481
|
+
const duplicates = detectDuplicates(features);
|
|
1482
|
+
if (duplicates.length > 0) {
|
|
1483
|
+
s.stop("Duplicate features detected");
|
|
1484
|
+
for (const dup of duplicates) p.log.error(`Duplicate "${dup.name}" (${dup.type}): ${dup.paths.join(", ")}`);
|
|
1485
|
+
process.exit(1);
|
|
1486
|
+
}
|
|
1487
|
+
const rootDuplicates = detectRootFileDuplicates(rootFiles);
|
|
1488
|
+
if (rootDuplicates.length > 0) {
|
|
1489
|
+
s.stop("Duplicate root files detected");
|
|
1490
|
+
for (const dup of rootDuplicates) p.log.error(`Duplicate "${dup.fileName}": ${dup.paths.join(", ")}`);
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
const toolRootDuplicates = detectToolRootDuplicates(toolRootEntries);
|
|
1494
|
+
if (toolRootDuplicates.length > 0) {
|
|
1495
|
+
s.stop("Duplicate tool root entries detected");
|
|
1496
|
+
for (const dup of toolRootDuplicates) p.log.error(`Duplicate "${dup.name}" for tool "${dup.toolName}": ${dup.paths.join(", ")}`);
|
|
1497
|
+
process.exit(1);
|
|
1498
|
+
}
|
|
1499
|
+
s.stop(`${features.length} features found${rootFiles.length > 0 ? `, ${rootFiles.length} root file(s)` : ""}${toolRootEntries.length > 0 ? `, ${toolRootEntries.length} tool root entr${toolRootEntries.length === 1 ? "y" : "ies"}` : ""}`);
|
|
1500
|
+
s.start("Checking for path conflicts…");
|
|
1501
|
+
const conflicts = [];
|
|
1502
|
+
for (const tool of config.tools) for (const feature of features) {
|
|
1503
|
+
if (!featureMatchesTool(feature, tool.name)) continue;
|
|
1504
|
+
const linkName = featureName(feature);
|
|
1505
|
+
const featureTypeDir = join(repoRoot, tool.folder, feature.displayType);
|
|
1506
|
+
const dest = featureDestPath(repoRoot, tool.folder, feature.displayType, linkName);
|
|
1507
|
+
if (await checkPathConflict(featureTypeDir, linkName, feature.isFile)) conflicts.push(dest);
|
|
1508
|
+
}
|
|
1509
|
+
if (conflicts.length > 0) {
|
|
1510
|
+
s.stop("Path conflicts detected");
|
|
1511
|
+
for (const c of conflicts) p.log.error(`Conflict: "${c}" exists as a real file or directory`);
|
|
1512
|
+
p.log.info("Remove or rename the conflicting paths, then re-run sync.");
|
|
1513
|
+
process.exit(1);
|
|
1514
|
+
}
|
|
1515
|
+
s.stop("No path conflicts");
|
|
1516
|
+
s.start("Reconciling features…");
|
|
1517
|
+
const result = await reconcileFeatures(repoRoot, config, features);
|
|
1518
|
+
s.stop("Features reconciled");
|
|
1519
|
+
p.log.info(`Added: ${result.added} Updated: ${result.updated} Removed: ${result.removed}`);
|
|
1520
|
+
if (result.errors.length > 0) {
|
|
1521
|
+
for (const err of result.errors) p.log.error(`${err.path}: ${err.error}`);
|
|
1522
|
+
p.outro(`Sync completed with ${result.errors.length} error(s).`);
|
|
1523
|
+
process.exit(1);
|
|
1524
|
+
}
|
|
1525
|
+
if (rootFiles.length > 0) {
|
|
1526
|
+
s.start("Syncing root files…");
|
|
1527
|
+
const rootResult = await syncRootFiles(repoRoot, rootFiles);
|
|
1528
|
+
for (const name of rootResult.synced) p.log.info(`Root file synced: ${name}`);
|
|
1529
|
+
for (const name of rootResult.removed) p.log.info(`Root file removed: ${name}`);
|
|
1530
|
+
for (const err of rootResult.errors) p.log.error(`${err.path}: ${err.error}`);
|
|
1531
|
+
s.stop("Root files synced");
|
|
1532
|
+
} else {
|
|
1533
|
+
const rootResult = await syncRootFiles(repoRoot, []);
|
|
1534
|
+
for (const name of rootResult.removed) p.log.info(`Root file removed: ${name}`);
|
|
1535
|
+
}
|
|
1536
|
+
s.start("Syncing tool root entries…");
|
|
1537
|
+
const toolRootResult = await reconcileToolRootEntries(repoRoot, config, toolRootEntries);
|
|
1538
|
+
if (toolRootResult.added > 0 || toolRootResult.updated > 0 || toolRootResult.removed > 0) p.log.info(`Tool root: Added: ${toolRootResult.added} Updated: ${toolRootResult.updated} Removed: ${toolRootResult.removed}`);
|
|
1539
|
+
if (toolRootResult.errors.length > 0) {
|
|
1540
|
+
for (const err of toolRootResult.errors) p.log.error(`${err.path}: ${err.error}`);
|
|
1541
|
+
s.stop("Tool root entries synced with errors");
|
|
1542
|
+
p.outro(`Sync completed with ${toolRootResult.errors.length} error(s).`);
|
|
1543
|
+
process.exit(1);
|
|
1544
|
+
}
|
|
1545
|
+
s.stop("Tool root entries synced");
|
|
1546
|
+
p.outro("Sync complete.");
|
|
1547
|
+
}
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/commands/update.ts
|
|
1550
|
+
async function updateCommand(cwd, _opts) {
|
|
1551
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
1552
|
+
p.intro("Agent Bridge — Update Sources");
|
|
1553
|
+
const config = await loadConfig(repoRoot);
|
|
1554
|
+
const migrationResult = await runMigrations(repoRoot);
|
|
1555
|
+
if (migrationResult) p.log.info(`Config upgraded ${migrationResult.fromVersion} → ${migrationResult.toVersion}` + (migrationResult.applied.length > 0 ? ` (${migrationResult.applied.length} migration(s))` : ""));
|
|
1556
|
+
const s = p.spinner();
|
|
1557
|
+
s.start("Updating all remote sources…");
|
|
1558
|
+
const results = await syncAllSources(repoRoot, config);
|
|
1559
|
+
s.stop("Update complete");
|
|
1560
|
+
for (const r of results) if (r.error) p.log.error(`${r.name}: ${r.error}`);
|
|
1561
|
+
else p.log.info(`${r.name}: ${r.action}`);
|
|
1562
|
+
const errors = results.filter((r) => r.error);
|
|
1563
|
+
if (errors.length > 0) p.outro(`Done with ${errors.length} error(s).`);
|
|
1564
|
+
else p.outro("All sources up to date.");
|
|
1565
|
+
}
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region src/commands/opt-out.ts
|
|
1568
|
+
function summarizeTools(config) {
|
|
1569
|
+
return config.tools.map((t) => t.name).join(", ");
|
|
1570
|
+
}
|
|
1571
|
+
async function optOutCommand(cwd, _opts) {
|
|
1572
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
1573
|
+
p.intro("Agent Bridge Opt-out");
|
|
1574
|
+
const hasConfig = await configExists(repoRoot);
|
|
1575
|
+
let config;
|
|
1576
|
+
if (hasConfig) config = await loadConfig(repoRoot);
|
|
1577
|
+
const toolSummary = config ? summarizeTools(config) : "unknown (no config found)";
|
|
1578
|
+
p.log.info(`Non-interactive opt-out: removing Agent Bridge managed files for tools: ${toolSummary}`);
|
|
1579
|
+
const s = p.spinner();
|
|
1580
|
+
let featureErrors = 0;
|
|
1581
|
+
let toolRootErrors = 0;
|
|
1582
|
+
if (config) {
|
|
1583
|
+
s.start("Removing synced Agent Bridge files…");
|
|
1584
|
+
const featureResult = await reconcileFeatures(repoRoot, config, []);
|
|
1585
|
+
const toolRootResult = await reconcileToolRootEntries(repoRoot, config, []);
|
|
1586
|
+
featureErrors = featureResult.errors.length;
|
|
1587
|
+
toolRootErrors = toolRootResult.errors.length;
|
|
1588
|
+
s.stop("Synced files removed");
|
|
1589
|
+
p.log.info(`Features removed: ${featureResult.removed} (errors: ${featureErrors})`);
|
|
1590
|
+
p.log.info(`Tool-root files removed: ${toolRootResult.removed} (errors: ${toolRootErrors})`);
|
|
1591
|
+
p.log.info("Root files are not removed by opt-out (manifest-only cleanup).");
|
|
1592
|
+
for (const err of featureResult.errors) p.log.error(`${err.path}: ${err.error}`);
|
|
1593
|
+
for (const err of toolRootResult.errors) p.log.error(`${err.path}: ${err.error}`);
|
|
1594
|
+
} else p.log.warn("No .agent-bridge/config.yml found. Skipping synced file cleanup.");
|
|
1595
|
+
s.start("Removing Agent Bridge git hooks…");
|
|
1596
|
+
const removedHooks = isInGitRepo(repoRoot) ? await removeGitHooks(repoRoot) : [];
|
|
1597
|
+
s.stop("Hooks cleanup complete");
|
|
1598
|
+
if (removedHooks.length > 0) p.log.info(`Removed hooks: ${removedHooks.join(", ")}`);
|
|
1599
|
+
else if (isInGitRepo(repoRoot)) p.log.info("No Agent Bridge hooks found.");
|
|
1600
|
+
else p.log.info("Not a git repository; hook cleanup skipped.");
|
|
1601
|
+
s.start("Removing .agent-bridge directory…");
|
|
1602
|
+
const bridgePath = bridgeDir(repoRoot);
|
|
1603
|
+
if (await dirExists(bridgePath)) {
|
|
1604
|
+
await removeDir(bridgePath);
|
|
1605
|
+
s.stop(".agent-bridge removed");
|
|
1606
|
+
} else s.stop(".agent-bridge not found");
|
|
1607
|
+
await writeOptOutMarker(repoRoot);
|
|
1608
|
+
p.log.info(`Wrote ${OPT_OUT_MARKER} (gitignored, local to this machine). Force-add it (\`git add -f\`) for a repo-wide opt-out. Run \`agent-bridge init --force\` to re-enable.`);
|
|
1609
|
+
const totalErrors = featureErrors + toolRootErrors;
|
|
1610
|
+
if (totalErrors > 0) {
|
|
1611
|
+
p.outro(`Opt-out completed with ${totalErrors} cleanup error(s).`);
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
}
|
|
1614
|
+
p.outro("Opt-out complete. Agent Bridge is removed from this repository.");
|
|
1615
|
+
}
|
|
1616
|
+
//#endregion
|
|
1617
|
+
//#region src/index.ts
|
|
1618
|
+
async function assertCwdExists(cwd) {
|
|
1619
|
+
try {
|
|
1620
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error(`--cwd path is not a directory: ${cwd}`);
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
if (err.code === "ENOENT") throw new Error(`--cwd path does not exist: ${cwd}`);
|
|
1623
|
+
throw err;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
async function withCwdValidation(action) {
|
|
1627
|
+
return async (opts) => {
|
|
1628
|
+
if (opts.cwd) {
|
|
1629
|
+
opts.cwd = resolve(opts.cwd);
|
|
1630
|
+
await assertCwdExists(opts.cwd);
|
|
1631
|
+
}
|
|
1632
|
+
await action(opts.cwd, opts);
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function collect(value, previous) {
|
|
1636
|
+
previous.push(value);
|
|
1637
|
+
return previous;
|
|
1638
|
+
}
|
|
1639
|
+
const program = new Command().name("agent-bridge").description("Manage AI tool configurations from multiple sources").version(VERSION, "-v, --version");
|
|
1640
|
+
program.command("init").description("Initialize Agent Bridge (creates .agent-bridge/config.yml)").option("--cwd <path>", "Override the working directory").option("--force", "Overwrite existing non-Agent-Bridge git hooks").option("--domains <list>", "Comma-separated domain list (default: backend,frontend,shared)").option("--tools <list>", "Comma-separated tool names (cursor,vscode,claude) or name:folder pairs").option("-s, --source <url>", "Source URL or path (repeatable, append #branch for branch)", collect, []).option("--hooks", "Auto-install git hooks without prompting").action(await withCwdValidation(initCommand));
|
|
1641
|
+
program.command("sync").description("Fetch sources, discover features, and sync files").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(syncCommand));
|
|
1642
|
+
program.command("update").description("Fetch latest changes for all remote sources").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(updateCommand));
|
|
1643
|
+
program.command("opt-out").description("Remove Agent Bridge hooks, synced files, and .agent-bridge state").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(optOutCommand));
|
|
1644
|
+
program.parse();
|
|
1645
|
+
//#endregion
|
|
1646
|
+
export {};
|
|
1647
|
+
|
|
1648
|
+
//# sourceMappingURL=index.mjs.map
|