Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 50 additions & 35 deletions Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <accountDir>/.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)")
}
Expand All @@ -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
// <accountDir>/.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
Expand Down
8 changes: 8 additions & 0 deletions Sources/OrreryCore/Shell/ShellFunctionGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
17 changes: 17 additions & 0 deletions Tests/OrreryTests/ShellFunctionClaudeWrapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading