cargai 0.1.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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
cargai-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lionel Harry
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.
cargai-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: cargai
3
+ Version: 0.1.0
4
+ Summary: Toolkit CLI pour installer et gérer des outils tiers liés à l'IA (headroom-ai, ...)
5
+ Project-URL: Repository, https://github.com/LionelHARRY/cargai
6
+ Project-URL: Issues, https://github.com/LionelHARRY/cargai/issues
7
+ Author: Lionel Harry
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,cli,installer,llm,toolkit
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: rich>=13
16
+ Requires-Dist: tomli>=2; python_version < '3.11'
17
+ Requires-Dist: typer>=0.12
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # cargai
23
+
24
+ Toolkit CLI pour installer et gérer des outils tiers liés à l'IA, à partir d'un catalogue déclaratif.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ # recommandé
30
+ uv tool install cargai # ou depuis le repo : uv tool install .
31
+ # alternative
32
+ pipx install cargai
33
+ ```
34
+
35
+ ## Utilisation
36
+
37
+ ```bash
38
+ cargai list # catalogue + statut d'installation
39
+ cargai info headroom # détails d'un outil
40
+ cargai install headroom # installe headroom-ai[proxy]
41
+ cargai install headroom -e all # avec d'autres extras
42
+ cargai install headroom --dry-run # affiche la commande sans l'exécuter
43
+ cargai install headroom -U # met à jour
44
+ cargai uninstall headroom
45
+ cargai doctor # vérifie uv / pipx / npm / brew
46
+ ```
47
+
48
+ Les outils Python sont installés isolément via `uv tool` (ou `pipx` à défaut), chacun dans son propre environnement.
49
+
50
+ ## Ajouter un outil
51
+
52
+ Chaque outil est un fichier TOML :
53
+
54
+ - intégré au package : `src/cargai/catalog/<nom>.toml`
55
+ - perso (sans modifier le package) : `~/.config/cargai/tools/<nom>.toml` — prioritaire sur le catalogue intégré
56
+
57
+ ```toml
58
+ name = "mon-outil"
59
+ description = "Ce que fait l'outil."
60
+ homepage = "https://..."
61
+
62
+ [install]
63
+ method = "python" # python | npm | brew
64
+ package = "mon-outil" # nom du package PyPI / npm / formule brew
65
+ extras = [] # python uniquement
66
+ python = "3.12" # optionnel, version de Python à utiliser
67
+ binaries = ["mon-outil"] # sert à détecter si l'outil est installé
68
+
69
+ [post_install]
70
+ message = "Lance : mon-outil --help"
71
+ ```
72
+
73
+ ## Développement
74
+
75
+ ```bash
76
+ python3 -m venv .venv && source .venv/bin/activate
77
+ pip install -e ".[dev]"
78
+ pytest
79
+ ```
80
+
81
+ ## Publication
82
+
83
+ Le package PyPI et la commande portent le même nom : `cargai`.
84
+
85
+ ```bash
86
+ uv build && uv publish # ou : python -m build && twine upload dist/*
87
+ ```
88
+
89
+ ## Licence
90
+
91
+ MIT — voir [LICENSE](LICENSE).
cargai-0.1.0/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # cargai
2
+
3
+ Toolkit CLI pour installer et gérer des outils tiers liés à l'IA, à partir d'un catalogue déclaratif.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # recommandé
9
+ uv tool install cargai # ou depuis le repo : uv tool install .
10
+ # alternative
11
+ pipx install cargai
12
+ ```
13
+
14
+ ## Utilisation
15
+
16
+ ```bash
17
+ cargai list # catalogue + statut d'installation
18
+ cargai info headroom # détails d'un outil
19
+ cargai install headroom # installe headroom-ai[proxy]
20
+ cargai install headroom -e all # avec d'autres extras
21
+ cargai install headroom --dry-run # affiche la commande sans l'exécuter
22
+ cargai install headroom -U # met à jour
23
+ cargai uninstall headroom
24
+ cargai doctor # vérifie uv / pipx / npm / brew
25
+ ```
26
+
27
+ Les outils Python sont installés isolément via `uv tool` (ou `pipx` à défaut), chacun dans son propre environnement.
28
+
29
+ ## Ajouter un outil
30
+
31
+ Chaque outil est un fichier TOML :
32
+
33
+ - intégré au package : `src/cargai/catalog/<nom>.toml`
34
+ - perso (sans modifier le package) : `~/.config/cargai/tools/<nom>.toml` — prioritaire sur le catalogue intégré
35
+
36
+ ```toml
37
+ name = "mon-outil"
38
+ description = "Ce que fait l'outil."
39
+ homepage = "https://..."
40
+
41
+ [install]
42
+ method = "python" # python | npm | brew
43
+ package = "mon-outil" # nom du package PyPI / npm / formule brew
44
+ extras = [] # python uniquement
45
+ python = "3.12" # optionnel, version de Python à utiliser
46
+ binaries = ["mon-outil"] # sert à détecter si l'outil est installé
47
+
48
+ [post_install]
49
+ message = "Lance : mon-outil --help"
50
+ ```
51
+
52
+ ## Développement
53
+
54
+ ```bash
55
+ python3 -m venv .venv && source .venv/bin/activate
56
+ pip install -e ".[dev]"
57
+ pytest
58
+ ```
59
+
60
+ ## Publication
61
+
62
+ Le package PyPI et la commande portent le même nom : `cargai`.
63
+
64
+ ```bash
65
+ uv build && uv publish # ou : python -m build && twine upload dist/*
66
+ ```
67
+
68
+ ## Licence
69
+
70
+ MIT — voir [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cargai"
7
+ version = "0.1.0"
8
+ description = "Toolkit CLI pour installer et gérer des outils tiers liés à l'IA (headroom-ai, ...)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Lionel Harry" }]
14
+ keywords = ["ai", "llm", "cli", "installer", "toolkit"]
15
+ classifiers = [
16
+ "Environment :: Console",
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "typer>=0.12",
22
+ "rich>=13",
23
+ "tomli>=2; python_version < '3.11'",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/LionelHARRY/cargai"
28
+ Issues = "https://github.com/LionelHARRY/cargai/issues"
29
+
30
+ [project.scripts]
31
+ cargai = "cargai.cli:app"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=8"]
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/cargai"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from cargai.cli import app
2
+
3
+ app()
@@ -0,0 +1,20 @@
1
+ name = "headroom"
2
+ display_name = "Headroom"
3
+ description = "Couche d'optimisation de contexte pour LLM : compresse les prompts et réduit les coûts de 50 à 90 %."
4
+ homepage = "https://docs.headroomlabs.ai"
5
+ repository = "https://github.com/headroomlabs-ai/headroom"
6
+ tags = ["context", "proxy", "cost"]
7
+
8
+ [install]
9
+ method = "python" # python | npm | brew
10
+ package = "headroom-ai"
11
+ extras = ["proxy"] # extras installés par défaut (surchargeable avec --extras)
12
+ python = "3.12" # headroom-ai exige Python >= 3.10
13
+ binaries = ["headroom"]
14
+
15
+ [post_install]
16
+ message = """
17
+ Démarrer le proxy : headroom proxy --port 8787
18
+ Puis pointer ton client vers http://localhost:8787 (ex. ANTHROPIC_BASE_URL / OPENAI_BASE_URL).
19
+ Docs : https://docs.headroomlabs.ai/docs
20
+ """
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from typing import List, Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from cargai import __version__
12
+ from cargai.installers import InstallerError, install_command, is_installed, run, uninstall_command
13
+ from cargai.registry import RegistryError, get_tool, load_catalog, user_catalog_dir
14
+
15
+ app = typer.Typer(help="cargai — installe et gère des outils tiers liés à l'IA.", no_args_is_help=True)
16
+ console = Console()
17
+
18
+
19
+ def _fail(message: str) -> None:
20
+ console.print(f"[red]✗[/red] {message}")
21
+ raise typer.Exit(1)
22
+
23
+
24
+ def _version_callback(value: bool) -> None:
25
+ if value:
26
+ console.print(f"cargai {__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True),
33
+ ) -> None:
34
+ pass
35
+
36
+
37
+ @app.command("list")
38
+ def list_tools() -> None:
39
+ """Liste les outils disponibles dans le catalogue."""
40
+ try:
41
+ catalog = load_catalog()
42
+ except RegistryError as e:
43
+ _fail(str(e))
44
+ table = Table(title="Catalogue cargai")
45
+ table.add_column("Nom", style="bold cyan")
46
+ table.add_column("Description")
47
+ table.add_column("Méthode")
48
+ table.add_column("Installé", justify="center")
49
+ for tool in sorted(catalog.values(), key=lambda t: t.name):
50
+ table.add_row(tool.name, tool.description, tool.method, "✓" if is_installed(tool) else "—")
51
+ console.print(table)
52
+
53
+
54
+ @app.command()
55
+ def info(name: str) -> None:
56
+ """Affiche le détail d'un outil."""
57
+ try:
58
+ tool = get_tool(name)
59
+ except RegistryError as e:
60
+ _fail(str(e))
61
+ lines = [
62
+ tool.description,
63
+ "",
64
+ f"[bold]Package :[/bold] {tool.package} ({tool.method})",
65
+ f"[bold]Extras :[/bold] {', '.join(tool.extras) or '—'}",
66
+ f"[bold]Binaires :[/bold] {', '.join(tool.binaries) or '—'}",
67
+ f"[bold]Installé :[/bold] {'oui' if is_installed(tool) else 'non'}",
68
+ ]
69
+ if tool.homepage:
70
+ lines.append(f"[bold]Site :[/bold] {tool.homepage}")
71
+ if tool.repository:
72
+ lines.append(f"[bold]Repo :[/bold] {tool.repository}")
73
+ console.print(Panel("\n".join(lines), title=tool.title, expand=False))
74
+
75
+
76
+ @app.command()
77
+ def install(
78
+ names: List[str] = typer.Argument(..., help="Outil(s) à installer"),
79
+ extras: Optional[str] = typer.Option(
80
+ None, "--extras", "-e", help="Extras Python séparés par des virgules (remplace ceux par défaut). '' pour aucun."
81
+ ),
82
+ upgrade: bool = typer.Option(False, "--upgrade", "-U", help="Réinstalle / met à jour l'outil."),
83
+ dry_run: bool = typer.Option(False, "--dry-run", help="Affiche les commandes sans les exécuter."),
84
+ ) -> None:
85
+ """Installe un ou plusieurs outils."""
86
+ chosen_extras = None if extras is None else [x.strip() for x in extras.split(",") if x.strip()]
87
+ for name in names:
88
+ try:
89
+ tool = get_tool(name)
90
+ if is_installed(tool) and not upgrade:
91
+ console.print(f"[green]✓[/green] {tool.title} est déjà installé (utilise --upgrade pour mettre à jour).")
92
+ continue
93
+ cmd = install_command(tool, chosen_extras, upgrade)
94
+ console.print(f"[bold]→ {tool.title}[/bold] : [dim]{' '.join(cmd)}[/dim]")
95
+ if dry_run:
96
+ continue
97
+ run(cmd)
98
+ except (RegistryError, InstallerError) as e:
99
+ _fail(str(e))
100
+ console.print(f"[green]✓[/green] {tool.title} installé.")
101
+ if tool.post_install:
102
+ console.print(Panel(tool.post_install, title="Pour commencer", expand=False))
103
+
104
+
105
+ @app.command()
106
+ def uninstall(
107
+ names: List[str] = typer.Argument(..., help="Outil(s) à désinstaller"),
108
+ dry_run: bool = typer.Option(False, "--dry-run", help="Affiche les commandes sans les exécuter."),
109
+ ) -> None:
110
+ """Désinstalle un ou plusieurs outils."""
111
+ for name in names:
112
+ try:
113
+ tool = get_tool(name)
114
+ cmd = uninstall_command(tool)
115
+ console.print(f"[bold]→ {tool.title}[/bold] : [dim]{' '.join(cmd)}[/dim]")
116
+ if dry_run:
117
+ continue
118
+ run(cmd)
119
+ except (RegistryError, InstallerError) as e:
120
+ _fail(str(e))
121
+ console.print(f"[green]✓[/green] {tool.title} désinstallé.")
122
+
123
+
124
+ @app.command()
125
+ def doctor() -> None:
126
+ """Vérifie les prérequis (uv/pipx, npm, brew) et le catalogue."""
127
+ checks = {
128
+ "uv": "recommandé pour les outils Python",
129
+ "pipx": "alternative à uv",
130
+ "npm": "outils Node.js",
131
+ "brew": "outils Homebrew",
132
+ }
133
+ for binary, purpose in checks.items():
134
+ found = shutil.which(binary)
135
+ mark = "[green]✓[/green]" if found else "[yellow]—[/yellow]"
136
+ console.print(f"{mark} {binary:<5} {found or 'absent'} [dim]({purpose})[/dim]")
137
+ try:
138
+ catalog = load_catalog()
139
+ console.print(f"[green]✓[/green] catalogue : {len(catalog)} outil(s) chargé(s)")
140
+ except RegistryError as e:
141
+ console.print(f"[red]✗[/red] catalogue : {e}")
142
+ console.print(f"[dim]Outils perso : ajoute des fichiers .toml dans {user_catalog_dir()}[/dim]")
@@ -0,0 +1,80 @@
1
+ """Backends d'installation : chaque méthode traduit un Tool en commandes shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from typing import List, Optional
8
+
9
+ from cargai.registry import Tool
10
+
11
+
12
+ class InstallerError(Exception):
13
+ pass
14
+
15
+
16
+ def _python_spec(tool: Tool, extras: Optional[List[str]]) -> str:
17
+ chosen = tool.extras if extras is None else extras
18
+ return f"{tool.package}[{','.join(chosen)}]" if chosen else tool.package
19
+
20
+
21
+ def _python_backend() -> str:
22
+ for backend in ("uv", "pipx"):
23
+ if shutil.which(backend):
24
+ return backend
25
+ raise InstallerError(
26
+ "Ni 'uv' ni 'pipx' n'est installé. Installe uv : "
27
+ "curl -LsSf https://astral.sh/uv/install.sh | sh"
28
+ )
29
+
30
+
31
+ def _require(binary: str, hint: str) -> None:
32
+ if not shutil.which(binary):
33
+ raise InstallerError(f"'{binary}' est introuvable. {hint}")
34
+
35
+
36
+ def install_command(tool: Tool, extras: Optional[List[str]] = None, upgrade: bool = False) -> List[str]:
37
+ if tool.method == "python":
38
+ spec = _python_spec(tool, extras)
39
+ if _python_backend() == "uv":
40
+ cmd = ["uv", "tool", "install", spec]
41
+ if tool.python:
42
+ cmd += ["--python", tool.python]
43
+ if upgrade:
44
+ cmd.append("--force")
45
+ return cmd
46
+ cmd = ["pipx", "install", spec]
47
+ if tool.python:
48
+ cmd += ["--python", f"python{tool.python}"]
49
+ if upgrade:
50
+ cmd.append("--force")
51
+ return cmd
52
+ if tool.method == "npm":
53
+ _require("npm", "Installe Node.js : https://nodejs.org")
54
+ return ["npm", "install", "-g", f"{tool.package}@latest" if upgrade else tool.package]
55
+ if tool.method == "brew":
56
+ _require("brew", "Installe Homebrew : https://brew.sh")
57
+ return ["brew", "upgrade" if upgrade else "install", tool.package]
58
+ raise InstallerError(f"Méthode inconnue : {tool.method}")
59
+
60
+
61
+ def uninstall_command(tool: Tool) -> List[str]:
62
+ if tool.method == "python":
63
+ if _python_backend() == "uv":
64
+ return ["uv", "tool", "uninstall", tool.package]
65
+ return ["pipx", "uninstall", tool.package]
66
+ if tool.method == "npm":
67
+ return ["npm", "uninstall", "-g", tool.package]
68
+ if tool.method == "brew":
69
+ return ["brew", "uninstall", tool.package]
70
+ raise InstallerError(f"Méthode inconnue : {tool.method}")
71
+
72
+
73
+ def is_installed(tool: Tool) -> bool:
74
+ return bool(tool.binaries) and all(shutil.which(b) for b in tool.binaries)
75
+
76
+
77
+ def run(cmd: List[str]) -> None:
78
+ result = subprocess.run(cmd)
79
+ if result.returncode != 0:
80
+ raise InstallerError(f"La commande a échoué (code {result.returncode}) : {' '.join(cmd)}")
@@ -0,0 +1,107 @@
1
+ """Catalogue des outils : chargé depuis les fichiers TOML embarqués et ceux de l'utilisateur."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+ BUILTIN_CATALOG = Path(__file__).parent / "catalog"
17
+ SUPPORTED_METHODS = ("python", "npm", "brew")
18
+
19
+
20
+ def user_catalog_dir() -> Path:
21
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
22
+ return Path(base) / "cargai" / "tools"
23
+
24
+
25
+ class RegistryError(Exception):
26
+ pass
27
+
28
+
29
+ @dataclass
30
+ class Tool:
31
+ name: str
32
+ description: str
33
+ method: str
34
+ package: str
35
+ display_name: str = ""
36
+ homepage: str = ""
37
+ repository: str = ""
38
+ tags: List[str] = field(default_factory=list)
39
+ extras: List[str] = field(default_factory=list)
40
+ python: Optional[str] = None
41
+ binaries: List[str] = field(default_factory=list)
42
+ post_install: str = ""
43
+ source: Optional[Path] = None
44
+
45
+ @property
46
+ def title(self) -> str:
47
+ return self.display_name or self.name
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict, source: Optional[Path] = None) -> "Tool":
51
+ install = data.get("install", {})
52
+ missing = [k for k in ("name", "description") if k not in data]
53
+ missing += [f"install.{k}" for k in ("method", "package") if k not in install]
54
+ if missing:
55
+ raise RegistryError(f"{source}: champs manquants : {', '.join(missing)}")
56
+ if install["method"] not in SUPPORTED_METHODS:
57
+ raise RegistryError(
58
+ f"{source}: méthode '{install['method']}' non supportée "
59
+ f"(attendu : {', '.join(SUPPORTED_METHODS)})"
60
+ )
61
+ return cls(
62
+ name=data["name"],
63
+ description=data["description"],
64
+ method=install["method"],
65
+ package=install["package"],
66
+ display_name=data.get("display_name", ""),
67
+ homepage=data.get("homepage", ""),
68
+ repository=data.get("repository", ""),
69
+ tags=data.get("tags", []),
70
+ extras=install.get("extras", []),
71
+ python=install.get("python"),
72
+ binaries=install.get("binaries", []),
73
+ post_install=data.get("post_install", {}).get("message", "").strip(),
74
+ source=source,
75
+ )
76
+
77
+
78
+ def _load_dir(directory: Path) -> Dict[str, Tool]:
79
+ tools: Dict[str, Tool] = {}
80
+ if not directory.is_dir():
81
+ return tools
82
+ for path in sorted(directory.glob("*.toml")):
83
+ with path.open("rb") as f:
84
+ try:
85
+ data = tomllib.load(f)
86
+ except tomllib.TOMLDecodeError as e:
87
+ raise RegistryError(f"{path}: TOML invalide : {e}") from e
88
+ tool = Tool.from_dict(data, source=path)
89
+ tools[tool.name] = tool
90
+ return tools
91
+
92
+
93
+ def load_catalog(extra_dirs: Optional[List[Path]] = None) -> Dict[str, Tool]:
94
+ """Charge le catalogue intégré, puis les définitions utilisateur (qui l'emportent)."""
95
+ tools = _load_dir(BUILTIN_CATALOG)
96
+ for directory in [user_catalog_dir(), *(extra_dirs or [])]:
97
+ tools.update(_load_dir(directory))
98
+ return tools
99
+
100
+
101
+ def get_tool(name: str) -> Tool:
102
+ catalog = load_catalog()
103
+ if name not in catalog:
104
+ raise RegistryError(
105
+ f"Outil inconnu : '{name}'. Disponibles : {', '.join(sorted(catalog)) or 'aucun'}"
106
+ )
107
+ return catalog[name]
@@ -0,0 +1,45 @@
1
+ from unittest import mock
2
+
3
+ import pytest
4
+
5
+ from cargai.installers import install_command
6
+ from cargai.registry import RegistryError, Tool, load_catalog
7
+
8
+
9
+ def test_builtin_catalog_has_headroom():
10
+ tool = load_catalog()["headroom"]
11
+ assert tool.package == "headroom-ai"
12
+ assert tool.method == "python"
13
+ assert "headroom" in tool.binaries
14
+
15
+
16
+ def test_user_catalog_overrides_and_extends(tmp_path):
17
+ (tmp_path / "foo.toml").write_text(
18
+ 'name = "foo"\ndescription = "d"\n[install]\nmethod = "npm"\npackage = "foo-cli"\n'
19
+ )
20
+ catalog = load_catalog(extra_dirs=[tmp_path])
21
+ assert catalog["foo"].method == "npm"
22
+ assert "headroom" in catalog
23
+
24
+
25
+ def test_missing_fields_rejected():
26
+ with pytest.raises(RegistryError):
27
+ Tool.from_dict({"name": "x", "install": {"method": "python"}})
28
+
29
+
30
+ def test_unknown_method_rejected():
31
+ with pytest.raises(RegistryError):
32
+ Tool.from_dict({"name": "x", "description": "d", "install": {"method": "apt", "package": "x"}})
33
+
34
+
35
+ @mock.patch("cargai.installers.shutil.which", side_effect=lambda b: "/bin/uv" if b == "uv" else None)
36
+ def test_uv_command_with_extras(_which):
37
+ tool = load_catalog()["headroom"]
38
+ assert install_command(tool) == ["uv", "tool", "install", "headroom-ai[proxy]", "--python", "3.12"]
39
+ assert install_command(tool, extras=[])[3] == "headroom-ai"
40
+
41
+
42
+ @mock.patch("cargai.installers.shutil.which", side_effect=lambda b: "/bin/pipx" if b == "pipx" else None)
43
+ def test_pipx_fallback(_which):
44
+ tool = load_catalog()["headroom"]
45
+ assert install_command(tool, extras=["all"]) == ["pipx", "install", "headroom-ai[all]", "--python", "python3.12"]