mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
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).
This commit is contained in:
parent
0458b4f612
commit
04715c1dfd
4 changed files with 915 additions and 0 deletions
23
docs/architecture/INDEX.md
Normal file
23
docs/architecture/INDEX.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Architecture Decision Records
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) for Obsidian MCP Plugin.
|
||||
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
|
||||
|
||||
_This index is auto-generated by `adr index`. Configuration: [`adr.yaml`](./adr.yaml)_
|
||||
|
||||
## Core
|
||||
_Server architecture, transport, connection handling, plugin lifecycle_
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [ADR-100](./core/ADR-100-remove-concurrent-mode-toggle-and-simplify-connection-setup.md) | Remove concurrent mode toggle and simplify connection setup | Draft |
|
||||
39
docs/architecture/adr.yaml
Normal file
39
docs/architecture/adr.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# ADR Configuration for obsidian-mcp-plugin
|
||||
|
||||
project_name: Obsidian MCP Plugin
|
||||
|
||||
domains:
|
||||
core:
|
||||
range: [100, 199]
|
||||
name: Core
|
||||
description: Server architecture, transport, connection handling, plugin lifecycle
|
||||
folder: core
|
||||
|
||||
tools:
|
||||
range: [200, 299]
|
||||
name: Tools & API
|
||||
description: MCP tool design, semantic operations, graph operations, formatters
|
||||
folder: tools
|
||||
|
||||
delivery:
|
||||
range: [300, 399]
|
||||
name: Delivery
|
||||
description: Build, release, versioning, community plugin submission
|
||||
folder: delivery
|
||||
|
||||
statuses:
|
||||
- Draft
|
||||
- Proposed
|
||||
- Accepted
|
||||
- Superseded
|
||||
- Deprecated
|
||||
|
||||
defaults:
|
||||
deciders: [aaronsb]
|
||||
status: Draft
|
||||
|
||||
legacy:
|
||||
range: [1, 99]
|
||||
label: "Legacy"
|
||||
|
||||
viewer: cat {file}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
status: Draft
|
||||
date: 2026-03-14
|
||||
deciders:
|
||||
- aaronsb
|
||||
related: []
|
||||
---
|
||||
|
||||
# ADR-100: Remove concurrent mode toggle and simplify connection setup
|
||||
|
||||
## Context
|
||||
|
||||
The plugin currently has a "concurrent sessions for agent swarms" toggle in settings, with a separate code path for single-server mode (`MCPServer`) vs pooled mode (`MCPServerPool`). This toggle was added when no MCP client supported multiple concurrent connections over HTTP — Claude Desktop required `mcp-remote` as a stdio bridge, and agent swarms were experimental.
|
||||
|
||||
The MCP ecosystem has since matured:
|
||||
|
||||
- **Claude Code** natively supports Streamable HTTP transport (`claude mcp add --transport http`)
|
||||
- **Claude Desktop** now supports direct HTTP transport in its config
|
||||
- **Cline, Continue, and other clients** support HTTP transport
|
||||
- The MCP SDK itself handles session multiplexing at the transport layer
|
||||
|
||||
The dual code path (concurrent vs non-concurrent) adds maintenance burden with no user benefit. The pool handles single-client connections gracefully — it's just a pool of size 1.
|
||||
|
||||
The settings UI also presents 4 connection options (Claude Code command, direct HTTP, mcp-remote, Windows mcp-remote workaround), which confuses users. The `mcp-remote` option was a bridge for clients that couldn't speak HTTP — that world no longer exists.
|
||||
|
||||
Additionally, the direct HTTP config template embeds credentials in the URL (`obsidian:key@localhost`), which is non-standard and some clients may not parse correctly.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Always run in concurrent/pooled mode
|
||||
|
||||
Remove the `enableConcurrentSessions` setting and the single-server code path. The `MCPServerPool` becomes the only mode. Remove `maxConcurrentConnections` from the settings UI (keep it as a sensible default, e.g. 32, or move to an advanced/hidden setting).
|
||||
|
||||
### 2. Simplify connection templates to two options
|
||||
|
||||
**Claude Code** — a ready-to-copy `claude mcp add` command:
|
||||
```
|
||||
claude mcp add --transport http obsidian http://localhost:3001/mcp --header "Authorization: Bearer <key>"
|
||||
```
|
||||
|
||||
**Other MCP clients** — a single JSON template using standard header-based auth:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Vault": {
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Remove deprecated connection options
|
||||
|
||||
- Drop `mcp-remote` option (Option 2) — clients speak HTTP natively now
|
||||
- Drop Windows `mcp-remote` workaround (Option 2a) — no longer needed
|
||||
- Drop URL-embedded credentials format — use standard `Authorization` header
|
||||
|
||||
### 4. Update documentation
|
||||
|
||||
- Update README connection setup section
|
||||
- Update plugin settings UI descriptions
|
||||
- Remove references to "concurrent mode" and "agent swarms" toggle
|
||||
- Update CLAUDE.md if it references the concurrent setting
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Simpler settings UI — fewer toggles, fewer "which option do I pick?" moments
|
||||
- One code path to maintain instead of two
|
||||
- Standard auth pattern (Bearer header) works with all clients
|
||||
- Reduced confusion for new users setting up the plugin
|
||||
|
||||
### Negative
|
||||
|
||||
- Users on very old MCP clients that only support stdio may lose a documented path (mcp-remote). They can still figure it out from the JSON template, but it's no longer hand-held.
|
||||
- Breaking change for anyone who has `enableConcurrentSessions: false` in their saved settings — migration should handle this gracefully (ignore the field).
|
||||
|
||||
### Neutral
|
||||
|
||||
- The `MCPServerPool` default of 32 max connections remains reasonable for all use cases
|
||||
- The API key mechanism is unchanged — only the config template format changes
|
||||
- HTTPS/SSL configuration is unaffected
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep the toggle but default to on**: Still maintains two code paths. Adds complexity for zero benefit since the pool handles single connections fine.
|
||||
- **Keep mcp-remote as a third option**: Adds UI clutter for a shrinking audience. Users who need it can adapt the JSON template.
|
||||
- **Remove auth entirely and rely on localhost trust**: Rejected — some users expose the port via tunnels or run in multi-user environments. Auth should remain but with a simpler config format.
|
||||
758
docs/scripts/adr
Executable file
758
docs/scripts/adr
Executable file
|
|
@ -0,0 +1,758 @@
|
|||
#!/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())
|
||||
Loading…
Reference in a new issue