use adrs_core::{ConfigSource, discover};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use std::io;
use std::path::PathBuf;
mod commands;
#[cfg(feature = "mcp")]
mod mcp;
#[derive(Parser)]
#[command(name = "adrs")]
#[command(author, version)]
#[command(about = "Manage Architecture Decision Records")]
#[command(long_about = "\
A command-line tool for creating and managing Architecture Decision Records (ADRs).
Compatible with adr-tools repositories. Supports both Nygard and MADR 4.0.0 formats.
GETTING STARTED:
adrs init Create a new ADR repository
adrs new \"My Decision\" Create your first ADR
adrs list View all ADRs
adrs doctor Check repository health
FORMATS:
nygard Classic adr-tools format (default)
madr MADR 4.0.0 with structured metadata
MODES:
Compatible (default) Works with adr-tools, metadata in markdown
NextGen (--ng) YAML frontmatter for richer metadata (tags, custom fields)
EXAMPLES:
adrs init Initialize repository
adrs new --format madr \"Use PostgreSQL\" Create MADR-format ADR
adrs new --supersedes 2 \"Use MySQL instead\" Supersede an ADR
adrs link 3 Amends 1 Link two ADRs (auto-derives reverse)
adrs generate toc > doc/adr/README.md Generate table of contents
DOCUMENTATION: https://joshrotenberg.com/adrs/")]
struct Cli {
#[arg(
long,
global = true,
help = "Enable NextGen mode with YAML frontmatter"
)]
ng: bool,
#[arg(short = 'C', long = "cwd", global = true, value_name = "DIR")]
working_dir: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(after_long_help = "\
EXAMPLES:
adrs init Initialize in doc/adr (default)
adrs init docs/decisions Use custom directory
adrs --ng init Initialize with NextGen mode (YAML frontmatter)
Creates the ADR directory and an initial ADR documenting the use of ADRs.
If ADRs already exist in the directory, they are preserved.")]
Init {
#[arg(default_value = "doc/adr")]
directory: PathBuf,
},
#[command(after_long_help = "\
EXAMPLES:
adrs new \"Use PostgreSQL for persistence\" Basic ADR
adrs new --format madr \"Use React\" MADR format with structured sections
adrs new --supersedes 2 \"Use MySQL instead\" Supersede ADR 2
adrs new --link \"2:Amends:Amended by\" \"...\" Create with link to ADR 2
adrs new --status accepted \"Already decided\" Start with accepted status
adrs new --no-edit \"Quick note\" Create without opening editor
NEXTGEN MODE (--ng):
adrs --ng new -t api,security \"Auth Design\" Create with tags (requires --ng)
adrs --ng new \"My Decision\" Enable YAML frontmatter metadata
LINK FORMAT:
The --link option uses format: TARGET:KIND:REVERSE_KIND
Example: \"2:Amends:Amended by\" links to ADR 2 with bidirectional links")]
New {
title: String,
#[arg(short, long, value_name = "NUMBER")]
supersedes: Option<u32>,
#[arg(short, long, value_name = "LINK")]
link: Option<String>,
#[arg(short, long, value_name = "FORMAT")]
format: Option<String>,
#[arg(short, long, value_name = "VARIANT")]
variant: Option<String>,
#[arg(long, value_name = "PATH")]
template: Option<PathBuf>,
#[arg(long, value_name = "STATUS")]
status: Option<String>,
#[arg(short = 't', long, value_name = "TAGS", value_delimiter = ',')]
tags: Option<Vec<String>>,
#[arg(long)]
no_edit: bool,
},
Edit {
adr: String,
},
#[command(after_long_help = "\
EXAMPLES:
adrs list List all ADRs (default format)
adrs list -l Detailed view with status and date
adrs list --status accepted Show only accepted ADRs
adrs list --since 2024-01-01 ADRs created since Jan 1, 2024
adrs list --until 2024-06-30 ADRs created before July 2024
adrs list --tag security Filter by tag (requires --ng mode)
adrs list --decider \"Alice\" Filter by decision maker (MADR)
COMBINING FILTERS:
adrs list -l --status accepted --since 2024-01-01
adrs list --tag api --status proposed")]
List {
#[arg(short, long, value_name = "STATUS")]
status: Option<String>,
#[arg(long, value_name = "DATE")]
since: Option<String>,
#[arg(long, value_name = "DATE")]
until: Option<String>,
#[arg(long, value_name = "NAME")]
decider: Option<String>,
#[arg(short = 't', long, value_name = "TAG")]
tag: Option<String>,
#[arg(short = 'l', long)]
long: bool,
},
#[command(after_long_help = "\
EXAMPLES:
adrs search postgres Search all content for 'postgres'
adrs search -t database Search titles only
adrs search --status accepted auth Search accepted ADRs for 'auth'
adrs search -c PostgreSQL Case-sensitive search
TIPS:
- Search is case-insensitive by default
- Searches both title and full content unless -t is used
- Combine with --status to narrow results")]
Search {
query: String,
#[arg(short = 't', long)]
title: bool,
#[arg(short, long, value_name = "STATUS")]
status: Option<String>,
#[arg(short = 'c', long)]
case_sensitive: bool,
},
#[command(after_long_help = "\
EXAMPLES:
adrs link 3 Supersedes 1 ADR 3 supersedes ADR 1
adrs link 5 Amends 2 ADR 5 amends ADR 2
adrs link 4 \"Relates to\" 3 ADR 4 relates to ADR 3
CUSTOM REVERSE LINK:
adrs link 3 Extends 1 \"Extended by\" Specify custom reverse link
COMMON LINK TYPES (reverse auto-derived):
Supersedes -> Superseded by
Amends -> Amended by
Relates to -> Relates to (symmetric)
The reverse link is automatically added to the target ADR.")]
Link {
source: u32,
link: String,
target: u32,
reverse_link: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
adrs status 3 accepted Mark ADR 3 as accepted
adrs status 2 deprecated Mark ADR 2 as deprecated
adrs status 1 superseded --by 5 Mark ADR 1 as superseded by ADR 5
adrs status 4 rejected Mark ADR 4 as rejected
adrs status 3 \"In Review\" Use custom status
STANDARD STATUSES:
proposed Initial state (default for new ADRs)
accepted Decision has been approved
deprecated No longer recommended but not replaced
superseded Replaced by another ADR (use --by)
rejected Decision was not approved
Note: Use --by with 'superseded' to create a link to the replacing ADR.")]
Status {
adr: u32,
status: String,
#[arg(long, value_name = "NUMBER")]
by: Option<u32>,
},
Config,
Doctor,
Generate {
#[command(subcommand)]
command: GenerateCommands,
},
Export {
#[command(subcommand)]
command: ExportCommands,
},
Import {
#[command(subcommand)]
command: ImportCommands,
},
Template {
#[command(subcommand)]
command: TemplateCommands,
},
#[command(after_long_help = "\
EXAMPLES:
adrs completions bash > ~/.bash_completion.d/adrs
adrs completions zsh > ~/.zfunc/_adrs
adrs completions fish > ~/.config/fish/completions/adrs.fish
adrs completions powershell > _adrs.ps1
BASH:
Add to ~/.bashrc:
source ~/.bash_completion.d/adrs
ZSH:
Add to ~/.zshrc (before compinit):
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
FISH:
Completions are loaded automatically from ~/.config/fish/completions/")]
Completions {
#[arg(value_enum)]
shell: ShellArg,
},
#[cfg(feature = "mcp")]
#[command(after_long_help = "\
Starts an MCP (Model Context Protocol) server on stdio for AI agent integration.
TOOLS PROVIDED (15):
Read-only:
list_adrs, get_adr, search_adrs, get_repository_info, get_related_adrs,
validate_adr, get_adr_sections, compare_adrs, suggest_tags
Write:
create_adr, update_status, update_content, update_tags, link_adrs,
bulk_update_status
USAGE WITH CLAUDE:
Add to your Claude Desktop config (claude_desktop_config.json):
{
\"mcpServers\": {
\"adrs\": {
\"command\": \"adrs\",
\"args\": [\"mcp\", \"serve\"],
\"cwd\": \"/path/to/your/project\"
}
}
}
The server reads ADRs from the current working directory's repository.")]
Mcp {
#[command(subcommand)]
command: McpCommands,
},
#[command(alias = "qr")]
Cheatsheet,
}
#[cfg(feature = "mcp")]
#[derive(Subcommand)]
enum McpCommands {
#[command(after_long_help = "\
EXAMPLES:
adrs mcp serve Start on stdio (for Claude Desktop)
adrs mcp serve --http 127.0.0.1:3000 Start HTTP server (requires mcp-http feature)
STDIO MODE (default):
Claude Desktop spawns this process. Server lifecycle tied to Claude session.
HTTP MODE (--http):
Server runs independently. Can restart without restarting Claude.
Configure Claude with: {\"url\": \"http://127.0.0.1:3000/mcp\"}
Build with HTTP support:
cargo build --release --features mcp-http")]
Serve {
#[cfg(feature = "mcp-http")]
#[arg(long, value_name = "ADDR")]
http: Option<std::net::SocketAddr>,
},
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum ShellArg {
Bash,
Zsh,
Fish,
Powershell,
Elvish,
}
#[derive(Subcommand)]
enum GenerateCommands {
#[command(after_long_help = "\
EXAMPLES:
adrs generate toc Generate markdown TOC
adrs generate toc > doc/adr/README.md Save to README
adrs generate toc --ordered Use numbered list (1. 2. 3.)
adrs generate toc --prefix ./ Adjust link paths
adrs generate toc --intro header.md Prepend content from file")]
Toc {
#[arg(short, long)]
ordered: bool,
#[arg(short, long, value_name = "FILE")]
intro: Option<PathBuf>,
#[arg(short = 'O', long, value_name = "FILE")]
outro: Option<PathBuf>,
#[arg(short, long, value_name = "PREFIX")]
prefix: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
adrs generate graph Generate DOT format graph
adrs generate graph | dot -Tpng > g.png Render as PNG
adrs generate graph --prefix https://example.com/adr/
Add URLs to nodes
adrs generate graph -e html Use .html extension for links")]
Graph {
#[arg(short, long, value_name = "PREFIX")]
prefix: Option<String>,
#[arg(short, long, default_value = "md")]
extension: String,
},
#[command(after_long_help = "\
EXAMPLES:
adrs generate book Generate in ./book directory
adrs generate book -o docs/adr-book Custom output directory
adrs generate book -t \"Our ADRs\" Set book title
cd book && mdbook serve Preview the generated book")]
Book {
#[arg(short, long, default_value = "book")]
output: PathBuf,
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
description: Option<String>,
},
}
#[derive(Subcommand)]
enum ExportCommands {
#[command(after_long_help = "\
EXAMPLES:
adrs export json Export all ADRs as JSON array
adrs export json --pretty Pretty-printed JSON output
adrs export json 3 Export only ADR 3
adrs export json --dir ./adrs Export from directory (no repo needed)
FOR DOCUMENTATION/CATALOGS:
adrs export json --metadata-only Export metadata without full content
adrs export json --base-url https://github.com/org/repo/blob/main/doc/adr
Include source URLs in export
PIPING:
adrs export json --pretty > adrs.json Save to file
adrs export json | jq '.[] | .title' Process with jq")]
Json {
#[arg(value_name = "NUMBER")]
adr: Option<u32>,
#[arg(short, long, value_name = "PATH")]
dir: Option<PathBuf>,
#[arg(short, long)]
pretty: bool,
#[arg(long)]
metadata_only: bool,
#[arg(long, value_name = "URL")]
base_url: Option<String>,
},
}
#[derive(Subcommand)]
enum ImportCommands {
#[command(after_long_help = "\
EXAMPLES:
adrs import json adrs.json Import from JSON file
adrs import json --dry-run adrs.json Preview without writing files
adrs import json --overwrite adrs.json Replace existing ADRs
cat adrs.json | adrs import json - Import from stdin
MERGING REPOSITORIES:
adrs import json --renumber external.json
Append ADRs with new numbers
adrs import json --renumber --dry-run external.json
Preview renumbering
OPTIONS:
--dry-run See what would be imported without making changes
--renumber Assign new numbers starting after existing ADRs
(also available as --append)
--overwrite Replace existing files instead of skipping
--ng Use YAML frontmatter in imported files")]
Json {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short, long, value_name = "PATH")]
dir: Option<PathBuf>,
#[arg(short, long)]
overwrite: bool,
#[arg(short, long, alias = "append")]
renumber: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
ng: bool,
},
}
#[derive(Subcommand)]
enum TemplateCommands {
List,
Show {
format: String,
#[arg(short, long, value_name = "VARIANT")]
variant: Option<String>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let start_dir = cli
.working_dir
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
match cli.command {
Commands::Init { directory } => commands::init(&start_dir, directory, cli.ng),
Commands::New {
title,
supersedes,
link,
format,
variant,
template,
status,
tags,
no_edit,
} => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::new(
&discovered.root,
cli.ng,
title,
supersedes,
link,
format,
variant,
template,
status,
tags,
no_edit,
&discovered.config,
)
}
Commands::Edit { adr } => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::edit(&discovered.root, &adr)
}
Commands::List {
status,
since,
until,
decider,
tag,
long,
} => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::list(&discovered.root, status, since, until, decider, tag, long)
}
Commands::Search {
query,
title,
status,
case_sensitive,
} => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::search(&discovered.root, &query, title, status, case_sensitive)
}
Commands::Link {
source,
link,
target,
reverse_link,
} => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::link(
&discovered.root,
source,
&link,
target,
reverse_link.as_deref(),
)
}
Commands::Status { adr, status, by } => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::status(&discovered.root, adr, &status, by)
}
Commands::Config => {
let discovered = discover(&start_dir).ok();
commands::config_with_discovery(&start_dir, discovered)
}
Commands::Doctor => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::doctor(&discovered.root)
}
Commands::Generate { command } => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
match command {
GenerateCommands::Toc {
ordered,
intro,
outro,
prefix,
} => commands::generate_toc(&discovered.root, ordered, intro, outro, prefix),
GenerateCommands::Graph { prefix, extension } => {
commands::generate_graph(&discovered.root, prefix, &extension)
}
GenerateCommands::Book {
output,
title,
description,
} => commands::generate_book(&discovered.root, &output, title, description),
}
}
Commands::Export { command } => match command {
ExportCommands::Json {
adr,
dir,
pretty,
metadata_only,
base_url,
} => {
if let Some(ref dir_path) = dir {
commands::export_json(
&start_dir,
adr,
Some(dir_path),
pretty,
metadata_only,
base_url,
)
} else {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::export_json(
&discovered.root,
adr,
None,
pretty,
metadata_only,
base_url,
)
}
}
},
Commands::Import { command } => match command {
ImportCommands::Json {
file,
dir,
overwrite,
renumber,
dry_run,
ng,
} => {
if let Some(ref dir_path) = dir {
commands::import_json(
&start_dir,
&file,
Some(dir_path),
overwrite,
renumber,
dry_run,
ng,
)
} else {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
commands::import_json(
&discovered.root,
&file,
None,
overwrite,
renumber,
dry_run,
ng,
)
}
}
},
Commands::Template { command } => match command {
TemplateCommands::List => commands::template_list(),
TemplateCommands::Show { format, variant } => {
commands::template_show(&format, variant.as_deref())
}
},
Commands::Completions { shell } => {
let shell = match shell {
ShellArg::Bash => Shell::Bash,
ShellArg::Zsh => Shell::Zsh,
ShellArg::Fish => Shell::Fish,
ShellArg::Powershell => Shell::PowerShell,
ShellArg::Elvish => Shell::Elvish,
};
let mut cmd = Cli::command();
generate(shell, &mut cmd, "adrs", &mut io::stdout());
Ok(())
}
#[cfg(all(feature = "mcp", not(feature = "mcp-http")))]
Commands::Mcp { command } => match command {
McpCommands::Serve {} => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
tokio::runtime::Runtime::new()
.context("Failed to create tokio runtime")?
.block_on(mcp::serve_stdio(discovered.root))
.context("MCP server error")
}
},
#[cfg(feature = "mcp-http")]
Commands::Mcp { command } => match command {
McpCommands::Serve { http } => {
let discovered = discover_or_error(&start_dir, cli.working_dir.is_some())?;
let runtime =
tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
if let Some(addr) = http {
runtime
.block_on(mcp::serve_http(discovered.root, addr))
.context("MCP HTTP server error")
} else {
runtime
.block_on(mcp::serve_stdio(discovered.root))
.context("MCP server error")
}
}
},
Commands::Cheatsheet => {
print_cheatsheet();
Ok(())
}
}
}
fn print_cheatsheet() {
print!(
r#"ADR CHEATSHEET
==============
GETTING STARTED
adrs init Create ADR repository in doc/adr
adrs init docs/decisions Use custom directory
adrs --ng init Enable NextGen mode (YAML frontmatter)
CREATING ADRs
adrs new "Use PostgreSQL" Create new ADR (opens editor)
adrs new --no-edit "Quick Decision" Create without editor (CI/scripts)
adrs new --format madr "..." Use MADR format
adrs new --status accepted "..." Start as accepted
adrs --ng new -t api,db "..." Add tags (requires --ng)
SUPERSEDING & LINKING
adrs new --supersedes 2 "Use MySQL" Create ADR that supersedes #2
adrs link 3 Supersedes 1 Link: ADR 3 supersedes ADR 1
adrs link 3 Amends 1 Link: ADR 3 amends ADR 1
adrs link 3 "Relates to" 2 Symmetric relationship
MANAGING STATUS
adrs status 3 accepted Mark as accepted
adrs status 2 deprecated Mark as deprecated
adrs status 1 superseded --by 3 Mark as superseded by ADR 3
VIEWING & SEARCHING
adrs list List all ADRs
adrs list -l Detailed view (status, date)
adrs list --status accepted Filter by status
adrs list --since 2024-01-01 Filter by date
adrs search postgres Search content
adrs search -t database Search titles only
GENERATING DOCS
adrs generate toc > doc/adr/README.md Create table of contents
adrs generate graph | dot -Tpng > g.png Create relationship graph
adrs generate book Create mdbook
IMPORT/EXPORT
adrs export json --pretty > adrs.json Export to JSON
adrs import json external.json Import from JSON
adrs import json --renumber ext.json Append with new numbers
adrs import json --dry-run ext.json Preview import
CONFIGURATION
adrs config Show current config
adrs doctor Check repository health
adrs template list Show available templates
For detailed help: adrs <command> --help
Documentation: https://joshrotenberg.com/adrs/
"#
);
}
fn discover_or_error(
start_dir: &std::path::Path,
explicit_dir: bool,
) -> Result<adrs_core::DiscoveredConfig> {
let discovered = discover(start_dir).context(if explicit_dir {
"No ADR repository found in the specified directory. Run 'adrs init' first."
} else {
"No ADR repository found. Run 'adrs init' to create one, or use '-C' to specify a directory."
})?;
if matches!(discovered.source, ConfigSource::Default)
&& !start_dir.join("doc/adr").exists()
&& !explicit_dir
{
anyhow::bail!(
"No ADR repository found. Run 'adrs init' to create one, or use '-C' to specify a directory."
);
}
Ok(discovered)
}