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.
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 = get_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
- By default, searches plan, subagent_summary, summary_label, and user_message content.
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
- Modes:
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
- Only deletes YOUR data (matching your user_name), not teammates' data.
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
- def install(sounds: bool):
686
- """Auto-install hook config, skill, agents, and commands."""
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
- # Hook configuration - assumes jacked is on PATH (installed via pipx)
695
- hook_config = {
696
- "hooks": {
697
- "Stop": [
698
- {
699
- "matcher": "",
700
- "hooks": [
701
- {
702
- "type": "command",
703
- "command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"'
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
- # Check if hook already exists
730
- hook_exists = any(
731
- "jacked" in str(h.get("hooks", []))
732
- for h in existing["hooks"]["Stop"]
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
- if not hook_exists:
736
- existing["hooks"]["Stop"].append(hook_config["hooks"]["Stop"][0])
737
- settings_path.parent.mkdir(parents=True, exist_ok=True)
738
- settings_path.write_text(json.dumps(existing, indent=2))
739
- console.print(f"[green][OK][/green] Added Stop hook to {settings_path}")
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(f"[yellow][-][/yellow] Stop hook already exists in {settings_path}")
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
- shutil.copy(agent_file, agents_dst / agent_file.name)
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
- console.print(f"[green][OK][/green] Installed {agent_count} agents")
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
- shutil.copy(cmd_file, commands_dst / cmd_file.name)
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
- console.print(f"[green][OK][/green] Installed {cmd_count} commands")
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
- console.print(" 2. Set environment variables (run 'jacked configure' for help)")
797
- console.print(" 3. Run 'jacked backfill' to index existing sessions")
798
- console.print(" 4. Use '/jacked <description>' in Claude to search past sessions")
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
- def uninstall(yes: bool, sounds: bool):
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():