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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-19
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- Codex startup context is inflated by exposing all Soul skills to every session.
- The user needs authmux to launch Codex and Claude Code with small, role-specific skill sets so new sessions do not burn context on unrelated skills.

## What Changes

- Add authmux account metadata for `skillProfile`.
- Add `authmux skills` commands to list, inspect, save, and activate Soul skill profiles.
- Activate the current skill profile from the Codex shell hook before `command codex`.
- Extend Claude parallel profiles so generated aliases activate a per-profile skills directory before launching Claude Code.

## Impact

- Affects authmux registry shape, `save`, `login`, `use`, `list`, `parallel`, and generated shell hooks.
- Defaults to the `base` profile when no account-specific profile is configured.
- If Soul is not installed, profile activation is skipped with a warning for explicit commands and silently ignored by shell hooks.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## ADDED Requirements

### Requirement: Account Skill Profile Metadata
authmux SHALL allow a saved Codex account to store an optional Soul skill profile name.

#### Scenario: Saving an account profile
- **WHEN** a user runs `authmux save <name> --skill-profile frontend`
- **THEN** the registry entry for `<name>` stores `skillProfile=frontend`
- **AND** later account listings can expose that profile.

### Requirement: Current Skill Profile Resolution
authmux SHALL resolve the current skill profile from explicit environment override, active account metadata, then the `base` default.

#### Scenario: Activating current profile
- **WHEN** `authmux skills activate-current --agent codex` runs for an active account without metadata
- **THEN** authmux activates the `base` Soul profile.

### Requirement: Codex Launch Hook Profile Activation
The generated Codex shell hook SHALL activate the current skill profile before launching Codex.

#### Scenario: Starting Codex through the hook
- **WHEN** the shell function `codex` is invoked
- **THEN** it restores the authmux session
- **AND** runs `authmux skills activate-current --agent codex`
- **AND** then runs `command codex`.

### Requirement: Claude Parallel Profile Activation
authmux SHALL allow Claude parallel profiles to carry a Soul skill profile and SHALL activate that profile in generated aliases.

#### Scenario: Generating a Claude alias
- **WHEN** a Claude parallel profile has `skillProfile=frontend`
- **THEN** its generated alias activates the `frontend` profile into that Claude profile's skills directory before launching `claude`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-codex-authmux-skill-profiles-2026-05-19-08-28`; branch=`agent/codex/authmux-skill-profiles-2026-05-19-08-28`; scope=`Soul profile activation plus authmux profile wiring`; action=`finish PR/merge cleanup if takeover is required`.
- Copy prompt: Continue `agent-codex-authmux-skill-profiles-2026-05-19-08-28` on branch `agent/codex/authmux-skill-profiles-2026-05-19-08-28`. Work inside the existing sandbox, review `openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/tasks.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/authmux-skill-profiles-2026-05-19-08-28 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-authmux-skill-profiles-2026-05-19-08-28`.
- [x] 1.2 Define normative requirements in `specs/authmux-skill-profiles/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- [x] 3.2 Run `openspec validate agent-codex-authmux-skill-profiles-2026-05-19-08-28 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/authmux-skill-profiles-2026-05-19-08-28 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
1 change: 1 addition & 0 deletions scripts/postinstall-login-hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function renderHookBlock() {
"codex() {",
" if command -v authmux >/dev/null 2>&1; then",
" command authmux restore-session >/dev/null 2>&1 || true",
" command authmux skills activate-current --agent codex >/dev/null 2>&1 || true",
" fi",
" command codex \"$@\"",
" local __codex_exit=$?",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default class ListCommand extends BaseCommand {
` email=${account.email ?? "-"} account=${account.accountId ?? "-"} user=${account.userId ?? "-"}`,
);
this.log(
` type=${formatAccountType(account.planType)} plan=${account.planType ?? "-"} usage=${account.usageSource ?? "-"} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)} lastUsageAt=${account.lastUsageAt ?? "-"}`,
` type=${formatAccountType(account.planType)} plan=${account.planType ?? "-"} skillProfile=${account.skillProfile ?? "-"} usage=${account.usageSource ?? "-"} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)} lastUsageAt=${account.lastUsageAt ?? "-"}`,
);
}
});
Expand Down
7 changes: 7 additions & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export default class LoginCommand extends BaseCommand {
"Force overwrite when the existing snapshot name belongs to a different detected account identity",
default: false,
}),
"skill-profile": Flags.string({
description: "Attach a Soul skill profile to this account",
}),
} as const;

