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.
Files changed (48) hide show
  1. {sourceindex-0.1.1 → sourceindex-0.1.2}/GETTING_STARTED.md +22 -0
  2. {sourceindex-0.1.1 → sourceindex-0.1.2}/PKG-INFO +23 -3
  3. {sourceindex-0.1.1 → sourceindex-0.1.2}/pyproject.toml +1 -4
  4. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/__init__.py +1 -1
  5. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/__init__.py +81 -29
  6. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/api_key.py +108 -17
  7. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/commands.py +58 -43
  8. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/install.py +8 -59
  9. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/client.py +3 -0
  10. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/protocol.py +1 -0
  11. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/server.py +163 -3
  12. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/llm.py +17 -3
  13. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/__init__.py +1 -1
  14. sourceindex-0.1.1/sourceindex/server.py +0 -127
  15. {sourceindex-0.1.1 → sourceindex-0.1.2}/.gitignore +0 -0
  16. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/__init__.py +0 -0
  17. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/indexer.py +0 -0
  18. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/__init__.py +0 -0
  19. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/python_ast.py +0 -0
  20. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/linerange/treesitter.py +0 -0
  21. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/prompts.py +0 -0
  22. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/state.py +0 -0
  23. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/build/walker.py +0 -0
  24. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/__init__.py +0 -0
  25. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/savings.py +0 -0
  26. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/savings_summary.py +0 -0
  27. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/claudecode/statusline.py +0 -0
  28. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/cli/__main__.py +0 -0
  29. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/__init__.py +0 -0
  30. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/crypto.py +0 -0
  31. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/keyring_store.py +0 -0
  32. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/lifecycle.py +0 -0
  33. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/daemon/store.py +0 -0
  34. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/__init__.py +0 -0
  35. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/backend.py +0 -0
  36. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/cost.py +0 -0
  37. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/env.py +0 -0
  38. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/errors.py +0 -0
  39. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/git.py +0 -0
  40. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/languages.py +0 -0
  41. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/log.py +0 -0
  42. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/registry.py +0 -0
  43. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/lib/timing.py +0 -0
  44. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/experiments.py +0 -0
  45. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/imports.py +0 -0
  46. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/passes.py +0 -0
  47. {sourceindex-0.1.1 → sourceindex-0.1.2}/sourceindex/search/prompts.py +0 -0
  48. {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.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.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
 
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.1.2"
2
2
 
3
3
  # Surfaced at the top level so the CLI's argparse setup can use it for
4
4
  # --workers help/default without importing the build pipeline (which
@@ -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 serve [--repo-root PATH]
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", "serve", "search", "deinit", "daemon"})
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
- sub = parser.add_subparsers(dest="command")
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="Start the daemon with its admin socket bound, enabling "
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="See `sourceindex init --allow-dump`.",
164
+ help=argparse.SUPPRESS,
165
165
  )
166
166
 
167
167
  p_dump = sub.add_parser(
168
168
  "dump",
169
- help="Admin-only: decrypt the index into a plaintext mirror at --out.",
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="Bind the admin socket so `sourceindex dump` and other admin RPCs work.",
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
- if repo_root_arg is not None:
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=Path(repo_root_arg).resolve() if repo_root_arg is not None else None,
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", "serve", "search", "install-hooks", "dump", "daemon", "deinit"):
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`` so the post-commit hook,
8
- search subagent, and MCP server pick it up without an env var.
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
- MCP server) should keep using _require_api_key so they fail loudly."""
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.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)
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
- f"Persisted API key to {index_dir / PERSISTED_KEY_FILENAME}",
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). Encrypted .env is loaded by the
175
- daemon itself at startup — client processes can't read it."""
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
- env_path = IndexDir.for_repo(repo_root).path / PERSISTED_KEY_FILENAME
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
- for k, v in _parse_env_file(env_path).items():
180
- if k and k not in os.environ:
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 _persist_api_key, _prompt_api_key, _require_api_key
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
- # First-run write of plaintext .env; the daemon migrates it on its
109
- # next start. Writing into an encrypted store would corrupt the blob.
110
- if not idx.is_encrypted_layout():
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
- if mode == "mcp":
125
- non_claude = sorted(set(requested) - {"claude"})
126
- if non_claude:
127
- _log.warning("--mode mcp is Claude-only; skipping %s", ", ".join(non_claude))
128
- _log.info("Writing MCP config")
129
- _write_mcp_config(repo_root)
130
- _log.info("Updating CLAUDE.md with workflow instructions...")
131
- _append_claude_md(repo_root, CLAUDE_MD_SECTION_MCP, CLAUDE_MD_MARKER_MCP)
132
- elif mode == "agent":
133
- # Positional selection wins; otherwise sniff the repo's own markers;
134
- # a bare repo with no markers gets the claude set. Install order is
135
- # owned by AGENT_INSTALLERS (see its comment in install.py).
136
- agents = requested or _detect_agents(repo_root)
137
- if not agents:
138
- agents = ["claude"]
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 cmd_serve(args: argparse.Namespace) -> None:
220
- from ..daemon import IndexDir
221
- from ..lib.backend import apply_remote_config
222
- from ..search import DEFAULT_SEARCH_MODEL
223
- from ..server import run_server
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
- index_dir = IndexDir.for_repo(repo_root, args.index_dir).path
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
- model = apply_remote_config(index_dir=index_dir, default_model=DEFAULT_SEARCH_MODEL).search
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"