mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 16:40:32 +00:00
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).
758 lines
24 KiB
Python
Executable file
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())
|