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 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-02 14:20:37 -06:00
parent ba388dc382
commit cabf09e7b1
8 changed files with 176 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -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>", "Path to Paperclip config file")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "Base URL for the Paperclip API")

View File

@@ -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 <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "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 <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "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>", "Profile name")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "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 <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "Profile name (default: current profile)")
.option("--api-base <url>", "Default API base URL")

View File

@@ -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;
}

View File

@@ -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>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(onboard);
program
.command("doctor")
.description("Run diagnostic checks on your Paperclip setup")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", 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>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(envCommand);
program
.command("configure")
.description("Update configuration sections")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-s, --section <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("<host>", "Hostname to allow (for example dotta-macbook-pro)")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(addAllowedHostname);
program
.command("run")
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-i, --instance <id>", "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 <agentId>", "Agent ID to invoke")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "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>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--force", "Create new invite even if admin already exists", false)
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
.option("--base-url <url>", "Public base URL used to print invite link")