Claude Code를 쓰면 쓸수록 한 가지 불편함이 쌓였다. 스킬, MCP 등 설정 관리가 점점 어려워지고 있다는 것. 단순하게 모든 개발 환경에 공통적으로 적용되면 좋을 스킬, MCP 설정을 user 레벨에 넣으면 된다고 생각하고있었지만, 점차 회사의 FE개발자로서, 사이드프로젝트의 풀스택 개발자로서 설정들이 혼재되어가고 있었다. 레포지토리별로 프로젝트 설정은 매번 해줘야한다는 불편함이 있었고, 마켓플레이스와 플러그인 기반은 사용여부에 따라 설정을 해줘야한다는 점이 불편했다. 그리고 실험적인 설정을 해보고 지우고 하는 과정도 번거로웠고..

불편함을 넘어, Agent 를 통한 개발을 진행함에 있어 개발자로서 페르소나도 점점 많아지고 있다고 느낀다. 내가 할 수 있는 역할이 다양해지면서 페르소나도 다양해지는건 당연하다고 생각하고, 나는 이 페르소나들을 명확히 분리하고 격리할 수 있는 시스템이 필요하다고 생각했다.

nvm이나 pyenv처럼 Claude Code 환경도 프로필로 전환하면 안 될까?

라는 생각이 들었고, clenv가 됐다.


핵심 아이디어: 심볼릭 링크

구현은 생각보다 단순하다. ~/.claude를 실제 디렉토리 대신 심볼릭 링크로 만들고, 링크가 가리키는 대상을 바꾸는 것이다.

~/.clenv/profiles/
  ├── default/    ← 실제 .claude 디렉토리
  ├── work/
  └── agent-dev/

~/.claude → ~/.clenv/profiles/work  (심볼릭 링크)

clenv profile use personal을 실행하면 링크 대상만 바뀐다. Claude Code는 여전히 ~/.claude를 읽지만, 실제로는 전혀 다른 디렉토리다.

여기에 각 프로필 디렉토리를 git 저장소로 초기화했다. 덕분에 설정 변경 히스토리를 추적하고, 특정 버전으로 되돌리고, .clenvprofile로 내보내고 가져올 수 있게 됐다.


brew

clenv는 Claude Code 안에서 쓰는 도구로 생각하지 않았다. Claude Code의 환경 자체를 관리하는 도구이기에 Claude Code를 열기 전에, 혹은 Claude Code와 완전히 독립적으로 동작해야 한다.

그래서 목표는 명확했다. brew install clenv 하나로 끝나는 독립 CLI.

brew install로 배포하려면 정적 바이너리여야 한다. Homebrew는 Formula에 명시된 바이너리를 그대로 가져다 쓴다. 런타임이나 인터프리터를 별도로 설치하게 만들면 사용자 경험이 망가진다. Node.js CLI처럼 node가 깔려 있어야 한다거나, Python 스크립트처럼 버전 충돌이 날 여지를 처음부터 없애고 싶었다.

Rust가 이 조건에 가장 잘 맞았다. 정적 바이너리를 만들기 쉽고, git2 크레이트 덕분에 사용자 환경의 git에 의존하지 않아도 됐다. (사실 난 Rust 하나도 모른다..;;)

주요 의존성은 이렇다:

크레이트역할
clapCLI 인자 파싱 (derive 피처로 구조체에서 자동 생성)
git2프로필별 git 저장소 조작
tokio비동기 런타임
tar + flate2프로필 export/import 압축
dialoguer대화형 프롬프트 (삭제 확인 등)
tabled프로필 목록 테이블 출력

릴리즈 빌드 설정도 최대한 작게 맞췄다

Homebrew Tap 설정

Homebrew에는 두 가지 배포 방식이 있다. 공식 homebrew-core에 포함되는 것과, 별도 tap 레포를 운영하는 것이다. 공식 포함은 일정 규모와 기준이 필요해서, 개인 도구는 tap으로 시작하는 게 현실적이라고 알고있고, 나도 그렇게 운영해보고자 했다.

