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.
- cargai-0.1.0/.gitignore +5 -0
- cargai-0.1.0/LICENSE +21 -0
- cargai-0.1.0/PKG-INFO +91 -0
- cargai-0.1.0/README.md +70 -0
- cargai-0.1.0/pyproject.toml +37 -0
- cargai-0.1.0/src/cargai/__init__.py +1 -0
- cargai-0.1.0/src/cargai/__main__.py +3 -0
- cargai-0.1.0/src/cargai/catalog/headroom.toml +20 -0
- cargai-0.1.0/src/cargai/cli.py +142 -0
- cargai-0.1.0/src/cargai/installers.py +80 -0
- cargai-0.1.0/src/cargai/registry.py +107 -0
- cargai-0.1.0/tests/test_registry.py +45 -0
cargai-0.1.0/.gitignore
ADDED
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,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"]
|