Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,28 @@ OMP does **not** execute Claude `hooks/hooks.json` command hooks. The TypeScript

Skill-only (no MCP, no auto-index): install the skill via `npx skills add cocoindex-io/cocoindex-code`, or run `ccc search` / `ccc index` from the shell.

### Compare indexes in one checkout

Set `COCOINDEX_CODE_PROFILE` to use a named profile with its own embedding
configuration, file filters, databases, and daemon.

```bash
COCOINDEX_CODE_PROFILE=baseline ccc init
COCOINDEX_CODE_PROFILE=baseline ccc index
COCOINDEX_CODE_PROFILE=experiment ccc init
COCOINDEX_CODE_PROFILE=experiment ccc index
COCOINDEX_CODE_PROFILE=experiment ccc search "parse configuration"
```

Each `init` prompts for a model on first use. Edit
`.cocoindex_code/profiles/profile-<name>/settings.yml` in the checkout to set
profile-specific file filters. The corresponding global settings live under
`~/.cocoindex_code/profiles/profile-<name>/global_settings.yml` (or under
`COCOINDEX_CODE_DIR`). Use the same environment variable for `ccc status`,
`ccc reset`, or when launching the MCP server. Unset it to use the original
settings and index. In PowerShell, set it with
`$env:COCOINDEX_CODE_PROFILE = "experiment"`.

### MCP Server

<!-- Ownership marker for the official MCP registry. It must match "name" in server.json. Keep the next line. -->
Expand Down
4 changes: 2 additions & 2 deletions src/cocoindex_code/_daemon_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pathlib import Path
from typing import NamedTuple

from .settings import user_settings_dir
from .settings import profile_directory, user_settings_dir

# Max bytes in sockaddr_un.sun_path, including the NUL terminator: 104 on the
# BSDs (incl. macOS), 108 on Linux. Exceeding it makes bind() fail with
Expand All @@ -38,7 +38,7 @@ def daemon_runtime_dir() -> Path:
"""
override = os.environ.get("COCOINDEX_CODE_RUNTIME_DIR")
if override:
return Path(override)
return profile_directory(Path(override))
return user_settings_dir()


Expand Down
24 changes: 13 additions & 11 deletions src/cocoindex_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ._version import __version__
from .settings import (
DEFAULT_ST_MODEL,
PROFILE_ENV,
EmbeddingSettings,
auto_init_refusal,
cocoindex_db_path,
Expand All @@ -33,6 +34,7 @@
find_project_root,
format_path_for_display,
normalize_input_path,
project_has_profiles,
project_settings_path,
resolve_db_dir,
save_initial_user_settings,
Expand Down Expand Up @@ -61,6 +63,11 @@ def _apply_host_cwd() -> None:
cwd-driven discovery (``find_project_root`` etc.) sees the user's real
project subtree. Unset → no-op.
"""
try:
user_settings_path()
except ValueError as e:
raise _typer.BadParameter(str(e), param_hint=PROFILE_ENV) from e

host_cwd = os.environ.get("COCOINDEX_CODE_HOST_CWD")
if not host_cwd:
return
Expand Down Expand Up @@ -818,7 +825,6 @@ def reset(
) -> None:
"""Reset project databases and optionally remove settings."""
project_root = require_project_root()
cocoindex_dir = project_root / ".cocoindex_code"
db_dir = resolve_db_dir(project_root)

