sampler-cli 0.2.0__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 (39) hide show
  1. sampler_cli-0.2.0/LICENSE +21 -0
  2. sampler_cli-0.2.0/PKG-INFO +130 -0
  3. sampler_cli-0.2.0/README.md +93 -0
  4. sampler_cli-0.2.0/pyproject.toml +64 -0
  5. sampler_cli-0.2.0/setup.cfg +4 -0
  6. sampler_cli-0.2.0/src/sampler/__init__.py +3 -0
  7. sampler_cli-0.2.0/src/sampler/__main__.py +5 -0
  8. sampler_cli-0.2.0/src/sampler/cli/__init__.py +0 -0
  9. sampler_cli-0.2.0/src/sampler/cli/main.py +187 -0
  10. sampler_cli-0.2.0/src/sampler/config.py +77 -0
  11. sampler_cli-0.2.0/src/sampler/db.py +316 -0
  12. sampler_cli-0.2.0/src/sampler/indexer/__init__.py +0 -0
  13. sampler_cli-0.2.0/src/sampler/indexer/builder.py +70 -0
  14. sampler_cli-0.2.0/src/sampler/indexer/discover.py +53 -0
  15. sampler_cli-0.2.0/src/sampler/indexer/parsers/__init__.py +0 -0
  16. sampler_cli-0.2.0/src/sampler/indexer/parsers/base.py +9 -0
  17. sampler_cli-0.2.0/src/sampler/indexer/parsers/go.py +9 -0
  18. sampler_cli-0.2.0/src/sampler/indexer/parsers/python.py +139 -0
  19. sampler_cli-0.2.0/src/sampler/indexer/parsers/typescript.py +9 -0
  20. sampler_cli-0.2.0/src/sampler/indexer/store.py +47 -0
  21. sampler_cli-0.2.0/src/sampler/mcp/__init__.py +0 -0
  22. sampler_cli-0.2.0/src/sampler/mcp/server.py +2 -0
  23. sampler_cli-0.2.0/src/sampler/models.py +35 -0
  24. sampler_cli-0.2.0/src/sampler/query/__init__.py +0 -0
  25. sampler_cli-0.2.0/src/sampler/query/engine.py +16 -0
  26. sampler_cli-0.2.0/src/sampler/query/semantic.py +4 -0
  27. sampler_cli-0.2.0/src/sampler_cli.egg-info/PKG-INFO +130 -0
  28. sampler_cli-0.2.0/src/sampler_cli.egg-info/SOURCES.txt +37 -0
  29. sampler_cli-0.2.0/src/sampler_cli.egg-info/dependency_links.txt +1 -0
  30. sampler_cli-0.2.0/src/sampler_cli.egg-info/entry_points.txt +2 -0
  31. sampler_cli-0.2.0/src/sampler_cli.egg-info/requires.txt +19 -0
  32. sampler_cli-0.2.0/src/sampler_cli.egg-info/top_level.txt +1 -0
  33. sampler_cli-0.2.0/tests/test_cli.py +23 -0
  34. sampler_cli-0.2.0/tests/test_config.py +20 -0
  35. sampler_cli-0.2.0/tests/test_db.py +22 -0
  36. sampler_cli-0.2.0/tests/test_discover.py +18 -0
  37. sampler_cli-0.2.0/tests/test_index_query.py +42 -0
  38. sampler_cli-0.2.0/tests/test_python_parser.py +26 -0
  39. sampler_cli-0.2.0/tests/test_smoke.py +10 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Ignacio Carmona Rodriguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: sampler-cli
