diff --git a/Cargo.lock b/Cargo.lock index ec96a93..7a6a856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,15 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -111,6 +120,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "clap_complete", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 451d81b..e09fa70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive", "wrap_help"] } +clap_complete = "4.5.67" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } diff --git a/README.md b/README.md index d8064a3..9d65eab 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ which of them the `claude` command runs as, and every account gets a Headroom proxy of its own, reachable under a name that does not change. ```sh -claude-account setup # wrapper, a proxy per account, the router +claude-account setup # wrapper, a proxy per account, the router, completions +claude-account add kunde # another account: profile, port, workspace, proxy claude-account switch work # `claude` runs as the work account from now on claude-account status # everything, and everything that is wrong ``` @@ -35,6 +36,12 @@ started deliberately in another profile -- a jail does this -- stays where it was put. Sessions that are already open are never disturbed by a switch; each keeps the profile it started with. +One thing beats the wrapper and should not: an `alias claude=...` or a shell +function of that name, which is found before anything in the `PATH` and, where +it sets `CLAUDE_CONFIG_DIR` itself, makes every switch look ignored. `status` +reads the startup files of bash, zsh and fish and names the file and the line, +because nothing else in the report would show why the account never changes. + ## A proxy per account A Headroom proxy keeps what it sees: a cache, a retrieval store and logs, all @@ -45,12 +52,23 @@ written from one systemd template: | Address | What | | --- | --- | -| `headroom.localhost` | the proxy of the account that is active right now | -| `.headroom.localhost` | one account's proxy, active or not | +| `.headroom.localhost` | the proxy of that account, active or not | | `.headroom.localhost` | the proxy a jail started for one session | +| `headroom.localhost` | the proxy of the account that is active right now | +| `headroom.localhost/names` | everything that resolves, and `?port=` for one name | -The bare name follows the switch. That is the whole point of it: one address in -a bookmark, always showing the account being worked in. +Every account has its own name, so two accounts in use at the same time -- +one in each terminal -- never share an address. The bare domain follows the +switch, for a bookmark that should show whichever account is being worked in, +but nothing prints it: an address printed in one session would open the other +account's proxy after a switch. + +A jail called like an account does not get the name; the account keeps it, and +`status` points the jail out with the command that renames it. Programs that +want the name of a port ask `headroom.localhost/names?port=8791`, which answers +with the address as plain text, or 404 where the port has no name. That is how +the `enqs-jail` and `enqs-info` plugins find it, so the rules of who gets which +name live here alone. Jails keep their own names. They are published as run files by the `enqs-jail` plugin, and the router here reads the same directory in the same format, so a @@ -67,12 +85,49 @@ workspaces over unchanged. | `doctor` | the same, and a non-zero exit when something is wrong | | `list`, `current`, `dir`, `env`, `url` | the small answers, for scripts | | `switch ` | make an account the one `claude` runs as | -| `setup` | wrapper, proxies and router in one go | +| `add ` | register an account: profile, port, workspace, proxy, all at once | +| `remove ` | forget one; its profile directory stays | +| `completions [shell]` | print a completion script, or `--install` it | +| `setup` | wrapper, proxies, router and completions in one go | | `adopt` | take over services that were written by hand | | `shim install \| uninstall` | the `claude` wrapper | | `headroom install \| start \| stop \| restart \| port \| logs` | one account's proxy | | `router serve \| install \| uninstall \| port \| list` | the name router | +## Adding an account + +```sh +claude-account add kunde # ~/.claude-kunde, the next free port +claude-account add kunde --dir ~/elsewhere --port 8795 +claude-account add kunde --switch # and run as it straight away +``` + +`add` does everything an account needs rather than half of it: it creates the +profile directory, reserves a port, gives the account a workspace of its own, +writes and starts its proxy service, and points the profile's `settings.json` +at that proxy. A login is the one thing left, because only a browser can do it: +`claude-account switch kunde && claude /login`. + +`remove` is the counterpart and undoes the registration alone. The profile +directory and the proxy workspace stay where they are -- they hold a login, +projects and memories -- so a profile that still has credentials keeps showing +up in `list`, found rather than configured. + +## Completions + +```sh +claude-account completions --install # every shell on this machine +claude-account completions fish # or print one +``` + +`setup` installs them as well. The subcommands come from the parser, so they +cannot drift; the account names are not written into the file but asked for +when Tab is pressed (`claude-account list --names`), so an account added a +minute ago completes without regenerating anything. Files go to +`~/.config/fish/completions`, `~/.local/share/bash-completion/completions` and +`~/.local/share/zsh/site-functions` -- the last one has to be in the `fpath` +before `compinit`. + ## Configuration `~/.config/claude-account/config.json`. Everything in it has a default derived diff --git a/src/account.rs b/src/account.rs index 63c2ce2..1e8f28e 100644 --- a/src/account.rs +++ b/src/account.rs @@ -24,7 +24,8 @@ pub const DEFAULT_NAME: &str = "personal"; #[derive(Debug, Clone)] pub struct Account { - /// The name on the command line and in `.headroom.localhost`. + /// The name on the command line. An account has no name under the domain: + /// the bare one belongs to whichever account is active. pub name: String, /// The directory `CLAUDE_CONFIG_DIR` is pointed at. pub dir: PathBuf, @@ -175,7 +176,7 @@ pub fn find(config: &Config, name: &str) -> Result { let wanted = router::label(name); all(config) .into_iter() - .find(|account| account.name == wanted) + .find(|account| router::label(&account.name) == wanted) .ok_or_else(|| anyhow!("there is no account called {name}. Run `claude-account list`.")) } @@ -215,6 +216,65 @@ pub fn switch(config: &mut Config, name: &str) -> Result { Ok(account) } +/// Register an account: a new profile directory, or one this machine already +/// holds that discovery would not have found. +/// +/// The directory is created empty when it does not exist. An empty profile is +/// a working one -- Claude Code fills it on the first run and `/login` puts a +/// credential in it -- so nothing is copied from another account, which would +/// be the one way to end up with two accounts sharing a login again. +pub fn add(config: &mut Config, name: &str, dir: Option, port: Option) -> Result { + let name = router::label(name); + if name.is_empty() { + return Err(anyhow!("an account needs a name of letters, digits or hyphens")); + } + if let Some(existing) = all(config).into_iter().find(|account| account.name == name) { + return Err(anyhow!("there is already an account called {name}, at {}", shorten(&existing.dir))); + } + let dir = dir.unwrap_or_else(|| dir_for(&name)); + if let Some(other) = all(config).into_iter().find(|account| account.dir == dir) { + return Err(anyhow!("{} is already the account {}", shorten(&dir), other.name)); + } + if let Some(port) = port { + if let Some(other) = all(config).into_iter().find(|account| account.port == port) { + return Err(anyhow!("port {port} already belongs to {}", other.name)); + } + } + std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", shorten(&dir)))?; + + let entry = config.entry_mut(&name); + entry.dir = Some(shorten(&dir)); + entry.port = port; + config.save()?; + // Read the account back rather than building one here, so that a port and + // a workspace nobody named are the ones discovery hands out, and are the + // same numbers every later run will see. + let account = find(config, &name)?; + let entry = config.entry_mut(&name); + entry.port = Some(account.port); + entry.workspace.get_or_insert_with(|| shorten(&account.workspace)); + config.save()?; + Ok(account) +} + +/// Forget an account: the configuration entry goes, the profile stays. +/// +/// A profile directory holds a login, projects and memories, so removing an +/// account here never removes any of that. What the command undoes is the +/// registration; `rm -rf` is a decision for a reader who means it. +pub fn forget(config: &mut Config, name: &str) -> Result { + let account = find(config, name)?; + if config.active.as_deref() == Some(account.name.as_str()) { + return Err(anyhow!( + "{} is the account `claude` runs as. Switch to another one first.", + account.name + )); + } + config.accounts.remove(&account.name); + config.save()?; + Ok(account) +} + /// Sessions of Claude Code that are running right now, by the profile they /// were started in. /// diff --git a/src/completions.rs b/src/completions.rs new file mode 100644 index 0000000..2fc8dd8 --- /dev/null +++ b/src/completions.rs @@ -0,0 +1,133 @@ +//! Tab completion, including the account names. +//! +//! The subcommands come out of the parser, so they cannot drift from what the +//! tool accepts. The account names cannot be baked in the same way: an account +//! is added, renamed or forgotten long after a completion file was written, and +//! a file listing yesterday's accounts is worse than none. So the generated +//! script is followed by a few lines per shell that ask this tool for the names +//! at the moment somebody presses Tab -- `claude-account list --names` is there +//! for that and prints nothing else. + +use anyhow::{Context, Result}; +use clap::CommandFactory; +use clap_complete::Shell; +use std::path::PathBuf; + +use crate::paths::{home, shorten, which}; +use crate::Cli; + +/// The subcommands whose argument is an account. The dynamic rules offer names +/// after each of them; anywhere else the generated script has the last word. +const TAKES_ACCOUNT: &str = "switch dir env url remove start stop restart logs install uninstall port"; + +/// The completion script for one shell. +pub fn script(shell: Shell) -> String { + let mut command = Cli::command(); + let name = command.get_name().to_string(); + let mut out = Vec::new(); + clap_complete::generate(shell, &mut command, name, &mut out); + let mut script = String::from_utf8_lossy(&out).to_string(); + script.push_str(&dynamic(shell)); + script +} + +/// Where a shell looks for a completion file it does not have to be told about. +pub fn destination(shell: Shell) -> Option { + let home = home(); + match shell { + Shell::Fish => Some(home.join(".config/fish/completions/claude-account.fish")), + Shell::Bash => Some(home.join(".local/share/bash-completion/completions/claude-account")), + Shell::Zsh => Some(home.join(".local/share/zsh/site-functions/_claude-account")), + _ => None, + } +} + +/// Write the completion file for one shell. +pub fn install(shell: Shell) -> Result { + let path = destination(shell) + .with_context(|| format!("{shell} has no place this tool knows to put a completion file"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", shorten(parent)))?; + } + std::fs::write(&path, script(shell)).with_context(|| format!("cannot write {}", shorten(&path)))?; + Ok(path) +} + +/// The shells on this machine that a completion file would reach. +/// +/// A shell nobody uses gets no file: the point of installing without being +/// asked which shell is that the answer is obvious, and writing into three +/// directories to be safe is not that. +pub fn present() -> Vec { + let home = home(); + let mut out = Vec::new(); + if home.join(".config/fish").is_dir() || which("fish").is_some() { + out.push(Shell::Fish); + } + if home.join(".bashrc").is_file() || which("bash").is_some() { + out.push(Shell::Bash); + } + if home.join(".zshrc").is_file() || which("zsh").is_some() { + out.push(Shell::Zsh); + } + out +} + +/// The lines that put live account names into the completion. +fn dynamic(shell: Shell) -> String { + match shell { + Shell::Fish => format!( + "\n# The accounts as they are right now, asked for when Tab is pressed.\n\ + complete -c claude-account -n '__fish_seen_subcommand_from {TAKES_ACCOUNT}' \ + -f -a '(claude-account list --names)'\n" + ), + // A wrapper rather than an edit of the generated function: the + // generated part stays exactly as clap wrote it, and the dynamic rule + // is one case statement that hands back to it for everything else. + Shell::Bash => format!( + "\n# The accounts as they are right now, asked for when Tab is pressed.\n\ + _claude_account_dynamic() {{\n\ + \x20 local previous=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n\ + \x20 case \" {TAKES_ACCOUNT} \" in\n\ + \x20 *\" $previous \"*)\n\ + \x20 mapfile -t COMPREPLY < <(compgen -W \"$(claude-account list --names \ + 2>/dev/null)\" -- \"${{COMP_WORDS[COMP_CWORD]}}\")\n\ + \x20 return 0 ;;\n\ + \x20 esac\n\ + \x20 _claude-account \"$@\"\n\ + }}\n\ + complete -F _claude_account_dynamic -o nosort -o bashdefault -o default claude-account\n" + ), + Shell::Zsh => format!( + "\n# The accounts as they are right now, asked for when Tab is pressed.\n\ + _claude_account_dynamic() {{\n\ + \x20 local previous=${{words[CURRENT-1]}}\n\ + \x20 if [[ \" {TAKES_ACCOUNT} \" == *\" $previous \"* ]]; then\n\ + \x20 local -a names\n\ + \x20 names=(${{(f)\"$(claude-account list --names 2>/dev/null)\"}})\n\ + \x20 if (( ${{#names}} )); then\n\ + \x20 _describe -t accounts account names\n\ + \x20 return\n\ + \x20 fi\n\ + \x20 fi\n\ + \x20 _claude-account \"$@\"\n\ + }}\n\ + compdef _claude_account_dynamic claude-account\n" + ), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_script_asks_for_the_names_at_completion_time() { + for shell in [Shell::Fish, Shell::Bash, Shell::Zsh] { + let script = script(shell); + assert!(script.contains("claude-account list --names"), "{shell} has no dynamic names"); + assert!(script.contains("switch"), "{shell} is missing the subcommands"); + } + } +} diff --git a/src/main.rs b/src/main.rs index f1955e1..f694db5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! whichever account was switched to last. mod account; +mod completions; mod config; mod headroom; mod paths; @@ -16,6 +17,7 @@ mod ui; use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; +use clap_complete::Shell; use crate::account::Account; use crate::config::Config; @@ -28,7 +30,7 @@ use crate::paths::shorten; about = "Switch the Claude account `claude` runs as, with a Headroom proxy per account.", disable_help_subcommand = true )] -struct Cli { +pub struct Cli { #[command(subcommand)] command: Option, } @@ -42,12 +44,35 @@ enum Command { verbose: bool, }, /// List the accounts, one per line - List, + List { + /// Print the names alone, for scripts and for tab completion + #[arg(long)] + names: bool, + }, /// Make an account the one `claude` runs as Switch { /// The account, as `list` names it account: String, }, + /// Register another account, with a profile and a proxy of its own + Add { + /// What to call it; also the directory, as `~/.claude-` + name: String, + /// A profile directory elsewhere + #[arg(long)] + dir: Option, + /// A port for its proxy; the next free one by default + #[arg(long)] + port: Option, + /// Make it the account `claude` runs as straight away + #[arg(long)] + switch: bool, + }, + /// Forget an account. Its profile directory is left where it is + Remove { + /// The account, as `list` names it + account: String, + }, /// Print the name of the active account Current, /// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR` @@ -86,6 +111,14 @@ enum Command { #[command(subcommand)] command: RouterCommand, }, + /// Tab completion, with the account names in it + Completions { + /// The shell; every shell on this machine when left out + shell: Option, + /// Write the file where the shell reads it, instead of printing it + #[arg(long)] + install: bool, + }, } #[derive(Subcommand)] @@ -165,9 +198,13 @@ fn run() -> Result<()> { } std::process::exit(1); } - Command::List => { + Command::List { names } => { let active = account::active(&config).map(|account| account.name); for account in account::all(&config) { + if names { + println!("{}", account.name); + continue; + } let marker = if Some(&account.name) == active.as_ref() { "*" } else { " " }; println!( "{marker} {:<10} {:<28} {}", @@ -179,6 +216,10 @@ fn run() -> Result<()> { Ok(()) } Command::Switch { account } => switch(&mut config, &account), + Command::Add { name, dir, port, switch: and_switch } => { + add(&mut config, &name, dir, port, and_switch) + } + Command::Remove { account } => remove(&mut config, &account), Command::Current => { let account = require_active(&config)?; println!("{}", account.name); @@ -195,22 +236,13 @@ fn run() -> Result<()> { println!("export ENQS_CLAUDE_PROFILE={}", account.dir.display()); Ok(()) } - Command::Url { name } => { - let name = match name { - Some(name) => name, - None => require_active(&config)?.name, - }; - println!("{}", router::url(&config, &name)); - if router::resolve(&config, &name).is_none() { - eprintln!("{} nothing is published as {name} right now", ui::yellow("note:")); - } - Ok(()) - } + Command::Url { name } => url(&config, name.as_deref()), Command::Setup => setup(&mut config), Command::Adopt => adopt(&mut config), Command::Shim { command } => shim_command(command), Command::Headroom { command } => headroom_command(&mut config, command), Command::Router { command } => router_command(&mut config, command), + Command::Completions { shell, install } => completions_command(shell, install), } } @@ -241,6 +273,111 @@ fn switch(config: &mut Config, name: &str) -> Result<()> { Ok(()) } +/// Register another account and give it everything an account has: a profile +/// directory, a port, a workspace and a proxy service of its own. +/// +/// Doing all of that here is the point. An account added by hand is an entry +/// in a file that nothing acts on until somebody remembers the three commands +/// that follow it, and a proxy that is missing is only noticed when a session +/// cannot connect. +fn add( + config: &mut Config, + name: &str, + dir: Option, + port: Option, + and_switch: bool, +) -> Result<()> { + let account = account::add(config, name, dir, port)?; + ui::done(format!("Account {} added.", ui::bold(&account.name))); + ui::field("profile", shorten(&account.dir)); + match headroom::binary() { + Some(_) => { + let installed = headroom::install(&account)?; + ui::field("proxy", format!("port {} ({})", account.port, installed.unit)); + ui::field("workspace", shorten(&account.workspace)); + } + None => { + ui::field( + "proxy", + ui::yellow(format!("port {} is reserved, but headroom is not installed", account.port)), + ); + ui::hint("pip install \"headroom-ai[proxy]\""); + } + } + if and_switch { + return switch(config, &account.name); + } + if account.email().is_none() { + ui::field("login", ui::yellow("none yet")); + ui::hint(format!("claude-account switch {} && claude /login", account.name)); + } + Ok(()) +} + +/// Forget an account. The profile directory and the workspace stay: this +/// command undoes a registration, not a login and not a year of projects. +fn remove(config: &mut Config, name: &str) -> Result<()> { + let account = account::forget(config, name)?; + headroom::uninstall(&account)?; + ui::done(format!("Account {} forgotten; its proxy service is gone.", ui::bold(&account.name))); + ui::field("profile", format!("{} is left where it is", shorten(&account.dir))); + ui::field("workspace", format!("{} is left where it is", shorten(&account.workspace))); + // Discovery looks at `~/.claude*` as well, so a profile that still holds a + // login comes back in the listing without its overrides. Better said than + // discovered in the next `list`. + if account.has_credentials() { + ui::field("note", "the profile still holds a login, so `list` keeps showing it"); + } + Ok(()) +} + +/// The address of a name: an account's proxy, or a jail's. +/// +/// An account's own label comes before a jail's, the way the router resolves +/// it, and the active account is meant when nothing is named. +fn url(config: &Config, name: Option<&str>) -> Result<()> { + let name = match name { + Some(name) => name.to_string(), + None => require_active(config)?.name, + }; + let Some(port) = router::resolve(config, &name) else { + return Err(anyhow!("nothing is called {name} right now. Run `claude-account router list`.")); + }; + println!("{}", router::label_url(config, &name)); + if !router::is_listening(port) { + eprintln!("{} nothing answers on port {port}, where that name points", ui::yellow("note:")); + } + Ok(()) +} + +fn completions_command(shell: Option, install: bool) -> Result<()> { + let shells: Vec = match shell { + Some(shell) => vec![shell], + None if install => completions::present(), + None => { + return Err(anyhow!( + "name a shell to print its completion, or use --install to write the files for \ + every shell on this machine" + )) + } + }; + if !install { + for shell in shells { + print!("{}", completions::script(shell)); + } + return Ok(()); + } + for shell in shells { + let path = completions::install(shell)?; + ui::done(format!("Completion for {shell} written to {}.", shorten(&path))); + if shell == Shell::Zsh { + ui::field("note", "that directory has to be in the fpath before compinit"); + } + } + ui::field("in this shell", "open a new one, or reload the completions"); + Ok(()) +} + fn setup(config: &mut Config) -> Result<()> { let (path, real) = shim::install(false)?; ui::done(format!("Wrapper written to {} (hands over to {}).", shorten(&path), shorten(&real))); @@ -257,14 +394,31 @@ fn setup(config: &mut Config) -> Result<()> { let (path, unit) = service::install(config)?; ui::done(format!("Router on port {}.", config.router_port())); ui::field("unit", format!("{unit} ({})", shorten(&path))); - if let Some(account) = account::active(config) { - ui::field("bare name", format!("{} goes to {}", router::bare_url(config), account.name)); + for account in account::all(config) { + ui::field(&account.name, router::label_url(config, &account.name)); + } + for shell in completions::present() { + let path = completions::install(shell)?; + ui::done(format!("Completion for {shell} in {}.", shorten(&path))); } let legacy = headroom::legacy_units(); if !legacy.is_empty() { ui::field("note", format!("{} still run beside these", legacy.join(", "))); ui::hint("claude-account adopt"); } + // A leftover alias is the one thing here that cannot be repaired from this + // process: it lives in somebody's shell configuration and beats every + // executable in the PATH, so all it can be given is a line and a location. + for shadow in shim::shadows() { + ui::field( + "in the way", + ui::yellow(format!( + "{}:{} defines `claude` itself and wins over the wrapper", + shorten(&shadow.file), + shadow.line + )), + ); + } Ok(()) } @@ -355,7 +509,7 @@ fn headroom_command(config: &mut Config, command: HeadroomCommand) -> Result<()> ui::field("unit", installed.unit); ui::field("environment", shorten(&installed.environment)); ui::field("workspace", shorten(&account.workspace)); - ui::field("address", router::url(config, &account.name)); + ui::field("address", address(config, &account)); if installed.routed { ui::field("settings.json", "now points at that proxy"); } @@ -406,7 +560,7 @@ fn headroom_command(config: &mut Config, command: HeadroomCommand) -> Result<()> let installed = headroom::install(&account)?; ui::done(format!("{} now runs its proxy on port {port}.", account.name)); ui::field("unit", installed.unit); - ui::field("address", router::url(config, &account.name)); + ui::field("address", address(config, &account)); Ok(()) } HeadroomCommand::Logs { account, lines } => { @@ -470,7 +624,7 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> { service::install(config)?; ui::field("service", "rewritten and restarted"); } - ui::field("addresses", router::bare_url(config)); + ui::field("addresses", format!("{} and every under it", router::bare_url(config))); Ok(()) } RouterCommand::List => { @@ -478,7 +632,7 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> { let answers = if router::is_listening(entry.port) { "" } else { " (nothing answers)" }; println!( "{:<40} {:>6} {}{answers}", - router::url(config, &entry.name), + router::name_url(config, &entry), entry.port, entry.source ); @@ -488,6 +642,12 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> { } } +/// Where one account's proxy is reached: its own name, whichever account is +/// active. +fn address(config: &Config, account: &Account) -> String { + router::label_url(config, &account.name) +} + /// Write what discovery worked out into the configuration, so that the router /// service and the wrapper see the same numbers as this run. fn remember(config: &mut Config, account: &Account) { diff --git a/src/router.rs b/src/router.rs index 51f703b..1f1f621 100644 --- a/src/router.rs +++ b/src/router.rs @@ -6,16 +6,27 @@ //! name, and one router on a fixed port forwards that name to the port of the //! moment: //! -//! * `headroom.localhost` -- the proxy of the account that is active right -//! now. The shortest address follows the switch, which is the point: there -//! is one address to keep in a bookmark and it is always the dashboard of -//! the account being worked in. //! * `.headroom.localhost` -- that one account's proxy, active or -//! not, for looking at the other one without switching. +//! not. Two accounts can have sessions open at the same time, and each of +//! them has to be able to name its own proxy: an address that follows the +//! switch would send a session of the other account to the wrong dashboard. //! * `.headroom.localhost` -- the proxy a jail started for itself. Those //! are published as run files by the `enqs-jail` plugin, and this router -//! reads the same directory in the same format, so a jail keeps its name -//! whichever of the two routers is serving the domain. +//! reads the same directory in the same format. +//! * `headroom.localhost` -- the proxy of the account that is active right +//! now, for a bookmark. Nothing this tool or the plugin prints uses it, +//! because which account it means changes under a session that is open. +//! +//! Accounts and jails share one set of labels, so a jail may be called like an +//! account. The account keeps the name then: it is there for good and a jail +//! only for one session, and a bookmark must not start opening a customer's +//! proxy because a jail of that name happens to be running. The jail answers +//! on its port instead, and `status` says why. +//! +//! What a name points at is only ever decided here. A client that wants to +//! print one asks `/names?port=` which name reaches that port, and gets +//! the address or a 404; it never builds a name itself and so never prints one +//! that goes somewhere else. //! //! `*.localhost` resolves to the loopback address without any configuration, //! in every browser and on this machine, so none of these names costs a line @@ -56,6 +67,8 @@ pub struct Published { /// What one name stands for, for the listing and the diagnostics. #[derive(Debug, Clone)] pub struct Name { + /// The label in front of the domain. Empty for the bare name, which is + /// what the active account answers on. pub name: String, pub port: u16, pub kind: Kind, @@ -65,10 +78,22 @@ pub struct Name { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { + /// The bare name, pointing at the account that is active right now. + Active, Account, Jail, } +impl Kind { + pub fn describe(&self) -> &'static str { + match self { + Kind::Active => "the active account", + Kind::Account => "account", + Kind::Jail => "jail", + } + } +} + /// A name as a DNS label: lower case, letters, digits and hyphens. /// /// A jail may be called anything, and a name that is not a label would simply @@ -113,53 +138,81 @@ pub fn loopback_port(url: &str) -> Option { } } -/// The URL a name is reachable at. +/// The URL a label is reachable at, an account's or a jail's. /// /// Port 80 is left out, because a browser and a terminal both assume it: that /// is what a router on the privileged port buys. -pub fn url(config: &Config, name: &str) -> String { - let host = format!("{}.{DOMAIN}", label(name)); +pub fn label_url(config: &Config, name: &str) -> String { + host_url(config, &format!("{}.{DOMAIN}", label(name))) +} + +/// The URL the domain itself is reachable at: the active account's proxy. +pub fn bare_url(config: &Config) -> String { + host_url(config, DOMAIN) +} + +fn host_url(config: &Config, host: &str) -> String { match config.router_port() { 80 => format!("http://{host}"), port => format!("http://{host}:{port}"), } } -/// The URL the domain itself is reachable at: the active account's proxy. -pub fn bare_url(config: &Config) -> String { - match config.router_port() { - 80 => format!("http://{DOMAIN}"), - port => format!("http://{DOMAIN}:{port}"), - } -} - -/// Every name that resolves right now. -/// -/// Jails come first: a jail may be called `work` as well, and the proxy a -/// session started for itself is the one that session means. +/// Every name that resolves right now: the bare one, the accounts, then the +/// jails that do not share a label with an account. pub fn names(config: &Config) -> Vec { - let mut out: Vec = published() - .into_iter() - .map(|entry| Name { name: entry.name, port: entry.port, kind: Kind::Jail, source: entry.jail }) - .collect(); + let mut out = Vec::new(); + if let Some(account) = account::active(config) { + out.push(Name { + name: String::new(), + port: account.port, + kind: Kind::Active, + source: crate::paths::shorten(&account.dir), + }); + } for account in account::all(config) { - if out.iter().any(|name| name.name == account.name) { + let name = label(&account.name); + if out.iter().any(|entry| entry.kind != Kind::Active && entry.name == name) { continue; } out.push(Name { - name: account.name, + name, port: account.port, kind: Kind::Account, source: crate::paths::shorten(&account.dir), }); } + for entry in published() { + if out.iter().any(|name| name.kind != Kind::Active && name.name == entry.name) { + continue; + } + out.push(Name { name: entry.name, port: entry.port, kind: Kind::Jail, source: entry.jail }); + } out } -/// The port a name resolves to right now. +/// The jails that are running but have no name, because an account holds the +/// label they would have had. +pub fn shadowed_jails(config: &Config) -> Vec { + let accounts: Vec = account::all(config).iter().map(|account| label(&account.name)).collect(); + published().into_iter().filter(|entry| accounts.contains(&entry.name)).collect() +} + +/// The URL one of those names is reachable at. +pub fn name_url(config: &Config, name: &Name) -> String { + match name.kind { + Kind::Active => bare_url(config), + Kind::Account | Kind::Jail => label_url(config, &name.name), + } +} + +/// The port a label resolves to right now. pub fn resolve(config: &Config, name: &str) -> Option { let wanted = label(name); - names(config).into_iter().find(|entry| entry.name == wanted).map(|entry| entry.port) + names(config) + .into_iter() + .find(|entry| entry.kind != Kind::Active && entry.name == wanted) + .map(|entry| entry.port) } /// The port the domain itself resolves to: the proxy of the active account. @@ -167,6 +220,19 @@ pub fn resolve_bare(config: &Config) -> Option { account::active(config).map(|account| account.port) } +/// The name that reaches a port, as an address. +/// +/// Never the bare domain: that one follows the switch, and the session that +/// asks keeps its account when the switch moves on. `None` when no label +/// reaches the port, which is the answer for a jail whose name an account +/// holds, and for a port nothing here knows. +pub fn lookup(config: &Config, port: u16) -> Option { + names(config) + .into_iter() + .find(|entry| entry.kind != Kind::Active && entry.port == port) + .map(|entry| name_url(config, &entry)) +} + /// Every jail proxy whose session is still running. pub fn published() -> Vec { let Ok(entries) = std::fs::read_dir(runtime_dir()) else { return Vec::new() }; @@ -244,9 +310,23 @@ fn handle(mut client: TcpStream) -> Result<()> { let Some(host) = header(&head, "host") else { return answer(&mut client, 400, "No Host header, so there is no name to resolve."); }; - let name = host_label(&host); - let bare = name.is_empty() || name == "headroom"; + let Some(name) = host_label(&host) else { + return answer( + &mut client, + 404, + &format!("{host} is not a name under {DOMAIN}. The list is at http://{DOMAIN}/names."), + ); + }; + let bare = name.is_empty(); if bare && target(&head).as_deref() == Some("/names") { + // A client that wants to print an address asks here which one reaches + // its port, rather than working a name out for itself. + if let Some(wanted) = query(&head, "port") { + return match wanted.parse::().ok().and_then(|port| lookup(&config, port)) { + Some(url) => answer_plain(&mut client, 200, &url), + None => answer(&mut client, 404, &format!("No name reaches port {wanted}.")), + }; + } return answer_html(&mut client, &index(&config)); } let port = if bare { resolve_bare(&config) } else { resolve(&config, &name) }; @@ -312,13 +392,37 @@ fn target(head: &[u8]) -> Option { Some(path.split('?').next().unwrap_or(path).to_string()) } -/// The label in front of `.headroom.localhost` in a Host header. -fn host_label(host: &str) -> String { - let host = host.split(':').next().unwrap_or(host); - let host = host.strip_suffix('.').unwrap_or(host); - let front = host.strip_suffix(".localhost").unwrap_or(host); - let front = front.strip_suffix(".headroom").unwrap_or(front); - sanitize(front) +/// One parameter of the request's query. +fn query(head: &[u8], wanted: &str) -> Option { + let text = String::from_utf8_lossy(head); + let line = text.lines().next()?; + let (_, query) = line.split_whitespace().nth(1)?.split_once('?')?; + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == wanted).then(|| value.to_string()) + }) +} + +/// The label in front of `.headroom.localhost` in a Host header: empty for +/// the domain itself, and `None` for a host that is not under the domain. +/// +/// Strict, because a name that is only nearly right must not reach a proxy: +/// `localhost` or `127.0.0.1` would otherwise become a label, and so would +/// every level of `a.b.headroom.localhost`. +fn host_label(host: &str) -> Option { + if host.starts_with('[') { + return None; + } + let host = host.split(':').next().unwrap_or(host).trim_end_matches('.').to_ascii_lowercase(); + if host == DOMAIN { + return Some(String::new()); + } + let front = host.strip_suffix(&format!(".{DOMAIN}"))?; + if front.contains('.') { + return None; + } + let name = sanitize(front); + (!name.is_empty()).then_some(name) } fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> { @@ -333,6 +437,19 @@ fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> { Ok(()) } +/// A one-line answer for a program, without the status repeated in the body. +fn answer_plain(stream: &mut TcpStream, status: u16, line: &str) -> Result<()> { + let body = format!("{line}\n"); + let head = format!( + "HTTP/1.1 {status} \r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(head.as_bytes())?; + stream.write_all(body.as_bytes())?; + stream.flush()?; + Ok(()) +} + fn answer_html(stream: &mut TcpStream, body: &str) -> Result<()> { let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -349,12 +466,8 @@ fn index(config: &Config) -> String { let active = account::active(config).map(|account| account.name).unwrap_or_default(); let mut rows = String::new(); for entry in names(config) { - let kind = match entry.kind { - Kind::Account if entry.name == active => "account, active", - Kind::Account => "account", - Kind::Jail => "jail", - }; - let address = url(config, &entry.name); + let kind = entry.kind.describe(); + let address = name_url(config, &entry); rows.push_str(&format!( "{address}{kind}{}{}", entry.port, @@ -367,7 +480,8 @@ fn index(config: &Config) -> String { td,th{{padding:.25rem 1rem .25rem 0;text-align:left;vertical-align:top}}\ code{{background:#eee;padding:.1rem .3rem;border-radius:3px}}\

