diff --git a/.claude/skills/new-project/scripts/verify.sh b/.claude/skills/new-project/scripts/verify.sh new file mode 100755 index 0000000..e7857d7 --- /dev/null +++ b/.claude/skills/new-project/scripts/verify.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +# verify.sh — 检查项目工具链配置是否完整 +# +# 用法: +# ./scripts/verify.sh [OPTIONS] [PROJECT_PATH] +# +# 选项: +# -h, --help 显示帮助信息 +# -v, --verbose 详细输出(显示所有通过项和跳过项) +# -s, --strict 严格模式:警告也视为失败 +# +# 退出码: +# 0 全部通过(严格模式下无警告且无失败) +# 1 存在失败项 +# 2 命令行参数错误 + +set -euo pipefail + +# ── 默认值 ──────────────────────────────────────────────────────────────────── +VERBOSE=0 +STRICT=0 +ROOT="." + +# ── 全局计数(按分类) ──────────────────────────────────────────────────────── +# 用索引数组而非关联数组(declare -A),以兼容 macOS 自带的 bash 3.2。 +CATEGORIES=("根目录" "Backend" "Agent" "Frontend" "Pre-commit 钩子" "Claude 规则文件" "工具链可用性") +CAT_PASS=(); CAT_WARN=(); CAT_FAIL=() +for _i in "${!CATEGORIES[@]}"; do CAT_PASS[$_i]=0; CAT_WARN[$_i]=0; CAT_FAIL[$_i]=0; done +TOTAL_PASS=0 +TOTAL_WARN=0 +TOTAL_FAIL=0 +CURRENT_CAT="" +CURRENT_IDX=-1 + +# ── 颜色 ────────────────────────────────────────────────────────────────────── +if [ -t 1 ]; then + C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RED='\033[31m' + C_CYAN='\033[36m'; C_BOLD='\033[1m'; C_RESET='\033[0m' +else + C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_BOLD=''; C_RESET='' +fi + +# ── 帮助 ────────────────────────────────────────────────────────────────────── +usage() { + cat <&2; usage >&2; exit 2 ;; + *) ROOT="$1"; shift ;; + esac +done + +ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || { echo "路径不存在: $ROOT" >&2; exit 2; } + +# ── 输出辅助 ────────────────────────────────────────────────────────────────── +log_pass() { + TOTAL_PASS=$((TOTAL_PASS + 1)) + [ "$CURRENT_IDX" -ge 0 ] && CAT_PASS[$CURRENT_IDX]=$(( ${CAT_PASS[$CURRENT_IDX]:-0} + 1 )) + [ "$VERBOSE" -eq 1 ] && printf " ${C_GREEN}✓${C_RESET} %s\n" "$*" + return 0 +} + +log_warn() { + TOTAL_WARN=$((TOTAL_WARN + 1)) + [ "$CURRENT_IDX" -ge 0 ] && CAT_WARN[$CURRENT_IDX]=$(( ${CAT_WARN[$CURRENT_IDX]:-0} + 1 )) + printf " ${C_YELLOW}△${C_RESET} %s\n" "$*" +} + +log_fail() { + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + [ "$CURRENT_IDX" -ge 0 ] && CAT_FAIL[$CURRENT_IDX]=$(( ${CAT_FAIL[$CURRENT_IDX]:-0} + 1 )) + printf " ${C_RED}✗${C_RESET} %s\n" "$*" +} + +log_skip() { + [ "$VERBOSE" -eq 1 ] && printf " ${C_CYAN}–${C_RESET} %s\n" "$*" + return 0 +} + +section() { + CURRENT_CAT="$1" + CURRENT_IDX=-1 + local i + for i in "${!CATEGORIES[@]}"; do + [ "${CATEGORIES[$i]}" = "$1" ] && { CURRENT_IDX=$i; break; } + done + printf "\n${C_BOLD}[ %s ]${C_RESET}\n" "$1" +} + +# ── 检查函数 ────────────────────────────────────────────────────────────────── +# 检查文件或目录是否存在 +check() { + local label="$1" path="$2" severity="${3:-fail}" + if [ -e "$ROOT/$path" ]; then + log_pass "$label" + else + [ "$severity" = "warn" ] && log_warn "$label — 缺失: $path" || log_fail "$label — 缺失: $path" + fi +} + +# 检查文件中是否包含指定内容 +check_contains() { + local label="$1" path="$2" pattern="$3" severity="${4:-fail}" + if [ ! -e "$ROOT/$path" ]; then + log_skip "$label — 文件不存在,跳过内容检查: $path" + return + fi + if grep -q "$pattern" "$ROOT/$path" 2>/dev/null; then + log_pass "$label" + else + [ "$severity" = "warn" ] \ + && log_warn "$label — 未找到 '$pattern' 在 $path" \ + || log_fail "$label — 未找到 '$pattern' 在 $path" + fi +} + +# 检查命令是否可用 +check_cmd() { + local label="$1" cmd="$2" severity="${3:-warn}" + if command -v "$cmd" &>/dev/null; then + local ver + ver=$(${cmd} --version 2>/dev/null | head -1 || echo "版本未知") + log_pass "$label ($ver)" + else + [ "$severity" = "warn" ] && log_warn "$label — 未安装: $cmd" || log_fail "$label — 未安装: $cmd" + fi +} + +# ── 读取 copier 配置(可选)──────────────────────────────────────────────────── +COPIER_ANSWERS="$ROOT/.copier-answers.yml" +LLM_PROVIDER="" +DB_TYPE="" +if [ -f "$COPIER_ANSWERS" ]; then + LLM_PROVIDER=$(grep "^llm_provider:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true) + DB_TYPE=$(grep "^database:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true) +fi + +# ── 开始检查 ────────────────────────────────────────────────────────────────── +printf "${C_BOLD}═══ 工具链合规检查: %s ═══${C_RESET}\n" "$ROOT" +[ -n "$LLM_PROVIDER" ] && printf "${C_CYAN}配置来源:.copier-answers.yml llm_provider=%s database=%s${C_RESET}\n" \ + "${LLM_PROVIDER:-?}" "${DB_TYPE:-?}" + +# ── 根目录 ──────────────────────────────────────────────────────────────────── +section "根目录" +check "Git 仓库" ".git" +check "Pre-commit 配置" ".pre-commit-config.yaml" +check "Gitignore" ".gitignore" +check "CLAUDE.md" "CLAUDE.md" +check "Docker Compose" "docker-compose.yml" "warn" +check "Copier 答案文件" ".copier-answers.yml" "warn" + +# ── Backend ─────────────────────────────────────────────────────────────────── +if [ -d "$ROOT/backend" ]; then + section "Backend" + check "pyproject.toml" "backend/pyproject.toml" + check "uv.lock" "backend/uv.lock" + check ".env.example" "backend/.env.example" + check "Dockerfile" "backend/Dockerfile" "warn" + check_contains "ruff 命名规则 N" "backend/pyproject.toml" '"N"' + check_contains "ruff 禁 print T20" "backend/pyproject.toml" '"T20"' + check_contains "mypy strict" "backend/pyproject.toml" "strict = true" + check_contains "pytest-asyncio 配置" "backend/pyproject.toml" "asyncio_mode" + if [ -n "$DB_TYPE" ]; then + local_driver="aiosqlite" + [ "$DB_TYPE" = "postgresql" ] && local_driver="asyncpg" + check_contains "DB driver ($DB_TYPE)" "backend/pyproject.toml" "$local_driver" + fi +else + log_skip "Backend 目录不存在,跳过 Backend 检查" +fi + +# ── Agent ───────────────────────────────────────────────────────────────────── +if [ -d "$ROOT/agent" ]; then + section "Agent" + check "pyproject.toml" "agent/pyproject.toml" + check "uv.lock" "agent/uv.lock" + check ".env.example" "agent/.env.example" + check "Dockerfile" "agent/Dockerfile" "warn" + check_contains "ruff 命名规则 N" "agent/pyproject.toml" '"N"' + check_contains "ruff 禁 print T20" "agent/pyproject.toml" '"T20"' + check_contains "mypy strict" "agent/pyproject.toml" "strict = true" + check_contains "pytest-asyncio 配置" "agent/pyproject.toml" "asyncio_mode" + if [ -n "$LLM_PROVIDER" ]; then + case "$LLM_PROVIDER" in + gemini-cli) : ;; # pty 是标准库,无需额外包 + openai-api) check_contains "openai SDK" "agent/pyproject.toml" "openai" ;; + anthropic-api) check_contains "anthropic SDK" "agent/pyproject.toml" "anthropic" ;; + esac + fi +else + log_skip "Agent 目录不存在,跳过 Agent 检查" +fi + +# ── Frontend ────────────────────────────────────────────────────────────────── +if [ -d "$ROOT/frontend" ]; then + section "Frontend" + check "package.json" "frontend/package.json" + check "pnpm-lock.yaml" "frontend/pnpm-lock.yaml" + check ".env.example" "frontend/.env.example" + check "tsconfig.json" "frontend/tsconfig.json" + check "vite.config.ts" "frontend/vite.config.ts" + check "vitest.config.ts" "frontend/vitest.config.ts" "warn" + check "eslint.config.js" "frontend/eslint.config.js" + check_contains "禁 console.log" "frontend/eslint.config.js" "no-console" + check_contains "命名约定规则" "frontend/eslint.config.js" "naming-convention" + check_contains "TypeScript strict" "frontend/tsconfig.json" '"strict": true' +else + log_skip "Frontend 目录不存在,跳过 Frontend 检查" +fi + +# ── Pre-commit 钩子 ─────────────────────────────────────────────────────────── +if [ -f "$ROOT/.pre-commit-config.yaml" ]; then + section "Pre-commit 钩子" + check_contains "Conventional Commits" ".pre-commit-config.yaml" "conventional-pre-commit" + check_contains "ruff lint/format" ".pre-commit-config.yaml" "ruff" + check_contains "防密钥泄漏" ".pre-commit-config.yaml" "detect-private-key" + check_contains "gitleaks" ".pre-commit-config.yaml" "gitleaks" "warn" + check "Git hooks 已安装" ".git/hooks/pre-commit" "warn" +fi + +# ── Claude 规则文件 ─────────────────────────────────────────────────────────── +if [ -d "$ROOT/.claude/rules" ]; then + section "Claude 规则文件" + RULES_DIR="$ROOT/.claude/rules" + + # 全局规则(原理层,始终期望存在) + GLOBAL_RULES=(architecture.md ddd.md design-discipline.md communication-protocol.md + testing-tdd.md quality-gates.md security.md config.md + git-workflow.md conventions.md) + for r in "${GLOBAL_RULES[@]}"; do + check "全局规则: $r" ".claude/rules/$r" + done + + # Backend 落地层规则 + if [ -d "$ROOT/backend" ]; then + check "Backend 规则: backend.md" ".claude/rules/backend.md" + check "Backend 规则: backend-db.md" ".claude/rules/backend-db.md" + check "Backend 规则: backend-concurrency.md" ".claude/rules/backend-concurrency.md" + fi + + # Agent 落地层规则 + if [ -d "$ROOT/agent" ]; then + check "Agent 规则: agent.md" ".claude/rules/agent.md" + check "Agent 规则: agent-dag.md" ".claude/rules/agent-dag.md" + check "Agent 规则: agent-concurrency.md" ".claude/rules/agent-concurrency.md" + check "Agent 规则: agent-extension-points.md" ".claude/rules/agent-extension-points.md" + + # LLM 驱动层规则:按 llm_provider 检查正确的变体 + if [ -z "$LLM_PROVIDER" ] || [ "$LLM_PROVIDER" = "gemini-cli" ]; then + check "Agent LLM 驱动规则: agent-llm-pty.md" ".claude/rules/agent-llm-pty.md" + if [ -f "$RULES_DIR/agent-llm-api.md" ]; then + log_warn "Agent LLM 驱动规则冲突 — agent-llm-api.md 不应与 gemini-cli 共存" + fi + else + check "Agent LLM 驱动规则: agent-llm-api.md" ".claude/rules/agent-llm-api.md" + if [ -f "$RULES_DIR/agent-llm-pty.md" ]; then + log_warn "Agent LLM 驱动规则冲突 — agent-llm-pty.md 不应与 $LLM_PROVIDER 共存" + fi + fi + fi + + # Frontend 落地层规则 + if [ -d "$ROOT/frontend" ]; then + check "Frontend 规则: frontend.md" ".claude/rules/frontend.md" + check "Frontend 规则: frontend-runtime.md" ".claude/rules/frontend-runtime.md" + fi + + # 跨服务规则 + if [ -d "$ROOT/backend" ] && [ -d "$ROOT/agent" ]; then + check "跨服务规则: data-lifecycle.md" ".claude/rules/data-lifecycle.md" + check "跨服务规则: communication-catalog.md" ".claude/rules/communication-catalog.md" "warn" + check "跨服务规则: error-codes.md" ".claude/rules/error-codes.md" "warn" + fi +else + log_warn ".claude/rules/ 目录不存在 — Claude 规则文件未部署" +fi + +# ── 工具链可用性(信息性,不影响退出码)───────────────────────────────────── +section "工具链可用性" +check_cmd "uv" "uv" "warn" +check_cmd "pnpm" "pnpm" "warn" +check_cmd "pre-commit" "pre-commit" "warn" +check_cmd "copier" "copier" "warn" +if [ -d "$ROOT/agent" ]; then + check_cmd "playwright" "playwright" "warn" +fi + +# ── 汇总 ────────────────────────────────────────────────────────────────────── +printf "\n${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n" +printf "${C_BOLD}%-22s %6s %6s %6s${C_RESET}\n" "分类" "通过" "警告" "失败" +printf "───────────────────────────────────────────────────────────────\n" +for i in "${!CATEGORIES[@]}"; do + cat="${CATEGORIES[$i]}" + p=${CAT_PASS[$i]:-0}; w=${CAT_WARN[$i]:-0}; f=${CAT_FAIL[$i]:-0} + [ $((p + w + f)) -eq 0 ] && continue + wc="${C_RESET}"; fc="${C_RESET}" + [ "$w" -gt 0 ] && wc="${C_YELLOW}" + [ "$f" -gt 0 ] && fc="${C_RED}" + printf "%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}\n" \ + "$cat" "$p" "$w" "$f" +done +printf "───────────────────────────────────────────────────────────────\n" +wc="${C_RESET}"; fc="${C_RESET}" +[ "$TOTAL_WARN" -gt 0 ] && wc="${C_YELLOW}" +[ "$TOTAL_FAIL" -gt 0 ] && fc="${C_RED}" +printf "${C_BOLD}%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}${C_BOLD}${C_RESET}\n" \ + "合计" "$TOTAL_PASS" "$TOTAL_WARN" "$TOTAL_FAIL" +printf "${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n" + +# 最终判定 +if [ "$TOTAL_FAIL" -gt 0 ]; then + printf "\n${C_RED}${C_BOLD}✗ %d 项失败,项目尚不合规${C_RESET}\n" "$TOTAL_FAIL" + [ "$TOTAL_WARN" -gt 0 ] && printf "${C_YELLOW}△ %d 项警告(非阻塞)${C_RESET}\n" "$TOTAL_WARN" + exit 1 +elif [ "$STRICT" -eq 1 ] && [ "$TOTAL_WARN" -gt 0 ]; then + printf "\n${C_YELLOW}${C_BOLD}△ 严格模式:%d 项警告视为失败${C_RESET}\n" "$TOTAL_WARN" + exit 1 +elif [ "$TOTAL_WARN" -gt 0 ]; then + printf "\n${C_GREEN}${C_BOLD}✓ 核心检查全部通过${C_RESET} ${C_YELLOW}△ %d 项警告(建议修复)${C_RESET}\n" "$TOTAL_WARN" + exit 0 +else + printf "\n${C_GREEN}${C_BOLD}✓ 全部 %d 项通过,项目完全合规${C_RESET}\n" "$TOTAL_PASS" + exit 0 +fi diff --git a/README.md b/README.md index 8aef908..e61ff4c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scrip 可用 preset:`principles`(仅规则)· `fullstack` · `fullstack-ai` · `api-only`。 -> 模板由 copier 从 GitHub 实时拉取。`verify.sh` 在仓库根 `scripts/` 单一维护,安装时由 `install-skill.sh` 组装进 skill,使安装后的 skill 自包含。 +> 模板由 copier 从 GitHub 实时拉取。skill 目录 `.claude/skills/new-project/` **自包含**(SKILL.md + scripts/verify.sh),安装即整目录拷贝,符合 skill 可移植惯例。 --- @@ -139,12 +139,13 @@ backend/src/ agent/src/ frontend/sr scaffold/ ├── copier.yml ← 问卷变量 + 条件排除逻辑 ├── README.md -├── .claude/skills/new-project/ -│ └── SKILL.md ← skill 元数据 + 生成指令(verify.sh 安装时组装) -├── .github/workflows/docs-check.yml ← scaffold 自身 CI:防 README 与 template 漂移 +├── .claude/skills/new-project/ ← 自包含 skill(拷了即用,符合惯例) +│ ├── SKILL.md ← skill 元数据 + 生成指令 +│ └── scripts/verify.sh ← skill 自带(与根 scripts/verify.sh 同步,CI 防漂移) +├── .github/workflows/docs-check.yml ← scaffold 自身 CI:防文档/verify.sh 漂移 ├── scripts/ -│ ├── verify.sh ← 工具链合规检查(幂等只读,单一源) -│ ├── install-skill.sh ← 把 skill 安装到 ~/.claude/skills/ +│ ├── verify.sh ← 工具链合规检查(幂等只读,维护/retrofit 用) +│ ├── install-skill.sh ← 整目录拷贝安装 skill 到 ~/.claude/skills/ │ ├── check-docs.sh ← 校验 README 覆盖 template 架构基座目录 │ └── retrofit.sh ← 补装工具链到已有项目 └── template/ diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 91d7c30..8b90be0 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# 校验 README.md 是否记录了 template/ 的架构基座目录,防止文档随模板改动漂移。 -# 仅作用于 scaffold 仓库自身(不进生成项目、不属 skill)。 -# 用法:./scripts/check-docs.sh 退出码 0=同步 / 1=有未记录目录 +# scaffold 仓库自身的一致性检查(不进生成项目、不属 skill): +# 1) README 是否记录了 template/ 的架构基座目录(防文档漂移) +# 2) skill 自包含的 verify.sh 是否与根 scripts/verify.sh 同步(防两份漂移) +# 用法:./scripts/check-docs.sh 退出码 0=一致 / 1=有问题 set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -34,10 +35,24 @@ for entry in ".vscode" ".claude/commands" ".copier-answers.yml" "pnpm-workspace. fi done +# ── skill 自包含校验:两份 verify.sh 必须一致 ────────────────────────────── +ROOT_VERIFY="$ROOT/scripts/verify.sh" +SKILL_VERIFY="$ROOT/.claude/skills/new-project/scripts/verify.sh" +if [ -f "$ROOT_VERIFY" ] && [ -f "$SKILL_VERIFY" ]; then + if ! cmp -s "$ROOT_VERIFY" "$SKILL_VERIFY"; then + echo "✗ scripts/verify.sh 与 skill 内副本不一致" + echo " 改了 scripts/verify.sh 后需同步:cp scripts/verify.sh .claude/skills/new-project/scripts/verify.sh" + fail=1 + fi +else + echo "✗ verify.sh 缺失(根或 skill 内)" + fail=1 +fi + if [ "$fail" -eq 0 ]; then - echo "✓ README 已覆盖 template 的架构基座目录与关键产物" + echo "✓ README 覆盖 template 架构基座目录;skill verify.sh 与根一致" else echo "" - echo "→ 请更新 README.md 的「架构基座」章节与「脚手架内容」文件树后重试。" + echo "→ 修复上述问题后重试(文档更新 / 同步 verify.sh)。" exit 1 fi diff --git a/scripts/install-skill.sh b/scripts/install-skill.sh index 36b196d..7ada08e 100755 --- a/scripts/install-skill.sh +++ b/scripts/install-skill.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash # 把 new-project skill 安装到 ~/.claude/skills/(全局,任意项目可用)。 # -# 单一源原则:verify.sh 只在仓库根 scripts/ 维护一份, -# 本脚本在安装时把它和 SKILL.md 组装进 ~/.claude/skills/new-project/, -# 使安装后的 skill 自包含(无需仓库即可运行验证)。 +# skill 目录是自包含的(SKILL.md + scripts/verify.sh 都在 .claude/skills/new-project/ 内), +# 安装即整目录拷贝——符合 skill 自包含、可移植的惯例,无需"组装"。 # # 用法: # curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scripts/install-skill.sh | bash @@ -13,14 +12,12 @@ set -euo pipefail SKILL_NAME="new-project" DEST="$HOME/.claude/skills/$SKILL_NAME" -RAW_BASE="https://raw.githubusercontent.com/baozaotumao2025/scaffold/main" -SKILL_MD_URL="$RAW_BASE/.claude/skills/$SKILL_NAME/SKILL.md" -VERIFY_URL="$RAW_BASE/scripts/verify.sh" +RAW_BASE="https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/.claude/skills/$SKILL_NAME" +# skill 自包含的文件清单(新增文件时在此登记) +FILES=("SKILL.md" "scripts/verify.sh") -# 在仓库内运行时优先用本地文件,否则从 GitHub 拉取 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOCAL_SKILL_MD="$SCRIPT_DIR/../.claude/skills/$SKILL_NAME/SKILL.md" -LOCAL_VERIFY="$SCRIPT_DIR/verify.sh" +LOCAL_SKILL="$SCRIPT_DIR/../.claude/skills/$SKILL_NAME" fetch() { # fetch if command -v curl &>/dev/null; then @@ -35,14 +32,14 @@ fetch() { # fetch mkdir -p "$DEST/scripts" -if [[ -f "$LOCAL_SKILL_MD" && -f "$LOCAL_VERIFY" ]]; then - echo "→ 从本地仓库组装安装" - cp "$LOCAL_SKILL_MD" "$DEST/SKILL.md" - cp "$LOCAL_VERIFY" "$DEST/scripts/verify.sh" +if [[ -d "$LOCAL_SKILL" ]]; then + echo "→ 从本地仓库整目录拷贝" + cp -R "$LOCAL_SKILL/." "$DEST/" else - echo "→ 从 GitHub 组装安装" - fetch "$SKILL_MD_URL" "$DEST/SKILL.md" - fetch "$VERIFY_URL" "$DEST/scripts/verify.sh" + echo "→ 从 GitHub 拉取 skill 文件" + for f in "${FILES[@]}"; do + fetch "$RAW_BASE/$f" "$DEST/$f" + done fi chmod +x "$DEST/scripts/verify.sh"