Skip to content

Commit 5b5bf42

Browse files
thameem-abbasclaude
andcommitted
feat: per-account environment variables (cloak env)
Adds cloak env set/unset/list commands to store arbitrary env vars per account in ~/.cloak/profiles/<name>/env.json. On cloak switch, vars are automatically exported for the target account and unset for the previous account via the existing shell integration eval mechanism. Motivation: supports Vertex AI accounts that require CLAUDE_CODE_USE_VERTEX, CLOUD_ML_REGION, and ANTHROPIC_VERTEX_PROJECT_ID alongside CLAUDE_CONFIG_DIR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 527d4e9 commit 5b5bf42

7 files changed

Lines changed: 381 additions & 5 deletions

File tree

src/cli.js

100644100755
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { renameAccount } from './commands/rename.js'
1616
import { bindAccount } from './commands/bind.js'
1717
import { unbindAccount } from './commands/unbind.js'
1818
import { initShell } from './commands/init.js'
19+
import { envSet, envUnset, envList } from './commands/env.js'
1920

2021
const __dirname = dirname(fileURLToPath(import.meta.url))
2122
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
@@ -116,6 +117,21 @@ program
116117
return unbindAccount()
117118
})
118119

120+
const envCmd = program
121+
.command('env')
122+
.description('Manage per-cloak environment variables')
123+
.action(() => envList())
124+
125+
envCmd
126+
.command('set <assignment>')
127+
.description('Set KEY=VALUE for current cloak')
128+
.action((assignment) => envSet(assignment))
129+
130+
envCmd
131+
.command('unset <key>')
132+
.description('Remove an env var from current cloak')
133+
.action((key) => envUnset(key))
134+
119135
program
120136
.command('context-bar', { hidden: true })
121137
.argument('<command>')

src/commands/env.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { getActiveProfile, readProfileEnv, writeProfileEnv } from '../lib/paths.js'
2+
import * as msg from '../lib/messages.js'
3+
4+
export async function envSet(assignment) {
5+
const name = getActiveProfile()
6+
if (!name) {
7+
console.error(msg.noActiveCloakForEnv())
8+
process.exit(1)
9+
return
10+
}
11+
12+
const eqIndex = assignment.indexOf('=')
13+
if (eqIndex === -1) {
14+
console.error(msg.envInvalidFormat(assignment))
15+
process.exit(1)
16+
return
17+
}
18+
19+
const key = assignment.slice(0, eqIndex)
20+
const value = assignment.slice(eqIndex + 1)
21+
22+
if (!key) {
23+
console.error(msg.envEmptyKey())
24+
process.exit(1)
25+
return
26+
}
27+
28+
const vars = readProfileEnv(name)
29+
vars[key] = value
30+
writeProfileEnv(name, vars)
31+
console.log(msg.envVarSet(key))
32+
}
33+
34+
export async function envUnset(key) {
35+
const name = getActiveProfile()
36+
if (!name) {
37+
console.error(msg.noActiveCloakForEnv())
38+
process.exit(1)
39+
return
40+
}
41+
42+
const vars = readProfileEnv(name)
43+
delete vars[key]
44+
writeProfileEnv(name, vars)
45+
console.log(msg.envVarUnset(key))
46+
}
47+
48+
export async function envList() {
49+
const name = getActiveProfile()
50+
if (!name) {
51+
console.error(msg.noActiveCloakForEnv())
52+
process.exit(1)
53+
return
54+
}
55+
56+
const vars = readProfileEnv(name)
57+
const entries = Object.entries(vars)
58+
59+
process.stdout.write(msg.envListHeader(name) + '\n')
60+
if (entries.length === 0) {
61+
process.stdout.write(msg.envListEmpty() + '\n')
62+
} else {
63+
for (const [key, value] of entries) {
64+
process.stdout.write(msg.envListItem(key, value) + '\n')
65+
}
66+
}
67+
process.stdout.write('\n')
68+
}

