use std::path::PathBuf;
use clap::{Parser, Subcommand};
use tracing::debug;
pub const VERSION_WITH_BUILD: &str = concat!(
"v",
env!("CARGO_PKG_VERSION"),
" (",
env!("BUILD_NUMBER"),
")"
);
const fn get_version_string() -> &'static str {
VERSION_WITH_BUILD
}
#[derive(Parser, Debug)]
#[command(name = "cflx")]
#[command(version = get_version_string())]
#[command(about = "Automates OpenSpec change workflow (list → apply → archive)")]
#[command(long_about = "Conflux - OpenSpec Change Orchestrator
Automates the OpenSpec change workflow:
1. Lists pending changes in openspec/changes/
2. Applies changes using configured AI agent
3. Archives completed changes to openspec/specs/
SUBCOMMANDS:
run Execute orchestration loop (non-interactive)
tui Launch interactive TUI dashboard (default)
init Generate configuration template
KEY OPTIONS:
--parallel Enable parallel execution using git worktrees
--max-concurrent N Limit concurrent workspaces (default: 3)
--dry-run Preview parallelization groups without execution
--vcs BACKEND VCS backend: auto, git (default: auto)
--web Enable web monitoring server
--web-port PORT Web server port (default: 0 = auto-assign)
--web-bind ADDR Web server bind address (default: 127.0.0.1)
--server URL Connect TUI to a remote Conflux server
--server-token TOKEN Bearer token for remote server authentication
--server-token-env VAR Environment variable holding the bearer token
Use 'cflx <subcommand> --help' for more information on a specific command.")]
#[command(subcommand_required(false))]
pub struct Cli {
#[arg(long, short = 'c')]
pub config: Option<PathBuf>,
#[arg(long)]
pub web: bool,
#[arg(long, default_value = "0")]
pub web_port: u16,
#[arg(long, default_value = "127.0.0.1")]
pub web_bind: String,
#[arg(long)]
pub server: Option<String>,
#[arg(long)]
pub server_token: Option<String>,
#[arg(long)]
pub server_token_env: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Run(RunArgs),
Tui(TuiArgs),
Init(InitArgs),
CheckConflicts(CheckConflictsArgs),
Server(ServerArgs),
Project(ProjectArgs),
Service(ServiceArgs),
#[command(name = "install-skills")]
InstallSkills(InstallSkillsArgs),
Openspec(OpenspecArgs),
}
#[derive(Parser, Debug)]
#[command(
long_about = "Execute the OpenSpec change orchestration loop in non-interactive mode.
This mode processes changes sequentially or in parallel (with --parallel flag),
applying each change using the configured AI agent and archiving when complete.
PARALLEL EXECUTION:
--parallel enables concurrent processing using git worktrees. Changes are
analyzed for dependencies and executed in optimal parallel groups.
WEB MONITORING:
--web enables remote monitoring via HTTP. Access progress from any browser
while orchestration runs in background.
EXAMPLES:
cflx run # Process all changes
cflx run --change my-feature # Process specific change
cflx run --parallel --max-concurrent 5 # Parallel with 5 workers
cflx run --parallel --dry-run # Preview parallelization plan
cflx run --web --web-port 8080 # Enable web monitoring on port 8080"
)]
pub struct RunArgs {
#[arg(long, value_delimiter = ',')]
pub change: Option<Vec<String>>,
#[arg(long, short = 'c')]
pub config: Option<PathBuf>,
#[arg(long)]
pub max_iterations: Option<u32>,
#[arg(long)]
pub parallel: bool,
#[arg(long)]
pub max_concurrent: Option<usize>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, default_value = "auto")]
pub vcs: String,
#[arg(long)]
pub no_resume: bool,
#[arg(long)]
pub web: bool,
#[arg(long, default_value = "0")]
pub web_port: u16,
#[arg(long, default_value = "127.0.0.1")]
pub web_bind: String,
}
#[derive(Parser, Debug)]
#[command(long_about = "Launch the interactive Terminal UI dashboard.
The TUI provides real-time visualization of change processing with:
• Change selection and queue management
• Live progress tracking with task completion percentages
• Streaming logs from AI agent execution
• Git worktree visualization and management
• Parallel execution monitoring
KEY BINDINGS:
Space Toggle change selection/queue status
F5 Start/resume processing
Esc Stop processing (press twice to force)
Tab Switch between Changes/Worktrees view
q Quit
WEB MONITORING:
--web enables simultaneous web-based monitoring alongside the TUI.
REMOTE SERVER:
--server connects the TUI to a remote Conflux server instead of the local workspace.
--server-token provides the bearer token for authentication.
--server-token-env reads the token from the named environment variable.
EXAMPLES:
cflx tui # Launch TUI (default when no subcommand)
cflx tui --web # TUI with web monitoring enabled
cflx tui --server http://host:39876 # Connect to remote server
cflx tui --server http://host:39876 --server-token mytoken # With bearer auth")]
pub struct TuiArgs {
#[arg(long, short = 'c')]
pub config: Option<PathBuf>,
#[arg(long)]
pub web: bool,
#[arg(long, default_value = "0")]
pub web_port: u16,
#[arg(long, default_value = "127.0.0.1")]
pub web_bind: String,
#[arg(long)]
pub server: Option<String>,
#[arg(long)]
pub server_token: Option<String>,
#[arg(long)]
pub server_token_env: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum Template {
#[default]
Claude,
Opencode,
Codex,
}
#[derive(Parser, Debug)]
pub struct InitArgs {
#[arg(long, short = 't', value_enum, default_value_t = Template::Claude)]
pub template: Template,
#[arg(long, short = 'f')]
pub force: bool,
}
#[derive(Parser, Debug)]
pub struct CheckConflictsArgs {
#[arg(long, short = 'j')]
pub json: bool,
}
#[derive(Parser, Debug)]
#[command(long_about = "Start the multi-project server daemon.
The server daemon runs independently of any particular directory and manages
multiple projects via a REST API. Projects are identified by remote_url + branch.
SECURITY:
When binding to a non-loopback address, bearer token authentication is required.
The server will refuse to start if --auth-token is not provided for non-loopback binds.
EXAMPLES:
cflx server # Start on 127.0.0.1:39876
cflx server --port 39876 # Explicit port
cflx server --bind 0.0.0.0 --auth-token mytoken # Public bind with auth
cflx server --data-dir /var/lib/cflx # Custom data directory")]
pub struct ServerArgs {
#[arg(long, short = 'c')]
pub config: Option<std::path::PathBuf>,
#[arg(long)]
pub bind: Option<String>,
#[arg(long)]
pub port: Option<u16>,
#[arg(long)]
pub auth_token: Option<String>,
#[arg(long)]
pub max_concurrent_total: Option<usize>,
#[arg(long)]
pub data_dir: Option<std::path::PathBuf>,
}
#[derive(Parser, Debug)]
#[command(long_about = "Manage projects on a Conflux server.
Connects to a Conflux server and manages projects via the REST API.
When --server is not specified, the URL is resolved from the global
configuration (server.bind / server.port, defaulting to 127.0.0.1:39876).
Authentication is not supported by this command. If the server requires
bearer token authentication, an explicit error is returned.
EXAMPLES:
cflx project add https://github.com/org/repo.git main
cflx project status
cflx project status <project-id>
cflx project remove <project-id>
cflx project sync <project-id>
cflx project --server http://host:39876 status")]
pub struct ProjectArgs {
#[arg(long)]
pub server: Option<String>,
#[arg(long, short = 'j')]
pub json: bool,
#[command(subcommand)]
pub command: ProjectCommands,
}
#[derive(Subcommand, Debug)]
pub enum ProjectCommands {
Add(ProjectAddArgs),
Remove(ProjectRemoveArgs),
Status(ProjectStatusArgs),
Sync(ProjectSyncArgs),
}
#[derive(Parser, Debug)]
#[command(about = "Add a project to the server")]
#[command(long_about = "Add a project to the Conflux server.
Accepts repository URLs with optional branch specification embedded in the URL:
cflx project add https://github.com/org/repo # auto-resolve default branch
cflx project add https://github.com/org/repo/tree/main # branch from /tree/<branch> path
cflx project add https://github.com/org/repo#develop # branch from #<branch> fragment
cflx project add https://github.com/org/repo main # explicit branch argument
When both a branch is embedded in the URL and an explicit branch argument is given,
the explicit argument takes precedence.")]
pub struct ProjectAddArgs {
pub remote_url: String,
pub branch: Option<String>,
}
#[derive(Parser, Debug)]
pub struct ProjectRemoveArgs {
pub project_id: String,
}
#[derive(Parser, Debug)]
pub struct ProjectStatusArgs {
pub project_id: Option<String>,
}
#[derive(Parser, Debug)]
pub struct ProjectSyncArgs {
#[arg(long, conflicts_with = "project_id")]
pub all: bool,
pub project_id: Option<String>,
#[arg(long, default_value = "http://127.0.0.1:39876")]
pub server: String,
}
#[derive(Subcommand, Debug)]
pub enum ServiceSubcommand {
Install,
Uninstall,
Status,
Start,
Stop,
Restart,
}
#[derive(Parser, Debug)]
#[command(about = "Manage cflx server as a background service")]
pub struct ServiceArgs {
#[command(subcommand)]
pub command: ServiceSubcommand,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstallSkillsTarget {
Agents,
Claude,
}
#[derive(Parser, Debug)]
#[command(
long_about = "Install bundled agent skills into the standard skills location.
Skills are embedded into the cflx binary at compile time and installed directly
without requiring a skills/ directory to be present. When no embedded skills are
available (uncommon), the command falls back to discovering skills from a local
skills/ directory at the project root.
TARGETS:
Default target: .agents (existing behavior)
--claude: .claude
SCOPE (.agents target):
Project scope (default): installs to ./.agents/skills
lock file: ./.agents/.skill-lock.json
Global scope (--global): installs to ~/.agents/skills
lock file: ~/.agents/.skill-lock.json
SCOPE (.claude target):
Project scope (default): installs to ./.claude/skills
lock file: ./.claude/.skill-lock.json
Global scope (--global): installs to ~/.claude/skills
lock file: ~/.claude/.skill-lock.json
EXAMPLES:
cflx install-skills
cflx install-skills --global
cflx install-skills --claude
cflx install-skills --claude --global"
)]
pub struct InstallSkillsArgs {
#[arg(long)]
pub global: bool,
#[arg(long, default_value = "false")]
pub claude: bool,
#[arg(hide = true)]
pub legacy_source: Option<String>,
}
impl InstallSkillsArgs {
pub fn target(&self) -> InstallSkillsTarget {
if self.claude {
InstallSkillsTarget::Claude
} else {
InstallSkillsTarget::Agents
}
}
}
pub fn install_skills_legacy_error(src: &str) -> String {
format!(
"error: unrecognized argument '{src}'\n\n\
The source argument is no longer accepted.\n\
Use:\n \
cflx install-skills # project scope\n \
cflx install-skills --global # global scope"
)
}
#[derive(Parser, Debug)]
#[command(about = "OpenSpec utility commands")]
pub struct OpenspecArgs {
#[command(subcommand)]
pub command: OpenspecCommands,
}
#[derive(Subcommand, Debug)]
pub enum OpenspecCommands {
List(OpenspecListArgs),
Show(OpenspecShowArgs),
Validate(OpenspecValidateArgs),
Archive(OpenspecArchiveArgs),
}
#[derive(Parser, Debug)]
pub struct OpenspecListArgs {
#[arg(long)]
pub specs: bool,
}
#[derive(Parser, Debug)]
pub struct OpenspecShowArgs {
pub change_id: String,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub deltas_only: bool,
}
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum EvidenceMode {
#[default]
Off,
Warn,
Error,
}
#[derive(Parser, Debug)]
pub struct OpenspecValidateArgs {
pub change_id: Option<String>,
#[arg(long)]
pub strict: bool,
#[arg(long, value_enum, default_value_t = EvidenceMode::Off)]
pub evidence: EvidenceMode,
}
#[derive(Parser, Debug)]
pub struct OpenspecArchiveArgs {
pub change_id: String,
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub skip_specs: bool,
}
pub fn check_git_directory() -> bool {
std::path::Path::new(".git").exists()
}
pub fn check_git_available() -> bool {
debug!(
module = module_path!(),
"Executing git command: git --version (cwd: {:?})",
std::env::current_dir().ok()
);
std::process::Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn check_parallel_available() -> bool {
check_git_directory() && check_git_available()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_run_subcommand_config_option() {
let cli = Cli::parse_from(["cflx", "run", "--config", "/path/to/config.jsonc"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.config, Some(PathBuf::from("/path/to/config.jsonc")));
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_change_option() {
let cli = Cli::parse_from(["cflx", "run", "--change", "add-feature-x"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.change, Some(vec!["add-feature-x".to_string()]));
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_multiple_changes_comma_separated() {
let cli = Cli::parse_from(["cflx", "run", "--change", "a,b,c"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(
args.change,
Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_multiple_changes_with_spaces() {
let cli = Cli::parse_from(["cflx", "run", "--change", "a, b, c"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.change.is_some());
let changes = args.change.unwrap();
assert_eq!(changes.len(), 3);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_no_change_option() {
let cli = Cli::parse_from(["cflx", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.change.is_none());
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_no_subcommand() {
let cli = Cli::parse_from(["cflx"]);
assert!(cli.command.is_none());
}
#[test]
fn test_init_subcommand_default_template() {
let cli = Cli::parse_from(["cflx", "init"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(matches!(args.template, Template::Claude));
assert!(!args.force);
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_opencode_template() {
let cli = Cli::parse_from(["cflx", "init", "--template", "opencode"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(matches!(args.template, Template::Opencode));
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_claude_template() {
let cli = Cli::parse_from(["cflx", "init", "--template", "claude"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(matches!(args.template, Template::Claude));
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_codex_template() {
let cli = Cli::parse_from(["cflx", "init", "--template", "codex"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(matches!(args.template, Template::Codex));
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_short_template_flag() {
let cli = Cli::parse_from(["cflx", "init", "-t", "opencode"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(matches!(args.template, Template::Opencode));
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_force_flag() {
let cli = Cli::parse_from(["cflx", "init", "--force"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(args.force);
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_init_subcommand_short_force_flag() {
let cli = Cli::parse_from(["cflx", "init", "-f"]);
match cli.command {
Some(Commands::Init(args)) => {
assert!(args.force);
}
_ => panic!("Expected Init subcommand"),
}
}
#[test]
fn test_version_flag_exits_with_display_version() {
let result = Cli::try_parse_from(["cflx", "--version"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn test_short_version_flag() {
let result = Cli::try_parse_from(["cflx", "-V"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn test_run_subcommand_max_iterations_default() {
let cli = Cli::parse_from(["cflx", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.max_iterations.is_none());
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_max_iterations_custom() {
let cli = Cli::parse_from(["cflx", "run", "--max-iterations", "100"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.max_iterations, Some(100));
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_max_iterations_zero() {
let cli = Cli::parse_from(["cflx", "run", "--max-iterations", "0"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.max_iterations, Some(0));
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_parallel_flag_default() {
let cli = Cli::parse_from(["cflx", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(!args.parallel);
assert!(args.max_concurrent.is_none());
assert!(!args.dry_run);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_parallel_flag_enabled() {
let cli = Cli::parse_from(["cflx", "run", "--parallel"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.parallel);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_max_concurrent() {
let cli = Cli::parse_from(["cflx", "run", "--parallel", "--max-concurrent", "5"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.parallel);
assert_eq!(args.max_concurrent, Some(5));
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_dry_run() {
let cli = Cli::parse_from(["cflx", "run", "--parallel", "--dry-run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.parallel);
assert!(args.dry_run);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_web_port_default_auto_assign() {
let cli = Cli::parse_from(["cflx", "run", "--web"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.web);
assert_eq!(args.web_port, 0); assert_eq!(args.web_bind, "127.0.0.1");
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_web_port_explicit() {
let cli = Cli::parse_from(["cflx", "run", "--web", "--web-port", "9000"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.web);
assert_eq!(args.web_port, 9000);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_tui_subcommand_web_port_default_auto_assign() {
let cli = Cli::parse_from(["cflx", "tui", "--web"]);
match cli.command {
Some(Commands::Tui(args)) => {
assert!(args.web);
assert_eq!(args.web_port, 0); assert_eq!(args.web_bind, "127.0.0.1");
}
_ => panic!("Expected Tui subcommand"),
}
}
#[test]
fn test_no_subcommand_with_web() {
let cli = Cli::parse_from(["cflx", "tui", "--web"]);
match cli.command {
Some(Commands::Tui(args)) => {
assert!(args.web);
assert_eq!(args.web_port, 0); assert_eq!(args.web_bind, "127.0.0.1");
}
_ => panic!("Expected Tui subcommand"),
}
}
#[test]
fn test_check_conflicts_subcommand_default() {
let cli = Cli::parse_from(["cflx", "check-conflicts"]);
match cli.command {
Some(Commands::CheckConflicts(args)) => {
assert!(!args.json);
}
_ => panic!("Expected CheckConflicts subcommand"),
}
}
#[test]
fn test_check_conflicts_subcommand_json_flag() {
let cli = Cli::parse_from(["cflx", "check-conflicts", "--json"]);
match cli.command {
Some(Commands::CheckConflicts(args)) => {
assert!(args.json);
}
_ => panic!("Expected CheckConflicts subcommand"),
}
}
#[test]
fn test_check_conflicts_subcommand_short_json_flag() {
let cli = Cli::parse_from(["cflx", "check-conflicts", "-j"]);
match cli.command {
Some(Commands::CheckConflicts(args)) => {
assert!(args.json);
}
_ => panic!("Expected CheckConflicts subcommand"),
}
}
#[test]
fn test_top_level_server_option() {
let cli = Cli::try_parse_from(["cflx", "--server", "http://127.0.0.1:39876"]).unwrap();
assert_eq!(cli.server, Some("http://127.0.0.1:39876".to_string()));
assert!(cli.command.is_none());
}
#[test]
fn test_top_level_server_token_option() {
let cli = Cli::try_parse_from([
"cflx",
"--server",
"http://host:39876",
"--server-token",
"mytoken",
])
.unwrap();
assert_eq!(cli.server, Some("http://host:39876".to_string()));
assert_eq!(cli.server_token, Some("mytoken".to_string()));
}
#[test]
fn test_top_level_server_token_env_option() {
let cli = Cli::try_parse_from([
"cflx",
"--server",
"http://host:39876",
"--server-token-env",
"MY_TOKEN_VAR",
])
.unwrap();
assert_eq!(cli.server, Some("http://host:39876".to_string()));
assert_eq!(cli.server_token_env, Some("MY_TOKEN_VAR".to_string()));
}
#[test]
fn test_top_level_no_server_defaults_to_none() {
let cli = Cli::try_parse_from(["cflx"]).unwrap();
assert!(cli.server.is_none());
assert!(cli.server_token.is_none());
assert!(cli.server_token_env.is_none());
}
#[test]
fn test_case_1_cflx() {
let cli = Cli::try_parse_from(["cflx"]).unwrap();
assert!(cli.command.is_none());
println!("Case 1: 'cflx' -> No subcommand (TUI with web=false via parse_tui_args)");
}
#[test]
fn test_case_2_cflx_web() {
let cli = Cli::try_parse_from(["cflx", "--web"]).unwrap();
assert!(cli.web);
assert!(cli.command.is_none());
println!("Case 2: 'cflx --web' -> No subcommand with web=true (TUI with web)");
}
#[test]
fn test_case_3_cflx_tui_web() {
let cli = Cli::try_parse_from(["cflx", "tui", "--web"]).unwrap();
match &cli.command {
Some(Commands::Tui(args)) => {
assert!(args.web);
println!("Case 3: 'cflx tui --web' -> TuiArgs with web=true");
}
_ => panic!("Expected Tui subcommand"),
}
}
#[test]
fn test_case_4_cflx_run_web() {
let cli = Cli::try_parse_from(["cflx", "run", "--web"]).unwrap();
match &cli.command {
Some(Commands::Run(args)) => {
assert!(args.web);
println!("Case 4: 'cflx run --web' -> RunArgs with web=true");
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_parse_tui_args_with_web_simulation() {
let args: Vec<String> = vec!["--web".to_string()];
let full_args = {
let mut v = vec!["cflx".to_string(), "tui".to_string()];
v.extend(args);
v
};
let cli_result = Cli::try_parse_from(full_args.clone());
match cli_result {
Ok(cli) => match &cli.command {
Some(Commands::Tui(tui_args)) => {
assert!(tui_args.web);
println!("Case 5 (parse_tui_args simulation): 'cflx --web' -> via Cli -> TuiArgs with web=true");
}
_ => panic!("Expected Tui subcommand"),
},
Err(e) => {
panic!("Expected successful parse: {}", e);
}
}
}
#[test]
fn test_project_add_subcommand() {
let cli = Cli::parse_from([
"cflx",
"project",
"add",
"https://github.com/org/repo.git",
"main",
]);
match cli.command {
Some(Commands::Project(args)) => {
assert!(!args.json);
assert!(args.server.is_none());
match args.command {
ProjectCommands::Add(a) => {
assert_eq!(a.remote_url, "https://github.com/org/repo.git");
assert_eq!(a.branch, Some("main".to_string()));
}
_ => panic!("Expected Add"),
}
}
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_remove_subcommand() {
let cli = Cli::parse_from(["cflx", "project", "remove", "proj-abc123"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Remove(a) => {
assert_eq!(a.project_id, "proj-abc123");
}
_ => panic!("Expected Remove"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_status_no_id() {
let cli = Cli::parse_from(["cflx", "project", "status"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Status(a) => {
assert!(a.project_id.is_none());
}
_ => panic!("Expected Status"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_status_with_id() {
let cli = Cli::parse_from(["cflx", "project", "status", "proj-abc123"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Status(a) => {
assert_eq!(a.project_id, Some("proj-abc123".to_string()));
}
_ => panic!("Expected Status"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_sync_subcommand() {
let cli = Cli::parse_from(["cflx", "project", "sync", "proj-abc123"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Sync(a) => {
assert_eq!(a.project_id, Some("proj-abc123".to_string()));
}
_ => panic!("Expected Sync"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_json_flag() {
let cli = Cli::parse_from(["cflx", "project", "--json", "status"]);
match cli.command {
Some(Commands::Project(args)) => {
assert!(args.json);
}
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_json_short_flag() {
let cli = Cli::parse_from(["cflx", "project", "-j", "status"]);
match cli.command {
Some(Commands::Project(args)) => {
assert!(args.json);
}
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_server_flag() {
let cli = Cli::parse_from(["cflx", "project", "--server", "http://host:39876", "status"]);
match cli.command {
Some(Commands::Project(args)) => {
assert_eq!(args.server, Some("http://host:39876".to_string()));
}
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_sync_all_flag() {
let cli = Cli::parse_from(["cflx", "project", "sync", "--all"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Sync(sync_args) => {
assert!(sync_args.all);
assert!(sync_args.project_id.is_none());
}
_ => panic!("Expected Sync"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_sync_project_id() {
let cli = Cli::parse_from(["cflx", "project", "sync", "my-project-id"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Sync(sync_args) => {
assert!(!sync_args.all);
assert_eq!(sync_args.project_id, Some("my-project-id".to_string()));
}
_ => panic!("Expected Sync"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_sync_all_and_project_id_conflict() {
let result = Cli::try_parse_from(["cflx", "project", "sync", "--all", "proj-id"]);
assert!(
result.is_err(),
"Expected parse error when --all and project_id are both set"
);
}
#[test]
fn test_project_sync_default_server() {
let cli = Cli::parse_from(["cflx", "project", "sync", "--all"]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Sync(sync_args) => {
assert_eq!(sync_args.server, "http://127.0.0.1:39876");
}
_ => panic!("Expected Sync"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_project_sync_custom_server() {
let cli = Cli::parse_from([
"cflx",
"project",
"sync",
"--all",
"--server",
"http://myhost:1234",
]);
match cli.command {
Some(Commands::Project(args)) => match args.command {
ProjectCommands::Sync(sync_args) => {
assert_eq!(sync_args.server, "http://myhost:1234");
}
_ => panic!("Expected Sync"),
},
_ => panic!("Expected Project subcommand"),
}
}
#[test]
fn test_install_skills_no_args() {
let cli = Cli::parse_from(["cflx", "install-skills"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert!(!args.global);
assert!(!args.claude);
assert_eq!(args.target(), InstallSkillsTarget::Agents);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_install_skills_global_flag() {
let cli = Cli::parse_from(["cflx", "install-skills", "--global"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert!(args.global);
assert!(!args.claude);
assert_eq!(args.target(), InstallSkillsTarget::Agents);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_install_skills_claude_flag() {
let cli = Cli::parse_from(["cflx", "install-skills", "--claude"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert!(!args.global);
assert!(args.claude);
assert_eq!(args.target(), InstallSkillsTarget::Claude);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_install_skills_claude_and_global_flags() {
let cli = Cli::parse_from(["cflx", "install-skills", "--claude", "--global"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert!(args.global);
assert!(args.claude);
assert_eq!(args.target(), InstallSkillsTarget::Claude);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_install_skills_legacy_self_arg_captured() {
let cli = Cli::parse_from(["cflx", "install-skills", "self"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert_eq!(args.legacy_source.as_deref(), Some("self"));
let msg = install_skills_legacy_error("self");
assert!(
msg.contains("cflx install-skills"),
"Migration guidance must mention 'cflx install-skills'"
);
assert!(
msg.contains("--global"),
"Migration guidance must mention '--global'"
);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_install_skills_legacy_local_arg_captured() {
let cli = Cli::parse_from(["cflx", "install-skills", "local:../my-skills"]);
match cli.command {
Some(Commands::InstallSkills(args)) => {
assert_eq!(args.legacy_source.as_deref(), Some("local:../my-skills"));
let msg = install_skills_legacy_error("local:../my-skills");
assert!(
msg.contains("cflx install-skills"),
"Migration guidance must mention 'cflx install-skills'"
);
assert!(
msg.contains("--global"),
"Migration guidance must mention '--global'"
);
}
_ => panic!("Expected InstallSkills subcommand"),
}
}
#[test]
fn test_openspec_list_default() {
let cli = Cli::parse_from(["cflx", "openspec", "list"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::List(list_args) => {
assert!(!list_args.specs);
}
_ => panic!("Expected List subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_list_specs_flag() {
let cli = Cli::parse_from(["cflx", "openspec", "list", "--specs"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::List(list_args) => {
assert!(list_args.specs);
}
_ => panic!("Expected List subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_show_basic() {
let cli = Cli::parse_from(["cflx", "openspec", "show", "my-change"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Show(show_args) => {
assert_eq!(show_args.change_id, "my-change");
assert!(!show_args.json);
assert!(!show_args.deltas_only);
}
_ => panic!("Expected Show subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_show_json_deltas_only() {
let cli = Cli::parse_from([
"cflx",
"openspec",
"show",
"my-change",
"--json",
"--deltas-only",
]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Show(show_args) => {
assert_eq!(show_args.change_id, "my-change");
assert!(show_args.json);
assert!(show_args.deltas_only);
}
_ => panic!("Expected Show subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_validate_all_default() {
let cli = Cli::parse_from(["cflx", "openspec", "validate"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Validate(val_args) => {
assert!(val_args.change_id.is_none());
assert!(!val_args.strict);
assert!(matches!(val_args.evidence, super::EvidenceMode::Off));
}
_ => panic!("Expected Validate subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_validate_strict_with_change() {
let cli = Cli::parse_from(["cflx", "openspec", "validate", "my-change", "--strict"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Validate(val_args) => {
assert_eq!(val_args.change_id, Some("my-change".to_string()));
assert!(val_args.strict);
}
_ => panic!("Expected Validate subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_validate_evidence_modes() {
for (flag, expected) in [("off", "Off"), ("warn", "Warn"), ("error", "Error")] {
let cli = Cli::parse_from(["cflx", "openspec", "validate", "--evidence", flag]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Validate(val_args) => {
let actual = format!("{:?}", val_args.evidence);
assert_eq!(
actual, expected,
"Evidence mode mismatch for flag '{}'",
flag
);
}
_ => panic!("Expected Validate subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
}
#[test]
fn test_openspec_validate_rejects_strict_as_evidence_mode_name() {
use clap::Parser;
let parsed = Cli::try_parse_from(["cflx", "openspec", "validate", "--evidence", "strict"]);
assert!(parsed.is_err(), "strict evidence mode should be rejected");
}
#[test]
fn test_openspec_archive_basic() {
let cli = Cli::parse_from(["cflx", "openspec", "archive", "my-change", "--yes"]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Archive(arc_args) => {
assert_eq!(arc_args.change_id, "my-change");
assert!(arc_args.yes);
assert!(!arc_args.skip_specs);
}
_ => panic!("Expected Archive subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_openspec_archive_skip_specs() {
let cli = Cli::parse_from([
"cflx",
"openspec",
"archive",
"my-change",
"--yes",
"--skip-specs",
]);
match cli.command {
Some(Commands::Openspec(args)) => match args.command {
super::OpenspecCommands::Archive(arc_args) => {
assert_eq!(arc_args.change_id, "my-change");
assert!(arc_args.yes);
assert!(arc_args.skip_specs);
}
_ => panic!("Expected Archive subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[test]
fn test_tui_help_displays_key_bindings() {
use clap::CommandFactory;
let app = Cli::command();
let tui_subcommand = app
.find_subcommand("tui")
.expect("tui subcommand should exist");
let mut help_output = Vec::new();
tui_subcommand
.clone()
.write_long_help(&mut help_output)
.unwrap();
let help_text = String::from_utf8(help_output).unwrap();
assert!(help_text.contains("Space"), "Help should mention Space key");
assert!(help_text.contains("F5"), "Help should mention F5 key");
assert!(help_text.contains("Esc"), "Help should mention Esc key");
assert!(help_text.contains("Tab"), "Help should mention Tab key");
assert!(help_text.contains("q"), "Help should mention q key");
assert!(
help_text.contains("Key bindings"),
"Help should have 'Key bindings' section"
);
}
}