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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -237,6 +240,7 @@ source ~/.bashrc

```sh
authmux parallel --add <name> # Create a new profile
authmux parallel --login <name> # Refresh Claude OAuth inside that profile
authmux parallel --remove <name> # Remove a profile
authmux parallel --list # List all profiles
authmux parallel --aliases # Print aliases (without installing)
Expand All @@ -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/<name>`. 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/<name>`. 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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`).
178 changes: 167 additions & 11 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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-<name> 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,
Expand All @@ -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",
Expand All @@ -90,15 +118,18 @@ export default class ClaudeParallel extends Command {
async run(): Promise<void> {
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();
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -193,7 +269,7 @@ export default class ClaudeParallel extends Command {
this.log(`\nRun any profile: claude-<name> (after installing aliases)`);
}

private generateAliases(): string {
private generateBashAliases(): string {
const profiles = getProfiles();
if (!profiles.length) return "";
const lines = [
Expand All @@ -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).
Expand All @@ -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,
}));
Expand All @@ -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 <name>");
}

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)) {
Expand All @@ -273,6 +401,7 @@ export default class ClaudeParallel extends Command {
if (this.jsonMode) {
writeJsonEnvelope(jsonSuccess({
action: "install" as const,
shell,
rc,
profiles,
}));
Expand All @@ -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}`);
}
}
}
Loading
Loading