cocoindex-code 0.2.23__tar.gz → 0.2.25__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 (23) hide show
  1. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/.gitignore +4 -0
  2. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/PKG-INFO +89 -27
  3. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/README.md +88 -26
  4. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/pyproject.toml +4 -1
  5. cocoindex_code-0.2.25/src/cocoindex_code/_daemon_paths.py +60 -0
  6. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/_version.py +2 -2
  7. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/cli.py +47 -14
  8. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/client.py +40 -14
  9. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/daemon.py +54 -20
  10. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/protocol.py +1 -0
  11. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/settings.py +83 -29
  12. cocoindex_code-0.2.23/src/cocoindex_code/_daemon_paths.py +0 -44
  13. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/LICENSE +0 -0
  14. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/__init__.py +0 -0
  15. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/__main__.py +0 -0
  16. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/chunking.py +0 -0
  17. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/indexer.py +0 -0
  18. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/litellm_embedder.py +0 -0
  19. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/project.py +0 -0
  20. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/query.py +0 -0
  21. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/schema.py +0 -0
  22. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/server.py +0 -0
  23. {cocoindex_code-0.2.23 → cocoindex_code-0.2.25}/src/cocoindex_code/shared.py +0 -0
@@ -46,3 +46,7 @@ src/cocoindex_code/_version.py
46
46
 
47
47
  # CocoIndex Code (ccc)
48
48
  /.cocoindex_code/
