claude-jacked 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- claude_jacked-0.3.0.dist-info/METADATA +667 -0
- claude_jacked-0.3.0.dist-info/RECORD +33 -0
- jacked/__init__.py +34 -14
- jacked/cli.py +513 -60
- jacked/client.py +78 -28
- jacked/data/agents/double-check-reviewer.md +42 -0
- jacked/data/commands/audit-rules.md +103 -0
- jacked/data/commands/dc.md +36 -3
- jacked/data/commands/learn.md +89 -0
- jacked/data/commands/redo.md +85 -0
- jacked/data/commands/techdebt.md +115 -0
- jacked/data/hooks/security_gatekeeper.py +415 -0
- jacked/data/rules/jacked_behaviors.md +11 -0
- jacked/index_write_tracker.py +227 -0
- jacked/indexer.py +189 -163
- jacked/searcher.py +4 -0
- claude_jacked-0.2.7.dist-info/METADATA +0 -580
- claude_jacked-0.2.7.dist-info/RECORD +0 -26
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.3.0.dist-info}/WHEEL +0 -0
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.3.0.dist-info}/entry_points.txt +0 -0
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.3.0.dist-info}/licenses/LICENSE +0 -0
jacked/cli.py
CHANGED
|
@@ -32,11 +32,18 @@ def setup_logging(verbose: bool = False):
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def get_config() -> SmartForkConfig:
|
|
36
|
-
"""Load configuration from environment.
|
|
35
|
+
def get_config(quiet: bool = False) -> Optional[SmartForkConfig]:
|
|
36
|
+
"""Load configuration from environment.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
quiet: If True, return None instead of printing error and exiting.
|
|
40
|
+
Used by hooks that should fail gracefully.
|
|
41
|
+
"""
|
|
37
42
|
try:
|
|
38
43
|
return SmartForkConfig.from_env()
|
|
39
44
|
except ValueError as e:
|
|
45
|
+
if quiet:
|
|
46
|
+
return None
|
|
40
47
|
console.print(f"[red]Configuration error:[/red] {e}")
|
|
41
48
|
console.print("\nSet these environment variables:")
|
|
42
49
|
console.print(" QDRANT_CLAUDE_SESSIONS_ENDPOINT=<your-qdrant-url>")
|
|
@@ -44,6 +51,19 @@ def get_config() -> SmartForkConfig:
|
|
|
44
51
|
sys.exit(1)
|
|
45
52
|
|
|
46
53
|
|
|
54
|
+
def _require_search(command_name: str) -> bool:
|
|
55
|
+
"""Check if qdrant-client is installed. If not, print helpful error and return False."""
|
|
56
|
+
try:
|
|
57
|
+
import qdrant_client # noqa: F401
|
|
58
|
+
return True
|
|
59
|
+
except ImportError:
|
|
60
|
+
console.print(f"[red]Error:[/red] '{command_name}' requires the search extra.")
|
|
61
|
+
console.print('\nInstall it with:')
|
|
62
|
+
console.print(' [bold]pip install "claude-jacked[search]"[/bold]')
|
|
63
|
+
console.print(' [bold]pipx install "claude-jacked[search]"[/bold]')
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
47
67
|
@click.group()
|
|
48
68
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
|
|
49
69
|
def main(verbose: bool):
|
|
@@ -59,11 +79,32 @@ def index(session: Optional[str], repo: Optional[str]):
|
|
|
59
79
|
Index a Claude session to Qdrant.
|
|
60
80
|
|
|
61
81
|
If SESSION is not provided, indexes the current session (from CLAUDE_SESSION_ID).
|
|
82
|
+
Requires: pip install "claude-jacked[search]"
|
|
62
83
|
"""
|
|
63
84
|
import os
|
|
85
|
+
|
|
86
|
+
# Check if qdrant is available
|
|
87
|
+
try:
|
|
88
|
+
import qdrant_client # noqa: F401
|
|
89
|
+
except ImportError:
|
|
90
|
+
# If called from Stop hook (CLAUDE_SESSION_ID set), exit silently
|
|
91
|
+
# If called manually, show helpful message
|
|
92
|
+
if os.getenv("CLAUDE_SESSION_ID") and not session:
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
else:
|
|
95
|
+
console.print("[red]Error:[/red] 'index' requires the search extra.")
|
|
96
|
+
console.print('\nInstall it with:')
|
|
97
|
+
console.print(' [bold]pip install "claude-jacked[search]"[/bold]')
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
64
100
|
from jacked.indexer import SessionIndexer
|
|
65
101
|
|
|
66
|
-
config
|
|
102
|
+
# Try to get config quietly - if not configured, nudge and exit cleanly
|
|
103
|
+
config = get_config(quiet=True)
|
|
104
|
+
if config is None:
|
|
105
|
+
print("[jacked] Indexing skipped - run 'jacked configure' to set up Qdrant")
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
67
108
|
indexer = SessionIndexer(config)
|
|
68
109
|
|
|
69
110
|
if session:
|
|
@@ -133,7 +174,10 @@ def index(session: Optional[str], repo: Optional[str]):
|
|
|
133
174
|
@click.option("--repo", "-r", help="Filter by repository name pattern")
|
|
134
175
|
@click.option("--force", "-f", is_flag=True, help="Re-index all sessions")
|
|
135
176
|
def backfill(repo: Optional[str], force: bool):
|
|
136
|
-
"""Index all existing Claude sessions."""
|
|
177
|
+
"""Index all existing Claude sessions. Requires: pip install "claude-jacked[search]" """
|
|
178
|
+
if not _require_search("backfill"):
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
137
181
|
from jacked.indexer import SessionIndexer
|
|
138
182
|
|
|
139
183
|
config = get_config()
|
|
@@ -175,9 +219,11 @@ def backfill(repo: Optional[str], force: bool):
|
|
|
175
219
|
def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Optional[str], content_types: tuple):
|
|
176
220
|
"""Search for sessions by semantic similarity with multi-factor ranking.
|
|
177
221
|
|
|
178
|
-
|
|
179
|
-
Use --type to filter to specific content types.
|
|
222
|
+
Requires: pip install "claude-jacked[search]"
|
|
180
223
|
"""
|
|
224
|
+
if not _require_search("search"):
|
|
225
|
+
sys.exit(1)
|
|
226
|
+
|
|
181
227
|
import os
|
|
182
228
|
from jacked.searcher import SessionSearcher
|
|
183
229
|
|
|
@@ -292,13 +338,11 @@ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Option
|
|
|
292
338
|
def retrieve(session_id: str, output: Optional[str], summary: bool, mode: str, max_tokens: int, inject: bool):
|
|
293
339
|
"""Retrieve a session's context with smart mode support.
|
|
294
340
|
|
|
295
|
-
|
|
296
|
-
smart - Plan + agent summaries + labels + user messages (default)
|
|
297
|
-
plan - Just the plan file
|
|
298
|
-
labels - Just summary labels (tiny)
|
|
299
|
-
agents - All subagent summaries
|
|
300
|
-
full - Everything including full transcript
|
|
341
|
+
Requires: pip install "claude-jacked[search]"
|
|
301
342
|
"""
|
|
343
|
+
if not _require_search("retrieve"):
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
302
346
|
from jacked.retriever import SessionRetriever
|
|
303
347
|
|
|
304
348
|
config = get_config()
|
|
@@ -369,7 +413,10 @@ def retrieve(session_id: str, output: Optional[str], summary: bool, mode: str, m
|
|
|
369
413
|
@click.option("--repo", "-r", help="Filter by repository path")
|
|
370
414
|
@click.option("--limit", "-n", default=20, help="Maximum results")
|
|
371
415
|
def list_sessions(repo: Optional[str], limit: int):
|
|
372
|
-
"""List indexed sessions."""
|
|
416
|
+
"""List indexed sessions. Requires: pip install "claude-jacked[search]" """
|
|
417
|
+
if not _require_search("sessions"):
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
|
|
373
420
|
from jacked.client import QdrantSessionClient
|
|
374
421
|
|
|
375
422
|
config = get_config()
|
|
@@ -407,7 +454,10 @@ def list_sessions(repo: Optional[str], limit: int):
|
|
|
407
454
|
@click.argument("session_id")
|
|
408
455
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
409
456
|
def delete(session_id: str, yes: bool):
|
|
410
|
-
"""Delete a session from the index."""
|
|
457
|
+
"""Delete a session from the index. Requires: pip install "claude-jacked[search]" """
|
|
458
|
+
if not _require_search("delete"):
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
|
|
411
461
|
from jacked.client import QdrantSessionClient
|
|
412
462
|
|
|
413
463
|
config = get_config()
|
|
@@ -427,9 +477,11 @@ def cleardb():
|
|
|
427
477
|
"""
|
|
428
478
|
Delete ALL your indexed data from Qdrant.
|
|
429
479
|
|
|
430
|
-
|
|
431
|
-
Use this before re-indexing with a new schema or to start fresh.
|
|
480
|
+
Requires: pip install "claude-jacked[search]"
|
|
432
481
|
"""
|
|
482
|
+
if not _require_search("cleardb"):
|
|
483
|
+
sys.exit(1)
|
|
484
|
+
|
|
433
485
|
from jacked.client import QdrantSessionClient
|
|
434
486
|
|
|
435
487
|
config = get_config()
|
|
@@ -468,7 +520,10 @@ def cleardb():
|
|
|
468
520
|
|
|
469
521
|
@main.command()
|
|
470
522
|
def status():
|
|
471
|
-
"""Show indexing health and Qdrant connectivity."""
|
|
523
|
+
"""Show indexing health and Qdrant connectivity. Requires: pip install "claude-jacked[search]" """
|
|
524
|
+
if not _require_search("status"):
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
472
527
|
from jacked.client import QdrantSessionClient
|
|
473
528
|
|
|
474
529
|
config = get_config()
|
|
@@ -570,8 +625,6 @@ def configure(show: bool):
|
|
|
570
625
|
console.print("[dim]Run 'jacked configure --show' to see current values[/dim]")
|
|
571
626
|
|
|
572
627
|
|
|
573
|
-
# Import for configure command
|
|
574
|
-
from jacked.config import SmartForkConfig
|
|
575
628
|
|
|
576
629
|
|
|
577
630
|
def _get_data_root() -> Path:
|
|
@@ -623,6 +676,8 @@ def _get_sound_command(hook_type: str) -> str:
|
|
|
623
676
|
|
|
624
677
|
def _install_sound_hooks(existing: dict, settings_path: Path):
|
|
625
678
|
"""Install sound notification hooks."""
|
|
679
|
+
import json
|
|
680
|
+
|
|
626
681
|
marker = _sound_hook_marker()
|
|
627
682
|
|
|
628
683
|
# Notification hook
|
|
@@ -680,10 +735,310 @@ def _remove_sound_hooks(settings_path: Path) -> bool:
|
|
|
680
735
|
return modified
|
|
681
736
|
|
|
682
737
|
|
|
738
|
+
def _get_behavioral_rules() -> str:
|
|
739
|
+
"""Load behavioral rules from data file."""
|
|
740
|
+
rules_path = _get_data_root() / "rules" / "jacked_behaviors.md"
|
|
741
|
+
if not rules_path.exists():
|
|
742
|
+
raise FileNotFoundError(f"Behavioral rules not found: {rules_path}")
|
|
743
|
+
return rules_path.read_text(encoding="utf-8").strip()
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _behavioral_rules_marker() -> str:
|
|
747
|
+
"""Start marker for jacked behavioral rules block."""
|
|
748
|
+
return "# jacked-behaviors-v2"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _behavioral_rules_end_marker() -> str:
|
|
752
|
+
"""End marker for jacked behavioral rules block."""
|
|
753
|
+
return "# end-jacked-behaviors"
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _install_behavioral_rules(claude_md_path: Path):
|
|
757
|
+
"""Install behavioral rules into CLAUDE.md with marker boundaries.
|
|
758
|
+
|
|
759
|
+
- Show rules before writing, require confirmation
|
|
760
|
+
- Backup file before first modification
|
|
761
|
+
- Atomic write (build in memory, write once)
|
|
762
|
+
- Skip if already installed with same version
|
|
763
|
+
"""
|
|
764
|
+
import shutil
|
|
765
|
+
|
|
766
|
+
try:
|
|
767
|
+
rules_text = _get_behavioral_rules()
|
|
768
|
+
except FileNotFoundError as e:
|
|
769
|
+
console.print(f"[red][FAIL][/red] {e}")
|
|
770
|
+
console.print("[yellow]Skipping behavioral rules installation[/yellow]")
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
start_marker = _behavioral_rules_marker()
|
|
774
|
+
end_marker = _behavioral_rules_end_marker()
|
|
775
|
+
|
|
776
|
+
# Read existing content
|
|
777
|
+
existing_content = ""
|
|
778
|
+
if claude_md_path.exists():
|
|
779
|
+
existing_content = claude_md_path.read_text(encoding="utf-8")
|
|
780
|
+
|
|
781
|
+
# Check if already installed (any version)
|
|
782
|
+
marker_prefix = "# jacked-behaviors-v"
|
|
783
|
+
has_start = marker_prefix in existing_content
|
|
784
|
+
has_end = end_marker in existing_content
|
|
785
|
+
|
|
786
|
+
# Orphaned marker detection: start without end (or end without start)
|
|
787
|
+
if has_start != has_end:
|
|
788
|
+
which = "start" if has_start else "end"
|
|
789
|
+
missing = "end" if has_start else "start"
|
|
790
|
+
console.print(f"[red][FAIL][/red] Found {which} marker but no {missing} marker in CLAUDE.md")
|
|
791
|
+
console.print("Your CLAUDE.md has a corrupted jacked rules block. Please fix it manually:")
|
|
792
|
+
console.print(f" Start marker: {start_marker}")
|
|
793
|
+
console.print(f" End marker: {end_marker}")
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
has_existing = has_start and has_end
|
|
797
|
+
if has_existing:
|
|
798
|
+
# Extract existing block (find the versioned start marker)
|
|
799
|
+
start_idx = existing_content.index(marker_prefix)
|
|
800
|
+
end_idx = existing_content.index(end_marker) + len(end_marker)
|
|
801
|
+
existing_block = existing_content[start_idx:end_idx].strip()
|
|
802
|
+
|
|
803
|
+
if existing_block == rules_text:
|
|
804
|
+
console.print("[yellow][-][/yellow] Behavioral rules already configured correctly")
|
|
805
|
+
return
|
|
806
|
+
else:
|
|
807
|
+
# Version upgrade needed
|
|
808
|
+
console.print("\n[bold]Behavioral rules update available:[/bold]")
|
|
809
|
+
console.print(f"[dim]{rules_text}[/dim]")
|
|
810
|
+
if not click.confirm("Update behavioral rules in CLAUDE.md?"):
|
|
811
|
+
console.print("[yellow][-][/yellow] Skipped behavioral rules update")
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
# Backup before modifying
|
|
815
|
+
backup_path = claude_md_path.with_suffix(".md.pre-jacked")
|
|
816
|
+
if not backup_path.exists():
|
|
817
|
+
shutil.copy2(claude_md_path, backup_path)
|
|
818
|
+
console.print(f"[dim]Backup: {backup_path}[/dim]")
|
|
819
|
+
|
|
820
|
+
# Replace the block (symmetric with _remove_behavioral_rules)
|
|
821
|
+
before = existing_content[:start_idx].rstrip("\n")
|
|
822
|
+
after = existing_content[end_idx:].lstrip("\n")
|
|
823
|
+
if before and after:
|
|
824
|
+
new_content = before + "\n\n" + rules_text + "\n\n" + after
|
|
825
|
+
elif before:
|
|
826
|
+
new_content = before + "\n\n" + rules_text + "\n"
|
|
827
|
+
else:
|
|
828
|
+
new_content = rules_text + "\n" + after if after else rules_text + "\n"
|
|
829
|
+
try:
|
|
830
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
831
|
+
except PermissionError:
|
|
832
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
833
|
+
console.print("Check file permissions and try again.")
|
|
834
|
+
return
|
|
835
|
+
console.print("[green][OK][/green] Updated behavioral rules to latest version")
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
# Fresh install - show and confirm
|
|
839
|
+
console.print("\n[bold]Proposed behavioral rules for ~/.claude/CLAUDE.md:[/bold]")
|
|
840
|
+
console.print(f"[dim]{rules_text}[/dim]")
|
|
841
|
+
if not click.confirm("Add these behavioral rules to your global CLAUDE.md?"):
|
|
842
|
+
console.print("[yellow][-][/yellow] Skipped behavioral rules")
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
# Backup before modifying (if file exists and no backup yet)
|
|
846
|
+
if claude_md_path.exists():
|
|
847
|
+
backup_path = claude_md_path.with_suffix(".md.pre-jacked")
|
|
848
|
+
if not backup_path.exists():
|
|
849
|
+
shutil.copy2(claude_md_path, backup_path)
|
|
850
|
+
console.print(f"[dim]Backup: {backup_path}[/dim]")
|
|
851
|
+
|
|
852
|
+
# Ensure parent directory exists
|
|
853
|
+
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
854
|
+
|
|
855
|
+
# Build new content atomically
|
|
856
|
+
if existing_content and not existing_content.endswith("\n\n"):
|
|
857
|
+
if existing_content.endswith("\n"):
|
|
858
|
+
new_content = existing_content + "\n" + rules_text + "\n"
|
|
859
|
+
else:
|
|
860
|
+
new_content = existing_content + "\n\n" + rules_text + "\n"
|
|
861
|
+
else:
|
|
862
|
+
new_content = existing_content + rules_text + "\n"
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
866
|
+
except PermissionError:
|
|
867
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
868
|
+
console.print("Check file permissions and try again.")
|
|
869
|
+
return
|
|
870
|
+
console.print("[green][OK][/green] Installed behavioral rules in CLAUDE.md")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _remove_behavioral_rules(claude_md_path: Path) -> bool:
|
|
874
|
+
"""Remove jacked behavioral rules block from CLAUDE.md.
|
|
875
|
+
|
|
876
|
+
Returns True if rules were found and removed.
|
|
877
|
+
"""
|
|
878
|
+
if not claude_md_path.exists():
|
|
879
|
+
return False
|
|
880
|
+
|
|
881
|
+
content = claude_md_path.read_text(encoding="utf-8")
|
|
882
|
+
marker_prefix = "# jacked-behaviors-v"
|
|
883
|
+
end_marker = _behavioral_rules_end_marker()
|
|
884
|
+
|
|
885
|
+
if marker_prefix not in content or end_marker not in content:
|
|
886
|
+
return False
|
|
887
|
+
|
|
888
|
+
start_idx = content.index(marker_prefix)
|
|
889
|
+
end_idx = content.index(end_marker) + len(end_marker)
|
|
890
|
+
|
|
891
|
+
# Strip the block and any extra blank lines around it
|
|
892
|
+
before = content[:start_idx].rstrip("\n")
|
|
893
|
+
after = content[end_idx:].lstrip("\n")
|
|
894
|
+
|
|
895
|
+
if before and after:
|
|
896
|
+
new_content = before + "\n\n" + after
|
|
897
|
+
elif before:
|
|
898
|
+
new_content = before + "\n"
|
|
899
|
+
else:
|
|
900
|
+
new_content = after
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
904
|
+
except PermissionError:
|
|
905
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
906
|
+
return False
|
|
907
|
+
return True
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _security_hook_marker() -> str:
|
|
911
|
+
"""Marker to identify jacked security gatekeeper hooks."""
|
|
912
|
+
return "# jacked-security"
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _install_security_hook(existing: dict, settings_path: Path):
|
|
917
|
+
"""Install security gatekeeper command hook for Bash PreToolUse events.
|
|
918
|
+
|
|
919
|
+
Uses a PreToolUse command hook (blocking) that calls a Python script.
|
|
920
|
+
The script evaluates commands via local rules, Anthropic API, or claude -p
|
|
921
|
+
and returns permissionDecision:"allow" to auto-approve safe commands.
|
|
922
|
+
|
|
923
|
+
Handles fresh install, version upgrades, and migration from PermissionRequest.
|
|
924
|
+
"""
|
|
925
|
+
import json
|
|
926
|
+
import shutil
|
|
927
|
+
|
|
928
|
+
marker = _security_hook_marker()
|
|
929
|
+
script_path = _get_data_root() / "hooks" / "security_gatekeeper.py"
|
|
930
|
+
|
|
931
|
+
if not script_path.exists():
|
|
932
|
+
console.print(f"[red][FAIL][/red] Security gatekeeper script not found: {script_path}")
|
|
933
|
+
console.print("[yellow]Skipping security gatekeeper installation[/yellow]")
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
# Find python executable — prefer the one running this process
|
|
937
|
+
python_exe = sys.executable
|
|
938
|
+
if not python_exe or not Path(python_exe).exists():
|
|
939
|
+
python_exe = shutil.which("python3") or shutil.which("python") or "python"
|
|
940
|
+
|
|
941
|
+
# Use forward slashes for the command (works on Windows too)
|
|
942
|
+
python_path = str(Path(python_exe)).replace("\\", "/")
|
|
943
|
+
script_str = str(script_path).replace("\\", "/")
|
|
944
|
+
command_str = f"{python_path} {script_str}"
|
|
945
|
+
|
|
946
|
+
# Migrate: remove old PermissionRequest hooks with our marker
|
|
947
|
+
if "PermissionRequest" in existing.get("hooks", {}):
|
|
948
|
+
old_hooks = existing["hooks"]["PermissionRequest"]
|
|
949
|
+
before = len(old_hooks)
|
|
950
|
+
existing["hooks"]["PermissionRequest"] = [
|
|
951
|
+
h for h in old_hooks
|
|
952
|
+
if marker not in str(h) and "security_gatekeeper" not in str(h)
|
|
953
|
+
]
|
|
954
|
+
if len(existing["hooks"]["PermissionRequest"]) < before:
|
|
955
|
+
console.print("[green][OK][/green] Migrated security hook from PermissionRequest to PreToolUse")
|
|
956
|
+
|
|
957
|
+
if "PreToolUse" not in existing["hooks"]:
|
|
958
|
+
existing["hooks"]["PreToolUse"] = []
|
|
959
|
+
|
|
960
|
+
# Check if already installed and whether it needs upgrading
|
|
961
|
+
hook_index = None
|
|
962
|
+
needs_upgrade = False
|
|
963
|
+
for i, hook_entry in enumerate(existing["hooks"]["PreToolUse"]):
|
|
964
|
+
hook_str = str(hook_entry)
|
|
965
|
+
if marker in hook_str or "security_gatekeeper" in hook_str:
|
|
966
|
+
hook_index = i
|
|
967
|
+
for h in hook_entry.get("hooks", []):
|
|
968
|
+
installed_cmd = h.get("command", "")
|
|
969
|
+
if installed_cmd != command_str:
|
|
970
|
+
needs_upgrade = True
|
|
971
|
+
break
|
|
972
|
+
|
|
973
|
+
if hook_index is not None and not needs_upgrade:
|
|
974
|
+
console.print("[yellow][-][/yellow] Security gatekeeper hook already configured")
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
hook_entry = {
|
|
978
|
+
"matcher": "Bash",
|
|
979
|
+
"hooks": [{
|
|
980
|
+
"type": "command",
|
|
981
|
+
"command": command_str,
|
|
982
|
+
"timeout": 30,
|
|
983
|
+
}]
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if hook_index is not None and needs_upgrade:
|
|
987
|
+
existing["hooks"]["PreToolUse"][hook_index] = hook_entry
|
|
988
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
989
|
+
console.print("[green][OK][/green] Updated security gatekeeper to latest version")
|
|
990
|
+
else:
|
|
991
|
+
existing["hooks"]["PreToolUse"].append(hook_entry)
|
|
992
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
993
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
994
|
+
console.print("[green][OK][/green] Installed security gatekeeper (PreToolUse, blocking)")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _remove_security_hook(settings_path: Path) -> bool:
|
|
998
|
+
"""Remove jacked security gatekeeper hook. Returns True if removed.
|
|
999
|
+
|
|
1000
|
+
Checks both PreToolUse (current) and PermissionRequest (legacy).
|
|
1001
|
+
"""
|
|
1002
|
+
import json
|
|
1003
|
+
|
|
1004
|
+
if not settings_path.exists():
|
|
1005
|
+
return False
|
|
1006
|
+
|
|
1007
|
+
settings = json.loads(settings_path.read_text())
|
|
1008
|
+
marker = _security_hook_marker()
|
|
1009
|
+
modified = False
|
|
1010
|
+
|
|
1011
|
+
for hook_type in ["PreToolUse", "PermissionRequest"]:
|
|
1012
|
+
if hook_type not in settings.get("hooks", {}):
|
|
1013
|
+
continue
|
|
1014
|
+
before = len(settings["hooks"][hook_type])
|
|
1015
|
+
settings["hooks"][hook_type] = [
|
|
1016
|
+
h for h in settings["hooks"][hook_type]
|
|
1017
|
+
if marker not in str(h)
|
|
1018
|
+
]
|
|
1019
|
+
if len(settings["hooks"][hook_type]) < before:
|
|
1020
|
+
modified = True
|
|
1021
|
+
|
|
1022
|
+
if modified:
|
|
1023
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
1024
|
+
console.print("[green][OK][/green] Removed security gatekeeper hook")
|
|
1025
|
+
return True
|
|
1026
|
+
|
|
1027
|
+
return False
|
|
1028
|
+
|
|
1029
|
+
|
|
683
1030
|
@main.command()
|
|
684
1031
|
@click.option("--sounds", is_flag=True, help="Install sound notification hooks")
|
|
685
|
-
|
|
686
|
-
|
|
1032
|
+
@click.option("--search", is_flag=True, help="Install session indexing hook (requires [search] extra)")
|
|
1033
|
+
@click.option("--security", is_flag=True, help="Install security gatekeeper hook (requires [security] extra)")
|
|
1034
|
+
@click.option("--no-rules", is_flag=True, help="Skip behavioral rules in CLAUDE.md")
|
|
1035
|
+
def install(sounds: bool, search: bool, security: bool, no_rules: bool):
|
|
1036
|
+
"""Auto-install skill, agents, commands, and optional hooks.
|
|
1037
|
+
|
|
1038
|
+
Base install: agents, commands, behavioral rules, /jacked skill.
|
|
1039
|
+
Use --search to add session indexing (requires qdrant-client).
|
|
1040
|
+
Use --security to add security gatekeeper (requires anthropic SDK).
|
|
1041
|
+
"""
|
|
687
1042
|
import os
|
|
688
1043
|
import json
|
|
689
1044
|
import shutil
|
|
@@ -691,22 +1046,16 @@ def install(sounds: bool):
|
|
|
691
1046
|
home = Path.home()
|
|
692
1047
|
pkg_root = _get_data_root()
|
|
693
1048
|
|
|
694
|
-
#
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
]
|
|
706
|
-
}
|
|
707
|
-
]
|
|
708
|
-
}
|
|
709
|
-
}
|
|
1049
|
+
# Auto-detect extras: if the package is installed, enable by default
|
|
1050
|
+
has_qdrant = False
|
|
1051
|
+
try:
|
|
1052
|
+
import qdrant_client # noqa: F401
|
|
1053
|
+
has_qdrant = True
|
|
1054
|
+
except ImportError:
|
|
1055
|
+
pass
|
|
1056
|
+
|
|
1057
|
+
install_search = search or has_qdrant
|
|
1058
|
+
install_security = security
|
|
710
1059
|
|
|
711
1060
|
console.print("[bold]Installing Jacked...[/bold]\n")
|
|
712
1061
|
|
|
@@ -720,25 +1069,47 @@ def install(sounds: bool):
|
|
|
720
1069
|
else:
|
|
721
1070
|
existing = {}
|
|
722
1071
|
|
|
723
|
-
# Merge hook config
|
|
724
1072
|
if "hooks" not in existing:
|
|
725
1073
|
existing["hooks"] = {}
|
|
726
1074
|
if "Stop" not in existing["hooks"]:
|
|
727
1075
|
existing["hooks"]["Stop"] = []
|
|
728
1076
|
|
|
729
|
-
#
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1077
|
+
# Stop hook for session indexing — only if search extra available
|
|
1078
|
+
if install_search:
|
|
1079
|
+
hook_config_stop = {
|
|
1080
|
+
"matcher": "",
|
|
1081
|
+
"hooks": [
|
|
1082
|
+
{
|
|
1083
|
+
"type": "command",
|
|
1084
|
+
"command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"',
|
|
1085
|
+
"async": True
|
|
1086
|
+
}
|
|
1087
|
+
]
|
|
1088
|
+
}
|
|
734
1089
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1090
|
+
hook_index = None
|
|
1091
|
+
needs_async_update = False
|
|
1092
|
+
for i, hook_entry in enumerate(existing["hooks"]["Stop"]):
|
|
1093
|
+
for h in hook_entry.get("hooks", []):
|
|
1094
|
+
if "jacked" in h.get("command", ""):
|
|
1095
|
+
hook_index = i
|
|
1096
|
+
if not h.get("async"):
|
|
1097
|
+
needs_async_update = True
|
|
1098
|
+
break
|
|
1099
|
+
|
|
1100
|
+
if hook_index is None:
|
|
1101
|
+
existing["hooks"]["Stop"].append(hook_config_stop)
|
|
1102
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1103
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
1104
|
+
console.print(f"[green][OK][/green] Added Stop hook (session indexing)")
|
|
1105
|
+
elif needs_async_update:
|
|
1106
|
+
existing["hooks"]["Stop"][hook_index] = hook_config_stop
|
|
1107
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
1108
|
+
console.print(f"[green][OK][/green] Updated Stop hook with async: true")
|
|
1109
|
+
else:
|
|
1110
|
+
console.print(f"[yellow][-][/yellow] Stop hook already configured")
|
|
740
1111
|
else:
|
|
741
|
-
console.print(
|
|
1112
|
+
console.print("[dim][-][/dim] Skipping session indexing hook (install [search] extra to enable)")
|
|
742
1113
|
|
|
743
1114
|
# Copy skill file with Python path templating
|
|
744
1115
|
# Claude Code expects skills in subdirectories with SKILL.md
|
|
@@ -754,29 +1125,59 @@ def install(sounds: bool):
|
|
|
754
1125
|
else:
|
|
755
1126
|
console.print(f"[yellow][-][/yellow] Skill file not found at {skill_src}")
|
|
756
1127
|
|
|
757
|
-
# Copy agents
|
|
1128
|
+
# Copy agents (with conflict detection)
|
|
758
1129
|
agents_src = pkg_root / "agents"
|
|
759
1130
|
agents_dst = home / ".claude" / "agents"
|
|
760
1131
|
if agents_src.exists():
|
|
761
1132
|
agents_dst.mkdir(parents=True, exist_ok=True)
|
|
762
1133
|
agent_count = 0
|
|
1134
|
+
skipped = 0
|
|
763
1135
|
for agent_file in agents_src.glob("*.md"):
|
|
764
|
-
|
|
1136
|
+
dst_file = agents_dst / agent_file.name
|
|
1137
|
+
src_content = agent_file.read_text(encoding="utf-8")
|
|
1138
|
+
if dst_file.exists():
|
|
1139
|
+
dst_content = dst_file.read_text(encoding="utf-8")
|
|
1140
|
+
if src_content == dst_content:
|
|
1141
|
+
skipped += 1
|
|
1142
|
+
continue # Same content, skip silently
|
|
1143
|
+
# Different content - ask before overwriting
|
|
1144
|
+
if not click.confirm(f"Agent '{agent_file.name}' exists with different content. Overwrite?"):
|
|
1145
|
+
console.print(f"[yellow][-][/yellow] Skipped {agent_file.name}")
|
|
1146
|
+
continue
|
|
1147
|
+
shutil.copy(agent_file, dst_file)
|
|
765
1148
|
agent_count += 1
|
|
766
|
-
|
|
1149
|
+
msg = f"[green][OK][/green] Installed {agent_count} agents"
|
|
1150
|
+
if skipped:
|
|
1151
|
+
msg += f" ({skipped} unchanged)"
|
|
1152
|
+
console.print(msg)
|
|
767
1153
|
else:
|
|
768
1154
|
console.print(f"[yellow][-][/yellow] Agents directory not found")
|
|
769
1155
|
|
|
770
|
-
# Copy commands
|
|
1156
|
+
# Copy commands (with conflict detection)
|
|
771
1157
|
commands_src = pkg_root / "commands"
|
|
772
1158
|
commands_dst = home / ".claude" / "commands"
|
|
773
1159
|
if commands_src.exists():
|
|
774
1160
|
commands_dst.mkdir(parents=True, exist_ok=True)
|
|
775
1161
|
cmd_count = 0
|
|
1162
|
+
skipped = 0
|
|
776
1163
|
for cmd_file in commands_src.glob("*.md"):
|
|
777
|
-
|
|
1164
|
+
dst_file = commands_dst / cmd_file.name
|
|
1165
|
+
src_content = cmd_file.read_text(encoding="utf-8")
|
|
1166
|
+
if dst_file.exists():
|
|
1167
|
+
dst_content = dst_file.read_text(encoding="utf-8")
|
|
1168
|
+
if src_content == dst_content:
|
|
1169
|
+
skipped += 1
|
|
1170
|
+
continue # Same content, skip silently
|
|
1171
|
+
# Different content - ask before overwriting
|
|
1172
|
+
if not click.confirm(f"Command '{cmd_file.name}' exists with different content. Overwrite?"):
|
|
1173
|
+
console.print(f"[yellow][-][/yellow] Skipped {cmd_file.name}")
|
|
1174
|
+
continue
|
|
1175
|
+
shutil.copy(cmd_file, dst_file)
|
|
778
1176
|
cmd_count += 1
|
|
779
|
-
|
|
1177
|
+
msg = f"[green][OK][/green] Installed {cmd_count} commands"
|
|
1178
|
+
if skipped:
|
|
1179
|
+
msg += f" ({skipped} unchanged)"
|
|
1180
|
+
console.print(msg)
|
|
780
1181
|
else:
|
|
781
1182
|
console.print(f"[yellow][-][/yellow] Commands directory not found")
|
|
782
1183
|
|
|
@@ -784,24 +1185,55 @@ def install(sounds: bool):
|
|
|
784
1185
|
if sounds:
|
|
785
1186
|
_install_sound_hooks(existing, settings_path)
|
|
786
1187
|
|
|
1188
|
+
# Install security gatekeeper — only if --security flag passed
|
|
1189
|
+
if install_security:
|
|
1190
|
+
_install_security_hook(existing, settings_path)
|
|
1191
|
+
else:
|
|
1192
|
+
console.print("[dim][-][/dim] Skipping security gatekeeper (use --security to enable)")
|
|
1193
|
+
|
|
1194
|
+
# Install behavioral rules in CLAUDE.md (default on, --no-rules to skip)
|
|
1195
|
+
if not no_rules:
|
|
1196
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1197
|
+
_install_behavioral_rules(claude_md_path)
|
|
1198
|
+
|
|
787
1199
|
console.print("\n[bold]Installation complete![/bold]")
|
|
788
1200
|
console.print("\n[yellow]IMPORTANT: Restart Claude Code for new commands to take effect![/yellow]")
|
|
789
1201
|
console.print("\nWhat you get:")
|
|
790
1202
|
console.print(" - /jacked - Search past Claude sessions")
|
|
791
1203
|
console.print(" - /dc - Double-check reviewer")
|
|
792
1204
|
console.print(" - /pr - PR workflow helper")
|
|
1205
|
+
console.print(" - /learn - Distill lessons into CLAUDE.md rules")
|
|
1206
|
+
console.print(" - /techdebt - Project tech debt audit")
|
|
1207
|
+
console.print(" - /redo - Scrap and re-implement with hindsight")
|
|
1208
|
+
console.print(" - /audit-rules - CLAUDE.md quality audit")
|
|
793
1209
|
console.print(" - 10 specialized agents (readme, wiki, tests, etc.)")
|
|
1210
|
+
if install_search:
|
|
1211
|
+
console.print(" - Session indexing hook (auto-indexes after each response)")
|
|
1212
|
+
if install_security:
|
|
1213
|
+
console.print(" - Security gatekeeper (auto-approves safe Bash commands)")
|
|
1214
|
+
if not no_rules:
|
|
1215
|
+
console.print(" - Behavioral rules in CLAUDE.md")
|
|
1216
|
+
|
|
1217
|
+
# Show next steps based on what's installed
|
|
794
1218
|
console.print("\nNext steps:")
|
|
795
1219
|
console.print(" 1. Restart Claude Code (exit and run 'claude' again)")
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1220
|
+
if install_search:
|
|
1221
|
+
console.print(" 2. Set Qdrant credentials (run 'jacked configure' for help)")
|
|
1222
|
+
console.print(" 3. Run 'jacked backfill' to index existing sessions")
|
|
1223
|
+
console.print(" 4. Use '/jacked <description>' to search past sessions")
|
|
1224
|
+
else:
|
|
1225
|
+
console.print("\nOptional extras:")
|
|
1226
|
+
console.print(' pip install "claude-jacked[search]" # Session search via Qdrant')
|
|
1227
|
+
console.print(' pip install "claude-jacked[security]" # Auto-approve safe Bash commands')
|
|
1228
|
+
console.print(' pip install "claude-jacked[all]" # Everything')
|
|
799
1229
|
|
|
800
1230
|
|
|
801
1231
|
@main.command()
|
|
802
1232
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
803
1233
|
@click.option("--sounds", is_flag=True, help="Remove only sound hooks")
|
|
804
|
-
|
|
1234
|
+
@click.option("--security", is_flag=True, help="Remove only security gatekeeper hook")
|
|
1235
|
+
@click.option("--rules", is_flag=True, help="Remove only behavioral rules from CLAUDE.md")
|
|
1236
|
+
def uninstall(yes: bool, sounds: bool, security: bool, rules: bool):
|
|
805
1237
|
"""Remove jacked hooks, skill, agents, and commands from Claude Code."""
|
|
806
1238
|
import json
|
|
807
1239
|
import shutil
|
|
@@ -818,6 +1250,23 @@ def uninstall(yes: bool, sounds: bool):
|
|
|
818
1250
|
console.print("[yellow]No sound hooks found[/yellow]")
|
|
819
1251
|
return
|
|
820
1252
|
|
|
1253
|
+
# If --security flag, only remove security hook
|
|
1254
|
+
if security:
|
|
1255
|
+
if _remove_security_hook(settings_path):
|
|
1256
|
+
console.print("[bold]Security gatekeeper removed![/bold]")
|
|
1257
|
+
else:
|
|
1258
|
+
console.print("[yellow]No security gatekeeper hook found[/yellow]")
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
# If --rules flag, only remove behavioral rules
|
|
1262
|
+
if rules:
|
|
1263
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1264
|
+
if _remove_behavioral_rules(claude_md_path):
|
|
1265
|
+
console.print("[bold]Behavioral rules removed from CLAUDE.md![/bold]")
|
|
1266
|
+
else:
|
|
1267
|
+
console.print("[yellow]No behavioral rules found in CLAUDE.md[/yellow]")
|
|
1268
|
+
return
|
|
1269
|
+
|
|
821
1270
|
if not yes:
|
|
822
1271
|
if not click.confirm("Remove jacked from Claude Code? (This won't delete your Qdrant index)"):
|
|
823
1272
|
console.print("Cancelled")
|
|
@@ -825,8 +1274,12 @@ def uninstall(yes: bool, sounds: bool):
|
|
|
825
1274
|
|
|
826
1275
|
console.print("[bold]Uninstalling Jacked...[/bold]\n")
|
|
827
1276
|
|
|
828
|
-
# Also remove sound hooks during full uninstall
|
|
1277
|
+
# Also remove sound, security hooks, and behavioral rules during full uninstall
|
|
829
1278
|
_remove_sound_hooks(settings_path)
|
|
1279
|
+
_remove_security_hook(settings_path)
|
|
1280
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1281
|
+
if _remove_behavioral_rules(claude_md_path):
|
|
1282
|
+
console.print("[green][OK][/green] Removed behavioral rules from CLAUDE.md")
|
|
830
1283
|
|
|
831
1284
|
# Remove Stop hook from settings.json
|
|
832
1285
|
if settings_path.exists():
|