Skip to content

Commit 7cd4e13

Browse files
goulartfsclaude
andcommitted
feat: auto-switch by directory with cloak bind/unbind (121 tests)
New commands: - cloak bind <name> — creates .cloak file in current directory - cloak unbind — removes .cloak file Shell function reads .cloak file before launching claude. If present, auto-switches to the indicated profile. Follows .nvmrc/.node-version convention. Example: cd ~/projects/company && cloak bind work cd ~/projects/personal && cloak bind home cd ~/projects/company && claude # auto-uses work profile New tests: BI-01 to BI-06, I-13, I-14 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa40bfb commit 7cd4e13

File tree

7 files changed

+194
-0
lines changed

7 files changed

+194
-0
lines changed

src/cli.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { listAccounts } from './commands/list.js'
1313
import { deleteAccount } from './commands/delete.js'
1414
import { whoami } from './commands/whoami.js'
1515
import { renameAccount } from './commands/rename.js'
16+
import { bindAccount } from './commands/bind.js'
17+
import { unbindAccount } from './commands/unbind.js'
1618
import { initShell } from './commands/init.js'
1719

1820
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -94,6 +96,22 @@ program
9496
return renameAccount(oldName, newName)
9597
})
9698

99+
program
100+
.command('bind <name>')
101+
.description('Bind this directory to a cloak')
102+
.action((name) => {
103+
renderContextBar('bind')
104+
return bindAccount(name)
105+
})
106+
107+
program
108+
.command('unbind')
109+
.description('Unbind this directory')
110+
.action(() => {
111+
renderContextBar('unbind')
112+
return unbindAccount()
113+
})
114+
97115
program
98116
.command('context-bar', { hidden: true })
99117
.argument('<command>')

src/commands/bind.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { writeFileSync } from 'fs'
2+
import { join } from 'path'
3+
import { profileExists } from '../lib/paths.js'
4+
import { validateAccountName } from '../lib/validate.js'
5+
import * as msg from '../lib/messages.js'
6+
7+
export async function bindAccount(name, dir = process.cwd()) {
8+
const validation = validateAccountName(name)
9+
if (!validation.valid) {
10+
console.error(msg.validationError(validation.error))
11+
process.exit(1)
12+
return
13+
}
14+
15+
if (!profileExists(name)) {
16+
console.error(msg.accountNotFound(name))
17+
console.error(msg.suggestCreate(name))
18+
process.exit(1)
19+
return
20+
}
21+
22+
writeFileSync(join(dir, '.cloak'), name + '\n')
23+
console.log(msg.cloakBound(name))
24+
}

