docs(hooks): document lib/ symlink requirement in installation instru… #17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Clean Environment Install Test | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'claude-dev-toolkit/**' | |
| - 'hooks/**' | |
| - 'scripts/**' | |
| - '.github/workflows/install-clean-env.yml' | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - 'claude-dev-toolkit/**' | |
| - 'hooks/**' | |
| - 'scripts/**' | |
| workflow_dispatch: | |
| # Feature coverage: 15/20 (75%) | |
| # Not testable in CI: oidc (needs AWS), update (needs registry), | |
| # iTerm2 hooks (needs terminal), shell hooks requiring tty | |
| jobs: | |
| clean-install: | |
| name: "Clean install (${{ matrix.os }}, Node ${{ matrix.node }})" | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest] | |
| node: ["18", "20"] | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Verify clean state (no pre-existing .claude) | |
| run: | | |
| if [ -d "$HOME/.claude" ]; then | |
| echo "WARN: ~/.claude exists, removing for clean test" | |
| rm -rf "$HOME/.claude" | |
| fi | |
| echo "Confirmed: no ~/.claude directory" | |
| # --------------------------------------------------------------- | |
| # Install | |
| # --------------------------------------------------------------- | |
| - name: Clean install and pack npm package | |
| working-directory: claude-dev-toolkit | |
| run: | | |
| rm -rf node_modules package-lock.json | |
| npm install --ignore-scripts | |
| npm pack | |
| TARBALL=$(ls *.tgz) | |
| echo "TARBALL=$TARBALL" >> "$GITHUB_ENV" | |
| echo "Packed: $TARBALL" | |
| - name: Install package globally from tarball | |
| working-directory: claude-dev-toolkit | |
| run: npm install -g "./$TARBALL" | |
| # --------------------------------------------------------------- | |
| # CLI basics | |
| # --------------------------------------------------------------- | |
| - name: "Feature: CLI --version and --help" | |
| run: | | |
| claude-commands --version | |
| claude-commands --help | |
| # --------------------------------------------------------------- | |
| # Setup (creates dirs, commands, hooks, config) | |
| # --------------------------------------------------------------- | |
| - name: "Feature: setup (active commands, basic config)" | |
| run: claude-commands setup --type basic --commands active | |
| - name: Verify directory structure created | |
| run: | | |
| FAIL=0 | |
| for dir in "$HOME/.claude" "$HOME/.claude/commands" "$HOME/.claude/hooks"; do | |
| if [ -d "$dir" ]; then | |
| echo "OK: $dir exists" | |
| else | |
| echo "FAIL: $dir missing" | |
| FAIL=1 | |
| fi | |
| done | |
| [ $FAIL -eq 0 ] || exit 1 | |
| - name: Verify commands installed | |
| run: | | |
| COUNT=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| echo "Commands installed: $COUNT" | |
| if [ "$COUNT" -lt 10 ]; then | |
| echo "FAIL: expected >= 10 active commands, got $COUNT" | |
| exit 1 | |
| fi | |
| echo "OK: $COUNT commands installed" | |
| - name: Verify hooks installed | |
| run: | | |
| SHELL_HOOKS=$(ls "$HOME/.claude/hooks/"*.sh 2>/dev/null | wc -l) | |
| echo "Shell hooks installed: $SHELL_HOOKS" | |
| if [ "$SHELL_HOOKS" -lt 1 ]; then | |
| echo "FAIL: no shell hooks installed" | |
| exit 1 | |
| fi | |
| echo "OK: $SHELL_HOOKS shell hooks installed" | |
| - name: Verify settings.json is valid JSON | |
| run: | | |
| SETTINGS="$HOME/.claude/settings.json" | |
| if [ -f "$SETTINGS" ]; then | |
| python3 -c "import json; json.load(open('$SETTINGS'))" | |
| echo "OK: settings.json is valid JSON" | |
| else | |
| echo "WARN: settings.json not created (acceptable for basic template)" | |
| fi | |
| # --------------------------------------------------------------- | |
| # CLI commands: list, status, verify | |
| # --------------------------------------------------------------- | |
| - name: "Feature: list" | |
| run: claude-commands list | |
| - name: "Feature: list --active" | |
| run: claude-commands list --active | |
| - name: "Feature: status" | |
| run: claude-commands status | |
| - name: "Feature: verify" | |
| run: claude-commands verify || true | |
| # --------------------------------------------------------------- | |
| # Config and configure | |
| # --------------------------------------------------------------- | |
| - name: "Feature: config --list" | |
| run: claude-commands config --list | |
| - name: "Feature: configure --validate" | |
| run: claude-commands configure --validate || true | |
| - name: "Feature: configure --template basic" | |
| run: | | |
| claude-commands configure --template basic | |
| SETTINGS="$HOME/.claude/settings.json" | |
| python3 -c "import json; json.load(open('$SETTINGS'))" | |
| echo "OK: settings.json valid after configure --template basic" | |
| - name: "Feature: configure --template security-focused" | |
| run: | | |
| claude-commands configure --template security-focused | |
| SETTINGS="$HOME/.claude/settings.json" | |
| python3 -c "import json; json.load(open('$SETTINGS'))" | |
| echo "OK: settings.json valid after configure --template security-focused" | |
| # --------------------------------------------------------------- | |
| # Subagents | |
| # --------------------------------------------------------------- | |
| - name: "Feature: subagents --list" | |
| run: claude-commands subagents --list | |
| - name: "Feature: subagents --install" | |
| run: claude-commands subagents --install || true | |
| # --------------------------------------------------------------- | |
| # Backup and restore | |
| # --------------------------------------------------------------- | |
| - name: "Feature: backup creates archive" | |
| run: | | |
| claude-commands backup test-snapshot | |
| # Verify backup file was created | |
| if ls "$HOME/.claude/backups/test-snapshot"* 2>/dev/null; then | |
| echo "OK: backup created" | |
| else | |
| echo "FAIL: no backup file found" | |
| ls -la "$HOME/.claude/backups/" 2>/dev/null || echo "No backups dir" | |
| exit 1 | |
| fi | |
| - name: "Feature: restore from backup" | |
| run: | | |
| # Restore (may fail due to compressed backup — known limitation) | |
| if claude-commands restore test-snapshot; then | |
| echo "OK: restore succeeded" | |
| else | |
| echo "WARN: restore failed (known issue with compressed backups)" | |
| fi | |
| # --------------------------------------------------------------- | |
| # Python hook smoke tests | |
| # --------------------------------------------------------------- | |
| - name: "Feature: check-complexity.py blocks smelly code" | |
| run: | | |
| # Create a Python file with a long function (>20 lines) | |
| TMPFILE=$(mktemp /tmp/smelly_XXXX.py) | |
| python3 -c " | |
| lines = ['def long_function():'] | |
| for i in range(25): | |
| lines.append(f' x_{i} = {i}') | |
| print('\n'.join(lines)) | |
| " > "$TMPFILE" | |
| # Pipe a PostToolUse event into check-complexity.py | |
| RESULT=$(echo "{\"tool_input\":{\"file_path\":\"$TMPFILE\"}}" | \ | |
| python3 hooks/check-complexity.py 2>/dev/null || true) | |
| rm -f "$TMPFILE" | |
| if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['decision']=='block'" 2>/dev/null; then | |
| echo "OK: check-complexity.py blocked smelly code" | |
| else | |
| echo "FAIL: check-complexity.py did not block smelly code" | |
| echo "Output: $RESULT" | |
| exit 1 | |
| fi | |
| - name: "Feature: check-complexity.py passes clean code" | |
| run: | | |
| TMPFILE=$(mktemp /tmp/clean_XXXX.py) | |
| echo -e "def hello():\n return 1\n" > "$TMPFILE" | |
| RESULT=$(echo "{\"tool_input\":{\"file_path\":\"$TMPFILE\"}}" | \ | |
| python3 hooks/check-complexity.py 2>/dev/null || true) | |
| rm -f "$TMPFILE" | |
| if [ -z "$RESULT" ]; then | |
| echo "OK: check-complexity.py passed clean code (no output)" | |
| else | |
| echo "FAIL: check-complexity.py unexpectedly blocked clean code" | |
| echo "Output: $RESULT" | |
| exit 1 | |
| fi | |
| - name: "Feature: check-security.py blocks hardcoded secrets" | |
| run: | | |
| TMPFILE=$(mktemp /tmp/secret_XXXX.py) | |
| echo "aws_key = 'AKIAIOSFODNN7ZZZZZZZ'" > "$TMPFILE" | |
| RESULT=$(echo "{\"tool_input\":{\"file_path\":\"$TMPFILE\"}}" | \ | |
| python3 hooks/check-security.py 2>/dev/null || true) | |
| rm -f "$TMPFILE" | |
| if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['decision']=='block'" 2>/dev/null; then | |
| echo "OK: check-security.py blocked hardcoded secret" | |
| else | |
| echo "FAIL: check-security.py did not block hardcoded secret" | |
| echo "Output: $RESULT" | |
| exit 1 | |
| fi | |
| - name: "Feature: check-security.py passes clean code" | |
| run: | | |
| TMPFILE=$(mktemp /tmp/safe_XXXX.py) | |
| echo -e "x = 1\nprint(x)\n" > "$TMPFILE" | |
| RESULT=$(echo "{\"tool_input\":{\"file_path\":\"$TMPFILE\"}}" | \ | |
| python3 hooks/check-security.py 2>/dev/null || true) | |
| rm -f "$TMPFILE" | |
| if [ -z "$RESULT" ]; then | |
| echo "OK: check-security.py passed clean code (no output)" | |
| else | |
| echo "FAIL: check-security.py unexpectedly blocked clean code" | |
| echo "Output: $RESULT" | |
| exit 1 | |
| fi | |
| - name: "Feature: check-commit-signing.py passes non-git commands" | |
| run: | | |
| RESULT=$(echo '{"tool_input":{"command":"ls -la"}}' | \ | |
| python3 hooks/check-commit-signing.py 2>/dev/null || true) | |
| if [ -z "$RESULT" ]; then | |
| echo "OK: check-commit-signing.py passed non-git command" | |
| else | |
| echo "FAIL: check-commit-signing.py blocked a non-git command" | |
| echo "Output: $RESULT" | |
| exit 1 | |
| fi | |
| - name: "Feature: check-commit-signing.py checks git commit" | |
| run: | | |
| RESULT=$(echo '{"tool_input":{"command":"git commit -m test"}}' | \ | |
| python3 hooks/check-commit-signing.py 2>/dev/null || true) | |
| # In CI, signing is not configured, so it should block | |
| if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['decision']=='block'" 2>/dev/null; then | |
| echo "OK: check-commit-signing.py blocked unsigned commit" | |
| else | |
| echo "WARN: commit signing may be configured in CI (passed)" | |
| fi | |
| # --------------------------------------------------------------- | |
| # Install command directly (separate from setup) | |
| # --------------------------------------------------------------- | |
| - name: "Feature: install --all" | |
| run: | | |
| claude-commands install --all | |
| COUNT=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| echo "Commands after install --all: $COUNT" | |
| if [ "$COUNT" -lt 40 ]; then | |
| echo "FAIL: expected >= 40 total commands, got $COUNT" | |
| exit 1 | |
| fi | |
| echo "OK: $COUNT commands installed with --all" | |
| # --------------------------------------------------------------- | |
| # Idempotent re-install | |
| # --------------------------------------------------------------- | |
| - name: "Test: idempotent re-install (no corruption)" | |
| run: | | |
| BEFORE_CMDS=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| claude-commands setup --type basic --commands active | |
| AFTER_CMDS=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| if [ "$BEFORE_CMDS" -ne "$AFTER_CMDS" ]; then | |
| echo "FAIL: command count changed ($BEFORE_CMDS -> $AFTER_CMDS)" | |
| exit 1 | |
| fi | |
| echo "OK: idempotent re-install preserved $AFTER_CMDS commands" | |
| SETTINGS="$HOME/.claude/settings.json" | |
| if [ -f "$SETTINGS" ]; then | |
| python3 -c "import json; json.load(open('$SETTINGS'))" | |
| echo "OK: settings.json still valid after re-install" | |
| fi | |
| # --------------------------------------------------------------- | |
| # User config preservation | |
| # --------------------------------------------------------------- | |
| - name: "Test: install preserves pre-existing user config" | |
| run: | | |
| SETTINGS="$HOME/.claude/settings.json" | |
| cat > "$SETTINGS" << 'USERJSON' | |
| { | |
| "permissions": { | |
| "allow": ["Bash(npm test)", "Read"] | |
| }, | |
| "userCustomField": "do-not-delete" | |
| } | |
| USERJSON | |
| claude-commands setup --type basic --commands active | |
| python3 -c " | |
| import json | |
| with open('$SETTINGS') as f: | |
| data = json.load(f) | |
| print('Valid JSON: OK') | |
| " | |
| echo "OK: user config survived setup" | |
| # --------------------------------------------------------------- | |
| # Uninstall | |
| # --------------------------------------------------------------- | |
| - name: "Feature: uninstall" | |
| run: | | |
| BEFORE=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| claude-commands uninstall | |
| AFTER=$(ls "$HOME/.claude/commands/"*.md 2>/dev/null | wc -l) | |
| echo "Commands before: $BEFORE, after: $AFTER" | |
| if [ "$AFTER" -ge "$BEFORE" ]; then | |
| echo "FAIL: uninstall did not remove any commands" | |
| exit 1 | |
| fi | |
| echo "OK: uninstall removed $((BEFORE - AFTER)) commands" | |
| # --------------------------------------------------------------- | |
| # Cleanup | |
| # --------------------------------------------------------------- | |
| - name: Clean up global install | |
| if: always() | |
| run: npm uninstall -g @paulduvall/claude-dev-toolkit || true |