Skip to content

Commit 792f709

Browse files
committed
feat: implement XDG Base Directory layout for all paths #18
Migrate from monolithic ~/.localgpt/ to XDG-compliant directory structure: - Config: ~/.config/localgpt/ - Data: ~/.local/share/localgpt/ (workspace, device key, skills) - State: ~/.local/state/localgpt/ (sessions, audit log, logs) - Cache: ~/.cache/localgpt/ (search index, embeddings) - Runtime: $TMPDIR/localgpt-$UID/ (PID file, workspace lock) Adds Paths struct (src/paths.rs) with three-level env var fallback: LOCALGPT_* -> XDG_* -> platform defaults (via etcetera crate). Existing configs with memory.workspace set are honored via deprecated compat logic. New `localgpt paths` subcommand for debugging.
1 parent f38616d commit 792f709

23 files changed

Lines changed: 666 additions & 172 deletions

Cargo.lock

Lines changed: 79 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ mime_guess = "2.0"
7575
# Utilities
7676
chrono = { version = "0.4", features = ["serde"] }
7777
directories = "6.0"
78+
etcetera = "0.8"
79+
libc = "0.2"
7880
thiserror = "2.0"
7981
anyhow = "1.0"
8082
uuid = { version = "1.20", features = ["v4"] }

