adrinaline 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.
adrinaline/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """adrinaline: git-native Architecture Decision Records."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("adrinaline")
adrinaline/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from adrinaline.cli import run
2
+
3
+ run()
adrinaline/check.py ADDED
@@ -0,0 +1,155 @@
1
+ """Validation rules for `adr check`. Each rule returns Finding objects."""
2
+
3
+ from collections import defaultdict
4
+ from pathlib import PurePosixPath
5
+
6
+ from adrinaline.graph import ACYCLIC, AdrGraph
7
+ from adrinaline.model import STORED_RELATIONS, Finding, Status
8
+ from adrinaline.repo import AdrSet
9
+
10
+ SNAKE_KEYS = {"superseded_by", "depends_on", "relates_to", "amended_by", "dependency_of"}
11
+
12
+
13
+ def _duplicates(s: AdrSet) -> list[Finding]:
14
+ groups = defaultdict(list)
15
+ for adr in s.adrs:
16
+ groups[adr.number].append(adr)
17
+ return [
18
+ Finding(
19
+ "duplicate-number",
20
+ "error",
21
+ str(adr.path),
22
+ f"{adr.id} is used by {', '.join(a.path.name for a in group)}; fix with `adr renumber`",
23
+ )
24
+ for group in groups.values()
25
+ if len(group) > 1
26
+ for adr in group
27
+ ]
28
+
29
+
30
+ def _gaps(s: AdrSet, taken_elsewhere: set[int]) -> list[Finding]:
31
+ return [
32
+ Finding("number-gap", "warning", "", f"{n:04d} is missing")
33
+ for n in range(1, max(s.numbers, default=0))
34
+ if n not in s.numbers and n not in taken_elsewhere
35
+ ]
36
+
37
+
38
+ def _relations(s: AdrSet, graph: AdrGraph) -> list[Finding]:
39
+ found: list[Finding] = []
40
+ for adr in s.adrs:
41
+ path = str(adr.path)
42
+ for name in STORED_RELATIONS:
43
+ for n in adr.relations[name]:
44
+ if n == adr.number:
45
+ found.append(Finding("self-relation", "error", path, f"{adr.id} {name} itself"))
46
+ elif n not in graph.nodes:
47
+ found.append(
48
+ Finding(
49
+ "dangling-relation",
50
+ "error",
51
+ path,
52
+ f"{adr.id} {name} {n:04d}, which does not exist",
53
+ )
54
+ )
55
+ for name, inverse in (("supersedes", "superseded-by"), ("superseded-by", "supersedes")):
56
+ for n in adr.relations[name]:
57
+ other = graph.nodes.get(n)
58
+ if (
59
+ other
60
+ and other.number != adr.number
61
+ and adr.number not in other.relations[inverse]
62
+ ):
63
+ found.append(
64
+ Finding(
65
+ "asymmetric-relation",
66
+ "error",
67
+ path,
68
+ f"{adr.id} {name} {other.id}, but {other.id} has no "
69
+ f"{inverse}: [{adr.number}]",
70
+ )
71
+ )
72
+ if bool(adr.relations["superseded-by"]) != (adr.status is Status.SUPERSEDED):
73
+ found.append(
74
+ Finding(
75
+ "status-mismatch",
76
+ "error",
77
+ path,
78
+ f"{adr.id}: status {adr.status} does not match superseded-by "
79
+ f"{adr.relations['superseded-by']}",
80
+ )
81
+ )
82
+ for relation in ACYCLIC:
83
+ if cycle := graph.cycle(relation):
84
+ chain = " -> ".join(f"{n:04d}" for n in cycle)
85
+ found.append(Finding("cycle", "error", "", f"{relation} cycle: {chain}"))
86
+ return found
87
+
88
+
89
+ def _unknown_keys(s: AdrSet) -> list[Finding]:
90
+ return [
91
+ Finding(
92
+ "unknown-relation-key",
93
+ "warning",
94
+ str(adr.path),
95
+ f"`{key}` is ignored; use `{key.replace('_', '-')}`",
96
+ )
97
+ for adr in s.adrs
98
+ for key in adr.meta
99
+ if key in SNAKE_KEYS
100
+ ]
101
+
102
+
103
+ def _scopes(
104
+ s: AdrSet, graph: AdrGraph, tracked: list[str] | None, changed: list[str] | None
105
+ ) -> list[Finding]:
106
+ found: list[Finding] = []
107
+ if tracked is not None:
108
+ paths = [PurePosixPath(f) for f in tracked]
109
+ for adr in s.adrs:
110
+ for glob in adr.scope:
111
+ if not any(p.full_match(glob) for p in paths):
112
+ found.append(
113
+ Finding(
114
+ "scope-matches-nothing",
115
+ "warning",
116
+ str(adr.path),
117
+ f"{adr.id}: scope {glob!r} matches no file",
118
+ )
119
+ )
120
+ for path in changed or []:
121
+ if hits := graph.governing(path):
122
+ names = ", ".join(f"{a.id} {a.title}" for a in hits)
123
+ found.append(
124
+ Finding(
125
+ "scoped-change",
126
+ "warning",
127
+ path,
128
+ f"{path} is governed by {names}; review before merging",
129
+ )
130
+ )
131
+ return found
132
+
133
+
134
+ def run_checks(
135
+ s: AdrSet,
136
+ *,
137
+ template_exists: bool,
138
+ tracked: list[str] | None = None,
139
+ changed: list[str] | None = None,
140
+ taken_elsewhere: set[int] | None = None,
141
+ ) -> list[Finding]:
142
+ graph = AdrGraph(s.adrs)
143
+ findings = [
144
+ *s.problems,
145
+ *_duplicates(s),
146
+ *_gaps(s, taken_elsewhere or set()),
147
+ *_relations(s, graph),
148
+ *_unknown_keys(s),
149
+ *_scopes(s, graph, tracked, changed),
150
+ ]
151
+ if not template_exists:
152
+ findings.append(
153
+ Finding("no-template", "warning", "", "template is missing; `adr init` recreates it")
154
+ )
155
+ return sorted(findings, key=lambda f: (f.path, f.code))
adrinaline/cli.py ADDED
@@ -0,0 +1,504 @@
1
+ """Command-line interface: argparse wiring and one cmd_<name> function per command."""
2
+
3
+ import argparse
4
+ import datetime as dt
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+
13
+ from adrinaline import __version__, git, prime, repo
14
+ from adrinaline.check import run_checks
15
+ from adrinaline.frontmatter import render, split
16
+ from adrinaline.graph import (
17
+ FORWARD,
18
+ IMPACT,
19
+ AdrGraph,
20
+ all_nodes,
21
+ graph_data,
22
+ render_dot,
23
+ render_mermaid,
24
+ render_text,
25
+ )
26
+ from adrinaline.model import LINKABLE, Adr, AdrError, Status
27
+ from adrinaline.output import EXIT_FINDINGS, EXIT_OK, Out, adr_dict, error
28
+
29
+ AGENT_HINT = "Agents: run `adr prime` first. Every command accepts --json."
30
+
31
+
32
+ @dataclass
33
+ class Ctx:
34
+ args: argparse.Namespace
35
+ loc: repo.Location
36
+ out: Out
37
+ cwd: Path
38
+
39
+ def load(self) -> repo.AdrSet:
40
+ return repo.load(self.loc, getattr(self.args, "at", None))
41
+
42
+
43
+ def today() -> dt.date:
44
+ return dt.date.today()
45
+
46
+
47
+ def list_line(adr: Adr) -> str:
48
+ return f"{adr.id} {adr.status:<10} {adr.date} {adr.title}"
49
+
50
+
51
+ def show_text(
52
+ adr: Adr, relations: dict[str, list[int]], by_number: dict[int, Adr], out: Out
53
+ ) -> str:
54
+ lines = [f"{adr.id} {adr.title}", f"status: {adr.status} date: {adr.date}"]
55
+ if adr.tags:
56
+ lines.append("tags: " + ", ".join(adr.tags))
57
+ if adr.scope:
58
+ lines.append("scope: " + ", ".join(adr.scope))
59
+ for name, numbers in relations.items():
60
+ labels = [
61
+ f"{n:04d} {by_number[n].title}" if n in by_number else f"{n:04d} (missing)"
62
+ for n in numbers
63
+ ]
64
+ lines.append(f"{name}: " + "; ".join(labels))
65
+ lines += [f"path: {out.rel(adr.path)}", "", adr.body.strip()]
66
+ return "\n".join(lines)
67
+
68
+
69
+ def cmd_init(ctx: Ctx) -> None:
70
+ loc, force = ctx.loc, ctx.args.force
71
+ loc.dir.mkdir(parents=True, exist_ok=True)
72
+ written: list[Path] = []
73
+ if force or not loc.template.exists():
74
+ loc.template.parent.mkdir(parents=True, exist_ok=True)
75
+ repo.atomic_write(loc.template, repo.data_text("template.md"))
76
+ written.append(loc.template)
77
+ first = next(loc.dir.glob("0001-*.md"), None)
78
+ if force or first is None:
79
+ target = first or loc.dir / "0001-record-architecture-decisions.md"
80
+ meta, body = split(repo.data_text("0001.md"))
81
+ meta["date"] = today()
82
+ if name := git.user_name(ctx.cwd):
83
+ meta["decision-makers"] = repo.flow([name])
84
+ repo.atomic_write(target, render(meta, body))
85
+ written.append(target)
86
+ paths = [ctx.out.rel(p) for p in written]
87
+ text = "\n".join(f"wrote {p}" for p in paths) or "already initialised"
88
+ ctx.out.result({"dir": ctx.out.rel(loc.dir), "written": paths}, text)
89
+
90
+
91
+ def _edit(path: Path) -> None:
92
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
93
+ try:
94
+ argv = shlex.split(editor)
95
+ if not argv:
96
+ raise ValueError("empty editor command")
97
+ subprocess.run([*argv, str(path)], check=False)
98
+ except (OSError, ValueError) as exc:
99
+ raise AdrError("io", f"cannot run editor {editor!r}: {exc}") from exc
100
+
101
+
102
+ def non_negative(value: str) -> int:
103
+ n = int(value)
104
+ if n < 0:
105
+ raise argparse.ArgumentTypeError("must be >= 0")
106
+ return n
107
+
108
+
109
+ def cmd_new(ctx: Ctx) -> None:
110
+ args, loc = ctx.args, ctx.loc
111
+ if not loc.dir.is_dir():
112
+ raise repo.no_dir(loc)
113
+ wanted = {r: getattr(args, r.replace("-", "_")) for r in ("supersedes", *LINKABLE)}
114
+ targets: dict[str, list[Adr]] = {}
115
+ if any(wanted.values()):
116
+ adrs = ctx.load().adrs
117
+ targets = {
118
+ r: list({a.number: a for a in (repo.resolve(adrs, ref) for ref in refs)}.values())
119
+ for r, refs in wanted.items()
120
+ }
121
+ for old in targets["supersedes"]:
122
+ if old.status is not Status.ACCEPTED and not args.force:
123
+ raise AdrError(
124
+ "invalid-transition",
125
+ f"{old.id} is {old.status}; only accepted ADRs can be superseded",
126
+ current=old.status.value,
127
+ requested=Status.SUPERSEDED.value,
128
+ )
129
+ body = None
130
+ if args.body_file == "-":
131
+ body = sys.stdin.read()
132
+ elif args.body_file:
133
+ try:
134
+ body = Path(args.body_file).read_text(encoding="utf-8")
135
+ except (OSError, UnicodeDecodeError) as exc:
136
+ raise AdrError("io", f"cannot read {args.body_file}: {exc}") from exc
137
+ status = Status.ACCEPTED if args.accepted else Status.PROPOSED
138
+ text = repo.render_new(repo.template_text(loc), args.title, status, today(), body, args.tag)
139
+ number, skipped = repo.next_number(loc)
140
+ path = repo.create_file(loc.dir, number, repo.slugify(args.title), text)
141
+ for n, ref in sorted(skipped.items()):
142
+ ctx.out.note(f"skipped {n:04d}, taken on {ref}")
143
+ if targets:
144
+ adrs = ctx.load().adrs
145
+ by_path = {a.path: a for a in adrs}
146
+ graph = AdrGraph(adrs)
147
+ new = by_path[path]
148
+ for old in targets["supersedes"]:
149
+ repo.supersede(graph, by_path[old.path], new, force=args.force, today=today())
150
+ for relation in LINKABLE:
151
+ for dst in targets[relation]:
152
+ repo.link(graph, new, relation, by_path[dst.path])
153
+ if args.edit:
154
+ _edit(path)
155
+ adr = repo.parse_adr(path, path.read_text(encoding="utf-8"))
156
+ skipped_json = {f"{n:04d}": ref for n, ref in sorted(skipped.items())}
157
+ ctx.out.result(adr_dict(adr, ctx.out, skipped=skipped_json), ctx.out.rel(path))
158
+
159
+
160
+ def cmd_show(ctx: Ctx) -> None:
161
+ adrs = ctx.load().adrs
162
+ adr = repo.resolve(adrs, ctx.args.ref)
163
+ if ctx.args.raw:
164
+ ctx.out.result({"path": ctx.out.rel(adr.path), "text": adr.text}, adr.text)
165
+ return
166
+ graph = AdrGraph(adrs)
167
+ relations = graph.related(adr.number)
168
+ extra = {}
169
+ if relations.get("superseded-by"):
170
+ extra["successor"] = graph.successor(adr.number)
171
+ ctx.out.result(
172
+ adr_dict(adr, ctx.out, relations=relations, body=True, **extra),
173
+ show_text(adr, relations, graph.nodes, ctx.out),
174
+ )
175
+
176
+
177
+ def cmd_supersede(ctx: Ctx) -> None:
178
+ adrs = ctx.load().adrs
179
+ old, new = repo.resolve(adrs, ctx.args.old), repo.resolve(adrs, ctx.args.by)
180
+ repo.supersede(AdrGraph(adrs), old, new, force=ctx.args.force, today=today())
181
+ ctx.out.result(
182
+ {"superseded": adr_dict(old, ctx.out), "by": adr_dict(new, ctx.out)},
183
+ f"{old.id} {old.title}: superseded by {new.id} {new.title}",
184
+ )
185
+
186
+
187
+ def cmd_link(ctx: Ctx) -> None:
188
+ adrs = ctx.load().adrs
189
+ src, dst = repo.resolve(adrs, ctx.args.src), repo.resolve(adrs, ctx.args.dst)
190
+ changed = repo.link(AdrGraph(adrs), src, ctx.args.relation, dst)
191
+ verb = "linked" if changed else "already linked"
192
+ ctx.out.result(
193
+ {"changed": [ctx.out.rel(src.path)] if changed else []},
194
+ f"{verb}: {src.id} {ctx.args.relation} {dst.id}",
195
+ )
196
+
197
+
198
+ def cmd_unlink(ctx: Ctx) -> None:
199
+ adrs = ctx.load().adrs
200
+ src, dst = repo.resolve(adrs, ctx.args.src), repo.resolve(adrs, ctx.args.dst)
201
+ changed = repo.unlink(src, ctx.args.relation, dst)
202
+ text = "\n".join(f"updated {ctx.out.rel(a.path)}" for a in changed) or "no such link"
203
+ ctx.out.result({"changed": [ctx.out.rel(a.path) for a in changed]}, text)
204
+
205
+
206
+ def cmd_log(ctx: Ctx) -> None:
207
+ adr = repo.resolve(ctx.load().adrs, ctx.args.ref)
208
+ rows = git.log(ctx.loc.root, adr.path)
209
+ text = "\n".join(f"{r['date']} {r['commit'][:8]} {r['author']} {r['subject']}" for r in rows)
210
+ ctx.out.result(rows, text or "no commits yet")
211
+
212
+
213
+ def cmd_graph(ctx: Ctx) -> None:
214
+ args = ctx.args
215
+ graph = AdrGraph(ctx.load().adrs)
216
+ relations = tuple(args.relation) if args.relation else (IMPACT if args.reverse else FORWARD)
217
+ sub = None
218
+ if args.ref is not None:
219
+ start = repo.resolve(graph.adrs, args.ref).number
220
+ sub = graph.reachable(start, relations, reverse=args.reverse, depth=args.depth)
221
+ nodes, edges = sub.nodes, sub.edges
222
+ else:
223
+ edges = [e for e in graph.edges if e.relation in relations]
224
+ nodes = all_nodes(graph)
225
+ fmt = "json" if args.json else args.format
226
+ if args.rank:
227
+ ranks = graph.rank()
228
+ nodes = sorted(nodes, key=lambda n: (-ranks.get(n, 0.0), n))
229
+ if fmt == "json":
230
+ ctx.out.json_mode = True
231
+ ctx.out.result(graph_data(graph, nodes, edges, ranks), "")
232
+ else:
233
+ lines = [
234
+ f"{ranks.get(n, 0.0):.3f} {graph.label(n)} [{graph.status(n)}]" for n in nodes
235
+ ]
236
+ ctx.out.result(None, "\n".join(lines))
237
+ return
238
+ if fmt == "json":
239
+ ctx.out.json_mode = True
240
+ ctx.out.result(graph_data(graph, nodes, edges), "")
241
+ elif fmt == "mermaid":
242
+ ctx.out.result(None, render_mermaid(graph, nodes, edges))
243
+ elif fmt == "dot":
244
+ ctx.out.result(None, render_dot(graph, nodes, edges))
245
+ else:
246
+ ctx.out.result(None, render_text(graph, sub, edges))
247
+
248
+
249
+ def cmd_list(ctx: Ctx) -> None:
250
+ args = ctx.args
251
+ adrs = [
252
+ adr
253
+ for adr in ctx.load().adrs
254
+ if (not args.status or adr.status in args.status)
255
+ and (not args.tag or set(args.tag) & set(adr.tags))
256
+ ]
257
+ ctx.out.result([adr_dict(a, ctx.out) for a in adrs], "\n".join(list_line(a) for a in adrs))
258
+
259
+
260
+ def cmd_index(ctx: Ctx) -> None:
261
+ adrs = ctx.load().adrs
262
+ rows = ["| ADR | Title | Status | Date |", "|---|---|---|---|"]
263
+ rows += [
264
+ f"| [{a.id}]({a.path.name}) | {a.title.replace('|', '\\|')} | {a.status} | {a.date} |"
265
+ for a in adrs
266
+ ]
267
+ ctx.out.result([adr_dict(a, ctx.out) for a in adrs], "\n".join(rows))
268
+
269
+
270
+ def _set_status(to: Status) -> Callable[[Ctx], None]:
271
+ def cmd(ctx: Ctx) -> None:
272
+ adr = repo.resolve(ctx.load().adrs, ctx.args.ref)
273
+ before = adr.status
274
+ repo.transition(adr, to, force=ctx.args.force, today=today())
275
+ repo.save(adr)
276
+ ctx.out.result(adr_dict(adr, ctx.out), f"{adr.id} {adr.title}: {before} -> {to}")
277
+
278
+ return cmd
279
+
280
+
281
+ def cmd_check(ctx: Ctx) -> int:
282
+ root = ctx.loc.root
283
+ if ctx.args.diff and root is None:
284
+ raise AdrError("no-git", "--diff needs a git repository")
285
+ at = getattr(ctx.args, "at", None)
286
+ findings = run_checks(
287
+ ctx.load(),
288
+ template_exists=ctx.loc.template.is_file(),
289
+ tracked=(git.files_at(root, at) if at else git.tracked_files(root)) if root else None,
290
+ changed=git.changed_files(root, ctx.args.diff) if root and ctx.args.diff else None,
291
+ taken_elsewhere=set(git.numbers_on_refs(root, ctx.loc.dir)) if root and not at else None,
292
+ )
293
+ failed = any(f.severity == "error" for f in findings) or (ctx.args.strict and bool(findings))
294
+ rows = [
295
+ asdict(f) | {"path": ctx.out.rel(Path(f.path)) if Path(f.path).is_absolute() else f.path}
296
+ for f in findings
297
+ ]
298
+ text = "\n".join(f"{r['severity']}: {r['code']}: {r['message']}" for r in rows) or "ok"
299
+ ctx.out.result({"ok": not failed, "findings": rows}, text)
300
+ return EXIT_FINDINGS if failed else EXIT_OK
301
+
302
+
303
+ def _repo_path(raw: str, cwd: Path, base: Path) -> str:
304
+ try:
305
+ return (cwd / raw).resolve().relative_to(base).as_posix()
306
+ except ValueError:
307
+ return Path(raw).as_posix()
308
+
309
+
310
+ def cmd_for(ctx: Ctx) -> None:
311
+ graph = AdrGraph(ctx.load().adrs)
312
+ base = ctx.loc.root or ctx.cwd
313
+ data: dict[str, list[dict]] = {}
314
+ lines: list[str] = []
315
+ for raw in ctx.args.paths:
316
+ path = _repo_path(raw, ctx.cwd, base)
317
+ hits = graph.governing(path, include_proposed=ctx.args.all)
318
+ data[path] = [adr_dict(a, ctx.out) for a in hits]
319
+ lines.append(f"{path}:" if hits else f"{path}: no governing ADRs")
320
+ lines += [f" {a.id} {a.title} [{a.status}]" for a in hits]
321
+ ctx.out.result(data, "\n".join(lines))
322
+
323
+
324
+ def cmd_prime(ctx: Ctx) -> None:
325
+ graph = AdrGraph(ctx.load().adrs)
326
+ root = ctx.loc.root
327
+ at = getattr(ctx.args, "at", None)
328
+ changed = git.working_changes(root) if root and at is None else []
329
+ b = prime.briefing(graph, ctx.out.rel(ctx.loc.dir), changed, ctx.args.budget)
330
+
331
+ def rows(adrs: list[Adr]) -> list[dict]:
332
+ return [adr_dict(a, ctx.out, relations=graph.related(a.number)) for a in adrs]
333
+
334
+ data = {
335
+ "dir": ctx.out.rel(ctx.loc.dir),
336
+ "counts": b.counts,
337
+ "in_force": rows(b.shown),
338
+ "omitted": b.omitted,
339
+ "proposed": rows(b.proposed),
340
+ "rules": prime.RULES,
341
+ }
342
+ ctx.out.result(data, b.markdown)
343
+
344
+
345
+ def cmd_renumber(ctx: Ctx) -> None:
346
+ s = ctx.load()
347
+ adr = repo.resolve(s.adrs, ctx.args.ref)
348
+ old = ctx.out.rel(adr.path)
349
+ new_path, changed = repo.renumber(s, adr, ctx.args.to)
350
+ updated = [ctx.out.rel(a.path) for a in changed]
351
+ lines = [f"renamed {old} -> {ctx.out.rel(new_path)}", *(f"updated {p}" for p in updated)]
352
+ ctx.out.result({"from": old, "to": ctx.out.rel(new_path), "updated": updated}, "\n".join(lines))
353
+
354
+
355
+ def cmd_skill(ctx: Ctx) -> None:
356
+ text = repo.data_text("SKILL.md")
357
+ ctx.out.result({"skill": text}, text)
358
+
359
+
360
+ def build_parser() -> argparse.ArgumentParser:
361
+ # Global flags work before and after the subcommand: the subparser copies use
362
+ # SUPPRESS so they never overwrite a value given at the top level.
363
+ common = argparse.ArgumentParser(add_help=False)
364
+ parser = argparse.ArgumentParser(
365
+ prog="adr", description="Git-native Architecture Decision Records.", epilog=AGENT_HINT
366
+ )
367
+ S = argparse.SUPPRESS
368
+ for target, hidden in ((common, True), (parser, False)):
369
+ target.add_argument(
370
+ "--json",
371
+ action="store_true",
372
+ default=S if hidden else False,
373
+ help="print JSON on stdout",
374
+ )
375
+ target.add_argument(
376
+ "--dir",
377
+ type=Path,
378
+ default=S if hidden else None,
379
+ help="ADR directory (overrides discovery)",
380
+ )
381
+ target.add_argument(
382
+ "-q", "--quiet", action="store_true", default=S if hidden else False, help="no output"
383
+ )
384
+ parser.add_argument("--version", action="version", version=f"adr {__version__}")
385
+ reads = argparse.ArgumentParser(add_help=False)
386
+ for target, hidden in ((reads, True), (parser, False)):
387
+ target.add_argument(
388
+ "--at",
389
+ metavar="REF",
390
+ default=S if hidden else None,
391
+ help="read ADRs as of a git revision (read commands only)",
392
+ )
393
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
394
+
395
+ def add(
396
+ name: str, func: Callable[[Ctx], int | None], help_: str, *, reads_: bool = False
397
+ ) -> argparse.ArgumentParser:
398
+ p = sub.add_parser(
399
+ name, parents=[common, reads] if reads_ else [common], help=help_, description=help_
400
+ )
401
+ p.set_defaults(func=func, reads=reads_)
402
+ return p
403
+
404
+ p = add("init", cmd_init, "create the ADR directory, template and ADR 0001")
405
+ p.add_argument("--force", action="store_true", help="overwrite the template and ADR 0001")
406
+
407
+ p = add("new", cmd_new, "create a new ADR")
408
+ p.add_argument("title")
409
+ p.add_argument("--accepted", action="store_true", help="create as accepted")
410
+ p.add_argument("--tag", action="append", default=[])
411
+ p.add_argument("--body-file", metavar="PATH", help="body below the title ('-' = stdin)")
412
+ p.add_argument("--edit", action="store_true", help="open $VISUAL/$EDITOR afterwards")
413
+ for relation in ("supersedes", *LINKABLE):
414
+ p.add_argument(f"--{relation}", action="append", default=[], metavar="REF")
415
+ p.add_argument("--force", action="store_true", help="supersede ADRs that are not accepted")
416
+
417
+ p = add("show", cmd_show, "show one ADR", reads_=True)
418
+ p.add_argument("ref")
419
+ p.add_argument("--raw", action="store_true", help="print the file verbatim")
420
+
421
+ p = add("list", cmd_list, "list ADRs", reads_=True)
422
+ p.add_argument("--status", action="append", choices=[s.value for s in Status])
423
+ p.add_argument("--tag", action="append")
424
+
425
+ add("index", cmd_index, "print a Markdown index table", reads_=True)
426
+
427
+ for name, to in (
428
+ ("accept", Status.ACCEPTED),
429
+ ("reject", Status.REJECTED),
430
+ ("deprecate", Status.DEPRECATED),
431
+ ):
432
+ p = add(name, _set_status(to), f"mark an ADR as {to}")
433
+ p.add_argument("ref")
434
+ p.add_argument("--force", action="store_true", help="allow any transition (repairs)")
435
+
436
+ p = add("check", cmd_check, "validate all ADRs", reads_=True)
437
+ p.add_argument("--strict", action="store_true", help="fail on warnings too")
438
+ p.add_argument("--diff", metavar="REF", help="also report changed files governed by ADRs")
439
+
440
+ p = add("supersede", cmd_supersede, "mark OLD as superseded by NEW")
441
+ p.add_argument("old", metavar="OLD")
442
+ p.add_argument("--by", required=True, metavar="NEW")
443
+ p.add_argument("--force", action="store_true", help="supersede even if OLD is not accepted")
444
+
445
+ for name, func, help_ in (
446
+ ("link", cmd_link, "add a relation"),
447
+ ("unlink", cmd_unlink, "remove a relation"),
448
+ ):
449
+ p = add(name, func, help_)
450
+ p.add_argument("src", metavar="REF")
451
+ p.add_argument("relation", choices=LINKABLE)
452
+ p.add_argument("dst", metavar="REF")
453
+
454
+ p = add("graph", cmd_graph, "render the ADR graph", reads_=True)
455
+ p.add_argument("ref", nargs="?")
456
+ p.add_argument("--relation", action="append", choices=FORWARD)
457
+ p.add_argument("--depth", type=non_negative)
458
+ p.add_argument("--reverse", action="store_true", help="follow inverse edges (impact)")
459
+ p.add_argument("--format", choices=("text", "mermaid", "dot", "json"), default="text")
460
+ p.add_argument("--rank", action="store_true", help="order ADRs by importance (PageRank)")
461
+
462
+ p = add("renumber", cmd_renumber, "give an ADR a new number and update references")
463
+ p.add_argument("ref")
464
+ p.add_argument("--to", type=int, required=True, metavar="N")
465
+
466
+ p = add("log", cmd_log, "show the git history of an ADR")
467
+ p.add_argument("ref")
468
+
469
+ p = add("for", cmd_for, "list the ADRs in force that govern each path", reads_=True)
470
+ p.add_argument("paths", nargs="+", metavar="PATH")
471
+ p.add_argument("--all", action="store_true", help="include proposed ADRs")
472
+
473
+ p = add("prime", cmd_prime, "print the agent briefing", reads_=True)
474
+ p.add_argument(
475
+ "--budget", type=non_negative, metavar="N", help="limit decisions in force to about N words"
476
+ )
477
+
478
+ add("skill", cmd_skill, "print the bundled agent skill (SKILL.md)")
479
+ return parser
480
+
481
+
482
+ def main(
483
+ argv: list[str] | None = None,
484
+ cwd: Path | None = None,
485
+ env: Mapping[str, str] | None = None,
486
+ ) -> int:
487
+ parser = build_parser()
488
+ args = parser.parse_args(argv)
489
+ if args.at and not args.reads:
490
+ parser.error("--at is only valid for read commands (show, list, index, graph, check, for)")
491
+ if args.func is cmd_graph and args.rank and not args.json and args.format in ("mermaid", "dot"):
492
+ parser.error("--rank supports only text and json output")
493
+ cwd = (cwd or Path.cwd()).resolve()
494
+ env = os.environ if env is None else env
495
+ try:
496
+ loc = repo.locate(cwd, env, args.dir)
497
+ out = Out(json_mode=args.json, quiet=args.quiet, base=loc.root or cwd)
498
+ return args.func(Ctx(args, loc, out, cwd)) or EXIT_OK
499
+ except AdrError as exc:
500
+ return error(exc, args.json)
501
+
502
+
503
+ def run() -> None:
504
+ sys.exit(main())