SourceIndex 0.1.2__tar.gz → 0.1.3__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 (49) hide show
  1. sourceindex-0.1.3/LICENSE +115 -0
  2. {sourceindex-0.1.2 → sourceindex-0.1.3}/PKG-INFO +8 -1
  3. {sourceindex-0.1.2 → sourceindex-0.1.3}/pyproject.toml +17 -1
  4. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/__init__.py +1 -1
  5. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/api_key.py +87 -22
  6. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/commands.py +47 -0
  7. sourceindex-0.1.3/sourceindex/cli/upgrade.py +143 -0
  8. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/client.py +46 -2
  9. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/server.py +68 -39
  10. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/env.py +6 -0
  11. {sourceindex-0.1.2 → sourceindex-0.1.3}/.gitignore +0 -0
  12. {sourceindex-0.1.2 → sourceindex-0.1.3}/GETTING_STARTED.md +0 -0
  13. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/__init__.py +0 -0
  14. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/indexer.py +0 -0
  15. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/__init__.py +0 -0
  16. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/python_ast.py +0 -0
  17. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/treesitter.py +0 -0
  18. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/prompts.py +0 -0
  19. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/state.py +0 -0
  20. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/walker.py +0 -0
  21. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/__init__.py +0 -0
  22. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/savings.py +0 -0
  23. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/savings_summary.py +0 -0
  24. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/statusline.py +0 -0
  25. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/__init__.py +0 -0
  26. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/__main__.py +0 -0
  27. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/install.py +0 -0
  28. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/__init__.py +0 -0
  29. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/crypto.py +0 -0
  30. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/keyring_store.py +0 -0
  31. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/lifecycle.py +0 -0
  32. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/protocol.py +0 -0
  33. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/store.py +0 -0
  34. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/__init__.py +0 -0
  35. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/backend.py +0 -0
  36. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/cost.py +0 -0
  37. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/errors.py +0 -0
  38. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/git.py +0 -0
  39. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/languages.py +0 -0
  40. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/llm.py +0 -0
  41. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/log.py +0 -0
  42. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/registry.py +0 -0
  43. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/timing.py +0 -0
  44. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/__init__.py +0 -0
  45. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/experiments.py +0 -0
  46. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/imports.py +0 -0
  47. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/passes.py +0 -0
  48. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/prompts.py +0 -0
  49. {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/roadmap.py +0 -0
@@ -0,0 +1,115 @@
1
+ SourceIndex — Software License Agreement
2
+
3
+ Copyright (c) 2026 MANTEON PTE. LTD. (Singapore). All rights reserved.
4
+
5
+ SourceIndex is commercial software. It is licensed, not sold. By installing,
6
+ copying, or using it you agree to this Agreement. If you do not agree, do not
7
+ install or use it.
8
+
9
+ 1. DEFINITIONS
10
+
11
+ "Licensor" means MANTEON PTE. LTD., a company incorporated in the Republic
12
+ of Singapore. "Software" means the SourceIndex program distributed by the
13
+ Licensor, in source or object form, together with its documentation.
14
+ "You" means the individual or entity exercising rights under this
15
+ Agreement.
16
+
17
+ 2. GRANT OF LICENSE
18
+
19
+ Subject to Your compliance with this Agreement, the Licensor grants You a
20
+ limited, non-exclusive, non-transferable, non-sublicensable, revocable
21
+ licence to install and use unmodified copies of the Software for Your own
22
+ internal purposes.
23
+
24
+ No other rights are granted. All rights not expressly granted are reserved
25
+ by the Licensor.
26
+
27
+ 3. RESTRICTIONS
28
+
29
+ Except to the extent that this restriction is prohibited by applicable law,
30
+ or expressly permitted by the Licensor in writing, You may not:
31
+
32
+ (a) distribute, publish, sublicense, sell, rent, lease, lend, or otherwise
33
+ make the Software available to any third party;
34
+ (b) modify the Software or create derivative works from it;
35
+ (c) reverse engineer, decompile, or disassemble the Software, or otherwise
36
+ attempt to derive its source code, methods, or prompts, save to the
37
+ extent applicable law confers a non-excludable right to do so for
38
+ interoperability purposes;
39
+ (d) use the Software, or any information derived from it, to develop,
40
+ train, or improve a product or service that competes with it;
41
+ (e) remove, obscure, or alter any copyright, trademark, or other
42
+ proprietary notice; or
43
+ (f) circumvent or disable any licensing, metering, or security mechanism.
44
+
45
+ The Software is distributed in a human-readable form because it is written
46
+ in an interpreted language. That fact does not grant any right beyond
47
+ Clause 2, and does not make the Software open source.
48
+
49
+ 4. OWNERSHIP
50
+
51
+ The Software is protected by copyright and other intellectual property
52
+ laws. The Licensor and its licensors retain all right, title, and interest
53
+ in and to the Software, including all prompts, templates, indexes, models
54
+ of operation, and other content contained in it.
55
+
56
+ 5. THIRD-PARTY COMPONENTS
57
+
58
+ The Software depends on third-party components distributed under their own
59
+ licences. Those components are licensed to You by their respective owners
60
+ under those licences, not under this Agreement, and nothing here limits
61
+ Your rights under them.
62
+
63
+ 6. HOSTED SERVICES
64
+
65
+ The Software may communicate with services operated by the Licensor. Use of
66
+ those services is governed by separate terms and may require a valid
67
+ subscription or API credential. The Licensor may modify, suspend, or
68
+ discontinue those services at any time.
69
+
70
+ 7. NO WARRANTY
71
+
72
+ THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF
73
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED
74
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
75
+ SATISFACTORY QUALITY, AND NON-INFRINGEMENT. THE LICENSOR DOES NOT WARRANT
76
+ THAT THE SOFTWARE WILL BE UNINTERRUPTED, ERROR-FREE, OR THAT ITS OUTPUT
77
+ WILL BE ACCURATE OR COMPLETE.
78
+
79
+ 8. LIMITATION OF LIABILITY
80
+
81
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE LICENSOR SHALL NOT BE LIABLE
82
+ FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY
83
+ DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, DATA, OR GOODWILL, ARISING
84
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE, EVEN IF ADVISED OF THE
85
+ POSSIBILITY OF SUCH DAMAGES.
86
+
87
+ THE LICENSOR'S TOTAL AGGREGATE LIABILITY ARISING OUT OF OR IN CONNECTION
88
+ WITH THIS AGREEMENT SHALL NOT EXCEED THE GREATER OF (a) THE AMOUNTS PAID BY
89
+ YOU TO THE LICENSOR FOR THE SOFTWARE IN THE TWELVE MONTHS PRECEDING THE
90
+ CLAIM, OR (b) ONE HUNDRED SINGAPORE DOLLARS (SGD 100).
91
+
92
+ Nothing in this Agreement excludes or limits liability that cannot lawfully
93
+ be excluded or limited, including liability for death or personal injury
94
+ caused by negligence, or for fraud or fraudulent misrepresentation.
95
+
96
+ 9. TERMINATION
97
+
98
+ This licence terminates automatically if You breach any term of it. On
99
+ termination You must cease all use of the Software and destroy all copies
100
+ in Your possession. Clauses 4, 7, 8, and 10 survive termination.
101
+
102
+ 10. GOVERNING LAW AND JURISDICTION
103
+
104
+ This Agreement is governed by the laws of the Republic of Singapore,
105
+ without regard to its conflict of laws rules. The courts of Singapore
106
+ have exclusive jurisdiction over any dispute arising out of or in
107
+ connection with it.
108
+
109
+ 11. ENTIRE AGREEMENT
110
+
111
+ This Agreement is the entire agreement between You and the Licensor
112
+ regarding the Software, and supersedes any prior understanding. If any
113
+ provision is held unenforceable, the remainder stays in effect.
114
+
115
+ For licensing enquiries, contact MANTEON PTE. LTD.
@@ -1,7 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SourceIndex
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Codebase index for agentic coding
5
+ Author: MANTEON PTE. LTD.
6
+ License-Expression: LicenseRef-Proprietary
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Libraries
5
12
  Requires-Python: >=3.10
6
13
  Requires-Dist: cryptography>=42
7
14
  Requires-Dist: keyring>=24
@@ -4,10 +4,25 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "SourceIndex"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Codebase index for agentic coding"
9
9
  readme = "GETTING_STARTED.md"
10
10
  requires-python = ">=3.10"
11
+ authors = [{ name = "MANTEON PTE. LTD." }]
12
+ # Proprietary: the published wheel ships readable .py because Python is
13
+ # interpreted, not because the source is licensed for reuse. LicenseRef-* is
14
+ # how PEP 639 spells a licence with no SPDX identifier.
15
+ license = "LicenseRef-Proprietary"
16
+ license-files = ["LICENSE"]
17
+ # No "License ::" classifier: PEP 639 deprecates them, and pairing one with
18
+ # License-Expression is the case where tools are permitted to reject the
19
+ # upload. The expression above is the authoritative declaration.
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Developers",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
11
26
  dependencies = [
12
27
  "litellm>=1.50",
13
28
  "tree-sitter>=0.23",
@@ -46,4 +61,5 @@ include = [
46
61
  "sourceindex/",
47
62
  "pyproject.toml",
48
63
  "GETTING_STARTED.md",
64
+ "LICENSE",
49
65
  ]
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.2"
1
+ __version__ = "0.1.3"
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
@@ -23,6 +23,39 @@ _log = get_logger(__name__)
23
23
  PERSISTED_KEY_FILENAME = ".env"
24
24
  _GLOBAL_API_KEY_MARKER = "_SOURCEINDEX_API_KEY_FROM_GLOBAL"
25
25
 
26
+ # Records the key the user's *shell* exported, as opposed to one the loaders
27
+ # below put into SOURCEINDEX_API_KEY themselves. The daemon is exec'd with our
28
+ # environment and cannot otherwise tell the two apart — and it must, because
29
+ # only a genuine export outranks the config files it re-reads per RPC. Pinning
30
+ # a file-sourced key at that tier is what used to make `api-key set` a no-op
31
+ # until the daemon restarted.
32
+ #
33
+ # Set at import, which `cli/__init__.py` performs before any loader runs, so
34
+ # the value is the shell's or nothing. `setdefault` preserves what an outer
35
+ # sourceindex process already established.
36
+ _SHELL_API_KEY_VAR = "_SOURCEINDEX_SHELL_API_KEY"
37
+
38
+
39
+ def _capture_shell_api_key() -> None:
40
+ """Snapshot SOURCEINDEX_API_KEY as the shell's, once per process tree.
41
+
42
+ Writes the variable even when there is no key, because the empty value is
43
+ what tells a child process not to snapshot again. The daemon is exec'd by
44
+ a CLI that has already loaded a key out of a config file into the shared
45
+ environment; without that sentinel the daemon's own import would re-badge
46
+ that key as a shell export — the exact bug this indirection exists to fix.
47
+ """
48
+ os.environ.setdefault(_SHELL_API_KEY_VAR, os.environ.get("SOURCEINDEX_API_KEY", ""))
49
+
50
+
51
+ _capture_shell_api_key()
52
+
53
+
54
+ def shell_api_key() -> str | None:
55
+ """The key exported into this process tree's environment by the user's
56
+ shell, or None if SOURCEINDEX_API_KEY was only ever loaded from a file."""
57
+ return os.environ.get(_SHELL_API_KEY_VAR) or None
58
+
26
59
  # Loose shape check: alphanumerics + `_` / `-`, at least 20 chars, no
27
60
  # whitespace or other punctuation. Designed to reject obvious paste
28
61
  # mistakes (prompt strings, error messages, paths, URLs) without
@@ -58,27 +91,30 @@ def _warn_if_provider_key(key: str) -> None:
58
91
  def _require_api_key(cli_arg: str | None, *, daemon_managed: bool = False) -> str | None:
59
92
  """Resolve the API key from --api-key/env, asserting it exists.
60
93
 
61
- ``daemon_managed=True`` tolerates a missing key on encrypted-store
62
- repos: the daemon loaded ``SOURCEINDEX_API_KEY`` from its own encrypted
63
- .env at startup (see ``daemon/server.py:_load_env_from_store``), so RPC
64
- handlers fall back to env when the client passes no key. Necessary for
65
- hook-triggered subprocesses (post-commit ``sourceindex update``, the
66
- Claude Code subagent's ``sourceindex search``) that spawn fresh and
67
- don't inherit the user's shell env after init prompted for the key.
94
+ ``daemon_managed=True`` returns only an *explicit* key — ``--api-key`` or
95
+ a genuine shell export — and ``None`` otherwise, deferring to the daemon's
96
+ own per-RPC resolution (``daemon/server.py:_resolve_api_key``). Forwarding
97
+ a file-sourced key here would pin it for the call and defeat that, which is
98
+ how a rotated key used to keep getting ignored. Returning ``None`` is also
99
+ what lets hook-triggered subprocesses (post-commit ``sourceindex update``,
100
+ the Claude Code subagent's ``sourceindex search``) work at all: they spawn
101
+ without the user's shell env and rely on the daemon to supply the key.
68
102
  """
69
103
  from ..lib.llm import resolve_api_key
104
+ if daemon_managed:
105
+ explicit = _explicit_api_key(cli_arg)
106
+ if explicit:
107
+ _warn_if_provider_key(explicit)
108
+ return explicit
70
109
  key = resolve_api_key(cli_arg)
71
110
  if key:
72
111
  _warn_if_provider_key(key)
73
112
  return key
74
- if daemon_managed:
75
- return None
76
- assert key, (
113
+ raise AssertionError(
77
114
  "No API key found. Pass --api-key or set SOURCEINDEX_API_KEY "
78
115
  "(a sourceindex bearer key from the hosted backend, or a direct "
79
116
  "provider key if SOURCEINDEX_BACKEND_URL=\"\" disables the backend)."
80
117
  )
81
- return None
82
118
 
83
119
 
84
120
  def _prompt_api_key(cli_arg: str | None) -> str:
@@ -157,18 +193,26 @@ def _global_env_path() -> Path:
157
193
  return _user_config_dir() / PERSISTED_KEY_FILENAME
158
194
 
159
195
 
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
196
+ def _write_env_path(env_path: Path, values: dict[str, str]) -> None:
197
+ """Write a ``.env``, creating its directory and clamping permissions.
198
+ Every writer goes through here so the hardening can't end up applying to
199
+ only some of them."""
200
+ from ..lib.env import format_env_text
163
201
  env_path.parent.mkdir(parents=True, exist_ok=True)
164
202
  try:
165
203
  env_path.parent.chmod(0o700)
166
204
  except OSError:
167
205
  pass
168
- env_path.write_text("".join(f"{k}={v}\n" for k, v in existing.items()))
206
+ env_path.write_text(format_env_text(values))
169
207
  env_path.chmod(0o600)
170
208
 
171
209
 
210
+ def _persist_env_path_var(env_path: Path, key: str, value: str) -> None:
211
+ existing = _parse_env_file(env_path)
212
+ existing[key] = value
213
+ _write_env_path(env_path, existing)
214
+
215
+
172
216
  def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
173
217
  """Merge ``key=value`` into ``<index_dir>/.env`` without clobbering other keys.
174
218
 
@@ -183,6 +227,16 @@ def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
183
227
  _persist_env_path_var(index_dir / PERSISTED_KEY_FILENAME, key, value)
184
228
 
185
229
 
230
+ def _clear_env_path_var(env_path: Path, key: str) -> bool:
231
+ """Remove one variable from a plaintext .env, leaving the rest intact.
232
+ Returns whether it was there."""
233
+ existing = _parse_env_file(env_path)
234
+ if existing.pop(key, None) is None:
235
+ return False
236
+ _write_env_path(env_path, existing)
237
+ return True
238
+
239
+
186
240
  def _persist_api_key(index_dir: Path, key: str) -> None:
187
241
  if not _is_plausible_api_key(key):
188
242
  # Skip (don't abort) an explicitly-provided key whose shape is unusual but
@@ -248,6 +302,14 @@ def _load_env_path(path: Path, *, source: str | None = None) -> None:
248
302
  os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
249
303
 
250
304
 
305
+ def _explicit_api_key(cli_arg: str | None) -> str | None:
306
+ """The key this invocation was *given*: ``--api-key``, else a genuine
307
+ shell export. A key merely loaded from a config file is not explicit —
308
+ the daemon reads those files itself and must stay free to re-resolve
309
+ them per RPC."""
310
+ return (cli_arg or "").strip() or shell_api_key()
311
+
312
+
251
313
  def _load_global_env() -> None:
252
314
  _load_env_path(_global_env_path(), source="global")
253
315
 
@@ -259,14 +321,17 @@ def _api_key_from_global_fallback(cli_arg: str | None) -> bool:
259
321
  def _load_persisted_env(repo_root: Path) -> None:
260
322
  """Merge plaintext .sourceindex/.env into os.environ (pre-existing vars
261
323
  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."""
324
+
325
+ An encrypted repo has no readable repo-level .env — the daemon owns it —
326
+ but the user-level config is still ours to read, and skipping it used to
327
+ hide the global key from every indexed repo. Loading it is safe now that
328
+ file-sourced keys are marked and never forwarded over RPC, so they can no
329
+ longer shadow the per-repo key the daemon holds."""
264
330
  from ..daemon import IndexDir
265
331
  os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
266
332
  idx = IndexDir.for_repo(repo_root)
267
- if idx.is_encrypted_layout():
268
- return
269
- env_path = idx.path / PERSISTED_KEY_FILENAME
270
- if env_path.is_file():
271
- _load_env_path(env_path)
333
+ if not idx.is_encrypted_layout():
334
+ env_path = idx.path / PERSISTED_KEY_FILENAME
335
+ if env_path.is_file():
336
+ _load_env_path(env_path)
272
337
  _load_global_env()
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from ..lib.log import get_logger
16
16
  from .api_key import (
17
+ PERSISTED_KEY_FILENAME,
17
18
  _api_key_from_global_fallback,
18
19
  _persist_api_key,
19
20
  _persist_global_api_key,
@@ -30,6 +31,7 @@ from .install import (
30
31
  _ensure_gitignored,
31
32
  _install_hooks,
32
33
  )
34
+ from .upgrade import schedule_auto_upgrade
33
35
 
34
36
 
35
37
  def _parse_languages(value: str | None):
@@ -109,6 +111,10 @@ def cmd_init(args: argparse.Namespace) -> None:
109
111
 
110
112
  api_key_is_global_fallback = _api_key_from_global_fallback(args.api_key)
111
113
  api_key = _prompt_api_key(args.api_key)
114
+ # apply_remote_config and the preflight below read the key from the
115
+ # environment, so it has to go there. The daemon we spawn inherits this,
116
+ # but only treats _SHELL_API_KEY_VAR as an export, so a prompted key
117
+ # cannot masquerade as one.
112
118
  os.environ["SOURCEINDEX_API_KEY"] = api_key
113
119
  if not api_key_is_global_fallback:
114
120
  _persist_global_api_key(api_key)
@@ -176,6 +182,8 @@ def cmd_update(args: argparse.Namespace) -> None:
176
182
  from ..lib.backend import apply_remote_config
177
183
  from ..lib.llm import llm_error_to_systemexit
178
184
 
185
+ schedule_auto_upgrade()
186
+
179
187
  repo_root = Path(args.repo_root).resolve()
180
188
  idx = IndexDir.for_repo(repo_root)
181
189
  index_dir = idx.path
@@ -208,6 +216,42 @@ def cmd_update(args: argparse.Namespace) -> None:
208
216
  )
209
217
 
210
218
 
219
+ def _clear_local_api_key_override(repo_root: Path) -> None:
220
+ """Drop any per-repo key for ``repo_root`` after a global set.
221
+
222
+ A per-repo key outranks the user-level one in both resolvers, so a global
223
+ `api-key set` run from an indexed repo would otherwise report success and
224
+ change nothing there. Encrypted stores can only be rewritten by the daemon;
225
+ plaintext ones we edit directly. A repo with no index has neither.
226
+ """
227
+ from ..daemon import Client, IndexDir
228
+ from .api_key import _clear_env_path_var
229
+
230
+ idx = IndexDir.for_repo(repo_root)
231
+ if idx.is_encrypted_layout():
232
+ try:
233
+ cleared = bool(Client(repo_root).set_api_key(scope="global").get("cleared_local"))
234
+ except Exception as e:
235
+ _log.warning(
236
+ f"Saved the global key, but could not reach this repository's "
237
+ f"daemon to clear a per-repo override ({e}). If searches keep "
238
+ f"failing with the old key, run `sourceindex api-key set --local`.",
239
+ extra={"event": "api_key.local_override_clear_failed"},
240
+ )
241
+ return
242
+ else:
243
+ cleared = _clear_env_path_var(
244
+ idx.path / PERSISTED_KEY_FILENAME, "SOURCEINDEX_API_KEY"
245
+ )
246
+ if cleared:
247
+ _log.info(
248
+ "Cleared this repository's per-repo API key so the global one "
249
+ "applies. Run `sourceindex api-key set --local` to pin a key to "
250
+ "this repository again.",
251
+ extra={"event": "api_key.local_override_cleared"},
252
+ )
253
+
254
+
211
255
  def cmd_api_key_set(args: argparse.Namespace) -> None:
212
256
  from ..daemon import Client, IndexDir
213
257
 
@@ -226,6 +270,7 @@ def cmd_api_key_set(args: argparse.Namespace) -> None:
226
270
 
227
271
  if not local_key:
228
272
  _persist_global_api_key(api_key)
273
+ _clear_local_api_key_override(Path(args.repo_root).resolve())
229
274
  return
230
275
 
231
276
  repo_root = Path(args.repo_root).resolve()
@@ -300,6 +345,8 @@ def cmd_search(args: argparse.Namespace) -> None:
300
345
  from ..lib.llm import llm_error_to_systemexit
301
346
  from ..search import search_index
302
347
 
348
+ schedule_auto_upgrade()
349
+
303
350
  repo_root = Path(args.repo_root).resolve()
304
351
  idx = IndexDir.for_repo(repo_root, args.index_dir)
305
352
  index_dir = idx.path
@@ -0,0 +1,143 @@
1
+ """Background self-upgrade for the installed sourceindex package.
2
+
3
+ ``sourceindex search`` and ``sourceindex update`` call
4
+ :func:`schedule_auto_upgrade` on every invocation, which defers the work to
5
+ interpreter exit; at most once per :data:`CHECK_INTERVAL_S` it then spawns
6
+ the package manager that owns this install (pip / pipx / ``uv tool``) fully
7
+ detached to pull the latest release. The finished invocation is unaffected —
8
+ every CLI run is a fresh interpreter, and a live daemon is recycled by the
9
+ version handshake in ``daemon/client.py`` the next time a newer client talks
10
+ to it.
11
+
12
+ Hard rules:
13
+
14
+ - Never write to stdout/stderr. Search's stdout is relayed verbatim by the
15
+ subagent, so any stray line would corrupt the roadmap completion signal.
16
+ The package manager's output goes to ``auto-upgrade.log`` in the user
17
+ config dir instead.
18
+ - Never raise, and never block: an upgrade problem must not break or slow
19
+ the search that triggered it.
20
+
21
+ ``SOURCEINDEX_AUTO_UPGRADE=0`` disables the whole mechanism (evals and CI
22
+ should set it). Installs that did not come from a package index — editable
23
+ checkouts, local wheels, VCS installs, anything with a ``direct_url.json``
24
+ — are never touched: PyPI is not their source, so an automatic upgrade
25
+ would silently switch sources.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import atexit
31
+ import fcntl
32
+ import importlib.metadata
33
+ import os
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import time
38
+
39
+ from ..lib.log import get_logger
40
+ from .api_key import _user_config_dir
41
+
42
+ _log = get_logger(__name__)
43
+
44
+ CHECK_INTERVAL_S = 3600.0
45
+ STAMP_FILENAME = "auto-upgrade.stamp"
46
+ LOG_FILENAME = "auto-upgrade.log"
47
+
48
+
49
+ def schedule_auto_upgrade() -> None:
50
+ """Arrange for the upgrade to be spawned once this command is finished.
51
+
52
+ Registered rather than spawned on the spot, because pip upgrades by
53
+ deleting the installed package and unpacking the new one — and this
54
+ process is still reading from it. Most of the package's imports are
55
+ deferred into function bodies, so a long command (a post-commit
56
+ ``update`` reindexing a large diff) has modules left to load minutes
57
+ after startup, and ``search`` execs a whole new interpreter from that
58
+ same install partway through. Overwriting it underneath either one risks
59
+ a half-old, half-new import or an exec of a momentarily incomplete
60
+ package. By interpreter exit there is nothing left to disturb.
61
+
62
+ Deferring also keeps pip off the CPU and network while the command is
63
+ doing its own work.
64
+
65
+ atexit runs on SystemExit too, so a command that failed still upgrades —
66
+ the newer version may be exactly the fix. It does not run on a signal or
67
+ ``os._exit``, which only costs a skipped cycle.
68
+ """
69
+ atexit.register(maybe_spawn_auto_upgrade)
70
+
71
+
72
+ def maybe_spawn_auto_upgrade() -> None:
73
+ """Spawn a detached best-effort upgrade, throttled to once per hour."""
74
+ try:
75
+ _maybe_spawn()
76
+ except Exception as e: # noqa: BLE001 — must never break the caller
77
+ _log.debug(
78
+ "auto-upgrade skipped", extra={"event": "upgrade.skipped", "error": str(e)}
79
+ )
80
+
81
+
82
+ def _disabled() -> bool:
83
+ return os.environ.get("SOURCEINDEX_AUTO_UPGRADE", "").strip().lower() in (
84
+ "0", "false", "no", "off",
85
+ )
86
+
87
+
88
+ def _upgrade_command() -> list[str] | None:
89
+ """Pick the upgrade command for this install, or ``None`` to leave it alone."""
90
+ try:
91
+ dist = importlib.metadata.distribution("sourceindex")
92
+ except importlib.metadata.PackageNotFoundError:
93
+ return None # running from a checkout that was never installed
94
+ if dist.read_text("direct_url.json"):
95
+ return None # editable / local / VCS install — not ours to upgrade
96
+ exe = sys.executable
97
+ if "/uv/tools/" in exe and shutil.which("uv"):
98
+ return ["uv", "tool", "upgrade", "sourceindex"]
99
+ if "/pipx/venvs/" in exe and shutil.which("pipx"):
100
+ return ["pipx", "upgrade", "sourceindex"]
101
+ return [exe, "-m", "pip", "install", "--upgrade", "--quiet", "sourceindex"]
102
+
103
+
104
+ def _maybe_spawn() -> None:
105
+ if _disabled():
106
+ return
107
+ cmd = _upgrade_command()
108
+ if cmd is None:
109
+ return
110
+
111
+ config_dir = _user_config_dir()
112
+ config_dir.mkdir(parents=True, exist_ok=True)
113
+ stamp = config_dir / STAMP_FILENAME
114
+ fd = os.open(stamp, os.O_RDWR | os.O_CREAT, 0o644)
115
+ try:
116
+ try:
117
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
118
+ except OSError:
119
+ return # a concurrent invocation is claiming this window
120
+ st = os.fstat(fd)
121
+ # A zero-size stamp is the O_CREAT of a claimant that died before
122
+ # writing — its fresh mtime must not count as a completed claim.
123
+ if st.st_size and time.time() - st.st_mtime < CHECK_INTERVAL_S:
124
+ return
125
+ os.ftruncate(fd, 0)
126
+ os.write(fd, f"{time.time():.0f}\n".encode())
127
+
128
+ with open(config_dir / LOG_FILENAME, "wb") as out:
129
+ out.write((" ".join(cmd) + "\n").encode())
130
+ out.flush()
131
+ subprocess.Popen(
132
+ cmd,
133
+ stdin=subprocess.DEVNULL,
134
+ stdout=out,
135
+ stderr=out,
136
+ start_new_session=True,
137
+ )
138
+ _log.info(
139
+ "Spawned background upgrade: %s", " ".join(cmd),
140
+ extra={"event": "upgrade.spawned", "cmd": cmd},
141
+ )
142
+ finally:
143
+ os.close(fd)
@@ -10,6 +10,10 @@ from typing import Any
10
10
 
11
11
  from . import lifecycle
12
12
  from .protocol import E_SERVER, RpcError, encode_frame, make_request, read_frame_sync
13
+ from .. import __version__
14
+ from ..lib.log import get_logger
15
+
16
+ _log = get_logger(__name__)
13
17
 
14
18
 
15
19
  def _drop_none(**kwargs: Any) -> dict[str, Any]:
@@ -35,6 +39,7 @@ class Client:
35
39
  self.allow_dump_on_spawn = allow_dump_on_spawn
36
40
  self.repo_hash = lifecycle.repo_hash_for(self._main_worktree())
37
41
  self._next_id = 1
42
+ self._version_checked = False
38
43
 
39
44
  def _main_worktree(self) -> Path:
40
45
  from ..lib.git import resolve_main_worktree
@@ -49,6 +54,7 @@ class Client:
49
54
 
50
55
  def _ensure_running(self) -> None:
51
56
  if lifecycle.is_daemon_running(self.repo_root):
57
+ self._recycle_if_stale()
52
58
  return
53
59
  if not self.auto_spawn:
54
60
  raise FileNotFoundError(
@@ -57,6 +63,38 @@ class Client:
57
63
  )
58
64
  lifecycle.spawn_daemon(self.repo_root, allow_dump=self.allow_dump_on_spawn)
59
65
 
66
+ def _recycle_if_stale(self) -> None:
67
+ """Restart a running daemon that predates the installed package.
68
+
69
+ The background auto-upgrade (``cli/upgrade.py``) swaps the code on
70
+ disk, but a live daemon keeps serving whatever it loaded at startup.
71
+ Ping reports that version; when it differs from ours, restart the
72
+ daemon so RPCs run the same code as this CLI. Checked once per
73
+ Client. Admin clients skip (ping is a user-socket method), and so
74
+ does a client that may not spawn — it must not kill a daemon it
75
+ cannot replace.
76
+ """
77
+ if self._version_checked or self.admin or not self.auto_spawn:
78
+ return
79
+ self._version_checked = True
80
+ try:
81
+ info = self._call("ping")
82
+ except Exception:
83
+ return # let the real RPC surface any failure
84
+ daemon_version = info.get("version")
85
+ if daemon_version == __version__:
86
+ return
87
+ _log.info(
88
+ "Daemon runs sourceindex %s but %s is installed; restarting it",
89
+ daemon_version, __version__,
90
+ extra={"event": "daemon.version_recycle", "daemon_version": daemon_version},
91
+ )
92
+ lifecycle.stop_daemon(self.repo_root)
93
+ lifecycle.spawn_daemon(
94
+ self.repo_root,
95
+ allow_dump=bool(info.get("allow_dump")) or self.allow_dump_on_spawn,
96
+ )
97
+
60
98
  def _call(self, method: str, params: dict | None = None) -> Any:
61
99
  self._ensure_running()
62
100
  path = self._socket_path()
@@ -129,8 +167,14 @@ class Client:
129
167
  def usage(self) -> dict:
130
168
  return self._call("usage")
131
169
 
132
- def set_api_key(self, api_key: str) -> dict:
133
- return self._call("set_api_key", {"api_key": api_key})
170
+ def set_api_key(self, api_key: str | None = None, *, scope: str = "local") -> dict:
171
+ params: dict = {"scope": scope}
172
+ # A global set needs no secret over the wire — the client has already
173
+ # written the user-level config and only wants the per-repo override
174
+ # out of the way.
175
+ if scope == "local":
176
+ params["api_key"] = api_key
177
+ return self._call("set_api_key", params)
134
178
 
135
179
  def dump_all(self, out_dir: Path) -> dict:
136
180
  return self._call("dump_all", {"out_dir": str(Path(out_dir).resolve())})
@@ -26,6 +26,7 @@ from .protocol import (
26
26
  read_frame_async,
27
27
  )
28
28
  from .store import EncryptedStore
29
+ from .. import __version__
29
30
  from ..lib.log import log_error
30
31
 
31
32
 
@@ -81,10 +82,13 @@ class Daemon:
81
82
  self._last_activity = time.monotonic()
82
83
  self._shutdown_evt: asyncio.Event | None = None
83
84
  self._repo_was_deleted = False
84
- # Captured here, before run() loads either .env, so a key genuinely
85
- # exported into our environment stays distinguishable from one this
86
- # daemon loaded itself. Only the former outranks the persisted files.
87
- self._exported_api_key = os.environ.get("SOURCEINDEX_API_KEY") or None
85
+ # Only a key the user's shell exported outranks the persisted files we
86
+ # re-read per RPC. Reading SOURCEINDEX_API_KEY directly would not do:
87
+ # we are exec'd from a CLI that may have loaded that value out of a
88
+ # config file itself, and pinning such a key at the top tier is what
89
+ # used to survive every rotation.
90
+ from ..cli.api_key import shell_api_key
91
+ self._exported_api_key = shell_api_key()
88
92
  self._api_key_source: str | None = None
89
93
  self._api_key_fingerprint: str | None = None
90
94
  self._user_handlers: dict[str, Callable[[dict], Awaitable[dict]]] = {}
@@ -141,29 +145,40 @@ class Daemon:
141
145
  lifecycle.pidfile(self.repo_hash).unlink(missing_ok=True)
142
146
  lifecycle.cleanup_stale_files(self.repo_hash)
143
147
 
144
- def _load_env_from_store(self) -> None:
148
+ def _read_store_env(self) -> dict[str, str]:
149
+ """Decrypt and parse the store's ``.env``. A store without one, or one
150
+ we cannot read, is reported as empty — every caller treats a missing
151
+ setting the same way, and none of them can act on a decrypt failure."""
145
152
  if not self.store.exists(".env"):
146
- return
153
+ return {}
147
154
  try:
148
155
  text = self.store.read_text(".env")
149
156
  except Exception as e:
150
- _log.warning("could not load encrypted .env: %s", e)
151
- return
157
+ _log.warning("could not read encrypted .env: %s", e)
158
+ return {}
152
159
  from ..lib.env import parse_env_text
153
- for k, v in parse_env_text(text).items():
160
+ return parse_env_text(text)
161
+
162
+ def _write_store_env(self, env_vars: dict[str, str]) -> None:
163
+ from ..lib.env import format_env_text
164
+ self.store.write_text(".env", format_env_text(env_vars))
165
+
166
+ def _load_env_from_store(self) -> None:
167
+ for k, v in self._read_store_env().items():
154
168
  if k and k not in os.environ:
155
169
  os.environ[k] = v
156
170
 
157
171
  def _api_key_from_store(self) -> str | None:
158
- if not self.store.exists(".env"):
159
- return None
160
- try:
161
- text = self.store.read_text(".env")
162
- except Exception as e:
163
- _log.warning("could not read encrypted .env: %s", e)
164
- return None
165
- from ..lib.env import parse_env_text
166
- return parse_env_text(text).get("SOURCEINDEX_API_KEY") or None
172
+ return self._read_store_env().get("SOURCEINDEX_API_KEY") or None
173
+
174
+ def _clear_store_api_key(self) -> bool:
175
+ """Drop the per-repo key override, leaving the store's other settings
176
+ alone. Returns whether one was actually there."""
177
+ env_vars = self._read_store_env()
178
+ if env_vars.pop("SOURCEINDEX_API_KEY", None) is None:
179
+ return False
180
+ self._write_store_env(env_vars)
181
+ return True
167
182
 
168
183
  def _resolve_api_key(self) -> tuple[str | None, str]:
169
184
  """Resolve the effective API key from scratch, highest precedence
@@ -385,6 +400,10 @@ class Daemon:
385
400
  "ok": True,
386
401
  "repo_hash": self.repo_hash,
387
402
  "allow_dump": self.allow_dump,
403
+ # The code loaded at startup, not what is on disk — the client
404
+ # compares this against its own version to recycle a daemon that
405
+ # predates a background auto-upgrade.
406
+ "version": __version__,
388
407
  "idle_for_s": time.monotonic() - self._last_activity,
389
408
  }
390
409
 
@@ -486,21 +505,34 @@ class Daemon:
486
505
  return {"budget": budget}
487
506
 
488
507
  async def _h_set_api_key(self, params: dict) -> dict:
489
- api_key = params.get("api_key")
490
- if not isinstance(api_key, str) or not api_key:
491
- raise RpcError(E_INVALID_PARAMS, "api_key is required")
492
- if "\n" in api_key or "\r" in api_key:
493
- raise RpcError(E_INVALID_PARAMS, "api_key must be a single line")
508
+ """Apply an ``api-key set``.
494
509
 
495
- from ..lib.env import parse_env_text
510
+ ``scope="local"`` writes the key into this repo's store as a per-repo
511
+ override. ``scope="global"`` means the client already wrote the
512
+ user-level config and we only need to stand out of the way: clear any
513
+ per-repo override, which would otherwise outrank it and make the
514
+ global command a silent no-op on every indexed repo.
515
+ """
516
+ scope = params.get("scope") or "local"
517
+ if scope not in ("local", "global"):
518
+ raise RpcError(E_INVALID_PARAMS, "scope must be 'local' or 'global'")
519
+
520
+ api_key = params.get("api_key")
521
+ cleared_local = False
522
+ if scope == "local":
523
+ if not isinstance(api_key, str) or not api_key:
524
+ raise RpcError(E_INVALID_PARAMS, "api_key is required")
525
+ if "\n" in api_key or "\r" in api_key:
526
+ raise RpcError(E_INVALID_PARAMS, "api_key must be a single line")
527
+
528
+ env_vars = self._read_store_env()
529
+ env_vars["SOURCEINDEX_API_KEY"] = api_key
530
+ self._write_store_env(env_vars)
531
+ else:
532
+ # The client narrates this one to the user off `cleared_local`;
533
+ # duplicating it here would double-count the event in the logs.
534
+ cleared_local = self._clear_store_api_key()
496
535
 
497
- env_vars = {}
498
- if self.store.exists(".env"):
499
- env_vars = parse_env_text(self.store.read_text(".env"))
500
- env_vars["SOURCEINDEX_API_KEY"] = api_key
501
- self.store.write_text(
502
- ".env", "".join(f"{k}={v}\n" for k, v in env_vars.items())
503
- )
504
536
  # An explicit `api-key set` is a deliberate act and outranks whatever
505
537
  # was exported into the environment we were launched with — otherwise
506
538
  # a stale export would silently shadow the key just written, which is
@@ -508,14 +540,14 @@ class Daemon:
508
540
  if self._exported_api_key and self._exported_api_key != api_key:
509
541
  _log.warning(
510
542
  "Superseding the SOURCEINDEX_API_KEY exported in the daemon's "
511
- "environment (%s) with the key just set (%s). Remove that "
512
- "export so every SourceIndex process agrees on one key.",
513
- _key_fingerprint(self._exported_api_key), _key_fingerprint(api_key),
543
+ "environment (%s). Remove that export so every SourceIndex "
544
+ "process agrees on one key.",
545
+ _key_fingerprint(self._exported_api_key),
514
546
  extra={"event": "api_key.export_superseded"},
515
547
  )
516
548
  self._exported_api_key = None
517
549
  self._refresh_api_key()
518
- return {"updated": True}
550
+ return {"updated": True, "cleared_local": cleared_local}
519
551
 
520
552
  def _register_admin_handlers(self) -> None:
521
553
  self._admin_handlers = {
@@ -545,10 +577,7 @@ class Daemon:
545
577
  return {"text": self.store.read_text(f"details/{rel}.md")}
546
578
 
547
579
  async def _h_read_env(self, params: dict) -> dict:
548
- if not self.store.exists(".env"):
549
- return {"vars": {}}
550
- from ..lib.env import parse_env_text
551
- return {"vars": parse_env_text(self.store.read_text(".env"))}
580
+ return {"vars": self._read_store_env()}
552
581
 
553
582
  async def _h_shutdown(self, params: dict) -> dict:
554
583
  self._request_shutdown()
@@ -28,3 +28,9 @@ def parse_env_text(contents: str) -> dict[str, str]:
28
28
  k, _, v = line.partition("=")
29
29
  out[k.strip()] = v.strip()
30
30
  return out
31
+
32
+
33
+ def format_env_text(values: dict[str, str]) -> str:
34
+ """Serialize ``{key: value}`` back to ``.env``-style text. Round-trip
35
+ partner of ``parse_env_text`` — kept beside it so the two cannot drift."""
36
+ return "".join(f"{k}={v}\n" for k, v in values.items())
File without changes