@sofatutor/agent-bridge 0.13.1 → 0.15.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/README.md +78 -186
- package/dist/index.mjs +871 -484
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import yaml from "js-yaml";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
9
|
import fsExtra from "fs-extra";
|
|
10
|
+
import { Prompt, isCancel } from "@clack/core";
|
|
10
11
|
//#region src/lib/config.ts
|
|
11
12
|
const SAFE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
12
13
|
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 .." });
|
|
@@ -25,12 +26,53 @@ const sourceConfigSchema = z.object({
|
|
|
25
26
|
source: z.string().min(1).refine((v) => !v.startsWith("-"), { message: "Must not start with '-'" }),
|
|
26
27
|
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
|
});
|
|
29
|
+
/**
|
|
30
|
+
* A path inside a domain that should be synced. One or two segments:
|
|
31
|
+
* `skills` → the whole feature type
|
|
32
|
+
* `skills/deploy` → a single feature (folder or file)
|
|
33
|
+
* `AGENTS.md` → a flat file at the domain root
|
|
34
|
+
*/
|
|
35
|
+
const includePath = z.string().min(1).refine((v) => {
|
|
36
|
+
const segs = v.split("/");
|
|
37
|
+
return segs.length <= 2 && segs.every((seg) => SAFE_NAME_RE.test(seg) && seg !== "." && seg !== "..");
|
|
38
|
+
}, { message: "Must be <feature-type>, <feature-type>/<feature> or <file> using [A-Za-z0-9._-]" });
|
|
39
|
+
const domainObjectSchema = z.object({
|
|
40
|
+
name: safeName,
|
|
41
|
+
include: z.array(includePath).optional()
|
|
42
|
+
});
|
|
43
|
+
/** Domains are written as objects; a bare string (`- shared`) is accepted as shorthand. */
|
|
44
|
+
const domainConfigSchema = z.union([safeName.transform((name) => ({ name })), domainObjectSchema]);
|
|
28
45
|
const bridgeConfigSchema = z.object({
|
|
29
46
|
version: z.string().optional(),
|
|
30
|
-
domains: z.array(safeName).
|
|
47
|
+
domains: z.array(safeName).optional(),
|
|
31
48
|
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")
|
|
49
|
+
sources: z.array(sourceConfigSchema.extend({ domains: z.array(domainConfigSchema).optional() })).min(1, "'sources' must be a non-empty array")
|
|
33
50
|
}).superRefine((data, ctx) => {
|
|
51
|
+
data.sources.forEach((s, i) => {
|
|
52
|
+
const domains = s.domains ?? data.domains;
|
|
53
|
+
if (!domains || domains.length === 0) ctx.addIssue({
|
|
54
|
+
code: z.ZodIssueCode.custom,
|
|
55
|
+
message: `Source '${s.name}' has no domains (set 'sources[].domains' or top-level 'domains')`,
|
|
56
|
+
path: [
|
|
57
|
+
"sources",
|
|
58
|
+
i,
|
|
59
|
+
"domains"
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
const seen = /* @__PURE__ */ new Set();
|
|
63
|
+
for (const d of s.domains ?? []) {
|
|
64
|
+
if (seen.has(d.name)) ctx.addIssue({
|
|
65
|
+
code: z.ZodIssueCode.custom,
|
|
66
|
+
message: `Duplicate domain '${d.name}' in source '${s.name}'`,
|
|
67
|
+
path: [
|
|
68
|
+
"sources",
|
|
69
|
+
i,
|
|
70
|
+
"domains"
|
|
71
|
+
]
|
|
72
|
+
});
|
|
73
|
+
seen.add(d.name);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
34
76
|
const toolNames = /* @__PURE__ */ new Set();
|
|
35
77
|
const toolFolders = /* @__PURE__ */ new Set();
|
|
36
78
|
data.tools.forEach((t, i) => {
|
|
@@ -88,6 +130,24 @@ const bridgeConfigSchema = z.object({
|
|
|
88
130
|
});
|
|
89
131
|
});
|
|
90
132
|
});
|
|
133
|
+
/**
|
|
134
|
+
* Domains to scan for a source: its own `domains`, falling back to the legacy
|
|
135
|
+
* top-level `domains` list (everything included).
|
|
136
|
+
*/
|
|
137
|
+
function sourceDomains(config, source) {
|
|
138
|
+
if (source.domains) return source.domains;
|
|
139
|
+
return (config.domains ?? []).map((name) => ({ name }));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Whether `relPath` (relative to the domain root, e.g. `skills`,
|
|
143
|
+
* `skills/deploy`, `AGENTS.md`) is selected by the domain's `include` list.
|
|
144
|
+
* No `include` means everything is selected.
|
|
145
|
+
*/
|
|
146
|
+
function isIncluded(domain, relPath) {
|
|
147
|
+
const inc = domain.include;
|
|
148
|
+
if (!inc) return true;
|
|
149
|
+
return inc.some((entry) => entry === relPath || relPath.startsWith(entry + "/") || entry.startsWith(relPath + "/"));
|
|
150
|
+
}
|
|
91
151
|
const BRIDGE_DIR = ".agent-bridge";
|
|
92
152
|
const CONFIG_FILENAME = "config.yml";
|
|
93
153
|
/**
|
|
@@ -169,7 +229,8 @@ async function saveConfig(repoRoot, config) {
|
|
|
169
229
|
await mkdir(bridgeDir(repoRoot), { recursive: true });
|
|
170
230
|
const content = yaml.dump(config, {
|
|
171
231
|
lineWidth: -1,
|
|
172
|
-
noRefs: true
|
|
232
|
+
noRefs: true,
|
|
233
|
+
skipInvalid: true
|
|
173
234
|
});
|
|
174
235
|
await writeFile(configPath(repoRoot), content, "utf-8");
|
|
175
236
|
}
|
|
@@ -216,15 +277,15 @@ const AGENT_BRIDGE_HOOKS = ["post-checkout", "post-merge"];
|
|
|
216
277
|
const HOOK_MARKER = "# agent-bridge-hook";
|
|
217
278
|
/**
|
|
218
279
|
* Generate the hook script content.
|
|
219
|
-
* Runs
|
|
280
|
+
* Runs sync in the background, logging to `.agent-bridge/hook.log`
|
|
220
281
|
* (trimmed to the last ~200 lines) so failures are diagnosable.
|
|
221
282
|
*/
|
|
222
283
|
function generateHookScript() {
|
|
223
284
|
return `#!/bin/sh
|
|
224
285
|
${HOOK_MARKER}
|
|
225
286
|
# This hook was installed by Agent Bridge.
|
|
226
|
-
# It runs 'agent-bridge
|
|
227
|
-
#
|
|
287
|
+
# It runs 'agent-bridge sync' in the background to keep your AI agent
|
|
288
|
+
# configurations up to date.
|
|
228
289
|
|
|
229
290
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
230
291
|
LOG_DIR="\${REPO_ROOT:-.}/.agent-bridge"
|
|
@@ -239,9 +300,9 @@ mkdir -p "\$LOG_DIR" 2>/dev/null
|
|
|
239
300
|
{
|
|
240
301
|
echo "--- $(date '+%Y-%m-%dT%H:%M:%S%z') agent-bridge hook ---"
|
|
241
302
|
if command -v agent-bridge >/dev/null 2>&1; then
|
|
242
|
-
agent-bridge
|
|
303
|
+
agent-bridge sync
|
|
243
304
|
elif command -v npx >/dev/null 2>&1; then
|
|
244
|
-
npx @sofatutor/agent-bridge
|
|
305
|
+
npx @sofatutor/agent-bridge sync
|
|
245
306
|
else
|
|
246
307
|
echo "agent-bridge not found (install globally or ensure npx is available)"
|
|
247
308
|
fi
|
|
@@ -333,6 +394,23 @@ async function installGitHooks(repoRoot, force = false) {
|
|
|
333
394
|
return result;
|
|
334
395
|
}
|
|
335
396
|
/**
|
|
397
|
+
* Rewrite hooks that Agent Bridge installed earlier with the current script.
|
|
398
|
+
* Hooks we did not install (no marker) and missing hooks are left alone.
|
|
399
|
+
*/
|
|
400
|
+
async function refreshGitHooks(repoRoot) {
|
|
401
|
+
const refreshed = [];
|
|
402
|
+
if (!isInGitRepo(repoRoot)) return refreshed;
|
|
403
|
+
const hooksDir = getGitHooksDir(repoRoot);
|
|
404
|
+
for (const hookName of AGENT_BRIDGE_HOOKS) {
|
|
405
|
+
const hookPath = join(hooksDir, hookName);
|
|
406
|
+
if (!await hasAgentBridgeHook(hookPath)) continue;
|
|
407
|
+
await writeFile(hookPath, generateHookScript(), "utf-8");
|
|
408
|
+
await chmod(hookPath, 493);
|
|
409
|
+
refreshed.push(hookName);
|
|
410
|
+
}
|
|
411
|
+
return refreshed;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
336
414
|
* Remove Agent Bridge git hooks from the repository.
|
|
337
415
|
* Only removes hooks that have the Agent Bridge marker.
|
|
338
416
|
*/
|
|
@@ -641,304 +719,428 @@ async function removeStaleSourceDirs(repoRoot, config) {
|
|
|
641
719
|
return removed;
|
|
642
720
|
}
|
|
643
721
|
//#endregion
|
|
644
|
-
//#region src/lib/
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
label: "Pi (.pi/)"
|
|
722
|
+
//#region src/lib/manifest.ts
|
|
723
|
+
const TOOL_PREFIX_SEPARATOR = "--";
|
|
724
|
+
function parseToolPrefix(name) {
|
|
725
|
+
const idx = name.indexOf(TOOL_PREFIX_SEPARATOR);
|
|
726
|
+
if (idx > 0) return {
|
|
727
|
+
toolPrefix: name.substring(0, idx),
|
|
728
|
+
baseName: name.substring(idx + 2)
|
|
729
|
+
};
|
|
730
|
+
return { baseName: name };
|
|
731
|
+
}
|
|
732
|
+
function featureMatchesTool(feature, toolName) {
|
|
733
|
+
if (!feature.toolPrefix) return true;
|
|
734
|
+
return feature.toolPrefix === toolName;
|
|
735
|
+
}
|
|
736
|
+
function featureName(feature) {
|
|
737
|
+
if (feature.toolPrefix) return parseToolPrefix(feature.name).baseName;
|
|
738
|
+
return feature.name;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Discover all feature types across all sources and domains.
|
|
742
|
+
*/
|
|
743
|
+
async function discoverFeatureTypes(repoRoot, config) {
|
|
744
|
+
const types = /* @__PURE__ */ new Set();
|
|
745
|
+
for (const source of config.sources) {
|
|
746
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
747
|
+
for (const domain of sourceDomains(config, source)) {
|
|
748
|
+
const domainDir = join(srcPath, domain.name);
|
|
749
|
+
if (!await dirExists(domainDir)) continue;
|
|
750
|
+
const entries = await readdir(domainDir, { withFileTypes: true });
|
|
751
|
+
for (const entry of entries) if (entry.isDirectory() && isIncluded(domain, entry.name)) types.add(entry.name);
|
|
752
|
+
}
|
|
676
753
|
}
|
|
677
|
-
];
|
|
678
|
-
|
|
679
|
-
const CUSTOM_TOOL_SENTINEL = {
|
|
680
|
-
name: "__custom__",
|
|
681
|
-
folder: "__custom__"
|
|
682
|
-
};
|
|
683
|
-
const DEFAULT_DOMAINS = [
|
|
684
|
-
"backend",
|
|
685
|
-
"frontend",
|
|
686
|
-
"shared"
|
|
687
|
-
];
|
|
754
|
+
return [...types].sort();
|
|
755
|
+
}
|
|
688
756
|
/**
|
|
689
|
-
*
|
|
757
|
+
* Scan all features across sources × domains × feature types.
|
|
690
758
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
* git@github.com:org/repo.git → repo
|
|
694
|
-
* file:///tmp/bare.git → bare
|
|
695
|
-
* /path/to/my-folder → my-folder
|
|
759
|
+
* Structure: `<source-path>/<domain>/<feature-type>/<feature>/` (folder-based)
|
|
760
|
+
* or `<source-path>/<domain>/<feature-type>/<feature.ext>` (file-based)
|
|
696
761
|
*/
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
762
|
+
async function scanFeatures(repoRoot, config, featureTypes) {
|
|
763
|
+
const features = [];
|
|
764
|
+
for (const source of config.sources) {
|
|
765
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
766
|
+
for (const domain of sourceDomains(config, source)) for (const ft of featureTypes) {
|
|
767
|
+
if (!isIncluded(domain, ft)) continue;
|
|
768
|
+
const { toolPrefix: typeToolPrefix, baseName: baseType } = parseToolPrefix(ft);
|
|
769
|
+
const ftDir = join(srcPath, domain.name, ft);
|
|
770
|
+
if (!await dirExists(ftDir)) continue;
|
|
771
|
+
const entries = await readdir(ftDir, { withFileTypes: true });
|
|
772
|
+
for (const entry of entries) {
|
|
773
|
+
const isFile = entry.isFile();
|
|
774
|
+
const isDir = entry.isDirectory();
|
|
775
|
+
if (!isFile && !isDir) continue;
|
|
776
|
+
if (!isIncluded(domain, `${ft}/${entry.name}`)) continue;
|
|
777
|
+
const { toolPrefix: itemToolPrefix } = parseToolPrefix(entry.name);
|
|
778
|
+
const toolPrefix = itemToolPrefix ?? typeToolPrefix;
|
|
779
|
+
features.push({
|
|
780
|
+
name: entry.name,
|
|
781
|
+
type: ft,
|
|
782
|
+
displayType: baseType,
|
|
783
|
+
source: source.name,
|
|
784
|
+
domain: domain.name,
|
|
785
|
+
absolutePath: join(ftDir, entry.name),
|
|
786
|
+
toolPrefix,
|
|
787
|
+
isFile
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return features;
|
|
793
|
+
}
|
|
794
|
+
/** Top-level directories of a source that can act as domains. */
|
|
795
|
+
async function listDomains(srcPath) {
|
|
796
|
+
if (!await dirExists(srcPath)) return [];
|
|
797
|
+
return (await readdir(srcPath, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name).sort();
|
|
705
798
|
}
|
|
706
799
|
/**
|
|
707
|
-
*
|
|
708
|
-
*
|
|
800
|
+
* List what `sync` would consider inside a domain, so the user can pick a
|
|
801
|
+
* subset. `toolNames` filters `<tool>--file` entries to configured tools.
|
|
709
802
|
*/
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
803
|
+
async function listDomainContents(srcPath, domain, toolNames) {
|
|
804
|
+
const domainDir = join(srcPath, domain);
|
|
805
|
+
const tools = new Set(toolNames);
|
|
806
|
+
const result = {
|
|
807
|
+
featureTypes: [],
|
|
808
|
+
files: []
|
|
809
|
+
};
|
|
810
|
+
if (!await dirExists(domainDir)) return result;
|
|
811
|
+
const entries = await readdir(domainDir, { withFileTypes: true });
|
|
812
|
+
for (const entry of entries) {
|
|
813
|
+
if (entry.name.startsWith(".")) continue;
|
|
814
|
+
if (entry.isDirectory()) {
|
|
815
|
+
const features = (await readdir(join(domainDir, entry.name), { withFileTypes: true })).filter((f) => (f.isFile() || f.isDirectory()) && !f.name.startsWith(".")).map((f) => f.name).sort();
|
|
816
|
+
result.featureTypes.push({
|
|
817
|
+
name: entry.name,
|
|
818
|
+
features
|
|
819
|
+
});
|
|
820
|
+
} else if (entry.isFile()) {
|
|
821
|
+
const { toolPrefix } = parseToolPrefix(entry.name);
|
|
822
|
+
if (ROOT_FILES.includes(entry.name) || toolPrefix && tools.has(toolPrefix)) result.files.push(entry.name);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
result.featureTypes.sort((a, b) => a.name.localeCompare(b.name));
|
|
826
|
+
result.files.sort();
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
function detectDuplicates(features) {
|
|
830
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
831
|
+
for (const f of features) {
|
|
832
|
+
const linkName = featureName(f);
|
|
833
|
+
const key = `${f.displayType}/${linkName}`;
|
|
834
|
+
const group = byKey.get(key) ?? [];
|
|
835
|
+
group.push(f);
|
|
836
|
+
byKey.set(key, group);
|
|
837
|
+
}
|
|
838
|
+
const conflicts = [];
|
|
839
|
+
for (const [, group] of byKey) if (group.length > 1) conflicts.push({
|
|
840
|
+
name: featureName(group[0]),
|
|
841
|
+
type: group[0].type,
|
|
842
|
+
paths: group.map((f) => f.absolutePath)
|
|
721
843
|
});
|
|
844
|
+
return conflicts;
|
|
722
845
|
}
|
|
723
846
|
/**
|
|
724
|
-
*
|
|
725
|
-
*
|
|
847
|
+
* Well-known root files that live at the domain root and should be synced to the
|
|
848
|
+
* workspace root. When a source contains `<domain>/AGENTS.md` (etc.), Agent Bridge
|
|
849
|
+
* copies it to the project root.
|
|
726
850
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
851
|
+
const ROOT_FILES = [
|
|
852
|
+
"AGENTS.md",
|
|
853
|
+
"CLAUDE.md",
|
|
854
|
+
"SYSTEM.md"
|
|
855
|
+
];
|
|
856
|
+
/**
|
|
857
|
+
* Scan all sources × domains for well-known root files.
|
|
858
|
+
* Returns one entry per found file.
|
|
859
|
+
*/
|
|
860
|
+
async function scanRootFiles(repoRoot, config) {
|
|
861
|
+
const found = [];
|
|
862
|
+
for (const source of config.sources) {
|
|
863
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
864
|
+
for (const domain of sourceDomains(config, source)) for (const fileName of ROOT_FILES) {
|
|
865
|
+
if (!isIncluded(domain, fileName)) continue;
|
|
866
|
+
const filePath = join(srcPath, domain.name, fileName);
|
|
867
|
+
if (await fileExists(filePath)) found.push({
|
|
868
|
+
fileName,
|
|
869
|
+
source: source.name,
|
|
870
|
+
domain: domain.name,
|
|
871
|
+
absolutePath: filePath
|
|
872
|
+
});
|
|
873
|
+
}
|
|
734
874
|
}
|
|
735
|
-
|
|
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;
|
|
875
|
+
return found;
|
|
743
876
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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;
|
|
877
|
+
/**
|
|
878
|
+
* Detect duplicate root files (same filename provided by multiple sources/domains).
|
|
879
|
+
*/
|
|
880
|
+
function detectRootFileDuplicates(rootFiles) {
|
|
881
|
+
const byName = /* @__PURE__ */ new Map();
|
|
882
|
+
for (const rf of rootFiles) {
|
|
883
|
+
const group = byName.get(rf.fileName) ?? [];
|
|
884
|
+
group.push(rf);
|
|
885
|
+
byName.set(rf.fileName, group);
|
|
790
886
|
}
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|
887
|
+
const duplicates = [];
|
|
888
|
+
for (const [fileName, group] of byName) if (group.length > 1) duplicates.push({
|
|
889
|
+
fileName,
|
|
890
|
+
paths: group.map((rf) => rf.absolutePath)
|
|
816
891
|
});
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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);
|
|
892
|
+
return duplicates;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Scan all sources × domains for tool-prefixed flat files at the domain level.
|
|
896
|
+
* A file named `cursor--settings.json` targets the tool "cursor" with
|
|
897
|
+
* destination filename "settings.json".
|
|
898
|
+
*/
|
|
899
|
+
async function scanToolRootEntries(repoRoot, config) {
|
|
900
|
+
const entries = [];
|
|
901
|
+
const toolNames = new Set(config.tools.map((t) => t.name));
|
|
902
|
+
for (const source of config.sources) {
|
|
903
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
904
|
+
for (const domain of sourceDomains(config, source)) {
|
|
905
|
+
const domainDir = join(srcPath, domain.name);
|
|
906
|
+
if (!await dirExists(domainDir)) continue;
|
|
907
|
+
const domainEntries = await readdir(domainDir, { withFileTypes: true });
|
|
908
|
+
for (const entry of domainEntries) {
|
|
909
|
+
if (!entry.isFile() || !isIncluded(domain, entry.name)) continue;
|
|
910
|
+
const { toolPrefix, baseName } = parseToolPrefix(entry.name);
|
|
911
|
+
if (!toolPrefix || !toolNames.has(toolPrefix)) continue;
|
|
912
|
+
entries.push({
|
|
913
|
+
toolName: toolPrefix,
|
|
914
|
+
name: baseName,
|
|
915
|
+
source: source.name,
|
|
916
|
+
domain: domain.name,
|
|
917
|
+
absolutePath: join(domainDir, entry.name)
|
|
918
|
+
});
|
|
919
|
+
}
|
|
858
920
|
}
|
|
859
921
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
922
|
+
return entries;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Detect duplicate tool root entries (same tool + name from multiple sources/domains).
|
|
926
|
+
*/
|
|
927
|
+
function detectToolRootDuplicates(entries) {
|
|
928
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const entry of entries) {
|
|
930
|
+
const key = `${entry.toolName}/${entry.name}`;
|
|
931
|
+
const group = byKey.get(key) ?? [];
|
|
932
|
+
group.push(entry);
|
|
933
|
+
byKey.set(key, group);
|
|
934
|
+
}
|
|
935
|
+
const duplicates = [];
|
|
936
|
+
for (const [, group] of byKey) if (group.length > 1) duplicates.push({
|
|
937
|
+
toolName: group[0].toolName,
|
|
938
|
+
name: group[0].name,
|
|
939
|
+
paths: group.map((e) => e.absolutePath)
|
|
940
|
+
});
|
|
941
|
+
return duplicates;
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/lib/tree.ts
|
|
945
|
+
var TreeModel = class {
|
|
946
|
+
selected = /* @__PURE__ */ new Set();
|
|
947
|
+
expanded = /* @__PURE__ */ new Set();
|
|
948
|
+
cursor = 0;
|
|
949
|
+
constructor(roots, opts = {}) {
|
|
950
|
+
this.roots = roots;
|
|
951
|
+
const depth = opts.expandDepth ?? 1;
|
|
952
|
+
const expand = (nodes, d) => {
|
|
953
|
+
if (d >= depth) return;
|
|
954
|
+
for (const n of nodes) if (n.children?.length) {
|
|
955
|
+
this.expanded.add(n);
|
|
956
|
+
expand(n.children, d + 1);
|
|
870
957
|
}
|
|
871
|
-
});
|
|
872
|
-
if (p.isCancel(source)) return false;
|
|
873
|
-
const entry = {
|
|
874
|
-
name: deriveSourceName(source.trim()),
|
|
875
|
-
source: source.trim()
|
|
876
958
|
};
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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);
|
|
959
|
+
expand(roots, 0);
|
|
960
|
+
for (const v of opts.initialSelected ?? []) this.selected.add(v);
|
|
961
|
+
}
|
|
962
|
+
/** Visible rows in display order, honoring collapsed nodes. */
|
|
963
|
+
rows() {
|
|
964
|
+
const out = [];
|
|
965
|
+
const walk = (nodes, depth, parent) => {
|
|
966
|
+
for (const node of nodes) {
|
|
967
|
+
out.push({
|
|
968
|
+
node,
|
|
969
|
+
depth,
|
|
970
|
+
parent
|
|
971
|
+
});
|
|
972
|
+
if (node.children?.length && this.expanded.has(node)) walk(node.children, depth + 1, node);
|
|
897
973
|
}
|
|
898
|
-
|
|
974
|
+
};
|
|
975
|
+
walk(this.roots, 0);
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
current() {
|
|
979
|
+
return this.rows()[this.cursor];
|
|
980
|
+
}
|
|
981
|
+
isExpanded(node) {
|
|
982
|
+
return this.expanded.has(node);
|
|
983
|
+
}
|
|
984
|
+
leaves(node) {
|
|
985
|
+
if (!node.children?.length) return node.value !== void 0 ? [node.value] : [];
|
|
986
|
+
return node.children.flatMap((c) => this.leaves(c));
|
|
987
|
+
}
|
|
988
|
+
state(node) {
|
|
989
|
+
const leaves = this.leaves(node);
|
|
990
|
+
if (leaves.length === 0) return "none";
|
|
991
|
+
const n = leaves.filter((l) => this.selected.has(l)).length;
|
|
992
|
+
return n === 0 ? "none" : n === leaves.length ? "all" : "some";
|
|
993
|
+
}
|
|
994
|
+
/** Space: leaf toggles; parent selects all descendants unless already all. */
|
|
995
|
+
toggle() {
|
|
996
|
+
const row = this.current();
|
|
997
|
+
if (!row) return;
|
|
998
|
+
const leaves = this.leaves(row.node);
|
|
999
|
+
if (this.state(row.node) === "all") for (const l of leaves) this.selected.delete(l);
|
|
1000
|
+
else for (const l of leaves) this.selected.add(l);
|
|
1001
|
+
}
|
|
1002
|
+
move(delta) {
|
|
1003
|
+
const n = this.rows().length;
|
|
1004
|
+
if (n === 0) return;
|
|
1005
|
+
this.cursor = (this.cursor + delta + n) % n;
|
|
1006
|
+
}
|
|
1007
|
+
/** Right: expand. On a leaf or an open node, nothing happens. */
|
|
1008
|
+
expand() {
|
|
1009
|
+
const row = this.current();
|
|
1010
|
+
if (row?.node.children?.length) this.expanded.add(row.node);
|
|
1011
|
+
}
|
|
1012
|
+
/** Left: collapse an open node; on a closed node or leaf, jump to its parent. */
|
|
1013
|
+
collapse() {
|
|
1014
|
+
const row = this.current();
|
|
1015
|
+
if (!row) return;
|
|
1016
|
+
if (row.node.children?.length && this.expanded.has(row.node)) {
|
|
1017
|
+
this.expanded.delete(row.node);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (row.parent) {
|
|
1021
|
+
const idx = this.rows().findIndex((r) => r.node === row.parent);
|
|
1022
|
+
if (idx >= 0) this.cursor = idx;
|
|
899
1023
|
}
|
|
900
|
-
const addMore = await p.confirm({
|
|
901
|
-
message: "Add another source?",
|
|
902
|
-
initialValue: false
|
|
903
|
-
});
|
|
904
|
-
if (p.isCancel(addMore) || !addMore) addingSource = false;
|
|
905
1024
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1025
|
+
toggleExpand() {
|
|
1026
|
+
const row = this.current();
|
|
1027
|
+
if (!row?.node.children?.length) return;
|
|
1028
|
+
if (this.expanded.has(row.node)) this.expanded.delete(row.node);
|
|
1029
|
+
else this.expanded.add(row.node);
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/lib/tree-prompt.ts
|
|
1034
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
1035
|
+
const paint = (code, s) => tty ? `\x1b[${code}m${s}\x1b[39m` : s;
|
|
1036
|
+
const dim = (s) => tty ? `\x1b[2m${s}\x1b[22m` : s;
|
|
1037
|
+
const cyan = (s) => paint(36, s);
|
|
1038
|
+
const green = (s) => paint(32, s);
|
|
1039
|
+
const yellow = (s) => paint(33, s);
|
|
1040
|
+
const red = (s) => paint(31, s);
|
|
1041
|
+
const gray = (s) => paint(90, s);
|
|
1042
|
+
const S_BAR = "│";
|
|
1043
|
+
const S_BAR_END = "└";
|
|
1044
|
+
const CHECK = {
|
|
1045
|
+
none: "◻",
|
|
1046
|
+
some: yellow("◧"),
|
|
1047
|
+
all: green("◼")
|
|
1048
|
+
};
|
|
1049
|
+
function symbol(state) {
|
|
1050
|
+
if (state === "cancel") return red("■");
|
|
1051
|
+
if (state === "error") return yellow("▲");
|
|
1052
|
+
if (state === "submit") return green("◇");
|
|
1053
|
+
return cyan("◆");
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* A checkbox tree. Space toggles the node under the cursor (a parent toggles
|
|
1057
|
+
* everything beneath it), ←/→ collapse/expand, Enter confirms.
|
|
1058
|
+
* Resolves to the selected leaf values, or the clack cancel symbol.
|
|
1059
|
+
*/
|
|
1060
|
+
async function treeSelect(opts) {
|
|
1061
|
+
const model = new TreeModel(opts.tree, {
|
|
1062
|
+
expandDepth: opts.expandDepth,
|
|
1063
|
+
initialSelected: opts.initialValues
|
|
1064
|
+
});
|
|
1065
|
+
const maxItems = Math.max(5, opts.maxItems ?? (process.stdout.rows || 24) - 6);
|
|
1066
|
+
const prompt = new Prompt({
|
|
1067
|
+
validate: () => {
|
|
1068
|
+
if (opts.required !== false && model.selected.size === 0) return "Select at least one item.";
|
|
1069
|
+
},
|
|
1070
|
+
render() {
|
|
1071
|
+
const title = `${gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
|
|
1072
|
+
if (this.state === "submit") {
|
|
1073
|
+
const n = model.selected.size;
|
|
1074
|
+
return `${title}${gray(S_BAR)} ${dim(`${n} item${n === 1 ? "" : "s"} selected`)}`;
|
|
1075
|
+
}
|
|
1076
|
+
if (this.state === "cancel") return `${title}${gray(S_BAR)} ${dim("cancelled")}\n${gray(S_BAR)}`;
|
|
1077
|
+
const rows = model.rows();
|
|
1078
|
+
let start = 0;
|
|
1079
|
+
if (rows.length > maxItems) start = Math.min(Math.max(0, model.cursor - Math.floor(maxItems / 2)), rows.length - maxItems);
|
|
1080
|
+
const end = Math.min(rows.length, start + maxItems);
|
|
1081
|
+
const lines = [];
|
|
1082
|
+
if (start > 0) lines.push(`${cyan(S_BAR)} ${dim("…")}`);
|
|
1083
|
+
for (let i = start; i < end; i++) {
|
|
1084
|
+
const { node, depth } = rows[i];
|
|
1085
|
+
const active = i === model.cursor;
|
|
1086
|
+
const arrow = !!node.children?.length ? model.isExpanded(node) ? "▾" : "▸" : " ";
|
|
1087
|
+
const box = CHECK[model.state(node)];
|
|
1088
|
+
const indent = " ".repeat(depth);
|
|
1089
|
+
let label = active ? node.label : dim(node.label);
|
|
1090
|
+
if (node.hint) label += ` ${dim(node.hint)}`;
|
|
1091
|
+
lines.push(`${cyan(S_BAR)} ${indent}${dim(arrow)} ${box} ${label}`);
|
|
933
1092
|
}
|
|
934
|
-
if (
|
|
1093
|
+
if (end < rows.length) lines.push(`${cyan(S_BAR)} ${dim("…")}`);
|
|
1094
|
+
const footer = this.state === "error" ? `${yellow(S_BAR_END)} ${yellow(this.error)}` : `${cyan(S_BAR_END)} ${dim("space toggle · ←/→ collapse/expand · enter confirm")}`;
|
|
1095
|
+
return `${title}${lines.join("\n")}\n${footer}\n`;
|
|
935
1096
|
}
|
|
936
|
-
}
|
|
937
|
-
|
|
1097
|
+
}, false);
|
|
1098
|
+
prompt.on("cursor", (key) => {
|
|
1099
|
+
switch (key) {
|
|
1100
|
+
case "up":
|
|
1101
|
+
model.move(-1);
|
|
1102
|
+
break;
|
|
1103
|
+
case "down":
|
|
1104
|
+
model.move(1);
|
|
1105
|
+
break;
|
|
1106
|
+
case "left":
|
|
1107
|
+
model.collapse();
|
|
1108
|
+
break;
|
|
1109
|
+
case "right":
|
|
1110
|
+
model.expand();
|
|
1111
|
+
break;
|
|
1112
|
+
case "space":
|
|
1113
|
+
model.toggle();
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
prompt.value = [...model.selected];
|
|
1117
|
+
});
|
|
1118
|
+
prompt.value = [...model.selected];
|
|
1119
|
+
const result = await prompt.prompt();
|
|
1120
|
+
if (isCancel(result)) return result;
|
|
1121
|
+
return [...model.selected];
|
|
938
1122
|
}
|
|
939
1123
|
//#endregion
|
|
1124
|
+
//#region src/lib/version.ts
|
|
1125
|
+
const VERSION = "0.15.0";
|
|
1126
|
+
//#endregion
|
|
940
1127
|
//#region src/lib/migrations/index.ts
|
|
941
|
-
const migrations = [
|
|
1128
|
+
const migrations = [{
|
|
1129
|
+
version: "0.14.0",
|
|
1130
|
+
description: "move top-level domains into each source; git hooks run `sync` only",
|
|
1131
|
+
migrate: async (repoRoot, config) => {
|
|
1132
|
+
const { domains, ...rest } = config;
|
|
1133
|
+
const sources = config.sources.map((s) => s.domains ? s : {
|
|
1134
|
+
...s,
|
|
1135
|
+
domains: (domains ?? []).map((name) => ({ name }))
|
|
1136
|
+
});
|
|
1137
|
+
await refreshGitHooks(repoRoot);
|
|
1138
|
+
return {
|
|
1139
|
+
...rest,
|
|
1140
|
+
sources
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
}];
|
|
942
1144
|
/** Parse "1.2.3" or "1.2.3-beta.1" into [major, minor, patch]. */
|
|
943
1145
|
function parseSemver(version) {
|
|
944
1146
|
const parts = version.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
@@ -994,190 +1196,6 @@ async function runMigrations(repoRoot) {
|
|
|
994
1196
|
};
|
|
995
1197
|
}
|
|
996
1198
|
//#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
1199
|
//#region src/lib/sync.ts
|
|
1182
1200
|
/**
|
|
1183
1201
|
* Compute the destination path for a feature inside a tool's folder.
|
|
@@ -1458,11 +1476,11 @@ async function syncCommand(cwd, _opts) {
|
|
|
1458
1476
|
}
|
|
1459
1477
|
const s = p.spinner();
|
|
1460
1478
|
s.start("Loading configuration…");
|
|
1461
|
-
const config = await loadConfig(repoRoot);
|
|
1462
1479
|
const migrationResult = await runMigrations(repoRoot);
|
|
1463
1480
|
if (migrationResult) p.log.info(`Config upgraded ${migrationResult.fromVersion} → ${migrationResult.toVersion}` + (migrationResult.applied.length > 0 ? ` (${migrationResult.applied.length} migration(s))` : ""));
|
|
1481
|
+
const config = await loadConfig(repoRoot);
|
|
1464
1482
|
s.stop("Configuration valid");
|
|
1465
|
-
s.start("
|
|
1483
|
+
s.start("Fetching sources…");
|
|
1466
1484
|
const sourceResults = await syncAllSources(repoRoot, config);
|
|
1467
1485
|
const sourceErrors = sourceResults.filter((r) => r.error);
|
|
1468
1486
|
if (sourceErrors.length > 0) {
|
|
@@ -1473,7 +1491,7 @@ async function syncCommand(cwd, _opts) {
|
|
|
1473
1491
|
const staleRemoved = await removeStaleSourceDirs(repoRoot, config);
|
|
1474
1492
|
if (staleRemoved.length > 0) for (const name of staleRemoved) p.log.info(`Removed stale source: ${name}`);
|
|
1475
1493
|
for (const r of sourceResults) if (r.action !== "local") p.log.info(`${r.name}: ${r.action}`);
|
|
1476
|
-
s.stop("Sources
|
|
1494
|
+
s.stop("Sources up to date");
|
|
1477
1495
|
s.start("Discovering features…");
|
|
1478
1496
|
const features = await scanFeatures(repoRoot, config, await discoverFeatureTypes(repoRoot, config));
|
|
1479
1497
|
const rootFiles = await scanRootFiles(repoRoot, config);
|
|
@@ -1546,22 +1564,388 @@ async function syncCommand(cwd, _opts) {
|
|
|
1546
1564
|
p.outro("Sync complete.");
|
|
1547
1565
|
}
|
|
1548
1566
|
//#endregion
|
|
1549
|
-
//#region src/commands/
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1567
|
+
//#region src/commands/init.ts
|
|
1568
|
+
const WELL_KNOWN_TOOLS = [
|
|
1569
|
+
{
|
|
1570
|
+
value: {
|
|
1571
|
+
name: "vscode",
|
|
1572
|
+
folder: ".github"
|
|
1573
|
+
},
|
|
1574
|
+
label: "VS Code (.github/)"
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
value: {
|
|
1578
|
+
name: "cursor",
|
|
1579
|
+
folder: ".cursor"
|
|
1580
|
+
},
|
|
1581
|
+
label: "Cursor (.cursor/)"
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
value: {
|
|
1585
|
+
name: "claude",
|
|
1586
|
+
folder: ".claude"
|
|
1587
|
+
},
|
|
1588
|
+
label: "Claude (.claude/)"
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
value: {
|
|
1592
|
+
name: "pi",
|
|
1593
|
+
folder: ".pi"
|
|
1594
|
+
},
|
|
1595
|
+
label: "Pi (.pi/)"
|
|
1596
|
+
}
|
|
1597
|
+
];
|
|
1598
|
+
const WELL_KNOWN_TOOL_MAP = Object.fromEntries(WELL_KNOWN_TOOLS.map((t) => [t.value.name, t.value]));
|
|
1599
|
+
const CUSTOM_TOOL_SENTINEL = {
|
|
1600
|
+
name: "__custom__",
|
|
1601
|
+
folder: "__custom__"
|
|
1602
|
+
};
|
|
1603
|
+
/**
|
|
1604
|
+
* Derive a short source name from a URL or local path.
|
|
1605
|
+
*
|
|
1606
|
+
* Examples:
|
|
1607
|
+
* https://github.com/org/repo.git → repo
|
|
1608
|
+
* git@github.com:org/repo.git → repo
|
|
1609
|
+
* file:///tmp/bare.git → bare
|
|
1610
|
+
* /path/to/my-folder → my-folder
|
|
1611
|
+
*/
|
|
1612
|
+
function deriveSourceName(source) {
|
|
1613
|
+
let segment = source;
|
|
1614
|
+
const sshMatch = segment.match(/^[\w.-]+@[\w.-]+:(.+)$/);
|
|
1615
|
+
if (sshMatch) segment = sshMatch[1];
|
|
1616
|
+
try {
|
|
1617
|
+
segment = new URL(segment).pathname;
|
|
1618
|
+
} catch {}
|
|
1619
|
+
return (segment.replace(/\/+$/, "").split("/").pop() ?? segment).replace(/\.git$/, "") || "source";
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Parse a comma-separated `--tools` argument into ToolConfig[].
|
|
1623
|
+
* Accepts well-known names (cursor, vscode, claude) or `name:folder` pairs.
|
|
1624
|
+
*/
|
|
1625
|
+
function parseToolsArg(input) {
|
|
1626
|
+
return input.split(",").map((t) => {
|
|
1627
|
+
const trimmed = t.trim();
|
|
1628
|
+
if (!trimmed) throw new Error("Empty tool name in --tools");
|
|
1629
|
+
if (WELL_KNOWN_TOOL_MAP[trimmed]) return WELL_KNOWN_TOOL_MAP[trimmed];
|
|
1630
|
+
const colonIdx = trimmed.indexOf(":");
|
|
1631
|
+
if (colonIdx > 0) return {
|
|
1632
|
+
name: trimmed.slice(0, colonIdx),
|
|
1633
|
+
folder: trimmed.slice(colonIdx + 1)
|
|
1634
|
+
};
|
|
1635
|
+
throw new Error(`Unknown tool "${trimmed}". Use a known name (${Object.keys(WELL_KNOWN_TOOL_MAP).join(", ")}) or name:folder format.`);
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Parse a single `--source` argument into a SourceConfig.
|
|
1640
|
+
* Supports `#branch` suffix for remote sources.
|
|
1641
|
+
*/
|
|
1642
|
+
function parseSourceArg(input, repoRoot) {
|
|
1643
|
+
let source = input.trim();
|
|
1644
|
+
let branch;
|
|
1645
|
+
const hashIdx = source.lastIndexOf("#");
|
|
1646
|
+
if (hashIdx > 0) {
|
|
1647
|
+
branch = source.slice(hashIdx + 1);
|
|
1648
|
+
source = source.slice(0, hashIdx);
|
|
1649
|
+
}
|
|
1650
|
+
if (!source) throw new Error("Empty source in --source");
|
|
1651
|
+
const entry = {
|
|
1652
|
+
name: deriveSourceName(source),
|
|
1653
|
+
source
|
|
1654
|
+
};
|
|
1655
|
+
if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
|
|
1656
|
+
if (branch) entry.branch = branch;
|
|
1657
|
+
return entry;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Turn a per-domain selection into the `include` list stored in config.
|
|
1661
|
+
* Returns `undefined` when everything is selected (= sync the whole domain).
|
|
1662
|
+
* A fully selected feature type collapses to its name (`skills`).
|
|
1663
|
+
*/
|
|
1664
|
+
function buildInclude(contents, selected) {
|
|
1665
|
+
const include = [];
|
|
1666
|
+
let everything = true;
|
|
1667
|
+
for (const ft of contents.featureTypes) {
|
|
1668
|
+
const picked = ft.features.filter((f) => selected.has(`${ft.name}/${f}`));
|
|
1669
|
+
if (picked.length === ft.features.length) include.push(ft.name);
|
|
1670
|
+
else {
|
|
1671
|
+
everything = false;
|
|
1672
|
+
include.push(...picked.map((f) => `${ft.name}/${f}`));
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
for (const file of contents.files) if (selected.has(file)) include.push(file);
|
|
1676
|
+
else everything = false;
|
|
1677
|
+
return everything ? void 0 : include;
|
|
1678
|
+
}
|
|
1679
|
+
function cancelled(value) {
|
|
1680
|
+
if (p.isCancel(value)) {
|
|
1681
|
+
p.cancel("Setup cancelled.");
|
|
1682
|
+
process.exit(1);
|
|
1683
|
+
}
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
/** Clone remote sources / verify local ones. Exits on failure. */
|
|
1687
|
+
async function fetchSources(repoRoot, sources) {
|
|
1688
|
+
await ensureBridgeGitignore(repoRoot);
|
|
1556
1689
|
const s = p.spinner();
|
|
1557
|
-
s.start("
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1690
|
+
s.start("Fetching sources…");
|
|
1691
|
+
const errors = (await Promise.all(sources.map((src) => syncSource(repoRoot, src)))).filter((r) => r.error);
|
|
1692
|
+
if (errors.length > 0) {
|
|
1693
|
+
s.stop("Some sources failed");
|
|
1694
|
+
for (const err of errors) p.log.error(`${err.name}: ${err.error}`);
|
|
1695
|
+
p.cancel("Fix the source URL/path and run `agent-bridge init` again.");
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
}
|
|
1698
|
+
s.stop(`${sources.length} source(s) ready`);
|
|
1699
|
+
}
|
|
1700
|
+
async function maybeInstallHooks(repoRoot, force) {
|
|
1701
|
+
const hookResult = await installGitHooks(repoRoot, force);
|
|
1702
|
+
if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
|
|
1703
|
+
if (hookResult.skipped.length > 0) {
|
|
1704
|
+
p.log.warn(`Skipped hooks (existing non-Agent-Bridge hooks): ${hookResult.skipped.join(", ")}`);
|
|
1705
|
+
p.log.info("Re-run `agent-bridge init --force` to overwrite, or integrate manually.");
|
|
1706
|
+
}
|
|
1707
|
+
for (const e of hookResult.errors) p.log.error(`Hook ${e.hook}: ${e.error}`);
|
|
1708
|
+
}
|
|
1709
|
+
async function promptTools() {
|
|
1710
|
+
const selected = await p.multiselect({
|
|
1711
|
+
message: "Which tools should receive synced files?",
|
|
1712
|
+
options: [...WELL_KNOWN_TOOLS, {
|
|
1713
|
+
value: CUSTOM_TOOL_SENTINEL,
|
|
1714
|
+
label: "Other (add custom tool)"
|
|
1715
|
+
}],
|
|
1716
|
+
required: true
|
|
1717
|
+
});
|
|
1718
|
+
cancelled(selected);
|
|
1719
|
+
const tools = selected.filter((t) => t.name !== CUSTOM_TOOL_SENTINEL.name);
|
|
1720
|
+
if (!selected.some((t) => t.name === CUSTOM_TOOL_SENTINEL.name)) return tools;
|
|
1721
|
+
for (;;) {
|
|
1722
|
+
const name = await p.text({
|
|
1723
|
+
message: "Custom tool name (used for <tool>-- prefix matching)",
|
|
1724
|
+
placeholder: "windsurf",
|
|
1725
|
+
validate: (v) => {
|
|
1726
|
+
if (!v.trim()) return "Tool name cannot be empty";
|
|
1727
|
+
if (tools.some((t) => t.name === v.trim())) return "Tool name already used";
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
if (p.isCancel(name)) break;
|
|
1731
|
+
const folder = await p.text({
|
|
1732
|
+
message: `Target folder for "${name}"`,
|
|
1733
|
+
placeholder: `.${name}`,
|
|
1734
|
+
validate: (v) => {
|
|
1735
|
+
if (!v.trim()) return "Folder cannot be empty";
|
|
1736
|
+
if (tools.some((t) => t.folder === v.trim())) return "Folder already used by another tool";
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
if (p.isCancel(folder)) break;
|
|
1740
|
+
tools.push({
|
|
1741
|
+
name: name.trim(),
|
|
1742
|
+
folder: folder.trim()
|
|
1743
|
+
});
|
|
1744
|
+
const more = await p.confirm({
|
|
1745
|
+
message: "Add another custom tool?",
|
|
1746
|
+
initialValue: false
|
|
1747
|
+
});
|
|
1748
|
+
if (p.isCancel(more) || !more) break;
|
|
1749
|
+
}
|
|
1750
|
+
if (tools.length === 0) {
|
|
1751
|
+
p.cancel("At least one tool is required.");
|
|
1752
|
+
process.exit(1);
|
|
1753
|
+
}
|
|
1754
|
+
return tools;
|
|
1755
|
+
}
|
|
1756
|
+
async function promptSources(repoRoot) {
|
|
1757
|
+
const sources = [];
|
|
1758
|
+
p.log.info("Add at least one source — a Git URL or a local folder that follows the domain layout.");
|
|
1759
|
+
for (;;) {
|
|
1760
|
+
const input = await p.text({
|
|
1761
|
+
message: sources.length === 0 ? "Source URL or local path" : "Another source URL or local path",
|
|
1762
|
+
placeholder: "https://github.com/org/ai-hub.git",
|
|
1763
|
+
validate: (v) => {
|
|
1764
|
+
if (!v.trim()) return "Source URL/path cannot be empty";
|
|
1765
|
+
const derived = deriveSourceName(v.trim());
|
|
1766
|
+
if (sources.some((s) => s.name === derived)) return `Source name "${derived}" (derived from URL) already used`;
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
if (p.isCancel(input)) {
|
|
1770
|
+
if (sources.length === 0) cancelled(input);
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1773
|
+
const entry = parseSourceArg(input, repoRoot);
|
|
1774
|
+
if (isRemoteSource(entry.source) && !entry.branch) {
|
|
1775
|
+
const branch = await p.text({
|
|
1776
|
+
message: "Branch (leave empty for the remote default)",
|
|
1777
|
+
placeholder: "main",
|
|
1778
|
+
defaultValue: ""
|
|
1779
|
+
});
|
|
1780
|
+
cancelled(branch);
|
|
1781
|
+
if (branch.trim()) entry.branch = branch.trim();
|
|
1782
|
+
}
|
|
1783
|
+
sources.push(entry);
|
|
1784
|
+
const more = await p.confirm({
|
|
1785
|
+
message: "Add another source?",
|
|
1786
|
+
initialValue: false
|
|
1787
|
+
});
|
|
1788
|
+
if (p.isCancel(more) || !more) break;
|
|
1789
|
+
}
|
|
1790
|
+
return sources;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* One checkbox tree: source → domain → feature type → feature (plus a
|
|
1794
|
+
* `files` group per domain). Ticking a node ticks everything beneath it.
|
|
1795
|
+
* Returns, per source, the picked domains with their `include` lists
|
|
1796
|
+
* (`undefined` include = whole domain).
|
|
1797
|
+
*/
|
|
1798
|
+
async function promptSelection(repoRoot, sources, toolNames) {
|
|
1799
|
+
const SEP = "\0";
|
|
1800
|
+
const contentsByKey = /* @__PURE__ */ new Map();
|
|
1801
|
+
const tree = [];
|
|
1802
|
+
for (const source of sources) {
|
|
1803
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1804
|
+
const domains = await listDomains(srcPath);
|
|
1805
|
+
if (domains.length === 0) {
|
|
1806
|
+
p.log.warn(`${source.name}: no domain folders found — nothing to select.`);
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const domainNodes = [];
|
|
1810
|
+
for (const domain of domains) {
|
|
1811
|
+
const contents = await listDomainContents(srcPath, domain, toolNames);
|
|
1812
|
+
contentsByKey.set(`${source.name}${SEP}${domain}`, contents);
|
|
1813
|
+
const prefix = `${source.name}${SEP}${domain}${SEP}`;
|
|
1814
|
+
const children = contents.featureTypes.filter((ft) => ft.features.length > 0).map((ft) => ({
|
|
1815
|
+
label: ft.name,
|
|
1816
|
+
hint: `(${ft.features.length})`,
|
|
1817
|
+
children: ft.features.map((f) => ({
|
|
1818
|
+
label: f,
|
|
1819
|
+
value: `${prefix}${ft.name}/${f}`
|
|
1820
|
+
}))
|
|
1821
|
+
}));
|
|
1822
|
+
if (contents.files.length > 0) children.push({
|
|
1823
|
+
label: "files",
|
|
1824
|
+
children: contents.files.map((f) => ({
|
|
1825
|
+
label: f,
|
|
1826
|
+
value: `${prefix}${f}`
|
|
1827
|
+
}))
|
|
1828
|
+
});
|
|
1829
|
+
const hint = contents.featureTypes.filter((ft) => ft.features.length > 0).map((ft) => `${ft.features.length} ${ft.name}`).join(", ");
|
|
1830
|
+
domainNodes.push(children.length > 0 ? {
|
|
1831
|
+
label: domain,
|
|
1832
|
+
hint: hint ? `(${hint})` : void 0,
|
|
1833
|
+
children
|
|
1834
|
+
} : {
|
|
1835
|
+
label: domain,
|
|
1836
|
+
hint: "(empty)",
|
|
1837
|
+
value: prefix
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
tree.push({
|
|
1841
|
+
label: source.name,
|
|
1842
|
+
children: domainNodes
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
if (tree.length === 0) {
|
|
1846
|
+
p.cancel("No domains found in any source. Check the source layout: <source>/<domain>/<feature-type>/…");
|
|
1847
|
+
process.exit(1);
|
|
1848
|
+
}
|
|
1849
|
+
const picked = await treeSelect({
|
|
1850
|
+
message: "What do you want to sync? Tick a domain to take all of it, or open it and pick pieces.",
|
|
1851
|
+
tree,
|
|
1852
|
+
expandDepth: 1,
|
|
1853
|
+
required: true
|
|
1854
|
+
});
|
|
1855
|
+
cancelled(picked);
|
|
1856
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const value of picked) {
|
|
1858
|
+
const [sourceName, domain, rel] = value.split(SEP);
|
|
1859
|
+
const key = `${sourceName}${SEP}${domain}`;
|
|
1860
|
+
const set = byDomain.get(key) ?? /* @__PURE__ */ new Set();
|
|
1861
|
+
if (rel) set.add(rel);
|
|
1862
|
+
byDomain.set(key, set);
|
|
1863
|
+
}
|
|
1864
|
+
const result = /* @__PURE__ */ new Map();
|
|
1865
|
+
for (const [key, rels] of byDomain) {
|
|
1866
|
+
const [sourceName, domain] = key.split(SEP);
|
|
1867
|
+
const contents = contentsByKey.get(key);
|
|
1868
|
+
const include = rels.size === 0 ? void 0 : buildInclude(contents, rels);
|
|
1869
|
+
const list = result.get(sourceName) ?? [];
|
|
1870
|
+
list.push(include ? {
|
|
1871
|
+
name: domain,
|
|
1872
|
+
include
|
|
1873
|
+
} : { name: domain });
|
|
1874
|
+
result.set(sourceName, list);
|
|
1875
|
+
}
|
|
1876
|
+
return result;
|
|
1877
|
+
}
|
|
1878
|
+
async function initCommand(cwd, opts) {
|
|
1879
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
1880
|
+
if (await isOptedOut(repoRoot)) if (opts?.force) await removeOptOutMarker(repoRoot);
|
|
1881
|
+
else {
|
|
1882
|
+
p.log.warn(`${OPT_OUT_MARKER} present — Agent Bridge is opted out. Skipping init. Delete the file or run with --force to re-enable.`);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
const hasToolsArg = !!opts?.tools;
|
|
1886
|
+
const hasSourceArg = !!(opts?.source && opts.source.length > 0);
|
|
1887
|
+
if (hasToolsArg !== hasSourceArg) {
|
|
1888
|
+
p.log.error("Both --tools and --source are required for non-interactive init.");
|
|
1889
|
+
process.exit(1);
|
|
1890
|
+
}
|
|
1891
|
+
if (hasToolsArg && hasSourceArg) {
|
|
1892
|
+
const tools = parseToolsArg(opts.tools);
|
|
1893
|
+
const sources = opts.source.map((s) => parseSourceArg(s, repoRoot));
|
|
1894
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1895
|
+
for (const s of sources) {
|
|
1896
|
+
if (seen.has(s.name)) throw new Error(`Duplicate source name "${s.name}" derived from --source arguments`);
|
|
1897
|
+
seen.add(s.name);
|
|
1898
|
+
}
|
|
1899
|
+
await fetchSources(repoRoot, sources);
|
|
1900
|
+
const domainsArg = opts.domains ? opts.domains.split(",").map((d) => d.trim()).filter(Boolean) : void 0;
|
|
1901
|
+
for (const source of sources) {
|
|
1902
|
+
source.domains = (domainsArg ?? await listDomains(resolveSourcePath(repoRoot, source))).map((name) => ({ name }));
|
|
1903
|
+
if (source.domains.length === 0) p.log.warn(`${source.name}: no domains found — add some or pass --domains.`);
|
|
1904
|
+
}
|
|
1905
|
+
await saveConfig(repoRoot, {
|
|
1906
|
+
version: VERSION,
|
|
1907
|
+
tools,
|
|
1908
|
+
sources
|
|
1909
|
+
});
|
|
1910
|
+
p.log.success("Saved .agent-bridge/config.yml");
|
|
1911
|
+
if (opts.hooks && isInGitRepo(repoRoot)) await maybeInstallHooks(repoRoot, opts.force === true);
|
|
1912
|
+
p.outro("Done! Run `agent-bridge sync` to sync features.");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
p.intro("Agent Bridge — Project Setup");
|
|
1916
|
+
if (await configExists(repoRoot)) {
|
|
1917
|
+
const existing = await loadConfig(repoRoot);
|
|
1918
|
+
p.log.info(`Config already exists with ${existing.sources.length} source(s). Finishing this setup will overwrite it.`);
|
|
1919
|
+
}
|
|
1920
|
+
const tools = await promptTools();
|
|
1921
|
+
const sources = await promptSources(repoRoot);
|
|
1922
|
+
await fetchSources(repoRoot, sources);
|
|
1923
|
+
const picked = await promptSelection(repoRoot, sources, tools.map((t) => t.name));
|
|
1924
|
+
for (const source of sources) source.domains = picked.get(source.name) ?? [];
|
|
1925
|
+
const activeSources = sources.filter((s) => (s.domains?.length ?? 0) > 0);
|
|
1926
|
+
for (const s of sources) if (!activeSources.includes(s)) p.log.warn(`${s.name}: no domains selected — source dropped from config.`);
|
|
1927
|
+
await saveConfig(repoRoot, {
|
|
1928
|
+
version: VERSION,
|
|
1929
|
+
tools,
|
|
1930
|
+
sources: activeSources
|
|
1931
|
+
});
|
|
1932
|
+
p.log.success("Saved .agent-bridge/config.yml — commit this file.");
|
|
1933
|
+
if (isInGitRepo(repoRoot)) {
|
|
1934
|
+
const installHooks = await p.confirm({
|
|
1935
|
+
message: "Install git hooks to auto-sync after checkout/merge?",
|
|
1936
|
+
initialValue: false
|
|
1937
|
+
});
|
|
1938
|
+
if (!p.isCancel(installHooks) && installHooks) await maybeInstallHooks(repoRoot, opts?.force === true);
|
|
1939
|
+
}
|
|
1940
|
+
const syncNow = await p.confirm({
|
|
1941
|
+
message: "Run `agent-bridge sync` now?",
|
|
1942
|
+
initialValue: true
|
|
1943
|
+
});
|
|
1944
|
+
if (!p.isCancel(syncNow) && syncNow) {
|
|
1945
|
+
await syncCommand(repoRoot);
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
p.outro("Done! Run `agent-bridge sync` whenever you want to pull the latest features.");
|
|
1565
1949
|
}
|
|
1566
1950
|
//#endregion
|
|
1567
1951
|
//#region src/commands/opt-out.ts
|
|
@@ -1637,9 +2021,12 @@ function collect(value, previous) {
|
|
|
1637
2021
|
return previous;
|
|
1638
2022
|
}
|
|
1639
2023
|
const program = new Command().name("agent-bridge").description("Manage AI tool configurations from multiple sources").version(VERSION, "-v, --version");
|
|
1640
|
-
program.command("init").description("
|
|
1641
|
-
program.command("sync").description("Fetch
|
|
1642
|
-
program.command("update"
|
|
2024
|
+
program.command("init").description("Set up Agent Bridge: pick tools, sources and domains (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: every domain found in each source)").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));
|
|
2025
|
+
program.command("sync").description("Fetch the latest sources and sync features into your tool folders").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(syncCommand));
|
|
2026
|
+
program.command("update", { hidden: true }).option("--cwd <path>", "Override the working directory").action(await withCwdValidation(async (cwd) => {
|
|
2027
|
+
console.error("`agent-bridge update` is deprecated — running `agent-bridge sync` instead (it fetches sources too).");
|
|
2028
|
+
await syncCommand(cwd);
|
|
2029
|
+
}));
|
|
1643
2030
|
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
2031
|
program.parse();
|
|
1645
2032
|
//#endregion
|