diff --git a/AGENTS.md b/AGENTS.md index 8e9f37bd..5dd425b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ plannotator/ │ ├── shared/ # Shared types, utilities, and cross-runtime logic │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) +│ │ ├── paths.ts # XDG base directory helpers (getDataBase, getStateBase, getConfigBase) │ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) │ ├── editor/ # Plan review App.tsx │ └── review-editor/ # Code review UI @@ -104,9 +105,12 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | -| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. | +| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via the config file (`{ "jina": false }`) or per-invocation via `--no-jina`. | | `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. | -| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. | +| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via the config file (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. | +| `XDG_DATA_HOME` | Base for persistent data (plans, version history). Default: `~/.local/share`. Plannotator stores data in `$XDG_DATA_HOME/plannotator/`. Ignored when `~/.plannotator` already exists (legacy compat). | +| `XDG_STATE_HOME` | Base for ephemeral state (drafts, sessions, debug logs). Default: `~/.local/state`. Plannotator stores state in `$XDG_STATE_HOME/plannotator/`. Ignored when `~/.plannotator` already exists (legacy compat). | +| `XDG_CONFIG_HOME` | Base for configuration (config.json, improvement hooks). Default: `~/.config`. Plannotator stores config in `$XDG_CONFIG_HOME/plannotator/`. Ignored when `~/.plannotator` already exists (legacy compat). | **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. diff --git a/packages/server/codex-review.ts b/packages/server/codex-review.ts index 62abef7e..a0953a7f 100644 --- a/packages/server/codex-review.ts +++ b/packages/server/codex-review.ts @@ -6,7 +6,8 @@ */ import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { getStateBase } from "@plannotator/shared/paths"; import { appendFile, mkdir, unlink, writeFile, readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import type { DiffType } from "./vcs"; @@ -18,17 +19,18 @@ import { toRelativePath } from "./path-utils"; // --------------------------------------------------------------------------- const DEBUG_ENABLED = !!process.env.PLANNOTATOR_DEBUG; -const DEBUG_LOG_PATH = join(homedir(), ".plannotator", "codex-review-debug.log"); async function debugLog(label: string, data?: unknown): Promise { if (!DEBUG_ENABLED) return; try { - await mkdir(join(homedir(), ".plannotator"), { recursive: true }); + const stateBase = getStateBase(); + const debugLogPath = join(stateBase, "codex-review-debug.log"); + await mkdir(stateBase, { recursive: true }); const timestamp = new Date().toISOString(); const line = data !== undefined ? `[${timestamp}] ${label}: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}\n` : `[${timestamp}] ${label}\n`; - await appendFile(DEBUG_LOG_PATH, line); + await appendFile(debugLogPath, line); } catch { /* never fail the main flow */ } } @@ -80,21 +82,23 @@ const CODEX_REVIEW_SCHEMA = JSON.stringify({ additionalProperties: false, }); -const SCHEMA_DIR = join(homedir(), ".plannotator"); -const SCHEMA_FILE = join(SCHEMA_DIR, "codex-review-schema.json"); let schemaMaterialized = false; +let schemaFilePath: string | undefined; /** Ensure the schema file exists on disk and return its path. */ async function ensureSchemaFile(): Promise { if (!schemaMaterialized) { - await mkdir(SCHEMA_DIR, { recursive: true }); - await writeFile(SCHEMA_FILE, CODEX_REVIEW_SCHEMA); + const schemaDir = getStateBase(); + const filePath = join(schemaDir, "codex-review-schema.json"); + await mkdir(schemaDir, { recursive: true }); + await writeFile(filePath, CODEX_REVIEW_SCHEMA); schemaMaterialized = true; + schemaFilePath = filePath; } - return SCHEMA_FILE; + return schemaFilePath!; } -export { SCHEMA_FILE as CODEX_REVIEW_SCHEMA_PATH }; +export { schemaFilePath as CODEX_REVIEW_SCHEMA_PATH }; // --------------------------------------------------------------------------- // System prompt — copied verbatim from codex-rs/core/review_prompt.md diff --git a/packages/server/sessions.ts b/packages/server/sessions.ts index 9a109cc1..61a00426 100644 --- a/packages/server/sessions.ts +++ b/packages/server/sessions.ts @@ -5,8 +5,8 @@ * so users can discover and reopen closed browser tabs. */ -import { homedir } from "os"; import { join } from "path"; +import { getStateBase } from "@plannotator/shared/paths"; import { mkdirSync, writeFileSync, @@ -27,7 +27,7 @@ export interface SessionInfo { } function getSessionsDir(): string { - const dir = join(homedir(), ".plannotator", "sessions"); + const dir = join(getStateBase(), "sessions"); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/packages/shared/config.ts b/packages/shared/config.ts index eeba1b95..f55a25f7 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -1,14 +1,14 @@ /** * Plannotator Config * - * Reads/writes ~/.plannotator/config.json for persistent user settings. + * Reads/writes config.json from the plannotator config directory. * Runtime-agnostic: uses only node:fs, node:os, node:child_process. */ -import { homedir } from "os"; import { join } from "path"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; +import { getConfigBase } from "./paths"; export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged'; @@ -54,7 +54,7 @@ export interface PlannotatorConfig { jina?: boolean; } -const CONFIG_DIR = join(homedir(), ".plannotator"); +const CONFIG_DIR = getConfigBase(); const CONFIG_PATH = join(CONFIG_DIR, "config.json"); /** diff --git a/packages/shared/draft.ts b/packages/shared/draft.ts index 41ca501e..91e35ae4 100644 --- a/packages/shared/draft.ts +++ b/packages/shared/draft.ts @@ -1,23 +1,23 @@ /** * Draft Storage * - * Persists annotation drafts to ~/.plannotator/drafts/ so they survive - * server crashes. Each draft is keyed by a content hash of the plan/diff - * it was created against. + * Persists annotation drafts to the plannotator state directory so they + * survive server crashes. Each draft is keyed by a content hash of the + * plan/diff it was created against. * * Runtime-agnostic: uses only node:fs, node:path, node:os, node:crypto. */ -import { homedir } from "os"; import { join } from "path"; import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from "fs"; import { createHash } from "crypto"; +import { getStateBase } from "./paths"; /** * Get the drafts directory, creating it if needed. */ export function getDraftDir(): string { - const dir = join(homedir(), ".plannotator", "drafts"); + const dir = join(getStateBase(), "drafts"); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/packages/shared/improvement-hooks.ts b/packages/shared/improvement-hooks.ts index 465cc0ac..6fcc1090 100644 --- a/packages/shared/improvement-hooks.ts +++ b/packages/shared/improvement-hooks.ts @@ -20,11 +20,12 @@ import { homedir } from "os"; import { join } from "path"; import { readFileSync, statSync } from "fs"; +import { getConfigBase } from "./paths"; -/** Base directory for hook-injectable files (new path) */ -const HOOKS_BASE_DIR = join(homedir(), ".plannotator", "hooks"); +/** Base directory for hook-injectable files. */ +const HOOKS_BASE_DIR = join(getConfigBase(), "hooks"); -/** Legacy base directory (pre-migration path) */ +/** Legacy base directory (pre-hooks-subdir path, always ~/.plannotator). */ const LEGACY_BASE_DIR = join(homedir(), ".plannotator"); /** Maximum file size to read (50 KB) */ diff --git a/packages/shared/package.json b/packages/shared/package.json index a8a9389a..7832cd1c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,7 @@ "./agent-jobs": "./agent-jobs.ts", "./config": "./config.ts", "./improvement-hooks": "./improvement-hooks.ts", + "./paths": "./paths.ts", "./worktree": "./worktree.ts", "./html-to-markdown": "./html-to-markdown.ts", "./url-to-markdown": "./url-to-markdown.ts" diff --git a/packages/shared/paths.ts b/packages/shared/paths.ts new file mode 100644 index 00000000..80190a8e --- /dev/null +++ b/packages/shared/paths.ts @@ -0,0 +1,40 @@ +/** + * XDG Base Directory helpers for plannotator. + * + * Each helper falls back to ~/.plannotator when that directory already exists, + * preserving backwards-compatibility for existing installations. + * + * New installations use the XDG Base Directory Specification: + * data → $XDG_DATA_HOME/plannotator (default: ~/.local/share/plannotator) + * state → $XDG_STATE_HOME/plannotator (default: ~/.local/state/plannotator) + * config → $XDG_CONFIG_HOME/plannotator (default: ~/.config/plannotator) + * + * Runtime-agnostic: uses only node:os, node:path, node:fs. + */ + +import { homedir } from "os"; +import { join } from "path"; +import { existsSync } from "fs"; + +const LEGACY_DIR = join(homedir(), ".plannotator"); + +/** Persistent user data: plans, version history. */ +export function getDataBase(): string { + if (existsSync(LEGACY_DIR)) return LEGACY_DIR; + const xdg = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); + return join(xdg, "plannotator"); +} + +/** Ephemeral state: drafts, sessions, debug logs. */ +export function getStateBase(): string { + if (existsSync(LEGACY_DIR)) return LEGACY_DIR; + const xdg = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"); + return join(xdg, "plannotator"); +} + +/** User configuration: config.json, improvement hooks. */ +export function getConfigBase(): string { + if (existsSync(LEGACY_DIR)) return LEGACY_DIR; + const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); + return join(xdg, "plannotator"); +} diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index 758634fe..5ca16032 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -1,7 +1,7 @@ /** * Plan Storage Utility * - * Saves plans and annotations to ~/.plannotator/plans/ + * Saves plans and annotations to the plannotator data directory. * Cross-platform: works on Windows, macOS, and Linux. * * Runtime-agnostic: uses only node:fs, node:path, node:os. @@ -11,6 +11,7 @@ import { homedir } from "os"; import { join, resolve, sep } from "path"; import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "fs"; import { sanitizeTag } from "./project"; +import { getDataBase } from "./paths"; /** * Get the plan storage directory, creating it if needed. @@ -26,7 +27,7 @@ export function getPlanDir(customPath?: string | null): string { ? join(homedir(), customPath.slice(1)) : customPath; } else { - planDir = join(homedir(), ".plannotator", "plans"); + planDir = join(getDataBase(), "plans"); } planDir = resolve(planDir); @@ -202,7 +203,7 @@ export function readArchivedPlan(filename: string, customPath?: string | null): * Not affected by the customPath setting (that only affects decision saves). */ export function getHistoryDir(project: string, slug: string): string { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataBase(), "history", project, slug); mkdirSync(historyDir, { recursive: true }); return historyDir; } @@ -269,7 +270,7 @@ export function getPlanVersion( slug: string, version: number ): string | null { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataBase(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); @@ -289,7 +290,7 @@ export function getPlanVersionPath( slug: string, version: number ): string | null { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataBase(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); return existsSync(filePath) ? filePath : null; @@ -300,7 +301,7 @@ export function getPlanVersionPath( * Returns 0 if the directory doesn't exist. */ export function getVersionCount(project: string, slug: string): number { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataBase(), "history", project, slug); try { const entries = readdirSync(historyDir); return entries.filter((e) => /^\d+\.md$/.test(e)).length; @@ -317,7 +318,7 @@ export function listVersions( project: string, slug: string ): Array<{ version: number; timestamp: string }> { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataBase(), "history", project, slug); try { const entries = readdirSync(historyDir); const versions: Array<{ version: number; timestamp: string }> = []; @@ -347,7 +348,7 @@ export function listVersions( export function listProjectPlans( project: string ): Array<{ slug: string; versions: number; lastModified: string }> { - const projectDir = join(homedir(), ".plannotator", "history", project); + const projectDir = join(getDataBase(), "history", project); try { const entries = readdirSync(projectDir, { withFileTypes: true }); const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];