mod cli;
mod herdr;
mod history;
mod indicator;
mod launch;
mod profile;
mod program;
#[cfg(unix)]
mod proxy;
mod settings;
mod shared;
mod shell;
mod tools;
mod ui;
mod update;
mod workspace;
use std::{
io::{self, IsTerminal},
path::PathBuf,
};
use anyhow::{Context, Result, bail};
use clap::{CommandFactory, FromArgMatches};
use serde_json::{Value, json};
use cli::{
AutoState, Cli, CodexAppArgs, Command, DefaultAction, DefaultArgs, DeleteArgs, IndicatorAction,
IndicatorArgs, LaunchArgs, ShellInitArgs, SyncArgs, WorkspaceArgs, WorkspaceCommand,
};
use indicator::Existing;
use launch::{AuthStatus, Tool};
use profile::{DEFAULT_PROFILE, Fallback, Profile, Store};
use workspace::{WORKSPACE_FILE, Workspaces};
fn main() {
restore_sigpipe();
let matches = Cli::command().after_help(other_agents_help()).get_matches();
let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
let json = cli.json;
if let Err(error) = run(cli) {
if json {
eprintln!("{}", json!({ "error": format!("{error:#}") }));
} else {
eprintln!("ditto-cli: {error:#}");
}
std::process::exit(1);
}
}
fn other_agents_help() -> String {
let names = tools::ALL
.iter()
.map(|spec| spec.key)
.collect::<Vec<_>>()
.chunks(6)
.map(|row| row.join(", "))
.collect::<Vec<_>>()
.join(",\n ");
format!(
"Other agents, launched the same way as the commands above \
(`ditto-cli <agent> [profile] -- [args]`):\n {names}"
)
}
#[cfg(unix)]
fn restore_sigpipe() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
#[cfg(not(unix))]
fn restore_sigpipe() {}
fn run(cli: Cli) -> Result<()> {
let store = Store::discover()?;
let workspaces = Workspaces::new(&store);
let json = cli.json;
match cli.command {
None => run_tui(&store, &workspaces),
Some(Command::List) => list_profiles(&store, &workspaces, json),
Some(Command::Status { profile }) => {
show_status(&store, &workspaces, profile.as_deref(), json)
}
Some(Command::Create { name }) => create_profile(&store, &name, json),
Some(Command::Rename { profile, new_name }) => {
rename_profile(&store, &profile, &new_name, json)
}
Some(Command::Delete(arguments)) => delete_profile(&store, arguments, json),
Some(Command::Sync(arguments)) => sync_settings(&store, &workspaces, arguments, json),
Some(Command::Default(arguments)) => {
set_default_profile(&store, &workspaces, arguments, json)
}
Some(Command::Workspace(arguments)) => run_workspace(&store, &workspaces, arguments, json),
Some(Command::Paths { profile }) => {
show_paths(&store, &workspaces, profile.as_deref(), json)
}
Some(Command::Claude(arguments)) => {
launch_direct(&store, &workspaces, Tool::Claude, arguments)
}
Some(Command::Codex(arguments)) => {
launch_direct(&store, &workspaces, Tool::Codex, arguments)
}
Some(Command::CodexApp(arguments)) => launch_codex_app(&store, &workspaces, arguments),
Some(Command::Fx(arguments)) => launch_direct(&store, &workspaces, Tool::Fx, arguments),
Some(Command::Opencode(arguments)) => {
launch_direct(&store, &workspaces, Tool::Opencode, arguments)
}
Some(Command::Omp(arguments)) => launch_direct(&store, &workspaces, Tool::Omp, arguments),
Some(Command::PrimeAgent(arguments)) => {
launch_direct(&store, &workspaces, Tool::PrimeAgent, arguments)
}
Some(Command::Pi(arguments)) => launch_direct(&store, &workspaces, Tool::Pi, arguments),
Some(Command::Other(argv)) => {
let (name, arguments) = cli::external_launch(&argv)?;
let Some(tool) = Tool::by_key(&name) else {
bail!(
"'{name}' is not a Ditto command or an agent it launches; run `ditto-cli --help`"
);
};
launch_direct(&store, &workspaces, tool, arguments)
}
Some(Command::ShellInit(arguments)) => print_shell_init(arguments),
Some(Command::Indicator(arguments)) => set_indicator(&store, &workspaces, arguments, json),
Some(Command::Statusline(arguments)) => {
indicator::render(arguments.with, arguments.with_encoded)
}
Some(Command::Update(arguments)) => update::run(arguments.check, arguments.git),
}
}
fn report(json: bool, payload: impl FnOnce() -> Value, human: impl FnOnce()) {
if json {
println!("{}", payload());
} else {
human();
}
}
fn run_tui(store: &Store, workspaces: &Workspaces) -> Result<()> {
if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
bail!(
"the profile picker needs an interactive terminal, and this is not one.\n\
Every command works without it:\n\
\x20 ditto-cli list --json\n\
\x20 ditto-cli status <profile> --json\n\
\x20 ditto-cli create <profile>\n\
\x20 ditto-cli default <profile>\n\
\x20 ditto-cli workspace use <profile>\n\
Run `ditto-cli --help` for the full set."
);
}
let mut selected = match current_binding(workspaces) {
Some(binding) if store.load_profile(&binding.profile).is_ok() => Some(binding.profile),
_ => store.last_profile()?,
};
loop {
let profiles = store.list_profiles()?;
let default_profile = store.default_profile_name()?;
let Some(action) = ui::run(store, profiles, selected.as_deref(), default_profile)? else {
return Ok(());
};
match action {
ui::UiAction::Launch { tool, profile } => {
store.save_last_profile(&profile.name)?;
auto_bind(store, workspaces, &profile.name)?;
return launch::launch(tool, &profile, &[]);
}
#[cfg(target_os = "macos")]
ui::UiAction::LaunchCodexDesktop { profile } => {
return launch_codex_app(
store,
workspaces,
CodexAppArgs {
profile: Some(profile.name),
directory: None,
},
);
}
ui::UiAction::Authenticate {
operation,
tool,
profile,
} => {
store.save_last_profile(&profile.name)?;
selected = Some(profile.name.clone());
launch::authenticate(operation, tool, &profile)?;
}
}
}
}
fn list_profiles(store: &Store, workspaces: &Workspaces, json: bool) -> Result<()> {
let last_profile = store.last_profile()?;
let default_profile = store.default_profile_name()?;
let profiles = store.list_profiles()?;
let binding = current_binding(workspaces);
let fallback = effective_fallback(store, binding.as_ref())?;
let is_default = |profile: &Profile| default_profile.as_deref() == Some(&profile.name);
let is_last = |profile: &Profile| last_profile.as_deref() == Some(&profile.name);
report(
json,
|| {
json!({
"profiles": profiles
.iter()
.map(|profile| json!({
"name": profile.name,
"managed": profile.managed,
"is_default": is_default(profile),
"is_last_selected": is_last(profile),
}))
.collect::<Vec<_>>(),
"default_profile": default_profile,
"last_profile": last_profile,
"fallback_profile": fallback,
"workspace": binding_payload(binding.as_ref()),
})
},
|| {
for profile in &profiles {
let selected = if is_last(profile) { "*" } else { " " };
let kind = if profile.managed {
"isolated"
} else {
"native"
};
let pinned = if is_default(profile) { " default" } else { "" };
println!("{selected} {:<32} {kind}{pinned}", profile.name);
}
},
);
Ok(())
}
fn show_status(
store: &Store,
workspaces: &Workspaces,
requested_profile: Option<&str>,
json: bool,
) -> Result<()> {
let (profile, _) = resolve_profile(store, workspaces, requested_profile)?;
let statuses = Tool::ALL.map(|tool| (tool, launch::auth_status(tool, &profile)));
report(
json,
|| {
json!({
"profile": profile.name,
"managed": profile.managed,
"tools": statuses
.iter()
.map(|(tool, status)| json!({
"tool": tool.key(),
"label": tool.label(),
"status": status.key(),
"signed_in": *status == AuthStatus::SignedIn,
}))
.collect::<Vec<_>>(),
})
},
|| {
println!("{}", profile.name);
for (tool, status) in &statuses {
if matches!(tool, Tool::Generic(_)) && *status == AuthStatus::Unavailable {
continue;
}
print_auth_status(*tool, *status);
}
},
);
Ok(())
}
fn print_auth_status(tool: Tool, status: AuthStatus) {
let status = match status {
AuthStatus::SignedIn => "signed in",
AuthStatus::SignedOut => "sign in required",
AuthStatus::Unavailable => "CLI or status unavailable",
};
println!(" {:<13} {status}", tool.label());
}
fn create_profile(store: &Store, name: &str, json: bool) -> Result<()> {
let profile = store.create_profile(name)?;
let copied = settings::seed(store, &profile);
let linked = shared::seed(store, &profile);
report(
json,
|| {
let mut created = profile_paths(&profile);
created["created"] = json!(true);
created["settings_copied"] = json!(copied.copied);
created["shared"] = json!(linked.linked);
let mut sign_in = json!({
"claude": format!("ditto-cli claude {} -- auth login", profile.name),
"codex": format!("ditto-cli codex {} -- login", profile.name),
"fx": format!("ditto-cli fx {} -- login", profile.name),
"opencode": format!("ditto-cli opencode {} -- auth login", profile.name),
"omp": format!("ditto-cli omp {} (then /login inside OMP)", profile.name),
"prime-agent": format!(
"ditto-cli prime-agent {} -- /login",
profile.name
),
"pi": format!("ditto-cli pi {} (then /login inside Pi)", profile.name),
});
for spec in tools::ALL {
sign_in[spec.key] = json!(match spec.login {
Some(login) => format!(
"ditto-cli {} {} -- {}",
spec.key,
profile.name,
login.join(" ")
),
None => format!(
"ditto-cli {} {} (then sign in inside {})",
spec.key, profile.name, spec.label
),
});
}
created["sign_in"] = sign_in;
created["preserve_history"] = json!({
"this_profile": format!("ditto-cli sync {} --history", profile.name),
"all_profiles": "ditto-cli sync --all --history",
});
created
},
|| {
println!("Created profile '{}'.", profile.name);
if copied.changed() {
println!(
"Copied your Claude Code settings into it: {}.",
copied.copied.join(", ")
);
}
if !linked.linked.is_empty() {
println!("Reading yours for: {}.", linked.linked.join(", "));
}
println!(
"Preserve existing chats in this profile with `ditto-cli sync {} \
--history`, or in every profile with `ditto-cli sync --all --history`.",
profile.name
);
print_login_instructions(&profile);
},
);
Ok(())
}
fn sync_settings(
store: &Store,
workspaces: &Workspaces,
arguments: SyncArgs,
json: bool,
) -> Result<()> {
let source = store.load_profile(DEFAULT_PROFILE)?;
let profiles = if arguments.all {
store
.list_profiles()?
.into_iter()
.filter(|profile| profile.managed)
.collect::<Vec<_>>()
} else {
vec![resolve_profile(store, workspaces, arguments.profile.as_deref())?.0]
};
let outcomes = profiles
.into_iter()
.map(|profile| {
sync_profile(
store,
&source,
profile,
arguments.overwrite,
arguments.adopt,
arguments.history,
)
})
.collect::<Result<Vec<_>>>()?;
if arguments.all {
report(
json,
|| {
json!({
"profiles": outcomes.iter().map(sync_payload).collect::<Vec<_>>(),
"changed": outcomes.iter().any(SyncOutcome::changed),
})
},
|| {
for outcome in &outcomes {
print_sync(outcome);
}
},
);
} else {
let outcome = &outcomes[0];
report(json, || sync_payload(outcome), || print_sync(outcome));
}
Ok(())
}
struct SyncOutcome {
profile: String,
copied: settings::Copied,
linked: shared::Linked,
repaired: shared::Repaired,
history: Option<history::Backfilled>,
}
impl SyncOutcome {
fn changed(&self) -> bool {
self.copied.changed()
|| self.linked.changed()
|| self.repaired.changed()
|| self
.history
.as_ref()
.is_some_and(history::Backfilled::changed)
}
}
fn sync_profile(
store: &Store,
source: &Profile,
profile: Profile,
overwrite: bool,
adopt: bool,
backfill_history: bool,
) -> Result<SyncOutcome> {
store.ensure_profile_directories(&profile)?;
let copied = settings::copy(source, &profile, overwrite)?;
let linked = shared::link(source, &profile, adopt)?;
let repaired = shared::repair(&profile);
let history = backfill_history
.then(|| history::backfill(source, &profile))
.transpose()?;
Ok(SyncOutcome {
profile: profile.name,
copied,
linked,
repaired,
history,
})
}
fn sync_payload(outcome: &SyncOutcome) -> Value {
let mut payload = json!({
"profile": outcome.profile,
"source": DEFAULT_PROFILE,
"copied": outcome.copied.copied,
"kept": outcome.copied.kept,
"shared": outcome.linked.linked,
"shared_kept": outcome.linked.kept,
"shared_failed": outcome
.linked
.failed
.iter()
.map(|(path, reason)| json!({ "path": path, "reason": reason }))
.collect::<Vec<_>>(),
"repaired": outcome.repaired.links,
"repair_failed": outcome
.repaired
.failed
.iter()
.map(|(path, reason)| json!({ "path": path, "reason": reason }))
.collect::<Vec<_>>(),
"changed": outcome.changed(),
});
if let Some(history) = &outcome.history {
payload["history"] = json!(
history
.tools
.iter()
.map(|(tool, counts)| (
tool.key().to_owned(),
json!({ "copied": counts.copied, "kept": counts.kept })
))
.collect::<serde_json::Map<_, _>>()
);
}
payload
}
fn print_sync(outcome: &SyncOutcome) {
if outcome.copied.changed() {
println!(
"Copied into '{}': {}.",
outcome.profile,
outcome.copied.copied.join(", ")
);
} else {
println!(
"'{}' already has every setting your own configuration sets.",
outcome.profile
);
}
if !outcome.copied.kept.is_empty() {
println!(
"Left '{}' as it is for: {}.",
outcome.profile,
outcome.copied.kept.join(", ")
);
println!(
" Replace those too with `ditto-cli sync {} --overwrite`.",
outcome.profile
);
}
if !outcome.linked.linked.is_empty() {
println!("Reading yours for: {}.", outcome.linked.linked.join(", "));
}
if !outcome.linked.kept.is_empty() {
println!(
"'{}' has its own and keeps it: {}.",
outcome.profile,
outcome.linked.kept.join(", ")
);
println!(
" Point those at yours too with `ditto-cli sync {} --adopt`, \
which moves what is there aside rather than deleting it.",
outcome.profile
);
}
for (path, reason) in &outcome.linked.failed {
println!("Could not share {path}: {reason}");
}
if !outcome.repaired.links.is_empty() {
println!(
"Repaired links installed pointing at nothing: {}.",
outcome.repaired.links.join(", ")
);
}
for (path, reason) in &outcome.repaired.failed {
println!("Could not repair {path}: {reason}");
}
if let Some(history) = &outcome.history {
for (tool, counts) in &history.tools {
println!(
"Backfilled {} {} history files or sessions into '{}'; kept {} already there.",
counts.copied,
tool.label(),
outcome.profile,
counts.kept
);
}
}
}
fn rename_profile(store: &Store, current_name: &str, new_name: &str, json: bool) -> Result<()> {
let current = store.load_profile(current_name)?;
let signs_out = launch::auth_status(Tool::Claude, ¤t) == AuthStatus::SignedIn;
if signs_out && !json {
println!(
"Claude Code ties its credentials to the profile directory, which this \
rename moves,\nso '{current_name}' will be signed out."
);
println!();
}
let profile = store.rename_profile(current_name, new_name)?;
report(
json,
|| {
json!({
"renamed": true,
"from": current_name,
"profile": profile.name,
"claude_signed_out": signs_out,
})
},
|| {
println!("Renamed profile '{current_name}' to '{}'.", profile.name);
if signs_out {
println!();
println!("Sign Claude Code back in with:");
println!(" ditto-cli claude {} -- auth login", profile.name);
}
},
);
Ok(())
}
fn delete_profile(store: &Store, arguments: DeleteArgs, json: bool) -> Result<()> {
let name = arguments.profile;
store.load_profile(&name)?;
let targets = store.deletion_targets(&name);
let listed = targets
.iter()
.map(|path| format!(" {}", path.display()))
.collect::<Vec<_>>()
.join("\n");
if !arguments.yes {
bail!(
"deleting '{name}' removes its credentials, settings, and session history \
for good:\n{listed}\nPass --yes to confirm."
);
}
store.delete_profile(&name)?;
report(
json,
|| {
json!({
"deleted": true,
"profile": name,
"removed": targets
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
})
},
|| {
println!("Deleted profile '{name}'.");
println!("{listed}");
},
);
Ok(())
}
fn set_default_profile(
store: &Store,
workspaces: &Workspaces,
arguments: DefaultArgs,
json: bool,
) -> Result<()> {
match arguments.action() {
Some(DefaultAction::Pin(name)) => store.set_default_profile_name(Some(name))?,
Some(DefaultAction::Clear) => store.set_default_profile_name(None)?,
None => {}
}
let default_profile = store.default_profile_name()?;
let binding = current_binding(workspaces);
let fallback = effective_fallback(store, binding.as_ref())?;
report(
json,
|| {
json!({
"default_profile": default_profile,
"fallback_profile": fallback,
"workspace": binding_payload(binding.as_ref()),
})
},
|| {
match &default_profile {
Some(name) => println!("default profile: {name}"),
None => println!("no default profile; commands fall back to {fallback}"),
}
if let Some(binding) = &binding {
println!(
"here, {} outranks it and names '{}'",
binding.describe_origin(),
binding.profile
);
}
},
);
Ok(())
}
fn print_shell_init(arguments: ShellInitArgs) -> Result<()> {
let shell = match arguments.shell {
Some(shell) => shell,
None => shell::detect()?,
};
print!("{}", shell::script(shell));
Ok(())
}
fn set_indicator(
store: &Store,
workspaces: &Workspaces,
arguments: IndicatorArgs,
json: bool,
) -> Result<()> {
let (profile, _) = resolve_profile(store, workspaces, arguments.profile.as_deref())?;
let existing = if arguments.keep_mine {
Existing::KeepAlongside
} else {
Existing::LeaveAlone
};
let outcome = indicator::shadowed(match arguments.action() {
Some(IndicatorAction::On) => indicator::enable(&profile, existing)?,
Some(IndicatorAction::Off) => indicator::disable(&profile)?,
None => indicator::state(&profile)?,
});
report(
json,
|| {
json!({
"profile": profile.name,
"outcome": outcome.key(),
"on": outcome.is_on(),
"description": outcome.describe(),
})
},
|| println!("{}: {}", profile.name, outcome.describe()),
);
Ok(())
}
fn show_paths(
store: &Store,
workspaces: &Workspaces,
requested_profile: Option<&str>,
json: bool,
) -> Result<()> {
let (profile, _) = resolve_profile(store, workspaces, requested_profile)?;
report(
json,
|| profile_paths(&profile),
|| {
println!("profile={}", profile.name);
println!("claude={}", profile.claude_home.display());
println!("codex={}", profile.codex_home.display());
println!("fx={}", profile.fx_dir().display());
println!("opencode={}", profile.opencode.data_dir().display());
println!(
"opencode-config={}",
profile.opencode.config_dir().display()
);
println!("omp={}", profile.omp_home.display());
println!("prime-agent={}", profile.prime_agent_home.display());
println!("pi={}", profile.pi_home.display());
for spec in tools::ALL {
println!("{}={}", spec.key, profile.tool_root(spec).display());
}
},
);
Ok(())
}
fn profile_paths(profile: &Profile) -> Value {
let mut paths = json!({
"profile": profile.name,
"managed": profile.managed,
"claude": profile.claude_home.display().to_string(),
"codex": profile.codex_home.display().to_string(),
"fx": profile.fx_dir().display().to_string(),
"opencode": profile.opencode.data_dir().display().to_string(),
"opencode_config": profile.opencode.config_dir().display().to_string(),
"omp": profile.omp_home.display().to_string(),
"prime_agent": profile.prime_agent_home.display().to_string(),
"pi": profile.pi_home.display().to_string(),
});
for spec in tools::ALL {
paths[spec.key.replace('-', "_")] = json!(profile.tool_root(spec).display().to_string());
}
paths
}
fn launch_codex_app(store: &Store, workspaces: &Workspaces, arguments: CodexAppArgs) -> Result<()> {
let open_directory = arguments.directory.is_some();
let directory = directory_argument(arguments.directory)?;
std::env::set_current_dir(&directory)
.with_context(|| format!("could not enter {}", directory.display()))?;
let directory = current_directory()?;
let (profile, fell_back) = resolve_profile(store, workspaces, arguments.profile.as_deref())?;
if let Some(fallback) = fell_back {
eprintln!(
"ditto-cli: using '{}'; nothing binds {}, and it is {}",
profile.name,
directory.display(),
fallback.describe()
);
}
launch::launch_codex_desktop(
&profile,
store.user_home(),
open_directory.then_some(directory.as_path()),
)?;
store.save_last_profile(&profile.name)?;
if open_directory {
auto_bind(store, workspaces, &profile.name)?;
}
Ok(())
}
fn launch_direct(
store: &Store,
workspaces: &Workspaces,
tool: Tool,
arguments: LaunchArgs,
) -> Result<()> {
let (profile, fell_back) = resolve_profile(store, workspaces, arguments.profile.as_deref())?;
store.save_last_profile(&profile.name)?;
if let Some(fallback) = &fell_back {
eprintln!(
"ditto-cli: using '{}'; nothing binds this directory, and it is {}",
profile.name,
fallback.describe()
);
}
let bound = auto_bind(store, workspaces, &profile.name)?;
if fell_back.is_some() && !bound {
eprintln!(
"ditto-cli: bind this directory with `ditto-cli workspace use <profile>`, or name a \
profile with `ditto-cli {} <profile>`",
tool.key()
);
}
launch::launch(tool, &profile, &arguments.args)
}
fn resolve_profile(
store: &Store,
workspaces: &Workspaces,
requested_profile: Option<&str>,
) -> Result<(Profile, Option<Fallback>)> {
let binding = current_binding(workspaces);
if let Some(name) = requested_profile {
let profile = store.load_profile(name)?;
if let Some(binding) = &binding {
if binding.profile != profile.name {
eprintln!(
"ditto-cli: using '{}' for this run; {} still names '{}'",
profile.name,
binding.describe_origin(),
binding.profile
);
}
}
return Ok((profile, None));
}
if let Some(binding) = binding {
match store.load_profile(&binding.profile) {
Ok(profile) => return Ok((profile, None)),
Err(error) => eprintln!(
"ditto-cli: {} names profile '{}', which is unavailable: {error:#}",
binding.describe_origin(),
binding.profile
),
}
}
let fallback = store.fallback_profile()?;
let profile = store.load_profile(fallback.name())?;
Ok((profile, Some(fallback)))
}
fn effective_fallback(store: &Store, binding: Option<&workspace::Binding>) -> Result<String> {
if let Some(binding) = binding {
if store.load_profile(&binding.profile).is_ok() {
return Ok(binding.profile.clone());
}
}
Ok(store.fallback_profile()?.name().to_owned())
}
fn current_binding(workspaces: &Workspaces) -> Option<workspace::Binding> {
match current_directory().and_then(|directory| workspaces.find(&directory)) {
Ok(binding) => binding,
Err(error) => {
eprintln!("ditto-cli: ignoring this directory's profile: {error:#}");
None
}
}
}
fn auto_bind(store: &Store, workspaces: &Workspaces, profile: &str) -> Result<bool> {
if !store.workspace_auto_bind()? {
return Ok(false);
}
let Ok(directory) = current_directory() else {
return Ok(false);
};
if !workspaces.may_auto_bind(&directory) || workspaces.find(&directory)?.is_some() {
return Ok(false);
}
match workspaces.bind_file(&directory, profile) {
Ok(path) => {
println!("Bound this directory to '{profile}' ({}).", path.display());
Ok(true)
}
Err(error) => {
eprintln!("ditto-cli: could not bind this directory: {error:#}");
Ok(false)
}
}
}
fn run_workspace(
store: &Store,
workspaces: &Workspaces,
arguments: WorkspaceArgs,
json: bool,
) -> Result<()> {
match arguments.command {
None => show_workspace(workspaces, json),
Some(WorkspaceCommand::Use {
profile,
global,
path,
}) => bind_workspace(store, workspaces, &profile, global, path, json),
Some(WorkspaceCommand::Clear { path }) => clear_workspace(workspaces, path, json),
Some(WorkspaceCommand::List) => list_workspaces(workspaces, json),
Some(WorkspaceCommand::Auto { state }) => set_auto_bind(store, state, json),
}
}
fn binding_payload(binding: Option<&workspace::Binding>) -> Value {
match binding {
Some(binding) => json!({
"profile": binding.profile,
"directory": binding.directory.display().to_string(),
"origin": binding.origin_key(),
"source": binding.describe_origin(),
}),
None => Value::Null,
}
}
fn show_workspace(workspaces: &Workspaces, json: bool) -> Result<()> {
let directory = current_directory()?;
let binding = workspaces.find(&directory)?;
report(
json,
|| {
json!({
"directory": directory.display().to_string(),
"bound": binding.is_some(),
"binding": binding_payload(binding.as_ref()),
})
},
|| {
println!("directory={}", directory.display());
match &binding {
Some(binding) => {
println!("profile={}", binding.profile);
println!("source={}", binding.describe_origin());
}
None => println!("profile=<unbound>"),
}
},
);
Ok(())
}
fn bind_workspace(
store: &Store,
workspaces: &Workspaces,
profile: &str,
global: bool,
path: Option<PathBuf>,
json: bool,
) -> Result<()> {
let profile = store.load_profile(profile)?;
let directory = directory_argument(path)?;
let (directory, written) = if global {
let directory = workspaces.bind_registry(&directory, &profile.name)?;
let written = workspaces.registry_path().display().to_string();
(directory, written)
} else {
let path = workspaces.bind_file(&directory, &profile.name)?;
(directory, path.display().to_string())
};
report(
json,
|| {
json!({
"profile": profile.name,
"directory": directory.display().to_string(),
"origin": if global { "registry" } else { "file" },
"written": written,
})
},
|| {
println!("Bound {} to '{}'.", directory.display(), profile.name);
if global {
println!("Recorded in {written}.");
} else {
println!("Wrote {written}.");
}
},
);
Ok(())
}
fn clear_workspace(workspaces: &Workspaces, path: Option<PathBuf>, json: bool) -> Result<()> {
let directory = directory_argument(path)?;
let removed = workspaces.clear(&directory)?;
let inherited = workspaces.find(&directory)?;
report(
json,
|| {
json!({
"directory": directory.display().to_string(),
"removed": removed,
"inherits": binding_payload(inherited.as_ref()),
})
},
|| {
if removed.is_empty() {
println!("{} was not bound.", directory.display());
} else {
for entry in &removed {
println!("Removed {entry}.");
}
}
if let Some(binding) = &inherited {
println!(
"{} now inherits '{}' from {}.",
directory.display(),
binding.profile,
binding.describe_origin()
);
}
},
);
Ok(())
}
fn list_workspaces(workspaces: &Workspaces, json: bool) -> Result<()> {
let entries = workspaces.entries()?;
report(
json,
|| {
json!({
"workspaces": entries
.iter()
.map(|(directory, profile)| json!({
"directory": directory.display().to_string(),
"profile": profile,
}))
.collect::<Vec<_>>(),
"registry": workspaces.registry_path().display().to_string(),
"includes_files": false,
})
},
|| {
if entries.is_empty() {
println!("No directories are recorded in the registry.");
} else {
for (directory, profile) in &entries {
println!("{:<48} {profile}", directory.display());
}
}
println!();
println!(
"{WORKSPACE_FILE} files are not listed: they are found by walking up from the \
directory"
);
println!("Ditto runs in. Run `ditto-cli workspace` to see the one in effect here.");
},
);
Ok(())
}
fn set_auto_bind(store: &Store, state: Option<AutoState>, json: bool) -> Result<()> {
if let Some(state) = state {
store.set_workspace_auto_bind(state.enabled())?;
}
let enabled = store.workspace_auto_bind()?;
report(
json,
|| json!({ "auto_bind": enabled }),
|| {
println!(
"Launching from an unbound directory {} it.",
if enabled { "binds" } else { "does not bind" }
);
},
);
Ok(())
}
fn directory_argument(path: Option<PathBuf>) -> Result<PathBuf> {
match path {
Some(path) => {
if !path.is_dir() {
anyhow::bail!("{} is not a directory", path.display());
}
Ok(path)
}
None => current_directory(),
}
}
fn current_directory() -> Result<PathBuf> {
std::env::current_dir().context("could not determine the current directory")
}
fn print_login_instructions(profile: &Profile) {
println!();
println!(
"Open `ditto-cli`, select '{}', then press l to sign in to Claude Code, Codex, fx, opencode, or Prime Agent.",
profile.name
);
println!();
println!("Or authenticate directly:");
println!(" ditto-cli claude {} -- auth login", profile.name);
println!(" ditto-cli codex {} -- login", profile.name);
println!(" ditto-cli fx {} -- login", profile.name);
println!(" ditto-cli opencode {} -- auth login", profile.name);
println!(" ditto-cli prime-agent {} -- /login", profile.name);
println!();
println!("Launch OMP or Pi, then use `/login` inside it:");
println!(" ditto-cli omp {}", profile.name);
println!(" ditto-cli pi {}", profile.name);
println!();
println!("Every other agent runs the same way; `ditto-cli --help` lists them:");
println!(
" ditto-cli <agent> {} -- <its login arguments>",
profile.name
);
}