src/commands/init.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export function getInitScript() {
4343
' command claude "$@"',
4444
' fi',
4545
' else',
46+
' if [ -f ".cloak" ]; then',
47+
' local _bind_name',
48+
' _bind_name=$(cat .cloak 2>/dev/null | tr -d "[:space:]")',
49+
' if [ -n "$_bind_name" ]; then',
50+
' local _bind_output',
51+
' _bind_output=$(command cloak switch --print-env "$_bind_name" 2>/dev/null)',
52+
' if [ $? -eq 0 ]; then',
53+
' eval "$_bind_output"',
54+
' fi',
55+
' fi',
56+
' fi',
4657
' if [ -n "$CLAUDE_CONFIG_DIR" ]; then',
4758
' command cloak context-bar claude',
4859
' fi',

src/commands/unbind.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { unlinkSync, existsSync } from 'fs'
2+
import { join } from 'path'
3+
import * as msg from '../lib/messages.js'
4+
5+
export async function unbindAccount(dir = process.cwd()) {
6+
const cloakFile = join(dir, '.cloak')
7+
8+
if (!existsSync(cloakFile)) {
9+
console.error(msg.noCloakFile())
10+
process.exit(1)
11+
return
12+
}
13+
14+
unlinkSync(cloakFile)
15+
console.log(msg.cloakUnbound())
16+
}

src/lib/messages.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ export function shellIntegrationTip(rcFile) {
128128
chalk.dim(` echo 'eval "$(cloak init)"' >> ${file} && source ${file}\n\n`)
129129
}
130130

131+
// --- Bind/unbind ---
132+
133+
export function cloakBound(name) {
134+
return `${icon.success} Bound this directory to cloak ${chalk.bold(`"${name}"`)}.`
135+
}
136+
137+
export function cloakUnbound() {
138+
return `${icon.success} Unbound this directory.`
139+
}
140+
141+
export function noCloakFile() {
142+
return `${icon.error} No .cloak file in this directory.`
143+
}
144+
131145
// --- Active cloak indicator (shown on claude launch) ---
132146

133147
export function wearingCloak(name) {

tests/bind.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 } = await import('../src/lib/paths.js')
12+
const { bindAccount } = await import('../src/commands/bind.js')
13+
const { unbindAccount } = await import('../src/commands/unbind.js')
14+
15+
const WORK_DIR = path.join(TMP, 'project')
16+
17+
function createFakeProfile(name) {
18+
const dir = profileDir(name)
19+
fs.mkdirSync(dir, { recursive: true })
20+
fs.writeFileSync(path.join(dir, '.claude.json'), '{}')
21+
}
22+
23+
function cleanup() {
24+
if (fs.existsSync(PROFILES_DIR)) fs.rmSync(PROFILES_DIR, { recursive: true, force: true })
25+
const cloakFile = path.join(WORK_DIR, '.cloak')
26+
if (fs.existsSync(cloakFile)) fs.unlinkSync(cloakFile)
27+
}
28+
29+
function interceptExit(fn) {
30+
let exitCode = null
31+
const original = process.exit
32+
process.exit = (code) => { exitCode = code }
33+
return async () => {
34+
try { await fn() } finally { process.exit = original }
35+
return exitCode
36+
}
37+
}
38+
39+
describe('bind', () => {
40+
beforeEach(() => {
41+
cleanup()
42+
if (!fs.existsSync(WORK_DIR)) fs.mkdirSync(WORK_DIR, { recursive: true })
43+
})
44+
45+
after(() => {
46+
fs.rmSync(TMP, { recursive: true, force: true })
47+
})
48+
49+
it('BI-01: bind creates .cloak file with profile name', async () => {
50+
createFakeProfile('work')
51+
await bindAccount('work', WORK_DIR)
52+
const content = fs.readFileSync(path.join(WORK_DIR, '.cloak'), 'utf8')
53+
assert.equal(content.trim(), 'work')
54+
})
55+
56+
it('BI-02: bind fails for non-existent profile', async () => {
57+
const run = interceptExit(() => bindAccount('ghost', WORK_DIR))
58+
const code = await run()
59+
assert.equal(code, 1)
60+
assert.ok(!fs.existsSync(path.join(WORK_DIR, '.cloak')))
61+
})
62+
63+
it('BI-03: bind fails for invalid name', async () => {
64+
const run = interceptExit(() => bindAccount('../bad', WORK_DIR))
65+
const code = await run()
66+
assert.equal(code, 1)
67+
assert.ok(!fs.existsSync(path.join(WORK_DIR, '.cloak')))
68+
})
69+
70+
it('BI-04: unbind removes .cloak file', async () => {
71+
fs.writeFileSync(path.join(WORK_DIR, '.cloak'), 'work')
72+
await unbindAccount(WORK_DIR)
73+
assert.ok(!fs.existsSync(path.join(WORK_DIR, '.cloak')))
74+
})
75+
76+
it('BI-05: unbind fails when no .cloak file', async () => {
77+
const run = interceptExit(() => unbindAccount(WORK_DIR))
78+
const code = await run()
79+
assert.equal(code, 1)
80+
})
81+
82+
it('BI-06: .cloak file contains only the profile name', async () => {
83+
createFakeProfile('my-project')
84+
await bindAccount('my-project', WORK_DIR)
85+
const content = fs.readFileSync(path.join(WORK_DIR, '.cloak'), 'utf8')
86+
assert.equal(content, 'my-project\n')
87+
})
88+
})

tests/init.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,27 @@ describe('init', () => {
123123
assert.ok(ctxIdx > -1, '-a branch contains context-bar')
124124
assert.ok(claudeIdx > ctxIdx, 'command claude comes after context-bar in -a branch')
125125
})
126+
127+
it('I-13: passthrough reads .cloak file', () => {
128+
const output = getInitScript()
129+
const func = extractFunction(output, 'claude')
130+
const lines = func.split('\n')
131+
132+
const elseIdx = lines.findLastIndex(l => l.trim() === 'else')
133+
const afterElse = lines.slice(elseIdx)
134+
const cloakFileIdx = afterElse.findIndex(l => l.includes('.cloak'))
135+
assert.ok(cloakFileIdx > -1, 'else branch reads .cloak file')
136+
})
137+
138+
it('I-14: .cloak auto-switch happens before context-bar', () => {
139+
const output = getInitScript()
140+
const func = extractFunction(output, 'claude')
141+
const lines = func.split('\n')
142+
143+
const elseIdx = lines.findLastIndex(l => l.trim() === 'else')
144+
const afterElse = lines.slice(elseIdx)
145+
const cloakFileIdx = afterElse.findIndex(l => l.includes('.cloak'))
146+
const ctxIdx = afterElse.findIndex(l => l.includes('context-bar'))
147+
assert.ok(cloakFileIdx < ctxIdx, '.cloak read comes before context-bar')
148+
})
126149
})

0 commit comments

Comments
 (0)