headroom.localhost

\ -

The bare name goes to the proxy of the active account, {active}.

\ +

Every account and every jail has a name of its own. The bare name goes to the proxy of \ + the active account, {active}, and follows the switch.

\ {rows}
AddressWhatPortWhere
", active = html(&active), ) @@ -383,9 +497,34 @@ mod tests { #[test] fn a_host_header_gives_the_label() { - assert_eq!(host_label("work.headroom.localhost"), "work"); - assert_eq!(host_label("headroom.localhost:80"), "headroom"); - assert_eq!(host_label("my_jail.headroom.localhost"), "my-jail"); + assert_eq!(host_label("work.headroom.localhost").as_deref(), Some("work")); + assert_eq!(host_label("Work.Headroom.Localhost.:80").as_deref(), Some("work")); + assert_eq!(host_label("my_jail.headroom.localhost").as_deref(), Some("my-jail")); + assert_eq!(host_label("headroom.headroom.localhost").as_deref(), Some("headroom")); + } + + #[test] + fn the_domain_itself_is_the_bare_name() { + assert_eq!(host_label("headroom.localhost").as_deref(), Some("")); + assert_eq!(host_label("headroom.localhost:80").as_deref(), Some("")); + } + + #[test] + fn a_host_outside_the_domain_is_no_name() { + assert_eq!(host_label("localhost"), None); + assert_eq!(host_label("127.0.0.1:80"), None); + assert_eq!(host_label("[::1]:80"), None); + assert_eq!(host_label("a.b.headroom.localhost"), None); + assert_eq!(host_label("evilheadroom.localhost"), None); + assert_eq!(host_label("-.headroom.localhost"), None); + } + + #[test] + fn the_port_is_read_from_the_query() { + let head = b"GET /names?x=1&port=8788 HTTP/1.1\r\nHost: headroom.localhost\r\n\r\n"; + assert_eq!(query(head, "port").as_deref(), Some("8788")); + assert_eq!(target(head).as_deref(), Some("/names")); + assert_eq!(query(b"GET /names HTTP/1.1\r\n\r\n", "port"), None); } #[test] diff --git a/src/shim.rs b/src/shim.rs index 7c8273e..67f85b1 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -24,6 +24,69 @@ pub fn path() -> PathBuf { home().join(".local/bin/claude") } +/// A line in a shell's startup file that defines `claude` itself. +/// +/// This is the one way to be in the right profile and still land in the wrong +/// account, and it is invisible from here otherwise: an alias or a function +/// beats every executable in the `PATH`, and one that sets `CLAUDE_CONFIG_DIR` +/// beats the wrapper twice over, because the wrapper leaves a value that is +/// already set alone. The machine this tool was written on had exactly that, +/// left over from before there was a switch, and every switch looked ignored. +#[derive(Debug, Clone)] +pub struct Shadow { + pub file: PathBuf, + pub line: usize, + pub text: String, +} + +/// The startup files that are read often enough to hide a definition in. +fn startup_files() -> Vec { + let home = home(); + let mut files = vec![ + home.join(".bashrc"), + home.join(".bash_profile"), + home.join(".zshrc"), + home.join(".profile"), + home.join(".config/fish/config.fish"), + ]; + if let Ok(entries) = std::fs::read_dir(home.join(".config/fish/conf.d")) { + files.extend(entries.flatten().map(|entry| entry.path())); + } + files +} + +/// Every definition of `claude` a shell would pick up instead of the wrapper. +pub fn shadows() -> Vec { + let mut out = Vec::new(); + for file in startup_files() { + let Ok(body) = std::fs::read_to_string(&file) else { continue }; + for (index, line) in body.lines().enumerate() { + let text = line.trim(); + if text.starts_with('#') { + continue; + } + if defines_claude(text) { + out.push(Shadow { file: file.clone(), line: index + 1, text: text.to_string() }); + } + } + } + out +} + +/// Whether a line defines the `claude` command, in any of the three shells. +/// +/// `claude-work` and the like are deliberately not matched: a second name is +/// somebody's shortcut and shadows nothing. +fn defines_claude(line: &str) -> bool { + let defined = |keyword: &str| -> bool { + let Some(rest) = line.strip_prefix(keyword) else { return false }; + let rest = rest.trim_start(); + let name = rest.split(|character: char| character.is_whitespace() || character == '=').next(); + name.map(|name| name.trim_matches(['"', '\''])) == Some("claude") + }; + defined("alias ") || defined("function ") || line.starts_with("claude()") +} + /// What the wrapper looks like right now. #[derive(Debug, Clone)] pub struct State { @@ -122,3 +185,23 @@ fn make_executable(path: &Path) -> Result<()> { std::fs::set_permissions(path, permissions)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_definition_of_claude_is_recognised_in_every_shell() { + assert!(defines_claude("alias claude=\"CLAUDE_CONFIG_DIR=~/.claude command claude\"")); + assert!(defines_claude("alias claude='claude --verbose'")); + assert!(defines_claude("function claude --description 'x'")); + assert!(defines_claude("claude() { command claude \"$@\"; }")); + } + + #[test] + fn another_command_is_not_a_shadow() { + assert!(!defines_claude("alias claude-work=\"CLAUDE_CONFIG_DIR=~/.claude-work claude\"")); + assert!(!defines_claude("alias cc=claude")); + assert!(!defines_claude("function claudia")); + } +} diff --git a/src/status.rs b/src/status.rs index cc9e900..0bd58d2 100644 --- a/src/status.rs +++ b/src/status.rs @@ -12,7 +12,7 @@ use crate::account::{self, Account}; use crate::config::Config; use crate::headroom; use crate::paths::shorten; -use crate::router::{self, Kind}; +use crate::router; use crate::service; use crate::shim; use crate::systemd; @@ -68,7 +68,7 @@ fn account_section( let plan = account.plan().map(|plan| format!(" ({plan})")).unwrap_or_default(); println!(" {marker} {:<10} {}{}", ui::bold(&account.name), who, ui::dim(plan)); ui::field("profile", shorten(&account.dir)); - ui::field("proxy", format!("{} on port {}", router::url(config, &account.name), account.port)); + ui::field("proxy", proxy_line(config, account)); ui::field("plugins", plugins_line(account)); match account.token_minutes_left() { Some(minutes) if minutes < 0 => { @@ -122,6 +122,12 @@ fn account_section( let _ = config; } +/// Where an account's proxy is reached. Every account has a name of its own, +/// so a session of either one can show its dashboard while both are open. +fn proxy_line(config: &Config, account: &Account) -> String { + format!("{} on port {}", router::label_url(config, &account.name), account.port) +} + fn plugins_line(account: &Account) -> String { let plugins = account.plugins(); match plugins.len() { @@ -199,6 +205,22 @@ fn command_section(active: Option<&Account>, problems: &mut Vec) { ui::field("this shell", format!("CLAUDE_CONFIG_DIR={explicit}")); } } + // An alias or a shell function called `claude` is found before anything in + // the PATH, and one that sets CLAUDE_CONFIG_DIR is never overruled by the + // wrapper, which leaves a value that is already set alone. Every switch + // then looks ignored, and nothing else in this report would show why. + for shadow in shim::shadows() { + ui::warn("shell", format!("{}:{} defines `claude`", shorten(&shadow.file), shadow.line)); + ui::field("line", ui::dim(&shadow.text)); + problems.push(Problem::new( + format!( + "{}:{} defines `claude` in the shell, which wins over the wrapper and pins the account", + shorten(&shadow.file), + shadow.line + ), + Some("remove that line; `claude` follows the switch by itself"), + )); + } } fn proxy_section(accounts: &[Account], active: Option<&Account>, problems: &mut Vec, verbose: bool) { @@ -345,20 +367,22 @@ fn router_section(config: &Config, active: Option<&Account>, problems: &mut Vec< match active { Some(account) => ui::field( "bare name", - format!("{} goes to {} on port {}", router::bare_url(config), account.name, account.port), + format!( + "{} follows the switch, now to {} on port {}", + router::bare_url(config), + account.name, + account.port + ), ), None => ui::field("bare name", ui::yellow("goes nowhere: no account is active")), } for entry in router::names(config) { - let kind = match entry.kind { - Kind::Account => "account", - Kind::Jail => "jail", - }; + let kind = entry.kind.describe(); let answers = router::is_listening(entry.port); let mark = if answers { ui::green("✓") } else { ui::yellow("!") }; println!( " {mark} {:<38} {}", - router::url(config, &entry.name), + router::name_url(config, &entry), ui::dim(format!("{kind}, port {} — {}", entry.port, entry.source)) ); } @@ -370,10 +394,22 @@ fn jail_section(config: &Config, problems: &mut Vec) { return; } ui::heading("Jails with a proxy of their own"); + let shadowed: Vec = router::shadowed_jails(config).into_iter().map(|jail| jail.name).collect(); for jail in jails { let answers = router::is_listening(jail.port); - if answers { - ui::ok(&jail.name, format!("{} — {}", router::url(config, &jail.name), jail.jail)); + if answers && shadowed.contains(&jail.name) { + // The label is the account's, so printing it would hand out the + // address of another proxy. The port is the one right answer. + ui::warn(&jail.name, format!("http://127.0.0.1:{} — {}", jail.port, jail.jail)); + problems.push(Problem::new( + format!( + "the jail {} has no name: the account {} holds that label, so the jail answers on port {} only", + jail.name, jail.name, jail.port + ), + Some("register the jail under another name with `enqs-jail register --name --force`"), + )); + } else if answers { + ui::ok(&jail.name, format!("{} — {}", router::label_url(config, &jail.name), jail.jail)); } else { ui::bad(&jail.name, format!("published on port {} but nothing answers", jail.port)); problems.push(Problem::new(