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,88 @@
1
+ """Stop-hook entry point that prints a per-turn sourceindex savings summary.
2
+
3
+ Wired as a second Stop hook alongside ``sourceindex report-savings``: the
4
+ first hook walks the transcript and updates the on-disk ledger, this one
5
+ reads the updated ledger and renders a multi-line breakdown to stdout —
6
+ which Claude Code surfaces under the assistant's response area.
7
+
8
+ Output (only when there's something to report):
9
+
10
+ SourceIndex savings
11
+ this session: $0.18
12
+ last 7 days: $4.20 across 3 sessions
13
+ this month: $19.50 across 12 sessions
14
+
15
+ Stays silent if no ledger exists or the current session's saved is below
16
+ the rendering threshold — same gate the statusLine companion applies.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from datetime import date, timedelta
24
+ from typing import Any
25
+
26
+ from .savings import load_state
27
+
28
+ # Suppress sub-cent noise.
29
+ RENDER_THRESHOLD = 0.005
30
+
31
+
32
+ def run(payload: dict[str, Any], *, stdout=sys.stdout, today: date | None = None) -> int:
33
+ state = load_state()
34
+ if not state:
35
+ return 0
36
+
37
+ session_id = payload.get("session_id")
38
+ today = today or date.today()
39
+ week_start = today - timedelta(days=7)
40
+ month_start = today.replace(day=1)
41
+
42
+ session_saved = 0.0
43
+ week_saved = 0.0
44
+ week_count = 0
45
+ month_saved = 0.0
46
+ month_count = 0
47
+
48
+ for sid, entry in state.items():
49
+ if not isinstance(entry, dict):
50
+ continue
51
+ saved = float(entry.get("saved") or 0)
52
+ entry_date_str = entry.get("date") or ""
53
+ try:
54
+ entry_date = date.fromisoformat(entry_date_str)
55
+ except ValueError:
56
+ continue
57
+ if entry_date >= week_start:
58
+ week_saved += saved
59
+ week_count += 1
60
+ if entry_date >= month_start:
61
+ month_saved += saved
62
+ month_count += 1
63
+ if sid == session_id:
64
+ session_saved = saved
65
+
66
+ if session_saved < RENDER_THRESHOLD and week_saved < RENDER_THRESHOLD:
67
+ return 0
68
+
69
+ lines = [
70
+ "SourceIndex savings",
71
+ f" this session: ${session_saved:0.2f}",
72
+ f" last 7 days: ${week_saved:0.2f} across {week_count} sessions",
73
+ f" this month: ${month_saved:0.2f} across {month_count} sessions",
74
+ ]
75
+ print("\n".join(lines), file=stdout)
76
+ return 0
77
+
78
+
79
+ def main() -> int:
80
+ try:
81
+ payload = json.loads(sys.stdin.read() or "{}")
82
+ except (ValueError, OSError):
83
+ payload = {}
84
+ return run(payload)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
@@ -0,0 +1,116 @@
1
+ """Render the sourceindex savings line into the Claude Code status line.
2
+
3
+ Wired into Claude Code as a `statusLine` command. Reads the session ledger
4
+ that the Stop hook (`sourceindex.report_savings`) writes after each turn
5
+ and prints a one-liner like
6
+
7
+ SourceIndex saved $0.18 this session · $4.20 this week
8
+
9
+ to stdout. Claude Code renders that flush-left on the bottom row above
10
+ the mode indicator — per code.claude.com/docs/en/statusline, statusLine
11
+ output is left-aligned and only `color` / `hyperlink` ANSI is honored,
12
+ so cursor-positioning right-align tricks don't survive the renderer.
13
+
14
+ Stays silent unless the current session itself has activity, AND the
15
+ Stop hook fired within the last `FRESH_WINDOW_S` seconds — so the line
16
+ appears briefly after each turn and fades out instead of staying lit
17
+ between turns or while Claude Code is idle.
18
+
19
+ The Stop hook does the heavy work — walking the transcript and computing
20
+ cost — so this command can stay cheap enough for the per-tick refresh.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import sys
27
+ from datetime import datetime
28
+ from typing import Any
29
+
30
+ from .savings import load_state
31
+
32
+ # Show the savings line for this many seconds after the Stop hook fires.
33
+ # Long enough to read on a fast turn, short enough that it doesn't linger
34
+ # while Claude Code is idle waiting for the next prompt.
35
+ FRESH_WINDOW_S = 60
36
+
37
+ # Dim so the line doesn't compete with the bright auto-mode indicator
38
+ # Claude Code renders directly below the status row. Right-alignment
39
+ # isn't possible — Claude Code reserves the right side of the status
40
+ # row for system notifications (xhigh, MCP errors, context-low) and
41
+ # strips leading whitespace from statusLine output.
42
+ DIM = "\x1b[2m"
43
+ ITALIC = "\x1b[3m"
44
+ NOT_ITALIC = "\x1b[23m"
45
+ RESET = "\x1b[0m"
46
+
47
+
48
+ def _stylize(msg: str) -> str:
49
+ branded = msg.replace("SourceIndex", f"{ITALIC}SourceIndex{NOT_ITALIC}", 1)
50
+ return f"{DIM}{branded}{RESET}"
51
+
52
+
53
+ def format_compact(saved_session: float, weekly: float) -> str:
54
+ # Gate on session activity (not weekly) so the row stays blank on idle
55
+ # turns. Showing only the weekly total — the most common case — left
56
+ # the line permanently lit and competing with the auto-mode indicator.
57
+ if saved_session < 0.005:
58
+ return ""
59
+ parts = [f"${saved_session:0.2f} this session"]
60
+ if weekly >= 0.005:
61
+ parts.append(f"${weekly:0.2f} this week")
62
+ return "SourceIndex saved " + " · ".join(parts)
63
+
64
+
65
+ def _is_fresh(entry: dict[str, Any], now: datetime) -> bool:
66
+ raw = entry.get("updated_at")
67
+ if not isinstance(raw, str):
68
+ return False
69
+ try:
70
+ ts = datetime.fromisoformat(raw)
71
+ except ValueError:
72
+ return False
73
+ return (now - ts).total_seconds() <= FRESH_WINDOW_S
74
+
75
+
76
+ def run(payload: dict[str, Any], *, stdout=sys.stdout, now: datetime | None = None) -> int:
77
+ state = load_state()
78
+ if not state:
79
+ return 0
80
+
81
+ actual_now = now or datetime.now()
82
+ session_id = payload.get("session_id") or ""
83
+ entry = state.get(session_id) if isinstance(state.get(session_id), dict) else None
84
+ if not entry or not _is_fresh(entry, actual_now):
85
+ return 0
86
+
87
+ saved_session = float(entry.get("saved", 0.0))
88
+ weekly = sum(
89
+ float(e.get("saved", 0.0))
90
+ for e in state.values()
91
+ if isinstance(e, dict)
92
+ )
93
+
94
+ msg = format_compact(saved_session, weekly)
95
+ if not msg:
96
+ return 0
97
+
98
+ stdout.write(_stylize(msg))
99
+ return 0
100
+
101
+
102
+ def main() -> int:
103
+ try:
104
+ payload = json.load(sys.stdin)
105
+ except (json.JSONDecodeError, ValueError):
106
+ payload = {}
107
+ if not isinstance(payload, dict):
108
+ payload = {}
109
+ try:
110
+ return run(payload)
111
+ except Exception:
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entry point for sourceindex.
3
+
4
+ Usage:
5
+ sourceindex init [--repo-root PATH] [--api-key KEY]
6
+ sourceindex update [--repo-root PATH]
7
+ sourceindex serve [--repo-root PATH]
8
+ sourceindex report-savings # Stop-hook entry point; reads payload JSON from stdin
9
+ sourceindex statusline # statusLine entry point; reads payload JSON from stdin
10
+ """
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from ..lib.log import configure, get_logger
17
+ from .api_key import _load_persisted_env
18
+ from .commands import (
19
+ cmd_daemon,
20
+ cmd_deinit,
21
+ cmd_dump,
22
+ cmd_init,
23
+ cmd_install_hooks,
24
+ cmd_log,
25
+ cmd_report_savings,
26
+ cmd_savings_summary,
27
+ cmd_search,
28
+ cmd_serve,
29
+ cmd_statusline,
30
+ cmd_update,
31
+ cmd_usage,
32
+ )
33
+
34
+
35
+ _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
36
+
37
+
38
+ def _add_log_flags(parser: argparse.ArgumentParser) -> None:
39
+ """Attach the cross-cutting logging flags. Used as a parent parser so
40
+ every repo-touching subcommand carries them. ``statusline`` and
41
+ ``report-savings`` skip these (their stdout is reserved for product
42
+ output and they don't write to the on-disk log)."""
43
+ g = parser.add_argument_group("logging")
44
+ g.add_argument(
45
+ "--log-level",
46
+ choices=_LOG_LEVELS,
47
+ default=None,
48
+ help="Stderr log level. Default: INFO (or $SOURCEINDEX_LOG_LEVEL).",
49
+ )
50
+ g.add_argument("-v", "--verbose", action="store_true", help="Shortcut for --log-level DEBUG.")
51
+ g.add_argument("-q", "--quiet", action="store_true", help="Shortcut for --log-level WARNING.")
52
+ g.add_argument(
53
+ "--log-file",
54
+ default=None,
55
+ help="Path to JSONL log file. Default: <repo>/.sourceindex/sourceindex.log "
56
+ "(or $SOURCEINDEX_LOG_FILE).",
57
+ )
58
+ g.add_argument(
59
+ "--no-log-file",
60
+ action="store_true",
61
+ help="Disable the JSONL file log (equivalent to SOURCEINDEX_NO_LOG_FILE=1).",
62
+ )
63
+
64
+
65
+ def _resolve_log_level(args: argparse.Namespace) -> str | None:
66
+ if getattr(args, "verbose", False):
67
+ return "DEBUG"
68
+ if getattr(args, "quiet", False):
69
+ return "WARNING"
70
+ return getattr(args, "log_level", None)
71
+
72
+
73
+ def main() -> None:
74
+ parser = argparse.ArgumentParser(prog="sourceindex", description="Codebase index for agentic coding")
75
+ sub = parser.add_subparsers(dest="command")
76
+
77
+ lang_help = "Comma-separated languages to index (e.g. python,cpp,rust). Default: auto-detect."
78
+ model_help = (
79
+ "LiteLLM model string. Default: openrouter/openai/gpt-5.4-nano "
80
+ "(best price/latency/quality balance per Opus-judged description "
81
+ "quality + AST recall + line-range accuracy). Other examples: "
82
+ "claude-haiku-4-5, openrouter/deepseek/deepseek-v4-flash, "
83
+ "openrouter/google/gemini-2.5-flash. SOURCEINDEX_*_KEY must hold "
84
+ "a key matching the provider."
85
+ )
86
+
87
+ # Import from sourceindex/__init__.py rather than ..build — fetching from
88
+ # the build package would import litellm + the language registry just to
89
+ # render --help, ballooning `sourceindex statusline` startup.
90
+ from .. import REALTIME_MAX_WORKERS as _DEFAULT_WORKERS
91
+ workers_help = (
92
+ f"Max concurrent LLM requests (default: {_DEFAULT_WORKERS}). Pool is "
93
+ "auto-capped at min(workers, file_count). Lower if your provider "
94
+ "key has tight rate limits or your machine has limited file descriptors."
95
+ )
96
+
97
+ log_parent = argparse.ArgumentParser(add_help=False)
98
+ _add_log_flags(log_parent)
99
+
100
+ p_init = sub.add_parser("init", help="Build the full index", parents=[log_parent])
101
+ p_init.add_argument("--repo-root", default=".", help="Repository root (default: .)")
102
+ p_init.add_argument(
103
+ "--api-key",
104
+ default=None,
105
+ help="Sourceindex bearer key (or set SOURCEINDEX_API_KEY). Used for both indexing and search. "
106
+ "By default this routes through the hosted backend baked into lib/backend.py; override the "
107
+ "backend with SOURCEINDEX_BACKEND_URL=... to talk to a different deployment.",
108
+ )
109
+ p_init.add_argument("--languages", default=None, help=lang_help)
110
+ p_init.add_argument(
111
+ "--mode",
112
+ choices=["mcp", "agent"],
113
+ default="agent",
114
+ help="Claude integration mode: 'agent' writes a Claude subagent config + CLAUDE.md, 'mcp' writes .mcp.json + CLAUDE.md (default: agent)",
115
+ )
116
+ p_init.add_argument("--model", default="openrouter/openai/gpt-5.4-nano", help=model_help)
117
+ p_init.add_argument("--workers", type=int, default=_DEFAULT_WORKERS, help=workers_help)
118
+ p_init.add_argument(
119
+ "--allow-dump",
120
+ action="store_true",
121
+ help="Start the daemon with its admin socket bound, enabling "
122
+ "`sourceindex dump`. Production never sets this; eval scripts "
123
+ "pass it so they can materialize a plaintext copy of the index "
124
+ "per task.",
125
+ )
126
+
127
+ p_update = sub.add_parser("update", help="Update index for changed files", parents=[log_parent])
128
+ p_update.add_argument("--repo-root", default=".", help="Repository root (default: .)")
129
+ p_update.add_argument("--languages", default=None, help=lang_help)
130
+ p_update.add_argument("--model", default="openrouter/openai/gpt-5.4-nano", help=model_help)
131
+ p_update.add_argument("--workers", type=int, default=_DEFAULT_WORKERS, help=workers_help)
132
+ p_update.add_argument(
133
+ "--allow-dump",
134
+ action="store_true",
135
+ help="See `sourceindex init --allow-dump`.",
136
+ )
137
+
138
+ p_dump = sub.add_parser(
139
+ "dump",
140
+ help="Admin-only: decrypt the index into a plaintext mirror at --out.",
141
+ parents=[log_parent],
142
+ )
143
+ p_dump.add_argument("--repo-root", default=".", help="Repository root (default: .)")
144
+ p_dump.add_argument("--out", required=True, help="Output directory to write the plaintext mirror to.")
145
+
146
+ p_daemon = sub.add_parser(
147
+ "daemon",
148
+ help="Run or stop the per-repo daemon (auto-spawned by clients; rarely invoked directly).",
149
+ parents=[log_parent],
150
+ )
151
+ p_daemon.add_argument("--repo-root", default=".", help="Repository root (default: .)")
152
+ p_daemon.add_argument(
153
+ "--allow-dump", action="store_true",
154
+ help="Bind the admin socket so `sourceindex dump` and other admin RPCs work.",
155
+ )
156
+ p_daemon.add_argument(
157
+ "--stop", action="store_true",
158
+ help="Send SIGTERM to the running daemon and exit.",
159
+ )
160
+
161
+ p_deinit = sub.add_parser(
162
+ "deinit",
163
+ help="Tear down per-repo state: stop the daemon, delete the encryption key, "
164
+ "remove the runtime socket / pidfile. Use after `rm -rf <repo>` to clean "
165
+ "up orphan keyring entries.",
166
+ parents=[log_parent],
167
+ )
168
+ p_deinit.add_argument(
169
+ "--repo-root",
170
+ default=".",
171
+ help="Repository root (default: .). Works even if the path no longer exists — "
172
+ "the canonical form is hashed to look up the keyring entry.",
173
+ )
174
+ p_deinit.add_argument(
175
+ "--force",
176
+ action="store_true",
177
+ help="Proceed even if <repo>/.sourceindex/ still exists. The encrypted "
178
+ "store is removed along with the key. Without this flag, deinit "
179
+ "refuses on a live index so you don't lose data by surprise.",
180
+ )
181
+ p_deinit.add_argument(
182
+ "--all",
183
+ action="store_true",
184
+ help="Sweep mode: walk the registry at ~/.config/sourceindex/repos.tsv "
185
+ "and deinit every entry whose repo_root no longer exists. Live "
186
+ "repos are skipped. Triggered automatically at login by the "
187
+ "systemd unit that `sourceindex init` installs.",
188
+ )
189
+
190
+ p_install_hooks = sub.add_parser(
191
+ "install-hooks",
192
+ help="Refresh git hooks (post-commit/post-checkout/post-merge) in an existing repo. Use after a sourceindex upgrade adds a new hook.",
193
+ parents=[log_parent],
194
+ )
195
+ p_install_hooks.add_argument("--repo-root", default=".", help="Repository root (default: .)")
196
+
197
+ p_serve = sub.add_parser("serve", help="Start MCP server", parents=[log_parent])
198
+ p_serve.add_argument("--repo-root", default=".", help="Repository root (default: .)")
199
+ p_serve.add_argument("--index-dir", default=None, help="Index directory (default: REPO_ROOT/.sourceindex)")
200
+
201
+ p_usage = sub.add_parser(
202
+ "usage",
203
+ help="Show this month's spend and remaining budget from the hosted backend.",
204
+ parents=[log_parent],
205
+ )
206
+ p_usage.add_argument("--repo-root", default=".", help="Repository root (default: .)")
207
+
208
+ p_search = sub.add_parser("search", help="Run two-pass search and emit an enriched roadmap", parents=[log_parent])
209
+ p_search.add_argument("--repo-root", default=".", help="Repository root (default: .)")
210
+ p_search.add_argument("--index-dir", default=None, help="Index directory (default: REPO_ROOT/.sourceindex)")
211
+ p_search.add_argument("--query", required=True, help="Task description / search query")
212
+ p_search.add_argument("--index-search-model", default="openrouter/anthropic/claude-haiku-4-5", help=f"Model for search passes 1a/1b. {model_help}")
213
+ p_search.add_argument("--mode", choices=["implement", "qa"], default="implement",
214
+ help="Prompt mode: 'implement' (feature work, default) or 'qa' (read-only question answering).")
215
+ p_search.add_argument("--output", default=None, help="Write the roadmap to this path instead of INDEX_DIR/sourceindex-output.txt. Lets a caller isolate per-run output — the sourceindex-bare eval variant points this at its own scratch dir so parallel same-task runs don't clobber one shared cache file.")
216
+
217
+ sub.add_parser(
218
+ "report-savings",
219
+ help="Claude Code Stop-hook entry point: reads payload JSON from stdin and persists the per-session savings ledger.",
220
+ )
221
+
222
+ sub.add_parser(
223
+ "statusline",
224
+ help="Claude Code statusLine entry point: reads payload JSON from stdin and prints the savings line for the bottom status row.",
225
+ )
226
+
227
+ sub.add_parser(
228
+ "savings-summary",
229
+ help="Stop-hook entry point: prints a multi-line per-turn savings breakdown (session/week/month). Pair with `report-savings` as a second Stop hook.",
230
+ )
231
+
232
+ # Developer-only: pretty-print the JSONL log. Hidden from --help (help=...
233
+ # is suppressed) so it doesn't appear in the user-facing surface; still
234
+ # invokable as `sourceindex log` for debugging.
235
+ p_log = sub.add_parser("log", parents=[log_parent])
236
+ p_log.add_argument("--repo-root", default=".")
237
+ p_log.add_argument("--log-path", default=None,
238
+ help="Path to JSONL log (default: <repo>/.sourceindex/sourceindex.log)")
239
+ p_log.add_argument("--tail", type=int, default=0,
240
+ help="Show only the last N records.")
241
+ p_log.add_argument("--filter", default=None,
242
+ help="Substring to match against the event name (e.g. 'llm', 'search').")
243
+
244
+ args = parser.parse_args()
245
+
246
+ repo_root_arg = getattr(args, "repo_root", None)
247
+ if repo_root_arg is not None:
248
+ _load_persisted_env(Path(repo_root_arg).resolve())
249
+
250
+ # Configure logging after _load_persisted_env so SOURCEINDEX_LOG_* picked
251
+ # up from .sourceindex/.env take effect.
252
+ configure(
253
+ level=_resolve_log_level(args),
254
+ repo_root=Path(repo_root_arg).resolve() if repo_root_arg is not None else None,
255
+ log_file=getattr(args, "log_file", None),
256
+ enable_file=not getattr(args, "no_log_file", False),
257
+ )
258
+
259
+ # `log` is intentionally excluded — it's a read-only viewer over the
260
+ # log file, and recording each invocation pollutes the file it's
261
+ # about to print.
262
+ if args.command in ("init", "update", "serve", "search", "install-hooks", "dump", "daemon", "deinit"):
263
+ get_logger(__name__).debug(
264
+ "CLI invocation",
265
+ extra={
266
+ "event": "cli.invocation",
267
+ "command": args.command,
268
+ "repo_root": str(Path(repo_root_arg).resolve()) if repo_root_arg else None,
269
+ },
270
+ )
271
+
272
+ if args.command == "init":
273
+ cmd_init(args)
274
+ elif args.command == "update":
275
+ cmd_update(args)
276
+ elif args.command == "install-hooks":
277
+ cmd_install_hooks(args)
278
+ elif args.command == "serve":
279
+ cmd_serve(args)
280
+ elif args.command == "search":
281
+ cmd_search(args)
282
+ elif args.command == "report-savings":
283
+ cmd_report_savings(args)
284
+ elif args.command == "statusline":
285
+ cmd_statusline(args)
286
+ elif args.command == "savings-summary":
287
+ cmd_savings_summary(args)
288
+ elif args.command == "log":
289
+ cmd_log(args)
290
+ elif args.command == "usage":
291
+ cmd_usage(args)
292
+ elif args.command == "dump":
293
+ cmd_dump(args)
294
+ elif args.command == "daemon":
295
+ cmd_daemon(args)
296
+ elif args.command == "deinit":
297
+ cmd_deinit(args)
298
+ else:
299
+ parser.print_help()
300
+ sys.exit(1)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m sourceindex.cli``.
2
+
3
+ The pyproject script entry point ``sourceindex = "sourceindex.cli:main"``
4
+ covers the installed-binary path, but eval.sh and other tooling invoke the
5
+ package directly via ``-m`` — that needs this module.
6
+ """
7
+
8
+ from . import main
9
+
10
+ main()
@@ -0,0 +1,171 @@
1
+ """API key resolution + persistence for the CLI commands.
2
+
3
+ Three concerns share this module:
4
+
5
+ - Look up the API key (CLI arg → env var → persisted file).
6
+ - Prompt the user for one on a TTY when ``init`` runs without any source.
7
+ - Persist the key into ``<repo>/.sourceindex/.env`` so the post-commit hook,
8
+ search subagent, and MCP server pick it up without an env var.
9
+ """
10
+
11
+ import getpass
12
+ import os
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from ..lib.log import get_logger
18
+
19
+ _log = get_logger(__name__)
20
+
21
+
22
+ PERSISTED_KEY_FILENAME = ".env"
23
+
24
+ # Loose shape check: alphanumerics + `_` / `-`, at least 20 chars, no
25
+ # whitespace or other punctuation. Designed to reject obvious paste
26
+ # mistakes (prompt strings, error messages, paths, URLs) without
27
+ # locking in a specific provider's key format. Real cases we've seen
28
+ # slip through and poison .sourceindex/.env: the literal getpass prompt
29
+ # string, captured when stdin was non-interactive but isatty() lied.
30
+ _API_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_\-]{20,}$")
31
+
32
+
33
+ def _is_plausible_api_key(key: str) -> bool:
34
+ return bool(key) and bool(_API_KEY_PATTERN.match(key))
35
+
36
+
37
+ def _warn_if_provider_key(key: str) -> None:
38
+ """Warn only when an explicit pin forces a provider key through the hosted
39
+ backend (auto-opt-out handles the unpinned case in ``backend_base``)."""
40
+ from ..lib.backend import looks_like_provider_key, backend_base
41
+ if not looks_like_provider_key(key):
42
+ return
43
+ base = backend_base(api_key=key)
44
+ if not base:
45
+ return
46
+ _log.warning(
47
+ f"SOURCEINDEX_API_KEY looks like a direct-provider key but sourceindex "
48
+ f"is routing through {base} (pinned via SOURCEINDEX_BACKEND_URL or the "
49
+ f"admin config), which expects a sourceindex bearer. Unset the pin to "
50
+ f"auto-route direct, or re-run `sourceindex init` with a sourceindex "
51
+ f"bearer key.",
52
+ extra={"event": "api_key.provider_key_with_backend", "backend": base},
53
+ )
54
+
55
+
56
+ def _require_api_key(cli_arg: str | None, *, daemon_managed: bool = False) -> str | None:
57
+ """Resolve the API key from --api-key/env, asserting it exists.
58
+
59
+ ``daemon_managed=True`` tolerates a missing key on encrypted-store
60
+ repos: the daemon loaded ``SOURCEINDEX_API_KEY`` from its own encrypted
61
+ .env at startup (see ``daemon/server.py:_load_env_from_store``), so RPC
62
+ handlers fall back to env when the client passes no key. Necessary for
63
+ hook-triggered subprocesses (post-commit ``sourceindex update``, the
64
+ Claude Code subagent's ``sourceindex search``) that spawn fresh and
65
+ don't inherit the user's shell env after init prompted for the key.
66
+ """
67
+ from ..lib.llm import resolve_api_key
68
+ key = resolve_api_key(cli_arg)
69
+ if key:
70
+ _warn_if_provider_key(key)
71
+ return key
72
+ if daemon_managed:
73
+ return None
74
+ assert key, (
75
+ "No API key found. Pass --api-key or set SOURCEINDEX_API_KEY "
76
+ "(a sourceindex bearer key from the hosted backend, or a direct "
77
+ "provider key if SOURCEINDEX_BACKEND_URL=\"\" disables the backend)."
78
+ )
79
+ return None
80
+
81
+
82
+ def _prompt_api_key(cli_arg: str | None) -> str:
83
+ """Like _require_api_key, but prompts on a TTY if no key is available.
84
+ Used only by `init` — non-interactive callers (post-commit hook, subagent,
85
+ MCP server) should keep using _require_api_key so they fail loudly."""
86
+ from ..lib.llm import resolve_api_key
87
+ key = resolve_api_key(cli_arg)
88
+ if not key:
89
+ if not sys.stdin.isatty():
90
+ raise SystemExit(
91
+ "No API key found. Pass --api-key or set SOURCEINDEX_API_KEY."
92
+ )
93
+ _log.info("Paste your sourceindex API key (input hidden):")
94
+ key = getpass.getpass("API key: ").strip()
95
+ if not key:
96
+ raise SystemExit("[sourceindex] Empty API key — aborting.")
97
+ if not _is_plausible_api_key(key):
98
+ raise SystemExit(
99
+ f"[sourceindex] That doesn't look like an API key ({len(key)} chars, "
100
+ "expected ≥20 alphanumeric/_-). Aborting before persisting junk."
101
+ )
102
+ _warn_if_provider_key(key)
103
+ return key
104
+
105
+
106
+ def _parse_env_file(path: Path) -> dict[str, str]:
107
+ from ..lib.env import parse_env_text
108
+ try:
109
+ return parse_env_text(path.read_text())
110
+ except OSError:
111
+ return {}
112
+
113
+
114
+ def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
115
+ """Merge ``key=value`` into ``<index_dir>/.env`` without clobbering other keys.
116
+
117
+ Refuses on an encrypted layout — the daemon owns those writes."""
118
+ from ..daemon import IndexDir
119
+ idx = IndexDir(path=index_dir, repo_hash="")
120
+ if idx.is_encrypted_layout():
121
+ raise RuntimeError(
122
+ f"{index_dir} is an encrypted store; persist .env values via the "
123
+ "daemon RPC instead of writing the file directly."
124
+ )
125
+ index_dir.mkdir(parents=True, exist_ok=True)
126
+ env_path = index_dir / PERSISTED_KEY_FILENAME
127
+ existing = _parse_env_file(env_path)
128
+ existing[key] = value
129
+ env_path.write_text("".join(f"{k}={v}\n" for k, v in existing.items()))
130
+ env_path.chmod(0o600)
131
+
132
+
133
+ def _persist_api_key(index_dir: Path, key: str) -> None:
134
+ if not _is_plausible_api_key(key):
135
+ raise SystemExit(
136
+ f"[sourceindex] Refusing to persist key ({len(key)} chars) — does "
137
+ "not match the expected shape (≥20 alphanumeric/_-, no whitespace)."
138
+ )
139
+ _persist_env_var(index_dir, "SOURCEINDEX_API_KEY", key)
140
+ _log.info(
141
+ f"Persisted API key to {index_dir / PERSISTED_KEY_FILENAME}",
142
+ extra={"event": "api_key.persisted", "path": str(index_dir / PERSISTED_KEY_FILENAME)},
143
+ )
144
+
145
+
146
+ def _persist_backend_url(index_dir: Path, url: str) -> None:
147
+ """Persist a non-default ``SOURCEINDEX_BACKEND_URL`` next to the API key.
148
+ Only needed when overriding the default backend baked into
149
+ ``lib/backend.py`` (e.g. pointing at a local ``wrangler dev`` for testing
150
+ or an alternate operator's deployment)."""
151
+ if not url.startswith(("http://", "https://")):
152
+ raise SystemExit(
153
+ f"[sourceindex] Invalid backend URL ({url!r}) — must start with http(s)://"
154
+ )
155
+ _persist_env_var(index_dir, "SOURCEINDEX_BACKEND_URL", url.rstrip("/"))
156
+ _log.info(
157
+ f"Persisted backend URL to {index_dir / PERSISTED_KEY_FILENAME}",
158
+ extra={"event": "api_key.backend_persisted", "path": str(index_dir / PERSISTED_KEY_FILENAME)},
159
+ )
160
+
161
+
162
+ def _load_persisted_env(repo_root: Path) -> None:
163
+ """Merge plaintext .sourceindex/.env into os.environ (pre-existing vars
164
+ win, standard dotenv semantics). Encrypted .env is loaded by the
165
+ daemon itself at startup — client processes can't read it."""
166
+ from ..daemon import IndexDir
167
+ env_path = IndexDir.for_repo(repo_root).path / PERSISTED_KEY_FILENAME
168
+ if env_path.is_file():
169
+ for k, v in _parse_env_file(env_path).items():
170
+ if k and k not in os.environ:
171
+ os.environ[k] = v