Skip to content
Open
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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. **`<profile>/.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 <profile>`.

### 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: <your-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=<your-azure-foundry-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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ensureProfileDir, initAutoDetect, initFromSource, detectClaudeDirs,
syncProfile, syncAllProfiles, checkAllProfiles,
launchProfile, getLastProfile, recordHistory, getProfile,
loadProfileEnv,
} from './core/index.js';

function requireConfig(): AimuxConfig {
Expand Down Expand Up @@ -379,7 +380,7 @@ program
const resolved = resolveProfile(config, profile);
const p = getProfile(config, resolved);
const profilePath = expandHome(p.path);
const env: Record<string, string> = {};
const env: Record<string, string> = loadProfileEnv(p, profilePath);
if (!p.is_source) {
env.CLAUDE_CONFIG_DIR = profilePath;
}
Expand Down
11 changes: 11 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)) {
if (typeof v !== 'string') {
errors.push(`Profile '${name}': env.${k} must be a string`);
}
}
}
}
if (p.is_source) sourceCount++;
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
83 changes: 79 additions & 4 deletions src/core/run.test.ts
Original file line number Diff line number Diff line change
@@ -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['profiles']['']>): 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'],
};
Expand Down Expand Up @@ -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({});
});
});
50 changes: 48 additions & 2 deletions src/core/run.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, string> {
const result: Record<string, string> = {};
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<string, string> {
const env: Record<string, string> = {};
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,
Expand All @@ -32,7 +78,7 @@ export function buildRunParams(
args.push(...options.extraArgs);
}

const env: Record<string, string> = {};
const env: Record<string, string> = loadProfileEnv(profile, profilePath);
if (!profile.is_source) {
env.CLAUDE_CONFIG_DIR = profilePath;
}
Expand Down
1 change: 1 addition & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface ProfileConfig {
model?: string;
path: string;
is_source?: boolean;
env?: Record<string, string>;
}

export interface AimuxConfig {
Expand Down