db_files = [
Expand Down Expand Up @@ -867,20 +873,16 @@ def reset(
f.unlink(missing_ok=True)

if all_:
# Remove db_dir if empty and different from cocoindex_dir
if db_dir != cocoindex_dir:
for directory in {db_dir, settings_file.parent}:
try:
db_dir.rmdir()
directory.rmdir()
except OSError:
pass # Not empty or doesn't exist
# Remove .cocoindex_code/ if empty
try:
cocoindex_dir.rmdir()
except OSError:
pass # Not empty

# Remove from .gitignore
remove_from_gitignore(project_root)
# The shared ignore entry still protects any other initialized profile.
default_settings = project_root / ".cocoindex_code" / "settings.yml"
if not default_settings.is_file() and not project_has_profiles(project_root):
remove_from_gitignore(project_root)
_typer.echo("Project fully reset.")
else:
_typer.echo("Databases deleted.")
Expand Down
7 changes: 6 additions & 1 deletion src/cocoindex_code/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
encode_response,
)
from .settings import (
PROFILE_ENV,
ChunkerMapping,
DaemonSettings,
UserSettings,
Expand Down Expand Up @@ -744,9 +745,13 @@ def run_daemon(
if user_settings_path().is_file():
user_settings = load_user_settings()
daemon_settings = user_settings.daemon
settings_env_keys = list(user_settings.envs.keys())
settings_env_keys = []
for key, value in user_settings.envs.items():
if key == PROFILE_ENV:
logger.warning("%s must be set before starting ccc; ignoring it in envs", key)
continue
os.environ[key] = value
settings_env_keys.append(key)
clear_mps_cache_after_index = configure_mps_environment(user_settings.embedding)
# Resolve params BEFORE constructing the embedder so invalid configs
# fail fast without paying the model-load cost.
Expand Down
46 changes: 36 additions & 10 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -192,6 +193,28 @@ def default_project_settings() -> ProjectSettings:
_SETTINGS_FILE_NAME = "settings.yml" # project-level
_USER_SETTINGS_FILE_NAME = "global_settings.yml" # user-level

PROFILE_ENV = "COCOINDEX_CODE_PROFILE"
_PROFILE_DIR = "profiles"


def profile_directory(base: Path) -> Path:
"""Scope a settings, database, or runtime directory to the selected profile."""
profile = os.environ.get(PROFILE_ENV)
if not profile:
return base
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile):
raise ValueError(
f"{PROFILE_ENV} must be 1–64 lowercase letters, digits, underscores, or hyphens "
"and start with a letter or digit"
)
return base / _PROFILE_DIR / f"profile-{profile}"


def project_has_profiles(project_root: Path) -> bool:
profiles = project_root / _SETTINGS_DIR_NAME / _PROFILE_DIR
return any(path.is_file() for path in profiles.glob(f"profile-*/{_SETTINGS_FILE_NAME}"))


_ENV_DB_PATH_MAPPING = "COCOINDEX_CODE_DB_PATH_MAPPING"
_ENV_HOST_PATH_MAPPING = "COCOINDEX_CODE_HOST_PATH_MAPPING"

Expand Down Expand Up @@ -269,8 +292,8 @@ def resolve_db_dir(project_root: Path) -> Path:
for mapping in _db_path_mapping:
if resolved == mapping.source or resolved.is_relative_to(mapping.source):
rel = resolved.relative_to(mapping.source)
return mapping.target / rel
return project_root / _SETTINGS_DIR_NAME
return profile_directory(mapping.target / rel)
return profile_directory(project_root / _SETTINGS_DIR_NAME)


def get_db_path_mappings() -> list[PathMapping]:
Expand Down Expand Up @@ -333,7 +356,7 @@ def cocoindex_db_path(project_root: Path) -> Path:
return resolve_db_dir(project_root) / _COCOINDEX_DB_NAME


def user_settings_dir() -> Path:
def _base_user_settings_dir() -> Path:
"""Return ``~/.cocoindex_code/``.

Respects ``COCOINDEX_CODE_DIR`` env var for overriding the base directory.
Expand All @@ -344,14 +367,19 @@ def user_settings_dir() -> Path:
return Path.home() / _SETTINGS_DIR_NAME


def user_settings_dir() -> Path:
"""Return the user settings directory for the selected profile."""
return profile_directory(_base_user_settings_dir())


def user_settings_path() -> Path:
"""Return ``~/.cocoindex_code/global_settings.yml``."""
return user_settings_dir() / _USER_SETTINGS_FILE_NAME


def project_settings_path(project_root: Path) -> Path:
"""Return ``$PROJECT_ROOT/.cocoindex_code/settings.yml``."""
return project_root / _SETTINGS_DIR_NAME / _SETTINGS_FILE_NAME
return profile_directory(project_root / _SETTINGS_DIR_NAME) / _SETTINGS_FILE_NAME


def is_user_settings_root(directory: Path) -> bool:
Expand All @@ -362,7 +390,7 @@ def is_user_settings_root(directory: Path) -> bool:
A project there covers every directory below it, so ccc never creates one
automatically; ``ccc init`` run there by hand still does.
"""
return (directory / _SETTINGS_DIR_NAME).resolve() == user_settings_dir().resolve()
return (directory / _SETTINGS_DIR_NAME).resolve() == _base_user_settings_dir().resolve()


def auto_init_refusal(directory: Path) -> str | None:
Expand Down Expand Up @@ -409,7 +437,7 @@ def find_project_root(start: Path) -> Path | None:
"""
current = start.resolve()
while True:
if (current / _SETTINGS_DIR_NAME / _SETTINGS_FILE_NAME).is_file():
if project_settings_path(current).is_file():
return current
parent = current.parent
if parent == current:
Expand All @@ -425,7 +453,7 @@ def find_legacy_project_root(start: Path) -> Path | None:
"""
current = start.resolve()
while True:
if (current / _SETTINGS_DIR_NAME / _COCOINDEX_DB_NAME).exists():
if (profile_directory(current / _SETTINGS_DIR_NAME) / _COCOINDEX_DB_NAME).exists():
return current
parent = current.parent
if parent == current:
Expand All @@ -452,9 +480,7 @@ def find_parent_with_marker(start: Path) -> Path | None:
parent = current.parent
if parent == current:
return None
if (current / _SETTINGS_DIR_NAME / _SETTINGS_FILE_NAME).is_file() or (
current / ".git"
).is_dir():
if project_settings_path(current).is_file() or (current / ".git").is_dir():
return current
current = parent

Expand Down
87 changes: 87 additions & 0 deletions tests/test_index_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Two configurations can index and search the same checkout independently."""

from pathlib import Path
from typing import cast

import numpy as np
import pytest
from cocoindex.resources.schema import VectorSchema
from typer.testing import CliRunner

from cocoindex_code import cli, client, settings
from cocoindex_code._daemon_paths import daemon_socket_path
from cocoindex_code.project import Project
from cocoindex_code.shared import Embedder


class _Embedder:
def __coco_memo_key__(self) -> str:
return "profile-test-embedder"

async def __coco_vector_schema__(self) -> VectorSchema:
return VectorSchema(dtype=np.dtype("float32"), size=4)

async def embed(self, text: str, **kwargs: object) -> np.ndarray:
return np.array([1, 0, 0, 0], dtype=np.float32)


async def test_profile_isolation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
repo = tmp_path / "repo"
(repo / ".git").mkdir(parents=True)
(repo / "alpha.py").write_text("def alpha():\n return 1\n")
(repo / "beta.py").write_text("def beta():\n return 2\n")
monkeypatch.chdir(repo)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(tmp_path / "user"))
monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", str(tmp_path / "run"))
monkeypatch.setenv("COCOINDEX_CODE_DB_PATH_MAPPING", f"{repo}={tmp_path / 'db'}")
settings._reset_db_path_mapping_cache()
monkeypatch.setattr(client, "remove_project", lambda _root: None)
monkeypatch.delenv(settings.PROFILE_ENV, raising=False)
settings.save_project_settings(repo, settings.ProjectSettings())
default_settings = settings.project_settings_path(repo)
projects: dict[str, Project] = {}
addresses = set()
runner = CliRunner()
try:
for name in ("alpha", "beta"):
monkeypatch.setenv(settings.PROFILE_ENV, name)
assert settings.find_project_root(repo) is None
user_settings = settings.UserSettings(settings.EmbeddingSettings(model=name))
settings.save_user_settings(user_settings)
initialized = runner.invoke(cli.app, ["init"])
assert initialized.exit_code == 0, initialized.output
settings.save_project_settings(
repo, settings.ProjectSettings(include_patterns=[f"{name}.py"])
)
assert settings.resolve_db_dir(repo).is_relative_to(tmp_path / "db")
addresses.add(daemon_socket_path())
projects[name] = await Project.create(repo, cast(Embedder, _Embedder()), {}, {})
await projects[name].run_index()

assert len(addresses) == 2
for name, project in projects.items():
monkeypatch.setenv(settings.PROFILE_ENV, name)
assert settings.load_user_settings().embedding.model == name
assert [result.file_path for result in await project.search(name)] == [f"{name}.py"]
project.close()

reset = runner.invoke(cli.app, ["reset", "--all", "--force"])
assert reset.exit_code == 0, reset.output
assert not settings.target_sqlite_db_path(repo).exists()
assert not settings.project_settings_path(repo).exists()
assert default_settings.is_file()
assert "/.cocoindex_code/" in (repo / ".gitignore").read_text()

monkeypatch.setenv(settings.PROFILE_ENV, "alpha")
assert settings.target_sqlite_db_path(repo).is_file()
assert settings.project_settings_path(repo).is_file()
finally:
settings._reset_db_path_mapping_cache()


@pytest.mark.parametrize("name", ["../escape", "UPPERCASE", "foo/bar", "foo\\bar"])
def test_invalid_profile_is_rejected(monkeypatch: pytest.MonkeyPatch, name: str) -> None:
monkeypatch.setenv(settings.PROFILE_ENV, name)
result = CliRunner().invoke(cli.app, ["status"])
assert result.exit_code == 2
assert "COCOINDEX_CODE_PROFILE" in result.output