livequill 0.2.0__py3-none-any.whl

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.
livequill/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """livequill : barre de prompt flottante qui fait éditer un site local par n'importe quel modèle."""
2
+ __version__ = '0.2.0'
3
+ APP = 'livequill'
livequill/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ """Registre des fournisseurs. Ajouter un fournisseur = écrire un adaptateur et l'inscrire dans KINDS."""
2
+ from .anthropic import AnthropicAdapter
3
+ from .base import Adapter, ToolsUnsupported
4
+ from .cli import CLIAdapter
5
+ from .gemini import GeminiAdapter
6
+ from .ollama import OllamaAdapter
7
+ from .openai import CustomAdapter, LlamaCppAdapter, LMStudioAdapter, MistralAdapter, OpenAIAdapter, OpenRouterAdapter
8
+
9
+ # group : « cloud », « local » ou « cli » (rangement de la page de setup)
10
+ # fields : champs du formulaire — key (clé API), base_url, model (nom libre), extra_models, num_ctx
11
+ KINDS = {
12
+ # --- API cloud : url = page où créer la clé --------------------------------------------------------
13
+ 'anthropic': {'label': 'Anthropic', 'group': 'cloud', 'adapter': AnthropicAdapter, 'fields': ['key'], 'env': 'ANTHROPIC_API_KEY',
14
+ 'url': 'https://console.anthropic.com/settings/keys', 'url_label': 'Créer une clé',
15
+ 'help': 'Clé sur console.anthropic.com → Settings → API keys'},
16
+ 'openai': {'label': 'OpenAI', 'group': 'cloud', 'adapter': OpenAIAdapter, 'fields': ['key'], 'env': 'OPENAI_API_KEY',
17
+ 'url': 'https://platform.openai.com/api-keys', 'url_label': 'Créer une clé',
18
+ 'help': 'Clé sur platform.openai.com → API keys'},
19
+ 'gemini': {'label': 'Google Gemini', 'group': 'cloud', 'adapter': GeminiAdapter, 'fields': ['key'], 'env': 'GEMINI_API_KEY',
20
+ 'url': 'https://aistudio.google.com/apikey', 'url_label': 'Créer une clé',
21
+ 'help': 'Clé sur aistudio.google.com → Get API key'},
22
+ 'mistral': {'label': 'Mistral', 'group': 'cloud', 'adapter': MistralAdapter, 'fields': ['key'], 'env': 'MISTRAL_API_KEY',
23
+ 'url': 'https://console.mistral.ai/api-keys', 'url_label': 'Créer une clé',
24
+ 'help': 'Clé sur console.mistral.ai → API keys'},
25
+ 'openrouter': {'label': 'OpenRouter', 'group': 'cloud', 'adapter': OpenRouterAdapter, 'fields': ['key'], 'env': 'OPENROUTER_API_KEY',
26
+ 'url': 'https://openrouter.ai/keys', 'url_label': 'Créer une clé',
27
+ 'help': 'Clé sur openrouter.ai/keys — donne accès à des centaines de modèles'},
28
+ # --- modèles locaux : url = page de téléchargement ; « Autre » pour tout serveur compatible OpenAI ----
29
+ 'ollama': {'label': 'Ollama', 'group': 'local', 'adapter': OllamaAdapter, 'fields': ['base_url', 'num_ctx'],
30
+ 'url': 'https://ollama.com/download', 'url_label': 'Télécharger',
31
+ 'help': 'ollama serve, puis ollama pull qwen2.5:7b-instruct (ou un autre modèle qui gère les tools)'},
32
+ 'lmstudio': {'label': 'LM Studio', 'group': 'local', 'adapter': LMStudioAdapter, 'fields': ['base_url'],
33
+ 'url': 'https://lmstudio.ai', 'url_label': 'Télécharger',
34
+ 'help': 'Onglet Developer → Start server (port 1234 par défaut)'},
35
+ 'llamacpp': {'label': 'llama.cpp server', 'group': 'local', 'adapter': LlamaCppAdapter, 'fields': ['base_url'],
36
+ 'url': 'https://github.com/ggml-org/llama.cpp', 'url_label': 'Installer',
37
+ 'help': 'llama-server -m modele.gguf --jinja (port 8080 par défaut, --jinja active les tools)'},
38
+ 'custom': {'label': 'Autre', 'group': 'local', 'adapter': CustomAdapter, 'fields': ['base_url', 'key', 'model'], 'multi': True,
39
+ 'help': 'N’importe quel serveur compatible OpenAI (/v1/chat/completions) : vLLM, LocalAI, Jan, Groq, DeepSeek, un proxy maison…'},
40
+ # --- agents CLI : login = commande pour se connecter au compte du fournisseur ------------------------
41
+ 'cli-claude': {'label': 'Claude Code (CLI)', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'claude', 'fields': ['extra_models'],
42
+ 'url': 'https://code.claude.com/docs', 'url_label': 'Installer', 'login': 'claude, puis /login',
43
+ 'help': 'Utilise « claude -p » dans le dossier du projet, avec ton compte Claude Code (CLI, ou celle intégrée à l’app Claude Desktop)'},
44
+ 'cli-codex': {'label': 'Codex (CLI)', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'codex', 'fields': ['extra_models'],
45
+ 'url': 'https://github.com/openai/codex', 'url_label': 'Installer', 'login': 'codex login',
46
+ 'help': 'Utilise « codex exec » dans le dossier du projet, avec ton compte (CLI, ou celle intégrée à l’app ChatGPT ou Codex). Écriture limitée au projet, mais Codex peut lire tout ton disque'},
47
+ 'cli-gemini': {'label': 'Gemini CLI', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'gemini', 'fields': ['extra_models'],
48
+ 'url': 'https://github.com/google-gemini/gemini-cli', 'url_label': 'Installer', 'login': 'gemini (connexion au 1er lancement)',
49
+ 'help': 'Utilise « gemini -p » dans le dossier du projet'},
50
+ }
51
+
52
+
53
+ def make(conf, transport=None):
54
+ kind = KINDS[conf['type']]
55
+ conf = dict(conf)
56
+ if 'command' in kind:
57
+ conf.setdefault('command', kind['command'])
58
+ return kind['adapter'](conf, transport)
59
+
60
+
61
+ def public_kinds():
62
+ return {k: {x: v for x, v in d.items() if x != 'adapter'} for k, d in KINDS.items()}
63
+
64
+
65
+ __all__ = ['Adapter', 'KINDS', 'ToolsUnsupported', 'make', 'public_kinds']
@@ -0,0 +1,128 @@
1
+ """Anthropic Messages API (HTTP brut, en flux SSE)."""
2
+ import json, re
3
+
4
+ from ..net import sse
5
+ from .base import Adapter, levels
6
+
7
+ VERSION = '2023-06-01'
8
+ FAST_MODELS = {'claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8'}
9
+ # Modèles qui acceptent fallbacks: "default" (re-routage côté serveur en cas de refus)
10
+ FALLBACK_MODELS = {'claude-fable-5-1', 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-5-5'}
11
+ KNOWN = [('claude-opus-5-5', 'Opus 5.5'), ('claude-sonnet-5-5', 'Sonnet 5.5'), ('claude-haiku-5-5', 'Haiku 5.5'),
12
+ ('claude-fable-5-1', 'Fable 5.1')]
13
+
14
+
15
+ def effort_levels(model):
16
+ if re.match(r'claude-(opus|sonnet|haiku|fable|mythos)-5', model) or re.match(r'claude-opus-4-[78]', model):
17
+ return levels('low', 'medium', 'high', 'xhigh', 'max')
18
+ if re.match(r'claude-(opus|sonnet)-4-6', model):
19
+ return levels('low', 'medium', 'high', 'max')
20
+ return None
21
+
22
+
23
+ class AnthropicAdapter(Adapter):
24
+ kind = 'anthropic'
25
+ default_base = 'https://api.anthropic.com'
26
+
27
+ def headers(self, betas=()):
28
+ h = {'x-api-key': self.key, 'anthropic-version': VERSION}
29
+ if betas:
30
+ h['anthropic-beta'] = ','.join(betas)
31
+ return h
32
+
33
+ def list_models(self):
34
+ data = self.http.json('GET', f'{self.base}/v1/models?limit=100', self.headers())
35
+ out = [{'id': m['id'], 'label': m.get('display_name') or m['id'], 'meta': {}} for m in data.get('data', [])]
36
+ return out or [{'id': i, 'label': l, 'meta': {}} for i, l in KNOWN]
37
+
38
+ def caps(self, model, meta=None):
39
+ return {'tools': True, 'vision': True, 'effort': effort_levels(model), 'fast': model in FAST_MODELS}
40
+
41
+ def convert(self, messages):
42
+ out = []
43
+ for m in messages:
44
+ if m['role'] == 'tool':
45
+ blocks = [{'type': 'tool_result', 'tool_use_id': r['id'], 'content': r['content'] or '(vide)',
46
+ **({'is_error': True} if r.get('is_error') else {})} for r in m['results']]
47
+ out.append({'role': 'user', 'content': blocks})
48
+ continue
49
+ blocks = []
50
+ for p in m['content']:
51
+ t = p['type']
52
+ if t == 'text' and p['text']:
53
+ blocks.append({'type': 'text', 'text': p['text']})
54
+ elif t == 'image':
55
+ blocks.append({'type': 'image', 'source': {'type': 'base64', 'media_type': p['media_type'], 'data': p['data']}})
56
+ elif t == 'tool_call':
57
+ blocks.append({'type': 'tool_use', 'id': p['id'], 'name': p['name'], 'input': p['args']})
58
+ elif t == 'raw' and p.get('provider') == self.kind:
59
+ blocks.append(p['block']) # blocs de réflexion renvoyés tels quels pendant la boucle
60
+ out.append({'role': m['role'], 'content': blocks or [{'type': 'text', 'text': '…'}]})
61
+ return out
62
+
63
+ def build_request(self, model, system, messages, tools, opts):
64
+ body = {'model': model, 'max_tokens': 32000, 'stream': True, 'system': system, 'messages': self.convert(messages)}
65
+ if tools:
66
+ body['tools'] = [{'name': t['name'], 'description': t['description'], 'input_schema': t['parameters']} for t in tools]
67
+ betas = []
68
+ if effort_levels(model):
69
+ body['thinking'] = {'type': 'adaptive', 'display': 'summarized'}
70
+ if opts.get('effort') in dict(effort_levels(model)):
71
+ body['output_config'] = {'effort': opts['effort']}
72
+ if opts.get('fast') and model in FAST_MODELS:
73
+ body['speed'] = 'fast'
74
+ betas.append('fast-mode-2026-02-01')
75
+ elif model in FALLBACK_MODELS and self.conf.get('fallbacks', True):
76
+ body['fallbacks'] = 'default'
77
+ betas.append('server-side-fallback-2026-07-01')
78
+ return 'POST', f'{self.base}/v1/messages', self.headers(betas), body
79
+
80
+ def parse_stream(self, lines):
81
+ blocks, stop = {}, 'end'
82
+ for _event, data in sse(lines):
83
+ try:
84
+ ev = json.loads(data)
85
+ except ValueError:
86
+ continue
87
+ t = ev.get('type')
88
+ if t == 'error':
89
+ raise RuntimeError(ev.get('error', {}).get('message') or data)
90
+ if t == 'content_block_start':
91
+ b = dict(ev['content_block'])
92
+ if b.get('type') == 'tool_use':
93
+ b['_json'] = ''
94
+ blocks[ev['index']] = b
95
+ elif t == 'content_block_delta':
96
+ b, d = blocks.get(ev['index']), ev['delta']
97
+ if b is None:
98
+ continue
99
+ dt = d.get('type')
100
+ if dt == 'text_delta':
101
+ b['text'] = b.get('text', '') + d['text']
102
+ yield {'type': 'delta', 'text': d['text']}
103
+ elif dt == 'thinking_delta':
104
+ b['thinking'] = b.get('thinking', '') + d['thinking']
105
+ yield {'type': 'thinking', 'text': d['thinking']}
106
+ elif dt == 'signature_delta':
107
+ b['signature'] = b.get('signature', '') + d['signature']
108
+ elif dt == 'input_json_delta':
109
+ b['_json'] += d.get('partial_json', '')
110
+ elif t == 'content_block_stop':
111
+ b = blocks.pop(ev['index'], None)
112
+ if not b:
113
+ continue
114
+ if b['type'] == 'text':
115
+ yield {'type': 'part', 'part': {'type': 'text', 'text': b.get('text', '')}}
116
+ elif b['type'] == 'tool_use':
117
+ try:
118
+ args = json.loads(b['_json'] or '{}')
119
+ except ValueError:
120
+ args = {'_invalid_json': b['_json']}
121
+ yield {'type': 'part', 'part': {'type': 'tool_call', 'id': b['id'], 'name': b['name'], 'args': args}}
122
+ else: # thinking, redacted_thinking, fallback… : renvoyés tels quels au tour suivant
123
+ yield {'type': 'part', 'part': {'type': 'raw', 'provider': self.kind, 'block': b}}
124
+ elif t == 'message_delta':
125
+ r = ev.get('delta', {}).get('stop_reason')
126
+ if r:
127
+ stop = {'tool_use': 'tool_use', 'max_tokens': 'max_tokens', 'refusal': 'refusal'}.get(r, 'end')
128
+ yield {'type': 'stop', 'reason': stop}
@@ -0,0 +1,88 @@
1
+ """Interface commune des adaptateurs.
2
+
3
+ Format interne des messages (indépendant du fournisseur) :
4
+ {"role": "user", "content": [{"type": "text", "text": ...}, {"type": "image", "media_type": ..., "data": <base64>}]}
5
+ {"role": "assistant", "content": [{"type": "text", "text": ...},
6
+ {"type": "tool_call", "id": ..., "name": ..., "args": {...}, "meta": {...}},
7
+ {"type": "raw", "provider": "anthropic", "block": {...}}]}
8
+ {"role": "tool", "results": [{"id": ..., "name": ..., "content": str, "is_error": bool}]}
9
+
10
+ Un adaptateur transforme ce format en requête HTTP (build_request, pure donc testable),
11
+ puis lit le flux de réponse (parse_stream) et produit des événements :
12
+ {"type": "delta", "text": ...} texte affiché en direct
13
+ {"type": "thinking", "text": ...} réflexion (si le fournisseur la renvoie)
14
+ {"type": "part", "part": {...}} bloc terminé, ajouté au message assistant
15
+ {"type": "stop", "reason": "end" | "tool_use" | "max_tokens" | "refusal"}
16
+ """
17
+ from ..config import api_key
18
+ from ..net import HTTPError, Transport
19
+
20
+
21
+ class ToolsUnsupported(Exception):
22
+ """Le modèle refuse les tool calls : l'agent repasse en mode texte."""
23
+
24
+
25
+ class Adapter:
26
+ kind = '' # identifiant du type de fournisseur
27
+ is_cli = False # True : l'agent CLI fait lui-même la boucle et écrit les fichiers
28
+ default_base = ''
29
+
30
+ def __init__(self, conf, transport=None):
31
+ self.conf = conf
32
+ self.http = transport or Transport()
33
+
34
+ @property
35
+ def base(self):
36
+ return (self.conf.get('base_url') or self.default_base).rstrip('/')
37
+
38
+ @property
39
+ def key(self):
40
+ return api_key(self.conf)
41
+
42
+ # --- à fournir par chaque adaptateur ---------------------------------
43
+ def list_models(self):
44
+ """[{"id": str, "label": str, "meta": {...}}] — meta sert à calculer les capacités."""
45
+ raise NotImplementedError
46
+
47
+ def caps(self, model, meta=None):
48
+ """{"tools": bool, "vision": bool, "effort": [[valeur, libellé], ...] | None, "fast": bool}"""
49
+ return {'tools': True, 'vision': False, 'effort': None, 'fast': False}
50
+
51
+ def build_request(self, model, system, messages, tools, opts):
52
+ """-> (méthode, url, en-têtes, corps JSON)"""
53
+ raise NotImplementedError
54
+
55
+ def parse_stream(self, lines):
56
+ raise NotImplementedError
57
+
58
+ # --- commun ----------------------------------------------------------
59
+ def test(self):
60
+ models = self.list_models()
61
+ return f'{len(models)} modèle(s) disponible(s)', models
62
+
63
+ def chat(self, model, system, messages, tools, opts, job=None):
64
+ method, url, headers, body = self.build_request(model, system, messages, tools, opts)
65
+ try:
66
+ yield from self.parse_stream(self.http.lines(method, url, headers, body, job))
67
+ except HTTPError as e:
68
+ if tools and looks_like_no_tools(e.body):
69
+ raise ToolsUnsupported(e.body) from None
70
+ raise
71
+
72
+
73
+ def looks_like_no_tools(body):
74
+ b = (body or '').lower()
75
+ return 'does not support tools' in b or 'tool use is not supported' in b or ('tools' in b and 'not supported' in b) \
76
+ or 'function calling is not enabled' in b
77
+
78
+
79
+ EFFORT_LABELS = {'off': 'Désactivée', 'on': 'Activée', 'minimal': 'Minimal', 'low': 'Low', 'medium': 'Medium',
80
+ 'high': 'High', 'xhigh': 'Extra high', 'max': 'Max'}
81
+
82
+
83
+ def levels(*vals):
84
+ return [[v, EFFORT_LABELS.get(v, v)] for v in vals]
85
+
86
+
87
+ def text_of(content):
88
+ return ''.join(p.get('text', '') for p in content if p.get('type') == 'text')
@@ -0,0 +1,245 @@
1
+ """Agents CLI déjà installés (Claude Code, Codex, Gemini CLI) : ils font leur propre boucle et écrivent les fichiers.
2
+
3
+ Pont vers les applications desktop : Claude Desktop embarque Claude Code, ChatGPT (et l'app Codex) embarquent Codex.
4
+ Sans CLI installée, on utilise l'agent de l'application, déjà connecté au compte de l'utilisateur."""
5
+ import glob, json, os, re, shutil, subprocess
6
+
7
+ from .base import Adapter, levels
8
+
9
+ AGENTS = {
10
+ 'claude': {'label': 'Claude Code', 'models': [('default', 'Par défaut (Claude Code)'), ('opus', 'Opus'), ('sonnet', 'Sonnet'), ('haiku', 'Haiku')],
11
+ 'effort': levels('low', 'medium', 'high', 'xhigh', 'max')},
12
+ 'codex': {'label': 'Codex', 'models': [('default', 'Par défaut (Codex)')], 'effort': levels('low', 'medium', 'high')},
13
+ 'gemini': {'label': 'Gemini CLI', 'models': [('default', 'Par défaut (Gemini CLI)')], 'effort': None},
14
+ }
15
+
16
+
17
+ # Où les installeurs officiels posent ces CLI, au cas où LiveQuill est lancé avec un PATH réduit
18
+ # (raccourci de bureau, service, autre session) qui ne contient pas ces dossiers.
19
+ EXTRA_DIRS = ['~/.local/bin', '~/bin', '~/.npm-global/bin', '~/.bun/bin', '~/.volta/bin', '~/.cargo/bin',
20
+ '~/.claude/local', '/usr/local/bin', '/opt/homebrew/bin']
21
+
22
+
23
+ # Agents embarqués dans les applications desktop (Linux puis macOS). Le dossier de version change à chaque mise à jour
24
+ # de l'application : on cherche à chaque fois et on prend la version la plus récente.
25
+ BUNDLED = {
26
+ 'claude': [('Claude Desktop', '~/.config/Claude/claude-code/*/*/claude'),
27
+ ('Claude Desktop', '~/Library/Application Support/Claude/claude-code/*/*/claude')],
28
+ 'codex': [('ChatGPT', '/usr/lib/chatgpt/resources/codex'),
29
+ ('ChatGPT', '/opt/ChatGPT/resources/codex'),
30
+ ('ChatGPT', '/Applications/ChatGPT.app/Contents/Resources/codex'),
31
+ ('ChatGPT', '~/Applications/ChatGPT.app/Contents/Resources/codex'),
32
+ ('Codex', '/Applications/Codex.app/Contents/Resources/codex'),
33
+ ('Codex', '~/Applications/Codex.app/Contents/Resources/codex')],
34
+ }
35
+
36
+
37
+ def _version_key(path):
38
+ return [int(n) for n in re.findall(r'\d+', path)]
39
+
40
+
41
+ def locate(name):
42
+ """-> (chemin, application) : la CLI installée d'abord, sinon l'agent embarqué dans une application desktop."""
43
+ exe = shutil.which(name)
44
+ if exe:
45
+ return exe, None
46
+ for d in EXTRA_DIRS:
47
+ p = os.path.join(os.path.expanduser(d), name)
48
+ if os.path.isfile(p) and os.access(p, os.X_OK):
49
+ return p, None
50
+ for app, pattern in BUNDLED.get(name, []):
51
+ hits = [p for p in glob.glob(os.path.expanduser(pattern)) if os.path.isfile(p) and os.access(p, os.X_OK)]
52
+ if hits:
53
+ return max(hits, key=_version_key), app
54
+ return None, None
55
+
56
+
57
+ def find(name):
58
+ return locate(name)[0]
59
+
60
+
61
+ def detect():
62
+ return {name: find(name) for name in AGENTS}
63
+
64
+
65
+ def detect_via():
66
+ """Application desktop qui fournit l'agent, quand ce n'est pas une CLI installée."""
67
+ return {name: locate(name)[1] for name in AGENTS}
68
+
69
+
70
+ def codex_user_model():
71
+ """Le modèle de premier niveau de ~/.codex/config.toml (seule valeur reprise de la config ignorée)."""
72
+ path = os.path.join(os.environ.get('CODEX_HOME') or os.path.expanduser('~/.codex'), 'config.toml')
73
+ try:
74
+ with open(path, encoding='utf-8') as f:
75
+ for line in f:
76
+ if line.lstrip().startswith('['):
77
+ break # fin des clés de premier niveau
78
+ m = re.match(r'\s*model\s*=\s*"([^"]+)"', line)
79
+ if m:
80
+ return m.group(1)
81
+ except OSError:
82
+ pass
83
+ return None
84
+
85
+
86
+ def logged_in(name, exe):
87
+ """True / False si l'agent sait le dire, None sinon (vieille version, commande inconnue)."""
88
+ try:
89
+ if name == 'claude':
90
+ out = subprocess.run([exe, 'auth', 'status', '--json'], capture_output=True, text=True, timeout=20)
91
+ return bool(json.loads(out.stdout).get('loggedIn')) if out.stdout.strip().startswith('{') else None
92
+ if name == 'codex':
93
+ out = subprocess.run([exe, 'login', 'status'], capture_output=True, text=True, timeout=20)
94
+ text = (out.stdout + out.stderr).lower()
95
+ return True if 'logged in' in text and 'not logged in' not in text else (False if 'not logged in' in text else None)
96
+ except (OSError, ValueError, subprocess.SubprocessError):
97
+ return None
98
+ return None
99
+
100
+
101
+ class CLIAdapter(Adapter):
102
+ kind = 'cli'
103
+ is_cli = True
104
+
105
+ @property
106
+ def agent(self):
107
+ return self.conf.get('command', 'claude')
108
+
109
+ def list_models(self):
110
+ a = AGENTS[self.agent]
111
+ extra = [m.strip() for m in (self.conf.get('extra_models') or '').split(',') if m.strip()]
112
+ return [{'id': i, 'label': l, 'meta': {}} for i, l in a['models']] + [{'id': m, 'label': m, 'meta': {}} for m in extra]
113
+
114
+ def caps(self, model, meta=None):
115
+ return {'tools': True, 'vision': self.agent in ('claude', 'codex'), 'effort': AGENTS[self.agent]['effort'], 'fast': False}
116
+
117
+ def test(self):
118
+ exe, app = locate(self.agent)
119
+ label = AGENTS[self.agent]['label']
120
+ if not exe:
121
+ raise RuntimeError(f'« {self.agent} » est introuvable : installe la CLI, ou l’application '
122
+ f'{"Claude Desktop" if self.agent == "claude" else "ChatGPT" if self.agent == "codex" else "officielle"}')
123
+ if logged_in(self.agent, exe) is False:
124
+ how = f'ouvre l’application {app} et connecte-toi' if app else ('lance « claude auth login »' if self.agent == 'claude' else 'lance « codex login »')
125
+ raise RuntimeError(f'{label} n’est pas connecté à ton compte : {how}, puis reteste')
126
+ out = subprocess.run([exe, '--version'], capture_output=True, text=True, timeout=20)
127
+ version = (out.stdout or out.stderr).strip()[:80]
128
+ return f'{label} trouvé{f" (via l’app {app})" if app else ""} : {version}', self.list_models()
129
+
130
+ def command(self, root, model, opts, images, prompt=''):
131
+ """-> (argv, texte envoyé sur stdin). Le prompt passe par stdin : pas de limite de taille d'argument."""
132
+ exe = find(self.agent) or self.agent
133
+ m = None if model in (None, '', 'default') else model
134
+ e = opts.get('effort')
135
+ if self.agent == 'claude':
136
+ # --restricted : outils de fichiers confinés au dossier du projet, aucun outil qui exécute du code,
137
+ # réglages utilisateur/projet ignorés (hooks compris) ; --strict-mcp-config : aucun serveur MCP de l'utilisateur
138
+ argv = [exe, '-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
139
+ '--restricted', '--strict-mcp-config', '--tools', 'Read,Edit,Write,Glob,Grep',
140
+ '--permission-mode', 'acceptEdits', '--no-session-persistence',
141
+ '--disallowedTools', 'Bash', 'WebFetch', 'WebSearch', 'Read(./.env*)', 'Edit(./.env*)', 'Write(./.env*)',
142
+ '--allowedTools', 'Read', 'Edit', 'Write', 'Glob', 'Grep']
143
+ if m:
144
+ argv += ['--model', m]
145
+ if e:
146
+ argv += ['--effort', e]
147
+ return argv
148
+ if self.agent == 'codex':
149
+ # --ignore-user-config : ni les serveurs MCP ni les dossiers inscriptibles ajoutés dans ~/.codex/config.toml
150
+ # (un serveur MCP tourne hors de la sandbox) ; la connexion au compte reste celle de CODEX_HOME
151
+ # Sans la config utilisateur, on fixe nous-mêmes : jamais d'approbation demandée (impossible en non
152
+ # interactif ; les commandes restent dans la sandbox) et pas de réseau.
153
+ argv = [exe, 'exec', '--json', '--sandbox', 'workspace-write', '--ignore-user-config',
154
+ '-c', 'approval_policy="never"', '-c', 'sandbox_workspace_write.network_access=false',
155
+ '--cd', root, '--skip-git-repo-check', '--ephemeral']
156
+ m = m or codex_user_model() # « Par défaut » garde le modèle choisi dans ~/.codex/config.toml
157
+ if m:
158
+ argv += ['--model', m]
159
+ if e:
160
+ argv += ['-c', f'model_reasoning_effort="{e}"']
161
+ if images:
162
+ argv += ['--image'] + images # en dernier : l'option prend plusieurs valeurs
163
+ return argv
164
+ argv = [exe, '--approval-mode', 'auto_edit']
165
+ if m:
166
+ argv += ['-m', m]
167
+ return argv + ['-p', prompt] # non vérifié : gemini n'est pas installé sur la machine de dev
168
+
169
+ def run(self, job, root, prompt, model, opts, images, emit):
170
+ argv = self.command(root, model, opts, images, prompt)
171
+ env = dict(os.environ, NO_COLOR='1')
172
+ if os.path.isabs(argv[0]): # trouvé hors du PATH : ses outils voisins (node…) doivent rester accessibles
173
+ env['PATH'] = os.pathsep.join([os.path.dirname(argv[0]), env.get('PATH', '')])
174
+ p = subprocess.Popen(argv, cwd=root, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
175
+ text=True, bufsize=1, env=env)
176
+ job.on_cancel(p.kill)
177
+ p.stdin.write('' if self.agent == 'gemini' else prompt)
178
+ p.stdin.close()
179
+ final = []
180
+ for ln in p.stdout:
181
+ for ev in self.parse_line(ln.rstrip('\n')):
182
+ if ev['type'] == 'final':
183
+ final = [ev['text']]
184
+ else:
185
+ if ev['type'] == 'delta':
186
+ final.append(ev['text'])
187
+ emit(ev)
188
+ err = p.stderr.read()
189
+ code = p.wait()
190
+ if code and not job.cancelled:
191
+ raise RuntimeError(f'{self.agent} a échoué (code {code}) : {err.strip()[-600:]}')
192
+ return ''.join(final).strip()
193
+
194
+ def parse_line(self, ln):
195
+ if self.agent == 'gemini':
196
+ return [{'type': 'delta', 'text': ln + '\n'}] if ln else []
197
+ try:
198
+ ev = json.loads(ln)
199
+ except ValueError:
200
+ return []
201
+ return (parse_claude if self.agent == 'claude' else parse_codex)(ev)
202
+
203
+
204
+ def tool_summary(name, inp):
205
+ path = inp.get('file_path') or inp.get('path') or inp.get('pattern') or ''
206
+ return {'type': 'tool', 'name': name, 'summary': f'{name} {path}'.strip(), 'path': path}
207
+
208
+
209
+ def parse_claude(ev):
210
+ t = ev.get('type')
211
+ if t == 'stream_event':
212
+ e = ev.get('event') or {}
213
+ d = e.get('delta') or {}
214
+ if e.get('type') == 'content_block_delta' and d.get('type') == 'text_delta':
215
+ return [{'type': 'delta', 'text': d['text']}]
216
+ if e.get('type') == 'content_block_delta' and d.get('type') == 'thinking_delta':
217
+ return [{'type': 'thinking', 'text': d['thinking']}]
218
+ return []
219
+ if t == 'assistant':
220
+ return [tool_summary(b['name'], b.get('input') or {}) for b in (ev.get('message') or {}).get('content', [])
221
+ if b.get('type') == 'tool_use']
222
+ if t == 'result':
223
+ if ev.get('is_error'):
224
+ raise RuntimeError(str(ev.get('result') or ev.get('subtype')))
225
+ return [{'type': 'final', 'text': ev.get('result') or ''}]
226
+ return []
227
+
228
+
229
+ def parse_codex(ev):
230
+ t = ev.get('type', '')
231
+ if t in ('error', 'turn.failed'):
232
+ raise RuntimeError(str(ev.get('message') or ev.get('error') or ev))
233
+ if t != 'item.completed':
234
+ return []
235
+ it = ev.get('item') or {}
236
+ k = it.get('type') or it.get('item_type')
237
+ if k in ('agent_message', 'assistant_message'):
238
+ return [{'type': 'delta', 'text': (it.get('text') or '') + '\n'}]
239
+ if k == 'reasoning':
240
+ return [{'type': 'thinking', 'text': it.get('text') or ''}]
241
+ if k == 'file_change':
242
+ return [{'type': 'tool', 'name': 'edit', 'summary': f'edit {c.get("path")}', 'path': c.get('path')} for c in it.get('changes') or []]
243
+ if k == 'command_execution':
244
+ return [{'type': 'tool', 'name': 'shell', 'summary': f'$ {it.get("command")}'}]
245
+ return []