Claude Code 설정을 여러 기기로 옮기기 — 통째 복사 대신 4분류

AI CLI 하네스(룰·훅·플러그인·시크릿)를 여러 기기에서 재현할 때, config·인증·캐시·시크릿을 어떻게 분리해야 조용히 안 깨지는지.

리눅스 데스크톱에서 Claude Code 하네스를 꽤 두껍게 만들어 뒀다. CLAUDE.md 룰, 훅, 스킬, 스테이터스라인, 플러그인, MCP 서버 선언까지. 이걸 맥북에서도 똑같이 쓰고 싶어서 ~/.claude 를 통째로 복사했다. 결과는 훅이 조용히 죽고, 스킬은 안 뜨고, MCP 는 인증이 꼬였다. 그때부터 “무엇을 옮기고 무엇을 옮기면 안 되는지”를 분류하는 게 이 작업의 전부라는 걸 알았다.

배경 — ~/.claude 는 세 가지가 섞여 있다

설정 디렉터리 하나 안에 성질이 완전히 다른 것들이 공존한다.

  • 기기와 무관한 자산: CLAUDE.md, 룰, 훅, 스크립트, 스킬, settings.json
  • 기기마다 새로 잡혀야 하는 것: 인증 토큰, 세션 상태, 캐시, 다운로드된 플러그인 바이너리
  • 절대 평문으로 굴리면 안 되는 것: MCP API 키 같은 시크릿

통째 복사가 깨지는 이유가 여기 있다. 첫 번째는 그대로 옮겨야 맞지만, 두 번째를 같이 옮기면 다른 기기의 자격증명·머신 경로가 따라와서 깨지고, 세 번째는 git 에 올리는 순간 사고다.

증상·재현조건 — 통째 복사 뒤에 벌어지는 일

# 안티패턴
rsync -a ~/.claude/ new-machine:~/.claude/

이렇게 옮기면 겉보기엔 성공하지만 새 기기에서:

  • 훅이 실행되다 말고 조용히 죽는다 (에러도 안 뜬다).
  • 스킬 목록이 비거나 일부만 뜬다.
  • MCP 서버가 “인증됨”으로 보이는데 호출하면 거부된다.
  • 스테이터스라인이 안 그려진다.

공통점은 에러가 눈에 안 띈다는 것. 훅과 스테이터스라인은 실패해도 본 세션을 막지 않도록 설계돼 있어서, 깨진 걸 한참 뒤에야 안다.

실패했던 접근 — 시크릿을 1Password CLI 로 무인화

시크릿은 별도 금고에 두고 CLI 로 주입하려 했다. 개인용 요금제에서는 서비스 계정 토큰이 없어서 CLI 무인 인증이 안 된다는 게 문제였다.

eval "$(op signin)"   # 세션 토큰이 env 로만 산다

AI 에이전트가 자율로 시크릿을 읽게 하려면 이게 치명적이다. 에이전트의 셸 호출은 매번 새 shell 이라 signin 으로 얻은 세션 토큰이 호출 간 남지 않는다. 매번 인터랙티브 로그인이 끼어들거나 세션이 만료된다. 장수 머신 토큰이 유일한 해법인데 그건 비즈니스 등급 전용이었다.

근본 원인 — “선언”과 “상태”는 다른 물건이다

플러그인·MCP·스킬이 기기 간에 “공유”되는 원리를 뜯어보면 답이 나온다. 공유되는 건 바이너리가 아니라 선언이다.

// settings.json — 이건 옮겨도 되는 선언
{
  "enabledPlugins": { "some-plugin@marketplace": true },
  "extraKnownMarketplaces": { "marketplace": { "source": { /* ... */ } } }
}

각 기기는 이 선언을 보고 마켓플레이스에서 자동으로 다시 설치한다. 캐시된 바이너리를 옮길 필요가 없다. 반대로 인증·세션·캐시는 그 기기에서 새로 만들어져야 정상이다. 그래서 분류가 이렇게 된다.

분류 처리
A. 기기 무관 자산 CLAUDE.md·룰·훅·스크립트·스킬·settings.json git 커밋
B. 플러그인·MCP enabledPlugins / marketplace 선언 선언만 커밋 → 자동 재설치
C. 인증·상태·캐시 credentials·세션 json·projects·history .gitignore, 커밋 금지
S. 시크릿 MCP API 키 암호화해서만

실제 해결 — sops + age 로 무인 복호

시크릿은 로컬 age 키파일로 복호하는 sops 로 갈았다. 인터랙티브 인증도 세션 만료도 아예 없어서 에이전트가 무인으로 읽고 쓸 수 있다. 암호화본 자체는 private repo 에 커밋되므로 git 동기화에 그대로 얹힌다.

