SourceIndex 0.1.1__tar.gz → 0.1.2__tar.gz
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-0.1.1 → sourceindex-0.1.2}/GETTING_STARTED.md +22 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/PKG-INFO +23 -3
- {sourceindex-0.1.1 → sourceindex-0.1.2}/pyproject.toml +1 -4
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/__init__.py +1 -1
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/__init__.py +81 -29
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/api_key.py +108 -17
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/commands.py +58 -43
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/install.py +8 -59
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/client.py +3 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/protocol.py +1 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/server.py +163 -3
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/llm.py +17 -3
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/__init__.py +1 -1
- sourceindex-0.1.1/sourceindex/server.py +0 -127
- {sourceindex-0.1.1 → sourceindex-0.1.2}/.gitignore +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/__init__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/indexer.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/__init__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/python_ast.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/treesitter.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/prompts.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/state.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/walker.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/__init__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/savings.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/savings_summary.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/statusline.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/__main__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/__init__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/crypto.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/keyring_store.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/lifecycle.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/store.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/__init__.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/backend.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/cost.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/env.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/errors.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/git.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/languages.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/log.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/registry.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/timing.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/experiments.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/imports.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/passes.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/prompts.py +0 -0
- {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/roadmap.py +0 -0
|
@@ -44,6 +44,28 @@ to re-run init later to add the other agent.
|
|
|
44
44
|
If you don't have a `CLAUDE.md` (Claude Code) or `AGENTS.md` (opencode)
|
|
45
45
|
yet, run `/init` in your agent first.
|
|
46
46
|
|
|
47
|
+
To replace a rotated or expired key later:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
sourceindex api-key set
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That prompts for the new key and persists it as your user-level default. New
|
|
54
|
+
repos and repos without a local override will use it automatically. For
|
|
55
|
+
automation, use
|
|
56
|
+
`sourceindex api-key set --from-env` with `SOURCEINDEX_API_KEY` already set,
|
|
57
|
+
pass `--api-key sk-si-...`, or pass the key positionally:
|
|
58
|
+
`sourceindex api-key set sk-si-...`.
|
|
59
|
+
|
|
60
|
+
To set a repo-local override:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
sourceindex api-key set --local
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Explicit environment variables still win, and a repo-local key overrides the
|
|
67
|
+
global default.
|
|
68
|
+
|
|
47
69
|
## Day-to-day
|
|
48
70
|
|
|
49
71
|
Nothing. Open the repo in Claude Code or opencode as usual — it'll use
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SourceIndex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Codebase index for agentic coding
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: cryptography>=42
|
|
@@ -16,8 +16,6 @@ Requires-Dist: tree-sitter-ruby>=0.23
|
|
|
16
16
|
Requires-Dist: tree-sitter-rust>=0.23
|
|
17
17
|
Requires-Dist: tree-sitter-typescript>=0.23
|
|
18
18
|
Requires-Dist: tree-sitter>=0.23
|
|
19
|
-
Provides-Extra: mcp
|
|
20
|
-
Requires-Dist: mcp<2,>=1.0.0; extra == 'mcp'
|
|
21
19
|
Description-Content-Type: text/markdown
|
|
22
20
|
|
|
23
21
|
# Getting Started with SourceIndex
|
|
@@ -66,6 +64,28 @@ to re-run init later to add the other agent.
|
|
|
66
64
|
If you don't have a `CLAUDE.md` (Claude Code) or `AGENTS.md` (opencode)
|
|
67
65
|
yet, run `/init` in your agent first.
|
|
68
66
|
|
|
67
|
+
To replace a rotated or expired key later:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
sourceindex api-key set
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
That prompts for the new key and persists it as your user-level default. New
|
|
74
|
+
repos and repos without a local override will use it automatically. For
|
|
75
|
+
automation, use
|
|
76
|
+
`sourceindex api-key set --from-env` with `SOURCEINDEX_API_KEY` already set,
|
|
77
|
+
pass `--api-key sk-si-...`, or pass the key positionally:
|
|
78
|
+
`sourceindex api-key set sk-si-...`.
|
|
79
|
+
|
|
80
|
+
To set a repo-local override:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
sourceindex api-key set --local
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Explicit environment variables still win, and a repo-local key overrides the
|
|
87
|
+
global default.
|
|
88
|
+
|
|
69
89
|
## Day-to-day
|
|
70
90
|
|
|
71
91
|
Nothing. Open the repo in Claude Code or opencode as usual — it'll use
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "SourceIndex"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Codebase index for agentic coding"
|
|
9
9
|
readme = "GETTING_STARTED.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -32,9 +32,6 @@ dependencies = [
|
|
|
32
32
|
"tree-sitter-typescript>=0.23",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
-
[project.optional-dependencies]
|
|
36
|
-
mcp = ["mcp>=1.0.0,<2"]
|
|
37
|
-
|
|
38
35
|
[project.scripts]
|
|
39
36
|
sourceindex = "sourceindex.cli:main"
|
|
40
37
|
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
Usage:
|
|
5
5
|
sourceindex init [--repo-root PATH] [--api-key KEY]
|
|
6
6
|
sourceindex update [--repo-root PATH]
|
|
7
|
-
sourceindex
|
|
7
|
+
sourceindex api-key set
|
|
8
|
+
sourceindex api-key set --local [--repo-root PATH]
|
|
9
|
+
sourceindex search [--repo-root PATH] QUERY
|
|
8
10
|
sourceindex report-savings # Stop-hook entry point; reads payload JSON from stdin
|
|
9
11
|
sourceindex statusline # statusLine entry point; reads payload JSON from stdin
|
|
10
12
|
"""
|
|
@@ -20,13 +22,13 @@ from .commands import (
|
|
|
20
22
|
cmd_daemon,
|
|
21
23
|
cmd_deinit,
|
|
22
24
|
cmd_dump,
|
|
25
|
+
cmd_api_key_set,
|
|
23
26
|
cmd_init,
|
|
24
27
|
cmd_install_hooks,
|
|
25
28
|
cmd_log,
|
|
26
29
|
cmd_report_savings,
|
|
27
30
|
cmd_savings_summary,
|
|
28
31
|
cmd_search,
|
|
29
|
-
cmd_serve,
|
|
30
32
|
cmd_statusline,
|
|
31
33
|
cmd_update,
|
|
32
34
|
cmd_usage,
|
|
@@ -38,7 +40,7 @@ _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
|
38
40
|
# Commands that do real backend/LLM work — the only ones that attach the error
|
|
39
41
|
# upload handler. Others skip it so lightweight/read-only paths don't spin up
|
|
40
42
|
# the background sender thread. (The gate itself is litellm-free.)
|
|
41
|
-
_UPLOAD_COMMANDS = frozenset({"init", "update", "
|
|
43
|
+
_UPLOAD_COMMANDS = frozenset({"init", "update", "search", "deinit", "daemon"})
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
def _add_log_flags(parser: argparse.ArgumentParser) -> None:
|
|
@@ -78,7 +80,16 @@ def _resolve_log_level(args: argparse.Namespace) -> str | None:
|
|
|
78
80
|
|
|
79
81
|
def main() -> None:
|
|
80
82
|
parser = argparse.ArgumentParser(prog="sourceindex", description="Codebase index for agentic coding")
|
|
81
|
-
|
|
83
|
+
public_commands = (
|
|
84
|
+
"init,update,daemon,deinit,install-hooks,usage,api-key,search,"
|
|
85
|
+
"report-savings,statusline,savings-summary"
|
|
86
|
+
)
|
|
87
|
+
sub = parser.add_subparsers(dest="command", metavar="{" + public_commands + "}")
|
|
88
|
+
|
|
89
|
+
def _hide_subcommand_from_help(name: str) -> None:
|
|
90
|
+
# argparse keeps hidden subcommands in a private help-action list.
|
|
91
|
+
# Leave choices intact so internal/admin commands still parse.
|
|
92
|
+
sub._choices_actions = [a for a in sub._choices_actions if a.dest != name]
|
|
82
93
|
|
|
83
94
|
lang_help = "Comma-separated languages to index (e.g. python,cpp,rust). Default: auto-detect."
|
|
84
95
|
model_help = (
|
|
@@ -134,23 +145,12 @@ def main() -> None:
|
|
|
134
145
|
"backend with SOURCEINDEX_BACKEND_URL=... to talk to a different deployment.",
|
|
135
146
|
)
|
|
136
147
|
p_init.add_argument("--languages", default=None, help=lang_help)
|
|
137
|
-
p_init.add_argument(
|
|
138
|
-
"--mode",
|
|
139
|
-
choices=["mcp", "agent"],
|
|
140
|
-
default="agent",
|
|
141
|
-
help="Delivery shape: 'agent' writes subagent configs for the selected/detected "
|
|
142
|
-
"platforms (see the positional `agent` args), 'mcp' writes .mcp.json + "
|
|
143
|
-
"CLAUDE.md (Claude-only, currently broken) (default: agent)",
|
|
144
|
-
)
|
|
145
148
|
p_init.add_argument("--model", default=_DEFAULT_BUILD_MODEL, help=build_model_help)
|
|
146
149
|
p_init.add_argument("--workers", type=int, default=_DEFAULT_WORKERS, help=workers_help)
|
|
147
150
|
p_init.add_argument(
|
|
148
151
|
"--allow-dump",
|
|
149
152
|
action="store_true",
|
|
150
|
-
help=
|
|
151
|
-
"`sourceindex dump`. Production never sets this; eval scripts "
|
|
152
|
-
"pass it so they can materialize a plaintext copy of the index "
|
|
153
|
-
"per task.",
|
|
153
|
+
help=argparse.SUPPRESS,
|
|
154
154
|
)
|
|
155
155
|
|
|
156
156
|
p_update = sub.add_parser("update", help="Update index for changed files", parents=[log_parent])
|
|
@@ -161,16 +161,17 @@ def main() -> None:
|
|
|
161
161
|
p_update.add_argument(
|
|
162
162
|
"--allow-dump",
|
|
163
163
|
action="store_true",
|
|
164
|
-
help=
|
|
164
|
+
help=argparse.SUPPRESS,
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
p_dump = sub.add_parser(
|
|
168
168
|
"dump",
|
|
169
|
-
help=
|
|
169
|
+
help=argparse.SUPPRESS,
|
|
170
170
|
parents=[log_parent],
|
|
171
171
|
)
|
|
172
172
|
p_dump.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
173
173
|
p_dump.add_argument("--out", required=True, help="Output directory to write the plaintext mirror to.")
|
|
174
|
+
_hide_subcommand_from_help("dump")
|
|
174
175
|
|
|
175
176
|
p_daemon = sub.add_parser(
|
|
176
177
|
"daemon",
|
|
@@ -180,7 +181,7 @@ def main() -> None:
|
|
|
180
181
|
p_daemon.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
181
182
|
p_daemon.add_argument(
|
|
182
183
|
"--allow-dump", action="store_true",
|
|
183
|
-
help=
|
|
184
|
+
help=argparse.SUPPRESS,
|
|
184
185
|
)
|
|
185
186
|
p_daemon.add_argument(
|
|
186
187
|
"--stop", action="store_true",
|
|
@@ -223,10 +224,6 @@ def main() -> None:
|
|
|
223
224
|
)
|
|
224
225
|
p_install_hooks.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
225
226
|
|
|
226
|
-
p_serve = sub.add_parser("serve", help="Start MCP server", parents=[log_parent])
|
|
227
|
-
p_serve.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
228
|
-
p_serve.add_argument("--index-dir", default=None, help="Index directory (default: REPO_ROOT/.sourceindex)")
|
|
229
|
-
|
|
230
227
|
p_usage = sub.add_parser(
|
|
231
228
|
"usage",
|
|
232
229
|
help="Show this month's spend and remaining budget from the hosted backend.",
|
|
@@ -234,6 +231,46 @@ def main() -> None:
|
|
|
234
231
|
)
|
|
235
232
|
p_usage.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
236
233
|
|
|
234
|
+
p_api_key = sub.add_parser("api-key", help="Manage SourceIndex API keys")
|
|
235
|
+
api_key_sub = p_api_key.add_subparsers(dest="api_key_command")
|
|
236
|
+
p_api_key_set = api_key_sub.add_parser(
|
|
237
|
+
"set",
|
|
238
|
+
help="Replace the persisted SourceIndex API key",
|
|
239
|
+
parents=[log_parent],
|
|
240
|
+
)
|
|
241
|
+
p_api_key_set.add_argument(
|
|
242
|
+
"key",
|
|
243
|
+
nargs="?",
|
|
244
|
+
help="New SourceIndex bearer key. Prefer the prompt or --from-env if shell history is a concern.",
|
|
245
|
+
)
|
|
246
|
+
p_api_key_set.add_argument(
|
|
247
|
+
"--repo-root", default=".", help="Repository root for --local (default: .)"
|
|
248
|
+
)
|
|
249
|
+
key_scope = p_api_key_set.add_mutually_exclusive_group()
|
|
250
|
+
key_scope.add_argument(
|
|
251
|
+
"--local",
|
|
252
|
+
dest="local_key",
|
|
253
|
+
action="store_true",
|
|
254
|
+
help="Persist as a repo-local override instead of the user-level default key.",
|
|
255
|
+
)
|
|
256
|
+
key_scope.add_argument(
|
|
257
|
+
"--global",
|
|
258
|
+
dest="global_key",
|
|
259
|
+
action="store_true",
|
|
260
|
+
help=argparse.SUPPRESS,
|
|
261
|
+
)
|
|
262
|
+
api_key_input = p_api_key_set.add_mutually_exclusive_group()
|
|
263
|
+
api_key_input.add_argument(
|
|
264
|
+
"--api-key",
|
|
265
|
+
default=None,
|
|
266
|
+
help="New SourceIndex bearer key. Omitting this prompts on a TTY.",
|
|
267
|
+
)
|
|
268
|
+
api_key_input.add_argument(
|
|
269
|
+
"--from-env",
|
|
270
|
+
action="store_true",
|
|
271
|
+
help="Persist the current SOURCEINDEX_API_KEY environment value.",
|
|
272
|
+
)
|
|
273
|
+
|
|
237
274
|
p_search = sub.add_parser("search", help="Run two-pass search and emit an enriched roadmap", parents=[log_parent])
|
|
238
275
|
p_search.add_argument("--repo-root", default=".", help="Repository root (default: .)")
|
|
239
276
|
p_search.add_argument("--index-dir", default=None, help="Index directory (default: REPO_ROOT/.sourceindex)")
|
|
@@ -261,7 +298,7 @@ def main() -> None:
|
|
|
261
298
|
# Developer-only: pretty-print the JSONL log. Hidden from --help (help=...
|
|
262
299
|
# is suppressed) so it doesn't appear in the user-facing surface; still
|
|
263
300
|
# invokable as `sourceindex log` for debugging.
|
|
264
|
-
p_log = sub.add_parser("log", parents=[log_parent])
|
|
301
|
+
p_log = sub.add_parser("log", parents=[log_parent], help=argparse.SUPPRESS)
|
|
265
302
|
p_log.add_argument("--repo-root", default=".")
|
|
266
303
|
p_log.add_argument("--log-path", default=None,
|
|
267
304
|
help="Path to JSONL log (default: <repo>/.sourceindex/sourceindex.log)")
|
|
@@ -269,6 +306,7 @@ def main() -> None:
|
|
|
269
306
|
help="Show only the last N records.")
|
|
270
307
|
p_log.add_argument("--filter", default=None,
|
|
271
308
|
help="Substring to match against the event name (e.g. 'llm', 'search').")
|
|
309
|
+
_hide_subcommand_from_help("log")
|
|
272
310
|
|
|
273
311
|
args = parser.parse_args()
|
|
274
312
|
|
|
@@ -282,14 +320,24 @@ def main() -> None:
|
|
|
282
320
|
args.repo_root = str(discover_repo_root(Path(args.repo_root)))
|
|
283
321
|
|
|
284
322
|
repo_root_arg = getattr(args, "repo_root", None)
|
|
285
|
-
|
|
323
|
+
replacing_key = (
|
|
324
|
+
getattr(args, "command", None) == "api-key"
|
|
325
|
+
and getattr(args, "api_key_command", None) == "set"
|
|
326
|
+
)
|
|
327
|
+
local_key_set = replacing_key and bool(getattr(args, "local_key", False))
|
|
328
|
+
if repo_root_arg is not None and not replacing_key:
|
|
286
329
|
_load_persisted_env(Path(repo_root_arg).resolve())
|
|
287
330
|
|
|
288
331
|
# Configure logging after _load_persisted_env so SOURCEINDEX_LOG_* picked
|
|
289
332
|
# up from .sourceindex/.env take effect.
|
|
333
|
+
log_repo_root = (
|
|
334
|
+
None
|
|
335
|
+
if (replacing_key and not local_key_set) or repo_root_arg is None
|
|
336
|
+
else Path(repo_root_arg).resolve()
|
|
337
|
+
)
|
|
290
338
|
configure(
|
|
291
339
|
level=_resolve_log_level(args),
|
|
292
|
-
repo_root=
|
|
340
|
+
repo_root=log_repo_root,
|
|
293
341
|
log_file=getattr(args, "log_file", None),
|
|
294
342
|
enable_file=not getattr(args, "no_log_file", False),
|
|
295
343
|
enable_upload=getattr(args, "command", None) in _UPLOAD_COMMANDS,
|
|
@@ -298,7 +346,7 @@ def main() -> None:
|
|
|
298
346
|
# `log` is intentionally excluded — it's a read-only viewer over the
|
|
299
347
|
# log file, and recording each invocation pollutes the file it's
|
|
300
348
|
# about to print.
|
|
301
|
-
if args.command in ("init", "update", "
|
|
349
|
+
if args.command in ("init", "update", "search", "install-hooks", "dump", "daemon", "deinit", "api-key"):
|
|
302
350
|
get_logger(__name__).debug(
|
|
303
351
|
"CLI invocation",
|
|
304
352
|
extra={
|
|
@@ -315,8 +363,6 @@ def main() -> None:
|
|
|
315
363
|
cmd_update(args)
|
|
316
364
|
elif args.command == "install-hooks":
|
|
317
365
|
cmd_install_hooks(args)
|
|
318
|
-
elif args.command == "serve":
|
|
319
|
-
cmd_serve(args)
|
|
320
366
|
elif args.command == "search":
|
|
321
367
|
cmd_search(args)
|
|
322
368
|
elif args.command == "report-savings":
|
|
@@ -329,6 +375,12 @@ def main() -> None:
|
|
|
329
375
|
cmd_log(args)
|
|
330
376
|
elif args.command == "usage":
|
|
331
377
|
cmd_usage(args)
|
|
378
|
+
elif args.command == "api-key":
|
|
379
|
+
if getattr(args, "api_key_command", None) == "set":
|
|
380
|
+
cmd_api_key_set(args)
|
|
381
|
+
else:
|
|
382
|
+
p_api_key.print_help()
|
|
383
|
+
sys.exit(1)
|
|
332
384
|
elif args.command == "dump":
|
|
333
385
|
cmd_dump(args)
|
|
334
386
|
elif args.command == "daemon":
|
|
@@ -4,8 +4,9 @@ Three concerns share this module:
|
|
|
4
4
|
|
|
5
5
|
- Look up the API key (CLI arg → env var → persisted file).
|
|
6
6
|
- Prompt the user for one on a TTY when ``init`` runs without any source.
|
|
7
|
-
- Persist the key into ``<repo>/.sourceindex/.env``
|
|
8
|
-
|
|
7
|
+
- Persist the key into ``<repo>/.sourceindex/.env`` or the user-level
|
|
8
|
+
sourceindex config so the post-commit hook and search subagent pick it up
|
|
9
|
+
without an env var.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
import getpass
|
|
@@ -20,6 +21,7 @@ _log = get_logger(__name__)
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
PERSISTED_KEY_FILENAME = ".env"
|
|
24
|
+
_GLOBAL_API_KEY_MARKER = "_SOURCEINDEX_API_KEY_FROM_GLOBAL"
|
|
23
25
|
|
|
24
26
|
# Loose shape check: alphanumerics + `_` / `-`, at least 20 chars, no
|
|
25
27
|
# whitespace or other punctuation. Designed to reject obvious paste
|
|
@@ -81,8 +83,8 @@ def _require_api_key(cli_arg: str | None, *, daemon_managed: bool = False) -> st
|
|
|
81
83
|
|
|
82
84
|
def _prompt_api_key(cli_arg: str | None) -> str:
|
|
83
85
|
"""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
|
-
|
|
86
|
+
Used only by `init` — non-interactive callers (post-commit hook, subagent)
|
|
87
|
+
should keep using _require_api_key so they fail loudly."""
|
|
86
88
|
from ..lib.llm import resolve_api_key
|
|
87
89
|
key = resolve_api_key(cli_arg)
|
|
88
90
|
if not key:
|
|
@@ -103,6 +105,40 @@ def _prompt_api_key(cli_arg: str | None) -> str:
|
|
|
103
105
|
return key
|
|
104
106
|
|
|
105
107
|
|
|
108
|
+
def _prompt_replacement_api_key(cli_arg: str | None, *, from_env: bool = False) -> str:
|
|
109
|
+
"""Read a key for `sourceindex api-key set`.
|
|
110
|
+
|
|
111
|
+
Unlike init, the default interactive path prompts even if an old key was
|
|
112
|
+
loaded from .sourceindex/.env. Use --from-env when the current process env
|
|
113
|
+
is intentionally the new value.
|
|
114
|
+
"""
|
|
115
|
+
key = (cli_arg or "").strip()
|
|
116
|
+
if not key and from_env:
|
|
117
|
+
key = os.environ.get("SOURCEINDEX_API_KEY", "").strip()
|
|
118
|
+
if not key:
|
|
119
|
+
raise SystemExit(
|
|
120
|
+
"[sourceindex] SOURCEINDEX_API_KEY is not set; pass --api-key "
|
|
121
|
+
"or run without --from-env to paste the key."
|
|
122
|
+
)
|
|
123
|
+
if not key and not from_env:
|
|
124
|
+
if not sys.stdin.isatty():
|
|
125
|
+
raise SystemExit(
|
|
126
|
+
"No API key provided. Pass --api-key, set SOURCEINDEX_API_KEY "
|
|
127
|
+
"and use --from-env, or run on a TTY to paste it."
|
|
128
|
+
)
|
|
129
|
+
_log.info("Paste your new sourceindex API key (input hidden):")
|
|
130
|
+
key = getpass.getpass("API key: ").strip()
|
|
131
|
+
if not key:
|
|
132
|
+
raise SystemExit("[sourceindex] Empty API key - aborting.")
|
|
133
|
+
if not _is_plausible_api_key(key):
|
|
134
|
+
raise SystemExit(
|
|
135
|
+
f"[sourceindex] That doesn't look like an API key ({len(key)} chars, "
|
|
136
|
+
"expected >=20 alphanumeric/_-). Aborting before persisting junk."
|
|
137
|
+
)
|
|
138
|
+
_warn_if_provider_key(key)
|
|
139
|
+
return key
|
|
140
|
+
|
|
141
|
+
|
|
106
142
|
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
107
143
|
from ..lib.env import parse_env_text
|
|
108
144
|
try:
|
|
@@ -111,6 +147,28 @@ def _parse_env_file(path: Path) -> dict[str, str]:
|
|
|
111
147
|
return {}
|
|
112
148
|
|
|
113
149
|
|
|
150
|
+
def _user_config_dir() -> Path:
|
|
151
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
152
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
153
|
+
return base / "sourceindex"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _global_env_path() -> Path:
|
|
157
|
+
return _user_config_dir() / PERSISTED_KEY_FILENAME
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _persist_env_path_var(env_path: Path, key: str, value: str) -> None:
|
|
161
|
+
existing = _parse_env_file(env_path)
|
|
162
|
+
existing[key] = value
|
|
163
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
try:
|
|
165
|
+
env_path.parent.chmod(0o700)
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
env_path.write_text("".join(f"{k}={v}\n" for k, v in existing.items()))
|
|
169
|
+
env_path.chmod(0o600)
|
|
170
|
+
|
|
171
|
+
|
|
114
172
|
def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
|
|
115
173
|
"""Merge ``key=value`` into ``<index_dir>/.env`` without clobbering other keys.
|
|
116
174
|
|
|
@@ -122,12 +180,7 @@ def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
|
|
|
122
180
|
f"{index_dir} is an encrypted store; persist .env values via the "
|
|
123
181
|
"daemon RPC instead of writing the file directly."
|
|
124
182
|
)
|
|
125
|
-
index_dir
|
|
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)
|
|
183
|
+
_persist_env_path_var(index_dir / PERSISTED_KEY_FILENAME, key, value)
|
|
131
184
|
|
|
132
185
|
|
|
133
186
|
def _persist_api_key(index_dir: Path, key: str) -> None:
|
|
@@ -148,11 +201,26 @@ def _persist_api_key(index_dir: Path, key: str) -> None:
|
|
|
148
201
|
return
|
|
149
202
|
_persist_env_var(index_dir, "SOURCEINDEX_API_KEY", key)
|
|
150
203
|
_log.info(
|
|
151
|
-
|
|
204
|
+
"Updated API key.",
|
|
152
205
|
extra={"event": "api_key.persisted", "path": str(index_dir / PERSISTED_KEY_FILENAME)},
|
|
153
206
|
)
|
|
154
207
|
|
|
155
208
|
|
|
209
|
+
def _persist_global_api_key(key: str) -> None:
|
|
210
|
+
if not _is_plausible_api_key(key):
|
|
211
|
+
_log.warning(
|
|
212
|
+
f"Not persisting global API key ({len(key)} chars) — it doesn't "
|
|
213
|
+
"match the plausible shape (>=20 alphanumeric/_-, no whitespace).",
|
|
214
|
+
extra={"event": "api_key.global_persist_skipped_shape"},
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
_persist_env_path_var(_global_env_path(), "SOURCEINDEX_API_KEY", key)
|
|
218
|
+
_log.info(
|
|
219
|
+
"Updated global API key.",
|
|
220
|
+
extra={"event": "api_key.global_persisted", "path": str(_global_env_path())},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
156
224
|
def _persist_backend_url(index_dir: Path, url: str) -> None:
|
|
157
225
|
"""Persist a non-default ``SOURCEINDEX_BACKEND_URL`` next to the API key.
|
|
158
226
|
Only needed when overriding the default backend baked into
|
|
@@ -169,13 +237,36 @@ def _persist_backend_url(index_dir: Path, url: str) -> None:
|
|
|
169
237
|
)
|
|
170
238
|
|
|
171
239
|
|
|
240
|
+
def _load_env_path(path: Path, *, source: str | None = None) -> None:
|
|
241
|
+
for k, v in _parse_env_file(path).items():
|
|
242
|
+
if k and k not in os.environ:
|
|
243
|
+
os.environ[k] = v
|
|
244
|
+
if k == "SOURCEINDEX_API_KEY":
|
|
245
|
+
if source == "global":
|
|
246
|
+
os.environ[_GLOBAL_API_KEY_MARKER] = "1"
|
|
247
|
+
else:
|
|
248
|
+
os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_global_env() -> None:
|
|
252
|
+
_load_env_path(_global_env_path(), source="global")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _api_key_from_global_fallback(cli_arg: str | None) -> bool:
|
|
256
|
+
return not cli_arg and os.environ.get(_GLOBAL_API_KEY_MARKER) == "1"
|
|
257
|
+
|
|
258
|
+
|
|
172
259
|
def _load_persisted_env(repo_root: Path) -> None:
|
|
173
260
|
"""Merge plaintext .sourceindex/.env into os.environ (pre-existing vars
|
|
174
|
-
win, standard dotenv semantics)
|
|
175
|
-
daemon itself at startup — client
|
|
261
|
+
win, standard dotenv semantics), then fall back to the user-level config.
|
|
262
|
+
Encrypted .env is loaded by the daemon/server itself at startup — client
|
|
263
|
+
processes can't read it without risking global keys overriding local ones."""
|
|
176
264
|
from ..daemon import IndexDir
|
|
177
|
-
|
|
265
|
+
os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
|
|
266
|
+
idx = IndexDir.for_repo(repo_root)
|
|
267
|
+
if idx.is_encrypted_layout():
|
|
268
|
+
return
|
|
269
|
+
env_path = idx.path / PERSISTED_KEY_FILENAME
|
|
178
270
|
if env_path.is_file():
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
os.environ[k] = v
|
|
271
|
+
_load_env_path(env_path)
|
|
272
|
+
_load_global_env()
|
|
@@ -13,19 +13,22 @@ from datetime import datetime, timezone
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from ..lib.log import get_logger
|
|
16
|
-
from .api_key import
|
|
16
|
+
from .api_key import (
|
|
17
|
+
_api_key_from_global_fallback,
|
|
18
|
+
_persist_api_key,
|
|
19
|
+
_persist_global_api_key,
|
|
20
|
+
_prompt_api_key,
|
|
21
|
+
_prompt_replacement_api_key,
|
|
22
|
+
_require_api_key,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
_log = get_logger(__name__)
|
|
19
26
|
from .install import (
|
|
20
27
|
AGENT_INSTALLERS,
|
|
21
|
-
CLAUDE_MD_MARKER_MCP,
|
|
22
|
-
CLAUDE_MD_SECTION_MCP,
|
|
23
28
|
KNOWN_AGENTS,
|
|
24
|
-
_append_claude_md,
|
|
25
29
|
_detect_agents,
|
|
26
30
|
_ensure_gitignored,
|
|
27
31
|
_install_hooks,
|
|
28
|
-
_write_mcp_config,
|
|
29
32
|
)
|
|
30
33
|
|
|
31
34
|
|
|
@@ -104,11 +107,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
104
107
|
f"(choose from {', '.join(sorted(KNOWN_AGENTS))})"
|
|
105
108
|
)
|
|
106
109
|
|
|
110
|
+
api_key_is_global_fallback = _api_key_from_global_fallback(args.api_key)
|
|
107
111
|
api_key = _prompt_api_key(args.api_key)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
_persist_api_key(index_dir, api_key)
|
|
112
|
+
os.environ["SOURCEINDEX_API_KEY"] = api_key
|
|
113
|
+
if not api_key_is_global_fallback:
|
|
114
|
+
_persist_global_api_key(api_key)
|
|
112
115
|
_ensure_gitignored(repo_root)
|
|
113
116
|
|
|
114
117
|
model = apply_remote_config(index_dir=index_dir, default_model=args.model).build
|
|
@@ -121,32 +124,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
121
124
|
from ..lib import registry
|
|
122
125
|
registry.add(idx.repo_hash, str(repo_root))
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
_log.info(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
_log.info(
|
|
140
|
-
"No agent config detected in the repo; defaulting to claude. "
|
|
141
|
-
"Run `sourceindex init opencode` (or `claude opencode`) to choose."
|
|
142
|
-
)
|
|
143
|
-
elif not requested:
|
|
144
|
-
_log.info(f"Detected agent platforms: {', '.join(agents)}")
|
|
145
|
-
for name, installer in AGENT_INSTALLERS.items():
|
|
146
|
-
if name in agents:
|
|
147
|
-
installer(repo_root)
|
|
148
|
-
else:
|
|
149
|
-
raise ValueError(f"Unknown init mode: {mode!r} (expected 'mcp' or 'agent')")
|
|
127
|
+
# Positional selection wins; otherwise sniff the repo's own markers;
|
|
128
|
+
# a bare repo with no markers gets the claude set. Install order is
|
|
129
|
+
# owned by AGENT_INSTALLERS (see its comment in install.py).
|
|
130
|
+
agents = requested or _detect_agents(repo_root)
|
|
131
|
+
if not agents:
|
|
132
|
+
agents = ["claude"]
|
|
133
|
+
_log.info(
|
|
134
|
+
"No agent config detected in the repo; defaulting to claude. "
|
|
135
|
+
"Run `sourceindex init opencode` (or `claude opencode`) to choose."
|
|
136
|
+
)
|
|
137
|
+
elif not requested:
|
|
138
|
+
_log.info(f"Detected agent platforms: {', '.join(agents)}")
|
|
139
|
+
for name, installer in AGENT_INSTALLERS.items():
|
|
140
|
+
if name in agents:
|
|
141
|
+
installer(repo_root)
|
|
150
142
|
|
|
151
143
|
if idx.is_encrypted_layout() or idx.has_plaintext_index():
|
|
152
144
|
_log.info(
|
|
@@ -216,17 +208,40 @@ def cmd_update(args: argparse.Namespace) -> None:
|
|
|
216
208
|
)
|
|
217
209
|
|
|
218
210
|
|
|
219
|
-
def
|
|
220
|
-
from ..daemon import IndexDir
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
211
|
+
def cmd_api_key_set(args: argparse.Namespace) -> None:
|
|
212
|
+
from ..daemon import Client, IndexDir
|
|
213
|
+
|
|
214
|
+
positional_key = getattr(args, "key", None)
|
|
215
|
+
option_key = getattr(args, "api_key", None)
|
|
216
|
+
from_env = bool(getattr(args, "from_env", False))
|
|
217
|
+
local_key = bool(getattr(args, "local_key", False))
|
|
218
|
+
if positional_key and option_key:
|
|
219
|
+
raise SystemExit("[sourceindex] Pass the new key either positionally or via --api-key, not both.")
|
|
220
|
+
if positional_key and from_env:
|
|
221
|
+
raise SystemExit("[sourceindex] Pass the new key either positionally or via --from-env, not both.")
|
|
222
|
+
api_key = _prompt_replacement_api_key(
|
|
223
|
+
option_key or positional_key,
|
|
224
|
+
from_env=from_env,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if not local_key:
|
|
228
|
+
_persist_global_api_key(api_key)
|
|
229
|
+
return
|
|
224
230
|
|
|
225
231
|
repo_root = Path(args.repo_root).resolve()
|
|
226
|
-
|
|
232
|
+
idx = IndexDir.for_repo(repo_root)
|
|
233
|
+
if idx.is_encrypted_layout():
|
|
234
|
+
Client(repo_root).set_api_key(api_key)
|
|
235
|
+
_log.info(
|
|
236
|
+
"Updated API key.",
|
|
237
|
+
extra={
|
|
238
|
+
"event": "api_key.updated",
|
|
239
|
+
"encrypted": True,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
return
|
|
227
243
|
|
|
228
|
-
|
|
229
|
-
run_server(repo_root, index_dir, index_search_model=model)
|
|
244
|
+
_persist_api_key(idx.path, api_key)
|
|
230
245
|
|
|
231
246
|
|
|
232
247
|
ROADMAP_FILENAME_PREFIX = "sourceindex-output"
|