async run(): Promise<void> {
Expand All @@ -54,6 +57,7 @@ export default class LoginCommand extends BaseCommand {
const forceOverwrite = Boolean(flags.force || resolvedName.forceOverwrite);
const savedName = await this.accounts.saveAccount(resolvedName.name, {
force: forceOverwrite,
skillProfile: flags["skill-profile"],
});

const suffix =
Expand All @@ -65,6 +69,9 @@ export default class LoginCommand extends BaseCommand {
? " (reused saved account name)"
: " (inferred from auth email)";
this.log(`Saved current Codex auth tokens as "${savedName}"${suffix}.`);
if (flags["skill-profile"]) {
this.log(`Attached skill profile "${flags["skill-profile"]}".`);
}
} catch (error) {
if (autoSwitchWasEnabled) {
try {
Expand Down
50 changes: 44 additions & 6 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "../lib/cli/json-envelope";

const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts");
const SKILL_PROFILE_FILE = ".authmux-skill-profile";

function getProfiles(): string[] {
if (!fs.existsSync(CLAUDE_PARALLEL_DIR)) return [];
Expand All @@ -28,6 +29,22 @@ function shellRcPath(): string {
return path.join(os.homedir(), ".bashrc");
}

function shellQuote(value: string): string {
return `'${value.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;
const profile = fs.readFileSync(file, "utf8").trim();
return profile.length > 0 ? profile : undefined;
}

function writeSkillProfile(name: string, skillProfile: string): void {
const file = path.join(CLAUDE_PARALLEL_DIR, name, SKILL_PROFILE_FILE);
fs.writeFileSync(file, `${skillProfile.trim()}\n`);
}

export default class ClaudeParallel extends Command {
static description = "Manage parallel Claude Code accounts via CLAUDE_CONFIG_DIR";

Expand All @@ -37,6 +54,7 @@ export default class ClaudeParallel extends Command {
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" }),
json: Flags.boolean({
description: "Emit a single JSON envelope to stdout (Theme X4).",
default: false,
Expand All @@ -45,6 +63,7 @@ export default class ClaudeParallel extends Command {

static examples = [
"agent-auth parallel --add work",
"agent-auth parallel --add frontend --skill-profile frontend",
"agent-auth parallel --add personal",
"agent-auth parallel --list",
"agent-auth parallel --aliases",
Expand All @@ -58,7 +77,7 @@ export default class ClaudeParallel extends Command {
this.jsonMode = Boolean(flags.json);

if (flags.add) {
this.addProfile(flags.add);
this.addProfile(flags.add, flags["skill-profile"]);
} else if (flags.remove) {
this.removeProfile(flags.remove);
} else if (flags.install) {
Expand All @@ -70,29 +89,37 @@ export default class ClaudeParallel extends Command {
}
}

private addProfile(name: string): void {
private addProfile(name: string, skillProfile?: string): void {
const dir = path.join(CLAUDE_PARALLEL_DIR, name);
const existed = fs.existsSync(dir);
if (!existed) {
fs.mkdirSync(dir, { recursive: true });
}
if (skillProfile) {
writeSkillProfile(name, skillProfile);
}

if (this.jsonMode) {
writeJsonEnvelope(jsonSuccess({
action: "add" as const,
profile: name,
dir,
created: !existed,
skillProfile: skillProfile ?? readSkillProfile(name) ?? "base",
}));
return;
}

if (existed) {
this.log(`Profile "${name}" already exists at ${dir}`);
if (skillProfile) {
this.log(` Skill profile: ${skillProfile}`);
}
return;
}
this.log(`Created profile: ${name}`);
this.log(` Config dir: ${dir}`);
this.log(` Skill profile: ${skillProfile ?? "base"}`);
this.log(` Run: CLAUDE_CONFIG_DIR=${dir} claude`);
this.log(`\nTo install shell aliases: agent-auth parallel --install`);
}
Expand Down Expand Up @@ -120,6 +147,7 @@ export default class ClaudeParallel extends Command {
const entries = profiles.map((p) => ({
name: p,
configDir: path.join(CLAUDE_PARALLEL_DIR, p),
skillProfile: readSkillProfile(p) ?? "base",
}));

if (this.jsonMode) {
Expand All @@ -137,7 +165,7 @@ export default class ClaudeParallel extends Command {
}
this.log("Claude Code parallel profiles:\n");
for (const p of entries) {
this.log(` • ${p.name} → ${p.configDir}`);
this.log(` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile}`);
}
this.log(`\nRun any profile: claude-<name> (after installing aliases)`);
}
Expand All @@ -147,9 +175,19 @@ export default class ClaudeParallel extends Command {
if (!profiles.length) return "";
const lines = [
"# Claude Code parallel accounts (managed by agent-auth)",
...profiles.map((p) =>
`alias claude-${p}="CLAUDE_CONFIG_DIR=${path.join(CLAUDE_PARALLEL_DIR, p)} command claude"`
),
...profiles.map((p) => {
const dir = path.join(CLAUDE_PARALLEL_DIR, p);
const profile = readSkillProfile(p) ?? "base";
const activate = [
"command authmux skills activate",
shellQuote(profile),
"--agent claude",
"--target",
shellQuote(path.join(dir, "skills")),
">/dev/null 2>&1 || true",
].join(" ");
return `alias claude-${p}="${activate}; CLAUDE_CONFIG_DIR=${shellQuote(dir)} command claude"`;
}),
];
return lines.join("\n");
}
Expand Down
8 changes: 8 additions & 0 deletions src/commands/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default class SaveCommand extends BaseCommand {
"Force overwrite when the existing snapshot name belongs to a different email account",
default: false,
}),
"skill-profile": Flags.string({
description: "Attach a Soul skill profile to this account",
}),
...BaseCommand.jsonFlag,
} as const;

Expand All @@ -34,13 +37,15 @@ export default class SaveCommand extends BaseCommand {
: await this.accounts.resolveDefaultAccountNameFromCurrentAuth();
const savedName = await this.accounts.saveAccount(resolvedName.name, {
force: Boolean(flags.force || resolvedName.forceOverwrite),
skillProfile: flags["skill-profile"],
});

this.emit(
{
saved: savedName,
source: resolvedName.source,
forced: Boolean(flags.force || resolvedName.forceOverwrite),
skillProfile: flags["skill-profile"] ?? null,
},
(data) => {
const suffix =
Expand All @@ -52,6 +57,9 @@ export default class SaveCommand extends BaseCommand {
? " (reused saved account name)"
: " (inferred from auth email)";
this.log(`Saved current Codex auth tokens as "${data.saved}"${suffix}.`);
if (data.skillProfile) {
this.log(`Attached skill profile "${data.skillProfile}".`);
}
},
);
});
Expand Down
Loading
Loading