diff --git a/README.md b/README.md index 8c3915c..bcd2df8 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,9 @@ source ~/.bashrc # or ~/.zshrc # Authenticate each in separate terminals claude-work # logs in, credentials saved to ~/.claude-accounts/work claude-personal # logs in, credentials saved to ~/.claude-accounts/personal + +# Force a fresh OAuth login for one profile +authmux parallel --login work ``` ### Quick setup (standalone script) @@ -237,6 +240,7 @@ source ~/.bashrc ```sh authmux parallel --add # Create a new profile +authmux parallel --login # Refresh Claude OAuth inside that profile authmux parallel --remove # Remove a profile authmux parallel --list # List all profiles authmux parallel --aliases # Print aliases (without installing) @@ -245,7 +249,7 @@ authmux parallel --install # Write aliases to shell rc file ### How it works -Each profile gets its own config directory at `~/.claude-accounts/`. Shell aliases set `CLAUDE_CONFIG_DIR` before launching `claude`, so each instance uses isolated credentials, settings, and history. Run them in separate terminal tabs or tmux panes for true parallel usage. +Each profile gets its own config directory at `~/.claude-accounts/`. Shell aliases/functions set `CLAUDE_CONFIG_DIR` before launching `claude`, so each instance uses isolated credentials, settings, and history. `authmux parallel --install` writes Bash/Zsh aliases or Fish autoload functions based on `$SHELL`. Login and logout commands bypass Cue and run raw Claude against the selected profile so refreshed OAuth tokens are written back to the right account directory. Run profiles in separate terminal tabs or tmux panes for true parallel usage. ### Notes diff --git a/openspec/changes/agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29/notes.md b/openspec/changes/agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29/notes.md new file mode 100644 index 0000000..2ab3b30 --- /dev/null +++ b/openspec/changes/agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29/notes.md @@ -0,0 +1,23 @@ +# agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29 (minimal / T1) + +Branch: `agent/codex/refresh-claude-parallel-relogin-2026-06-25-10-29` + +Refresh Claude parallel account relogin behavior so login/logout and missing credentials bypass Cue and write OAuth tokens directly into the selected `CLAUDE_CONFIG_DIR`. Add Fish function installation because the live shell is Fish, and ignore dot-directories in `~/.claude-accounts` so backups are not treated as profiles. + +Verification: +- `npm run build` +- `node dist/tests/json-parity.test.js` +- live `fish -lc 'type claude-account1; type claude-account2'` +- live `fish -lc 'claude-account1 --version; claude-account2 --version'` +- live `authmux forecast` shows both Claude accounts unhealthy/unknown after intentional credential removal. + +## Handoff + +- Handoff: change=`agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29`; branch=`agent/codex/refresh-claude-parallel-relogin-2026-06-25-10-29`; scope=`src/commands/parallel.ts, src/tests/json-parity.test.ts, README.md`; action=`finish via PR after verification`. +- Copy prompt: Continue `agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29` on branch `agent/codex/refresh-claude-parallel-relogin-2026-06-25-10-29`. Work inside the existing sandbox, review `openspec/changes/agent-codex-refresh-claude-parallel-relogin-2026-06-25-10-29/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/refresh-claude-parallel-relogin-2026-06-25-10-29 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/refresh-claude-parallel-relogin-2026-06-25-10-29 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index 4c583b5..dbec7dd 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -4,6 +4,7 @@ // with BaseCommand commands. import { Command, Flags } from "@oclif/core"; +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; @@ -16,25 +17,45 @@ const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts"); const SKILL_PROFILE_FILE = ".authmux-skill-profile"; const CUE_PROFILE_FILE = ".authmux-cue-profile"; const DEFAULT_CUE_PROFILE = "core"; +type ShellName = "bash" | "zsh" | "fish"; function getProfiles(): string[] { if (!fs.existsSync(CLAUDE_PARALLEL_DIR)) return []; return fs.readdirSync(CLAUDE_PARALLEL_DIR, { withFileTypes: true }) - .filter((d) => d.isDirectory()) + .filter((d) => d.isDirectory() && !d.name.startsWith(".")) .map((d) => d.name) .sort(); } -function shellRcPath(): string { +function detectDefaultShell(): ShellName { const shell = process.env.SHELL || "/bin/bash"; - if (shell.includes("zsh")) return path.join(os.homedir(), ".zshrc"); + if (shell.includes("fish")) return "fish"; + if (shell.includes("zsh")) return "zsh"; + return "bash"; +} + +function resolveShellName(shell: string | undefined): ShellName { + if (!shell || shell === "auto") return detectDefaultShell(); + return shell as ShellName; +} + +function shellRcPath(shell: ShellName): string { + if (shell === "zsh") return path.join(os.homedir(), ".zshrc"); return path.join(os.homedir(), ".bashrc"); } +function fishFunctionsDir(): string { + return path.join(os.homedir(), ".config", "fish", "functions"); +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +function fishQuote(value: string): string { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; +} + function readSkillProfile(name: string): string | undefined { const file = path.join(CLAUDE_PARALLEL_DIR, name, SKILL_PROFILE_FILE); if (!fs.existsSync(file)) return undefined; @@ -64,12 +85,18 @@ export default class ClaudeParallel extends Command { static flags = { add: Flags.string({ description: "Add a new profile name" }), + login: Flags.string({ description: "Run Claude Code login inside a parallel profile" }), remove: Flags.string({ description: "Remove a profile" }), aliases: Flags.boolean({ description: "Print shell aliases for all profiles" }), install: Flags.boolean({ description: "Install aliases into shell rc file" }), list: Flags.boolean({ char: "l", description: "List profiles" }), "skill-profile": Flags.string({ description: "Soul skill profile for this Claude profile" }), "cue-profile": Flags.string({ description: "Cue profile used by generated claude- aliases" }), + shell: Flags.string({ + description: "Shell syntax for --aliases/--install", + options: ["auto", "bash", "zsh", "fish"], + default: "auto", + }), json: Flags.boolean({ description: "Emit a single JSON envelope to stdout (Theme X4).", default: false, @@ -80,6 +107,7 @@ export default class ClaudeParallel extends Command { "agent-auth parallel --add work", "agent-auth parallel --add frontend --skill-profile frontend", "agent-auth parallel --add personal", + "agent-auth parallel --login work", "agent-auth parallel --list", "agent-auth parallel --aliases", "agent-auth parallel --install", @@ -90,15 +118,18 @@ export default class ClaudeParallel extends Command { async run(): Promise { const { flags } = await this.parse(ClaudeParallel); this.jsonMode = Boolean(flags.json); + const shell = resolveShellName(flags.shell); if (flags.add) { this.addProfile(flags.add, flags["skill-profile"], flags["cue-profile"]); + } else if (flags.login) { + this.loginProfile(flags.login); } else if (flags.remove) { this.removeProfile(flags.remove); } else if (flags.install) { - this.installAliases(); + this.installAliases(shell); } else if (flags.aliases) { - this.printAliases(); + this.printAliases(shell); } else { this.listProfiles(); } @@ -146,6 +177,51 @@ export default class ClaudeParallel extends Command { this.log(`\nTo install shell aliases: agent-auth parallel --install`); } + private loginProfile(name: string): void { + if (this.jsonMode) { + this.error("parallel --login is interactive and does not support --json."); + } + + const dir = path.join(CLAUDE_PARALLEL_DIR, name); + const existed = fs.existsSync(dir); + if (!existed) { + fs.mkdirSync(dir, { recursive: true }); + writeCueProfile(name, DEFAULT_CUE_PROFILE); + this.log(`Created profile: ${name}`); + this.log(` Config dir: ${dir}`); + } + + const credentialsPath = path.join(dir, ".credentials.json"); + const before = fs.statSync(credentialsPath, { throwIfNoEntry: false })?.mtimeMs ?? 0; + const result = spawnSync("claude", ["login"], { + stdio: "inherit", + env: { + ...process.env, + CLAUDE_CONFIG_DIR: dir, + }, + }); + + if (result.error) { + const err = result.error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + this.error("`claude` CLI was not found in PATH. Install Claude Code first, then retry."); + } + throw result.error; + } + + if (result.status !== 0) { + this.error(`\`claude login\` failed with exit code ${result.status ?? "unknown"}.`); + } + + const after = fs.statSync(credentialsPath, { throwIfNoEntry: false }); + if (!after) { + this.error(`Claude login completed but did not write ${credentialsPath}.`); + } + + const suffix = after.mtimeMs > before ? "refreshed" : "present"; + this.log(`Claude credentials ${suffix} for "${name}" at ${credentialsPath}.`); + } + private removeProfile(name: string): void { const dir = path.join(CLAUDE_PARALLEL_DIR, name); if (!fs.existsSync(dir)) { @@ -193,7 +269,7 @@ export default class ClaudeParallel extends Command { this.log(`\nRun any profile: claude- (after installing aliases)`); } - private generateAliases(): string { + private generateBashAliases(): string { const profiles = getProfiles(); if (!profiles.length) return ""; const lines = [ @@ -205,6 +281,10 @@ export default class ClaudeParallel extends Command { " shift 3", " local dir=\"$HOME/.claude-accounts/$name\"", " command authmux skills activate \"$skill_profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1 || true", + " if [ \"${1:-}\" = \"login\" ] || [ \"${1:-}\" = \"logout\" ] || [ ! -s \"$dir/.credentials.json\" ]; then", + " CLAUDE_CONFIG_DIR=\"$dir\" command claude \"$@\"", + " return $?", + " fi", " if command -v cue >/dev/null 2>&1 && [ -z \"${AUTHMUX_SKIP_CUE_LAUNCH:-}\" ]; then", // The sentinel `pick` means \"don't force a profile — open cue's selector\". // Any other value is forced via --cue-profile (which suppresses the picker). @@ -226,13 +306,56 @@ export default class ClaudeParallel extends Command { return lines.join("\n"); } - private printAliases(): void { - const aliases = this.generateAliases(); + private generateFishFunction(profile: string): string { + const skillProfile = readSkillProfile(profile) ?? "base"; + const cueProfile = readCueProfile(profile) ?? DEFAULT_CUE_PROFILE; + const lines = [ + `function claude-${profile} --description ${fishQuote(`Claude Code with ${profile} config`)}`, + ` set -l name ${fishQuote(profile)}`, + ` set -l skill_profile ${fishQuote(skillProfile)}`, + ` set -l cue_profile ${fishQuote(cueProfile)}`, + " set -l dir \"$HOME/.claude-accounts/$name\"", + " command authmux skills activate \"$skill_profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1; or true", + " set -lx CLAUDE_CONFIG_DIR \"$dir\"", + " if set -q argv[1]; and test \"$argv[1]\" = login", + " command claude $argv", + " return $status", + " end", + " if set -q argv[1]; and test \"$argv[1]\" = logout", + " command claude $argv", + " return $status", + " end", + " if not test -s \"$dir/.credentials.json\"", + " command claude $argv", + " return $status", + " end", + " if command -q cue; and not set -q AUTHMUX_SKIP_CUE_LAUNCH", + cueProfile === "pick" + ? " cue launch claude --cue-pick $argv" + : " cue launch claude --cue-profile \"$cue_profile\" $argv", + " else", + " command claude $argv", + " end", + "end", + ]; + return lines.join("\n"); + } + + private generateAliases(shell: ShellName): string { + if (shell === "fish") { + return getProfiles().map((p) => this.generateFishFunction(p)).join("\n\n"); + } + return this.generateBashAliases(); + } + + private printAliases(shell: ShellName): void { + const aliases = this.generateAliases(shell); const profiles = getProfiles(); if (this.jsonMode) { writeJsonEnvelope(jsonSuccess({ action: "aliases" as const, + shell, profiles, aliases, })); @@ -246,16 +369,21 @@ export default class ClaudeParallel extends Command { this.log(aliases); } - private installAliases(): void { + private installAliases(shell: ShellName): void { const profiles = getProfiles(); if (!profiles.length) { this.error("No profiles. Add one first: agent-auth parallel --add "); } - const rc = shellRcPath(); + if (shell === "fish") { + this.installFishFunctions(profiles); + return; + } + + const rc = shellRcPath(shell); const marker = "# >>> agent-auth parallel >>>"; const endMarker = "# <<< agent-auth parallel <<<"; - const block = [marker, this.generateAliases(), endMarker].join("\n"); + const block = [marker, this.generateAliases(shell), endMarker].join("\n"); let content = ""; if (fs.existsSync(rc)) { @@ -273,6 +401,7 @@ export default class ClaudeParallel extends Command { if (this.jsonMode) { writeJsonEnvelope(jsonSuccess({ action: "install" as const, + shell, rc, profiles, })); @@ -285,4 +414,31 @@ export default class ClaudeParallel extends Command { this.log(` claude-${p}`); } } + + private installFishFunctions(profiles: string[]): void { + const functionsDir = fishFunctionsDir(); + fs.mkdirSync(functionsDir, { recursive: true }); + const files = profiles.map((p) => { + const file = path.join(functionsDir, `claude-${p}.fish`); + fs.writeFileSync(file, `${this.generateFishFunction(p)}\n`); + return file; + }); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "install" as const, + shell: "fish" as const, + functionsDir, + files, + profiles, + })); + return; + } + + this.log(`Installed Fish functions in ${functionsDir}`); + this.log(`\nAvailable commands:`); + for (const p of profiles) { + this.log(` claude-${p}`); + } + } } diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 23d9ed5..91f9d55 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -281,18 +281,23 @@ test("parallel aliases pass explicit cue profile per Claude account", async () = assert.equal(parsedAdd.data.skillProfile, "frontend"); assert.equal(parsedAdd.data.cueProfile, "frontend"); - const aliases = runCli(["parallel", "--aliases", "--json"], env); + const aliases = runCli(["parallel", "--aliases", "--shell", "bash", "--json"], env); assert.equal(aliases.status, 0, aliases.stderr); const parsedAliases = JSON.parse(aliases.stdout.trim()) as { ok: true; - data: { aliases: string }; + data: { aliases: string; shell: string }; }; + assert.equal(parsedAliases.data.shell, "bash"); assert.match(parsedAliases.data.aliases, /__authmux_claude_account\(\)/); assert.match( parsedAliases.data.aliases, /authmux skills activate "\$skill_profile" --agent claude/, ); + assert.match( + parsedAliases.data.aliases, + /\[ "\$\{1:-\}" = "login" \]/, + ); assert.match( parsedAliases.data.aliases, /cue launch claude --cue-profile "\$cue_profile"/, @@ -303,3 +308,43 @@ test("parallel aliases pass explicit cue profile per Claude account", async () = ); }); }); + +test("parallel install writes Fish functions with direct login refresh path", async () => { + await withSandbox(async (env) => { + const addDefault = runCli( + ["parallel", "--add", "account1", "--cue-profile", "pick", "--json"], + env, + ); + assert.equal(addDefault.status, 0, addDefault.stderr); + await fsp.mkdir(path.join(env.HOME as string, ".claude-accounts", ".backup"), { + recursive: true, + }); + + const install = runCli( + ["parallel", "--install", "--shell", "fish", "--json"], + env, + ); + assert.equal(install.status, 0, install.stderr); + + const parsedInstall = JSON.parse(install.stdout.trim()) as { + ok: true; + data: { shell: string; files: string[] }; + }; + assert.equal(parsedInstall.data.shell, "fish"); + assert.equal(parsedInstall.data.files.length, 1); + + const functionPath = path.join( + env.HOME as string, + ".config", + "fish", + "functions", + "claude-account1.fish", + ); + const body = await fsp.readFile(functionPath, "utf8"); + assert.match(body, /set -lx CLAUDE_CONFIG_DIR "\$dir"/); + assert.match(body, /test "\$argv\[1\]" = login/); + assert.match(body, /not test -s "\$dir\/\.credentials\.json"/); + assert.match(body, /cue launch claude --cue-pick \$argv/); + assert.match(body, /command claude \$argv/); + }); +});