use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use anyhow::Result;
use clap::{Parser, Subcommand};
use agentstatedeveloper_core::{AuditSink, RatifyOps};
pub mod commands;
pub mod config;
#[allow(dead_code, unused_imports)]
pub(crate) mod skillgen;
pub use config::Config;
static AUDIT_SINK_OVERRIDE: OnceLock<Arc<dyn AuditSink>> = OnceLock::new();
pub fn set_audit_sink_override(sink: Arc<dyn AuditSink>) {
let _ = AUDIT_SINK_OVERRIDE.set(sink);
}
pub(crate) fn audit_sink_override() -> Option<Arc<dyn AuditSink>> {
AUDIT_SINK_OVERRIDE.get().cloned()
}
static RATIFY_OVERRIDE: OnceLock<Arc<dyn RatifyOps>> = OnceLock::new();
pub fn set_ratify_ops_override(ratify: Arc<dyn RatifyOps>) {
let _ = RATIFY_OVERRIDE.set(ratify);
}
pub(crate) fn ratify_ops_override() -> Option<Arc<dyn RatifyOps>> {
RATIFY_OVERRIDE.get().cloned()
}
#[derive(Debug, Parser)]
#[command(
name = "asd",
version,
about = "AgentStateDeveloper CLI",
// We ship our own `help` subcommand (on-demand asd/ctx feature docs), so the
// auto-generated clap `help` subcommand is disabled. `-h`/`--help` still work.
disable_help_subcommand = true,
long_about = "AgentStateDeveloper CLI — code-level context and audit overlay.
Bootstrap a NEW repo (no sidecar yet) — manual sequence:
asd init # install git hooks, create .asd/ directory
asd index <path> # walk source files; build FTS + ASG state
asd sync # write .asd/v1/ sidecar (commit this)
asd status # verify (expect state: fresh, sidecar: hydrated)
Bootstrap a FRESH CLONE (sidecar already in repo) — manual sequence:
asd init # install git hooks
asd hydrate --verify # restore ASG from sidecar; --verify catches drift
asd status # verify (index_consistency.consistent == true)
One-shot alternative (recommended): `asd onboard` runs the right sequence
automatically for either case above. Idempotent — safe to re-run.
Daily loop:
asd prepare-change \"<task>\" # one-call context package (use BEFORE editing)
asd search \"<query>\" --agent # ranked semantic search
asd impact <symbol> # blast radius for a planned change
asd ledger append ... # record a decision / invariant / hazard
Which command when (they overlap — pick by intent):
Cold repo, don't know the layout -> asd architecture (languages, layers, hotspots)
\"Where is the code that does X?\" -> asd search \"X\" (ranked symbols)
\"Explain feature X end-to-end\" -> asd investigate X (search + call chains + hazards)
About to edit — give me everything -> asd prepare-change \"<task>\" (one-call package)
I know the symbol; what breaks? -> asd impact <symbol> (blast radius)
Pre-edit safety checklist -> asd checklist \"<task>\"
Reviewing a diff / PR -> asd since <base-sha>
Pull full context on named symbols -> asd context-for a,b,c
When source changes outside ASD's commit hooks, re-run `asd index <path>`
to refresh the FTS cache. `asd status` will flag staleness if the index
is more than an hour behind."
)]
pub struct Cli {
#[arg(long, global = true)]
pub db: Option<PathBuf>,
#[arg(long, global = true)]
pub policy: Option<PathBuf>,
#[arg(long, global = true)]
pub audit_log: Option<PathBuf>,
#[arg(long, global = true)]
pub brief: bool,
#[command(subcommand)]
pub cmd: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Help(commands::help::HelpArgs),
Init(commands::init::InitArgs),
Onboard(commands::onboard::OnboardArgs),
Hooks(commands::hooks::HooksArgs),
#[command(alias = "reindex")]
Index(commands::index::IndexArgs),
#[command(alias = "code_read")]
Read(commands::read::ReadArgs),
#[command(subcommand)]
Ledger(commands::ledger::LedgerCmd),
#[command(subcommand)]
Policy(commands::policy::PolicyCmd),
VerifyEffects(commands::verify_effects::VerifyEffectsArgs),
Trace(commands::trace::TraceArgs),
Sync(commands::sync::SyncArgs),
Hydrate(commands::hydrate::HydrateArgs),
#[command(subcommand)]
Audit(commands::audit::AuditCmd),
#[command(subcommand)]
Mcp(commands::mcp::McpCmd),
Skill(commands::skill::SkillArgs),
Bootstrap(commands::bootstrap::BootstrapArgs),
Watch(commands::watch::WatchArgs),
List(commands::list::ListArgs),
#[command(alias = "callers_of")]
Callers(commands::graph::CallersArgs),
#[command(alias = "callees_of")]
Callees(commands::graph::CalleesArgs),
#[command(name = "context-for")]
ContextFor(commands::context_for::ContextForArgs),
Repair(commands::repair::RepairArgs),
Gc(commands::gc::GcArgs),
#[command(subcommand)]
Scratch(commands::scratch::ScratchCmd),
#[command(subcommand)]
Worktree(commands::worktree::WorktreeCmd),
#[command(aliases = ["code_search", "code_query"])]
Search(commands::search::SearchArgs),
References(commands::references::ReferencesArgs),
#[command(subcommand)]
Scopes(commands::scopes::ScopesCmd),
#[command(subcommand)]
Conclusions(commands::conclusions::ConclusionsCmd),
#[command(subcommand)]
Recipe(commands::recipe::RecipeCmd),
Map(commands::map::MapArgs),
#[command(subcommand)]
Think(commands::think::ThinkCmd),
#[command(subcommand)]
Sidecar(commands::sidecar::SidecarCmd),
Investigate(commands::investigate::InvestigateArgs),
Status(commands::status::StatusArgs),
Impact(commands::impact::ImpactArgs),
Checklist(commands::checklist::ChecklistArgs),
#[command(subcommand)]
Invariant(commands::invariant::InvariantCmd),
#[command(name = "prepare-change")]
PrepareChange(commands::prepare_change::PrepareChangeArgs),
Since(commands::since::SinceArgs),
Endpoints(commands::endpoints::EndpointsArgs),
Architecture(commands::architecture::ArchitectureArgs),
DeadCode(commands::dead_code::DeadCodeArgs),
TestSummary(commands::test_summary::TestSummaryArgs),
#[command(subcommand)]
Feedback(commands::feedback::FeedbackCmd),
#[command(name = "annotate-commit")]
AnnotateCommit(commands::annotate_commit::AnnotateCommitArgs),
#[command(name = "task-close")]
TaskClose(commands::task_close::TaskCloseArgs),
Scorecard(commands::scorecard::ScorecardArgs),
Probe(commands::probe::ProbeCmd),
Trust(commands::trust::TrustArgs),
Workflow(commands::workflow::WorkflowArgs),
#[command(subcommand)]
Repo(commands::repo::RepoCmd),
}
pub fn config_from_cli(cli: &Cli) -> Config {
let local_only = matches!(
cli.cmd,
Command::Init(_) | Command::Onboard(_) | Command::Index(_) | Command::Hydrate(_)
);
let explicit_db = cli.db.clone().or_else(|| match &cli.cmd {
Command::Hydrate(args) if std::env::var_os("ASD_DB").is_none() => {
args.dir.as_ref().map(|d| d.join(".asd-state.db"))
}
Command::Index(args) if std::env::var_os("ASD_DB").is_none() => std::env::current_dir()
.ok()
.map(|cwd| index_store(&args.path, &cwd)),
_ => None,
});
Config::resolve_with_brief(
explicit_db,
cli.policy.clone(),
cli.audit_log.clone(),
cli.brief,
local_only,
)
}
fn index_store(path: &Path, cwd: &Path) -> PathBuf {
let here = PathBuf::from("./.asd-state.db");
let (Ok(dir), Ok(cwd)) = (cwd.join(path).canonicalize(), cwd.canonicalize()) else {
return here;
};
if !dir.is_dir() || dir == cwd {
return here;
}
let own = dir.join(".asd-state.db");
if own.exists() {
return own;
}
if cwd.join(".asd-state.db").exists() && dir.starts_with(&cwd) {
return here;
}
own
}
pub fn run(cli: Cli) -> Result<()> {
let cfg = config_from_cli(&cli);
run_with_config(&cfg, cli.cmd)
}
pub fn run_with_config(cfg: &Config, cmd: Command) -> Result<()> {
use commands::*;
match cmd {
Command::Help(args) => help::run(cfg, args),
Command::Init(args) => init::run(cfg, args),
Command::Onboard(args) => onboard::run(cfg, args),
Command::Hooks(args) => hooks::run(cfg, args),
Command::Index(args) => index::run(cfg, args),
Command::Read(args) => read::run(cfg, args),
Command::Ledger(sub) => ledger::run(cfg, sub),
Command::Policy(sub) => policy::run(cfg, sub),
Command::VerifyEffects(args) => verify_effects::run(cfg, args),
Command::Trace(args) => trace::run(cfg, args),
Command::Sync(args) => sync::run(cfg, args),
Command::Gc(args) => gc::run(cfg, args),
Command::Hydrate(args) => hydrate::run(cfg, args),
Command::Audit(sub) => audit::run(cfg, sub),
Command::Mcp(sub) => mcp::run(cfg, sub),
Command::Skill(args) => skill::run(args),
Command::Bootstrap(args) => bootstrap::run(args),
Command::Watch(args) => watch::run(cfg, args),
Command::List(args) => list::run(cfg, args),
Command::Callers(args) => graph::run_callers(cfg, args),
Command::Callees(args) => graph::run_callees(cfg, args),
Command::ContextFor(args) => context_for::run(cfg, args),
Command::Repair(args) => repair::run(cfg, args),
Command::Scratch(cmd) => scratch::run(cfg, cmd),
Command::Worktree(cmd) => worktree::run(cmd),
Command::Search(args) => search::run(cfg, args),
Command::References(args) => references::run(cfg, args),
Command::Scopes(cmd) => scopes::run(cfg, cmd),
Command::Conclusions(cmd) => conclusions::run(cfg, cmd),
Command::Recipe(cmd) => recipe::run(cfg, cmd),
Command::Map(args) => map::run(cfg, args),
Command::Think(cmd) => think::run(cfg, cmd),
Command::Sidecar(cmd) => sidecar::run(cfg, cmd),
Command::Investigate(args) => investigate::run(cfg, args),
Command::Status(args) => status::run(cfg, args),
Command::Impact(args) => impact::run(cfg, args),
Command::Checklist(args) => checklist::run(cfg, args),
Command::Invariant(sub) => invariant::run(cfg, sub),
Command::PrepareChange(args) => prepare_change::run(cfg, args),
Command::Since(args) => since::run(cfg, args),
Command::Endpoints(args) => endpoints::run(cfg, args),
Command::Architecture(args) => architecture::run(cfg, args),
Command::DeadCode(args) => dead_code::run(cfg, args),
Command::TestSummary(args) => test_summary::run(cfg, args),
Command::Feedback(sub) => feedback::run(cfg, sub),
Command::AnnotateCommit(args) => annotate_commit::run(cfg, args),
Command::TaskClose(args) => task_close::run(cfg, args),
Command::Scorecard(args) => scorecard::run(cfg, args),
Command::Probe(cmd) => probe::run(cfg, cmd),
Command::Trust(args) => trust::run(cfg, args),
Command::Workflow(args) => workflow::run(cfg, args),
Command::Repo(cmd) => repo::run(cfg, cmd),
}
}
#[cfg(test)]
mod index_store_tests {
use super::index_store;
use std::path::PathBuf;
fn touch_store(dir: &std::path::Path) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(".asd-state.db"), b"").unwrap();
}
#[test]
fn the_current_directory_and_files_keep_the_local_store() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().canonicalize().unwrap();
std::fs::write(cwd.join("a.py"), b"").unwrap();
let here = PathBuf::from("./.asd-state.db");
assert_eq!(index_store(&PathBuf::from("."), &cwd), here);
assert_eq!(index_store(&cwd, &cwd), here);
assert_eq!(index_store(&PathBuf::from("a.py"), &cwd), here);
}
#[test]
fn a_project_indexed_from_its_parent_uses_its_own_store() {
let tmp = tempfile::tempdir().unwrap();
let apps = tmp.path().canonicalize().unwrap();
touch_store(&apps.join("proj"));
assert_eq!(
index_store(&PathBuf::from("proj"), &apps),
apps.join("proj/.asd-state.db")
);
touch_store(&apps.join("other"));
assert_eq!(
index_store(&apps.join("proj"), &apps.join("other")),
apps.join("proj/.asd-state.db")
);
}
#[test]
fn a_subdirectory_of_the_current_project_uses_its_store() {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().canonicalize().unwrap();
touch_store(&proj);
std::fs::create_dir_all(proj.join("src")).unwrap();
assert_eq!(
index_store(&PathBuf::from("src"), &proj),
PathBuf::from("./.asd-state.db")
);
}
#[test]
fn a_directory_with_no_store_gets_one_of_its_own() {
let tmp = tempfile::tempdir().unwrap();
let apps = tmp.path().canonicalize().unwrap();
std::fs::create_dir_all(apps.join("fresh")).unwrap();
assert_eq!(
index_store(&PathBuf::from("fresh"), &apps),
apps.join("fresh/.asd-state.db"),
"would have built a store in the parent"
);
}
}