From cabf09e7b1b71133393d4cb221f1fc0b48025c51 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Mar 2026 14:20:37 -0600 Subject: [PATCH] feat(cli): add --data-dir flag to isolate local state Add --data-dir option to all CLI commands, allowing users to override the default ~/.paperclip root for config, context, database, logs, and storage. Includes preAction hook to auto-derive --config and --context paths when --data-dir is set. Add unit tests and doc updates. Co-Authored-By: Claude Opus 4.6 --- cli/src/__tests__/data-dir.test.ts | 79 ++++++++++++++++++++++++++++++ cli/src/commands/client/common.ts | 2 + cli/src/commands/client/context.ts | 5 ++ cli/src/config/data-dir.ts | 48 ++++++++++++++++++ cli/src/index.ts | 20 ++++++++ doc/CLI.md | 8 +++ docs/cli/overview.md | 7 +++ docs/cli/setup-commands.md | 7 +++ 8 files changed, 176 insertions(+) create mode 100644 cli/src/__tests__/data-dir.test.ts create mode 100644 cli/src/config/data-dir.ts diff --git a/cli/src/__tests__/data-dir.test.ts b/cli/src/__tests__/data-dir.test.ts new file mode 100644 index 00000000..520c71fd --- /dev/null +++ b/cli/src/__tests__/data-dir.test.ts @@ -0,0 +1,79 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { applyDataDirOverride } from "../config/data-dir.js"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("applyDataDirOverride", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_CONTEXT; + delete process.env.PAPERCLIP_INSTANCE_ID; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("sets PAPERCLIP_HOME and isolated default config/context paths", () => { + const home = applyDataDirOverride({ + dataDir: "~/paperclip-data", + config: undefined, + context: undefined, + }, { hasConfigOption: true, hasContextOption: true }); + + const expectedHome = path.resolve(os.homedir(), "paperclip-data"); + expect(home).toBe(expectedHome); + expect(process.env.PAPERCLIP_HOME).toBe(expectedHome); + expect(process.env.PAPERCLIP_CONFIG).toBe( + path.resolve(expectedHome, "instances", "default", "config.json"), + ); + expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json")); + expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default"); + }); + + it("uses the provided instance id when deriving default config path", () => { + const home = applyDataDirOverride({ + dataDir: "/tmp/paperclip-alt", + instance: "dev_1", + config: undefined, + context: undefined, + }, { hasConfigOption: true, hasContextOption: true }); + + expect(home).toBe(path.resolve("/tmp/paperclip-alt")); + expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1"); + expect(process.env.PAPERCLIP_CONFIG).toBe( + path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"), + ); + }); + + it("does not override explicit config/context settings", () => { + process.env.PAPERCLIP_CONFIG = "/env/config.json"; + process.env.PAPERCLIP_CONTEXT = "/env/context.json"; + + applyDataDirOverride({ + dataDir: "/tmp/paperclip-alt", + config: "/flag/config.json", + context: "/flag/context.json", + }, { hasConfigOption: true, hasContextOption: true }); + + expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json"); + expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json"); + }); + + it("only applies defaults for options supported by the command", () => { + applyDataDirOverride( + { + dataDir: "/tmp/paperclip-alt", + }, + { hasConfigOption: false, hasContextOption: false }, + ); + + expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt")); + expect(process.env.PAPERCLIP_CONFIG).toBeUndefined(); + expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined(); + }); +}); diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index fad92a74..10c943df 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -6,6 +6,7 @@ import { ApiRequestError, PaperclipApiClient } from "../../client/http.js"; export interface BaseClientOptions { config?: string; + dataDir?: string; context?: string; profile?: string; apiBase?: string; @@ -25,6 +26,7 @@ export interface ResolvedClientContext { export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command { command .option("-c, --config ", "Path to Paperclip config file") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Paperclip API") diff --git a/cli/src/commands/client/context.ts b/cli/src/commands/client/context.ts index a45ea845..8bba0ba5 100644 --- a/cli/src/commands/client/context.ts +++ b/cli/src/commands/client/context.ts @@ -10,6 +10,7 @@ import { import { printOutput } from "./common.js"; interface ContextOptions { + dataDir?: string; context?: string; profile?: string; json?: boolean; @@ -28,6 +29,7 @@ export function registerContextCommands(program: Command): void { context .command("show") .description("Show current context and active profile") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .option("--profile ", "Profile to inspect") .option("--json", "Output raw JSON") @@ -48,6 +50,7 @@ export function registerContextCommands(program: Command): void { context .command("list") .description("List available context profiles") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .option("--json", "Output raw JSON") .action((opts: ContextOptions) => { @@ -66,6 +69,7 @@ export function registerContextCommands(program: Command): void { .command("use") .description("Set active context profile") .argument("", "Profile name") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .action((profile: string, opts: ContextOptions) => { setCurrentProfile(profile, opts.context); @@ -75,6 +79,7 @@ export function registerContextCommands(program: Command): void { context .command("set") .description("Set values on a profile") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .option("--profile ", "Profile name (default: current profile)") .option("--api-base ", "Default API base URL") diff --git a/cli/src/config/data-dir.ts b/cli/src/config/data-dir.ts new file mode 100644 index 00000000..bb4b5f70 --- /dev/null +++ b/cli/src/config/data-dir.ts @@ -0,0 +1,48 @@ +import path from "node:path"; +import { + expandHomePrefix, + resolveDefaultConfigPath, + resolveDefaultContextPath, + resolvePaperclipInstanceId, +} from "./home.js"; + +export interface DataDirOptionLike { + dataDir?: string; + config?: string; + context?: string; + instance?: string; +} + +export interface DataDirCommandSupport { + hasConfigOption?: boolean; + hasContextOption?: boolean; +} + +export function applyDataDirOverride( + options: DataDirOptionLike, + support: DataDirCommandSupport = {}, +): string | null { + const rawDataDir = options.dataDir?.trim(); + if (!rawDataDir) return null; + + const resolvedDataDir = path.resolve(expandHomePrefix(rawDataDir)); + process.env.PAPERCLIP_HOME = resolvedDataDir; + + if (support.hasConfigOption) { + const hasConfigOverride = Boolean(options.config?.trim()) || Boolean(process.env.PAPERCLIP_CONFIG?.trim()); + if (!hasConfigOverride) { + const instanceId = resolvePaperclipInstanceId(options.instance); + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath(instanceId); + } + } + + if (support.hasContextOption) { + const hasContextOverride = Boolean(options.context?.trim()) || Boolean(process.env.PAPERCLIP_CONTEXT?.trim()); + if (!hasContextOverride) { + process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath(); + } + } + + return resolvedDataDir; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 0a5424c0..30f0f6bd 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -15,24 +15,38 @@ import { registerAgentCommands } from "./commands/client/agent.js"; import { registerApprovalCommands } from "./commands/client/approval.js"; import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; +import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; const program = new Command(); +const DATA_DIR_OPTION_HELP = + "Paperclip data directory root (isolates state from ~/.paperclip)"; program .name("paperclip") .description("Paperclip CLI — setup, diagnose, and configure your instance") .version("0.0.1"); +program.hook("preAction", (_thisCommand, actionCommand) => { + const options = actionCommand.optsWithGlobals() as DataDirOptionLike; + const optionNames = new Set(actionCommand.options.map((option) => option.attributeName())); + applyDataDirOverride(options, { + hasConfigOption: optionNames.has("config"), + hasContextOption: optionNames.has("context"), + }); +}); + program .command("onboard") .description("Interactive first-run setup wizard") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .action(onboard); program .command("doctor") .description("Run diagnostic checks on your Paperclip setup") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("--repair", "Attempt to repair issues automatically") .alias("--fix") .option("-y, --yes", "Skip repair confirmation prompts") @@ -44,12 +58,14 @@ program .command("env") .description("Print environment variables for deployment") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .action(envCommand); program .command("configure") .description("Update configuration sections") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("-s, --section
", "Section to configure (llm, database, logging, server, storage, secrets)") .action(configure); @@ -58,12 +74,14 @@ program .description("Allow a hostname for authenticated/private mode access") .argument("", "Hostname to allow (for example dotta-macbook-pro)") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .action(addAllowedHostname); program .command("run") .description("Bootstrap local setup (onboard + doctor) and run Paperclip") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("-i, --instance ", "Local instance id (default: default)") .option("--repair", "Attempt automatic repairs during doctor", true) .option("--no-repair", "Disable automatic repairs during doctor") @@ -76,6 +94,7 @@ heartbeat .description("Run one agent heartbeat and stream live logs") .requiredOption("-a, --agent-id ", "Agent ID to invoke") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("--context ", "Path to CLI context file") .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Paperclip server API") @@ -105,6 +124,7 @@ auth .command("bootstrap-ceo") .description("Create a one-time bootstrap invite URL for first instance admin") .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("--force", "Create new invite even if admin already exists", false) .option("--expires-hours ", "Invite expiration window in hours", (value) => Number(value)) .option("--base-url ", "Public base URL used to print invite link") diff --git a/doc/CLI.md b/doc/CLI.md index 05dbda02..04a953d1 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -45,6 +45,7 @@ pnpm paperclip allowed-hostname dotta-macbook-pro All client commands support: +- `--data-dir ` - `--api-base ` - `--api-key ` - `--context ` @@ -53,6 +54,13 @@ All client commands support: Company-scoped commands also support `--company-id `. +Use `--data-dir` on any CLI command to isolate all default local state (config/context/db/logs/storage/secrets) away from `~/.paperclip`: + +```sh +pnpm paperclip run --data-dir ./tmp/paperclip-dev +pnpm paperclip issue list --data-dir ./tmp/paperclip-dev +``` + ## Context Profiles Store local defaults in `~/.paperclip/context.json`: diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 2727ca67..3d79d3c6 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -19,6 +19,7 @@ All commands support: | Flag | Description | |------|-------------| +| `--data-dir ` | Local Paperclip data root (isolates from `~/.paperclip`) | | `--api-base ` | API base URL | | `--api-key ` | API authentication token | | `--context ` | Context file path | @@ -27,6 +28,12 @@ All commands support: Company-scoped commands also accept `--company-id `. +For clean local instances, pass `--data-dir` on the command you run: + +```sh +pnpm paperclip run --data-dir ./tmp/paperclip-dev +``` + ## Context Profiles Store defaults to avoid repeating flags: diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index d86e65ae..2517773f 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -100,3 +100,10 @@ Override with: ```sh PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run ``` + +Or pass `--data-dir` directly on any command: + +```sh +pnpm paperclip run --data-dir ./tmp/paperclip-dev +pnpm paperclip doctor --data-dir ./tmp/paperclip-dev +```