claude-code-starter 0.14.1 → 0.15.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/dist/cli.js +414 -14
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { execSync, spawn } from "child_process";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import fs5 from "fs";
|
|
6
|
+
import path5 from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import ora from "ora";
|
|
9
9
|
import pc from "picocolors";
|
|
@@ -599,6 +599,239 @@ function writeSettings(rootDir, stack) {
|
|
|
599
599
|
fs2.writeFileSync(fullPath, content);
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
+
// src/hooks.ts
|
|
603
|
+
import fs3 from "fs";
|
|
604
|
+
import path3 from "path";
|
|
605
|
+
var HOOK_SCRIPT = String.raw`#!/usr/bin/env node
|
|
606
|
+
/**
|
|
607
|
+
* Block Dangerous Commands - PreToolUse Hook for Bash
|
|
608
|
+
* Blocks dangerous patterns before execution.
|
|
609
|
+
*
|
|
610
|
+
* SAFETY_LEVEL: 'critical' | 'high' | 'strict'
|
|
611
|
+
* critical - Only catastrophic: rm -rf ~, dd to disk, fork bombs
|
|
612
|
+
* high - + risky: force push main, secrets exposure, git reset --hard
|
|
613
|
+
* strict - + cautionary: any force push, sudo rm, docker prune
|
|
614
|
+
*/
|
|
615
|
+
|
|
616
|
+
const fs = require('fs');
|
|
617
|
+
const path = require('path');
|
|
618
|
+
|
|
619
|
+
const SAFETY_LEVEL = 'high';
|
|
620
|
+
|
|
621
|
+
const PATTERNS = [
|
|
622
|
+
// CRITICAL — Catastrophic, unrecoverable
|
|
623
|
+
|
|
624
|
+
// Filesystem destruction
|
|
625
|
+
{ level: 'critical', id: 'rm-home', regex: /\brm\s+(-.+\s+)*["']?~\/?["']?(\s|$|[;&|])/, reason: 'rm targeting home directory' },
|
|
626
|
+
{ level: 'critical', id: 'rm-home-var', regex: /\brm\s+(-.+\s+)*["']?\$HOME["']?(\s|$|[;&|])/, reason: 'rm targeting $HOME' },
|
|
627
|
+
{ level: 'critical', id: 'rm-home-trailing', regex: /\brm\s+.+\s+["']?(~\/?|\$HOME)["']?(\s*$|[;&|])/, reason: 'rm with trailing ~/ or $HOME' },
|
|
628
|
+
{ level: 'critical', id: 'rm-root', regex: /\brm\s+(-.+\s+)*\/(\*|\s|$|[;&|])/, reason: 'rm targeting root filesystem' },
|
|
629
|
+
{ level: 'critical', id: 'rm-system', regex: /\brm\s+(-.+\s+)*\/(etc|usr|var|bin|sbin|lib|boot|dev|proc|sys)(\/|\s|$)/, reason: 'rm targeting system directory' },
|
|
630
|
+
{ level: 'critical', id: 'rm-cwd', regex: /\brm\s+(-.+\s+)*(\.\/?|\*|\.\/\*)(\s|$|[;&|])/, reason: 'rm deleting current directory contents' },
|
|
631
|
+
|
|
632
|
+
// Disk operations
|
|
633
|
+
{ level: 'critical', id: 'dd-disk', regex: /\bdd\b.+of=\/dev\/(sd[a-z]|nvme|hd[a-z]|vd[a-z]|xvd[a-z])/, reason: 'dd writing to disk device' },
|
|
634
|
+
{ level: 'critical', id: 'mkfs', regex: /\bmkfs(\.\w+)?\s+\/dev\/(sd[a-z]|nvme|hd[a-z]|vd[a-z])/, reason: 'mkfs formatting disk' },
|
|
635
|
+
{ level: 'critical', id: 'fdisk', regex: /\b(fdisk|wipefs|parted)\s+\/dev\//, reason: 'disk partitioning/wiping operation' },
|
|
636
|
+
|
|
637
|
+
// Shell exploits
|
|
638
|
+
{ level: 'critical', id: 'fork-bomb', regex: /:\(\)\s*\{.*:\s*\|\s*:.*&/, reason: 'fork bomb detected' },
|
|
639
|
+
|
|
640
|
+
// Git — history destruction
|
|
641
|
+
{ level: 'critical', id: 'git-filter', regex: /\bgit\s+(filter-branch|filter-repo)\b/, reason: 'git history rewriting blocked' },
|
|
642
|
+
{ level: 'critical', id: 'git-reflog-exp', regex: /\bgit\s+(reflog\s+expire|gc\s+--prune|prune)\b/, reason: 'removes git recovery safety net' },
|
|
643
|
+
|
|
644
|
+
// HIGH — Significant risk, data loss, security exposure
|
|
645
|
+
|
|
646
|
+
// Remote code execution
|
|
647
|
+
{ level: 'high', id: 'curl-pipe-sh', regex: /\b(curl|wget)\b.+\|\s*(ba)?sh\b/, reason: 'piping URL to shell (RCE risk)' },
|
|
648
|
+
|
|
649
|
+
// Git — destructive operations
|
|
650
|
+
{ level: 'high', id: 'git-force-main', regex: /\bgit\s+push\b(?!.+--force-with-lease).+(--force|-f)\b.+\b(main|master)\b/, reason: 'force push to main/master' },
|
|
651
|
+
{ level: 'high', id: 'git-reset-hard', regex: /\bgit\s+reset\s+--hard/, reason: 'git reset --hard loses uncommitted work' },
|
|
652
|
+
{ level: 'high', id: 'git-clean-f', regex: /\bgit\s+clean\s+(-\w*f|-f)/, reason: 'git clean -f deletes untracked files' },
|
|
653
|
+
{ level: 'high', id: 'git-no-verify', regex: /\bgit\b.+--no-verify/, reason: '--no-verify skips safety hooks' },
|
|
654
|
+
{ level: 'high', id: 'git-stash-destruct', regex: /\bgit\s+stash\s+(drop|clear|pop)\b/, reason: 'destructive git stash operation' },
|
|
655
|
+
{ level: 'high', id: 'git-branch-D', regex: /\bgit\s+branch\s+(-D|--delete\s+--force)\b/, reason: 'git branch -D force-deletes branch' },
|
|
656
|
+
{ level: 'high', id: 'git-checkout-force', regex: /\bgit\s+checkout\s+(-f|--\s+\.)/, reason: 'git checkout -f/-- . discards changes' },
|
|
657
|
+
{ level: 'high', id: 'git-restore-destruct', regex: /\bgit\s+restore\s+(--staged\s+--worktree|\.)/, reason: 'git restore discards changes' },
|
|
658
|
+
{ level: 'high', id: 'git-update-ref', regex: /\bgit\s+(update-ref|symbolic-ref|replace)\b/, reason: 'git ref manipulation blocked' },
|
|
659
|
+
{ level: 'high', id: 'git-config-global', regex: /\bgit\s+config\s+--(global|system)\b/, reason: 'git global/system config blocked' },
|
|
660
|
+
{ level: 'high', id: 'git-tag-delete', regex: /\bgit\s+tag\s+(-d|--delete)\b/, reason: 'git tag deletion blocked' },
|
|
661
|
+
|
|
662
|
+
// Git — write operations (user handles manually)
|
|
663
|
+
{ level: 'high', id: 'git-push', regex: /\bgit\s+push\b/, reason: 'git push blocked — user handles manually' },
|
|
664
|
+
{ level: 'high', id: 'git-pull', regex: /\bgit\s+pull\b/, reason: 'git pull blocked — user handles manually' },
|
|
665
|
+
{ level: 'high', id: 'git-fetch', regex: /\bgit\s+fetch\b/, reason: 'git fetch blocked — user handles manually' },
|
|
666
|
+
{ level: 'high', id: 'git-clone', regex: /\bgit\s+clone\b/, reason: 'git clone blocked — user handles manually' },
|
|
667
|
+
{ level: 'high', id: 'git-add', regex: /\bgit\s+(add|stage)\b/, reason: 'git add/stage blocked — user handles manually' },
|
|
668
|
+
{ level: 'high', id: 'git-commit', regex: /\bgit\s+commit\b/, reason: 'git commit blocked — user handles manually' },
|
|
669
|
+
{ level: 'high', id: 'git-merge', regex: /\bgit\s+merge\b/, reason: 'git merge blocked — user handles manually' },
|
|
670
|
+
{ level: 'high', id: 'git-rebase', regex: /\bgit\s+rebase\b/, reason: 'git rebase blocked — user handles manually' },
|
|
671
|
+
{ level: 'high', id: 'git-reset', regex: /\bgit\s+reset\b/, reason: 'git reset blocked — user handles manually' },
|
|
672
|
+
{ level: 'high', id: 'git-remote-mod', regex: /\bgit\s+remote\s+(add|set-url|remove)\b/, reason: 'git remote modification blocked' },
|
|
673
|
+
{ level: 'high', id: 'git-submodule', regex: /\bgit\s+submodule\s+(add|update)\b/, reason: 'git submodule operation blocked' },
|
|
674
|
+
|
|
675
|
+
// Credentials & secrets
|
|
676
|
+
{ level: 'high', id: 'chmod-777', regex: /\bchmod\b.+\b777\b/, reason: 'chmod 777 is a security risk' },
|
|
677
|
+
{ level: 'high', id: 'cat-env', regex: /\b(cat|less|head|tail|more)\s+\.env\b/, reason: 'reading .env file exposes secrets' },
|
|
678
|
+
{ level: 'high', id: 'cat-secrets', regex: /\b(cat|less|head|tail|more)\b.+(credentials|secrets?|\.pem|\.key|id_rsa|id_ed25519)/i, reason: 'reading secrets file' },
|
|
679
|
+
{ level: 'high', id: 'env-dump', regex: /\b(printenv|^env)\s*([;&|]|$)/, reason: 'env dump may expose secrets' },
|
|
680
|
+
{ level: 'high', id: 'echo-secret', regex: /\becho\b.+\$\w*(SECRET|KEY|TOKEN|PASSWORD|API_|PRIVATE)/i, reason: 'echoing secret variable' },
|
|
681
|
+
{ level: 'high', id: 'rm-ssh', regex: /\brm\b.+\.ssh\/(id_|authorized_keys|known_hosts)/, reason: 'deleting SSH keys' },
|
|
682
|
+
{ level: 'high', id: 'security-keychain', regex: /\bsecurity\s+find-generic-password\b/, reason: 'keychain access blocked' },
|
|
683
|
+
{ level: 'high', id: 'gpg-export-secret', regex: /\bgpg\s+--export-secret-keys\b/, reason: 'GPG secret key export blocked' },
|
|
684
|
+
{ level: 'high', id: 'history-cmd', regex: /\bhistory\b/, reason: 'history may expose secrets' },
|
|
685
|
+
|
|
686
|
+
// Destructive system commands
|
|
687
|
+
{ level: 'high', id: 'elevated-priv', regex: /\b(sudo|doas|pkexec)\b/, reason: 'elevated privilege command blocked' },
|
|
688
|
+
{ level: 'high', id: 'su-cmd', regex: /\bsu\b/, reason: 'su (switch user) blocked' },
|
|
689
|
+
{ level: 'high', id: 'chmod-R', regex: /\bchmod\s+(-\w*R|-R)/, reason: 'recursive chmod blocked' },
|
|
690
|
+
{ level: 'high', id: 'chown-R', regex: /\bchown\s+(-\w*R|-R)/, reason: 'recursive chown blocked' },
|
|
691
|
+
{ level: 'high', id: 'kill-all', regex: /\bkill\s+-9\s+-1\b/, reason: 'kill all processes blocked' },
|
|
692
|
+
{ level: 'high', id: 'killall', regex: /\b(killall|pkill\s+-9)\b/, reason: 'mass process killing blocked' },
|
|
693
|
+
{ level: 'high', id: 'truncate-zero', regex: /\btruncate\s+-s\s*0\b/, reason: 'truncating file to zero blocked' },
|
|
694
|
+
{ level: 'high', id: 'empty-file', regex: /\bcat\s+\/dev\/null\s*>/, reason: 'emptying file via /dev/null blocked' },
|
|
695
|
+
{ level: 'high', id: 'crontab-r', regex: /\bcrontab\s+-r/, reason: 'removes all cron jobs' },
|
|
696
|
+
|
|
697
|
+
// Docker
|
|
698
|
+
{ level: 'high', id: 'docker-vol-rm', regex: /\bdocker\s+volume\s+(rm|prune)/, reason: 'docker volume deletion loses data' },
|
|
699
|
+
{ level: 'high', id: 'docker-push', regex: /\bdocker\s+push\b/, reason: 'docker push blocked' },
|
|
700
|
+
{ level: 'high', id: 'docker-rm-all', regex: /\bdocker\s+rm\s+-f\b.+\$\(docker\s+ps/, reason: 'docker rm all containers blocked' },
|
|
701
|
+
{ level: 'high', id: 'docker-sys-prune-a', regex: /\bdocker\s+system\s+prune\s+-a/, reason: 'docker system prune -a blocked' },
|
|
702
|
+
{ level: 'high', id: 'docker-compose-destr', regex: /\bdocker[\s-]compose\s+down\s+(-v|--rmi)/, reason: 'docker-compose destructive down blocked' },
|
|
703
|
+
|
|
704
|
+
// Publishing & deployment
|
|
705
|
+
{ level: 'high', id: 'npm-publish', regex: /\bnpm\s+(publish|unpublish|deprecate)\b/, reason: 'npm publishing blocked' },
|
|
706
|
+
{ level: 'high', id: 'npm-audit-force', regex: /\bnpm\s+audit\s+fix\s+--force\b/, reason: 'npm audit fix --force can break deps' },
|
|
707
|
+
{ level: 'high', id: 'cargo-publish', regex: /\bcargo\s+publish\b/, reason: 'cargo publish blocked' },
|
|
708
|
+
{ level: 'high', id: 'pip-twine-upload', regex: /\b(pip|twine)\s+upload\b/, reason: 'Python package upload blocked' },
|
|
709
|
+
{ level: 'high', id: 'gem-push', regex: /\bgem\s+push\b/, reason: 'gem push blocked' },
|
|
710
|
+
{ level: 'high', id: 'pod-push', regex: /\bpod\s+trunk\s+push\b/, reason: 'pod trunk push blocked' },
|
|
711
|
+
{ level: 'high', id: 'vercel-prod', regex: /\bvercel\b.+--prod/, reason: 'vercel production deploy blocked' },
|
|
712
|
+
{ level: 'high', id: 'netlify-prod', regex: /\bnetlify\s+deploy\b.+--prod/, reason: 'netlify production deploy blocked' },
|
|
713
|
+
{ level: 'high', id: 'fly-deploy', regex: /\bfly\s+deploy\b/, reason: 'fly deploy blocked' },
|
|
714
|
+
{ level: 'high', id: 'firebase-deploy', regex: /\bfirebase\s+deploy\b/, reason: 'firebase deploy blocked' },
|
|
715
|
+
{ level: 'high', id: 'terraform', regex: /\bterraform\s+(apply|destroy)\b/, reason: 'terraform apply/destroy blocked' },
|
|
716
|
+
{ level: 'high', id: 'pulumi-cdktf', regex: /\b(pulumi|cdktf)\s+destroy\b/, reason: 'infrastructure destroy blocked' },
|
|
717
|
+
{ level: 'high', id: 'kubectl-mutate', regex: /\bkubectl\s+(apply|delete|drain)\b/, reason: 'kubectl mutating operation blocked' },
|
|
718
|
+
{ level: 'high', id: 'kubectl-scale-zero', regex: /\bkubectl\s+scale\b.+--replicas=0/, reason: 'kubectl scale to zero blocked' },
|
|
719
|
+
{ level: 'high', id: 'helm-ops', regex: /\bhelm\s+(install|uninstall|upgrade)\b/, reason: 'helm operation blocked' },
|
|
720
|
+
{ level: 'high', id: 'heroku', regex: /\bheroku\b/, reason: 'heroku command blocked' },
|
|
721
|
+
{ level: 'high', id: 'eb-terminate', regex: /\beb\s+terminate\b/, reason: 'eb terminate blocked' },
|
|
722
|
+
{ level: 'high', id: 'serverless-remove', regex: /\bserverless\s+remove\b/, reason: 'serverless remove blocked' },
|
|
723
|
+
{ level: 'high', id: 'cap-prod-deploy', regex: /\bcap\s+production\s+deploy\b/, reason: 'production deploy blocked' },
|
|
724
|
+
{ level: 'high', id: 'cloud-delete', regex: /\b(aws\s+cloudformation\s+delete-stack|gcloud\s+projects\s+delete|az\s+group\s+delete)\b/, reason: 'cloud resource deletion blocked' },
|
|
725
|
+
|
|
726
|
+
// Network & infrastructure
|
|
727
|
+
{ level: 'high', id: 'curl-mutating', regex: /\bcurl\b.+-X\s*(POST|PUT|DELETE|PATCH)\b/, reason: 'mutating HTTP request blocked' },
|
|
728
|
+
{ level: 'high', id: 'ssh-remote', regex: /\bssh\s/, reason: 'SSH remote connection blocked' },
|
|
729
|
+
{ level: 'high', id: 'scp-remote', regex: /\bscp\s/, reason: 'SCP remote copy blocked' },
|
|
730
|
+
{ level: 'high', id: 'rsync-delete', regex: /\brsync\b.+--delete/, reason: 'rsync --delete blocked' },
|
|
731
|
+
{ level: 'high', id: 'firewall', regex: /\b(iptables\s+-F|ufw\s+disable)\b/, reason: 'firewall manipulation blocked' },
|
|
732
|
+
{ level: 'high', id: 'network-kill', regex: /\bifconfig\s+\w+\s+down\b/, reason: 'network interface down blocked' },
|
|
733
|
+
{ level: 'high', id: 'route-delete', regex: /\broute\s+del\s+default\b/, reason: 'default route deletion blocked' },
|
|
734
|
+
|
|
735
|
+
// Database
|
|
736
|
+
{ level: 'high', id: 'sql-drop', regex: /\b(DROP\s+(DATABASE|TABLE)|TRUNCATE\s+TABLE)\b/i, reason: 'SQL drop/truncate blocked' },
|
|
737
|
+
{ level: 'high', id: 'sql-mass-delete', regex: /\bDELETE\s+FROM\b.+\bWHERE\s+1\s*=\s*1/i, reason: 'SQL mass delete blocked' },
|
|
738
|
+
{ level: 'high', id: 'redis-flush', regex: /\bredis-cli\s+(FLUSHALL|FLUSHDB)\b/, reason: 'redis flush blocked' },
|
|
739
|
+
{ level: 'high', id: 'orm-reset', regex: /\b(prisma\s+migrate\s+reset|rails\s+db:(drop|reset)|django\s+flush)\b/, reason: 'ORM database reset blocked' },
|
|
740
|
+
{ level: 'high', id: 'alembic-downgrade', regex: /\balembic\s+downgrade\s+base\b/, reason: 'alembic downgrade base blocked' },
|
|
741
|
+
{ level: 'high', id: 'mongo-drop', regex: /\bmongosh\b.+dropDatabase/, reason: 'MongoDB drop database blocked' },
|
|
742
|
+
|
|
743
|
+
// STRICT — Cautionary, context-dependent
|
|
744
|
+
{ level: 'strict', id: 'git-checkout-dot', regex: /\bgit\s+checkout\s+\./, reason: 'git checkout . discards changes' },
|
|
745
|
+
{ level: 'strict', id: 'docker-prune', regex: /\bdocker\s+(system|image)\s+prune/, reason: 'docker prune removes images' },
|
|
746
|
+
];
|
|
747
|
+
|
|
748
|
+
const LEVELS = { critical: 1, high: 2, strict: 3 };
|
|
749
|
+
const EMOJIS = { critical: '\u{1F6A8}', high: '\u26D4', strict: '\u26A0\uFE0F' };
|
|
750
|
+
const LOG_DIR = path.join(process.env.HOME || '/tmp', '.claude', 'hooks-logs');
|
|
751
|
+
|
|
752
|
+
function log(data) {
|
|
753
|
+
try {
|
|
754
|
+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
755
|
+
const file = path.join(LOG_DIR, new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
756
|
+
fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...data }) + '\n');
|
|
757
|
+
} catch {}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function checkCommand(cmd, safetyLevel) {
|
|
761
|
+
safetyLevel = safetyLevel || SAFETY_LEVEL;
|
|
762
|
+
const threshold = LEVELS[safetyLevel] || 2;
|
|
763
|
+
for (const p of PATTERNS) {
|
|
764
|
+
if (LEVELS[p.level] <= threshold && p.regex.test(cmd)) {
|
|
765
|
+
return { blocked: true, pattern: p };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return { blocked: false, pattern: null };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function main() {
|
|
772
|
+
let input = '';
|
|
773
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
const data = JSON.parse(input);
|
|
777
|
+
const { tool_name, tool_input, session_id, cwd, permission_mode } = data;
|
|
778
|
+
if (tool_name !== 'Bash') return console.log('{}');
|
|
779
|
+
|
|
780
|
+
const cmd = tool_input?.command || '';
|
|
781
|
+
const result = checkCommand(cmd);
|
|
782
|
+
|
|
783
|
+
if (result.blocked) {
|
|
784
|
+
const p = result.pattern;
|
|
785
|
+
log({ level: 'BLOCKED', id: p.id, priority: p.level, cmd, session_id, cwd, permission_mode });
|
|
786
|
+
return console.log(JSON.stringify({
|
|
787
|
+
hookSpecificOutput: {
|
|
788
|
+
hookEventName: 'PreToolUse',
|
|
789
|
+
permissionDecision: 'deny',
|
|
790
|
+
permissionDecisionReason: EMOJIS[p.level] + ' [' + p.id + '] ' + p.reason
|
|
791
|
+
}
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
794
|
+
console.log('{}');
|
|
795
|
+
} catch (e) {
|
|
796
|
+
log({ level: 'ERROR', error: e.message });
|
|
797
|
+
console.log('{}');
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (require.main === module) {
|
|
802
|
+
main();
|
|
803
|
+
} else {
|
|
804
|
+
module.exports = { PATTERNS, LEVELS, SAFETY_LEVEL, checkCommand };
|
|
805
|
+
}
|
|
806
|
+
`;
|
|
807
|
+
function installHook(rootDir) {
|
|
808
|
+
const hooksDir = path3.join(rootDir, ".claude", "hooks");
|
|
809
|
+
const hookPath = path3.join(hooksDir, "block-dangerous-commands.js");
|
|
810
|
+
const settingsPath = path3.join(rootDir, ".claude", "settings.json");
|
|
811
|
+
fs3.mkdirSync(hooksDir, { recursive: true });
|
|
812
|
+
fs3.writeFileSync(hookPath, HOOK_SCRIPT);
|
|
813
|
+
fs3.chmodSync(hookPath, 493);
|
|
814
|
+
try {
|
|
815
|
+
const existing = fs3.existsSync(settingsPath) ? JSON.parse(fs3.readFileSync(settingsPath, "utf-8")) : {};
|
|
816
|
+
existing.hooks = {
|
|
817
|
+
...existing.hooks,
|
|
818
|
+
PreToolUse: [
|
|
819
|
+
{
|
|
820
|
+
matcher: "Bash",
|
|
821
|
+
hooks: [
|
|
822
|
+
{
|
|
823
|
+
type: "command",
|
|
824
|
+
command: "node .claude/hooks/block-dangerous-commands.js"
|
|
825
|
+
}
|
|
826
|
+
]
|
|
827
|
+
}
|
|
828
|
+
]
|
|
829
|
+
};
|
|
830
|
+
fs3.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
831
|
+
} catch {
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
602
835
|
// src/prompt.ts
|
|
603
836
|
function getAnalysisPrompt(projectInfo) {
|
|
604
837
|
const context = buildContextSection(projectInfo);
|
|
@@ -1272,10 +1505,154 @@ Body: This command delegates to the code-reviewer agent for thorough review.
|
|
|
1272
1505
|
3. If the agent is unavailable, perform a lightweight review: run the linter and check for obvious issues
|
|
1273
1506
|
Do NOT duplicate the code-reviewer agent's checklist here \u2014 the agent has the full review criteria.`;
|
|
1274
1507
|
|
|
1508
|
+
// src/validator.ts
|
|
1509
|
+
import fs4 from "fs";
|
|
1510
|
+
import path4 from "path";
|
|
1511
|
+
function extractCommands(claudeMd) {
|
|
1512
|
+
const commands = [];
|
|
1513
|
+
const match = claudeMd.match(/## Common Commands[\s\S]*?```(?:bash)?\n([\s\S]*?)```/);
|
|
1514
|
+
if (!match) return commands;
|
|
1515
|
+
for (const line of match[1].split("\n")) {
|
|
1516
|
+
const trimmed = line.trim();
|
|
1517
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1518
|
+
const cmd = trimmed.split(/\s+#/)[0].trim();
|
|
1519
|
+
if (cmd.length > 3) commands.push(cmd);
|
|
1520
|
+
}
|
|
1521
|
+
return commands;
|
|
1522
|
+
}
|
|
1523
|
+
function extractConventionFingerprints(claudeMd) {
|
|
1524
|
+
const fingerprints = [];
|
|
1525
|
+
const startIdx = claudeMd.indexOf("## Code Conventions");
|
|
1526
|
+
if (startIdx === -1) return fingerprints;
|
|
1527
|
+
const rest = claudeMd.slice(startIdx + "## Code Conventions".length);
|
|
1528
|
+
const nextHeading = rest.match(/\n## [A-Z]/);
|
|
1529
|
+
const section = nextHeading ? claudeMd.slice(startIdx, startIdx + "## Code Conventions".length + nextHeading.index) : claudeMd.slice(startIdx);
|
|
1530
|
+
for (const kw of ["camelCase", "PascalCase", "kebab-case", "snake_case"]) {
|
|
1531
|
+
if (section.includes(kw)) fingerprints.push(kw);
|
|
1532
|
+
}
|
|
1533
|
+
if (/\bnamed exports?\b/i.test(section)) fingerprints.push("named export");
|
|
1534
|
+
if (/\bdefault exports?\b/i.test(section)) fingerprints.push("default export");
|
|
1535
|
+
if (section.includes("import type")) fingerprints.push("import type");
|
|
1536
|
+
for (const kw of [".skip()", ".only()", "console.log"]) {
|
|
1537
|
+
if (section.includes(kw)) fingerprints.push(kw);
|
|
1538
|
+
}
|
|
1539
|
+
return fingerprints;
|
|
1540
|
+
}
|
|
1541
|
+
var RULE_WORDS = /\b(verify|check|ensure|always|never|must|should|avoid)\b/i;
|
|
1542
|
+
function isConventionDuplication(line, fingerprints) {
|
|
1543
|
+
const trimmed = line.trim();
|
|
1544
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.includes("CLAUDE.md")) return false;
|
|
1545
|
+
if (!/^[-*]\s/.test(trimmed)) return false;
|
|
1546
|
+
const matchCount = fingerprints.filter((fp) => trimmed.includes(fp)).length;
|
|
1547
|
+
if (matchCount >= 2) return true;
|
|
1548
|
+
if (matchCount === 1 && RULE_WORDS.test(trimmed)) return true;
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
function findLiteralCommand(line, commands) {
|
|
1552
|
+
const trimmed = line.trim();
|
|
1553
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.includes("CLAUDE.md")) return null;
|
|
1554
|
+
for (const cmd of commands) {
|
|
1555
|
+
if (trimmed.includes(cmd)) return cmd;
|
|
1556
|
+
}
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
function separateFrontmatter(content) {
|
|
1560
|
+
const match = content.match(/^---\n[\s\S]*?\n---(?:\n|$)/);
|
|
1561
|
+
if (!match) {
|
|
1562
|
+
return { frontmatter: "", body: content };
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
frontmatter: match[0],
|
|
1566
|
+
body: content.slice(match[0].length)
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
function processFile(filePath, commands, fingerprints) {
|
|
1570
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
1571
|
+
const { frontmatter, body } = separateFrontmatter(content);
|
|
1572
|
+
const lines = body.split("\n");
|
|
1573
|
+
const changes = [];
|
|
1574
|
+
const newLines = [];
|
|
1575
|
+
let inCodeBlock = false;
|
|
1576
|
+
for (const line of lines) {
|
|
1577
|
+
if (line.trim().startsWith("```")) {
|
|
1578
|
+
inCodeBlock = !inCodeBlock;
|
|
1579
|
+
newLines.push(line);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
if (inCodeBlock) {
|
|
1583
|
+
newLines.push(line);
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (isConventionDuplication(line, fingerprints)) {
|
|
1587
|
+
changes.push({ file: filePath, original: line.trim(), replacement: null });
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
const cmd = findLiteralCommand(line, commands);
|
|
1591
|
+
if (cmd) {
|
|
1592
|
+
const newLine = line.replace(cmd, "see Common Commands in CLAUDE.md");
|
|
1593
|
+
changes.push({ file: filePath, original: line.trim(), replacement: newLine.trim() });
|
|
1594
|
+
newLines.push(newLine);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
newLines.push(line);
|
|
1598
|
+
}
|
|
1599
|
+
if (changes.length > 0) {
|
|
1600
|
+
fs4.writeFileSync(filePath, frontmatter + newLines.join("\n"));
|
|
1601
|
+
}
|
|
1602
|
+
return changes;
|
|
1603
|
+
}
|
|
1604
|
+
function walkMdFiles(dir) {
|
|
1605
|
+
const files = [];
|
|
1606
|
+
if (!fs4.existsSync(dir)) return files;
|
|
1607
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
1608
|
+
for (const entry of entries) {
|
|
1609
|
+
const fullPath = path4.join(dir, entry.name);
|
|
1610
|
+
if (entry.isDirectory()) {
|
|
1611
|
+
files.push(...walkMdFiles(fullPath));
|
|
1612
|
+
} else if (entry.name.endsWith(".md")) {
|
|
1613
|
+
files.push(fullPath);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return files;
|
|
1617
|
+
}
|
|
1618
|
+
function validateArtifacts(rootDir) {
|
|
1619
|
+
const result = {
|
|
1620
|
+
filesChecked: 0,
|
|
1621
|
+
filesModified: 0,
|
|
1622
|
+
duplicationsRemoved: 0,
|
|
1623
|
+
changes: []
|
|
1624
|
+
};
|
|
1625
|
+
const claudeMdPath = path4.join(rootDir, ".claude", "CLAUDE.md");
|
|
1626
|
+
if (!fs4.existsSync(claudeMdPath)) return result;
|
|
1627
|
+
try {
|
|
1628
|
+
const claudeMd = fs4.readFileSync(claudeMdPath, "utf-8");
|
|
1629
|
+
const commands = extractCommands(claudeMd);
|
|
1630
|
+
const fingerprints = extractConventionFingerprints(claudeMd);
|
|
1631
|
+
if (commands.length === 0 && fingerprints.length === 0) return result;
|
|
1632
|
+
const claudeDir = path4.join(rootDir, ".claude");
|
|
1633
|
+
const files = walkMdFiles(claudeDir).filter((f) => !f.endsWith("CLAUDE.md"));
|
|
1634
|
+
for (const filePath of files) {
|
|
1635
|
+
result.filesChecked++;
|
|
1636
|
+
const changes = processFile(filePath, commands, fingerprints);
|
|
1637
|
+
if (changes.length > 0) {
|
|
1638
|
+
result.filesModified++;
|
|
1639
|
+
result.duplicationsRemoved += changes.length;
|
|
1640
|
+
for (const change of changes) {
|
|
1641
|
+
change.file = path4.relative(rootDir, filePath);
|
|
1642
|
+
}
|
|
1643
|
+
result.changes.push(...changes);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
} catch {
|
|
1647
|
+
return result;
|
|
1648
|
+
}
|
|
1649
|
+
return result;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1275
1652
|
// src/cli.ts
|
|
1276
|
-
var __dirname2 =
|
|
1653
|
+
var __dirname2 = path5.dirname(fileURLToPath(import.meta.url));
|
|
1277
1654
|
var VERSION = JSON.parse(
|
|
1278
|
-
|
|
1655
|
+
fs5.readFileSync(path5.join(__dirname2, "..", "package.json"), "utf-8")
|
|
1279
1656
|
).version;
|
|
1280
1657
|
function parseArgs(args) {
|
|
1281
1658
|
return {
|
|
@@ -1313,7 +1690,7 @@ ${pc.bold("WHAT IT DOES")}
|
|
|
1313
1690
|
- Skills for your frameworks and workflows
|
|
1314
1691
|
- Agents for code review and testing
|
|
1315
1692
|
- Rules matching your code style
|
|
1316
|
-
- Commands for
|
|
1693
|
+
- Commands for analysis and code review
|
|
1317
1694
|
|
|
1318
1695
|
${pc.bold("REQUIREMENTS")}
|
|
1319
1696
|
Claude CLI must be installed: https://claude.ai/download
|
|
@@ -1789,17 +2166,17 @@ function runClaudeAnalysis(projectDir, projectInfo) {
|
|
|
1789
2166
|
});
|
|
1790
2167
|
}
|
|
1791
2168
|
function getGeneratedFiles(projectDir) {
|
|
1792
|
-
const claudeDir =
|
|
2169
|
+
const claudeDir = path5.join(projectDir, ".claude");
|
|
1793
2170
|
const files = [];
|
|
1794
2171
|
function walk(dir) {
|
|
1795
|
-
if (!
|
|
1796
|
-
const entries =
|
|
2172
|
+
if (!fs5.existsSync(dir)) return;
|
|
2173
|
+
const entries = fs5.readdirSync(dir, { withFileTypes: true });
|
|
1797
2174
|
for (const entry of entries) {
|
|
1798
|
-
const fullPath =
|
|
2175
|
+
const fullPath = path5.join(dir, entry.name);
|
|
1799
2176
|
if (entry.isDirectory()) {
|
|
1800
2177
|
walk(fullPath);
|
|
1801
2178
|
} else {
|
|
1802
|
-
files.push(
|
|
2179
|
+
files.push(path5.relative(projectDir, fullPath));
|
|
1803
2180
|
}
|
|
1804
2181
|
}
|
|
1805
2182
|
}
|
|
@@ -1856,7 +2233,7 @@ async function main() {
|
|
|
1856
2233
|
const { proceed } = await prompts({
|
|
1857
2234
|
type: "confirm",
|
|
1858
2235
|
name: "proceed",
|
|
1859
|
-
message: "Update existing configuration?
|
|
2236
|
+
message: "Update existing configuration?",
|
|
1860
2237
|
initial: true
|
|
1861
2238
|
});
|
|
1862
2239
|
if (!proceed) {
|
|
@@ -1883,6 +2260,14 @@ async function main() {
|
|
|
1883
2260
|
console.error(pc.red("Claude analysis failed. Please try again."));
|
|
1884
2261
|
process.exit(1);
|
|
1885
2262
|
}
|
|
2263
|
+
const validation = validateArtifacts(projectDir);
|
|
2264
|
+
if (validation.duplicationsRemoved > 0) {
|
|
2265
|
+
console.log(
|
|
2266
|
+
pc.gray(
|
|
2267
|
+
` Deduplication: removed ${validation.duplicationsRemoved} redundancies from ${validation.filesModified} files`
|
|
2268
|
+
)
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
1886
2271
|
const generatedFiles = getGeneratedFiles(projectDir);
|
|
1887
2272
|
console.log();
|
|
1888
2273
|
console.log(pc.green(`Done! (${generatedFiles.length} files)`));
|
|
@@ -1897,12 +2282,12 @@ async function main() {
|
|
|
1897
2282
|
}
|
|
1898
2283
|
if (skills.length > 0) {
|
|
1899
2284
|
console.log(
|
|
1900
|
-
` ${skills.length} skills (${skills.map((s) =>
|
|
2285
|
+
` ${skills.length} skills (${skills.map((s) => path5.basename(s, ".md")).join(", ")})`
|
|
1901
2286
|
);
|
|
1902
2287
|
}
|
|
1903
2288
|
if (agents.length > 0) {
|
|
1904
2289
|
console.log(
|
|
1905
|
-
` ${agents.length} agents (${agents.map((a) =>
|
|
2290
|
+
` ${agents.length} agents (${agents.map((a) => path5.basename(a, ".md")).join(", ")})`
|
|
1906
2291
|
);
|
|
1907
2292
|
}
|
|
1908
2293
|
if (rules.length > 0) {
|
|
@@ -1912,6 +2297,21 @@ async function main() {
|
|
|
1912
2297
|
console.log(` ${commands.length} commands`);
|
|
1913
2298
|
}
|
|
1914
2299
|
console.log();
|
|
2300
|
+
if (args.interactive) {
|
|
2301
|
+
console.log();
|
|
2302
|
+
const { installSafetyHook } = await prompts({
|
|
2303
|
+
type: "confirm",
|
|
2304
|
+
name: "installSafetyHook",
|
|
2305
|
+
message: "Add a safety hook to block dangerous commands? (git push, rm -rf, etc.)",
|
|
2306
|
+
initial: true
|
|
2307
|
+
});
|
|
2308
|
+
if (installSafetyHook) {
|
|
2309
|
+
installHook(projectDir);
|
|
2310
|
+
console.log(pc.green(" + .claude/hooks/block-dangerous-commands.js"));
|
|
2311
|
+
console.log(pc.gray(" Blocks destructive Bash commands before execution"));
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
console.log();
|
|
1915
2315
|
console.log(`${pc.cyan("Next step:")} Run ${pc.bold("claude")} to start working!`);
|
|
1916
2316
|
console.log();
|
|
1917
2317
|
console.log(
|
|
@@ -1921,7 +2321,7 @@ async function main() {
|
|
|
1921
2321
|
);
|
|
1922
2322
|
}
|
|
1923
2323
|
try {
|
|
1924
|
-
const isMain = process.argv[1] &&
|
|
2324
|
+
const isMain = process.argv[1] && fs5.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1925
2325
|
if (isMain) {
|
|
1926
2326
|
main().catch((err) => {
|
|
1927
2327
|
console.error(pc.red("Error:"), err.message);
|