use std::{ffi::OsString, path::PathBuf};
use anyhow::{Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Debug, Parser)]
#[command(
name = "ditto-cli",
version,
about = "Launch Claude Code, Codex, fx, opencode, OMP, Prime Agent, Pi, and other coding agents with isolated profiles"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(long, global = true)]
pub json: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
List,
Status {
profile: Option<String>,
},
Create {
name: String,
},
Rename {
profile: String,
new_name: String,
},
Delete(DeleteArgs),
Sync(SyncArgs),
Default(DefaultArgs),
Workspace(WorkspaceArgs),
Paths {
profile: Option<String>,
},
#[command(visible_alias = "cc")]
Claude(LaunchArgs),
#[command(visible_alias = "cx")]
Codex(LaunchArgs),
#[command(visible_alias = "cxa")]
CodexApp(CodexAppArgs),
Fx(LaunchArgs),
#[command(visible_alias = "oc")]
Opencode(LaunchArgs),
Omp(LaunchArgs),
#[command(visible_alias = "pa")]
PrimeAgent(LaunchArgs),
Pi(LaunchArgs),
#[command(external_subcommand)]
Other(Vec<OsString>),
ShellInit(ShellInitArgs),
Indicator(IndicatorArgs),
#[command(hide = true)]
Statusline(StatuslineArgs),
Update(UpdateArgs),
}
#[derive(Debug, Args)]
pub struct WorkspaceArgs {
#[command(subcommand)]
pub command: Option<WorkspaceCommand>,
}
#[derive(Debug, Subcommand)]
pub enum WorkspaceCommand {
Use {
profile: String,
#[arg(long)]
global: bool,
#[arg(long)]
path: Option<PathBuf>,
},
Clear {
#[arg(long)]
path: Option<PathBuf>,
},
List,
Auto {
state: Option<AutoState>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum AutoState {
On,
Off,
}
impl AutoState {
pub fn enabled(self) -> bool {
matches!(self, Self::On)
}
}
#[derive(Debug, Args)]
pub struct ShellInitArgs {
pub shell: Option<ShellKind>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ShellKind {
Bash,
Fish,
Zsh,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
pub profile: String,
#[arg(long)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct SyncArgs {
#[arg(required_unless_present = "all", conflicts_with = "all")]
pub profile: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long, visible_alias = "pi-history")]
pub history: bool,
#[arg(long)]
pub overwrite: bool,
#[arg(long)]
pub adopt: bool,
}
#[derive(Debug, Args)]
pub struct DefaultArgs {
pub profile: Option<String>,
#[arg(long, conflicts_with = "profile")]
pub clear: bool,
}
impl DefaultArgs {
pub fn action(&self) -> Option<DefaultAction<'_>> {
match (self.profile.as_deref(), self.clear) {
(Some(name), _) => Some(DefaultAction::Pin(name)),
(_, true) => Some(DefaultAction::Clear),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DefaultAction<'a> {
Pin(&'a str),
Clear,
}
#[derive(Debug, Args)]
pub struct IndicatorArgs {
pub profile: Option<String>,
#[arg(long, conflicts_with = "off")]
pub on: bool,
#[arg(long)]
pub off: bool,
#[arg(long, conflicts_with = "off")]
pub keep_mine: bool,
}
impl IndicatorArgs {
pub fn action(&self) -> Option<IndicatorAction> {
match (self.on || self.keep_mine, self.off) {
(true, _) => Some(IndicatorAction::On),
(_, true) => Some(IndicatorAction::Off),
_ => None,
}
}
}
#[derive(Debug, Args)]
pub struct StatuslineArgs {
#[arg(long, allow_hyphen_values = true)]
pub with: Option<String>,
#[arg(long, allow_hyphen_values = true, conflicts_with = "with")]
pub with_encoded: Option<String>,
}
#[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>,
}
#[derive(Debug, Args)]
pub struct CodexAppArgs {
pub profile: Option<String>,
#[arg(short = 'C', long, value_name = "PATH")]
pub directory: Option<PathBuf>,
}
pub fn external_launch(argv: &[OsString]) -> Result<(String, LaunchArgs)> {
let Some((name, rest)) = argv.split_first() else {
bail!("no tool was named");
};
let name = name.to_string_lossy().into_owned();
let (positional, args) = match rest.iter().position(|word| word == "--") {
Some(separator) => (&rest[..separator], rest[separator + 1..].to_vec()),
None => (rest, Vec::new()),
};
let profile = match positional {
[] => None,
[profile] if !profile.to_string_lossy().starts_with('-') => {
Some(profile.to_string_lossy().into_owned())
}
_ => bail!(
"`ditto-cli {name}` takes at most a profile name; put {name}'s own arguments after `--`"
),
};
Ok((name, LaunchArgs { profile, args }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_launch_of_a_tool_named_outside_the_subcommand_list() {
let cli =
Cli::try_parse_from(["ditto-cli", "gemini", "work", "--", "--model", "x"]).unwrap();
let Some(Command::Other(argv)) = cli.command else {
panic!("a name clap does not know should reach the table");
};
let (name, launch) = external_launch(&argv).unwrap();
assert_eq!(name, "gemini");
assert_eq!(launch.profile.as_deref(), Some("work"));
assert_eq!(
launch.args,
[OsString::from("--model"), OsString::from("x")]
);
let words = |words: &[&str]| words.iter().map(OsString::from).collect::<Vec<_>>();
let (_, launch) = external_launch(&words(&["gemini"])).unwrap();
assert_eq!(launch.profile, None);
assert!(launch.args.is_empty());
let (_, launch) = external_launch(&words(&["gemini", "--", "-p", "hi"])).unwrap();
assert_eq!(launch.profile, None);
assert_eq!(launch.args.len(), 2);
assert!(external_launch(&words(&["gemini", "--model", "x"])).is_err());
assert!(external_launch(&words(&["gemini", "work", "extra"])).is_err());
}
#[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_sync_arguments() {
let named = Cli::try_parse_from(["ditto-cli", "sync", "work"]).unwrap();
assert!(matches!(
named.command,
Some(Command::Sync(SyncArgs {
profile, overwrite, ..
}))
if profile.as_deref() == Some("work") && !overwrite
));
assert!(Cli::try_parse_from(["ditto-cli", "sync", "--overwrite"]).is_err());
let all =
Cli::try_parse_from(["ditto-cli", "sync", "--all", "--history", "--adopt"]).unwrap();
assert!(matches!(
all.command,
Some(Command::Sync(SyncArgs {
all: true,
history: true,
adopt: true,
..
}))
));
assert!(Cli::try_parse_from(["ditto-cli", "sync", "work", "--all"]).is_err());
}
#[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_prime_agent_launch_arguments() {
let cli = Cli::try_parse_from([
"ditto-cli",
"prime-agent",
"work",
"--",
"--model",
"claude-opus-4-1",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::PrimeAgent(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work")
&& args == [
OsString::from("--model"),
OsString::from("claude-opus-4-1"),
]
));
}
#[test]
fn parses_pi_launch_arguments() {
let cli = Cli::try_parse_from([
"ditto-cli",
"pi",
"work",
"--",
"--model",
"anthropic/claude-opus-4-6",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Pi(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work")
&& args == [
OsString::from("--model"),
OsString::from("anthropic/claude-opus-4-6"),
]
));
}
#[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 codex_app =
Cli::try_parse_from(["ditto-cli", "cxa", "work", "-C", "/tmp/project"]).unwrap();
assert!(matches!(
codex_app.command,
Some(Command::CodexApp(CodexAppArgs { profile, directory }))
if profile.as_deref() == Some("work")
&& directory == Some(PathBuf::from("/tmp/project"))
));
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")]
));
let prime_agent = Cli::try_parse_from(["ditto-cli", "pa", "work"]).unwrap();
assert!(matches!(
prime_agent.command,
Some(Command::PrimeAgent(LaunchArgs { profile, args }))
if profile.as_deref() == Some("work") && args.is_empty()
));
}
#[test]
fn parses_a_launch_with_nothing_after_the_separator() {
let cli = Cli::try_parse_from(["ditto-cli", "omp", "--"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Omp(LaunchArgs { profile, args })) if profile.is_none() && args.is_empty()
));
let with_arguments =
Cli::try_parse_from(["ditto-cli", "omp", "--", "--model", "opus"]).unwrap();
assert!(matches!(
with_arguments.command,
Some(Command::Omp(LaunchArgs { profile, args }))
if profile.is_none()
&& args == [OsString::from("--model"), OsString::from("opus")]
));
}
#[test]
fn parses_shell_init_commands() {
let named = Cli::try_parse_from(["ditto-cli", "shell-init", "fish"]).unwrap();
assert!(matches!(
named.command,
Some(Command::ShellInit(ShellInitArgs { shell })) if shell == Some(ShellKind::Fish)
));
let detected = Cli::try_parse_from(["ditto-cli", "shell-init"]).unwrap();
assert!(matches!(
detected.command,
Some(Command::ShellInit(ShellInitArgs { shell })) if shell.is_none()
));
assert!(Cli::try_parse_from(["ditto-cli", "shell-init", "pwsh"]).is_err());
}
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());
}
fn workspace_command(arguments: &[&str]) -> Option<WorkspaceCommand> {
match Cli::try_parse_from(arguments).unwrap().command {
Some(Command::Workspace(arguments)) => arguments.command,
other => panic!("expected a workspace command, got {other:?}"),
}
}
#[test]
fn parses_workspace_commands() {
assert!(workspace_command(&["ditto-cli", "workspace"]).is_none());
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "use", "work"]),
Some(WorkspaceCommand::Use { profile, global, path })
if profile == "work" && !global && path.is_none()
));
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "use", "work", "--global"]),
Some(WorkspaceCommand::Use { global, .. }) if global
));
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "clear", "--path", "/tmp/project"]),
Some(WorkspaceCommand::Clear { path })
if path.as_deref() == Some(std::path::Path::new("/tmp/project"))
));
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "list"]),
Some(WorkspaceCommand::List)
));
assert!(Cli::try_parse_from(["ditto-cli", "workspace", "use"]).is_err());
}
#[test]
fn parses_the_auto_bind_setting() {
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "auto"]),
Some(WorkspaceCommand::Auto { state: None })
));
for (argument, expected) in [("on", AutoState::On), ("off", AutoState::Off)] {
assert!(matches!(
workspace_command(&["ditto-cli", "workspace", "auto", argument]),
Some(WorkspaceCommand::Auto { state: Some(state) }) if state == expected
));
}
assert!(AutoState::On.enabled());
assert!(!AutoState::Off.enabled());
assert!(Cli::try_parse_from(["ditto-cli", "workspace", "auto", "maybe"]).is_err());
}
#[test]
fn parses_the_status_line_command_claude_code_runs() {
let bare = Cli::try_parse_from(["ditto-cli", "statusline"]).unwrap();
assert!(matches!(
bare.command,
Some(Command::Statusline(StatuslineArgs { with, with_encoded }))
if with.is_none() && with_encoded.is_none()
));
let keeping =
Cli::try_parse_from(["ditto-cli", "statusline", "--with", "-x | y --with z"]).unwrap();
assert!(matches!(
keeping.command,
Some(Command::Statusline(StatuslineArgs { with: Some(with), .. }))
if with == "-x | y --with z"
));
let encoded =
Cli::try_parse_from(["ditto-cli", "statusline", "--with-encoded", "a%20b"]).unwrap();
assert!(matches!(
encoded.command,
Some(Command::Statusline(StatuslineArgs { with_encoded: Some(value), .. }))
if value == "a%20b"
));
}
fn default_args(arguments: &[&str]) -> DefaultArgs {
match Cli::try_parse_from(arguments).unwrap().command {
Some(Command::Default(arguments)) => arguments,
other => panic!("expected a default command, got {other:?}"),
}
}
#[test]
fn parses_default_profile_commands() {
assert_eq!(default_args(&["ditto-cli", "default"]).action(), None);
assert_eq!(
default_args(&["ditto-cli", "default", "work"]).action(),
Some(DefaultAction::Pin("work"))
);
assert_eq!(
default_args(&["ditto-cli", "default", "--clear"]).action(),
Some(DefaultAction::Clear)
);
assert!(Cli::try_parse_from(["ditto-cli", "default", "work", "--clear"]).is_err());
}
#[test]
fn requires_confirmation_to_parse_a_deletion() {
let confirmed = Cli::try_parse_from(["ditto-cli", "delete", "work", "--yes"]).unwrap();
assert!(matches!(
confirmed.command,
Some(Command::Delete(DeleteArgs { profile, yes })) if profile == "work" && yes
));
let unconfirmed = Cli::try_parse_from(["ditto-cli", "delete", "work"]).unwrap();
assert!(matches!(
unconfirmed.command,
Some(Command::Delete(DeleteArgs { yes, .. })) if !yes
));
assert!(Cli::try_parse_from(["ditto-cli", "delete"]).is_err());
}
#[test]
fn accepts_json_on_either_side_of_the_subcommand() {
for arguments in [
["ditto-cli", "list", "--json"],
["ditto-cli", "--json", "list"],
] {
let cli = Cli::try_parse_from(arguments).unwrap();
assert!(cli.json, "did not set json for {arguments:?}");
assert!(matches!(cli.command, Some(Command::List)));
}
assert!(!Cli::try_parse_from(["ditto-cli", "list"]).unwrap().json);
}
#[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")]
));
}
}