From 869e03720288bc1cc41a7f1d4dc3a9ddc676f6a9 Mon Sep 17 00:00:00 2001 From: Glen <5329798+gbryer@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:29:01 +1000 Subject: [PATCH] feat: add GSD_HOME env var to override global ~/.gsd directory (#1566) Centralise all ~/.gsd path construction through app-paths.ts (compiled code) or a module-level `gsdHome` const (runtime extensions that cannot import app-paths). When GSD_HOME is set, every path that previously resolved under ~/.gsd now resolves under the override. Existing overrides (GSD_STATE_DIR, GSD_CODING_AGENT_DIR) continue to take precedence when set. --- docs/configuration.md | 8 ++++++++ src/app-paths.ts | 2 +- src/extension-registry.ts | 4 ++-- src/remote-questions-config.ts | 4 ++-- src/resources/extensions/gsd/auto-worktree-sync.ts | 4 +++- src/resources/extensions/gsd/commands-extensions.ts | 6 ++++-- src/resources/extensions/gsd/commands.ts | 4 +++- src/resources/extensions/gsd/detection.ts | 4 ++-- src/resources/extensions/gsd/index.ts | 4 +++- src/resources/extensions/gsd/preferences.ts | 6 ++++-- src/resources/extensions/gsd/repo-identity.ts | 4 +++- src/resources/extensions/gsd/resource-version.ts | 4 +++- src/resources/extensions/remote-questions/status.ts | 4 +++- src/resources/extensions/remote-questions/store.ts | 4 +++- src/resources/extensions/search-the-web/provider.ts | 3 ++- src/resources/extensions/subagent/isolation.ts | 4 +++- src/resources/extensions/ttsr/rule-loader.ts | 4 +++- 17 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8f4f9830a..d5c9a3a7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,6 +151,14 @@ Recommended verification order: - If a server is team-shared and safe to commit, `.mcp.json` is usually the better home. - If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.gsd/mcp.json`. +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GSD_HOME` | `~/.gsd` | Global GSD directory. All paths derive from this unless individually overridden. | +| `GSD_STATE_DIR` | `$GSD_HOME` | Per-project state root. Controls where `projects//` directories are created. Takes precedence over `GSD_HOME` for project state. | +| `GSD_CODING_AGENT_DIR` | `$GSD_HOME/agent` | Agent directory containing managed resources, extensions, and auth. Takes precedence over `GSD_HOME` for agent paths. | + ## All Settings ### `models` diff --git a/src/app-paths.ts b/src/app-paths.ts index 94cc70a39..d6e171d99 100644 --- a/src/app-paths.ts +++ b/src/app-paths.ts @@ -1,7 +1,7 @@ import { homedir } from 'os' import { join } from 'path' -export const appRoot = join(homedir(), '.gsd') +export const appRoot = process.env.GSD_HOME || join(homedir(), '.gsd') export const agentDir = join(appRoot, 'agent') export const sessionsDir = join(appRoot, 'sessions') export const authFilePath = join(agentDir, 'auth.json') diff --git a/src/extension-registry.ts b/src/extension-registry.ts index 0f30eeefd..042df6799 100644 --- a/src/extension-registry.ts +++ b/src/extension-registry.ts @@ -7,7 +7,7 @@ */ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { appRoot } from "./app-paths.js"; import { dirname, join } from "node:path"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -66,7 +66,7 @@ function isManifest(data: unknown): data is ExtensionManifest { // ─── Registry Path ────────────────────────────────────────────────────────── export function getRegistryPath(): string { - return join(homedir(), ".gsd", "extensions", "registry.json"); + return join(appRoot, "extensions", "registry.json"); } // ─── Registry I/O ─────────────────────────────────────────────────────────── diff --git a/src/remote-questions-config.ts b/src/remote-questions-config.ts index ffa753ecd..e7f0d8cae 100644 --- a/src/remote-questions-config.ts +++ b/src/remote-questions-config.ts @@ -10,13 +10,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; -import { homedir } from "node:os"; +import { appRoot } from "./app-paths.js"; // Inlined from preferences.ts to avoid crossing the compiled/uncompiled // boundary — this file is compiled by tsc, but preferences.ts is loaded // via jiti at runtime. Importing it as .js fails because no .js exists // in dist/. See #592, #1110. -const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); +const GLOBAL_PREFERENCES_PATH = join(appRoot, "preferences.md"); export function saveRemoteQuestionsConfig(channel: "slack" | "discord" | "telegram", channelId: string): void { const prefsPath = GLOBAL_PREFERENCES_PATH; diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts index d4328008a..0f4dd6158 100644 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -22,6 +22,8 @@ import { join, sep as pathSep } from "node:path"; import { homedir } from "node:os"; import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + // ─── Project Root → Worktree Sync ───────────────────────────────────────── /** @@ -111,7 +113,7 @@ export function syncStateToProjectRoot( */ export function readResourceVersion(): string | null { const agentDir = - process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); + process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent"); const manifestPath = join(agentDir, "managed-resources.json"); try { const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); diff --git a/src/resources/extensions/gsd/commands-extensions.ts b/src/resources/extensions/gsd/commands-extensions.ts index 95a51b18d..e63f90405 100644 --- a/src/resources/extensions/gsd/commands-extensions.ts +++ b/src/resources/extensions/gsd/commands-extensions.ts @@ -11,6 +11,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFile import { dirname, join } from "node:path"; import { homedir } from "node:os"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + // ─── Types (mirrored from extension-registry.ts) ──────────────────────────── interface ExtensionManifest { @@ -48,11 +50,11 @@ interface ExtensionRegistry { // ─── Registry I/O ─────────────────────────────────────────────────────────── function getRegistryPath(): string { - return join(homedir(), ".gsd", "extensions", "registry.json"); + return join(gsdHome, "extensions", "registry.json"); } function getAgentExtensionsDir(): string { - return join(homedir(), ".gsd", "agent", "extensions"); + return join(gsdHome, "agent", "extensions"); } function loadRegistry(): ExtensionRegistry { diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 8cb97428c..fcc9c6878 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -10,6 +10,8 @@ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; + +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); import { enableDebug } from "./debug-logger.js"; import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; @@ -482,7 +484,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts.length === 3 && ["enable", "disable", "info"].includes(parts[1])) { const idPrefix = parts[2] ?? ""; try { - const extDir = join(homedir(), ".gsd", "agent", "extensions"); + const extDir = join(gsdHome, "agent", "extensions"); const ids: { id: string; name: string }[] = []; for (const entry of readdirSync(extDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 813e47461..9401dae9b 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -11,6 +11,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { gsdRoot } from "./paths.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + // ─── Types ────────────────────────────────────────────────────────────────────── export interface ProjectDetection { @@ -400,7 +402,6 @@ function detectVerificationCommands( * Check if global GSD setup exists (has ~/.gsd/ with preferences). */ export function hasGlobalSetup(): boolean { - const gsdHome = join(homedir(), ".gsd"); return ( existsSync(join(gsdHome, "preferences.md")) || existsSync(join(gsdHome, "PREFERENCES.md")) @@ -412,7 +413,6 @@ export function hasGlobalSetup(): boolean { * Returns true if ~/.gsd/ doesn't exist or has no preferences or auth. */ export function isFirstEverLaunch(): boolean { - const gsdHome = join(homedir(), ".gsd"); if (!existsSync(gsdHome)) return true; // If we have preferences, not first launch diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index a72b92abc..2ef65a2cf 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -60,6 +60,8 @@ import { join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { shortcutDesc } from "../shared/mod.js"; + +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); import { Text } from "@gsd/pi-tui"; import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js"; import { toPosixPath } from "../shared/mod.js"; @@ -73,7 +75,7 @@ import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../cmux/index.js" function warnDeprecatedAgentInstructions(): void { const paths = [ - join(homedir(), ".gsd", "agent-instructions.md"), + join(gsdHome, "agent-instructions.md"), join(process.cwd(), ".gsd", "agent-instructions.md"), ]; for (const p of paths) { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 8e48888a9..d8f005984 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -13,6 +13,8 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; + +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); import { gsdRoot } from "./paths.js"; import { parse as parseYaml } from "yaml"; import type { PostUnitHookConfig, PreDispatchHookConfig, TokenProfile } from "./types.js"; @@ -82,14 +84,14 @@ export { // ─── Path Constants & Getters ─────────────────────────────────────────────── -const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); +const GLOBAL_PREFERENCES_PATH = join(gsdHome, "preferences.md"); const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md"); function projectPreferencesPath(): string { return join(gsdRoot(process.cwd()), "preferences.md"); } // Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake. // Check uppercase as a fallback so those files aren't silently ignored. -const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md"); +const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(gsdHome, "PREFERENCES.md"); function projectPreferencesPathUppercase(): string { return join(gsdRoot(process.cwd()), "PREFERENCES.md"); } diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index f9429458e..42c792e11 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -12,6 +12,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, s import { homedir } from "node:os"; import { join, resolve } from "node:path"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + // ─── Repo Identity ────────────────────────────────────────────────────────── /** @@ -113,7 +115,7 @@ export function repoIdentity(basePath: string): string { * otherwise `~/.gsd/projects/`. */ export function externalGsdRoot(basePath: string): string { - const base = process.env.GSD_STATE_DIR || join(homedir(), ".gsd"); + const base = process.env.GSD_STATE_DIR || gsdHome; return join(base, "projects", repoIdentity(basePath)); } diff --git a/src/resources/extensions/gsd/resource-version.ts b/src/resources/extensions/gsd/resource-version.ts index 060eb3fa1..cba47b30f 100644 --- a/src/resources/extensions/gsd/resource-version.ts +++ b/src/resources/extensions/gsd/resource-version.ts @@ -11,6 +11,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { resolveProjectRoot } from "./worktree.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + // ─── Resource Staleness ─────────────────────────────────────────────────── /** @@ -23,7 +25,7 @@ function isManifestWithVersion(data: unknown): data is { gsdVersion: string } { } export function readResourceVersion(): string | null { - const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); + const agentDir = process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent"); const manifestPath = join(agentDir, "managed-resources.json"); const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion); return manifest?.gsdVersion ?? null; diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts index dd4593488..a3329b214 100644 --- a/src/resources/extensions/remote-questions/status.ts +++ b/src/resources/extensions/remote-questions/status.ts @@ -7,6 +7,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { readPromptRecord } from "./store.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + export interface LatestPromptSummary { id: string; status: string; @@ -14,7 +16,7 @@ export interface LatestPromptSummary { } export function getLatestPromptSummary(): LatestPromptSummary | null { - const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); + const runtimeDir = join(gsdHome, "runtime", "remote-questions"); if (!existsSync(runtimeDir)) return null; const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")); if (files.length === 0) return null; diff --git a/src/resources/extensions/remote-questions/store.ts b/src/resources/extensions/remote-questions/store.ts index 83c80dcd0..eb3a19049 100644 --- a/src/resources/extensions/remote-questions/store.ts +++ b/src/resources/extensions/remote-questions/store.ts @@ -7,8 +7,10 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + function runtimeDir(): string { - return join(homedir(), ".gsd", "runtime", "remote-questions"); + return join(gsdHome, "runtime", "remote-questions"); } function recordPath(id: string): string { diff --git a/src/resources/extensions/search-the-web/provider.ts b/src/resources/extensions/search-the-web/provider.ts index 49363b8e2..e1f8b2312 100644 --- a/src/resources/extensions/search-the-web/provider.ts +++ b/src/resources/extensions/search-the-web/provider.ts @@ -17,7 +17,8 @@ import { resolveSearchProviderFromPreferences } from '../gsd/preferences.js' // Compute authFilePath locally instead of importing from app-paths.ts, // because extensions are copied to ~/.gsd/agent/extensions/ at runtime // where the relative import '../../../app-paths.ts' doesn't resolve. -const authFilePath = join(homedir(), '.gsd', 'agent', 'auth.json') +const gsdHome = process.env.GSD_HOME || join(homedir(), '.gsd') +const authFilePath = join(gsdHome, 'agent', 'auth.json') export type SearchProvider = 'tavily' | 'brave' | 'ollama' export type SearchProviderPreference = SearchProvider | 'auto' diff --git a/src/resources/extensions/subagent/isolation.ts b/src/resources/extensions/subagent/isolation.ts index 6e6ab1df7..a326f55d3 100644 --- a/src/resources/extensions/subagent/isolation.ts +++ b/src/resources/extensions/subagent/isolation.ts @@ -57,8 +57,10 @@ function encodeCwd(cwd: string): string { return cwd.replace(/\//g, "--"); } +const gsdHome = process.env.GSD_HOME || path.join(os.homedir(), ".gsd"); + function getIsolationBaseDir(cwd: string, taskId: string): string { - return path.join(os.homedir(), ".gsd", "wt", encodeCwd(cwd), taskId); + return path.join(gsdHome, "wt", encodeCwd(cwd), taskId); } // Track active isolation dirs for cleanup on exit diff --git a/src/resources/extensions/ttsr/rule-loader.ts b/src/resources/extensions/ttsr/rule-loader.ts index 5c2c10f92..bae38cc98 100644 --- a/src/resources/extensions/ttsr/rule-loader.ts +++ b/src/resources/extensions/ttsr/rule-loader.ts @@ -8,6 +8,8 @@ import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir } from "node:os"; + +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); import type { Rule } from "./ttsr-manager.js"; import { splitFrontmatter, parseFrontmatterMap } from "../shared/frontmatter.js"; @@ -59,7 +61,7 @@ function scanDir(dir: string): Rule[] { * Project rules override global rules with the same name. */ export function loadRules(cwd: string): Rule[] { - const globalDir = join(homedir(), ".gsd", "agent", "rules"); + const globalDir = join(gsdHome, "agent", "rules"); const projectDir = join(cwd, ".gsd", "rules"); const globalRules = scanDir(globalDir);