diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ac7b69..a5dd43b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,9 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org + - name: Use recent npm + run: npm install -g npm@latest + - name: Install dependencies run: npm ci @@ -40,4 +43,4 @@ jobs: run: npm run prepublishOnly - name: Publish to npm - run: npm publish + run: npm publish --provenance diff --git a/README.md b/README.md index 55ffbe5..0c6242a 100644 --- a/README.md +++ b/README.md @@ -49,18 +49,18 @@ arbella --help ## What it does ```text - push ~/.claude · ~/.codex ──▶ strip · template ──▶ private repo - pull private repo ──▶ install · place ──▶ ~/.claude · ~/.codex + push ~/.claude · ~/.codex · Cursor ──▶ strip · template ──▶ private repo + pull private repo ──▶ install · place ──▶ tools + Cursor User data ``` -`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires your shared instructions into both Claude and Codex. +`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires shared Claude/Codex instructions back into `CLAUDE.md` and `AGENTS.md`. Two things it deliberately won't do: - **Never commits secrets.** API keys, OAuth tokens, `auth.json`, `.credentials.json` — all excluded, no exceptions. You sign back in after a pull, or carry them yourself with [`arbella secrets`](#arbella-secrets). - **Doesn't copy what it can reinstall.** Plugins and registry skills are saved as a list and pulled fresh, so the repo stays small and never goes stale. -Supported today: **Claude Code** and **Codex**, plus a **Cursor** adapter that quietly does nothing when Cursor isn't installed. +Supported today: **Claude Code**, **Codex**, and **Cursor**. Cursor support covers global MCP config, user settings, keybindings, snippets, local skills, skills.sh symlinks, and extension IDs; runtime state and credentials stay out. ## Commands diff --git a/src/adapters/adapter.interface.ts b/src/adapters/adapter.interface.ts index 239d174..cac0bd7 100644 --- a/src/adapters/adapter.interface.ts +++ b/src/adapters/adapter.interface.ts @@ -24,6 +24,7 @@ import type { TemplaterService, TemplateVariables, SourceOfTruth, + EnvVars, } from "../types.js"; /* Re-export ToolManifest so consumers can import it from the interface module @@ -47,6 +48,8 @@ export interface CoreServices { vars: TemplateVariables; /** Current OS (from platform/os.ts). */ os: OS; + /** Process environment, injected so path logic can respect platform env vars in tests. */ + env: EnvVars; } /* -------------------------------------------------------------------------- */ diff --git a/src/adapters/claude/capture.ts b/src/adapters/claude/capture.ts index 729b8c0..3e91da3 100644 --- a/src/adapters/claude/capture.ts +++ b/src/adapters/claude/capture.ts @@ -44,6 +44,7 @@ import { denylistFor, matchesDeny, } from "../../core/sanitizer/denylist.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { listNpmGlobals } from "../../platform/install.js"; import { REPO_PREFIX, FROZEN_PATHS, INSTRUCTIONS_FILE, paths } from "./paths.js"; @@ -111,7 +112,7 @@ async function walk( if (kind === "symlink") { // A symlink anywhere in the frozen tree is preserved as a link. (Skill // symlinks are special-cased by the caller before recursing into skills/.) - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathFor(rel), target }); ctx.log.debug(`claude: symlink ${rel} -> ${target}`); return; @@ -225,7 +226,7 @@ async function captureSkills( const kind = await ctx.fs.statKind(abs); if (kind === "symlink") { // skills.sh: ~/.claude/skills/ -> ../../.agents/skills/ - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathFor(rel), target }); skills.push({ name, diff --git a/src/adapters/codex/capture.ts b/src/adapters/codex/capture.ts index 61a2a76..39b5818 100644 --- a/src/adapters/codex/capture.ts +++ b/src/adapters/codex/capture.ts @@ -32,6 +32,7 @@ import type { } from "../../types.js"; import { denylistFor, matchesDeny } from "../../core/sanitizer/denylist.js"; import { listNpmGlobals } from "../../platform/install.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { CONFIG_FILE, FROZEN_PATHS, @@ -173,7 +174,7 @@ async function captureSymlink( ): Promise { let target: string; try { - target = await ctx.fs.readLink(absPath); + target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(absPath)); } catch { out.warnings.push(`codex: could not read symlink ${rel}; skipped`); return; diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index ac841f5..4888f57 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -2,26 +2,24 @@ * The Cursor adapter — implements the Adapter contract for Cursor. * * Cursor is a desktop app; its CLI may be entirely absent (notably on Linux, - * where there is no headless install). This adapter is therefore deliberately - * MINIMAL but real, and FULLY GRACEFUL when ~/.cursor does not exist: + * where there is no headless install). This adapter is therefore real for the + * portable parts of Cursor and fully graceful when Cursor does not exist: * - * - detect(): true only if ~/.cursor exists (returns false cleanly, - * never throws, when the dir is missing). + * - detect(): true if ~/.cursor or Cursor User data exists; false + * cleanly when both are missing. * - isCliInstalled():probes `cursor` on PATH (often false on this kind of box). * - installCli(os): brew --cask cursor (darwin) / winget (win32); on Linux * there is no standard install -> warn + skip (no throw). * - capture(ctx): if Cursor is absent, returns an EMPTY CaptureResult with a * single warning (so backup of the other tools is never - * blocked). Otherwise freezes ~/.cursor/mcp.json, sanitized - * (secret values redacted) + templated (machine paths -> - * {{HOME}}/...). MCP server entries whose env/headers held - * secret values are recorded as SecretRefs (metadata only; - * the redacted file is what gets stored). - * - restore(ctx,d): writes the frozen files back onto ~/.cursor (rehydrating - * {{TOKENS}} -> machine paths). When the backup repo carried - * shared instructions (R9), also materializes a Cursor user - * rule from /shared/instructions.md so the single - * CLAUDE.md == AGENTS.md content reaches Cursor too. + * blocked). Otherwise freezes global MCP config, Cursor User + * settings/keybindings/snippets, local skills, skills.sh + * symlinks, and extension IDs. Secret values are redacted and + * machine paths are templated before anything enters the repo. + * - restore(ctx,d): writes those files back onto ~/.cursor or Cursor's User + * data dir, rehydrating {{TOKENS}} to this machine's paths. + * It also recreates skill symlinks, best-effort installs + * extensions via `cursor --install-extension`. * * All fs work goes through the injected CoreServices on the context objects, so * the adapter is unit-testable against a fixture dir. No direct node:fs, no clock. @@ -29,6 +27,9 @@ */ import path from "node:path"; +import process from "node:process"; + +import { execa } from "execa"; import type { Adapter, CaptureContext, RestoreContext, RestoreData } from "../adapter.interface.js"; import type { @@ -36,20 +37,24 @@ import type { CapturedFile, CapturedSymlink, OS, + PluginEntry, + RestoreAction, SecretRef, + SkillEntry, ToolManifest, } from "../../types.js"; import { denylistFor, matchesDeny } from "../../core/sanitizer/denylist.js"; -import { cliBinaryName, installCommandFor } from "../../platform/os.js"; +import { cliBinaryName, detectOS, installCommandFor } from "../../platform/os.js"; import { runInstall, which } from "../../platform/install.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { FROZEN_PATHS, REPO_PREFIX, - SHARED_INSTRUCTIONS_REPO_PATH, + USER_REPO_PREFIX, + cursorUserPaths, paths, - sharedRulePath, } from "./paths.js"; /* -------------------------------------------------------------------------- */ @@ -61,18 +66,35 @@ function repoPathFor(rel: string): string { return `${REPO_PREFIX}/${rel}`; } +/** Build the repo path for a Cursor User-data-relative POSIX path. */ +function userRepoPathFor(rel: string): string { + return `${USER_REPO_PREFIX}/${rel}`; +} + /** Convert an absolute child path under `home` into a POSIX rel path. */ function toRel(home: string, abs: string): string { return path.relative(home, abs).split(path.sep).join("/"); } -/** Strip the leading "cursor/files/" prefix from a repoPath. Returns the POSIX - * tail relative to the tool home, or undefined if the path is not ours. */ -function relFromRepoPath(repoPath: string): string | undefined { +type CursorTarget = { root: "home" | "user"; rel: string }; + +/** Strip cursor/files or cursor/user from a repo path and keep which root it uses. */ +function targetFromRepoPath(repoPath: string): CursorTarget | undefined { const norm = repoPath.replace(/\\/g, "/"); - const prefix = `${REPO_PREFIX}/`; - if (!norm.startsWith(prefix)) return undefined; - return norm.slice(prefix.length); + const homePrefix = `${REPO_PREFIX}/`; + if (norm.startsWith(homePrefix)) return { root: "home", rel: norm.slice(homePrefix.length) }; + + const userPrefix = `${USER_REPO_PREFIX}/`; + if (norm.startsWith(userPrefix)) return { root: "user", rel: norm.slice(userPrefix.length) }; + + return undefined; +} + +/** Resolve a CursorTarget onto the target machine. */ +function targetAbsFor(ctx: RestoreContext, target: CursorTarget): string { + const root = + target.root === "home" ? ctx.toolHome : cursorUserPaths(ctx.toolHome, ctx.os, ctx.env).userDir; + return path.join(root, ...target.rel.split("/").filter(Boolean)); } /** Heuristic: treat a file as binary if its bytes contain a NUL. */ @@ -142,6 +164,268 @@ function collectMcpSecretRefs( return refs; } +/** Read mode bits best-effort so executable helper files keep their mode. */ +async function readMode(abs: string): Promise { + try { + const { promises: fsp } = await import("node:fs"); + const st = await fsp.stat(abs); + return st.mode & 0o777; + } catch { + return undefined; + } +} + +/** Read + sanitize + template a single file and push a CapturedFile. */ +async function captureFile(args: { + ctx: CaptureContext; + abs: string; + rel: string; + repoPath: string; + files: CapturedFile[]; + secrets: SecretRef[]; + warnings: string[]; + scanMcpSecrets?: boolean; +}): Promise { + const { ctx, abs, rel, repoPath, files, secrets, warnings, scanMcpSecrets = false } = args; + const mode = await readMode(abs); + + let bytes: Buffer; + try { + bytes = await ctx.fs.readBytes(abs); + } catch (err) { + warnings.push(`cursor: could not read ${rel}: ${(err as Error).message}`); + return; + } + + if (looksBinary(bytes)) { + const file: CapturedFile = { + repoPath, + content: bytes.toString("base64"), + binary: true, + }; + if (mode !== undefined) file.mode = mode; + files.push(file); + return; + } + + const raw = bytes.toString("utf8"); + if (scanMcpSecrets) { + try { + secrets.push(...collectMcpSecretRefs(ctx, JSON.parse(raw), rel)); + } catch (err) { + warnings.push(`cursor: could not parse ${rel} for secret scan: ${(err as Error).message}`); + } + } + + let content = raw; + if (!ctx.includeSecrets) { + const sanitized = ctx.sanitizer.sanitizeFile(raw, "cursor", rel); + content = sanitized.content; + secrets.push(...sanitized.found); + } + const templated = ctx.templater.toTemplate(content, ctx.vars); + const file: CapturedFile = { repoPath, content: templated }; + if (mode !== undefined) file.mode = mode; + files.push(file); +} + +/** Recursively freeze a Cursor directory tree. */ +async function walkFrozen( + ctx: CaptureContext, + root: string, + abs: string, + deny: string[], + repoPathBuilder: (rel: string) => string, + files: CapturedFile[], + symlinks: CapturedSymlink[], + secrets: SecretRef[], + warnings: string[], +): Promise { + const kind = await ctx.fs.statKind(abs); + if (kind === "missing") return; + + const rel = toRel(root, abs); + if (rel && matchesDeny(rel, deny)) { + ctx.log.debug(`cursor: skip (denylist) ${rel}`); + return; + } + + if (kind === "symlink") { + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); + symlinks.push({ repoPath: repoPathBuilder(rel), target }); + ctx.log.debug(`cursor: symlink ${rel} -> ${target}`); + return; + } + + if (kind === "dir") { + const entries = await ctx.fs.list(abs); + entries.sort(); + for (const name of entries) { + await walkFrozen(ctx, root, path.join(abs, name), deny, repoPathBuilder, files, symlinks, secrets, warnings); + } + return; + } + + await captureFile({ + ctx, + abs, + rel, + repoPath: repoPathBuilder(rel), + files, + secrets, + warnings, + scanMcpSecrets: rel === "mcp.json", + }); +} + +/** Classify Cursor skills as reinstallable symlinks or frozen local skill dirs. */ +async function captureSkills( + ctx: CaptureContext, + home: string, + skillsDir: string, + deny: string[], + files: CapturedFile[], + symlinks: CapturedSymlink[], + skills: SkillEntry[], + secrets: SecretRef[], + warnings: string[], +): Promise { + if ((await ctx.fs.statKind(skillsDir)) !== "dir") return; + + const entries = await ctx.fs.list(skillsDir); + entries.sort(); + + for (const name of entries) { + const abs = path.join(skillsDir, name); + const rel = toRel(home, abs); + if (matchesDeny(rel, deny)) continue; + + const kind = await ctx.fs.statKind(abs); + if (kind === "symlink") { + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); + symlinks.push({ repoPath: repoPathFor(rel), target }); + skills.push({ + name, + source: "skills.sh", + installCommand: `npx skills add ${name}`, + symlinked: true, + }); + continue; + } + + if (kind === "dir") { + skills.push({ name, source: "frozen", symlinked: false }); + await walkFrozen(ctx, home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + continue; + } + + if (kind === "file") { + skills.push({ name, source: "frozen", symlinked: false }); + await walkFrozen(ctx, home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + } + } +} + +function parseExtensionId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(trimmed)) return undefined; + return trimmed; +} + +function parseCursorExtensionEntry(entry: unknown): PluginEntry | undefined { + if (entry === null || typeof entry !== "object") return undefined; + const obj = entry as Record; + const identifier = obj.identifier; + const identifierObj = identifier !== null && typeof identifier === "object" + ? identifier as Record + : {}; + const id = parseExtensionId(identifierObj.id) ?? parseExtensionId(obj.id); + if (id === undefined) return undefined; + const version = typeof obj.version === "string" ? obj.version : undefined; + return { + id, + name: id, + version, + enabled: true, + scope: "user", + }; +} + +function parseCursorExtensionsJson(raw: string): PluginEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const out: PluginEntry[] = []; + const seen = new Set(); + for (const entry of parsed) { + const plugin = parseCursorExtensionEntry(entry); + if (plugin === undefined || seen.has(plugin.id)) continue; + seen.add(plugin.id); + out.push(plugin); + } + return out; +} + +function parseCursorExtensionLine(line: string): PluginEntry | undefined { + const trimmed = line.trim(); + if (trimmed.length === 0) return undefined; + const at = trimmed.lastIndexOf("@"); + const id = at > 0 ? trimmed.slice(0, at) : trimmed; + const version = at > 0 ? trimmed.slice(at + 1) : undefined; + const parsedId = parseExtensionId(id); + if (parsedId === undefined) return undefined; + return { + id: parsedId, + name: parsedId, + version: version && version.length > 0 ? version : undefined, + enabled: true, + scope: "user", + }; +} + +/** Capture extension IDs without copying extension payloads. */ +async function captureExtensions(ctx: CaptureContext, cursorHome: string, warnings: string[]): Promise { + const extensionState = path.join(cursorHome, "extensions", "extensions.json"); + if ((await ctx.fs.statKind(extensionState)) === "file") { + try { + const fromState = parseCursorExtensionsJson(await ctx.fs.read(extensionState)); + if (fromState.length > 0) return fromState; + } catch (err) { + warnings.push(`cursor: could not read extensions metadata: ${(err as Error).message}`); + } + } + + if (!(await which(cliBinaryName("cursor")))) return []; + + try { + const result = await execa(cliBinaryName("cursor"), ["--list-extensions", "--show-versions"], { + stdin: "ignore", + stderr: "ignore", + stdout: "pipe", + reject: false, + }); + const plugins = result.stdout + .split(/\r?\n/) + .map(parseCursorExtensionLine) + .filter((plugin): plugin is PluginEntry => plugin !== undefined); + const seen = new Set(); + return plugins.filter((plugin) => { + if (seen.has(plugin.id)) return false; + seen.add(plugin.id); + return true; + }); + } catch (err) { + warnings.push(`cursor: could not list extensions via CLI: ${(err as Error).message}`); + return []; + } +} + /* -------------------------------------------------------------------------- */ /* Capture */ /* -------------------------------------------------------------------------- */ @@ -168,82 +452,39 @@ export async function capture( const manifest = emptyCursorManifest(); const p = paths(ctx.toolHome); + const userPaths = cursorUserPaths(p.home, ctx.os, ctx.env); const deny = denylistFor("cursor"); // Graceful absence: Cursor may not be installed at all. Never block the rest // of the backup — return an empty result with a single explanatory warning. - if ((await ctx.fs.statKind(p.home)) !== "dir") { - warnings.push("cursor: ~/.cursor not found; skipping (Cursor not installed?)."); + const hasToolHome = (await ctx.fs.statKind(p.home)) === "dir"; + const hasUserDir = (await ctx.fs.statKind(userPaths.userDir)) === "dir"; + if (!hasToolHome && !hasUserDir) { + warnings.push("cursor: no ~/.cursor or Cursor User data found; skipping (Cursor not installed?)."); return { tool: "cursor", files, symlinks, manifest, secrets, warnings }; } - for (const rel of FROZEN_PATHS) { - const abs = path.join(p.home, rel); - const relPosix = toRel(p.home, abs); - - if (matchesDeny(relPosix, deny)) { - ctx.log.debug(`cursor: skip (denylist) ${relPosix}`); - continue; - } - - const kind = await ctx.fs.statKind(abs); - if (kind === "missing") { - ctx.log.debug(`cursor: not present ${relPosix}`); - continue; - } - if (kind !== "file") { - // The minimal Cursor adapter only freezes top-level files (mcp.json). - ctx.log.debug(`cursor: skip non-file ${relPosix}`); - continue; - } - - let bytes: Buffer; - try { - bytes = await ctx.fs.readBytes(abs); - } catch (err) { - warnings.push(`cursor: could not read ${relPosix}: ${(err as Error).message}`); - continue; - } - - if (looksBinary(bytes)) { - // Unexpected for mcp.json, but stay robust: store as base64. - files.push({ repoPath: repoPathFor(relPosix), content: bytes.toString("base64"), binary: true }); - continue; + if (hasToolHome) { + for (const rel of FROZEN_PATHS) { + const abs = path.join(p.home, rel); + if (rel === "skills") { + await captureSkills(ctx, p.home, abs, deny, files, symlinks, manifest.skills, secrets, warnings); + } else { + await walkFrozen(ctx, p.home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + } } + manifest.plugins = await captureExtensions(ctx, p.home, warnings); + } - const raw = bytes.toString("utf8"); - - // For mcp.json: discover secret refs from the parsed shape BEFORE redaction, - // so we can tell the user which MCP server env/header to re-supply. - if (relPosix === "mcp.json") { - try { - secrets.push(...collectMcpSecretRefs(ctx, JSON.parse(raw), relPosix)); - } catch (err) { - warnings.push(`cursor: could not parse mcp.json for secret scan: ${(err as Error).message}`); - } + if (hasUserDir) { + const userItems = [userPaths.settingsJson, userPaths.keybindingsJson, userPaths.snippetsDir] as const; + for (const abs of userItems) { + await walkFrozen(ctx, userPaths.userDir, abs, deny, userRepoPathFor, files, symlinks, secrets, warnings); } + } - // Sanitize secret VALUES (unless includeSecrets opts INTO carrying them), - // then template machine paths to placeholders. - // - // sanitizeFile routes mcp.json through the STRUCTURAL key-name-aware sanitizer - // so an opaque MCP env/header secret stored under a secret KEY NAME (e.g. - // ACME_API_KEY with a custom-shaped value) is redacted before it is stored, - // not just values matching a known token regex. - // - // When ctx.includeSecrets is ON (opt-in, default OFF), the documented switch - // carries the real MCP secret values into the PRIVATE repo (we still recorded - // the SecretRefs above so the user is told which ones are now present). - const content = ctx.includeSecrets - ? raw - : ctx.sanitizer.sanitizeFile(raw, "cursor", relPosix).content; - const templated = ctx.templater.toTemplate(content, ctx.vars); - files.push({ repoPath: repoPathFor(relPosix), content: templated }); - ctx.log.debug(`cursor: froze ${relPosix}`); - } - - if (files.length === 0) { - warnings.push("cursor: present but nothing portable to capture (no mcp.json)."); + if (files.length === 0 && symlinks.length === 0 && manifest.plugins.length === 0) { + warnings.push("cursor: present but nothing portable to capture."); } return { tool: "cursor", files, symlinks, manifest, secrets, warnings }; @@ -260,15 +501,15 @@ export async function capture( * local file (the repo only "wins" when sourceOfTruth === "repo"). */ async function writeRestoredFile(ctx: RestoreContext, file: CapturedFile): Promise { - const rel = relFromRepoPath(file.repoPath); - if (rel === undefined) { + const target = targetFromRepoPath(file.repoPath); + if (target === undefined) { ctx.log.debug(`cursor: ignoring foreign repoPath ${file.repoPath}`); return; } - const dest = path.join(ctx.toolHome, ...rel.split("/")); + const dest = targetAbsFor(ctx, target); if (ctx.sourceOfTruth === "local" && (await ctx.fs.exists(dest))) { - ctx.log.debug(`cursor: keep local ${rel} (sourceOfTruth=local)`); + ctx.log.debug(`cursor: keep local ${target.rel} (sourceOfTruth=local)`); return; } @@ -281,41 +522,118 @@ async function writeRestoredFile(ctx: RestoreContext, file: CapturedFile): Promi await ctx.fs.write(dest, expanded, file.mode); } +/** Recreate a captured symlink under ~/.cursor or Cursor User data. */ +async function writeRestoredSymlink(ctx: RestoreContext, link: CapturedSymlink): Promise { + const target = targetFromRepoPath(link.repoPath); + if (target === undefined) { + ctx.log.debug(`cursor: ignoring foreign symlink ${link.repoPath}`); + return; + } + const dest = targetAbsFor(ctx, target); + + if (ctx.sourceOfTruth === "local" && (await ctx.fs.statKind(dest)) !== "missing") { + ctx.log.debug(`cursor: keep local symlink ${target.rel} (sourceOfTruth=local)`); + return; + } + + await ctx.fs.symlink(link.target, dest); +} + +/** Best-effort Cursor extension reinstall via the `cursor` CLI. */ +async function reinstallExtensions(ctx: RestoreContext, plugins: PluginEntry[]): Promise { + const userPlugins = plugins.filter((plugin) => plugin.scope === "user"); + if (userPlugins.length === 0) return; + + const cursorBin = cliBinaryName("cursor"); + if (!(await which(cursorBin))) { + ctx.log.warn( + "cursor CLI not found; extension IDs were restored in manifest.json, but extensions were not installed.", + ); + return; + } + + for (const plugin of userPlugins) { + try { + const result = await execa(cursorBin, ["--install-extension", plugin.id], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + reject: false, + }); + if (result.exitCode === 0) { + ctx.log.step(`cursor: installed extension ${plugin.id}`); + } else { + const stderr = result.stderr.trim(); + ctx.log.warn( + `cursor: failed to install extension ${plugin.id}${stderr ? ` (${stderr})` : ""}`, + ); + } + } catch (err) { + ctx.log.warn(`cursor: failed to install extension ${plugin.id}: ${(err as Error).message}`); + } + } +} + /** - * Deploy the shared-instructions (R9) content as a Cursor user rule. Reads - * /shared/instructions.md (if present) and writes it under the Cursor - * rules dir. Best-effort: absence of the shared file is not an error. + * Build the list of restore actions WITHOUT executing them (for --dry-run and + * safety-backup sizing). */ -async function deploySharedRule(ctx: RestoreContext): Promise { - const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); - if (!(await ctx.fs.exists(sharedAbs))) { - ctx.log.debug("cursor: no shared/instructions.md to deploy as a Cursor rule."); - return; +export async function planActions( + ctx: RestoreContext, + data: RestoreData, +): Promise { + const actions: RestoreAction[] = []; + + for (const file of data.files) { + const target = targetFromRepoPath(file.repoPath); + if (target === undefined) continue; + const targetPath = targetAbsFor(ctx, target); + const overwrites = await ctx.fs.exists(targetPath); + if (ctx.sourceOfTruth === "local" && overwrites) continue; + actions.push({ + type: "write-file", + tool: "cursor", + targetPath, + description: `Write ${target.root === "user" ? "User/" : ""}${target.rel}`, + overwrites, + }); } - const body = await ctx.fs.read(sharedAbs); - // Cursor `.mdc` rules support frontmatter; `alwaysApply: true` makes the rule - // global. The instructions content is plain markdown and needs no templating. - const rule = `---\ndescription: Shared agent instructions (managed by arbella)\nalwaysApply: true\n---\n\n${body}`; - const dest = sharedRulePath(ctx.toolHome); - await ctx.fs.write(dest, rule); - ctx.log.debug(`cursor: wrote shared-instructions rule -> ${dest}`); + + for (const link of data.symlinks) { + const target = targetFromRepoPath(link.repoPath); + if (target === undefined) continue; + const targetPath = targetAbsFor(ctx, target); + const overwrites = (await ctx.fs.statKind(targetPath)) !== "missing"; + if (ctx.sourceOfTruth === "local" && overwrites) continue; + actions.push({ + type: "write-symlink", + tool: "cursor", + targetPath, + description: `Link ${target.root === "user" ? "User/" : ""}${target.rel} -> ${link.target}`, + overwrites, + }); + } + + for (const plugin of data.manifest.plugins.filter((p) => p.scope === "user")) { + actions.push({ + type: "install-plugin", + tool: "cursor", + description: `cursor --install-extension ${plugin.id}`, + }); + } + + return actions; } /** - * Restore Cursor: place frozen files back, then (if the repo carried shared - * instructions) materialize the Cursor user rule. Honors ctx.dryRun (plan only). - * Exported for direct use by the restore command + the Adapter wrapper below. + * Restore Cursor: place frozen files/User data back, recreate symlinks, and + * best-effort install extensions. Honors ctx.dryRun (plan only). Exported for + * direct use by the restore command + the Adapter wrapper below. */ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { if (ctx.dryRun) { - for (const file of data.files) { - const rel = relFromRepoPath(file.repoPath); - if (rel) ctx.log.step(`cursor: would write ${path.join(ctx.toolHome, ...rel.split("/"))}`); - } - const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); - if (await ctx.fs.exists(sharedAbs)) { - ctx.log.step(`cursor: would write shared-instructions rule -> ${sharedRulePath(ctx.toolHome)}`); - } + const actions = await planActions(ctx, data); + for (const action of actions) ctx.log.step(action.description); return; } @@ -327,21 +645,28 @@ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { - return fsExistsDir(paths().home); + const p = paths(); + return ( + (await fsExistsDir(p.home)) || + (await fsExistsDir(cursorUserPaths(p.home, detectOS(), process.env).userDir)) + ); } /** Local lstat-free existence-as-dir probe via the default fs service. */ diff --git a/src/adapters/cursor/paths.ts b/src/adapters/cursor/paths.ts index 4550058..b30fe35 100644 --- a/src/adapters/cursor/paths.ts +++ b/src/adapters/cursor/paths.ts @@ -10,17 +10,17 @@ * correct on win32 as well. The `repoPath` prefix, by contrast, is a POSIX-only * string used inside the backup repo and is intentionally a literal. * - * Cursor reality: Cursor is a desktop app and may be - * entirely absent (no CLI on Linux). The only globally-portable artifact is - * `~/.cursor/mcp.json` (`{ "mcpServers": { ... } }`). Project-level - * `.cursor/rules` are repo-specific and out of scope for the global backup; on - * restore, the shared-instructions (R9) content is materialized as a Cursor user - * rule under the rules dir. + * Cursor reality: Cursor is a desktop app and may be entirely absent (no CLI on + * Linux). Portable global state is split between `~/.cursor` (MCP, skills, + * rules) and the VS Code-style application User directory (settings, + * keybindings, snippets). Runtime state, projects, workspace DBs, and extension + * payloads remain out of scope. */ import path from "node:path"; -import { toolHomeDir } from "../../platform/os.js"; +import type { EnvVars, OS } from "../../types.js"; +import { appUserConfigRoot, toolHomeDir } from "../../platform/os.js"; /** Absolute path to ~/.cursor on this machine. */ export function home(): string { @@ -33,6 +33,12 @@ export function home(): string { */ export const REPO_PREFIX = "cursor/files"; +/** + * Prefix (POSIX) for Cursor application User data. A file at + * `/X` is stored at `cursor/user/X` in the backup repo. + */ +export const USER_REPO_PREFIX = "cursor/user"; + /** Fully-resolved set of Cursor paths, all absolute. */ export interface CursorPaths { /** ~/.cursor */ @@ -41,8 +47,18 @@ export interface CursorPaths { mcpJson: string; /** ~/.cursor/skills */ skillsDir: string; - /** ~/.cursor/rules (global user rules; may not exist) */ - rulesDir: string; +} + +/** Fully-resolved Cursor application User data paths, all absolute. */ +export interface CursorUserPaths { + /** VS Code-style Cursor User data directory. */ + userDir: string; + /** settings.json */ + settingsJson: string; + /** keybindings.json */ + keybindingsJson: string; + /** snippets directory */ + snippetsDir: string; } /** @@ -56,37 +72,32 @@ export function paths(overrideHome?: string): CursorPaths { home: base, mcpJson: path.join(base, "mcp.json"), skillsDir: path.join(base, "skills"), - rulesDir: path.join(base, "rules"), }; } /** - * Files/dirs to FREEZE (copy into the repo), relative to the tool home, in - * capture order. Cursor's global, portable state is just `mcp.json`. The skills - * dir is intentionally NOT frozen here: like Claude/Codex it is a skills.sh - * mechanism (relative symlinks into ~/.agents/skills) handled centrally, and the - * minimal Cursor adapter keeps to the MCP config + R9 rule on restore. - * - * NOTE: anything matching the denylist is skipped during capture regardless of - * its presence here. + * Build Cursor's platform-specific app User data paths from the tool home. The + * tests pass a fixture tool home; live code passes the real ~/.cursor. */ -export const FROZEN_PATHS: readonly string[] = ["mcp.json"] as const; +export function cursorUserPaths(toolHome: string, os: OS, env: EnvVars = {}): CursorUserPaths { + const userHome = path.dirname(toolHome); + const userDir = path.join(appUserConfigRoot(os, userHome, env), "Cursor", "User"); -/** - * Repo path (POSIX) of the shared-instructions file the backup command writes at - * the repo root when R9 is active. The Cursor adapter reads this on restore to - * materialize a Cursor user rule. - */ -export const SHARED_INSTRUCTIONS_REPO_PATH = "shared/instructions.md"; + return { + userDir, + settingsJson: path.join(userDir, "settings.json"), + keybindingsJson: path.join(userDir, "keybindings.json"), + snippetsDir: path.join(userDir, "snippets"), + }; +} /** - * Basename of the Cursor user-rule file the adapter writes (under the rules dir) - * to carry the shared CLAUDE.md/AGENTS.md instructions across to Cursor (R9). - * `.mdc` is Cursor's rule file extension. + * Files/dirs to FREEZE (copy into the repo), relative to the tool home, in + * capture order. Cursor's `skills` dir is intentionally included: symlinked + * skills are recorded as reinstallable skills.sh entries, while local skill dirs + * are frozen just like Claude/Codex local skills. + * + * NOTE: anything matching the denylist is skipped during capture regardless of + * its presence here. */ -export const SHARED_RULE_FILENAME = "arbella-shared-instructions.mdc"; - -/** Absolute path to the Cursor user rule deployed on restore for R9. */ -export function sharedRulePath(overrideHome?: string): string { - return path.join(paths(overrideHome).rulesDir, SHARED_RULE_FILENAME); -} +export const FROZEN_PATHS: readonly string[] = ["mcp.json", "skills"] as const; diff --git a/src/commands/_context.ts b/src/commands/_context.ts index 25df735..5a00c12 100644 --- a/src/commands/_context.ts +++ b/src/commands/_context.ts @@ -16,6 +16,7 @@ */ import { confirm, isCancel, password } from "@clack/prompts"; +import process from "node:process"; import type { CaptureContext, @@ -53,6 +54,7 @@ export function buildCoreServices(toolHome: string): CoreServices { templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, }; } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 23ae692..9df5922 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -43,6 +43,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -88,9 +89,10 @@ import { capture as captureCodex } from "../adapters/codex/capture.js"; import { capture as captureCursor } from "../adapters/cursor/index.js"; import type { Adapter } from "../adapters/adapter.interface.js"; +import { getPackageVersion } from "../core/version.js"; -/** arbella version stamped into arbella.json (kept in sync with package.json). */ -const ARBELLA_VERSION = "0.1.0"; +/** arbella version stamped into arbella.json. */ +const ARBELLA_VERSION = getPackageVersion(); /* -------------------------------------------------------------------------- */ /* Options + CLI registration */ @@ -178,6 +180,7 @@ function buildCoreServices(toolHome: string): CoreServices { templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, }; } @@ -405,16 +408,18 @@ function toolFilesPrefix(tool: ToolId): string { /** * Replace a captured tool's frozen subtree wholesale, then write its fresh files - * + symlinks + manifest. Removing `/files` first makes the backup a true - * mirror: files deleted locally disappear from the repo on the next backup. + * + symlinks + manifest. Removing each owned repo data root first makes the + * backup a true mirror: files deleted locally disappear from the repo on the next + * backup. Cursor owns an extra `cursor/user` root for application User data. * * NOTE: memories (when included) are emitted by the codex adapter under * `codex/files/memories/...`, so they live inside the same subtree and are * covered by this replace. */ async function replaceToolFiles(repoRoot: string, result: CaptureResult): Promise { - const filesDir = repoJoin(repoRoot, toolFilesPrefix(result.tool)); - await fs.rmrf(filesDir); + for (const root of toolRepoDataRoots(result.tool)) { + await fs.rmrf(repoJoin(repoRoot, root)); + } for (const file of result.files) { await writeCapturedFile(repoRoot, file); @@ -432,6 +437,12 @@ async function replaceToolFiles(repoRoot: string, result: CaptureResult): Promis await fs.write(manifestPath, serialize(result.manifest)); } +function toolRepoDataRoots(tool: ToolId): string[] { + const roots = [toolFilesPrefix(tool)]; + if (tool === "cursor") roots.push("cursor/user"); + return roots; +} + /** * Write a single CapturedFile into the repo working tree. Handles the text vs. * base64-binary distinction and preserves an explicit file mode (e.g. 0o755 for @@ -482,9 +493,13 @@ function renderRepoReadme( generatedAtIso: string, ): string { const toolList = meta.tools.map((t) => `- ${toolLabel(t)} (\`${t}\`)`).join("\n"); + const cursorUserLine = meta.tools.includes("cursor") + ? "- `cursor/user/…` — Cursor application User data such as settings,\n" + + " keybindings, and snippets.\n" + : ""; const sharedLine = meta.sharedInstructions ? "Your `CLAUDE.md` and `AGENTS.md` were identical and are stored once in " + - "[`shared/instructions.md`](shared/instructions.md); restore deploys it to both tools.\n" + "[`shared/instructions.md`](shared/instructions.md); restore deploys it to Claude Code and Codex.\n" : ""; return `# arbella backup @@ -506,6 +521,7 @@ Each tool lives under \`/\`: - \`/files/…\` — frozen config files (paths replaced with \`{{HOME}}\`-style placeholders, secret values redacted). +${cursorUserLine} - \`/manifest.json\` — what to reinstall (plugins, marketplaces, skills, npm globals) and which plugins to re-enable. @@ -520,7 +536,7 @@ npm install -g arbella arbella pull \`\`\` -arbella will (R6/R14) take a timestamped safety copy of any existing tool homes, +arbella will (R6/R14) take a timestamped safety copy of any existing restore targets, auto-install missing CLIs, write the frozen files back (re-expanding placeholders to this machine's paths), reinstall plugins/marketplaces/skills, and re-enable plugins. @@ -587,19 +603,21 @@ function renderRepoGitignore(tools: ToolId[]): string { ]; for (const name of secretBasenames) lines.push(name); - // Scope each tool's denylist directory patterns under /files/ so noise - // dirs (sessions/, cache/, …) cannot sneak in if someone copies a raw home in. - lines.push("", "# Per-tool excluded directories (scoped under /files/)"); + // Scope each tool's denylist directory patterns under owned data roots so noise + // dirs (sessions/, cache/, …) cannot sneak in if someone copies raw data in. + lines.push("", "# Per-tool excluded directories (scoped under owned data roots)"); const seen = new Set(); for (const tool of tools) { for (const pattern of denylistFor(tool)) { // Only directory patterns are useful to scope here; loose globs are already // covered by the universal rules above. if (!pattern.endsWith("/")) continue; - const scoped = `${tool}/files/${pattern}`; - if (seen.has(scoped)) continue; - seen.add(scoped); - lines.push(scoped); + for (const root of toolRepoDataRoots(tool)) { + const scoped = `${root}/${pattern}`; + if (seen.has(scoped)) continue; + seen.add(scoped); + lines.push(scoped); + } } } diff --git a/src/commands/restore.ts b/src/commands/restore.ts index b778af7..aa0c9cb 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -7,9 +7,8 @@ * to `config.repo`. Clone it if it is not already present locally, else pull * so the working copy is fresh (R12). * 2. Parse `arbella.json` (ArbellaMeta) at the repo root. - * 3. Decide the set of tools to restore: intersection of the repo's captured - * tools and (a) the `--tools` flag if given, else (b) the configured tools, - * else (c) every captured tool. + * 3. Decide the set of tools to restore: every tool captured in the repo, unless + * the user explicitly narrows it with `--tools`. * 4. For each tool, load its RestoreData (frozen files + symlinks reconstructed * from //files, manifest via parseManifest) and build the * adapter's planned actions. Probe which CLIs are missing (R6). @@ -20,10 +19,9 @@ * and run each adapter's restore() (which places files, recreates symlinks, * reinstalls plugins/marketplaces/skills, re-enables plugins, installs npm * globals — all guarded + best-effort per adapter). - * 7. Deploy the shared instructions (R9) to BOTH tools when meta.sharedInstructions + * 7. Deploy the shared instructions (R9) to Claude + Codex when meta.sharedInstructions * is set: write shared/instructions.md to each sharedInstructionsTargets() - * destination; the cursor adapter additionally writes a Cursor rule from the - * same source inside its own restore(). + * destination. * 8. Print a re-auth reminder: secret files (.credentials.json / auth.json) were * NEVER carried, so the user must re-login. Never prints any secret value. * @@ -41,6 +39,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -97,7 +96,8 @@ import { import { claudeAdapter } from "../adapters/claude/index.js"; import { codexAdapter } from "../adapters/codex/index.js"; -import { cursorAdapter } from "../adapters/cursor/index.js"; +import { cursorAdapter, planActions as cursorPlanActions } from "../adapters/cursor/index.js"; +import { cursorUserPaths } from "../adapters/cursor/paths.js"; import { planActions as claudePlanActions } from "../adapters/claude/restore.js"; import { planActions as codexPlanActions } from "../adapters/codex/restore.js"; @@ -124,9 +124,7 @@ export interface RestoreOptions { /** * Per-tool adapter + its (optional) standalone planActions exporter. The Adapter * interface itself only exposes restore(); planActions lives as a sibling module - * export for claude/codex. Cursor has no separate - * planner — its dry-run plan is derived by running restore() with dryRun=true, - * which logs the intended writes (see cursor/index.ts). We model that uniformly. + * export for the adapters that need tool-specific path mapping. */ interface ToolWiring { readonly id: ToolId; @@ -141,7 +139,7 @@ interface ToolWiring { const WIRING: readonly ToolWiring[] = [ { id: "claude", adapter: claudeAdapter, planActions: claudePlanActions }, { id: "codex", adapter: codexAdapter, planActions: codexPlanActions }, - { id: "cursor", adapter: cursorAdapter }, + { id: "cursor", adapter: cursorAdapter, planActions: cursorPlanActions }, ]; /** Look up the wiring for a tool id (every ToolId has an entry). */ @@ -169,6 +167,7 @@ function buildCoreServices(toolHome: string, os: OS): CoreServices { templater: createTemplater(), vars: buildVariables(toolHome), os, + env: process.env, }; } @@ -210,10 +209,10 @@ function looksBinary(buf: Buffer): boolean { } /** - * Recursively walk `/files` inside the cloned repo and reconstruct the + * Recursively walk frozen roots inside the cloned repo and reconstruct the * CapturedFile[] / CapturedSymlink[] the adapter expects. `repoPath` is rebuilt - * with the canonical "/files/" prefix (POSIX separators) so the - * adapter's stripPrefix() maps it back onto the target tool home. + * with the root's canonical prefix (POSIX separators), e.g. "claude/files/..." + * or "cursor/user/...". * * Symlinks captured under skills/ are preserved verbatim (their target is the * data). Regular files are classified binary vs. text on read. Missing dirs are @@ -223,17 +222,12 @@ async function readToolFrozen( repoToolDir: string, tool: ToolId, ): Promise<{ files: CapturedFile[]; symlinks: CapturedSymlink[] }> { - const filesRoot = path.join(repoToolDir, "files"); const files: CapturedFile[] = []; const symlinks: CapturedSymlink[] = []; + const roots = frozenRootsForTool(repoToolDir, tool); - // Nothing frozen for this tool (e.g. cursor absent at capture) -> empty. - if ((await fs.statKind(filesRoot)) !== "dir") { - return { files, symlinks }; - } - - /** relParts: POSIX path segments under filesRoot accumulated so far. */ - async function walk(absDir: string, relParts: string[]): Promise { + /** relParts: POSIX path segments under the frozen root accumulated so far. */ + async function walk(absDir: string, relParts: string[], repoPrefix: string): Promise { const entries = await fs.list(absDir); // Stable order so dry-run output and writes are deterministic. entries.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); @@ -242,7 +236,7 @@ async function readToolFrozen( const abs = path.join(absDir, name); const nextRel = [...relParts, name]; const relPosix = nextRel.join("/"); - const repoPath = `${tool}/files/${relPosix}`; + const repoPath = `${repoPrefix}/${relPosix}`; const kind = await fs.statKind(abs); if (kind === "symlink") { @@ -260,7 +254,7 @@ async function readToolFrozen( } if (kind === "dir") { - await walk(abs, nextRel); + await walk(abs, nextRel, repoPrefix); continue; } @@ -295,10 +289,26 @@ async function readToolFrozen( } } - await walk(filesRoot, []); + for (const root of roots) { + if ((await fs.statKind(root.absRoot)) !== "dir") continue; + await walk(root.absRoot, [], root.repoPrefix); + } return { files, symlinks }; } +function frozenRootsForTool( + repoToolDir: string, + tool: ToolId, +): Array<{ absRoot: string; repoPrefix: string }> { + const roots = [ + { absRoot: path.join(repoToolDir, "files"), repoPrefix: `${tool}/files` }, + ]; + if (tool === "cursor") { + roots.push({ absRoot: path.join(repoToolDir, "user"), repoPrefix: "cursor/user" }); + } + return roots; +} + /** * Read a tool's manifest.json from //manifest.json and validate * it. A missing manifest is tolerated (returns an empty manifest) so a tool that @@ -329,7 +339,7 @@ async function readToolManifest( } /** Assemble the full RestoreData (manifest + frozen files/symlinks) for a tool. */ -async function loadRestoreData( +export async function loadRestoreData( repoRoot: string, tool: ToolId, ): Promise { @@ -358,22 +368,21 @@ function parseToolsFlag(raw: string | undefined): ToolId[] | undefined { /** * Decide which tools to restore. Always constrained to what the repo actually - * captured (`meta.tools`). Then narrowed by `--tools` if provided, else by the - * configured tools, else left as the full captured set. Order follows the - * canonical TOOL_IDS order for deterministic output. + * captured (`meta.tools`). `--tools` is the only narrowing mechanism: a stale + * local config on a fresh/old machine must not silently skip tools that are + * present in the backup repo. Order follows the canonical TOOL_IDS order for + * deterministic output. */ -function selectTools( +export function selectToolsForRestore( meta: ArbellaMeta, flagTools: ToolId[] | undefined, - configTools: ToolId[], + _configTools: ToolId[], ): ToolId[] { const captured = new Set(meta.tools); let candidate: Set; if (flagTools && flagTools.length > 0) { candidate = new Set(flagTools); - } else if (configTools.length > 0) { - candidate = new Set(configTools); } else { candidate = new Set(captured); } @@ -393,7 +402,7 @@ interface SafetyBackup { } /** - * Make a timestamped safety copy of each existing target tool home BEFORE any + * Make a timestamped safety copy of each existing restore target BEFORE any * restore mutation (R14). Destinations live under dataDir()/safety-backups so * they never pollute a tool home. Only existing homes are copied; absent ones * are skipped. `iso` is supplied by the caller (commands own the clock). @@ -403,33 +412,59 @@ interface SafetyBackup { async function safetyBackup( tools: ToolId[], iso: string, + os: OS, ): Promise { const stamp = iso.replace(/[:.]/g, "-"); - const backupsRoot = path.join(dataDir(), "safety-backups"); const made: SafetyBackup[] = []; for (const tool of tools) { - const source = toolHomeDir(tool); - if (!(await fs.exists(source))) { - log.debug(`restore: no existing ${tool} home to back up (${source}).`); - continue; - } - const dest = path.join(backupsRoot, `${tool}-${stamp}`); - try { - await fs.copy(source, dest); - made.push({ tool, source, dest }); - log.step(`Safety backup: ${source} -> ${dest}`); - } catch (err) { - // A failed safety copy is serious: warn loudly but do not abort the whole - // restore over a single tool's snapshot (the others still get protected). - log.warn( - `restore: failed to safety-backup ${tool} (${source}): ${errMsg(err)}`, - ); + for (const entry of safetySourcesForTool(tool, os, stamp)) { + if (!(await fs.exists(entry.source))) { + log.debug(`restore: no existing ${entry.label} to back up (${entry.source}).`); + continue; + } + try { + await fs.copy(entry.source, entry.dest); + made.push({ tool, source: entry.source, dest: entry.dest }); + log.step(`Safety backup: ${entry.source} -> ${entry.dest}`); + } catch (err) { + // A failed safety copy is serious: warn loudly but do not abort the whole + // restore over a single target's snapshot (the others still get protected). + log.warn( + `restore: failed to safety-backup ${entry.label} (${entry.source}): ${errMsg(err)}`, + ); + } } } return made; } +function safetySourcesForTool( + tool: ToolId, + os: OS, + stamp: string, +): Array<{ label: string; source: string; dest: string }> { + const backupsRoot = path.join(dataDir(), "safety-backups"); + const toolHome = toolHomeDir(tool); + const sources = [ + { + label: `${tool} home`, + source: toolHome, + dest: path.join(backupsRoot, `${tool}-${stamp}`), + }, + ]; + + if (tool === "cursor") { + sources.push({ + label: "cursor User data", + source: cursorUserPaths(toolHome, os, process.env).userDir, + dest: path.join(backupsRoot, `cursor-user-${stamp}`), + }); + } + + return sources; +} + /* -------------------------------------------------------------------------- */ /* Shared instructions (R9) deployment */ /* -------------------------------------------------------------------------- */ @@ -437,9 +472,9 @@ async function safetyBackup( /** * When the repo stored a single shared instructions file (CLAUDE.md == AGENTS.md * at capture, R9), deploy it to BOTH tool targets: ~/.claude/CLAUDE.md and - * ~/.codex/AGENTS.md (from sharedInstructionsTargets()). The cursor adapter - * deploys its own Cursor rule from the same source inside its restore(), so it - * is intentionally not handled here. + * ~/.codex/AGENTS.md (from sharedInstructionsTargets()). Cursor User Rules are + * stored in Cursor settings, not as global `.mdc` files, so restore does not + * synthesize a Cursor rule here. * * Respects `dryRun` (reports only). Best-effort + graceful: a missing shared * file is logged, not fatal. Only deploys to a target whose tool is in scope. @@ -563,8 +598,8 @@ function stripFilesPrefix(tool: ToolId, repoPath: string): string { } /** - * Synthesize a plan fragment for a tool that has no dedicated planActions export - * (cursor). Mirrors the planner shape used by the claude/codex restore modules: + * Synthesize a plan fragment for a tool that has no dedicated planActions export. + * Mirrors the planner shape used by adapter restore modules: * one write-file/write-symlink action per frozen artifact, honoring sourceOfTruth * (when local is authoritative, an existing destination is kept, not overwritten, * so we omit the action just as the real restore would skip it). @@ -609,8 +644,8 @@ async function fallbackActions( /** * Build the full RestorePlan: gather each tool's RestoreData, compute its planned - * actions (via the tool's planActions when available; otherwise a synthesized - * file/symlink action list — see fallbackActions), and probe which tool CLIs are + * actions (via the tool's planActions when available; otherwise a synthesized + * file/symlink action list), and probe which tool CLIs are * missing on this machine (R6). No mutation happens here. * * Actions are always computed against a dryRun context so planning is side-effect @@ -639,9 +674,8 @@ async function buildPlan(args: { os: args.os, }); - // Prefer the tool's own planner (claude/codex). For tools without one - // (cursor), synthesize file/symlink write actions from the loaded RestoreData - // so the dry-run plan is still complete and uniform. + // Prefer the tool's own planner. For tools without one, synthesize file/symlink + // write actions from RestoreData so the dry-run plan is still complete. const toolActions = wiring.planActions ? await wiring.planActions(planCtx, data) : await fallbackActions(planCtx, id, data); @@ -686,7 +720,7 @@ function printPlan( `Tools: ${plan.tools.length > 0 ? plan.tools.join(", ") : "(none)"}`, ); l.step( - `A timestamped safety backup of existing tool homes WILL be taken first (R14).`, + `A timestamped safety backup of existing restore targets WILL be taken first (R14).`, ); if (plan.missingClis.length > 0) { l.step(`CLIs to auto-install: ${plan.missingClis.join(", ")}`); @@ -903,7 +937,7 @@ export async function run( // ---- 3. Decide which tools to restore ----------------------------------- const config = await loadConfigOrDefault(); const flagTools = parseToolsFlag(opts.tools); - const tools = selectTools(meta, flagTools, config.tools); + const tools = selectToolsForRestore(meta, flagTools, config.tools); if (tools.length === 0) { log.warn( "No tools to restore (the repo + your selection have no overlap). " + @@ -940,7 +974,7 @@ export async function run( // ---- 6. R14 safety backup BEFORE any overwrite -------------------------- const iso = new Date().toISOString(); log.info("Creating safety backups of existing tool homes (R14)…"); - const backups = await safetyBackup(tools, iso); + const backups = await safetyBackup(tools, iso, os); if (backups.length === 0) { log.debug("restore: no existing tool homes needed backing up."); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 1d4700c..f4a57ef 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -30,6 +30,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -325,6 +326,7 @@ function buildCaptureContext( templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, toolHome, includeSecrets, includeMemories, diff --git a/src/core/manifest/index.ts b/src/core/manifest/index.ts index 0e753df..45dc999 100644 --- a/src/core/manifest/index.ts +++ b/src/core/manifest/index.ts @@ -287,9 +287,8 @@ export function buildSharedInstructionsFile(content: string): CapturedFile { /** * The destinations the single shared instructions file deploys to on restore, * expressed relative to each tool's home dir. Claude reads CLAUDE.md; Codex reads - * AGENTS.md. (Cursor rule deployment is handled by the cursor adapter, which - * reads shared/instructions.md from the repo root directly — it is intentionally - * not listed here because its destination is not a fixed tool-home-relative path.) + * AGENTS.md. Cursor User Rules live in Cursor settings, so Arbella does not + * synthesize a global Cursor rule from this file. */ export function sharedInstructionsTargets(): Array<{ tool: ToolId; relPath: string }> { return [ diff --git a/src/core/sanitizer/index.ts b/src/core/sanitizer/index.ts index 7eb92fe..d448825 100644 --- a/src/core/sanitizer/index.ts +++ b/src/core/sanitizer/index.ts @@ -141,11 +141,11 @@ export function createSanitizer(): SanitizerService { * they reach the repo — the gap that sanitizeText (value-patterns only) leaves * open. Behavior: * - * - If `source` looks like JSON (".json" basename) AND JSON.parse succeeds: - * run sanitizeJson on the parsed object, then re-serialize with the file's - * detected indentation. Returns changed=true iff the serialized output - * differs from the input (so callers/reporting see a real redaction). - * - Otherwise (non-JSON, or JSON that fails to parse): fall back to the + * - If `source` looks like JSON (".json" basename) AND JSON/JSONC parsing + * succeeds: run sanitizeJson on the parsed object, then re-serialize with + * the file's detected indentation. Comments/trailing commas are not + * preserved, but opaque secrets under secret key names are redacted. + * - Otherwise (non-JSON, or JSON/JSONC parsing fails): fall back to the * pattern-based sanitizeText (which still catches known token shapes and * the key-name-aware env-assignment pattern in free text / hook scripts). * @@ -154,14 +154,12 @@ export function createSanitizer(): SanitizerService { */ function sanitizeFile(content: string, tool: ToolId, source: string): SanitizeResult { if (looksLikeJsonSource(source)) { - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { + const parsed = parseJsonOrJsonc(content); + if (!parsed.ok) { // Not valid JSON despite the extension — fall back to the text pass. return sanitizeText(content, tool, source); } - const { value, found } = sanitizeJson(parsed, tool, source); + const { value, found } = sanitizeJson(parsed.value, tool, source); const indent = detectJsonIndent(content); const serialized = JSON.stringify(value, null, indent); return { content: serialized, found, changed: serialized !== content }; @@ -182,6 +180,111 @@ function looksLikeJsonSource(source: string): boolean { return /\.json$/i.test(base); } +function parseJsonOrJsonc(content: string): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(content) }; + } catch { + // Cursor and VS Code-style settings files are commonly JSONC. + } + + try { + return { ok: true, value: JSON.parse(stripJsonc(content)) }; + } catch { + return { ok: false }; + } +} + +function stripJsonc(content: string): string { + return removeTrailingCommas(removeJsonComments(content)); +} + +function removeJsonComments(content: string): string { + let out = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + const next = content[i + 1]; + + if (inString) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === "\"") { + inString = false; + } + continue; + } + + if (ch === "\"") { + inString = true; + out += ch; + continue; + } + + if (ch === "/" && next === "/") { + while (i < content.length && content[i] !== "\n") i++; + if (i < content.length) out += "\n"; + continue; + } + + if (ch === "/" && next === "*") { + i += 2; + while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) { + if (content[i] === "\n") out += "\n"; + i++; + } + i++; + continue; + } + + out += ch; + } + + return out; +} + +function removeTrailingCommas(content: string): string { + let out = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + + if (inString) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === "\"") { + inString = false; + } + continue; + } + + if (ch === "\"") { + inString = true; + out += ch; + continue; + } + + if (ch === ",") { + let j = i + 1; + while (j < content.length && /\s/.test(content[j]!)) j++; + if (content[j] === "}" || content[j] === "]") continue; + } + + out += ch; + } + + return out; +} + /** * Best-effort detection of a JSON document's indentation so re-serialization * preserves the file's style. Looks at the whitespace before the first indented diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..7c61052 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +let cachedVersion: string | undefined; + +/** + * Package version used by the CLI and backup metadata. + * + * tsup bundles source modules into dist/index.js, so import.meta.url points at + * different depths in dev (`src/core/version.ts`) and in the packed CLI + * (`dist/index.js`). Try both stable locations and fall back only if package.json + * is unexpectedly unavailable. + */ +export function getPackageVersion(): string { + if (cachedVersion !== undefined) return cachedVersion; + + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, "..", "package.json"), + path.join(here, "..", "..", "package.json"), + ]; + + for (const packagePath of candidates) { + try { + const parsed = JSON.parse(fs.readFileSync(packagePath, "utf8")) as { + version?: unknown; + }; + if (typeof parsed.version === "string" && parsed.version.trim() !== "") { + cachedVersion = parsed.version; + return cachedVersion; + } + } catch { + // Try the next candidate. + } + } + + cachedVersion = "0.0.0"; + return cachedVersion; +} diff --git a/src/index.ts b/src/index.ts index 25ff2cc..4f14ea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,8 @@ * top-level handler (log.error + exit 1) so stack traces never leak to the * user. Commander's own --help / --version exits are passed through. * - * VERSION: hard-coded here and kept in sync with package.json. The contract - * explicitly prefers this over a JSON import, because rootDir:"src" puts - * package.json outside the compile root and would otherwise fight tsup/NodeNext. + * VERSION: read from package.json via core/version.ts so release bumps do not + * drift between the published package and `arbella --version`. */ import fs from "node:fs"; @@ -33,6 +32,7 @@ import pc from "picocolors"; import { log, setVerbose } from "./utils/log.js"; import { listAdapters } from "./adapters/registry.js"; +import { getPackageVersion } from "./core/version.js"; import { register as registerInit } from "./commands/init.js"; import { register as registerSetup } from "./commands/setup.js"; @@ -42,8 +42,8 @@ import { register as registerRestore } from "./commands/restore.js"; import { register as registerStatus } from "./commands/status.js"; import { register as registerSecrets } from "./commands/secrets.js"; -/** arbella version, kept in sync with package.json "version". */ -const VERSION = "0.1.0"; +/** arbella version, sourced from package.json. */ +const VERSION = getPackageVersion(); /** Build the configured root program (no parsing yet — easy to unit-test). */ export function buildProgram(): Command { diff --git a/src/platform/os.ts b/src/platform/os.ts index 19984c6..3bace3f 100644 --- a/src/platform/os.ts +++ b/src/platform/os.ts @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; -import type { OS, ToolId } from "../types.js"; +import type { EnvVars, OS, ToolId } from "../types.js"; /** Detect the current platform, narrowed to the OSes arbella supports. */ export function detectOS(): OS { @@ -74,6 +74,20 @@ export function toolHomeDir(tool: ToolId): string { } } +/** + * Root directory used by Electron/VS Code-style desktop apps for user config. + * Cursor's User data lives below this root. + */ +export function appUserConfigRoot(targetOS: OS, userHome: string, env: EnvVars = {}): string { + if (targetOS === "darwin") { + return path.join(userHome, "Library", "Application Support"); + } + if (targetOS === "win32") { + return env.APPDATA ?? path.join(userHome, "AppData", "Roaming"); + } + return env.XDG_CONFIG_HOME ?? path.join(userHome, ".config"); +} + /* -------------------------------------------------------------------------- */ /* CLI install command mapping (R6) */ /* -------------------------------------------------------------------------- */ diff --git a/src/types.ts b/src/types.ts index 6dbff40..083c37d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,9 @@ export const TOOL_IDS: readonly ToolId[] = ["claude", "codex", "cursor"] as cons /** node:os style platform identifiers arbella supports. */ export type OS = "darwin" | "linux" | "win32"; +/** Environment variables visible to command/adapters. Injected for testability. */ +export type EnvVars = Readonly>; + /* -------------------------------------------------------------------------- */ /* Captured files (the "frozen" half of hybrid capture) */ /* -------------------------------------------------------------------------- */ diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts new file mode 100644 index 0000000..4109cbb --- /dev/null +++ b/src/utils/symlink.ts @@ -0,0 +1,11 @@ +/** + * Normalize captured symlink metadata into the repo's portable representation. + * + * Node may report a relative symlink target with native separators on Windows + * even when the original target was created with "/". Backup metadata should be + * deterministic across capture OSes, so store relative-style targets with POSIX + * separators. + */ +export function normalizeCapturedSymlinkTarget(target: string): string { + return target.replace(/\\/g, "/"); +} diff --git a/test/integration/capture-restore.test.ts b/test/integration/capture-restore.test.ts index 44b0582..2db9c52 100644 --- a/test/integration/capture-restore.test.ts +++ b/test/integration/capture-restore.test.ts @@ -39,11 +39,17 @@ import { capture as captureClaude } from "../../src/adapters/claude/capture.js"; import { restore as restoreClaude } from "../../src/adapters/claude/restore.js"; import { capture as captureCodex } from "../../src/adapters/codex/capture.js"; import { restore as restoreCodex } from "../../src/adapters/codex/restore.js"; +import { + capture as captureCursor, + restore as restoreCursor, + planActions as planCursorActions, +} from "../../src/adapters/cursor/index.js"; import { fs as realFs } from "../../src/utils/fs.js"; import { createSanitizer } from "../../src/core/sanitizer/index.js"; import { createTemplater } from "../../src/core/templater/index.js"; import { makeVariables } from "../../src/core/templater/variables.js"; +import { loadRestoreData } from "../../src/commands/restore.js"; import { shouldShareInstructions, buildSharedInstructionsFile, @@ -52,6 +58,7 @@ import { import type { CaptureResult, CapturedFile, + EnvVars, TemplateVariables, } from "../../src/types.js"; import type { @@ -68,6 +75,7 @@ const CLAUDE_CRED_SECRET = "sk-ant-cred-DO-NOT-LEAK-AAAAAAAAAAAAAAAAAAAAAA"; const CODEX_AUTH_SECRET = "OPENAI_AUTH_TOKEN_DO_NOT_LEAK_BBBBBBBBBBBBBBB"; const SETTINGS_API_KEY = "sk-ant-api03-INLINE-KEY-CCCCCCCCCCCCCCCCCCCCCCCC"; const CONFIG_MCP_SECRET = "glpat-CONFIG-MCP-SECRET-DDDDDDDDDDDDD"; +const CURSOR_MCP_SECRET = "sk-ant-api03-CURSOR-MCP-SECRET-HHHHHHHHHHHHH"; // OPAQUE value under a secret KEY NAME — matches NO known token pattern. Proves // the STRUCTURAL key-name-aware capture path (sanitizeFile) redacts it; the old // value-only sanitizeText path would have leaked it verbatim into the repo. @@ -92,7 +100,7 @@ const silentLog = { const sanitizer = createSanitizer(); const templater = createTemplater(); -function makeCaptureCtx(toolHome: string, vars: TemplateVariables): CaptureContext { +function makeCaptureCtx(toolHome: string, vars: TemplateVariables, env: EnvVars = {}): CaptureContext { return { fs: realFs, log: silentLog, @@ -100,6 +108,7 @@ function makeCaptureCtx(toolHome: string, vars: TemplateVariables): CaptureConte templater, vars, os: "linux", + env, toolHome, includeSecrets: false, includeMemories: false, @@ -112,6 +121,7 @@ function makeRestoreCtx( repoToolDir: string, repoRoot: string, vars: TemplateVariables, + env: EnvVars = {}, ): RestoreContext { return { fs: realFs, @@ -120,6 +130,7 @@ function makeRestoreCtx( templater, vars, os: "linux", + env, toolHome, repoToolDir, repoRoot, @@ -139,6 +150,7 @@ let repoRoot: string; // the backup repo working tree let claudeCapture: CaptureResult; let codexCapture: CaptureResult; +let cursorCapture: CaptureResult; let sharedFile: CapturedFile; /** Absolute, OS-correct path under a tool home from a POSIX-ish rel path. */ @@ -158,8 +170,9 @@ beforeAll(async () => { dstHome = path.join(tmpRoot, "dst-home"); repoRoot = path.join(tmpRoot, "repo"); - const claudeSrc = path.join(srcHome, ".claude"); - const codexSrc = path.join(srcHome, ".codex"); + const claudeSrc = path.join(srcHome, ".claude"); + const codexSrc = path.join(srcHome, ".codex"); + const cursorSrc = path.join(srcHome, ".cursor"); // ---- ~/.claude ---- // settings.json: an inline API key (redact) + two machine paths to template: @@ -220,13 +233,66 @@ beforeAll(async () => { await writeFile(codexSrc, "config.toml", configToml); // Byte-identical to CLAUDE.md. await writeFile(codexSrc, "AGENTS.md", SHARED_MD); - await writeFile(codexSrc, "prompts/review.md", "Review prompt body.\n"); - // FAKE SECRET FILE — excluded wholesale. - await writeFile(codexSrc, "auth.json", `{"token":"${CODEX_AUTH_SECRET}"}`, 0o600); + await writeFile(codexSrc, "prompts/review.md", "Review prompt body.\n"); + // FAKE SECRET FILE — excluded wholesale. + await writeFile(codexSrc, "auth.json", `{"token":"${CODEX_AUTH_SECRET}"}`, 0o600); + + // ---- ~/.cursor + Cursor app user data ---- + await writeFile( + cursorSrc, + "mcp.json", + JSON.stringify( + { + mcpServers: { + figma: { + command: "node", + args: [`${srcHome}/.cursor/mcp/figma.js`], + env: { + ANTHROPIC_API_KEY: CURSOR_MCP_SECRET, + }, + }, + }, + }, + null, + 2, + ), + ); + await writeFile( + cursorSrc, + "skills/local/SKILL.md", + `# Local Cursor skill\n\nReads ${srcHome}/.cursor/skills/local/data.json\n`, + ); + await writeFile(cursorSrc, "extensions/extensions.json", JSON.stringify([ + { + identifier: { id: "anysphere.cursorpyright" }, + version: "1.2.3", + }, + ])); + const cursorSkillLink = under(cursorSrc, "skills/humanizer"); + await fsp.mkdir(path.dirname(cursorSkillLink), { recursive: true }); + await fsp.symlink("../../.agents/skills/humanizer", cursorSkillLink); + const cursorUserDir = path.join(srcHome, ".config", "Cursor", "User"); + await writeFile( + cursorUserDir, + "settings.json", + JSON.stringify({ "terminal.integrated.cwd": `${srcHome}/programming` }, null, 2), + ); + await writeFile( + cursorUserDir, + "keybindings.json", + JSON.stringify([{ key: "cmd+k", command: "cursorai.action.generate" }], null, 2), + ); + await writeFile( + cursorUserDir, + "snippets/typescript.json", + JSON.stringify({ log: { prefix: "cl", body: [`console.log('${srcHome}')`] } }, null, 2), + ); + await writeFile(cursorUserDir, "snippets/cache.sqlite", "sqlite cache should be ignored\n"); // ---- Capture orchestration (mirrors the backup command's R9 wiring) ---- - const claudeVars = makeVariables(srcHome, "fab", "linux", claudeSrc); - const codexVars = makeVariables(srcHome, "fab", "linux", codexSrc); + const claudeVars = makeVariables(srcHome, "fab", "linux", claudeSrc); + const codexVars = makeVariables(srcHome, "fab", "linux", codexSrc); + const cursorVars = makeVariables(srcHome, "fab", "linux", cursorSrc); const claudeMd = SHARED_MD; const agentsMd = SHARED_MD; @@ -236,10 +302,13 @@ beforeAll(async () => { claudeCapture = await captureClaude(makeCaptureCtx(claudeSrc, claudeVars), { skipInstructions: share, }); - codexCapture = await captureCodex(makeCaptureCtx(codexSrc, codexVars), { - skipInstructions: share, - }); - sharedFile = buildSharedInstructionsFile(SHARED_MD); + codexCapture = await captureCodex(makeCaptureCtx(codexSrc, codexVars), { + skipInstructions: share, + }); + cursorCapture = await captureCursor(makeCaptureCtx(cursorSrc, cursorVars), { + skipInstructions: share, + }); + sharedFile = buildSharedInstructionsFile(SHARED_MD); }); afterAll(async () => { @@ -250,10 +319,10 @@ afterAll(async () => { /* Helpers over the captured output */ /* -------------------------------------------------------------------------- */ -/** Concatenate every captured file's content + repoPath across both tools + shared. */ +/** Concatenate every captured file's content + repoPath across all tools + shared. */ function allCapturedText(): string { const parts: string[] = []; - for (const f of [...claudeCapture.files, ...codexCapture.files, sharedFile]) { + for (const f of [...claudeCapture.files, ...codexCapture.files, ...cursorCapture.files, sharedFile]) { parts.push(f.repoPath, f.content); } return parts.join("\n"); @@ -306,8 +375,9 @@ describe("capture: secrets never leave the machine", () => { itPosixHost("redacts inline secret VALUES in settings.json and config.toml", () => { const blob = allCapturedText(); - expect(blob).not.toContain(SETTINGS_API_KEY); - expect(blob).not.toContain(CONFIG_MCP_SECRET); + expect(blob).not.toContain(SETTINGS_API_KEY); + expect(blob).not.toContain(CONFIG_MCP_SECRET); + expect(blob).not.toContain(CURSOR_MCP_SECRET); const settings = findFile(claudeCapture.files, "claude/files/settings.json"); expect(settings).toBeDefined(); @@ -345,7 +415,7 @@ describe("capture: machine paths become placeholders", () => { expect(settings.content).toContain("{{HOME}}/.agents/skills"); }); - itPosixHost("folds tool-home and plain-home paths in config.toml (value side)", () => { + itPosixHost("folds tool-home and plain-home paths in config.toml (value side)", () => { const config = findFile(codexCapture.files, "codex/files/config.toml")!; expect(config.content).not.toContain(srcHome); expect(config.content).toContain("{{HOME}}/.agents/notify.sh"); @@ -357,8 +427,58 @@ describe("capture: machine paths become placeholders", () => { expect(agent.content).not.toContain(srcHome); expect(agent.content).toContain("{{TOOL_HOME}}/agents/reviewer.log"); expect(agent.content).toContain("{{HOME}}/.agents/shared.md"); - }); -}); + }); + + itPosixHost("folds Cursor MCP, user settings, snippets, and skill paths", () => { + const mcp = findFile(cursorCapture.files, "cursor/files/mcp.json")!; + expect(mcp.content).not.toContain(srcHome); + expect(mcp.content).toContain("{{TOOL_HOME}}/mcp/figma.js"); + expect(mcp.content).toContain("{{REDACTED}}"); + + const settings = findFile(cursorCapture.files, "cursor/user/settings.json")!; + expect(settings.content).not.toContain(srcHome); + expect(settings.content).toContain("{{HOME}}/programming"); + + const snippet = findFile(cursorCapture.files, "cursor/user/snippets/typescript.json")!; + expect(snippet.content).not.toContain(srcHome); + expect(snippet.content).toContain("{{HOME}}"); + + const skill = findFile(cursorCapture.files, "cursor/files/skills/local/SKILL.md")!; + expect(skill.content).not.toContain(srcHome); + expect(skill.content).toContain("{{TOOL_HOME}}/skills/local/data.json"); + }); + }); + + describe("capture: Cursor portable state", () => { + it("captures global MCP, user data, local skills, skills.sh symlinks, and extension metadata", () => { + expect(findFile(cursorCapture.files, "cursor/files/mcp.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/settings.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/keybindings.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/snippets/typescript.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/snippets/cache.sqlite")).toBeUndefined(); + expect(findFile(cursorCapture.files, "cursor/files/skills/local/SKILL.md")).toBeDefined(); + + expect(cursorCapture.symlinks).toContainEqual({ + repoPath: "cursor/files/skills/humanizer", + target: "../../.agents/skills/humanizer", + }); + expect(cursorCapture.manifest.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "local", source: "frozen", symlinked: false }), + expect.objectContaining({ name: "humanizer", source: "skills.sh", symlinked: true }), + ]), + ); + expect(cursorCapture.manifest.plugins).toContainEqual( + expect.objectContaining({ + id: "anysphere.cursorpyright", + name: "anysphere.cursorpyright", + version: "1.2.3", + scope: "user", + }), + ); + expect(findFile(cursorCapture.files, "cursor/files/extensions/extensions.json")).toBeUndefined(); + }); + }); describe("capture: shared instructions are deduped (R9)", () => { it("does NOT emit per-tool CLAUDE.md / AGENTS.md when sharing", () => { @@ -381,26 +501,33 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { beforeAll(async () => { // Materialize the captured repo tree on disk under repoRoot. - for (const f of [...claudeCapture.files, ...codexCapture.files]) { - const abs = path.join(repoRoot, ...f.repoPath.split("/")); - await fsp.mkdir(path.dirname(abs), { recursive: true }); - if (f.binary) { - await fsp.writeFile(abs, Buffer.from(f.content, "base64")); - } else { - await fsp.writeFile(abs, f.content); - } - } + for (const f of [...claudeCapture.files, ...codexCapture.files, ...cursorCapture.files]) { + const abs = path.join(repoRoot, ...f.repoPath.split("/")); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + if (f.binary) { + await fsp.writeFile(abs, Buffer.from(f.content, "base64")); + } else { + await fsp.writeFile(abs, f.content); + } + } + for (const link of [...claudeCapture.symlinks, ...codexCapture.symlinks, ...cursorCapture.symlinks]) { + const abs = path.join(repoRoot, ...link.repoPath.split("/")); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.symlink(link.target, abs); + } // Write the single shared instructions file into the repo. const sharedAbs = path.join(repoRoot, ...sharedFile.repoPath.split("/")); await fsp.mkdir(path.dirname(sharedAbs), { recursive: true }); await fsp.writeFile(sharedAbs, sharedFile.content); // Restore target: a DIFFERENT home, so {{HOME}} must expand to dstHome. - const claudeDst = path.join(dstHome, ".claude"); - const codexDst = path.join(dstHome, ".codex"); - restoredVars = makeVariables(dstHome, "newuser", "linux"); - const claudeVars = makeVariables(dstHome, "newuser", "linux", claudeDst); - const codexVars = makeVariables(dstHome, "newuser", "linux", codexDst); + const claudeDst = path.join(dstHome, ".claude"); + const codexDst = path.join(dstHome, ".codex"); + const cursorDst = path.join(dstHome, ".cursor"); + restoredVars = makeVariables(dstHome, "newuser", "linux"); + const claudeVars = makeVariables(dstHome, "newuser", "linux", claudeDst); + const codexVars = makeVariables(dstHome, "newuser", "linux", codexDst); + const cursorVars = makeVariables(dstHome, "newuser", "linux", cursorDst); // Restore Claude frozen files + the shared instructions (-> CLAUDE.md). const claudeData: RestoreData = { @@ -425,10 +552,16 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { ], symlinks: codexCapture.symlinks, }; - await restoreCodex( - makeRestoreCtx(codexDst, path.join(repoRoot, "codex"), repoRoot, codexVars), - codexData, - ); + await restoreCodex( + makeRestoreCtx(codexDst, path.join(repoRoot, "codex"), repoRoot, codexVars), + codexData, + ); + + const cursorData = await loadRestoreData(repoRoot, "cursor"); + await restoreCursor( + makeRestoreCtx(cursorDst, path.join(repoRoot, "cursor"), repoRoot, cursorVars), + cursorData, + ); }); itPosixHost("re-creates settings.json with placeholders expanded to the TARGET home", async () => { @@ -464,7 +597,7 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { expect(content).not.toContain(CONFIG_MCP_SECRET); }); - it("deploys the single shared instructions file to BOTH tools", async () => { + it("deploys the single shared instructions file to BOTH tools", async () => { const claudeMd = await fsp.readFile( under(path.join(dstHome, ".claude"), "CLAUDE.md"), "utf8", @@ -475,7 +608,41 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { ); expect(claudeMd).toBe(SHARED_MD); expect(agentsMd).toBe(SHARED_MD); - }); + }); + + itPosixHost("restores Cursor tool-home and app User files to the correct locations", async () => { + const mcp = await fsp.readFile(under(path.join(dstHome, ".cursor"), "mcp.json"), "utf8"); + expect(mcp).toContain(`${dstHome}/.cursor/mcp/figma.js`); + expect(mcp).toContain("{{REDACTED}}"); + expect(mcp).not.toContain(CURSOR_MCP_SECRET); + + const settings = await fsp.readFile( + path.join(dstHome, ".config", "Cursor", "User", "settings.json"), + "utf8", + ); + expect(settings).toContain(`${dstHome}/programming`); + expect(settings).not.toContain("{{HOME}}"); + + const snippet = await fsp.readFile( + path.join(dstHome, ".config", "Cursor", "User", "snippets", "typescript.json"), + "utf8", + ); + expect(snippet).toContain(dstHome); + expect(snippet).not.toContain("{{HOME}}"); + + const skill = await fsp.readFile( + under(path.join(dstHome, ".cursor"), "skills/local/SKILL.md"), + "utf8", + ); + expect(skill).toContain(`${dstHome}/.cursor/skills/local/data.json`); + const linkTarget = await fsp.readlink(under(path.join(dstHome, ".cursor"), "skills/humanizer")); + expect(linkTarget).toBe("../../.agents/skills/humanizer"); + + const sharedRuleExists = await realFs.exists( + under(path.join(dstHome, ".cursor"), "rules/arbella-shared-instructions.mdc"), + ); + expect(sharedRuleExists).toBe(false); + }); it("does NOT recreate any secret file in the fresh home", async () => { const credExists = await realFs.exists( @@ -489,6 +656,65 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { }); }); +describe("restore: Cursor plan actions", () => { + it("plans Cursor User files outside ~/.cursor and extension installs", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "arbella-cursor-plan-")); + try { + const home = path.join(root, "home"); + const cursorHome = path.join(home, ".cursor"); + const repo = path.join(root, "repo"); + const ctx = makeRestoreCtx( + cursorHome, + path.join(repo, "cursor"), + repo, + makeVariables(home, "newuser", "linux", cursorHome), + ); + const actions = await planCursorActions(ctx, { + manifest: { + tool: "cursor", + plugins: [ + { + id: "anysphere.cursorpyright", + name: "anysphere.cursorpyright", + version: "1.2.3", + enabled: true, + scope: "user", + }, + ], + marketplaces: [], + skills: [], + npmGlobals: [], + enabledPlugins: {}, + }, + files: [ + { + repoPath: "cursor/user/settings.json", + content: "{}", + }, + ], + symlinks: [], + }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: "write-file", + tool: "cursor", + targetPath: path.join(home, ".config", "Cursor", "User", "settings.json"), + }), + ); + expect(actions).toContainEqual( + expect.objectContaining({ + type: "install-plugin", + tool: "cursor", + description: "cursor --install-extension anysphere.cursorpyright", + }), + ); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); +}); + /* -------------------------------------------------------------------------- */ /* includeSecrets flag is actually CONSUMED by capture (not dead config) */ /* -------------------------------------------------------------------------- */ diff --git a/test/unit/cli-help.test.ts b/test/unit/cli-help.test.ts index 97cc2a6..4310753 100644 --- a/test/unit/cli-help.test.ts +++ b/test/unit/cli-help.test.ts @@ -1,8 +1,20 @@ +import fs from "node:fs"; + import { describe, expect, it } from "vitest"; import { buildProgram } from "../../src/index.js"; +import { getPackageVersion } from "../../src/core/version.js"; describe("CLI help", () => { + it("uses the package.json version", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version: string }; + + expect(getPackageVersion()).toBe(packageJson.version); + expect(buildProgram().version()).toBe(packageJson.version); + }); + it("shows auto-push as the public init cadence flag and keeps auto-backup hidden", () => { const program = buildProgram(); const init = program.commands.find((command) => command.name() === "init"); diff --git a/test/unit/cursor-paths.test.ts b/test/unit/cursor-paths.test.ts new file mode 100644 index 0000000..0e02aa2 --- /dev/null +++ b/test/unit/cursor-paths.test.ts @@ -0,0 +1,25 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { cursorUserPaths } from "../../src/adapters/cursor/paths.js"; + +describe("Cursor paths", () => { + it("uses XDG_CONFIG_HOME for Linux Cursor User data", () => { + const paths = cursorUserPaths("/home/fab/.cursor", "linux", { + XDG_CONFIG_HOME: "/mnt/config", + }); + + expect(paths.userDir).toBe(path.join("/mnt/config", "Cursor", "User")); + expect(paths.settingsJson).toBe(path.join("/mnt/config", "Cursor", "User", "settings.json")); + }); + + it("uses APPDATA for Windows Cursor User data", () => { + const paths = cursorUserPaths(path.join("C:", "Users", "fab", ".cursor"), "win32", { + APPDATA: path.join("D:", "Roaming"), + }); + + expect(paths.userDir).toBe(path.join("D:", "Roaming", "Cursor", "User")); + expect(paths.keybindingsJson).toBe(path.join("D:", "Roaming", "Cursor", "User", "keybindings.json")); + }); +}); diff --git a/test/unit/restore-tool-selection.test.ts b/test/unit/restore-tool-selection.test.ts new file mode 100644 index 0000000..e80f632 --- /dev/null +++ b/test/unit/restore-tool-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { selectToolsForRestore } from "../../src/commands/restore.js"; +import type { ArbellaMeta, ToolId } from "../../src/types.js"; + +function metaWithTools(tools: ToolId[]): ArbellaMeta { + return { + schemaVersion: 1, + arbellaVersion: "0.1.1", + tools, + options: { + includeSecrets: false, + includeMemories: false, + sourceOfTruth: "local", + }, + createdAt: "2026-05-31T00:00:00.000Z", + sharedInstructions: false, + }; +} + +describe("restore tool selection", () => { + it("restores every captured tool by default even when local config is stale", () => { + const tools = selectToolsForRestore( + metaWithTools(["claude", "codex", "cursor"]), + undefined, + ["claude", "codex"], + ); + + expect(tools).toEqual(["claude", "codex", "cursor"]); + }); + + it("still lets --tools explicitly narrow the restore", () => { + const tools = selectToolsForRestore( + metaWithTools(["claude", "codex", "cursor"]), + ["cursor"], + ["claude", "codex"], + ); + + expect(tools).toEqual(["cursor"]); + }); +}); diff --git a/test/unit/sanitizer.test.ts b/test/unit/sanitizer.test.ts index 56c67d4..6156b48 100644 --- a/test/unit/sanitizer.test.ts +++ b/test/unit/sanitizer.test.ts @@ -172,7 +172,7 @@ describe("sanitizer: sanitizeFile is the key-name-aware production path (§0.6)" expect(textOnly.content).toContain("corp-gateway-OPAQUE-7766aabb"); }); - it("redacts an opaque api_token / apiKeyHelper value in a JSON config", () => { + it("redacts an opaque api_token / apiKeyHelper value in a JSON config", () => { const blob = JSON.stringify( { api_token: "plain-opaque-no-prefix-001122", apiKeyHelper: "echo my-opaque-zzz999" }, null, @@ -181,9 +181,30 @@ describe("sanitizer: sanitizeFile is the key-name-aware production path (§0.6)" const res = sanitizer.sanitizeFile(blob, "cursor", "mcp.json"); expect(res.content).not.toContain("plain-opaque-no-prefix-001122"); expect(res.content).not.toContain("my-opaque-zzz999"); - }); - - it("falls back to the text pass for non-JSON content (hook scripts)", () => { + }); + + it("redacts opaque secret-key values in JSONC Cursor settings", () => { + const jsonc = [ + "{", + " // Cursor settings allow comments", + " \"mcpServers\": {", + " \"internal\": {", + " \"env\": {", + " \"ANTHROPIC_AUTH_TOKEN\": \"plain-opaque-jsonc-secret\"", + " },", + " },", + " },", + "}", + ].join("\n"); + + const res = sanitizer.sanitizeFile(jsonc, "cursor", "settings.json"); + expect(res.changed).toBe(true); + expect(res.content).not.toContain("plain-opaque-jsonc-secret"); + expect(res.content).toContain(REDACTED); + expect(res.found.some((ref) => ref.source.includes("ANTHROPIC_AUTH_TOKEN"))).toBe(true); + }); + + it("falls back to the text pass for non-JSON content (hook scripts)", () => { const hook = 'export ANTHROPIC_API_KEY="sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; const res = sanitizer.sanitizeFile(hook, "claude", "hooks/run.sh"); expect(res.content).toContain(REDACTED); diff --git a/test/unit/symlink-target.test.ts b/test/unit/symlink-target.test.ts new file mode 100644 index 0000000..6a2dd11 --- /dev/null +++ b/test/unit/symlink-target.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeCapturedSymlinkTarget } from "../../src/utils/symlink.js"; + +describe("normalizeCapturedSymlinkTarget", () => { + it("serializes relative symlink targets with POSIX separators", () => { + expect(normalizeCapturedSymlinkTarget("..\\..\\.agents\\skills\\humanizer")).toBe( + "../../.agents/skills/humanizer", + ); + }); + + it("leaves already-POSIX targets unchanged", () => { + expect(normalizeCapturedSymlinkTarget("../../.agents/skills/humanizer")).toBe( + "../../.agents/skills/humanizer", + ); + }); +});