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.
Files changed (47) hide show
  1. sourceindex/__init__.py +30 -0
  2. sourceindex/build/__init__.py +592 -0
  3. sourceindex/build/indexer.py +403 -0
  4. sourceindex/build/linerange/__init__.py +24 -0
  5. sourceindex/build/linerange/python_ast.py +155 -0
  6. sourceindex/build/linerange/treesitter.py +397 -0
  7. sourceindex/build/prompts.py +76 -0
  8. sourceindex/build/state.py +261 -0
  9. sourceindex/build/walker.py +94 -0
  10. sourceindex/claudecode/__init__.py +0 -0
  11. sourceindex/claudecode/savings.py +325 -0
  12. sourceindex/claudecode/savings_summary.py +88 -0
  13. sourceindex/claudecode/statusline.py +116 -0
  14. sourceindex/cli/__init__.py +304 -0
  15. sourceindex/cli/__main__.py +10 -0
  16. sourceindex/cli/api_key.py +171 -0
  17. sourceindex/cli/commands.py +678 -0
  18. sourceindex/cli/install.py +378 -0
  19. sourceindex/daemon/__init__.py +83 -0
  20. sourceindex/daemon/client.py +141 -0
  21. sourceindex/daemon/crypto.py +48 -0
  22. sourceindex/daemon/keyring_store.py +129 -0
  23. sourceindex/daemon/lifecycle.py +237 -0
  24. sourceindex/daemon/protocol.py +134 -0
  25. sourceindex/daemon/server.py +389 -0
  26. sourceindex/daemon/store.py +426 -0
  27. sourceindex/lib/__init__.py +0 -0
  28. sourceindex/lib/backend.py +240 -0
  29. sourceindex/lib/cost.py +96 -0
  30. sourceindex/lib/env.py +30 -0
  31. sourceindex/lib/git.py +33 -0
  32. sourceindex/lib/languages.py +406 -0
  33. sourceindex/lib/llm.py +298 -0
  34. sourceindex/lib/log.py +228 -0
  35. sourceindex/lib/registry.py +75 -0
  36. sourceindex/lib/timing.py +10 -0
  37. sourceindex/search/__init__.py +251 -0
  38. sourceindex/search/experiments.py +276 -0
  39. sourceindex/search/imports.py +155 -0
  40. sourceindex/search/passes.py +51 -0
  41. sourceindex/search/prompts.py +379 -0
  42. sourceindex/search/roadmap.py +265 -0
  43. sourceindex/server.py +127 -0
  44. sourceindex-0.1.0.dist-info/METADATA +73 -0
  45. sourceindex-0.1.0.dist-info/RECORD +47 -0
  46. sourceindex-0.1.0.dist-info/WHEEL +4 -0
  47. 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)