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 +12 -0
- agentmeld/__main__.py +4 -0
- agentmeld/adopt.py +243 -0
- agentmeld/cli.py +308 -0
- agentmeld/config.py +186 -0
- agentmeld/detect.py +46 -0
- agentmeld/doctor.py +150 -0
- agentmeld/hooks.py +138 -0
- agentmeld/init.py +228 -0
- agentmeld/linker.py +293 -0
- agentmeld/model.py +263 -0
- agentmeld/planner.py +339 -0
- agentmeld/py.typed +0 -0
- agentmeld/registry/__init__.py +63 -0
- agentmeld/registry/adapters/agents.toml +16 -0
- agentmeld/registry/adapters/claude.toml +44 -0
- agentmeld/registry/adapters/copilot.toml +54 -0
- agentmeld/registry/adapters/cursor.toml +39 -0
- agentmeld/registry/adapters/gemini.toml +26 -0
- agentmeld/registry/adapters/zed.toml +12 -0
- agentmeld/registry/schema.py +85 -0
- agentmeld/state.py +138 -0
- agentmeld/transform/__init__.py +145 -0
- agentmeld/transform/aggregate.py +63 -0
- agentmeld/transform/frontmatter.py +164 -0
- agentmeld/transform/json_merge.py +118 -0
- agentmeld/transform/mcp.py +108 -0
- agentmeld/transform/toml_cmd.py +50 -0
- agentmeld/watch.py +181 -0
- agentmeld-0.1.0.dist-info/METADATA +304 -0
- agentmeld-0.1.0.dist-info/RECORD +34 -0
- agentmeld-0.1.0.dist-info/WHEEL +4 -0
- agentmeld-0.1.0.dist-info/entry_points.txt +3 -0
- agentmeld-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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())
|