use std::path::PathBuf;
use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
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
}
fn parse_push_remote(value: &str) -> std::result::Result<String, String> {
if value.contains(':') {
return Err("branch selection is not supported for --push; use a remote name only".into());
}
Ok(value.to_string())
}
#[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)
client Operate an existing owner (status/mark/start/stop/wait/subscribe/mcp) without becoming one
init Generate configuration template
openapi Print the generated /api/v2 OpenAPI 3.1 schema to stdout
KEY OPTIONS:
--max-concurrent N Limit concurrent workspaces (default: 3)
--dry-run Preview dependency execution groups without execution
--vcs BACKEND VCS backend: auto, git (default: auto)
--web Enable the browser-facing TCP 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)
--web-unix-socket PATH Override the default ${GIT_COMMON_DIR}/cflx-api.sock
--no-web-unix-socket Do not serve /api/v2 on a Unix socket
--web-auth-token TOKEN Bearer token for the /api/v2 remote-control API
(visible in process listings; prefer the -env form)
--web-auth-token-env VAR Environment variable holding that bearer token
--web-allowed-origin ORIGIN Exact extra CORS origin for /api/v2 (repeatable)
logs View persistent Conflux log files without mutating them
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, conflicts_with = "web_auth_token_env")]
pub web_auth_token: Option<String>,
#[arg(long, conflicts_with = "web_auth_token")]
pub web_auth_token_env: Option<String>,
#[arg(long = "web-allowed-origin", value_name = "ORIGIN")]
pub web_allowed_origins: Vec<String>,
#[arg(long, value_name = "PATH", conflicts_with = "no_web_unix_socket")]
pub web_unix_socket: Option<PathBuf>,
#[arg(long, conflicts_with = "web_unix_socket")]
pub no_web_unix_socket: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "origin", value_parser = parse_push_remote)]
pub push: Option<String>,
#[arg(
long,
num_args = 0..=1,
require_equals = true,
default_missing_value = crate::upstream::DEFAULT_UPSTREAM_REMOTE,
value_parser = crate::upstream::options::parse_upstream_remote
)]
pub integrate_upstream: Option<String>,
#[arg(short = 'u', action = clap::ArgAction::SetTrue, conflicts_with = "integrate_upstream")]
pub integrate_upstream_default_remote: bool,
#[arg(long)]
pub upstream_verify_command: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopLevelUpstreamOptionError {
pub option: &'static str,
pub subcommand: &'static str,
}
impl std::fmt::Display for TopLevelUpstreamOptionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} must follow the '{}' subcommand: 'cflx {} {}' applies to the subcommand, while 'cflx {} {}' would be ignored",
self.option, self.subcommand, self.subcommand, self.option, self.option, self.subcommand
)
}
}
impl std::error::Error for TopLevelUpstreamOptionError {}
impl Cli {
fn subcommand_name(&self) -> Option<&'static str> {
match self.command.as_ref()? {
Commands::Run(_) => Some("run"),
Commands::Tui(_) => Some("tui"),
Commands::Init(_) => Some("init"),
Commands::CheckConflicts(_) => Some("check-conflicts"),
Commands::InstallSkills(_) => Some("install-skills"),
Commands::Logs(_) => Some("logs"),
Commands::JudgeEval(_) => Some("judge-eval"),
Commands::Openspec(_) => Some("openspec"),
Commands::Client(_) => Some("client"),
Commands::Openapi => Some("openapi"),
Commands::Completion(_) => Some("completion"),
Commands::Complete(_) => Some("__complete"),
}
}
pub fn validate_upstream_option_placement(
&self,
) -> std::result::Result<(), TopLevelUpstreamOptionError> {
let Some(subcommand) = self.subcommand_name() else {
return Ok(());
};
let option = if self.integrate_upstream_default_remote {
"-u"
} else if self.integrate_upstream.is_some() {
"--integrate-upstream"
} else if self.upstream_verify_command.is_some() {
"--upstream-verify-command"
} else {
return Ok(());
};
Err(TopLevelUpstreamOptionError { option, subcommand })
}
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Run(RunArgs),
Tui(TuiArgs),
Init(InitArgs),
CheckConflicts(CheckConflictsArgs),
#[command(name = "install-skills")]
InstallSkills(InstallSkillsArgs),
Logs(LogsArgs),
#[command(name = "judge-eval")]
JudgeEval(JudgeEvalArgs),
Openspec(OpenspecArgs),
Client(ClientArgs),
Openapi,
Completion(CompletionArgs),
#[command(name = "__complete", hide = true)]
Complete(InternalCompleteArgs),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum CompletionShell {
Bash,
Fish,
#[value(name = "powershell")]
PowerShell,
Zsh,
}
impl From<CompletionShell> for Shell {
fn from(shell: CompletionShell) -> Self {
match shell {
CompletionShell::Bash => Shell::Bash,
CompletionShell::Fish => Shell::Fish,
CompletionShell::PowerShell => Shell::PowerShell,
CompletionShell::Zsh => Shell::Zsh,
}
}
}
#[derive(Parser, Debug)]
pub struct CompletionArgs {
#[arg(value_enum)]
pub shell: CompletionShell,
}
#[derive(Parser, Debug)]
pub struct InternalCompleteArgs {
#[command(subcommand)]
pub command: InternalCompleteCommands,
}
#[derive(Subcommand, Debug)]
pub enum InternalCompleteCommands {
#[command(name = "change-ids")]
ChangeIds(ChangeIdCompletionArgs),
}
#[derive(Parser, Debug)]
pub struct ChangeIdCompletionArgs {
#[arg(long)]
pub active: bool,
#[arg(long)]
pub archived: bool,
#[arg(long)]
pub prefix: Option<String>,
}
#[derive(Parser, Debug)]
#[command(
long_about = "View persistent Conflux log files without initializing runtime logging.
By default, prints the last 200 lines from the latest existing log for the current project.
Use --path to inspect the selected path without requiring the file to exist.
EXAMPLES:
cflx logs --path
cflx logs --last 50
cflx logs --follow
cflx logs --today --project conflux-a1b2c3d4"
)]
pub struct LogsArgs {
#[arg(long)]
pub path: bool,
#[arg(long, value_name = "N")]
pub last: Option<usize>,
#[arg(long)]
pub follow: bool,
#[arg(long)]
pub today: bool,
#[arg(long, value_name = "SLUG")]
pub project: Option<String>,
}
const MAX_JUDGE_EVAL_SINCE_DAYS: u32 = 3_650;
fn parse_since_days(value: &str) -> std::result::Result<u32, String> {
let days: u32 = value
.parse()
.map_err(|_| format!("`{value}` is not a whole number of days"))?;
if days == 0 {
return Err("--since-days must be at least 1".to_string());
}
if days > MAX_JUDGE_EVAL_SINCE_DAYS {
return Err(format!(
"--since-days must be at most {MAX_JUDGE_EVAL_SINCE_DAYS}"
));
}
Ok(days)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum JudgeEvalPurpose {
#[value(name = "parallel_dependency")]
ParallelDependency,
}
impl JudgeEvalPurpose {
pub fn as_str(self) -> &'static str {
match self {
Self::ParallelDependency => "parallel_dependency",
}
}
}
#[derive(Parser, Debug)]
pub struct JudgeEvalArgs {
#[command(subcommand)]
pub command: JudgeEvalCommands,
}
#[derive(Subcommand, Debug)]
pub enum JudgeEvalCommands {
Summary(JudgeEvalSummaryArgs),
}
#[derive(Parser, Debug)]
#[command(long_about = "Summarize opt-in shadow judge evaluation records.
Reads only <state-root>/evaluations. It creates no directory, salt, or record,
initializes no operational logging, and runs no retention, so inspecting the
store never changes it.
Malformed lines and records carrying an unsupported schema version are counted
as skipped and excluded from every metric, rather than silently dropped or
guessed at. Precision, recall, and specificity are null — not zero — when their
denominator is empty, because \"never right\" and \"never asked\" are different
findings.
EXAMPLES:
cflx judge-eval summary --purpose parallel_dependency
cflx judge-eval summary --purpose parallel_dependency --json
cflx judge-eval summary --purpose parallel_dependency --since-days 7
cflx judge-eval summary --purpose parallel_dependency --project conflux-a1b2c3d4")]
pub struct JudgeEvalSummaryArgs {
#[arg(long, value_enum)]
pub purpose: JudgeEvalPurpose,
#[arg(long, value_name = "N", value_parser = parse_since_days)]
pub since_days: Option<u32>,
#[arg(long, value_name = "SLUG")]
pub project: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
#[command(
group(
ArgGroup::new("run_target")
.required(true)
.multiple(false)
.args(["all", "change", "changes"])
),
long_about = "Execute the OpenSpec change orchestration loop in non-interactive mode.
This mode requires an explicit target: --all for every current change, positional
change IDs for selected changes, or legacy --change for comma-separated IDs.
EXECUTION MODEL:
Every run executes changes in managed git worktrees. Changes are analyzed for
dependencies and executed in optimal concurrent groups. There is no execution
mode to select; a usable git repository is required.
LOCAL API:
/api/v2 is served on ${GIT_COMMON_DIR}/cflx-api.sock by default, with no TCP
port. Use --web-unix-socket PATH for another location, or --no-web-unix-socket
to serve no Unix socket at all.
WEB MONITORING:
--web additionally enables remote monitoring via HTTP. Access progress from
any browser while orchestration runs in background.
EXAMPLES:
cflx run --all # Process all current changes
cflx run my-feature other-change # Process selected changes
cflx run --change my-feature,other-change # Legacy selected changes
cflx run --all --max-concurrent 5 # Run with 5 concurrent workspaces
cflx run my-feature --dry-run # Preview the selected execution plan
cflx run --all --web --web-port 8080 # Enable web monitoring on port 8080"
)]
pub struct RunArgs {
#[arg(long)]
pub all: bool,
#[arg(long, value_delimiter = ',')]
pub change: Option<Vec<String>>,
pub changes: Vec<String>,
#[arg(long, short = 'c')]
pub config: Option<PathBuf>,
#[arg(long)]
pub max_iterations: Option<u32>,
#[arg(long, num_args = 0..=1, default_missing_value = "origin", value_parser = parse_push_remote)]
pub push: Option<String>,
#[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,
#[arg(long, conflicts_with = "web_auth_token_env")]
pub web_auth_token: Option<String>,
#[arg(long, conflicts_with = "web_auth_token")]
pub web_auth_token_env: Option<String>,
#[arg(long = "web-allowed-origin", value_name = "ORIGIN")]
pub web_allowed_origins: Vec<String>,
#[arg(long, value_name = "PATH", conflicts_with = "no_web_unix_socket")]
pub web_unix_socket: Option<PathBuf>,
#[arg(long, conflicts_with = "web_unix_socket")]
pub no_web_unix_socket: bool,
#[arg(
long,
num_args = 0..=1,
require_equals = true,
default_missing_value = crate::upstream::DEFAULT_UPSTREAM_REMOTE,
value_parser = crate::upstream::options::parse_upstream_remote
)]
pub integrate_upstream: Option<String>,
#[arg(short = 'u', action = clap::ArgAction::SetTrue, conflicts_with = "integrate_upstream")]
pub integrate_upstream_default_remote: bool,
#[arg(long)]
pub upstream_verify_command: Option<String>,
}
impl RunArgs {
pub fn upstream_integration(
&self,
) -> std::result::Result<
Option<crate::upstream::UpstreamIntegrationConfig>,
crate::upstream::UpstreamOptionError,
> {
let selected = crate::upstream::options::selected_upstream_remote(
self.integrate_upstream_default_remote,
self.integrate_upstream.as_deref(),
);
crate::upstream::options::resolve_frontend_upstream_config(
selected.as_deref(),
self.upstream_verify_command.as_deref(),
self.push.as_deref(),
)
}
pub fn normalized_target_changes(&self) -> Option<Vec<String>> {
if self.all {
None
} else if !self.changes.is_empty() {
Some(self.changes.clone())
} else {
self.change.clone()
}
}
}
#[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 (default; override in ~/.config/cflx/tui.jsonc)
Esc Stop processing (press twice to force)
Tab Switch between Changes/Worktrees view
q Quit
TUI USER CONFIG:
Set keybindings.start in ~/.config/cflx/tui.jsonc, for example:
{ \"keybindings\": { \"start\": [\"F5\", \"!\"] } }
The help text documents defaults only and does not render dynamic user config values.
LOCAL API:
/api/v2 is served on ${GIT_COMMON_DIR}/cflx-api.sock by default, with no TCP
port. Use --web-unix-socket PATH for another location, or --no-web-unix-socket
to serve no Unix socket at all.
WEB MONITORING:
--web additionally enables simultaneous web-based monitoring alongside the TUI.
EXAMPLES:
cflx tui # Launch TUI (default when no subcommand)
cflx tui --web # TUI with web monitoring enabled")]
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, conflicts_with = "web_auth_token_env")]
pub web_auth_token: Option<String>,
#[arg(long, conflicts_with = "web_auth_token")]
pub web_auth_token_env: Option<String>,
#[arg(long = "web-allowed-origin", value_name = "ORIGIN")]
pub web_allowed_origins: Vec<String>,
#[arg(long, value_name = "PATH", conflicts_with = "no_web_unix_socket")]
pub web_unix_socket: Option<PathBuf>,
#[arg(long, conflicts_with = "web_unix_socket")]
pub no_web_unix_socket: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "origin", value_parser = parse_push_remote)]
pub push: Option<String>,
#[arg(
long,
num_args = 0..=1,
require_equals = true,
default_missing_value = crate::upstream::DEFAULT_UPSTREAM_REMOTE,
value_parser = crate::upstream::options::parse_upstream_remote
)]
pub integrate_upstream: Option<String>,
#[arg(short = 'u', action = clap::ArgAction::SetTrue, conflicts_with = "integrate_upstream")]
pub integrate_upstream_default_remote: bool,
#[arg(long)]
pub upstream_verify_command: Option<String>,
}
impl TuiArgs {
pub fn upstream_integration(
&self,
) -> std::result::Result<
Option<crate::upstream::UpstreamIntegrationConfig>,
crate::upstream::UpstreamOptionError,
> {
let selected = crate::upstream::options::selected_upstream_remote(
self.integrate_upstream_default_remote,
self.integrate_upstream.as_deref(),
);
crate::upstream::options::resolve_frontend_upstream_config(
selected.as_deref(),
self.upstream_verify_command.as_deref(),
self.push.as_deref(),
)
}
}
#[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(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"
)
}
const MIN_CLIENT_TIMEOUT_MILLIS: u64 = 100;
const MAX_CLIENT_TIMEOUT_MILLIS: u64 = 7 * 24 * 60 * 60 * 1000;
const MAX_CHANGE_ID_LEN: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitTimeout(Option<std::time::Duration>);
impl WaitTimeout {
pub const UNBOUNDED: Self = Self(None);
pub fn deadline(self) -> Option<std::time::Duration> {
self.0
}
}
pub fn parse_client_timeout(value: &str) -> std::result::Result<WaitTimeout, String> {
let trimmed = value.trim();
let (digits, multiplier) = if let Some(digits) = trimmed.strip_suffix("ms") {
(digits, 1)
} else if let Some(digits) = trimmed.strip_suffix('s') {
(digits, 1_000)
} else if let Some(digits) = trimmed.strip_suffix('m') {
(digits, 60_000)
} else if let Some(digits) = trimmed.strip_suffix('h') {
(digits, 3_600_000)
} else {
(trimmed, 1_000)
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!(
"'{value}' is not a duration: use a whole number optionally suffixed with ms, s, m, or h (for example 500ms, 30s, 45m, 2h)"
));
}
let magnitude: u64 = digits
.parse()
.map_err(|_| format!("'{value}' is out of range: the maximum timeout is 7d"))?;
let millis = magnitude
.checked_mul(multiplier)
.ok_or_else(|| format!("'{value}' is out of range: the maximum timeout is 7d"))?;
if millis == 0 {
return Ok(WaitTimeout::UNBOUNDED);
}
if !(MIN_CLIENT_TIMEOUT_MILLIS..=MAX_CLIENT_TIMEOUT_MILLIS).contains(&millis) {
return Err(format!(
"'{value}' is out of range: the timeout must be 0 (wait indefinitely) or between {MIN_CLIENT_TIMEOUT_MILLIS}ms and 7d"
));
}
Ok(WaitTimeout(Some(std::time::Duration::from_millis(millis))))
}
pub fn parse_change_id(value: &str) -> std::result::Result<String, String> {
let invalid = |reason: &str| {
Err(format!(
"'{value}' is not a change ID: {reason}. Change IDs are 1-{MAX_CHANGE_ID_LEN} characters of [A-Za-z0-9._-] and may not start with '.' or '-'"
))
};
if value.is_empty() {
return invalid("it is empty");
}
if value.len() > MAX_CHANGE_ID_LEN {
return invalid("it is too long");
}
if value.starts_with('.') || value.starts_with('-') {
return invalid("it starts with a reserved character");
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return invalid("it contains characters outside [A-Za-z0-9._-]");
}
Ok(value.to_string())
}
const MAX_PROCESS_LOCAL_ID_LEN: usize = 128;
pub fn parse_process_local_id(value: &str) -> std::result::Result<String, String> {
let invalid = |reason: &str| {
Err(format!(
"'{value}' is not an owner-issued identifier: {reason}. These are 1-{MAX_PROCESS_LOCAL_ID_LEN} characters of [A-Za-z0-9._-] and may not start with '.' or '-'"
))
};
if value.is_empty() {
return invalid("it is empty");
}
if value.len() > MAX_PROCESS_LOCAL_ID_LEN {
return invalid("it is too long");
}
if value.starts_with('.') || value.starts_with('-') {
return invalid("it starts with a reserved character");
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return invalid("it contains characters outside [A-Za-z0-9._-]");
}
Ok(value.to_string())
}
#[derive(Parser, Debug)]
#[command(
about = "Operate an existing Conflux owner without becoming one",
long_about = "Client-only namespace for one existing repository owner.
Connects to the owner's local /api/v2 Unix socket, which defaults to
${GIT_COMMON_DIR}/cflx-api.sock — the same repository identity the orchestration
lock uses, so every linked worktree of one repository reaches one owner.
No client command acquires the repository lock, binds a listener, starts an
orchestration run, launches a lifecycle adapter or an AI subprocess, or writes
to the workspace. `status` and `wait` submit no command at all.
Machine output: --json prints exactly one versioned envelope on stdout and sends
every diagnostic to stderr. Outcomes and exit statuses are stable.
Secrets are never accepted in argv: --auth-token-env names an environment
variable that holds the bearer token, and the token is never printed.
EXAMPLES:
cflx client status --json
cflx client mark alpha beta --json
cflx client unmark alpha --json
cflx client start --json
cflx client stop --json
cflx client force-stop --json
cflx client wait alpha --json # Wait indefinitely (the default)
cflx client wait alpha --timeout 45m --json # Give up after an explicit deadline
cflx client subscribe set alpha beta --instance-id <owner-instance> --json -- /absolute/callback --flag value
cflx client subscribe get alpha --instance-id <owner-instance> --json
cflx client subscribe clear alpha beta --instance-id <owner-instance> --json
cflx client mcp
cflx client --unix-socket /tmp/cflx-api.sock status"
)]
pub struct ClientArgs {
#[arg(
long,
value_name = "ABSOLUTE_PATH",
global = true,
conflicts_with = "unix_socket"
)]
pub project_dir: Option<PathBuf>,
#[arg(long, value_name = "PATH", global = true)]
pub unix_socket: Option<PathBuf>,
#[arg(long, value_name = "NAME", global = true)]
pub auth_token_env: Option<String>,
#[command(subcommand)]
pub command: ClientCommands,
}
#[derive(Subcommand, Debug)]
pub enum ClientCommands {
Status(ClientStatusArgs),
Mark(ClientMarkArgs),
Unmark(ClientMarkArgs),
Start(ClientLifecycleArgs),
Stop(ClientLifecycleArgs),
ForceStop(ClientLifecycleArgs),
ForceStopChange(ClientForceStopChangeArgs),
Wait(ClientWaitArgs),
Subscribe(ClientSubscribeArgs),
Mcp(ClientMcpArgs),
}
#[derive(Parser, Debug)]
pub struct ClientMcpArgs {}
#[derive(Parser, Debug)]
pub struct ClientStatusArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ClientMarkArgs {
#[arg(required = true, num_args = 1.., value_parser = parse_change_id)]
pub change_ids: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ClientForceStopChangeArgs {
#[arg(value_parser = parse_change_id)]
pub change_id: String,
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ClientLifecycleArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ClientWaitArgs {
#[arg(value_parser = parse_change_id)]
pub change_id: String,
#[arg(long, value_name = "DURATION", default_value = "0", value_parser = parse_client_timeout)]
pub timeout: WaitTimeout,
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ClientSubscribeArgs {
#[command(subcommand)]
pub command: ClientSubscribeCommands,
}
#[derive(Subcommand, Debug)]
pub enum ClientSubscribeCommands {
Set(ClientSubscribeSetArgs),
Get(ClientSubscribeRefArgs),
Clear(ClientSubscribeRefArgs),
}
#[derive(Parser, Debug)]
pub struct ClientSubscribeSetArgs {
#[arg(required = true, num_args = 1.., value_parser = parse_change_id)]
pub change_ids: Vec<String>,
#[arg(long, value_name = "ID", required = true, value_parser = parse_process_local_id)]
pub instance_id: String,
#[arg(long)]
pub blocked: bool,
#[arg(long)]
pub json: bool,
#[arg(last = true, required = true, num_args = 1.., value_name = "COMMAND")]
pub command: Vec<String>,
}
#[derive(Parser, Debug)]
pub struct ClientSubscribeRefArgs {
#[arg(required = true, num_args = 1.., value_parser = parse_change_id)]
pub change_ids: Vec<String>,
#[arg(long, value_name = "ID", required = true, value_parser = parse_process_local_id)]
pub instance_id: String,
#[arg(long)]
pub json: bool,
}
#[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),
Verify(OpenspecVerifyArgs),
}
#[derive(Parser, Debug)]
pub struct OpenspecVerifyArgs {
pub change_id: String,
#[arg(long)]
pub verification_id: Option<String>,
#[arg(long)]
pub plan: bool,
#[arg(long)]
pub json: bool,
}
#[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)]
pub archive_gate: 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_git_workspace_usable() -> bool {
check_git_directory() && check_git_available()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
fn run_args(argv: &[&str]) -> RunArgs {
match Cli::parse_from(argv).command {
Some(Commands::Run(args)) => args,
_ => panic!("expected run subcommand for {:?}", argv),
}
}
#[test]
fn upstream_integration_is_absent_by_default() {
let args = run_args(&["cflx", "run", "--all"]);
assert_eq!(args.integrate_upstream, None);
assert_eq!(args.upstream_verify_command, None);
assert_eq!(args.upstream_integration().unwrap(), None);
}
#[test]
fn upstream_integration_short_and_long_aliases_are_equivalent() {
let short = run_args(&[
"cflx",
"run",
"--all",
"-u",
"--upstream-verify-command",
"cargo test",
]);
let long = run_args(&[
"cflx",
"run",
"--all",
"--integrate-upstream",
"--upstream-verify-command",
"cargo test",
]);
assert!(short.integrate_upstream_default_remote);
assert_eq!(short.integrate_upstream, None);
assert_eq!(long.integrate_upstream.as_deref(), Some("origin"));
assert_eq!(
short.upstream_integration().unwrap(),
long.upstream_integration().unwrap()
);
assert_eq!(
short.upstream_integration().unwrap(),
Some(crate::upstream::UpstreamIntegrationConfig::new(
"origin",
"cargo test"
))
);
}
#[test]
fn upstream_integration_short_option_does_not_consume_change_id() {
let args = run_args(&[
"cflx",
"run",
"-u",
"--upstream-verify-command",
"cargo test",
"my-change",
]);
assert!(args.integrate_upstream_default_remote);
assert_eq!(args.changes, vec!["my-change".to_string()]);
assert_eq!(
args.normalized_target_changes(),
Some(vec!["my-change".to_string()])
);
}
#[test]
fn upstream_integration_named_remote_requires_equals() {
let args = run_args(&[
"cflx",
"run",
"--all",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(args.integrate_upstream.as_deref(), Some("upstream"));
assert_eq!(
args.upstream_integration().unwrap(),
Some(crate::upstream::UpstreamIntegrationConfig::new(
"upstream",
"cargo test"
))
);
let spaced = run_args(&[
"cflx",
"run",
"--integrate-upstream",
"upstream",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(spaced.integrate_upstream.as_deref(), Some("origin"));
assert_eq!(spaced.changes, vec!["upstream".to_string()]);
}
#[test]
fn upstream_integration_rejects_invalid_remote_value() {
let err = Cli::try_parse_from(["cflx", "run", "--all", "--integrate-upstream=origin:main"])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn upstream_integration_requires_verify_command() {
let args = run_args(&["cflx", "run", "--all", "-u"]);
assert_eq!(
args.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::MissingVerifyCommand)
);
}
#[test]
fn upstream_integration_verify_command_alone_is_rejected() {
let args = run_args(&[
"cflx",
"run",
"--all",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(
args.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::VerifyCommandWithoutOption)
);
}
fn tui_args(argv: &[&str]) -> TuiArgs {
match Cli::parse_from(argv).command {
Some(Commands::Tui(args)) => args,
_ => panic!("expected tui subcommand for {:?}", argv),
}
}
pub(super) fn bare_tui_args(argv: &[&str]) -> TuiArgs {
let cli = Cli::parse_from(argv);
assert!(cli.command.is_none(), "expected bare invocation");
TuiArgs {
config: cli.config,
web: cli.web,
web_port: cli.web_port,
web_bind: cli.web_bind,
web_auth_token: cli.web_auth_token,
web_auth_token_env: cli.web_auth_token_env,
web_allowed_origins: cli.web_allowed_origins,
web_unix_socket: cli.web_unix_socket,
no_web_unix_socket: cli.no_web_unix_socket,
push: cli.push,
integrate_upstream: cli.integrate_upstream,
integrate_upstream_default_remote: cli.integrate_upstream_default_remote,
upstream_verify_command: cli.upstream_verify_command,
}
}
#[test]
fn per_change_upstream_is_absent_by_default_for_tui() {
let bare = bare_tui_args(&["cflx"]);
assert_eq!(bare.integrate_upstream, None);
assert_eq!(bare.upstream_verify_command, None);
assert_eq!(bare.upstream_integration().unwrap(), None);
let explicit = tui_args(&["cflx", "tui"]);
assert_eq!(explicit.upstream_integration().unwrap(), None);
}
#[test]
fn per_change_upstream_run_bare_tui_and_explicit_tui_are_equivalent() {
let run = run_args(&[
"cflx",
"run",
"--all",
"-u",
"--upstream-verify-command",
"cargo test",
]);
let bare = bare_tui_args(&["cflx", "-u", "--upstream-verify-command", "cargo test"]);
let explicit = tui_args(&[
"cflx",
"tui",
"-u",
"--upstream-verify-command",
"cargo test",
]);
let expected = Some(crate::upstream::UpstreamIntegrationConfig::new(
"origin",
"cargo test",
));
assert_eq!(run.upstream_integration().unwrap(), expected);
assert_eq!(bare.upstream_integration().unwrap(), expected);
assert_eq!(explicit.upstream_integration().unwrap(), expected);
}
#[test]
fn per_change_upstream_tui_accepts_explicit_remote_with_equals_only() {
let explicit = tui_args(&[
"cflx",
"tui",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(
explicit.upstream_integration().unwrap(),
Some(crate::upstream::UpstreamIntegrationConfig::new(
"upstream",
"cargo test"
))
);
let bare = bare_tui_args(&[
"cflx",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(bare.integrate_upstream.as_deref(), Some("upstream"));
assert_eq!(bare.push, None, "upstream must not configure push mode");
let spaced = tui_args(&[
"cflx",
"tui",
"--integrate-upstream",
"--upstream-verify-command",
"cargo test",
]);
assert_eq!(spaced.integrate_upstream.as_deref(), Some("origin"));
}
#[test]
fn per_change_upstream_tui_rejects_push() {
let with_push = tui_args(&[
"cflx",
"tui",
"-u",
"--upstream-verify-command",
"cargo test",
"--push",
]);
assert_eq!(
with_push.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::ConflictsWithPush)
);
let bare_push = bare_tui_args(&[
"cflx",
"-u",
"--upstream-verify-command",
"cargo test",
"--push",
]);
assert_eq!(
bare_push.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::ConflictsWithPush)
);
}
#[test]
fn per_change_upstream_tui_requires_verify_command() {
let missing = tui_args(&["cflx", "tui", "-u"]);
assert_eq!(
missing.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::MissingVerifyCommand)
);
let orphan_command = tui_args(&["cflx", "tui", "--upstream-verify-command", "cargo test"]);
assert_eq!(
orphan_command.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::VerifyCommandWithoutOption)
);
}
#[test]
fn per_change_upstream_run_rejects_push_combination_at_parse_time() {
let args = run_args(&[
"cflx",
"run",
"--all",
"-u",
"--upstream-verify-command",
"cargo test",
"--push",
]);
assert_eq!(
args.upstream_integration(),
Err(crate::upstream::UpstreamOptionError::ConflictsWithPush)
);
}
#[test]
fn per_change_upstream_short_flag_never_carries_a_remote() {
for argv in [
vec!["cflx", "run", "--all", "-u=upstream"],
vec!["cflx", "-u=upstream"],
vec!["cflx", "tui", "-u=upstream"],
] {
let err = Cli::try_parse_from(&argv)
.err()
.unwrap_or_else(|| panic!("-u must not accept a remote value: {:?}", argv));
assert_ne!(
err.kind(),
clap::error::ErrorKind::DisplayHelp,
"rejection must be a parse error, not help: {:?}",
argv
);
}
let run = run_args(&[
"cflx",
"run",
"--all",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
let bare = bare_tui_args(&[
"cflx",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
let explicit = tui_args(&[
"cflx",
"tui",
"--integrate-upstream=upstream",
"--upstream-verify-command",
"cargo test",
]);
let expected = Some(crate::upstream::UpstreamIntegrationConfig::new(
"upstream",
"cargo test",
));
assert_eq!(run.upstream_integration().unwrap(), expected);
assert_eq!(bare.upstream_integration().unwrap(), expected);
assert_eq!(explicit.upstream_integration().unwrap(), expected);
}
#[test]
fn per_change_upstream_short_flag_still_selects_the_default_remote() {
for args in [
run_args(&[
"cflx",
"run",
"--all",
"-u",
"--upstream-verify-command",
"cargo test",
])
.upstream_integration(),
bare_tui_args(&["cflx", "-u", "--upstream-verify-command", "cargo test"])
.upstream_integration(),
tui_args(&[
"cflx",
"tui",
"-u",
"--upstream-verify-command",
"cargo test",
])
.upstream_integration(),
] {
assert_eq!(
args.unwrap(),
Some(crate::upstream::UpstreamIntegrationConfig::new(
"origin",
"cargo test"
))
);
}
}
#[test]
fn per_change_upstream_top_level_options_are_rejected_before_a_subcommand() {
for (argv, option) in [
(vec!["cflx", "-u", "run", "--all"], "-u"),
(
vec!["cflx", "--integrate-upstream=upstream", "run", "--all"],
"--integrate-upstream",
),
(
vec![
"cflx",
"--upstream-verify-command",
"cargo test",
"run",
"--all",
],
"--upstream-verify-command",
),
(vec!["cflx", "-u", "tui"], "-u"),
] {
let cli = Cli::try_parse_from(&argv)
.unwrap_or_else(|e| panic!("expected {:?} to parse: {e}", argv));
let error = cli
.validate_upstream_option_placement()
.expect_err(&format!("{:?} must not silently drop the opt-in", argv));
assert_eq!(error.option, option, "{:?}", argv);
assert!(
error.to_string().contains(error.subcommand),
"the diagnostic must name the subcommand: {error}"
);
}
}
#[test]
fn per_change_upstream_top_level_options_stay_valid_for_bare_invocation() {
let bare = Cli::try_parse_from(["cflx", "-u", "--upstream-verify-command", "cargo test"])
.expect("bare invocation parses");
assert_eq!(bare.validate_upstream_option_placement(), Ok(()));
let plain_subcommand = Cli::try_parse_from(["cflx", "run", "--all"]).expect("run parses");
assert_eq!(
plain_subcommand.validate_upstream_option_placement(),
Ok(())
);
}
#[test]
fn per_change_upstream_verify_command_help_states_it_is_rejected_alone() {
use clap::CommandFactory;
let mut command = Cli::command();
let run = command
.find_subcommand_mut("run")
.expect("run subcommand")
.render_long_help()
.to_string();
let tui = command
.find_subcommand_mut("tui")
.expect("tui subcommand")
.render_long_help()
.to_string();
let top = Cli::command().render_long_help().to_string();
for (entrypoint, help) in [("run", &run), ("tui", &tui), ("bare", &top)] {
assert!(
help.contains("Requires `-u`/`--integrate-upstream`; rejected without it."),
"{entrypoint} help must not claim the verification command is ignored: {help}"
);
assert!(
!help.contains("ignored otherwise"),
"{entrypoint} help retains the stale wording: {help}"
);
}
}
#[test]
fn per_change_upstream_is_exposed_in_tui_help() {
use clap::CommandFactory;
let help = Cli::command()
.find_subcommand_mut("tui")
.expect("tui subcommand")
.render_long_help()
.to_string();
assert!(help.contains("--integrate-upstream"), "help: {}", help);
assert!(help.contains("--upstream-verify-command"), "help: {}", help);
let top = Cli::command().render_long_help().to_string();
assert!(top.contains("--integrate-upstream"), "help: {}", top);
}
#[test]
fn test_completion_subcommand_supported_shells() {
for (shell_name, expected) in [
("zsh", CompletionShell::Zsh),
("bash", CompletionShell::Bash),
("fish", CompletionShell::Fish),
("powershell", CompletionShell::PowerShell),
] {
let cli = Cli::parse_from(["cflx", "completion", shell_name]);
match cli.command {
Some(Commands::Completion(args)) => assert_eq!(args.shell, expected),
_ => panic!("Expected Completion subcommand for {shell_name}"),
}
}
}
#[test]
fn test_completion_subcommand_rejects_unsupported_shell() {
for shell in ["tcsh", "elvish"] {
let err = Cli::try_parse_from(["cflx", "completion", shell]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
}
}
#[test]
fn test_internal_change_id_completion_defaults() {
let cli = Cli::parse_from(["cflx", "__complete", "change-ids"]);
match cli.command {
Some(Commands::Complete(args)) => match args.command {
InternalCompleteCommands::ChangeIds(change_args) => {
assert!(!change_args.active);
assert!(!change_args.archived);
assert_eq!(change_args.prefix, None);
}
},
_ => panic!("Expected hidden Complete subcommand"),
}
}
#[test]
fn test_internal_change_id_completion_flags() {
let cli = Cli::parse_from([
"cflx",
"__complete",
"change-ids",
"--active",
"--archived",
"--prefix",
"add-",
]);
match cli.command {
Some(Commands::Complete(args)) => match args.command {
InternalCompleteCommands::ChangeIds(change_args) => {
assert!(change_args.active);
assert!(change_args.archived);
assert_eq!(change_args.prefix.as_deref(), Some("add-"));
}
},
_ => panic!("Expected hidden Complete subcommand"),
}
}
#[test]
fn test_internal_complete_rejects_invalid_mode() {
let err = Cli::try_parse_from(["cflx", "__complete", "spec-ids"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
}
#[test]
fn test_logs_subcommand_flags() {
let cli = Cli::parse_from([
"cflx",
"logs",
"--path",
"--last",
"50",
"--follow",
"--today",
"--project",
"conflux-test",
]);
match cli.command {
Some(Commands::Logs(args)) => {
assert!(args.path);
assert_eq!(args.last, Some(50));
assert!(args.follow);
assert!(args.today);
assert_eq!(args.project.as_deref(), Some("conflux-test"));
}
_ => panic!("Expected Logs subcommand"),
}
}
#[test]
fn test_logs_help_documents_flags() {
let err = Cli::try_parse_from(["cflx", "logs", "--help"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(help.contains("--path"));
assert!(help.contains("--last <N>"));
assert!(help.contains("--follow"));
assert!(help.contains("--today"));
assert!(help.contains("--project <SLUG>"));
}
#[test]
fn test_logs_subcommand_default_mode() {
let cli = Cli::parse_from(["cflx", "logs"]);
match cli.command {
Some(Commands::Logs(args)) => {
assert!(!args.path);
assert_eq!(args.last, None);
assert!(!args.follow);
assert!(!args.today);
assert!(args.project.is_none());
}
_ => panic!("Expected Logs subcommand"),
}
}
#[test]
fn test_run_subcommand_config_option() {
let cli = Cli::parse_from(["cflx", "run", "--all", "--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_requires_explicit_target() {
let err = Cli::try_parse_from(["cflx", "run"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn test_run_subcommand_all_target() {
let cli = Cli::parse_from(["cflx", "run", "--all"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.all);
assert_eq!(args.normalized_target_changes(), None);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_positional_changes() {
let cli = Cli::parse_from(["cflx", "run", "a", "b"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.changes, vec!["a".to_string(), "b".to_string()]);
assert_eq!(
args.normalized_target_changes(),
Some(vec!["a".to_string(), "b".to_string()])
);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_rejects_target_mode_combinations() {
for argv in [
vec!["cflx", "run", "--all", "a"],
vec!["cflx", "run", "--all", "--change", "a"],
vec!["cflx", "run", "--change", "a", "b"],
] {
let err = Cli::try_parse_from(argv).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
}
#[test]
fn test_no_subcommand() {
let cli = Cli::parse_from(["cflx"]);
assert!(cli.command.is_none());
}
#[test]
fn tui_default_push_defaults_to_origin() {
let cli = Cli::parse_from(["cflx", "--push"]);
assert!(cli.command.is_none());
assert_eq!(cli.push.as_deref(), Some("origin"));
}
#[test]
fn tui_default_push_accepts_remote_name() {
let cli = Cli::parse_from(["cflx", "--push", "upstream"]);
assert!(cli.command.is_none());
assert_eq!(cli.push.as_deref(), Some("upstream"));
}
#[test]
fn tui_default_push_rejects_branch_selection() {
let err = Cli::try_parse_from(["cflx", "--push", "origin:main"]).unwrap_err();
assert!(err
.to_string()
.contains("branch selection is not supported"));
}
#[test]
fn tui_subcommand_push_defaults_to_origin() {
let cli = Cli::parse_from(["cflx", "tui", "--push"]);
match cli.command {
Some(Commands::Tui(args)) => assert_eq!(args.push.as_deref(), Some("origin")),
_ => panic!("Expected Tui subcommand"),
}
}
#[test]
fn tui_subcommand_push_accepts_remote_name() {
let cli = Cli::parse_from(["cflx", "tui", "--push", "upstream"]);
match cli.command {
Some(Commands::Tui(args)) => assert_eq!(args.push.as_deref(), Some("upstream")),
_ => panic!("Expected Tui subcommand"),
}
}
#[test]
fn tui_subcommand_push_rejects_branch_selection() {
let err = Cli::try_parse_from(["cflx", "tui", "--push", "origin:main"]).unwrap_err();
assert!(err
.to_string()
.contains("branch selection is not supported"));
}
#[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", "--all"]);
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", "--all", "--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", "--all", "--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_execution_options_default() {
let cli = Cli::parse_from(["cflx", "run", "--all"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.max_concurrent.is_none());
assert!(!args.dry_run);
}
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn retired_parallel_flag_is_rejected_and_unadvertised() {
let error = Cli::try_parse_from(["cflx", "run", "--all", "--parallel"])
.expect_err("--parallel must not parse");
assert_eq!(
error.kind(),
clap::error::ErrorKind::UnknownArgument,
"the retired flag must fail as an unknown argument, got: {error}"
);
let help = Cli::command()
.find_subcommand_mut("run")
.expect("run subcommand")
.render_long_help()
.to_string();
assert!(
!help.contains("--parallel"),
"run help must not advertise the retired flag, got: {help}"
);
let root_help = Cli::command().render_long_help().to_string();
assert!(
!root_help.contains("--parallel"),
"root help must not advertise the retired flag, got: {root_help}"
);
}
#[test]
fn cli_push_defaults_to_origin() {
let cli = Cli::parse_from(["cflx", "run", "--all", "--push"]);
match cli.command {
Some(Commands::Run(args)) => assert_eq!(args.push.as_deref(), Some("origin")),
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn cli_push_accepts_remote_name() {
let cli = Cli::parse_from(["cflx", "run", "--all", "--push", "upstream"]);
match cli.command {
Some(Commands::Run(args)) => assert_eq!(args.push.as_deref(), Some("upstream")),
_ => panic!("Expected Run subcommand"),
}
}
#[test]
fn cli_push_rejects_branch_selection() {
let err =
Cli::try_parse_from(["cflx", "run", "--all", "--push", "origin:main"]).unwrap_err();
assert!(err
.to_string()
.contains("branch selection is not supported"));
}
#[test]
fn test_run_subcommand_max_concurrent() {
let cli = Cli::parse_from(["cflx", "run", "--all", "--max-concurrent", "5"]);
match cli.command {
Some(Commands::Run(args)) => {
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", "--all", "--dry-run"]);
match cli.command {
Some(Commands::Run(args)) => {
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", "--all", "--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", "--all", "--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 removed_top_level_server_options_are_rejected() {
for argv in [
vec!["cflx", "--server", "http://127.0.0.1:39876"],
vec!["cflx", "--server-token", "mytoken"],
vec!["cflx", "--server-token-env", "MY_TOKEN_VAR"],
] {
let err = Cli::try_parse_from(&argv)
.expect_err("removed top-level server option must not parse");
assert_eq!(
err.kind(),
clap::error::ErrorKind::UnknownArgument,
"unexpected error for {:?}: {}",
argv,
err
);
}
}
#[test]
fn removed_tui_server_options_are_rejected() {
for argv in [
vec!["cflx", "tui", "--server", "http://127.0.0.1:39876"],
vec!["cflx", "tui", "--server-token", "mytoken"],
vec!["cflx", "tui", "--server-token-env", "MY_TOKEN_VAR"],
] {
let err =
Cli::try_parse_from(&argv).expect_err("removed tui server option must not parse");
assert_eq!(
err.kind(),
clap::error::ErrorKind::UnknownArgument,
"unexpected error for {:?}: {}",
argv,
err
);
}
}
#[test]
fn removed_server_service_and_project_subcommands_are_rejected() {
for argv in [
vec!["cflx", "server"],
vec!["cflx", "server", "--port", "39876"],
vec!["cflx", "service", "install"],
vec!["cflx", "service", "status"],
vec!["cflx", "project", "status"],
vec!["cflx", "project", "add", "https://github.com/org/repo.git"],
vec!["cflx", "project", "sync", "--all"],
] {
let err = Cli::try_parse_from(&argv).expect_err("removed subcommand must not parse");
assert_eq!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand,
"unexpected error for {:?}: {}",
argv,
err
);
}
}
#[test]
fn help_no_longer_advertises_removed_server_surfaces() {
let mut cmd = <Cli as clap::CommandFactory>::command();
let long_help = cmd.render_long_help().to_string();
for needle in [
"--server",
"--server-token",
"--server-token-env",
"cflx server",
"cflx project",
"cflx service",
] {
assert!(
!long_help.contains(needle),
"top-level help still advertises '{}'",
needle
);
}
let mut tui = cmd
.find_subcommand_mut("tui")
.expect("tui subcommand")
.clone();
let tui_help = tui.render_long_help().to_string();
for needle in ["--server", "--server-token", "--server-token-env"] {
assert!(
!tui_help.contains(needle),
"tui help still advertises '{}'",
needle
);
}
assert!(tui_help.contains("--web"));
assert!(tui_help.contains("--web-auth-token"));
}
#[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", "--all", "--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_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!(!val_args.archive_gate);
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_validate_archive_gate_flag() {
let cli = Cli::parse_from([
"cflx",
"openspec",
"validate",
"my-change",
"--archive-gate",
]);
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.archive_gate);
}
_ => panic!("Expected Validate subcommand"),
},
_ => panic!("Expected Openspec subcommand"),
}
}
#[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("~/.config/cflx/tui.jsonc"),
"Help should mention TUI user config path"
);
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"
);
}
}
#[cfg(test)]
mod remote_control_api_cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).unwrap_or_else(|e| panic!("{args:?} must parse: {e}"))
}
fn parse_err(args: &[&str]) -> String {
Cli::try_parse_from(args)
.err()
.unwrap_or_else(|| panic!("{args:?} must be rejected"))
.to_string()
}
#[test]
fn web_auth_options_default_to_absent_in_every_web_enabled_scope() {
let root = parse(&["cflx", "--web"]);
assert_eq!(root.web_auth_token, None);
assert_eq!(root.web_auth_token_env, None);
assert!(root.web_allowed_origins.is_empty());
match parse(&["cflx", "run", "--all", "--web"]).command {
Some(Commands::Run(args)) => {
assert_eq!(args.web_auth_token, None);
assert_eq!(args.web_auth_token_env, None);
assert!(args.web_allowed_origins.is_empty());
}
other => panic!("expected run, got {other:?}"),
}
match parse(&["cflx", "tui", "--web"]).command {
Some(Commands::Tui(args)) => {
assert_eq!(args.web_auth_token, None);
assert_eq!(args.web_auth_token_env, None);
assert!(args.web_allowed_origins.is_empty());
}
other => panic!("expected tui, got {other:?}"),
}
}
#[test]
fn a_literal_token_parses_in_every_web_enabled_scope() {
assert_eq!(
parse(&["cflx", "--web", "--web-auth-token", "abc"]).web_auth_token,
Some("abc".to_string())
);
match parse(&["cflx", "run", "--all", "--web", "--web-auth-token", "abc"]).command {
Some(Commands::Run(args)) => assert_eq!(args.web_auth_token, Some("abc".to_string())),
other => panic!("expected run, got {other:?}"),
}
match parse(&["cflx", "tui", "--web", "--web-auth-token", "abc"]).command {
Some(Commands::Tui(args)) => assert_eq!(args.web_auth_token, Some("abc".to_string())),
other => panic!("expected tui, got {other:?}"),
}
}
#[test]
fn the_environment_form_parses_in_every_web_enabled_scope() {
assert_eq!(
parse(&["cflx", "--web", "--web-auth-token-env", "CFLX_TOKEN"]).web_auth_token_env,
Some("CFLX_TOKEN".to_string())
);
match parse(&[
"cflx",
"run",
"--all",
"--web",
"--web-auth-token-env",
"CFLX_TOKEN",
])
.command
{
Some(Commands::Run(args)) => {
assert_eq!(args.web_auth_token_env, Some("CFLX_TOKEN".to_string()))
}
other => panic!("expected run, got {other:?}"),
}
match parse(&["cflx", "tui", "--web", "--web-auth-token-env", "CFLX_TOKEN"]).command {
Some(Commands::Tui(args)) => {
assert_eq!(args.web_auth_token_env, Some("CFLX_TOKEN".to_string()))
}
other => panic!("expected tui, got {other:?}"),
}
}
#[test]
fn token_sources_are_mutually_exclusive_in_every_web_enabled_scope() {
for prefix in [
vec!["cflx"],
vec!["cflx", "run", "--all"],
vec!["cflx", "tui"],
] {
let mut args = prefix.clone();
args.extend([
"--web",
"--web-auth-token",
"a",
"--web-auth-token-env",
"V",
]);
let error = parse_err(&args);
assert!(
error.contains("cannot be used with"),
"{prefix:?} must reject both token sources, got: {error}"
);
}
}
#[test]
fn allowed_origins_are_repeatable_in_every_web_enabled_scope() {
let root = parse(&[
"cflx",
"--web",
"--web-allowed-origin",
"https://ops.example.com",
"--web-allowed-origin",
"http://localhost:5173",
]);
assert_eq!(
root.web_allowed_origins,
vec![
"https://ops.example.com".to_string(),
"http://localhost:5173".to_string()
]
);
match parse(&[
"cflx",
"run",
"--all",
"--web",
"--web-allowed-origin",
"https://a.example",
"--web-allowed-origin",
"https://b.example",
])
.command
{
Some(Commands::Run(args)) => assert_eq!(args.web_allowed_origins.len(), 2),
other => panic!("expected run, got {other:?}"),
}
match parse(&[
"cflx",
"tui",
"--web",
"--web-allowed-origin",
"https://a.example",
"--web-allowed-origin",
"https://b.example",
])
.command
{
Some(Commands::Tui(args)) => assert_eq!(args.web_allowed_origins.len(), 2),
other => panic!("expected tui, got {other:?}"),
}
}
#[test]
fn help_documents_the_literal_token_exposure_and_the_recommended_form() {
let help = Cli::command().render_long_help().to_string();
assert!(help.contains("--web-auth-token"));
assert!(help.contains("--web-auth-token-env"));
assert!(help.contains("--web-allowed-origin"));
assert!(
help.contains("inspect this process's arguments"),
"the literal-token exposure must be documented where an operator will read it"
);
assert!(
help.contains("wildcards are\nrejected") || help.contains("wildcards are rejected"),
"the exact-origin rule must be documented"
);
}
#[test]
#[cfg(feature = "web-monitoring")]
fn parsed_web_options_flow_into_a_validated_web_config() {
let args = parse(&[
"cflx",
"--web",
"--web-bind",
"0.0.0.0",
"--web-port",
"9000",
"--web-auth-token",
"abc",
"--web-allowed-origin",
"https://ops.example.com",
]);
let config = crate::web::WebConfig::enabled(args.web_port, args.web_bind.clone())
.with_auth(
args.web_auth_token.clone(),
args.web_auth_token_env.clone(),
args.web_allowed_origins.clone(),
);
assert!(config.validate().is_ok());
assert_eq!(config.resolve_auth_token().as_deref(), Some("abc"));
let unsafe_args = parse(&["cflx", "--web", "--web-bind", "0.0.0.0"]);
let unsafe_config =
crate::web::WebConfig::enabled(unsafe_args.web_port, unsafe_args.web_bind.clone())
.with_auth(None, None, Vec::new());
assert!(
unsafe_config.validate().is_err(),
"a routable bind without credentials must never reach a listener"
);
}
#[test]
#[cfg(feature = "web-monitoring")]
fn a_routable_bind_only_matters_when_the_tcp_listener_participates() {
let config = crate::web::WebConfig::enabled(0, "0.0.0.0".to_string())
.with_tcp_enabled(false)
.with_auth(None, None, Vec::new());
assert!(config.validate().is_ok());
assert!(config
.with_tcp_enabled(true)
.validate()
.is_err_and(|e| e.contains("non-loopback")));
}
fn unix_options(args: &[&str]) -> (Option<PathBuf>, bool) {
let cli = parse(args);
match cli.command {
None => (cli.web_unix_socket, cli.no_web_unix_socket),
Some(Commands::Run(run)) => (run.web_unix_socket, run.no_web_unix_socket),
Some(Commands::Tui(tui)) => (tui.web_unix_socket, tui.no_web_unix_socket),
other => panic!("unexpected command {other:?}"),
}
}
const UNIX_SCOPES: [&[&str]; 3] = [&["cflx"], &["cflx", "tui"], &["cflx", "run", "--all"]];
#[test]
fn unix_socket_options_default_to_the_repository_default_in_every_scope() {
for scope in UNIX_SCOPES {
assert_eq!(
unix_options(scope),
(None, false),
"scope={scope:?} must default to neither override nor opt-out"
);
}
}
#[test]
fn an_explicit_unix_path_parses_in_every_scope() {
for scope in UNIX_SCOPES {
let mut args = scope.to_vec();
args.extend_from_slice(&["--web-unix-socket", "/run/user/1000/custom.sock"]);
assert_eq!(
unix_options(&args),
(Some(PathBuf::from("/run/user/1000/custom.sock")), false),
"scope={scope:?}"
);
}
}
#[test]
fn the_unix_opt_out_parses_in_every_scope() {
for scope in UNIX_SCOPES {
let mut args = scope.to_vec();
args.push("--no-web-unix-socket");
assert_eq!(unix_options(&args), (None, true), "scope={scope:?}");
}
}
#[test]
fn the_unix_override_and_opt_out_are_mutually_exclusive_in_every_scope() {
for scope in UNIX_SCOPES {
let mut args = scope.to_vec();
args.extend_from_slice(&["--web-unix-socket", "/tmp/a.sock", "--no-web-unix-socket"]);
let error = parse_err(&args);
assert!(
error.contains("cannot be used with"),
"scope={scope:?} must report a conflict, got {error}"
);
}
}
#[test]
fn the_web_flag_leaves_the_unix_selection_alone() {
for scope in UNIX_SCOPES {
let mut args = scope.to_vec();
args.push("--web");
assert_eq!(unix_options(&args), (None, false), "scope={scope:?}");
}
}
#[test]
fn bare_invocation_forwards_its_unix_options_to_the_tui_args() {
let bare = super::tests::bare_tui_args(&["cflx", "--web-unix-socket", "/tmp/bare.sock"]);
assert_eq!(bare.web_unix_socket, Some(PathBuf::from("/tmp/bare.sock")));
assert!(!bare.no_web_unix_socket);
let opted_out = super::tests::bare_tui_args(&["cflx", "--no-web-unix-socket"]);
assert_eq!(opted_out.web_unix_socket, None);
assert!(opted_out.no_web_unix_socket);
}
#[test]
fn help_documents_both_unix_socket_choices() {
for help in [
Cli::command().render_long_help().to_string(),
Cli::command()
.find_subcommand_mut("run")
.expect("run subcommand")
.render_long_help()
.to_string(),
Cli::command()
.find_subcommand_mut("tui")
.expect("tui subcommand")
.render_long_help()
.to_string(),
] {
assert!(help.contains("--web-unix-socket"), "help={help}");
assert!(help.contains("--no-web-unix-socket"), "help={help}");
assert!(
help.contains("cflx-api.sock"),
"the default socket name must be discoverable from help, help={help}"
);
}
}
#[test]
#[cfg(all(unix, feature = "web-monitoring"))]
fn parsed_unix_options_flow_into_socket_resolution() {
use crate::web::unix_socket::{resolve_unix_socket, UnixSocketSelection};
use std::path::Path;
let (explicit, opt_out) = unix_options(&["cflx", "run", "--all"]);
assert_eq!(
resolve_unix_socket(explicit.as_deref(), opt_out, Some(Path::new("/repo/.git")))
.unwrap(),
UnixSocketSelection::Bind(PathBuf::from("/repo/.git/cflx-api.sock"))
);
assert!(
resolve_unix_socket(explicit.as_deref(), opt_out, None).is_err(),
"outside Git the default must be refused rather than guessed"
);
let (explicit, opt_out) =
unix_options(&["cflx", "tui", "--web-unix-socket", "/tmp/x.sock"]);
assert_eq!(
resolve_unix_socket(explicit.as_deref(), opt_out, None).unwrap(),
UnixSocketSelection::Bind(PathBuf::from("/tmp/x.sock"))
);
let (explicit, opt_out) = unix_options(&["cflx", "--no-web-unix-socket"]);
assert_eq!(
resolve_unix_socket(explicit.as_deref(), opt_out, None).unwrap(),
UnixSocketSelection::Disabled
);
}
mod judge_evaluation_cli {
use super::*;
fn summary(argv: &[&str]) -> JudgeEvalSummaryArgs {
match Cli::parse_from(argv).command {
Some(Commands::JudgeEval(args)) => match args.command {
JudgeEvalCommands::Summary(summary) => summary,
},
other => panic!("expected judge-eval summary, got {other:?} for {argv:?}"),
}
}
#[test]
fn summary_parses_its_selectors() {
let args = summary(&[
"cflx",
"judge-eval",
"summary",
"--purpose",
"parallel_dependency",
"--since-days",
"7",
"--project",
"conflux-a1b2c3d4",
"--json",
]);
assert_eq!(args.purpose, JudgeEvalPurpose::ParallelDependency);
assert_eq!(args.purpose.as_str(), "parallel_dependency");
assert_eq!(args.since_days, Some(7));
assert_eq!(args.project.as_deref(), Some("conflux-a1b2c3d4"));
assert!(args.json);
}
#[test]
fn every_selector_except_purpose_is_optional() {
let args = summary(&[
"cflx",
"judge-eval",
"summary",
"--purpose",
"parallel_dependency",
]);
assert_eq!(args.since_days, None, "the window is defaulted downstream");
assert_eq!(args.project, None);
assert!(!args.json, "human output is the default");
}
#[test]
fn purpose_is_required_and_closed() {
assert!(
Cli::try_parse_from(["cflx", "judge-eval", "summary"]).is_err(),
"--purpose must be required"
);
assert!(
Cli::try_parse_from([
"cflx",
"judge-eval",
"summary",
"--purpose",
"some_other_purpose",
])
.is_err(),
"an unknown purpose must not parse"
);
}
#[test]
fn since_days_rejects_zero_negative_and_oversized_windows() {
for bad in ["0", "-1", "3651", "seven", "1.5", ""] {
assert!(
Cli::try_parse_from([
"cflx",
"judge-eval",
"summary",
"--purpose",
"parallel_dependency",
"--since-days",
bad,
])
.is_err(),
"--since-days {bad} must be a usage error"
);
}
for good in ["1", "30", "3650"] {
let args = summary(&[
"cflx",
"judge-eval",
"summary",
"--purpose",
"parallel_dependency",
"--since-days",
good,
]);
assert_eq!(args.since_days, Some(good.parse().expect("bound")));
}
}
#[test]
fn the_namespace_exposes_only_the_read_only_summary() {
for verb in ["export", "purge", "delete", "clear", "prune"] {
assert!(
Cli::try_parse_from(["cflx", "judge-eval", verb]).is_err(),
"`judge-eval {verb}` must not exist"
);
}
assert!(
Cli::try_parse_from(["cflx", "judge-eval"]).is_err(),
"a subcommand is required"
);
}
#[test]
fn judge_eval_is_named_for_diagnostics_like_every_other_subcommand() {
let cli = Cli::parse_from([
"cflx",
"judge-eval",
"summary",
"--purpose",
"parallel_dependency",
]);
assert_eq!(cli.subcommand_name(), Some("judge-eval"));
assert!(Cli::command()
.get_subcommands()
.any(|sub| sub.get_name() == "judge-eval" && !sub.is_hide_set()));
}
}
}