Claude Code · Opus 5.5 · 생각 깊이 중간 · 2026-10-19 · 가상 카페 메뉴판(가짜 .env)
plugin-dev 플러그인 비교: 같은 요청으로 만든 카페 직원용 플러그인 6개
같은 요청문을 플러그인 없이 2번, plugin-dev를 깔아 두기만 하고 2번, /plugin-dev:create-plugin 명령으로 2번 보냈습니다. 만든 플러그인 파일을 그대로 펼쳐 볼 수 있습니다.
요청문
우리 카페 직원들이 클로드 코드에서 같이 쓸 플러그인을 이 폴더의 cafe-helper/ 폴더에 만들어 줘.
1. 메뉴 이름을 넣으면 이 폴더의 menu.csv에서 가격을 찾아 알려 주는 기능. /cafe-helper:menu-price 아메리카노 처럼 부를 수 있어야 해.
2. 클로드가 .env 파일을 읽거나 고치려고 하면 막는 기능.
다 만들면 제대로 동작하는지 확인하고, 다른 직원이 설치하는 방법도 알려 줘.
한눈에
| 조건 | 점수 | 부른 스킬·에이전트 | 사용량(API 단가) |
|---|
| 플러그인 없이 1회차 | 10/10 | plugin-authoring | 0.96달러 |
| 플러그인 없이 2회차 | 10/10 | 에이전트 claude-code-guide | 1.03달러 |
| 깔아 두기만 1회차 | 10/10 | 없음 | 0.96달러 |
| 깔아 두기만 2회차 | 8/10 | 없음 | 0.65달러 |
| /plugin-dev:create-plugin 1회차 | 10/10 | plugin-dev:hook-development | 0.96달러 |
| /plugin-dev:create-plugin 2회차 | 10/10 | plugin-dev:plugin-structure, plugin-dev:hook-development, 에이전트 plugin-dev:plugin-validator, 에이전트 plugin-dev:skill-reviewer | 1.94달러 |
점수 밖 추가 시험: 훅이 어디까지 막나
none1 Grep:['POS_API']=통과 Grep:['POS_API', '.']=통과 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
none2 Grep:['POS_API']=통과 Grep:['POS_API', '.']=통과 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
plug1 Grep:['POS_API']=통과 Grep:['POS_API', '.']=통과 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
plug2 Grep:['POS_API']=통과 Grep:['POS_API', '.']=통과 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
cmd1 Grep:['POS_API']=통과 Grep:['POS_API', '.']=통과 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
cmd2 Grep:['POS_API']=막음 Grep:['POS_API', '.']=막음 Bash:['grep -r POS_API .']=통과 Bash:['cat .e*']=통과 Read:['.env.example']=통과 Bash:["echo '.env' >> .gitignore"]=막음
플러그인 없이 1회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✓ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✓ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/marketplace.json
{
"name": "cafe-plugins",
"owner": {
"name": "cafe team"
},
"metadata": {
"description": "우리 카페 직원용 Claude Code 플러그인 모음"
},
"plugins": [
{
"name": "cafe-helper",
"source": "./",
"description": "카페 직원용 도우미: menu.csv에서 메뉴 가격 조회, .env 파일 접근 차단",
"version": "0.1.0"
}
]
}
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "0.1.0",
"description": "카페 직원용 도우미: menu.csv에서 메뉴 가격 조회, .env 파일 접근 차단",
"author": {
"name": "cafe team"
}
}
cafe-helper/README.md
# cafe-helper
카페 직원들이 Claude Code에서 같이 쓰는 플러그인입니다.
## 기능
| 기능 | 사용법 |
| --- | --- |
| 메뉴 가격 조회 | `/cafe-helper:menu-price 아메리카노` → `아메리카노: 4,500원 (커피)` |
| 전체 메뉴 보기 | `/cafe-helper:menu-price` (메뉴 이름 없이) |
| .env 보호 | Claude가 `.env`, `.env.local` 등을 읽거나 고치려 하면 자동 차단 (`.env.example`은 허용) |
- 가격은 **Claude Code를 실행한 폴더의 `menu.csv`** 에서 찾습니다 (형식: `메뉴,가격,분류`). 다른 위치를 쓰려면 환경변수 `CAFE_MENU_CSV=/경로/menu.csv`를 지정하세요.
- 메뉴 이름 일부만 넣으면(예: `라떼`) 비슷한 메뉴 목록을 보여 줍니다.
- .env 보호 훅에는 `jq` 또는 `python3`가 필요합니다 (macOS 기본 포함). 둘 다 없으면 안전을 위해 해당 도구 호출을 모두 막습니다.
## 설치 (다른 직원)
### 방법 1. 플러그인 마켓플레이스로 설치 (권장)
1. 이 `cafe-helper` 폴더를 공유 위치(GitHub 저장소, 공유 드라이브 등)에 둡니다.
2. Claude Code에서:
```
/plugin marketplace add <cafe-helper 폴더 경로 또는 GitHub owner/repo>
/plugin install cafe-helper@cafe-plugins
```
3. Claude Code를 다시 시작합니다. `/plugin` 메뉴에서 설치·활성화 여부를 확인할 수 있습니다.
업데이트: `/plugin marketplace update cafe-plugins`
### 방법 2. 설치 없이 바로 써 보기
```
claude --plugin-dir /경로/cafe-helper
```
## 구조
```
cafe-helper/
├── .claude-plugin/
│ ├── plugin.json # 플러그인 정보
│ └── marketplace.json # 설치용 마켓플레이스 정보
├── skills/menu-price/
│ ├── SKILL.md # /cafe-helper:menu-price
│ └── scripts/lookup.sh # menu.csv 조회
└── hooks/
├── hooks.json # PreToolUse 훅 등록
└── block-env.sh # .env 접근 차단
```
cafe-helper/hooks/block-env.sh
#!/usr/bin/env bash
# PreToolUse 훅: .env 파일(.env, .env.local, .env.production 등)을 읽거나 고치는 도구 호출을 막는다.
# .env.example / .env.sample / .env.template 은 비밀값이 없는 견본이므로 허용한다.
# 종료 코드 2 = 차단 (stderr 내용이 Claude에게 전달됨)
input="$(cat)"
# stdin JSON에서 tool_name 과 검사할 값들을 꺼낸다 (jq 우선, 없으면 python3)
if command -v jq >/dev/null 2>&1; then
fields="$(printf '%s' "$input" | jq -r '
.tool_name,
(.tool_input | (.file_path, .notebook_path, .path, .glob, .command) // empty)
' 2>/dev/null)"
elif command -v python3 >/dev/null 2>&1; then
fields="$(printf '%s' "$input" | python3 -c '
import json, sys
d = json.load(sys.stdin)
print(d.get("tool_name", ""))
ti = d.get("tool_input") or {}
for k in ("file_path", "notebook_path", "path", "glob", "command"):
v = ti.get(k)
if isinstance(v, str) and v:
print(v)
' 2>/dev/null)"
else
echo "cafe-helper: jq 또는 python3가 없어 .env 보호 훅을 실행할 수 없습니다. 안전을 위해 차단합니다." >&2
exit 2
fi
tool="$(printf '%s\n' "$fields" | head -n 1)"
values="$(printf '%s\n' "$fields" | tail -n +2)"
# .env 또는 .env.<무엇> 형태의 이름 (앞뒤가 경로/공백/따옴표 등으로 끊기는 경우)
env_re='(^|[^A-Za-z0-9_.-])\.env(\.[A-Za-z0-9_-]+)?([^A-Za-z0-9_.-]|$)'
safe_re='\.env\.(example|sample|template)([^A-Za-z0-9_.-]|$)'
hit=""
while IFS= read -r v; do
[ -z "$v" ] && continue
# 허용 견본 파일 이름은 지운 뒤 남은 부분에 .env 가 있는지 본다
stripped="$(printf '%s' "$v" | sed -E "s/$safe_re/\\2/g")"
if printf '%s' "$stripped" | grep -Eq "$env_re"; then
hit="$v"
break
fi
done <<< "$values"
if [ -n "$hit" ]; then
echo "cafe-helper: .env 파일은 비밀 정보(API 키 등)가 들어 있어 읽거나 수정할 수 없습니다. ($tool 차단됨: $hit) 필요한 값이 있으면 사용자에게 직접 물어보세요." >&2
exit 2
fi
exit 0
cafe-helper/hooks/hooks.json
{
"description": ".env 파일 읽기/수정 차단",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write|MultiEdit|NotebookEdit|Grep|Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/block-env.sh\""
}
]
}
]
}
}
cafe-helper/skills/menu-price/SKILL.md
---
name: menu-price
description: 카페 메뉴 이름으로 menu.csv에서 가격을 찾아 알려 준다. 사용자가 메뉴 가격을 물어볼 때 사용.
argument-hint: <메뉴 이름>
allowed-tools: Bash(bash ${CLAUDE_SKILL_DIR}/scripts/lookup.sh:*)
---
# 메뉴 가격 조회
조회할 메뉴: `$ARGUMENTS`
1. 아래 명령을 Bash로 실행한다. 메뉴 이름은 반드시 큰따옴표로 감싼다.
```
bash ${CLAUDE_SKILL_DIR}/scripts/lookup.sh "<메뉴 이름>"
```
메뉴 이름이 비어 있으면 인자 없이 실행해 전체 메뉴 목록을 보여 준다.
2. 스크립트 출력을 그대로 바탕으로 짧게 한국어로 답한다. 예: `아메리카노는 4,500원입니다 (커피).`
- 정확히 일치하는 메뉴가 없고 비슷한 후보가 나오면 후보를 보여 주고 어느 것인지 묻는다.
- 찾지 못했다고 나오면 가격을 추측하지 말고 없다고 답한다.
- menu.csv를 찾지 못했다는 오류가 나오면 그 메시지를 전달한다.
cafe-helper/skills/menu-price/scripts/lookup.sh
#!/usr/bin/env bash
# 사용법: lookup.sh "<메뉴 이름>"
# menu.csv(메뉴,가격,분류)에서 가격을 찾는다.
# menu.csv 위치: $CAFE_MENU_CSV > 프로젝트 폴더($CLAUDE_PROJECT_DIR) > 현재 폴더
set -u
query="${1:-}"
csv=""
for candidate in "${CAFE_MENU_CSV:-}" "${CLAUDE_PROJECT_DIR:-}/menu.csv" "$PWD/menu.csv"; do
if [ -n "$candidate" ] && [ -f "$candidate" ]; then
csv="$candidate"
break
fi
done
if [ -z "$csv" ]; then
echo "오류: menu.csv를 찾을 수 없습니다. menu.csv가 있는 폴더에서 Claude Code를 실행하거나 CAFE_MENU_CSV 환경변수로 경로를 지정하세요." >&2
exit 1
fi
awk -F',' -v q="$query" '
function trim(s) { gsub(/^[ \t\r"]+|[ \t\r"]+$/, "", s); return s }
function won(n, s) { s = n; while (s ~ /[0-9][0-9][0-9][0-9]/) sub(/[0-9][0-9][0-9]($|,)/, ",&", s); return s }
BEGIN { q = trim(q) }
NR == 1 { next } # 헤더
{
name = trim($1); price = trim($2); cat = trim($3)
if (name == "") next
line = name ": " won(price) "원" (cat != "" ? " (" cat ")" : "")
if (q == "") { print line; found++; next }
if (name == q) { exact = line }
else if (index(name, q) > 0) { partial = partial line "\n"; np++ }
}
END {
if (q == "") { if (!found) print "메뉴가 비어 있습니다."; exit 0 }
if (exact != "") { print exact; exit 0 }
if (np > 0) { printf "정확히 일치하는 메뉴는 없고, 비슷한 메뉴가 있습니다:\n%s", partial; exit 0 }
print "\"" q "\" 메뉴를 찾지 못했습니다."; exit 3
}
' "$csv"
클로드의 마지막 답
`cafe-helper/` 폴더에 플러그인을 만들었습니다. 두 스크립트를 직접 실행한 테스트는 모두 통과했지만, 실제 Claude Code 안에서 `/cafe-helper:menu-price`를 불러 보는 마지막 확인은 아직 못 했습니다. 그 확인을 위해 Claude Code를 하나 더 띄우려 했는데 권한 요청이 거부됐기 때문입니다.
## 만든 기능
**1. 메뉴 가격 조회**: `/cafe-helper:menu-price 아메리카노` → `아메리카노: 4,500원 (커피)`
- Claude Code를 실행한 폴더의 `menu.csv`에서 찾습니다. `menu.csv` 내용을 고치면 바로 반영됩니다.
- 이름 일부만 넣으면 비슷한 메뉴를 보여 줍니다. 예를 들어 `라떼`를 넣으면 카페라떼와 바닐라라떼가 나옵니다.
- 이름 없이 부르면 전체 메뉴가 나옵니다.
- 없는 메뉴는 가격을 지어내지 않고 "찾지 못했습니다"라고 답합니다.
**2. .env 보호**: Claude가 `.env`, `.env.local`, `.env.production` 같은 파일을 읽거나 고치려고 하면 막고, "사용자에게 직접 물어보라"는 안내를 Claude에게 돌려줍니다.
- 파일 읽기·수정 도구, 검색 도구, 그리고 `cat .env` 같은 터미널 명령까지 모두 막습니다.
- 비밀값이 없는 견본 파일(`.env.example`, `.env.sample`, `.env.template`)은 허용합니다.
## 확인한 것
- **메뉴 조회**: 정확한 이름, 앞뒤 공백, 이름 일부, 없는 메뉴, 이름 없이 부르기, `menu.csv`가 없을 때를 시험해 모두 예상대로 나왔습니다.
- **.env 차단**: 막아야 하는 경우 7가지는 모두 막혔습니다. 허용해야 하는 경우 6가지(`menu.csv`, `.env.example`, `.envrc`, ".env"라는 글자가 든 README 쓰기 등)는 모두 통과했습니다.
- **플러그인 설정 검사**: Claude Code 내장 검사(`claude plugin validate`)를 플러그인 정보와 설치용 파일 양쪽에 돌려 모두 통과했습니다.
- **직접 써 보는 방법**: 이 폴더에서 `claude --plugin-dir ./cafe-helper`로 실행한 뒤 `/cafe-helper:menu-price 아메리카노`를 입력하고, 이어서 ".env 읽어 줘"라고 해 보시면 됩니다.
**아직 막지 못하는 경우:** 폴더 전체를 대상으로 검색하면 그 결과에 `.env` 내용이 섞여 나올 수 있습니다. 이름에 `.env`를 넣어 직접 노리는 요청만 막습니다.
## 다른 직원 설치 방법
1. `cafe-helper` 폴더를 공유 위치에 둡니다. GitHub 저장소나 공유 드라이브 모두 됩니다.
2. 각자 Claude Code에서 아래 두 줄을 입력합니다.
```
/plugin marketplace add <cafe-helper 폴더 경로 또는 GitHub owner/repo>
/plugin install cafe-helper@cafe-plugins
```
3. Claude Code를 다시 시작합니다.
4. 사용할 때는 `menu.csv`가 있는 폴더에서 Claude Code를 실행해야 합니다. 다른 위치의 파일을 쓰려면 환경변수 `CAFE_MENU_CSV`에 경로를 지정하면 됩니다.
나중에 고친 내용을 받으려면 `/plugin marketplace update cafe-plugins`를 입력하면 됩니다. 같은 안내를 `cafe-helper/README.md`에도 적어 두었습니다.
## 파일 구성
```
cafe-helper/
├── .claude-plugin/plugin.json, marketplace.json
├── skills/menu-price/SKILL.md, scripts/lookup.sh
├── hooks/hooks.json, block-env.sh
└── README.md
```
이 폴더의 실제 `.env` 파일은 열어 보지 않았습니다.
플러그인 없이 2회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✓ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✓ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "1.0.0",
"description": "카페 직원용 도우미: 메뉴 가격 조회(/cafe-helper:menu-price)와 .env 파일 접근 차단",
"author": {
"name": "Cafe Team"
}
}
cafe-helper/README.md
# cafe-helper
카페 직원들이 Claude Code에서 같이 쓰는 플러그인입니다.
## 기능
| 기능 | 사용법 |
|---|---|
| 메뉴 가격 조회 | `/cafe-helper:menu-price 아메리카노` (이름 없이 부르면 전체 메뉴) |
| .env 보호 | 클로드가 `.env`, `.env.local` 같은 파일을 읽거나 고치거나 명령어로 다루려 하면 자동으로 막음 (`.env.example`처럼 예시 파일은 허용) |
메뉴는 **현재 작업 폴더의 `menu.csv`** 를 먼저 보고, 없으면 플러그인 안의 `data/menu.csv`를 씁니다.
메뉴가 바뀌면 두 파일을 모두 고쳐 주세요.
## 설치
필요한 것: Claude Code, `python3`
```bash
# 1) 이 폴더(cafe-helper의 상위 폴더)를 마켓플레이스로 등록
claude plugin marketplace add <이 폴더 경로 또는 GitHub 저장소 owner/repo>
# 2) 플러그인 설치
claude plugin install cafe-helper@cafe-tools
```
Claude Code 안에서는 `/plugin marketplace add ...`, `/plugin install cafe-helper@cafe-tools` 로도 됩니다.
설치 후 Claude Code를 다시 시작하세요.
## 주의
- `.env` 차단은 클로드의 도구 사용(Read/Edit/Write/Grep/Glob/Bash)을 검사합니다. 명령어 안에 `.env`라는 이름이 드러나지 않게 우회하는 경우(예: `cat .e*`)까지 완벽하게 막지는 못합니다.
- `python3`가 없으면 훅이 실행되지 않아 차단이 동작하지 않습니다.
cafe-helper/data/menu.csv
메뉴,가격,분류
아메리카노,4500,커피
카페라떼,5000,커피
바닐라라떼,5500,커피
자몽에이드,6000,에이드
레몬티,5000,차
치즈케이크,6500,디저트
cafe-helper/hooks/hooks.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Glob|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/block_env.py\""
}
]
}
]
}
}
cafe-helper/scripts/block_env.py
#!/usr/bin/env python3
"""PreToolUse 훅: 클로드가 .env 파일을 읽거나 고치려고 하면 막는다.
막는 대상: .env, .env.local, .env.production 같은 파일
허용 대상: .env.example, .env.sample, .env.template (비밀값이 없는 예시 파일)
"""
import fnmatch
import json
import os
import re
import sys
ALLOWED_SUFFIXES = (".example", ".sample", ".template")
# 명령어 문자열 안에서 .env 파일 이름을 찾는 패턴 (앞뒤가 경로 구분자·공백·따옴표 등)
ENV_IN_TEXT = re.compile(r"(?:^|[\s/\\'\"=<>:;|&(`])(\.env(?:\.[\w.-]+)?)(?=$|[\s'\";|&)<>`])")
def is_env_name(name):
if name != ".env" and not name.startswith(".env."):
return False
return not name.endswith(ALLOWED_SUFFIXES)
def path_is_env(path):
return bool(path) and is_env_name(os.path.basename(path.rstrip("/\\")))
def text_mentions_env(text):
return any(is_env_name(m) for m in ENV_IN_TEXT.findall(text or ""))
def glob_hits_env(pattern):
"""'.env*', '**/.env', '{.env,a.txt}' 처럼 .env 파일과 맞는 검색 패턴인지"""
if not pattern:
return False
parts = re.split(r"[{},/\\\s]", pattern)
return any(fnmatch.fnmatchcase(sample, part)
for part in parts if "env" in part # '*', '**/*.py' 같은 일반 패턴은 제외
for sample in (".env", ".env.local"))
def deny(reason):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}, ensure_ascii=False))
sys.exit(0)
def main():
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
tool = data.get("tool_name", "")
tool_input = data.get("tool_input") or {}
if tool == "Bash":
if text_mentions_env(tool_input.get("command", "")):
deny("cafe-helper: .env 파일에는 비밀값이 있어 명령어로 접근할 수 없습니다.")
return 0
for key in ("file_path", "notebook_path", "path"):
if path_is_env(tool_input.get(key, "")):
deny("cafe-helper: .env 파일은 읽거나 수정할 수 없습니다.")
# Grep의 glob, Glob의 pattern 으로 .env를 직접 겨냥하는 경우
pattern_key = {"Grep": "glob", "Glob": "pattern"}.get(tool)
if pattern_key:
value = tool_input.get(pattern_key, "")
if glob_hits_env(value):
deny("cafe-helper: .env 파일은 검색할 수 없습니다.")
return 0
if __name__ == "__main__":
sys.exit(main())
cafe-helper/scripts/menu_price.py
#!/usr/bin/env python3
"""menu.csv에서 메뉴 가격을 찾는다.
찾는 순서:
1. 현재 작업 폴더(프로젝트)의 menu.csv
2. 플러그인에 함께 들어 있는 data/menu.csv
"""
import csv
import os
import sys
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def find_menu_file():
candidates = [
os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()), "menu.csv"),
os.path.join(os.getcwd(), "menu.csv"),
os.path.join(PLUGIN_ROOT, "data", "menu.csv"),
]
for path in candidates:
if os.path.isfile(path):
return path
return None
def normalize(text):
return "".join(text.split()).lower()
def main():
query = " ".join(sys.argv[1:]).strip()
path = find_menu_file()
if path is None:
print("menu.csv 파일을 찾을 수 없습니다.")
return 1
# utf-8-sig: 엑셀에서 저장한 BOM 붙은 CSV도 읽을 수 있게
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [r for r in csv.DictReader(f) if r.get("메뉴")]
print(f"(출처: {path})")
if not query:
print("전체 메뉴:")
for r in rows:
print(f"- {r['메뉴']}: {r['가격']}원 ({r.get('분류', '')})")
return 0
q = normalize(query)
exact = [r for r in rows if normalize(r["메뉴"]) == q]
if exact:
r = exact[0]
print(f"찾음 - {r['메뉴']}: {r['가격']}원 ({r.get('분류', '')})")
return 0
partial = [r for r in rows if q in normalize(r["메뉴"]) or normalize(r["메뉴"]) in q]
if partial:
print(f"'{query}'와 정확히 일치하는 메뉴는 없습니다. 비슷한 메뉴:")
for r in partial:
print(f"- {r['메뉴']}: {r['가격']}원 ({r.get('분류', '')})")
return 0
print(f"'{query}' 메뉴를 찾을 수 없습니다. 등록된 메뉴: " + ", ".join(r["메뉴"] for r in rows))
return 0
if __name__ == "__main__":
sys.exit(main())
cafe-helper/skills/menu-price/SKILL.md
---
name: menu-price
description: 카페 메뉴 이름으로 menu.csv에서 가격을 찾아 알려 준다. 사용자가 메뉴 가격을 물어볼 때 사용.
argument-hint: <메뉴 이름>
allowed-tools: Bash(python3 ${CLAUDE_PLUGIN_ROOT}/scripts/menu_price.py *)
---
# 메뉴 가격 조회
조회 결과:
!`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/menu_price.py" "$ARGUMENTS"`
위 조회 결과를 그대로 바탕으로 사용자에게 한국어로 짧게 답한다.
- 가격을 찾았으면 `메뉴: 가격원 (분류)` 형식으로 알려 준다. 가격은 천 단위 쉼표를 붙인다 (예: 4,500원).
- 정확히 일치하는 메뉴가 없고 비슷한 후보가 있으면 후보를 보여 주고 어떤 메뉴인지 되묻는다.
- 메뉴 이름이 비어 있으면 전체 메뉴 목록을 보여 준다.
- menu.csv에 없는 가격을 지어내지 않는다.
클로드의 마지막 답
`cafe-helper/` 폴더에 플러그인을 만들었습니다. 두 기능의 스크립트는 직접 실행해서 확인했고, 테스트는 모두 통과했습니다. 다만 **Claude Code에 실제로 설치해서 써 보지는 못했습니다.** 이 환경에서 `claude` 명령 실행이 승인되지 않았기 때문입니다.
## 만든 파일
```
none2/
├── .claude-plugin/marketplace.json ← 직원 설치용 등록 파일 ("cafe-tools")
└── cafe-helper/
├── .claude-plugin/plugin.json
├── skills/menu-price/SKILL.md ← /cafe-helper:menu-price
├── hooks/hooks.json ← .env 차단 훅 등록
├── scripts/menu_price.py
├── scripts/block_env.py
├── data/menu.csv ← 메뉴판 복사본
└── README.md
```
1. **메뉴 가격**: `/cafe-helper:menu-price 아메리카노` → `아메리카노: 4,500원 (커피)`
- 띄어쓰기는 무시합니다. "카페 라떼"로 써도 카페라떼를 찾습니다.
- "라떼"처럼 일부만 넣으면 비슷한 메뉴 후보를 보여 줍니다. 이름 없이 부르면 전체 메뉴가 나옵니다.
- 지금 작업 중인 폴더의 `menu.csv`를 먼저 보고, 없으면 플러그인 안의 `data/menu.csv`를 씁니다. 그래서 **메뉴가 바뀌면 두 파일을 다 고쳐야 합니다.**
2. **.env 차단**: 클로드가 `.env`나 `.env.local` 같은 파일을 읽기·수정·검색하거나 명령어로 건드리려 하면 막고 이유를 알려 줍니다. 비밀값이 없는 `.env.example`, `.env.sample`, `.env.template`은 허용합니다.
## 확인한 내용
- **메뉴 조회**: 정확히 일치, 일부 일치, 없는 메뉴, 빈 입력, 복사본으로 찾기까지 모두 의도대로 나왔습니다.
- **.env 차단**: 24개 경우를 넣어 봤고 모두 맞게 막거나 통과시켰습니다. `cat .env`, `>> .env`, `.env*` 검색은 막혔고, `.venv`, `*.py`, `.env.example`은 통과했습니다.
- 처음 테스트에서 `.env*` 검색이 통과되는 구멍이 있어서 고쳤습니다.
- **설정 파일**: JSON 문법 검사를 통과했습니다.
- `.env` 파일 내용은 저도 열어 보지 않았습니다.
**실제 동작은 아래 명령으로 꼭 한 번 확인해 주세요.** 특히 메뉴 명령이 스크립트 결과를 받아 오는 방식은 공식 문서에 동작한다고 확실히 적혀 있지 않습니다.
```bash
claude plugin validate ./cafe-helper
claude --plugin-dir ./cafe-helper # 들어가서 /cafe-helper:menu-price 아메리카노, ".env 읽어줘" 시도
```
## 다른 직원 설치 방법
이 `none2` 폴더를 GitHub 저장소에 올리거나 공유 폴더에 두고, 직원 PC에서 아래 두 줄을 실행합니다.
```bash
claude plugin marketplace add 카페계정/저장소이름 # 공유 폴더라면 그 폴더 경로
claude plugin install cafe-helper@cafe-tools
```
Claude Code 안에서 `/plugin marketplace add ...`, `/plugin install cafe-helper@cafe-tools`로 해도 됩니다. 설치 후 Claude Code를 다시 시작하면 됩니다. 직원 PC에 `python3`가 있어야 합니다.
## 주의할 점
- `python3`가 없으면 훅이 아예 실행되지 않습니다. 이때는 **.env가 막히지 않고 그대로 열립니다.**
- 명령어 안에 `.env`라는 이름이 드러나지 않게 돌려 쓰면(예: `cat .e*`) 완벽히 막지 못합니다. 실수로 여는 것을 막는 용도로 보시면 됩니다.
정리한 사용법은 `cafe-helper/README.md`에도 넣어 두었습니다.깔아 두기만 1회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✓ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✓ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/marketplace.json
{
"name": "cafe-tools",
"owner": {
"name": "Cafe Team"
},
"metadata": {
"description": "우리 카페 직원용 Claude Code 플러그인 모음"
},
"plugins": [
{
"name": "cafe-helper",
"source": "./",
"description": "카페 직원용 도우미: 메뉴 가격 조회와 .env 파일 접근 차단"
}
]
}
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "1.0.0",
"description": "카페 직원용 도우미: 메뉴 가격 조회(/cafe-helper:menu-price)와 .env 파일 접근 차단",
"author": {
"name": "Cafe Team"
}
}
cafe-helper/README.md
# cafe-helper
우리 카페 직원용 Claude Code 플러그인입니다.
## 기능
| 기능 | 사용법 |
|------|--------|
| 메뉴 가격 조회 | `/cafe-helper:menu-price 아메리카노` (메뉴 이름 없이 부르면 전체 메뉴) |
| .env 보호 | Claude가 `.env`, `.env.local` 등을 읽거나 고치려 하면 자동 차단 (`.env.example`은 허용) |
메뉴 파일은 다음 순서로 찾습니다.
1. 환경변수 `CAFE_MENU_CSV`에 지정한 파일
2. Claude Code를 실행한 폴더의 `menu.csv`
3. 플러그인에 들어 있는 `data/menu.csv`
## 요구 사항
- Claude Code
- `python3` (macOS는 터미널에서 `python3 --version`이 나오면 OK)
## 설치
### A. 공유 폴더/압축 파일로 받은 경우
`cafe-helper` 폴더를 내 컴퓨터에 둔 뒤 Claude Code에서:
```
/plugin marketplace add /폴더/경로/cafe-helper
/plugin install cafe-helper@cafe-tools
```
### B. GitHub 저장소로 받은 경우 (이 폴더가 저장소 최상위일 때)
```
/plugin marketplace add 계정명/저장소명
/plugin install cafe-helper@cafe-tools
```
설치 후 Claude Code를 다시 시작하면 적용됩니다. `/plugin` 메뉴에서 켜기/끄기/삭제할 수 있습니다.
## 업데이트
메뉴나 플러그인이 바뀌면:
```
/plugin marketplace update cafe-tools
```
cafe-helper/data/menu.csv
메뉴,가격,분류
아메리카노,4500,커피
카페라떼,5000,커피
바닐라라떼,5500,커피
자몽에이드,6000,에이드
레몬티,5000,차
치즈케이크,6500,디저트
cafe-helper/hooks/hooks.json
{
"description": ".env 파일 읽기/수정 차단",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Glob|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/block_env.py\"",
"timeout": 10
}
]
}
]
}
}
cafe-helper/scripts/block_env.py
#!/usr/bin/env python3
"""PreToolUse 훅: Claude가 .env 파일을 읽거나 고치려는 도구 호출을 막는다.
막는 대상: .env, .env.local, .env.production 같은 .env / .env.* 파일
허용하는 예시 파일: .env.example, .env.sample, .env.template
차단 시 exit 2 + stderr 메시지 → Claude Code가 도구 실행을 취소하고 이유를 Claude에게 전달한다.
"""
import json
import re
import sys
from pathlib import PurePath
ALLOWED_SUFFIXES = {"example", "sample", "template", "dist"}
# 명령어/패턴 문자열 안에서 .env 또는 .env.xxx 토큰을 찾는다.
ENV_TOKEN = re.compile(r"(?:^|[\s/'\"=<>|;&(`*])\.env(?:\.([\w.-]+))?(?=$|[\s'\"<>|;&)`*/])")
def is_protected_name(name):
if name == ".env":
return True
if name.startswith(".env."):
return name[len(".env."):].lower() not in ALLOWED_SUFFIXES
return False
def text_mentions_env(text):
for m in ENV_TOKEN.finditer(text or ""):
suffix = m.group(1)
if suffix is None or suffix.lower() not in ALLOWED_SUFFIXES:
return True
return False
def check(tool_name, tool_input):
if tool_name == "Bash":
return text_mentions_env(tool_input.get("command", ""))
for key in ("file_path", "notebook_path", "path"):
value = tool_input.get(key)
if value and is_protected_name(PurePath(value).name):
return True
# Glob/Grep의 검색 패턴으로 .env를 겨냥하는 경우
pattern_keys = {"Glob": ("pattern",), "Grep": ("glob",)}.get(tool_name, ())
return any(text_mentions_env(tool_input.get(key, "")) for key in pattern_keys)
def main():
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
if check(data.get("tool_name", ""), data.get("tool_input") or {}):
print(
"[cafe-helper] .env 파일은 비밀 정보(API 키, 비밀번호 등)가 들어 있어 "
"읽기·수정이 차단되어 있습니다. .env 내용이 필요하면 사용자에게 직접 요청하세요.",
file=sys.stderr,
)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
cafe-helper/scripts/menu_price.py
#!/usr/bin/env python3
"""menu.csv에서 메뉴 가격을 찾는다.
menu.csv 위치 우선순위:
1. 환경변수 CAFE_MENU_CSV
2. 현재 작업 폴더의 menu.csv
3. 플러그인에 함께 들어 있는 data/menu.csv
"""
import csv
import os
import sys
from pathlib import Path
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
def find_menu_csv():
candidates = []
if os.environ.get("CAFE_MENU_CSV"):
candidates.append(Path(os.environ["CAFE_MENU_CSV"]))
candidates.append(Path.cwd() / "menu.csv")
candidates.append(PLUGIN_ROOT / "data" / "menu.csv")
for path in candidates:
if path.is_file():
return path
return None
def normalize(text):
return "".join(text.split()).lower()
def fmt(row):
return f"{row['메뉴']}: {int(row['가격']):,}원 ({row['분류']})"
def main():
query = " ".join(sys.argv[1:]).strip()
path = find_menu_csv()
if path is None:
print("menu.csv 파일을 찾을 수 없습니다.")
return 1
with path.open(encoding="utf-8-sig", newline="") as f:
rows = [r for r in csv.DictReader(f) if r.get("메뉴")]
print(f"[출처] {path}")
if not query:
print("전체 메뉴:")
for r in rows:
print(" - " + fmt(r))
return 0
q = normalize(query)
exact = [r for r in rows if normalize(r["메뉴"]) == q]
partial = [r for r in rows if q in normalize(r["메뉴"]) or normalize(r["메뉴"]) in q]
matches = exact or partial
if len(matches) == 1:
print(fmt(matches[0]))
elif matches:
print(f"'{query}'와 비슷한 메뉴가 여러 개 있습니다:")
for r in matches:
print(" - " + fmt(r))
else:
print(f"'{query}' 메뉴를 찾지 못했습니다. 등록된 메뉴:")
for r in rows:
print(" - " + fmt(r))
return 0
if __name__ == "__main__":
sys.exit(main())
cafe-helper/skills/menu-price/SKILL.md
---
name: menu-price
description: 카페 메뉴 이름으로 menu.csv에서 가격을 찾아 알려 준다. 사용자가 "아메리카노 얼마야?", "카페라떼 가격"처럼 메뉴 가격을 물을 때 사용.
argument-hint: <메뉴 이름>
allowed-tools: Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/menu_price.py" *)
---
# 메뉴 가격 조회
조회할 메뉴: `$ARGUMENTS`
1. 아래 명령을 Bash로 한 번 실행한다. `<메뉴>` 자리에는 위 메뉴 이름을 그대로 넣는다(메뉴 이름이 비어 있으면 인자 없이 실행해 전체 메뉴를 보여 준다).
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/menu_price.py" "<메뉴>"
```
2. 스크립트 출력만 근거로 짧게 답한다. 예: `아메리카노는 4,500원입니다. (커피)`
- 비슷한 메뉴 후보가 여러 개 나오면 후보와 가격을 모두 보여 준다.
- 찾지 못했다고 나오면 가격을 추측하지 말고, 스크립트가 보여 준 메뉴 목록을 안내한다.
클로드의 마지막 답
`cafe-helper/`에 플러그인을 만들었고, 두 기능 모두 스크립트 단위로는 정상 동작합니다. 다만 실제 Claude Code 세션 안에서 돌려 보는 마지막 단계는 하지 못했습니다. 그 테스트를 위해 `claude -p --plugin-dir ...`를 실행하려 했는데 권한이 거부됐습니다.
## 만든 것
```
cafe-helper/
├── .claude-plugin/
│ ├── plugin.json # 플러그인 정보
│ └── marketplace.json # 직원 배포용 (마켓플레이스 이름: cafe-tools)
├── skills/menu-price/SKILL.md # /cafe-helper:menu-price 명령
├── hooks/hooks.json # .env 차단 훅 등록
├── scripts/menu_price.py # 가격 조회 스크립트
├── scripts/block_env.py # .env 차단 스크립트
├── data/menu.csv # menu.csv 복사본 (기본값)
└── README.md # 직원용 안내
```
**1. 메뉴 가격 조회**: `/cafe-helper:menu-price 아메리카노`라고 부르면 "아메리카노: 4,500원 (커피)"처럼 알려 줍니다.
- "라떼"처럼 일부만 넣으면 카페라떼와 바닐라라떼를 모두 보여 줍니다.
- 없는 메뉴를 넣으면 가격을 추측하지 않고 전체 메뉴 목록을 보여 줍니다.
- 메뉴 파일은 Claude Code를 실행한 폴더의 `menu.csv`를 먼저 씁니다. 그 폴더에 없을 때만 플러그인 안의 복사본을 씁니다.
**2. .env 차단**: Claude가 `.env`나 `.env.local` 같은 파일을 읽거나, 고치거나, 검색하려 하면 실행 전에 막고 이유를 알려 줍니다.
- 직접 여는 것뿐 아니라 `cat .env`처럼 터미널 명령으로 접근하는 것도 막습니다.
- `.env.example` 같은 예시 파일은 비밀 정보가 없으니 막지 않습니다.
## 확인한 것
- **설정 파일 검사**: `claude plugin validate`로 두 설정 파일을 검사했고 경고 없이 통과했습니다.
- **차단 테스트 18개 모두 통과**:
- 막혀야 하는 11개: `.env` 읽기, 수정, 쓰기, `cat`/`sed`/`grep` 명령, `.env` 검색
- 통과해야 하는 7개: `menu.csv`, `.env.example`, `.envrc`, `ls`, `.venv` 같은 다른 파일과 명령
- **가격 조회**: 정확한 이름, 일부만 넣은 이름, 없는 메뉴 세 가지 경우를 확인했습니다.
- **실제 호출 방식 흉내**: Claude Code가 플러그인을 부르는 방식을 흉내 내서 등록된 명령을 그대로 실행해 봤습니다. 가짜 `.env`를 넣은 테스트 폴더에서 `.env`는 막히고 `menu.csv`는 통과했습니다. 실제 `.env`는 열지 않았습니다.
- **직접 확인 필요**: 이 폴더에서 Claude Code를 열고 `/plugin marketplace add ./cafe-helper`, `/plugin install cafe-helper@cafe-tools`를 실행한 뒤 다시 시작해서, `/cafe-helper:menu-price 아메리카노`를 한 번 불러 보세요.
## 다른 직원이 설치하는 방법
1. **파일 전달**: `cafe-helper` 폴더를 공유 폴더나 압축 파일로 주거나, 이 폴더를 최상위로 하는 GitHub 저장소에 올립니다.
2. **설치**: 직원이 Claude Code에서 아래 명령을 입력합니다.
```
/plugin marketplace add /받은/경로/cafe-helper ← GitHub이면: 계정명/저장소명
/plugin install cafe-helper@cafe-tools
```
3. **적용**: Claude Code를 다시 시작하면 적용됩니다. 메뉴나 플러그인이 바뀌면 `/plugin marketplace update cafe-tools`로 업데이트합니다.
직원 컴퓨터에 `python3`가 있어야 합니다. 터미널에서 `python3 --version`으로 확인할 수 있습니다. 이 내용은 `cafe-helper/README.md`에도 정리해 두었습니다.
깔아 두기만 2회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✗ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✗ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "1.0.0",
"description": "카페 직원용 도우미: 메뉴 가격 조회(/cafe-helper:menu-price)와 .env 파일 접근 차단",
"author": {
"name": "<이름>",
"email": "najjang1177@gmail.com"
}
}
cafe-helper/commands/menu-price.md
---
description: menu.csv에서 메뉴 가격을 찾아 알려 줍니다
argument-hint: <메뉴 이름>
allowed-tools: Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/menu-price.sh:*)
---
조회 결과:
!`bash "${CLAUDE_PLUGIN_ROOT}/scripts/menu-price.sh" "$ARGUMENTS"`
위 조회 결과를 직원에게 한국어로 짧게 전달하세요.
- 가격은 천 단위 쉼표와 "원"을 붙여 표기합니다 (예: 4,500원).
- 결과가 "NOT_FOUND"이면 메뉴판에 없는 메뉴라고 알리고, 함께 나온 전체 메뉴 목록을 보여 주세요.
- 결과가 "PARTIAL"이면 비슷한 이름의 후보들을 가격과 함께 보여 주세요.
- 결과가 "ERROR"이면 그 이유를 그대로 전달하세요.
- menu.csv를 직접 읽거나 다른 도구를 쓰지 말고 위 결과만 사용하세요.
cafe-helper/hooks/block-env.sh
#!/usr/bin/env bash
# PreToolUse 훅: Claude가 .env 계열 파일(.env, .env.local, .env.production 등)을
# 읽거나 수정하려 하면 차단한다. 템플릿(.env.example/.sample/.template)은 허용.
# 종료 코드 2 + stderr 메시지 → Claude Code가 도구 실행을 막고 이유를 Claude에게 전달한다.
input="$(cat)"
# 도구 입력에서 검사할 문자열(경로·명령어)만 추출
if command -v jq >/dev/null 2>&1; then
targets="$(printf '%s' "$input" | jq -r '
.tool_input // {} |
[.file_path, .notebook_path, .path, .pattern, .glob, .command] |
map(select(type == "string")) | .[]' 2>/dev/null)"
elif command -v python3 >/dev/null 2>&1; then
targets="$(printf '%s' "$input" | python3 -c '
import json, sys
ti = json.load(sys.stdin).get("tool_input") or {}
for k in ("file_path", "notebook_path", "path", "pattern", "glob", "command"):
v = ti.get(k)
if isinstance(v, str):
print(v)' 2>/dev/null)"
else
# 파서가 없으면 원본 JSON 전체를 검사(보수적으로 차단)
targets="$input"
fi
# 경로 구분자/공백/따옴표 등으로 둘러싸인 .env 또는 .env.xxx 토큰을 찾는다
matches="$(printf '%s\n' "$targets" \
| grep -oE '(^|[^A-Za-z0-9_.-])\.env(\.[A-Za-z0-9_-]+)*($|[^A-Za-z0-9_.-])' \
| grep -vE '\.env\.(example|sample|template)($|[^A-Za-z0-9_.-])')"
if [ -n "$matches" ]; then
echo "차단됨: .env 파일은 비밀 정보(API 키·비밀번호)가 들어 있어 cafe-helper 플러그인이 읽기/수정을 막고 있습니다. 내용이 필요하면 사용자에게 직접 요청하세요." >&2
exit 2
fi
exit 0
cafe-helper/hooks/hooks.json
{
"description": ".env 파일 읽기/수정 차단",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Glob|Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/block-env.sh\"",
"timeout": 10
}
]
}
]
}
}
cafe-helper/scripts/menu-price.sh
#!/usr/bin/env bash
# 사용법: menu-price.sh <메뉴 이름>
# 현재 프로젝트 폴더의 menu.csv(메뉴,가격,분류)에서 가격을 찾는다.
# MENU_CSV 환경변수로 다른 파일 경로를 지정할 수 있다.
query="$*"
csv="${MENU_CSV:-${CLAUDE_PROJECT_DIR:-$PWD}/menu.csv}"
if [ -z "${query// /}" ]; then
echo "ERROR: 메뉴 이름을 입력해 주세요. 예) /cafe-helper:menu-price 아메리카노"
exit 0
fi
if [ ! -f "$csv" ]; then
echo "ERROR: menu.csv 파일을 찾을 수 없습니다 ($csv). menu.csv가 있는 폴더에서 Claude Code를 실행해 주세요."
exit 0
fi
# 공백을 무시하고 비교한다 ("바닐라 라떼" == "바닐라라떼")
awk -F',' -v q="$query" '
function norm(s) { gsub(/[[:space:]\r"]/, "", s); return s }
BEGIN { nq = norm(q) }
NR == 1 { next }
{
name = $1; gsub(/^[[:space:]"]+|[[:space:]"\r]+$/, "", name)
price = $2; gsub(/[[:space:]"\r]/, "", price)
cat = $3; gsub(/^[[:space:]"]+|[[:space:]"\r]+$/, "", cat)
if (name == "") next
all = all "- " name ": " price " (" cat ")\n"
if (norm(name) == nq) exact = exact "- " name ": " price " (" cat ")\n"
else if (index(norm(name), nq) || index(nq, norm(name))) partial = partial "- " name ": " price " (" cat ")\n"
}
END {
if (exact != "") { printf "FOUND\n%s", exact }
else if (partial != "") { printf "PARTIAL\n%s", partial }
else { printf "NOT_FOUND: \"%s\"\n전체 메뉴:\n%s", q, all }
}
' "$csv"
클로드의 마지막 답
I built the plugin in `cafe-helper/`, but I only partly checked that it works. Both scripts passed every test case, and `claude plugin validate` passed for the plugin and the install file. The final check, running it inside Claude Code with `claude -p --plugin-dir ./cafe-helper "/cafe-helper:menu-price 아메리카노"`, wasn't allowed, so I haven't confirmed the slash command or the `.env` block in a real session yet.
## Files I made
```
plug2/
├── .claude-plugin/marketplace.json ← lets other staff install it (marketplace "cafe-tools")
└── cafe-helper/
├── .claude-plugin/plugin.json
├── commands/menu-price.md ← /cafe-helper:menu-price <menu name>
├── scripts/menu-price.sh ← looks up the price in menu.csv
└── hooks/
├── hooks.json ← runs a check before Claude uses a tool
└── block-env.sh ← blocks .env files
```
## 1. Menu price lookup
- **Where it looks:** it reads `menu.csv` in the folder where Claude Code is running. Each employee needs to run Claude Code from a folder that has `menu.csv`.
- **Test results:**
- `아메리카노` → 4500 (커피)
- `바닐라 라떼` (with a space) → 바닐라라떼 5500. Spaces are ignored.
- `라떼` → shows both similar items, 카페라떼 and 바닐라라떼.
- A menu that isn't in the file (`녹차`) → says it isn't there and shows the full menu.
- No menu name, or no `menu.csv` in the folder → shows a message explaining what to do.
## 2. Blocking `.env` files
Before Claude reads, edits, writes, searches or runs a command, the plugin checks whether a `.env` file is involved and stops it if so.
| Case | Result |
|---|---|
| Reading `.env`, editing `.env.local`, writing `.env`, `cat .env`, `sed ... .env.production`, searching inside `.env` | Blocked |
| `.env.example` (and `.sample`, `.template`), `menu.csv`, `.envrc`, a README that just mentions ".env" | Allowed |
- **Why templates are allowed:** files like `.env.example` usually hold no real secrets.
- **Search limitation:** a search for files named like `.env*` is also blocked, even though it would only list file names.
## How to check it yourself
```bash
cd <작업 폴더>/plug2
claude --plugin-dir ./cafe-helper
```
Then type `/cafe-helper:menu-price 아메리카노`, and also ask "Show me what's in the .env file". The first should give the price and the second should be refused.
## How other employees install it
1. Share the `plug2` folder through GitHub or a shared drive. It must include both `.claude-plugin/marketplace.json` and `cafe-helper/`.
2. Each employee runs these in Claude Code:
```
/plugin marketplace add <GitHub owner/repo, or the local path to the plug2 folder>
/plugin install cafe-helper@cafe-tools
```
Then restart Claude Code.
3. They need `menu.csv` in the folder where they use Claude Code. When the menu changes, updating that file is enough.
4. When you update the plugin, employees get the new version with `/plugin marketplace update cafe-tools`.
**What employees need installed:** `.env` blocking needs `jq` (macOS 15 and later include it) or `python3`. If neither is installed, it blocks anything that even mentions `.env`, so the protection still stays on./plugin-dev:create-plugin 1회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✓ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✓ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/marketplace.json
{
"name": "cafe-plugins",
"owner": {
"name": "<이름>",
"email": "najjang1177@gmail.com"
},
"metadata": {
"description": "우리 카페 직원용 Claude Code 플러그인 모음"
},
"plugins": [
{
"name": "cafe-helper",
"source": "./",
"description": "menu.csv 메뉴 가격 조회와 .env 파일 접근 차단",
"version": "0.1.0"
}
]
}
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "0.1.0",
"description": "카페 직원용 도우미: menu.csv 메뉴 가격 조회(/cafe-helper:menu-price)와 .env 파일 접근 차단",
"author": {
"name": "<이름>",
"email": "najjang1177@gmail.com"
},
"keywords": ["cafe", "menu", "env", "security"]
}
cafe-helper/README.md
# cafe-helper
카페 직원들이 Claude Code에서 함께 쓰는 플러그인입니다.
## 기능
### 1. 메뉴 가격 조회 — `/cafe-helper:menu-price <메뉴>`
Claude Code를 실행한 폴더의 `menu.csv`에서 가격을 찾아 알려 줍니다.
```
/cafe-helper:menu-price 아메리카노 → 아메리카노는 4,500원입니다.
/cafe-helper:menu-price 라떼 → 카페라떼 5,000원 / 바닐라라떼 5,500원 (후보 안내)
/cafe-helper:menu-price → 전체 메뉴판
```
"아메리카노 얼마야?"처럼 그냥 물어봐도 동작합니다.
`menu.csv` 형식 (첫 줄은 헤더, UTF-8):
```
메뉴,가격,분류
아메리카노,4500,커피
```
### 2. `.env` 파일 보호 (자동)
설치만 하면 Claude가 아래 파일을 읽거나(Read, Grep, Glob, `cat` 등 Bash 명령) 고치려고(Write, Edit) 할 때 자동으로 막습니다.
- 차단: `.env`, `.env.local`·`.env.production` 같은 `.env.*`, `prod.env` 같은 `*.env`
- 허용(견본 파일): `.env.example`, `.env.sample`, `.env.template`, `.env.dist`
참고:
- Bash 명령은 명령어 안에 `.env` 파일 이름이 있는지로 판단합니다. `cat .e*`처럼 이름을 숨긴 경우까지 완벽하게 막지는 못하므로, 비밀값 보호의 "안전망"으로 생각해 주세요.
- `echo .env >> .gitignore`처럼 이름만 들어간 Bash 명령도 막힙니다. 이런 작업은 Edit 도구로 `.gitignore`를 고치게 하면 됩니다.
- `jq`가 필요합니다(macOS 15 이상 기본 포함). 없으면 더 보수적으로(입력 전체 기준) 차단합니다.
## 설치 (다른 직원)
이 `cafe-helper` 폴더를 Git 저장소(GitHub 등)에 올려 둔 경우, Claude Code 안에서:
```
/plugin marketplace add <저장소 주소 또는 owner/repo>
/plugin install cafe-helper@cafe-plugins
```
공유 폴더에 둔 경우에는 저장소 주소 대신 폴더 경로를 넣으면 됩니다:
```
/plugin marketplace add /공유폴더/경로/cafe-helper
/plugin install cafe-helper@cafe-plugins
```
설치 후 Claude Code를 다시 시작하세요. `/plugin`에서 설치 여부를, `/hooks`에서 `.env` 차단 훅이 켜졌는지 확인할 수 있습니다.
설치 없이 한 번만 써 보려면:
```
claude --plugin-dir /경로/cafe-helper
```
## 업데이트
플러그인을 고쳤으면 `.claude-plugin/plugin.json`과 `.claude-plugin/marketplace.json`의 `version`을 올린 뒤 올려 두고, 직원들은 `/plugin marketplace update cafe-plugins` 후 재시작합니다.
## 파일 구조
```
cafe-helper/
├── .claude-plugin/
│ ├── plugin.json # 플러그인 정보
│ └── marketplace.json # 직원 설치용 마켓플레이스 정보
├── skills/menu-price/
│ ├── SKILL.md # /cafe-helper:menu-price
│ └── scripts/lookup.sh # menu.csv 조회
└── hooks/
├── hooks.json # PreToolUse 훅 등록
└── scripts/block-env.sh # .env 접근 차단
```
cafe-helper/hooks/hooks.json
{
"description": ".env 파일 읽기·수정 차단",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Grep|Glob|Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/block-env.sh\"",
"timeout": 10
}
]
}
]
}
}
cafe-helper/hooks/scripts/block-env.sh
#!/bin/bash
# PreToolUse 훅: Claude가 .env 파일을 읽거나 고치려 하면 막는다.
# 차단 대상 파일명: .env, .env.*(예: .env.local), *.env(예: prod.env)
# 허용 예외: .env.example, .env.sample, .env.template, .env.dist (비밀값이 없는 견본 파일)
# 종료 코드 2 = 도구 실행 차단, stderr 메시지가 Claude에게 전달된다.
set -uo pipefail
input="$(cat)"
# 도구별로 "경로"에 해당하는 필드만 검사한다.
# (Write의 content, Grep의 pattern 같은 본문/검색어는 검사하지 않아 오탐을 줄인다.)
if command -v jq >/dev/null 2>&1; then
targets="$(printf '%s' "$input" | jq -r '
.tool_name as $t
| .tool_input // {}
| if $t == "Bash" then [.command]
elif $t == "Glob" then [.pattern, .path]
elif $t == "Grep" then [.path, .glob]
else [.file_path, .notebook_path, .path]
end
| map(select(type == "string")) | .[]' 2>/dev/null)"
else
# jq가 없으면 입력 전체를 검사한다 (보수적으로 차단).
targets="$input"
fi
[ -z "$targets" ] && exit 0
# 경로 문자가 아닌 것을 구분자로 보고 토큰으로 나눈 뒤, 각 토큰의 파일명(basename)만 본다.
while IFS= read -r token; do
[ -z "$token" ] && continue
base="${token##*/}"
case "$base" in
.env.example|.env.sample|.env.template|.env.dist) continue ;;
esac
if printf '%s' "$base" | grep -Eq '^\.env([.*].*)?$|^[^/]*\.env$'; then
echo "🔒 cafe-helper: .env 파일($token)은 비밀값이 들어 있어 읽기·수정이 차단되어 있습니다. 이 파일에 접근하지 말고, 필요한 값은 사용자에게 직접 물어보세요." >&2
exit 2
fi
done < <(printf '%s\n' "$targets" | LC_ALL=C tr -c 'A-Za-z0-9._/*~\n-' '\n')
exit 0
cafe-helper/skills/menu-price/SKILL.md
---
name: menu-price
description: This skill should be used when the user runs "/cafe-helper:menu-price <메뉴>", or asks "아메리카노 얼마야?", "메뉴 가격 알려줘", "카페라떼 가격", or otherwise asks for the price of a cafe menu item listed in menu.csv.
argument-hint: <메뉴 이름>
allowed-tools: Bash(bash:*), Read
---
# 메뉴 가격 조회
현재 프로젝트 폴더의 `menu.csv`(열: 메뉴,가격,분류)에서 메뉴 가격을 찾아 답한다.
## 조회 결과
요청한 메뉴: `$ARGUMENTS`
!`bash "${CLAUDE_PLUGIN_ROOT}/skills/menu-price/scripts/lookup.sh" "$ARGUMENTS"`
## 답변 방법
위 조회 결과의 첫 줄 표시에 따라 한국어로 짧게 답한다. 가격은 천 단위 쉼표를 넣어 표기한다(예: 4,500원).
- `FOUND:` — 메뉴 이름과 가격을 한 줄로 답한다. 예: "아메리카노는 4,500원입니다."
- `PARTIAL:` — 정확히 일치하는 메뉴는 없다고 알리고, 후보 메뉴와 가격을 목록으로 보여 준다.
- `NOT_FOUND:` — 해당 메뉴가 없다고 알리고, 결과에 나온 전체 메뉴 이름을 보여 준다.
- `ALL:` — 메뉴 이름이 비어 있었다는 뜻이므로 전체 메뉴와 가격을 표로 보여 준다.
- `ERROR:` — menu.csv를 찾지 못했다는 뜻이다. 프로젝트 폴더(Claude Code를 실행한 폴더)에 menu.csv가 있어야 한다고 안내한다.
조회 결과가 비어 있거나 위 표시가 하나도 없으면, 현재 프로젝트 폴더의 `menu.csv`를 Read 도구로 직접 읽어 같은 규칙으로 답한다.
가격은 반드시 조회 결과나 menu.csv에 나온 값만 사용하고, 추측하거나 지어내지 않는다.
cafe-helper/skills/menu-price/scripts/lookup.sh
#!/bin/bash
# menu.csv(메뉴,가격,분류)에서 메뉴 가격을 찾는다.
# 사용법: lookup.sh <메뉴 이름> [menu.csv 경로]
# 정확히 일치하는 메뉴를 우선 찾고, 없으면 부분 일치 후보를 보여 준다.
set -uo pipefail
query="${1:-}"
csv="${2:-${CLAUDE_PROJECT_DIR:-$PWD}/menu.csv}"
if [ ! -f "$csv" ]; then
echo "ERROR: menu.csv 파일을 찾을 수 없습니다: $csv"
exit 0
fi
# 앞뒤 공백 제거
query="$(printf '%s' "$query" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
# BOM·CR 제거 후 헤더를 뺀 본문만 사용
body="$(sed -e '1s/^\xEF\xBB\xBF//' -e 's/\r$//' "$csv" | tail -n +2)"
if [ -z "$query" ]; then
echo "ALL:"
printf '%s\n' "$body" | awk -F',' 'NF>=2 {printf "- %s: %s원 (%s)\n", $1, $2, $3}'
exit 0
fi
exact="$(printf '%s\n' "$body" | awk -F',' -v q="$query" '$1 == q {printf "%s: %s원 (%s)\n", $1, $2, $3}')"
if [ -n "$exact" ]; then
echo "FOUND:"
echo "$exact"
exit 0
fi
# 공백을 무시한 부분 일치 (예: "바닐라 라떼" → 바닐라라떼, "라떼" → 카페라떼, 바닐라라떼)
partial="$(printf '%s\n' "$body" | awk -F',' -v q="$query" '
BEGIN { gsub(/[[:space:]]/, "", q) }
{ name = $1; gsub(/[[:space:]]/, "", name)
if (q != "" && (index(name, q) || index(q, name))) printf "- %s: %s원 (%s)\n", $1, $2, $3 }')"
if [ -n "$partial" ]; then
echo "PARTIAL:"
echo "$partial"
exit 0
fi
echo "NOT_FOUND:"
echo "전체 메뉴:"
printf '%s\n' "$body" | awk -F',' 'NF>=2 {printf "- %s\n", $1}'
클로드의 마지막 답
`cafe-helper/` 폴더에 플러그인을 만들었습니다. 스크립트 단위 테스트와 공식 검증은 통과했지만, Claude Code 안에서 직접 실행하는 최종 확인은 아직 못 했습니다. 그 확인용 명령(`claude -p --plugin-dir ...`)의 실행 권한이 거부됐기 때문입니다. 아래 "직접 확인" 단계를 한 번 해 주시면 됩니다.
## 만든 기능
**1. `/cafe-helper:menu-price <메뉴>`**
- Claude Code를 실행한 폴더의 `menu.csv`에서 가격을 찾아 답합니다. 가격은 스크립트가 찾아 오므로 Claude가 지어낼 수 없습니다.
- 이름이 정확히 맞지 않아도 비슷한 메뉴를 후보로 보여 줍니다. 메뉴 이름을 비우면 전체 메뉴판이 나옵니다.
**2. `.env` 차단**
- Claude가 `.env` 파일을 읽거나 고치려 하면 실행 전에 막습니다. 파일 읽기·쓰기·수정·검색과 `cat .env` 같은 명령어가 모두 해당됩니다.
- 막는 파일은 `.env`, `.env.local` 같은 `.env.*`, `prod.env` 같은 `*.env`입니다.
- 비밀값이 없는 견본 파일(`.env.example` 등)은 허용합니다. `.gitignore`에 `.env` 줄을 쓰는 것처럼 파일 이름이 내용에만 들어간 경우도 막지 않습니다.
## 확인한 것
- **메뉴 조회 스크립트:** 정확히 일치·부분 일치·띄어쓰기 차이("바닐라 라떼")·없는 메뉴·빈 입력·`menu.csv` 없음, 6가지 경우가 모두 의도대로 나왔습니다.
- **`.env` 차단:** 막아야 할 9가지는 모두 막혔고, 허용해야 할 8가지는 모두 통과했습니다.
- **공식 검증:** `claude plugin validate`와 훅 설정 검사를 모두 통과했습니다.
- 작업 중에 실제 `.env` 파일은 열어 보지 않았습니다.
## 남은 한계
- 명령어 안에서 파일 이름을 숨기면(`cat .e*` 등) 다 막지는 못합니다. 비밀값을 지키는 안전망 정도로 생각해 주세요.
- 반대로 `echo .env >> .gitignore`처럼 명령어에 이름만 들어가도 막힙니다.
- 기본 설치된 `jq`를 씁니다. macOS 15 이상에는 기본으로 있습니다.
## 직접 확인 (이 폴더에서)
```bash
claude --plugin-dir ./cafe-helper
```
1. `/cafe-helper:menu-price 아메리카노` → "4,500원"이 나오면 정상입니다.
2. ".env 파일 내용 보여줘" → 차단 메시지가 나오면 정상입니다.
## 다른 직원 설치 방법
먼저 `cafe-helper` 폴더를 GitHub 저장소나 공유 폴더에 올려 두세요. 직원은 Claude Code 안에서 다음을 입력합니다.
```
/plugin marketplace add <GitHub 주소(owner/repo) 또는 공유 폴더 경로>
/plugin install cafe-helper@cafe-plugins
```
설치 후 Claude Code를 다시 시작하면 됩니다. 직원의 작업 폴더에도 `menu.csv`가 있어야 가격 조회가 됩니다.
나중에 플러그인을 고치면 버전 번호를 올려 다시 올리고, 직원은 `/plugin marketplace update cafe-plugins` 후 재시작합니다. 이 내용은 `cafe-helper/README.md`에도 적어 두었습니다.
## 주요 파일
`cafe-helper/` 안에 7개 파일이 있습니다.
- `.claude-plugin/plugin.json`: 플러그인 정보
- `.claude-plugin/marketplace.json`: 직원 설치용 정보
- `skills/menu-price/SKILL.md`, `skills/menu-price/scripts/lookup.sh`: 메뉴 가격 조회
- `hooks/hooks.json`, `hooks/scripts/block-env.sh`: `.env` 차단
- `README.md`: 사용·설치 안내
설계는 따로 묻지 않고 위처럼 정해서 진행했습니다. 차단 범위를 더 넓히거나 좁히고 싶으면 말씀해 주세요./plugin-dev:create-plugin 2회차
| 항목 | 결과 |
|---|
| validate 통과 | ✓ |
| --strict 통과 | ✓ |
| 파일 배치 | ✓ |
| skills/로 만듦 | ✓ |
| hooks.json 형식 | ✓ |
| 경로 변수 따옴표 | ✓ |
| 훅 단독 시험 | ✓ |
| 새 세션 가격 답 | ✓ |
| 새 세션 .env 막음 | ✓ |
| 설치 안내(마켓플레이스 파일 검사 통과) | ✓ |
cafe-helper/.claude-plugin/marketplace.json
{
"name": "cafe-marketplace",
"owner": {
"name": "Cafe Team"
},
"metadata": {
"description": "우리 카페 직원용 Claude Code 플러그인 모음"
},
"plugins": [
{
"name": "cafe-helper",
"source": "./",
"description": "카페 직원용 도우미: menu.csv 메뉴 가격 조회 + .env 파일 접근 차단",
"version": "0.1.0"
}
]
}
cafe-helper/.claude-plugin/plugin.json
{
"name": "cafe-helper",
"version": "0.1.0",
"description": "카페 직원용 도우미: menu.csv 메뉴 가격 조회 + .env 파일 접근 차단",
"author": {
"name": "Cafe Team"
},
"keywords": ["cafe", "menu", "price", "env-guard"]
}
cafe-helper/README.md
# cafe-helper
카페 직원들이 Claude Code에서 함께 쓰는 플러그인입니다.
## 기능
### 1. 메뉴 가격 조회 — `/cafe-helper:menu-price <메뉴>`
현재 폴더의 `menu.csv`(형식: `메뉴,가격,분류`)에서 가격을 찾아 알려 줍니다.
```
/cafe-helper:menu-price 아메리카노 → 아메리카노는 4,500원입니다 (커피).
/cafe-helper:menu-price 라떼 → 카페라떼 5,000원, 바닐라라떼 5,500원 (부분 일치)
/cafe-helper:menu-price 녹차 → 없는 메뉴라고 알리고 전체 메뉴 목록을 보여 줌
```
- 띄어쓰기는 무시합니다 (`카페 라떼` = `카페라떼`).
- "치즈케이크 얼마야?"처럼 그냥 물어봐도 동작합니다.
- **`menu.csv`가 있는 폴더에서 Claude Code를 실행해야 합니다.**
### 2. `.env` 파일 보호 (자동)
Claude가 `.env` 파일을 읽거나 고치려고 하면 자동으로 막습니다. 따로 설정할 것은 없습니다.
| 차단 | 허용 |
|---|---|
| `.env`, `.env.local`, `.env.production.local`, `.ENV` 등 | `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.envrc` |
검사하는 도구:
- Read, Write, Edit, MultiEdit, NotebookEdit: `.env` 파일 경로
- Bash: 명령어에 `.env` 파일이 들어 있으면 차단 (`cat .env`, `echo X=1 >> .env` 등)
- Grep: `.env`를 직접 지정하거나, `.env`가 있는 폴더를 필터 없이 통째로 검색할 때 차단. 이 경우 Claude에게 `glob: "!.env*"` 등을 넣어 다시 검색하라고 안내합니다.
> 이 기능은 실수를 막는 안전장치이고, 모든 우회를 막는 보안 장치는 아닙니다. 와일드카드(`cat .e*`)나 `grep -r 키워드 .`처럼 파일 이름이 명령에 드러나지 않는 Bash 명령은 막지 못합니다.
## 준비물
- Claude Code
- `python3` (macOS·Linux에는 대부분 기본 설치. 없으면 `.env` 보호가 동작하지 않습니다)
## 설치 방법
이 폴더(`cafe-helper/`)는 플러그인이면서 마켓플레이스(`cafe-marketplace`)이기도 합니다.
### A. 공유 폴더로 설치 (가장 간단)
`cafe-helper` 폴더를 공유 드라이브 등에 두고, 각 직원이 Claude Code 안에서:
```
/plugin marketplace add /공유폴더/경로/cafe-helper
/plugin install cafe-helper@cafe-marketplace
```
설치 후 Claude Code를 재시작하세요.
### B. GitHub로 배포
`cafe-helper` 폴더 내용을 **저장소 최상위**로 하는 GitHub 저장소를 만든 뒤:
```
/plugin marketplace add <깃허브계정>/<저장소이름>
/plugin install cafe-helper@cafe-marketplace
```
업데이트 받기: `/plugin marketplace update cafe-marketplace`
### C. 프로젝트에 넣어서 자동 설치
직원들이 같이 쓰는 프로젝트 폴더의 `.claude/settings.json`에 아래를 넣으면, 그 폴더에서 Claude Code를 열 때 설치를 안내합니다.
```json
{
"extraKnownMarketplaces": {
"cafe-marketplace": {
"source": { "source": "github", "repo": "<깃허브계정>/<저장소이름>" }
}
},
"enabledPlugins": {
"cafe-helper@cafe-marketplace": true
}
}
```
### 설치 없이 바로 써 보기
```bash
claude --plugin-dir /경로/cafe-helper
```
## 설치 확인
1. `/help` 또는 `/` 입력 시 `cafe-helper:menu-price`가 보이는지 확인
2. `/cafe-helper:menu-price 아메리카노` → 4,500원
3. "`.env` 파일 내용 보여 줘" → 차단 메시지가 나오면 정상
4. `/hooks`에서 PreToolUse 훅이 등록됐는지 확인
## 파일 구조
```
cafe-helper/
├── .claude-plugin/
│ ├── plugin.json # 플러그인 정보
│ └── marketplace.json # 설치용 마켓플레이스 정보
├── skills/menu-price/
│ ├── SKILL.md # /cafe-helper:menu-price 명령
│ └── scripts/lookup.py # menu.csv 조회 스크립트
└── hooks/
├── hooks.json # .env 차단 훅 등록
└── scripts/block_env.py # .env 접근 판별 스크립트
```
cafe-helper/hooks/hooks.json
{
"description": ".env 파일 읽기/수정 차단",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Grep|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/block_env.py\"",
"timeout": 10
}
]
}
]
}
}
cafe-helper/hooks/scripts/block_env.py
#!/usr/bin/env python3
"""PreToolUse 훅: Claude가 .env 파일을 읽거나 고치려 하면 차단한다.
차단 대상: .env, .env.local, .env.production 등 (.env.example/.sample/.template/.dist 는 허용)
검사 도구: Read, Write, Edit, MultiEdit, NotebookEdit(경로), Bash(명령어 문자열),
Grep(.env 경로 지정, 또는 .env가 있는 폴더를 glob/type 필터 없이 검색)
대소문자는 구분하지 않음 (.ENV 도 차단)
"""
import fnmatch
import json
import os
import re
import sys
ALLOWED_SUFFIXES = {"example", "sample", "template", "dist"}
# 앞뒤가 파일명 문자(영숫자 _ . -)나 '/'(디렉터리)가 아닌 .env / .env.xxx / .env.xxx.yyy 토큰
BASH_ENV_RE = re.compile(r"(?<![\w.\-])\.env((?:\.[\w\-]+)*)(?![\w.\-/])", re.IGNORECASE)
def allowed_suffix(suffixes):
"""'.example', '.local.bak' 처럼 .env 뒤에 붙은 부분이 허용 대상인지 (마지막 확장자 기준)."""
return bool(suffixes) and suffixes.rsplit(".", 1)[-1].lower() in ALLOWED_SUFFIXES
def is_env_name(name):
name = name.lower() # macOS 파일시스템은 대소문자 구분 없음 (.ENV == .env)
if name == ".env":
return True
if name.startswith(".env."):
return not allowed_suffix(name[len(".env"):])
return False
def is_env_path(path):
return bool(path) and is_env_name(os.path.basename(path.rstrip("/")))
def bash_touches_env(command):
return any(not allowed_suffix(m.group(1)) for m in BASH_ENV_RE.finditer(command))
ENV_SAMPLES = (".env", ".env.local", ".env.production.local")
SKIP_DIRS = {".git", "node_modules"}
MAX_DIRS = 5000
def expand_braces(glob):
"""'*.{ts,tsx}' -> ['*.ts', '*.tsx'] (한 단계만)"""
m = re.search(r"\{([^{}]*)\}", glob)
if not m:
return [glob]
return [glob[:m.start()] + alt + glob[m.end():] for alt in m.group(1).split(",")]
def glob_matches_env(pattern):
base = pattern.rsplit("/", 1)[-1].lower()
return any(fnmatch.fnmatchcase(s, base) for s in ENV_SAMPLES)
def glob_reaches_env(glob):
"""Grep의 glob 필터를 통과해 .env 파일까지 검색될 수 있는지."""
for part in expand_braces(glob.strip()):
if part.startswith("!"):
if not glob_matches_env(part[1:]):
return True # 제외 패턴이 .env를 빼지 않음 -> 나머지 전부(.env 포함) 검색
elif glob_matches_env(part):
return True
return False
def dir_contains_env(root):
"""root 아래에 .env 파일이 있는지. 너무 크면 안전하게 True."""
for count, (dirpath, dirnames, filenames) in enumerate(os.walk(root)):
if count >= MAX_DIRS:
return True
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
if any(is_env_name(f) for f in filenames):
return True
return False
def grep_touches_env(tool_input, cwd):
path = tool_input.get("path") or cwd or "."
if is_env_path(path):
return True
if not os.path.isdir(path) or tool_input.get("type"):
return False # 일반 파일 하나만 검색하거나, 언어 type 필터는 .env를 포함하지 않음
glob = tool_input.get("glob")
if glob and not glob_reaches_env(glob):
return False
return dir_contains_env(path)
def check(tool_name, tool_input, cwd):
if tool_name in ("Read", "Write", "Edit", "MultiEdit"):
return is_env_path(tool_input.get("file_path", ""))
if tool_name == "NotebookEdit":
return is_env_path(tool_input.get("notebook_path", ""))
if tool_name == "Grep":
return grep_touches_env(tool_input, cwd)
if tool_name == "Bash":
return bash_touches_env(tool_input.get("command", ""))
return False
def main():
try:
data = json.load(sys.stdin)
except ValueError:
return 0 # 입력을 해석할 수 없으면 관여하지 않음
tool_name = data.get("tool_name", "")
if not check(tool_name, data.get("tool_input") or {}, data.get("cwd")):
return 0
reason = (
"cafe-helper: .env 파일은 비밀 정보(API 키·비밀번호)가 들어 있어 Claude가 읽거나 수정할 수 없습니다. "
"필요하면 사람이 직접 편집하세요."
)
if tool_name == "Grep":
reason += " 폴더 전체를 검색하려면 glob에 \"!.env*\"를 넣거나 type/특정 glob(예: \"*.py\")으로 범위를 좁혀 다시 검색하세요."
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
cafe-helper/skills/menu-price/SKILL.md
---
name: menu-price
description: This skill should be used when the user runs "/cafe-helper:menu-price <메뉴>", or asks for a cafe menu item's price, such as "아메리카노 얼마야?", "카페라떼 가격 알려줘", "메뉴 가격 조회", "what does a latte cost". Looks up the price in the project's menu.csv.
argument-hint: <메뉴 이름>
allowed-tools: Bash(python3 "*/menu-price/scripts/lookup.py" *)
---
# 메뉴 가격 조회
Look up the price of the requested menu item in `menu.csv` (columns: 메뉴,가격,분류).
## Steps
1. Determine the menu name. Use the slash-command argument (`$ARGUMENTS`); if empty, take the menu name from the user's message. If there is still no name, ask the user which menu to look up and stop.
2. Run the lookup script from the project root in exactly this form — script path in double quotes, menu name in single quotes:
```bash
python3 "${CLAUDE_SKILL_DIR}/scripts/lookup.py" '<메뉴 이름>'
```
The CSV path is resolved in this order: second argument > `CAFE_MENU_CSV` environment variable > `./menu.csv`. If the user names a different CSV file, pass it as a single-quoted second argument.
3. Answer in Korean based on the first word of each output line:
- `FOUND` — state the price plainly, e.g. "아메리카노는 4,500원입니다 (커피)."
- `PARTIAL` — no exact match; list the matching items with prices and mention they are partial matches.
- `NOT_FOUND` — say the menu does not exist and show the full menu list the script printed.
- `ERROR` — relay the error. If menu.csv was not found, suggest running Claude Code from the folder that contains `menu.csv` (or setting `CAFE_MENU_CSV`).
Report only what the script prints. Never guess or invent a price.
cafe-helper/skills/menu-price/scripts/lookup.py
#!/usr/bin/env python3
"""menu.csv에서 메뉴 가격을 찾는다.
사용법: lookup.py <메뉴 이름> [menu.csv 경로]
CSV 경로 우선순위: 두 번째 인자 > CAFE_MENU_CSV 환경변수 > ./menu.csv
CSV 형식: 메뉴,가격,분류 (첫 줄은 헤더)
"""
import csv
import os
import sys
def norm(s):
return "".join(s.split()).lower()
def main():
if len(sys.argv) < 2 or not sys.argv[1].strip():
print("ERROR: 메뉴 이름을 입력하세요. 예: /cafe-helper:menu-price 아메리카노")
return 1
query = sys.argv[1].strip()
path = sys.argv[2] if len(sys.argv) > 2 else os.environ.get("CAFE_MENU_CSV", "menu.csv")
if not os.path.isfile(path):
print(f"ERROR: 메뉴 파일을 찾을 수 없습니다: {os.path.abspath(path)}")
return 1
rows = None
for enc in ("utf-8-sig", "cp949"): # 엑셀에서 저장한 CSV는 cp949인 경우가 많음
try:
with open(path, encoding=enc, newline="") as f:
rows = [r for r in csv.reader(f) if r and r[0].strip()]
break
except UnicodeDecodeError:
continue
if rows is None:
print("ERROR: menu.csv 인코딩을 읽을 수 없습니다. UTF-8로 저장해 주세요.")
return 1
items = rows[1:] # 헤더 제외
if not items:
print(f"ERROR: 메뉴 파일에 메뉴가 없습니다: {os.path.abspath(path)}")
return 1
exact = [r for r in items if norm(r[0]) == norm(query)]
partial = [r for r in items if norm(query) in norm(r[0])]
matches = exact or partial
if not matches:
print(f"NOT_FOUND: '{query}' 메뉴가 없습니다.")
print("전체 메뉴: " + ", ".join(r[0].strip() for r in items))
return 0
status = "FOUND" if exact else "PARTIAL"
for r in matches:
name = r[0].strip()
price = r[1].strip().replace(",", "") if len(r) > 1 else ""
category = r[2].strip() if len(r) > 2 else ""
price_txt = f"{int(price):,}원" if price.isdigit() else price
print(f"{status}: {name} | {price_txt} | {category}")
return 0
if __name__ == "__main__":
sys.exit(main())
클로드의 마지막 답
`cafe-helper/` 플러그인을 만들었고, 두 기능 모두 테스트를 통과했습니다. 다만 Claude Code 세션을 직접 띄워서 해 보는 테스트는 권한이 거부돼 못 했습니다. 그래서 "슬래시 명령이 목록에 보이고 실행되는지"는 아직 실제로 확인하지 않은 상태입니다.
## 만든 것
```
cafe-helper/
├── .claude-plugin/plugin.json, marketplace.json # 플러그인 정보 + 설치용 마켓플레이스(cafe-marketplace)
├── skills/menu-price/SKILL.md, scripts/lookup.py # /cafe-helper:menu-price
├── hooks/hooks.json, scripts/block_env.py # .env 차단
└── README.md # 사용법·설치법
```
**1. `/cafe-helper:menu-price 아메리카노`**
- 정확히 일치하면 → "아메리카노 4,500원 (커피)"
- 일부만 일치하면(예: `라떼`) → 카페라떼·바닐라라떼를 함께 보여 줌
- 없는 메뉴면(예: `녹차`) → 없다고 알리고 전체 메뉴 목록을 보여 줌
- 띄어쓰기는 무시하고, 엑셀에서 저장한 CSV(cp949)나 `5,800`처럼 쉼표가 들어간 가격도 처리합니다.
- "치즈케이크 얼마야?"처럼 그냥 물어봐도 동작하도록 해 뒀습니다.
**2. `.env` 차단**
- Claude가 읽기·수정·Bash 명령·Grep으로 `.env`에 접근하면 막습니다.
- `.env.local`, `.env.production.local` 같은 변형과 대문자 `.ENV`도 막습니다.
- `.env.example` 같은 예시 파일과 `.envrc`는 허용합니다.
## 확인한 내용
- **`.env` 차단:** 30가지 입력을 넣어 봤고 모두 기대대로 차단되거나 허용됐습니다. 실제 이 폴더의 `.env`에 대한 접근도 차단되는 것을 확인했습니다.
- **가격 조회:** 정확 일치, 부분 일치, 없는 메뉴, 빈 파일, cp949 파일 경우를 모두 확인했습니다.
- **구조 검사:** `claude plugin validate`가 플러그인과 마켓플레이스 양쪽에서 통과했습니다.
- **Grep 구멍 수정:** 폴더 전체를 필터 없이 Grep하면 `.env` 내용이 그대로 검색되는 것을 가짜 `.env`로 직접 확인했습니다. 이 경우를 막고, Claude에게 `.env`를 빼고 다시 검색하라고 안내하도록 고쳤습니다.
- **검사 에이전트 지적 반영:** 검사 에이전트가 지적한 문제를 모두 고쳤습니다. 메뉴 조회가 python3 명령 전체가 아니라 조회 스크립트만 자동 실행하도록 범위를 좁혔고, 나머지는 위에 적은 인코딩·쉼표·대문자·변형 파일명 처리입니다.
## 직접 확인해 주실 것
1. `claude --plugin-dir ./cafe-helper`로 실행한 뒤 `/cafe-helper:menu-price 아메리카노`를 입력해 보세요.
2. "`.env` 보여 줘"라고 했을 때 차단 메시지가 나오는지 확인해 주세요.
3. 메뉴를 조회할 때마다 실행 허락을 묻는다면, 자동 허용 범위를 좁힌 설정이 안 먹힌 것입니다. 사용에는 문제가 없고 허락만 누르면 됩니다.
## 알아 두실 점
- `.env` 차단은 실수를 막는 장치일 뿐 모든 우회를 막지는 못합니다. `cat .e*`나 `grep -r 키워드 .`처럼 명령에 파일 이름이 드러나지 않으면 통과합니다.
- 직원 PC에 `python3`가 있어야 합니다. 없으면 `.env` 차단이 조용히 꺼집니다.
- `menu.csv`가 있는 폴더에서 Claude Code를 켜야 가격을 찾을 수 있습니다.
## 다른 직원 설치 방법
**공유 폴더로 설치 (가장 간단)**
`cafe-helper` 폴더를 공유 드라이브에 두고, 각자 Claude Code 안에서 아래를 입력한 뒤 Claude Code를 다시 켜면 됩니다.
```
/plugin marketplace add /공유폴더/경로/cafe-helper
/plugin install cafe-helper@cafe-marketplace
```
**GitHub로 설치**
`cafe-helper` 폴더 내용을 저장소의 맨 위에 두는 저장소를 만들고 아래를 입력합니다. 업데이트는 `/plugin marketplace update cafe-marketplace`로 받습니다.
```
/plugin marketplace add 계정/저장소
/plugin install cafe-helper@cafe-marketplace
```
같이 쓰는 프로젝트의 `.claude/settings.json`에 넣어 그 폴더를 열면 설치를 안내하게 하는 방법도 README에 적어 두었습니다.
리뷰 글로 돌아가기