agentmeld 0.1.0__py3-none-any.whl

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.
agentmeld/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """agentmeld -- one AI context, every agent.
2
+
3
+ A repo used with several AI coding tools ends up holding the same knowledge in
4
+ ``CLAUDE.md``, ``.github/copilot-instructions.md`` and ``.cursor/rules/*.mdc``,
5
+ drifting apart. agentmeld keeps one canonical copy in ``.ai/`` and mirrors it
6
+ into every vendor location -- as a real symlink where the formats agree, and as
7
+ a small generated file where they do not.
8
+ """
9
+
10
+ __all__ = ["__version__"]
11
+
12
+ __version__ = "0.1.0"
agentmeld/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
agentmeld/adopt.py ADDED
@@ -0,0 +1,243 @@
1
+ """Pulling vendor files back into the canonical tree.
2
+
3
+ This is the direction the existing tools do not automate, and the reason watch
4
+ mode can work: when an agent writes a brand-new ``.claude/skills/foo/SKILL.md``,
5
+ that content becomes canonical and is fanned out to every other tool, instead of
6
+ staying stranded in one vendor's directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ from .config import Config
17
+ from .model import Adapter, AssetKind, KindSpec, Strategy
18
+ from .state import State
19
+ from .transform import HEADER_TOKEN, parse_header
20
+ from .transform import frontmatter as fm
21
+
22
+ __all__ = ["classify_path", "reverse_frontmatter", "canonical_dest", "run_adopt", "is_ours"]
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # which adapter/kind does a path belong to?
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def _target_regex(template: str) -> "re.Pattern":
31
+ parts = template.split("{slug}")
32
+ pattern = "(?P<slug>.+)".join(re.escape(p) for p in parts)
33
+ return re.compile("^" + pattern + "$")
34
+
35
+
36
+ def classify_path(
37
+ rel_path: str,
38
+ registry: Dict[str, Adapter],
39
+ ) -> Optional[Tuple[Adapter, KindSpec, str]]:
40
+ """Match a repo-relative path against every adapter's target templates.
41
+
42
+ Templates with a ``{slug}`` are preferred over literal ones, so
43
+ ``.github/instructions/x.instructions.md`` is recognised as a rule rather
44
+ than accidentally matching something broader.
45
+ """
46
+ best: Optional[Tuple[Adapter, KindSpec, str]] = None
47
+ for adapter in registry.values():
48
+ for spec in adapter.kinds.values():
49
+ match = _target_regex(spec.target).match(rel_path)
50
+ if not match:
51
+ continue
52
+ slug = match.groupdict().get("slug") or spec.kind.value
53
+ candidate = (adapter, spec, slug)
54
+ if "{slug}" in spec.target:
55
+ return candidate
56
+ best = best or candidate
57
+ return best
58
+
59
+
60
+ def is_ours(path: Path, rel_path: str, state: State) -> bool:
61
+ """True when this path is already a mirror we manage.
62
+
63
+ Checked three ways, because any one of them can be stale: state, a symlink
64
+ pointing into the canonical tree, and our generated header. This is the guard
65
+ that stops watch mode from adopting its own output in a loop.
66
+ """
67
+ if state.get(rel_path) is not None:
68
+ return True
69
+ if path.is_symlink():
70
+ return True
71
+ if path.is_file():
72
+ try:
73
+ head = path.read_text(encoding="utf-8", errors="replace")[:4096]
74
+ except OSError:
75
+ return False
76
+ if HEADER_TOKEN in head:
77
+ return True
78
+ return False
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # vendor frontmatter -> canonical frontmatter
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def reverse_frontmatter(vendor: Dict[str, object], spec: KindSpec) -> Dict[str, object]:
87
+ """Invert an adapter's frontmatter mapping.
88
+
89
+ Ambiguity is real here: Copilot maps both ``globs`` and ``always`` onto
90
+ ``applyTo``. It is resolved by value -- ``applyTo: "**"`` came from
91
+ ``always``, anything else from ``globs``.
92
+ """
93
+ plain: Dict[str, Tuple[str, str]] = {}
94
+ flags: Dict[str, List[Tuple[str, str]]] = {}
95
+
96
+ for canonical_key, directive in spec.frontmatter.items():
97
+ if directive == fm.DROP:
98
+ continue
99
+ if "!" in directive:
100
+ vendor_key, _, literal = directive.partition("!")
101
+ flags.setdefault(vendor_key, []).append((canonical_key, literal))
102
+ elif directive.endswith(":comma"):
103
+ plain[directive[: -len(":comma")]] = (canonical_key, "comma")
104
+ else:
105
+ plain[directive] = (canonical_key, "plain")
106
+
107
+ out: Dict[str, object] = {}
108
+ for vendor_key, value in vendor.items():
109
+ matched = False
110
+ for canonical_key, literal in flags.get(vendor_key, []):
111
+ if _matches_literal(value, literal):
112
+ out[canonical_key] = True
113
+ matched = True
114
+ break
115
+ if matched:
116
+ continue
117
+ if vendor_key in plain:
118
+ canonical_key, how = plain[vendor_key]
119
+ if how == "comma" and isinstance(value, str):
120
+ out[canonical_key] = [p.strip() for p in value.split(",") if p.strip()]
121
+ else:
122
+ out[canonical_key] = value
123
+ continue
124
+ if vendor_key in spec.defaults and spec.defaults[vendor_key] == value:
125
+ continue # a default we wrote ourselves carries no information
126
+ if vendor_key not in plain and vendor_key not in flags:
127
+ out[vendor_key] = value # unknown key: keep it rather than lose it
128
+ return out
129
+
130
+
131
+ def _matches_literal(value: object, literal: str) -> bool:
132
+ lowered = literal.strip().lower()
133
+ if lowered in ("true", "false"):
134
+ return bool(value) is (lowered == "true")
135
+ return value == literal
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # adoption
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def canonical_dest(config: Config, kind: AssetKind, slug: str) -> Path:
144
+ base = config.canonical
145
+ if kind is AssetKind.INSTRUCTIONS:
146
+ return base / "instructions.md"
147
+ if kind is AssetKind.MCP:
148
+ return base / "mcp.json"
149
+ if kind is AssetKind.SKILL:
150
+ return base / "skills" / slug / "SKILL.md"
151
+ return base / {AssetKind.RULE: "rules", AssetKind.AGENT: "agents", AssetKind.COMMAND: "commands"}[kind] / (slug + ".md")
152
+
153
+
154
+ def adopt_path(
155
+ path: Path,
156
+ config: Config,
157
+ registry: Dict[str, Adapter],
158
+ state: State,
159
+ dry_run: bool = False,
160
+ ) -> Optional[str]:
161
+ """Move one vendor file into the canonical tree. Returns a log line, or None."""
162
+ from .planner import now_iso
163
+
164
+ rel = config.rel(path)
165
+ if not path.exists():
166
+ return None
167
+ if is_ours(path, rel, state):
168
+ return None
169
+
170
+ hit = classify_path(rel, registry)
171
+ if hit is None:
172
+ return None
173
+ adapter, spec, slug = hit
174
+ if spec.strategy is Strategy.MERGE and hit[1].kind is not AssetKind.MCP:
175
+ return None
176
+
177
+ dest = canonical_dest(config, spec.kind, slug)
178
+ if dest.exists():
179
+ return "{}: canonical {} already exists, left alone".format(rel, config.rel(dest))
180
+
181
+ if dry_run:
182
+ return "{} -> {} (would adopt)".format(rel, config.rel(dest))
183
+
184
+ dest.parent.mkdir(parents=True, exist_ok=True)
185
+
186
+ if spec.kind is AssetKind.SKILL:
187
+ # A skill is its whole directory. Moving only SKILL.md would strand the
188
+ # sidecars and leave a real directory where the symlink belongs.
189
+ source_dir, dest_dir = path.parent, dest.parent
190
+ if dest_dir.exists():
191
+ shutil.rmtree(str(dest_dir))
192
+ shutil.move(str(source_dir), str(dest_dir))
193
+ state.record_adoption(config.rel(dest), rel, now_iso())
194
+ return "{} -> {} (adopted whole skill dir from {})".format(
195
+ config.rel(source_dir), config.rel(dest_dir), adapter.name
196
+ )
197
+
198
+ if spec.kind is AssetKind.MCP or path.suffix == ".json":
199
+ shutil.move(str(path), str(dest))
200
+ elif path.suffix == ".toml":
201
+ return "{}: adopting TOML commands is not supported yet".format(rel)
202
+ else:
203
+ text = path.read_text(encoding="utf-8")
204
+ vendor_fm, body = fm.split(text)
205
+ canonical_fm = reverse_frontmatter(vendor_fm, spec)
206
+ dest.write_bytes((fm.render(canonical_fm, body)).encode("utf-8"))
207
+ path.unlink()
208
+
209
+ state.record_adoption(config.rel(dest), rel, now_iso())
210
+ return "{} -> {} (adopted from {})".format(rel, config.rel(dest), adapter.name)
211
+
212
+
213
+ def run_adopt(config: Config, registry, state: State, args) -> int:
214
+ from .cli import EXIT_ERROR, EXIT_OK
215
+
216
+ lines: List[str] = []
217
+ for raw in args.paths:
218
+ path = Path(raw)
219
+ if not path.is_absolute():
220
+ path = config.root / path
221
+ line = adopt_path(path, config, registry, state, dry_run=args.dry_run)
222
+ lines.append(line or "{}: nothing to adopt (unrecognised, or already managed)".format(raw))
223
+
224
+ for line in lines:
225
+ print(" " + line)
226
+
227
+ if args.dry_run:
228
+ print("dry run: nothing written")
229
+ return EXIT_OK
230
+
231
+ state.save(config.state_path)
232
+ if args.no_sync:
233
+ return EXIT_OK
234
+
235
+ print("\nfanning out:")
236
+ from .cli import build_parser
237
+
238
+ sync_args = build_parser().parse_args(["sync"])
239
+ sync_args.root = str(config.root)
240
+ sync_args.include_unverified = config.include_unverified
241
+ from .cli import cmd_sync
242
+
243
+ return cmd_sync(sync_args)
agentmeld/cli.py ADDED
@@ -0,0 +1,308 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import List, Optional, Sequence
9
+
10
+ from . import __version__
11
+ from .config import Config, load_config
12
+ from .model import Action, AssetKind, Confidence
13
+ from .registry import kind_confidence, load_registry
14
+ from .state import State
15
+
16
+ EXIT_OK = 0
17
+ EXIT_DRIFT = 1
18
+ EXIT_CONFLICT = 2
19
+ EXIT_ERROR = 3
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # output helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _SYMBOL = {
27
+ Action.CREATE: "+",
28
+ Action.UPDATE: "~",
29
+ Action.UNCHANGED: " ",
30
+ Action.CONFLICT: "!",
31
+ Action.SKIP: "-",
32
+ }
33
+
34
+
35
+ def _print_plan(plan, config: Config, verbose: bool = False) -> None:
36
+ rows = plan.mirrors if verbose else plan.changes + plan.conflicts
37
+ if not rows:
38
+ print("everything already in sync ({} mirror(s) checked)".format(len(plan)))
39
+ for mirror in rows:
40
+ print(
41
+ " {sym} {target:<48} {strategy:<9} {adapter}".format(
42
+ sym=_SYMBOL[mirror.action],
43
+ target=config.rel(mirror.target),
44
+ strategy=str(mirror.strategy),
45
+ adapter=mirror.adapter_id,
46
+ )
47
+ )
48
+ if mirror.reason and mirror.action is Action.CONFLICT:
49
+ print(" {}".format(mirror.reason))
50
+ for warning in plan.warnings:
51
+ print(" note: {}".format(warning))
52
+
53
+
54
+ def _context(args) -> "tuple":
55
+ config = load_config(Path(args.root).resolve() if args.root else None)
56
+ if getattr(args, "include_unverified", False):
57
+ config.include_unverified = True
58
+ if getattr(args, "mode", None):
59
+ config.mode = args.mode
60
+ registry = load_registry([config.root / d for d in config.extra_adapter_dirs])
61
+ state = State.load(config.state_path)
62
+ return config, registry, state
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # commands
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def cmd_detect(args) -> int:
71
+ from .detect import detect_tools
72
+
73
+ config, registry, state = _context(args)
74
+ found = detect_tools(registry, config, state)
75
+ print("repo: {}".format(config.root))
76
+ if not found:
77
+ print("no AI tool configuration detected")
78
+ print("run 'agentmeld init' to start a canonical {}/ tree".format(config.canonical_dir))
79
+ return EXIT_OK
80
+ print("detected {} tool(s):".format(len(found)))
81
+ for adapter_id, evidence in sorted(found.items()):
82
+ adapter = registry[adapter_id]
83
+ print(" {:<10} {:<26} {}".format(adapter_id, adapter.name, ", ".join(evidence)))
84
+ return EXIT_OK
85
+
86
+
87
+ def cmd_list_adapters(args) -> int:
88
+ config, registry, _state = _context(args)
89
+ print("{:<10} {:<26} {:<13} {}".format("id", "tool", "kind", "confidence / strategy"))
90
+ for adapter_id, adapter in sorted(registry.items()):
91
+ for kind, spec in adapter.kinds.items():
92
+ confidence = kind_confidence(adapter, kind)
93
+ flag = "" if confidence is Confidence.VERIFIED else " (opt-in)"
94
+ print(
95
+ "{:<10} {:<26} {:<13} {} / {}{}".format(
96
+ adapter_id, adapter.name, str(kind), confidence, spec.strategy, flag
97
+ )
98
+ )
99
+ print()
100
+ print("Unverified paths are excluded from sync unless --include-unverified is passed.")
101
+ return EXIT_OK
102
+
103
+
104
+ def cmd_sync(args) -> int:
105
+ from .config import discover_assets
106
+ from .linker import git_symlinks_enabled, resolve_mode
107
+ from .planner import apply_plan, build_plan, select_adapters
108
+
109
+ config, registry, state = _context(args)
110
+ if not config.canonical.is_dir():
111
+ print("no {}/ directory -- run 'agentmeld init' first".format(config.canonical_dir))
112
+ return EXIT_ERROR
113
+
114
+ mode = resolve_mode(config.mode, config.root)
115
+ if mode == "copy" and config.mode == "auto":
116
+ print("note: symlinks unavailable here, mirroring as copies instead")
117
+ if git_symlinks_enabled(config.root) is False and mode == "link":
118
+ print("warning: git core.symlinks=false -- links may not survive a fresh clone")
119
+
120
+ if getattr(args, "adopt", False):
121
+ from .watch import reconcile
122
+
123
+ for line in reconcile(config, registry, state):
124
+ print(" adopted: {}".format(line))
125
+
126
+ assets = discover_assets(config)
127
+ if not assets:
128
+ print("no canonical assets found under {}/".format(config.canonical_dir))
129
+ return EXIT_OK
130
+
131
+ adapters = select_adapters(registry, config, state, args.targets or None)
132
+ plan = build_plan(config, assets, adapters, state, mode)
133
+
134
+ quiet = getattr(args, "quiet", False)
135
+ if not (quiet and plan.is_clean):
136
+ print("{} asset(s) -> {} tool(s), mode={}".format(len(assets), len(adapters), mode))
137
+ _print_plan(plan, config, verbose=args.verbose)
138
+
139
+ if args.check:
140
+ if plan.conflicts:
141
+ return EXIT_CONFLICT
142
+ return EXIT_DRIFT if plan.changes else EXIT_OK
143
+
144
+ if args.dry_run:
145
+ print("\ndry run: nothing written")
146
+ return EXIT_CONFLICT if plan.conflicts else EXIT_OK
147
+
148
+ written = apply_plan(plan, config, state, mode)
149
+ if not (quiet and written == 0):
150
+ print("\nwrote {} file(s)".format(written))
151
+ if plan.conflicts:
152
+ print(
153
+ "{} conflict(s) left untouched -- resolve by hand, or delete the file to "
154
+ "let agentmeld own it".format(len(plan.conflicts))
155
+ )
156
+ return EXIT_CONFLICT
157
+ return EXIT_OK
158
+
159
+
160
+ def cmd_doctor(args) -> int:
161
+ from .config import discover_assets
162
+ from .doctor import run_doctor
163
+
164
+ config, registry, state = _context(args)
165
+ return run_doctor(config, registry, state, args)
166
+
167
+
168
+ def cmd_init(args) -> int:
169
+ from .init import run_init
170
+
171
+ config, registry, state = _context(args)
172
+ return run_init(config, registry, state, args)
173
+
174
+
175
+ def cmd_adopt(args) -> int:
176
+ from .adopt import run_adopt
177
+
178
+ config, registry, state = _context(args)
179
+ return run_adopt(config, registry, state, args)
180
+
181
+
182
+ def cmd_watch(args) -> int:
183
+ from .watch import run_watch
184
+
185
+ config, registry, state = _context(args)
186
+ return run_watch(config, registry, state, args)
187
+
188
+
189
+ def cmd_install_hooks(args) -> int:
190
+ from .hooks import run_install_hooks
191
+
192
+ config, registry, state = _context(args)
193
+ return run_install_hooks(config, registry, state, args)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # parser
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def build_parser() -> argparse.ArgumentParser:
202
+ parser = argparse.ArgumentParser(
203
+ prog="agentmeld",
204
+ description="One AI context, every agent. Keep one canonical copy of your "
205
+ "AI instructions, rules, skills, agents, commands and MCP config, "
206
+ "mirrored into every tool's location.",
207
+ )
208
+ parser.add_argument("--version", action="version", version="agentmeld " + __version__)
209
+ parser.add_argument("--root", help="repo root (default: nearest .git or .ai ancestor)")
210
+
211
+ # --root is also accepted *after* the subcommand, because `agentmeld init
212
+ # --root .` is what people naturally type. SUPPRESS keeps the subcommand copy
213
+ # from overwriting a value given before the subcommand with None.
214
+ positional_root = argparse.ArgumentParser(add_help=False)
215
+ positional_root.add_argument("--root", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
216
+
217
+ sub = parser.add_subparsers(dest="command", required=True)
218
+ _original_add_parser = sub.add_parser
219
+
220
+ def add_parser(name, **kwargs):
221
+ kwargs.setdefault("parents", [positional_root])
222
+ return _original_add_parser(name, **kwargs)
223
+
224
+ sub.add_parser = add_parser
225
+
226
+ def common(p):
227
+ p.add_argument(
228
+ "--include-unverified",
229
+ action="store_true",
230
+ help="also sync paths not confirmed against vendor documentation",
231
+ )
232
+ return p
233
+
234
+ p = sub.add_parser("detect", help="show which AI tools this repo uses")
235
+ common(p).set_defaults(func=cmd_detect)
236
+
237
+ p = sub.add_parser("list-adapters", help="show the support matrix")
238
+ common(p).set_defaults(func=cmd_list_adapters)
239
+
240
+ p = sub.add_parser("init", help="create the canonical tree, adopting existing config")
241
+ common(p)
242
+ p.add_argument("--force", action="store_true", help="proceed on a dirty worktree")
243
+ p.add_argument("--no-migrate", action="store_true", help="do not move existing files")
244
+ p.add_argument("--dry-run", action="store_true")
245
+ p.set_defaults(func=cmd_init)
246
+
247
+ p = sub.add_parser("sync", help="materialise every mirror")
248
+ common(p)
249
+ p.add_argument("--check", action="store_true", help="exit 1 on drift, writing nothing (CI)")
250
+ p.add_argument("--dry-run", action="store_true")
251
+ p.add_argument("-v", "--verbose", action="store_true", help="list unchanged mirrors too")
252
+ p.add_argument("-q", "--quiet", action="store_true", help="only print on change or error")
253
+ p.add_argument("--mode", choices=["auto", "link", "copy"])
254
+ p.add_argument("--targets", nargs="*", help="limit to these adapter ids")
255
+ p.add_argument(
256
+ "--adopt",
257
+ action="store_true",
258
+ help="first pull any new vendor files into the canonical tree",
259
+ )
260
+ p.set_defaults(func=cmd_sync)
261
+
262
+ p = sub.add_parser("adopt", help="pull a vendor file into canonical, then fan out")
263
+ common(p)
264
+ p.add_argument("paths", nargs="+", help="vendor file(s) to adopt")
265
+ p.add_argument("--dry-run", action="store_true")
266
+ p.add_argument("--no-sync", action="store_true", help="adopt without fanning out")
267
+ p.set_defaults(func=cmd_adopt)
268
+
269
+ p = sub.add_parser("watch", help="adopt and fan out automatically as files change")
270
+ common(p)
271
+ p.add_argument("--interval", type=float, default=1.0, help="poll interval in seconds")
272
+ p.add_argument("--once", action="store_true", help="one pass, for testing")
273
+ p.add_argument("--timeout", type=float, help="stop after this many seconds")
274
+ p.set_defaults(func=cmd_watch)
275
+
276
+ p = sub.add_parser("install-hooks", help="auto-sync on agent writes and on commit")
277
+ common(p)
278
+ p.add_argument(
279
+ "--kind",
280
+ nargs="*",
281
+ choices=["claude", "git"],
282
+ help="which hooks to install (default: both)",
283
+ )
284
+ p.add_argument("--dry-run", action="store_true")
285
+ p.set_defaults(func=cmd_install_hooks)
286
+
287
+ p = sub.add_parser("doctor", help="report drift, conflicts, orphans and dropped keys")
288
+ common(p)
289
+ p.set_defaults(func=cmd_doctor)
290
+
291
+ return parser
292
+
293
+
294
+ def main(argv: Optional[Sequence[str]] = None) -> int:
295
+ parser = build_parser()
296
+ args = parser.parse_args(argv)
297
+ try:
298
+ return int(args.func(args) or EXIT_OK)
299
+ except KeyboardInterrupt:
300
+ print("\ninterrupted")
301
+ return EXIT_ERROR
302
+ except (OSError, ValueError, KeyError) as exc:
303
+ print("error: {}".format(exc), file=sys.stderr)
304
+ return EXIT_ERROR
305
+
306
+
307
+ if __name__ == "__main__":
308
+ raise SystemExit(main())