claude-jacked 0.2.7__py3-none-any.whl → 0.2.9__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.2.9.dist-info/METADATA +523 -0
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.2.9.dist-info}/RECORD +18 -11
- jacked/cli.py +391 -22
- 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/prompts/security_gatekeeper.txt +58 -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 → claude_jacked-0.2.9.dist-info}/WHEEL +0 -0
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.2.9.dist-info}/entry_points.txt +0 -0
- {claude_jacked-0.2.7.dist-info → claude_jacked-0.2.9.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>")
|
|
@@ -63,7 +70,12 @@ def index(session: Optional[str], repo: Optional[str]):
|
|
|
63
70
|
import os
|
|
64
71
|
from jacked.indexer import SessionIndexer
|
|
65
72
|
|
|
66
|
-
config
|
|
73
|
+
# Try to get config quietly - if not configured, nudge and exit cleanly
|
|
74
|
+
config = get_config(quiet=True)
|
|
75
|
+
if config is None:
|
|
76
|
+
print("[jacked] Indexing skipped - run 'jacked configure' to set up Qdrant")
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
67
79
|
indexer = SessionIndexer(config)
|
|
68
80
|
|
|
69
81
|
if session:
|
|
@@ -623,6 +635,8 @@ def _get_sound_command(hook_type: str) -> str:
|
|
|
623
635
|
|
|
624
636
|
def _install_sound_hooks(existing: dict, settings_path: Path):
|
|
625
637
|
"""Install sound notification hooks."""
|
|
638
|
+
import json
|
|
639
|
+
|
|
626
640
|
marker = _sound_hook_marker()
|
|
627
641
|
|
|
628
642
|
# Notification hook
|
|
@@ -680,9 +694,280 @@ def _remove_sound_hooks(settings_path: Path) -> bool:
|
|
|
680
694
|
return modified
|
|
681
695
|
|
|
682
696
|
|
|
697
|
+
def _get_behavioral_rules() -> str:
|
|
698
|
+
"""Load behavioral rules from data file."""
|
|
699
|
+
rules_path = _get_data_root() / "rules" / "jacked_behaviors.md"
|
|
700
|
+
if not rules_path.exists():
|
|
701
|
+
raise FileNotFoundError(f"Behavioral rules not found: {rules_path}")
|
|
702
|
+
return rules_path.read_text(encoding="utf-8").strip()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _behavioral_rules_marker() -> str:
|
|
706
|
+
"""Start marker for jacked behavioral rules block."""
|
|
707
|
+
return "# jacked-behaviors-v2"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _behavioral_rules_end_marker() -> str:
|
|
711
|
+
"""End marker for jacked behavioral rules block."""
|
|
712
|
+
return "# end-jacked-behaviors"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _install_behavioral_rules(claude_md_path: Path):
|
|
716
|
+
"""Install behavioral rules into CLAUDE.md with marker boundaries.
|
|
717
|
+
|
|
718
|
+
- Show rules before writing, require confirmation
|
|
719
|
+
- Backup file before first modification
|
|
720
|
+
- Atomic write (build in memory, write once)
|
|
721
|
+
- Skip if already installed with same version
|
|
722
|
+
"""
|
|
723
|
+
import shutil
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
rules_text = _get_behavioral_rules()
|
|
727
|
+
except FileNotFoundError as e:
|
|
728
|
+
console.print(f"[red][FAIL][/red] {e}")
|
|
729
|
+
console.print("[yellow]Skipping behavioral rules installation[/yellow]")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
start_marker = _behavioral_rules_marker()
|
|
733
|
+
end_marker = _behavioral_rules_end_marker()
|
|
734
|
+
|
|
735
|
+
# Read existing content
|
|
736
|
+
existing_content = ""
|
|
737
|
+
if claude_md_path.exists():
|
|
738
|
+
existing_content = claude_md_path.read_text(encoding="utf-8")
|
|
739
|
+
|
|
740
|
+
# Check if already installed (any version)
|
|
741
|
+
marker_prefix = "# jacked-behaviors-v"
|
|
742
|
+
has_start = marker_prefix in existing_content
|
|
743
|
+
has_end = end_marker in existing_content
|
|
744
|
+
|
|
745
|
+
# Orphaned marker detection: start without end (or end without start)
|
|
746
|
+
if has_start != has_end:
|
|
747
|
+
which = "start" if has_start else "end"
|
|
748
|
+
missing = "end" if has_start else "start"
|
|
749
|
+
console.print(f"[red][FAIL][/red] Found {which} marker but no {missing} marker in CLAUDE.md")
|
|
750
|
+
console.print("Your CLAUDE.md has a corrupted jacked rules block. Please fix it manually:")
|
|
751
|
+
console.print(f" Start marker: {start_marker}")
|
|
752
|
+
console.print(f" End marker: {end_marker}")
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
has_existing = has_start and has_end
|
|
756
|
+
if has_existing:
|
|
757
|
+
# Extract existing block (find the versioned start marker)
|
|
758
|
+
start_idx = existing_content.index(marker_prefix)
|
|
759
|
+
end_idx = existing_content.index(end_marker) + len(end_marker)
|
|
760
|
+
existing_block = existing_content[start_idx:end_idx].strip()
|
|
761
|
+
|
|
762
|
+
if existing_block == rules_text:
|
|
763
|
+
console.print("[yellow][-][/yellow] Behavioral rules already configured correctly")
|
|
764
|
+
return
|
|
765
|
+
else:
|
|
766
|
+
# Version upgrade needed
|
|
767
|
+
console.print("\n[bold]Behavioral rules update available:[/bold]")
|
|
768
|
+
console.print(f"[dim]{rules_text}[/dim]")
|
|
769
|
+
if not click.confirm("Update behavioral rules in CLAUDE.md?"):
|
|
770
|
+
console.print("[yellow][-][/yellow] Skipped behavioral rules update")
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
# Backup before modifying
|
|
774
|
+
backup_path = claude_md_path.with_suffix(".md.pre-jacked")
|
|
775
|
+
if not backup_path.exists():
|
|
776
|
+
shutil.copy2(claude_md_path, backup_path)
|
|
777
|
+
console.print(f"[dim]Backup: {backup_path}[/dim]")
|
|
778
|
+
|
|
779
|
+
# Replace the block (symmetric with _remove_behavioral_rules)
|
|
780
|
+
before = existing_content[:start_idx].rstrip("\n")
|
|
781
|
+
after = existing_content[end_idx:].lstrip("\n")
|
|
782
|
+
if before and after:
|
|
783
|
+
new_content = before + "\n\n" + rules_text + "\n\n" + after
|
|
784
|
+
elif before:
|
|
785
|
+
new_content = before + "\n\n" + rules_text + "\n"
|
|
786
|
+
else:
|
|
787
|
+
new_content = rules_text + "\n" + after if after else rules_text + "\n"
|
|
788
|
+
try:
|
|
789
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
790
|
+
except PermissionError:
|
|
791
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
792
|
+
console.print("Check file permissions and try again.")
|
|
793
|
+
return
|
|
794
|
+
console.print("[green][OK][/green] Updated behavioral rules to latest version")
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
# Fresh install - show and confirm
|
|
798
|
+
console.print("\n[bold]Proposed behavioral rules for ~/.claude/CLAUDE.md:[/bold]")
|
|
799
|
+
console.print(f"[dim]{rules_text}[/dim]")
|
|
800
|
+
if not click.confirm("Add these behavioral rules to your global CLAUDE.md?"):
|
|
801
|
+
console.print("[yellow][-][/yellow] Skipped behavioral rules")
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
# Backup before modifying (if file exists and no backup yet)
|
|
805
|
+
if claude_md_path.exists():
|
|
806
|
+
backup_path = claude_md_path.with_suffix(".md.pre-jacked")
|
|
807
|
+
if not backup_path.exists():
|
|
808
|
+
shutil.copy2(claude_md_path, backup_path)
|
|
809
|
+
console.print(f"[dim]Backup: {backup_path}[/dim]")
|
|
810
|
+
|
|
811
|
+
# Ensure parent directory exists
|
|
812
|
+
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
|
|
814
|
+
# Build new content atomically
|
|
815
|
+
if existing_content and not existing_content.endswith("\n\n"):
|
|
816
|
+
if existing_content.endswith("\n"):
|
|
817
|
+
new_content = existing_content + "\n" + rules_text + "\n"
|
|
818
|
+
else:
|
|
819
|
+
new_content = existing_content + "\n\n" + rules_text + "\n"
|
|
820
|
+
else:
|
|
821
|
+
new_content = existing_content + rules_text + "\n"
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
825
|
+
except PermissionError:
|
|
826
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
827
|
+
console.print("Check file permissions and try again.")
|
|
828
|
+
return
|
|
829
|
+
console.print("[green][OK][/green] Installed behavioral rules in CLAUDE.md")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _remove_behavioral_rules(claude_md_path: Path) -> bool:
|
|
833
|
+
"""Remove jacked behavioral rules block from CLAUDE.md.
|
|
834
|
+
|
|
835
|
+
Returns True if rules were found and removed.
|
|
836
|
+
"""
|
|
837
|
+
if not claude_md_path.exists():
|
|
838
|
+
return False
|
|
839
|
+
|
|
840
|
+
content = claude_md_path.read_text(encoding="utf-8")
|
|
841
|
+
marker_prefix = "# jacked-behaviors-v"
|
|
842
|
+
end_marker = _behavioral_rules_end_marker()
|
|
843
|
+
|
|
844
|
+
if marker_prefix not in content or end_marker not in content:
|
|
845
|
+
return False
|
|
846
|
+
|
|
847
|
+
start_idx = content.index(marker_prefix)
|
|
848
|
+
end_idx = content.index(end_marker) + len(end_marker)
|
|
849
|
+
|
|
850
|
+
# Strip the block and any extra blank lines around it
|
|
851
|
+
before = content[:start_idx].rstrip("\n")
|
|
852
|
+
after = content[end_idx:].lstrip("\n")
|
|
853
|
+
|
|
854
|
+
if before and after:
|
|
855
|
+
new_content = before + "\n\n" + after
|
|
856
|
+
elif before:
|
|
857
|
+
new_content = before + "\n"
|
|
858
|
+
else:
|
|
859
|
+
new_content = after
|
|
860
|
+
|
|
861
|
+
try:
|
|
862
|
+
claude_md_path.write_text(new_content, encoding="utf-8")
|
|
863
|
+
except PermissionError:
|
|
864
|
+
console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
|
|
865
|
+
return False
|
|
866
|
+
return True
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _security_hook_marker() -> str:
|
|
870
|
+
"""Marker to identify jacked security gatekeeper hooks."""
|
|
871
|
+
return "# jacked-security"
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _get_security_prompt() -> str:
|
|
875
|
+
"""Load security gatekeeper prompt from data file."""
|
|
876
|
+
prompt_path = _get_data_root() / "prompts" / "security_gatekeeper.txt"
|
|
877
|
+
if not prompt_path.exists():
|
|
878
|
+
raise FileNotFoundError(f"Security prompt not found: {prompt_path}")
|
|
879
|
+
return prompt_path.read_text(encoding="utf-8")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _install_security_hook(existing: dict, settings_path: Path):
|
|
883
|
+
"""Install Opus-powered security gatekeeper hook for Bash commands.
|
|
884
|
+
|
|
885
|
+
Handles fresh install and version upgrades (detects stale prompts).
|
|
886
|
+
"""
|
|
887
|
+
import json
|
|
888
|
+
|
|
889
|
+
marker = _security_hook_marker()
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
prompt_text = _get_security_prompt()
|
|
893
|
+
except FileNotFoundError as e:
|
|
894
|
+
console.print(f"[red][FAIL][/red] {e}")
|
|
895
|
+
console.print("[yellow]Skipping security gatekeeper installation[/yellow]")
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
if "PermissionRequest" not in existing["hooks"]:
|
|
899
|
+
existing["hooks"]["PermissionRequest"] = []
|
|
900
|
+
|
|
901
|
+
# Check if already installed and whether it needs upgrading
|
|
902
|
+
hook_index = None
|
|
903
|
+
needs_upgrade = False
|
|
904
|
+
for i, hook_entry in enumerate(existing["hooks"]["PermissionRequest"]):
|
|
905
|
+
hook_str = str(hook_entry)
|
|
906
|
+
if marker in hook_str:
|
|
907
|
+
hook_index = i
|
|
908
|
+
# Check if installed prompt matches current version
|
|
909
|
+
for h in hook_entry.get("hooks", []):
|
|
910
|
+
installed_prompt = h.get("prompt", "")
|
|
911
|
+
if installed_prompt != prompt_text:
|
|
912
|
+
needs_upgrade = True
|
|
913
|
+
break
|
|
914
|
+
|
|
915
|
+
if hook_index is not None and not needs_upgrade:
|
|
916
|
+
console.print("[yellow][-][/yellow] Security gatekeeper hook already configured")
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
hook_entry = {
|
|
920
|
+
"matcher": "Bash",
|
|
921
|
+
"hooks": [{
|
|
922
|
+
"type": "prompt",
|
|
923
|
+
"prompt": prompt_text,
|
|
924
|
+
"model": "opus",
|
|
925
|
+
"timeout": 60,
|
|
926
|
+
}]
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if hook_index is not None and needs_upgrade:
|
|
930
|
+
existing["hooks"]["PermissionRequest"][hook_index] = hook_entry
|
|
931
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
932
|
+
console.print("[green][OK][/green] Updated security gatekeeper prompt to latest version")
|
|
933
|
+
else:
|
|
934
|
+
existing["hooks"]["PermissionRequest"].append(hook_entry)
|
|
935
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
936
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
937
|
+
console.print("[green][OK][/green] Installed security gatekeeper (Opus evaluates Bash commands)")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _remove_security_hook(settings_path: Path) -> bool:
|
|
941
|
+
"""Remove jacked security gatekeeper hook. Returns True if removed."""
|
|
942
|
+
import json
|
|
943
|
+
|
|
944
|
+
if not settings_path.exists():
|
|
945
|
+
return False
|
|
946
|
+
|
|
947
|
+
settings = json.loads(settings_path.read_text())
|
|
948
|
+
marker = _security_hook_marker()
|
|
949
|
+
|
|
950
|
+
if "PermissionRequest" not in settings.get("hooks", {}):
|
|
951
|
+
return False
|
|
952
|
+
|
|
953
|
+
before = len(settings["hooks"]["PermissionRequest"])
|
|
954
|
+
settings["hooks"]["PermissionRequest"] = [
|
|
955
|
+
h for h in settings["hooks"]["PermissionRequest"]
|
|
956
|
+
if marker not in str(h)
|
|
957
|
+
]
|
|
958
|
+
if len(settings["hooks"]["PermissionRequest"]) < before:
|
|
959
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
960
|
+
console.print("[green][OK][/green] Removed security gatekeeper hook")
|
|
961
|
+
return True
|
|
962
|
+
|
|
963
|
+
return False
|
|
964
|
+
|
|
965
|
+
|
|
683
966
|
@main.command()
|
|
684
967
|
@click.option("--sounds", is_flag=True, help="Install sound notification hooks")
|
|
685
|
-
|
|
968
|
+
@click.option("--no-security", is_flag=True, help="Skip security gatekeeper hook")
|
|
969
|
+
@click.option("--no-rules", is_flag=True, help="Skip behavioral rules in CLAUDE.md")
|
|
970
|
+
def install(sounds: bool, no_security: bool, no_rules: bool):
|
|
686
971
|
"""Auto-install hook config, skill, agents, and commands."""
|
|
687
972
|
import os
|
|
688
973
|
import json
|
|
@@ -692,6 +977,7 @@ def install(sounds: bool):
|
|
|
692
977
|
pkg_root = _get_data_root()
|
|
693
978
|
|
|
694
979
|
# Hook configuration - assumes jacked is on PATH (installed via pipx)
|
|
980
|
+
# async: True runs indexing in background so Claude Code doesn't wait
|
|
695
981
|
hook_config = {
|
|
696
982
|
"hooks": {
|
|
697
983
|
"Stop": [
|
|
@@ -700,7 +986,8 @@ def install(sounds: bool):
|
|
|
700
986
|
"hooks": [
|
|
701
987
|
{
|
|
702
988
|
"type": "command",
|
|
703
|
-
"command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"'
|
|
989
|
+
"command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"',
|
|
990
|
+
"async": True
|
|
704
991
|
}
|
|
705
992
|
]
|
|
706
993
|
}
|
|
@@ -726,19 +1013,31 @@ def install(sounds: bool):
|
|
|
726
1013
|
if "Stop" not in existing["hooks"]:
|
|
727
1014
|
existing["hooks"]["Stop"] = []
|
|
728
1015
|
|
|
729
|
-
# Check if hook already exists
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1016
|
+
# Check if hook already exists and if it needs updating
|
|
1017
|
+
hook_index = None
|
|
1018
|
+
needs_async_update = False
|
|
1019
|
+
for i, hook_entry in enumerate(existing["hooks"]["Stop"]):
|
|
1020
|
+
for h in hook_entry.get("hooks", []):
|
|
1021
|
+
if "jacked" in h.get("command", ""):
|
|
1022
|
+
hook_index = i
|
|
1023
|
+
# Check if async is missing or false
|
|
1024
|
+
if not h.get("async"):
|
|
1025
|
+
needs_async_update = True
|
|
1026
|
+
break
|
|
1027
|
+
|
|
1028
|
+
if hook_index is None:
|
|
1029
|
+
# No hook exists - add it
|
|
736
1030
|
existing["hooks"]["Stop"].append(hook_config["hooks"]["Stop"][0])
|
|
737
1031
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
738
1032
|
settings_path.write_text(json.dumps(existing, indent=2))
|
|
739
1033
|
console.print(f"[green][OK][/green] Added Stop hook to {settings_path}")
|
|
1034
|
+
elif needs_async_update:
|
|
1035
|
+
# Hook exists but needs async: true
|
|
1036
|
+
existing["hooks"]["Stop"][hook_index] = hook_config["hooks"]["Stop"][0]
|
|
1037
|
+
settings_path.write_text(json.dumps(existing, indent=2))
|
|
1038
|
+
console.print(f"[green][OK][/green] Updated Stop hook with async: true")
|
|
740
1039
|
else:
|
|
741
|
-
console.print(f"[yellow][-][/yellow] Stop hook already
|
|
1040
|
+
console.print(f"[yellow][-][/yellow] Stop hook already configured correctly")
|
|
742
1041
|
|
|
743
1042
|
# Copy skill file with Python path templating
|
|
744
1043
|
# Claude Code expects skills in subdirectories with SKILL.md
|
|
@@ -754,29 +1053,59 @@ def install(sounds: bool):
|
|
|
754
1053
|
else:
|
|
755
1054
|
console.print(f"[yellow][-][/yellow] Skill file not found at {skill_src}")
|
|
756
1055
|
|
|
757
|
-
# Copy agents
|
|
1056
|
+
# Copy agents (with conflict detection)
|
|
758
1057
|
agents_src = pkg_root / "agents"
|
|
759
1058
|
agents_dst = home / ".claude" / "agents"
|
|
760
1059
|
if agents_src.exists():
|
|
761
1060
|
agents_dst.mkdir(parents=True, exist_ok=True)
|
|
762
1061
|
agent_count = 0
|
|
1062
|
+
skipped = 0
|
|
763
1063
|
for agent_file in agents_src.glob("*.md"):
|
|
764
|
-
|
|
1064
|
+
dst_file = agents_dst / agent_file.name
|
|
1065
|
+
src_content = agent_file.read_text(encoding="utf-8")
|
|
1066
|
+
if dst_file.exists():
|
|
1067
|
+
dst_content = dst_file.read_text(encoding="utf-8")
|
|
1068
|
+
if src_content == dst_content:
|
|
1069
|
+
skipped += 1
|
|
1070
|
+
continue # Same content, skip silently
|
|
1071
|
+
# Different content - ask before overwriting
|
|
1072
|
+
if not click.confirm(f"Agent '{agent_file.name}' exists with different content. Overwrite?"):
|
|
1073
|
+
console.print(f"[yellow][-][/yellow] Skipped {agent_file.name}")
|
|
1074
|
+
continue
|
|
1075
|
+
shutil.copy(agent_file, dst_file)
|
|
765
1076
|
agent_count += 1
|
|
766
|
-
|
|
1077
|
+
msg = f"[green][OK][/green] Installed {agent_count} agents"
|
|
1078
|
+
if skipped:
|
|
1079
|
+
msg += f" ({skipped} unchanged)"
|
|
1080
|
+
console.print(msg)
|
|
767
1081
|
else:
|
|
768
1082
|
console.print(f"[yellow][-][/yellow] Agents directory not found")
|
|
769
1083
|
|
|
770
|
-
# Copy commands
|
|
1084
|
+
# Copy commands (with conflict detection)
|
|
771
1085
|
commands_src = pkg_root / "commands"
|
|
772
1086
|
commands_dst = home / ".claude" / "commands"
|
|
773
1087
|
if commands_src.exists():
|
|
774
1088
|
commands_dst.mkdir(parents=True, exist_ok=True)
|
|
775
1089
|
cmd_count = 0
|
|
1090
|
+
skipped = 0
|
|
776
1091
|
for cmd_file in commands_src.glob("*.md"):
|
|
777
|
-
|
|
1092
|
+
dst_file = commands_dst / cmd_file.name
|
|
1093
|
+
src_content = cmd_file.read_text(encoding="utf-8")
|
|
1094
|
+
if dst_file.exists():
|
|
1095
|
+
dst_content = dst_file.read_text(encoding="utf-8")
|
|
1096
|
+
if src_content == dst_content:
|
|
1097
|
+
skipped += 1
|
|
1098
|
+
continue # Same content, skip silently
|
|
1099
|
+
# Different content - ask before overwriting
|
|
1100
|
+
if not click.confirm(f"Command '{cmd_file.name}' exists with different content. Overwrite?"):
|
|
1101
|
+
console.print(f"[yellow][-][/yellow] Skipped {cmd_file.name}")
|
|
1102
|
+
continue
|
|
1103
|
+
shutil.copy(cmd_file, dst_file)
|
|
778
1104
|
cmd_count += 1
|
|
779
|
-
|
|
1105
|
+
msg = f"[green][OK][/green] Installed {cmd_count} commands"
|
|
1106
|
+
if skipped:
|
|
1107
|
+
msg += f" ({skipped} unchanged)"
|
|
1108
|
+
console.print(msg)
|
|
780
1109
|
else:
|
|
781
1110
|
console.print(f"[yellow][-][/yellow] Commands directory not found")
|
|
782
1111
|
|
|
@@ -784,13 +1113,30 @@ def install(sounds: bool):
|
|
|
784
1113
|
if sounds:
|
|
785
1114
|
_install_sound_hooks(existing, settings_path)
|
|
786
1115
|
|
|
1116
|
+
# Install security gatekeeper (default on, --no-security to skip)
|
|
1117
|
+
if not no_security:
|
|
1118
|
+
_install_security_hook(existing, settings_path)
|
|
1119
|
+
|
|
1120
|
+
# Install behavioral rules in CLAUDE.md (default on, --no-rules to skip)
|
|
1121
|
+
if not no_rules:
|
|
1122
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1123
|
+
_install_behavioral_rules(claude_md_path)
|
|
1124
|
+
|
|
787
1125
|
console.print("\n[bold]Installation complete![/bold]")
|
|
788
1126
|
console.print("\n[yellow]IMPORTANT: Restart Claude Code for new commands to take effect![/yellow]")
|
|
789
1127
|
console.print("\nWhat you get:")
|
|
790
1128
|
console.print(" - /jacked - Search past Claude sessions")
|
|
791
|
-
console.print(" - /dc - Double-check reviewer")
|
|
1129
|
+
console.print(" - /dc - Double-check reviewer (with grill mode)")
|
|
792
1130
|
console.print(" - /pr - PR workflow helper")
|
|
1131
|
+
console.print(" - /learn - Distill lessons into CLAUDE.md rules")
|
|
1132
|
+
console.print(" - /techdebt - Project tech debt audit")
|
|
1133
|
+
console.print(" - /redo - Scrap and re-implement with hindsight")
|
|
1134
|
+
console.print(" - /audit-rules - CLAUDE.md quality audit")
|
|
793
1135
|
console.print(" - 10 specialized agents (readme, wiki, tests, etc.)")
|
|
1136
|
+
if not no_security:
|
|
1137
|
+
console.print(" - Security gatekeeper (Opus evaluates Bash commands)")
|
|
1138
|
+
if not no_rules:
|
|
1139
|
+
console.print(" - Behavioral rules in CLAUDE.md (auto-triggers for jacked commands)")
|
|
794
1140
|
console.print("\nNext steps:")
|
|
795
1141
|
console.print(" 1. Restart Claude Code (exit and run 'claude' again)")
|
|
796
1142
|
console.print(" 2. Set environment variables (run 'jacked configure' for help)")
|
|
@@ -801,7 +1147,9 @@ def install(sounds: bool):
|
|
|
801
1147
|
@main.command()
|
|
802
1148
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
803
1149
|
@click.option("--sounds", is_flag=True, help="Remove only sound hooks")
|
|
804
|
-
|
|
1150
|
+
@click.option("--security", is_flag=True, help="Remove only security gatekeeper hook")
|
|
1151
|
+
@click.option("--rules", is_flag=True, help="Remove only behavioral rules from CLAUDE.md")
|
|
1152
|
+
def uninstall(yes: bool, sounds: bool, security: bool, rules: bool):
|
|
805
1153
|
"""Remove jacked hooks, skill, agents, and commands from Claude Code."""
|
|
806
1154
|
import json
|
|
807
1155
|
import shutil
|
|
@@ -818,6 +1166,23 @@ def uninstall(yes: bool, sounds: bool):
|
|
|
818
1166
|
console.print("[yellow]No sound hooks found[/yellow]")
|
|
819
1167
|
return
|
|
820
1168
|
|
|
1169
|
+
# If --security flag, only remove security hook
|
|
1170
|
+
if security:
|
|
1171
|
+
if _remove_security_hook(settings_path):
|
|
1172
|
+
console.print("[bold]Security gatekeeper removed![/bold]")
|
|
1173
|
+
else:
|
|
1174
|
+
console.print("[yellow]No security gatekeeper hook found[/yellow]")
|
|
1175
|
+
return
|
|
1176
|
+
|
|
1177
|
+
# If --rules flag, only remove behavioral rules
|
|
1178
|
+
if rules:
|
|
1179
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1180
|
+
if _remove_behavioral_rules(claude_md_path):
|
|
1181
|
+
console.print("[bold]Behavioral rules removed from CLAUDE.md![/bold]")
|
|
1182
|
+
else:
|
|
1183
|
+
console.print("[yellow]No behavioral rules found in CLAUDE.md[/yellow]")
|
|
1184
|
+
return
|
|
1185
|
+
|
|
821
1186
|
if not yes:
|
|
822
1187
|
if not click.confirm("Remove jacked from Claude Code? (This won't delete your Qdrant index)"):
|
|
823
1188
|
console.print("Cancelled")
|
|
@@ -825,8 +1190,12 @@ def uninstall(yes: bool, sounds: bool):
|
|
|
825
1190
|
|
|
826
1191
|
console.print("[bold]Uninstalling Jacked...[/bold]\n")
|
|
827
1192
|
|
|
828
|
-
# Also remove sound hooks during full uninstall
|
|
1193
|
+
# Also remove sound, security hooks, and behavioral rules during full uninstall
|
|
829
1194
|
_remove_sound_hooks(settings_path)
|
|
1195
|
+
_remove_security_hook(settings_path)
|
|
1196
|
+
claude_md_path = home / ".claude" / "CLAUDE.md"
|
|
1197
|
+
if _remove_behavioral_rules(claude_md_path):
|
|
1198
|
+
console.print("[green][OK][/green] Removed behavioral rules from CLAUDE.md")
|
|
830
1199
|
|
|
831
1200
|
# Remove Stop hook from settings.json
|
|
832
1201
|
if settings_path.exists():
|
jacked/client.py
CHANGED
|
@@ -55,11 +55,13 @@ class QdrantSessionClient:
|
|
|
55
55
|
|
|
56
56
|
def ensure_collection(self) -> bool:
|
|
57
57
|
"""
|
|
58
|
-
Ensure the collection exists
|
|
58
|
+
Ensure the collection exists with all required indexes.
|
|
59
59
|
|
|
60
60
|
Creates collection with:
|
|
61
61
|
- Dense vectors for semantic search
|
|
62
|
-
- Payload indexing for
|
|
62
|
+
- Payload indexing for filtering
|
|
63
|
+
|
|
64
|
+
Also ensures indexes exist on existing collections (for upgrades).
|
|
63
65
|
|
|
64
66
|
Returns:
|
|
65
67
|
True if collection exists or was created
|
|
@@ -75,7 +77,9 @@ class QdrantSessionClient:
|
|
|
75
77
|
exists = any(c.name == collection_name for c in collections.collections)
|
|
76
78
|
|
|
77
79
|
if exists:
|
|
78
|
-
logger.
|
|
80
|
+
logger.debug(f"Collection '{collection_name}' already exists")
|
|
81
|
+
# Ensure indexes exist (handles upgrades)
|
|
82
|
+
self._ensure_indexes(collection_name)
|
|
79
83
|
return True
|
|
80
84
|
|
|
81
85
|
logger.info(f"Creating collection '{collection_name}'")
|
|
@@ -134,6 +138,30 @@ class QdrantSessionClient:
|
|
|
134
138
|
logger.error(f"Failed to create collection: {e}")
|
|
135
139
|
raise
|
|
136
140
|
|
|
141
|
+
def _ensure_indexes(self, collection_name: str):
|
|
142
|
+
"""
|
|
143
|
+
Ensure all required payload indexes exist on a collection.
|
|
144
|
+
|
|
145
|
+
Creates indexes if they don't exist (idempotent).
|
|
146
|
+
"""
|
|
147
|
+
required_indexes = [
|
|
148
|
+
"repo_id", "repo_name", "session_id", "type",
|
|
149
|
+
"machine", "user_name", "content_type"
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
for field_name in required_indexes:
|
|
153
|
+
try:
|
|
154
|
+
self.client.create_payload_index(
|
|
155
|
+
collection_name=collection_name,
|
|
156
|
+
field_name=field_name,
|
|
157
|
+
field_schema=models.PayloadSchemaType.KEYWORD,
|
|
158
|
+
)
|
|
159
|
+
logger.debug(f"Created index for '{field_name}'")
|
|
160
|
+
except UnexpectedResponse as e:
|
|
161
|
+
# Index might already exist - that's fine
|
|
162
|
+
if "already exists" not in str(e).lower():
|
|
163
|
+
logger.warning(f"Could not create index for '{field_name}': {e}")
|
|
164
|
+
|
|
137
165
|
def upsert_points(self, points: list[models.PointStruct]) -> bool:
|
|
138
166
|
"""
|
|
139
167
|
Upsert points to the collection.
|
|
@@ -192,6 +220,48 @@ class QdrantSessionClient:
|
|
|
192
220
|
logger.error(f"Failed to delete session {session_id}: {e}")
|
|
193
221
|
raise
|
|
194
222
|
|
|
223
|
+
def get_session_points(self, session_id: str, user_name: str) -> list:
|
|
224
|
+
"""
|
|
225
|
+
Get all points for a session owned by this user (for write tracker seeding).
|
|
226
|
+
|
|
227
|
+
IMPORTANT: Filters by BOTH session_id AND user_name to ensure we only
|
|
228
|
+
see our own data. This is for write-side tracking only - not for retrieval.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
session_id: Session UUID
|
|
232
|
+
user_name: User name to filter by
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of Qdrant points with payloads (no vectors)
|
|
236
|
+
"""
|
|
237
|
+
points = []
|
|
238
|
+
offset = None
|
|
239
|
+
while True:
|
|
240
|
+
result = self.client.scroll(
|
|
241
|
+
collection_name=self.config.collection_name,
|
|
242
|
+
scroll_filter=models.Filter(
|
|
243
|
+
must=[
|
|
244
|
+
models.FieldCondition(
|
|
245
|
+
key="session_id",
|
|
246
|
+
match=models.MatchValue(value=session_id)
|
|
247
|
+
),
|
|
248
|
+
models.FieldCondition(
|
|
249
|
+
key="user_name",
|
|
250
|
+
match=models.MatchValue(value=user_name)
|
|
251
|
+
)
|
|
252
|
+
]
|
|
253
|
+
),
|
|
254
|
+
limit=100,
|
|
255
|
+
offset=offset,
|
|
256
|
+
with_payload=True,
|
|
257
|
+
with_vectors=False, # Don't need vectors, just metadata
|
|
258
|
+
)
|
|
259
|
+
points.extend(result[0])
|
|
260
|
+
offset = result[1]
|
|
261
|
+
if offset is None:
|
|
262
|
+
break
|
|
263
|
+
return points
|
|
264
|
+
|
|
195
265
|
def delete_by_user(self, user_name: str) -> int:
|
|
196
266
|
"""
|
|
197
267
|
Delete all points for a specific user.
|
|
@@ -387,28 +457,6 @@ class QdrantSessionClient:
|
|
|
387
457
|
logger.error(f"Failed to get points for session {session_id}: {e}")
|
|
388
458
|
raise
|
|
389
459
|
|
|
390
|
-
def get_point_by_id(self, point_id: str) -> Optional[models.Record]:
|
|
391
|
-
"""
|
|
392
|
-
Get a single point by ID.
|
|
393
|
-
|
|
394
|
-
Args:
|
|
395
|
-
point_id: Point ID to retrieve
|
|
396
|
-
|
|
397
|
-
Returns:
|
|
398
|
-
Record object or None if not found
|
|
399
|
-
"""
|
|
400
|
-
try:
|
|
401
|
-
results = self.client.retrieve(
|
|
402
|
-
collection_name=self.config.collection_name,
|
|
403
|
-
ids=[point_id],
|
|
404
|
-
with_payload=True,
|
|
405
|
-
with_vectors=False,
|
|
406
|
-
)
|
|
407
|
-
return results[0] if results else None
|
|
408
|
-
except UnexpectedResponse as e:
|
|
409
|
-
logger.error(f"Failed to get point {point_id}: {e}")
|
|
410
|
-
return None
|
|
411
|
-
|
|
412
460
|
def list_sessions(
|
|
413
461
|
self,
|
|
414
462
|
repo_id: Optional[str] = None,
|
|
@@ -424,10 +472,11 @@ class QdrantSessionClient:
|
|
|
424
472
|
Returns:
|
|
425
473
|
List of session metadata dicts
|
|
426
474
|
"""
|
|
475
|
+
# Filter by plan content_type - one per session, gives unique sessions
|
|
427
476
|
filter_conditions = [
|
|
428
477
|
models.FieldCondition(
|
|
429
|
-
key="
|
|
430
|
-
match=models.MatchValue(value="
|
|
478
|
+
key="content_type",
|
|
479
|
+
match=models.MatchValue(value="plan"),
|
|
431
480
|
)
|
|
432
481
|
]
|
|
433
482
|
|
|
@@ -455,9 +504,10 @@ class QdrantSessionClient:
|
|
|
455
504
|
"session_id": payload.get("session_id"),
|
|
456
505
|
"repo_name": payload.get("repo_name"),
|
|
457
506
|
"repo_path": payload.get("repo_path"),
|
|
507
|
+
"user_name": payload.get("user_name"),
|
|
458
508
|
"machine": payload.get("machine"),
|
|
459
509
|
"timestamp": payload.get("timestamp"),
|
|
460
|
-
"chunk_count": payload.get("
|
|
510
|
+
"chunk_count": payload.get("total_chunks", 0),
|
|
461
511
|
})
|
|
462
512
|
|
|
463
513
|
return sessions
|