diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx index bf3baa8a472..843383817fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx @@ -10,6 +10,8 @@ import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { LuCheck, + LuCircle, + LuCircleCheck, LuCopy, LuEllipsis, LuPlus, @@ -86,12 +88,16 @@ function creditsLine(account: UsageAccount): string | null { return null; } +const DEFAULT_TITLE = + "New agent launches use this account. Relaunch a running agent to switch it."; + function AccountCard({ account, onMakeDefault, onSwitchSignIn, onRemove, isSwitching, + selectable, }: { account: UsageAccount; onMakeDefault: () => void; @@ -99,14 +105,41 @@ function AccountCard({ /** Null on the system-default card — the main login is never removable. */ onRemove: (() => void) | null; isSwitching: boolean; + /** True when the provider has several accounts, so the cards read as a + * radio group: the default gets a check + accent border, the rest get a + * selectable circle. */ + selectable: boolean; }) { const credits = creditsLine(account); const { copyToClipboard, copied } = useCopyToClipboard(); const expiredCommand = account.status === "token_expired" ? switchSignInCommand(account) : null; return ( -
+
+ {selectable && + (account.isDefault ? ( + + + + ) : ( + + ))} {account.email ?? PROVIDER_LABELS[account.provider]} @@ -124,38 +157,17 @@ function AccountCard({ : "Unavailable"} )} - {account.isDefault ? ( - - Default - - ) : ( - - )} {/* Source label always shows — it is the only thing that tells two profiles of the same account apart. */} - {credits - ? `${credits} · ${account.sourceLabel}` - : account.sourceLabel} + {account.sourceLabel} @@ -205,6 +217,39 @@ function AccountCard({ {account.statusDetail ?? "Usage unavailable."}
)} + {/* The radio + accent border already mark the default when the cards + read as a group; the footer label only carries it for a lone card. */} + {(!account.isDefault || !selectable || credits) && ( +
+ {account.isDefault ? ( + !selectable && ( + + + Default for new agents + + ) + ) : ( + + )} + {credits && ( + + {credits} + + )} +
+ )}
); } @@ -231,10 +276,9 @@ export function UsageView({ hostUrl }: { hostUrl: string | null }) { { onSuccess: () => { toast.success( - `New ${PROVIDER_LABELS[account.provider]} terminals and agents will use ${account.email ?? account.sourceLabel}.`, + `New ${PROVIDER_LABELS[account.provider]} agents will use ${account.email ?? account.sourceLabel}.`, { - description: - "Running sessions keep their current account — restart them to switch.", + description: "Relaunch running agents to switch them.", }, ); }, @@ -333,6 +377,7 @@ export function UsageView({ hostUrl }: { hostUrl: string | null }) { : () => setRemoveTarget(account) } isSwitching={setDefault.isPending} + selectable={providerAccounts.length > 1} /> ))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx index 22496237690..5ea3acd8948 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx @@ -155,13 +155,14 @@ export function AddAccountDialog({ } if (found) return; - const seedProfile = (configDir: string) => { - // A fresh (or re-signed) Claude profile may have the CLI's first-boot - // wizard armed; mark onboarding done so its first agent launch opens - // the prompt. + const provisionProfile = (selection: string) => { + // Shares the default account's skills, plugins, MCP servers, and + // settings into the new profile dir — a bare login there would boot + // the CLI on an empty install (and, for Claude, into the first-boot + // wizard). if (!hostUrl) return; void getHostServiceClientByUrl(hostUrl) - .usage.prepareClaudeProfile.mutate({ configDir }) + .usage.prepareAccount.mutate({ provider, selection }) .catch(() => {}); }; @@ -174,9 +175,8 @@ export function AddAccountDialog({ if (change) { setFound({ selection: null, label: change.label }); onAccountAdded(); - if (switchTarget.provider === "claude" && switchTarget.selection) { - seedProfile(switchTarget.selection); - } + // The system default (null) is the share's source, not a target. + if (switchTarget.selection) provisionProfile(switchTarget.selection); } return; } @@ -184,7 +184,7 @@ export function AddAccountDialog({ if (fresh) { setFound(fresh); onAccountAdded(); - if (provider === "claude") seedProfile(fresh.selection); + provisionProfile(fresh.selection); } }, [ open, @@ -225,7 +225,7 @@ export function AddAccountDialog({ {switchDescription ?? - "Sign in to a second subscription as a separate profile. Your current login is untouched."} + "Sign in to a second subscription as a separate profile. Your current login is untouched, and the new profile shares your skills, plugins, MCP servers, and settings."} diff --git a/apps/docs/content/docs/usage.mdx b/apps/docs/content/docs/usage.mdx index 2779cbbf6b4..9589dbcb515 100644 --- a/apps/docs/content/docs/usage.mdx +++ b/apps/docs/content/docs/usage.mdx @@ -1,6 +1,6 @@ --- title: Usage -description: Subscription quota meters, token-cost analytics, and machine resources +description: Subscription quota meters, token-cost analytics, machine resources, and multi-account switching --- ## Overview @@ -19,11 +19,16 @@ Quota comes from the providers' own usage endpoints using your existing sign-ins Run different agents on different subscriptions from one machine: +![Two Claude Code accounts with quota meters, one marked as the default](/images/usage-accounts.png) + - **Add account** creates a second profile: pick a profile name, then run the shown sign-in command (e.g. `CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth login`) in any terminal on this host. The dialog detects the sign-in automatically. -- **Make default** (hover an account card) routes **new** terminals and agents to that account. Running sessions keep their current account until restarted. -- The **…** menu on a card offers **Switch sign-in…** (re-login that profile to a different account) and, for secondary profiles, **Remove…**. The system-default login can't be removed. +- **Make default** picks which account new agent launches use — when a provider has several accounts, the cards read as a radio group with the default checked. Switching reaches terminals you already have open too: a running agent keeps its account until you relaunch it. +- Your setup comes along: skills, plugins, MCP servers, and settings are shared across accounts, and for Claude Code there's one conversation history — `--resume` shows the same sessions whichever account is active. +- Logins, credentials, and account identity stay per account. The **…** menu on a card offers **Switch sign-in…** (re-login that profile to a different account) and, for secondary profiles, **Remove…** — which deletes that profile and its credentials, never your shared setup or history. The system-default login can't be removed. - If a sign-in expires, the card shows the exact login command to run, with click-to-copy. +Under the hood, a profile is just a config dir the CLI is pointed at (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`). Superset's agent wrappers re-resolve the current default every time the agent starts in a Superset terminal — so switching works for agents you launch by hand too, and a value you export yourself always wins. + ## Token Costs Below the meters, the **Token usage** chart estimates spend from the agents' local session logs, priced at API list rates. Subscription usage isn't billed per token (the page shows **Cost to you: $0**); the estimate is what the same work would cost at API rates, plus how much caching saved. diff --git a/apps/docs/public/images/usage-accounts.png b/apps/docs/public/images/usage-accounts.png new file mode 100644 index 00000000000..c6343cf5fd2 Binary files /dev/null and b/apps/docs/public/images/usage-accounts.png differ diff --git a/docs/agent-tooling.md b/docs/agent-tooling.md index 5022cacfb0d..49c5c56ba2f 100644 --- a/docs/agent-tooling.md +++ b/docs/agent-tooling.md @@ -34,6 +34,40 @@ Everything else links to those: Agents other than Claude Code should read the relevant `.agents/skills/*/SKILL.md` when its description matches the task. +## Provider accounts (multi-login) + +The Usage tab can hold several Claude Code / Codex logins and pick which one agents use. A login +is just a config dir the CLI is pointed at — `CLAUDE_CONFIG_DIR` / `CODEX_HOME`, injected at PTY +and agent launch by `packages/host-service/src/trpc/router/usage/default-account.ts`, which also +publishes the selection to `$SUPERSET_HOME_DIR/state/default-*` pointer files. The agent wrappers +re-read those at every launch (`buildDefaultAccountResolver` in agent-setup), so a switch reaches +existing terminals the next time the agent starts; a value the user exported by hand always wins. +Superset never touches the credential stores; the provider CLIs own every login end to end. + +Because those CLIs read *everything* from their active config dir, a second dir would otherwise +mean a second, empty setup. `packages/agent-setup/src/provider-profiles.ts` provisions each +non-default profile from the default account (`~/.claude`, `~/.codex`): + +- Capability directories (`skills/`, `plugins/`, `agents/`, `commands/`, `output-styles/`; + `prompts/` for Codex) are **symlinked** at the default account's, so anything installed later is + shared with no sync step. +- Files (`CLAUDE.md`, `config.toml`, `AGENTS.md`) are **copied**, and `settings.json` / + `.claude.json` are **key-merged** — the CLIs rename-replace files, which would silently break a + file symlink. +- Copies and merged keys are recorded in `/.superset-profile.json`, so anything the user + changes inside a profile is never overwritten by a later provision. +- Claude session state (`projects/`, `sessions/`, `history.jsonl`, …) is shared too — by symlink, + which is safe there because transcripts are append-only — so every account sees one + conversation history and `--resume` list + (`packages/host-service/src/trpc/router/usage/session-share.ts`; existing trees are merged in, + live sessions included). +- Per-account and never shared: credentials, `oauthAccount`/`userID` identity and per-project + state in `.claude.json`, auth-related settings keys, and Superset's own lifecycle hooks + (written per profile). + +Provisioning is idempotent and runs when an account is added, when one is selected, and at host +boot for the selected accounts (`usage/account-provisioning.ts`). + ## Testing the CLI and skills against the dev app The dev desktop app is local-first: it registers its host service under a diff --git a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts index 35aa5f5a56f..b629a2f3cc7 100644 --- a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts +++ b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + buildDefaultAccountResolver, buildWrapperScript, createWrapper, getManagedNotifyHookCommand, @@ -251,25 +252,32 @@ export function getOpenCodePluginContent(notifyPath: string): string { } /** - * Pass-through wrapper for Claude. Hooks live in ~/.claude/settings.json - * (createClaudeSettingsJson); the wrapper exists only to forward SUPERSET_* - * env vars into the agent process tree. + * Wrapper for Claude: forwards SUPERSET_* env vars into the agent process + * tree and follows the Usage-tab account switch at launch time. Hooks live + * in ~/.claude/settings.json (createClaudeSettingsJson). */ export function createClaudeWrapper(): void { - const script = buildWrapperScript("claude", `exec "$REAL_BIN" "$@"`, { - agentId: "claude", - }); + const script = buildWrapperScript( + "claude", + buildDefaultAccountResolver( + "CLAUDE_CONFIG_DIR", + "default-claude-config-dir", + ) + `exec "$REAL_BIN" "$@"`, + { agentId: "claude" }, + ); createWrapper("claude", script); } /** - * Creates the Codex wrapper that enables native hooks and session-log signals. + * Creates the Codex wrapper that enables native hooks and session-log signals, + * and follows the Usage-tab account switch at launch time. */ export function createCodexWrapper(): void { const notifyPath = getNotifyScriptPath(); const script = buildWrapperScript( "codex", - buildCodexWrapperExecLine(notifyPath), + buildDefaultAccountResolver("CODEX_HOME", "default-codex-home") + + buildCodexWrapperExecLine(notifyPath), { agentId: "codex" }, ); createWrapper("codex", script); diff --git a/packages/agent-setup/src/agent-wrappers-common.ts b/packages/agent-setup/src/agent-wrappers-common.ts index 52b4749d625..04cc7452d8e 100644 --- a/packages/agent-setup/src/agent-wrappers-common.ts +++ b/packages/agent-setup/src/agent-wrappers-common.ts @@ -105,6 +105,34 @@ function buildRealBinaryResolver(): string { `; } +/** + * Shell block that re-resolves the Usage-tab default account at launch. + * The PTY env is frozen at terminal spawn, so an account switch would + * otherwise reach only brand-new terminals; this re-reads the host's + * pointer file every time the agent starts instead. Superset terminals + * only, and a value the user exported by hand — one that differs from what + * Superset injected at spawn — always wins. A missing pointer file (older + * host build) changes nothing; an empty one means the system default. + */ +export function buildDefaultAccountResolver( + envVar: string, + pointerName: string, +): string { + const pointer = `"$SUPERSET_HOME_DIR/state/${pointerName}"`; + return `if [ -n "$SUPERSET_TERMINAL_ID" ] && [ -n "$SUPERSET_HOME_DIR" ] \\ + && { [ -z "\${${envVar}}" ] || [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]; } \\ + && [ -f ${pointer} ]; then + superset_default_account="$(cat ${pointer} 2>/dev/null)" + if [ -n "$superset_default_account" ] && [ -d "$superset_default_account" ]; then + export ${envVar}="$superset_default_account" + else + unset ${envVar} + fi +fi + +`; +} + function getMissingBinaryMessage(name: string): string { return `Superset: ${name} not found in PATH. Install it and ensure it is on PATH, then retry.`; } diff --git a/packages/agent-setup/src/agent-wrappers.test.ts b/packages/agent-setup/src/agent-wrappers.test.ts index dbed78030a0..04cce83385f 100644 --- a/packages/agent-setup/src/agent-wrappers.test.ts +++ b/packages/agent-setup/src/agent-wrappers.test.ts @@ -173,7 +173,11 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain( 'projects={\\"$_superset_workspace_path_toml\\"={trust_level=\\"trusted\\"}}', ); - expect(wrapper).not.toContain("export CODEX_HOME="); + // The Usage-tab default resolver may export CODEX_HOME dynamically from + // the pointer file, but the wrapper must never hardcode a home. + expect(wrapper).toContain("state/default-codex-home"); + expect(wrapper).toContain('export CODEX_HOME="$superset_default_account"'); + expect(wrapper).not.toContain('export CODEX_HOME="$HOME'); expect(wrapper).not.toContain("rollout-*.jsonl"); expect(wrapper).not.toContain("_superset_sessions_dir"); expect(wrapper).not.toContain("$" + "{CODEX_HOME:-$HOME/.codex}"); diff --git a/packages/agent-setup/src/default-account-resolver.test.ts b/packages/agent-setup/src/default-account-resolver.test.ts new file mode 100644 index 00000000000..a8b4e1b4390 --- /dev/null +++ b/packages/agent-setup/src/default-account-resolver.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildDefaultAccountResolver } from "./agent-wrappers-common"; + +/** Runs the resolver block under bash and returns the resulting env var. */ +function resolve(env: Record): string { + const script = `${buildDefaultAccountResolver( + "CLAUDE_CONFIG_DIR", + "default-claude-config-dir", + )}printf "%s" "\${CLAUDE_CONFIG_DIR:-}"`; + const cleanEnv: Record = { PATH: process.env.PATH ?? "" }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) cleanEnv[key] = value; + } + return execFileSync("bash", ["-c", script], { + env: cleanEnv, + encoding: "utf-8", + }); +} + +function makeHome(pointer: string | null): { + home: string; + profile: string; +} { + const home = mkdtempSync(join(tmpdir(), "superset-resolver-")); + const profile = join(home, "profile"); + mkdirSync(join(home, "state"), { recursive: true }); + mkdirSync(profile); + if (pointer !== null) { + writeFileSync(join(home, "state", "default-claude-config-dir"), pointer); + } + return { home, profile }; +} + +describe("buildDefaultAccountResolver", () => { + it("adopts the pointer in a Superset terminal with no spawn-time value", () => { + const { home, profile } = makeHome(""); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + expect( + resolve({ SUPERSET_TERMINAL_ID: "t1", SUPERSET_HOME_DIR: home }), + ).toBe(profile); + }); + + it("re-resolves over a stale Superset-injected value", () => { + const { home, profile } = makeHome(null); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + expect( + resolve({ + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: "/tmp/old-spawn-time-default", + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: "/tmp/old-spawn-time-default", + }), + ).toBe(profile); + }); + + it("clears a stale injected value when the pointer says system default", () => { + const { home, profile } = makeHome(""); + expect( + resolve({ + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: profile, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }), + ).toBe(""); + }); + + it("never overrides a value the user exported by hand", () => { + const { home, profile } = makeHome(null); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + expect( + resolve({ + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: "/tmp/user-picked-this", + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }), + ).toBe("/tmp/user-picked-this"); + }); + + it("does nothing outside Superset terminals", () => { + const { home, profile } = makeHome(null); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + expect(resolve({ SUPERSET_HOME_DIR: home })).toBe(""); + }); + + it("does nothing when the pointer file is missing (older host build)", () => { + const { home } = makeHome(null); + expect( + resolve({ + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: "/tmp/spawn-time", + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: "/tmp/spawn-time", + }), + ).toBe("/tmp/spawn-time"); + }); + + it("ignores a pointer at a vanished dir instead of booting signed out", () => { + const { home } = makeHome("/tmp/deleted-profile-dir-that-is-gone"); + expect( + resolve({ SUPERSET_TERMINAL_ID: "t1", SUPERSET_HOME_DIR: home }), + ).toBe(""); + }); +}); diff --git a/packages/agent-setup/src/index.ts b/packages/agent-setup/src/index.ts index b3c1d0ac48a..2dbbee19a2b 100644 --- a/packages/agent-setup/src/index.ts +++ b/packages/agent-setup/src/index.ts @@ -60,6 +60,11 @@ export { ensureClaudeManagedHooksAt, ensureCodexManagedHooksAt, } from "./agent-wrappers-claude-codex-opencode"; +export { + type ProfileProvisionReport, + provisionClaudeProfile, + provisionCodexProfile, +} from "./provider-profiles"; export { getCommandShellArgs, getShellArgs, getShellEnv }; diff --git a/packages/agent-setup/src/managed-skills.ts b/packages/agent-setup/src/managed-skills.ts index 54a1ab6105a..7e6f5801bbd 100644 --- a/packages/agent-setup/src/managed-skills.ts +++ b/packages/agent-setup/src/managed-skills.ts @@ -120,17 +120,17 @@ async function syncDir(src: string, dest: string): Promise { } } -/** Provisions the bundled plugin as a Claude Code skills-directory plugin. */ +/** + * Provisions the bundled plugin as a Claude Code skills-directory plugin + * inside one Claude config dir — `~/.claude` for the default account, or a + * profile dir whose own `skills/` the user owns (a shared profile links + * `skills/` at the default account instead, and gets it that way). + */ async function provisionClaudePlugin( bundledPluginDir: string, - homeDir: string, + claudeDir: string, ): Promise { - const target = path.join( - homeDir, - ".claude", - "skills", - CLAUDE_PLUGIN_DIR_NAME, - ); + const target = path.join(claudeDir, "skills", CLAUDE_PLUGIN_DIR_NAME); const sentinel = path.join(target, MANAGED_SENTINEL_NAME); if (fs.existsSync(target) && !fs.existsSync(sentinel)) { console.log(`[agent-setup] Skipping user-owned plugin dir at ${target}`); @@ -245,7 +245,10 @@ export async function createManagedSkills( ); try { - await provisionClaudePlugin(bundledPluginDir, homeDir); + await provisionClaudePlugin( + bundledPluginDir, + path.join(homeDir, ".claude"), + ); } catch (error) { console.warn("[agent-setup] Failed to provision Claude plugin:", error); } @@ -331,3 +334,25 @@ export async function createManagedSkills( console.log("[agent-setup] Managed skills provisioned"); } + +/** + * Provisions the bundled Superset plugin into one Claude config dir. Used for + * a secondary account whose `skills/` directory the user owns, so it can't be + * linked at the default account's — the plugin is written into it directly + * instead of being shared. + */ +export async function provisionManagedClaudePluginAt( + claudeDir: string, + options: ManagedSkillsOptions = {}, +): Promise { + const bundledPluginDir = options.templatesDir + ? path.join(options.templatesDir, "plugin") + : getBundledPluginDir(); + if (!fs.existsSync(path.join(bundledPluginDir, "skills"))) { + console.warn( + `[agent-setup] Bundled plugin missing at ${bundledPluginDir}; skipping plugin provisioning for ${claudeDir}`, + ); + return; + } + await provisionClaudePlugin(bundledPluginDir, claudeDir); +} diff --git a/packages/agent-setup/src/profile-sharing.test.ts b/packages/agent-setup/src/profile-sharing.test.ts new file mode 100644 index 00000000000..afe469c7291 --- /dev/null +++ b/packages/agent-setup/src/profile-sharing.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + linkSharedDir, + mergeSharedJsonKeys, + type ProfileLedger, + readProfileLedger, + syncSharedFile, + writeProfileLedger, +} from "./profile-sharing"; + +const TEST_ROOT = path.join( + os.tmpdir(), + `superset-profile-sharing-${process.pid}-${Date.now()}`, +); +const SOURCE = path.join(TEST_ROOT, "default"); +const PROFILE = path.join(TEST_ROOT, "profile"); + +function ledger(): ProfileLedger { + return readProfileLedger(PROFILE, SOURCE); +} + +beforeEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(SOURCE, { recursive: true }); + mkdirSync(PROFILE, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe("linkSharedDir", () => { + it("links a free path and is idempotent", () => { + mkdirSync(path.join(SOURCE, "skills", "demo"), { recursive: true }); + writeFileSync(path.join(SOURCE, "skills", "demo", "SKILL.md"), "hi"); + + const target = path.join(PROFILE, "skills"); + expect(linkSharedDir(path.join(SOURCE, "skills"), target)).toBe("linked"); + expect(lstatSync(target).isSymbolicLink()).toBe(true); + expect(readFileSync(path.join(target, "demo", "SKILL.md"), "utf-8")).toBe( + "hi", + ); + expect(linkSharedDir(path.join(SOURCE, "skills"), target)).toBe( + "unchanged", + ); + }); + + it("shares later additions without re-provisioning", () => { + mkdirSync(path.join(SOURCE, "skills"), { recursive: true }); + linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")); + + mkdirSync(path.join(SOURCE, "skills", "added"), { recursive: true }); + expect(existsSync(path.join(PROFILE, "skills", "added"))).toBe(true); + }); + + it("reports absent when the default account has no such dir", () => { + expect( + linkSharedDir( + path.join(SOURCE, "plugins"), + path.join(PROFILE, "plugins"), + ), + ).toBe("absent"); + expect(existsSync(path.join(PROFILE, "plugins"))).toBe(false); + }); + + it("adopts an empty dir but never one with content", () => { + mkdirSync(path.join(SOURCE, "skills"), { recursive: true }); + mkdirSync(path.join(PROFILE, "skills"), { recursive: true }); + expect( + linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")), + ).toBe("linked"); + + rmSync(path.join(PROFILE, "skills"), { recursive: true, force: true }); + mkdirSync(path.join(PROFILE, "skills", "mine"), { recursive: true }); + expect( + linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")), + ).toBe("user-owned"); + expect(lstatSync(path.join(PROFILE, "skills")).isDirectory()).toBe(true); + expect(lstatSync(path.join(PROFILE, "skills")).isSymbolicLink()).toBe( + false, + ); + }); + + it("leaves a link the user aimed elsewhere, replaces a dangling one", () => { + mkdirSync(path.join(SOURCE, "skills"), { recursive: true }); + const elsewhere = path.join(TEST_ROOT, "elsewhere"); + mkdirSync(elsewhere, { recursive: true }); + symlinkSync(elsewhere, path.join(PROFILE, "skills"), "dir"); + expect( + linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")), + ).toBe("user-owned"); + expect(readlinkSync(path.join(PROFILE, "skills"))).toBe(elsewhere); + + rmSync(elsewhere, { recursive: true, force: true }); + expect( + linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")), + ).toBe("linked"); + }); +}); + +describe("syncSharedFile", () => { + const args = () => ({ + sourcePath: path.join(SOURCE, "CLAUDE.md"), + targetPath: path.join(PROFILE, "CLAUDE.md"), + surface: "CLAUDE.md", + ledger: ledger(), + }); + + it("copies, then reports unchanged", () => { + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "be brief"); + const first = args(); + expect(syncSharedFile(first)).toBe("synced"); + expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe( + "be brief", + ); + expect(syncSharedFile({ ...first, ledger: first.ledger })).toBe( + "unchanged", + ); + }); + + it("re-syncs its own copy when the default account changes", () => { + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v1"); + const state = args(); + syncSharedFile(state); + writeProfileLedger(PROFILE, state.ledger); + + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v2"); + expect(syncSharedFile(args())).toBe("synced"); + expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe("v2"); + }); + + it("never clobbers an edit made inside the profile", () => { + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v1"); + const state = args(); + syncSharedFile(state); + writeProfileLedger(PROFILE, state.ledger); + + writeFileSync(path.join(PROFILE, "CLAUDE.md"), "mine"); + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v2"); + expect(syncSharedFile(args())).toBe("user-owned"); + expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe("mine"); + }); + + it("leaves a pre-existing file the profile already had", () => { + writeFileSync(path.join(SOURCE, "CLAUDE.md"), "shared"); + writeFileSync(path.join(PROFILE, "CLAUDE.md"), "theirs"); + expect(syncSharedFile(args())).toBe("user-owned"); + }); +}); + +describe("mergeSharedJsonKeys", () => { + const settings = () => ({ + sourcePath: path.join(SOURCE, "settings.json"), + targetPath: path.join(PROFILE, "settings.json"), + surface: "settings.json", + ledger: ledger(), + pick: (source: Record) => source, + }); + + function readTarget(): Record { + return JSON.parse( + readFileSync(path.join(PROFILE, "settings.json"), "utf-8"), + ); + } + + it("copies shared keys and preserves keys the profile owns", () => { + writeFileSync( + path.join(SOURCE, "settings.json"), + JSON.stringify({ model: "opus", effortLevel: "high" }), + ); + writeFileSync( + path.join(PROFILE, "settings.json"), + JSON.stringify({ hooks: { Stop: [] } }), + ); + + const state = settings(); + expect(mergeSharedJsonKeys(state)).toBe("merged"); + writeProfileLedger(PROFILE, state.ledger); + expect(readTarget()).toEqual({ + hooks: { Stop: [] }, + model: "opus", + effortLevel: "high", + }); + expect(mergeSharedJsonKeys(settings())).toBe("unchanged"); + }); + + it("keeps a value the user changed inside the profile", () => { + writeFileSync( + path.join(SOURCE, "settings.json"), + JSON.stringify({ model: "opus" }), + ); + const state = settings(); + mergeSharedJsonKeys(state); + writeProfileLedger(PROFILE, state.ledger); + + writeFileSync( + path.join(PROFILE, "settings.json"), + JSON.stringify({ model: "sonnet" }), + ); + writeFileSync( + path.join(SOURCE, "settings.json"), + JSON.stringify({ model: "fable" }), + ); + expect(mergeSharedJsonKeys(settings())).toBe("unchanged"); + expect(readTarget().model).toBe("sonnet"); + }); + + it("skips a state file the profile does not have yet", () => { + writeFileSync( + path.join(SOURCE, "settings.json"), + JSON.stringify({ mcpServers: { linear: {} } }), + ); + expect( + mergeSharedJsonKeys({ + ...settings(), + force: { hasCompletedOnboarding: true }, + requireExistingTarget: true, + }), + ).toBe("absent"); + expect(existsSync(path.join(PROFILE, "settings.json"))).toBe(false); + }); + + it("re-applies forced values even when they were changed", () => { + writeFileSync(path.join(SOURCE, "settings.json"), JSON.stringify({})); + writeFileSync( + path.join(PROFILE, "settings.json"), + JSON.stringify({ hasCompletedOnboarding: false }), + ); + expect( + mergeSharedJsonKeys({ + ...settings(), + force: { hasCompletedOnboarding: true }, + requireExistingTarget: true, + }), + ).toBe("merged"); + expect(readTarget().hasCompletedOnboarding).toBe(true); + }); + + it("leaves an unparsable profile file alone", () => { + writeFileSync( + path.join(SOURCE, "settings.json"), + JSON.stringify({ model: "opus" }), + ); + writeFileSync(path.join(PROFILE, "settings.json"), "{ not json"); + expect(mergeSharedJsonKeys(settings())).toBe("user-owned"); + expect(readFileSync(path.join(PROFILE, "settings.json"), "utf-8")).toBe( + "{ not json", + ); + }); +}); diff --git a/packages/agent-setup/src/profile-sharing.ts b/packages/agent-setup/src/profile-sharing.ts new file mode 100644 index 00000000000..1c83248aac6 --- /dev/null +++ b/packages/agent-setup/src/profile-sharing.ts @@ -0,0 +1,298 @@ +/** + * Shared-config plumbing behind provider "accounts". + * + * A secondary account is just a config dir the provider CLI gets pointed at + * (CLAUDE_CONFIG_DIR / CODEX_HOME), so switching to one handed the CLI a + * blank install: no skills, no plugins, no MCP servers, none of the user's + * settings. These primitives make a profile dir mirror the default account + * for everything that isn't a login. + * + * Two mechanisms, picked by what the CLI does to the path: + * + * - Directories are symlinked at the default account's dir. The share stays + * live — a skill or plugin installed later shows up in every account with + * no sync step — and a directory can't be atomically replaced out from + * under the link. + * - Files are copied, and JSON files key-merged, because the CLIs write them + * with write-tmp-then-rename: a symlink would be replaced by a regular + * file on the first write and the config would silently fork. + * + * Copies and merges record what they wrote in a per-profile ledger, so a + * value the user changed inside the profile is never clobbered by the next + * provision. Nothing here reads or writes credentials. + */ + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { writeFileIfChanged } from "./agent-wrappers-common"; + +/** Per-profile record of what we last wrote, kept inside the profile dir. */ +export const PROFILE_LEDGER_NAME = ".superset-profile.json"; + +export type SurfaceOutcome = + /** A symlink now points the profile at the default account's dir. */ + | "linked" + /** Content copied from the default account. */ + | "synced" + /** JSON keys copied from the default account. */ + | "merged" + /** Already matched the default account. */ + | "unchanged" + /** The profile has its own version — left untouched. */ + | "user-owned" + /** Nothing to share: the default account has no such surface. */ + | "absent"; + +export interface ProfileLedger { + version: 1; + /** Default-account dir these values were shared from. */ + sharedFrom: string; + /** Surface name -> sha256 of the content we last wrote. */ + files: Record; + /** Surface name -> key -> serialized value we last wrote. */ + keys: Record>; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function readJsonObject(filePath: string): Record | null { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); + return isPlainObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function readProfileLedger( + profileDir: string, + sharedFrom: string, +): ProfileLedger { + const empty: ProfileLedger = { + version: 1, + sharedFrom, + files: {}, + keys: {}, + }; + const stored = readJsonObject(path.join(profileDir, PROFILE_LEDGER_NAME)); + if (!stored) return empty; + return { + version: 1, + sharedFrom, + files: isPlainObject(stored.files) + ? (stored.files as Record) + : {}, + keys: isPlainObject(stored.keys) + ? (stored.keys as Record>) + : {}, + }; +} + +export function writeProfileLedger( + profileDir: string, + ledger: ProfileLedger, +): void { + try { + writeFileIfChanged( + path.join(profileDir, PROFILE_LEDGER_NAME), + JSON.stringify(ledger, null, 2), + 0o644, + ); + } catch (error) { + console.warn( + `[agent-setup] Could not write the profile ledger in ${profileDir}:`, + error, + ); + } +} + +function lstatOrNull(target: string): fs.Stats | null { + try { + return fs.lstatSync(target); + } catch { + return null; + } +} + +function realpathOrNull(target: string): string | null { + try { + return fs.realpathSync(target); + } catch { + return null; + } +} + +/** + * Points `targetPath` at `sourceDir` with a symlink. Only ever claims a path + * that is free: an existing real directory with anything in it, or a symlink + * the user aimed somewhere else, is reported as user-owned and left alone. + */ +export function linkSharedDir( + sourceDir: string, + targetPath: string, +): SurfaceOutcome { + const source = realpathOrNull(sourceDir); + if (source === null) return "absent"; + + const existing = lstatOrNull(targetPath); + if (existing?.isSymbolicLink()) { + const current = realpathOrNull(targetPath); + if (current === source) return "unchanged"; + // A link the user aimed elsewhere is deliberate; a dangling one is + // debris (a profile whose old share target was deleted) and ours to + // replace. + if (current !== null) return "user-owned"; + fs.unlinkSync(targetPath); + } else if (existing) { + if (!existing.isDirectory()) return "user-owned"; + if (fs.readdirSync(targetPath).length > 0) return "user-owned"; + fs.rmdirSync(targetPath); + } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + // "junction" is the one dir link Windows grants without elevation. + fs.symlinkSync( + source, + targetPath, + process.platform === "win32" ? "junction" : "dir", + ); + return "linked"; +} + +export interface SyncSharedFileArgs { + sourcePath: string; + targetPath: string; + /** Ledger key, normally the file name. */ + surface: string; + ledger: ProfileLedger; +} + +/** + * Copies a file from the default account into the profile. The profile's copy + * is only overwritten while it still holds what we last put there — once the + * user edits it inside the profile, that edit wins from then on. + */ +export function syncSharedFile({ + sourcePath, + targetPath, + surface, + ledger, +}: SyncSharedFileArgs): SurfaceOutcome { + let content: string; + try { + content = fs.readFileSync(sourcePath, "utf-8"); + } catch { + return "absent"; + } + const hash = sha256(content); + + if (fs.existsSync(targetPath)) { + let current: string; + try { + current = fs.readFileSync(targetPath, "utf-8"); + } catch { + return "user-owned"; + } + const currentHash = sha256(current); + if (currentHash === hash) { + ledger.files[surface] = hash; + return "unchanged"; + } + if (ledger.files[surface] !== currentHash) return "user-owned"; + } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileIfChanged(targetPath, content, 0o644); + ledger.files[surface] = hash; + return "synced"; +} + +export interface MergeSharedJsonKeysArgs { + sourcePath: string; + targetPath: string; + /** Ledger key, normally the file name. */ + surface: string; + ledger: ProfileLedger; + /** Shared subset of the default account's file. */ + pick(source: Record): Record; + /** + * Values written regardless of the default account — facts about being a + * secondary profile rather than shared config, so they are not tracked in + * the ledger and are re-applied whenever they drift. + */ + force?: Record; + /** + * Skip entirely when the profile has no readable file yet. Set for state + * files the CLI owns end to end (`.claude.json`), where creating one + * ourselves would fabricate a login that has not happened. + */ + requireExistingTarget?: boolean; +} + +/** + * Copies selected top-level keys from the default account's JSON file into + * the profile's. Per key, the same rule as syncSharedFile: we only overwrite + * a value that is still the one we wrote. A key we wrote and the user then + * deleted comes back — the surface is shared, and re-adding it is the whole + * point of the share. + */ +export function mergeSharedJsonKeys({ + sourcePath, + targetPath, + surface, + ledger, + pick, + force, + requireExistingTarget, +}: MergeSharedJsonKeysArgs): SurfaceOutcome { + const existing = readJsonObject(targetPath); + if (requireExistingTarget && existing === null) return "absent"; + if (existing === null && fs.existsSync(targetPath)) { + console.warn( + `[agent-setup] Could not parse ${targetPath}; skipping the shared-config merge`, + ); + return "user-owned"; + } + + const source = readJsonObject(sourcePath); + const desired = source ? pick(source) : {}; + if (source === null && !force) return "absent"; + + const target = existing ?? {}; + const written = ledger.keys[surface] ?? {}; + let changed = false; + + for (const [key, value] of Object.entries(desired)) { + const serialized = JSON.stringify(value); + const current = key in target ? JSON.stringify(target[key]) : undefined; + if (current === serialized) { + written[key] = serialized; + continue; + } + // Present with a value we never wrote: the user set it in this profile. + if (current !== undefined && written[key] !== current) continue; + target[key] = value; + written[key] = serialized; + changed = true; + } + + for (const [key, value] of Object.entries(force ?? {})) { + if (JSON.stringify(target[key]) === JSON.stringify(value)) continue; + target[key] = value; + changed = true; + } + + ledger.keys[surface] = written; + if (!changed) return "unchanged"; + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileIfChanged(targetPath, JSON.stringify(target, null, 2), 0o644); + return "merged"; +} diff --git a/packages/agent-setup/src/provider-profiles.test.ts b/packages/agent-setup/src/provider-profiles.test.ts new file mode 100644 index 00000000000..f5540202ad0 --- /dev/null +++ b/packages/agent-setup/src/provider-profiles.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + provisionClaudeProfile, + provisionCodexProfile, +} from "./provider-profiles"; + +const TEST_ROOT = path.join( + os.tmpdir(), + `superset-provider-profiles-${process.pid}-${Date.now()}`, +); +const HOME = path.join(TEST_ROOT, "home"); +const DEFAULT_DIR = path.join(HOME, ".claude"); +const PROFILE = path.join(HOME, ".claude-work"); + +function writeJson(filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function readJson(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf-8")); +} + +/** A default account with something to share on every surface. */ +function seedDefaultAccount(): void { + mkdirSync(path.join(DEFAULT_DIR, "skills", "redesign"), { recursive: true }); + writeFileSync( + path.join(DEFAULT_DIR, "skills", "redesign", "SKILL.md"), + "# redesign", + ); + mkdirSync(path.join(DEFAULT_DIR, "plugins"), { recursive: true }); + writeJson(path.join(DEFAULT_DIR, "plugins", "installed_plugins.json"), { + version: 2, + }); + mkdirSync(path.join(DEFAULT_DIR, "agents"), { recursive: true }); + writeFileSync(path.join(DEFAULT_DIR, "CLAUDE.md"), "be brief"); + writeJson(path.join(DEFAULT_DIR, "settings.json"), { + model: "claude-opus-5", + effortLevel: "high", + enabledPlugins: { "superset@superset": true }, + env: { DISABLE_TELEMETRY: "1", ANTHROPIC_API_KEY: "sk-secret" }, + apiKeyHelper: "/usr/local/bin/key.sh", + hooks: { Stop: [{ hooks: [{ type: "command", command: "user-hook" }] }] }, + }); + writeJson(path.join(HOME, ".claude.json"), { + mcpServers: { linear: { command: "linear-mcp" } }, + theme: "dark", + oauthAccount: { emailAddress: "default@example.com" }, + userID: "default-user", + }); +} + +/** A profile that has completed its own login. */ +function seedProfile(): void { + writeJson(path.join(PROFILE, ".claude.json"), { + oauthAccount: { emailAddress: "work@example.com" }, + userID: "work-user", + }); + writeFileSync(path.join(PROFILE, ".credentials.json"), "{}"); +} + +beforeEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(HOME, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe("provisionClaudeProfile", () => { + it("shares every capability surface into the profile", async () => { + seedDefaultAccount(); + seedProfile(); + + const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + expect(report.surfaces["skills/"]).toBe("linked"); + expect(report.surfaces["plugins/"]).toBe("linked"); + expect(report.surfaces["agents/"]).toBe("linked"); + // The default account has none of these; nothing to share. + expect(report.surfaces["commands/"]).toBe("absent"); + expect(lstatSync(path.join(PROFILE, "skills")).isSymbolicLink()).toBe(true); + expect( + readFileSync( + path.join(PROFILE, "skills", "redesign", "SKILL.md"), + "utf-8", + ), + ).toBe("# redesign"); + expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe( + "be brief", + ); + }); + + it("shares settings but never the login's own resolution", async () => { + seedDefaultAccount(); + seedProfile(); + + await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + const settings = readJson(path.join(PROFILE, "settings.json")); + expect(settings.model).toBe("claude-opus-5"); + expect(settings.effortLevel).toBe("high"); + expect(settings.enabledPlugins).toEqual({ "superset@superset": true }); + expect(settings.env).toEqual({ DISABLE_TELEMETRY: "1" }); + expect(settings.apiKeyHelper).toBeUndefined(); + // Hooks are provisioned per profile, not copied from the default's file. + const hooks = settings.hooks as Record; + expect(JSON.stringify(hooks)).not.toContain("user-hook"); + expect(JSON.stringify(hooks)).toContain("notify.sh"); + }); + + it("shares MCP servers and completes onboarding without touching identity", async () => { + seedDefaultAccount(); + seedProfile(); + + await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + const state = readJson(path.join(PROFILE, ".claude.json")); + expect(state.mcpServers).toEqual({ linear: { command: "linear-mcp" } }); + expect(state.theme).toBe("dark"); + expect(state.hasCompletedOnboarding).toBe(true); + expect(state.oauthAccount).toEqual({ emailAddress: "work@example.com" }); + expect(state.userID).toBe("work-user"); + // Credentials are the one thing an account owns outright. + expect(readFileSync(path.join(PROFILE, ".credentials.json"), "utf-8")).toBe( + "{}", + ); + expect(existsSync(path.join(DEFAULT_DIR, ".credentials.json"))).toBe(false); + }); + + it("skips the state merge until the profile has logged in", async () => { + seedDefaultAccount(); + mkdirSync(PROFILE, { recursive: true }); + + const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + expect(report.surfaces[".claude.json"]).toBe("absent"); + expect(existsSync(path.join(PROFILE, ".claude.json"))).toBe(false); + }); + + it("is idempotent", async () => { + seedDefaultAccount(); + seedProfile(); + + await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + const second = await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + expect(second.surfaces["skills/"]).toBe("unchanged"); + expect(second.surfaces["CLAUDE.md"]).toBe("unchanged"); + expect(second.surfaces["settings.json"]).toBe("unchanged"); + expect(second.surfaces[".claude.json"]).toBe("unchanged"); + }); + + it("writes the bundled plugin into a skills dir the profile owns", async () => { + seedDefaultAccount(); + seedProfile(); + mkdirSync(path.join(PROFILE, "skills", "mine"), { recursive: true }); + writeFileSync(path.join(PROFILE, "skills", "mine", "SKILL.md"), "# mine"); + + const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME }); + + expect(report.surfaces["skills/"]).toBe("user-owned"); + expect(existsSync(path.join(PROFILE, "skills", "mine", "SKILL.md"))).toBe( + true, + ); + expect(existsSync(path.join(PROFILE, "skills", "superset", "skills"))).toBe( + true, + ); + }); + + it("does nothing to the default account itself", async () => { + seedDefaultAccount(); + + const report = await provisionClaudeProfile(DEFAULT_DIR, { homeDir: HOME }); + + expect(report.surfaces).toEqual({}); + expect(existsSync(path.join(DEFAULT_DIR, ".superset-profile.json"))).toBe( + false, + ); + }); +}); + +describe("provisionCodexProfile", () => { + const DEFAULT_CODEX = path.join(HOME, ".codex"); + const CODEX_PROFILE = path.join(HOME, ".codex-work"); + + it("shares prompts, config, and instructions but not auth", async () => { + mkdirSync(path.join(DEFAULT_CODEX, "prompts"), { recursive: true }); + writeFileSync(path.join(DEFAULT_CODEX, "prompts", "review.md"), "review"); + writeFileSync( + path.join(DEFAULT_CODEX, "config.toml"), + 'model = "gpt-5"\n[mcp_servers.linear]\ncommand = "linear-mcp"\n', + ); + writeFileSync(path.join(DEFAULT_CODEX, "AGENTS.md"), "be brief"); + writeFileSync(path.join(DEFAULT_CODEX, "auth.json"), '{"tokens":{}}'); + mkdirSync(CODEX_PROFILE, { recursive: true }); + + const report = await provisionCodexProfile(CODEX_PROFILE, { + homeDir: HOME, + }); + + expect(report.surfaces["prompts/"]).toBe("linked"); + expect( + readFileSync(path.join(CODEX_PROFILE, "config.toml"), "utf-8"), + ).toContain("linear-mcp"); + expect(readFileSync(path.join(CODEX_PROFILE, "AGENTS.md"), "utf-8")).toBe( + "be brief", + ); + expect(existsSync(path.join(CODEX_PROFILE, "auth.json"))).toBe(false); + expect( + readFileSync(path.join(CODEX_PROFILE, "hooks.json"), "utf-8"), + ).toContain("notify.sh"); + }); +}); + +describe("default homes are never provisioning targets", () => { + it("refuses ~/.config/claude and the home dir itself", async () => { + const configClaude = path.join(HOME, ".config", "claude"); + mkdirSync(configClaude, { recursive: true }); + for (const target of [configClaude, HOME, path.join(HOME, ".config")]) { + const report = await provisionClaudeProfile(target, { homeDir: HOME }); + expect(report.surfaces).toEqual({}); + } + expect(existsSync(path.join(configClaude, "skills"))).toBe(false); + expect(existsSync(path.join(HOME, ".superset-profile.json"))).toBe(false); + }); +}); diff --git a/packages/agent-setup/src/provider-profiles.ts b/packages/agent-setup/src/provider-profiles.ts new file mode 100644 index 00000000000..798e106d948 --- /dev/null +++ b/packages/agent-setup/src/provider-profiles.ts @@ -0,0 +1,305 @@ +/** + * Makes a provider account (a CLAUDE_CONFIG_DIR / CODEX_HOME profile dir) a + * complete environment rather than a bare login. + * + * The CLIs read *everything* — skills, plugins, subagents, commands, MCP + * servers, settings — from their active config dir, so pointing one at a + * second dir to switch accounts also swapped the user's whole setup for an + * empty one. Provisioning shares each of those surfaces from the default + * account (see profile-sharing.ts for the link/sync/merge rules) and leaves + * exactly one thing per-account: the login, plus the transcript and session + * state that belongs to it. + * + * Idempotent and safe to re-run: it is called when an account is added, when + * one is selected, and for the selected accounts at host boot. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ensureClaudeManagedHooksAt, + ensureCodexManagedHooksAt, +} from "./agent-wrappers-claude-codex-opencode"; +import { provisionManagedClaudePluginAt } from "./managed-skills"; +import { + linkSharedDir, + mergeSharedJsonKeys, + readProfileLedger, + type SurfaceOutcome, + syncSharedFile, + writeProfileLedger, +} from "./profile-sharing"; + +export interface ProfileProvisionReport { + configDir: string; + /** Surface name -> what provisioning did with it, for setup logs. */ + surfaces: Record; +} + +export interface ProvisionProfileOptions { + /** Overridden in tests; the real home in every other caller. */ + homeDir?: string; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Real path when the dir exists, plain resolution otherwise — a symlink + * alias of a protected dir must compare equal to it. */ +function canonical(target: string): string { + try { + return fs.realpathSync(target); + } catch { + return path.resolve(target); + } +} + +/** + * True for the share's source and the other default homes — provisioning a + * default home would link it into itself (discovery excludes them, but a + * CLAUDE_CONFIG_DIR env entry can name one directly, including via symlink). + */ +function isProtectedTarget( + target: string, + homeDir: string, + defaultDir: string, +): boolean { + const resolved = canonical(target); + const protectedDirs = [ + homeDir, + defaultDir, + path.join(homeDir, ".config"), + path.join(homeDir, ".config", "claude"), + ]; + return protectedDirs.some((dir) => resolved === canonical(dir)); +} + +function logReport(provider: string, report: ProfileProvisionReport): void { + const summary = Object.entries(report.surfaces) + .map(([surface, outcome]) => `${surface}=${outcome}`) + .join(" "); + console.log( + `[agent-setup] Provisioned ${provider} account ${report.configDir}: ${summary}`, + ); +} + +// --------------------------------------------------------------------------- +// Claude Code +// --------------------------------------------------------------------------- + +/** + * Directories shared by symlink. Everything Claude Code loads as user-level + * capability lives in one of these; what's left in a config dir is the login + * and its own history (projects/, sessions/, shell-snapshots/, …). + */ +const CLAUDE_SHARED_DIRS = [ + "skills", + "plugins", + "agents", + "commands", + "output-styles", +] as const; + +/** Files copied verbatim (the CLI rename-replaces them, so no symlink). */ +const CLAUDE_SHARED_FILES = ["CLAUDE.md"] as const; + +/** + * settings.json keys that must NOT follow the account. Everything else is + * shared by default so a key added by a future CLI release comes along + * without a code change here. + */ +const CLAUDE_PRIVATE_SETTINGS_KEYS = new Set([ + // Provisioned per profile right after the merge — the hooks are identical, + // but they are ours to write, not the user's to share. + "hooks", + // Auth resolution: sharing these would let one account's credential + // mechanism silently answer for another. + "apiKeyHelper", + "awsAuthRefresh", + "awsCredentialExport", + "forceLoginMethod", + "forceLoginOrgUUID", +]); + +/** `env` entries holding a credential; the rest of `env` is shared. */ +const CLAUDE_PRIVATE_ENV_KEYS = new Set([ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", +]); + +/** + * `.claude.json` keys that are shared — an allowlist, inverted from + * settings.json on purpose: that file is mostly identity (oauthAccount, + * userID), per-project transcript history, and server-driven caches, none of + * which may cross accounts. + */ +const CLAUDE_SHARED_STATE_KEYS = ["mcpServers", "theme"] as const; + +function pickClaudeSettings( + source: Record, +): Record { + const shared: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (CLAUDE_PRIVATE_SETTINGS_KEYS.has(key)) continue; + if (key === "env" && isPlainObject(value)) { + shared.env = Object.fromEntries( + Object.entries(value).filter( + ([name]) => !CLAUDE_PRIVATE_ENV_KEYS.has(name), + ), + ); + continue; + } + shared[key] = value; + } + return shared; +} + +function pickClaudeState( + source: Record, +): Record { + const shared: Record = {}; + for (const key of CLAUDE_SHARED_STATE_KEYS) { + if (key in source) shared[key] = source[key]; + } + return shared; +} + +/** + * Brings one Claude config dir up to date with the default account. A no-op + * for `~/.claude` itself, which is the source of every share. + */ +export async function provisionClaudeProfile( + configDir: string, + options: ProvisionProfileOptions = {}, +): Promise { + const homeDir = options.homeDir ?? os.homedir(); + const defaultDir = path.join(homeDir, ".claude"); + const target = path.resolve(configDir); + const surfaces: Record = {}; + if (isProtectedTarget(target, homeDir, defaultDir)) { + return { configDir: target, surfaces }; + } + + fs.mkdirSync(target, { recursive: true }); + const ledger = readProfileLedger(target, defaultDir); + + for (const name of CLAUDE_SHARED_DIRS) { + surfaces[`${name}/`] = linkSharedDir( + path.join(defaultDir, name), + path.join(target, name), + ); + } + for (const name of CLAUDE_SHARED_FILES) { + surfaces[name] = syncSharedFile({ + sourcePath: path.join(defaultDir, name), + targetPath: path.join(target, name), + surface: name, + ledger, + }); + } + + surfaces["settings.json"] = mergeSharedJsonKeys({ + sourcePath: path.join(defaultDir, "settings.json"), + targetPath: path.join(target, "settings.json"), + surface: "settings.json", + ledger, + pick: pickClaudeSettings, + }); + + // Only the default account keeps its state next door at `~/.claude.json`; + // a custom config dir keeps it inside. + surfaces[".claude.json"] = mergeSharedJsonKeys({ + sourcePath: path.join(homeDir, ".claude.json"), + targetPath: path.join(target, ".claude.json"), + surface: ".claude.json", + ledger, + pick: pickClaudeState, + // Without this the profile's first launch opens the first-boot wizard, + // where a stray Enter starts a login that silently rebinds the profile. + force: { hasCompletedOnboarding: true }, + // No parsable state file means no completed login yet — writing one + // would fabricate an account that doesn't exist. + requireExistingTarget: true, + }); + + writeProfileLedger(target, ledger); + // After the settings merge: the merge only writes shared keys, and the + // hook entries have to survive it. + ensureClaudeManagedHooksAt(target); + + if (surfaces["skills/"] === "user-owned") { + // The profile brought its own skills dir, so it isn't sharing ours — + // the bundled Superset plugin has to be written into it directly. + await provisionManagedClaudePluginAt(target); + surfaces["skills/superset"] = "synced"; + } + + const report = { configDir: target, surfaces }; + logReport("Claude", report); + return report; +} + +// --------------------------------------------------------------------------- +// Codex +// --------------------------------------------------------------------------- + +/** `prompts/` holds custom slash-command prompts; the rest of a Codex home + * is auth, history, and rollouts. */ +const CODEX_SHARED_DIRS = ["prompts"] as const; + +/** + * `config.toml` carries the model, approval policy, and MCP servers; + * `AGENTS.md` the global instructions. Neither holds credentials — Codex + * keeps those in `auth.json`, which stays per-account. + */ +const CODEX_SHARED_FILES = ["config.toml", "AGENTS.md"] as const; + +function defaultCodexHome(homeDir: string, homeDirOverridden: boolean): string { + // An overridden homeDir (tests) must win over the ambient CODEX_HOME, or + // the provision would share from the real machine's Codex home. + if (homeDirOverridden) return path.join(homeDir, ".codex"); + const fromEnv = process.env.CODEX_HOME?.trim(); + return fromEnv ? path.resolve(fromEnv) : path.join(homeDir, ".codex"); +} + +/** Brings one Codex home up to date with the default one. */ +export async function provisionCodexProfile( + home: string, + options: ProvisionProfileOptions = {}, +): Promise { + const homeDir = options.homeDir ?? os.homedir(); + const defaultDir = defaultCodexHome(homeDir, options.homeDir !== undefined); + const target = path.resolve(home); + const surfaces: Record = {}; + if (isProtectedTarget(target, homeDir, defaultDir)) { + return { configDir: target, surfaces }; + } + + fs.mkdirSync(target, { recursive: true }); + const ledger = readProfileLedger(target, defaultDir); + + for (const name of CODEX_SHARED_DIRS) { + surfaces[`${name}/`] = linkSharedDir( + path.join(defaultDir, name), + path.join(target, name), + ); + } + for (const name of CODEX_SHARED_FILES) { + surfaces[name] = syncSharedFile({ + sourcePath: path.join(defaultDir, name), + targetPath: path.join(target, name), + surface: name, + ledger, + }); + } + + writeProfileLedger(target, ledger); + ensureCodexManagedHooksAt(target); + + const report = { configDir: target, surfaces }; + logReport("Codex", report); + return report; +} diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index f51ce8e582c..dca7c54c944 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -30,6 +30,7 @@ import { TerminalAgentStore, } from "./terminal-agents"; import { appRouter } from "./trpc/router"; +import { provisionSelectedAccounts } from "./trpc/router/usage/account-provisioning"; import { execGh as defaultExecGh, type ExecGh, @@ -239,6 +240,12 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }).catch((err) => { console.warn("[host-service] archived-workspace reconcile failed:", err); }); + // Re-share the default account's Claude/Codex config into the selected + // provider profiles. Last: it touches no host state the sweeps above + // repair, and a slow filesystem must not delay them. + await provisionSelectedAccounts(db).catch((err) => { + console.warn("[host-service] account provisioning failed:", err); + }); })(); const wsAuth: MiddlewareHandler = async (c, next) => { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index b688c015fde..fa6ecd7c393 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -2480,8 +2480,8 @@ export async function createTerminalSessionInternal({ hostAgentHookUrl: getHostAgentHookUrl(), }), // Usage-tab default account: provider CLIs typed or preset-launched in - // this terminal run on the selected login. Baked at spawn — existing - // terminals keep the account they started with. + // this terminal run on the selected login. Baked at spawn as the fast + // path; the agent wrappers re-resolve later switches at launch time. ...resolveDefaultAccountTerminalEnv(db), }; diff --git a/packages/host-service/src/trpc/router/usage/account-provisioning.ts b/packages/host-service/src/trpc/router/usage/account-provisioning.ts new file mode 100644 index 00000000000..9e1bdf23537 --- /dev/null +++ b/packages/host-service/src/trpc/router/usage/account-provisioning.ts @@ -0,0 +1,64 @@ +/** + * Keeps the selected provider accounts provisioned — see + * packages/agent-setup/src/provider-profiles.ts for what that means. Split + * from default-account.ts so the terminal's env-resolution path (loaded by + * node --test) doesn't pull the whole agent-setup surface in. + */ + +import { existsSync } from "node:fs"; +import { + provisionClaudeProfile, + provisionCodexProfile, +} from "@superset/agent-setup"; +import type { HostDb } from "../../../db/index.ts"; +import { + getDefaultAccountSelections, + syncDefaultAccountPointers, +} from "./default-account.ts"; +import { shareClaudeSessionState } from "./session-share.ts"; + +/** + * Everything a Claude account needs on this host: the shared session state + * (one `--resume` history across accounts — session-share.ts) plus the + * config surfaces agent-setup owns (skills, plugins, settings, MCP servers). + */ +export async function provisionClaudeAccount(configDir: string): Promise { + shareClaudeSessionState(configDir); + await provisionClaudeProfile(configDir); +} + +/** + * Re-shares the default account's config into whichever profiles are + * currently selected. Runs at host boot so a profile keeps up with skills, + * plugins, and settings added since it was selected, and so one provisioned + * by an older build — or by a switch that failed halfway — is repaired + * before the next agent launches on it. + */ +export async function provisionSelectedAccounts(db: HostDb): Promise { + // Heal the wrapper pointer files first — a build predating them (or a + // crashed switch) leaves agents launching on a stale spawn-time default. + syncDefaultAccountPointers(db); + const { claudeConfigDir, codexHome } = getDefaultAccountSelections(db); + const targets: Array Promise]> = []; + // A pointer at a vanished dir is skipped, not recreated: agent launches + // already fall back to the system-default login in that case. + if (claudeConfigDir && existsSync(claudeConfigDir)) { + targets.push([ + claudeConfigDir, + () => provisionClaudeAccount(claudeConfigDir), + ]); + } + if (codexHome && existsSync(codexHome)) { + targets.push([codexHome, () => provisionCodexProfile(codexHome)]); + } + for (const [dir, provision] of targets) { + try { + await provision(); + } catch (error) { + console.warn( + `[host-service] provisioning the selected account ${dir} failed (continuing):`, + error, + ); + } + } +} diff --git a/packages/host-service/src/trpc/router/usage/default-account.ts b/packages/host-service/src/trpc/router/usage/default-account.ts index ab209f1349d..879a8fdaed0 100644 --- a/packages/host-service/src/trpc/router/usage/default-account.ts +++ b/packages/host-service/src/trpc/router/usage/default-account.ts @@ -5,11 +5,55 @@ * provider CLI itself keeps owning every login end to end. */ -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { HostDb } from "../../../db/index.ts"; import { hostSettings } from "../../../db/schema.ts"; import type { UsageProvider } from "./types.ts"; +const POINTER_NAMES: Record = { + claude: "default-claude-config-dir", + codex: "default-codex-home", +}; + +/** + * Mirror of agent-setup's resolveSupersetHomeDir, not imported: this module + * sits on the terminal env-resolution path (loaded by node --test) and must + * stay free of the agent-setup surface — see account-provisioning.ts. + */ +function supersetHomeDir(): string { + return process.env.SUPERSET_HOME_DIR?.trim() || join(homedir(), ".superset"); +} + +/** + * Publishes a selection where the agent wrappers can re-read it on every + * launch (buildDefaultAccountResolver in agent-setup), so switching accounts + * reaches existing terminals the next time the agent starts — the PTY env + * alone is frozen at spawn. Empty file = system default. Best-effort: the DB + * stays the source of truth and the wrapper falls back to the spawn-time env. + */ +export function syncDefaultAccountPointer( + provider: UsageProvider, + selection: string | null, +): void { + try { + const dir = join(supersetHomeDir(), "state"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, POINTER_NAMES[provider]), selection ?? ""); + } catch { + // Wrapper keeps using the spawn-time env until the next successful sync. + } +} + +/** Reconciles both pointer files from the DB — run at host boot so files + * from an older build (or a crashed switch) heal. */ +export function syncDefaultAccountPointers(db: HostDb): void { + const selections = getDefaultAccountSelections(db); + syncDefaultAccountPointer("claude", selections.claudeConfigDir); + syncDefaultAccountPointer("codex", selections.codexHome); +} + export interface DefaultAccountSelections { /** CLAUDE_CONFIG_DIR to inject, or null for the system-default login. */ claudeConfigDir: string | null; @@ -40,12 +84,15 @@ export function setDefaultAccountSelection( .values({ id: 1, ...values }) .onConflictDoUpdate({ target: hostSettings.id, set: values }) .run(); + syncDefaultAccountPointer(provider, selection); } /** * Env for a new terminal so provider CLIs typed or launched in it run on the * host-default accounts. Both providers' vars — a shell can run either CLI. - * Baked at PTY spawn; existing terminals keep the account they started with. + * Baked at PTY spawn as the fast path; the agent wrappers re-resolve from the + * pointer files at every launch, so a later switch still reaches this + * terminal when the agent is relaunched. */ export function resolveDefaultAccountTerminalEnv( db: HostDb, @@ -72,14 +119,23 @@ export function resolveDefaultAccountEnv( selections.claudeConfigDir && existsSync(selections.claudeConfigDir) ) { - return { CLAUDE_CONFIG_DIR: selections.claudeConfigDir }; + // The SUPERSET_DEFAULT_* twin marks the value as Superset-injected, so + // the agent wrapper can re-resolve a later switch without ever + // overriding a value the user exported by hand. + return { + CLAUDE_CONFIG_DIR: selections.claudeConfigDir, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: selections.claudeConfigDir, + }; } if ( presetId === "codex" && selections.codexHome && existsSync(selections.codexHome) ) { - return { CODEX_HOME: selections.codexHome }; + return { + CODEX_HOME: selections.codexHome, + SUPERSET_DEFAULT_CODEX_HOME: selections.codexHome, + }; } return {}; } diff --git a/packages/host-service/src/trpc/router/usage/history/aggregate.ts b/packages/host-service/src/trpc/router/usage/history/aggregate.ts index c6535a22dd5..09c3e57400e 100644 --- a/packages/host-service/src/trpc/router/usage/history/aggregate.ts +++ b/packages/host-service/src/trpc/router/usage/history/aggregate.ts @@ -1,3 +1,4 @@ +import { realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { discoverClaudeProfiles, discoverCodexHomes } from "../profiles"; @@ -188,18 +189,34 @@ export async function computeUsageHistory( ]); for (const profile of claudeProfiles) claudeHomes.add(profile.configDir); - const [claudeFileGroups, codexFileGroups] = await Promise.all([ - Promise.all( - [...claudeHomes].map((root) => - collectLogFiles(join(root, "projects"), days + 1), - ), - ), - Promise.all( - codexHomes.map((codexHome) => - collectLogFiles(join(codexHome.home, "sessions"), days + 1), - ), + // Shared-history profiles symlink their projects/ into ~/.claude (see + // session-share.ts), so two homes can name the same tree under different + // paths — resolve every scan root and dedupe by real path, or the tree + // gets walked and parsed once per profile. + const resolveRoots = async (roots: string[]): Promise => { + const resolved = await Promise.all( + roots.map(async (root) => { + try { + return await realpath(root); + } catch { + return null; // Dir absent — collectLogFiles would find nothing. + } + }), + ); + return [ + ...new Set(resolved.filter((root): root is string => root !== null)), + ]; + }; + const [claudeRoots, codexRoots] = await Promise.all([ + resolveRoots([...claudeHomes].map((root) => join(root, "projects"))), + resolveRoots( + codexHomes.map((codexHome) => join(codexHome.home, "sessions")), ), ]); + const [claudeFileGroups, codexFileGroups] = await Promise.all([ + Promise.all(claudeRoots.map((root) => collectLogFiles(root, days + 1))), + Promise.all(codexRoots.map((root) => collectLogFiles(root, days + 1))), + ]); const claudeFiles = dedupeLogFiles(claudeFileGroups.flat()); const codexFiles = dedupeLogFiles(codexFileGroups.flat()); diff --git a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts index 8cea1f54535..c240fa8d9d0 100644 --- a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts +++ b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { homedir } from "node:os"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { assertRemovableProfileDir, @@ -58,3 +66,34 @@ describe("remove functions refuse protected dirs before touching the disk", () = ); }); }); + +// A provisioned profile links its capability dirs at the default account's +// (packages/agent-setup/src/provider-profiles.ts). Removing the profile must +// unlink those, never delete through them. +describe("removeClaudeProfile with shared-config links", () => { + it("leaves the default account's linked dirs intact", async () => { + const root = mkdtempSync(join(tmpdir(), "superset-profile-remove-")); + try { + const sharedSkills = join(root, "default-skills"); + mkdirSync(join(sharedSkills, "redesign"), { recursive: true }); + writeFileSync(join(sharedSkills, "redesign", "SKILL.md"), "# redesign"); + + // Under the home dir so the removal guards accept it. + const profile = mkdtempSync(join(homedir(), ".claude-remove-test-")); + try { + symlinkSync(sharedSkills, join(profile, "skills"), "dir"); + + await removeClaudeProfile(profile); + + expect(existsSync(profile)).toBe(false); + expect(existsSync(join(sharedSkills, "redesign", "SKILL.md"))).toBe( + true, + ); + } finally { + rmSync(profile, { recursive: true, force: true }); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/profile-seed.test.ts b/packages/host-service/src/trpc/router/usage/profile-seed.test.ts deleted file mode 100644 index ddf242648ef..00000000000 --- a/packages/host-service/src/trpc/router/usage/profile-seed.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { seedClaudeProfileOnboarding } from "./profile-seed"; - -function makeProfile(state: unknown): string { - const dir = mkdtempSync(join(tmpdir(), "claude-profile-seed-")); - if (state !== undefined) { - writeFileSync(join(dir, ".claude.json"), JSON.stringify(state)); - } - return dir; -} - -function readState(dir: string): Record { - return JSON.parse(readFileSync(join(dir, ".claude.json"), "utf-8")); -} - -describe("seedClaudeProfileOnboarding", () => { - it("marks onboarding complete and preserves the CLI's other state", () => { - const dir = makeProfile({ - oauthAccount: { emailAddress: "a@b.c" }, - numStartups: 3, - }); - seedClaudeProfileOnboarding(dir); - const state = readState(dir); - expect(state.hasCompletedOnboarding).toBe(true); - expect(state.oauthAccount).toEqual({ emailAddress: "a@b.c" }); - expect(state.numStartups).toBe(3); - }); - - it("never overwrites a theme the profile already chose", () => { - const dir = makeProfile({ theme: "light" }); - seedClaudeProfileOnboarding(dir); - expect(readState(dir).theme).toBe("light"); - }); - - it("does nothing when onboarding is already complete", () => { - const dir = makeProfile({ hasCompletedOnboarding: true, theme: "light" }); - const before = readFileSync(join(dir, ".claude.json"), "utf-8"); - seedClaudeProfileOnboarding(dir); - expect(readFileSync(join(dir, ".claude.json"), "utf-8")).toBe(before); - }); - - it("does not create a state file where none exists (no login yet)", () => { - const dir = makeProfile(undefined); - seedClaudeProfileOnboarding(dir); - expect(() => readState(dir)).toThrow(); - }); -}); diff --git a/packages/host-service/src/trpc/router/usage/profile-seed.ts b/packages/host-service/src/trpc/router/usage/profile-seed.ts deleted file mode 100644 index 7db17770583..00000000000 --- a/packages/host-service/src/trpc/router/usage/profile-seed.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Marks a secondary Claude profile's onboarding as complete so its first - * agent launch goes straight to the prompt instead of the first-boot wizard - * (where a stray Enter can start a login that silently rebinds the profile). - * - * This edits the CLI's state file (`.claude.json`), never a credential - * store, and only ever adds two preference keys — everything else is left - * exactly as the CLI wrote it. - */ - -import { readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -export function seedClaudeProfileOnboarding(configDir: string): void { - const statePath = join(configDir, ".claude.json"); - let state: Record; - try { - state = JSON.parse(readFileSync(statePath, "utf-8")); - } catch { - // No parsable state file means no completed login yet — nothing to seed. - return; - } - if (state.hasCompletedOnboarding === true) return; - state.hasCompletedOnboarding = true; - if (state.theme === undefined) { - // Carry the default profile's theme so the first launch looks familiar. - try { - const defaultState = JSON.parse( - readFileSync(join(homedir(), ".claude.json"), "utf-8"), - ) as { theme?: unknown }; - if (typeof defaultState.theme === "string") { - state.theme = defaultState.theme; - } - } catch { - // Theme stays unset; the CLI falls back to its own default. - } - } - writeFileSync(statePath, JSON.stringify(state, null, 2)); -} diff --git a/packages/host-service/src/trpc/router/usage/session-share.test.ts b/packages/host-service/src/trpc/router/usage/session-share.test.ts new file mode 100644 index 00000000000..b486b91d8f6 --- /dev/null +++ b/packages/host-service/src/trpc/router/usage/session-share.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { shareableProfileDir, shareClaudeSessionState } from "./session-share"; + +function makeDirs(): { profile: string; main: string } { + const root = mkdtempSync(join(tmpdir(), "claude-session-share-")); + const profile = join(root, "profile"); + const main = join(root, "main"); + mkdirSync(profile); + mkdirSync(main); + return { profile, main }; +} + +function isLinkTo(path: string, target: string): boolean { + return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target; +} + +function lstatOrNull(path: string) { + try { + return lstatSync(path); + } catch { + return null; + } +} + +describe("shareableProfileDir", () => { + it("refuses the default homes and the main home itself", () => { + const home = homedir(); + const main = join(home, ".claude"); + expect(shareableProfileDir(home, main)).toBeNull(); + expect(shareableProfileDir(join(home, ".claude"), main)).toBeNull(); + expect( + shareableProfileDir(join(home, ".config", "claude"), main), + ).toBeNull(); + expect( + shareableProfileDir("/tmp/custom-main", "/tmp/custom-main"), + ).toBeNull(); + }); + + it("accepts an ordinary profile dir", () => { + const home = homedir(); + expect( + shareableProfileDir(join(home, ".claude-work"), join(home, ".claude")), + ).toBe(join(home, ".claude-work")); + }); +}); + +describe("shareClaudeSessionState", () => { + it("links a fresh profile's session entries into main", () => { + const { profile, main } = makeDirs(); + shareClaudeSessionState(profile, main); + for (const name of ["projects", "sessions", "file-history", "todos"]) { + expect(isLinkTo(join(profile, name), join(main, name))).toBe(true); + expect(lstatSync(join(main, name)).isDirectory()).toBe(true); + } + expect( + isLinkTo(join(profile, "history.jsonl"), join(main, "history.jsonl")), + ).toBe(true); + }); + + it("merges existing session trees into main before linking", () => { + const { profile, main } = makeDirs(); + mkdirSync(join(profile, "projects", "-repo-a"), { recursive: true }); + writeFileSync(join(profile, "projects", "-repo-a", "s1.jsonl"), "a"); + mkdirSync(join(main, "projects", "-repo-b"), { recursive: true }); + writeFileSync(join(main, "projects", "-repo-b", "s2.jsonl"), "b"); + shareClaudeSessionState(profile, main); + expect(isLinkTo(join(profile, "projects"), join(main, "projects"))).toBe( + true, + ); + expect( + readFileSync(join(main, "projects", "-repo-a", "s1.jsonl"), "utf-8"), + ).toBe("a"); + expect( + readFileSync(join(main, "projects", "-repo-b", "s2.jsonl"), "utf-8"), + ).toBe("b"); + // The merged file is reachable through the profile's own path too. + expect( + readFileSync(join(profile, "projects", "-repo-a", "s1.jsonl"), "utf-8"), + ).toBe("a"); + }); + + it("leaves conflicting files behind in the pending dir and still links", () => { + const { profile, main } = makeDirs(); + mkdirSync(join(profile, "projects", "-repo"), { recursive: true }); + writeFileSync(join(profile, "projects", "-repo", "s.jsonl"), "profile"); + mkdirSync(join(main, "projects", "-repo"), { recursive: true }); + writeFileSync(join(main, "projects", "-repo", "s.jsonl"), "main"); + shareClaudeSessionState(profile, main); + expect(isLinkTo(join(profile, "projects"), join(main, "projects"))).toBe( + true, + ); + expect( + readFileSync(join(main, "projects", "-repo", "s.jsonl"), "utf-8"), + ).toBe("main"); + expect( + readFileSync( + join(profile, "projects.superset-merge", "-repo", "s.jsonl"), + "utf-8", + ), + ).toBe("profile"); + }); + + it("flushes a pending dir left by an interrupted merge", () => { + const { profile, main } = makeDirs(); + mkdirSync(join(profile, "projects.superset-merge", "-repo"), { + recursive: true, + }); + writeFileSync( + join(profile, "projects.superset-merge", "-repo", "s.jsonl"), + "leftover", + ); + symlinkSync(join(main, "projects"), join(profile, "projects")); + mkdirSync(join(main, "projects")); + shareClaudeSessionState(profile, main); + expect( + readFileSync(join(main, "projects", "-repo", "s.jsonl"), "utf-8"), + ).toBe("leftover"); + expect(readdirSync(profile)).not.toContain("projects.superset-merge"); + }); + + it("separates records when main's history lacks a trailing newline", () => { + const { profile, main } = makeDirs(); + writeFileSync(join(main, "history.jsonl"), '{"m":1}'); // crash-truncated + writeFileSync(join(profile, "history.jsonl"), '{"p":1}\n'); + shareClaudeSessionState(profile, main); + expect(readFileSync(join(main, "history.jsonl"), "utf-8")).toBe( + '{"m":1}\n{"p":1}\n', + ); + }); + + it("refuses a profile dir that is a symlink alias of the main home", () => { + const { profile, main } = makeDirs(); + const alias = join(profile, "..", "main-alias"); + symlinkSync(main, alias); + expect(shareableProfileDir(alias, main)).toBeNull(); + }); + + it("appends the profile's prompt history to main's", () => { + const { profile, main } = makeDirs(); + writeFileSync(join(main, "history.jsonl"), '{"m":1}\n'); + writeFileSync(join(profile, "history.jsonl"), '{"p":1}\n'); + shareClaudeSessionState(profile, main); + expect( + isLinkTo(join(profile, "history.jsonl"), join(main, "history.jsonl")), + ).toBe(true); + expect(readFileSync(join(main, "history.jsonl"), "utf-8")).toBe( + '{"m":1}\n{"p":1}\n', + ); + }); + + it("leaves config surfaces to agent-setup provisioning", () => { + const { profile, main } = makeDirs(); + writeFileSync(join(main, "settings.json"), "{}"); + writeFileSync(join(main, "CLAUDE.md"), "memory"); + mkdirSync(join(main, "agents")); + shareClaudeSessionState(profile, main); + for (const name of ["settings.json", "CLAUDE.md", "agents", "skills"]) { + expect(lstatOrNull(join(profile, name))).toBeNull(); + } + }); + + it("leaves existing symlinks alone, wherever they point", () => { + const { profile, main } = makeDirs(); + const elsewhere = join(profile, "elsewhere"); + mkdirSync(elsewhere); + symlinkSync(elsewhere, join(profile, "projects")); + shareClaudeSessionState(profile, main); + expect(readlinkSync(join(profile, "projects"))).toBe(elsewhere); + }); + + it("never touches identity or runtime entries", () => { + const { profile, main } = makeDirs(); + writeFileSync(join(profile, ".claude.json"), '{"oauthAccount":{}}'); + writeFileSync(join(profile, ".credentials.json"), "secret"); + mkdirSync(join(profile, "daemon")); + shareClaudeSessionState(profile, main); + expect(lstatSync(join(profile, ".claude.json")).isSymbolicLink()).toBe( + false, + ); + expect(lstatSync(join(profile, ".credentials.json")).isSymbolicLink()).toBe( + false, + ); + expect(lstatSync(join(profile, "daemon")).isSymbolicLink()).toBe(false); + expect(readdirSync(main)).not.toContain(".claude.json"); + expect(readdirSync(main)).not.toContain(".credentials.json"); + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/session-share.ts b/packages/host-service/src/trpc/router/usage/session-share.ts new file mode 100644 index 00000000000..1502f02f55f --- /dev/null +++ b/packages/host-service/src/trpc/router/usage/session-share.ts @@ -0,0 +1,243 @@ +/** + * Shares session state between a secondary Claude profile dir and the + * default `~/.claude` home via symlinks, so every login sees one + * conversation history — switching accounts stops meaning losing + * `--resume` and the prompt history. + * + * Only surfaces a symlink can survive are shared here: directories, and + * `history.jsonl`, which the CLI appends to in place. Config files like + * settings.json and `.claude.json` are written with write-tmp-then-rename — + * a rename would replace the symlink with a real file and silently fork the + * config — so those (plus skills, plugins, MCP servers) belong to + * agent-setup's ledger-based profile provisioning, not to this module. + * Identity (`.claude.json`, credential stores) and runtime dirs (daemon/, + * cache/, telemetry/, backups/) always stay per-profile. + * + * Existing real state is merged, not clobbered. Session trees are renamed + * aside, the symlink lands immediately, and files then move into `~/.claude` + * one by one: renames preserve inodes, so a live session's open transcripts + * stay valid and its paths resolve through the new link. The prompt history + * is appended. Anything that cannot merge safely is left where it is. + */ + +import { + appendFileSync, + closeSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + realpathSync, + renameSync, + rmdirSync, + statSync, + symlinkSync, + unlinkSync, +} from "node:fs"; +import { homedir, platform } from "node:os"; +import { join, resolve } from "node:path"; + +/** Session-scoped state keyed by session UUID or cwd slug: safe to merge + * file-by-file, since two profiles' sessions collide no more than two + * concurrent sessions in one dir already do. */ +const SESSION_DIRS = [ + "projects", + "sessions", + "session-env", + "file-history", + "shell-snapshots", + "todos", + "paste-cache", + "tasks", + "plans", + "transcripts", +]; + +const MERGE_SUFFIX = ".superset-merge"; + +/** Real path when the dir exists (a symlink alias of a protected dir must + * compare equal to it), plain resolution otherwise. */ +function canonical(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +/** Resolved profile dir, or null when it IS a default home (never share a + * default home into itself, and never operate on `~`). Compares real paths: + * a profile dir that is itself a symlink to `~/.claude` would otherwise pass + * and get linked into itself. */ +export function shareableProfileDir( + configDir: string, + mainHome: string, +): string | null { + const resolved = canonical(configDir); + const home = homedir(); + const excluded = new Set( + [ + home, + join(home, ".claude"), + join(home, ".config"), + join(home, ".config", "claude"), + mainHome, + ].map(canonical), + ); + return excluded.has(resolved) ? null : resolved; +} + +function lstatOrNull(path: string) { + try { + return lstatSync(path); + } catch { + return null; + } +} + +/** + * Moves every file under `srcDir` into `dstDir`, creating dirs as needed. + * Conflicts (a path that already exists in `dstDir`) are left behind in + * `srcDir`; empty dirs are pruned as the move drains them. + */ +function moveTreeInto(srcDir: string, dstDir: string): void { + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + const from = join(srcDir, entry.name); + const to = join(dstDir, entry.name); + if (entry.isDirectory()) { + const toInfo = lstatOrNull(to); + if (!toInfo) { + renameSync(from, to); + continue; + } + if (!toInfo.isDirectory()) continue; + moveTreeInto(from, to); + } else if (!lstatOrNull(to)) { + renameSync(from, to); + } + } + try { + rmdirSync(srcDir); + } catch { + // Conflicts remain — the leftovers stay for the next run to retry. + } +} + +function mergeAndLinkSessionDir( + profile: string, + main: string, + name: string, +): void { + const src = join(profile, name); + const dst = join(main, name); + const pending = `${src}${MERGE_SUFFIX}`; + // Finish what an interrupted earlier merge left behind before anything + // else — src may already be a symlink by now. + if (lstatOrNull(pending)?.isDirectory()) { + mkdirSync(dst, { recursive: true }); + moveTreeInto(pending, dst); + } + const info = lstatOrNull(src); + if (info?.isSymbolicLink()) return; + if (info && !info.isDirectory()) return; + mkdirSync(dst, { recursive: true }); + if (!info) { + symlinkSync(dst, src); + return; + } + // Swap first, merge after: the path is only ever missing for the instant + // between rename and symlink, and open file handles ride the renames. + renameSync(src, pending); + try { + symlinkSync(dst, src); + } catch { + // A live CLI recreated the dir in the gap — restore and skip. + moveTreeInto(pending, src); + return; + } + moveTreeInto(pending, dst); +} + +/** Appends line-delimited records, inserting a newline first when the + * target's last record lacks one (a crash-truncated write) — plain + * concatenation would fuse the boundary records into one unparsable line. */ +function appendHistoryRecords(dst: string, content: Buffer): void { + if (content.length === 0) return; + let needsSeparator = false; + try { + const size = statSync(dst).size; + if (size > 0) { + const tail = Buffer.alloc(1); + const fd = openSync(dst, "r"); + try { + readSync(fd, tail, 0, 1, size - 1); + } finally { + closeSync(fd); + } + needsSeparator = tail[0] !== 0x0a; + } + } catch { + // Missing target — appendFileSync creates it; no separator needed. + } + appendFileSync( + dst, + needsSeparator ? Buffer.concat([Buffer.from("\n"), content]) : content, + ); +} + +function mergeAndLinkHistory(profile: string, main: string): void { + const src = join(profile, "history.jsonl"); + const dst = join(main, "history.jsonl"); + const pending = `${src}${MERGE_SUFFIX}`; + if (lstatOrNull(pending)?.isFile()) { + appendHistoryRecords(dst, readFileSync(pending)); + unlinkSync(pending); + } + const info = lstatOrNull(src); + if (info?.isSymbolicLink()) return; + if (info && !info.isFile()) return; + if (!lstatOrNull(dst)) appendFileSync(dst, ""); + if (!info) { + symlinkSync(dst, src); + return; + } + renameSync(src, pending); + try { + symlinkSync(dst, src); + } catch { + renameSync(pending, src); + return; + } + appendHistoryRecords(dst, readFileSync(pending)); + unlinkSync(pending); +} + +/** + * Best-effort per entry: one unmergeable path must not stop the rest, and a + * partially shared profile is strictly better than an unshared one. + */ +export function shareClaudeSessionState( + configDir: string, + mainHome: string = join(homedir(), ".claude"), +): void { + // Windows symlinks need elevation; profiles are a macOS/Linux feature. + if (platform() === "win32") return; + const profile = shareableProfileDir(configDir, mainHome); + if (!profile) return; + const main = resolve(mainHome); + const steps: Array<() => void> = [ + ...SESSION_DIRS.map( + (name) => () => mergeAndLinkSessionDir(profile, main, name), + ), + () => mergeAndLinkHistory(profile, main), + ]; + for (const step of steps) { + try { + step(); + } catch { + // Skipped entry; retried on the next switch to this profile. + } + } +} diff --git a/packages/host-service/src/trpc/router/usage/usage.ts b/packages/host-service/src/trpc/router/usage/usage.ts index 9524b328e99..dd3e95ea2b3 100644 --- a/packages/host-service/src/trpc/router/usage/usage.ts +++ b/packages/host-service/src/trpc/router/usage/usage.ts @@ -1,16 +1,14 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { basename, join } from "node:path"; -import { - ensureClaudeManagedHooksAt, - ensureCodexManagedHooksAt, -} from "@superset/agent-setup"; +import { provisionCodexProfile } from "@superset/agent-setup"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { usageHistoryTask } from "../../../workers/tasks/usage"; import { protectedProcedure, queryProcedure, router } from "../../index"; import { offLoop } from "../../off-loop"; +import { provisionClaudeAccount } from "./account-provisioning"; import { fetchClaudeAccounts, readDefaultLoginEmail } from "./claude"; import { fetchCodexAccounts } from "./codex"; import { @@ -18,7 +16,6 @@ import { setDefaultAccountSelection, } from "./default-account"; import { removeClaudeProfile, removeCodexHome } from "./profile-remove"; -import { seedClaudeProfileOnboarding } from "./profile-seed"; import { discoverClaudeProfiles, discoverCodexHomes } from "./profiles"; import type { UsageAccount } from "./types"; @@ -146,22 +143,22 @@ export const usageRouter = router({ } } setDefaultAccountSelection(ctx.db, input.provider, input.selection); - // Secondary profiles read hooks from their own dir, so agents launched - // there would otherwise lose lifecycle/status reporting; fresh Claude - // profiles also need onboarding marked done or the first launch runs - // the first-boot wizard. Best-effort: a failed merge must not undo - // the switch. + // A profile dir is a whole config root, not just a login: without + // provisioning, agents launched there lose the user's skills, + // plugins, MCP servers and settings along with Superset's lifecycle + // hooks — and, for Claude, the shared session history. Best-effort — + // a failed share must not undo the switch, and provisioning retries + // on the next switch and at host boot. if (input.selection !== null) { try { - if (input.provider === "claude") { - ensureClaudeManagedHooksAt(input.selection); - seedClaudeProfileOnboarding(input.selection); - } else { - ensureCodexManagedHooksAt(input.selection); - } - } catch { - // Agents still run without hooks; provisioning retries on the - // next switch. + await (input.provider === "claude" + ? provisionClaudeAccount(input.selection) + : provisionCodexProfile(input.selection)); + } catch (error) { + console.warn( + `[host-service] provisioning ${input.provider} account ${input.selection} failed (continuing):`, + error, + ); } } return { success: true as const }; @@ -214,21 +211,33 @@ export const usageRouter = router({ }), /** - * One-time preparation for a freshly added Claude profile: mark onboarding - * complete so the first agent launch doesn't open the first-boot wizard. - * Only accepts discovered profile dirs. + * Preparation for a freshly added profile: share the default account's + * config into it (and, for Claude, mark onboarding complete), so its first + * agent launch opens the prompt with the user's usual setup instead of the + * first-boot wizard on an empty install. Only accepts discovered profile + * dirs. */ - prepareClaudeProfile: protectedProcedure - .input(z.object({ configDir: z.string() })) + prepareAccount: protectedProcedure + .input( + z.object({ + provider: z.enum(["claude", "codex"]), + selection: z.string(), + }), + ) .mutation(async ({ input }) => { - const profiles = await discoverClaudeProfiles(); - if (!profiles.some((profile) => profile.configDir === input.configDir)) { + const discovered = + input.provider === "claude" + ? (await discoverClaudeProfiles()).map((profile) => profile.configDir) + : (await discoverCodexHomes()).map((home) => home.home); + if (!discovered.includes(input.selection)) { throw new TRPCError({ code: "BAD_REQUEST", - message: `No Claude profile found at ${input.configDir}.`, + message: `No ${input.provider} profile found at ${input.selection}.`, }); } - seedClaudeProfileOnboarding(input.configDir); + await (input.provider === "claude" + ? provisionClaudeAccount(input.selection) + : provisionCodexProfile(input.selection)); return { success: true as const }; }),