49
+
50
+ # Docker E2E fixtures contain a `lib/` dir that the generic Python rule above
51
+ # would ignore — keep it tracked.
52
+ !tests/e2e_docker_fixtures/**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoindex-code
3
- Version: 0.2.23
3
+ Version: 0.2.25
4
4
  Summary: MCP server for indexing and querying codebases using CocoIndex
5
5
  Project-URL: Homepage, https://github.com/cocoindex-io/cocoindex-code
6
6
  Project-URL: Repository, https://github.com/cocoindex-io/cocoindex-code
@@ -242,33 +242,79 @@ The recommended approach is a **persistent container**: start it once, and use
242
242
  `docker exec` to run CLI commands or connect MCP sessions to it. The daemon
243
243
  inside stays warm across sessions, so the embedding model is loaded only once.
244
244
 
245
- ### Step 1Start the container
245
+ ### Quick start`docker compose up -d`
246
+
247
+ Grab [`docker/docker-compose.yml`](./docker/docker-compose.yml) from this repo and run:
248
+
249
+ ```bash
250
+ # macOS / Windows
251
+ docker compose up -d
252
+
253
+ # Linux (aligns file ownership on bind-mounted paths with your host user)
254
+ PUID=$(id -u) PGID=$(id -g) docker compose up -d
255
+ ```
256
+
257
+ By default your home directory is mounted into the container (set
258
+ `COCOINDEX_HOST_WORKSPACE` to narrow this to a specific code folder). Index
259
+ data and the embedding model cache persist in a Docker volume across
260
+ restarts. Your global settings file at `$HOME/.cocoindex_code/global_settings.yml`
261
+ is visible and editable on the host; edits take effect on your next `ccc` command.
262
+
263
+ > **GHCR:** to pull from GitHub Container Registry instead of Docker Hub,
264
+ > change the `image:` line in your copy of `docker-compose.yml` to
265
+ > `ghcr.io/cocoindex-io/cocoindex-code:latest`.
266
+
267
+ ### Or: `docker run`
268
+
269
+ <details>
270
+ <summary>Docker Desktop (macOS / Windows)</summary>
246
271
 
247
272
  ```bash
248
273
  docker run -d --name cocoindex-code \
249
- --volume "$(pwd):/workspace" \
250
- --volume cocoindex-db:/db \
251
- --volume cocoindex-model-cache:/root/.cache \
252
- ghcr.io/cocoindex-io/cocoindex-code:latest
274
+ --volume "$HOME:/workspace" \
275
+ --volume cocoindex-data:/var/cocoindex \
276
+ -e COCOINDEX_CODE_HOST_PATH_MAPPING="/workspace=$HOME" \
277
+ cocoindex/cocoindex-code:latest
253
278
  ```
279
+ </details>
254
280
 
255
- - `/workspace` — mount your project root here
256
- - `cocoindex-db` — index databases live inside the container (fast native I/O, no cross-OS volume issues)
257
- - `cocoindex-model-cache` — persists the embedding model across image upgrades
281
+ <details>
282
+ <summary>Linux (with <code>PUID</code>/<code>PGID</code>)</summary>
258
283
 
259
- ### Step 2 — Index your codebase
284
+ ```bash
285
+ docker run -d --name cocoindex-code \
286
+ -e PUID=$(id -u) -e PGID=$(id -g) \
287
+ --volume "$HOME:/workspace" \
288
+ --volume cocoindex-data:/var/cocoindex \
289
+ -e COCOINDEX_CODE_HOST_PATH_MAPPING="/workspace=$HOME" \
290
+ cocoindex/cocoindex-code:latest
291
+ ```
292
+ </details>
293
+
294
+ ### Shell wrapper for `ccc` commands
295
+
296
+ Paste this into `~/.bashrc` / `~/.zshrc` so `ccc` feels native on the host
297
+ and picks up the right project based on your current directory:
260
298
 
261
299
  ```bash
262
- docker exec -it cocoindex-code ccc index
300
+ ccc() {
301
+ docker exec -it -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc "$@"
302
+ }
263
303
  ```
264
304
 
265
- ### Step 3 Connect your coding agent
305
+ Now `cd` into any project under your workspace and run `ccc init`, `ccc index`,
306
+ `ccc search ...`, `ccc status`, etc. — it just works.
307
+
308
+ ### Connect your coding agent
266
309
 
267
310
  <details>
268
311
  <summary>Claude Code</summary>
269
312
 
313
+ Register MCP from inside the target project so `$PWD` points there:
314
+
270
315
  ```bash
271
- claude mcp add cocoindex-code -- docker exec -i cocoindex-code ccc mcp
316
+ claude mcp add cocoindex-code -- docker exec -i \
317
+ -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc mcp
272
318
  ```
273
319
 
274
320
  Or via `.mcp.json`:
@@ -279,40 +325,50 @@ Or via `.mcp.json`:
279
325
  "cocoindex-code": {
280
326
  "type": "stdio",
281
327
  "command": "docker",
282
- "args": ["exec", "-i", "cocoindex-code", "ccc", "mcp"]
328
+ "args": [
329
+ "exec",
330
+ "-i",
331
+ "-e",
332
+ "COCOINDEX_CODE_HOST_CWD=${PWD}",
333
+ "cocoindex-code",
334
+ "ccc",
335
+ "mcp"
336
+ ]
283
337
  }
284
338
  }
285
339
  }
286
340
  ```
341
+
342
+ > Note: use `-i` (not `-it`). The `-t` flag allocates a terminal, which
343
+ > interferes with MCP's JSON messaging over stdin/stdout — only add it for
344
+ > interactive `ccc` commands like `ccc init`.
287
345
  </details>
288
346
 
289
347
  <details>
290
348
  <summary>Codex</summary>
291
349
 
292
350
  ```bash
293
- codex mcp add cocoindex-code -- docker exec -i cocoindex-code ccc mcp
351
+ codex mcp add cocoindex-code -- docker exec -i \
352
+ -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc mcp
294
353
  ```
295
354
  </details>
296
355
 
297
- ### CLI usage inside the container
356
+ ### Upgrading from an older image
298
357
 
299
- All `ccc` commands work via `docker exec`:
358
+ Earlier images used separate `cocoindex-db` and `cocoindex-model-cache`
359
+ volumes; the current image consolidates them into a single `cocoindex-data`
360
+ volume. Before pulling the new image, drop the old container and volumes —
361
+ indexes rebuild on your next `ccc index`, and the embedding model is
362
+ re-populated automatically on first start:
300
363
 
301
364
  ```bash
302
- docker exec -it cocoindex-code ccc index
303
- docker exec -it cocoindex-code ccc search "authentication logic"
304
- docker exec -it cocoindex-code ccc status
305
- ```
306
-
307
- Or set an alias on your host so it feels native:
308
-
309
- ```bash
310
- alias ccc='docker exec -it cocoindex-code ccc'
365
+ docker rm -f cocoindex-code
366
+ docker volume rm cocoindex-db cocoindex-model-cache
311
367
  ```
312
368
 
313
369
  ### Configuration via environment variables
314
370
 
315
- Pass configuration to `docker run` with `-e`:
371
+ Pass configuration to `docker run` / compose with `-e`:
316
372
 
317
373
  ```bash
318
374
  # Extra extensions (e.g. Typesafe Config, SBT build files)
@@ -325,6 +381,10 @@ Pass configuration to `docker run` with `-e`:
325
381
  -e VOYAGE_API_KEY=your-key
326
382
  ```
327
383
 
384
+ > **Security note:** mounting `$HOME` gives the container read/write access
385
+ > to everything under it. If that's too broad, bind-mount a narrower
386
+ > directory instead (`COCOINDEX_HOST_WORKSPACE=/path/to/code`).
387
+
328
388
  ### Build the image locally
329
389
 
330
390
  ```bash
@@ -359,6 +419,8 @@ envs: # extra environment variabl
359
419
 
360
420
  > **Note:** The daemon inherits your shell environment. If an API key (e.g. `OPENAI_API_KEY`) is already set as an environment variable, you don't need to duplicate it in `envs`. The `envs` field is only for values that aren't in your environment.
361
421
 
422
+ > **Custom location:** set `COCOINDEX_CODE_DIR` to place `global_settings.yml` somewhere other than `~/.cocoindex_code/` — useful if you want the file to live alongside your projects (e.g. on a synced folder).
423
+
362
424
  ### Project Settings (`<project>/.cocoindex_code/settings.yml`)
363
425
 
364
426
  Per-project. Controls which files to index.
@@ -198,33 +198,79 @@ The recommended approach is a **persistent container**: start it once, and use
198
198
  `docker exec` to run CLI commands or connect MCP sessions to it. The daemon
199
199
  inside stays warm across sessions, so the embedding model is loaded only once.
200
200
 
201
- ### Step 1Start the container
201
+ ### Quick start`docker compose up -d`
202
+
203
+ Grab [`docker/docker-compose.yml`](./docker/docker-compose.yml) from this repo and run:
204
+
205
+ ```bash
206
+ # macOS / Windows
207
+ docker compose up -d
208
+
209
+ # Linux (aligns file ownership on bind-mounted paths with your host user)
210
+ PUID=$(id -u) PGID=$(id -g) docker compose up -d
211
+ ```
212
+
213
+ By default your home directory is mounted into the container (set
214
+ `COCOINDEX_HOST_WORKSPACE` to narrow this to a specific code folder). Index
215
+ data and the embedding model cache persist in a Docker volume across
216
+ restarts. Your global settings file at `$HOME/.cocoindex_code/global_settings.yml`
217
+ is visible and editable on the host; edits take effect on your next `ccc` command.
218
+
219
+ > **GHCR:** to pull from GitHub Container Registry instead of Docker Hub,
220
+ > change the `image:` line in your copy of `docker-compose.yml` to
221
+ > `ghcr.io/cocoindex-io/cocoindex-code:latest`.
222
+
223
+ ### Or: `docker run`
224
+
225
+ <details>
226
+ <summary>Docker Desktop (macOS / Windows)</summary>
202
227
 
203
228
  ```bash
204
229
  docker run -d --name cocoindex-code \
205
- --volume "$(pwd):/workspace" \
206
- --volume cocoindex-db:/db \
207
- --volume cocoindex-model-cache:/root/.cache \
208
- ghcr.io/cocoindex-io/cocoindex-code:latest
230
+ --volume "$HOME:/workspace" \
231
+ --volume cocoindex-data:/var/cocoindex \
232
+ -e COCOINDEX_CODE_HOST_PATH_MAPPING="/workspace=$HOME" \
233
+ cocoindex/cocoindex-code:latest
209
234
  ```
235
+ </details>
210
236
 
211
- - `/workspace` — mount your project root here
212
- - `cocoindex-db` — index databases live inside the container (fast native I/O, no cross-OS volume issues)
213
- - `cocoindex-model-cache` — persists the embedding model across image upgrades
237
+ <details>
238
+ <summary>Linux (with <code>PUID</code>/<code>PGID</code>)</summary>
214
239
 
215
- ### Step 2 — Index your codebase
240
+ ```bash
241
+ docker run -d --name cocoindex-code \
242
+ -e PUID=$(id -u) -e PGID=$(id -g) \
243
+ --volume "$HOME:/workspace" \
244
+ --volume cocoindex-data:/var/cocoindex \
245
+ -e COCOINDEX_CODE_HOST_PATH_MAPPING="/workspace=$HOME" \
246
+ cocoindex/cocoindex-code:latest
247
+ ```
248
+ </details>
249
+
250
+ ### Shell wrapper for `ccc` commands
251
+
252
+ Paste this into `~/.bashrc` / `~/.zshrc` so `ccc` feels native on the host
253
+ and picks up the right project based on your current directory:
216
254
 
217
255
  ```bash
218
- docker exec -it cocoindex-code ccc index
256
+ ccc() {
257
+ docker exec -it -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc "$@"
258
+ }
219
259
  ```
220
260
 
221
- ### Step 3 Connect your coding agent
261
+ Now `cd` into any project under your workspace and run `ccc init`, `ccc index`,
262
+ `ccc search ...`, `ccc status`, etc. — it just works.
263
+
264
+ ### Connect your coding agent
222
265
 
223
266
  <details>
224
267
  <summary>Claude Code</summary>
225
268
 
269
+ Register MCP from inside the target project so `$PWD` points there:
270
+
226
271
  ```bash
227
- claude mcp add cocoindex-code -- docker exec -i cocoindex-code ccc mcp
272
+ claude mcp add cocoindex-code -- docker exec -i \
273
+ -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc mcp
228
274
  ```
229
275
 
230
276
  Or via `.mcp.json`:
@@ -235,40 +281,50 @@ Or via `.mcp.json`:
235
281
  "cocoindex-code": {
236
282
  "type": "stdio",
237
283
  "command": "docker",
238
- "args": ["exec", "-i", "cocoindex-code", "ccc", "mcp"]
284
+ "args": [
285
+ "exec",
286
+ "-i",
287
+ "-e",
288
+ "COCOINDEX_CODE_HOST_CWD=${PWD}",
289
+ "cocoindex-code",
290
+ "ccc",
291
+ "mcp"
292
+ ]
239
293
  }
240
294
  }
241
295
  }
242
296
  ```
297
+
298
+ > Note: use `-i` (not `-it`). The `-t` flag allocates a terminal, which
299
+ > interferes with MCP's JSON messaging over stdin/stdout — only add it for
300
+ > interactive `ccc` commands like `ccc init`.
243
301
  </details>
244
302
 
245
303
  <details>
246
304
  <summary>Codex</summary>
247
305
 
248
306
  ```bash
249
- codex mcp add cocoindex-code -- docker exec -i cocoindex-code ccc mcp
307
+ codex mcp add cocoindex-code -- docker exec -i \
308
+ -e COCOINDEX_CODE_HOST_CWD="$PWD" cocoindex-code ccc mcp
250
309
  ```
251
310
  </details>
252
311
 
253
- ### CLI usage inside the container
312
+ ### Upgrading from an older image
254
313
 
255
- All `ccc` commands work via `docker exec`:
314
+ Earlier images used separate `cocoindex-db` and `cocoindex-model-cache`
315
+ volumes; the current image consolidates them into a single `cocoindex-data`
316
+ volume. Before pulling the new image, drop the old container and volumes —
317
+ indexes rebuild on your next `ccc index`, and the embedding model is
318
+ re-populated automatically on first start:
256
319
 
257
320
  ```bash
258
- docker exec -it cocoindex-code ccc index
259
- docker exec -it cocoindex-code ccc search "authentication logic"
260
- docker exec -it cocoindex-code ccc status
261
- ```
262
-
263
- Or set an alias on your host so it feels native:
264
-
265
- ```bash
266
- alias ccc='docker exec -it cocoindex-code ccc'
321
+ docker rm -f cocoindex-code
322
+ docker volume rm cocoindex-db cocoindex-model-cache
267
323
  ```
268
324
 
269
325
  ### Configuration via environment variables
270
326
 
271
- Pass configuration to `docker run` with `-e`:
327
+ Pass configuration to `docker run` / compose with `-e`:
272
328
 
273
329
  ```bash
274
330
  # Extra extensions (e.g. Typesafe Config, SBT build files)
@@ -281,6 +337,10 @@ Pass configuration to `docker run` with `-e`:
281
337
  -e VOYAGE_API_KEY=your-key
282
338
  ```
283
339
 
340
+ > **Security note:** mounting `$HOME` gives the container read/write access
341
+ > to everything under it. If that's too broad, bind-mount a narrower
342
+ > directory instead (`COCOINDEX_HOST_WORKSPACE=/path/to/code`).
343
+
284
344
  ### Build the image locally
285
345
 
286
346
  ```bash
@@ -315,6 +375,8 @@ envs: # extra environment variabl
315
375
 
316
376
  > **Note:** The daemon inherits your shell environment. If an API key (e.g. `OPENAI_API_KEY`) is already set as an environment variable, you don't need to duplicate it in `envs`. The `envs` field is only for values that aren't in your environment.
317
377
 
378
+ > **Custom location:** set `COCOINDEX_CODE_DIR` to place `global_settings.yml` somewhere other than `~/.cocoindex_code/` — useful if you want the file to live alongside your projects (e.g. on a synced folder).
379
+
318
380
  ### Project Settings (`<project>/.cocoindex_code/settings.yml`)
319
381
 
320
382
  Per-project. Controls which files to index.
@@ -105,5 +105,8 @@ explicit_package_bases = true
105
105
  testpaths = ["tests"]
106
106
  python_files = ["test_*.py"]
107
107
  python_functions = ["test_*"]
108
- addopts = "-v --tb=short"
108
+ addopts = "-v --tb=short -m 'not docker_e2e'"
109
109
  asyncio_mode = "auto"
110
+ markers = [
111
+ "docker_e2e: requires Docker; builds the image and runs containerized E2E tests. Run with: pytest -m docker_e2e",
112
+ ]
@@ -0,0 +1,60 @@
1
+ """Daemon filesystem paths and connection helpers.
2
+
3
+ Lightweight module with no cocoindex dependency so that the CLI client
4
+ can import these without pulling in the full daemon stack.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .settings import user_settings_dir
14
+
15
+
16
+ def daemon_runtime_dir() -> Path:
17
+ """Return the directory that holds daemon runtime artifacts.
18
+
19
+ Holds ``daemon.sock``, ``daemon.pid``, ``daemon.log``. Kept separate from
20
+ the user-settings dir so that (e.g. in Docker) the socket can live on the
21
+ container's native filesystem while ``global_settings.yml`` lives on a
22
+ bind mount.
23
+
24
+ Override with ``COCOINDEX_CODE_RUNTIME_DIR``. Defaults to
25
+ :func:`user_settings_dir` for backward compatibility — non-Docker users
26
+ see identical behavior to before the split.
27
+ """
28
+ override = os.environ.get("COCOINDEX_CODE_RUNTIME_DIR")
29
+ if override:
30
+ return Path(override)
31
+ return user_settings_dir()
32
+
33
+
34
+ def connection_family() -> str:
35
+ """Return the multiprocessing connection family for this platform."""
36
+ return "AF_PIPE" if sys.platform == "win32" else "AF_UNIX"
37
+
38
+
39
+ def daemon_socket_path() -> str:
40
+ """Return the daemon socket/pipe address."""
41
+ if sys.platform == "win32":
42
+ import hashlib
43
+
44
+ # Hash the runtime dir so COCOINDEX_CODE_RUNTIME_DIR (or the
45
+ # COCOINDEX_CODE_DIR fallback) overrides produce unique pipe names,
46
+ # preventing conflicts between different daemon instances (tests,
47
+ # users, etc.)
48
+ dir_hash = hashlib.md5(str(daemon_runtime_dir()).encode()).hexdigest()[:12]
49
+ return rf"\\.\pipe\cocoindex_code_{dir_hash}"
50
+ return str(daemon_runtime_dir() / "daemon.sock")
51
+
52
+
53
+ def daemon_pid_path() -> Path:
54
+ """Return the path for the daemon's PID file."""
55
+ return daemon_runtime_dir() / "daemon.pid"
56
+
57
+
58
+ def daemon_log_path() -> Path:
59
+ """Return the path for the daemon's log file."""
60
+ return daemon_runtime_dir() / "daemon.log"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.23'
22
- __version_tuple__ = version_tuple = (0, 2, 23)
21
+ __version__ = version = '0.2.25'
22
+ __version_tuple__ = version_tuple = (0, 2, 25)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import functools
6
+ import os
6
7
  import sys
7
8
  from collections.abc import Callable
8
9
  from pathlib import Path
@@ -19,6 +20,8 @@ from .settings import (
19
20
  default_project_settings,
20
21
  find_parent_with_marker,
21
22
  find_project_root,
23
+ format_path_for_display,
24
+ normalize_input_path,
22
25
  project_settings_path,
23
26
  resolve_db_dir,
24
27
  save_initial_user_settings,
@@ -37,6 +40,29 @@ daemon_app = _typer.Typer(name="daemon", help="Manage the daemon process.")
37
40
  app.add_typer(daemon_app, name="daemon")
38
41
 
39
42
 
43
+ @app.callback()
44
+ def _apply_host_cwd() -> None:
45
+ """Honor ``COCOINDEX_CODE_HOST_CWD`` when forwarded from a ``docker exec`` wrapper.
46
+
47
+ The env var carries the host shell's pwd verbatim. We normalize it through
48
+ the host path mapping to container form and ``chdir`` there so
49
+ cwd-driven discovery (``find_project_root`` etc.) sees the user's real
50
+ project subtree. Unset → no-op.
51
+ """
52
+ host_cwd = os.environ.get("COCOINDEX_CODE_HOST_CWD")
53
+ if not host_cwd:
54
+ return
55
+ target = normalize_input_path(host_cwd)
56
+ try:
57
+ os.chdir(target)
58
+ except OSError as e:
59
+ _typer.echo(
60
+ f"Warning: COCOINDEX_CODE_HOST_CWD={host_cwd!r} → {target!r} "
61
+ f"is not accessible: {e}. Continuing with cwd={os.getcwd()!r}.",
62
+ err=True,
63
+ )
64
+
65
+
40
66
  # ---------------------------------------------------------------------------
41
67
  # Shared CLI helpers
42
68
  # ---------------------------------------------------------------------------
@@ -51,7 +77,7 @@ def require_project_root() -> Path:
51
77
  gs_path = user_settings_path()
52
78
  if not gs_path.is_file():
53
79
  _typer.echo(
54
- f"Error: Global settings not found: {gs_path}\n"
80
+ f"Error: Global settings not found: {format_path_for_display(gs_path)}\n"
55
81
  "Run `ccc init` to create it with default settings.",
56
82
  err=True,
57
83
  )
@@ -112,7 +138,7 @@ def _format_progress(progress: IndexingProgress) -> str:
112
138
 
113
139
  def print_project_header(project_root: str) -> None:
114
140
  """Print the project root directory."""
115
- _typer.echo(f"Project: {project_root}")
141
+ _typer.echo(f"Project: {format_path_for_display(project_root)}")
116
142
 
117
143
 
118
144
  def print_index_stats(status: ProjectStatusResponse) -> None:
@@ -400,8 +426,9 @@ def _run_init_model_check(settings_path: Path) -> None:
400
426
  failed = True
401
427
 
402
428
  if failed:
429
+ display_path = format_path_for_display(settings_path)
403
430
  _typer.echo(
404
- f"You can edit {settings_path} to change the model or add API keys\n"
431
+ f"You can edit {display_path} to change the model or add API keys\n"
405
432
  "under `envs:`. Then run `ccc doctor` to verify.",
406
433
  err=True,
407
434
  )
@@ -419,7 +446,7 @@ def _setup_user_settings_interactive(litellm_model_flag: str | None) -> None:
419
446
 
420
447
  path = save_initial_user_settings(embedding)
421
448
  _typer.echo()
422
- _typer.echo(f"Created user settings: {path}")
449
+ _typer.echo(f"Created user settings: {format_path_for_display(path)}")
423
450
 
424
451
  _typer.echo()
425
452
  _typer.echo(f"Testing embedding model: {embedding.provider} / {embedding.model}")
@@ -443,8 +470,9 @@ def init(
443
470
  user_path = user_settings_path()
444
471
  if user_path.is_file():
445
472
  if litellm_model is not None:
473
+ display_path = format_path_for_display(user_path)
446
474
  _typer.echo(
447
- f"Error: global settings already exist at {user_path}.\n"
475
+ f"Error: global settings already exist at {display_path}.\n"
448
476
  "Edit that file or remove it before passing `--litellm-model`.",
449
477
  err=True,
450
478
  )
@@ -461,8 +489,9 @@ def init(
461
489
  if not force:
462
490
  parent = find_parent_with_marker(cwd)
463
491
  if parent is not None and parent != cwd:
492
+ display_parent = format_path_for_display(parent)
464
493
  _typer.echo(
465
- f"Warning: A parent directory has a project marker: {parent}\n"
494
+ f"Warning: A parent directory has a project marker: {display_parent}\n"
466
495
  "You might want to run `ccc init` there instead.\n"
467
496
  "Use `ccc init -f` to initialize here anyway."
468
497
  )
@@ -470,7 +499,7 @@ def init(
470
499
 
471
500
  # Create project settings
472
501
  save_project_settings(cwd, default_project_settings())
473
- _typer.echo(f"Created project settings: {settings_file}")
502
+ _typer.echo(f"Created project settings: {format_path_for_display(settings_file)}")
474
503
 
475
504
  # Add to .gitignore
476
505
  add_to_gitignore(cwd)
@@ -538,10 +567,10 @@ def status() -> None:
538
567
  project_root = str(project_root_path)
539
568
  print_project_header(project_root)
540
569
 
541
- _typer.echo(f"Settings: {project_settings_path(project_root_path)}")
570
+ _typer.echo(f"Settings: {format_path_for_display(project_settings_path(project_root_path))}")
542
571
  db_path = target_sqlite_db_path(project_root_path)
543
572
  if db_path.exists():
544
- _typer.echo(f"Index DB: {db_path}")
573
+ _typer.echo(f"Index DB: {format_path_for_display(db_path)}")
545
574
 
546
575
  print_index_stats(_client.project_status(project_root))
547
576
 
@@ -576,7 +605,7 @@ def reset(
576
605
  if to_delete:
577
606
  _typer.echo("The following files will be deleted:")
578
607
  for f in to_delete:
579
- _typer.echo(f" {f}")
608
+ _typer.echo(f" {format_path_for_display(f)}")
580
609
 
581
610
  # Confirm
582
611
  if not force:
@@ -668,7 +697,7 @@ def doctor() -> None:
668
697
  # --- 1. Global settings (local, no daemon needed) ---
669
698
  _print_section("Global Settings")
670
699
  settings_path = user_settings_path()
671
- _typer.echo(f" Settings: {settings_path}")
700
+ _typer.echo(f" Settings: {format_path_for_display(settings_path)}")
672
701
  try:
673
702
  user_settings = _load_user_settings()
674
703
  emb = user_settings.embedding
@@ -706,6 +735,10 @@ def doctor() -> None:
706
735
  _typer.echo(" DB path mappings:")
707
736
  for m in env_resp.db_path_mappings:
708
737
  _typer.echo(f" {m.source} \u2192 {m.target}")
738
+ if env_resp.host_path_mappings:
739
+ _typer.echo(" Host path mappings:")
740
+ for m in env_resp.host_path_mappings:
741
+ _typer.echo(f" {m.source} \u2192 {m.target}")
709
742
  except Exception as e:
710
743
  _print_error(f"Failed to get daemon env: {e}")
711
744
 
@@ -726,7 +759,7 @@ def doctor() -> None:
726
759
  if project_root is not None:
727
760
  _print_section("Project Settings")
728
761
  ps_path = project_settings_path(project_root)
729
- _typer.echo(f" Settings: {ps_path}")
762
+ _typer.echo(f" Settings: {format_path_for_display(ps_path)}")
730
763
  try:
731
764
  ps = _load_project_settings(project_root)
732
765
  _typer.echo(f" Include patterns ({len(ps.include_patterns)}):")
@@ -754,7 +787,7 @@ def doctor() -> None:
754
787
  _print_section("Log Files")
755
788
  from ._daemon_paths import daemon_log_path as _daemon_log_path
756
789
 
757
- _typer.echo(f" Daemon logs: {_daemon_log_path()}")
790
+ _typer.echo(f" Daemon logs: {format_path_for_display(_daemon_log_path())}")
758
791
  _typer.echo(" Check logs above for further troubleshooting.")
759
792
 
760
793
 
@@ -805,7 +838,7 @@ def daemon_status() -> None:
805
838
  _typer.echo("Projects:")
806
839
  for p in resp.projects:
807
840
  state = "indexing" if p.indexing else "idle"
808
- _typer.echo(f" {p.project_root} [{state}]")
841
+ _typer.echo(f" {format_path_for_display(p.project_root)} [{state}]")
809
842
  else:
810
843
  _typer.echo("No projects loaded.")
811
844
 
@@ -19,9 +19,9 @@ from pathlib import Path
19
19
 
20
20
  from ._daemon_paths import (
21
21
  connection_family,
22
- daemon_dir,
23
22
  daemon_log_path,
24
23
  daemon_pid_path,
24
+ daemon_runtime_dir,
25
25
  daemon_socket_path,
26
26
  )
27
27
  from ._version import __version__
@@ -53,6 +53,7 @@ from .protocol import (
53
53
  decode_response,
54
54
  encode_request,
55
55
  )
56
+ from .settings import normalize_input_path
56
57
 
57
58
  logger = logging.getLogger(__name__)
58
59
 
@@ -65,6 +66,14 @@ logger = logging.getLogger(__name__)
65
66
  _daemon_ensured = False
66
67
 
67
68
 
69
+ def _is_daemon_supervised() -> bool:
70
+ """True when an external supervisor (Docker entrypoint loop, systemd, …) owns
71
+ daemon respawn. The client in that mode calls ``stop_daemon`` but never
72
+ ``start_daemon`` — it just waits for the socket to reappear.
73
+ """
74
+ return os.environ.get("COCOINDEX_CODE_DAEMON_SUPERVISED") == "1"
75
+
76
+
68
77
  def _connect_and_handshake() -> Connection:
69
78
  """Connect to the daemon and perform the version handshake.
70
79
 
@@ -90,8 +99,13 @@ def _connect_and_handshake() -> Connection:
90
99
  except (ConnectionRefusedError, OSError):
91
100
  pass
92
101
 
93
- proc = start_daemon()
94
- _wait_for_daemon(proc=proc)
102
+ if _is_daemon_supervised():
103
+ # Supervisor is responsible for (re)starting the daemon — just wait
104
+ # for the socket to reappear.
105
+ _wait_for_daemon()
106
+ else:
107
+ proc = start_daemon()
108
+ _wait_for_daemon(proc=proc)
95
109
 
96
110
  # Verify the fresh daemon is reachable
97
111
  for _attempt in range(10):
@@ -199,6 +213,7 @@ def index(
199
213
  on_waiting: Callable[[], None] | None = None,
200
214
  ) -> IndexResponse:
201
215
  """Request indexing with streaming progress. Blocks until complete."""
216
+ project_root = normalize_input_path(project_root)
202
217
  conn = _connect_and_handshake()
203
218
  try:
204
219
  conn.send_bytes(encode_request(IndexRequest(project_root=project_root)))
@@ -240,6 +255,7 @@ def search(
240
255
  progress), calls *on_waiting* (if provided) then continues reading
241
256
  until the final ``SearchResponse``.
242
257
  """
258
+ project_root = normalize_input_path(project_root)
243
259
  conn = _connect_and_handshake()
244
260
  try:
245
261
  conn.send_bytes(
@@ -274,7 +290,7 @@ def search(
274
290
 
275
291
 
276
292
  def project_status(project_root: str) -> ProjectStatusResponse:
277
- return _send(ProjectStatusRequest(project_root=project_root)) # type: ignore[return-value]
293
+ return _send(ProjectStatusRequest(project_root=normalize_input_path(project_root))) # type: ignore[return-value]
278
294
 
279
295
 
280
296
  def daemon_status() -> DaemonStatusResponse:
@@ -284,7 +300,7 @@ def daemon_status() -> DaemonStatusResponse:
284
300
 
285
301
 
286
302
  def remove_project(project_root: str) -> RemoveProjectResponse:
287
- return _send(RemoveProjectRequest(project_root=project_root)) # type: ignore[return-value]
303
+ return _send(RemoveProjectRequest(project_root=normalize_input_path(project_root))) # type: ignore[return-value]
288
304
 
289
305
 
290
306
  def stop() -> StopResponse:
@@ -301,6 +317,8 @@ def doctor(
301
317
  on_result: Callable[[DoctorCheckResult], None] | None = None,
302
318
  ) -> list[DoctorCheckResult]:
303
319
  """Run doctor checks via daemon, streaming results to on_result callback."""
320
+ if project_root is not None:
321
+ project_root = normalize_input_path(project_root)
304
322
  conn = _connect_and_handshake()
305
323
  try:
306
324
  conn.send_bytes(encode_request(DoctorRequest(project_root=project_root)))
@@ -349,7 +367,7 @@ def start_daemon() -> subprocess.Popen[bytes]:
349
367
  Returns the ``Popen`` object so callers can detect early process death
350
368
  (via ``proc.poll()``) instead of waiting for a full timeout.
351
369
  """
352
- daemon_dir().mkdir(parents=True, exist_ok=True)
370
+ daemon_runtime_dir().mkdir(parents=True, exist_ok=True)
353
371
  log_path = daemon_log_path()
354
372
 
355
373
  ccc_path = _find_ccc_executable()
@@ -508,18 +526,16 @@ def _wait_for_daemon(
508
526
  If *proc* is given, polls the process each iteration. When the process
509
527
  exits before the socket appears, raises ``DaemonStartError`` immediately
510
528
  with the daemon log content — no need to wait for the full timeout.
529
+
530
+ Socket existence is checked *before* ``proc.poll()`` so that races with a
531
+ supervisor (e.g. the Docker entrypoint restart loop) don't spuriously raise
532
+ ``DaemonStartError``: if the supervisor wins the bind and our subprocess
533
+ exits because the socket is already in use, the socket is still ready — we
534
+ should return success, not flag a failure.
511
535
  """
512
536
  deadline = time.monotonic() + timeout
513
537
  sock_path = daemon_socket_path()
514
538
  while time.monotonic() < deadline:
515
- # Check if the daemon process died before the socket appeared.
516
- if proc is not None and proc.poll() is not None:
517
- log = _read_daemon_log()
518
- msg = "Daemon process exited before it became ready."
519
- if log:
520
- msg += f"\n\nDaemon log:\n{log}"
521
- raise DaemonStartError(msg, log=log)
522
-
523
539
  if sys.platform == "win32":
524
540
  try:
525
541
  conn = Client(sock_path, family=connection_family())
@@ -530,6 +546,16 @@ def _wait_for_daemon(
530
546
  else:
531
547
  if os.path.exists(sock_path):
532
548
  return
549
+
550
+ # Daemon socket not yet up — if we spawned a subprocess that already
551
+ # exited, bail out with its log.
552
+ if proc is not None and proc.poll() is not None:
553
+ log = _read_daemon_log()
554
+ msg = "Daemon process exited before it became ready."
555
+ if log:
556
+ msg += f"\n\nDaemon log:\n{log}"
557
+ raise DaemonStartError(msg, log=log)
558
+
533
559
  time.sleep(0.2)
534
560
 
535
561
  # Timeout — also include log for diagnostics.
@@ -17,9 +17,9 @@ from typing import Any
17
17
 
18
18
  from ._daemon_paths import (
19
19
  connection_family,
20
- daemon_dir,
21
20
  daemon_log_path,
22
21
  daemon_pid_path,
22
+ daemon_runtime_dir,
23
23
  daemon_socket_path,
24
24
  )
25
25
  from ._version import __version__
@@ -56,10 +56,13 @@ from .protocol import (
56
56
  )
57
57
  from .settings import (
58
58
  ChunkerMapping,
59
+ format_path_for_display,
60
+ get_host_path_mappings,
59
61
  global_settings_mtime_us,
60
62
  load_project_settings,
61
63
  load_user_settings,
62
64
  target_sqlite_db_path,
65
+ user_settings_path,
63
66
  )
64
67
  from .shared import Embedder, check_embedding, create_embedder
65
68
 
@@ -91,17 +94,28 @@ def _resolve_chunker_registry(mappings: list[ChunkerMapping]) -> dict[str, _Chun
91
94
 
92
95
 
93
96
  class ProjectRegistry:
94
- """Cache of loaded projects, keyed by project root path."""
97
+ """Cache of loaded projects, keyed by project root path.
98
+
99
+ ``_embedder`` is ``None`` when the daemon is running in "no-settings mode"
100
+ (started before ``global_settings.yml`` existed). In that state
101
+ ``get_project`` raises an error pointing the user at ``ccc init``; the
102
+ daemon still serves handshakes so the client can detect the mtime
103
+ mismatch once the file is created and trigger a supervisor respawn.
104
+ """
95
105
 
96
106
  _projects: dict[str, Project]
97
- _embedder: Embedder
107
+ _embedder: Embedder | None
98
108
 
99
- def __init__(self, embedder: Embedder) -> None:
109
+ def __init__(self, embedder: Embedder | None) -> None:
100
110
  self._projects = {}
101
111
  self._embedder = embedder
102
112
 
103
113
  async def get_project(self, project_root: str) -> Project:
104
114
  """Get or create a Project for the given root. Lazy initialization."""
115
+ if self._embedder is None:
116
+ raise RuntimeError(
117
+ "Daemon has no global settings loaded. Run `ccc init` to set up cocoindex-code."
118
+ )
105
119
  if project_root not in self._projects:
106
120
  root = Path(project_root)
107
121
  project_settings = load_project_settings(root)
@@ -260,8 +274,19 @@ async def _handle_doctor(
260
274
  )
261
275
 
262
276
 
263
- async def _check_model(embedder: Embedder) -> DoctorCheckResult:
264
- """Test the embedding model by embedding a short string."""
277
+ async def _check_model(embedder: Embedder | None) -> DoctorCheckResult:
278
+ """Test the embedding model by embedding a short string.
279
+
280
+ Returns a failed result when the embedder is ``None`` (daemon running in
281
+ no-settings mode).
282
+ """
283
+ if embedder is None:
284
+ return DoctorCheckResult(
285
+ name="Model Check",
286
+ ok=False,
287
+ details=[],
288
+ errors=["Daemon has no global settings loaded. Run `ccc init` to set up."],
289
+ )
265
290
  result = await check_embedding(embedder)
266
291
  if result.error is None:
267
292
  return DoctorCheckResult(
@@ -340,7 +365,7 @@ async def _check_index_status(project_root_str: str) -> DoctorCheckResult:
340
365
 
341
366
  project_root = Path(project_root_str)
342
367
  db_path = target_sqlite_db_path(project_root)
343
- details = [f"Index: {db_path}"]
368
+ details = [f"Index: {format_path_for_display(db_path)}"]
344
369
 
345
370
  if not db_path.exists():
346
371
  details.append("Index not created yet.")
@@ -445,6 +470,10 @@ async def _dispatch(
445
470
  DbPathMappingEntry(source=str(m.source), target=str(m.target))
446
471
  for m in get_db_path_mappings()
447
472
  ],
473
+ host_path_mappings=[
474
+ DbPathMappingEntry(source=str(m.source), target=str(m.target))
475
+ for m in get_host_path_mappings()
476
+ ],
448
477
  )
449
478
 
450
479
  if isinstance(req, DoctorRequest):
@@ -468,19 +497,24 @@ def run_daemon() -> None:
468
497
  to serve connections, and performs cleanup when shutdown is requested via
469
498
  ``StopRequest`` or a signal (SIGTERM / SIGINT).
470
499
  """
471
- daemon_dir().mkdir(parents=True, exist_ok=True)
472
-
473
- # Load user settings and record mtime for staleness detection
474
- user_settings = load_user_settings()
475
- settings_mtime_us = global_settings_mtime_us()
476
-
477
- # Set environment variables from settings
478
- settings_env_keys = list(user_settings.envs.keys())
479
- for key, value in user_settings.envs.items():
480
- os.environ[key] = value
481
-
482
- # Create embedder
483
- embedder = create_embedder(user_settings.embedding)
500
+ daemon_runtime_dir().mkdir(parents=True, exist_ok=True)
501
+
502
+ # No-settings mode: start even when global_settings.yml is missing so the
503
+ # client can complete its handshake, detect the mtime mismatch once
504
+ # `ccc init` writes the file, and trigger a supervisor respawn. The
505
+ # alternative (auto-creating defaults) would skip the interactive
506
+ # provider/model picker in `ccc init`.
507
+ settings_mtime_us = global_settings_mtime_us() # None when file is missing
508
+ embedder: Embedder | None
509
+ if user_settings_path().is_file():
510
+ user_settings = load_user_settings()
511
+ settings_env_keys = list(user_settings.envs.keys())
512
+ for key, value in user_settings.envs.items():
513
+ os.environ[key] = value
514
+ embedder = create_embedder(user_settings.embedding)
515
+ else:
516
+ settings_env_keys = []
517
+ embedder = None
484
518
 
485
519
  # Write PID file
486
520
  pid_path = daemon_pid_path()
@@ -167,6 +167,7 @@ class DaemonEnvResponse(_msgspec.Struct, tag="daemon_env"):
167
167
  env_names: list[str]
168
168
  settings_env_names: list[str]
169
169
  db_path_mappings: list[DbPathMappingEntry] = []
170
+ host_path_mappings: list[DbPathMappingEntry] = []
170
171
 
171
172
 
172
173
  class ErrorResponse(_msgspec.Struct, tag="error"):
@@ -151,51 +151,68 @@ _SETTINGS_FILE_NAME = "settings.yml" # project-level
151
151
  _USER_SETTINGS_FILE_NAME = "global_settings.yml" # user-level
152
152
 
153
153
  _ENV_DB_PATH_MAPPING = "COCOINDEX_CODE_DB_PATH_MAPPING"
154
+ _ENV_HOST_PATH_MAPPING = "COCOINDEX_CODE_HOST_PATH_MAPPING"
154
155
 
155
156
 
156
157
  @dataclass
157
- class DbPathMapping:
158
+ class PathMapping:
158
159
  source: Path
159
160
  target: Path
160
161
 
161
162
 
162
- _db_path_mapping: list[DbPathMapping] | None = None
163
+ def _parse_path_mapping(env_var: str) -> list[PathMapping]:
164
+ """Parse a ``source=target[,source=target...]`` env var.
163
165
 
164
-
165
- def _parse_db_path_mapping() -> list[DbPathMapping]:
166
- """Parse ``COCOINDEX_CODE_DB_PATH_MAPPING`` env var.
167
-
168
- Format: ``/src1=/dst1,/src2=/dst2``
169
- Both source and target must be absolute paths.
166
+ Both source and target must be absolute paths. Returns an empty list when
167
+ the env var is unset or blank. Raises ``ValueError`` on malformed entries.
170
168
  """
171
- raw = os.environ.get(_ENV_DB_PATH_MAPPING, "")
169
+ raw = os.environ.get(env_var, "")
172
170
  if not raw.strip():
173
171
  return []
174
172
 
175
- mappings: list[DbPathMapping] = []
173
+ mappings: list[PathMapping] = []
176
174
  for entry in raw.split(","):
177
175
  entry = entry.strip()
178
176
  if not entry:
179
177
  continue
180
178
  parts = entry.split("=", 1)
181
179
  if len(parts) != 2 or not parts[0] or not parts[1]:
182
- raise ValueError(
183
- f"{_ENV_DB_PATH_MAPPING}: invalid entry {entry!r}, expected format 'source=target'"
184
- )
180
+ raise ValueError(f"{env_var}: invalid entry {entry!r}, expected format 'source=target'")
185
181
  source = Path(parts[0])
186
182
  target = Path(parts[1])
187
183
  if not source.is_absolute():
188
- raise ValueError(
189
- f"{_ENV_DB_PATH_MAPPING}: source path must be absolute, got {source!r}"
190
- )
184
+ raise ValueError(f"{env_var}: source path must be absolute, got {source!r}")
191
185
  if not target.is_absolute():
192
- raise ValueError(
193
- f"{_ENV_DB_PATH_MAPPING}: target path must be absolute, got {target!r}"
194
- )
195
- mappings.append(DbPathMapping(source=source.resolve(), target=target.resolve()))
186
+ raise ValueError(f"{env_var}: target path must be absolute, got {target!r}")
187
+ mappings.append(PathMapping(source=source.resolve(), target=target.resolve()))
196
188
  return mappings
197
189
 
198
190
 
191
+ def _apply_mapping(mappings: list[PathMapping], path: str | Path, reverse: bool = False) -> str:
192
+ """Rewrite ``path`` through ``mappings``. First prefix match wins.
193
+
194
+ ``reverse=False``: rewrites source-prefix → target-prefix (forward).
195
+ ``reverse=True``: rewrites target-prefix → source-prefix (reverse).
196
+
197
+ Relative paths and absolute paths with no matching prefix are returned
198
+ unchanged (as ``str``).
199
+ """
200
+ p = Path(path)
201
+ if not p.is_absolute():
202
+ return str(path)
203
+ resolved = p.resolve()
204
+ for m in mappings:
205
+ src, dst = (m.target, m.source) if reverse else (m.source, m.target)
206
+ if resolved == src or resolved.is_relative_to(src):
207
+ rel = resolved.relative_to(src)
208
+ return str(dst / rel) if str(rel) != "." else str(dst)
209
+ return str(path)
210
+
211
+
212
+ _db_path_mapping: list[PathMapping] | None = None
213
+ _host_path_mapping: list[PathMapping] | None = None
214
+
215
+
199
216
  def resolve_db_dir(project_root: Path) -> Path:
200
217
  """Return the directory for database files given a project root.
201
218
 
@@ -204,7 +221,7 @@ def resolve_db_dir(project_root: Path) -> Path:
204
221
  """
205
222
  global _db_path_mapping # noqa: PLW0603
206
223
  if _db_path_mapping is None:
207
- _db_path_mapping = _parse_db_path_mapping()
224
+ _db_path_mapping = _parse_path_mapping(_ENV_DB_PATH_MAPPING)
208
225
 
209
226
  resolved = project_root.resolve()
210
227
  for mapping in _db_path_mapping:
@@ -214,20 +231,52 @@ def resolve_db_dir(project_root: Path) -> Path:
214
231
  return project_root / _SETTINGS_DIR_NAME
215
232
 
216
233
 
217
- def get_db_path_mappings() -> list[DbPathMapping]:
234
+ def get_db_path_mappings() -> list[PathMapping]:
218
235
  """Return the parsed DB path mappings from ``COCOINDEX_CODE_DB_PATH_MAPPING``."""
219
236
  global _db_path_mapping # noqa: PLW0603
220
237
  if _db_path_mapping is None:
221
- _db_path_mapping = _parse_db_path_mapping()
238
+ _db_path_mapping = _parse_path_mapping(_ENV_DB_PATH_MAPPING)
222
239
  return list(_db_path_mapping)
223
240
 
224
241
 
242
+ def get_host_path_mappings() -> list[PathMapping]:
243
+ """Return the parsed host path mappings from ``COCOINDEX_CODE_HOST_PATH_MAPPING``."""
244
+ global _host_path_mapping # noqa: PLW0603
245
+ if _host_path_mapping is None:
246
+ _host_path_mapping = _parse_path_mapping(_ENV_HOST_PATH_MAPPING)
247
+ return list(_host_path_mapping)
248
+
249
+
250
+ def format_path_for_display(p: str | Path) -> str:
251
+ """Translate a container path to its host equivalent for user-facing output.
252
+
253
+ No-op when ``COCOINDEX_CODE_HOST_PATH_MAPPING`` is unset or when ``p`` is a
254
+ relative path / unmatched absolute path.
255
+ """
256
+ return _apply_mapping(get_host_path_mappings(), p, reverse=False)
257
+
258
+
259
+ def normalize_input_path(p: str | Path) -> str:
260
+ """Translate a host path back to its container form before using it internally.
261
+
262
+ Inverse of :func:`format_path_for_display`. No-op when the env var is unset
263
+ or when ``p`` is relative / unmatched.
264
+ """
265
+ return _apply_mapping(get_host_path_mappings(), p, reverse=True)
266
+
267
+
225
268
  def _reset_db_path_mapping_cache() -> None:
226
269
  """Reset the cached mapping (for tests)."""
227
270
  global _db_path_mapping # noqa: PLW0603
228
271
  _db_path_mapping = None
229
272
 
230
273
 
274
+ def _reset_host_path_mapping_cache() -> None:
275
+ """Reset the cached mapping (for tests)."""
276
+ global _host_path_mapping # noqa: PLW0603
277
+ _host_path_mapping = None
278
+
279
+
231
280
  _TARGET_SQLITE_DB_NAME = "target_sqlite.db"
232
281
  _COCOINDEX_DB_NAME = "cocoindex.db"
233
282
 
@@ -295,22 +344,27 @@ def find_legacy_project_root(start: Path) -> Path | None:
295
344
 
296
345
 
297
346
  def find_parent_with_marker(start: Path) -> Path | None:
298
- """Walk up from *start* looking for ``.cocoindex_code/`` or ``.git/``.
347
+ """Walk up from *start* looking for an initialized project or a git repo.
348
+
349
+ Match criteria: ``.cocoindex_code/settings.yml`` (a real project marker —
350
+ distinct from a workspace-root ``.cocoindex_code/global_settings.yml``
351
+ which should not trigger this check) or ``.git/``.
299
352
 
300
- Returns the first directory found, or ``None``.
301
- Does not consider the home directory or above, to avoid false positives
302
- on CI runners where ~/.git may exist.
353
+ Returns the first directory found, or ``None``. Does not consider the home
354
+ directory or above, to avoid false positives on CI runners where ~/.git
355
+ may exist.
303
356
  """
304
357
  home = Path.home().resolve()
305
358
  current = start.resolve()
306
359
  while True:
307
- # Stop before reaching the home directory (home itself is not a project root)
308
360
  if current == home:
309
361
  return None
310
362
  parent = current.parent
311
363
  if parent == current:
312
364
  return None
313
- if (current / _SETTINGS_DIR_NAME).is_dir() or (current / ".git").is_dir():
365
+ if (current / _SETTINGS_DIR_NAME / _SETTINGS_FILE_NAME).is_file() or (
366
+ current / ".git"
367
+ ).is_dir():
314
368
  return current
315
369
  current = parent
316
370
 
@@ -1,44 +0,0 @@
1
- """Daemon filesystem paths and connection helpers.
2
-
3
- Lightweight module with no cocoindex dependency so that the CLI client
4
- can import these without pulling in the full daemon stack.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sys
10
- from pathlib import Path
11
-
12
- from .settings import user_settings_dir
13
-
14
-
15
- def daemon_dir() -> Path:
16
- """Return the daemon directory (``~/.cocoindex_code/``)."""
17
- return user_settings_dir()
18
-
19
-
20
- def connection_family() -> str:
21
- """Return the multiprocessing connection family for this platform."""
22
- return "AF_PIPE" if sys.platform == "win32" else "AF_UNIX"
23
-
24
-
25
- def daemon_socket_path() -> str:
26
- """Return the daemon socket/pipe address."""
27
- if sys.platform == "win32":
28
- import hashlib
29
-
30
- # Hash the daemon dir so COCOINDEX_CODE_DIR overrides create unique pipe names,
31
- # preventing conflicts between different daemon instances (tests, users, etc.)
32
- dir_hash = hashlib.md5(str(daemon_dir()).encode()).hexdigest()[:12]
33
- return rf"\\.\pipe\cocoindex_code_{dir_hash}"
34
- return str(daemon_dir() / "daemon.sock")
35
-
36
-
37
- def daemon_pid_path() -> Path:
38
- """Return the path for the daemon's PID file."""
39
- return daemon_dir() / "daemon.pid"
40
-
41
-
42
- def daemon_log_path() -> Path:
43
- """Return the path for the daemon's log file."""
44
- return daemon_dir() / "daemon.log"
File without changes