From 199a71e08fac5ea36891f61798be3dfe68732503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Soares?= <85080530+fabox-os@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:01:26 +0000 Subject: [PATCH] Give Claude Code a writable conversation folder under Flatpak, and say how to sign in there (#561) Fixes #506. Under a Flatpak, where ~/.claude is read-only, Claude Code is started with a writable CLAUDE_CONFIG_DIR in the app's data folder so its conversations are saved and --resume works; the sign-in message says how to sign in inside the sandbox. A CLAUDE_CONFIG_DIR the person sets wins, and SSH builds are unchanged. --- crates/ai/src/cli_agents/adapters.rs | 25 +++++++++++-- crates/ai/src/cli_agents/mod.rs | 52 ++++++++++++++++++++++++---- docs/claude-code.md | 17 +++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/ai/src/cli_agents/adapters.rs b/crates/ai/src/cli_agents/adapters.rs index b9c89c3d..7a7df833 100644 --- a/crates/ai/src/cli_agents/adapters.rs +++ b/crates/ai/src/cli_agents/adapters.rs @@ -380,10 +380,31 @@ impl AgentAdapter for ClaudeCode { } /// Claude Code's own folder: `CLAUDE_CONFIG_DIR` when it's set (the person's choice, which the -/// agent we start keeps too), else `~/.claude`. +/// agent we start keeps too), else our own under a Flatpak (see [`flatpak_claude_dir`]), else +/// `~/.claude`. fn claude_config_dir() -> Option { let set = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()).map(std::path::PathBuf::from); - set.or_else(|| dirs::home_dir().map(|h| h.join(".claude"))) + set.or_else(flatpak_claude_dir).or_else(|| dirs::home_dir().map(|h| h.join(".claude"))) +} + +/// Whether this app runs in a Flatpak sandbox (it sets `FLATPAK_ID` and makes `/.flatpak-info`). +pub fn in_flatpak() -> bool { + static IN: std::sync::OnceLock = std::sync::OnceLock::new(); + *IN.get_or_init(|| std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists()) +} + +/// Where Claude Code keeps its conversations when we start it under a Flatpak and the person +/// hasn't chosen a folder: its own is `~/.claude`, which the sandbox only lets us read, so a +/// conversation was never saved and `--resume` found nothing (#506). A folder of ours in the +/// app's data folder is writable, and kept with the app. `None` outside a Flatpak, or when +/// `CLAUDE_CONFIG_DIR` is set. +pub fn flatpak_claude_dir() -> Option { + let chosen = std::env::var_os("CLAUDE_CONFIG_DIR").is_some_and(|v| !v.is_empty()); + flatpak_claude_dir_in(in_flatpak(), chosen, dirs::data_dir()) +} + +pub fn flatpak_claude_dir_in(flatpak: bool, chosen: bool, data: Option) -> Option { + (flatpak && !chosen).then_some(data?.join("craft-anything").join("claude")) } /// Claude Code's name for the folder a session ran in: the path with everything but letters and diff --git a/crates/ai/src/cli_agents/mod.rs b/crates/ai/src/cli_agents/mod.rs index c4a163d7..b1d9f81a 100644 --- a/crates/ai/src/cli_agents/mod.rs +++ b/crates/ai/src/cli_agents/mod.rs @@ -185,13 +185,28 @@ pub fn verify_flags(program: &str, help_args: &[&str], needed: &[&str]) -> bool /// The fix for a sign-in failure, in plain words. Sign-in always happens in Claude Code itself; /// we never see or handle the account. pub fn sign_in_hint(message: &str) -> Option { - let m = message.to_ascii_lowercase(); - ["authenticat", "oauth", "log in", "login", "not logged", "api key", "unauthorized"] - .iter() - .any(|w| m.contains(w)) - .then(|| "Sign in to Claude Code: open a terminal and run `claude auth login` (or start `claude` and type /login), then try again.".to_string()) + sign_in_hint_in(message, adapters::in_flatpak()) } +/// [`sign_in_hint`] for a Flatpak or not. In a Flatpak, Claude Code keeps its sign-in and +/// conversations in a folder of the app's own (#506), so signing in from a normal terminal signs +/// in the other copy: it's done through the sandbox, with that folder. +fn sign_in_hint_in(message: &str, flatpak: bool) -> Option { + let m = message.to_ascii_lowercase(); + let wants_sign_in = ["authenticat", "oauth", "log in", "login", "not logged", "api key", "unauthorized"].iter().any(|w| m.contains(w)); + if !wants_sign_in { + return None; + } + Some(if flatpak { + format!("Sign in to Claude Code inside this app's sandbox: open a terminal and run `{FLATPAK_SIGN_IN}`, then try again.") + } else { + "Sign in to Claude Code: open a terminal and run `claude auth login` (or start `claude` and type /login), then try again.".to_string() + }) +} + +/// Signing in for the Claude Code that a Flatpak build starts: the same folder it will use. +const FLATPAK_SIGN_IN: &str = "flatpak run --command=sh ai.storyteller.craft-anything -c 'CLAUDE_CONFIG_DIR=\"$XDG_DATA_HOME/craft-anything/claude\" \"$HOME/.local/bin/claude\" auth login'"; + /// Words in an API's answer when the installed Claude Code is older than the model it was asked /// to use (#165). pub const TOO_OLD_FOR_MODEL: &str = "does not support this model"; @@ -561,7 +576,15 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, sta // raised to match, so nothing cuts a long clone short (its defaults stop a quiet tool call at // 30 minutes and a background build at 10). let (turn, quiet) = if builder { (24 * 3600, 8 * 3600) } else { (30 * 60, 15 * 60) }; - let env = agent_env(builder, spec.options.effort); + let mut env = agent_env(builder, spec.options.effort); + // Under a Flatpak, Claude Code gets a folder it can write its conversations to (#506); on + // another computer over SSH it keeps its own. + if remote.is_none() + && let Some(dir) = adapters::flatpak_claude_dir() + { + let _ = std::fs::create_dir_all(&dir); + env.push(("CLAUDE_CONFIG_DIR", dir.to_string_lossy().into_owned())); + } let (prog, argv) = match &remote { Some(host) => ("ssh".to_string(), crate::ssh::ssh_args(host, &remote_command(&program, args, &env))), None => (program.clone(), args), @@ -823,6 +846,23 @@ impl Drop for CliConversation { mod tests { use super::*; + /// #506: under a Flatpak `~/.claude` is read-only, so Claude Code gets a folder of ours to save + /// its conversations in (so `--resume` finds them); the person's own `CLAUDE_CONFIG_DIR` and a + /// normal install are left as they are, and the sign-in hint says how to sign in there. + #[test] + fn under_a_flatpak_claude_code_keeps_its_conversations_in_a_folder_it_can_write() { + let data = || Some(std::path::PathBuf::from("/home/u/.var/app/ai.storyteller.craft-anything/data")); + let dir = adapters::flatpak_claude_dir_in(true, false, data()).unwrap(); + assert!(dir.ends_with("craft-anything/claude") && dir.starts_with("/home/u/.var/app/ai.storyteller.craft-anything/data"), "{dir:?}"); + assert_eq!(adapters::flatpak_claude_dir_in(true, true, data()), None, "their own choice stays"); + assert_eq!(adapters::flatpak_claude_dir_in(false, false, data()), None, "a normal install keeps ~/.claude"); + assert_eq!(adapters::flatpak_claude_dir_in(true, false, None), None); + let hint = sign_in_hint_in("Not logged in", true).unwrap(); + assert!(hint.contains("flatpak run") && hint.contains("CLAUDE_CONFIG_DIR") && hint.contains("auth login"), "{hint}"); + assert!(sign_in_hint_in("Not logged in", false).unwrap().contains("`claude auth login`")); + assert_eq!(sign_in_hint_in("all fine", true), None); + } + /// Over SSH our environment doesn't reach the other computer, so the chosen effort (and Claude /// Code's raised limits) go in the remote command itself. #[test] diff --git a/docs/claude-code.md b/docs/claude-code.md index 42963b3f..9fdbda20 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -30,6 +30,23 @@ integration does, why it is shaped this way, and what remains to confirm. - Before a build starts, `claude auth status` is checked. If Claude Code isn't signed in, the run stops with: "Sign in to Claude Code: open a terminal and run `claude auth login`". +## Under Flatpak + +The Flatpak build can read the home folder but not write to it, so Claude Code can't save its +conversations in `~/.claude`: the first turn ran, and `--resume` then said "No conversation found +with session ID" (#506). When the app runs in a Flatpak and `CLAUDE_CONFIG_DIR` isn't set, it starts +Claude Code with `CLAUDE_CONFIG_DIR` pointing at `craft-anything/claude` in the app's own data +folder (`~/.var/app/ai.storyteller.craft-anything/data/`), which is writable. Two consequences: + +- Claude Code there is signed in separately from the one on the host. The sign-in message gives the + command that does it through the sandbox: + `flatpak run --command=sh ai.storyteller.craft-anything -c 'CLAUDE_CONFIG_DIR="$XDG_DATA_HOME/craft-anything/claude" "$HOME/.local/bin/claude" auth login'` + (Craft Anything never reads Claude Code's credentials, so they aren't copied over.) +- Setting `CLAUDE_CONFIG_DIR` yourself, for example with `flatpak override --user --env=…`, wins. + Builds on another computer over SSH are not changed. + +Not yet checked in a real Flatpak (the tests cover the choice of folder and the message). + ## Why this is within Anthropic's terms (as published, October 2026) From Claude Code's *Legal and compliance* page: