aaronsb_obsidian-mcp-plugin/docs/scripts/adr
Aaron Bockelie 04715c1dfd chore: Add ADR tooling and ADR-100 (simplify connection setup)
Scaffolds Architecture Decision Records for the project with three
domains: core (100-199), tools (200-299), delivery (300-399).

ADR-100 proposes removing the concurrent mode toggle, dropping the
mcp-remote connection option, and simplifying to two config templates
(Claude Code command + standard JSON with header auth).
2026-03-14 22:53:46 -05:00

758 lines
24 KiB
Python
Executable file

#!/usr/bin/env python3
"""
ADR - Architecture Decision Record CLI Tool
A librarian for managing Architecture Decision Records.
Usage:
adr list [--domain DOMAIN] [--status STATUS] [--group]
adr view <number> # View an ADR (aliases: v, show)
adr new <domain> <title>
adr lint [--check] [paths...]
adr index [-y]
adr domains
adr config
Configuration is loaded from docs/architecture/adr.yaml
"""
import argparse
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
# Check for PyYAML
try:
import yaml
except ImportError:
print("Error: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
print(" Or system-wide: sudo apt install python3-yaml", file=sys.stderr)
sys.exit(1)
# ============================================================================
# Configuration
# ============================================================================
def get_config_path() -> Path:
"""Get path to adr.yaml config file."""
return Path(__file__).parent.parent / 'architecture' / 'adr.yaml'
def load_config() -> dict:
"""Load configuration from adr.yaml."""
config_path = get_config_path()
if not config_path.exists():
print(f"Error: Config not found: {config_path}", file=sys.stderr)
print("Run from project root or create docs/architecture/adr.yaml", file=sys.stderr)
sys.exit(1)
try:
with open(config_path) as f:
config = yaml.safe_load(f)
except yaml.YAMLError as e:
print(f"Error: Invalid YAML in config: {e}", file=sys.stderr)
sys.exit(1)
# Validate required fields
if 'domains' not in config:
print("Error: Config missing 'domains' section", file=sys.stderr)
sys.exit(1)
# Convert range lists to tuples for easier use
for domain, cfg in config.get('domains', {}).items():
if 'range' in cfg and isinstance(cfg['range'], list):
cfg['range'] = tuple(cfg['range'])
if 'legacy' in config and 'range' in config['legacy']:
if isinstance(config['legacy']['range'], list):
config['legacy']['range'] = tuple(config['legacy']['range'])
return config
# Global config (loaded on first access)
_config = None
def get_config() -> dict:
"""Get cached config."""
global _config
if _config is None:
_config = load_config()
return _config
def get_domains() -> dict:
"""Get domain configuration."""
return get_config().get('domains', {})
def get_statuses() -> set:
"""Get valid statuses."""
return set(get_config().get('statuses', ['Draft', 'Proposed', 'Accepted', 'Superseded', 'Deprecated']))
def relative_path(path: Path, base: Path = None) -> Path:
"""Get path relative to base, or return absolute if not possible."""
if base is None:
base = Path.cwd()
try:
return path.relative_to(base)
except ValueError:
return path
def get_defaults() -> dict:
"""Get default values for new ADRs."""
return get_config().get('defaults', {'deciders': [], 'status': 'Draft'})
def get_legacy_range() -> tuple:
"""Get legacy ADR number range."""
legacy = get_config().get('legacy', {})
return legacy.get('range', (1, 99))
def _detect_git_user() -> Optional[str]:
"""Detect current git user (GitHub username or git config name)."""
# Try GitHub username from gh CLI
try:
result = subprocess.run(
['gh', 'api', 'user', '--jq', '.login'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Fall back to git config user.name
try:
result = subprocess.run(
['git', 'config', 'user.name'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
# ============================================================================
# Data Classes
# ============================================================================
@dataclass
class ADRInfo:
path: Path
number: Optional[str] = None
title: Optional[str] = None
status: Optional[str] = None
date: Optional[str] = None
deciders: list = field(default_factory=list)
related: list = field(default_factory=list)
domain: Optional[str] = None
issues: list = field(default_factory=list)
@dataclass
class Issue:
message: str
severity: str = 'warning'
# ============================================================================
# Parsing
# ============================================================================
TITLE_PATTERN = re.compile(r'^# ADR-(\d+(?:\.\d+)?): (.+)$')
def parse_adr(path: Path) -> ADRInfo:
"""Parse an ADR file and extract metadata."""
info = ADRInfo(path=path)
try:
content = path.read_text()
except Exception as e:
info.issues.append(Issue(f"Cannot read: {e}", 'error'))
return info
lines = content.split('\n')
# Parse YAML frontmatter
has_frontmatter = lines and lines[0].strip() == '---'
if has_frontmatter:
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == '---':
end_idx = i
break
if end_idx:
yaml_content = '\n'.join(lines[1:end_idx])
try:
data = yaml.safe_load(yaml_content) or {}
info.status = data.get('status')
info.date = str(data.get('date', '')) if data.get('date') else None
deciders = data.get('deciders', [])
info.deciders = deciders if isinstance(deciders, list) else [deciders]
info.related = data.get('related', [])
except yaml.YAMLError as e:
info.issues.append(Issue(f"YAML error: {e}", 'error'))
else:
info.issues.append(Issue("Opening --- found but no closing --- for frontmatter", 'error'))
else:
# Check for inline metadata (pre-YAML pattern)
inline_meta = any(re.match(r'^(Status|Date|Deciders):\s', line) for line in lines[:15])
if inline_meta:
info.issues.append(Issue(
"No YAML frontmatter — found inline metadata (Status:/Date:/Deciders:). "
"Convert to YAML frontmatter: wrap in --- delimiters, use lowercase keys",
'error'))
else:
info.issues.append(Issue("No YAML frontmatter found", 'error'))
# Find title
for line in lines:
match = TITLE_PATTERN.match(line)
if match:
info.number = match.group(1)
info.title = match.group(2)
break
# Determine domain from number range (authoritative) or folder name (fallback)
# Range takes precedence: an ADR's number definitively places it in a domain,
# even if the file is physically in a different domain's folder.
if info.number:
try:
base_num = int(info.number.split('.')[0])
for domain, config in get_domains().items():
if config['range'][0] <= base_num <= config['range'][1]:
info.domain = domain
break
except ValueError:
pass
# Fallback: determine from folder name (for unnumbered or out-of-range ADRs)
if not info.domain:
folder_name = path.parent.name
for domain, config in get_domains().items():
folders = config['folder']
if isinstance(folders, str):
folders = [folders]
if folder_name in folders:
info.domain = domain
break
# Validation
if not info.number:
info.issues.append(Issue("Missing ADR number in title", 'error'))
# Skip field-level checks if no frontmatter — root cause already reported
if has_frontmatter:
valid_statuses = get_statuses()
if not info.status:
info.issues.append(Issue("Missing status in frontmatter", 'error'))
elif info.status not in valid_statuses:
info.issues.append(Issue(f"Invalid status: {info.status} (valid: {', '.join(sorted(valid_statuses))})", 'warning'))
if not info.date:
info.issues.append(Issue("Missing date in frontmatter", 'error'))
if not info.deciders:
info.issues.append(Issue("Missing deciders in frontmatter", 'warning'))
return info
def find_adrs() -> list[Path]:
"""Find all ADR files."""
docs_root = Path(__file__).parent.parent
arch_dir = docs_root / 'architecture'
return sorted(arch_dir.rglob("ADR-*.md"))
def get_all_adrs() -> list[ADRInfo]:
"""Parse all ADR files."""
return [parse_adr(p) for p in find_adrs()]
# ============================================================================
# Commands
# ============================================================================
def cmd_list(args):
"""List all ADRs."""
adrs = get_all_adrs()
domains = get_domains()
# Filter by domain
if args.domain:
adrs = [a for a in adrs if a.domain == args.domain]
# Filter by status
if args.status:
adrs = [a for a in adrs if a.status and a.status.lower() == args.status.lower()]
# Sort by number
def sort_key(adr):
if not adr.number:
return (9999, 0)
parts = adr.number.split('.')
return (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0)
adrs.sort(key=sort_key)
project = get_config().get('project_name', 'Project')
print(f"\n{project} — Architecture Decision Records ({len(adrs)} total)")
print("=" * 55)
def status_icon(status):
return {
'Draft': '📝',
'Proposed': '💡',
'Accepted': '',
'Superseded': '📦',
'Deprecated': '🗑️'
}.get(status, '')
def print_adr(adr):
print(f" {status_icon(adr.status)} ADR-{adr.number or '???':8} {adr.title or '(no title)'}")
if args.group:
# Group by domain - show domains in config order, then legacy
for domain_key, domain_info in domains.items():
domain_adrs = [a for a in adrs if a.domain == domain_key]
if not domain_adrs:
continue
print(f"\n## {domain_info.get('name', domain_key)} ({domain_key})")
print("-" * 50)
for adr in domain_adrs:
print_adr(adr)
# Legacy (no domain)
legacy_adrs = [a for a in adrs if not a.domain]
if legacy_adrs:
legacy_label = get_config().get('legacy', {}).get('label', 'Legacy')
print(f"\n## {legacy_label}")
print("-" * 50)
for adr in legacy_adrs:
print_adr(adr)
else:
# Flat list, sorted by number
for adr in adrs:
print_adr(adr)
print(f"\nTotal: {len(adrs)} ADRs")
return 0
def cmd_view(args):
"""View an ADR using the configured viewer."""
import shutil
# Normalize the ADR reference (accept "38", "038", "ADR-038", "ADR-38", "51.2")
ref = args.adr.upper().replace('ADR-', '').lstrip('0') or '0'
# Helper to extract number from filename (e.g., ADR-051.2-foo.md -> 51.2)
def filename_number(path):
match = re.match(r'ADR-(\d+(?:\.\d+)?)', path.name, re.IGNORECASE)
return match.group(1).lstrip('0') if match else None
# Find the ADR by title number or filename number
adrs = get_all_adrs()
matches = [a for a in adrs if
(a.number and a.number.lstrip('0') == ref) or
filename_number(a.path) == ref]
if not matches:
print(f"Error: ADR not found: {args.adr}", file=sys.stderr)
print(f"Use `adr list` to see available ADRs.", file=sys.stderr)
return 1
if len(matches) > 1:
print(f"Multiple ADRs match '{args.adr}':")
for adr in matches:
print(f" ADR-{adr.number}: {adr.title}")
return 1
adr = matches[0]
# Get viewer command from config
viewer_cmd = get_config().get('viewer', 'cat {file}')
# Check if viewer command exists
viewer_bin = viewer_cmd.split()[0]
if not shutil.which(viewer_bin):
print(f"Warning: Viewer '{viewer_bin}' not found, using cat", file=sys.stderr)
viewer_cmd = 'cat {file}'
# Build and run command
cmd = viewer_cmd.replace('{file}', str(adr.path))
return subprocess.run(cmd, shell=True).returncode
def cmd_new(args):
"""Create a new ADR."""
domain = args.domain.lower()
domains = get_domains()
defaults = get_defaults()
if domain not in domains:
print(f"Error: Unknown domain '{domain}'", file=sys.stderr)
print(f"Valid domains: {', '.join(domains.keys())}", file=sys.stderr)
return 1
config = domains[domain]
# Find next available number in range
adrs = get_all_adrs()
used_numbers = set()
for adr in adrs:
if adr.number:
try:
used_numbers.add(int(adr.number.split('.')[0]))
except ValueError:
pass
next_num = None
for n in range(config['range'][0], config['range'][1] + 1):
if n not in used_numbers:
next_num = n
break
if next_num is None:
print(f"Error: No available numbers in {domain} range ({config['range'][0]}-{config['range'][1]})", file=sys.stderr)
return 1
# Generate slug from title
slug = re.sub(r'[^a-z0-9]+', '-', args.title.lower()).strip('-')
# Create file path (use first folder if multiple)
docs_root = Path(__file__).parent.parent
folders = config['folder']
primary_folder = folders[0] if isinstance(folders, list) else folders
folder = docs_root / 'architecture' / primary_folder
filename = f"ADR-{next_num:03d}-{slug}.md"
filepath = folder / filename
if filepath.exists():
print(f"Error: File already exists: {filepath}", file=sys.stderr)
return 1
# Generate content
today = date.today().isoformat()
default_status = defaults.get('status', 'Draft')
default_deciders = defaults.get('deciders', [])
# Auto-detect current user if no deciders configured
if not default_deciders:
git_user = _detect_git_user()
if git_user:
default_deciders = [git_user]
deciders_yaml = '\n'.join(f' - {d}' for d in default_deciders) if default_deciders else ' - # add deciders'
content = f'''---
status: {default_status}
date: {today}
deciders:
{deciders_yaml}
related: []
---
# ADR-{next_num:03d}: {args.title}
## Context
[What is the issue that we're seeing that is motivating this decision or change?]
## Decision
[What is the change that we're proposing and/or doing?]
## Consequences
### Positive
- [What becomes easier?]
### Negative
- [What becomes harder?]
### Neutral
- [What other changes does this enable or require?]
## Alternatives Considered
- [What other options were evaluated?]
- [Why were they rejected?]
'''
# Write file
folder.mkdir(parents=True, exist_ok=True)
filepath.write_text(content)
print(f"Created: {relative_path(filepath)}")
print(f" Domain: {config['name']} ({domain})")
print(f" Number: ADR-{next_num:03d}")
return 0
def cmd_lint(args):
"""Lint ADR files for issues."""
if args.paths:
paths = [Path(p) for p in args.paths]
adrs = [parse_adr(p) for p in paths]
else:
adrs = get_all_adrs()
total_errors = 0
total_warnings = 0
# Status summary
status_counts = {}
for adr in adrs:
status = adr.status or 'Unknown'
status_counts[status] = status_counts.get(status, 0) + 1
print(f"\nScanned: {len(adrs)} ADRs")
print(f"\nStatus distribution:")
for status, count in sorted(status_counts.items()):
print(f" {status}: {count}")
# Issues
files_with_issues = [adr for adr in adrs if adr.issues]
if files_with_issues:
print(f"\n{''*60}")
print(f"Issues found in {len(files_with_issues)} files:")
print(f"{''*60}")
for adr in files_with_issues:
print(f"\n{relative_path(adr.path)}")
for issue in adr.issues:
icon = '' if issue.severity == 'error' else '⚠️'
print(f" {icon} {issue.message}")
if issue.severity == 'error':
total_errors += 1
else:
total_warnings += 1
print(f"\n{''*60}")
print(f"Summary: {total_errors} errors, {total_warnings} warnings")
print(f"{''*60}\n")
if args.check and total_errors > 0:
return 1
return 0
def cmd_index(args):
"""Generate/update the ADR index file."""
adrs = get_all_adrs()
domains = get_domains()
legacy_range = get_legacy_range()
# Sort by number
def sort_key(adr):
if not adr.number:
return (9999, 0)
parts = adr.number.split('.')
return (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0)
adrs.sort(key=sort_key)
lines = [
"# Architecture Decision Records",
"",
f"This directory contains Architecture Decision Records (ADRs) for {get_config().get('project_name', 'this project')}.",
"Each ADR documents a significant architectural decision, its context, and consequences.",
"",
"## ADR Format",
"",
"All ADRs follow a consistent format:",
"- **Status:** Draft / Proposed / Accepted / Deprecated / Superseded",
"- **Date:** When the decision was made",
"- **Deciders:** Who made the decision",
"- **Context:** The problem or situation requiring a decision",
"- **Decision:** The architectural choice made",
"- **Consequences:** Benefits, drawbacks, and other impacts",
"",
f"_This index is auto-generated by `adr index`. Configuration: [`adr.yaml`](./adr.yaml)_",
"",
]
# By domain
for domain, config in domains.items():
domain_adrs = [a for a in adrs if a.domain == domain]
if not domain_adrs:
continue
lines.append(f"## {config['name']}")
lines.append(f"_{config['description']}_")
lines.append("")
lines.append("| ADR | Title | Status |")
lines.append("|-----|-------|--------|")
for adr in domain_adrs:
# Use actual folder from path, not config (supports multiple folders)
folder = adr.path.parent.name
filename = adr.path.name
link = f"[ADR-{adr.number}](./{folder}/{filename})"
lines.append(f"| {link} | {adr.title or '?'} | {adr.status or '?'} |")
lines.append("")
# Legacy (pre-domain numbering)
legacy_label = get_config().get('legacy', {}).get('label', 'Legacy')
uncategorized = [a for a in adrs if not a.domain]
if uncategorized:
lines.append(f"## {legacy_label}")
lines.append("")
lines.append("| ADR | Title | Status |")
lines.append("|-----|-------|--------|")
for adr in uncategorized:
rel_path = adr.path.relative_to(adr.path.parent.parent)
link = f"[ADR-{adr.number}](./{rel_path})"
lines.append(f"| {link} | {adr.title or '?'} | {adr.status or '?'} |")
lines.append("")
content = '\n'.join(lines)
# Check against existing index
docs_root = Path(__file__).parent.parent
index_path = docs_root / 'architecture' / 'INDEX.md'
if index_path.exists():
existing = index_path.read_text()
if existing == content:
print(f"Index is up to date: {relative_path(index_path)}")
print(f" {len(adrs)} ADRs across {len(domains)} domains")
return 0
# Show what changed
existing_lines = existing.splitlines()
new_lines = content.splitlines()
added = len([l for l in new_lines if l not in existing_lines])
removed = len([l for l in existing_lines if l not in new_lines])
print(f"Index needs updating: {relative_path(index_path)}")
print(f" {len(adrs)} ADRs across {len(domains)} domains")
print(f" Changes: +{added} -{removed} lines")
if not args.yes:
try:
response = input("\nUpdate index? [y/N]: ")
if response.lower() != 'y':
print("Skipped.")
return 0
except (KeyboardInterrupt, EOFError):
print("\nSkipped.")
return 0
index_path.write_text(content)
print(f"Updated: {relative_path(index_path)}")
return 0
def cmd_domains(args):
"""List available domains."""
domains = get_domains()
project = get_config().get('project_name', 'Project')
print(f"\n{project} — ADR Domain Number Series")
print("=" * 60)
print(f"(from {relative_path(get_config_path())})")
for domain, config in domains.items():
r = config['range']
folders = config['folder']
if isinstance(folders, list):
folder_str = ', '.join(f"{f}/" for f in folders)
else:
folder_str = f"{folders}/"
print(f"\n {domain:8} ({r[0]:3}-{r[1]:3}) {config['name']}")
print(f" {config['description']}")
print(f" Folder: {folder_str}")
# Show legacy range
legacy = get_config().get('legacy', {})
if legacy:
r = legacy.get('range', (1, 99))
print(f"\n {'legacy':8} ({r[0]:3}-{r[1]:3}) {legacy.get('label', 'Legacy')}")
print()
return 0
def cmd_config(args):
"""Show current configuration."""
config_path = get_config_path()
print(f"\nConfig file: {config_path}")
print("-" * 60)
try:
print(config_path.read_text())
except Exception as e:
print(f"Error reading config: {e}", file=sys.stderr)
return 1
return 0
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description='ADR - Architecture Decision Record CLI Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Command')
# list
p_list = subparsers.add_parser('list', aliases=['ls'], help='List ADRs')
p_list.add_argument('--domain', '-d', help='Filter by domain')
p_list.add_argument('--status', '-s', help='Filter by status')
p_list.add_argument('--group', '-g', action='store_true',
help='Group by domain')
# view
p_view = subparsers.add_parser('view', aliases=['v', 'show'], help='View an ADR')
p_view.add_argument('adr', help='ADR number (e.g., 38, 038, ADR-038)')
# new
p_new = subparsers.add_parser('new', help='Create new ADR')
p_new.add_argument('domain', help='Domain (see `adr domains` for list)')
p_new.add_argument('title', help='ADR title')
# lint
p_lint = subparsers.add_parser('lint', help='Lint ADR files')
p_lint.add_argument('paths', nargs='*', help='Specific files to lint')
p_lint.add_argument('--check', action='store_true', help='Exit 1 if errors (CI mode)')
# index
index_parser = subparsers.add_parser('index', help='Generate ADR index')
index_parser.add_argument('-y', '--yes', action='store_true',
help='Update without prompting')
# domains
subparsers.add_parser('domains', help='List domain number series')
# config
subparsers.add_parser('config', help='Show configuration')
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
commands = {
'list': cmd_list,
'ls': cmd_list,
'view': cmd_view,
'v': cmd_view,
'show': cmd_view,
'new': cmd_new,
'lint': cmd_lint,
'index': cmd_index,
'domains': cmd_domains,
'config': cmd_config,
}
return commands[args.command](args)
if __name__ == '__main__':
sys.exit(main())