tap 레포 이름은 반드시 homebrew-{이름} 형식이어야 한다. Imchaemin/homebrew-clenv 레포를 만들고, 그 안에 Formula/clenv.rb를 두면 된다.

사용자는 이렇게 설치한다:

brew tap Imchaemin/clenv
brew install clenv

brew tap Imchaemin/clenv는 실제로 github.com/Imchaemin/homebrew-clenv를 클론하는 것과 같다.


Formula 파일

Formula는 Ruby로 작성한다. macOS ARM/Intel과 Linux 세 가지 바이너리를 분기하는 구조다. sha256는 반드시 정확해야 한다. Homebrew가 다운로드한 파일의 해시를 검증하기 때문이다. 한 글자라도 틀리면 설치가 실패한다.

처음엔 직접 계산해서 넣었는데, 버전이 바뀔 때마다 수동으로 업데이트하는 게 번거로웠다. 그래서 자동화했다.


GitHub Actions로 릴리즈 자동화

v* 태그를 푸시하면 전체 파이프라인이 돌아간다.

git tag v0.1.3
git push origin v0.1.3

이것만 하면 끝이다. 나머지는 GitHub Actions가 처리한다.

1단계: 크로스 빌드

세 개의 타겟을 병렬로 빌드한다:

matrix:
  include:
    - target: x86_64-unknown-linux-gnu
      os: ubuntu-latest
    - target: x86_64-apple-darwin
      os: macos-latest
    - target: aarch64-apple-darwin
      os: macos-latest

각 빌드가 끝나면 .tar.gz로 묶고 SHA256을 계산해서 아티팩트로 올린다

2단계: GitHub Release 생성

세 빌드가 끝나면 아티팩트를 모아 GitHub Release에 올린다. checksums.txt도 함께 업로드한다.

3단계: Formula 자동 업데이트

가장 핵심 부분이다. homebrew-clenv 레포를 체크아웃하고, 새 버전과 SHA256으로 Formula를 재작성한 뒤 커밋·푸시한다


삽질 기록

1. ~/.claude 복구 문제

심볼릭 링크 방식의 가장 큰 위험은 clenv를 제거하거나 비활성화할 때다. ~/.claude가 심볼릭 링크인 상태에서 clenv를 지우면, 링크가 가리키던 프로필 디렉토리도 같이 지워질 수 있다. 원래 ~/.claude를 복원해야 하는데, 초기에는 이 복구가 제대로 되지 않는 케이스가 계속 있었다.

심볼릭 링크를 지우고 프로필 디렉토리 내용을 복사하면 된다고 생각했는데, 실제로는 엣지 케이스가 많았다. 링크가 이미 깨져 있는 경우, 초기화 전에 deactivate를 시도하는 경우 등.

결국 clenv init 시점에 원본을 별도로 백업해두는 시스템을 구축했다.

~/.clenv/backup/
  ├── original/       ← clenv init 전 ~/.claude 내용 복사본
  └── manifest.json   ← 백업 메타데이터 (시점, 원본 경로 등)

clenv uninstall이나 clenv profile deactivate 실행 시 심볼릭 링크를 제거하고, 이 백업본을 ~/.claude에 복원한다. 백업이 없으면 빈 디렉토리를 만들어 최소한 Claude Code가 뜰 수 있게 한다.

2. ~/.claude.json도 프로필에 따라 달라져야 한다

~/.claude만 교체하면 끝날 줄 알았는데, Claude Code는 MCP 서버 설정 일부를 ~/.claude.json에 별도로 저장한다. ~/.claude 심볼릭 링크를 바꿔도 ~/.claude.json은 그대로라서, user 레벨 MCP 서버 설정이 프로필에 따라 바뀌지 않는 문제가 있었다.

이걸 해결하기 위해 프로필마다 user-level MCP 설정을 별도로 저장하도록 했다.

~/.clenv/profiles/work/user-mcp.json   ← work 프로필의 MCP 서버 설정
~/.clenv/profiles/personal/user-mcp.json

