@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/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).min(1, "'domains' must be a non-empty array"),
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 update and sync in the background, logging to `.agent-bridge/hook.log`
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 update && agent-bridge sync' in the background
227
- # to keep your AI agent configurations up to date.
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 update && agent-bridge sync
303
+ agent-bridge sync
243
304
  elif command -v npx >/dev/null 2>&1; then
244
- npx @sofatutor/agent-bridge update && npx @sofatutor/agent-bridge sync
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/version.ts
645
- const VERSION = "0.13.1";
646
- //#endregion
647
- //#region src/commands/init.ts
648
- const WELL_KNOWN_TOOLS = [
649
- {
650
- value: {
651
- name: "vscode",
652
- folder: ".github"
653
- },
654
- label: "VS Code (.github/)"
655
- },
656
- {
657
- value: {
658
- name: "cursor",
659
- folder: ".cursor"
660
- },
661
- label: "Cursor (.cursor/)"
662
- },
663
- {
664
- value: {
665
- name: "claude",
666
- folder: ".claude"
667
- },
668
- label: "Claude (.claude/)"
669
- },
670
- {
671
- value: {
672
- name: "pi",
673
- folder: ".pi"
674
- },
675
- label: "Pi (.pi/)"
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
- const WELL_KNOWN_TOOL_MAP = Object.fromEntries(WELL_KNOWN_TOOLS.map((t) => [t.value.name, t.value]));
679
- const CUSTOM_TOOL_SENTINEL = {
680
- name: "__custom__",
681
- folder: "__custom__"
682
- };
683
- const DEFAULT_DOMAINS = [
684
- "backend",
685
- "frontend",
686
- "shared"
687
- ];
754
+ return [...types].sort();
755
+ }
688
756
  /**
689
- * Derive a short source name from a URL or local path.
757
+ * Scan all features across sources × domains × feature types.
690
758
  *
691
- * Examples:
692
- * https://github.com/org/repo.git → repo
693
- * git@github.com:org/repo.git → repo
694
- * file:///tmp/bare.git → bare
695
- * /path/to/my-folder → my-folder
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 deriveSourceName(source) {
698
- let segment = source;
699
- const sshMatch = segment.match(/^[\w.-]+@[\w.-]+:(.+)$/);
700
- if (sshMatch) segment = sshMatch[1];
701
- try {
702
- segment = new URL(segment).pathname;
703
- } catch {}
704
- return (segment.replace(/\/+$/, "").split("/").pop() ?? segment).replace(/\.git$/, "") || "source";
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
- * Parse a comma-separated `--tools` argument into ToolConfig[].
708
- * Accepts well-known names (cursor, vscode, claude) or `name:folder` pairs.
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 parseToolsArg(input) {
711
- return input.split(",").map((t) => {
712
- const trimmed = t.trim();
713
- if (!trimmed) throw new Error("Empty tool name in --tools");
714
- if (WELL_KNOWN_TOOL_MAP[trimmed]) return WELL_KNOWN_TOOL_MAP[trimmed];
715
- const colonIdx = trimmed.indexOf(":");
716
- if (colonIdx > 0) return {
717
- name: trimmed.slice(0, colonIdx),
718
- folder: trimmed.slice(colonIdx + 1)
719
- };
720
- throw new Error(`Unknown tool "${trimmed}". Use a known name (${Object.keys(WELL_KNOWN_TOOL_MAP).join(", ")}) or name:folder format.`);
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
- * Parse a single `--source` argument into a SourceConfig.
725
- * Supports `#branch` suffix for remote sources.
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
- function parseSourceArg(input, repoRoot) {
728
- let source = input.trim();
729
- let branch;
730
- const hashIdx = source.lastIndexOf("#");
731
- if (hashIdx > 0) {
732
- branch = source.slice(hashIdx + 1);
733
- source = source.slice(0, hashIdx);
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
- if (!source) throw new Error("Empty source in --source");
736
- const entry = {
737
- name: deriveSourceName(source),
738
- source
739
- };
740
- if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
741
- if (branch) entry.branch = branch;
742
- return entry;
875
+ return found;
743
876
  }
744
- async function initCommand(cwd, opts) {
745
- const repoRoot = cwd ?? findRepoRoot();
746
- if (await isOptedOut(repoRoot)) if (opts?.force) await removeOptOutMarker(repoRoot);
747
- else {
748
- p.log.warn(`${OPT_OUT_MARKER} present Agent Bridge is opted out. Skipping init. Delete the file or run with --force to re-enable.`);
749
- return;
750
- }
751
- const hasToolsArg = !!opts?.tools;
752
- const hasSourceArg = !!(opts?.source && opts.source.length > 0);
753
- if (hasToolsArg !== hasSourceArg) {
754
- p.log.error("Both --tools and --source are required for non-interactive init.");
755
- process.exit(1);
756
- }
757
- if (hasToolsArg && hasSourceArg) {
758
- const domains = opts.domains ? opts.domains.split(",").map((d) => d.trim()).filter(Boolean) : [...DEFAULT_DOMAINS];
759
- const tools = parseToolsArg(opts.tools);
760
- const sources = opts.source.map((s) => parseSourceArg(s, repoRoot));
761
- const seen = /* @__PURE__ */ new Set();
762
- for (const s of sources) {
763
- if (seen.has(s.name)) throw new Error(`Duplicate source name "${s.name}" derived from --source arguments`);
764
- seen.add(s.name);
765
- }
766
- const config = {
767
- version: VERSION,
768
- domains,
769
- tools,
770
- sources
771
- };
772
- await saveConfig(repoRoot, config);
773
- await ensureBridgeGitignore(repoRoot);
774
- p.log.success("Saved .agent-bridge/config.yml");
775
- const spinner = p.spinner();
776
- spinner.start("Fetching remote sources…");
777
- const fetchErrors = (await syncAllSources(repoRoot, config)).filter((r) => r.error);
778
- if (fetchErrors.length > 0) {
779
- spinner.stop("Some sources failed");
780
- for (const err of fetchErrors) p.log.error(`${err.name}: ${err.error}`);
781
- } else spinner.stop("All sources ready");
782
- if (opts.hooks && isInGitRepo(repoRoot)) {
783
- const hookResult = await installGitHooks(repoRoot, opts.force === true);
784
- if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
785
- if (hookResult.skipped.length > 0) p.log.warn(`Skipped hooks: ${hookResult.skipped.join(", ")}`);
786
- if (hookResult.errors.length > 0) for (const e of hookResult.errors) p.log.error(`Hook ${e.hook}: ${e.error}`);
787
- }
788
- p.outro("Done! Run `agent-bridge sync` to sync features.");
789
- return;
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
- p.intro("Welcome to Agent Bridge — Project Setup");
792
- if (await configExists(repoRoot)) {
793
- const existing = await loadConfig(repoRoot);
794
- p.log.info(`Config already exists with ${existing.sources?.length ?? 0} source(s). Re-running will overwrite.`);
795
- }
796
- const domainsInput = await p.text({
797
- message: "Domains (comma-separated)",
798
- placeholder: DEFAULT_DOMAINS.join(", "),
799
- defaultValue: DEFAULT_DOMAINS.join(", "),
800
- validate: (v) => {
801
- if (!v.trim()) return "At least one domain is required";
802
- }
803
- });
804
- if (p.isCancel(domainsInput)) {
805
- p.cancel("Setup cancelled.");
806
- process.exit(1);
807
- }
808
- const domains = domainsInput.split(",").map((d) => d.trim()).filter(Boolean);
809
- const selectedTools = await p.multiselect({
810
- message: "Which tools (IDEs) should receive Agent Bridge files?",
811
- options: [...WELL_KNOWN_TOOLS, {
812
- value: CUSTOM_TOOL_SENTINEL,
813
- label: "Other (add custom tool)"
814
- }],
815
- required: true
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
- if (p.isCancel(selectedTools)) {
818
- p.cancel("Setup cancelled.");
819
- process.exit(1);
820
- }
821
- const tools = selectedTools.filter((t) => t.name !== "__custom__");
822
- if (selectedTools.some((t) => t.name === "__custom__")) {
823
- let addingCustom = true;
824
- while (addingCustom) {
825
- const name = await p.text({
826
- message: "Custom tool name (used for <tool>-- prefix matching)",
827
- placeholder: "windsurf",
828
- defaultValue: "",
829
- validate: (v) => {
830
- if (!v.trim()) return "Tool name cannot be empty";
831
- if (tools.some((t) => t.name === v.trim())) return "Tool name already used";
832
- }
833
- });
834
- if (p.isCancel(name)) break;
835
- const folder = await p.text({
836
- message: `Target folder for "${name}"`,
837
- placeholder: `.${name}`,
838
- defaultValue: "",
839
- validate: (v) => {
840
- if (!v.trim()) return "Folder cannot be empty";
841
- if (tools.some((t) => t.folder === v.trim())) return "Folder already used by another tool";
842
- }
843
- });
844
- if (p.isCancel(folder)) break;
845
- tools.push({
846
- name: name.trim(),
847
- folder: folder.trim()
848
- });
849
- const addMore = await p.confirm({
850
- message: "Add another custom tool?",
851
- initialValue: false
852
- });
853
- if (p.isCancel(addMore) || !addMore) addingCustom = false;
854
- }
855
- if (tools.length === 0) {
856
- p.cancel("At least one tool is required.");
857
- process.exit(1);
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
- 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`;
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
- 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);
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
- break;
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
- const config = {
907
- version: VERSION,
908
- domains,
909
- tools,
910
- sources
911
- };
912
- await saveConfig(repoRoot, config);
913
- await ensureBridgeGitignore(repoRoot);
914
- p.log.success("Saved .agent-bridge/config.yml");
915
- const s = p.spinner();
916
- s.start("Fetching remote sources…");
917
- const errors = (await syncAllSources(repoRoot, config)).filter((r) => r.error);
918
- if (errors.length > 0) {
919
- s.stop("Some sources failed");
920
- for (const err of errors) p.log.error(`${err.name}: ${err.error}`);
921
- } else s.stop("All sources ready");
922
- if (isInGitRepo(repoRoot)) {
923
- const installHooks = await p.confirm({
924
- message: "Install git hooks to auto-sync on checkout/merge?",
925
- initialValue: false
926
- });
927
- if (!p.isCancel(installHooks) && installHooks) {
928
- const hookResult = await installGitHooks(repoRoot, opts?.force === true);
929
- if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
930
- if (hookResult.skipped.length > 0) {
931
- p.log.warn(`Skipped hooks (existing non-Agent-Bridge hooks): ${hookResult.skipped.join(", ")}`);
932
- p.log.info("Re-run `agent-bridge init --force` to overwrite, or integrate manually.");
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 (hookResult.errors.length > 0) for (const err of hookResult.errors) p.log.error(`Hook ${err.hook}: ${err.error}`);
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
- p.outro("Done! Run `agent-bridge sync` to sync features.");
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("Syncing sources…");
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 synced");
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/update.ts
1550
- async function updateCommand(cwd, _opts) {
1551
- const repoRoot = cwd ?? findRepoRoot();
1552
- p.intro("Agent Bridge — Update Sources");
1553
- const config = await loadConfig(repoRoot);
1554
- const migrationResult = await runMigrations(repoRoot);
1555
- if (migrationResult) p.log.info(`Config upgraded ${migrationResult.fromVersion} → ${migrationResult.toVersion}` + (migrationResult.applied.length > 0 ? ` (${migrationResult.applied.length} migration(s))` : ""));
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("Updating all remote sources…");
1558
- const results = await syncAllSources(repoRoot, config);
1559
- s.stop("Update complete");
1560
- for (const r of results) if (r.error) p.log.error(`${r.name}: ${r.error}`);
1561
- else p.log.info(`${r.name}: ${r.action}`);
1562
- const errors = results.filter((r) => r.error);
1563
- if (errors.length > 0) p.outro(`Done with ${errors.length} error(s).`);
1564
- else p.outro("All sources up to date.");
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("Initialize Agent Bridge (creates .agent-bridge/config.yml)").option("--cwd <path>", "Override the working directory").option("--force", "Overwrite existing non-Agent-Bridge git hooks").option("--domains <list>", "Comma-separated domain list (default: backend,frontend,shared)").option("--tools <list>", "Comma-separated tool names (cursor,vscode,claude) or name:folder pairs").option("-s, --source <url>", "Source URL or path (repeatable, append #branch for branch)", collect, []).option("--hooks", "Auto-install git hooks without prompting").action(await withCwdValidation(initCommand));
1641
- program.command("sync").description("Fetch sources, discover features, and sync files").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(syncCommand));
1642
- program.command("update").description("Fetch latest changes for all remote sources").option("--cwd <path>", "Override the working directory").action(await withCwdValidation(updateCommand));
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