cc-safe-setup 28.3.5 → 28.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/examples/auto-compact-prep.sh +31 -0
- package/examples/auto-git-checkpoint.sh +15 -0
- package/examples/edit-verify.sh +30 -0
- package/examples/session-state-saver.sh +30 -0
- package/examples/subagent-budget-guard.sh +34 -0
- package/index.mjs +221 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**One command to make Claude Code safe for autonomous operation.** [日本語](docs/README.ja.md)
|
|
8
8
|
|
|
9
|
-
8 built-in +
|
|
9
|
+
8 built-in + 319 examples = **327 hooks**. 45 CLI commands. 955 tests. 5 languages. [**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Hub**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Wizard](https://yurukusa.github.io/cc-safe-setup/wizard.html) · [Cheat Sheet](https://yurukusa.github.io/cc-safe-setup/hooks-cheatsheet.html) · [Builder](https://yurukusa.github.io/cc-safe-setup/builder.html) · [FAQ](https://yurukusa.github.io/cc-safe-setup/faq.html) · [Examples](https://yurukusa.github.io/cc-safe-setup/by-example.html) · [Matrix](https://yurukusa.github.io/cc-safe-setup/matrix.html) · [Playground](https://yurukusa.github.io/cc-hook-registry/playground.html)
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx cc-safe-setup
|
|
@@ -110,6 +110,8 @@ Each hook exists because a real incident happened without it.
|
|
|
110
110
|
| `--init-project` | Full project setup (hooks + CLAUDE.md + CI) |
|
|
111
111
|
| `--score` | CI-friendly safety score (exit 1 if below threshold) |
|
|
112
112
|
| `--test-hook <name>` | Test a specific hook with sample input |
|
|
113
|
+
| `--simulate "cmd"` | Preview how all hooks react to a command |
|
|
114
|
+
| `--protect <path>` | Block edits to a file or directory |
|
|
113
115
|
| `--changelog` | Show what changed in each version |
|
|
114
116
|
| `--report` | Generate safety report |
|
|
115
117
|
| `--help` | Show help |
|
|
@@ -127,6 +129,11 @@ Each hook exists because a real incident happened without it.
|
|
|
127
129
|
| Choose a safety level | `npx cc-safe-setup --profile strict` |
|
|
128
130
|
| See what Claude blocked today | `npx cc-safe-setup --replay` |
|
|
129
131
|
| Know why a hook exists | `npx cc-safe-setup --why destructive-guard` |
|
|
132
|
+
| Block silent memory file edits | `npx cc-safe-setup --install-example memory-write-guard` |
|
|
133
|
+
| Stop built-in skills editing opaquely | `npx cc-safe-setup --install-example skill-gate` |
|
|
134
|
+
| Diagnose why hooks aren't working | `npx cc-safe-setup --doctor` |
|
|
135
|
+
| Preview how hooks react to a command | `npx cc-safe-setup --simulate "git push origin main"` |
|
|
136
|
+
| Protect a specific file from edits | `npx cc-safe-setup --protect .env` |
|
|
130
137
|
| Migrate from Cursor/Windsurf | [Migration Guide](https://yurukusa.github.io/cc-safe-setup/migration-guide.html) |
|
|
131
138
|
|
|
132
139
|
## How It Works
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
STATE_DIR="${HOME}/.claude"
|
|
3
|
+
COUNTER_FILE="${STATE_DIR}/session-call-count"
|
|
4
|
+
PREP_FLAG="${STATE_DIR}/compact-prep-done"
|
|
5
|
+
CHECKPOINT=".claude/pre-compact-checkpoint.md"
|
|
6
|
+
COUNT=0
|
|
7
|
+
[ -f "$COUNTER_FILE" ] && COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
8
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
9
|
+
COUNT=1
|
|
10
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
11
|
+
else
|
|
12
|
+
COUNT=$((COUNT + 1))
|
|
13
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
14
|
+
fi
|
|
15
|
+
THRESHOLD=${CC_COMPACT_PREP_THRESHOLD:-200}
|
|
16
|
+
if (( COUNT >= THRESHOLD )) && [ ! -f "$PREP_FLAG" ]; then
|
|
17
|
+
mkdir -p "$(dirname "$CHECKPOINT")" 2>/dev/null
|
|
18
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "?")
|
|
19
|
+
DIRTY=$(git status --porcelain 2>/dev/null | wc -l)
|
|
20
|
+
LAST_5=$(git log --oneline -5 2>/dev/null)
|
|
21
|
+
cat > "$CHECKPOINT" << CKPT
|
|
22
|
+
Saved: $(date -Iseconds) | Tool call: #${COUNT}
|
|
23
|
+
Branch: ${BRANCH} | Dirty files: ${DIRTY}
|
|
24
|
+
${LAST_5}
|
|
25
|
+
Read this file to understand what you were working on before context was compacted.
|
|
26
|
+
Check git status and git log for current state. Continue from the last commit.
|
|
27
|
+
CKPT
|
|
28
|
+
touch "$PREP_FLAG"
|
|
29
|
+
echo "NOTICE: Context may compact soon (call #${COUNT}). Checkpoint saved to ${CHECKPOINT}" >&2
|
|
30
|
+
fi
|
|
31
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
5
|
+
[ -z "$FILE" ] && exit 0
|
|
6
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
7
|
+
[ ! -f "$FILE" ] && exit 0
|
|
8
|
+
CKPT_DIR=".claude/checkpoints"
|
|
9
|
+
mkdir -p "$CKPT_DIR" 2>/dev/null
|
|
10
|
+
BASENAME=$(basename "$FILE")
|
|
11
|
+
TIMESTAMP=$(date +%H%M%S)
|
|
12
|
+
CKPT_FILE="${CKPT_DIR}/${BASENAME}.${TIMESTAMP}.bak"
|
|
13
|
+
cp "$FILE" "$CKPT_FILE" 2>/dev/null
|
|
14
|
+
ls -t "${CKPT_DIR}/${BASENAME}".*.bak 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
|
15
|
+
exit 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
5
|
+
[ -z "$FILE" ] && exit 0
|
|
6
|
+
[ ! -f "$FILE" ] && { echo "WARNING: File does not exist after edit: $FILE" >&2; exit 0; }
|
|
7
|
+
SIZE=$(wc -c < "$FILE" 2>/dev/null || echo 0)
|
|
8
|
+
if [ "$SIZE" -eq 0 ]; then
|
|
9
|
+
echo "WARNING: File is empty after edit: $FILE (possible truncation)" >&2
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
if [ "$TOOL" = "Edit" ]; then
|
|
13
|
+
NEW_STR=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null)
|
|
14
|
+
if [ -n "$NEW_STR" ]; then
|
|
15
|
+
FIRST_LINE=$(echo "$NEW_STR" | head -1)
|
|
16
|
+
if [ -n "$FIRST_LINE" ] && ! grep -qF "$FIRST_LINE" "$FILE" 2>/dev/null; then
|
|
17
|
+
echo "WARNING: Edit may not have applied — new_string not found in $FILE" >&2
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
fi
|
|
21
|
+
if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$FILE" 2>/dev/null; then
|
|
22
|
+
echo "WARNING: Merge conflict markers detected in $FILE after edit" >&2
|
|
23
|
+
fi
|
|
24
|
+
if [ "$SIZE" -lt 10 ]; then
|
|
25
|
+
case "$FILE" in
|
|
26
|
+
*.json|*.yaml|*.yml|*.toml) ;; # Config files can be small
|
|
27
|
+
*) echo "WARNING: File suspiciously small ($SIZE bytes) after edit: $FILE" >&2 ;;
|
|
28
|
+
esac
|
|
29
|
+
fi
|
|
30
|
+
exit 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
STATE_DIR="${HOME}/.claude"
|
|
4
|
+
COUNTER_FILE="${STATE_DIR}/session-call-count"
|
|
5
|
+
STATE_FILE=".claude/session-state.md"
|
|
6
|
+
SAVE_INTERVAL=${CC_STATE_SAVE_INTERVAL:-50}
|
|
7
|
+
mkdir -p "${STATE_DIR}" 2>/dev/null
|
|
8
|
+
mkdir -p "$(dirname "${STATE_FILE}")" 2>/dev/null
|
|
9
|
+
COUNT=0
|
|
10
|
+
[ -f "$COUNTER_FILE" ] && COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
11
|
+
COUNT=$((COUNT + 1))
|
|
12
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
13
|
+
if (( COUNT % SAVE_INTERVAL == 0 )); then
|
|
14
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
|
15
|
+
MODIFIED=$(git diff --name-only 2>/dev/null | head -10)
|
|
16
|
+
STAGED=$(git diff --cached --name-only 2>/dev/null | head -10)
|
|
17
|
+
RECENT_COMMITS=$(git log --oneline -5 2>/dev/null)
|
|
18
|
+
cat > "$STATE_FILE" << STATE
|
|
19
|
+
Updated: $(date -Iseconds)
|
|
20
|
+
${BRANCH}
|
|
21
|
+
${MODIFIED:-none}
|
|
22
|
+
${STAGED:-none}
|
|
23
|
+
${RECENT_COMMITS:-none}
|
|
24
|
+
${COUNT}
|
|
25
|
+
---
|
|
26
|
+
*Read this file after compaction to restore context.*
|
|
27
|
+
STATE
|
|
28
|
+
echo "Session state saved (call #${COUNT})" >&2
|
|
29
|
+
fi
|
|
30
|
+
exit 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
[[ "$TOOL" != "Agent" ]] && exit 0
|
|
4
|
+
MAX_AGENTS=${CC_MAX_SUBAGENTS:-5}
|
|
5
|
+
TRACKER="${HOME}/.claude/active-agents"
|
|
6
|
+
mkdir -p "$(dirname "$TRACKER")" 2>/dev/null
|
|
7
|
+
NOW=$(date +%s)
|
|
8
|
+
ACTIVE=0
|
|
9
|
+
if [ -f "$TRACKER" ]; then
|
|
10
|
+
while IFS= read -r line; do
|
|
11
|
+
TS=$(echo "$line" | cut -d'|' -f1)
|
|
12
|
+
AGE=$(( NOW - TS ))
|
|
13
|
+
if (( AGE < 1800 )); then
|
|
14
|
+
ACTIVE=$((ACTIVE + 1))
|
|
15
|
+
fi
|
|
16
|
+
done < "$TRACKER"
|
|
17
|
+
fi
|
|
18
|
+
if (( ACTIVE >= MAX_AGENTS )); then
|
|
19
|
+
echo "BLOCKED: $ACTIVE active subagents (max: $MAX_AGENTS)." >&2
|
|
20
|
+
echo "Wait for existing agents to complete before spawning more." >&2
|
|
21
|
+
echo "Override: CC_MAX_SUBAGENTS=10" >&2
|
|
22
|
+
exit 2
|
|
23
|
+
fi
|
|
24
|
+
echo "${NOW}|agent" >> "$TRACKER"
|
|
25
|
+
if [ -f "$TRACKER" ]; then
|
|
26
|
+
TMP=$(mktemp)
|
|
27
|
+
while IFS= read -r line; do
|
|
28
|
+
TS=$(echo "$line" | cut -d'|' -f1)
|
|
29
|
+
AGE=$(( NOW - TS ))
|
|
30
|
+
(( AGE < 1800 )) && echo "$line"
|
|
31
|
+
done < "$TRACKER" > "$TMP"
|
|
32
|
+
mv "$TMP" "$TRACKER"
|
|
33
|
+
fi
|
|
34
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -114,6 +114,10 @@ const SUGGEST = process.argv.includes('--suggest');
|
|
|
114
114
|
const INIT_PROJECT = process.argv.includes('--init-project');
|
|
115
115
|
const SCORE_ONLY = process.argv.includes('--score');
|
|
116
116
|
const CHANGELOG_CMD = process.argv.includes('--changelog');
|
|
117
|
+
const SIMULATE_IDX = process.argv.findIndex(a => a === '--simulate');
|
|
118
|
+
const SIMULATE_CMD = SIMULATE_IDX !== -1 ? process.argv.slice(SIMULATE_IDX + 1).join(' ') : null;
|
|
119
|
+
const PROTECT_IDX = process.argv.findIndex(a => a === '--protect');
|
|
120
|
+
const PROTECT_PATH = PROTECT_IDX !== -1 ? process.argv[PROTECT_IDX + 1] : null;
|
|
117
121
|
const TEST_HOOK_IDX = process.argv.findIndex(a => a === '--test-hook');
|
|
118
122
|
const TEST_HOOK = TEST_HOOK_IDX !== -1 ? process.argv[TEST_HOOK_IDX + 1] : null;
|
|
119
123
|
const WHY_IDX = process.argv.findIndex(a => a === '--why');
|
|
@@ -147,6 +151,8 @@ if (HELP) {
|
|
|
147
151
|
npx cc-safe-setup --diff <file> Compare your settings with another file
|
|
148
152
|
npx cc-safe-setup --lint Static analysis of hook configuration
|
|
149
153
|
npx cc-safe-setup --doctor Diagnose why hooks aren't working
|
|
154
|
+
npx cc-safe-setup --simulate "rm -rf /" See how hooks react to a command
|
|
155
|
+
npx cc-safe-setup --protect .env Block edits to a specific file/dir
|
|
150
156
|
npx cc-safe-setup --watch Live dashboard of blocked commands
|
|
151
157
|
npx cc-safe-setup --create "<desc>" Generate a custom hook from description
|
|
152
158
|
npx cc-safe-setup --test-hook <name> Test a specific hook with sample inputs
|
|
@@ -4260,6 +4266,218 @@ async function watch() {
|
|
|
4260
4266
|
}
|
|
4261
4267
|
}
|
|
4262
4268
|
|
|
4269
|
+
async function protect(targetPath) {
|
|
4270
|
+
const { resolve, basename } = await import('path');
|
|
4271
|
+
|
|
4272
|
+
console.log();
|
|
4273
|
+
console.log(c.bold + ' cc-safe-setup --protect' + c.reset);
|
|
4274
|
+
|
|
4275
|
+
// Normalize path
|
|
4276
|
+
const normalized = targetPath.replace(/\\/g, '/');
|
|
4277
|
+
const safeName = basename(normalized).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
4278
|
+
const hookName = `protect-${safeName}.sh`;
|
|
4279
|
+
const hookPath = join(HOOKS_DIR, hookName);
|
|
4280
|
+
|
|
4281
|
+
// Generate hook script
|
|
4282
|
+
const isDir = normalized.endsWith('/');
|
|
4283
|
+
const pattern = isDir ? `*${normalized}*` : normalized;
|
|
4284
|
+
|
|
4285
|
+
const script = `#!/bin/bash
|
|
4286
|
+
# Auto-generated by: npx cc-safe-setup --protect ${targetPath}
|
|
4287
|
+
# Blocks Edit/Write to: ${normalized}
|
|
4288
|
+
INPUT=$(cat)
|
|
4289
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
4290
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4291
|
+
|
|
4292
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
4293
|
+
[ -z "$FILE" ] && exit 0
|
|
4294
|
+
|
|
4295
|
+
${isDir
|
|
4296
|
+
? `if [[ "$FILE" == *"${normalized}"* ]]; then`
|
|
4297
|
+
: `if [[ "$FILE" == *"${normalized}" ]] || [[ "$FILE" == *"/${normalized}" ]]; then`
|
|
4298
|
+
}
|
|
4299
|
+
jq -n '{"decision":"block","reason":"Protected path: ${normalized}"}'
|
|
4300
|
+
exit 0
|
|
4301
|
+
fi
|
|
4302
|
+
|
|
4303
|
+
exit 0
|
|
4304
|
+
`;
|
|
4305
|
+
|
|
4306
|
+
// Write hook
|
|
4307
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
4308
|
+
writeFileSync(hookPath, script);
|
|
4309
|
+
chmodSync(hookPath, 0o755);
|
|
4310
|
+
console.log(c.green + ' + ' + c.reset + 'Created ' + hookName);
|
|
4311
|
+
|
|
4312
|
+
// Register in settings.json
|
|
4313
|
+
let settings = {};
|
|
4314
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4315
|
+
try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
4316
|
+
}
|
|
4317
|
+
if (!settings.hooks) settings.hooks = {};
|
|
4318
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
4319
|
+
|
|
4320
|
+
// Find or create Edit|Write matcher
|
|
4321
|
+
let editMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Edit|Write');
|
|
4322
|
+
if (!editMatcher) {
|
|
4323
|
+
editMatcher = { matcher: 'Edit|Write', hooks: [] };
|
|
4324
|
+
settings.hooks.PreToolUse.push(editMatcher);
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
const hookCmd = `bash ${hookPath}`;
|
|
4328
|
+
if (!editMatcher.hooks.some(h => h.command === hookCmd)) {
|
|
4329
|
+
editMatcher.hooks.push({ type: 'command', command: hookCmd });
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
4333
|
+
console.log(c.green + ' + ' + c.reset + 'Registered in settings.json');
|
|
4334
|
+
|
|
4335
|
+
console.log();
|
|
4336
|
+
console.log(c.bold + ' Protected: ' + c.reset + c.blue + normalized + c.reset);
|
|
4337
|
+
console.log(c.dim + ' Edit and Write to this path will be blocked.' + c.reset);
|
|
4338
|
+
console.log(c.dim + ' Restart Claude Code to activate.' + c.reset);
|
|
4339
|
+
console.log();
|
|
4340
|
+
|
|
4341
|
+
// Show how to unprotect
|
|
4342
|
+
console.log(c.dim + ' To unprotect: rm ' + hookPath + c.reset);
|
|
4343
|
+
console.log(c.dim + ' Then remove the entry from ~/.claude/settings.json' + c.reset);
|
|
4344
|
+
console.log();
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
async function simulate(command) {
|
|
4348
|
+
const { spawnSync } = await import('child_process');
|
|
4349
|
+
const { readdirSync } = await import('fs');
|
|
4350
|
+
|
|
4351
|
+
console.log();
|
|
4352
|
+
console.log(c.bold + ' cc-safe-setup --simulate' + c.reset);
|
|
4353
|
+
console.log(c.dim + ' Simulating how hooks would react to:' + c.reset);
|
|
4354
|
+
console.log(c.blue + ' $ ' + command + c.reset);
|
|
4355
|
+
console.log();
|
|
4356
|
+
|
|
4357
|
+
// Build fake PreToolUse JSON for Bash
|
|
4358
|
+
const fakeInput = JSON.stringify({
|
|
4359
|
+
tool_name: 'Bash',
|
|
4360
|
+
tool_input: { command }
|
|
4361
|
+
});
|
|
4362
|
+
|
|
4363
|
+
// Read settings to find registered hooks
|
|
4364
|
+
let settings = {};
|
|
4365
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4366
|
+
try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
const hookEntries = settings.hooks?.PreToolUse || [];
|
|
4370
|
+
let tested = 0;
|
|
4371
|
+
let approved = 0;
|
|
4372
|
+
let blocked = 0;
|
|
4373
|
+
let passthrough = 0;
|
|
4374
|
+
|
|
4375
|
+
for (const entry of hookEntries) {
|
|
4376
|
+
const matcher = entry.matcher || '';
|
|
4377
|
+
// Check if matcher includes Bash (or matches all)
|
|
4378
|
+
if (matcher && !matcher.match(/Bash/i) && matcher !== '') continue;
|
|
4379
|
+
|
|
4380
|
+
for (const h of (entry.hooks || [])) {
|
|
4381
|
+
if (h.type !== 'command') continue;
|
|
4382
|
+
const cmd = h.command;
|
|
4383
|
+
|
|
4384
|
+
// Extract script name for display
|
|
4385
|
+
const parts = cmd.split(/\s+/);
|
|
4386
|
+
const scriptPath = parts[parts.length - 1];
|
|
4387
|
+
const scriptName = scriptPath.split('/').pop().replace('.sh', '');
|
|
4388
|
+
|
|
4389
|
+
// Resolve path
|
|
4390
|
+
const resolved = scriptPath.replace(/^~/, HOME);
|
|
4391
|
+
if (!existsSync(resolved)) {
|
|
4392
|
+
console.log(c.yellow + ' ? ' + c.reset + scriptName + c.dim + ' (script not found)' + c.reset);
|
|
4393
|
+
continue;
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
tested++;
|
|
4397
|
+
|
|
4398
|
+
// Run the hook with fake input
|
|
4399
|
+
const result = spawnSync('bash', [resolved], {
|
|
4400
|
+
input: fakeInput,
|
|
4401
|
+
timeout: 5000,
|
|
4402
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4403
|
+
});
|
|
4404
|
+
|
|
4405
|
+
const stdout = (result.stdout || '').toString().trim();
|
|
4406
|
+
const stderr = (result.stderr || '').toString().trim();
|
|
4407
|
+
const exitCode = result.status;
|
|
4408
|
+
|
|
4409
|
+
if (exitCode === 2) {
|
|
4410
|
+
blocked++;
|
|
4411
|
+
console.log(c.red + ' ✗ BLOCK ' + c.reset + c.bold + scriptName + c.reset);
|
|
4412
|
+
if (stderr) console.log(c.dim + ' ' + stderr.split('\n')[0] + c.reset);
|
|
4413
|
+
} else if (stdout.includes('"approve"') || stdout.includes('"decision":"approve"')) {
|
|
4414
|
+
approved++;
|
|
4415
|
+
let reason = '';
|
|
4416
|
+
try { reason = JSON.parse(stdout).reason || JSON.parse(stdout).decision; } catch {}
|
|
4417
|
+
console.log(c.green + ' ✓ APPROVE ' + c.reset + scriptName + (reason ? c.dim + ' (' + reason + ')' + c.reset : ''));
|
|
4418
|
+
} else {
|
|
4419
|
+
passthrough++;
|
|
4420
|
+
console.log(c.dim + ' · pass ' + scriptName + c.reset);
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
// Also check installed hook scripts not in settings
|
|
4426
|
+
if (existsSync(HOOKS_DIR)) {
|
|
4427
|
+
const hookFiles = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
|
|
4428
|
+
const registeredScripts = new Set();
|
|
4429
|
+
for (const entry of hookEntries) {
|
|
4430
|
+
for (const h of (entry.hooks || [])) {
|
|
4431
|
+
if (h.command) registeredScripts.add(h.command.split('/').pop().replace('.sh', ''));
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
const unregistered = hookFiles.filter(f => !registeredScripts.has(f.replace('.sh', '')));
|
|
4436
|
+
if (unregistered.length > 0) {
|
|
4437
|
+
console.log();
|
|
4438
|
+
console.log(c.dim + ' Unregistered hooks (not in settings.json):' + c.reset);
|
|
4439
|
+
for (const f of unregistered.slice(0, 5)) {
|
|
4440
|
+
const resolved = join(HOOKS_DIR, f);
|
|
4441
|
+
const result = spawnSync('bash', [resolved], {
|
|
4442
|
+
input: fakeInput,
|
|
4443
|
+
timeout: 5000,
|
|
4444
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4445
|
+
});
|
|
4446
|
+
const exitCode = result.status;
|
|
4447
|
+
const stdout = (result.stdout || '').toString().trim();
|
|
4448
|
+
const name = f.replace('.sh', '');
|
|
4449
|
+
|
|
4450
|
+
if (exitCode === 2) {
|
|
4451
|
+
console.log(c.red + ' ✗ BLOCK ' + c.reset + name + c.dim + ' (unregistered)' + c.reset);
|
|
4452
|
+
} else if (stdout.includes('"approve"')) {
|
|
4453
|
+
console.log(c.green + ' ✓ APPROVE ' + c.reset + name + c.dim + ' (unregistered)' + c.reset);
|
|
4454
|
+
} else {
|
|
4455
|
+
console.log(c.dim + ' · pass ' + name + ' (unregistered)' + c.reset);
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
if (unregistered.length > 5) {
|
|
4459
|
+
console.log(c.dim + ' ... and ' + (unregistered.length - 5) + ' more' + c.reset);
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
// Summary
|
|
4465
|
+
console.log();
|
|
4466
|
+
console.log(c.bold + ' Summary: ' + c.reset +
|
|
4467
|
+
(blocked > 0 ? c.red + blocked + ' blocked' + c.reset + ' · ' : '') +
|
|
4468
|
+
(approved > 0 ? c.green + approved + ' approved' + c.reset + ' · ' : '') +
|
|
4469
|
+
passthrough + ' passthrough');
|
|
4470
|
+
|
|
4471
|
+
if (blocked > 0) {
|
|
4472
|
+
console.log(c.red + ' → This command would be BLOCKED' + c.reset);
|
|
4473
|
+
} else if (approved > 0) {
|
|
4474
|
+
console.log(c.green + ' → This command would be auto-approved (no prompt)' + c.reset);
|
|
4475
|
+
} else {
|
|
4476
|
+
console.log(c.dim + ' → This command would trigger a permission prompt' + c.reset);
|
|
4477
|
+
}
|
|
4478
|
+
console.log();
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4263
4481
|
async function doctor() {
|
|
4264
4482
|
const { execSync, spawnSync } = await import('child_process');
|
|
4265
4483
|
const { statSync, readdirSync } = await import('fs');
|
|
@@ -4579,6 +4797,8 @@ async function main() {
|
|
|
4579
4797
|
if (SCAN) return scan();
|
|
4580
4798
|
if (FULL) return fullSetup();
|
|
4581
4799
|
if (DOCTOR) return doctor();
|
|
4800
|
+
if (SIMULATE_CMD) return simulate(SIMULATE_CMD);
|
|
4801
|
+
if (PROTECT_PATH) return protect(PROTECT_PATH);
|
|
4582
4802
|
if (WATCH) return watch();
|
|
4583
4803
|
if (TEST_HOOK_IDX !== -1) return testHook(TEST_HOOK);
|
|
4584
4804
|
if (SAVE_PROFILE_IDX !== -1) return saveProfile(SAVE_PROFILE);
|
|
@@ -4707,7 +4927,7 @@ async function main() {
|
|
|
4707
4927
|
console.log(' ' + c.dim + 'Restart Claude Code to activate.' + c.reset);
|
|
4708
4928
|
console.log(' ' + c.dim + 'Verify:' + c.reset + ' ' + c.blue + 'npx cc-health-check' + c.reset);
|
|
4709
4929
|
console.log();
|
|
4710
|
-
console.log(' ' + c.dim + '
|
|
4930
|
+
console.log(' ' + c.dim + 'Need more? 16 hooks + templates for autonomous teams:' + c.reset);
|
|
4711
4931
|
console.log(' https://yurukusa.github.io/cc-ops-kit-landing/?utm_source=npm&utm_medium=cli&utm_campaign=safe-setup');
|
|
4712
4932
|
console.log();
|
|
4713
4933
|
}
|
package/package.json
CHANGED