use crate::adapter::AgentAdapter;
use crate::adapter::claude_code::ClaudeCodeAdapter;
use crate::config::{Bundle, Config};
use crate::git;
use crate::merge::{BundleRef, MergedManifest};
use crate::paths;
use crate::scope::ActiveScopes;
use anyhow::Context;
use clap::{Parser, Subcommand};
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
mod style;
pub use style::{
ColorMode, active_marker, doctor_fail, doctor_pass, doctor_warning, inactive_annotation,
orphan_annotation, should_use_color,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleStatus {
Fresh,
Stale { booted: String, current: String },
Unknown,
}
impl StaleStatus {
#[must_use]
pub fn is_drift(&self) -> bool {
matches!(self, StaleStatus::Stale { .. })
}
}
#[must_use]
pub fn stale_status(booted: Option<&str>, current: &str) -> StaleStatus {
match booted {
None => StaleStatus::Unknown,
Some(b) if b == current => StaleStatus::Fresh,
Some(b) => StaleStatus::Stale {
booted: b.to_string(),
current: current.to_string(),
},
}
}
#[must_use]
fn is_content_hash(s: &str) -> bool {
s.len() == 64
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
const VERSION: &str = env!("LLMENV_VERSION");
const VERSION_TAG: &str = env!("LLMENV_VERSION_TAG");
#[derive(Parser)]
#[command(
name = "llmenv",
version = VERSION,
about = "Universal scope-aware environment for AI coding agents",
arg_required_else_help = true
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
color: ColorChoice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum ColorChoice {
Auto,
Always,
Never,
}
impl ColorChoice {
fn to_mode(self) -> ColorMode {
match self {
ColorChoice::Auto => ColorMode::Auto,
ColorChoice::Always => ColorMode::Always,
ColorChoice::Never => ColorMode::Never,
}
}
}
#[derive(Subcommand)]
enum Command {
Doctor {
#[arg(long)]
gc: bool,
},
Export {
#[arg(short, long)]
scope: Option<String>,
#[arg(short, long)]
tag: Option<String>,
},
Hook {
shell: String,
},
Init {
path: Option<std::path::PathBuf>,
#[arg(long)]
repo: Option<String>,
},
Status,
Context,
#[command(alias = "scopes")]
ScopeLs,
#[command(alias = "tags")]
TagLs,
#[command(alias = "bundles")]
BundleLs,
#[command(name = "mcp-ls", alias = "mcps")]
McpLs,
#[command(name = "marketplace-ls", alias = "marketplaces")]
MarketplaceLs,
#[command(name = "plugin-ls", alias = "plugins")]
PluginLs,
PluginSync,
Sync,
CheckStale,
HookRun {
event: String,
},
Prune {
#[arg(long)]
all: bool,
#[arg(long)]
older_than: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
use std::io::IsTerminal;
let use_color = should_use_color(Some(cli.color.to_mode()), std::io::stdout().is_terminal());
match cli.command {
Some(Command::Doctor { gc }) => {
run_doctor(gc, use_color)?;
}
Some(Command::Export { scope, tag }) => {
run_export(scope, tag)?;
}
Some(Command::Hook { shell }) => {
run_hook(&shell)?;
}
Some(Command::Init { path, repo }) => {
run_init(path, repo)?;
}
Some(Command::Status) => {
run_status(use_color)?;
}
Some(Command::Context) => {
run_context(use_color)?;
}
Some(Command::ScopeLs) => {
run_scope_ls(use_color)?;
}
Some(Command::TagLs) => {
run_tag_ls(use_color)?;
}
Some(Command::BundleLs) => {
run_bundle_ls(use_color)?;
}
Some(Command::McpLs) => {
run_mcp_ls(use_color)?;
}
Some(Command::MarketplaceLs) => {
run_marketplace_ls(use_color)?;
}
Some(Command::PluginLs) => {
run_plugin_ls(use_color)?;
}
Some(Command::PluginSync) => {
run_plugin_sync()?;
}
Some(Command::Sync) => {
run_sync()?;
}
Some(Command::CheckStale) => {
run_check_stale(use_color)?;
}
Some(Command::HookRun { event }) => {
crate::hook_run::run(&event)?;
}
Some(Command::Prune {
all,
older_than,
dry_run,
}) => {
run_prune(all, older_than, dry_run)?;
}
None => {
eprintln!("Usage: llmenv [COMMAND]");
eprintln!("Run 'llmenv --help' for more information.");
}
}
Ok(())
}
fn run_doctor(gc: bool, use_color: bool) -> anyhow::Result<()> {
let pass = doctor_pass(use_color);
let warn = doctor_warning(use_color);
eprintln!("Running llmenv doctor...");
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
eprintln!("{pass} Configuration loaded from {}", config_path.display());
eprintln!("{pass} Config is valid YAML");
let cache_dir = expand_tilde(&config.cache.cache_dir)?;
std::fs::create_dir_all(&cache_dir).context("cache directory not writable")?;
eprintln!(
"{pass} Cache directory is writable: {}",
cache_dir.display()
);
match config.cache.hashing {
crate::config::HashingMode::Loose => {
eprintln!("{pass} Cache hashing: loose (folder: <shape>)");
}
crate::config::HashingMode::Normal => {
eprintln!(
"{pass} Cache hashing: normal (folder: {}/<shape>)",
crate::materialize::cache::version_mm()
);
}
crate::config::HashingMode::Strict => {
eprintln!("{pass} Cache hashing: strict (content-addressed folders)");
}
}
let adapter_cache = cache_dir.join(ClaudeCodeAdapter.name());
let skew_relevant = !matches!(config.cache.hashing, crate::config::HashingMode::Loose);
if let (true, Ok(entries)) = (skew_relevant, std::fs::read_dir(&adapter_cache)) {
let mut cached_versions: Vec<String> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if dir_name.ends_with(".tmp") {
continue; }
let version = match dir_name.rsplit_once('-') {
Some((prefix, tail)) if is_content_hash(tail) => prefix.to_string(),
_ => dir_name.to_string(),
};
cached_versions.push(version);
}
cached_versions.sort();
cached_versions.dedup();
let version_folder = crate::materialize::cache::version_mm();
let current_built = |v: &String| v == VERSION_TAG || *v == version_folder;
if !cached_versions.is_empty() {
let cached_versions_str = cached_versions.join(", ");
if !cached_versions.iter().any(current_built) {
eprintln!(
"{warn} Version skew detected: running llmenv {} but cache has versions [{}]",
VERSION_TAG, cached_versions_str
);
eprintln!("{warn} → Fix: cargo install --path . --force");
}
}
}
let config_dir = paths::config_dir()?;
if is_git_repo(&config_dir) {
match check_git_remote(&config_dir) {
Ok(remote) => {
let safe_url = sanitize_git_url(&remote);
eprintln!("{pass} Git remote reachable: {}", safe_url);
}
Err(e) => eprintln!("{warn} Git remote check failed: {}", e),
}
} else {
eprintln!("{warn} Config directory is not a git repo");
}
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let consumed = all_consumed_tags(&config);
let marker_enabled = marker_enabled_bundle_names(&active);
let mut orphan_count: usize = 0;
for s in &config.scope.network {
if !s.tags.iter().any(|t| consumed.contains(t)) {
eprintln!(
"{warn} orphan scope network:{}: no bundle consumes its tags",
s.id
);
orphan_count += 1;
}
}
for s in &config.scope.host {
if !s.tags.iter().any(|t| consumed.contains(t)) {
eprintln!(
"{warn} orphan scope host:{}: no bundle consumes its tags",
s.id
);
orphan_count += 1;
}
}
for s in &config.scope.user {
if !s.tags.iter().any(|t| consumed.contains(t)) {
eprintln!(
"{warn} orphan scope user:{}: no bundle consumes its tags",
s.id
);
orphan_count += 1;
}
}
let configured_bundle_names: std::collections::HashSet<&str> =
config.bundle.iter().map(|b| b.name.as_str()).collect();
for scope in &active.scopes {
if scope.kind != "project" {
continue;
}
for field in &scope.unknown_fields {
eprintln!("{warn} unknown field in .llmenv.yaml: {field}");
orphan_count += 1;
}
for bundle_name in &scope.enable_bundles {
if !configured_bundle_names.contains(bundle_name.as_str()) {
eprintln!(
"{warn} .llmenv.yaml enable_bundles references unknown bundle: {bundle_name}"
);
orphan_count += 1;
}
}
}
for b in &config.bundle {
let has_emitted_tag = b.tags.iter().any(|t| emitted.contains(t));
if !has_emitted_tag && !marker_enabled.contains(&b.name) {
eprintln!(
"{warn} orphan bundle {}: no scope emits its tags and no marker enables it",
b.name
);
orphan_count += 1;
}
}
for m in &config.mcp {
let has_emitted_tag = m.tags.iter().any(|t| emitted.contains(t));
if !has_emitted_tag {
eprintln!("{warn} orphan mcp {}: no scope emits its tags", m.name);
orphan_count += 1;
}
}
if let Some(mem) = config.features.as_ref().and_then(|f| f.memory.as_ref()) {
let has_emitted_tag = mem.tags.iter().any(|t| emitted.contains(t));
if !has_emitted_tag {
eprintln!("{warn} orphan memory: no scope emits its tags");
orphan_count += 1;
}
if !config.host.contains_key(&mem.server_host) {
eprintln!(
"{warn} memory: server_host '{}' has no entry in the host: table",
mem.server_host
);
orphan_count += 1;
}
}
{
use crate::config::split_plugin_ref;
let mut referenceable: HashSet<&str> = HashSet::new();
for c in &config.plugin_collection {
let selectable = c.tags.iter().any(|t| emitted.contains(t));
if !selectable {
eprintln!(
"{warn} orphan plugin-collection {}: no scope emits its tags",
c.name
);
orphan_count += 1;
}
if selectable {
referenceable.extend(
c.plugins
.iter()
.filter_map(|p| split_plugin_ref(p).map(|(m, _)| m)),
);
}
}
for m in &config.marketplace {
if !referenceable.contains(m.name.as_str()) {
eprintln!(
"{warn} orphan marketplace {}: no selectable plugin references it",
m.name
);
orphan_count += 1;
}
}
}
let mut tag_universe: HashSet<String> = HashSet::new();
tag_universe.extend(emitted.iter().cloned());
tag_universe.extend(consumed.iter().cloned());
tag_universe.extend(active.tags.iter().cloned());
let mut tag_orphans: Vec<String> = tag_universe
.into_iter()
.filter(|t| {
let emitted_anywhere = emitted.contains(t) || active.tags.contains(t);
let consumed_anywhere = consumed.contains(t);
!(emitted_anywhere && consumed_anywhere)
})
.collect();
tag_orphans.sort();
for t in &tag_orphans {
let emitted_anywhere = emitted.contains(t) || active.tags.contains(t);
let reason = if !emitted_anywhere {
"no scope emits it"
} else {
"no bundle consumes it"
};
eprintln!("{warn} orphan tag {}: {}", t, reason);
orphan_count += 1;
}
if orphan_count == 0 {
eprintln!("{pass} No orphan scopes/tags/bundles/plugins");
} else {
eprintln!("{warn} Found {} orphan item(s)", orphan_count);
}
for hook in &config.capabilities.hooks {
if let Some(cmd) = &hook.handler.command
&& cmd.contains("${CLAUDE_PLUGIN_ROOT}")
{
eprintln!(
"{warn} Hook command references ${{CLAUDE_PLUGIN_ROOT}} but runs in top-level settings.json: {}",
cmd
);
eprintln!(
"{warn} → ${{CLAUDE_PLUGIN_ROOT}} only works in plugin-scoped hooks/hooks.json files"
);
eprintln!("{warn} → Move or rewrite this hook in your config or bundle YAML");
}
}
eprintln!("{pass} Doctor check complete.");
if gc {
eprintln!("Running garbage collection...");
match std::fs::metadata(&cache_dir) {
Ok(meta) => {
if meta.permissions().readonly() {
eprintln!("{warn} GC failed: cache directory is read-only");
} else {
let cache_retention_hours = config.cache.cache_retention_hours.unwrap_or(168);
let retention = std::time::Duration::from_secs(cache_retention_hours * 3600);
match crate::materialize::cache::gc(&cache_dir, retention) {
Ok(report) => {
eprintln!(
"{pass} GC complete: removed {} entries, kept {}",
report.removed.len(),
report.kept
);
}
Err(e) => eprintln!("{warn} GC failed: {}", e),
}
}
}
Err(e) => eprintln!("{warn} GC failed to stat cache directory: {}", e),
}
}
Ok(())
}
fn expand_tilde(path: &str) -> anyhow::Result<PathBuf> {
if path.starts_with("~/") || path == "~" {
let home = std::env::var("HOME").context("HOME env var not set")?;
let expanded = path.replacen("~", &home, 1);
Ok(PathBuf::from(expanded))
} else {
Ok(PathBuf::from(path))
}
}
fn is_git_repo(dir: &Path) -> bool {
match git::secure_git()
.args(["rev-parse", "--git-dir"])
.current_dir(dir)
.output()
{
Ok(output) => output.status.success(),
Err(_) => false,
}
}
fn check_git_remote(dir: &Path) -> anyhow::Result<String> {
let output = git::secure_git()
.args(["config", "--get", "remote.origin.url"])
.current_dir(dir)
.output()
.context("failed to get git remote")?;
if !output.status.success() {
anyhow::bail!("no remote configured");
}
let remote = String::from_utf8(output.stdout)?.trim().to_string();
Ok(remote)
}
fn sanitize_git_url(url: &str) -> String {
if let Some(at_pos) = url.find('@') {
if let Some(proto_end) = url.find("://") {
if at_pos > proto_end {
let (proto, rest) = url.split_at(proto_end + 3);
if let Some(host_start) = rest.find('@') {
return format!("{}***@{}", proto, &rest[host_start + 1..]);
}
}
} else {
return format!("***{}", &url[at_pos..]);
}
}
url.to_string()
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn reject_invalid_var_names(vars: &[(String, String)]) -> anyhow::Result<()> {
for (name, _) in vars {
validate_var_name(name)?;
}
Ok(())
}
fn validate_var_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("Variable name cannot be empty");
}
let first = name.as_bytes()[0] as char;
if !first.is_ascii_alphabetic() && first != '_' {
anyhow::bail!(
"Variable name '{}' must start with letter or underscore",
name
);
}
for ch in name.chars() {
if !ch.is_ascii_alphanumeric() && ch != '_' {
anyhow::bail!(
"Variable name '{}' contains invalid character '{}'",
name,
ch
);
}
}
Ok(())
}
fn run_export(scope: Option<String>, tag: Option<String>) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let config_dir = paths::config_dir()?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
if let Some(bind) = local_memory_server_bind(&config, &active) {
match crate::mcp::proxy::default_pid_path() {
Ok(pid_path) => {
if let Err(e) = crate::mcp::proxy::ensure_running(
&bind,
&pid_path,
crate::mcp::proxy::spawn_mcp_proxy,
) {
eprintln!("warning: failed to ensure mcp-proxy running: {e}");
}
}
Err(e) => {
eprintln!("warning: cannot locate mcp-proxy pidfile: {e}");
}
}
}
let interval_secs = config.cache.sync_interval_minutes * 60;
let state_dir = paths::state_dir()?;
if let Err(e) = crate::sync::maybe_pull(
&config_dir,
&state_dir,
std::time::Duration::from_secs(interval_secs),
) {
tracing::debug!("throttled pull failed (non-fatal): {e}");
}
let manually_enabled: BTreeSet<&str> = active
.scopes
.iter()
.flat_map(|s| s.enable_bundles.iter().map(String::as_str))
.collect();
let firing: Vec<&Bundle> = config
.bundle
.iter()
.filter(|b| {
if let Some(t) = &tag
&& !b.tags.contains(t)
{
return false;
}
b.tags.iter().any(|bt| active.tags.contains(bt))
|| manually_enabled.contains(b.name.as_str())
})
.collect();
let mut vars = std::collections::BTreeMap::new();
for bundle in &firing {
for (key, value) in &bundle.vars {
vars.insert(key.clone(), value.clone());
}
}
if scope.is_some() {
eprintln!("warning: scope filtering not yet implemented, exporting all matching tags");
}
match build_and_materialize(&config, &config_dir, &active, &firing) {
Ok(Some((cache_path, extra_vars))) => {
tracing::debug!("materialized agent config at {}", cache_path.display());
for (k, v) in extra_vars {
vars.insert(k, v);
}
}
Ok(None) => {
tracing::debug!("no bundle content directories — skipping materialize");
}
Err(e) => {
eprintln!("warning: agent materialization failed: {e}");
}
}
let scopes_csv = active
.scopes
.iter()
.map(|s| format!("{}:{}", s.kind, s.id))
.collect::<Vec<_>>()
.join(",");
let tags_csv = active.tags.iter().cloned().collect::<Vec<_>>().join(",");
let bundles_csv = firing
.iter()
.map(|b| b.name.clone())
.collect::<Vec<_>>()
.join(",");
vars.insert("LLMENV_ACTIVE_SCOPES".into(), scopes_csv);
vars.insert("LLMENV_ACTIVE_TAGS".into(), tags_csv);
vars.insert("LLMENV_ACTIVE_BUNDLES".into(), bundles_csv);
let winning_project = active
.scopes
.iter()
.filter(|s| s.kind == "project")
.filter_map(|s| s.project_root.as_ref().map(|r| (s, r)))
.max_by_key(|(_, r)| r.as_os_str().len());
if let Some((scope, root)) = winning_project {
vars.insert("LLMENV_ACTIVE_PROJECT".into(), scope.id.clone());
vars.insert(
"LLMENV_PROJECT_ROOT".into(),
root.to_string_lossy().into_owned(),
);
}
let bundles_for_icm = firing.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
let icm_chunk = crate::icm::generate_context_chunk(&active, &bundles_for_icm);
vars.insert("LLMENV_ICM_CONTEXT".into(), icm_chunk);
if let Err(e) = crate::icm::store_tag_memory(&active, &bundles_for_icm) {
tracing::debug!("failed to store ICM tag memory (non-fatal): {e}");
}
for (key, value) in vars {
validate_var_name(&key)?;
println!("export {}={}", key, shell_escape(&value));
}
Ok(())
}
type Materialized = (PathBuf, Vec<(String, String)>);
fn build_and_materialize(
config: &Config,
config_dir: &Path,
active: &ActiveScopes,
firing: &[&Bundle],
) -> anyhow::Result<Option<Materialized>> {
let Some((manifest, cache_root)) = build_manifest(config, config_dir, active, firing, false)?
else {
return Ok(None);
};
let tags = &active.tags;
let bundles: BTreeSet<String> = active
.scopes
.iter()
.flat_map(|s| s.enable_bundles.iter().cloned())
.collect();
let shape = crate::materialize::cache::shape(tags, &bundles);
let adapter = ClaudeCodeAdapter;
let adapter_root = cache_root.join(adapter.name());
let rendered = crate::materialize::materialize_with_mode(
&manifest,
&adapter_root,
config.cache.hashing,
&shape,
)?;
let cache_path = rendered.path;
let adapter_owned = adapter.materialize(&manifest, &cache_path)?;
let owned = adapter_owned
.into_iter()
.chain(manifest.files.keys().cloned());
let current = crate::materialize::manifest::CacheManifest::new(&rendered.hash, owned)
.with_selection(tags.clone(), bundles);
write_cache_manifest(&cache_path, ¤t, config.cache.hashing)?;
let mut env_vars = adapter.env_vars(&cache_path)?;
let state_dir = crate::materialize::state::state_dir(&adapter_root);
crate::materialize::state::ensure_state_dirs(&config.state, &state_dir)
.context("creating durable state directories")?;
env_vars.extend(crate::materialize::state::state_env_vars(
&config.state,
&state_dir,
));
reject_invalid_var_names(&env_vars)?;
Ok(Some((cache_path, env_vars)))
}
fn write_cache_manifest(
cache_path: &Path,
current: &crate::materialize::manifest::CacheManifest,
mode: crate::config::HashingMode,
) -> anyhow::Result<()> {
use crate::materialize::manifest::CacheManifest;
if !matches!(mode, crate::config::HashingMode::Strict)
&& let Some(previous) = CacheManifest::read(cache_path)?
{
for ghost in previous.stale_against(current) {
if crate::paths::is_unsafe_join_target(&ghost) {
tracing::warn!("refusing to delete unsafe owned path from manifest: {ghost}");
continue;
}
let victim = cache_path.join(&ghost);
if let Err(e) = std::fs::remove_file(&victim) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("removing stale cache file {}: {e}", victim.display());
}
} else {
tracing::debug!("removed stale cache file {}", victim.display());
}
}
}
current.write(cache_path)
}
fn build_manifest(
config: &Config,
config_dir: &Path,
active: &ActiveScopes,
firing: &[&Bundle],
refresh_marketplaces: bool,
) -> anyhow::Result<Option<(MergedManifest, PathBuf)>> {
let refs = build_bundle_refs(config_dir, active, firing);
if refs.is_empty() {
return Ok(None);
}
let mut manifest: MergedManifest =
crate::merge::merge(&config.capabilities, &config.native, &refs)?;
manifest.mcps =
crate::mcp::resolve::resolve_mcps(config, &active.tags).context("resolving MCP servers")?;
let cache_root = expand_tilde(&config.cache.cache_dir)?;
let resolved = crate::plugins::resolve::resolve_plugins(config, &active.tags)
.context("resolving plugins")?;
manifest.plugins = resolved.plugins;
manifest.marketplaces = sync_marketplaces(
config,
&cache_root,
resolved.marketplaces,
refresh_marketplaces,
)?;
Ok(Some((manifest, cache_root)))
}
fn run_check_stale(use_color: bool) -> anyhow::Result<()> {
let booted = std::env::var("CLAUDE_CONFIG_DIR")
.ok()
.map(PathBuf::from)
.and_then(|dir| {
crate::materialize::manifest::CacheManifest::read(&dir)
.ok()
.flatten()
.map(|m| m.content_hash)
});
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let config_dir = paths::config_dir()?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let manually_enabled: BTreeSet<&str> = active
.scopes
.iter()
.flat_map(|s| s.enable_bundles.iter().map(String::as_str))
.collect();
let firing: Vec<&Bundle> = config
.bundle
.iter()
.filter(|b| {
b.tags.iter().any(|bt| active.tags.contains(bt))
|| manually_enabled.contains(b.name.as_str())
})
.collect();
let current = match build_manifest(&config, &config_dir, &active, &firing, false)? {
Some((manifest, _)) => crate::materialize::cache::hash_manifest(&manifest)?,
None => {
return Ok(());
}
};
match stale_status(booted.as_deref(), ¤t) {
StaleStatus::Stale { .. } => {
let warn = doctor_warning(use_color);
eprintln!(
"{warn} llmenv config changed in place; restart your agent to load it. \
(Bundles, MCP wiring, or plugin paths changed since this session started.)"
);
}
StaleStatus::Fresh => {}
StaleStatus::Unknown => {
tracing::debug!(
"check-stale: no booted manifest hash to compare against; \
drift detection skipped (current hash would be {current})"
);
}
}
Ok(())
}
fn sync_marketplaces(
config: &Config,
cache_root: &Path,
resolved: Vec<crate::plugins::resolve::ResolvedMarketplace>,
refresh: bool,
) -> anyhow::Result<Vec<crate::plugins::resolve::ResolvedMarketplace>> {
let by_name: std::collections::HashMap<&str, &crate::config::Marketplace> = config
.marketplace
.iter()
.map(|m| (m.name.as_str(), m))
.collect();
let mut out = Vec::with_capacity(resolved.len());
for mut rm in resolved {
let Some(market) = by_name.get(rm.name.as_str()) else {
out.push(rm);
continue;
};
let state = crate::plugins::cache::sync_marketplace(cache_root, market, refresh)
.with_context(|| format!("syncing marketplace '{}'", rm.name))?;
rm.install_location = Some(state.install_location.to_string_lossy().into_owned());
rm.head = state.head;
out.push(rm);
}
Ok(out)
}
fn build_bundle_refs(
config_dir: &Path,
active: &ActiveScopes,
firing: &[&Bundle],
) -> Vec<BundleRef> {
const PRECEDENCE: &[&str] = &["network", "host", "user", "project"];
let bundles_dir = config_dir.join("bundles");
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut refs: Vec<BundleRef> = Vec::new();
let push_ref =
|name: &str, precedence: u8, refs: &mut Vec<BundleRef>, seen: &mut BTreeSet<String>| {
if seen.contains(name) {
return;
}
if crate::paths::is_unsafe_join_target(name) {
tracing::warn!("rejecting bundle name with traversal/absolute path: {name}");
return;
}
let path = bundles_dir.join(name);
if !path.exists() {
return;
}
seen.insert(name.to_owned());
refs.push(BundleRef {
name: name.to_owned(),
path,
precedence,
});
};
for (tier, kind) in PRECEDENCE.iter().enumerate() {
let precedence = u8::try_from(PRECEDENCE.len() - tier).unwrap_or(u8::MAX);
let kind_tags: BTreeSet<&str> = active
.scopes
.iter()
.filter(|s| s.kind == *kind)
.flat_map(|s| s.tags.iter().map(String::as_str))
.collect();
for bundle in firing {
if bundle.tags.iter().any(|t| kind_tags.contains(t.as_str())) {
push_ref(&bundle.name, precedence, &mut refs, &mut seen);
}
}
}
for bundle in firing {
push_ref(&bundle.name, 0, &mut refs, &mut seen);
}
refs
}
fn run_hook(shell: &str) -> anyhow::Result<()> {
match shell {
"zsh" => {
println!("__llmenv_precmd() {{");
println!(" source <(llmenv export)");
println!("}}");
println!();
println!("# Add to precmd_functions if not already present");
println!("if [[ ! \" ${{precmd_functions[@]}} \" =~ \" __llmenv_precmd \" ]]; then");
println!(" precmd_functions+=(\"__llmenv_precmd\")");
println!("fi");
}
"bash" => {
println!("__llmenv_prompt() {{");
println!(" source <(llmenv export)");
println!("}}");
println!();
println!("# Prepend to PROMPT_COMMAND if not already present");
println!("if [[ \"$PROMPT_COMMAND\" != *\"__llmenv_prompt\"* ]]; then");
println!(" PROMPT_COMMAND=\"__llmenv_prompt;$PROMPT_COMMAND\"");
println!("fi");
}
_ => {
anyhow::bail!("Unsupported shell: {}. Supported: zsh, bash", shell);
}
}
Ok(())
}
fn run_init(path: Option<std::path::PathBuf>, repo: Option<String>) -> anyhow::Result<()> {
let config_dir = match path {
Some(p) => expand_tilde(p.to_string_lossy().as_ref())?,
None => paths::config_dir()?,
};
std::fs::create_dir_all(&config_dir)
.with_context(|| format!("creating config dir {}", config_dir.display()))?;
if let Some(_repo_url) = repo {
anyhow::bail!("Git clone not yet implemented");
}
let config_path = config_dir.join("config.yaml");
if config_path.exists() {
eprintln!("Config already exists at {}", config_path.display());
return Ok(());
}
let template = r#"cache:
cache_dir: "~/.cache/llmenv"
sync_interval_minutes: 60
# Cache-folder strictness dial (default: normal). One knob, three positions,
# ordered by how aggressively a folder is reused: loose ⊂ normal ⊂ strict.
# loose — folder = <shape>. Selection-addressed only; a binary upgrade
# reuses the same folder. Fewest folders.
# normal — folder = <major.minor>/<shape>. Config edits re-render into the
# SAME folder, so a running agent only picks up changes on its next
# launch; a minor version bump or selection change mints a new one.
# Preserves in-session agent/plugin state across re-renders.
# strict — folder = <version>-<content_hash>; every input change makes a new
# folder. Strongest isolation, but fragments the cache.
# (<shape> is a digest of the active tags + enabled bundles.)
# hashing: normal
# Scopes are lists — uncomment and fill in as needed.
# scope:
# network:
# - id: home
# match: { ssid: "MyHomeWiFi" }
# tags: [home]
# host:
# - id: laptop
# match: { hostname: "my-laptop" }
# tags: [laptop]
# user:
# - id: me
# match: { user: "alice" }
# tags: [me]
#
# Project scopes are NOT declared here. Drop a `.llmenv.yaml` marker file in a
# project directory instead; llmenv discovers it by walking the current
# directory upward to $HOME. Example `.llmenv.yaml`:
# id: myapp
# name: MyApp
# tags: [myapp]
# enable_bundles: [base] # optional: force-enable bundles regardless of tags
# Bundles fire when one of their tags is emitted by a matching scope.
bundle:
- name: base
tags: [me]
vars:
AGENT: "claude"
# MCP servers are selected by tag, like bundles, and rendered into the agent's
# MCP config (the top-level mcpServers of .claude.json for Claude Code). Each is
# stdio (a command) or remote (a url).
# mcp:
# - name: playwright
# tags: [me]
# command: npx
# args: ["-y", "@playwright/mcp@latest"]
# llmenv's memory backend: one host runs it, every host connects over the
# network. `host:` maps the server host name to a reachable address.
# host:
# my-laptop:
# addr: "my-laptop.local"
# memory:
# server_host: my-laptop
# port: 7878
# tags: [me]
# Durable per-tool state (#175). The materialized config folder is renamed on
# every version/config change, so tool state written under CLAUDE_CONFIG_DIR is
# lost. llmenv guarantees a stable state dir (sibling of the hashed folders,
# never garbage-collected) and always exports LLMENV_STATE_DIR pointing at it.
# Declare a tool's relocation env var here to point it at a per-tool subdir:
# state:
# tools:
# - env: CONTEXT_MODE_DATA_DIR # tool reads this to find its state
# subdir: context-mode # → $LLMENV_STATE_DIR/context-mode
"#;
std::fs::write(&config_path, template)
.with_context(|| format!("writing template to {}", config_path.display()))?;
eprintln!("Created template config at {}", config_path.display());
Config::load(&config_path)?;
eprintln!("✓ Config validated successfully");
Ok(())
}
fn run_status(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
match Config::load(&config_path) {
Ok(config) => {
eprintln!(
"{} Configuration loaded from {}",
doctor_pass(use_color),
config_path.display()
);
eprintln!(" Scopes:");
eprintln!(" Network: {}", config.scope.network.len());
eprintln!(" Host: {}", config.scope.host.len());
eprintln!(" User: {}", config.scope.user.len());
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
if let Some(proj) = active.scopes.iter().find(|s| s.kind == "project") {
let label = proj.name.as_deref().unwrap_or(&proj.id);
if let Some(desc) = &proj.description {
eprintln!(" Project: {label} — {desc}");
} else {
eprintln!(" Project: {label}");
}
} else {
eprintln!(" Project: (none)");
}
eprintln!(" Bundles: {}", config.bundle.len());
}
Err(e) => {
eprintln!("{} Configuration error: {}", doctor_fail(use_color), e);
return Err(e);
}
}
Ok(())
}
fn run_context(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config_dir = config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("config path has no parent directory"))?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let consumed = all_consumed_tags(&config);
let active_ids: HashSet<(&str, &str)> = active
.scopes
.iter()
.map(|s| (s.kind, s.id.as_str()))
.collect();
let mut active_scopes: Vec<String> = Vec::new();
let mut inactive_scopes: Vec<String> = Vec::new();
let mut classify = |kind: &str, id: &str, tags: &[String]| {
let is_active = active_ids.contains(&(kind, id));
let is_orphan = !tags.iter().any(|t| consumed.contains(t));
let name = format!("{}:{}", kind, id);
let annotation = annotate(is_active, is_orphan, use_color);
if is_active {
active_scopes.push(format!(
"{} {}{}",
active_marker(use_color),
name,
annotation
));
} else {
inactive_scopes.push(format!(" {}{}", name, annotation));
}
};
for s in &config.scope.network {
classify("network", &s.id, &s.tags);
}
for s in &config.scope.host {
classify("host", &s.id, &s.tags);
}
for s in &config.scope.user {
classify("user", &s.id, &s.tags);
}
for scope in &active.scopes {
if scope.kind == "project" {
classify("project", &scope.id, &scope.tags);
}
}
if !active_scopes.is_empty() {
println!("Active");
for line in active_scopes {
println!("{}", line);
}
}
if !inactive_scopes.is_empty() {
println!("Inactive");
for line in inactive_scopes {
println!("{}", line);
}
}
let firing: Vec<&Bundle> = config
.bundle
.iter()
.filter(|b| b.tags.iter().any(|tag| active.tags.contains(tag)))
.collect();
let bundle_refs = build_bundle_refs(config_dir, &active, &firing);
let manifest = crate::merge::merge(&config.capabilities, &config.native, &bundle_refs)?;
println!("\nMerged Manifest");
println!("Hooks");
if !manifest.capabilities.hooks.is_empty() {
for hook in &manifest.capabilities.hooks {
let source = hook
.bundle_origin
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("config.yaml");
println!(
" {} {} (from {})",
hook.event,
hook.matcher.as_deref().unwrap_or("*"),
source
);
}
} else {
println!(" (no hooks selected for active context)");
}
Ok(())
}
fn all_emitted_tags(config: &Config) -> HashSet<String> {
let mut out = HashSet::new();
for s in &config.scope.network {
out.extend(s.tags.iter().cloned());
}
for s in &config.scope.host {
out.extend(s.tags.iter().cloned());
}
for s in &config.scope.user {
out.extend(s.tags.iter().cloned());
}
out
}
fn all_consumed_tags(config: &Config) -> HashSet<String> {
config
.bundle
.iter()
.flat_map(|b| b.tags.iter().cloned())
.chain(config.mcp.iter().flat_map(|m| m.tags.iter().cloned()))
.chain(
config
.features
.as_ref()
.and_then(|f| f.memory.as_ref())
.iter()
.flat_map(|m| m.tags.iter().cloned()),
)
.collect()
}
fn marker_enabled_bundle_names(active: &ActiveScopes) -> HashSet<String> {
active
.scopes
.iter()
.flat_map(|s| s.enable_bundles.iter().cloned())
.collect()
}
fn active_host_ids(active: &ActiveScopes) -> BTreeSet<String> {
active
.scopes
.iter()
.filter(|s| s.kind == "host")
.map(|s| s.id.clone())
.collect()
}
fn is_memory_backend_active(config: &Config, active: &ActiveScopes) -> bool {
if let Some(mem) = config.features.as_ref().and_then(|f| f.memory.as_ref()) {
let selected = mem.tags.iter().any(|t| active.tags.contains(t));
let is_server = active_host_ids(active).contains(&mem.server_host);
selected && is_server
} else {
false
}
}
fn local_memory_server_bind(config: &Config, active: &ActiveScopes) -> Option<String> {
let mem = config.features.as_ref().and_then(|f| f.memory.as_ref())?;
if is_memory_backend_active(config, active) {
Some(format!("0.0.0.0:{}", mem.port))
} else {
None
}
}
fn annotate(active: bool, orphan: bool, use_color: bool) -> String {
if active {
String::new()
} else if orphan {
format!(" {}", orphan_annotation(use_color))
} else {
format!(" {}", inactive_annotation(use_color))
}
}
fn run_scope_ls(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let consumed = all_consumed_tags(&config);
let active_ids: HashSet<(&str, &str)> = active
.scopes
.iter()
.map(|s| (s.kind, s.id.as_str()))
.collect();
let mut rows: Vec<(String, bool, bool)> = Vec::new();
let push = |rows: &mut Vec<(String, bool, bool)>,
kind: &str,
id: &str,
tags: &[String],
active_ids: &HashSet<(&str, &str)>,
consumed: &HashSet<String>| {
let is_active = active_ids.contains(&(kind, id));
let is_orphan = !tags.iter().any(|t| consumed.contains(t));
rows.push((format!("{}:{}", kind, id), is_active, is_orphan));
};
for s in &config.scope.network {
push(&mut rows, "network", &s.id, &s.tags, &active_ids, &consumed);
}
for s in &config.scope.host {
push(&mut rows, "host", &s.id, &s.tags, &active_ids, &consumed);
}
for s in &config.scope.user {
push(&mut rows, "user", &s.id, &s.tags, &active_ids, &consumed);
}
for scope in &active.scopes {
if scope.kind == "project" {
push(
&mut rows,
"project",
&scope.id,
&scope.tags,
&active_ids,
&consumed,
);
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (name, is_active, is_orphan) in rows {
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {}{}",
mark,
name,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_tag_ls(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let consumed = all_consumed_tags(&config);
let mut universe: HashSet<String> = HashSet::new();
universe.extend(emitted.iter().cloned());
universe.extend(consumed.iter().cloned());
universe.extend(active.tags.iter().cloned());
let mut tags: Vec<String> = universe.into_iter().collect();
tags.sort();
for tag in tags {
let is_active = active.tags.contains(&tag);
let emitted_anywhere = emitted.contains(&tag) || active.tags.contains(&tag);
let consumed_anywhere = consumed.contains(&tag);
let is_orphan = !(emitted_anywhere && consumed_anywhere);
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {}{}",
mark,
tag,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_bundle_ls(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let marker_enabled = marker_enabled_bundle_names(&active);
let firing_names: HashSet<&str> = config
.bundle
.iter()
.filter(|b| {
b.tags.iter().any(|t| active.tags.contains(t))
|| marker_enabled.contains(b.name.as_str())
})
.map(|b| b.name.as_str())
.collect();
let mut rows: Vec<(String, bool, bool)> = config
.bundle
.iter()
.map(|b| {
let is_active = firing_names.contains(b.name.as_str());
let has_emitted_tag = b.tags.iter().any(|t| emitted.contains(t));
let is_orphan = !has_emitted_tag && !marker_enabled.contains(&b.name);
(b.name.clone(), is_active, is_orphan)
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (name, is_active, is_orphan) in rows {
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {}{}",
mark,
name,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_mcp_ls(use_color: bool) -> anyhow::Result<()> {
use crate::mcp::resolve::{MEMORY_MCP_NAME, ResolvedKind, resolve_mcps};
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let resolved =
resolve_mcps(&config, &active.tags).context("resolving MCP servers for listing")?;
let resolved_by_name: std::collections::HashMap<&str, &ResolvedKind> = resolved
.iter()
.map(|m| (m.name.as_str(), &m.kind))
.collect();
let detail_for = |name: &str, fallback: &str| match resolved_by_name.get(name) {
Some(ResolvedKind::Stdio { .. }) => "stdio server".to_string(),
Some(ResolvedKind::Remote { transport, .. }) => {
format!("{} client", format!("{transport:?}").to_lowercase())
}
None => fallback.to_string(),
};
let mut rows: Vec<(String, bool, bool, String)> = config
.mcp
.iter()
.map(|m| {
let is_active = m.tags.iter().any(|t| active.tags.contains(t));
let is_orphan = !m.tags.iter().any(|t| emitted.contains(t));
let detail = detail_for(&m.name, &format!("{:?}", m.transport).to_lowercase());
(m.name.clone(), is_active, is_orphan, detail)
})
.collect();
if let Some(mem) = config.features.as_ref().and_then(|f| f.memory.as_ref()) {
let is_active = mem.tags.iter().any(|t| active.tags.contains(t));
let is_orphan = !mem.tags.iter().any(|t| emitted.contains(t));
let detail = detail_for(MEMORY_MCP_NAME, "memory");
rows.push((MEMORY_MCP_NAME.to_string(), is_active, is_orphan, detail));
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (name, is_active, is_orphan, detail) in rows {
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {} ({}){}",
mark,
name,
detail,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_marketplace_ls(use_color: bool) -> anyhow::Result<()> {
use crate::config::split_plugin_ref;
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let active_refs: std::collections::HashSet<&str> = config
.plugin_collection
.iter()
.filter(|c| c.tags.iter().any(|t| active.tags.contains(t)))
.flat_map(|c| c.plugins.iter())
.filter_map(|p| split_plugin_ref(p).map(|(m, _)| m))
.collect();
let referenceable: std::collections::HashSet<&str> = config
.plugin_collection
.iter()
.filter(|c| c.tags.iter().any(|t| emitted.contains(t)))
.flat_map(|c| c.plugins.iter())
.filter_map(|p| split_plugin_ref(p).map(|(m, _)| m))
.collect();
let mut rows: Vec<(String, bool, bool, String)> = config
.marketplace
.iter()
.map(|m| {
let is_active = active_refs.contains(m.name.as_str());
let is_orphan = !referenceable.contains(m.name.as_str());
let kind = match m.classify_source() {
crate::config::MarketplaceSource::Git => "git",
crate::config::MarketplaceSource::Path => "path",
};
(m.name.clone(), is_active, is_orphan, kind.to_string())
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (name, is_active, is_orphan, kind) in rows {
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {} ({}){}",
mark,
name,
kind,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_plugin_ls(use_color: bool) -> anyhow::Result<()> {
use crate::config::split_plugin_ref;
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
let emitted = all_emitted_tags(&config);
let mut rows: Vec<(String, bool, bool, String)> = Vec::new();
for collection in &config.plugin_collection {
let is_active = collection.tags.iter().any(|t| active.tags.contains(t));
let is_orphan = !collection.tags.iter().any(|t| emitted.contains(t));
for plugin in &collection.plugins {
let display = split_plugin_ref(plugin)
.map_or_else(|| plugin.clone(), |(m, p)| format!("{p}@{m}"));
rows.push((display, is_active, is_orphan, collection.name.clone()));
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (name, is_active, is_orphan, collection) in rows {
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
println!(
"{} {} (from {}){}",
mark,
name,
collection,
annotate(is_active, is_orphan, use_color)
);
}
Ok(())
}
fn run_plugin_sync() -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let cache_root = expand_tilde(&config.cache.cache_dir)?;
if config.marketplace.is_empty() {
eprintln!("No marketplaces configured.");
return Ok(());
}
for m in &config.marketplace {
let state = crate::plugins::cache::sync_marketplace(&cache_root, m, true)
.with_context(|| format!("syncing marketplace '{}'", m.name))?;
let head = state.head.as_deref().unwrap_or("(local path)");
println!(
"✓ {} → {} [{}]",
m.name,
state.install_location.display(),
head
);
}
Ok(())
}
fn run_sync() -> anyhow::Result<()> {
let config_dir = paths::config_dir()?;
git::secure_git()
.args(["add", "-A"])
.current_dir(&config_dir)
.status()
.context("failed to stage changes (git add -A)")?;
let commit_result = git::secure_git()
.args(["commit", "-m", "Update llmenv config"])
.current_dir(&config_dir)
.status()
.context("failed to create commit (git commit)")?;
if !commit_result.success() {
eprintln!("No changes to commit (working tree clean)");
return Ok(());
}
git::secure_git()
.args(["push"])
.current_dir(&config_dir)
.status()
.context("failed to push config (git push)")?;
eprintln!("✓ Synced config to GitHub");
Ok(())
}
fn run_prune(all: bool, older_than: Option<String>, dry_run: bool) -> anyhow::Result<()> {
use crate::materialize::cache::PruneMode;
if all && older_than.is_some() {
anyhow::bail!("--all and --older-than are mutually exclusive");
}
let mode = if all {
PruneMode::All
} else if let Some(duration_str) = &older_than {
let dur = humantime::parse_duration(duration_str)
.with_context(|| format!("failed to parse --older-than duration: {}", duration_str))?;
PruneMode::OlderThan(dur)
} else {
PruneMode::StaleOnly
};
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let cache_dir = expand_tilde(&config.cache.cache_dir)?;
let current_version = match config.cache.hashing {
crate::config::HashingMode::Normal => Some(crate::materialize::cache::version_mm()),
crate::config::HashingMode::Loose | crate::config::HashingMode::Strict => None,
};
let report = crate::materialize::cache::prune(
&cache_dir.join(ClaudeCodeAdapter.name()),
mode,
config.cache.hashing,
current_version.as_deref(),
dry_run,
)?;
let verb = if dry_run { "would remove" } else { "removed" };
for p in &report.removed {
eprintln!(" {verb}: {}", p.display());
}
for p in &report.failed {
eprintln!(" failed to remove: {}", p.display());
}
eprintln!(
"prune complete: {} {} entry(ies), kept {}",
verb,
report.removed.len(),
report.kept
);
if !report.failed.is_empty() {
eprintln!(" {} entry(ies) could not be removed", report.failed.len());
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::config::HashingMode;
use crate::materialize::manifest::{CacheManifest, MANIFEST_FILE};
#[test]
fn is_content_hash_matches_only_64_lowercase_hex() {
assert!(is_content_hash(&"a".repeat(64)));
assert!(is_content_hash(&"0123456789abcdef".repeat(4)));
assert!(!is_content_hash(&"a".repeat(63)), "too short");
assert!(!is_content_hash(&"a".repeat(65)), "too long");
assert!(!is_content_hash(&"A".repeat(64)), "uppercase rejected");
assert!(!is_content_hash("1.2"), "version folder is not a hash");
assert!(!is_content_hash(&format!("{}g", "a".repeat(63))), "non-hex");
}
fn built(content_hash: &str, owned: &[&str]) -> CacheManifest {
CacheManifest::new(content_hash, owned.iter().map(PathBuf::from))
}
#[test]
fn write_cache_manifest_writes_dotfile_in_all_modes() {
for mode in [HashingMode::Loose, HashingMode::Normal, HashingMode::Strict] {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("folder");
std::fs::create_dir_all(&cache).unwrap();
let current = built("hash1", &["CLAUDE.md", "a.md"]);
write_cache_manifest(&cache, ¤t, mode).unwrap();
let read = CacheManifest::read(&cache).unwrap().unwrap();
assert_eq!(read.content_hash, "hash1");
assert!(read.owned.contains("CLAUDE.md"), "adapter-owned recorded");
assert!(read.owned.contains("a.md"), "generic file recorded");
assert!(
cache.join(MANIFEST_FILE).exists(),
"dotfile written ({mode:?})"
);
}
}
#[test]
fn write_cache_manifest_reuse_modes_remove_ghost_files() {
for mode in [HashingMode::Loose, HashingMode::Normal] {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("folder");
std::fs::create_dir_all(&cache).unwrap();
let ghost = cache.join("ghost.md");
std::fs::write(&ghost, b"old").unwrap();
write_cache_manifest(&cache, &built("h1", &["ghost.md", "keep.md"]), mode).unwrap();
let foreign = cache.join("foreign-state.json");
std::fs::write(&foreign, b"plugin state").unwrap();
write_cache_manifest(&cache, &built("h2", &["keep.md"]), mode).unwrap();
assert!(
!ghost.exists(),
"ghost owned file removed on re-render ({mode:?})"
);
assert!(
foreign.exists(),
"foreign file survives reconciliation ({mode:?})"
);
let read = CacheManifest::read(&cache).unwrap().unwrap();
assert_eq!(read.content_hash, "h2");
assert!(!read.owned.contains("ghost.md"));
}
}
#[test]
fn write_cache_manifest_refuses_to_delete_outside_cache() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("1.2");
std::fs::create_dir_all(&cache).unwrap();
let victim = tmp.path().join("victim.txt");
std::fs::write(&victim, b"do not delete").unwrap();
let tampered = r#"{"content_hash":"old","owned":["../victim.txt"]}"#;
std::fs::write(cache.join(MANIFEST_FILE), tampered).unwrap();
write_cache_manifest(&cache, &built("new", &[]), HashingMode::Normal).unwrap();
assert!(
victim.exists(),
"reconciliation must never delete a file outside the cache folder"
);
}
#[test]
fn write_cache_manifest_strict_mode_never_reconciles() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("v1-hash");
std::fs::create_dir_all(&cache).unwrap();
let prior = CacheManifest::new("old", vec![PathBuf::from("ghost.md")]);
prior.write(&cache).unwrap();
let bystander = cache.join("ghost.md");
std::fs::write(&bystander, b"x").unwrap();
write_cache_manifest(&cache, &built("new", &[]), HashingMode::Strict).unwrap();
assert!(
bystander.exists(),
"strict mode performs no ghost reconciliation"
);
}
#[test]
fn shell_escape_protects_metacharacters() {
assert_eq!(shell_escape("normal"), "'normal'");
assert_eq!(shell_escape("with'quote"), "'with'\\''quote'");
assert_eq!(shell_escape("$var"), "'$var'");
assert_eq!(shell_escape("$(cmd)"), "'$(cmd)'");
assert_eq!(shell_escape("`cmd`"), "'`cmd`'");
}
#[test]
fn validate_var_name_accepts_valid_names() {
assert!(validate_var_name("MY_VAR").is_ok());
assert!(validate_var_name("_private").is_ok());
assert!(validate_var_name("var123").is_ok());
assert!(validate_var_name("x").is_ok());
}
#[test]
fn validate_var_name_rejects_invalid_names() {
assert!(validate_var_name("").is_err());
assert!(validate_var_name("123var").is_err());
assert!(validate_var_name("my-var").is_err());
assert!(validate_var_name("my var").is_err());
assert!(validate_var_name("my$var").is_err());
}
#[test]
fn reject_invalid_var_names_passes_valid_and_fails_invalid() {
let ok = vec![
("CLAUDE_CONFIG_DIR".to_string(), "/x".to_string()),
("_PRIVATE".to_string(), "y".to_string()),
];
assert!(reject_invalid_var_names(&ok).is_ok());
let bad = vec![("bad-name".to_string(), "v".to_string())];
assert!(reject_invalid_var_names(&bad).is_err());
let bad_leading_digit = vec![("1ABC".to_string(), "v".to_string())];
assert!(reject_invalid_var_names(&bad_leading_digit).is_err());
}
#[test]
fn hook_zsh_generates_precmd_code() {
let result = run_hook("zsh");
assert!(result.is_ok());
}
#[test]
fn hook_bash_generates_prompt_command_code() {
let result = run_hook("bash");
assert!(result.is_ok());
}
#[test]
fn hook_unsupported_shell_fails() {
let result = run_hook("fish");
assert!(result.is_err());
}
#[test]
fn expand_tilde_home() {
let home = std::env::var("HOME")
.context("HOME env var not set")
.unwrap();
let result = expand_tilde("~/test").unwrap();
assert_eq!(result, PathBuf::from(format!("{}/test", home)));
}
#[test]
fn expand_tilde_tilde_only() {
let home = std::env::var("HOME")
.context("HOME env var not set")
.unwrap();
let result = expand_tilde("~").unwrap();
assert_eq!(result, PathBuf::from(&home));
}
#[test]
fn expand_tilde_no_tilde() {
let result = expand_tilde("/absolute/path").unwrap();
assert_eq!(result, PathBuf::from("/absolute/path"));
}
#[test]
fn sanitize_git_url_http_with_credentials() {
let url = "https://user:password@github.com/owner/repo.git";
let sanitized = sanitize_git_url(url);
assert_eq!(sanitized, "https://***@github.com/owner/repo.git");
}
#[test]
fn sanitize_git_url_ssh() {
let url = "git@github.com:owner/repo.git";
let sanitized = sanitize_git_url(url);
assert_eq!(sanitized, "***@github.com:owner/repo.git");
}
#[test]
fn sanitize_git_url_no_credentials() {
let url = "https://github.com/owner/repo.git";
let sanitized = sanitize_git_url(url);
assert_eq!(sanitized, url);
}
}