use anyhow::{Context, Result};
use cf_invariants_anchor_ai::{
AnthropicClient, AnthropicTransport, MockTransport, RawCandidate, SuggestRequest,
DEFAULT_MODEL,
};
#[cfg_attr(not(test), allow(unused_imports))]
use cf_invariants_anchor_core::{ContractSurface, InvariantCandidate, InvariantSource};
use cf_invariants_anchor_emit::Target;
use cf_invariants_anchor_suggest::ClassRegistry;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "cf-invariants-anchor",
version,
about = "AI invariant-author for Crucible / Trident on Anchor programs."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Version,
Ingest {
idl: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
Suggest {
idl: PathBuf,
#[arg(long, default_value_t = false)]
ai: bool,
#[arg(long, default_value = ".cf-invariants-anchor/ai-log")]
audit_dir: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
Emit {
idl: PathBuf,
#[arg(long, default_value = "crucible")]
target: String,
#[arg(long, default_value_t = 0)]
candidate_index: usize,
#[arg(long)]
out: Option<PathBuf>,
},
}
const CRUCIBLE_TARGET_VERSION: &str = "0.2.0";
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Version => {
println!(
"cf-invariants-anchor {} (target: Crucible v{})",
env!("CARGO_PKG_VERSION"),
CRUCIBLE_TARGET_VERSION
);
}
Command::Ingest { idl, out } => {
let surface = cf_invariants_anchor_idl::ingest_path(&idl)?;
let text = serde_json::to_string_pretty(&surface)?;
write_out(out, &text)?;
}
Command::Suggest {
idl,
ai,
audit_dir,
out,
} => {
let surface = cf_invariants_anchor_idl::ingest_path(&idl)?;
let candidates = if ai {
suggest_via_ai(&surface, &audit_dir).await?
} else {
ClassRegistry::default().propose_all(&surface)
};
let text = serde_json::to_string_pretty(&candidates)?;
write_out(out, &text)?;
}
Command::Emit {
idl,
target,
candidate_index,
out,
} => {
let surface = cf_invariants_anchor_idl::ingest_path(&idl)?;
let candidates = ClassRegistry::default().propose_all(&surface);
let candidate = candidates
.get(candidate_index)
.ok_or_else(|| anyhow::anyhow!("no candidate at index {candidate_index}"))?;
let target = parse_target(&target)?;
let rendered = cf_invariants_anchor_emit::render(&surface, candidate, target);
write_out(out, &rendered)?;
}
}
Ok(())
}
async fn suggest_via_ai(
surface: &ContractSurface,
audit_dir: &PathBuf,
) -> Result<Vec<InvariantCandidate>> {
let prompt_path = find_prompt_path()?;
let live_requested = std::env::var("CF_INVARIANTS_ANCHOR_AI_LIVE").ok().as_deref()
== Some("1");
if live_requested {
run_live_path(surface, audit_dir, prompt_path).await
} else {
run_mock_path(surface, audit_dir, prompt_path).await
}
}
async fn run_mock_path(
surface: &ContractSurface,
audit_dir: &PathBuf,
prompt_path: PathBuf,
) -> Result<Vec<InvariantCandidate>> {
let heuristic_candidates = ClassRegistry::default().propose_all(surface);
let canned_body = canned_body_from_heuristic(&heuristic_candidates)?;
let transport = MockTransport {
canned_body,
tokens_in: 2500,
tokens_out: 2000,
};
run_with_transport(transport, surface, audit_dir, prompt_path).await
}
#[cfg(feature = "live-ai")]
async fn run_live_path(
surface: &ContractSurface,
audit_dir: &PathBuf,
prompt_path: PathBuf,
) -> Result<Vec<InvariantCandidate>> {
let transport = cf_invariants_anchor_ai::LiveAnthropicTransport::from_env()
.context("CF_INVARIANTS_ANCHOR_AI_LIVE=1 set but live transport setup failed")?;
run_with_transport(transport, surface, audit_dir, prompt_path).await
}
#[cfg(not(feature = "live-ai"))]
async fn run_live_path(
_surface: &ContractSurface,
_audit_dir: &PathBuf,
_prompt_path: PathBuf,
) -> Result<Vec<InvariantCandidate>> {
anyhow::bail!(
"CF_INVARIANTS_ANCHOR_AI_LIVE=1 but this binary was not built with \
`--features live-ai`. Either rebuild with the feature, or unset the \
env var to fall back to the deterministic mock path."
)
}
async fn run_with_transport<T: AnthropicTransport>(
transport: T,
surface: &ContractSurface,
audit_dir: &PathBuf,
prompt_path: PathBuf,
) -> Result<Vec<InvariantCandidate>> {
let client = AnthropicClient::new(transport, audit_dir.clone(), prompt_path)
.with_model(DEFAULT_MODEL);
let req = SuggestRequest {
surface,
hint: None,
};
let resp = client
.suggest_invariants(req)
.await
.context("AI suggester call failed")?;
Ok(resp.candidates)
}
fn canned_body_from_heuristic(
heuristic: &[InvariantCandidate],
) -> Result<String> {
let raw: Vec<RawCandidate> = heuristic
.iter()
.map(|c| RawCandidate {
name: c.name.clone(),
summary: c.summary.clone(),
class: c.class.clone(),
rank: c.rank,
rationale: c.rationale.clone(),
emit_hints: c.emit_hints.clone(),
})
.collect();
Ok(serde_json::to_string(&raw)?)
}
fn find_prompt_path() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
let cwd_p = cwd.join("prompts").join("invariant_suggestion_v1.txt");
if cwd_p.exists() {
return Ok(cwd_p);
}
if let Some(parent) = cwd.parent() {
let p = parent.join("prompts").join("invariant_suggestion_v1.txt");
if p.exists() {
return Ok(p);
}
}
if let Some(gp) = cwd.parent().and_then(|p| p.parent()) {
let p = gp.join("prompts").join("invariant_suggestion_v1.txt");
if p.exists() {
return Ok(p);
}
}
if let Ok(env_path) = std::env::var("CF_INVARIANTS_ANCHOR_PROMPT_PATH") {
let p = PathBuf::from(env_path);
if p.exists() {
return Ok(p);
}
}
anyhow::bail!(
"could not locate prompts/invariant_suggestion_v1.txt — checked CWD, parent, \
grandparent; set CF_INVARIANTS_ANCHOR_PROMPT_PATH to override"
)
}
fn parse_target(s: &str) -> Result<Target> {
match s.to_ascii_lowercase().as_str() {
"crucible" => Ok(Target::Crucible),
"trident" => Ok(Target::Trident),
other => anyhow::bail!("unknown target `{other}` (expected `crucible` | `trident`)"),
}
}
fn write_out(out: Option<PathBuf>, text: &str) -> Result<()> {
match out {
Some(path) => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, text)?;
}
None => {
print!("{text}");
if !text.ends_with('\n') {
println!();
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cf_invariants_anchor_core::{BalanceField, Instruction};
fn vault_surface() -> ContractSurface {
ContractSurface {
program_id: "Va111tRef1111111111111111111111111111111111".into(),
program_name: "vault_ref".into(),
instructions: vec![
Instruction {
name: "deposit".into(),
args: vec!["amount".into()],
accounts: vec!["vault".into(), "depositor".into()],
},
Instruction {
name: "withdraw".into(),
args: vec!["amount".into()],
accounts: vec!["vault".into(), "depositor".into()],
},
],
balance_fields: vec![BalanceField {
account: "Vault".into(),
field: "amount".into(),
ty: "u64".into(),
}],
}
}
fn temp_dir() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"cfia-cli-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[tokio::test]
async fn ai_path_via_mock_attaches_ai_suggested_to_every_candidate() {
let surface = vault_surface();
let audit = temp_dir();
let candidates = run_mock_path(&surface, &audit, find_test_prompt()).await.unwrap();
assert!(!candidates.is_empty());
for c in &candidates {
assert!(
matches!(c.source, InvariantSource::AiSuggested { .. }),
"candidate {} not AI-tagged: {:?}",
c.name,
c.source
);
}
let entries: Vec<_> = std::fs::read_dir(&audit).unwrap().collect();
assert_eq!(entries.len(), 1);
let _ = std::fs::remove_dir_all(&audit);
}
fn find_test_prompt() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.pop(); p.pop(); p.push("prompts");
p.push("invariant_suggestion_v1.txt");
p
}
}