프로필 전환 시 해당 프로필의 user-mcp.json 내용을 ~/.claude.json의 MCP 섹션에 반영한다. 반대로 현재 ~/.claude.json 상태를 프로필에 저장하는 것도 함께 구현했다.

3. 디렉토리별 프로필 오버라이드 — .clenvrc

심볼릭 링크 전환은 전역이다. clenv profile use work를 하면 모든 터미널, 모든 디렉토리에서 work 프로필이 적용된다. 그런데 레포지토리마다, Claude Code를 여는 디렉토리마다 설정이 달랐으면 좋겠다는 생각이 들었다.

.nvmrcnode 버전을 디렉토리별로 고정하듯, .clenvrc로 프로필을 고정하도록 했다.

우선순위는 이렇다:

1. CLENV_PROFILE 환경변수
2. 현재 디렉토리부터 홈 디렉토리까지 .clenvrc 탐색 (상위로 올라가며)
3. ~/.clenvrc (글로벌 홈 디렉토리)
4. ~/.clenv/config.toml의 active_profile (전역 기본값)

.clenvrc는 파일 내용이 프로필 이름 한 줄이다. # 으로 시작하는 주석과 빈 줄은 무시한다.

# 현재 디렉토리에 work 프로필 고정
clenv rc set work
 
# 어떤 프로필이 활성화됐고 어디서 결정됐는지 확인
clenv rc show
# → profile: work (from /Users/chad/Develops/my-project/.clenvrc)

구현에서 한 가지 신경 쓴 부분은 탐색 범위다. 루트(/)까지 올라가면 의도치 않은 .clenvrc를 읽을 수 있어서, 홈 디렉토리까지만 탐색하도록 제한했다.

// 홈 디렉토리에 도달하면 탐색 종료
if current == home {
    break;
}

4. E2E 테스트는 실제 환경에 가깝게 짜야 한다

격리된 빈 TempDir에서 테스트를 짰는데, 실제 환경과 차이가 꽤 있었다.

fn setup_test_env(temp: &TempDir) -> HashMap<String, String> {
    let mut env = HashMap::new();
    env.insert("CLENV_HOME".to_string(), temp.path().join(".clenv").to_str()...);
    env.insert("CLAUDE_HOME".to_string(), temp.path().join(".claude").to_str()...);
    env
}

CLENV_HOME, CLAUDE_HOME 환경변수로 경로를 격리하는 방식 자체는 좋았다. 문제는 실제 ~/.claude가 이미 심볼릭 링크인 상태, 기존 ~/.claude.json에 MCP 설정이 있는 상태, 중간에 Claude Code가 디렉토리를 건드리는 상태 같은 것들을 테스트에서 재현하지 못했다는 거다.

빈 TempDir에서는 잘 통과하는데 실 사용에서 터지는 케이스가 있었다. clenv init을 이미 한 상태에서 --reinit을 쓰는 경우, 백업이 이미 있는 상태에서 재초기화하는 경우 등.

나중에는 “실제 홈 디렉토리 구조와 비슷한 픽스처”를 만들어서 테스트하는 방식으로 보완했다. 완벽하진 않지만, 빈 디렉토리보다는 훨씬 많은 케이스를 잡아냈다.


마무리

완성된 워크플로는 이렇다:

git tag v0.x.x && git push origin v0.x.x
         ↓
  GitHub Actions
  ├── x86_64-linux 빌드
  ├── x86_64-darwin 빌드
  └── aarch64-darwin 빌드
         ↓
  GitHub Release 생성 + checksums.txt
         ↓
  homebrew-clenv Formula 자동 업데이트
         ↓
brew install clenv  # 사용자는 이것만 하면 됨

Claude Code를 여러 컨텍스트에서 쓰고 있다면 써보길. 프로필 전환이 얼마나 편한지 금방 체감된다.

brew tap Imchaemin/clenv
brew install clenv
clenv init
clenv profile create work --use

소스는 github.com/Imchaemin/clenv에 있다.