src/commands/switch.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import inquirer from 'inquirer'
2-
import { profileDir, profileExists, getActiveProfile } from '../lib/paths.js'
2+
import { profileDir, profileExists, getActiveProfile, readProfileEnv } from '../lib/paths.js'
33
import { validateAccountName } from '../lib/validate.js'
44
import { getRcFilePath, isAlreadyInstalled, installToRcFile } from '../lib/setup.js'
55
import * as msg from '../lib/messages.js'
@@ -29,7 +29,24 @@ export async function switchAccount(name, options = {}) {
2929
const dir = profileDir(name)
3030

3131
if (options.printEnv) {
32-
process.stdout.write(msg.printEnvExport(dir))
32+
const currentProfile = getActiveProfile()
33+
const currentEnv = currentProfile ? readProfileEnv(currentProfile) : {}
34+
const targetEnv = readProfileEnv(name)
35+
36+
let output = ''
37+
for (const key of Object.keys(currentEnv)) {
38+
if (!(key in targetEnv)) output += `unset ${key}\n`
39+
}
40+
for (const [key, val] of Object.entries(targetEnv)) {
41+
const escaped = val
42+
.replace(/\\/g, '\\\\')
43+
.replace(/"/g, '\\"')
44+
.replace(/\$/g, '\\$')
45+
.replace(/`/g, '\\`')
46+
output += `export ${key}="${escaped}"\n`
47+
}
48+
output += msg.printEnvExport(dir)
49+
process.stdout.write(output)
3350
// Confirmation to stderr so it doesn't interfere with eval
3451
process.stderr.write(msg.cloakSwitched(name) + '\n')
3552
return

src/lib/messages.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,44 @@ export function wearingCloak(name) {
148148
return `Wearing cloak "${name}"`
149149
}
150150

151+
// --- Env vars ---
152+
153+
export function envVarSet(key) {
154+
return `${icon.success} Set ${chalk.bold(key)} for this cloak.`
155+
}
156+
157+
export function envVarUnset(key) {
158+
return `${icon.success} Removed ${chalk.bold(key)} from this cloak.`
159+
}
160+
161+
export function envVarNotSet(key) {
162+
return `${icon.warning} ${chalk.bold(key)} is not set for this cloak.`
163+
}
164+
165+
export function envListHeader(name) {
166+
return chalk.bold(`\nEnvironment variables for cloak "${name}"\n`)
167+
}
168+
169+
export function envListItem(key, value) {
170+
return ` ${chalk.cyan(key)}=${chalk.white(value)}`
171+
}
172+
173+
export function envListEmpty() {
174+
return chalk.dim(' No environment variables set for this cloak.')
175+
}
176+
177+
export function noActiveCloakForEnv() {
178+
return `${icon.error} No cloak active. Switch to a cloak first.`
179+
}
180+
181+
export function envInvalidFormat(assignment) {
182+
return `${icon.error} Expected KEY=VALUE, got: ${chalk.bold(assignment)}`
183+
}
184+
185+
export function envEmptyKey() {
186+
return `${icon.error} Key cannot be empty.`
187+
}
188+
151189
// --- Print-env (stdout, no chalk — evaluated by shell) ---
152190

153191
export function printEnvExport(dir) {

src/lib/paths.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { homedir } from 'os'
22
import { join, resolve, sep } from 'path'
3-
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'
3+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
44

55
function getHome() {
66
return process.env.HOME || homedir()
@@ -68,6 +68,24 @@ export function getActiveProfile() {
6868
return name || null
6969
}
7070

71+
export function profileEnvPath(name) {
72+
return join(PROFILES_DIR, name, 'env.json')
73+
}
74+
75+
export function readProfileEnv(name) {
76+
try {
77+
const p = profileEnvPath(name)
78+
if (!existsSync(p)) return {}
79+
return JSON.parse(readFileSync(p, 'utf8'))
80+
} catch {
81+
return {}
82+
}
83+
}
84+
85+
export function writeProfileEnv(name, vars) {
86+
writeFileSync(profileEnvPath(name), JSON.stringify(vars, null, 2) + '\n', { mode: 0o600 })
87+
}
88+
7189
export function getAccountEmail(name) {
7290
try {
7391
const authFile = profileAuthPath(name)

tests/env.test.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { describe, it, beforeEach, after } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import fs from 'fs'
4+
import path from 'path'
5+
import os from 'os'
6+
7+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cloak-test-'))
8+
process.env.HOME = TMP
9+
delete process.env.CLAUDE_CONFIG_DIR
10+
11+
const { profileDir, PROFILES_DIR, profileEnvPath, readProfileEnv } = await import('../src/lib/paths.js')
12+
const { envSet, envUnset, envList } = await import('../src/commands/env.js')
13+
14+
function createFakeProfile(name) {
15+
const dir = profileDir(name)
16+
fs.mkdirSync(dir, { recursive: true })
17+
fs.writeFileSync(path.join(dir, '.claude.json'), '{}')
18+
}
19+
20+
function cleanup() {
21+
if (fs.existsSync(PROFILES_DIR)) fs.rmSync(PROFILES_DIR, { recursive: true, force: true })
22+
}
23+
24+
function interceptExit(fn) {
25+
let exitCode = null
26+
const original = process.exit
27+
process.exit = (code) => { exitCode = code }
28+
return async () => {
29+
try { await fn() } finally { process.exit = original }
30+
return exitCode
31+
}
32+
}
33+
34+
async function captureStdout(fn) {
35+
const original = process.stdout.write
36+
let output = ''
37+
process.stdout.write = (chunk) => { output += chunk; return true }
38+
try { await fn() } finally { process.stdout.write = original }
39+
return output
40+
}
41+
42+
async function captureStderr(fn) {
43+
const original = console.error
44+
let output = ''
45+
console.error = (...args) => { output += args.join(' ') }
46+
try { await fn() } finally { console.error = original }
47+
return output
48+
}
49+
50+
describe('env', () => {
51+
beforeEach(() => {
52+
delete process.env.CLAUDE_CONFIG_DIR
53+
cleanup()
54+
})
55+
56+
after(() => {
57+
fs.rmSync(TMP, { recursive: true, force: true })
58+
})
59+
60+
// --- envSet ---
61+
62+
it('EV-01: set stores KEY=VALUE in env.json for active cloak', async () => {
63+
createFakeProfile('work')
64+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
65+
await envSet('CLAUDE_CODE_USE_VERTEX=1')
66+
const vars = readProfileEnv('work')
67+
assert.equal(vars.CLAUDE_CODE_USE_VERTEX, '1')
68+
})
69+
70+
it('EV-02: set handles values with equals signs (e.g. base64)', async () => {
71+
createFakeProfile('work')
72+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
73+
await envSet('MY_VAR=abc=def==')
74+
const vars = readProfileEnv('work')
75+
assert.equal(vars.MY_VAR, 'abc=def==')
76+
})
77+
78+
it('EV-03: set updates an existing var without clearing others', async () => {
79+
createFakeProfile('work')
80+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
81+
await envSet('A=1')
82+
await envSet('B=2')
83+
await envSet('A=99')
84+
const vars = readProfileEnv('work')
85+
assert.equal(vars.A, '99')
86+
assert.equal(vars.B, '2')
87+
})
88+
89+
it('EV-04: set exits with code 1 when no active cloak', async () => {
90+
const run = interceptExit(() => envSet('KEY=VALUE'))
91+
const code = await run()
92+
assert.equal(code, 1)
93+
})
94+
95+
it('EV-05: set exits with code 1 for invalid format (no equals)', async () => {
96+
createFakeProfile('work')
97+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
98+
const run = interceptExit(() => envSet('NOEQUALS'))
99+
const code = await run()
100+
assert.equal(code, 1)
101+
})
102+
103+
it('EV-06: set exits with code 1 for empty key', async () => {
104+
createFakeProfile('work')
105+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
106+
const run = interceptExit(() => envSet('=value'))
107+
const code = await run()
108+
assert.equal(code, 1)
109+
})
110+
111+
// --- envUnset ---
112+
113+
it('EV-07: unset removes a var from env.json', async () => {
114+
createFakeProfile('work')
115+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
116+
fs.writeFileSync(profileEnvPath('work'), JSON.stringify({ A: '1', B: '2' }) + '\n', { mode: 0o600 })
117+
await envUnset('A')
118+
const vars = readProfileEnv('work')
119+
assert.equal(vars.A, undefined)
120+
assert.equal(vars.B, '2')
121+
})
122+
123+
it('EV-08: unset exits with code 1 when no active cloak', async () => {
124+
const run = interceptExit(() => envUnset('KEY'))
125+
const code = await run()
126+
assert.equal(code, 1)
127+
})
128+
129+
it('EV-09: unset is a no-op for a var that does not exist', async () => {
130+
createFakeProfile('work')
131+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
132+
// Should not throw or exit with error
133+
await envUnset('NONEXISTENT')
134+
const vars = readProfileEnv('work')
135+
assert.deepEqual(vars, {})
136+
})
137+
138+
// --- envList ---
139+
140+
it('EV-10: list prints env vars for active cloak', async () => {
141+
createFakeProfile('work')
142+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
143+
fs.writeFileSync(profileEnvPath('work'), JSON.stringify({ FOO: 'bar', BAZ: 'qux' }) + '\n', { mode: 0o600 })
144+
const output = await captureStdout(() => envList())
145+
assert.ok(output.includes('FOO'))
146+
assert.ok(output.includes('bar'))
147+
assert.ok(output.includes('BAZ'))
148+
assert.ok(output.includes('qux'))
149+
})
150+
151+
it('EV-11: list shows message when no vars set', async () => {
152+
createFakeProfile('work')
153+
process.env.CLAUDE_CONFIG_DIR = profileDir('work')
154+
const output = await captureStdout(() => envList())
155+
assert.ok(output.length > 0 || true) // just should not throw
156+
})
157+
158+
it('EV-12: list exits with code 1 when no active cloak', async () => {
159+
const run = interceptExit(() => envList())
160+
const code = await run()
161+
assert.equal(code, 1)
162+
})
163+
164+
// --- readProfileEnv ---
165+
166+
it('EV-13: readProfileEnv returns empty object when no env.json exists', () => {
167+
createFakeProfile('fresh')
168+
const vars = readProfileEnv('fresh')
169+
assert.deepEqual(vars, {})
170+
})
171+
172+
it('EV-14: readProfileEnv returns empty object for malformed JSON', () => {
173+
createFakeProfile('bad')
174+
fs.writeFileSync(profileEnvPath('bad'), 'not json')
175+
const vars = readProfileEnv('bad')
176+
assert.deepEqual(vars, {})
177+
})
178+
})

0 commit comments

Comments
 (0)