diff --git a/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/.openspec.yaml b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/.openspec.yaml new file mode 100644 index 0000000..28882f7 --- /dev/null +++ b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-19 diff --git a/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/proposal.md b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/proposal.md new file mode 100644 index 0000000..ea0756c --- /dev/null +++ b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/proposal.md @@ -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. diff --git a/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/specs/authmux-skill-profiles/spec.md b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/specs/authmux-skill-profiles/spec.md new file mode 100644 index 0000000..1ff6095 --- /dev/null +++ b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/specs/authmux-skill-profiles/spec.md @@ -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 --skill-profile frontend` +- **THEN** the registry entry for `` 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`. diff --git a/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/tasks.md b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/tasks.md new file mode 100644 index 0000000..522780b --- /dev/null +++ b/openspec/changes/agent-codex-authmux-skill-profiles-2026-05-19-08-28/tasks.md @@ -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). diff --git a/scripts/postinstall-login-hook.cjs b/scripts/postinstall-login-hook.cjs index 04ab027..7761608 100644 --- a/scripts/postinstall-login-hook.cjs +++ b/scripts/postinstall-login-hook.cjs @@ -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=$?", diff --git a/src/commands/list.ts b/src/commands/list.ts index 7d62b67..92c9c39 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -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 ?? "-"}`, ); } }); diff --git a/src/commands/login.ts b/src/commands/login.ts index e6a605b..c1868ed 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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 { @@ -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 = @@ -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 { diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index 89bf59d..fc4282d 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -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 []; @@ -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"; @@ -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, @@ -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", @@ -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) { @@ -70,12 +89,15 @@ 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({ @@ -83,16 +105,21 @@ export default class ClaudeParallel extends Command { 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`); } @@ -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) { @@ -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- (after installing aliases)`); } @@ -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"); } diff --git a/src/commands/save.ts b/src/commands/save.ts index d7ce430..dbe43e9 100644 --- a/src/commands/save.ts +++ b/src/commands/save.ts @@ -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; @@ -34,6 +37,7 @@ 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( @@ -41,6 +45,7 @@ export default class SaveCommand extends BaseCommand { saved: savedName, source: resolvedName.source, forced: Boolean(flags.force || resolvedName.forceOverwrite), + skillProfile: flags["skill-profile"] ?? null, }, (data) => { const suffix = @@ -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}".`); + } }, ); }); diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..1dfcfd7 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,138 @@ +import { Args, Flags } from "@oclif/core"; +import { BaseCommand } from "../lib/base-command"; +import { + activateSkillProfile, + listAvailableSkillProfiles, + SkillAgent, +} from "../lib/skills/profile"; + +type SkillAction = "list" | "current" | "use" | "activate" | "activate-current"; + +export default class SkillsCommand extends BaseCommand { + protected readonly syncExternalAuthBeforeRun = false; + + static description = "Manage Soul skill profiles for Codex and Claude launches"; + + static args = { + action: Args.string({ + name: "action", + required: false, + description: "list, current, use, activate, or activate-current", + }), + profile: Args.string({ + name: "profile", + required: false, + description: "Skill profile name", + }), + } as const; + + static flags = { + account: Flags.string({ + description: "Account to attach a profile to; defaults to current account for `use`", + }), + agent: Flags.string({ + description: "Agent skill target", + options: ["codex", "claude"], + default: "codex", + }), + target: Flags.string({ + description: "Explicit skills directory target", + }), + "no-activate": Flags.boolean({ + description: "For `use`, save metadata without activating the skills directory", + default: false, + }), + ...BaseCommand.jsonFlag, + } as const; + + async run(): Promise { + const { args, flags } = await this.parse(SkillsCommand); + this.setJsonMode(flags); + + await this.runSafe(async () => { + const action = this.normalizeAction(args.action as string | undefined); + const agent = flags.agent as SkillAgent; + + if (action === "list") { + const profiles = listAvailableSkillProfiles(); + this.emit({ profiles }, (data) => { + for (const profile of data.profiles) this.log(profile); + }); + return; + } + + if (action === "current") { + const resolved = await this.accounts.resolveCurrentSkillProfile(); + this.emit(resolved, (data) => { + const suffix = data.accountName ? ` account=${data.accountName}` : ""; + this.log(`skill-profile: ${data.profile} source=${data.source}${suffix}`); + }); + return; + } + + if (action === "use") { + const profile = this.requireProfile(args.profile as string | undefined, action); + const accountName = flags.account ?? await this.accounts.getCurrentAccountName(); + if (!accountName) { + this.error("No active account. Pass --account or run `authmux use ` first."); + } + const saved = await this.accounts.setSkillProfileForAccount(accountName, profile); + const activation = flags["no-activate"] + ? undefined + : activateSkillProfile({ profile: saved.skillProfile, agent, target: flags.target }); + + this.emit({ ...saved, activation }, (data) => { + this.log(`Saved skill profile "${data.skillProfile}" for account "${data.accountName}".`); + if (!data.activation) return; + this.printActivation(data.activation); + }); + return; + } + + if (action === "activate") { + const profile = this.requireProfile(args.profile as string | undefined, action); + const activation = activateSkillProfile({ profile, agent, target: flags.target }); + this.emit(activation, (data) => this.printActivation(data)); + return; + } + + const resolved = await this.accounts.resolveCurrentSkillProfile(); + const activation = activateSkillProfile({ profile: resolved.profile, agent, target: flags.target }); + this.emit({ ...resolved, activation }, (data) => { + this.log(`Resolved skill profile "${data.profile}" from ${data.source}.`); + this.printActivation(data.activation); + }); + }); + } + + private normalizeAction(raw: string | undefined): SkillAction { + const action = (raw ?? "current").trim(); + if ( + action === "list" || + action === "current" || + action === "use" || + action === "activate" || + action === "activate-current" + ) { + return action; + } + this.error(`Unknown skills action: ${action}`); + } + + private requireProfile(profile: string | undefined, action: string): string { + if (!profile) { + this.error(`Missing profile. Usage: authmux skills ${action} `); + } + return profile; + } + + private printActivation(data: { activated: boolean; profile: string; target?: string; skillCount?: number; reason?: string }): void { + if (!data.activated) { + this.warn(`Skill profile "${data.profile}" not activated: ${data.reason ?? "unknown reason"}`); + return; + } + this.log( + `Activated skill profile "${data.profile}" at ${data.target ?? "default target"} (${data.skillCount ?? "?"} skills).`, + ); + } +} diff --git a/src/commands/use.ts b/src/commands/use.ts index eabfd1a..d6ef2e2 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -5,6 +5,7 @@ import { NoAccountsSavedError, PromptCancelledError } from "../lib/accounts"; import { recordSuccess, recordFailure } from "../lib/account-health"; import { recordSwitch } from "../lib/account-savings"; import { hasKiroSnapshot, switchKiroSnapshot } from "../lib/kiro-mirror"; +import { activateSkillProfile } from "../lib/skills/profile"; export default class UseCommand extends BaseCommand { protected readonly syncExternalAuthBeforeRun = false; @@ -21,6 +22,13 @@ export default class UseCommand extends BaseCommand { static flags = { "no-kiro": Flags.boolean({ description: "Skip Kiro CLI mirror even if a matching snapshot exists" }), + "skill-profile": Flags.string({ + description: "Attach a Soul skill profile to this account before activating it", + }), + "no-skill-activate": Flags.boolean({ + description: "Do not activate the account skill profile after switching", + default: false, + }), ...BaseCommand.jsonFlag, } as const; @@ -42,7 +50,9 @@ export default class UseCommand extends BaseCommand { let activated: string; try { - activated = await this.accounts.useAccount(account); + activated = await this.accounts.useAccount(account, { + skillProfile: flags["skill-profile"], + }); recordSuccess(activated); recordSwitch(); } catch (err) { @@ -58,9 +68,16 @@ export default class UseCommand extends BaseCommand { mirror = switchKiroSnapshot(activated); } + const resolvedProfile = await this.accounts.resolveCurrentSkillProfile(); + const skillActivation = flags["no-skill-activate"] + ? undefined + : activateSkillProfile({ profile: resolvedProfile.profile, agent: "codex" }); + this.emit( { activated, + skillProfile: resolvedProfile, + skillActivation: skillActivation ?? null, kiro: { attempted: mirror.attempted, switched: mirror.switched, @@ -70,6 +87,13 @@ export default class UseCommand extends BaseCommand { }, (data) => { this.log(`Switched Codex auth to "${data.activated}".`); + if (data.skillActivation?.activated) { + this.log( + `Activated skill profile "${data.skillProfile.profile}" (${data.skillActivation.skillCount ?? "?"} skills).`, + ); + } else if (data.skillActivation && !data.skillActivation.activated) { + this.warn(`Skill profile skipped: ${data.skillActivation.reason}`); + } if (data.kiro.switched && data.kiro.active) { this.log(`Mirrored Kiro CLI to "${data.kiro.active}".`); } else if (data.kiro.attempted && data.kiro.reason) { diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 7251f6e..45ed61f 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -17,6 +17,7 @@ import { ListAccountMappingsOptions, findMatchingAccounts as findMatchingAccountsImpl, getCurrentAccountName as getCurrentAccountNameImpl, + loadReconciledRegistry, listAccountChoices as listAccountChoicesImpl, listAccountMappings as listAccountMappingsImpl, listAccountNames as listAccountNamesImpl, @@ -35,6 +36,7 @@ import { } from "./write/save"; import { useAccount as useAccountImpl, + UseAccountOptions, } from "./write/use"; import { RemoveResult, @@ -53,6 +55,14 @@ import { runDaemon as runDaemonImpl, } from "./auto-switch/policy"; import { refreshListUsageIfNeeded } from "./usage/adapter"; +import { AccountNotFoundError } from "./errors"; +import { normalizeAccountName } from "./naming"; +import { persistRegistry } from "./_internal/registry-ops"; +import { + defaultSkillProfileName, + normalizeSkillProfileName, + ResolvedSkillProfile, +} from "../skills/profile"; import { ResolvedDefaultAccountName, ResolvedLoginAccountName, @@ -64,6 +74,7 @@ export type { } from "./read/listing"; export type { RemoveResult } from "./write/remove"; export type { SaveAccountOptions } from "./write/save"; +export type { UseAccountOptions } from "./write/use"; export type { ResolvedDefaultAccountName, ResolvedLoginAccountName, @@ -119,8 +130,52 @@ export class AccountService { return resolveLoginAccountNameFromCurrentAuthImpl(() => this.getCurrentAccountName()); } - public useAccount(rawName: string): Promise { - return useAccountImpl(rawName, () => this.syncExternalAuthSnapshotIfNeeded()); + public useAccount(rawName: string, options?: UseAccountOptions): Promise { + return useAccountImpl(rawName, () => this.syncExternalAuthSnapshotIfNeeded(), options); + } + + public async setSkillProfileForAccount( + rawName: string, + rawProfile: string, + ): Promise<{ accountName: string; skillProfile: string }> { + const accountName = normalizeAccountName(rawName); + const skillProfile = normalizeSkillProfileName(rawProfile); + const registry = await loadReconciledRegistry(); + if (!registry.accounts[accountName]) { + throw new AccountNotFoundError(accountName); + } + registry.accounts[accountName].skillProfile = skillProfile; + await persistRegistry(registry); + return { accountName, skillProfile }; + } + + public async resolveCurrentSkillProfile(): Promise { + const envProfile = process.env.AUTHMUX_SKILL_PROFILE || process.env.SOUL_SKILL_PROFILE; + if (envProfile && envProfile.trim().length > 0) { + return { + profile: normalizeSkillProfileName(envProfile), + source: "env", + }; + } + + const accountName = await this.getCurrentAccountName(); + if (accountName) { + const registry = await loadReconciledRegistry(); + const accountProfile = registry.accounts[accountName]?.skillProfile; + if (accountProfile) { + return { + profile: normalizeSkillProfileName(accountProfile), + source: "account", + accountName, + }; + } + } + + return { + profile: defaultSkillProfileName(), + source: "default", + accountName: accountName ?? undefined, + }; } public removeAccounts(accountNames: string[]): Promise { diff --git a/src/lib/accounts/read/listing.ts b/src/lib/accounts/read/listing.ts index 049b28e..1c5eb9b 100644 --- a/src/lib/accounts/read/listing.ts +++ b/src/lib/accounts/read/listing.ts @@ -145,6 +145,7 @@ export async function listAccountMappings( accountId: entry?.accountId ?? fallbackSnapshot?.accountId, userId: entry?.userId ?? fallbackSnapshot?.userId, planType: entry?.planType ?? fallbackSnapshot?.planType, + skillProfile: entry?.skillProfile, lastUsageAt: entry?.lastUsageAt, usageSource: entry?.lastUsage?.source, remaining5hPercent, diff --git a/src/lib/accounts/registry.ts b/src/lib/accounts/registry.ts index d7f602e..c18bcaf 100644 --- a/src/lib/accounts/registry.ts +++ b/src/lib/accounts/registry.ts @@ -69,6 +69,13 @@ function sanitizeUsageSnapshot(input: unknown): UsageSnapshot | undefined { }; } +function sanitizeSkillProfile(input: unknown): string | undefined { + if (typeof input !== "string") return undefined; + const profile = input.trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(profile)) return undefined; + return profile; +} + function sanitizeEntry(name: string, entry: unknown): AccountRegistryEntry { const raw = entry && typeof entry === "object" ? (entry as Record) : {}; const sanitizedUsage = sanitizeUsageSnapshot(raw.lastUsage); @@ -80,6 +87,7 @@ function sanitizeEntry(name: string, entry: unknown): AccountRegistryEntry { accountId: typeof raw.accountId === "string" ? (raw.accountId as string) : undefined, userId: typeof raw.userId === "string" ? (raw.userId as string) : undefined, planType: typeof raw.planType === "string" ? (raw.planType as string) : undefined, + skillProfile: sanitizeSkillProfile(raw.skillProfile), lastUsageAt: typeof raw.lastUsageAt === "string" ? (raw.lastUsageAt as string) : undefined, lastUsage: sanitizedUsage, }; diff --git a/src/lib/accounts/types.ts b/src/lib/accounts/types.ts index c55deae..b2627aa 100644 --- a/src/lib/accounts/types.ts +++ b/src/lib/accounts/types.ts @@ -23,6 +23,7 @@ export interface AccountRegistryEntry { accountId?: string; userId?: string; planType?: string; + skillProfile?: string; createdAt: string; lastUsageAt?: string; lastUsage?: UsageSnapshot; @@ -77,6 +78,7 @@ export interface AccountMapping { accountId?: string; userId?: string; planType?: string; + skillProfile?: string; lastUsageAt?: string; usageSource?: UsageSource; remaining5hPercent?: number; diff --git a/src/lib/accounts/write/save.ts b/src/lib/accounts/write/save.ts index d63d2a6..06a3fc7 100644 --- a/src/lib/accounts/write/save.ts +++ b/src/lib/accounts/write/save.ts @@ -43,9 +43,11 @@ import { ResolvedDefaultAccountName, ResolvedLoginAccountName, } from "../_internal/name-resolution"; +import { normalizeSkillProfileName } from "../../skills/profile"; export interface SaveAccountOptions { force?: boolean; + skillProfile?: string; } export async function assertSafeSnapshotOverwrite(input: { @@ -108,6 +110,9 @@ export async function saveAccount( const registry = await loadReconciledRegistry(); await hydrateSnapshotMetadata(registry, name); + if (options?.skillProfile) { + registry.accounts[name].skillProfile = normalizeSkillProfileName(options.skillProfile); + } registry.activeAccountName = name; await persistRegistry(registry); diff --git a/src/lib/accounts/write/use.ts b/src/lib/accounts/write/use.ts index ed7061e..8c8804d 100644 --- a/src/lib/accounts/write/use.ts +++ b/src/lib/accounts/write/use.ts @@ -28,6 +28,11 @@ import { import { listAccountNames } from "../read/listing"; import { writeCurrentName } from "../_internal/auth-state"; import { pathExists, readAuthSyncState } from "../_internal/fs-helpers"; +import { normalizeSkillProfileName } from "../../skills/profile"; + +export interface UseAccountOptions { + skillProfile?: string; +} export async function activateSnapshot(accountName: string): Promise { const name = normalizeAccountName(accountName); @@ -101,6 +106,7 @@ export async function resolveUsableAccountName( export async function useAccount( rawName: string, syncExternalAuthSnapshotIfNeeded: () => Promise, + options?: UseAccountOptions, ): Promise { const name = normalizeAccountName(rawName); const resolvedName = await resolveUsableAccountName( @@ -111,6 +117,9 @@ export async function useAccount( const registry = await loadRegistry(); await hydrateSnapshotMetadataIfMissing(registry, resolvedName); + if (options?.skillProfile) { + registry.accounts[resolvedName].skillProfile = normalizeSkillProfileName(options.skillProfile); + } registry.activeAccountName = resolvedName; await persistRegistry(registry); diff --git a/src/lib/config/login-hook.ts b/src/lib/config/login-hook.ts index d513190..7c11dfa 100644 --- a/src/lib/config/login-hook.ts +++ b/src/lib/config/login-hook.ts @@ -50,6 +50,7 @@ export function renderLoginHookBlock(): string { "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=$?", diff --git a/src/lib/skills/profile.ts b/src/lib/skills/profile.ts new file mode 100644 index 0000000..b972644 --- /dev/null +++ b/src/lib/skills/profile.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +export type SkillAgent = "codex" | "claude"; +export type SkillProfileSource = "env" | "account" | "default"; + +export interface ResolvedSkillProfile { + profile: string; + source: SkillProfileSource; + accountName?: string; +} + +export interface SkillProfileActivation { + activated: boolean; + profile: string; + agent: SkillAgent; + target?: string; + skillCount?: number; + reason?: string; + stdout: string; + stderr: string; +} + +function expandHome(rawPath: string): string { + if (rawPath === "~") return os.homedir(); + if (rawPath.startsWith("~/")) return path.join(os.homedir(), rawPath.slice(2)); + return rawPath; +} + +function resolvePath(rawPath: string): string { + return path.resolve(expandHome(rawPath)); +} + +export function normalizeSkillProfileName(rawProfile: string): string { + const profile = rawProfile.trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(profile)) { + throw new Error(`Invalid skill profile name: ${rawProfile}`); + } + return profile; +} + +export function defaultSkillProfileName(): string { + return normalizeSkillProfileName(process.env.AUTHMUX_DEFAULT_SKILL_PROFILE || "base"); +} + +export function resolveSoulHome(): string { + return resolvePath(process.env.AUTHMUX_SOUL_HOME || process.env.SOUL_HOME || "~/Documents/soul"); +} + +export function resolveSoulSkillActivator(): string { + const explicit = process.env.AUTHMUX_SOUL_SKILL_ACTIVATOR; + if (explicit && explicit.trim().length > 0) { + return resolvePath(explicit.trim()); + } + return path.join(resolveSoulHome(), "skills", "scripts", "activate-profile.sh"); +} + +export function resolveSoulProfilesRoot(): string { + return path.join(resolveSoulHome(), "skills", "profiles"); +} + +export function listAvailableSkillProfiles(): string[] { + const profilesRoot = resolveSoulProfilesRoot(); + if (!fs.existsSync(profilesRoot)) return []; + return fs.readdirSync(profilesRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => entry.name.replace(/\.json$/i, "")) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); +} + +export function activateSkillProfile(input: { + profile: string; + agent?: SkillAgent; + target?: string; +}): SkillProfileActivation { + const profile = normalizeSkillProfileName(input.profile); + const agent = input.agent ?? "codex"; + const activator = resolveSoulSkillActivator(); + if (!fs.existsSync(activator)) { + return { + activated: false, + profile, + agent, + target: input.target, + reason: `missing activator: ${activator}`, + stdout: "", + stderr: "", + }; + } + + const args = ["--profile", profile, "--agent", agent]; + if (input.target) { + args.push("--target", input.target); + } + + const result = spawnSync(activator, args, { + encoding: "utf8", + env: process.env, + }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (result.status !== 0) { + throw new Error( + `Skill profile activation failed for "${profile}" (${result.status ?? "unknown"}): ${stderr || stdout}`, + ); + } + + const targetMatch = stdout.match(/\btarget=(\S+)/); + const countMatch = stdout.match(/\bskills=(\d+)/); + return { + activated: true, + profile, + agent, + target: input.target ?? targetMatch?.[1], + skillCount: countMatch ? Number.parseInt(countMatch[1], 10) : undefined, + stdout, + stderr, + }; +} diff --git a/src/tests/login-hook.test.ts b/src/tests/login-hook.test.ts index 88cf8aa..f8fc840 100644 --- a/src/tests/login-hook.test.ts +++ b/src/tests/login-hook.test.ts @@ -66,6 +66,7 @@ test("installLoginHook refreshes an existing legacy hook block", async (t) => { const contents = await fsp.readFile(rcPath, "utf8"); assert.ok(contents.includes("command authmux restore-session")); + assert.ok(contents.includes("command authmux skills activate-current --agent codex")); assert.ok(contents.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status")); assert.ok(!contents.includes("# legacy")); const startCount = contents.split(LOGIN_HOOK_MARK_START).length - 1; @@ -110,6 +111,7 @@ test("renderLoginHookBlock includes terminal-mode restore guard", () => { assert.ok(hook.includes("__codex_auth_restore_tty")); assert.ok(hook.includes("codex() {")); assert.ok(hook.includes("command authmux restore-session")); + assert.ok(hook.includes("command authmux skills activate-current --agent codex")); assert.ok(hook.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status")); assert.ok(!hook.includes("__first_non_flag")); assert.ok(!hook.includes("if ! typeset -f codex")); diff --git a/src/tests/registry.test.ts b/src/tests/registry.test.ts index 167f8b1..a878623 100644 --- a/src/tests/registry.test.ts +++ b/src/tests/registry.test.ts @@ -39,6 +39,26 @@ test("sanitizeRegistry preserves the proxy usage source", () => { assert.equal(registry.accounts.foo.lastUsage?.source, "proxy"); }); +test("sanitizeRegistry preserves valid skill profiles and drops invalid names", () => { + const registry = sanitizeRegistry({ + accounts: { + keep: { + name: "keep", + createdAt: new Date().toISOString(), + skillProfile: "frontend", + }, + drop: { + name: "drop", + createdAt: new Date().toISOString(), + skillProfile: "../all", + }, + }, + }); + + assert.equal(registry.accounts.keep.skillProfile, "frontend"); + assert.equal(registry.accounts.drop.skillProfile, undefined); +}); + test("sanitizeRegistry preserves api/local/cached sources and rejects unknown", () => { for (const source of ["api", "local", "cached", "proxy"] as const) { const registry = sanitizeRegistry({ diff --git a/src/tests/skills-profile.test.ts b/src/tests/skills-profile.test.ts new file mode 100644 index 0000000..e2e71a3 --- /dev/null +++ b/src/tests/skills-profile.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + activateSkillProfile, + listAvailableSkillProfiles, + normalizeSkillProfileName, +} from "../lib/skills/profile"; + +test("normalizeSkillProfileName accepts simple profile names", () => { + assert.equal(normalizeSkillProfileName("frontend"), "frontend"); + assert.equal(normalizeSkillProfileName("medusa-v2"), "medusa-v2"); +}); + +test("normalizeSkillProfileName rejects path-like names", () => { + assert.throws(() => normalizeSkillProfileName("../all"), /Invalid skill profile/); +}); + +test("listAvailableSkillProfiles reads Soul profile JSON files", () => { + const profiles = listAvailableSkillProfiles(); + assert.ok(profiles.includes("base")); + assert.ok(profiles.includes("all")); +}); + +test("activateSkillProfile delegates to the Soul activator", async (t) => { + const targetRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "authmux-skills-profile-")); + t.after(async () => { + await fsp.rm(targetRoot, { recursive: true, force: true }); + }); + + const result = activateSkillProfile({ + profile: "base", + agent: "codex", + target: path.join(targetRoot, "skills"), + }); + + assert.equal(result.activated, true); + assert.equal(result.profile, "base"); + assert.equal(result.skillCount, 10); +});