use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod combine;
mod commands;
mod extends;
mod fs_paths;
mod git;
mod index;
mod launch;
mod lock;
mod pack;
mod profile;
mod progress;
mod provision;
mod resolve;
mod vendor;
mod vendor_fs;
#[derive(Parser)]
#[command(name = "claude-profile", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
profiles: Vec<String>,
#[arg(long)]
yes: bool,
#[arg(last = true)]
extra: Vec<String>,
}
#[derive(Subcommand)]
enum Command {
List,
Show { target: String },
Install { spec: String },
Update {
#[command(subcommand)]
target: Option<UpdateTarget>,
},
Status,
Remove { target: String },
New { name: String },
Test {
target: String,
#[arg(long)]
json: bool,
#[arg(last = true)]
extra: Vec<String>,
},
Find {
query: Vec<String>,
#[arg(long)]
sync: bool,
#[arg(long)]
refresh_seeds: bool,
#[arg(long)]
json: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
marketplace: Option<String>,
},
SelfUninstall {
#[arg(long)]
purge: bool,
},
Completions {
shell: commands::completions::Shell,
#[arg(long)]
install: bool,
},
#[command(hide = true, name = "profile-names")]
ProfileNames,
Statusline {
#[command(subcommand)]
action: commands::statusline::Action,
},
#[command(hide = true, name = "statusline-render")]
StatuslineRender,
}
#[derive(Subcommand)]
enum UpdateTarget {
Profiles {
#[arg(long)]
frozen: bool,
},
}
type LoadedProfiles = (Vec<(String, profile::Profile)>, Vec<(PathBuf, String)>);
fn load_all_profiles(
paths: &fs_paths::Paths, cwd: &std::path::Path,
env: Option<&std::path::Path>, bundled: &std::path::Path,
) -> LoadedProfiles {
let mut out = Vec::new();
let mut failed = Vec::new();
for (name, path, _src) in resolve::list_available(paths, cwd, env, bundled) {
match std::fs::read_to_string(&path) {
Ok(body) => match profile::Profile::from_json_str(&body) {
Ok(p) => out.push((name, p)),
Err(e) => failed.push((path, e.to_string())),
},
Err(e) => failed.push((path, e.to_string())),
}
}
(out, failed)
}
fn bundled_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles")
}
fn env_dir() -> Option<PathBuf> {
std::env::var_os("CLAUDE_PROFILE_DIR").map(PathBuf::from)
}
fn run() -> anyhow::Result<i32> {
let cli = Cli::parse();
let _progress = progress::install();
let paths = fs_paths::Paths::detect()?;
let cwd = std::env::current_dir()?;
let bundled = bundled_dir();
let env = env_dir();
match cli.command {
Some(command) => dispatch_command(command, &paths, &cwd, env.as_deref(), &bundled),
None => handle_launch(&cli.profiles, cli.yes, &cli.extra, &paths, &cwd, env.as_deref(), &bundled),
}
}
fn dispatch_command(
command: Command,
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<i32> {
match command {
Command::List => {
commands::list::run(paths, cwd, env, bundled)?;
Ok(0)
}
Command::Show { target } => {
commands::show::run(&target, paths, cwd, env, bundled)?;
Ok(0)
}
Command::Install { spec } => {
let dir = pack::install_pack(&git::RealGit, &spec, paths)?;
progress::clear();
println!("installed pack at {}", dir.display());
Ok(0)
}
Command::Update { target: None } => {
commands::check_update::run(&std::env::current_exe()?)?;
Ok(0)
}
Command::Update { target: Some(UpdateTarget::Profiles { frozen }) } => {
handle_update(frozen, paths, cwd, env, bundled)?;
Ok(0)
}
Command::New { name } => {
commands::new::run(&name, paths)?;
Ok(0)
}
Command::Test { target, json, extra } => commands::test::run(&target, json, &extra),
Command::Find { query, sync, refresh_seeds, json, limit, marketplace } => {
commands::find::run(paths, &query, sync, refresh_seeds, json, limit, marketplace.as_deref())
}
Command::SelfUninstall { purge } => {
commands::self_uninstall::run(paths, purge)?;
Ok(0)
}
Command::Status => handle_status(paths, cwd, env, bundled),
Command::Remove { target } => handle_remove(&target, paths, cwd, env, bundled),
Command::Completions { shell, install } => {
commands::completions::run(shell, install, paths)?;
Ok(0)
}
Command::ProfileNames => {
commands::list::run_names(paths, cwd, env, bundled)?;
Ok(0)
}
Command::Statusline { action } => commands::statusline::run_action(action, paths, cwd),
Command::StatuslineRender => {
print!("{}", commands::statusline::render(paths, cwd));
Ok(0)
}
}
}
fn handle_status(
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<i32> {
let (profiles, failed) = load_all_profiles(paths, cwd, env, bundled);
for (path, err) in &failed {
eprintln!("warning: could not parse profile {}: {err}", path.display());
}
commands::status::run(paths, &profiles)?;
Ok(0)
}
fn handle_remove(
target: &str,
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<i32> {
let plan = commands::remove::remove_target(target, paths, cwd, env, bundled)?;
commands::remove::apply(&plan)?;
println!("removed {target}");
Ok(0)
}
fn handle_update(
frozen: bool,
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<()> {
pack::update_all_packs(&git::RealGit, paths)?;
let (profiles, failed) = load_all_profiles(paths, cwd, env, bundled);
for (path, err) in &failed {
eprintln!("warning: skipping unparseable profile {}: {err}", path.display());
}
let dir_lookup = |n: &str| paths.marketplace_clone_dir(n);
if frozen {
let mut triples = Vec::new();
for (name, profile) in &profiles {
let Some(profile) = resolve_extends_or_warn(name, profile, paths, cwd, env, bundled) else { continue };
let resolved = resolve::resolve(name, paths, cwd, env, bundled)?;
let lp = lock::lock_path(name, &resolved.path, &resolved.source, paths);
let lf = lock::Lockfile::load(&lp)?.unwrap_or_else(|| lock::Lockfile::new(name));
triples.push((name.clone(), profile, lf));
}
commands::update::frozen_check(&triples)?;
progress::clear();
println!("--frozen: all locks up to date");
} else {
let total = profiles.len();
for (i, (name, profile)) in profiles.iter().enumerate() {
let Some(profile) = resolve_extends_or_warn(name, profile, paths, cwd, env, bundled) else { continue };
let missing = profile.marketplaces.keys().find(|mkt| !paths.marketplace_clone_dir(mkt).is_dir());
if let Some(mkt) = missing {
eprintln!(
"skipping '{name}': marketplace '{mkt}' not cloned yet (launch the profile once to provision it)"
);
continue;
}
let resolved = resolve::resolve(name, paths, cwd, env, bundled)?;
let lp = lock::lock_path(name, &resolved.path, &resolved.source, paths);
let mut lf = lock::Lockfile::load(&lp)?.unwrap_or_else(|| lock::Lockfile::new(name));
progress::step(&format!("re-resolving {name} ({}/{total})", i + 1));
commands::update::reresolve_profile(&git::RealGit, &profile, name, cwd, paths, &dir_lookup, &mut lf)?;
lf.save(&lp)?;
let mkt_count = profile.marketplaces.len();
let plugin_count = profile.plugins.len();
let mkt_plural = if mkt_count == 1 { "marketplace" } else { "marketplaces" };
let plugin_plural = if plugin_count == 1 { "plugin" } else { "plugins" };
progress::done(&format!(
"{name} — {mkt_count} {mkt_plural}, {plugin_count} {plugin_plural}"
));
}
}
Ok(())
}
fn resolve_extends_or_warn(
name: &str,
profile: &profile::Profile,
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> Option<profile::Profile> {
match extends::resolve_extends(profile.clone(), &|parent| {
Ok(resolve::resolve(parent, paths, cwd, env, bundled)?.profile)
}) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("warning: skipping '{name}': could not resolve extends: {e}");
None
}
}
}
fn handle_launch(
names: &[String],
assume_yes: bool,
extra: &[String],
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<i32> {
match names {
[] => {
use clap::CommandFactory;
Cli::command().print_help()?;
println!();
Ok(0)
}
[name] => {
let one = resolve_one(name, paths, cwd, env, bundled)?;
let lock_file = lock::lock_path(&one.key, &one.path, &one.source, paths);
provision_pin_launch(&one.profile, &one.key, &lock_file, assume_yes, extra, cwd, paths)
}
_ => launch_combined(names, assume_yes, extra, paths, cwd, env, bundled),
}
}
struct ResolvedTarget {
key: String,
profile: profile::Profile,
path: std::path::PathBuf,
source: resolve::ProfileSource,
}
fn resolve_one(
name: &str,
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<ResolvedTarget> {
let key = if name.contains('/') {
let dir = pack::install_pack(&git::RealGit, name, paths)?;
pack::default_profile_name(&dir)?
} else {
name.to_string()
};
let resolved = resolve::resolve(&key, paths, cwd, env, bundled)?;
let (path, source) = (resolved.path.clone(), resolved.source.clone());
let profile = extends::resolve_extends(resolved.profile, &|parent| {
Ok(resolve::resolve(parent, paths, cwd, env, bundled)?.profile)
})?;
Ok(ResolvedTarget { key, profile, path, source })
}
fn launch_combined(
names: &[String],
assume_yes: bool,
extra: &[String],
paths: &fs_paths::Paths,
cwd: &std::path::Path,
env: Option<&std::path::Path>,
bundled: &std::path::Path,
) -> anyhow::Result<i32> {
let mut resolved = Vec::new();
for name in names {
let t = resolve_one(name, paths, cwd, env, bundled)?;
resolved.push((t.key, t.profile));
}
let combined = combine::combine_profiles(&resolved)?;
let key = combined.name.clone();
let lock_file = paths.locks_dir().join(format!("{key}.lock"));
provision_pin_launch(&combined, &key, &lock_file, assume_yes, extra, cwd, paths)
}
fn provision_pin_launch(
profile: &profile::Profile,
key: &str,
lock_file: &std::path::Path,
assume_yes: bool,
extra: &[String],
cwd: &std::path::Path,
paths: &fs_paths::Paths,
) -> anyhow::Result<i32> {
provision::provision(&git::RealGit, profile, key, cwd, paths, assume_yes)?;
let mut lock = lock::Lockfile::load(lock_file)?.unwrap_or_else(|| lock::Lockfile::new(key));
let dir_lookup = |n: &str| paths.marketplace_clone_dir(n);
provision::pin_marketplaces(&git::RealGit, profile, &dir_lookup, &mut lock, false)?;
provision::vendor_plugins(&git::RealGit, profile, key, cwd, paths, false, &mut lock)?;
if let Some(parent) = lock_file.parent() {
std::fs::create_dir_all(parent)?;
}
lock.save(lock_file)?;
let args = launch::build_args(profile, key, paths, extra)?;
progress::clear();
launch::spawn(key, &args)
}
fn main() {
match run() {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
}