diff --git a/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift b/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift index 666167d..0aa6170 100644 --- a/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift +++ b/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift @@ -19,12 +19,21 @@ public struct PrepareClaudeLaunchCommand: ParsableCommand { @Option(name: .long, help: "Absolute path to the account directory (CLAUDE_CONFIG_DIR).") public var accountDir: String + @Flag(name: .long, help: "Only sync workspace symlinks; skip the .claude.json merge. Used for bare origin launches where claude reads ~/.claude.json, not /.claude.json.") + public var linksOnly: Bool = false + public init() {} public func run() throws { - let acctDirURL = URL(fileURLWithPath: accountDir) let fm = FileManager.default + // Resolve symlinks up front: bare origin launches pass ~/.claude, which + // is a symlink to the origin account dir. FileManager.contentsOfDirectory + // (at:) does not traverse a symlinked directory, so the metadata read and + // the workspace linker must operate on the real path. No-op for a real + // (non-symlink) account dir. + let acctDirURL = URL(fileURLWithPath: accountDir).resolvingSymlinksInPath() + guard fm.fileExists(atPath: acctDirURL.path) else { throw ValidationError("Account dir does not exist: \(accountDir)") } @@ -49,43 +58,49 @@ public struct PrepareClaudeLaunchCommand: ParsableCommand { let envStore = EnvironmentStore.default let wsDir = envStore.claudeWorkspaceDir(workspace: workspace) - // Load both stores (nil if absent — treat as empty). - var identity = ClaudeJsonMerge.loadJSON( - at: ClaudeJsonMerge.identityFileURL(accountDir: acctDirURL)) ?? [:] - let shared = ClaudeJsonMerge.loadJSON( - at: ClaudeJsonMerge.sharedFileURL(workspaceDir: wsDir)) ?? [:] + // The .claude.json merge is skipped for --links-only: bare origin + // launches (CLAUDE_CONFIG_DIR unset) read ~/.claude.json, NOT + // /.claude.json, so merging here would target the wrong + // file. Those launches only need the workspace symlinks synced below. + if !linksOnly { + // Load both stores (nil if absent — treat as empty). + var identity = ClaudeJsonMerge.loadJSON( + at: ClaudeJsonMerge.identityFileURL(accountDir: acctDirURL)) ?? [:] + let shared = ClaudeJsonMerge.loadJSON( + at: ClaudeJsonMerge.sharedFileURL(workspaceDir: wsDir)) ?? [:] - // v3.1 fix: If claude-identity.json has incomplete oauthAccount (missing - // refreshToken), load the full credentials from keychain/credentials file. - // This handles accounts created before the identity/shared split was added. - if let oauthDict = identity["oauthAccount"] as? [String: Any], - !oauthDict.keys.contains("refreshToken"), - let account = account { - // Load full credentials from keychain (macOS) or credentials file (Linux) - #if os(macOS) - if let keychainItem = account.keychainItem, - let credJSON = ClaudeKeychain.password(forService: keychainItem), - let credData = credJSON.data(using: .utf8), - let credObj = try? JSONSerialization.jsonObject(with: credData) as? [String: Any], - let fullOauth = credObj["claudeAiOauth"] as? [String: Any] { - identity["oauthAccount"] = fullOauth - } - #else - let credURL = acctDirURL.appendingPathComponent(".credentials.json") - if let credData = try? Data(contentsOf: credURL), - let credObj = try? JSONSerialization.jsonObject(with: credData) as? [String: Any], - let fullOauth = credObj["claudeAiOauth"] as? [String: Any] { - identity["oauthAccount"] = fullOauth + // v3.1 fix: If claude-identity.json has incomplete oauthAccount (missing + // refreshToken), load the full credentials from keychain/credentials file. + // This handles accounts created before the identity/shared split was added. + if let oauthDict = identity["oauthAccount"] as? [String: Any], + !oauthDict.keys.contains("refreshToken"), + let account = account { + // Load full credentials from keychain (macOS) or credentials file (Linux) + #if os(macOS) + if let keychainItem = account.keychainItem, + let credJSON = ClaudeKeychain.password(forService: keychainItem), + let credData = credJSON.data(using: .utf8), + let credObj = try? JSONSerialization.jsonObject(with: credData) as? [String: Any], + let fullOauth = credObj["claudeAiOauth"] as? [String: Any] { + identity["oauthAccount"] = fullOauth + } + #else + let credURL = acctDirURL.appendingPathComponent(".credentials.json") + if let credData = try? Data(contentsOf: credURL), + let credObj = try? JSONSerialization.jsonObject(with: credData) as? [String: Any], + let fullOauth = credObj["claudeAiOauth"] as? [String: Any] { + identity["oauthAccount"] = fullOauth + } + #endif } - #endif - } - // Merge and write out. - let merged = ClaudeJsonMerge.merge(identity: identity, shared: shared) - try ClaudeJsonMerge.saveJSON( - merged, - at: acctDirURL.appendingPathComponent(".claude.json") - ) + // Merge and write out. + let merged = ClaudeJsonMerge.merge(identity: identity, shared: shared) + try ClaudeJsonMerge.saveJSON( + merged, + at: acctDirURL.appendingPathComponent(".claude.json") + ) + } // v3.1: generalize workspace linking. Move any shareable account dir // (skills, plugins, or anything claude adds later) into the pinned diff --git a/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift b/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift index 0ac9b35..1334e99 100644 --- a/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift +++ b/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift @@ -332,6 +332,14 @@ public struct ShellFunctionGenerator { local _rc=$? command orrery-bin _capture-claude-exit --account-dir "$CLAUDE_CONFIG_DIR" 2>/dev/null || true return $_rc + elif [ -z "${CLAUDE_CONFIG_DIR:-}" ] && [ -f "$HOME/.claude/metadata.json" ]; then + # Bare launch on origin: ~/.claude points at the origin account dir. + # claude reads ~/.claude.json here (NOT ~/.claude/.claude.json), so we + # must NOT merge .claude.json — only sync the workspace symlinks so + # origin shares plugins/sessions/etc. like a pinned account. + # Best-effort: link failures/warnings never block the launch. + command orrery-bin _prepare-claude-launch --account-dir "$HOME/.claude" --links-only || true + command claude "$@" else command claude "$@" fi diff --git a/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift b/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift index d448448..0d8d9f8 100644 --- a/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift +++ b/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift @@ -109,6 +109,85 @@ struct PrepareClaudeLaunchCommandTests { atPath: wsDir.appendingPathComponent("skills/a.md").path)) } } + + @Test("--links-only syncs workspace symlinks without merging .claude.json") + func linksOnlySkipsClaudeJsonMerge() throws { + try withIsolatedHome { + let acctStore = AccountStore.default + let envStore = EnvironmentStore.default + let acct = Account(tool: .claude, displayName: "alice") + try acctStore.save(acct) + try PinCommand.parse(["alice", "--workspace", "work"]).run() + + let acctDir = acctStore.accountDir(id: acct.id, tool: .claude) + let wsDir = envStore.claudeWorkspaceDir(workspace: "work") + + // Seed an identity store — a FULL prepare would merge this into + // .claude.json. --links-only must NOT. + try ClaudeJsonMerge.saveJSON(["userID": "uid-alice"], + at: ClaudeJsonMerge.identityFileURL(accountDir: acctDir)) + + // A brand-new shareable folder in the account dir (like plugins). + let plugins = acctDir.appendingPathComponent("plugins") + try FileManager.default.createDirectory( + at: plugins, withIntermediateDirectories: true) + try Data("x".utf8).write(to: plugins.appendingPathComponent("config.json")) + + var cmd = try PrepareClaudeLaunchCommand.parse( + ["--account-dir", acctDir.path, "--links-only"]) + try cmd.run() + + let fm = FileManager.default + // plugins is now a symlink into the workspace (linker ran). + let dest = try fm.destinationOfSymbolicLink( + atPath: acctDir.appendingPathComponent("plugins").path) + #expect(dest == wsDir.appendingPathComponent("plugins").path) + #expect(fm.fileExists( + atPath: wsDir.appendingPathComponent("plugins/config.json").path)) + + // .claude.json was NOT merged — identity fields must be absent. + let claudeJSON = ClaudeJsonMerge.loadJSON( + at: acctDir.appendingPathComponent(".claude.json")) + #expect(claudeJSON?["userID"] == nil, + "--links-only must not merge the identity store into .claude.json") + } + } + + @Test("--account-dir follows a symlinked account dir (the ~/.claude origin case)") + func followsSymlinkedAccountDir() throws { + try withIsolatedHome { + let acctStore = AccountStore.default + let envStore = EnvironmentStore.default + let acct = Account(tool: .claude, displayName: "alice") + try acctStore.save(acct) + try PinCommand.parse(["alice", "--workspace", "origin"]).run() + + let acctDir = acctStore.accountDir(id: acct.id, tool: .claude) + let wsDir = envStore.claudeWorkspaceDir(workspace: "origin") + + let plugins = acctDir.appendingPathComponent("plugins") + try FileManager.default.createDirectory( + at: plugins, withIntermediateDirectories: true) + try Data("x".utf8).write(to: plugins.appendingPathComponent("config.json")) + + // Simulate ~/.claude: a symlink that points at the account dir. + let link = acctDir.deletingLastPathComponent() + .appendingPathComponent("dot-claude-link") + try FileManager.default.createSymbolicLink( + at: link, withDestinationURL: acctDir) + + // Launch through the SYMLINK path, as the wrapper does for bare origin. + var cmd = try PrepareClaudeLaunchCommand.parse( + ["--account-dir", link.path, "--links-only"]) + try cmd.run() + + // The REAL account dir's plugins must now be a symlink into the workspace. + let dest = try FileManager.default.destinationOfSymbolicLink( + atPath: acctDir.appendingPathComponent("plugins").path) + #expect(dest == wsDir.appendingPathComponent("plugins").path, + "linker must follow the symlinked account dir and link plugins") + } + } } @Suite("v3.1 launch+capture round trip") diff --git a/Tests/OrreryTests/ShellFunctionClaudeWrapperTests.swift b/Tests/OrreryTests/ShellFunctionClaudeWrapperTests.swift index b048c9c..6311d8e 100644 --- a/Tests/OrreryTests/ShellFunctionClaudeWrapperTests.swift +++ b/Tests/OrreryTests/ShellFunctionClaudeWrapperTests.swift @@ -96,6 +96,23 @@ struct ShellFunctionClaudeWrapperTests { "prepare failure must surface to stderr (not be silenced by 2>/dev/null)") } + @Test("claude() wrapper links workspace for bare origin launch (CLAUDE_CONFIG_DIR unset)") + func linksWorkspaceForBareOriginLaunch() { + let sh = ShellFunctionGenerator.generate() + guard let claudeFnStart = sh.range(of: "claude() {") else { + Issue.record("claude() function not found") + return + } + let body = String(sh[claudeFnStart.lowerBound...]) + // When CLAUDE_CONFIG_DIR is unset, ~/.claude is the origin account dir. + // The wrapper must still sync workspace symlinks against it, using + // --links-only (bare origin reads ~/.claude.json, so NO .claude.json merge). + #expect(body.contains("$HOME/.claude/metadata.json"), + "wrapper should detect the origin account dir at ~/.claude when CLAUDE_CONFIG_DIR is unset") + #expect(body.contains("--links-only"), + "bare origin launch should sync workspace symlinks via --links-only") + } + @Test("phantom loop account switch routes through orrery use shell function") func phantomAccountSwitchUsesShellFunction() { let sh = ShellFunctionGenerator.generate()