use std::ffi::OsString;
use clap::{Args, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "ditto-cli",
version,
about = "Launch Claude Code, Codex, opencode, and OMP with isolated profiles"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
List,
Status {
profile: Option<String>,
},
Create {
name: String,
},
Rename {
profile: String,
new_name: String,
},
Paths {
profile: Option<String>,
},
#[command(visible_alias = "cc")]
Claude(LaunchArgs),
#[command(visible_alias = "cx")]
Codex(LaunchArgs),
#[command(visible_alias = "oc")]
Opencode(LaunchArgs),
Omp(LaunchArgs),
Indicator(IndicatorArgs),
#[command(hide = true)]
Statusline,
Update(UpdateArgs),
}
#[derive(Debug, Args)]
pub struct IndicatorArgs {
pub profile: Option<String>,
#[arg(long, conflicts_with = "off")]
pub on: bool,
#[arg(long)]
pub off: bool,
}
impl IndicatorArgs {
pub fn action(&self) -> Option<IndicatorAction> {
match (self.on, self.off) {
(true, _) => Some(IndicatorAction::On),
(_, true) => Some(IndicatorAction::Off),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IndicatorAction {
On,
Off,
}
#[derive(Debug, Args)]
pub struct UpdateArgs {
#[arg(long)]
pub check: bool,
#[arg(long)]
pub git: bool,
}
#[derive(Debug, Args)]
pub struct LaunchArgs {
pub profile: Option<String>,
#[arg(last = true)]
pub args: Vec<OsString>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_profile_rename_command() {
let cli = Cli::try_parse_from(["ditto-cli", "rename", "work", "client"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Rename {
profile,
new_name
}) if profile == "work" && new_name == "client"
));
}
#[test]
fn parses_update_flags() {
let plain = Cli::try_parse_from(["ditto-cli", "update"]).unwrap();
assert!(matches!(
plain.command,
Some(Command::Update(UpdateArgs { check, git })) if !check && !git
));
let checking = Cli::try_parse_from(["ditto-cli", "update", "--check"]).unwrap();
assert!(matches!(
checking.command,
Some(Command::Update(UpdateArgs { check, git })) if check && !git
));
let from_git = Cli::try_parse_from(["ditto-cli", "update", "--git"]).unwrap();
assert!(matches!(
from_git.command,
Some(Command::Update(UpdateArgs { check, git })) if !check && git
));
}
#[test]
fn parses_opencode_launch_arguments() {
let cli = Cli::try_parse_from([
"ditto-cli",
"opencode",
"work",
"--",
"--model",
"anthropic/claude-opus-5",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Opencode(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work")
&& args == [
OsString::from("--model"),
OsString::from("anthropic/claude-opus-5"),
]
));
}
#[test]
fn parses_short_launch_aliases() {
let claude = Cli::try_parse_from(["ditto-cli", "cc", "work"]).unwrap();
assert!(matches!(
claude.command,
Some(Command::Claude(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work") && args.is_empty()
));
let codex = Cli::try_parse_from(["ditto-cli", "cx", "work"]).unwrap();
assert!(matches!(
codex.command,
Some(Command::Codex(LaunchArgs { profile, .. })) if profile.as_deref() == Some("work")
));
let opencode =
Cli::try_parse_from(["ditto-cli", "oc", "work", "--", "--model", "opus"]).unwrap();
assert!(matches!(
opencode.command,
Some(Command::Opencode(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work")
&& args == [OsString::from("--model"), OsString::from("opus")]
));
}
fn indicator_args(arguments: &[&str]) -> IndicatorArgs {
match Cli::try_parse_from(arguments).unwrap().command {
Some(Command::Indicator(arguments)) => arguments,
other => panic!("expected an indicator command, got {other:?}"),
}
}
#[test]
fn parses_indicator_commands() {
let reporting = indicator_args(&["ditto-cli", "indicator", "work"]);
assert_eq!(reporting.profile.as_deref(), Some("work"));
assert_eq!(reporting.action(), None);
assert_eq!(indicator_args(&["ditto-cli", "indicator"]).action(), None);
let turning_on = indicator_args(&["ditto-cli", "indicator", "work", "--on"]);
assert_eq!(turning_on.profile.as_deref(), Some("work"));
assert_eq!(turning_on.action(), Some(IndicatorAction::On));
let turning_off = indicator_args(&["ditto-cli", "indicator", "--off"]);
assert_eq!(turning_off.profile, None);
assert_eq!(turning_off.action(), Some(IndicatorAction::Off));
assert!(Cli::try_parse_from(["ditto-cli", "indicator", "--on", "--off"]).is_err());
}
#[test]
fn parses_the_status_line_command_claude_code_runs() {
let cli = Cli::try_parse_from(["ditto-cli", "statusline"]).unwrap();
assert!(matches!(cli.command, Some(Command::Statusline)));
}
#[test]
fn parses_omp_launch_arguments() {
let cli =
Cli::try_parse_from(["ditto-cli", "omp", "work", "--", "--model", "opus"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Omp(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work")
&& args == [OsString::from("--model"), OsString::from("opus")]
));
}
}