use std::path::PathBuf;
use anyhow::Result;
use caboodle::{
crew::CrewEvidence,
doctor,
embedding::EmbeddingModel,
emission, engine, interview,
model::{CrewMode, InstallIntent, Plan, Profile, QuipuFlavor, State},
observability, projection,
};
use clap::{Parser, Subcommand, ValueEnum};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("CABOODLE_GIT_SHA"),
")"
);
#[derive(Parser)]
#[command(name = env!("CARGO_BIN_NAME"), version = VERSION, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
ProvisionQuipuToken {
#[arg(long = "from")]
source: PathBuf,
#[arg(long)]
server: String,
},
Doctor {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
},
Init {
#[arg(long)]
guided: bool,
#[arg(short, long, default_value = "caboodle-plan.toml")]
output: PathBuf,
#[arg(long, default_value = ".caboodle/interview.toml")]
session: PathBuf,
},
Plan {
#[arg(long, value_enum, default_value_t = ProfileArg::Retrieval)]
profile: ProfileArg,
#[arg(long, value_enum, default_value_t = CrewModeArg::Standalone)]
crew: CrewModeArg,
#[arg(long, default_value = "caboodle-intent.toml")]
intent: PathBuf,
#[arg(short, long, default_value = "caboodle-plan.toml")]
output: PathBuf,
#[arg(long = "share")]
shares: Vec<PathBuf>,
#[arg(long, requires = "shares")]
quipu_db: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = QuipuFlavorArg::Release)]
quipu_flavor: QuipuFlavorArg,
#[arg(long)]
embedding_model: Option<PathBuf>,
#[arg(long)]
quipu_mcp_url: Option<String>,
#[arg(long, requires = "quipu_mcp_url")]
quipu_mcp_token_file: Option<String>,
},
Apply {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long)]
skip_install: bool,
#[arg(long)]
host_profile: Option<String>,
},
Verify {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long)]
creel_doctor: Option<PathBuf>,
#[arg(long)]
creel_admission: Option<PathBuf>,
#[arg(long)]
host_profile: Option<String>,
},
VerifyMember { member: String },
Install {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long)]
skip_install: bool,
#[arg(long)]
creel_doctor: Option<PathBuf>,
#[arg(long)]
creel_admission: Option<PathBuf>,
#[arg(long)]
host_profile: Option<String>,
},
CheckUpdates {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long)]
host_profile: Option<String>,
},
Update {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long)]
creel_doctor: Option<PathBuf>,
#[arg(long)]
creel_admission: Option<PathBuf>,
#[arg(long)]
host_profile: Option<String>,
},
#[cfg(unix)]
UpdateSelf {
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long)]
check: bool,
#[arg(long)]
tag: Option<String>,
#[arg(long, requires_all = ["tag", "binary_sha256"])]
archive_sha256: Option<String>,
#[arg(long, requires_all = ["tag", "archive_sha256"])]
binary_sha256: Option<String>,
},
#[cfg(unix)]
#[command(hide = true)]
PostInstallRepair,
#[cfg(unix)]
BumpMember {
manifest: PathBuf,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
probe_wrapper: Option<PathBuf>,
},
#[cfg(unix)]
UpdateRelease {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = ".caboodle/state.json")]
state: PathBuf,
#[arg(long, value_parser = update_release_tool)]
tool: String,
#[arg(long)]
check: bool,
},
#[cfg(unix)]
RollbackSeeds {
#[arg(long)]
plan: PathBuf,
#[arg(long)]
state: PathBuf,
#[arg(long)]
current_sha256: String,
#[arg(long)]
prior_sha256: String,
},
ProjectSettings {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
agent: Option<String>,
#[arg(long, value_parser = ["files", "quipu", "toml"], default_value = "files")]
registry: String,
#[arg(long, conflicts_with_all = ["root", "agent"])]
policy_only: bool,
#[arg(
short,
long,
default_value = "caboodle-plan.toml",
requires = "policy_only"
)]
plan: PathBuf,
#[arg(
short,
long,
default_value = "caboodle-settings",
requires = "policy_only"
)]
output: PathBuf,
},
VerifyQuestions {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long)]
db: Option<PathBuf>,
},
RenderObservability {
#[arg(short, long, default_value = "caboodle-plan.toml")]
plan: PathBuf,
#[arg(long, default_value = "caboodle-observability.toml")]
targets: PathBuf,
#[arg(short, long, default_value = "caboodle-observability")]
output: PathBuf,
},
ValidateObservability {
#[arg(short, long, default_value = "caboodle-observability")]
output: PathBuf,
},
QueueBr {
input: PathBuf,
#[arg(short, long, default_value = ".caboodle/episodes")]
queue: PathBuf,
},
FlushEpisodes {
#[arg(short, long, default_value = ".caboodle/episodes")]
queue: PathBuf,
#[arg(long)]
endpoint: String,
},
Packs {
#[command(subcommand)]
command: PacksCommand,
},
}
#[derive(Subcommand)]
enum PacksCommand {
Trust {
#[arg(long, global = true)]
file: Option<PathBuf>,
#[command(subcommand)]
command: TrustCommand,
},
}
#[derive(Subcommand)]
enum TrustCommand {
Own { owner: String },
Allow {
repo: String,
sha256: String,
#[arg(long)]
note: Option<String>,
},
AllowEndpoint {
repo: String,
endpoint: String,
#[arg(long)]
note: Option<String>,
},
List,
Check {
remote: String,
sha256: String,
#[arg(long, default_value = "pack")]
pack: String,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum ProfileArg {
Kg,
Retrieval,
CodeIntel,
Crew,
Everything,
}
#[derive(Clone, Copy, ValueEnum)]
enum QuipuFlavorArg {
Release,
Lancedb,
}
impl From<QuipuFlavorArg> for QuipuFlavor {
fn from(value: QuipuFlavorArg) -> Self {
match value {
QuipuFlavorArg::Release => Self::Release,
QuipuFlavorArg::Lancedb => Self::Lancedb,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum CrewModeArg {
Shantytown,
Creel,
Both,
Standalone,
}
impl From<CrewModeArg> for CrewMode {
fn from(value: CrewModeArg) -> Self {
match value {
CrewModeArg::Shantytown => Self::Shantytown,
CrewModeArg::Creel => Self::Creel,
CrewModeArg::Both => Self::Both,
CrewModeArg::Standalone => Self::Standalone,
}
}
}
impl From<ProfileArg> for Profile {
fn from(value: ProfileArg) -> Self {
match value {
ProfileArg::Kg => Self::Kg,
ProfileArg::Retrieval => Self::Retrieval,
ProfileArg::CodeIntel => Self::CodeIntel,
ProfileArg::Crew => Self::Crew,
ProfileArg::Everything => Self::Everything,
}
}
}
#[cfg(unix)]
fn update_release_tool(value: &str) -> Result<String, String> {
if [
"bobbin",
"yupana",
"desire-path",
"quipu",
"camayoc",
"shantytown",
]
.contains(&value)
|| caboodle::members::get(value).is_some()
{
Ok(value.to_owned())
} else {
let members: Vec<&str> = caboodle::members::all()
.iter()
.map(|m| m.name.as_str())
.collect();
Err(format!(
"possible values: bobbin, yupana, desire-path, quipu, camayoc, shantytown, or a stack member ({})",
members.join(", ")
))
}
}
fn main() -> Result<()> {
match Cli::parse().command {
Commands::ProvisionQuipuToken { source, server } => {
engine::provision_quipu_credential(&source, &server)?;
}
Commands::Doctor { plan, state } => {
let mut installed = false;
let scope = if plan.exists() {
println!("doctor: checking {}", plan.display());
let read = Plan::read(&plan)?;
installed = doctor::installed(&read, &State::read(&state)?);
let mut scope = doctor::Scope::from_plan(&read);
scope.work_dir = doctor::is_work_dir(&plan);
scope
} else {
println!(
"doctor: no plan at {}; checking every tool in the everything profile",
plan.display()
);
doctor::Scope::everything()
};
let findings = doctor::diagnose(&scope);
let stdout = std::io::stdout();
if !doctor::report(&findings, installed, &mut stdout.lock())? {
std::process::exit(1);
}
}
Commands::Init {
guided,
output,
session,
} => {
if !guided {
anyhow::bail!("caboodle init currently requires --guided");
}
let stdin = std::io::stdin();
let mut input = stdin.lock();
let stdout = std::io::stdout();
let mut output_stream = stdout.lock();
interview::guided(&mut input, &mut output_stream, &session, &output)?;
}
Commands::Plan {
profile,
crew,
intent,
output,
shares,
quipu_db,
quipu_flavor,
embedding_model,
quipu_mcp_url,
quipu_mcp_token_file,
} => {
let profile = Profile::from(profile);
let crew: CrewMode = crew.into();
let mut plan = if profile == Profile::Crew {
Plan::for_crew(crew)
} else {
let mut plan = Plan::for_profile(profile);
if profile == Profile::Everything && crew != CrewMode::Standalone {
plan.crew = Some(caboodle::model::CrewSelection::for_mode(crew));
}
plan
};
plan.shares = shares;
plan.quipu_db = quipu_db;
plan.quipu_flavor = quipu_flavor.into();
if let Some(spec) = embedding_model {
plan.embedding_model = Some(EmbeddingModel::read(&spec)?);
}
plan.quipu_mcp = quipu_mcp_url.map(|url| caboodle::model::QuipuMcp {
url,
token_file: quipu_mcp_token_file.or_else(|| {
std::env::var("QUIPU_AUTH_TOKEN_FILE")
.ok()
.filter(|path| !path.is_empty())
}),
});
plan.intent = Some(InstallIntent::read(&intent)?);
plan.write(&output)?;
println!("plan: {}", output.display());
}
Commands::Apply {
plan,
state,
skip_install,
host_profile,
} => {
engine::apply(
&read_plan(&plan, host_profile.as_deref())?,
&state,
skip_install,
)?;
}
Commands::Verify {
plan,
state,
creel_doctor,
creel_admission,
host_profile,
} => {
engine::verify(
&read_plan(&plan, host_profile.as_deref())?,
&state,
&CrewEvidence {
creel_doctor,
creel_admission,
},
)?;
}
Commands::VerifyMember { member } => {
caboodle::adapter::verify_member(&member)?;
println!("{member}: reviewed release functional verification passed");
}
Commands::Install {
plan,
state,
skip_install,
creel_doctor,
creel_admission,
host_profile,
} => {
let plan = read_plan(&plan, host_profile.as_deref())?;
engine::apply(&plan, &state, skip_install)?;
engine::verify(
&plan,
&state,
&CrewEvidence {
creel_doctor,
creel_admission,
},
)?;
}
Commands::CheckUpdates { plan, host_profile } => {
let plan = read_plan(&plan, host_profile.as_deref())?;
if !engine::check_updates(&plan)? {
anyhow::bail!("selected stack has pending reviewed updates");
}
}
Commands::Update {
plan,
state,
creel_doctor,
creel_admission,
host_profile,
} => {
engine::update(
&read_plan(&plan, host_profile.as_deref())?,
&state,
&CrewEvidence {
creel_doctor,
creel_admission,
},
)?;
}
#[cfg(unix)]
Commands::UpdateSelf {
state,
check,
tag,
archive_sha256,
binary_sha256,
} => match (tag, archive_sha256, binary_sha256) {
(Some(tag), Some(archive), Some(binary)) => {
caboodle::release_update::update_self_pinned(
&state, check, &tag, &archive, &binary,
)?
}
(Some(tag), None, None) => {
caboodle::release_update::update_self_at(&state, check, &tag)?
}
(None, None, None) => caboodle::release_update::update_self(&state, check)?,
_ => anyhow::bail!("installer artifact binding requires tag and both SHA256 values"),
},
#[cfg(unix)]
Commands::PostInstallRepair => {
println!(
"post-install repair: {}",
caboodle::adapter::repair_owned_quipu_alias()?
);
}
#[cfg(unix)]
Commands::BumpMember {
manifest,
tag,
probe_wrapper,
} => {
match caboodle::member_bump::bump(&manifest, tag.as_deref(), probe_wrapper.as_deref())?
{
caboodle::member_bump::Outcome::Current { version } => {
println!(
"{}: already pins {version}; nothing written",
manifest.display()
);
}
caboodle::member_bump::Outcome::Bumped { from, to, digests } => {
println!("{}: {from} -> {to}", manifest.display());
for (target, digest) in digests {
println!(" {target} {digest}");
}
println!(
"review the diff and open a PR; it ships in the next caboodle release"
);
}
}
}
#[cfg(unix)]
Commands::UpdateRelease {
plan,
state,
tool,
check,
} => {
if tool == "shantytown" {
anyhow::bail!(
"shantytown publishes a wheel without a checksum asset; release update refused"
);
}
let tool = serde_json::from_value(serde_json::Value::String(tool))?;
caboodle::release_update::update(&Plan::read(&plan)?, tool, &state, check)?;
}
#[cfg(unix)]
Commands::RollbackSeeds {
plan,
state,
current_sha256,
prior_sha256,
} => {
caboodle::release_update::rollback_seeds(
&Plan::read(&plan)?,
&state,
¤t_sha256,
&prior_sha256,
)?;
}
Commands::ProjectSettings {
plan,
output,
root,
agent,
registry,
policy_only,
} => {
if policy_only {
for name in projection::write(&Plan::read(&plan)?, &output)? {
println!(
"policy summary (not MCP registration): {}",
output.join(name).display()
);
}
} else {
projection::register(root.as_deref(), agent.as_deref(), ®istry)?;
}
}
Commands::VerifyQuestions { plan, db } => {
engine::verify_questions(&Plan::read(&plan)?, db.as_deref())?;
}
Commands::RenderObservability {
plan,
targets,
output,
} => {
observability::render(&Plan::read(&plan)?, &targets, &output)?;
println!("observability: {}", output.display());
}
Commands::ValidateObservability { output } => {
observability::validate(&output)?;
println!("observability contracts: verified");
}
Commands::QueueBr { input, queue } => {
let count = emission::queue_br_jsonl(&input, &queue)?;
println!("queued lifecycle projections: {count}");
}
Commands::FlushEpisodes { queue, endpoint } => {
let count = emission::flush(&queue, &endpoint)?;
println!("delivered queued episodes: {count}");
}
Commands::Packs {
command: PacksCommand::Trust { file, command },
} => run_pack_trust(file, command)?,
}
Ok(())
}
fn run_pack_trust(file: Option<PathBuf>, command: TrustCommand) -> Result<()> {
use caboodle::pack_trust::{default_path, Decision, TrustConfig};
let path = match file {
Some(path) => path,
None => default_path()?,
};
let mut trust = TrustConfig::load(&path)?;
let changed = match command {
TrustCommand::Own { owner } => trust.add_owner(&owner)?,
TrustCommand::Allow { repo, sha256, note } => {
trust.add_pack(&repo, &sha256, note.as_deref())?
}
TrustCommand::AllowEndpoint {
repo,
endpoint,
note,
} => trust.add_endpoint(&repo, &endpoint, note.as_deref())?,
TrustCommand::List => {
print!("{}", toml::to_string_pretty(&trust)?);
return Ok(());
}
TrustCommand::Check {
remote,
sha256,
pack,
} => {
match trust.decide_pack(&remote, &pack, &sha256) {
Decision::Allow { basis } => println!("load: {basis}"),
Decision::Refuse { message } => {
eprintln!("refused: {message}");
std::process::exit(3);
}
}
return Ok(());
}
};
if changed {
trust.save(&path)?;
println!("recorded in {}", path.display());
} else {
println!("already recorded in {}", path.display());
}
Ok(())
}
fn read_plan(path: &std::path::Path, host_profile: Option<&str>) -> anyhow::Result<Plan> {
let (plan, excluded) = Plan::read(path)?.for_host(host_profile)?;
for line in excluded {
println!("{line}");
}
Ok(plan)
}