diff --git a/README.md b/README.md index 02d5c05..cd70952 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,44 @@ All profile commands support **prefix matching**: `aimux run w` → `work`, `aim When you run `aimux run work`, it sets `CLAUDE_CONFIG_DIR=~/.aimux/profiles/work` and launches the CLI. Claude sees a complete config directory — shared content via symlinks, private auth locally. +## Per-profile environment variables + +Some Claude Code modes (Microsoft Foundry, Bedrock, Vertex, custom proxies) are activated by environment variables, not by JSON config. aimux gives each profile two ways to inject env vars into the spawned `claude` process: + +1. **`/.env`** — dotenv file inside the profile directory. Best for secrets; supports `KEY=value`, `export KEY=value`, comments, and quoted values. `chmod 600` it. +2. **`env:` block under the profile in `config.yaml`** — best for non-secret toggles you want versioned alongside the rest of the profile config. Overrides `.env` on key conflict. + +Both are merged together and passed to `claude` along with `CLAUDE_CONFIG_DIR`. The same env is also applied to `aimux auth login `. + +### Microsoft Foundry recipe + +```yaml +# ~/.aimux/config.yaml +profiles: + foundry: + cli: claude + path: ~/.aimux/profiles/foundry + model: claude-opus-4-7 + env: + CLAUDE_CODE_USE_FOUNDRY: "1" + ANTHROPIC_FOUNDRY_RESOURCE: + ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4-7 + ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6 + ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4-5 +``` + +```bash +# ~/.aimux/profiles/foundry/.env +ANTHROPIC_FOUNDRY_API_KEY= +``` + +```bash +chmod 600 ~/.aimux/profiles/foundry/.env +aimux run foundry +``` + +> Note: putting Foundry settings inside `.claude.json` is not enough — Claude Code activates Foundry mode from environment variables on startup. Fields like `useFoundry` / `foundryResource` in `.claude.json` are state Claude Code *writes* after the env-driven activation, not the activation switch itself. + ## Config ```yaml @@ -137,6 +175,10 @@ profiles: cli: claude model: claude-opus-4-6 path: /home/user/.aimux/profiles/own + # Optional per-profile env injected into the spawned CLI. + # env: + # CLAUDE_CODE_USE_FOUNDRY: "1" + # ANTHROPIC_FOUNDRY_RESOURCE: my-resource private: - .credentials.json diff --git a/src/cli.tsx b/src/cli.tsx index 58d9796..d747edc 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -13,6 +13,7 @@ import { ensureProfileDir, initAutoDetect, initFromSource, detectClaudeDirs, syncProfile, syncAllProfiles, checkAllProfiles, launchProfile, getLastProfile, recordHistory, getProfile, + loadProfileEnv, } from './core/index.js'; function requireConfig(): AimuxConfig { @@ -379,7 +380,7 @@ program const resolved = resolveProfile(config, profile); const p = getProfile(config, resolved); const profilePath = expandHome(p.path); - const env: Record = {}; + const env: Record = loadProfileEnv(p, profilePath); if (!p.is_source) { env.CLAUDE_CONFIG_DIR = profilePath; } diff --git a/src/core/config.ts b/src/core/config.ts index 4d36e72..d48b922 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -132,6 +132,17 @@ export function validateConfig(config: unknown): string[] { if (p.model !== undefined && typeof p.model !== 'string') { errors.push(`Profile '${name}': model must be a string`); } + if (p.env !== undefined) { + if (!p.env || typeof p.env !== 'object' || Array.isArray(p.env)) { + errors.push(`Profile '${name}': env must be a map of string keys to string values`); + } else { + for (const [k, v] of Object.entries(p.env as Record)) { + if (typeof v !== 'string') { + errors.push(`Profile '${name}': env.${k} must be a string`); + } + } + } + } if (p.is_source) sourceCount++; } diff --git a/src/core/index.ts b/src/core/index.ts index d136120..6c104f4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -44,5 +44,5 @@ export { export type { DetectedDir, InitResult } from './init.js'; -export { buildRunParams, launchProfile } from './run.js'; +export { buildRunParams, launchProfile, loadProfileEnv, parseDotenv } from './run.js'; export type { RunOptions, RunParams } from './run.js'; diff --git a/src/core/run.test.ts b/src/core/run.test.ts index 61a5c08..47ce3b7 100644 --- a/src/core/run.test.ts +++ b/src/core/run.test.ts @@ -1,15 +1,18 @@ -import { describe, it, expect } from 'vitest'; -import { buildRunParams } from './run.js'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildRunParams, parseDotenv, loadProfileEnv } from './run.js'; import type { AimuxConfig } from '../types/index.js'; -function makeConfig(): AimuxConfig { +function makeConfig(extras?: Partial): AimuxConfig { return { version: 1, shared_source: '/home/user/.claude', profiles: { main: { cli: 'claude', path: '/home/user/.claude', is_source: true }, work: { cli: 'claude', path: '/home/user/.aimux/profiles/work', model: 'claude-opus-4-6' }, - own: { cli: 'claude', path: '/home/user/.aimux/profiles/own' }, + own: { cli: 'claude', path: '/home/user/.aimux/profiles/own', ...extras }, }, private: ['.credentials.json'], }; @@ -52,4 +55,76 @@ describe('buildRunParams', () => { it('throws for unknown profile', () => { expect(() => buildRunParams(makeConfig(), 'unknown')).toThrow('not found'); }); + + it('forwards profile env block to spawned process', () => { + const config = makeConfig({ env: { CLAUDE_CODE_USE_FOUNDRY: '1', ANTHROPIC_FOUNDRY_RESOURCE: 'my-resource' } }); + const params = buildRunParams(config, 'own'); + expect(params.env.CLAUDE_CODE_USE_FOUNDRY).toBe('1'); + expect(params.env.ANTHROPIC_FOUNDRY_RESOURCE).toBe('my-resource'); + }); +}); + +describe('parseDotenv', () => { + it('parses basic KEY=VALUE pairs', () => { + expect(parseDotenv('FOO=bar\nBAZ=qux')).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('skips comments and blank lines', () => { + expect(parseDotenv('# a comment\n\nFOO=bar\n')).toEqual({ FOO: 'bar' }); + }); + + it('supports `export` prefix', () => { + expect(parseDotenv('export FOO=bar')).toEqual({ FOO: 'bar' }); + }); + + it('strips matching surrounding quotes', () => { + expect(parseDotenv('FOO="bar baz"\nBAR=\'qux\'')).toEqual({ FOO: 'bar baz', BAR: 'qux' }); + }); + + it('decodes escapes inside double quotes only', () => { + expect(parseDotenv('FOO="line1\\nline2"\nBAR=\'line1\\nline2\'')).toEqual({ + FOO: 'line1\nline2', + BAR: 'line1\\nline2', + }); + }); + + it('strips inline comments on unquoted values', () => { + expect(parseDotenv('FOO=bar # trailing\n')).toEqual({ FOO: 'bar' }); + }); + + it('preserves `#` inside quoted values', () => { + expect(parseDotenv('FOO="bar # not a comment"')).toEqual({ FOO: 'bar # not a comment' }); + }); +}); + +describe('loadProfileEnv', () => { + const TEST_DIR = join(tmpdir(), `aimux-env-test-${Date.now()}`); + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('reads .env from profile dir', () => { + writeFileSync(join(TEST_DIR, '.env'), 'ANTHROPIC_FOUNDRY_API_KEY=secret123\n'); + const env = loadProfileEnv({ cli: 'claude', path: TEST_DIR }, TEST_DIR); + expect(env.ANTHROPIC_FOUNDRY_API_KEY).toBe('secret123'); + }); + + it('YAML env overrides .env on key conflict', () => { + writeFileSync(join(TEST_DIR, '.env'), 'CLAUDE_CODE_USE_FOUNDRY=0\n'); + const env = loadProfileEnv( + { cli: 'claude', path: TEST_DIR, env: { CLAUDE_CODE_USE_FOUNDRY: '1' } }, + TEST_DIR, + ); + expect(env.CLAUDE_CODE_USE_FOUNDRY).toBe('1'); + }); + + it('returns empty object when no .env and no env block', () => { + const env = loadProfileEnv({ cli: 'claude', path: TEST_DIR }, TEST_DIR); + expect(env).toEqual({}); + }); }); diff --git a/src/core/run.ts b/src/core/run.ts index 22a7254..32a6eab 100644 --- a/src/core/run.ts +++ b/src/core/run.ts @@ -1,5 +1,7 @@ import { spawnSync } from 'node:child_process'; -import type { AimuxConfig } from '../types/index.js'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AimuxConfig, ProfileConfig } from '../types/index.js'; import { getProfile } from './config.js'; import { expandHome } from './paths.js'; @@ -15,6 +17,50 @@ export interface RunParams { profilePath: string; } +const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/; + +export function parseDotenv(contents: string): Record { + const result: Record = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = ENV_LINE.exec(rawLine); + if (!match) continue; + const key = match[1]; + let value = match[2]; + // Strip a trailing inline comment for unquoted values. + if (!/^['"]/.test(value)) { + const hash = value.indexOf(' #'); + if (hash >= 0) value = value.slice(0, hash).trimEnd(); + } + // Strip matching surrounding quotes. + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + value = value.slice(1, -1); + if (first === '"') { + value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + } + } + result[key] = value; + } + return result; +} + +export function loadProfileEnv(profile: ProfileConfig, profilePath: string): Record { + const env: Record = {}; + const dotenvPath = join(profilePath, '.env'); + if (existsSync(dotenvPath)) { + Object.assign(env, parseDotenv(readFileSync(dotenvPath, 'utf-8'))); + } + if (profile.env) { + Object.assign(env, profile.env); + } + return env; +} + export function buildRunParams( config: AimuxConfig, profileName: string, @@ -32,7 +78,7 @@ export function buildRunParams( args.push(...options.extraArgs); } - const env: Record = {}; + const env: Record = loadProfileEnv(profile, profilePath); if (!profile.is_source) { env.CLAUDE_CONFIG_DIR = profilePath; } diff --git a/src/types/config.ts b/src/types/config.ts index 89c41f3..fec3b22 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ export interface ProfileConfig { model?: string; path: string; is_source?: boolean; + env?: Record; } export interface AimuxConfig {