diff --git a/.gitignore b/.gitignore index e345e550..13afd5a2 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ project_overhaul_prompt.md .wrangler/ # Local runtime state +.codewhale/ .deepseek/ **/session_*.json *.db diff --git a/Cargo.lock b/Cargo.lock index 15e1eb61..bde11a3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -977,6 +977,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "codewhale-config", "codewhale-secrets", "codewhale-tools", "colored", diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b1d2016b..938dc616 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1120,15 +1120,21 @@ fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfi } } -/// Load a project-level config from `$WORKSPACE/.deepseek/config.toml`. -/// Returns `None` if the file doesn't exist or can't be parsed. +/// Load a project-level config from the workspace. +/// +/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to +/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility. +/// Returns `None` if neither file exists or can't be parsed. pub fn load_project_config(workspace: &Path) -> Option { - let path = workspace.join(".deepseek").join(CONFIG_FILE_NAME); - if !path.exists() { - return None; + for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] { + let path = workspace.join(dir).join(CONFIG_FILE_NAME); + if path.exists() { + if let Ok(raw) = fs::read_to_string(&path) { + return toml::from_str(&raw).ok(); + } + } } - let raw = fs::read_to_string(&path).ok()?; - toml::from_str(&raw).ok() + None } fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { @@ -1442,9 +1448,80 @@ pub fn default_secrets() -> &'static Secrets { }) } +// ── CodeWhale state root (v0.8.44) ────────────────────────────────── +// +// v0.8.44 migrates product-owned app state from ~/.deepseek/ to +// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback. +// New installs write to ~/.codewhale/. Existing installs with only +// ~/.deepseek/ continue working without data loss. + +/// Canonical CodeWhale app directory name under $HOME. +pub const CODEWHALE_APP_DIR: &str = ".codewhale"; + +/// Legacy DeepSeek-branded app directory name (compatibility fallback). +pub const LEGACY_APP_DIR: &str = ".deepseek"; + +/// Resolve the primary CodeWhale home directory. +/// +/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to +/// `$HOME/.codewhale`. This is the write target for new product state. +pub fn codewhale_home() -> Result { + if let Ok(val) = std::env::var("CODEWHALE_HOME") { + let trimmed = val.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + let home = dirs::home_dir().context("failed to resolve home directory")?; + Ok(home.join(CODEWHALE_APP_DIR)) +} + +/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`). +/// +/// Always returns the legacy path regardless of whether it exists. +pub fn legacy_deepseek_home() -> Result { + let home = dirs::home_dir().context("failed to resolve home directory")?; + Ok(home.join(LEGACY_APP_DIR)) +} + +/// Resolve a state subdirectory, preferring the CodeWhale root if +/// it already exists, otherwise falling back to the legacy root. +/// +/// This is the read-path resolver: it returns the primary path when +/// migration has occurred or on a fresh install, but keeps reading +/// from the legacy path for users who haven't migrated yet. +pub fn resolve_state_dir(subdir: &str) -> Result { + let primary = codewhale_home()?.join(subdir); + if primary.exists() { + return Ok(primary); + } + let legacy = legacy_deepseek_home()?.join(subdir); + if legacy.exists() { + return Ok(legacy); + } + // Neither exists — return primary for first-write creation. + Ok(primary) +} + +/// Ensure a state subdirectory exists under the primary CodeWhale root, +/// creating it if necessary. This is the write-path resolver. +pub fn ensure_state_dir(subdir: &str) -> Result { + let dir = codewhale_home()?.join(subdir); + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}/", dir.display()))?; + Ok(dir) +} + pub fn resolve_config_path(explicit: Option) -> Result { let path = if let Some(path) = explicit { path + } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + PathBuf::from(trimmed) + } else { + return default_config_path(); + } } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { @@ -1459,8 +1536,18 @@ pub fn resolve_config_path(explicit: Option) -> Result { } pub fn default_config_path() -> Result { - let home = dirs::home_dir().context("failed to resolve home directory for config path")?; - Ok(home.join(".deepseek").join(CONFIG_FILE_NAME)) + // Prefer ~/.codewhale/config.toml when it exists (fresh install or + // migrated), otherwise fall back to ~/.deepseek/config.toml. + let primary = codewhale_home()?.join(CONFIG_FILE_NAME); + if primary.exists() { + return Ok(primary); + } + let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME); + if legacy.exists() { + return Ok(legacy); + } + // Neither exists — return primary so first write creates it there. + Ok(primary) } fn parse_bool(raw: &str) -> Result { diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 271e2196..d0898371 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,6 +27,7 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" +codewhale-config = { path = "../config", version = "0.8.43" } codewhale-secrets = { path = "../secrets", version = "0.8.43" } codewhale-tools = { path = "../tools", version = "0.8.43" } schemaui = { version = "0.12.0", default-features = false, optional = true } diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 7a71e009..6dd74353 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -35,9 +35,9 @@ pub fn init(app: &mut App) -> CommandResult { } } -/// If `workspace` is inside a git repository, ensure `.deepseek/` is listed -/// in the nearest `.gitignore` so that snapshots, instructions, and other -/// workspace-local state are not accidentally committed. +/// If `workspace` is inside a git repository, ensure `.codewhale/` and +/// `.deepseek/` are listed in the nearest `.gitignore` so that snapshots, +/// instructions, and other workspace-local state are not accidentally committed. fn ensure_deepseek_gitignored(workspace: &Path) { // Only act if this workspace is a git repo. if !workspace.join(".git").exists() { @@ -45,24 +45,27 @@ fn ensure_deepseek_gitignored(workspace: &Path) { } let gitignore = workspace.join(".gitignore"); - let entry = ".deepseek/"; + let entries = [".codewhale/", ".deepseek/"]; - // Read existing contents (if any) and check whether the entry is already present. - // Check both with and without trailing slash to catch variants like - // ".deepseek" and ".deepseek/". - if let Ok(existing) = std::fs::read_to_string(&gitignore) { + // Read existing contents once. + let existing = std::fs::read_to_string(&gitignore).unwrap_or_default(); + let mut missing: Vec<&str> = Vec::new(); + for entry in entries { let entry_no_slash = entry.trim_end_matches('/'); - if existing.lines().any(|line| { + let already_ignored = existing.lines().any(|line| { let trimmed = line.trim(); trimmed == entry || trimmed == entry_no_slash - }) { - return; // already ignored + }); + if !already_ignored { + missing.push(entry); } } - // Append the entry. If .gitignore doesn't exist yet, create it with a header. - // Ensure there's a trailing newline before our entry to avoid joining with - // a previous unterminated line. + if missing.is_empty() { + return; + } + + // Append missing entries. If .gitignore doesn't exist yet, create it. use std::io::Write; if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) @@ -73,7 +76,6 @@ fn ensure_deepseek_gitignored(workspace: &Path) { if let Ok(meta) = file.metadata() && meta.len() > 0 { - // Read last byte to check for trailing newline. if let Ok(mut f) = std::fs::File::open(&gitignore) { use std::io::Seek; if f.seek(std::io::SeekFrom::End(-1)).is_ok() { @@ -84,7 +86,9 @@ fn ensure_deepseek_gitignored(workspace: &Path) { } } } - let _ = writeln!(file, "{entry}"); + for entry in &missing { + let _ = writeln!(file, "{entry}"); + } } } diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 54d11132..7a107797 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -12,13 +12,21 @@ use crate::tui::session_picker::SessionPickerView; use super::CommandResult; -/// Save session to file +/// Save session to file. +/// +/// When an explicit path is given, the session is exported there +/// (user-visible explicit export). Without a path, v0.8.44 saves +/// into the managed session directory (`~/.codewhale/sessions` +/// or legacy `~/.deepseek/sessions`) so repo-local `session_*.json` +/// artifacts are no longer created by default. pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { let save_path = if let Some(p) = path { PathBuf::from(p) } else { + let dir = crate::session_manager::default_sessions_dir() + .unwrap_or_else(|_| app.workspace.clone()); let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - PathBuf::from(format!("session_{timestamp}.json")) + dir.join(format!("session_{timestamp}.json")) }; let messages = app.api_messages.clone(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 466e113a..b5324407 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4452,10 +4452,8 @@ fn load_recent_checkpoint( ) -> Option<(session_manager::SavedSession, std::time::Duration)> { let session = manager.load_checkpoint().ok().flatten()?; - let home = dirs::home_dir()?; - let checkpoint_path = home - .join(".deepseek") - .join("sessions") + let checkpoint_path = manager + .sessions_dir() .join("checkpoints") .join("latest.json"); let metadata = std::fs::metadata(&checkpoint_path).ok()?; @@ -4745,6 +4743,12 @@ async fn run_interactive( ), } + // v0.8.44: prune managed sessions on boot to prevent unbounded growth. + // Keeps at most MAX_SESSIONS (50) recent sessions; non-fatal on error. + if let Ok(manager) = session_manager::SessionManager::default_location() { + let _ = manager.cleanup_old_sessions(); + } + tui::run_tui( config, tui::TuiOptions { diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index c72dd089..0ae53e4d 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -242,11 +242,16 @@ impl SessionManager { Ok(Self { sessions_dir }) } - /// Create a `SessionManager` using the default location (~/.deepseek/sessions) + /// Create a `SessionManager` using the default location. pub fn default_location() -> std::io::Result { Self::new(default_sessions_dir()?) } + /// Return the resolved sessions directory path. + pub fn sessions_dir(&self) -> &Path { + &self.sessions_dir + } + /// Save a session to disk using atomic write (temp file + fsync + rename). pub fn save_session(&self, session: &SavedSession) -> std::io::Result { let path = self.validated_session_path(&session.metadata.id)?; @@ -478,8 +483,8 @@ impl SessionManager { Ok(()) } - /// Clean up old sessions to stay within `MAX_SESSIONS` limit - fn cleanup_old_sessions(&self) -> std::io::Result<()> { + /// Clean up old sessions to stay within `MAX_SESSIONS` limit. + pub fn cleanup_old_sessions(&self) -> std::io::Result<()> { let sessions = self.list_sessions()?; if sessions.len() > MAX_SESSIONS { @@ -607,12 +612,14 @@ fn is_git_metadata_entry(path: &Path) -> bool { .unwrap_or(false) } -/// Resolve the default session directory path (`~/.deepseek/sessions`). +/// Resolve the default session directory path. +/// +/// v0.8.44: prefers `~/.codewhale/sessions`, falls back to +/// `~/.deepseek/sessions` for existing installs. pub fn default_sessions_dir() -> std::io::Result { - let home = dirs::home_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") - })?; - Ok(home.join(".deepseek").join("sessions")) + codewhale_config::resolve_state_dir("sessions").map_err(|e| { + std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()) + }) } /// Prune snapshots older than `max_age` for `workspace`. diff --git a/crates/tui/src/workspace_trust.rs b/crates/tui/src/workspace_trust.rs index 02ff7ef7..9cc4a2b1 100644 --- a/crates/tui/src/workspace_trust.rs +++ b/crates/tui/src/workspace_trust.rs @@ -158,7 +158,9 @@ fn canonicalize_or_keep(path: &Path) -> PathBuf { } fn trust_file_path() -> Option { - dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME)) + codewhale_config::ensure_state_dir(".") + .ok() + .map(|dir| dir.join(TRUST_FILE_NAME)) } fn read_trust_file_at(path: &Path) -> Result {