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,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
|