codetherapy 0.1.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.
- codetherapy/__init__.py +3 -0
- codetherapy/__main__.py +3 -0
- codetherapy/cli.py +494 -0
- codetherapy/clipboard.py +30 -0
- codetherapy/config.py +99 -0
- codetherapy/context.py +119 -0
- codetherapy/deep.py +93 -0
- codetherapy/notes.py +58 -0
- codetherapy/procutil.py +35 -0
- codetherapy/projects.py +131 -0
- codetherapy/search.py +163 -0
- codetherapy/server.py +163 -0
- codetherapy/sessions.py +101 -0
- codetherapy/textutil.py +11 -0
- codetherapy/tunnel.py +134 -0
- codetherapy-0.1.0.dist-info/METADATA +326 -0
- codetherapy-0.1.0.dist-info/RECORD +20 -0
- codetherapy-0.1.0.dist-info/WHEEL +4 -0
- codetherapy-0.1.0.dist-info/entry_points.txt +2 -0
- codetherapy-0.1.0.dist-info/licenses/LICENSE +21 -0
codetherapy/context.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Fast, voice-sized context about a project, built from files, git and session logs (no AI calls)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codetherapy import notes
|
|
9
|
+
from codetherapy.projects import Project, file_mtime
|
|
10
|
+
from codetherapy.sessions import SessionSummary, summarize
|
|
11
|
+
from codetherapy.textutil import clip
|
|
12
|
+
|
|
13
|
+
MAX_BRIEF = 6000
|
|
14
|
+
OVERVIEW_CHARS = 1500
|
|
15
|
+
TRIM_MARK = "\n(trimmed)"
|
|
16
|
+
GIT_TIMEOUT_SECONDS = 5.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_git(path: Path, *args: str) -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(["git", "--no-optional-locks", "-C", str(path), *args], capture_output=True, timeout=GIT_TIMEOUT_SECONDS)
|
|
22
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
23
|
+
return None
|
|
24
|
+
if result.returncode != 0:
|
|
25
|
+
return None
|
|
26
|
+
return result.stdout.decode("utf-8", errors="replace").strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def overview(path: Path) -> str:
|
|
30
|
+
for name in ("CLAUDE.md", "README.md"):
|
|
31
|
+
file = path / name
|
|
32
|
+
if file.is_file():
|
|
33
|
+
text = file.read_text(encoding="utf-8", errors="replace").strip()
|
|
34
|
+
if text:
|
|
35
|
+
return f"From {name}:\n{clip(text, OVERVIEW_CHARS)}"
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def git_summary(path: Path, since_days: int | None = None) -> str:
|
|
40
|
+
branch = run_git(path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
41
|
+
if branch is None:
|
|
42
|
+
return ""
|
|
43
|
+
status = run_git(path, "status", "--porcelain") or ""
|
|
44
|
+
changed = sum(1 for line in status.splitlines() if line.strip())
|
|
45
|
+
state = "clean" if changed == 0 else f"{changed} uncommitted file(s)"
|
|
46
|
+
log_args = ["log", "--date=short", "--pretty=format:%h %ad %s"]
|
|
47
|
+
log_args.append(f"--since={since_days} days ago" if since_days else "-10")
|
|
48
|
+
log = run_git(path, *log_args) or "(no commits in range)"
|
|
49
|
+
return f"Branch: {branch} ({state})\nCommits:\n{log}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _when(moment: datetime | None) -> str:
|
|
53
|
+
return moment.astimezone().strftime("%Y-%m-%d") if moment else "unknown date"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _session_line(s: SessionSummary, all_prompts: bool = False) -> str:
|
|
57
|
+
line = f"- {_when(s.ended)}: {s.title or '(untitled session)'}"
|
|
58
|
+
shown = s.prompts if all_prompts else s.prompts[:1]
|
|
59
|
+
if shown:
|
|
60
|
+
line += " — asked: " + " / ".join(f'"{p}"' for p in shown)
|
|
61
|
+
return line
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _summaries(files: tuple[Path, ...] | list[Path]) -> list[SessionSummary]:
|
|
65
|
+
out = []
|
|
66
|
+
for file in files:
|
|
67
|
+
try:
|
|
68
|
+
out.append(summarize(file))
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assemble(header: str, sections: list[tuple[str, str]], limit: int) -> str:
|
|
75
|
+
"""Render markdown sections, trimming the longest bodies until the whole fits in limit."""
|
|
76
|
+
|
|
77
|
+
def render(secs: list[tuple[str, str]]) -> str:
|
|
78
|
+
return "\n\n".join([header, *(f"## {title}\n{body}" for title, body in secs)])
|
|
79
|
+
|
|
80
|
+
secs = list(sections)
|
|
81
|
+
for _ in range(2 * len(secs)):
|
|
82
|
+
overflow = len(render(secs)) - limit
|
|
83
|
+
if overflow <= 0:
|
|
84
|
+
break
|
|
85
|
+
i = max(range(len(secs)), key=lambda j: len(secs[j][1]))
|
|
86
|
+
title, body = secs[i]
|
|
87
|
+
keep = max(0, len(body) - overflow - len(TRIM_MARK))
|
|
88
|
+
secs[i] = (title, body[:keep].rstrip() + TRIM_MARK)
|
|
89
|
+
return render(secs)[:limit]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def brief(project: Project) -> str:
|
|
93
|
+
header = f"# {project.name}\nPath: {project.path}\nLast active: {_when(project.last_active)}"
|
|
94
|
+
sections: list[tuple[str, str]] = []
|
|
95
|
+
if not project.exists:
|
|
96
|
+
header += "\nNote: the project folder no longer exists on disk; only Claude Code session history is available."
|
|
97
|
+
else:
|
|
98
|
+
if text := overview(project.path):
|
|
99
|
+
sections.append(("Overview", text))
|
|
100
|
+
if text := git_summary(project.path):
|
|
101
|
+
sections.append(("Git", text))
|
|
102
|
+
sessions = _summaries(project.session_files[:5])
|
|
103
|
+
sections.append(("Recent Claude Code sessions", "\n".join(_session_line(s) for s in sessions) or "(none)"))
|
|
104
|
+
if project.exists and (open_notes := notes.recent(project.path, 15)):
|
|
105
|
+
sections.append(("Open notes (PM_NOTES.md)", "\n".join(open_notes)))
|
|
106
|
+
return assemble(header, sections, MAX_BRIEF)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def activity(project: Project, days: int = 7) -> str:
|
|
110
|
+
days = max(1, min(int(days), 365))
|
|
111
|
+
cutoff = datetime.now().timestamp() - days * 86400
|
|
112
|
+
recent_files = [f for f in project.session_files if file_mtime(f) >= cutoff]
|
|
113
|
+
sessions = _summaries(recent_files[:20])
|
|
114
|
+
header = f"# {project.name}: activity in the last {days} day(s)"
|
|
115
|
+
sections = [("Claude Code sessions",
|
|
116
|
+
"\n".join(_session_line(s, all_prompts=True) for s in sessions) or "(no sessions in this window)")]
|
|
117
|
+
if project.exists and (text := git_summary(project.path, since_days=days)):
|
|
118
|
+
sections.append(("Git", text))
|
|
119
|
+
return assemble(header, sections, MAX_BRIEF)
|
codetherapy/deep.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Deep questions: run Claude Code headless and read-only inside the project folder."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codetherapy import procutil
|
|
10
|
+
from codetherapy.search import HOME_FOLDER_MESSAGE, SECRET_DIRS, SECRET_PATTERNS, is_home_or_above
|
|
11
|
+
from codetherapy.textutil import clip
|
|
12
|
+
|
|
13
|
+
TIMEOUT_SECONDS = 90.0
|
|
14
|
+
MAX_ANSWER = 4000
|
|
15
|
+
TOOLS = "Read,Grep,Glob"
|
|
16
|
+
# Pre-approve reads only inside the project folder ("./" resolves against cwd = the project path).
|
|
17
|
+
# A bare "Read" rule would pre-approve reading any file on the PC.
|
|
18
|
+
ALLOWED_TOOLS = "Read(./**),Grep(./**),Glob(./**)"
|
|
19
|
+
# Belt and braces: credentials and secrets stay unreadable even if a project path sits above them,
|
|
20
|
+
# and the secret files search_project skips are denied inside the project too.
|
|
21
|
+
SECRET_READ_RULES = (*(f"Read(**/{pattern})" for pattern in SECRET_PATTERNS),
|
|
22
|
+
*(f"Read(**/{folder}/**)" for folder in SECRET_DIRS))
|
|
23
|
+
# Home-folder secrets, in case a project path sits above them: Claude Code's own config and history,
|
|
24
|
+
# SSH keys, Windows app data, and the config of Code Therapy (the connector token), ngrok (its authtoken),
|
|
25
|
+
# gh, Docker, kubectl and PyPI. Targeted folders only: ~/.config and ~/Library hold harmless files too.
|
|
26
|
+
HOME_READ_RULES = ("Read(~/.claude/**)", "Read(~/.claude.json)", "Read(~/.ssh/**)", "Read(~/AppData/**)",
|
|
27
|
+
"Read(~/.config/codetherapy/**)", "Read(~/Library/Application Support/codetherapy/**)",
|
|
28
|
+
"Read(~/.config/ngrok/**)", "Read(~/Library/Application Support/ngrok/**)",
|
|
29
|
+
"Read(~/.config/gh/**)", "Read(~/.docker/config.json)", "Read(~/.kube/**)", "Read(~/.pypirc)")
|
|
30
|
+
DISALLOWED_TOOLS = ",".join(("Bash", "Write", "Edit", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent",
|
|
31
|
+
*HOME_READ_RULES, *SECRET_READ_RULES))
|
|
32
|
+
# Set by Claude Code in its own terminals; a child `claude` would refuse to start as a nested session.
|
|
33
|
+
NESTED_SESSION_VARS = ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT")
|
|
34
|
+
TIMEOUT_MESSAGE = "That needs a longer look than I can do live — try asking it in Claude Code directly."
|
|
35
|
+
FRAMING = (
|
|
36
|
+
"You are the project manager for this codebase, answering a question the owner asked out loud. "
|
|
37
|
+
"Investigate with your read-only tools as needed, then answer in a few plain spoken sentences: "
|
|
38
|
+
"no code blocks, no tables, no long lists.\n\nQuestion: "
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_command() -> list[str]:
|
|
43
|
+
exe = shutil.which("claude")
|
|
44
|
+
if not exe:
|
|
45
|
+
raise FileNotFoundError("Claude Code CLI ('claude') not found on PATH")
|
|
46
|
+
return [exe]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_args() -> list[str]:
|
|
50
|
+
"""Flags that make the headless run read-only by construction (not just by denylist)."""
|
|
51
|
+
return ["-p",
|
|
52
|
+
"--no-session-persistence", # a lookup isn't a session: keep it out of history and briefs
|
|
53
|
+
"--tools", TOOLS,
|
|
54
|
+
"--allowedTools", ALLOWED_TOOLS,
|
|
55
|
+
"--disallowedTools", DISALLOWED_TOOLS,
|
|
56
|
+
"--permission-mode", "dontAsk",
|
|
57
|
+
"--strict-mcp-config",
|
|
58
|
+
"--disable-slash-commands",
|
|
59
|
+
"--restricted",
|
|
60
|
+
"--output-format", "text"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(cmd: list[str], prompt: str, cwd: Path, env: dict[str, str], timeout: float):
|
|
64
|
+
"""Run cmd; on timeout kill the whole process tree so inherited pipes close. Returns (code, out, err) or None on timeout."""
|
|
65
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
66
|
+
cwd=cwd, env=env, encoding="utf-8", errors="replace", **procutil.popen_group_kwargs())
|
|
67
|
+
try:
|
|
68
|
+
out, err = proc.communicate(prompt, timeout=timeout)
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
procutil.kill_tree(proc)
|
|
71
|
+
return None
|
|
72
|
+
return proc.returncode, out, err
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ask(project_path: Path, question: str, command: list[str] | None = None,
|
|
76
|
+
timeout: float = TIMEOUT_SECONDS) -> str:
|
|
77
|
+
if not project_path.is_dir():
|
|
78
|
+
return f"I can't open that project's folder anymore ({project_path})."
|
|
79
|
+
if is_home_or_above(project_path):
|
|
80
|
+
return HOME_FOLDER_MESSAGE
|
|
81
|
+
env = {k: v for k, v in os.environ.items() if k not in NESTED_SESSION_VARS}
|
|
82
|
+
try:
|
|
83
|
+
cmd = [*(command or default_command()), *build_args()]
|
|
84
|
+
result = _run(cmd, FRAMING + question.strip(), project_path, env, timeout)
|
|
85
|
+
except OSError as exc: # includes FileNotFoundError and PermissionError
|
|
86
|
+
return f"The deep lookup isn't available: {exc}"
|
|
87
|
+
if result is None:
|
|
88
|
+
return TIMEOUT_MESSAGE
|
|
89
|
+
code, out, err = result
|
|
90
|
+
if code != 0:
|
|
91
|
+
lines = (err or out or "").strip().splitlines()
|
|
92
|
+
return f"The deep lookup failed: {lines[-1][:300]}" if lines else "The deep lookup failed."
|
|
93
|
+
return clip(out.strip() or "The deep lookup returned no answer.", MAX_ANSWER)
|
codetherapy/notes.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""PM_NOTES.md: the one file the server writes, read by the user's next Claude Code session."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codetherapy.textutil import one_line
|
|
8
|
+
|
|
9
|
+
NOTES_FILE = "PM_NOTES.md"
|
|
10
|
+
HEADER = "# PM Notes\n\nDecisions, to-dos and notes captured by Code Therapy.\n\n"
|
|
11
|
+
LABELS = {"decision": "DECISION", "todo": "TODO", "note": "NOTE"}
|
|
12
|
+
ALIASES = {
|
|
13
|
+
"to do": "todo", "to-do": "todo", "todos": "todo", "action item": "todo",
|
|
14
|
+
"decisions": "decision", "notes": "note",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_kind(kind: str) -> str:
|
|
19
|
+
key = " ".join(kind.lower().split())
|
|
20
|
+
key = ALIASES.get(key, key)
|
|
21
|
+
if key not in LABELS:
|
|
22
|
+
raise ValueError(f"kind must be one of: decision, todo, note (got {kind!r})")
|
|
23
|
+
return key
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def record(project_path: Path, kind: str, text: str, now: datetime | None = None) -> str:
|
|
27
|
+
label = LABELS[normalize_kind(kind)]
|
|
28
|
+
body = one_line(text)
|
|
29
|
+
if not body:
|
|
30
|
+
raise ValueError("note text is empty")
|
|
31
|
+
if not project_path.is_dir():
|
|
32
|
+
raise FileNotFoundError(f"project folder not found: {project_path}")
|
|
33
|
+
stamp = (now or datetime.now()).strftime("%Y-%m-%d %H:%M")
|
|
34
|
+
line = f"- [{stamp}] **{label}:** {body}"
|
|
35
|
+
path = project_path / NOTES_FILE
|
|
36
|
+
is_new = not path.exists() or path.stat().st_size == 0
|
|
37
|
+
needs_newline = not is_new and not _ends_with_newline(path) # hand-edited file without a final newline
|
|
38
|
+
with path.open("a", encoding="utf-8", newline="\n") as f:
|
|
39
|
+
if is_new:
|
|
40
|
+
f.write(HEADER)
|
|
41
|
+
elif needs_newline:
|
|
42
|
+
f.write("\n")
|
|
43
|
+
f.write(line + "\n")
|
|
44
|
+
return line
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ends_with_newline(path: Path) -> bool:
|
|
48
|
+
with path.open("rb") as f:
|
|
49
|
+
f.seek(-1, 2)
|
|
50
|
+
return f.read(1) == b"\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def recent(project_path: Path, n: int = 15) -> list[str]:
|
|
54
|
+
path = project_path / NOTES_FILE
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
return []
|
|
57
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
58
|
+
return [line for line in lines if line.startswith("- [")][-n:]
|
codetherapy/procutil.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Start child processes in their own group and kill the whole tree, so inherited pipes close."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def popen_group_kwargs() -> dict:
|
|
11
|
+
if sys.platform == "win32":
|
|
12
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
13
|
+
return {"start_new_session": True}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def kill_tree(proc: subprocess.Popen) -> None:
|
|
17
|
+
if sys.platform != "win32":
|
|
18
|
+
try: # always: a grandchild can outlive an already-exited leader and hold the pipes
|
|
19
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
20
|
+
except OSError:
|
|
21
|
+
pass
|
|
22
|
+
if proc.poll() is None:
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
try:
|
|
25
|
+
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=10)
|
|
26
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
27
|
+
pass
|
|
28
|
+
try:
|
|
29
|
+
proc.kill()
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
proc.communicate(timeout=5)
|
|
34
|
+
except (subprocess.TimeoutExpired, ValueError, OSError):
|
|
35
|
+
pass
|
codetherapy/projects.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Discover Claude Code projects from session logs and resolve spoken names to them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import difflib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from codetherapy.sessions import read_cwd
|
|
14
|
+
|
|
15
|
+
SCAN_TTL_SECONDS = 60.0
|
|
16
|
+
MAX_CANDIDATES = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Project:
|
|
21
|
+
name: str
|
|
22
|
+
path: Path
|
|
23
|
+
last_active: datetime
|
|
24
|
+
session_files: tuple[Path, ...] # newest first
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def exists(self) -> bool:
|
|
28
|
+
return self.path.is_dir()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Resolution:
|
|
33
|
+
project: Project | None
|
|
34
|
+
candidates: tuple[Project, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def file_mtime(path: Path) -> float:
|
|
38
|
+
try:
|
|
39
|
+
return path.stat().st_mtime
|
|
40
|
+
except OSError:
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unique_names(paths: list[Path]) -> dict[Path, str]:
|
|
45
|
+
"""Folder basename, or parent/basename when two projects share a basename."""
|
|
46
|
+
|
|
47
|
+
def dupes(names: dict[Path, str]) -> set[str]:
|
|
48
|
+
seen: dict[str, int] = {}
|
|
49
|
+
for n in names.values():
|
|
50
|
+
seen[n.lower()] = seen.get(n.lower(), 0) + 1
|
|
51
|
+
return {n for n, count in seen.items() if count > 1}
|
|
52
|
+
|
|
53
|
+
names = {p: p.name or str(p) for p in paths}
|
|
54
|
+
clashing = dupes(names)
|
|
55
|
+
names = {p: (f"{p.parent.name}/{p.name}" if n.lower() in clashing and p.parent.name else n) for p, n in names.items()}
|
|
56
|
+
clashing = dupes(names)
|
|
57
|
+
return {p: (str(p) if n.lower() in clashing else n) for p, n in names.items()}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover(projects_dir: Path) -> list[Project]:
|
|
61
|
+
if not projects_dir.is_dir():
|
|
62
|
+
return []
|
|
63
|
+
grouped: dict[str, tuple[Path, list[Path]]] = {}
|
|
64
|
+
for folder in projects_dir.iterdir():
|
|
65
|
+
if not folder.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
files = sorted(folder.glob("*.jsonl"), key=file_mtime, reverse=True)
|
|
68
|
+
# The folder name encoding is lossy; the real path is the cwd recorded in the log.
|
|
69
|
+
cwd = next((c for c in map(read_cwd, files) if c), "")
|
|
70
|
+
if not cwd:
|
|
71
|
+
continue
|
|
72
|
+
key = os.path.normcase(os.path.normpath(cwd))
|
|
73
|
+
grouped.setdefault(key, (Path(cwd), []))[1].extend(files)
|
|
74
|
+
names = _unique_names([path for path, _ in grouped.values()])
|
|
75
|
+
projects = []
|
|
76
|
+
for path, files in grouped.values():
|
|
77
|
+
files.sort(key=file_mtime, reverse=True)
|
|
78
|
+
last_active = datetime.fromtimestamp(file_mtime(files[0])).astimezone()
|
|
79
|
+
projects.append(Project(names[path], path, last_active, tuple(files)))
|
|
80
|
+
projects.sort(key=lambda p: p.last_active, reverse=True)
|
|
81
|
+
return projects
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize(text: str) -> str:
|
|
85
|
+
return re.sub(r"[^a-z0-9]+", "", text.lower())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve(projects: list[Project], query: str) -> Resolution:
|
|
89
|
+
q = query.strip()
|
|
90
|
+
if not q:
|
|
91
|
+
return Resolution(None, tuple(projects[:MAX_CANDIDATES]))
|
|
92
|
+
nq = _normalize(q)
|
|
93
|
+
checks: list[Callable[[Project], bool]] = [
|
|
94
|
+
lambda p: p.name == q,
|
|
95
|
+
lambda p: p.name.lower() == q.lower(),
|
|
96
|
+
lambda p: bool(nq) and _normalize(p.name) == nq,
|
|
97
|
+
lambda p: bool(nq) and nq in _normalize(p.name),
|
|
98
|
+
]
|
|
99
|
+
for check in checks:
|
|
100
|
+
hits = [p for p in projects if check(p)]
|
|
101
|
+
if len(hits) == 1:
|
|
102
|
+
return Resolution(hits[0])
|
|
103
|
+
if hits:
|
|
104
|
+
return Resolution(None, tuple(hits[:MAX_CANDIDATES]))
|
|
105
|
+
by_norm = {_normalize(p.name): p for p in projects}
|
|
106
|
+
close = difflib.get_close_matches(nq, list(by_norm), n=MAX_CANDIDATES, cutoff=0.6)
|
|
107
|
+
if len(close) == 1:
|
|
108
|
+
return Resolution(by_norm[close[0]])
|
|
109
|
+
return Resolution(None, tuple(by_norm[c] for c in close))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ProjectIndex:
|
|
113
|
+
"""discover() with a short cache, since tool calls arrive in bursts."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, projects_dir: Path, ttl: float = SCAN_TTL_SECONDS,
|
|
116
|
+
clock: Callable[[], float] = time.monotonic) -> None:
|
|
117
|
+
self._dir = projects_dir
|
|
118
|
+
self._ttl = ttl
|
|
119
|
+
self._clock = clock
|
|
120
|
+
self._cache: list[Project] | None = None
|
|
121
|
+
self._scanned_at = 0.0
|
|
122
|
+
|
|
123
|
+
def all(self) -> list[Project]:
|
|
124
|
+
now = self._clock()
|
|
125
|
+
if self._cache is None or now - self._scanned_at >= self._ttl:
|
|
126
|
+
self._cache = discover(self._dir)
|
|
127
|
+
self._scanned_at = now
|
|
128
|
+
return list(self._cache)
|
|
129
|
+
|
|
130
|
+
def resolve(self, query: str) -> Resolution:
|
|
131
|
+
return resolve(self.all(), query)
|
codetherapy/search.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Quick literal search over a project's file names and text: the fast alternative to a deep lookup."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codetherapy.textutil import clip
|
|
11
|
+
|
|
12
|
+
GIT_TIMEOUT_SECONDS = 5
|
|
13
|
+
MIN_GIT_SECONDS = 0.5
|
|
14
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
15
|
+
BINARY_SNIFF_BYTES = 8192
|
|
16
|
+
MAX_LINE = 160
|
|
17
|
+
MAX_OUTPUT = 4000
|
|
18
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next", ".tox",
|
|
19
|
+
".mypy_cache", ".pytest_cache", "target", ".idea", ".vscode"}
|
|
20
|
+
# Matched case-insensitively against the file name; such files are never listed or read.
|
|
21
|
+
# deep.py turns the same lists into deny rules for ask_project, so the two can't drift apart.
|
|
22
|
+
SECRET_PATTERNS = (".env", ".env.*", ".envrc", ".npmrc", ".netrc", ".pgpass", "*.pem", "*.key", "*.p12",
|
|
23
|
+
"*.pfx", "*.keystore", "*.jks", "id_rsa*", "id_ed25519*", "*credentials*", "*secret*")
|
|
24
|
+
SECRET_DIRS = (".ssh", ".aws", ".gnupg")
|
|
25
|
+
# People often run `claude` from their home folder, which makes ~ a "project"; that's every file they own.
|
|
26
|
+
HOME_FOLDER_MESSAGE = ("That project is your whole home folder, so I won't search it — "
|
|
27
|
+
"open Claude Code in a specific project folder instead.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_home_or_above(path: Path) -> bool:
|
|
31
|
+
"""True for the home folder or a folder that contains it (such as /, /Users or a drive root)."""
|
|
32
|
+
try:
|
|
33
|
+
home, target = Path.home().resolve(), path.resolve()
|
|
34
|
+
except (OSError, RuntimeError): # RuntimeError: no home folder can be determined
|
|
35
|
+
return False
|
|
36
|
+
return target == home or target in home.parents
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_secret(name: str) -> bool:
|
|
40
|
+
lowered = name.lower()
|
|
41
|
+
return any(fnmatch(lowered, pattern) for pattern in SECRET_PATTERNS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _in_secret_dir(rel: str) -> bool:
|
|
45
|
+
return any(part in SECRET_DIRS for part in rel.split("/")[:-1])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _git_files(project_path: Path, deadline: float) -> list[str] | None:
|
|
49
|
+
timeout = min(GIT_TIMEOUT_SECONDS, max(MIN_GIT_SECONDS, deadline - time.monotonic()))
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", "--no-optional-locks", "-C", str(project_path), "ls-files", "-z",
|
|
53
|
+
"--cached", "--others", "--exclude-standard"],
|
|
54
|
+
capture_output=True, timeout=timeout)
|
|
55
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
56
|
+
return None
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
return None
|
|
59
|
+
text = result.stdout.decode("utf-8", errors="replace")
|
|
60
|
+
return [name for name in text.split("\0") if name]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _walk_files(project_path: Path, deadline: float) -> tuple[list[str], bool]:
|
|
64
|
+
found: list[str] = []
|
|
65
|
+
for folder, dirs, files in os.walk(project_path):
|
|
66
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
67
|
+
for name in sorted(files):
|
|
68
|
+
if time.monotonic() >= deadline:
|
|
69
|
+
return found, True
|
|
70
|
+
found.append((Path(folder) / name).relative_to(project_path).as_posix())
|
|
71
|
+
return found, False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _candidates(project_path: Path, deadline: float) -> tuple[list[str], bool]:
|
|
75
|
+
"""Searchable relative paths, and whether gathering ran out of time."""
|
|
76
|
+
files = _git_files(project_path, deadline)
|
|
77
|
+
timed_out = False
|
|
78
|
+
if files is None:
|
|
79
|
+
files, timed_out = _walk_files(project_path, deadline)
|
|
80
|
+
root = project_path.resolve()
|
|
81
|
+
safe = []
|
|
82
|
+
for rel in sorted(set(files)):
|
|
83
|
+
if timed_out or time.monotonic() >= deadline:
|
|
84
|
+
return safe, True
|
|
85
|
+
if _is_secret(rel.rsplit("/", 1)[-1]) or _in_secret_dir(rel):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
resolved = (project_path / rel).resolve()
|
|
89
|
+
resolved.relative_to(root) # a symlink pointing outside the project is not ours to read
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
continue
|
|
92
|
+
if _is_secret(resolved.name): # e.g. notes.txt -> .env
|
|
93
|
+
continue
|
|
94
|
+
if resolved.is_file():
|
|
95
|
+
safe.append(rel)
|
|
96
|
+
return safe, timed_out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _matching_lines(file: Path, needle: str):
|
|
100
|
+
"""Yield (lineno, line) for text lines containing needle; nothing for big or binary files."""
|
|
101
|
+
try:
|
|
102
|
+
if file.stat().st_size > MAX_FILE_BYTES:
|
|
103
|
+
return
|
|
104
|
+
with open(file, "rb") as handle:
|
|
105
|
+
if b"\0" in handle.read(BINARY_SNIFF_BYTES):
|
|
106
|
+
return
|
|
107
|
+
text = file.read_text(encoding="utf-8", errors="replace")
|
|
108
|
+
except OSError:
|
|
109
|
+
return
|
|
110
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
111
|
+
if needle in line.lower():
|
|
112
|
+
yield lineno, line
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def search(project_path: Path, query: str, *, max_matches: int = 20, time_budget: float = 3.0) -> str:
|
|
116
|
+
needle = query.strip().lower()
|
|
117
|
+
if not needle:
|
|
118
|
+
return "Tell me what to search for."
|
|
119
|
+
if not project_path.is_dir():
|
|
120
|
+
return f"I can't open that project's folder anymore ({project_path})."
|
|
121
|
+
if is_home_or_above(project_path):
|
|
122
|
+
return HOME_FOLDER_MESSAGE
|
|
123
|
+
deadline = time.monotonic() + time_budget
|
|
124
|
+
name = project_path.name
|
|
125
|
+
files, note_stopped = _candidates(project_path, deadline)
|
|
126
|
+
stopped = f"(search stopped after {time_budget:g}s — narrow the search)"
|
|
127
|
+
|
|
128
|
+
lines: list[str] = []
|
|
129
|
+
note = stopped if note_stopped else ""
|
|
130
|
+
|
|
131
|
+
def add(line: str) -> bool:
|
|
132
|
+
"""Append a match; False (and set the note) once the cap or the clock says stop."""
|
|
133
|
+
nonlocal note
|
|
134
|
+
if len(lines) >= max_matches:
|
|
135
|
+
note = "(more matches — narrow the search)" # only reached when one more match exists
|
|
136
|
+
return False
|
|
137
|
+
lines.append(line)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def out_of_time() -> bool:
|
|
141
|
+
nonlocal note
|
|
142
|
+
if time.monotonic() >= deadline:
|
|
143
|
+
note = note or stopped
|
|
144
|
+
return bool(note)
|
|
145
|
+
|
|
146
|
+
for rel in files:
|
|
147
|
+
if needle in rel.lower() and (out_of_time() or not add(f"- {rel}")):
|
|
148
|
+
break
|
|
149
|
+
if not note:
|
|
150
|
+
for rel in files:
|
|
151
|
+
if out_of_time():
|
|
152
|
+
break
|
|
153
|
+
for lineno, line in _matching_lines(project_path / rel, needle):
|
|
154
|
+
if not add(f"{rel}:{lineno}: {clip(line.strip(), MAX_LINE)}"):
|
|
155
|
+
break
|
|
156
|
+
if note:
|
|
157
|
+
break
|
|
158
|
+
if not lines and not note:
|
|
159
|
+
return f'No matches for "{query.strip()}" in {name}.'
|
|
160
|
+
body = [f'Matches for "{query.strip()}" in {name}:', *lines]
|
|
161
|
+
if note:
|
|
162
|
+
body.append(note)
|
|
163
|
+
return clip("\n".join(body), MAX_OUTPUT)
|