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::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),
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>,
}
#[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
);
}
}