src/agent/mod.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,14 @@ impl Agent {
9898

9999
// Load and verify security policy
100100
let workspace = app_config.workspace_path();
101-
let state_dir = workspace
102-
.parent()
103-
.unwrap_or_else(|| std::path::Path::new("~/.localgpt"));
101+
let data_dir = &app_config.paths.data_dir;
102+
let state_dir = &app_config.paths.state_dir;
104103

105104
let verified_security_policy = if app_config.security.disable_policy {
106105
debug!("Security policy loading disabled by config");
107106
None
108107
} else {
109-
match crate::security::load_and_verify_policy(&workspace, state_dir) {
108+
match crate::security::load_and_verify_policy(&workspace, data_dir) {
110109
crate::security::PolicyVerification::Valid(content) => {
111110
let sha = crate::security::content_sha256(&content);
112111
let _ = crate::security::append_audit_entry(
@@ -220,13 +219,11 @@ impl Agent {
220219

221220
// Load security policy
222221
let workspace = app_config.workspace_path();
223-
let state_dir = workspace
224-
.parent()
225-
.unwrap_or_else(|| std::path::Path::new("~/.localgpt"));
222+
let data_dir = &app_config.paths.data_dir;
226223
let verified_security_policy = if app_config.security.disable_policy {
227224
None
228225
} else {
229-
match crate::security::load_and_verify_policy(&workspace, state_dir) {
226+
match crate::security::load_and_verify_policy(&workspace, data_dir) {
230227
crate::security::PolicyVerification::Valid(content) => Some(content),
231228
_ => None,
232229
}

src/agent/session.rs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -596,22 +596,13 @@ fn get_sessions_dir() -> Result<PathBuf> {
596596
}
597597

598598
pub fn get_sessions_dir_for_agent(agent_id: &str) -> Result<PathBuf> {
599-
let base = directories::BaseDirs::new()
600-
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
601-
602-
Ok(base
603-
.home_dir()
604-
.join(".localgpt")
605-
.join("agents")
606-
.join(agent_id)
607-
.join("sessions"))
599+
let paths = crate::paths::Paths::resolve()?;
600+
Ok(paths.sessions_dir(agent_id))
608601
}
609602

610603
pub fn get_state_dir() -> Result<PathBuf> {
611-
let base = directories::BaseDirs::new()
612-
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
613-
614-
Ok(base.home_dir().join(".localgpt"))
604+
let paths = crate::paths::Paths::resolve()?;
605+
Ok(paths.state_dir)
615606
}
616607

617608
fn estimate_tokens(text: &str) -> usize {

src/agent/skills.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,11 @@ pub fn load_skills(workspace: &Path) -> Result<Vec<Skill>> {
211211
Ok(skills)
212212
}
213213

214-
/// Get the managed skills directory (~/.localgpt/skills/)
214+
/// Get the managed skills directory (data_dir/skills)
215215
fn get_managed_skills_dir() -> Option<PathBuf> {
216-
directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".localgpt").join("skills"))
216+
crate::paths::Paths::resolve()
217+
.ok()
218+
.map(|paths| paths.managed_skills_dir())
217219
}
218220

219221
/// Load skills from a single directory

src/agent/tools.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,7 @@ pub fn create_default_tools(
2929
memory: Option<Arc<MemoryManager>>,
3030
) -> Result<Vec<Box<dyn Tool>>> {
3131
let workspace = config.workspace_path();
32-
let state_dir = workspace
33-
.parent()
34-
.unwrap_or_else(|| std::path::Path::new("~/.localgpt"))
35-
.to_path_buf();
32+
let state_dir = config.paths.state_dir.clone();
3633

3734
// Build sandbox policy if enabled
3835
let sandbox_policy = if config.sandbox.enabled {

src/cli/config.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,10 @@ interval = "30m"
144144
# end = "22:00"
145145
146146
[memory]
147-
workspace = "~/.localgpt/workspace"
147+
# Workspace directory for memory files (MEMORY.md, HEARTBEAT.md, etc.)
148+
# Default: XDG data dir (~/.local/share/localgpt/workspace)
149+
# Override with: LOCALGPT_WORKSPACE=/path or LOCALGPT_PROFILE=work
150+
# workspace = "~/.local/share/localgpt/workspace"
148151
149152
[server]
150153
enabled = true

src/cli/daemon.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -451,14 +451,13 @@ async fn run_heartbeat_once(agent_id: &str) -> Result<()> {
451451
}
452452

453453
fn get_pid_file() -> Result<PathBuf> {
454-
// Put PID file in state dir (~/.localgpt/), not workspace
455-
let state_dir = crate::agent::get_state_dir()?;
456-
Ok(state_dir.join("daemon.pid"))
454+
let paths = crate::paths::Paths::resolve()?;
455+
Ok(paths.pid_file())
457456
}
458457

459458
fn get_log_file(retention_days: u32) -> Result<PathBuf> {
460-
let state_dir = crate::agent::get_state_dir()?;
461-
let logs_dir = state_dir.join("logs");
459+
let paths = crate::paths::Paths::resolve()?;
460+
let logs_dir = paths.logs_dir();
462461
fs::create_dir_all(&logs_dir)?;
463462

464463
// Prune old logs only if retention_days > 0

src/cli/md.rs

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,11 @@ pub async fn run(args: MdArgs) -> Result<()> {
5050
async fn sign_policy() -> Result<()> {
5151
let config = Config::load()?;
5252
let workspace = config.workspace_path();
53-
let state_dir = workspace
54-
.parent()
55-
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
53+
let data_dir = &config.paths.data_dir;
54+
let state_dir = &config.paths.state_dir;
5655

5756
// Ensure device key exists
58-
security::ensure_device_key(state_dir)?;
57+
security::ensure_device_key(data_dir)?;
5958

6059
// Check policy file exists
6160
let policy_path = workspace.join(security::POLICY_FILENAME);
@@ -68,7 +67,7 @@ async fn sign_policy() -> Result<()> {
6867
}
6968

7069
// Sign
71-
let manifest = security::sign_policy(state_dir, &workspace, "cli")?;
70+
let manifest = security::sign_policy(data_dir, &workspace, "cli")?;
7271

7372
// Write audit entry
7473
security::append_audit_entry(
@@ -91,11 +90,10 @@ async fn sign_policy() -> Result<()> {
9190
async fn verify_policy() -> Result<()> {
9291
let config = Config::load()?;
9392
let workspace = config.workspace_path();
94-
let state_dir = workspace
95-
.parent()
96-
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
93+
let data_dir = &config.paths.data_dir;
94+
let state_dir = &config.paths.state_dir;
9795

98-
let result = security::load_and_verify_policy(&workspace, state_dir);
96+
let result = security::load_and_verify_policy(&workspace, data_dir);
9997

10098
match result {
10199
security::PolicyVerification::Valid(content) => {
@@ -145,10 +143,7 @@ async fn verify_policy() -> Result<()> {
145143

146144
async fn show_audit(json_output: bool, filter: Option<String>) -> Result<()> {
147145
let config = Config::load()?;
148-
let workspace = config.workspace_path();
149-
let state_dir = workspace
150-
.parent()
151-
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
146+
let state_dir = &config.paths.state_dir;
152147

153148
let mut entries = security::read_audit_log(state_dir)?;
154149

@@ -230,16 +225,15 @@ async fn show_audit(json_output: bool, filter: Option<String>) -> Result<()> {
230225
async fn show_status() -> Result<()> {
231226
let config = Config::load()?;
232227
let workspace = config.workspace_path();
233-
let state_dir = workspace
234-
.parent()
235-
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
228+
let data_dir = &config.paths.data_dir;
229+
let state_dir = &config.paths.state_dir;
236230

237231
println!("Security Status:");
238232

239233
// Policy file
240234
let policy_path = workspace.join(security::POLICY_FILENAME);
241235
if policy_path.exists() {
242-
let result = security::load_and_verify_policy(&workspace, state_dir);
236+
let result = security::load_and_verify_policy(&workspace, data_dir);
243237
let status = match result {
244238
security::PolicyVerification::Valid(_) => "Valid (signed and verified)",
245239
security::PolicyVerification::Unsigned => "Unsigned (run `localgpt md sign`)",
@@ -262,7 +256,7 @@ async fn show_status() -> Result<()> {
262256
}
263257

264258
// Device key
265-
let key_path = state_dir.join(".device_key");
259+
let key_path = config.paths.device_key();
266260
if key_path.exists() {
267261
println!(" Device Key: Present");
268262
} else {

src/cli/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod desktop;
88
pub mod gen3d;
99
pub mod md;
1010
pub mod memory;
11+
pub mod paths;
1112
pub mod sandbox;
1213

1314
use clap::{Parser, Subcommand};
@@ -67,6 +68,9 @@ pub enum Commands {
6768
/// LocalGPT.md policy management
6869
Md(md::MdArgs),
6970

71+
/// Show resolved XDG directory paths
72+
Paths,
73+
7074
/// Shell sandbox management
7175
Sandbox(sandbox::SandboxArgs),
7276
}

0 commit comments

Comments
 (0)