3
+ Version: 0.2.0
4
+ Summary: Token-efficient CLI for indexing and searching code symbols (Python-first, designed for minimal LLM/agent context size)
5
+ Author: Samuel Ignacio Carmona Rodriguez
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sicr0/sampler-cli
8
+ Project-URL: Repository, https://github.com/sicr0/sampler-cli
9
+ Project-URL: Issues, https://github.com/sicr0/sampler-cli/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Software Development :: Code Generators
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: typer>=0.12.0
21
+ Requires-Dist: rich>=13.7.0
22
+ Requires-Dist: tree-sitter>=0.21.0
23
+ Requires-Dist: tree-sitter-python>=0.23.0
24
+ Requires-Dist: gitignore-parser>=0.1.11
25
+ Requires-Dist: pydantic>=2.6.0
26
+ Requires-Dist: pyyaml>=6.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.5.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.7.0; extra == "dev"
32
+ Provides-Extra: mcp
33
+ Requires-Dist: fastmcp>=0.1.0; extra == "mcp"
34
+ Provides-Extra: semantic
35
+ Requires-Dist: sentence-transformers>=2.2.0; extra == "semantic"
36
+ Dynamic: license-file
37
+
38
+ # Sampler
39
+
40
+ CLI indexer para navegar símbolos y relaciones en codebases multiproyecto.
41
+
42
+ Versión actual: 0.1.2
43
+
44
+ ## Requisitos
45
+
46
+ - Python 3.11+
47
+ - `uv` (recomendado)
48
+ - Go (instalado para soporte parser Fase 1)
49
+
50
+ ## Instalación de Go (macOS)
51
+
52
+ ```bash
53
+ brew install go
54
+ go version
55
+ ```
56
+
57
+ ## Instalación
58
+
59
+ ```bash
60
+ pip install sampler-cli
61
+ ```
62
+
63
+ Para desarrollo (incluye tests, linters):
64
+
65
+ ```bash
66
+ pip install -e '.[dev]'
67
+ ```
68
+
69
+ ## Uso rápido
70
+
71
+ ```bash
72
+ pip install sampler-cli
73
+ sampler init
74
+ sampler project add myproj /absolute/path --language python
75
+ sampler project list
76
+ sampler index myproj
77
+ sampler search add --project myproj
78
+ sampler overview /absolute/path/file.py
79
+ ```
80
+
81
+ **Demo / LLM use (token-efficient by design):**
82
+ - Default outputs are compact single-line (no tables, short paths, no noise).
83
+ - Ideal for pasting into agents/LLMs with minimal context size.
84
+ - Example: `sampler search worker --project myproj` → `myproj:src/tasks.py:42 function process def process()`
85
+
86
+ ## Estado actual
87
+
88
+ Implementado:
89
+
90
+ - Bootstrap inicial de Fase 0
91
+ - Configuración global con archivo `~/.sampler/config.yaml`
92
+ - CRUD de proyectos en config (`add`, `list`, `remove`)
93
+ - Esquema SQLite core + queries de index/search en `src/sampler/db.py`
94
+ - Discovery de archivos por lenguaje con soporte `.gitignore`
95
+ - Parser Python estable basado en AST
96
+ - Indexer real (hash incremental + persistencia)
97
+ - Query engine real (`search`, `overview`)
98
+ - CI básico con GitHub Actions (`pytest -q`)
99
+ - Tests: smoke, config, db, cli, discovery, python_parser, index_query
100
+
101
+ Nota de estabilidad:
102
+
103
+ - Se desactivó uso runtime de tree-sitter en parser Python por crash nativo (`BUS/SEGV`) en indexación real.
104
+ - Se mantiene estrategia AST para estabilidad en producción local.
105
+
106
+ Pendiente inmediato:
107
+
108
+ - Filtros y paginación en búsqueda
109
+ - Comandos `callers`, `usages`, `related`
110
+ - Parsers Go y TypeScript/JavaScript
111
+
112
+ ## Estructura clave
113
+
114
+ ```text
115
+ src/sampler/cli/main.py # comandos CLI
116
+ src/sampler/config.py # config global YAML
117
+ src/sampler/db.py # capa SQLite
118
+ src/sampler/indexer/builder.py # indexación de proyectos
119
+ src/sampler/indexer/store.py # persistencia de símbolos/relaciones
120
+ src/sampler/indexer/parsers/python.py # parser python estable
121
+ src/sampler/query/engine.py # search/overview
122
+ src/sampler/indexer/discover.py # discovery y filtros
123
+ tests/ # pruebas base
124
+ ```
125
+
126
+ ## Ejecutar pruebas
127
+
128
+ ```bash
129
+ pytest -q
130
+ ```
@@ -0,0 +1,93 @@
1
+ # Sampler
2
+
3
+ CLI indexer para navegar símbolos y relaciones en codebases multiproyecto.
4
+
5
+ Versión actual: 0.1.2
6
+
7
+ ## Requisitos
8
+
9
+ - Python 3.11+
10
+ - `uv` (recomendado)
11
+ - Go (instalado para soporte parser Fase 1)
12
+
13
+ ## Instalación de Go (macOS)
14
+
15
+ ```bash
16
+ brew install go
17
+ go version
18
+ ```
19
+
20
+ ## Instalación
21
+
22
+ ```bash
23
+ pip install sampler-cli
24
+ ```
25
+
26
+ Para desarrollo (incluye tests, linters):
27
+
28
+ ```bash
29
+ pip install -e '.[dev]'
30
+ ```
31
+
32
+ ## Uso rápido
33
+
34
+ ```bash
35
+ pip install sampler-cli
36
+ sampler init
37
+ sampler project add myproj /absolute/path --language python
38
+ sampler project list
39
+ sampler index myproj
40
+ sampler search add --project myproj
41
+ sampler overview /absolute/path/file.py
42
+ ```
43
+
44
+ **Demo / LLM use (token-efficient by design):**
45
+ - Default outputs are compact single-line (no tables, short paths, no noise).
46
+ - Ideal for pasting into agents/LLMs with minimal context size.
47
+ - Example: `sampler search worker --project myproj` → `myproj:src/tasks.py:42 function process def process()`
48
+
49
+ ## Estado actual
50
+
51
+ Implementado:
52
+
53
+ - Bootstrap inicial de Fase 0
54
+ - Configuración global con archivo `~/.sampler/config.yaml`
55
+ - CRUD de proyectos en config (`add`, `list`, `remove`)
56
+ - Esquema SQLite core + queries de index/search en `src/sampler/db.py`
57
+ - Discovery de archivos por lenguaje con soporte `.gitignore`
58
+ - Parser Python estable basado en AST
59
+ - Indexer real (hash incremental + persistencia)
60
+ - Query engine real (`search`, `overview`)
61
+ - CI básico con GitHub Actions (`pytest -q`)
62
+ - Tests: smoke, config, db, cli, discovery, python_parser, index_query
63
+
64
+ Nota de estabilidad:
65
+
66
+ - Se desactivó uso runtime de tree-sitter en parser Python por crash nativo (`BUS/SEGV`) en indexación real.
67
+ - Se mantiene estrategia AST para estabilidad en producción local.
68
+
69
+ Pendiente inmediato:
70
+
71
+ - Filtros y paginación en búsqueda
72
+ - Comandos `callers`, `usages`, `related`
73
+ - Parsers Go y TypeScript/JavaScript
74
+
75
+ ## Estructura clave
76
+
77
+ ```text
78
+ src/sampler/cli/main.py # comandos CLI
79
+ src/sampler/config.py # config global YAML
80
+ src/sampler/db.py # capa SQLite
81
+ src/sampler/indexer/builder.py # indexación de proyectos
82
+ src/sampler/indexer/store.py # persistencia de símbolos/relaciones
83
+ src/sampler/indexer/parsers/python.py # parser python estable
84
+ src/sampler/query/engine.py # search/overview
85
+ src/sampler/indexer/discover.py # discovery y filtros
86
+ tests/ # pruebas base
87
+ ```
88
+
89
+ ## Ejecutar pruebas
90
+
91
+ ```bash
92
+ pytest -q
93
+ ```
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "sampler-cli"
3
+ version = "0.2.0"
4
+ description = "Token-efficient CLI for indexing and searching code symbols (Python-first, designed for minimal LLM/agent context size)"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Samuel Ignacio Carmona Rodriguez" },
9
+ ]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ "Topic :: Software Development :: Code Generators",
18
+ ]
19
+ requires-python = ">=3.11"
20
+ dependencies = [
21
+ "typer>=0.12.0",
22
+ "rich>=13.7.0",
23
+ "tree-sitter>=0.21.0",
24
+ "tree-sitter-python>=0.23.0",
25
+ "gitignore-parser>=0.1.11",
26
+ "pydantic>=2.6.0",
27
+ "pyyaml>=6.0.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=7.4.0",
33
+ "pytest-cov>=4.1.0",
34
+ "ruff>=0.5.0",
35
+ "mypy>=1.7.0",
36
+ ]
37
+ mcp = ["fastmcp>=0.1.0"]
38
+ semantic = ["sentence-transformers>=2.2.0"]
39
+
40
+ [project.scripts]
41
+ sampler = "sampler.cli.main:app"
42
+
43
+ [build-system]
44
+ requires = ["setuptools>=68", "wheel"]
45
+ build-backend = "setuptools.build_meta"
46
+
47
+ [tool.setuptools]
48
+ package-dir = {"" = "src"}
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py311"
56
+
57
+ [tool.pytest.ini_options]
58
+ pythonpath = ["src"]
59
+ testpaths = ["tests"]
60
+
61
+ [project.urls]
62
+ Homepage = "https://github.com/sicr0/sampler-cli"
63
+ Repository = "https://github.com/sicr0/sampler-cli"
64
+ Issues = "https://github.com/sicr0/sampler-cli/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ from sampler.cli.main import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
File without changes
@@ -0,0 +1,187 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from sampler import __version__
7
+ from sampler.config import ConfigManager
8
+ from sampler.db import Database
9
+ from sampler.indexer.builder import IndexBuilder
10
+ from sampler.query.engine import QueryEngine
11
+
12
+ app = typer.Typer(help="Sampler CLI")
13
+ project_app = typer.Typer(help="Project management commands")
14
+ app.add_typer(project_app, name="project")
15
+ console = Console()
16
+
17
+
18
+ def _database() -> Database:
19
+ cfg = ConfigManager().load()
20
+ db_path = Path(cfg.cache_dir).expanduser() / "graph.db"
21
+ db = Database(db_path=db_path)
22
+ db.init_schema()
23
+ return db
24
+
25
+
26
+ def _get_project_roots() -> dict[str, Path]:
27
+ """Map project name -> absolute root path for relative path computation."""
28
+ config = ConfigManager()
29
+ roots: dict[str, Path] = {}
30
+ for p in config.list_projects():
31
+ try:
32
+ roots[p.name] = Path(p.path).expanduser().resolve()
33
+ except Exception:
34
+ pass
35
+ return roots
36
+
37
+
38
+ def _short_path(project_name: str, full_path: str, roots: dict[str, Path]) -> str:
39
+ """Return shortest useful path for display: relative to project root if possible, else tail or name."""
40
+ root = roots.get(project_name)
41
+ if root:
42
+ try:
43
+ return str(Path(full_path).resolve().relative_to(root))
44
+ except Exception:
45
+ pass
46
+ # Fallback: last 1-2 path segments to keep output short (token friendly)
47
+ p = Path(full_path)
48
+ if len(p.parts) >= 3:
49
+ return "/".join(p.parts[-2:])
50
+ return p.name
51
+
52
+
53
+ @app.command("version")
54
+ def version() -> None:
55
+ """Show installed sampler version."""
56
+ console.print(f"sampler {__version__}")
57
+
58
+
59
+ @app.command("init")
60
+ def init() -> None:
61
+ """Initialize sampler local data directory."""
62
+ config = ConfigManager()
63
+ config.load()
64
+ data_dir = Path.home() / ".sampler"
65
+ console.print(f"Initialized [bold]{data_dir}[/bold]")
66
+
67
+
68
+ @project_app.command("list")
69
+ def project_list() -> None:
70
+ """List registered projects."""
71
+ config = ConfigManager()
72
+ projects = config.list_projects()
73
+ home = str(Path.home().resolve())
74
+
75
+ for project in projects:
76
+ try:
77
+ pp = Path(project.path).resolve()
78
+ ps = str(pp)
79
+ if ps.startswith(home):
80
+ disp = "~" + ps[len(home):]
81
+ else:
82
+ parts = pp.parts
83
+ disp = "/".join(parts[-2:]) if len(parts) > 2 else ps
84
+ except Exception:
85
+ disp = project.path
86
+ console.print(f"{project.name} {disp}")
87
+
88
+
89
+ @project_app.command("add")
90
+ def project_add(name: str, path: str, language: str = "python") -> None:
91
+ """Register project in global config."""
92
+ config = ConfigManager()
93
+ try:
94
+ project = config.add_project(name=name, path=path, language=language)
95
+ except ValueError as exc:
96
+ raise typer.BadParameter(str(exc)) from exc
97
+ console.print(f"Added project [bold]{project.name}[/bold]")
98
+
99
+
100
+ @project_app.command("remove")
101
+ def project_remove(name: str) -> None:
102
+ """Remove project from global config."""
103
+ config = ConfigManager()
104
+ try:
105
+ config.remove_project(name)
106
+ except ValueError as exc:
107
+ raise typer.BadParameter(str(exc)) from exc
108
+ console.print(f"Removed project [bold]{name}[/bold]")
109
+
110
+
111
+ @app.command("search")
112
+ def search(
113
+ query: str,
114
+ project: str | None = typer.Option(None, "--project", "-p"),
115
+ type: str | None = typer.Option(None, "--type", "-t", help="filter e.g. function,class"),
116
+ limit: int = typer.Option(100, "--limit", "-l"),
117
+ ) -> None:
118
+ """Search symbols by name."""
119
+ engine = QueryEngine(db=_database())
120
+ types = [x.strip() for x in type.split(",")] if type else None
121
+ if types:
122
+ exp = set(types)
123
+ for t in list(types):
124
+ if t == "function": exp.add("async function")
125
+ elif t == "method": exp.add("async method")
126
+ types = list(exp)
127
+ rows = engine.search(query=query, project_name=project, types=types, limit=limit)
128
+ roots = _get_project_roots()
129
+
130
+ for r in rows:
131
+ shortf = _short_path(r["project_name"], r["file_path"], roots)
132
+ name = r["qualified_name"] or r["name"]
133
+ sig = r.get("signature") or ""
134
+ line = f"{r['project_name']}:{shortf}:{r['start_line'] or '-'} {r['type']} {name}"
135
+ if sig:
136
+ line += f" {sig}"
137
+ console.print(line)
138
+
139
+
140
+ @app.command("search-all")
141
+ def search_all(
142
+ query: str,
143
+ type: str | None = typer.Option(None, "--type", "-t", help="filter e.g. function,class"),
144
+ limit: int = typer.Option(100, "--limit", "-l"),
145
+ ) -> None:
146
+ """Search symbols across ALL projects."""
147
+ search(query=query, project=None, type=type, limit=limit)
148
+
149
+
150
+ @app.command("index")
151
+ def index(project: str) -> None:
152
+ """Index selected project."""
153
+ config = ConfigManager()
154
+ project_cfg = config.get_project(project)
155
+ if project_cfg is None:
156
+ raise typer.BadParameter(f"Project '{project}' not found. Use 'sampler project list'.")
157
+
158
+ builder = IndexBuilder(db=_database())
159
+ stats = builder.index_project(
160
+ project_name=project_cfg.name,
161
+ project_path=project_cfg.path,
162
+ language=project_cfg.language,
163
+ )
164
+ console.print(
165
+ "Indexed project "
166
+ f"[bold]{stats['project']}[/bold]: discovered={stats['discovered']} indexed={stats['indexed']} "
167
+ f"skipped={stats['skipped']} failed={stats['failed']}"
168
+ )
169
+
170
+
171
+ @app.command("overview")
172
+ def overview(filepath: str) -> None:
173
+ """Show symbols for file."""
174
+ engine = QueryEngine(db=_database())
175
+ rows = engine.overview(filepath=filepath)
176
+
177
+ for r in rows:
178
+ name = r["qualified_name"] or r["name"]
179
+ sig = r.get("signature") or ""
180
+ line = f"{r['start_line'] or '-'}: {r['type']} {name}"
181
+ if sig:
182
+ line += f" {sig}"
183
+ console.print(line)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ app()
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ def default_data_dir() -> Path:
8
+ return Path.home() / ".sampler"
9
+
10
+
11
+ class ProjectConfig(BaseModel):
12
+ name: str
13
+ path: str
14
+ language: str
15
+ enabled: bool = True
16
+
17
+
18
+ class GlobalConfig(BaseModel):
19
+ version: int = 1
20
+ cache_dir: str = str(default_data_dir())
21
+ projects: dict[str, ProjectConfig] = Field(default_factory=dict)
22
+
23
+
24
+ class ConfigManager:
25
+ def __init__(self, config_path: Path | None = None) -> None:
26
+ self.config_path = config_path or (default_data_dir() / "config.yaml")
27
+
28
+ def load(self) -> GlobalConfig:
29
+ if not self.config_path.exists():
30
+ config = GlobalConfig()
31
+ self.save(config)
32
+ return config
33
+
34
+ with self.config_path.open("r", encoding="utf-8") as f:
35
+ raw = yaml.safe_load(f) or {}
36
+ config = GlobalConfig.model_validate(raw)
37
+
38
+ if config.version != 1:
39
+ config.version = 1
40
+ self.save(config)
41
+
42
+ return config
43
+
44
+ def save(self, config: GlobalConfig) -> None:
45
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload = config.model_dump(mode="python")
47
+ with self.config_path.open("w", encoding="utf-8") as f:
48
+ yaml.safe_dump(payload, f, sort_keys=False)
49
+
50
+ def add_project(self, name: str, path: str, language: str, enabled: bool = True) -> ProjectConfig:
51
+ config = self.load()
52
+ if name in config.projects:
53
+ raise ValueError(f"Project '{name}' already exists")
54
+
55
+ project_path = str(Path(path).expanduser().resolve())
56
+ if not Path(project_path).exists():
57
+ raise ValueError(f"Project path does not exist: {project_path}")
58
+
59
+ project = ProjectConfig(name=name, path=project_path, language=language, enabled=enabled)
60
+ config.projects[name] = project
61
+ self.save(config)
62
+ return project
63
+
64
+ def remove_project(self, name: str) -> None:
65
+ config = self.load()
66
+ if name not in config.projects:
67
+ raise ValueError(f"Project '{name}' does not exist")
68
+ del config.projects[name]
69
+ self.save(config)
70
+
71
+ def get_project(self, name: str) -> ProjectConfig | None:
72
+ config = self.load()
73
+ return config.projects.get(name)
74
+
75
+ def list_projects(self) -> list[ProjectConfig]:
76
+ config = self.load()
77
+ return list(config.projects.values())