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 doctor;
mod status;
mod style;
pub use style::{
ColorMode, active_marker, doctor_fail, doctor_info, 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,
#[arg(long)]
all: bool,
},
Export {
#[arg(short, long)]
scope: Option<String>,
#[arg(short, long)]
tag: Option<String>,
#[arg(long)]
explain: bool,
},
Regenerate,
Hook {
shell: String,
},
Init {
path: Option<std::path::PathBuf>,
#[arg(long)]
repo: Option<String>,
},
Status {
#[arg(value_enum)]
section: Option<status::StatusSection>,
},
Context {
#[arg(long)]
bundle: Option<String>,
#[arg(long)]
why: bool,
},
#[command(alias = "scopes", hide = true)]
ScopeLs,
#[command(alias = "tags", hide = true)]
TagLs,
#[command(alias = "bundles", hide = true)]
BundleLs,
#[command(name = "mcp-ls", alias = "mcps", hide = true)]
McpLs,
#[command(name = "marketplace-ls", alias = "marketplaces", hide = true)]
MarketplaceLs,
#[command(name = "plugin-ls", alias = "plugins", hide = true)]
PluginLs,
PluginSync,
Sync {
#[arg(long)]
dry_run: bool,
},
CheckStale {
#[arg(long)]
auto_fix: bool,
},
Validate,
Edit {
bundle: Option<String>,
},
Completions {
shell: clap_complete::Shell,
},
ConfigContext,
ConfigGuard,
HookRun {
event: String,
},
Login {
#[arg(long)]
global: bool,
},
Prune {
#[arg(long)]
all: bool,
#[arg(long)]
older_than: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
fn run_deprecated_shim(
old: &str,
new: &str,
section: status::StatusSection,
use_color: bool,
) -> anyhow::Result<()> {
eprintln!("warning: '{old}' is deprecated; use 'status {new}' instead");
status::run_status(Some(section), use_color)
}
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, all }) => {
doctor::run_doctor(gc, all, use_color)?;
}
Some(Command::Export {
scope,
tag,
explain,
}) => {
run_export(scope, tag, explain)?;
}
Some(Command::Regenerate) => {
run_regenerate()?;
}
Some(Command::Hook { shell }) => {
run_hook(&shell)?;
}
Some(Command::Init { path, repo }) => {
run_init(path, repo)?;
}
Some(Command::Status { section }) => {
status::run_status(section, use_color)?;
}
Some(Command::Context { bundle, why }) => {
run_context(bundle.as_deref(), why, use_color)?;
}
Some(Command::ScopeLs) => {
run_deprecated_shim(
"scope-ls",
"scopes",
status::StatusSection::Scopes,
use_color,
)?;
}
Some(Command::TagLs) => {
run_deprecated_shim("tag-ls", "tags", status::StatusSection::Tags, use_color)?;
}
Some(Command::BundleLs) => {
run_deprecated_shim(
"bundle-ls",
"bundles",
status::StatusSection::Bundles,
use_color,
)?;
}
Some(Command::McpLs) => {
run_deprecated_shim("mcp-ls", "mcps", status::StatusSection::Mcps, use_color)?;
}
Some(Command::MarketplaceLs) => {
run_deprecated_shim(
"marketplace-ls",
"marketplaces",
status::StatusSection::Marketplaces,
use_color,
)?;
}
Some(Command::PluginLs) => {
run_deprecated_shim(
"plugin-ls",
"plugins",
status::StatusSection::Plugins,
use_color,
)?;
}
Some(Command::PluginSync) => {
run_plugin_sync()?;
}
Some(Command::Sync { dry_run }) => {
run_sync(dry_run)?;
}
Some(Command::CheckStale { auto_fix }) => {
run_check_stale(use_color, auto_fix)?;
}
Some(Command::ConfigContext) => {
run_config_context();
}
Some(Command::ConfigGuard) => {
run_config_guard();
}
Some(Command::HookRun { event }) => {
crate::hook_run::run(&event)?;
}
Some(Command::Login { global }) => {
run_login(global)?;
}
Some(Command::Prune {
all,
older_than,
dry_run,
}) => {
run_prune(all, older_than, dry_run)?;
}
Some(Command::Validate) => {
run_validate(use_color)?;
}
Some(Command::Edit { bundle }) => {
run_edit(bundle)?;
}
Some(Command::Completions { shell }) => {
run_completions(shell)?;
}
None => {
eprintln!("Usage: llmenv [COMMAND]");
eprintln!("Run 'llmenv --help' for more information.");
}
}
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 shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn reject_invalid_var_names(env: &[(String, String)]) -> anyhow::Result<()> {
for (name, _) in env {
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>, explain: bool) -> 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);
let top_memory: &[_] = config
.features
.as_ref()
.map(|f| f.memory.as_slice())
.unwrap_or_default();
if let Some(bind) = local_memory_server_bind(top_memory, &active) {
match crate::mcp::proxy::default_pid_path() {
Ok(pid_path) => {
match crate::mcp::proxy::ensure_running(
&bind,
&pid_path,
crate::mcp::proxy::spawn_mcp_proxy,
) {
Ok(outcome) => {
if outcome == crate::mcp::proxy::EnsureOutcome::Spawned
&& let Some(mem) = find_local_memory_entry(top_memory, &active)
&& let Ok(addr) = mem.listen_host.parse::<std::net::IpAddr>()
&& addr.is_unspecified()
{
eprintln!(
"warning: memory.listen_host is '{}' — the ICM proxy \
will accept connections on ALL network interfaces. \
Set a specific IP to restrict access.",
mem.listen_host
);
}
}
Err(e) => {
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::warn!("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.when.contains(t)
{
return false;
}
b.when.iter().any(|bt| active.tags.contains(bt))
|| manually_enabled.contains(b.name.as_str())
})
.collect();
let mut vars = std::collections::BTreeMap::new();
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((ref cache_path, ref extra_vars))) => {
tracing::debug!("materialized agent config at {}", cache_path.display());
for (k, v) in extra_vars {
vars.insert(k.clone(), v.clone());
}
let adapter_root =
expand_tilde(&config.cache.cache_dir)?.join(ClaudeCodeAdapter.name());
match crate::materialize::manifest::CacheManifest::read(cache_path) {
Ok(Some(mut manifest)) => {
crate::auth::detect::sync_auth_on_export(
cache_path,
&adapter_root,
&mut manifest,
);
}
Ok(None) => {} Err(e) => {
tracing::warn!(
"auth sync skipped: could not read cache manifest at {}: {e}",
cache_path.display()
);
}
}
}
Ok(None) => {
tracing::debug!("no bundle content directories — skipping materialize");
}
Err(e) => return Err(e).context("agent materialization failed"),
}
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}");
}
if explain {
let bundle_list = firing
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ");
for (key, value) in vars {
validate_var_name(&key)?;
if key.starts_with("LLMENV_") {
println!("# source: llmenv introspection");
} else {
println!("# source: adapter (bundles: {bundle_list})");
}
println!("export {}={}", key, shell_escape(&value));
}
} else {
for (key, value) in vars {
validate_var_name(&key)?;
println!("export {}={}", key, shell_escape(&value));
}
}
Ok(())
}
fn run_regenerate() -> 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);
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.when.iter().any(|bt| active.tags.contains(bt))
|| manually_enabled.contains(b.name.as_str())
})
.collect();
match build_and_materialize(&config, &config_dir, &active, &firing) {
Ok(Some((cache_path, _))) => {
eprintln!("✓ Regenerated config at {}", cache_path.display());
eprintln!(
" Tags: {}",
active.tags.iter().cloned().collect::<Vec<_>>().join(", ")
);
eprintln!(
" Bundles: {}",
firing
.iter()
.map(|b| b.name.clone())
.collect::<Vec<_>>()
.join(", ")
);
eprintln!("\n Restart your shell session or source the config to load changes.");
}
Ok(None) => {
eprintln!("✓ No bundle content to materialize");
}
Err(e) => return Err(e).context("config regeneration failed"),
}
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)?;
crate::adapter::claude_code::apply_seeded_settings(&cache_path, &config.init.seeded_settings)?;
let auth_status = inject_cached_auth_if_available(&adapter_root, &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)
.with_auth_status(auth_status);
write_cache_manifest(&cache_path, ¤t, config.cache.hashing)?;
let mut env_vars = adapter.env_vars(&cache_path)?;
for (key, value) in &manifest.capabilities.env {
env_vars.push((key.clone(), value.clone()));
}
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 inject_cached_auth_if_available(
adapter_root: &std::path::Path,
cache_path: &std::path::Path,
) -> crate::materialize::manifest::AuthStatus {
use crate::materialize::manifest::{AuthSource, AuthStatus};
match crate::auth::choose_auth_for_inheritance(adapter_root) {
Err(e) => {
tracing::debug!("auth cache lookup failed (non-fatal): {e}");
AuthStatus::default()
}
Ok(None) => AuthStatus::default(),
Ok(Some(entry)) => match crate::auth::inject_auth_into_claude_json(cache_path, &entry) {
Ok(()) => {
eprintln!("[llmenv] auth: {} (inherited)", entry.email);
AuthStatus {
source: AuthSource::Inherited,
id: Some(entry.uuid),
email: Some(entry.email),
}
}
Err(e) => {
tracing::warn!("auth inject failed for {} (non-fatal): {e}", entry.email);
AuthStatus::default()
}
},
}
}
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)?;
let top_memory = config
.features
.as_ref()
.map(|f| f.memory.as_slice())
.unwrap_or_default();
let bundle_memory = manifest
.capabilities
.features
.as_ref()
.map(|f| f.memory.as_slice())
.unwrap_or_default();
let mut all_memory: Vec<crate::config::Memory> = top_memory
.iter()
.chain(bundle_memory.iter())
.cloned()
.collect();
crate::util::dedup(&mut all_memory);
let mut all_host = manifest.capabilities.host.clone();
for (k, v) in &config.host {
all_host.insert(k.clone(), v.clone());
}
manifest.mcps =
crate::mcp::resolve::resolve_mcps(&config.mcp, &all_memory, &all_host, &active.tags)
.context("resolving MCP servers")?;
manifest.mcps.extend(
crate::mcp::resolve::resolve_bundle_mcps(&manifest.capabilities.mcp, &active.tags)
.context(
"resolving bundle MCP servers \
(check mcp: entries in active bundle.yaml files)",
)?,
);
{
let mut seen = std::collections::HashSet::new();
for m in &manifest.mcps {
if !seen.insert(m.name.as_str()) {
anyhow::bail!(
"mcp name '{}' declared in both config.mcp and a bundle mcp: — \
rename one to avoid ambiguity",
m.name
);
}
}
}
let cache_root = expand_tilde(&config.cache.cache_dir)?;
let resolved = crate::plugins::resolve::resolve_plugins(config, &active.tags)
.context("resolving plugins")?;
manifest.plugins = sync_plugin_payloads(&cache_root, 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, auto_fix: 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.when.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 { .. } => {
if auto_fix {
match build_and_materialize(&config, &config_dir, &active, &firing) {
Ok(Some((cache_path, _))) => {
eprintln!("✓ Config refreshed at {}", cache_path.display());
}
Ok(None) => {
eprintln!("✓ Config up-to-date (no content directory)");
}
Err(e) => return Err(e).context("auto-fix: re-materialization failed"),
}
} else {
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 run_config_context() {
use std::io::Read;
let mut stdin_buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut stdin_buf) {
eprintln!("llmenv config-context: failed to read stdin: {e}");
}
let hook_event_name = serde_json::from_str::<serde_json::Value>(&stdin_buf)
.ok()
.and_then(|v| v["hook_event_name"].as_str().map(str::to_owned))
.unwrap_or_else(|| "SessionStart".to_owned());
let emit = |ctx: &str| {
println!(
"{}",
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": hook_event_name,
"additionalContext": ctx
}
})
);
};
let config_path = match paths::config_path() {
Ok(p) => p,
Err(e) => {
eprintln!("llmenv config-context: failed to resolve config path: {e}");
emit(
"llmenv config-context: could not resolve config path. \
Run `llmenv doctor` to diagnose.",
);
return;
}
};
let bundles_dir = match paths::config_dir() {
Ok(d) => d.join("bundles"),
Err(e) => {
eprintln!("llmenv config-context: failed to resolve config dir: {e}");
config_path
.parent()
.map(|p| p.join("bundles"))
.unwrap_or_else(|| PathBuf::from(paths::expand_tilde("~/.config/llmenv/bundles")))
}
};
let text = format!(
"llmenv source config:\n\
\u{2022} Config: {config}\n\
\u{2022} Bundles: {bundles}\n\
\n\
To update llmenv config, edit the source files above and run `llmenv regenerate`.\n\
Do NOT edit files under ~/.cache/llmenv/ \u{2014} they are managed and will be overwritten.",
config = config_path.display(),
bundles = bundles_dir.display(),
);
emit(&text);
}
fn run_config_guard() {
use std::io::Read;
let mut stdin_buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut stdin_buf) {
eprintln!("llmenv config-guard: failed to read stdin: {e}");
return;
}
let default_cache = PathBuf::from(paths::expand_tilde("~/.cache/llmenv"));
let cache_root = match std::env::var("CLAUDE_CONFIG_DIR") {
Err(_) => default_cache, Ok(dir) => {
let path = PathBuf::from(&dir);
match path
.ancestors()
.skip(1)
.find(|p| p.file_name().map(|n| n == "claude-code").unwrap_or(false))
.and_then(|p| p.parent().map(PathBuf::from))
{
Some(root) => root,
None => {
eprintln!(
"llmenv config-guard: could not locate cache root from \
CLAUDE_CONFIG_DIR={dir}; falling back to default"
);
default_cache
}
}
}
};
let parsed = serde_json::from_str::<serde_json::Value>(&stdin_buf);
let file_path = match parsed {
Ok(v) => v
.get("tool_input")
.and_then(|ti| ti.get("file_path"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
Err(e) => {
if !stdin_buf.trim().is_empty() {
eprintln!("llmenv config-guard: failed to parse hook payload: {e}");
}
None
}
};
let Some(path_str) = file_path else {
return;
};
let expanded = paths::expand_tilde(&path_str);
if is_within_cache(&cache_root, &expanded) {
let config_path = paths::config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| paths::expand_tilde("~/.config/llmenv/config.yaml"));
println!(
"\u{26a0} llmenv: {path_str} is inside the managed cache and will be overwritten \
on the next config regeneration.\n\
Edit your source config instead: {config_path}\n\
Then run: llmenv regenerate"
);
}
}
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 sync_result = crate::plugins::cache::sync_marketplace(cache_root, market, refresh);
match sync_result {
Ok(state) => {
rm.install_location = Some(state.install_location.to_string_lossy().into_owned());
rm.head = state.head;
out.push(rm);
}
Err(crate::plugins::cache::SyncError::NotCloned { .. }) => {
eprintln!(
"warning: marketplace '{}' not yet cloned\n → plugins from this marketplace are excluded; run `llmenv plugin-sync` to fetch it",
rm.name
);
}
Err(e) => return Err(anyhow::anyhow!("syncing marketplace '{}': {e}", rm.name)),
}
}
Ok(out)
}
fn sync_plugin_payloads(
cache_root: &Path,
plugins: Vec<crate::plugins::resolve::ResolvedPlugin>,
) -> Vec<crate::plugins::resolve::ResolvedPlugin> {
plugins
.into_iter()
.map(|mut p| {
let mkt_path = crate::plugins::cache::marketplace_path(cache_root, &p.marketplace);
let Ok(entries) = crate::plugins::cache::read_marketplace_plugins(&mkt_path) else {
tracing::warn!(
"cannot read marketplace manifest for '{}' — skipping external plugin '{}', \
run `llmenv plugin-sync` to repair",
p.marketplace,
p.plugin
);
return p;
};
let Some(entry) = entries.iter().find(|e| e.name == p.plugin) else {
tracing::warn!(
"plugin '{}' not found in marketplace '{}' manifest — \
verify plugin name or run `llmenv plugin-sync` to refresh the clone",
p.plugin,
p.marketplace
);
return p;
};
if !crate::plugins::cache::is_external_plugin_source(&entry.source) {
return p;
}
match crate::plugins::cache::sync_external_plugin(
cache_root,
&p.marketplace,
&p.plugin,
&entry.source,
false,
) {
Ok(state) => {
p.install_path = Some(state.install_location.to_string_lossy().into_owned());
p.git_commit_sha = state.head;
}
Err(crate::plugins::cache::SyncError::NotCloned { .. }) => {
eprintln!(
"warning: external plugin '{}@{}' not yet fetched\n \
→ run `llmenv plugin-sync` to download it",
p.plugin, p.marketplace
);
}
Err(e) => {
eprintln!(
"warning: external plugin '{}@{}' payload lookup failed: {e}",
p.plugin, p.marketplace
);
}
}
p
})
.collect()
}
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() {
tracing::warn!(
"bundle '{}' has no content directory at {}; \
skipping (tag-only bundle, or missing/deleted directory)",
name,
path.display()
);
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.when.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 emit_hook_guards() {
println!(" [[ $- != *i* ]] && return");
println!(" [[ -n \"$LLMENV_STATE_DIR\" ]] && return");
}
fn run_hook(shell: &str) -> anyhow::Result<()> {
match shell {
"zsh" => {
println!("__llmenv_precmd() {{");
emit_hook_guards();
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() {{");
emit_hook_guards();
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) => {
let path_str = p
.to_str()
.ok_or_else(|| anyhow::anyhow!("init path is not valid UTF-8: {}", p.display()))?;
expand_tilde(path_str)?
}
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 = crate::config::generate_template();
std::fs::write(&config_path, template)
.with_context(|| format!("writing template to {}", config_path.display()))?;
eprintln!("Created template config at {}", config_path.display());
let config = Config::load(&config_path).with_context(|| {
format!(
"validating newly-written config at {}",
config_path.display()
)
})?;
eprintln!("✓ Config validated successfully");
let agents_path = config_dir.join("AGENTS.md");
if !agents_path.exists() {
let agents_template = r#"# Agent Orientation
This directory contains llmenv configuration. Agents (Claude Code, Copilot, Gemini CLI)
operating here will have access to the merged config and bundles.
## Key Files & Directories
- **config.yaml** — Main configuration. Declares scopes, bundles, MCP servers, state locations.
- **bundles/** — Bundle directories. Each bundle contains files merged into agent config:
- `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` — Agent instructions (loaded automatically)
- `skills/` — Custom skills directory
- `hooks/` — Hook definitions
- `mcp.json` — MCP server configurations (optional)
- **state/** — Durable per-tool state (managed by llmenv; don't write here directly)
- **.llmenv.yaml** — Project scope marker (place in project roots, not here)
## Where to Add Things
### New Agent Instructions
Create or edit a bundle's `CLAUDE.md` (Claude Code), `AGENTS.md` (all agents), or `GEMINI.md` (Gemini CLI):
```
bundles/myname/CLAUDE.md
```
### New Skills
Add to a bundle's `skills/` directory:
```
bundles/myname/skills/my-skill.json
```
### New Hooks
Add to a bundle's `hooks/` directory or declare in `config.yaml`:
```
bundles/myname/hooks/some-hook.sh
```
### MCP Servers
Either add to a bundle's `mcp.json` or declare in `config.yaml` under `mcp:`.
### Per-Tool Durable State
Declare in `config.yaml` under `state: tools:`:
```yaml
state:
tools:
- env: MY_TOOL_STATE_DIR
subdir: my-tool
```
## Scopes & Tags
Scopes (network, host, user, project) emit **tags** when they match. Bundles and other
resources fire when one of their tags is in the active tag set. See `config.yaml` comments
for examples.
---
For more, see the llmenv documentation and the `config.yaml` template comments.
"#;
std::fs::write(&agents_path, agents_template)
.with_context(|| format!("writing agents template to {}", agents_path.display()))?;
eprintln!(
"Created agent orientation guide at {}",
agents_path.display()
);
}
let readme_path = config_dir.join("README.md");
let readme_content = r#"# llmenv Configuration
This directory contains your llmenv configuration.
## Layout
- **config.yaml** — Main configuration file. Declares scopes, bundles, MCP servers,
plugins, and the memory backend. Edit this to define which environments activate
in which contexts (networks, hosts, users, projects).
- **.llmenv.yaml** — Project markers (one per project). Drop a marker file at the
root of any project directory to give that project its own scope, tags, and
enabled bundles. llmenv discovers these by walking upward from the current
directory.
- **bundles/** — Bundle content directories. Each directory here matches a
`bundle:` entry in `config.yaml`. Bundles can contain:
- YAML files (`bundle.yaml`) with MCP servers, hooks, and other capabilities
- Environment variables
- Plugin declarations
## Getting Started
1. **Edit config.yaml** to add your first scopes (network, host, user) and a bundle.
See the comments in the file for examples and reference the [Configuration docs](https://phaedrus1992.github.io/llmenv/docs/configuration).
2. **Install the shell hook** to activate llmenv on every prompt:
```bash
eval "$(llmenv hook zsh)" # Add to ~/.zshrc
# or
eval "$(llmenv hook bash)" # Add to ~/.bashrc
```
3. **Verify setup**:
```bash
llmenv doctor # Check for configuration errors
llmenv status # Show active scopes and tags
llmenv export # Preview the exported environment
```
4. **Add projects** by creating `.llmenv.yaml` marker files at project roots.
## Concepts
- **Scopes** — Describe where you are (network/host/user/project). Each emits **tags**.
- **Tags** — Labels that trigger bundles, MCP servers, plugins, and memory.
- **Bundles** — Fire when their tags match the active set. Contribute config, env vars, and capabilities.
- **Materialize** — llmenv combines active scopes/bundles into a content-addressed config directory.
- **Adapter** — Renders the materialized config into the agent's native format (e.g. Claude Code).
## Documentation
- [Getting Started](https://phaedrus1992.github.io/llmenv/docs/getting-started) — First-run walkthrough
- [Configuration](https://phaedrus1992.github.io/llmenv/docs/configuration) — Complete schema reference
- [Concepts](https://phaedrus1992.github.io/llmenv/docs/concepts) — Scopes, tags, bundles, precedence
- [Main Site](https://phaedrus1992.github.io/llmenv/) — All documentation
"#;
if !readme_path.exists() {
std::fs::write(&readme_path, readme_content)
.with_context(|| format!("writing README to {}", readme_path.display()))?;
eprintln!("Created README at {}", readme_path.display());
}
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
let adapter_root = expand_tilde(&config.cache.cache_dir)?.join(ClaudeCodeAdapter.name());
run_init_auth_prompt(&adapter_root)?;
run_init_settings_prompt(&config_path)?;
}
Ok(())
}
fn run_init_auth_prompt(adapter_root: &Path) -> anyhow::Result<()> {
use dialoguer::Select;
let selection = Select::new()
.with_prompt("Configure authentication for new Claude Code sessions?")
.items([
"Login fresh via `claude auth login`",
"Import from ~/.claude (copy existing login)",
"Skip (configure later with `llmenv login`)",
])
.default(2)
.interact_opt()
.map_err(|e| anyhow::anyhow!("auth prompt failed: {e}"))?;
match selection {
Some(0) => {
eprintln!("Launching Claude Code login...");
run_login_capture(adapter_root, None)?;
}
Some(1) => {
let default_claude =
PathBuf::from(paths::expand_tilde("~/.claude")).join(".claude.json");
if default_claude.exists() {
import_auth_from_file(&default_claude, adapter_root)?;
} else {
eprintln!(
"~/.claude/.claude.json not found — skipping. \
Run `llmenv login` after authenticating."
);
}
}
_ => {
eprintln!("Skipping auth setup. Run `llmenv login` when ready.");
}
}
Ok(())
}
fn run_init_settings_prompt(config_path: &Path) -> anyhow::Result<()> {
use crate::adapter::claude_code::LLMENV_OWNED_SETTINGS_KEYS;
use dialoguer::MultiSelect;
let global_settings = PathBuf::from(paths::expand_tilde("~/.claude")).join("settings.json");
if !global_settings.exists() {
return Ok(());
}
let bytes = match std::fs::read(&global_settings) {
Ok(b) => b,
Err(e) => {
eprintln!(
"warning: could not read {} — skipping settings import: {e}",
global_settings.display()
);
return Ok(());
}
};
let doc: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
eprintln!(
"warning: {} is not valid JSON — skipping settings import: {e}",
global_settings.display()
);
return Ok(());
}
};
let Some(obj) = doc.as_object() else {
return Ok(());
};
let candidates: Vec<(&String, &serde_json::Value)> = obj
.iter()
.filter(|(k, _)| !LLMENV_OWNED_SETTINGS_KEYS.contains(&k.as_str()))
.collect();
if candidates.is_empty() {
return Ok(());
}
let labels: Vec<String> = candidates
.iter()
.map(|(k, v)| format!("{k} = {v}"))
.collect();
let chosen = MultiSelect::new()
.with_prompt(
"Select settings from ~/.claude/settings.json to seed into new folders \
(space to toggle, enter to confirm)",
)
.items(&labels)
.interact_opt()
.map_err(|e| anyhow::anyhow!("settings prompt failed: {e}"))?;
let Some(indices) = chosen else {
return Ok(());
};
if indices.is_empty() {
return Ok(());
}
let count = indices.len();
let mut config = Config::load(config_path)?;
for idx in indices {
let (key, val) = candidates[idx];
config.init.seeded_settings.insert(key.clone(), val.clone());
}
let yaml =
serde_yaml::to_string(&config).map_err(|e| anyhow::anyhow!("serializing config: {e}"))?;
paths::write_owner_only_atomic(config_path, yaml.as_bytes())
.with_context(|| format!("writing config {}", config_path.display()))?;
eprintln!("✓ {count} setting(s) added to init.seeded_settings in config.yaml");
Ok(())
}
fn run_login(global: bool) -> anyhow::Result<()> {
let config = Config::load(&paths::config_path()?)?;
let adapter_root = expand_tilde(&config.cache.cache_dir)?.join(ClaudeCodeAdapter.name());
if global {
eprintln!("Capturing global auth (will be inherited by all new folders)...");
run_login_capture(&adapter_root, None)?;
} else {
let current_folder = std::env::var("CLAUDE_CONFIG_DIR")
.ok()
.map(PathBuf::from)
.filter(|p| p.starts_with(&adapter_root));
if current_folder.is_none() {
eprintln!(
"note: CLAUDE_CONFIG_DIR is not set or not under the llmenv adapter root — \
capturing global auth only. Run `llmenv export` then re-run without \
--global to apply to the current folder."
);
}
run_login_capture(&adapter_root, current_folder.as_deref())?;
}
Ok(())
}
fn run_login_capture(adapter_root: &Path, current_folder: Option<&Path>) -> anyhow::Result<()> {
let tmp = tempfile::TempDir::new()
.map_err(|e| anyhow::anyhow!("creating temp dir for login: {e}"))?;
let status = std::process::Command::new("claude")
.args(["auth", "login"])
.env("CLAUDE_CONFIG_DIR", tmp.path())
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(|e| {
anyhow::anyhow!(
"running `claude auth login` failed: {e}. \
Is the Claude Code CLI installed and on PATH?"
)
})?;
if !status.success() {
anyhow::bail!("`claude auth login` exited with status {status}");
}
let entry = crate::auth::read_auth_from_dir(tmp.path())?.ok_or_else(|| {
anyhow::anyhow!(
"`claude auth login` completed but no oauthAccount found in the result. \
Try running `claude auth login` directly."
)
})?;
crate::auth::save_auth_entry(adapter_root, &entry)?;
eprintln!("[llmenv] login: saved auth for {}", entry.email);
if let Some(folder) = current_folder
&& folder.is_dir()
{
crate::auth::inject_auth_into_claude_json(folder, &entry)?;
if let Ok(Some(mut manifest)) = crate::materialize::manifest::CacheManifest::read(folder) {
manifest.auth_status = crate::materialize::manifest::AuthStatus {
source: crate::materialize::manifest::AuthSource::Explicit,
id: Some(entry.uuid),
email: Some(entry.email),
};
manifest.write(folder)?;
}
}
Ok(())
}
fn import_auth_from_file(source: &Path, adapter_root: &Path) -> anyhow::Result<()> {
let bytes = std::fs::read(source).with_context(|| format!("reading {}", source.display()))?;
let doc: serde_json::Value =
serde_json::from_slice(&bytes).with_context(|| format!("parsing {}", source.display()))?;
let entry = crate::auth::extract_auth_entry(&doc).ok_or_else(|| {
anyhow::anyhow!(
"{} has no oauthAccount block — not logged in. \
Try `llmenv login` to authenticate.",
source.display()
)
})?;
crate::auth::save_auth_entry(adapter_root, &entry)?;
eprintln!("[llmenv] login: imported auth for {}", entry.email);
Ok(())
}
fn run_context(bundle_filter: Option<&str>, why: bool, 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 {
let why_str = if why {
let matched: Vec<&str> = tags
.iter()
.filter(|t| active.tags.contains(*t))
.map(String::as_str)
.collect();
if matched.is_empty() {
String::new()
} else {
format!(" [why: tags={}]", matched.join(","))
}
} else {
String::new()
};
active_scopes.push(format!(
"{} {}{}{}",
active_marker(use_color),
name,
annotation,
why_str
));
} 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 bundles_to_show: Vec<&Bundle> = if let Some(filter) = bundle_filter {
let matching: Vec<&Bundle> = config.bundle.iter().filter(|b| b.name == filter).collect();
if matching.is_empty() {
anyhow::bail!("bundle not found: {filter}");
}
matching
} else {
config.bundle.iter().collect()
};
if !bundles_to_show.is_empty() {
println!("\nBundles");
for b in &bundles_to_show {
let is_active = b.when.iter().any(|tag| active.tags.contains(tag));
let mark = if is_active {
active_marker(use_color)
} else {
" ".to_string()
};
let why_str = if why && is_active {
let matched: Vec<&str> = b
.when
.iter()
.filter(|t| active.tags.contains(*t))
.map(String::as_str)
.collect();
format!(" [why: tags={}]", matched.join(","))
} else {
String::new()
};
println!("{} {}{}", mark, b.name, why_str);
}
}
let firing: Vec<&Bundle> = config
.bundle
.iter()
.filter(|b| b.when.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.when.iter().cloned())
.chain(config.mcp.iter().flat_map(|m| m.when.iter().cloned()))
.chain(
config
.features
.as_ref()
.iter()
.flat_map(|f| f.memory.iter())
.flat_map(|m| m.when.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 tag_looks_marker_sourced(tag: &str) -> bool {
tag.starts_with("lang-")
}
fn looks_marker_driven(bundle_name: &str, bundle: &Bundle) -> bool {
let marker_patterns = [
"rust", "python", "node", "go", "java", "csharp", "c++", "ruby", "php", "swift", "kotlin",
];
let name_matches = marker_patterns.iter().any(|p| bundle_name.contains(p));
let tag_matches = bundle.when.iter().any(|t| tag_looks_marker_sourced(t));
name_matches || tag_matches
}
fn active_host_ids(active: &ActiveScopes) -> BTreeSet<String> {
active
.scopes
.iter()
.filter(|s| s.kind == "host")
.map(|s| s.id.clone())
.collect()
}
fn find_local_memory_entry<'a>(
memory: &'a [crate::config::Memory],
active: &ActiveScopes,
) -> Option<&'a crate::config::Memory> {
let host_ids = active_host_ids(active);
memory.iter().find(|m| {
m.when.iter().any(|t| active.tags.contains(t)) && host_ids.contains(&m.server_host)
})
}
fn local_memory_server_bind(
memory: &[crate::config::Memory],
active: &ActiveScopes,
) -> Option<String> {
let mem = find_local_memory_entry(memory, active)?;
Some(format!("{}:{}", mem.listen_host, mem.port))
}
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_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
);
}
let all_plugin_refs: std::collections::HashSet<(String, String)> = config
.plugin_collection
.iter()
.flat_map(|c| c.plugins.iter())
.filter_map(|p| crate::config::split_plugin_ref(p))
.map(|(mkt, plugin)| (mkt.to_string(), plugin.to_string()))
.collect();
let mut missing_plugins: Vec<String> = Vec::new();
for (mkt_name, plugin_name) in &all_plugin_refs {
let mkt_path = crate::plugins::cache::marketplace_path(&cache_root, mkt_name);
let plugins = crate::plugins::cache::read_marketplace_plugins(&mkt_path)
.with_context(|| format!("reading marketplace manifest for '{mkt_name}'"))?;
let Some(entry) = plugins.iter().find(|p| p.name == *plugin_name) else {
eprintln!(
"✗ {plugin_name}@{mkt_name}: not found in marketplace manifest after sync — \
check that the plugin name matches an entry in {mkt_name}"
);
missing_plugins.push(format!("{plugin_name}@{mkt_name}"));
continue;
};
if !crate::plugins::cache::is_external_plugin_source(&entry.source) {
continue;
}
let state = crate::plugins::cache::sync_external_plugin(
&cache_root,
mkt_name,
plugin_name,
&entry.source,
true,
)
.with_context(|| format!("syncing external plugin '{plugin_name}@{mkt_name}'"))?;
let head = state.head.as_deref().unwrap_or("(unknown)");
println!(
"✓ {}@{} (external) → {} [{}]",
plugin_name,
mkt_name,
state.install_location.display(),
head
);
}
if !missing_plugins.is_empty() {
anyhow::bail!(
"{} plugin(s) not found after sync: {}",
missing_plugins.len(),
missing_plugins.join(", ")
);
}
Ok(())
}
fn run_sync(dry_run: bool) -> anyhow::Result<()> {
let config_dir = paths::config_dir()?;
if dry_run {
let out = git::secure_git()
.args(["status", "--short"])
.current_dir(&config_dir)
.output()
.context("failed to run git status")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("git status failed: {stderr}");
}
let output = String::from_utf8_lossy(&out.stdout);
if output.trim().is_empty() {
eprintln!("Nothing to sync (working tree clean)");
} else {
eprintln!("Would sync:\n{output}");
}
return Ok(());
}
match crate::sync::commit_and_push(&config_dir, "Update llmenv config")? {
crate::sync::SyncOutcome::NothingToCommit => {
eprintln!("No changes to commit (working tree clean)");
}
crate::sync::SyncOutcome::Pushed => {
eprintln!("✓ Synced config to GitHub");
}
}
Ok(())
}
fn run_validate(use_color: bool) -> anyhow::Result<()> {
let config_path = paths::config_path()?;
let config = Config::load(&config_path)?;
let pass = doctor_pass(use_color);
let fail = doctor_fail(use_color);
let mut valid = true;
let mut seen_names = HashSet::new();
for bundle in &config.bundle {
if !seen_names.insert(bundle.name.as_str()) {
eprintln!("{fail} duplicate bundle name: {}", bundle.name);
valid = false;
}
}
let env = crate::scope::matcher::Env::detect();
let active = crate::scope::evaluate(&config, &env);
for scope in &active.scopes {
if scope.kind != "project" {
continue;
}
for bundle_name in &scope.enable_bundles {
if !seen_names.contains(bundle_name.as_str()) {
eprintln!(
"{fail} .llmenv.yaml enable_bundles references unknown bundle: {bundle_name}"
);
valid = false;
}
}
}
if valid {
eprintln!("{pass} config valid ({} bundle(s))", config.bundle.len());
} else {
anyhow::bail!("validation failed");
}
Ok(())
}
fn run_edit(bundle: Option<String>) -> anyhow::Result<()> {
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "vi".to_owned());
let path = if let Some(name) = bundle {
if crate::paths::is_unsafe_join_target(&name) {
anyhow::bail!("unsafe bundle name: {name}");
}
let config_dir = paths::config_dir()?;
let candidate = config_dir.join("bundles").join(format!("{name}.yaml"));
if candidate.exists() {
candidate
} else {
let alt = config_dir.join("bundles").join(format!("{name}.yml"));
if alt.exists() {
alt
} else {
anyhow::bail!("bundle file not found: bundles/{name}.yaml");
}
}
} else {
paths::config_path()?
};
let parts: Vec<&str> = editor.split_whitespace().collect();
let bin = parts
.first()
.copied()
.ok_or_else(|| anyhow::anyhow!("$EDITOR / $VISUAL is set but empty"))?;
let extra_args = parts.get(1..).unwrap_or_default();
let status = std::process::Command::new(bin)
.args(extra_args)
.arg(&path)
.status()
.with_context(|| format!("failed to launch editor: {editor}"))?;
if !status.success() {
anyhow::bail!("editor exited with {status}");
}
Ok(())
}
fn run_completions(shell: clap_complete::Shell) -> anyhow::Result<()> {
use clap::CommandFactory;
use std::io::Write;
let mut buf: Vec<u8> = Vec::new();
clap_complete::generate(shell, &mut Cli::command(), "llmenv", &mut buf);
std::io::stdout()
.write_all(&buf)
.context("failed to write completions to stdout")?;
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"));
}
fn marketplace_config(name: &str, source: &str) -> Config {
Config {
marketplace: vec![crate::config::Marketplace {
name: name.into(),
source: source.into(),
}],
..Config::default()
}
}
fn resolved_marketplace(name: &str) -> crate::plugins::resolve::ResolvedMarketplace {
crate::plugins::resolve::ResolvedMarketplace {
name: name.into(),
source: String::new(),
install_location: None,
head: None,
}
}
#[test]
fn sync_marketplaces_git_not_cloned_non_fatal_when_not_refreshing() {
let config = marketplace_config("remote", "https://github.com/example/plugins.git");
let tmp = tempfile::tempdir().unwrap();
let result = sync_marketplaces(
&config,
tmp.path(),
vec![resolved_marketplace("remote")],
false,
);
assert!(
result.is_ok(),
"git not-cloned during export must be non-fatal"
);
assert!(
result.unwrap().is_empty(),
"non-cloned marketplace is dropped from output"
);
}
#[test]
fn sync_marketplaces_path_not_exist_fatal() {
let config = marketplace_config("missing", "/nonexistent/path/to/plugins");
let tmp = tempfile::tempdir().unwrap();
let result = sync_marketplaces(
&config,
tmp.path(),
vec![resolved_marketplace("missing")],
false,
);
assert!(result.is_err(), "path source not existing must be fatal");
}
#[test]
fn sync_marketplaces_propagates_error_when_refreshing() {
let config = marketplace_config("missing", "/nonexistent/path/to/plugins");
let tmp = tempfile::tempdir().unwrap();
let result = sync_marketplaces(
&config,
tmp.path(),
vec![resolved_marketplace("missing")],
true,
);
assert!(result.is_err(), "refresh=true sync failure must propagate");
}
fn memory_config(listen_host: &str, port: u16) -> Config {
use crate::config::{Features, HostEntry, Memory};
use std::collections::BTreeMap;
let mut host = BTreeMap::new();
host.insert(
"srv".to_string(),
HostEntry {
addr: "srv.local".to_string(),
},
);
Config {
features: Some(Features {
memory: vec![Memory {
server_host: "srv".to_string(),
port,
listen_host: listen_host.to_string(),
when: vec!["mem".to_string()],
default_topics: vec![],
}],
}),
host,
..Config::default()
}
}
fn active_as_server() -> ActiveScopes {
use crate::scope::ActiveScope;
ActiveScopes {
scopes: vec![ActiveScope {
id: "srv".to_string(),
kind: "host",
tags: vec!["mem".to_string()],
project_root: None,
enable_bundles: vec![],
name: None,
description: None,
unknown_fields: vec![],
}],
tags: {
let mut t = std::collections::BTreeSet::new();
t.insert("mem".to_string());
t
},
}
}
fn active_as_client() -> ActiveScopes {
use crate::scope::ActiveScope;
ActiveScopes {
scopes: vec![ActiveScope {
id: "client".to_string(),
kind: "host",
tags: vec!["mem".to_string()],
project_root: None,
enable_bundles: vec![],
name: None,
description: None,
unknown_fields: vec![],
}],
tags: {
let mut t = std::collections::BTreeSet::new();
t.insert("mem".to_string());
t
},
}
}
fn config_memory(config: &Config) -> &[crate::config::Memory] {
config
.features
.as_ref()
.map(|f| f.memory.as_slice())
.unwrap_or_default()
}
#[test]
fn local_memory_server_bind_defaults_to_loopback() {
let config = memory_config("127.0.0.1", 7878);
let active = active_as_server();
let bind = local_memory_server_bind(config_memory(&config), &active);
assert_eq!(bind, Some("127.0.0.1:7878".to_string()));
}
#[test]
fn local_memory_server_bind_honours_custom_host() {
let config = memory_config("0.0.0.0", 9000);
let active = active_as_server();
let bind = local_memory_server_bind(config_memory(&config), &active);
assert_eq!(bind, Some("0.0.0.0:9000".to_string()));
}
#[test]
fn local_memory_server_bind_returns_none_for_client_host() {
let config = memory_config("127.0.0.1", 7878);
let active = active_as_client();
let bind = local_memory_server_bind(config_memory(&config), &active);
assert_eq!(bind, None);
}
#[test]
fn local_memory_server_bind_returns_none_when_memory_unconfigured() {
let config = Config::default();
let active = active_as_server();
let bind = local_memory_server_bind(config_memory(&config), &active);
assert_eq!(bind, None);
}
#[test]
fn sync_marketplaces_succeeds_when_marketplace_available() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("my-market");
std::fs::create_dir(&src).unwrap();
let config = marketplace_config("local", &src.to_string_lossy());
let cache = tempfile::tempdir().unwrap();
for refresh in [false, true] {
let result = sync_marketplaces(
&config,
cache.path(),
vec![resolved_marketplace("local")],
refresh,
);
assert!(
result.is_ok(),
"available marketplace should succeed (refresh={refresh})"
);
let out = result.unwrap();
assert_eq!(out.len(), 1);
assert!(
out[0].install_location.is_some(),
"install_location filled in"
);
}
}
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
#[must_use]
fn is_within_cache(cache_root: &std::path::Path, expanded_path: &str) -> bool {
let path = normalize_path(std::path::Path::new(expanded_path));
path.starts_with(cache_root)
}
#[cfg(test)]
mod config_guard_tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_is_within_cache_basic() {
let cache_root = std::path::Path::new("/home/user/.cache/llmenv");
assert!(is_within_cache(
cache_root,
"/home/user/.cache/llmenv/config"
));
assert!(is_within_cache(
cache_root,
"/home/user/.cache/llmenv/data/file.txt"
));
assert!(!is_within_cache(
cache_root,
"/home/user/.config/llmenv/config.yaml"
));
assert!(!is_within_cache(
cache_root,
"/home/user/.cache/llmenv-adjacent/file"
));
}
#[test]
fn test_is_within_cache_empty_path() {
let cache_root = std::path::Path::new("/cache");
assert!(!is_within_cache(cache_root, ""));
}
#[test]
fn test_is_within_cache_dot_dot_escape() {
let cache_root = std::path::Path::new("/cache/llmenv");
let escaped = "/cache/llmenv/../../../etc/passwd";
assert!(!is_within_cache(cache_root, escaped));
}
proptest! {
#[test]
fn prop_within_cache_reflexive(ref cache_root in r"/[a-z/]+") {
let path = std::path::Path::new(cache_root);
prop_assert!(is_within_cache(path, cache_root));
}
#[test]
fn prop_within_cache_child_paths(ref cache_root in r"/[a-z/]+", ref suffix in r"[a-z0-9]+") {
let path = std::path::Path::new(cache_root);
let child = format!("{}/{}", cache_root, suffix);
prop_assert!(is_within_cache(path, &child));
}
#[test]
fn prop_sibling_paths_not_within(ref cache_root in r"/cache/[a-z]+") {
let path = std::path::Path::new(cache_root);
if let Some(base) = cache_root.rfind('/') {
let sibling = format!("{}{}", &cache_root[..=base], "other");
prop_assert!(!is_within_cache(path, &sibling));
}
}
#[test]
fn prop_no_panic_on_arbitrary_strings(ref cache_root in r"/[a-z/]*", ref path_str in ".*") {
let path = std::path::Path::new(cache_root);
let _ = is_within_cache(path, path_str);
}
}
}