# 최초 1회: age 키 생성 (공개키는 stderr 로 age1... 출력)
age-keygen -o "$HOME/.config/sops/age/keys.txt"
# .sops.yaml 의 creation_rules.age 에 그 공개키 등록

# 시크릿 편집 — 열면 자동 복호, 저장 시 자동 재암호화
sops secrets/secrets.enc.yaml

# 기기마다 복원: 복호 → 설정 json 에 병합 (평문을 디스크에 안 남긴다)
sops -d --output-type json secrets/secrets.enc.yaml | jq '/* merge */'

원칙 몇 가지가 이 구조를 지탱한다.

  • age 개인키가 유일한 마스터. git 금지(.gitignore), 기기당 1회 수동 복사. 분실 대비 콜드백업은 별도 금고에 한 번만.
  • 정적 API 키만 암호화 대상. OAuth 로 도는 MCP 는 저장하지 않고 기기마다 /mcp 에서 재인증한다. OAuth 토큰은 애초에 이식 대상이 아니다.
  • 기기/키 추가 시 .sops.yaml 에 공개키를 넣고 sops updatekeys 로 재봉인.

검증 방법 — 커밋 전 스캔과 배포 후 확인

repo 를 만들 때 .gitignore 는 최후의 안전망으로만 둔다. 실질 방어는 커밋 전 시크릿 스캔이다.

# 흔한 토큰 형태를 통으로 훑는다
grep -rEi 'Bearer [A-Za-z0-9._-]{8,}|[A-Fa-f0-9]{32,}' .

# 무인 복호가 되는지 (인증 절차 0 이어야 정상)
sops -d secrets/secrets.enc.yaml >/dev/null && echo ok

# 배포 후: 설정에 머신 경로가 새지 않았는지
grep -rn '/home/\|/Users/' ~/.claude/settings.json ~/.claude/statusline.sh \
  || echo "머신 경로 없음"

# 훅이 가리키는 경로가 전부 실재하는지 전수 확인
jq -r '.hooks|to_entries[]|.value[]|.hooks[]|.command' ~/.claude/settings.json

설정과 훅은 새 세션부터 로드된다. 배포 뒤에는 CLI 를 반드시 재기동한다.

타환경 주의점 — 조용히 실패하는 지점들

node 절대경로 함정. 훅과 스테이터스라인이 특정 node 경로를 박아 두면 위험하다. fnm/nvm 의 which node 는 셸 수명짜리 임시 심링크를 준다.

/run/user/1000/fnm_multishells/<pid>_<ts>/bin/node

이걸 그대로 박으면 그 셸이 죽는 순간 훅이 조용히 죽는다. 안정 경로로 한 번 고정해 두는 게 답이다.

mkdir -p ~/.local/bin
ln -sf "$(readlink -f "$(which node)")" ~/.local/bin/node   # readlink -f 가 핵심

settings.json 을 통으로 덮으면 live 전용 플러그인 등록이 날아간다. GUI 로 설치한 플러그인은 live 의 enabledPlugins / extraKnownMarketplaces 에만 있고 repo 는 모른다. 그대로 cp 하면 그 플러그인이 빠진다. 이 두 키만 live 값으로 병합해서 배포한다.

jq -s --argjson keys '["enabledPlugins","extraKnownMarketplaces"]' \
  '.[0] as $repo | .[1] as $live
   | reduce $keys[] as $k ($repo;
       if ($live[$k] // null) == null then .
       else .[$k] = ($live[$k]
         | if type=="object" then to_entries|sort_by(.key)|from_entries else . end)
       end)' \
  settings.json ~/.claude/settings.json > /tmp/settings.merged.json

sort_by(.key) 가 있어야 기기별 설치 순서 차이로 내용이 같은데도 매번 diff 가 나는 flip-flop 커밋을 막는다.

플랫폼별 경로/명령 차이. OS 알림 훅처럼 플랫폼 전용 명령(리눅스 ↔ macOS)은 기기마다 갈아 끼워야 한다. 특히 macOS sed 는 in-place 편집에 빈 인자를 요구한다.

sed -i ''  's/old/new/' file    # macOS (BSD sed)
sed -i     's/old/new/' file    # Linux (GNU sed)

스킬은 진입 파일이 있어야 로드된다. 스킬 폴더에 진입 매니페스트(예: SKILL.md)가 없으면 로드되지 않고 그냥 스캐폴드 폴더로 남는다. 안 뜨는 스킬이 있으면 파일을 빠뜨린 게 아니라 진입점이 없는 것부터 의심한다.