@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/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).min(1, "'domains' must be a non-empty array"),
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 update and sync in the background, logging to `.agent-bridge/hook.log`
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 update && agent-bridge sync' in the background
227
- # to keep your AI agent configurations up to date.
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 update && agent-bridge sync
302
+ agent-bridge sync
243
303
  elif command -v npx >/dev/null 2>&1; then
244
- npx @sofatutor/agent-bridge update && npx @sofatutor/agent-bridge sync
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/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/)"
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
- 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
- ];
753
+ return [...types].sort();
754
+ }
688
755
  /**
689
- * Derive a short source name from a URL or local path.
756
+ * Scan all features across sources × domains × feature types.
690
757
  *
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
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 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";
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
- * Parse a comma-separated `--tools` argument into ToolConfig[].
708
- * Accepts well-known names (cursor, vscode, claude) or `name:folder` pairs.
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 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.`);
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
- * Parse a single `--source` argument into a SourceConfig.
725
- * Supports `#branch` suffix for remote sources.
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
- 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);
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
- 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;
874
+ return found;
743
875
  }
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;
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
- 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
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
- if (p.isCancel(selectedTools)) {
818
- p.cancel("Setup cancelled.");
819
- process.exit(1);
820
- }
821
- const tools = selectedTools.filter((t) => t.name !== "__custom__");
822
- if (selectedTools.some((t) => t.name === "__custom__")) {
823
- let addingCustom = true;
824
- while (addingCustom) {
825
- const name = await p.text({
826
- message: "Custom tool name (used for <tool>-- prefix matching)",
827
- placeholder: "windsurf",
828
- defaultValue: "",
829
- validate: (v) => {
830
- if (!v.trim()) return "Tool name cannot be empty";
831
- if (tools.some((t) => t.name === v.trim())) return "Tool name already used";
832
- }
833
- });
834
- if (p.isCancel(name)) break;
835
- const folder = await p.text({
836
- message: `Target folder for "${name}"`,
837
- placeholder: `.${name}`,
838
- defaultValue: "",
839
- validate: (v) => {
840
- if (!v.trim()) return "Folder cannot be empty";
841
- if (tools.some((t) => t.folder === v.trim())) return "Folder already used by another tool";
842
- }
843
- });
844
- if (p.isCancel(folder)) break;
845
- tools.push({
846
- name: name.trim(),
847
- folder: folder.trim()
848
- });
849
- const addMore = await p.confirm({
850
- message: "Add another custom tool?",
851
- initialValue: false
852
- });
853
- if (p.isCancel(addMore) || !addMore) addingCustom = false;
854
- }
855
- if (tools.length === 0) {
856
- p.cancel("At least one tool is required.");
857
- process.exit(1);
858
- }
859
- }
860
- const sources = [];
861
- const addSource = async () => {
862
- const source = await p.text({
863
- message: "Source URL or local path",
864
- placeholder: "https://github.com/org/repo.git",
865
- defaultValue: "",
866
- validate: (v) => {
867
- if (!v.trim()) return "Source URL/path cannot be empty";
868
- const derived = deriveSourceName(v.trim());
869
- if (sources.some((s) => s.name === derived)) return `Source name "${derived}" (derived from URL) already used`;
870
- }
871
- });
872
- if (p.isCancel(source)) return false;
873
- const entry = {
874
- name: deriveSourceName(source.trim()),
875
- source: source.trim()
876
- };
877
- if (!isRemoteSource(entry.source)) entry.source = resolve(repoRoot, entry.source);
878
- if (isRemoteSource(entry.source)) {
879
- const branch = await p.text({
880
- message: "Branch (leave empty for remote default)",
881
- placeholder: "main",
882
- defaultValue: ""
883
- });
884
- if (p.isCancel(branch)) return false;
885
- if (branch.trim()) entry.branch = branch.trim();
886
- }
887
- sources.push(entry);
888
- return true;
889
- };
890
- p.log.info("Add at least one source.");
891
- let addingSource = true;
892
- while (addingSource) {
893
- if (!await addSource()) {
894
- if (sources.length === 0) {
895
- p.cancel("At least one source is required.");
896
- process.exit(1);
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
- const config = {
907
- version: VERSION,
908
- domains,
909
- tools,
910
- sources
911
- };
912
- await saveConfig(repoRoot, config);
913
- await ensureBridgeGitignore(repoRoot);
914
- p.log.success("Saved .agent-bridge/config.yml");
915
- const s = p.spinner();
916
- s.start("Fetching remote sources…");
917
- const errors = (await syncAllSources(repoRoot, config)).filter((r) => r.error);
918
- if (errors.length > 0) {
919
- s.stop("Some sources failed");
920
- for (const err of errors) p.log.error(`${err.name}: ${err.error}`);
921
- } else s.stop("All sources ready");
922
- if (isInGitRepo(repoRoot)) {
923
- const installHooks = await p.confirm({
924
- message: "Install git hooks to auto-sync on checkout/merge?",
925
- initialValue: false
926
- });
927
- if (!p.isCancel(installHooks) && installHooks) {
928
- const hookResult = await installGitHooks(repoRoot, opts?.force === true);
929
- if (hookResult.installed.length > 0) p.log.success(`Installed git hooks: ${hookResult.installed.join(", ")}`);
930
- if (hookResult.skipped.length > 0) {
931
- p.log.warn(`Skipped hooks (existing non-Agent-Bridge hooks): ${hookResult.skipped.join(", ")}`);
932
- p.log.info("Re-run `agent-bridge init --force` to overwrite, or integrate manually.");
933
- }
934
- if (hookResult.errors.length > 0) for (const err of hookResult.errors) p.log.error(`Hook ${err.hook}: ${err.error}`);
935
- }
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
- p.outro("Done! Run `agent-bridge sync` to sync features.");
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("Syncing sources…");
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 synced");
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/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))` : ""));
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("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.");
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("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));
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