use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use crate::config::{GREEN, RED, RESET, YELLOW};
const EMBEDDED_EXTENSION: &str = include_str!("../contrib/pi/paddington.ts");
const VERSION_HEADER: &str = concat!(
"// paddington v",
env!("CARGO_PKG_VERSION"),
" — managed by `paddington setup`, do not edit\n"
);
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
}
fn tildefy(path: &Path) -> String {
if let Some(home) = home_dir()
&& let Ok(rest) = path.strip_prefix(&home) {
return format!("~/{}", rest.display());
}
path.display().to_string()
}
fn resolve_binary_path() -> String {
std::env::current_exe()
.ok()
.and_then(|p| fs::canonicalize(p).ok())
.map(|p| tildefy(&p))
.unwrap_or_else(|| "~/.cargo/bin/paddington".into())
}
fn claude_settings_path() -> Option<PathBuf> {
home_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn pi_base_dir() -> Option<PathBuf> {
home_dir().map(|h| h.join(".pi"))
}
fn managed_extension_dir() -> Option<PathBuf> {
let data_dir = std::env::var("XDG_DATA_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home_dir().map(|h| h.join(".local").join("share")));
data_dir.map(|d| d.join("paddington").join("extensions"))
}
fn config_toml_path() -> PathBuf {
PathBuf::from(crate::config::config_path())
}
fn confirm(prompt: &str, force: bool) -> bool {
if force {
return true;
}
if !io::stdin().is_terminal() {
return false;
}
eprint!("{prompt} [y/N] ");
io::stderr().flush().ok();
let mut answer = String::new();
if io::stdin().read_line(&mut answer).is_err() {
return false;
}
matches!(answer.trim(), "y" | "Y" | "yes" | "YES")
}
fn setup_claude(force: bool, uninstall: bool) {
let Some(path) = claude_settings_path() else {
println!("Claude Code: {RED}✗{RESET} could not determine home directory");
return;
};
if !path.parent().is_some_and(|p| p.exists()) {
println!("Claude Code: {RED}✗{RESET} ~/.claude/ not found, skipping");
return;
}
if uninstall {
uninstall_claude(&path);
return;
}
let mut settings: serde_json::Value = if path.exists() {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
println!(
"Claude Code: {RED}✗{RESET} malformed settings.json: {e}"
);
return;
}
},
Err(e) => {
println!("Claude Code: {RED}✗{RESET} could not read settings.json: {e}");
return;
}
}
} else {
serde_json::json!({})
};
if settings.get("statusLine").is_some() && !force {
let existing = serde_json::to_string_pretty(settings.get("statusLine").unwrap())
.unwrap_or_default();
eprintln!("Claude Code: existing statusLine:\n{existing}");
if !confirm("Overwrite?", false) {
println!(
"Claude Code: · already configured (use --force to overwrite)"
);
return;
}
}
let binary_path = resolve_binary_path();
settings["statusLine"] = serde_json::json!({
"type": "command",
"command": binary_path,
"padding": 3
});
match write_json_file(&path, &settings) {
Ok(()) => println!(
"Claude Code: {GREEN}✓{RESET} configured ({})",
tildefy(&path)
),
Err(e) => println!("Claude Code: {RED}✗{RESET} failed to write: {e}"),
}
}
fn uninstall_claude(path: &Path) {
if !path.exists() {
println!("Claude Code: · nothing to remove");
return;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
println!("Claude Code: {RED}✗{RESET} could not read settings.json: {e}");
return;
}
};
let mut settings: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
println!("Claude Code: {RED}✗{RESET} malformed settings.json: {e}");
return;
}
};
if let Some(obj) = settings.as_object_mut() {
if obj.remove("statusLine").is_some() {
match write_json_file(path, &settings) {
Ok(()) => println!(
"Claude Code: {GREEN}✓{RESET} statusLine removed from {}",
tildefy(path)
),
Err(e) => println!("Claude Code: {RED}✗{RESET} failed to write: {e}"),
}
} else {
println!("Claude Code: · no statusLine key found, nothing to remove");
}
}
}
fn write_json_file(path: &Path, value: &serde_json::Value) -> io::Result<()> {
let json = serde_json::to_string_pretty(value)
.map_err(io::Error::other)?;
let json = if json.ends_with('\n') {
json
} else {
format!("{json}\n")
};
fs::write(path, json)
}
fn discover_pi_profiles(pi_dir: &Path) -> Vec<String> {
let mut profiles = Vec::new();
let entries = match fs::read_dir(pi_dir) {
Ok(e) => e,
Err(_) => return profiles,
};
let markers = ["extensions", "npm", "sessions"];
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let dir = entry.path();
let has_marker = markers.iter().any(|m| dir.join(m).is_dir());
if has_marker {
profiles.push(name);
}
}
profiles.sort();
profiles
}
fn setup_pi(profile: Option<&str>, force: bool, uninstall: bool) {
let Some(pi_dir) = pi_base_dir() else {
println!("Pi: {RED}✗{RESET} could not determine home directory");
return;
};
if !pi_dir.exists() {
println!("Pi: {RED}✗{RESET} ~/.pi/ not found, skipping");
return;
}
let profiles: Vec<String> = if let Some(name) = profile {
let profile_dir = pi_dir.join(name);
if !profile_dir.exists() {
println!(
"Pi: {RED}✗{RESET} profile '{name}' not found at {}",
tildefy(&profile_dir)
);
return;
}
vec![name.to_string()]
} else {
let discovered = discover_pi_profiles(&pi_dir);
if discovered.is_empty() {
println!("Pi: {YELLOW}·{RESET} no agent profiles found in ~/.pi/");
return;
}
discovered
};
if uninstall {
uninstall_pi(&pi_dir, &profiles);
return;
}
let Some(managed_dir) = managed_extension_dir() else {
println!("Pi: {RED}✗{RESET} could not determine data directory");
return;
};
if let Err(e) = fs::create_dir_all(&managed_dir) {
println!("Pi: {RED}✗{RESET} could not create {}: {e}", tildefy(&managed_dir));
return;
}
let managed_file = managed_dir.join("paddington.ts");
let content = format!("{VERSION_HEADER}{EMBEDDED_EXTENSION}");
if let Err(e) = fs::write(&managed_file, &content) {
println!("Pi: {RED}✗{RESET} could not write {}: {e}", tildefy(&managed_file));
return;
}
let mut any_installed = false;
for profile_name in &profiles {
let ext_dir = pi_dir.join(profile_name).join("extensions");
if let Err(e) = fs::create_dir_all(&ext_dir) {
println!(
"Pi ({profile_name}): {RED}✗{RESET} could not create extensions dir: {e}"
);
continue;
}
let symlink_path = ext_dir.join("paddington.ts");
let result = install_symlink(&symlink_path, &managed_file, profile_name, force);
match result {
SymlinkResult::Created | SymlinkResult::Replaced => {
println!(
"Pi ({profile_name}): {GREEN}✓{RESET} extension installed (symlink → {})",
tildefy(&managed_file)
);
any_installed = true;
}
SymlinkResult::AlreadyCurrent => {
println!(
"Pi ({profile_name}): {YELLOW}·{RESET} already configured"
);
any_installed = true;
}
SymlinkResult::Skipped => {
println!(
"Pi ({profile_name}): {YELLOW}·{RESET} skipped (use --force to overwrite)"
);
}
SymlinkResult::Error(e) => {
println!("Pi ({profile_name}): {RED}✗{RESET} {e}");
}
}
}
if !any_installed && profiles.len() == 1 {
}
}
enum SymlinkResult {
Created,
Replaced,
AlreadyCurrent,
Skipped,
Error(String),
}
fn install_symlink(
symlink_path: &Path,
managed_file: &Path,
profile_name: &str,
force: bool,
) -> SymlinkResult {
match fs::read_link(symlink_path) {
Ok(target) => {
if target == managed_file {
return SymlinkResult::AlreadyCurrent;
}
if !target.exists() {
let _ = fs::remove_file(symlink_path);
return match std::os::unix::fs::symlink(managed_file, symlink_path) {
Ok(()) => SymlinkResult::Created,
Err(e) => SymlinkResult::Error(format!("could not create symlink: {e}")),
};
}
let target_display = tildefy(&target);
eprintln!(
"Pi ({profile_name}): paddington.ts → {target_display}"
);
if !confirm(
" Overwrite with managed symlink?",
force,
) {
return SymlinkResult::Skipped;
}
if let Err(e) = fs::remove_file(symlink_path) {
return SymlinkResult::Error(format!("could not remove old symlink: {e}"));
}
match std::os::unix::fs::symlink(managed_file, symlink_path) {
Ok(()) => SymlinkResult::Replaced,
Err(e) => SymlinkResult::Error(format!("could not create symlink: {e}")),
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if symlink_path.exists() {
eprintln!(
"Pi ({profile_name}): paddington.ts exists as a regular file"
);
if !confirm(
" Replace with managed symlink?",
force,
) {
return SymlinkResult::Skipped;
}
if let Err(e) = fs::remove_file(symlink_path) {
return SymlinkResult::Error(format!("could not remove file: {e}"));
}
}
match std::os::unix::fs::symlink(managed_file, symlink_path) {
Ok(()) => SymlinkResult::Created,
Err(e) => SymlinkResult::Error(format!("could not create symlink: {e}")),
}
}
Err(_) => {
let _ = fs::remove_file(symlink_path);
match std::os::unix::fs::symlink(managed_file, symlink_path) {
Ok(()) => SymlinkResult::Created,
Err(e) => SymlinkResult::Error(format!("could not create symlink: {e}")),
}
}
}
}
fn uninstall_pi(pi_dir: &Path, profiles: &[String]) {
let mut removed_any = false;
for profile_name in profiles {
let symlink_path = pi_dir
.join(profile_name)
.join("extensions")
.join("paddington.ts");
match fs::read_link(&symlink_path) {
Ok(_) => {
if let Err(e) = fs::remove_file(&symlink_path) {
println!(
"Pi ({profile_name}): {RED}✗{RESET} could not remove symlink: {e}"
);
} else {
println!(
"Pi ({profile_name}): {GREEN}✓{RESET} extension removed"
);
removed_any = true;
}
}
Err(_) => {
if symlink_path.exists() {
if let Err(e) = fs::remove_file(&symlink_path) {
println!(
"Pi ({profile_name}): {RED}✗{RESET} could not remove file: {e}"
);
} else {
println!(
"Pi ({profile_name}): {GREEN}✓{RESET} extension removed"
);
removed_any = true;
}
} else {
println!(
"Pi ({profile_name}): {YELLOW}·{RESET} no extension found"
);
}
}
}
}
if removed_any
&& let Some(managed_dir) = managed_extension_dir() {
let managed_file = managed_dir.join("paddington.ts");
if managed_file.exists() {
let _ = fs::remove_file(&managed_file);
}
}
}
fn setup_config() {
let path = config_toml_path();
if path.exists() {
println!(
"Config: {YELLOW}·{RESET} already exists, not modified ({})",
tildefy(&path)
);
return;
}
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent) {
println!("Config: {RED}✗{RESET} could not create directory: {e}");
return;
}
let default_config = "\
# Paddington status line configuration
# See: https://github.com/cebarks/paddington
# [budget]
# monthly_limit = 200.0
# [format]
# template = \"...\" # Uses built-in default template when omitted
";
match fs::write(&path, default_config) {
Ok(()) => println!(
"Config: {GREEN}✓{RESET} created ({})",
tildefy(&path)
),
Err(e) => println!("Config: {RED}✗{RESET} could not write: {e}"),
}
}
pub fn run_setup(
claude: bool,
pi: bool,
profile: Option<String>,
force: bool,
uninstall: bool,
) {
let do_claude = !pi || claude; let do_pi = !claude || pi;
if do_claude {
if uninstall {
setup_claude(force, true);
} else {
setup_claude(force, false);
}
}
if do_pi {
setup_pi(profile.as_deref(), force, uninstall);
}
if !uninstall {
setup_config();
}
if !uninstall {
println!();
println!("Restart your coding agent to activate.");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
fn make_tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join("paddington-setup-test")
.join(name);
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn tildefy_replaces_home() {
if let Some(home) = home_dir() {
let path = home.join("foo").join("bar");
assert_eq!(tildefy(&path), "~/foo/bar");
}
}
#[test]
fn tildefy_leaves_non_home_paths() {
let path = PathBuf::from("/usr/bin/paddington");
assert_eq!(tildefy(&path), "/usr/bin/paddington");
}
#[test]
fn version_header_contains_version() {
assert!(VERSION_HEADER.contains(env!("CARGO_PKG_VERSION")));
assert!(VERSION_HEADER.starts_with("// paddington v"));
}
#[test]
fn embedded_extension_is_nonempty() {
assert!(!EMBEDDED_EXTENSION.is_empty());
assert!(EMBEDDED_EXTENSION.contains("ExtensionContext"));
}
#[test]
fn discover_profiles_finds_agent_dirs() {
let dir = make_tmpdir("discover-profiles");
fs::create_dir_all(dir.join("agent").join("extensions")).unwrap();
fs::create_dir_all(dir.join("agent-prodsec").join("npm")).unwrap();
fs::create_dir_all(dir.join(".internal").join("extensions")).unwrap();
fs::create_dir_all(dir.join("random-dir")).unwrap();
let profiles = discover_pi_profiles(&dir);
assert_eq!(profiles, vec!["agent", "agent-prodsec"]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_profiles_empty_dir() {
let dir = make_tmpdir("discover-empty");
let profiles = discover_pi_profiles(&dir);
assert!(profiles.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_symlink_creates_new() {
let dir = make_tmpdir("symlink-create");
let target = dir.join("managed.ts");
fs::write(&target, "content").unwrap();
let link = dir.join("paddington.ts");
let result = install_symlink(&link, &target, "test", false);
assert!(matches!(result, SymlinkResult::Created));
assert_eq!(fs::read_link(&link).unwrap(), target);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_symlink_already_current() {
let dir = make_tmpdir("symlink-current");
let target = dir.join("managed.ts");
fs::write(&target, "content").unwrap();
let link = dir.join("paddington.ts");
symlink(&target, &link).unwrap();
let result = install_symlink(&link, &target, "test", false);
assert!(matches!(result, SymlinkResult::AlreadyCurrent));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_symlink_dangling_recreates() {
let dir = make_tmpdir("symlink-dangling");
let target = dir.join("managed.ts");
fs::write(&target, "content").unwrap();
let old_target = dir.join("deleted.ts");
let link = dir.join("paddington.ts");
symlink(&old_target, &link).unwrap();
let result = install_symlink(&link, &target, "test", false);
assert!(matches!(result, SymlinkResult::Created));
assert_eq!(fs::read_link(&link).unwrap(), target);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_symlink_force_replaces() {
let dir = make_tmpdir("symlink-force");
let target = dir.join("managed.ts");
fs::write(&target, "content").unwrap();
let old_target = dir.join("dotfiles.ts");
fs::write(&old_target, "old").unwrap();
let link = dir.join("paddington.ts");
symlink(&old_target, &link).unwrap();
let result = install_symlink(&link, &target, "test", true);
assert!(matches!(result, SymlinkResult::Replaced));
assert_eq!(fs::read_link(&link).unwrap(), target);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_symlink_regular_file_force_replaces() {
let dir = make_tmpdir("symlink-file-force");
let target = dir.join("managed.ts");
fs::write(&target, "content").unwrap();
let link = dir.join("paddington.ts");
fs::write(&link, "manual copy").unwrap();
let result = install_symlink(&link, &target, "test", true);
assert!(matches!(result, SymlinkResult::Created));
assert_eq!(fs::read_link(&link).unwrap(), target);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_json_preserves_order() {
let json = r#"{"zebra": 1, "alpha": 2, "middle": 3}"#;
let value: serde_json::Value = serde_json::from_str(json).unwrap();
let output = serde_json::to_string_pretty(&value).unwrap();
let zebra_pos = output.find("zebra").unwrap();
let alpha_pos = output.find("alpha").unwrap();
assert!(zebra_pos < alpha_pos, "preserve_order should maintain insertion order");
}
#[test]
fn claude_settings_merge_preserves_keys() {
let dir = make_tmpdir("claude-merge");
let settings_path = dir.join("settings.json");
let original = serde_json::json!({
"env": {"key": "val"},
"model": "claude-sonnet-4-20250514"
});
write_json_file(&settings_path, &original).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let mut settings: serde_json::Value = serde_json::from_str(&content).unwrap();
settings["statusLine"] = serde_json::json!({
"type": "command",
"command": "~/.cargo/bin/paddington",
"padding": 3
});
write_json_file(&settings_path, &settings).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(result.get("env").is_some());
assert!(result.get("model").is_some());
assert!(result.get("statusLine").is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn claude_settings_uninstall_removes_key() {
let dir = make_tmpdir("claude-uninstall");
let settings_path = dir.join("settings.json");
let original = serde_json::json!({
"model": "claude-sonnet-4-20250514",
"statusLine": {"type": "command", "command": "paddington"}
});
write_json_file(&settings_path, &original).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let mut settings: serde_json::Value = serde_json::from_str(&content).unwrap();
if let Some(obj) = settings.as_object_mut() {
obj.remove("statusLine");
}
write_json_file(&settings_path, &settings).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(result.get("model").is_some());
assert!(result.get("statusLine").is_none());
let _ = fs::remove_dir_all(&dir);
}
}