@sofatutor/agent-bridge 0.13.1 → 0.14.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 +693 -489
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -2
package/dist/index.mjs
CHANGED
|
@@ -25,12 +25,53 @@ const sourceConfigSchema = z.object({
|
|
|
25
25
|
source: z.string().min(1).refine((v) => !v.startsWith("-"), { message: "Must not start with '-'" }),
|
|
26
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
27
|
});
|
|
28
|
+
/**
|
|
29
|
+
* A path inside a domain that should be synced. One or two segments:
|
|
30
|
+
* `skills` → the whole feature type
|
|
31
|
+
* `skills/deploy` → a single feature (folder or file)
|
|
32
|
+
* `AGENTS.md` → a flat file at the domain root
|
|
33
|
+
*/
|
|
34
|
+
const includePath = z.string().min(1).refine((v) => {
|
|
35
|
+
const segs = v.split("/");
|
|
36
|
+
return segs.length <= 2 && segs.every((seg) => SAFE_NAME_RE.test(seg) && seg !== "." && seg !== "..");
|
|
37
|
+
}, { message: "Must be <feature-type>, <feature-type>/<feature> or <file> using [A-Za-z0-9._-]" });
|
|
38
|
+
const domainObjectSchema = z.object({
|
|
39
|
+
name: safeName,
|
|
40
|
+
include: z.array(includePath).optional()
|
|
41
|
+
});
|
|
42
|
+
/** Domains are written as objects; a bare string (`- shared`) is accepted as shorthand. */
|
|
43
|
+
const domainConfigSchema = z.union([safeName.transform((name) => ({ name })), domainObjectSchema]);
|
|
28
44
|
const bridgeConfigSchema = z.object({
|
|
29
45
|
version: z.string().optional(),
|
|
30
|
-
domains: z.array(safeName).
|
|
46
|
+
domains: z.array(safeName).optional(),
|
|
31
47
|
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")
|
|
48
|
+
sources: z.array(sourceConfigSchema.extend({ domains: z.array(domainConfigSchema).optional() })).min(1, "'sources' must be a non-empty array")
|
|
33
49
|
}).superRefine((data, ctx) => {
|
|
50
|
+
data.sources.forEach((s, i) => {
|
|
51
|
+
const domains = s.domains ?? data.domains;
|
|
52
|
+
if (!domains || domains.length === 0) ctx.addIssue({
|
|
53
|
+
code: z.ZodIssueCode.custom,
|
|
54
|
+
message: `Source '${s.name}' has no domains (set 'sources[].domains' or top-level 'domains')`,
|
|
55
|
+
path: [
|
|
56
|
+
"sources",
|
|
57
|
+
i,
|
|
58
|
+
"domains"
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
const seen = /* @__PURE__ */ new Set();
|
|
62
|
+
for (const d of s.domains ?? []) {
|
|
63
|
+
if (seen.has(d.name)) ctx.addIssue({
|
|
64
|
+
code: z.ZodIssueCode.custom,
|
|
65
|
+
message: `Duplicate domain '${d.name}' in source '${s.name}'`,
|
|
66
|
+
path: [
|
|
67
|
+
"sources",
|
|
68
|
+
i,
|
|
69
|
+
"domains"
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
seen.add(d.name);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
34
75
|
const toolNames = /* @__PURE__ */ new Set();
|
|
35
76
|
const toolFolders = /* @__PURE__ */ new Set();
|
|
36
77
|
data.tools.forEach((t, i) => {
|
|
@@ -88,6 +129,24 @@ const bridgeConfigSchema = z.object({
|
|
|
88
129
|
});
|
|
89
130
|
});
|
|
90
131
|
});
|
|
132
|
+
/**
|
|
133
|
+
* Domains to scan for a source: its own `domains`, falling back to the legacy
|
|
134
|
+
* top-level `domains` list (everything included).
|
|
135
|
+
*/
|
|
136
|
+
function sourceDomains(config, source) {
|
|
137
|
+
if (source.domains) return source.domains;
|
|
138
|
+
return (config.domains ?? []).map((name) => ({ name }));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether `relPath` (relative to the domain root, e.g. `skills`,
|
|
142
|
+
* `skills/deploy`, `AGENTS.md`) is selected by the domain's `include` list.
|
|
143
|
+
* No `include` means everything is selected.
|
|
144
|
+
*/
|
|
145
|
+
function isIncluded(domain, relPath) {
|
|
146
|
+
const inc = domain.include;
|
|
147
|
+
if (!inc) return true;
|
|
148
|
+
return inc.some((entry) => entry === relPath || relPath.startsWith(entry + "/") || entry.startsWith(relPath + "/"));
|
|
149
|
+
}
|
|
91
150
|
const BRIDGE_DIR = ".agent-bridge";
|
|
92
151
|
const CONFIG_FILENAME = "config.yml";
|
|
93
152
|
/**
|
|
@@ -169,7 +228,8 @@ async function saveConfig(repoRoot, config) {
|
|
|
169
228
|
await mkdir(bridgeDir(repoRoot), { recursive: true });
|
|
170
229
|
const content = yaml.dump(config, {
|
|
171
230
|
lineWidth: -1,
|
|
172
|
-
noRefs: true
|
|
231
|
+
noRefs: true,
|
|
232
|
+
skipInvalid: true
|
|
173
233
|
});
|
|
174
234
|
await writeFile(configPath(repoRoot), content, "utf-8");
|
|
175
235
|
}
|
|
@@ -216,15 +276,15 @@ const AGENT_BRIDGE_HOOKS = ["post-checkout", "post-merge"];
|
|
|
216
276
|
const HOOK_MARKER = "# agent-bridge-hook";
|
|
217
277
|
/**
|
|
218
278
|
* Generate the hook script content.
|
|
219
|
-
* Runs
|
|
279
|
+
* Runs sync in the background, logging to `.agent-bridge/hook.log`
|
|
220
280
|
* (trimmed to the last ~200 lines) so failures are diagnosable.
|
|
221
281
|
*/
|
|
222
282
|
function generateHookScript() {
|
|
223
283
|
return `#!/bin/sh
|
|
224
284
|
${HOOK_MARKER}
|
|
225
285
|
# This hook was installed by Agent Bridge.
|
|
226
|
-
# It runs 'agent-bridge
|
|
227
|
-
#
|
|
286
|
+
# It runs 'agent-bridge sync' in the background to keep your AI agent
|
|
287
|
+
# configurations up to date.
|
|
228
288
|
|
|
229
289
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
230
290
|
LOG_DIR="\${REPO_ROOT:-.}/.agent-bridge"
|
|
@@ -239,9 +299,9 @@ mkdir -p "\$LOG_DIR" 2>/dev/null
|
|
|
239
299
|
{
|
|
240
300
|
echo "--- $(date '+%Y-%m-%dT%H:%M:%S%z') agent-bridge hook ---"
|
|
241
301
|
if command -v agent-bridge >/dev/null 2>&1; then
|
|
242
|
-
agent-bridge
|
|
302
|
+
agent-bridge sync
|
|
243
303
|
elif command -v npx >/dev/null 2>&1; then
|
|
244
|
-
npx @sofatutor/agent-bridge
|
|
304
|
+
npx @sofatutor/agent-bridge sync
|
|
245
305
|
else
|
|
246
306
|
echo "agent-bridge not found (install globally or ensure npx is available)"
|
|
247
307
|
fi
|
|
@@ -333,6 +393,23 @@ async function installGitHooks(repoRoot, force = false) {
|
|
|
333
393
|
return result;
|
|
334
394
|
}
|
|
335
395
|
/**
|
|
396
|
+
* Rewrite hooks that Agent Bridge installed earlier with the current script.
|
|
397
|
+
* Hooks we did not install (no marker) and missing hooks are left alone.
|
|
398
|
+
*/
|
|
399
|
+
async function refreshGitHooks(repoRoot) {
|
|
400
|
+
const refreshed = [];
|
|
401
|
+
if (!isInGitRepo(repoRoot)) return refreshed;
|
|
402
|
+
const hooksDir = getGitHooksDir(repoRoot);
|
|
403
|
+
for (const hookName of AGENT_BRIDGE_HOOKS) {
|
|
404
|
+
const hookPath = join(hooksDir, hookName);
|
|
405
|
+
if (!await hasAgentBridgeHook(hookPath)) continue;
|
|
406
|
+
await writeFile(hookPath, generateHookScript(), "utf-8");
|
|
407
|
+
await chmod(hookPath, 493);
|
|
408
|
+
refreshed.push(hookName);
|
|
409
|
+
}
|
|
410
|
+
return refreshed;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
336
413
|
* Remove Agent Bridge git hooks from the repository.
|
|
337
414
|
* Only removes hooks that have the Agent Bridge marker.
|
|
338
415
|
*/
|
|
@@ -641,304 +718,248 @@ async function removeStaleSourceDirs(repoRoot, config) {
|
|
|
641
718
|
return removed;
|
|
642
719
|
}
|
|
643
720
|
//#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/)"
|
|
721
|
+
//#region src/lib/manifest.ts
|
|
722
|
+
const TOOL_PREFIX_SEPARATOR = "--";
|
|
723
|
+
function parseToolPrefix(name) {
|
|
724
|
+
const idx = name.indexOf(TOOL_PREFIX_SEPARATOR);
|
|
725
|
+
if (idx > 0) return {
|
|
726
|
+
toolPrefix: name.substring(0, idx),
|
|
727
|
+
baseName: name.substring(idx + 2)
|
|
728
|
+
};
|
|
729
|
+
return { baseName: name };
|
|
730
|
+
}
|
|
731
|
+
function featureMatchesTool(feature, toolName) {
|
|
732
|
+
if (!feature.toolPrefix) return true;
|
|
733
|
+
return feature.toolPrefix === toolName;
|
|
734
|
+
}
|
|
735
|
+
function featureName(feature) {
|
|
736
|
+
if (feature.toolPrefix) return parseToolPrefix(feature.name).baseName;
|
|
737
|
+
return feature.name;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Discover all feature types across all sources and domains.
|
|
741
|
+
*/
|
|
742
|
+
async function discoverFeatureTypes(repoRoot, config) {
|
|
743
|
+
const types = /* @__PURE__ */ new Set();
|
|
744
|
+
for (const source of config.sources) {
|
|
745
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
746
|
+
for (const domain of sourceDomains(config, source)) {
|
|
747
|
+
const domainDir = join(srcPath, domain.name);
|
|
748
|
+
if (!await dirExists(domainDir)) continue;
|
|
749
|
+
const entries = await readdir(domainDir, { withFileTypes: true });
|
|
750
|
+
for (const entry of entries) if (entry.isDirectory() && isIncluded(domain, entry.name)) types.add(entry.name);
|
|
751
|
+
}
|
|
676
752
|
}
|
|
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
|
-
];
|
|
753
|
+
return [...types].sort();
|
|
754
|
+
}
|
|
688
755
|
/**
|
|
689
|
-
*
|
|
756
|
+
* Scan all features across sources × domains × feature types.
|
|
690
757
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
* git@github.com:org/repo.git → repo
|
|
694
|
-
* file:///tmp/bare.git → bare
|
|
695
|
-
* /path/to/my-folder → my-folder
|
|
758
|
+
* Structure: `<source-path>/<domain>/<feature-type>/<feature>/` (folder-based)
|
|
759
|
+
* or `<source-path>/<domain>/<feature-type>/<feature.ext>` (file-based)
|
|
696
760
|
*/
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
761
|
+
async function scanFeatures(repoRoot, config, featureTypes) {
|
|
762
|
+
const features = [];
|
|
763
|
+
for (const source of config.sources) {
|
|
764
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
765
|
+
for (const domain of sourceDomains(config, source)) for (const ft of featureTypes) {
|
|
766
|
+
if (!isIncluded(domain, ft)) continue;
|
|
767
|
+
const { toolPrefix: typeToolPrefix, baseName: baseType } = parseToolPrefix(ft);
|
|
768
|
+
const ftDir = join(srcPath, domain.name, ft);
|
|
769
|
+
if (!await dirExists(ftDir)) continue;
|
|
770
|
+
const entries = await readdir(ftDir, { withFileTypes: true });
|
|
771
|
+
for (const entry of entries) {
|
|
772
|
+
const isFile = entry.isFile();
|
|
773
|
+
const isDir = entry.isDirectory();
|
|
774
|
+
if (!isFile && !isDir) continue;
|
|
775
|
+
if (!isIncluded(domain, `${ft}/${entry.name}`)) continue;
|
|
776
|
+
const { toolPrefix: itemToolPrefix } = parseToolPrefix(entry.name);
|
|
777
|
+
const toolPrefix = itemToolPrefix ?? typeToolPrefix;
|
|
778
|
+
features.push({
|
|
779
|
+
name: entry.name,
|
|
780
|
+
type: ft,
|
|
781
|
+
displayType: baseType,
|
|
782
|
+
source: source.name,
|
|
783
|
+
domain: domain.name,
|
|
784
|
+
absolutePath: join(ftDir, entry.name),
|
|
785
|
+
toolPrefix,
|
|
786
|
+
isFile
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return features;
|
|
792
|
+
}
|
|
793
|
+
/** Top-level directories of a source that can act as domains. */
|
|
794
|
+
async function listDomains(srcPath) {
|
|
795
|
+
if (!await dirExists(srcPath)) return [];
|
|
796
|
+
return (await readdir(srcPath, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name).sort();
|
|
705
797
|
}
|
|
706
798
|
/**
|
|
707
|
-
*
|
|
708
|
-
*
|
|
799
|
+
* List what `sync` would consider inside a domain, so the user can pick a
|
|
800
|
+
* subset. `toolNames` filters `<tool>--file` entries to configured tools.
|
|
709
801
|
*/
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
802
|
+
async function listDomainContents(srcPath, domain, toolNames) {
|
|
803
|
+
const domainDir = join(srcPath, domain);
|
|
804
|
+
const tools = new Set(toolNames);
|
|
805
|
+
const result = {
|
|
806
|
+
featureTypes: [],
|
|
807
|
+
files: []
|
|
808
|
+
};
|
|
809
|
+
if (!await dirExists(domainDir)) return result;
|
|
810
|
+
const entries = await readdir(domainDir, { withFileTypes: true });
|
|
811
|
+
for (const entry of entries) {
|
|
812
|
+
if (entry.name.startsWith(".")) continue;
|
|
813
|
+
if (entry.isDirectory()) {
|
|
814
|
+
const features = (await readdir(join(domainDir, entry.name), { withFileTypes: true })).filter((f) => (f.isFile() || f.isDirectory()) && !f.name.startsWith(".")).map((f) => f.name).sort();
|
|
815
|
+
result.featureTypes.push({
|
|
816
|
+
name: entry.name,
|
|
817
|
+
features
|
|
818
|
+
});
|
|
819
|
+
} else if (entry.isFile()) {
|
|
820
|
+
const { toolPrefix } = parseToolPrefix(entry.name);
|
|
821
|
+
if (ROOT_FILES.includes(entry.name) || toolPrefix && tools.has(toolPrefix)) result.files.push(entry.name);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
result.featureTypes.sort((a, b) => a.name.localeCompare(b.name));
|
|
825
|
+
result.files.sort();
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
function detectDuplicates(features) {
|
|
829
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
830
|
+
for (const f of features) {
|
|
831
|
+
const linkName = featureName(f);
|
|
832
|
+
const key = `${f.displayType}/${linkName}`;
|
|
833
|
+
const group = byKey.get(key) ?? [];
|
|
834
|
+
group.push(f);
|
|
835
|
+
byKey.set(key, group);
|
|
836
|
+
}
|
|
837
|
+
const conflicts = [];
|
|
838
|
+
for (const [, group] of byKey) if (group.length > 1) conflicts.push({
|
|
839
|
+
name: featureName(group[0]),
|
|
840
|
+
type: group[0].type,
|
|
841
|
+
paths: group.map((f) => f.absolutePath)
|
|
721
842
|
});
|
|
843
|
+
return conflicts;
|
|
722
844
|
}
|
|
723
845
|
/**
|
|
724
|
-
*
|
|
725
|
-
*
|
|
846
|
+
* Well-known root files that live at the domain root and should be synced to the
|
|
847
|
+
* workspace root. When a source contains `<domain>/AGENTS.md` (etc.), Agent Bridge
|
|
848
|
+
* copies it to the project root.
|
|
726
849
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
850
|
+
const ROOT_FILES = [
|
|
851
|
+
"AGENTS.md",
|
|
852
|
+
"CLAUDE.md",
|
|
853
|
+
"SYSTEM.md"
|
|
854
|
+
];
|
|
855
|
+
/**
|
|
856
|
+
* Scan all sources × domains for well-known root files.
|
|
857
|
+
* Returns one entry per found file.
|
|
858
|
+
*/
|
|
859
|
+
async function scanRootFiles(repoRoot, config) {
|
|
860
|
+
const found = [];
|
|
861
|
+
for (const source of config.sources) {
|
|
862
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
863
|
+
for (const domain of sourceDomains(config, source)) for (const fileName of ROOT_FILES) {
|
|
864
|
+
if (!isIncluded(domain, fileName)) continue;
|
|
865
|
+
const filePath = join(srcPath, domain.name, fileName);
|
|
866
|
+
if (await fileExists(filePath)) found.push({
|
|
867
|
+
fileName,
|
|
868
|
+
source: source.name,
|
|
869
|
+
domain: domain.name,
|
|
870
|
+
absolutePath: filePath
|
|
871
|
+
});
|
|
872
|
+
}
|
|
734
873
|
}
|
|
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;
|
|
874
|
+
return found;
|
|
743
875
|
}
|
|
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;
|
|
876
|
+
/**
|
|
877
|
+
* Detect duplicate root files (same filename provided by multiple sources/domains).
|
|
878
|
+
*/
|
|
879
|
+
function detectRootFileDuplicates(rootFiles) {
|
|
880
|
+
const byName = /* @__PURE__ */ new Map();
|
|
881
|
+
for (const rf of rootFiles) {
|
|
882
|
+
const group = byName.get(rf.fileName) ?? [];
|
|
883
|
+
group.push(rf);
|
|
884
|
+
byName.set(rf.fileName, group);
|
|
790
885
|
}
|
|
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
|
|
886
|
+
const duplicates = [];
|
|
887
|
+
for (const [fileName, group] of byName) if (group.length > 1) duplicates.push({
|
|
888
|
+
fileName,
|
|
889
|
+
paths: group.map((rf) => rf.absolutePath)
|
|
816
890
|
});
|
|
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
|
-
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);
|
|
891
|
+
return duplicates;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Scan all sources × domains for tool-prefixed flat files at the domain level.
|
|
895
|
+
* A file named `cursor--settings.json` targets the tool "cursor" with
|
|
896
|
+
* destination filename "settings.json".
|
|
897
|
+
*/
|
|
898
|
+
async function scanToolRootEntries(repoRoot, config) {
|
|
899
|
+
const entries = [];
|
|
900
|
+
const toolNames = new Set(config.tools.map((t) => t.name));
|
|
901
|
+
for (const source of config.sources) {
|
|
902
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
903
|
+
for (const domain of sourceDomains(config, source)) {
|
|
904
|
+
const domainDir = join(srcPath, domain.name);
|
|
905
|
+
if (!await dirExists(domainDir)) continue;
|
|
906
|
+
const domainEntries = await readdir(domainDir, { withFileTypes: true });
|
|
907
|
+
for (const entry of domainEntries) {
|
|
908
|
+
if (!entry.isFile() || !isIncluded(domain, entry.name)) continue;
|
|
909
|
+
const { toolPrefix, baseName } = parseToolPrefix(entry.name);
|
|
910
|
+
if (!toolPrefix || !toolNames.has(toolPrefix)) continue;
|
|
911
|
+
entries.push({
|
|
912
|
+
toolName: toolPrefix,
|
|
913
|
+
name: baseName,
|
|
914
|
+
source: source.name,
|
|
915
|
+
domain: domain.name,
|
|
916
|
+
absolutePath: join(domainDir, entry.name)
|
|
917
|
+
});
|
|
897
918
|
}
|
|
898
|
-
break;
|
|
899
919
|
}
|
|
900
|
-
const addMore = await p.confirm({
|
|
901
|
-
message: "Add another source?",
|
|
902
|
-
initialValue: false
|
|
903
|
-
});
|
|
904
|
-
if (p.isCancel(addMore) || !addMore) addingSource = false;
|
|
905
920
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
}
|
|
921
|
+
return entries;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Detect duplicate tool root entries (same tool + name from multiple sources/domains).
|
|
925
|
+
*/
|
|
926
|
+
function detectToolRootDuplicates(entries) {
|
|
927
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
928
|
+
for (const entry of entries) {
|
|
929
|
+
const key = `${entry.toolName}/${entry.name}`;
|
|
930
|
+
const group = byKey.get(key) ?? [];
|
|
931
|
+
group.push(entry);
|
|
932
|
+
byKey.set(key, group);
|
|
936
933
|
}
|
|
937
|
-
|
|
934
|
+
const duplicates = [];
|
|
935
|
+
for (const [, group] of byKey) if (group.length > 1) duplicates.push({
|
|
936
|
+
toolName: group[0].toolName,
|
|
937
|
+
name: group[0].name,
|
|
938
|
+
paths: group.map((e) => e.absolutePath)
|
|
939
|
+
});
|
|
940
|
+
return duplicates;
|
|
938
941
|
}
|
|
939
942
|
//#endregion
|
|
943
|
+
//#region src/lib/version.ts
|
|
944
|
+
const VERSION = "0.14.0";
|
|
945
|
+
//#endregion
|
|
940
946
|
//#region src/lib/migrations/index.ts
|
|
941
|
-
const migrations = [
|
|
947
|
+
const migrations = [{
|
|
948
|
+
version: "0.14.0",
|
|
949
|
+
description: "move top-level domains into each source; git hooks run `sync` only",
|
|
950
|
+
migrate: async (repoRoot, config) => {
|
|
951
|
+
const { domains, ...rest } = config;
|
|
952
|
+
const sources = config.sources.map((s) => s.domains ? s : {
|
|
953
|
+
...s,
|
|
954
|
+
domains: (domains ?? []).map((name) => ({ name }))
|
|
955
|
+
});
|
|
956
|
+
await refreshGitHooks(repoRoot);
|
|
957
|
+
return {
|
|
958
|
+
...rest,
|
|
959
|
+
sources
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}];
|
|
942
963
|
/** Parse "1.2.3" or "1.2.3-beta.1" into [major, minor, patch]. */
|
|
943
964
|
function parseSemver(version) {
|
|
944
965
|
const parts = version.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
@@ -994,190 +1015,6 @@ async function runMigrations(repoRoot) {
|
|
|
994
1015
|
};
|
|
995
1016
|
}
|
|
996
1017
|
//#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
1018
|
//#region src/lib/sync.ts
|
|
1182
1019
|
/**
|
|
1183
1020
|
* Compute the destination path for a feature inside a tool's folder.
|
|
@@ -1458,11 +1295,11 @@ async function syncCommand(cwd, _opts) {
|
|
|
1458
1295
|
}
|
|
1459
1296
|
const s = p.spinner();
|
|
1460
1297
|
s.start("Loading configuration…");
|
|
1461
|
-
const config = await loadConfig(repoRoot);
|
|
1462
1298
|
const migrationResult = await runMigrations(repoRoot);
|
|
1463
1299
|
if (migrationResult) p.log.info(`Config upgraded ${migrationResult.fromVersion} → ${migrationResult.toVersion}` + (migrationResult.applied.length > 0 ? ` (${migrationResult.applied.length} migration(s))` : ""));
|
|
1300
|
+
const config = await loadConfig(repoRoot);
|
|
1464
1301
|
s.stop("Configuration valid");
|
|
1465
|
-
s.start("
|
|
1302
|
+
s.start("Fetching sources…");
|
|
1466
1303
|
const sourceResults = await syncAllSources(repoRoot, config);
|
|
1467
1304
|
const sourceErrors = sourceResults.filter((r) => r.error);
|
|
1468
1305
|
if (sourceErrors.length > 0) {
|
|
@@ -1473,7 +1310,7 @@ async function syncCommand(cwd, _opts) {
|
|
|
1473
1310
|
const staleRemoved = await removeStaleSourceDirs(repoRoot, config);
|
|
1474
1311
|
if (staleRemoved.length > 0) for (const name of staleRemoved) p.log.info(`Removed stale source: ${name}`);
|
|
1475
1312
|
for (const r of sourceResults) if (r.action !== "local") p.log.info(`${r.name}: ${r.action}`);
|
|
1476
|
-
s.stop("Sources
|
|
1313
|
+
s.stop("Sources up to date");
|
|
1477
1314
|
s.start("Discovering features…");
|
|
1478
1315
|
const features = await scanFeatures(repoRoot, config, await discoverFeatureTypes(repoRoot, config));
|
|
1479
1316
|
const rootFiles = await scanRootFiles(repoRoot, config);
|
|
@@ -1546,22 +1383,386 @@ async function syncCommand(cwd, _opts) {
|
|
|
1546
1383
|
p.outro("Sync complete.");
|
|
1547
1384
|
}
|
|
1548
1385
|
//#endregion
|
|
1549
|
-
//#region src/commands/
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1386
|
+
//#region src/commands/init.ts
|
|
1387
|
+
const WELL_KNOWN_TOOLS = [
|
|
1388
|
+
{
|
|
1389
|
+
value: {
|
|
1390
|
+
name: "vscode",
|
|
1391
|
+
folder: ".github"
|
|
1392
|
+
},
|
|
1393
|
+
label: "VS Code (.github/)"
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
value: {
|
|
1397
|
+
name: "cursor",
|
|
1398
|
+
folder: ".cursor"
|
|
1399
|
+
},
|
|
1400
|
+
label: "Cursor (.cursor/)"
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
value: {
|
|
1404
|
+
name: "claude",
|
|
1405
|
+
folder: ".claude"
|
|
1406
|
+
},
|
|
1407
|
+
label: "Claude (.claude/)"
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
value: {
|
|
1411
|
+
name: "pi",
|
|
1412
|
+
folder: ".pi"
|
|
1413
|
+
},
|
|
1414
|
+
label: "Pi (.pi/)"
|
|
1415
|
+
}
|
|
1416
|
+
];
|
|
1417
|
+
const WELL_KNOWN_TOOL_MAP = Object.fromEntries(WELL_KNOWN_TOOLS.map((t) => [t.value.name, t.value]));
|
|
1418
|
+
const CUSTOM_TOOL_SENTINEL = {
|
|
1419
|
+
name: "__custom__",
|
|
1420
|
+
folder: "__custom__"
|
|
1421
|
+
};
|
|
1422
|
+
/**
|
|
1423
|
+
* Derive a short source name from a URL or local path.
|
|
1424
|
+
*
|
|
1425
|
+
* Examples:
|
|
1426
|
+
* https://github.com/org/repo.git → repo
|
|
1427
|
+
* git@github.com:org/repo.git → repo
|
|
1428
|
+
* file:///tmp/bare.git → bare
|
|
1429
|
+
* /path/to/my-folder → my-folder
|
|
1430
|
+
*/
|
|
1431
|
+
function deriveSourceName(source) {
|
|
1432
|
+
let segment = source;
|
|
1433
|
+
const sshMatch = segment.match(/^[\w.-]+@[\w.-]+:(.+)$/);
|
|
1434
|
+
if (sshMatch) segment = sshMatch[1];
|
|
1435
|
+
try {
|
|
1436
|
+
segment = new URL(segment).pathname;
|
|
1437
|
+
} catch {}
|
|
1438
|
+
return (segment.replace(/\/+$/, "").split("/").pop() ?? segment).replace(/\.git$/, "") || "source";
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Parse a comma-separated `--tools` argument into ToolConfig[].
|
|
1442
|
+
* Accepts well-known names (cursor, vscode, claude) or `name:folder` pairs.
|
|
1443
|
+
*/
|
|
1444
|
+
function parseToolsArg(input) {
|
|
1445
|
+
return input.split(",").map((t) => {
|
|
1446
|
+
const trimmed = t.trim();
|
|
1447
|
+
if (!trimmed) throw new Error("Empty tool name in --tools");
|
|
1448
|
+
if (WELL_KNOWN_TOOL_MAP[trimmed]) return WELL_KNOWN_TOOL_MAP[trimmed];
|
|
1449
|
+
const colonIdx = trimmed.indexOf(":");
|
|
1450
|
+
if (colonIdx > 0) return {
|
|
1451
|
+
name: trimmed.slice(0, colonIdx),
|
|
1452
|
+
folder: trimmed.slice(colonIdx + 1)
|
|
1453
|
+
};
|
|
1454
|
+
throw new Error(`Unknown tool "${trimmed}". Use a known name (${Object.keys(WELL_KNOWN_TOOL_MAP).join(", ")}) or name:folder format.`);
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Parse a single `--source` argument into a SourceConfig.
|
|
1459
|
+
* Supports `#branch` suffix for remote sources.
|
|
1460
|
+
*/
|
|
1461
|
+
function parseSourceArg(input, repoRoot) {
|
|
1462
|
+
let source = input.trim();
|
|
1463
|
+
let branch;
|
|
1464
|
+
const hashIdx = source.lastIndexOf("#");
|
|
1465
|
+
if (hashIdx > 0) {
|
|
1466
|
+
branch = source.slice(hashIdx + 1);
|
|
1467
|
+
source = source.slice(0, hashIdx);
|
|
1468
|
+
}
|
|
1469
|
+
if (!source) throw new Error("Empty source in --source");
|
|
1470
|
+
const entry = {
|
|
1471
|
+
name: deriveSourceName(source),
|
|
1472
|
+
source
|
|
1473
|
+
};
|
|
1474
|
+
if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
|
|
1475
|
+
if (branch) entry.branch = branch;
|
|
1476
|
+
return entry;
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Turn a per-domain selection into the `include` list stored in config.
|
|
1480
|
+
* Returns `undefined` when everything is selected (= sync the whole domain).
|
|
1481
|
+
* A fully selected feature type collapses to its name (`skills`).
|
|
1482
|
+
*/
|
|
1483
|
+
function buildInclude(contents, selected) {
|
|
1484
|
+
const include = [];
|
|
1485
|
+
let everything = true;
|
|
1486
|
+
for (const ft of contents.featureTypes) {
|
|
1487
|
+
const picked = ft.features.filter((f) => selected.has(`${ft.name}/${f}`));
|
|
1488
|
+
if (picked.length === ft.features.length) include.push(ft.name);
|
|
1489
|
+
else {
|
|
1490
|
+
everything = false;
|
|
1491
|
+
include.push(...picked.map((f) => `${ft.name}/${f}`));
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
for (const file of contents.files) if (selected.has(file)) include.push(file);
|
|
1495
|
+
else everything = false;
|
|
1496
|
+
return everything ? void 0 : include;
|
|
1497
|
+
}
|
|
1498
|
+
function cancelled(value) {
|
|
1499
|
+
if (p.isCancel(value)) {
|
|
1500
|
+
p.cancel("Setup cancelled.");
|
|
1501
|
+
process.exit(1);
|
|
1502
|
+
}
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
/** Clone remote sources / verify local ones. Exits on failure. */
|
|
1506
|
+
async function fetchSources(repoRoot, sources) {
|
|
1507
|
+
await ensureBridgeGitignore(repoRoot);
|
|
1556
1508
|
const s = p.spinner();
|
|
1557
|
-
s.start("
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1509
|
+
s.start("Fetching sources…");
|
|
1510
|
+
const errors = (await Promise.all(sources.map((src) => syncSource(repoRoot, src)))).filter((r) => r.error);
|
|
1511
|
+
if (errors.length > 0) {
|
|
1512
|
+
s.stop("Some sources failed");
|
|
1513
|
+
for (const err of errors) p.log.error(`${err.name}: ${err.error}`);
|
|
1514
|
+
p.cancel("Fix the source URL/path and run `agent-bridge init` again.");
|
|
1515
|
+
process.exit(1);
|
|
1516
|
+
}
|
|
1517
|
+
s.stop(`${sources.length} source(s) ready`);
|
|
1518
|
+
}
|
|
1519
|
+
async function maybeInstallHooks(repoRoot, force) {
|
|
1520
|
+
const hookResult = await installGitHooks(repoRoot, force);
|
|
1521
|
+
if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
|
|
1522
|
+
if (hookResult.skipped.length > 0) {
|
|
1523
|
+
p.log.warn(`Skipped hooks (existing non-Agent-Bridge hooks): ${hookResult.skipped.join(", ")}`);
|
|
1524
|
+
p.log.info("Re-run `agent-bridge init --force` to overwrite, or integrate manually.");
|
|
1525
|
+
}
|
|
1526
|
+
for (const e of hookResult.errors) p.log.error(`Hook ${e.hook}: ${e.error}`);
|
|
1527
|
+
}
|
|
1528
|
+
async function promptTools() {
|
|
1529
|
+
const selected = await p.multiselect({
|
|
1530
|
+
message: "Which tools should receive synced files?",
|
|
1531
|
+
options: [...WELL_KNOWN_TOOLS, {
|
|
1532
|
+
value: CUSTOM_TOOL_SENTINEL,
|
|
1533
|
+
label: "Other (add custom tool)"
|
|
1534
|
+
}],
|
|
1535
|
+
required: true
|
|
1536
|
+
});
|
|
1537
|
+
cancelled(selected);
|
|
1538
|
+
const tools = selected.filter((t) => t.name !== CUSTOM_TOOL_SENTINEL.name);
|
|
1539
|
+
if (!selected.some((t) => t.name === CUSTOM_TOOL_SENTINEL.name)) return tools;
|
|
1540
|
+
for (;;) {
|
|
1541
|
+
const name = await p.text({
|
|
1542
|
+
message: "Custom tool name (used for <tool>-- prefix matching)",
|
|
1543
|
+
placeholder: "windsurf",
|
|
1544
|
+
validate: (v) => {
|
|
1545
|
+
if (!v.trim()) return "Tool name cannot be empty";
|
|
1546
|
+
if (tools.some((t) => t.name === v.trim())) return "Tool name already used";
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
if (p.isCancel(name)) break;
|
|
1550
|
+
const folder = await p.text({
|
|
1551
|
+
message: `Target folder for "${name}"`,
|
|
1552
|
+
placeholder: `.${name}`,
|
|
1553
|
+
validate: (v) => {
|
|
1554
|
+
if (!v.trim()) return "Folder cannot be empty";
|
|
1555
|
+
if (tools.some((t) => t.folder === v.trim())) return "Folder already used by another tool";
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
if (p.isCancel(folder)) break;
|
|
1559
|
+
tools.push({
|
|
1560
|
+
name: name.trim(),
|
|
1561
|
+
folder: folder.trim()
|
|
1562
|
+
});
|
|
1563
|
+
const more = await p.confirm({
|
|
1564
|
+
message: "Add another custom tool?",
|
|
1565
|
+
initialValue: false
|
|
1566
|
+
});
|
|
1567
|
+
if (p.isCancel(more) || !more) break;
|
|
1568
|
+
}
|
|
1569
|
+
if (tools.length === 0) {
|
|
1570
|
+
p.cancel("At least one tool is required.");
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
return tools;
|
|
1574
|
+
}
|
|
1575
|
+
async function promptSources(repoRoot) {
|
|
1576
|
+
const sources = [];
|
|
1577
|
+
p.log.info("Add at least one source — a Git URL or a local folder that follows the domain layout.");
|
|
1578
|
+
for (;;) {
|
|
1579
|
+
const input = await p.text({
|
|
1580
|
+
message: sources.length === 0 ? "Source URL or local path" : "Another source URL or local path",
|
|
1581
|
+
placeholder: "https://github.com/org/ai-hub.git",
|
|
1582
|
+
validate: (v) => {
|
|
1583
|
+
if (!v.trim()) return "Source URL/path cannot be empty";
|
|
1584
|
+
const derived = deriveSourceName(v.trim());
|
|
1585
|
+
if (sources.some((s) => s.name === derived)) return `Source name "${derived}" (derived from URL) already used`;
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
if (p.isCancel(input)) {
|
|
1589
|
+
if (sources.length === 0) cancelled(input);
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
const entry = parseSourceArg(input, repoRoot);
|
|
1593
|
+
if (isRemoteSource(entry.source) && !entry.branch) {
|
|
1594
|
+
const branch = await p.text({
|
|
1595
|
+
message: "Branch (leave empty for the remote default)",
|
|
1596
|
+
placeholder: "main",
|
|
1597
|
+
defaultValue: ""
|
|
1598
|
+
});
|
|
1599
|
+
cancelled(branch);
|
|
1600
|
+
if (branch.trim()) entry.branch = branch.trim();
|
|
1601
|
+
}
|
|
1602
|
+
sources.push(entry);
|
|
1603
|
+
const more = await p.confirm({
|
|
1604
|
+
message: "Add another source?",
|
|
1605
|
+
initialValue: false
|
|
1606
|
+
});
|
|
1607
|
+
if (p.isCancel(more) || !more) break;
|
|
1608
|
+
}
|
|
1609
|
+
return sources;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Show every domain found in every source as one grouped checklist
|
|
1613
|
+
* (group = source). Returns the picked domain names per source.
|
|
1614
|
+
*/
|
|
1615
|
+
async function promptDomains(repoRoot, sources) {
|
|
1616
|
+
const options = {};
|
|
1617
|
+
for (const source of sources) {
|
|
1618
|
+
const srcPath = resolveSourcePath(repoRoot, source);
|
|
1619
|
+
const domains = await listDomains(srcPath);
|
|
1620
|
+
if (domains.length === 0) {
|
|
1621
|
+
p.log.warn(`${source.name}: no domain folders found — nothing to select.`);
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
options[source.name] = [];
|
|
1625
|
+
for (const domain of domains) {
|
|
1626
|
+
const hint = (await listDomainContents(srcPath, domain, [])).featureTypes.filter((ft) => ft.features.length > 0).map((ft) => `${ft.features.length} ${ft.name}`).join(", ");
|
|
1627
|
+
options[source.name].push({
|
|
1628
|
+
value: `${source.name}/${domain}`,
|
|
1629
|
+
label: domain,
|
|
1630
|
+
hint: hint || void 0
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (Object.keys(options).length === 0) {
|
|
1635
|
+
p.cancel("No domains found in any source. Check the source layout: <source>/<domain>/<feature-type>/…");
|
|
1636
|
+
process.exit(1);
|
|
1637
|
+
}
|
|
1638
|
+
const picked = await p.groupMultiselect({
|
|
1639
|
+
message: "Which domains do you want to sync? (space = toggle, pick a source to toggle all its domains)",
|
|
1640
|
+
options,
|
|
1641
|
+
required: true
|
|
1642
|
+
});
|
|
1643
|
+
cancelled(picked);
|
|
1644
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
1645
|
+
for (const value of picked) {
|
|
1646
|
+
const idx = value.indexOf("/");
|
|
1647
|
+
const sourceName = value.slice(0, idx);
|
|
1648
|
+
const domain = value.slice(idx + 1);
|
|
1649
|
+
bySource.set(sourceName, [...bySource.get(sourceName) ?? [], domain]);
|
|
1650
|
+
}
|
|
1651
|
+
return bySource;
|
|
1652
|
+
}
|
|
1653
|
+
/** Let the user deselect individual features / files inside one domain. */
|
|
1654
|
+
async function promptInclude(srcPath, sourceName, domain, toolNames) {
|
|
1655
|
+
const contents = await listDomainContents(srcPath, domain, toolNames);
|
|
1656
|
+
const options = {};
|
|
1657
|
+
for (const ft of contents.featureTypes) {
|
|
1658
|
+
if (ft.features.length === 0) continue;
|
|
1659
|
+
options[ft.name] = ft.features.map((f) => ({
|
|
1660
|
+
value: `${ft.name}/${f}`,
|
|
1661
|
+
label: f
|
|
1662
|
+
}));
|
|
1663
|
+
}
|
|
1664
|
+
if (contents.files.length > 0) options["files"] = contents.files.map((f) => ({
|
|
1665
|
+
value: f,
|
|
1666
|
+
label: f
|
|
1667
|
+
}));
|
|
1668
|
+
if (Object.keys(options).length === 0) return void 0;
|
|
1669
|
+
const all = Object.values(options).flatMap((o) => o.map((x) => x.value));
|
|
1670
|
+
const picked = await p.groupMultiselect({
|
|
1671
|
+
message: `${sourceName}/${domain}: deselect what you don't want`,
|
|
1672
|
+
options,
|
|
1673
|
+
initialValues: all,
|
|
1674
|
+
required: true
|
|
1675
|
+
});
|
|
1676
|
+
cancelled(picked);
|
|
1677
|
+
return buildInclude(contents, new Set(picked));
|
|
1678
|
+
}
|
|
1679
|
+
async function initCommand(cwd, opts) {
|
|
1680
|
+
const repoRoot = cwd ?? findRepoRoot();
|
|
1681
|
+
if (await isOptedOut(repoRoot)) if (opts?.force) await removeOptOutMarker(repoRoot);
|
|
1682
|
+
else {
|
|
1683
|
+
p.log.warn(`${OPT_OUT_MARKER} present — Agent Bridge is opted out. Skipping init. Delete the file or run with --force to re-enable.`);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const hasToolsArg = !!opts?.tools;
|
|
1687
|
+
const hasSourceArg = !!(opts?.source && opts.source.length > 0);
|
|
1688
|
+
if (hasToolsArg !== hasSourceArg) {
|
|
1689
|
+
p.log.error("Both --tools and --source are required for non-interactive init.");
|
|
1690
|
+
process.exit(1);
|
|
1691
|
+
}
|
|
1692
|
+
if (hasToolsArg && hasSourceArg) {
|
|
1693
|
+
const tools = parseToolsArg(opts.tools);
|
|
1694
|
+
const sources = opts.source.map((s) => parseSourceArg(s, repoRoot));
|
|
1695
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1696
|
+
for (const s of sources) {
|
|
1697
|
+
if (seen.has(s.name)) throw new Error(`Duplicate source name "${s.name}" derived from --source arguments`);
|
|
1698
|
+
seen.add(s.name);
|
|
1699
|
+
}
|
|
1700
|
+
await fetchSources(repoRoot, sources);
|
|
1701
|
+
const domainsArg = opts.domains ? opts.domains.split(",").map((d) => d.trim()).filter(Boolean) : void 0;
|
|
1702
|
+
for (const source of sources) {
|
|
1703
|
+
source.domains = (domainsArg ?? await listDomains(resolveSourcePath(repoRoot, source))).map((name) => ({ name }));
|
|
1704
|
+
if (source.domains.length === 0) p.log.warn(`${source.name}: no domains found — add some or pass --domains.`);
|
|
1705
|
+
}
|
|
1706
|
+
await saveConfig(repoRoot, {
|
|
1707
|
+
version: VERSION,
|
|
1708
|
+
tools,
|
|
1709
|
+
sources
|
|
1710
|
+
});
|
|
1711
|
+
p.log.success("Saved .agent-bridge/config.yml");
|
|
1712
|
+
if (opts.hooks && isInGitRepo(repoRoot)) await maybeInstallHooks(repoRoot, opts.force === true);
|
|
1713
|
+
p.outro("Done! Run `agent-bridge sync` to sync features.");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
p.intro("Agent Bridge — Project Setup");
|
|
1717
|
+
if (await configExists(repoRoot)) {
|
|
1718
|
+
const existing = await loadConfig(repoRoot);
|
|
1719
|
+
p.log.info(`Config already exists with ${existing.sources.length} source(s). Finishing this setup will overwrite it.`);
|
|
1720
|
+
}
|
|
1721
|
+
const tools = await promptTools();
|
|
1722
|
+
const sources = await promptSources(repoRoot);
|
|
1723
|
+
await fetchSources(repoRoot, sources);
|
|
1724
|
+
const pickedDomains = await promptDomains(repoRoot, sources);
|
|
1725
|
+
const everything = await p.confirm({
|
|
1726
|
+
message: "Sync everything inside the selected domains? (No = pick individual skills, agents, files…)",
|
|
1727
|
+
initialValue: true
|
|
1728
|
+
});
|
|
1729
|
+
cancelled(everything);
|
|
1730
|
+
const toolNames = tools.map((t) => t.name);
|
|
1731
|
+
for (const source of sources) {
|
|
1732
|
+
const domains = pickedDomains.get(source.name) ?? [];
|
|
1733
|
+
source.domains = [];
|
|
1734
|
+
for (const domain of domains) {
|
|
1735
|
+
const include = everything ? void 0 : await promptInclude(resolveSourcePath(repoRoot, source), source.name, domain, toolNames);
|
|
1736
|
+
source.domains.push(include ? {
|
|
1737
|
+
name: domain,
|
|
1738
|
+
include
|
|
1739
|
+
} : { name: domain });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
const activeSources = sources.filter((s) => (s.domains?.length ?? 0) > 0);
|
|
1743
|
+
for (const s of sources) if (!activeSources.includes(s)) p.log.warn(`${s.name}: no domains selected — source dropped from config.`);
|
|
1744
|
+
await saveConfig(repoRoot, {
|
|
1745
|
+
version: VERSION,
|
|
1746
|
+
tools,
|
|
1747
|
+
sources: activeSources
|
|
1748
|
+
});
|
|
1749
|
+
p.log.success("Saved .agent-bridge/config.yml — commit this file.");
|
|
1750
|
+
if (isInGitRepo(repoRoot)) {
|
|
1751
|
+
const installHooks = await p.confirm({
|
|
1752
|
+
message: "Install git hooks to auto-sync after checkout/merge?",
|
|
1753
|
+
initialValue: false
|
|
1754
|
+
});
|
|
1755
|
+
if (!p.isCancel(installHooks) && installHooks) await maybeInstallHooks(repoRoot, opts?.force === true);
|
|
1756
|
+
}
|
|
1757
|
+
const syncNow = await p.confirm({
|
|
1758
|
+
message: "Run `agent-bridge sync` now?",
|
|
1759
|
+
initialValue: true
|
|
1760
|
+
});
|
|
1761
|
+
if (!p.isCancel(syncNow) && syncNow) {
|
|
1762
|
+
await syncCommand(repoRoot);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
p.outro("Done! Run `agent-bridge sync` whenever you want to pull the latest features.");
|
|
1565
1766
|
}
|
|
1566
1767
|
//#endregion
|
|
1567
1768
|
//#region src/commands/opt-out.ts
|
|
@@ -1637,9 +1838,12 @@ function collect(value, previous) {
|
|
|
1637
1838
|
return previous;
|
|
1638
1839
|
}
|
|
1639
1840
|
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"
|
|
1841
|
+
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));
|
|
1842
|
+
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));
|
|
1843
|
+
program.command("update", { hidden: true }).option("--cwd <path>", "Override the working directory").action(await withCwdValidation(async (cwd) => {
|
|
1844
|
+
console.error("`agent-bridge update` is deprecated — running `agent-bridge sync` instead (it fetches sources too).");
|
|
1845
|
+
await syncCommand(cwd);
|
|
1846
|
+
}));
|
|
1643
1847
|
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
1848
|
program.parse();
|
|
1645
1849
|
//#endregion
|