use crate::config::{ENV_CONFIG_DIR, override_runtime_config_dir, resolve_config_dir_with};
use crate::config_schema::ensure_global_sample_config;
use crate::sdd::command::SddArgs;
use crate::self_command::SelfArgs;
use crate::skills::cli::command::SkillsArgs;
use crate::skills::cli::interactive::is_interactive;
use crate::tool::command::{ToolArgs, ToolCommands};
use crate::x::claude_code::command::ClaudeCodeArgs;
use crate::x::codex::command::CodexArgs;
use crate::x::cursor::command::CursorArgs;
use anyhow::{Result, anyhow};
use clap::{CommandFactory, Parser, Subcommand};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
pub trait RequiresGlobalConfig {
fn requires_global_config(&self) -> bool;
}
impl RequiresGlobalConfig for Commands {
fn requires_global_config(&self) -> bool {
!matches!(self, Commands::Sdd(_))
}
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
#[command(arg_required_else_help = true)]
pub struct Cli {
#[arg(short = 'C', long = "config-dir", global = true)]
pub config_dir: Option<PathBuf>,
#[arg(long)]
pub print_config_dir_path: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(name = "prompts", aliases = ["prompt", "rule"])]
Prompts(PromptsArgs),
#[command(alias = "skill")]
Skills(SkillsArgs),
Sdd(SddArgs),
X(XArgs),
Tool(ToolArgs),
#[command(name = "self")]
SelfCommand(SelfArgs),
}
#[derive(Parser)]
pub struct PromptsArgs {
#[arg(long = "no-interactive")]
pub no_interactive: bool,
}
#[derive(Parser)]
pub struct XArgs {
#[command(subcommand)]
pub command: XCommands,
}
#[derive(Subcommand)]
pub enum XCommands {
Cursor(CursorArgs),
#[command(alias = "cc")]
ClaudeCode(ClaudeCodeArgs),
Codex(CodexArgs),
}
pub fn run() -> Result<()> {
let Cli {
config_dir,
print_config_dir_path,
command,
} = Cli::parse();
if print_config_dir_path {
let env_override = env::var(ENV_CONFIG_DIR).ok();
let config_dir = resolve_config_dir_with(config_dir.as_deref(), env_override.as_deref())?;
println!("{}", config_dir.display());
return Ok(());
}
let Some(command) = command else {
let mut command = Cli::command();
command.print_help()?;
println!();
return Ok(());
};
let _config_dir_guard = if command.requires_global_config() {
let config_dir = determine_config_dir(config_dir.as_ref())?;
let guard = override_runtime_config_dir(config_dir.clone());
ensure_global_sample_config(&config_dir)?;
Some(guard)
} else {
None
};
match command {
Commands::Prompts(args) => handle_prompts_command(&args),
Commands::Skills(args) => crate::skills::cli::command::run(&args),
Commands::Sdd(args) => crate::sdd::command::run(&args),
Commands::X(args) => handle_x_command(&args),
Commands::Tool(args) => handle_tool_command(&args),
Commands::SelfCommand(args) => crate::self_command::run(&args),
}
}
fn determine_config_dir(cli_config_dir: Option<&PathBuf>) -> Result<PathBuf> {
let has_cli_override = cli_config_dir.is_some();
let env_override = env::var(ENV_CONFIG_DIR).ok();
let has_env_override = env_override.is_some();
if !has_cli_override && !has_env_override && is_llman_dev_project() {
let message = t!(
"errors.dev_project_config_required",
env_var = ENV_CONFIG_DIR
);
return Err(anyhow!(message));
}
resolve_config_dir_with(
cli_config_dir.map(|path| path.as_path()),
env_override.as_deref(),
)
}
fn is_llman_dev_project() -> bool {
let Ok(current_dir) = env::current_dir() else {
return false;
};
is_llman_dev_project_at(¤t_dir)
}
fn is_llman_dev_project_at(current_dir: &Path) -> bool {
let cargo_toml = current_dir.join("Cargo.toml");
if !cargo_toml.exists() {
return false;
}
let Ok(content) = fs::read_to_string(&cargo_toml) else {
return false;
};
let Ok(parsed) = toml::from_str::<toml::Value>(&content) else {
return false;
};
parsed
.get("package")
.and_then(|pkg| pkg.get("name"))
.and_then(|name| name.as_str())
== Some("llman")
}
fn handle_prompts_command(args: &PromptsArgs) -> Result<()> {
if args.no_interactive {
print_prompts_delegation_guidance();
return Ok(());
}
if !is_interactive() {
return Err(anyhow!(
"`llman prompts` is interactive-only; use `--no-interactive` for guidance."
));
}
let apps = vec![
crate::config::CURSOR_APP,
crate::config::CODEX_APP,
crate::config::CLAUDE_CODE_APP,
];
let picked = inquire::MultiSelect::new("Select target app(s):", apps).prompt()?;
if picked.is_empty() {
println!("{}", t!("messages.operation_cancelled"));
return Ok(());
}
for app in picked {
match app {
crate::config::CURSOR_APP => {
crate::x::cursor::prompts::run(&crate::x::cursor::prompts::CursorPromptsArgs {
command: None,
})?;
}
crate::config::CODEX_APP => {
crate::x::codex::prompts::run(&crate::x::codex::prompts::CodexPromptsArgs {
command: None,
})?;
}
crate::config::CLAUDE_CODE_APP => {
crate::x::claude_code::prompts::run(
&crate::x::claude_code::prompts::ClaudeCodePromptsArgs { command: None },
)?;
}
_ => unreachable!("selected app comes from validated interactive options"),
}
}
Ok(())
}
fn print_prompts_delegation_guidance() {
println!("`llman prompts` is interactive-only.");
println!("Use app-specific commands instead:");
println!(" - llman x cursor prompts");
println!(" - llman x codex prompts");
println!(" - llman x claude-code prompts");
}
fn handle_x_command(args: &XArgs) -> Result<()> {
match &args.command {
XCommands::Cursor(cursor_args) => crate::x::cursor::command::run(cursor_args),
XCommands::ClaudeCode(claude_code_args) => {
crate::x::claude_code::command::run(claude_code_args)
}
XCommands::Codex(codex_args) => crate::x::codex::command::run(codex_args),
}
}
fn handle_tool_command(args: &ToolArgs) -> Result<()> {
match &args.command {
ToolCommands::CleanUselessComments(args) => crate::tool::clean_comments::run(args),
ToolCommands::RmUselessDirs(args) => crate::tool::rm_empty_dirs::run(args),
ToolCommands::RmEmptyDirs(args) => {
eprintln!("{}", t!("tool.rm_empty_dirs.deprecated_alias_warning"));
crate::tool::rm_empty_dirs::run(args)
}
ToolCommands::SyncIgnore(args) => crate::tool::sync_ignore::run(args),
}
}
#[cfg(test)]
mod tests {
use crate::config::resolve_config_dir_with;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_config_dir_cli_overrides_env() {
let env_temp = TempDir::new().expect("temp dir");
let cli_temp = TempDir::new().expect("temp dir");
let env_dir = env_temp.path().to_path_buf();
let cli_dir = cli_temp.path().to_path_buf();
let resolved = resolve_config_dir_with(Some(&cli_dir), env_dir.to_str()).unwrap();
assert_eq!(resolved, cli_dir);
}
#[test]
fn test_is_llman_dev_project_does_not_match_comments() {
let temp = TempDir::new().expect("temp dir");
fs::write(
temp.path().join("Cargo.toml"),
r#"
[package]
name = "not-llman" # name = "llman"
version = "0.1.0"
"#,
)
.expect("write Cargo.toml");
assert!(!super::is_llman_dev_project_at(temp.path()));
}
#[test]
fn test_is_llman_dev_project_matches_package_name() {
let temp = TempDir::new().expect("temp dir");
fs::write(
temp.path().join("Cargo.toml"),
r#"
[package]
name = "llman"
version = "0.1.0"
"#,
)
.expect("write Cargo.toml");
assert!(super::is_llman_dev_project_at(temp.path()));
}
#[test]
fn test_requires_global_config_sdd_returns_false() {
use super::{Commands, RequiresGlobalConfig, SddArgs};
use crate::sdd::command::SddCommands;
let sdd_args = SddArgs {
command: SddCommands::List {
specs: false,
changes: true,
sort: "recent".to_string(),
json: false,
compact_json: false,
no_interactive: true,
},
};
let command = Commands::Sdd(sdd_args);
assert!(!command.requires_global_config());
}
#[test]
fn test_requires_global_config_other_returns_true() {
use super::{Commands, PromptsArgs, RequiresGlobalConfig};
let prompts_args = PromptsArgs {
no_interactive: true,
};
let command = Commands::Prompts(prompts_args);
assert!(command.requires_global_config());
}
}