SourceIndex 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.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Init-time scaffolding installed into the user's repo.
|
|
2
|
+
|
|
3
|
+
Four artifacts share this module — all are templates + writers for files
|
|
4
|
+
that ``sourceindex init`` drops into the working repo:
|
|
5
|
+
|
|
6
|
+
- ``.gitignore`` entry for ``.sourceindex/``.
|
|
7
|
+
- ``.githooks/post-commit`` running ``sourceindex update``.
|
|
8
|
+
- ``.claude/agents/sourceindex-agent.md`` (subagent mode) or ``.mcp.json``
|
|
9
|
+
(MCP mode).
|
|
10
|
+
- A "Codebase Search" / "Workflow" section appended to ``CLAUDE.md``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..lib.log import get_logger
|
|
19
|
+
|
|
20
|
+
_log = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
GITIGNORE_ENTRY = ".sourceindex/"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
UPDATE_SCRIPT = """#!/usr/bin/env bash
|
|
27
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
28
|
+
sourceindex update --repo-root "$REPO_ROOT" &
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Git hooks that should trigger an index refresh. All are symlinks to the
|
|
32
|
+
# shared _update.sh, so adding a new hook is a one-line append here.
|
|
33
|
+
HOOKS = ("post-commit", "post-checkout", "post-merge")
|
|
34
|
+
|
|
35
|
+
# MCP mode is currently broken — DO NOT USE. The MCP detour (ToolSearch +
|
|
36
|
+
# search_index roundtrip) invalidates the prompt cache on turn 2, which negates
|
|
37
|
+
# the cache_input_tokens savings that the agent path relies on. Kept here for
|
|
38
|
+
# now as a reference / for future debugging; use --mode agent (the default).
|
|
39
|
+
MCP_CONFIG = {
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"sourceindex-server": {
|
|
42
|
+
"type": "stdio",
|
|
43
|
+
"command": "sourceindex",
|
|
44
|
+
"args": ["serve"],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
CLAUDE_MD_SECTION_MCP = """## Workflow
|
|
51
|
+
Before implementing any feature or making code changes, always use the
|
|
52
|
+
sourceindex-server MCP tool to get the implementation guide. Follow the
|
|
53
|
+
exact file paths and patterns it returns.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
CLAUDE_MD_MARKER_MCP = "sourceindex-server MCP tool"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CLAUDE_MD_SECTION_AGENT = """## Codebase Search
|
|
60
|
+
- The `sourceindex` subagent handles all codebase exploration — for implementation, debugging, planning, inventories, design suggestions, or any "where/how/what" question about this codebase, the first action is `Agent(subagent_type="sourceindex", ...)`.
|
|
61
|
+
- When calling the subagent, set its `prompt` to the user's request lightly polished — fix grammar or replace pronouns if needed, but do not restructure, expand into bullet lists, or wrap with "I need:..." prefaces. The Pass-1a/1b search performs best on the user's original phrasing.
|
|
62
|
+
- Never call `Agent(subagent_type="Explore")` for codebase questions in this repo — the SourceIndex roadmap supersedes Explore here. Same goes for `general-purpose` agents tasked with codebase research.
|
|
63
|
+
- Never use Read/Grep/Glob directly before running the sourceindex subagent first.
|
|
64
|
+
- Trust the search results — implement based on the patterns it returns.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
CLAUDE_MD_MARKER_AGENT = "`sourceindex` subagent handles all codebase exploration"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
CLAUDE_MD_HEADER = """# CLAUDE.md
|
|
71
|
+
|
|
72
|
+
This file provides guidance to Claude Code when working in this repository.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
SUBAGENT_FILENAME = "sourceindex-agent.md"
|
|
78
|
+
|
|
79
|
+
SUBAGENT_CONFIG = """---
|
|
80
|
+
name: sourceindex
|
|
81
|
+
description: MUST BE USED for ANY question that requires understanding this codebase — implementing features, fixing bugs, refactoring, planning, design suggestions, inventories ("map every command", "list all callers"), "how does X work" questions, or any answer that needs to cite specific files/functions. Prefer this over the built-in Explore subagent and over direct Read/Grep/Glob; the SourceIndex roadmap is the single entry point for codebase exploration in this repo. This agent is responsible for searching the codebase using the sourceindex tool and reporting the results.
|
|
82
|
+
tools: Bash
|
|
83
|
+
---
|
|
84
|
+
You are a codebase search agent. When invoked:
|
|
85
|
+
1. You will receive a query about the task that you are working on. Pass the query to the sourceindex tool executable directly.
|
|
86
|
+
2. Run immediately:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
sourceindex search --query "<your query here>" --repo-root .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
3. **Return the bash command's stdout verbatim — do NOT summarize, paraphrase, or analyze it.** The parent agent needs the full `=== CODEBASE ROADMAP …` block (including line ranges and import hints) in its prompt cache; any synthesis on top breaks the downstream savings telemetry and loses fidelity. Wrap the output in a fenced code block and stop.
|
|
93
|
+
|
|
94
|
+
If `sourceindex search` errors out, report the error verbatim with file names and line numbers. Do not fix anything — just report.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
RESTRICT_HOOK_FILENAME = "restrict-sourceindex-bash.sh"
|
|
99
|
+
|
|
100
|
+
# Resolved by Claude Code at hook-exec time. ${CLAUDE_PROJECT_DIR} expands to
|
|
101
|
+
# the project root (where .claude/ lives), so the registration stays portable
|
|
102
|
+
# across clones/machines — do NOT bake in an absolute path here.
|
|
103
|
+
RESTRICT_HOOK_COMMAND = "${CLAUDE_PROJECT_DIR}/.claude/hooks/" + RESTRICT_HOOK_FILENAME
|
|
104
|
+
|
|
105
|
+
# A PreToolUse gate that keeps the `sourceindex` subagent single-shot. Embedded
|
|
106
|
+
# verbatim (raw string preserves the inner triple-quotes and "\n" literals) so
|
|
107
|
+
# init is self-contained — no external file to drift or go missing.
|
|
108
|
+
RESTRICT_HOOK_SCRIPT = r'''#!/usr/bin/env python3
|
|
109
|
+
"""PreToolUse hook that keeps the `sourceindex` subagent single-shot.
|
|
110
|
+
|
|
111
|
+
The subagent's only job is to run `sourceindex search ...` once and return its
|
|
112
|
+
output. This hook hard-restricts it to exactly that: a single `Bash` call whose
|
|
113
|
+
program is `sourceindex search` (optionally prefixed with `VAR=value` env
|
|
114
|
+
assignments). Any other tool, any other program, or shell chaining/redirection
|
|
115
|
+
appended after the search is blocked.
|
|
116
|
+
|
|
117
|
+
Why a hook and not the frontmatter `tools:` allowlist: in bypassPermissions
|
|
118
|
+
sessions (`claude --dangerously-skip-permissions`) the subagent `tools:` field
|
|
119
|
+
is not enforced, and even in default mode it does not scope Bash to a single
|
|
120
|
+
command. PreToolUse hooks fire and can block in every mode, so this is the
|
|
121
|
+
reliable backstop — without it the subagent can wander off and run the whole
|
|
122
|
+
task by hand via Bash (read files, edit source, pip install, run tests),
|
|
123
|
+
inflating cost and corrupting the workspace.
|
|
124
|
+
|
|
125
|
+
Scoping: registered with matcher ".*" so it fires for every tool call;
|
|
126
|
+
`agent_type` (the calling subagent's frontmatter `name`, absent for main-agent
|
|
127
|
+
calls) gates it to the sourceindex subagent — the main agent and all other
|
|
128
|
+
subagents pass through untouched.
|
|
129
|
+
|
|
130
|
+
Validation is shlex-token based, not raw-string: queries are passed verbatim as
|
|
131
|
+
the quoted `--query` value and routinely contain shell metacharacters (`;`,
|
|
132
|
+
`|`, backticks, `<`, `>`). Inside the quotes those stay within one token and
|
|
133
|
+
are allowed; genuine chaining operators appear as standalone tokens and are
|
|
134
|
+
blocked.
|
|
135
|
+
"""
|
|
136
|
+
from __future__ import annotations
|
|
137
|
+
|
|
138
|
+
import json
|
|
139
|
+
import re
|
|
140
|
+
import shlex
|
|
141
|
+
import sys
|
|
142
|
+
|
|
143
|
+
ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
144
|
+
SHELL_OPERATORS = {
|
|
145
|
+
";", "|", "||", "&", "&&", ">", ">>", ">|",
|
|
146
|
+
"<", "<<", "<<<", "&>", "&>>", ">&", "|&",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def violation(tool_name: str, command: str) -> str | None:
|
|
151
|
+
"""Return a deny reason, or None if the call is allowed."""
|
|
152
|
+
if tool_name != "Bash":
|
|
153
|
+
return "only Bash running `sourceindex search ...` is permitted"
|
|
154
|
+
try:
|
|
155
|
+
tokens = shlex.split(command)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
return f"unparseable command: {exc}"
|
|
158
|
+
|
|
159
|
+
i = 0
|
|
160
|
+
while i < len(tokens) and ENV_ASSIGN.match(tokens[i]):
|
|
161
|
+
i += 1 # allow a leading `VAR=value ...` env prefix
|
|
162
|
+
|
|
163
|
+
if i + 1 >= len(tokens) or tokens[i] != "sourceindex" or tokens[i + 1] != "search":
|
|
164
|
+
return "only `[VAR=value ...] sourceindex search ...` is allowed"
|
|
165
|
+
|
|
166
|
+
for tok in tokens[i + 2:]:
|
|
167
|
+
if tok in SHELL_OPERATORS:
|
|
168
|
+
return f"shell operator {tok!r} not permitted (no chaining/redirection)"
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main() -> int:
|
|
173
|
+
payload = json.load(sys.stdin)
|
|
174
|
+
|
|
175
|
+
# Scope to the sourceindex subagent only; main-agent calls carry no
|
|
176
|
+
# agent_type and pass straight through.
|
|
177
|
+
if payload.get("agent_type") != "sourceindex":
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
tool_name = payload.get("tool_name", "")
|
|
181
|
+
command = payload.get("tool_input", {}).get("command", "")
|
|
182
|
+
|
|
183
|
+
reason = violation(tool_name, command)
|
|
184
|
+
if reason is None:
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
msg = [
|
|
188
|
+
"BLOCKED by sourceindex subagent restriction.",
|
|
189
|
+
f" tool: {tool_name}",
|
|
190
|
+
f" reason: {reason}",
|
|
191
|
+
" You may run ONLY a single `sourceindex search ...` Bash call.",
|
|
192
|
+
" If you have already run that search, you are DONE: stop now and return",
|
|
193
|
+
" its stdout to the caller verbatim. Do NOT run any other command",
|
|
194
|
+
" (find/ls/grep/cat/...) — retrying is futile, every one will be blocked.",
|
|
195
|
+
]
|
|
196
|
+
if command:
|
|
197
|
+
msg.append(f" attempted: {command}")
|
|
198
|
+
sys.stderr.write("\n".join(msg) + "\n")
|
|
199
|
+
return 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
sys.exit(main())
|
|
204
|
+
'''
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _ensure_gitignored(repo_root: Path) -> None:
|
|
208
|
+
gitignore = repo_root / ".gitignore"
|
|
209
|
+
existing = gitignore.read_text() if gitignore.exists() else ""
|
|
210
|
+
lines = {l.strip() for l in existing.splitlines()}
|
|
211
|
+
if GITIGNORE_ENTRY in lines or GITIGNORE_ENTRY.rstrip("/") in lines:
|
|
212
|
+
return
|
|
213
|
+
sep = "" if not existing or existing.endswith("\n") else "\n"
|
|
214
|
+
gitignore.write_text(existing + sep + GITIGNORE_ENTRY + "\n")
|
|
215
|
+
_log.info(f"Added {GITIGNORE_ENTRY} to {gitignore}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _install_hooks(repo_root: Path) -> None:
|
|
219
|
+
"""Install ``_update.sh`` + a symlink per entry in :data:`HOOKS`.
|
|
220
|
+
|
|
221
|
+
Idempotent: re-running rewrites the script when its content drifts and
|
|
222
|
+
replaces non-symlink leftovers (e.g. an older single-file ``post-commit``)
|
|
223
|
+
with fresh symlinks pointing at ``_update.sh``. Also sets the per-clone
|
|
224
|
+
``core.hooksPath`` config so git actually invokes them.
|
|
225
|
+
"""
|
|
226
|
+
hooks_dir = repo_root / ".githooks"
|
|
227
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
228
|
+
|
|
229
|
+
script = hooks_dir / "_update.sh"
|
|
230
|
+
if not script.exists() or script.read_text() != UPDATE_SCRIPT:
|
|
231
|
+
script.write_text(UPDATE_SCRIPT)
|
|
232
|
+
script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
233
|
+
|
|
234
|
+
for name in HOOKS:
|
|
235
|
+
link = hooks_dir / name
|
|
236
|
+
if link.is_symlink() or link.exists():
|
|
237
|
+
link.unlink()
|
|
238
|
+
link.symlink_to("_update.sh")
|
|
239
|
+
|
|
240
|
+
if (repo_root / ".git").exists():
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
["git", "-C", str(repo_root), "config", "--get", "core.hooksPath"],
|
|
243
|
+
capture_output=True, text=True,
|
|
244
|
+
)
|
|
245
|
+
if not (result.returncode == 0 and result.stdout.strip() == ".githooks"):
|
|
246
|
+
subprocess.run(
|
|
247
|
+
["git", "-C", str(repo_root), "config", "core.hooksPath", ".githooks"],
|
|
248
|
+
check=True,
|
|
249
|
+
)
|
|
250
|
+
_log.info(f"Installed hooks {list(HOOKS)} at {hooks_dir}")
|
|
251
|
+
else:
|
|
252
|
+
_log.warning(
|
|
253
|
+
f"{repo_root} is not a git repo; hooks written but core.hooksPath not set",
|
|
254
|
+
extra={"event": "install.no_git", "repo_root": str(repo_root)},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _append_claude_md(repo_root: Path, section: str, marker: str) -> None:
|
|
259
|
+
claude_md = repo_root / "CLAUDE.md"
|
|
260
|
+
if not claude_md.exists():
|
|
261
|
+
claude_md.write_text(CLAUDE_MD_HEADER + section)
|
|
262
|
+
_log.info(f"Created {claude_md} with sourceindex workflow section")
|
|
263
|
+
return
|
|
264
|
+
existing = claude_md.read_text()
|
|
265
|
+
if marker in existing:
|
|
266
|
+
_log.info(f"{claude_md} already contains the sourceindex workflow section, skipping")
|
|
267
|
+
return
|
|
268
|
+
claude_md.write_text(existing.rstrip("\n") + "\n\n" + section)
|
|
269
|
+
_log.info(f"Appended sourceindex workflow section to {claude_md}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _write_subagent_config(repo_root: Path) -> None:
|
|
273
|
+
agents_dir = repo_root / ".claude" / "agents"
|
|
274
|
+
agent_path = agents_dir / SUBAGENT_FILENAME
|
|
275
|
+
|
|
276
|
+
if agent_path.exists() and agent_path.read_text() == SUBAGENT_CONFIG:
|
|
277
|
+
_log.info(f"Subagent config already present at {agent_path}, skipping")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
agent_path.write_text(SUBAGENT_CONFIG)
|
|
282
|
+
_log.info(f"Wrote subagent config to {agent_path}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _write_restrict_hook(repo_root: Path) -> None:
|
|
286
|
+
"""Write the PreToolUse gate that keeps the subagent single-shot.
|
|
287
|
+
|
|
288
|
+
Idempotent: rewrites only when the content drifts; always (re)sets the
|
|
289
|
+
executable bit so Claude Code can invoke it.
|
|
290
|
+
"""
|
|
291
|
+
hooks_dir = repo_root / ".claude" / "hooks"
|
|
292
|
+
hook_path = hooks_dir / RESTRICT_HOOK_FILENAME
|
|
293
|
+
|
|
294
|
+
if hook_path.exists() and hook_path.read_text() == RESTRICT_HOOK_SCRIPT:
|
|
295
|
+
_log.info(f"Subagent gate hook already present at {hook_path}, skipping")
|
|
296
|
+
else:
|
|
297
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
hook_path.write_text(RESTRICT_HOOK_SCRIPT)
|
|
299
|
+
_log.info(f"Wrote subagent gate hook to {hook_path}")
|
|
300
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _register_pretooluse_hook(repo_root: Path) -> None:
|
|
304
|
+
"""Register the gate hook in ``.claude/settings.json`` (shared, committed).
|
|
305
|
+
|
|
306
|
+
Merges into any existing settings/hooks rather than overwriting, and is
|
|
307
|
+
idempotent — re-running init never adds a duplicate PreToolUse entry. Pairs
|
|
308
|
+
with :func:`_write_restrict_hook`; without the registration the script on
|
|
309
|
+
disk never fires.
|
|
310
|
+
"""
|
|
311
|
+
settings_path = repo_root / ".claude" / "settings.json"
|
|
312
|
+
|
|
313
|
+
settings: dict = {}
|
|
314
|
+
if settings_path.exists():
|
|
315
|
+
try:
|
|
316
|
+
loaded = json.loads(settings_path.read_text())
|
|
317
|
+
settings = loaded if isinstance(loaded, dict) else {}
|
|
318
|
+
except json.JSONDecodeError as e:
|
|
319
|
+
_log.warning(
|
|
320
|
+
f"{settings_path} is not valid JSON; rewriting with a fresh config.",
|
|
321
|
+
extra={
|
|
322
|
+
"event": "settings.decode_failed",
|
|
323
|
+
"path": str(settings_path),
|
|
324
|
+
"error": str(e),
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
settings = {}
|
|
328
|
+
|
|
329
|
+
if not isinstance(settings.get("hooks"), dict):
|
|
330
|
+
settings["hooks"] = {}
|
|
331
|
+
hooks = settings["hooks"]
|
|
332
|
+
if not isinstance(hooks.get("PreToolUse"), list):
|
|
333
|
+
hooks["PreToolUse"] = []
|
|
334
|
+
pre_hooks = hooks["PreToolUse"]
|
|
335
|
+
|
|
336
|
+
for group in pre_hooks:
|
|
337
|
+
if not isinstance(group, dict):
|
|
338
|
+
continue
|
|
339
|
+
for entry in group.get("hooks", []):
|
|
340
|
+
if isinstance(entry, dict) and entry.get("command") == RESTRICT_HOOK_COMMAND:
|
|
341
|
+
_log.info(f"Subagent gate already registered in {settings_path}, skipping")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
pre_hooks.append({
|
|
345
|
+
"matcher": ".*",
|
|
346
|
+
"hooks": [{"type": "command", "command": RESTRICT_HOOK_COMMAND}],
|
|
347
|
+
})
|
|
348
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
350
|
+
_log.info(f"Registered subagent gate (PreToolUse) in {settings_path}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _write_mcp_config(repo_root: Path) -> None:
|
|
354
|
+
mcp_path = repo_root / ".mcp.json"
|
|
355
|
+
if mcp_path.exists():
|
|
356
|
+
try:
|
|
357
|
+
existing = json.loads(mcp_path.read_text())
|
|
358
|
+
except json.JSONDecodeError as e:
|
|
359
|
+
_log.warning(
|
|
360
|
+
f"{mcp_path} is not valid JSON; replacing with a fresh config.",
|
|
361
|
+
extra={
|
|
362
|
+
"event": "mcp_config.decode_failed",
|
|
363
|
+
"path": str(mcp_path),
|
|
364
|
+
"error": str(e),
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
existing = None
|
|
368
|
+
if isinstance(existing, dict) and "sourceindex-server" in existing.get("mcpServers", {}):
|
|
369
|
+
_log.info(f"{mcp_path} already configures sourceindex-server, skipping")
|
|
370
|
+
return
|
|
371
|
+
if isinstance(existing, dict):
|
|
372
|
+
servers = existing.setdefault("mcpServers", {})
|
|
373
|
+
servers["sourceindex-server"] = MCP_CONFIG["mcpServers"]["sourceindex-server"]
|
|
374
|
+
mcp_path.write_text(json.dumps(existing, indent=2) + "\n")
|
|
375
|
+
_log.info(f"Added sourceindex-server to {mcp_path}")
|
|
376
|
+
return
|
|
377
|
+
mcp_path.write_text(json.dumps(MCP_CONFIG, indent=2) + "\n")
|
|
378
|
+
_log.info(f"Wrote {mcp_path}")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Privilege-separated daemon + encrypted-at-rest index store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .client import AdminSocketUnavailable, Client
|
|
10
|
+
from .server import run_daemon
|
|
11
|
+
from .store import EncryptedStore, PlaintextStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
INDEX_DIR_NAME = ".sourceindex"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class IndexDir:
|
|
21
|
+
path: Path
|
|
22
|
+
repo_hash: str
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def for_repo(cls, repo_root: Path, override: str | None = None) -> "IndexDir":
|
|
26
|
+
from ..lib.git import resolve_main_worktree
|
|
27
|
+
from .lifecycle import repo_hash_for
|
|
28
|
+
|
|
29
|
+
repo_root = Path(repo_root).resolve()
|
|
30
|
+
if override:
|
|
31
|
+
return cls(path=Path(override).resolve(), repo_hash=repo_hash_for(repo_root))
|
|
32
|
+
main = resolve_main_worktree(repo_root)
|
|
33
|
+
if main != repo_root:
|
|
34
|
+
_log.info(
|
|
35
|
+
"Linked worktree detected; using %s/%s for the shared index",
|
|
36
|
+
main, INDEX_DIR_NAME,
|
|
37
|
+
extra={
|
|
38
|
+
"event": "repo_root.worktree_redirect",
|
|
39
|
+
"worktree": str(repo_root),
|
|
40
|
+
"main_worktree": str(main),
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return cls(path=main / INDEX_DIR_NAME, repo_hash=repo_hash_for(main))
|
|
44
|
+
|
|
45
|
+
def is_encrypted_layout(self) -> bool:
|
|
46
|
+
from .store import ENC_SUFFIX, MANIFEST_FILENAME, TIER1_FILENAME
|
|
47
|
+
if (self.path / (MANIFEST_FILENAME + ENC_SUFFIX)).is_file():
|
|
48
|
+
return True
|
|
49
|
+
# Legacy per-file encrypted layout still routes through EncryptedStore.
|
|
50
|
+
return (self.path / (TIER1_FILENAME + ENC_SUFFIX)).is_file()
|
|
51
|
+
|
|
52
|
+
def has_plaintext_index(self) -> bool:
|
|
53
|
+
from .store import TIER1_FILENAME
|
|
54
|
+
return (self.path / TIER1_FILENAME).is_file()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def open_store_for(index_dir: "IndexDir | Path") -> "EncryptedStore | PlaintextStore":
|
|
58
|
+
if isinstance(index_dir, IndexDir):
|
|
59
|
+
idx = index_dir
|
|
60
|
+
else:
|
|
61
|
+
idx = IndexDir(path=Path(index_dir), repo_hash="")
|
|
62
|
+
if idx.is_encrypted_layout():
|
|
63
|
+
from .keyring_store import load_or_create_key
|
|
64
|
+
if not idx.repo_hash:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"{idx.path} is an encrypted store but --index-dir was "
|
|
67
|
+
"supplied without a repo context; encrypted stores can only "
|
|
68
|
+
"be read through the daemon."
|
|
69
|
+
)
|
|
70
|
+
key = load_or_create_key(idx.repo_hash)
|
|
71
|
+
return EncryptedStore(idx.path, key, idx.repo_hash)
|
|
72
|
+
return PlaintextStore(idx.path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"IndexDir",
|
|
77
|
+
"Client",
|
|
78
|
+
"AdminSocketUnavailable",
|
|
79
|
+
"EncryptedStore",
|
|
80
|
+
"PlaintextStore",
|
|
81
|
+
"open_store_for",
|
|
82
|
+
"run_daemon",
|
|
83
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Synchronous client for the daemon's UDS RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import lifecycle
|
|
12
|
+
from .protocol import E_SERVER, RpcError, encode_frame, make_request, read_frame_sync
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _drop_none(**kwargs: Any) -> dict[str, Any]:
|
|
16
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AdminSocketUnavailable(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Client:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
repo_root: Path,
|
|
27
|
+
*,
|
|
28
|
+
admin: bool = False,
|
|
29
|
+
auto_spawn: bool = True,
|
|
30
|
+
allow_dump_on_spawn: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.repo_root = Path(repo_root).resolve()
|
|
33
|
+
self.admin = admin
|
|
34
|
+
self.auto_spawn = auto_spawn
|
|
35
|
+
self.allow_dump_on_spawn = allow_dump_on_spawn
|
|
36
|
+
self.repo_hash = lifecycle.repo_hash_for(self._main_worktree())
|
|
37
|
+
self._next_id = 1
|
|
38
|
+
|
|
39
|
+
def _main_worktree(self) -> Path:
|
|
40
|
+
from ..lib.git import resolve_main_worktree
|
|
41
|
+
return resolve_main_worktree(self.repo_root)
|
|
42
|
+
|
|
43
|
+
def _socket_path(self) -> Path:
|
|
44
|
+
return (
|
|
45
|
+
lifecycle.admin_socket(self.repo_hash)
|
|
46
|
+
if self.admin
|
|
47
|
+
else lifecycle.user_socket(self.repo_hash)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _ensure_running(self) -> None:
|
|
51
|
+
if lifecycle.is_daemon_running(self.repo_root):
|
|
52
|
+
return
|
|
53
|
+
if not self.auto_spawn:
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
f"daemon for {self.repo_root} is not running; run "
|
|
56
|
+
"`sourceindex daemon --repo-root .`"
|
|
57
|
+
)
|
|
58
|
+
lifecycle.spawn_daemon(self.repo_root, allow_dump=self.allow_dump_on_spawn)
|
|
59
|
+
|
|
60
|
+
def _call(self, method: str, params: dict | None = None) -> Any:
|
|
61
|
+
self._ensure_running()
|
|
62
|
+
path = self._socket_path()
|
|
63
|
+
if self.admin and not path.exists():
|
|
64
|
+
raise AdminSocketUnavailable(
|
|
65
|
+
f"admin socket {path} not found; the daemon for {self.repo_root} "
|
|
66
|
+
"was not started with --allow-dump."
|
|
67
|
+
)
|
|
68
|
+
sock = lifecycle.connect_blocking(path)
|
|
69
|
+
try:
|
|
70
|
+
req_id = self._next_id
|
|
71
|
+
self._next_id += 1
|
|
72
|
+
sock.sendall(encode_frame(make_request(method, params, id_=req_id)))
|
|
73
|
+
resp = read_frame_sync(sock)
|
|
74
|
+
finally:
|
|
75
|
+
sock.close()
|
|
76
|
+
if "error" in resp and resp["error"] is not None:
|
|
77
|
+
err = resp["error"]
|
|
78
|
+
raise RpcError(
|
|
79
|
+
int(err.get("code", E_SERVER)),
|
|
80
|
+
str(err.get("message", "unknown error")),
|
|
81
|
+
err.get("data"),
|
|
82
|
+
)
|
|
83
|
+
return resp.get("result")
|
|
84
|
+
|
|
85
|
+
def ping(self) -> dict:
|
|
86
|
+
return self._call("ping")
|
|
87
|
+
|
|
88
|
+
def search(self, query: str, *, model: str | None = None, api_key: str | None = None) -> dict:
|
|
89
|
+
return self._call("search", _drop_none(query=query, model=model, api_key=api_key))
|
|
90
|
+
|
|
91
|
+
def init(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
api_key: str,
|
|
95
|
+
model: str | None = None,
|
|
96
|
+
languages: list[str] | None = None,
|
|
97
|
+
workers: int | None = None,
|
|
98
|
+
) -> dict:
|
|
99
|
+
return self._call("init", _drop_none(
|
|
100
|
+
api_key=api_key, model=model, languages=languages, workers=workers,
|
|
101
|
+
))
|
|
102
|
+
|
|
103
|
+
def update(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
api_key: str,
|
|
107
|
+
model: str | None = None,
|
|
108
|
+
languages: list[str] | None = None,
|
|
109
|
+
changed_files: list[str] | None = None,
|
|
110
|
+
workers: int | None = None,
|
|
111
|
+
) -> dict:
|
|
112
|
+
return self._call("update", _drop_none(
|
|
113
|
+
api_key=api_key, model=model, languages=languages,
|
|
114
|
+
changed_files=changed_files, workers=workers,
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
def install_hooks(self) -> dict:
|
|
118
|
+
return self._call("install_hooks")
|
|
119
|
+
|
|
120
|
+
def usage(self) -> dict:
|
|
121
|
+
return self._call("usage")
|
|
122
|
+
|
|
123
|
+
def dump_all(self, out_dir: Path) -> dict:
|
|
124
|
+
return self._call("dump_all", {"out_dir": str(Path(out_dir).resolve())})
|
|
125
|
+
|
|
126
|
+
def read_index_md(self) -> str:
|
|
127
|
+
return self._call("read_index_md")["text"]
|
|
128
|
+
|
|
129
|
+
def read_detail(self, rel_path: str) -> str:
|
|
130
|
+
return self._call("read_detail", {"rel_path": rel_path})["text"]
|
|
131
|
+
|
|
132
|
+
def read_env(self) -> dict[str, str]:
|
|
133
|
+
return self._call("read_env")["vars"]
|
|
134
|
+
|
|
135
|
+
def shutdown(self) -> dict:
|
|
136
|
+
try:
|
|
137
|
+
return self._call("shutdown")
|
|
138
|
+
except ConnectionError:
|
|
139
|
+
# Daemon may close the socket before we read the response;
|
|
140
|
+
# the request itself was delivered.
|
|
141
|
+
return {"ok": True}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""AES-256-GCM blob format. Layout: MAGIC(4) || nonce(12) || ciphertext || tag(16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from cryptography.exceptions import InvalidTag
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MAGIC = b"SI01"
|
|
12
|
+
NONCE_LEN = 12
|
|
13
|
+
KEY_LEN = 32
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidBlob(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_key() -> bytes:
|
|
21
|
+
return os.urandom(KEY_LEN)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encrypt(plaintext: bytes, key: bytes) -> bytes:
|
|
25
|
+
if len(key) != KEY_LEN:
|
|
26
|
+
raise ValueError(f"key must be {KEY_LEN} bytes, got {len(key)}")
|
|
27
|
+
nonce = os.urandom(NONCE_LEN)
|
|
28
|
+
ct = AESGCM(key).encrypt(nonce, plaintext, None)
|
|
29
|
+
return MAGIC + nonce + ct
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decrypt(blob: bytes, key: bytes) -> bytes:
|
|
33
|
+
if len(key) != KEY_LEN:
|
|
34
|
+
raise ValueError(f"key must be {KEY_LEN} bytes, got {len(key)}")
|
|
35
|
+
if len(blob) < len(MAGIC) + NONCE_LEN + 16:
|
|
36
|
+
raise InvalidBlob("blob too short")
|
|
37
|
+
if blob[: len(MAGIC)] != MAGIC:
|
|
38
|
+
raise InvalidBlob(f"bad magic: {blob[: len(MAGIC)]!r}")
|
|
39
|
+
nonce = blob[len(MAGIC) : len(MAGIC) + NONCE_LEN]
|
|
40
|
+
ct = blob[len(MAGIC) + NONCE_LEN :]
|
|
41
|
+
try:
|
|
42
|
+
return AESGCM(key).decrypt(nonce, ct, None)
|
|
43
|
+
except InvalidTag as e:
|
|
44
|
+
raise InvalidBlob("authentication failed (wrong key or tampered blob)") from e
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def looks_encrypted(head: bytes) -> bool:
|
|
48
|
+
return head.startswith(MAGIC)
|