opsiom-cli 1.0.2__tar.gz → 1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
5
5
  Author-email: Ton nom <toi@example.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opsiom-cli"
7
- version = "1.0.2"
7
+ version = "1.1.0"
8
8
  description = "CLI de chat pour Opsiom, l'assistant IA francophone."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,491 @@
1
+ #!/usr/bin/env python3
2
+ # ============================================================================
3
+ # Opsiom CLI — interface en ligne de commande pour discuter avec Opsiom
4
+ #
5
+ # Design : une seule couleur d'accent, texte de contenu en couleur par
6
+ # défaut du terminal (compatible thèmes clairs et sombres), un spinner
7
+ # discret plutôt que des animations plein écran. Inspiré des CLI d'outils
8
+ # IA professionnels (Claude Code, Mistral CLI).
9
+ #
10
+ # Dépendances : uniquement `requests`
11
+ # pip install requests
12
+ #
13
+ # Configuration (aucune URL n'est codée en dur dans ce fichier) :
14
+ # Au premier lancement, le CLI demande l'URL du serveur (et une éventuelle
15
+ # clé API) puis les enregistre dans ~/.config/opsiom/config.json.
16
+ # Alternative : variables d'environnement OPSIOM_URL / OPSIOM_API_KEY,
17
+ # ou options --url / --api-key.
18
+ # ============================================================================
19
+
20
+ import os
21
+ import sys
22
+ import json
23
+ import time
24
+ import shutil
25
+ import textwrap
26
+ import argparse
27
+ import threading
28
+ import itertools
29
+ from pathlib import Path
30
+
31
+ try:
32
+ import requests
33
+ except ImportError:
34
+ print("Ce CLI nécessite le paquet 'requests'. Installe-le avec : pip install requests")
35
+ sys.exit(1)
36
+
37
+ try:
38
+ import readline # historique flèche-haut persistant
39
+ except ImportError:
40
+ readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
41
+
42
+ VERSION = "1.1.0"
43
+ CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
44
+ HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
45
+ TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
46
+
47
+
48
+ # ----------------------------------------------------------------------------
49
+ # Palette — une seule couleur d'accent, le reste reste en couleur par défaut
50
+ # du terminal. C'est ce qui distingue un outil pro d'une démo colorée : le
51
+ # contenu doit rester lisible sur fond clair ou sombre, sans texte forcé
52
+ # en blanc/noir codé en dur.
53
+ # ----------------------------------------------------------------------------
54
+
55
+ RESET = "\033[0m"
56
+ BOLD = "\033[1m"
57
+ DIM = "\033[2m"
58
+ HIDE_CURSOR = "\033[?25l"
59
+ SHOW_CURSOR = "\033[?25h"
60
+
61
+ ACCENT = (150, 130, 235) # violet discret — prompt, spinner, éléments actifs
62
+ ERROR = (206, 100, 100) # rouge sobre — erreurs uniquement
63
+ OK = (120, 170, 140) # vert sobre — statut connecté uniquement
64
+
65
+
66
+ def accent(text, bold=False):
67
+ r, g, b = ACCENT
68
+ return f"{BOLD if bold else ''}\033[38;2;{r};{g};{b}m{text}{RESET}"
69
+
70
+
71
+ def muted(text):
72
+ return f"{DIM}{text}{RESET}"
73
+
74
+
75
+ def error_text(text, bold=False):
76
+ r, g, b = ERROR
77
+ return f"{BOLD if bold else ''}\033[38;2;{r};{g};{b}m{text}{RESET}"
78
+
79
+
80
+ def ok_text(text):
81
+ r, g, b = OK
82
+ return f"\033[38;2;{OK[0]};{OK[1]};{OK[2]}m{text}{RESET}"
83
+
84
+
85
+ # ----------------------------------------------------------------------------
86
+ # Marque — un petit repère ASCII discret, affiché une seule fois au
87
+ # démarrage (pas d'animation en boucle : c'est un logo, pas un spectacle).
88
+ # ----------------------------------------------------------------------------
89
+
90
+ OCTOPUS_MARK = [
91
+ " .------.",
92
+ " / o o \\",
93
+ " | -- |",
94
+ " \\________/",
95
+ " /| | |\\",
96
+ ]
97
+
98
+
99
+ def print_header(url, connected, api_key, models_loaded, current_model, show_mark=True):
100
+ print()
101
+ if show_mark:
102
+ for line in OCTOPUS_MARK:
103
+ print(" " + accent(line))
104
+ print()
105
+ print(f" {accent('Opsiom', bold=True)} {muted(f'v{VERSION}')}")
106
+ print(f" {muted('assistant IA francophone')}")
107
+ print()
108
+ status_symbol = ok_text("✓") if connected else error_text("✗")
109
+ print(f" {status_symbol} {muted('connecté à')} {url}")
110
+ print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
111
+ if models_loaded:
112
+ print(f" {muted('modèles')} {' · '.join(models_loaded)}")
113
+ if current_model:
114
+ print(f" {muted('actif')} {accent(current_model)}")
115
+ print()
116
+
117
+
118
+ # ----------------------------------------------------------------------------
119
+ # Spinner — une ligne, un caractère braille tournant, un minuteur en
120
+ # direct. C'est la convention de facto des CLI professionnelles (npm,
121
+ # cargo, Claude Code) : discret, informatif, jamais envahissant.
122
+ # ----------------------------------------------------------------------------
123
+
124
+ SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
125
+
126
+
127
+ class Spinner:
128
+ def __init__(self, label="Réflexion"):
129
+ self.label = label
130
+ self._stop_event = threading.Event()
131
+ self._thread = None
132
+
133
+ def _run(self):
134
+ start = time.perf_counter()
135
+ frames = itertools.cycle(SPINNER_FRAMES)
136
+ print(HIDE_CURSOR, end="")
137
+ while not self._stop_event.is_set():
138
+ elapsed = time.perf_counter() - start
139
+ frame = next(frames)
140
+ line = (
141
+ f"{accent(frame)} {muted(self.label)} {muted(f'{elapsed:0.1f}s')}"
142
+ f" {muted('(Ctrl+C pour annuler)')}"
143
+ )
144
+ sys.stdout.write("\r\033[K" + line)
145
+ sys.stdout.flush()
146
+ time.sleep(0.08)
147
+ sys.stdout.write("\r\033[K")
148
+ sys.stdout.write(SHOW_CURSOR)
149
+ sys.stdout.flush()
150
+
151
+ def start(self):
152
+ self._stop_event.clear()
153
+ self._thread = threading.Thread(target=self._run, daemon=True)
154
+ self._thread.start()
155
+
156
+ def stop(self):
157
+ self._stop_event.set()
158
+ if self._thread:
159
+ self._thread.join()
160
+
161
+
162
+ # ----------------------------------------------------------------------------
163
+ # Historique de saisie persistant
164
+ # ----------------------------------------------------------------------------
165
+
166
+ def setup_history():
167
+ if not readline:
168
+ return
169
+ HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
170
+ try:
171
+ readline.read_history_file(HISTORY_PATH)
172
+ except OSError:
173
+ pass
174
+ readline.set_history_length(500)
175
+
176
+
177
+ def save_history():
178
+ if not readline:
179
+ return
180
+ try:
181
+ readline.write_history_file(HISTORY_PATH)
182
+ except OSError:
183
+ pass
184
+
185
+
186
+ def save_transcript(transcript, filename=None):
187
+ TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
188
+ if not filename:
189
+ filename = f"opsiom-{time.strftime('%Y%m%d-%H%M%S')}"
190
+ if not filename.endswith(".md"):
191
+ filename += ".md"
192
+ path = TRANSCRIPTS_DIR / filename
193
+ lines = ["# Conversation Opsiom", ""]
194
+ for entry in transcript:
195
+ if entry["role"] == "user":
196
+ lines.append(f"**Vous :** {entry['text']}")
197
+ else:
198
+ lines.append(f"**Opsiom ({entry['model']}) :** {entry['text']} _({entry['elapsed_ms']} ms)_")
199
+ lines.append("")
200
+ path.write_text("\n".join(lines), encoding="utf-8")
201
+ return path
202
+
203
+
204
+ # ----------------------------------------------------------------------------
205
+ # Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
206
+ # ----------------------------------------------------------------------------
207
+
208
+ def load_config():
209
+ if CONFIG_PATH.exists():
210
+ try:
211
+ return json.loads(CONFIG_PATH.read_text())
212
+ except (json.JSONDecodeError, OSError):
213
+ return {}
214
+ return {}
215
+
216
+
217
+ def save_config(config):
218
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
219
+ CONFIG_PATH.write_text(json.dumps(config, indent=2))
220
+
221
+
222
+ def prompt_for_config():
223
+ print(f" {accent('Configuration initiale', bold=True)}")
224
+ print(f" {muted('ces informations te sont transmises par la personne qui héberge Opsiom')}\n")
225
+ url = input(" URL du serveur : ").strip().rstrip("/")
226
+ api_key = input(" Clé API (vide si aucune) : ").strip()
227
+ save = input(muted(" Sauvegarder localement pour la prochaine fois ? [O/n] ")).strip().lower()
228
+ config = {"url": url, "api_key": api_key}
229
+ if save in ("", "o", "oui", "y", "yes"):
230
+ save_config(config)
231
+ print(muted(f" → enregistré dans {CONFIG_PATH}\n"))
232
+ return config
233
+
234
+
235
+ def resolve_settings(args):
236
+ config = {} if args.configure else load_config()
237
+ url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
238
+ api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
239
+ if args.configure or not url:
240
+ new_config = prompt_for_config()
241
+ url = new_config["url"] or url
242
+ api_key = new_config["api_key"] or api_key
243
+ return url, api_key
244
+
245
+
246
+ # ----------------------------------------------------------------------------
247
+ # Client API
248
+ # ----------------------------------------------------------------------------
249
+
250
+ class OpsiomClient:
251
+ def __init__(self, base_url, api_key=None, timeout=60):
252
+ self.base_url = base_url.rstrip("/")
253
+ self.timeout = timeout
254
+ self.session = requests.Session()
255
+ headers = {"ngrok-skip-browser-warning": "true"}
256
+ if api_key:
257
+ headers["X-API-Key"] = api_key
258
+ self.session.headers.update(headers)
259
+
260
+ def health(self):
261
+ r = self.session.get(f"{self.base_url}/api/health", timeout=10)
262
+ r.raise_for_status()
263
+ return r.json()
264
+
265
+ def list_models(self):
266
+ r = self.session.get(f"{self.base_url}/api/models", timeout=10)
267
+ r.raise_for_status()
268
+ return r.json()
269
+
270
+ def chat(self, message, model=None, **params):
271
+ payload = {"message": message}
272
+ if model:
273
+ payload["model"] = model
274
+ payload.update(params)
275
+ r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
276
+ r.raise_for_status()
277
+ return r.json()
278
+
279
+
280
+ # ----------------------------------------------------------------------------
281
+ # Interface interactive
282
+ # ----------------------------------------------------------------------------
283
+
284
+ HELP_TEXT = """
285
+ /model menu numéroté pour choisir le modèle
286
+ /model <id|num> change directement de modèle
287
+ /models liste les modèles disponibles
288
+ /status réaffiche l'en-tête de connexion
289
+ /save [nom] exporte la conversation en Markdown
290
+ /clear efface l'écran
291
+ /help affiche cette aide
292
+ /quit, /exit quitte le CLI
293
+ """
294
+
295
+
296
+ def term_width(default=80):
297
+ return shutil.get_terminal_size((default, 20)).columns
298
+
299
+
300
+ def format_elapsed(ms):
301
+ return f"{ms} ms" if ms < 1000 else f"{ms / 1000:.1f} s"
302
+
303
+
304
+ def print_error(text, detail=None):
305
+ print(f" {error_text('✗', bold=True)} {text}")
306
+ if detail:
307
+ print(f" {muted(detail)}")
308
+
309
+
310
+ def choose_default_model(models_payload):
311
+ default = models_payload.get("default")
312
+ models = models_payload.get("models", [])
313
+ return default or (models[0]["id"] if models else None)
314
+
315
+
316
+ def print_models(models_payload, current_model):
317
+ for i, m in enumerate(models_payload.get("models", []), start=1):
318
+ is_current = m["id"] == current_model
319
+ marker = accent("›") if is_current else " "
320
+ label = accent(m["label"], bold=True) if is_current else m["label"]
321
+ detail = f"{m['params']} · {m['id']}"
322
+ print(f" {marker} {muted(f'[{i}]')} {label} {muted(detail)}")
323
+
324
+
325
+ def resolve_model_choice(choice, models):
326
+ if choice.isdigit():
327
+ idx = int(choice) - 1
328
+ if 0 <= idx < len(models):
329
+ return models[idx]["id"]
330
+ return None
331
+ ids = {m["id"] for m in models}
332
+ if choice in ids:
333
+ return choice
334
+ matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
335
+ return matches[0] if len(matches) == 1 else None
336
+
337
+
338
+ def select_model_interactively(models_payload, current_model):
339
+ models = models_payload.get("models", [])
340
+ if not models:
341
+ print_error("aucun modèle disponible côté serveur.")
342
+ return current_model
343
+ print_models(models_payload, current_model)
344
+ choice = input(f"\n {muted('Numéro (Entrée pour annuler) :')} ").strip()
345
+ if not choice:
346
+ return current_model
347
+ resolved = resolve_model_choice(choice, models)
348
+ if resolved is None:
349
+ print_error(f"choix invalide : '{choice}'")
350
+ return current_model
351
+ print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
352
+ return resolved
353
+
354
+
355
+ def print_assistant_message(text, elapsed_ms):
356
+ width = max(term_width() - 4, 30)
357
+ wrapped = textwrap.fill(text, width=width)
358
+ print()
359
+ for line in wrapped.split("\n"):
360
+ print(f" {line}")
361
+ print(f" {muted(format_elapsed(elapsed_ms))}")
362
+
363
+
364
+ def run_chat(client: OpsiomClient, url, api_key):
365
+ setup_history()
366
+ transcript = []
367
+ current_model = None
368
+ models_payload = {"models": []}
369
+ connected = False
370
+ models_loaded = []
371
+
372
+ try:
373
+ health = client.health()
374
+ connected = health.get("status") == "ok"
375
+ models_loaded = health.get("models_loaded", [])
376
+ except requests.RequestException:
377
+ connected = False
378
+
379
+ try:
380
+ models_payload = client.list_models()
381
+ current_model = choose_default_model(models_payload)
382
+ except requests.RequestException:
383
+ pass
384
+
385
+ print_header(url, connected, api_key, models_loaded, current_model)
386
+ print(f" {muted('Tapez votre message, ou /help pour la liste des commandes.')}\n")
387
+
388
+ while True:
389
+ try:
390
+ user_input = input(f"{accent('›')} ").strip()
391
+ except (EOFError, KeyboardInterrupt):
392
+ print()
393
+ break
394
+
395
+ if not user_input:
396
+ continue
397
+
398
+ if user_input.startswith("/"):
399
+ cmd, *rest = user_input[1:].split(maxsplit=1)
400
+ arg = rest[0].strip() if rest else ""
401
+ cmd = cmd.lower()
402
+
403
+ if cmd in ("quit", "exit"):
404
+ break
405
+ elif cmd == "help":
406
+ print(muted(HELP_TEXT))
407
+ elif cmd == "clear":
408
+ sys.stdout.write("\033[H\033[J")
409
+ sys.stdout.flush()
410
+ elif cmd == "status":
411
+ print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
412
+ elif cmd == "save":
413
+ if not transcript:
414
+ print(muted(" rien à enregistrer pour l'instant."))
415
+ else:
416
+ path = save_transcript(transcript, arg or None)
417
+ print(f" {accent('→')} enregistré dans {path}")
418
+ elif cmd == "models":
419
+ try:
420
+ models_payload = client.list_models()
421
+ print_models(models_payload, current_model)
422
+ except requests.RequestException as e:
423
+ print_error("impossible de récupérer les modèles", str(e))
424
+ elif cmd == "model":
425
+ if not arg:
426
+ current_model = select_model_interactively(models_payload, current_model)
427
+ else:
428
+ resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
429
+ current_model = resolved
430
+ print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
431
+ else:
432
+ print_error(f"commande inconnue : /{cmd}")
433
+ print()
434
+ continue
435
+
436
+ transcript.append({"role": "user", "text": user_input})
437
+
438
+ spinner = Spinner(label="Réflexion")
439
+ spinner.start()
440
+ start_time = time.perf_counter()
441
+ try:
442
+ result = client.chat(user_input, model=current_model)
443
+ except KeyboardInterrupt:
444
+ spinner.stop()
445
+ print(muted(" (requête annulée)\n"))
446
+ continue
447
+ except requests.RequestException as e:
448
+ spinner.stop()
449
+ print_error("la requête a échoué", str(e))
450
+ print()
451
+ continue
452
+ spinner.stop()
453
+ elapsed_ms = int((time.perf_counter() - start_time) * 1000)
454
+
455
+ response_text = result.get("response", "(réponse vide)")
456
+ used_model = result.get("model", current_model or "?")
457
+ print_assistant_message(response_text, elapsed_ms)
458
+ print()
459
+ transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
460
+
461
+ save_history()
462
+
463
+
464
+ # ----------------------------------------------------------------------------
465
+ # Point d'entrée
466
+ # ----------------------------------------------------------------------------
467
+
468
+ def main():
469
+ parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
470
+ parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
471
+ parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
472
+ parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
473
+ parser.add_argument("--no-logo", action="store_true", help="masque le repère ASCII au démarrage")
474
+ parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
475
+ args = parser.parse_args()
476
+
477
+ url, api_key = resolve_settings(args)
478
+ if not url:
479
+ print_error("aucune URL configurée, abandon.")
480
+ sys.exit(1)
481
+
482
+ client = OpsiomClient(url, api_key=api_key)
483
+ if args.no_logo:
484
+ global OCTOPUS_MARK
485
+ OCTOPUS_MARK = []
486
+ run_chat(client, url, api_key)
487
+ print(muted("À bientôt.\n"))
488
+
489
+
490
+ if __name__ == "__main__":
491
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
5
5
  Author-email: Ton nom <toi@example.com>
6
6
  License-Expression: MIT
@@ -1,712 +0,0 @@
1
- #!/usr/bin/env python3
2
- # ============================================================================
3
- # Opsiom CLI — interface en ligne de commande pour discuter avec Opsiom
4
- # Thème violet / rouge / rose. Mascotte poulpe procédurale avec plusieurs
5
- # animations distinctes : respiration, ondulation des tentacules, clin
6
- # d'œil, salut d'accueil, bulles de réflexion, et nuage d'encre en cas
7
- # d'erreur.
8
- #
9
- # Dépendances : uniquement `requests`
10
- # pip install requests
11
- #
12
- # Configuration (aucune URL n'est codée en dur dans ce fichier) :
13
- # Au premier lancement, le CLI demande l'URL du serveur (et une éventuelle
14
- # clé API) puis les enregistre dans ~/.config/opsiom/config.json.
15
- # Alternative : variables d'environnement OPSIOM_URL / OPSIOM_API_KEY,
16
- # ou options --url / --api-key.
17
- # ============================================================================
18
-
19
- import os
20
- import sys
21
- import json
22
- import time
23
- import shutil
24
- import textwrap
25
- import argparse
26
- import threading
27
- import itertools
28
- from pathlib import Path
29
-
30
- try:
31
- import requests
32
- except ImportError:
33
- print("Ce CLI nécessite le paquet 'requests'. Installe-le avec : pip install requests")
34
- sys.exit(1)
35
-
36
- try:
37
- import readline # active l'historique flèche-haut et son édition de ligne
38
- except ImportError:
39
- readline = None # non disponible nativement sur certains Windows (pyreadline3 comble le manque)
40
-
41
- VERSION = "1.0.2"
42
- CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
43
- HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
44
- TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
45
-
46
-
47
- def setup_history():
48
- if not readline:
49
- return
50
- HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
51
- try:
52
- readline.read_history_file(HISTORY_PATH)
53
- except OSError:
54
- pass
55
- readline.set_history_length(500)
56
-
57
-
58
- def save_history():
59
- if not readline:
60
- return
61
- try:
62
- readline.write_history_file(HISTORY_PATH)
63
- except OSError:
64
- pass
65
-
66
-
67
- def save_transcript(transcript, filename=None):
68
- TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
69
- if not filename:
70
- filename = f"opsiom-{time.strftime('%Y%m%d-%H%M%S')}"
71
- if not filename.endswith(".md"):
72
- filename += ".md"
73
- path = TRANSCRIPTS_DIR / filename
74
- lines = ["# Conversation Opsiom", ""]
75
- for entry in transcript:
76
- if entry["role"] == "user":
77
- lines.append(f"**Vous :** {entry['text']}")
78
- else:
79
- lines.append(f"**Opsiom ({entry['model']}) :** {entry['text']} _({entry['elapsed_ms']} ms)_")
80
- lines.append("")
81
- path.write_text("\n".join(lines), encoding="utf-8")
82
- return path
83
-
84
-
85
- # ----------------------------------------------------------------------------
86
- # Palette — dégradé violet -> rose -> rouge, en vraies couleurs 24 bits (ANSI)
87
- # ----------------------------------------------------------------------------
88
-
89
- RESET = "\033[0m"
90
- BOLD = "\033[1m"
91
- HIDE_CURSOR = "\033[?25l"
92
- SHOW_CURSOR = "\033[?25h"
93
-
94
- GRADIENT_STOPS = [
95
- (124, 58, 199),
96
- (168, 60, 178),
97
- (214, 62, 140),
98
- (235, 74, 96),
99
- ]
100
- ACCENT = (222, 90, 176)
101
- MUTED = (150, 110, 150)
102
- USER_COLOR = (235, 130, 190)
103
- ERROR_COLOR = (235, 90, 90)
104
- SUCCESS_COLOR = (180, 230, 190)
105
-
106
-
107
- def rgb(color, text, bold=False):
108
- r, g, b = color
109
- prefix = BOLD if bold else ""
110
- return f"{prefix}\033[38;2;{r};{g};{b}m{text}{RESET}"
111
-
112
-
113
- def _lerp(a, b, t):
114
- return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
115
-
116
-
117
- def gradient_line(text, stops=GRADIENT_STOPS, bold=False):
118
- n = max(len(text) - 1, 1)
119
- segments = len(stops) - 1
120
- out = []
121
- prefix = BOLD if bold else ""
122
- for i, ch in enumerate(text):
123
- t = (i / n) * segments
124
- seg = min(int(t), segments - 1)
125
- local_t = t - seg
126
- r, g, b = _lerp(stops[seg], stops[seg + 1], local_t)
127
- out.append(f"{prefix}\033[38;2;{r};{g};{b}m{ch}")
128
- return "".join(out) + RESET
129
-
130
-
131
- def gradient_lines(lines, stops=GRADIENT_STOPS):
132
- n = max(len(lines) - 1, 1)
133
- segments = len(stops) - 1
134
- out = []
135
- for i, line in enumerate(lines):
136
- t = (i / n) * segments
137
- seg = min(int(t), segments - 1)
138
- local_t = t - seg
139
- color = _lerp(stops[seg], stops[seg + 1], local_t)
140
- out.append(rgb(color, line))
141
- return out
142
-
143
-
144
- def gradient_block(lines, stops=GRADIENT_STOPS):
145
- return "\n".join(gradient_lines(lines, stops))
146
-
147
-
148
- def draw_panel(lines_with_colors, min_width=46):
149
- """Panneau encadré (box-drawing) : une couleur unie par ligne, largeur auto."""
150
- plain_texts = [t for t, _ in lines_with_colors]
151
- content_width = max(min_width, max((len(t) for t in plain_texts), default=0) + 2)
152
- top = "╭" + "─" * (content_width + 2) + "╮"
153
- bottom = "╰" + "─" * (content_width + 2) + "╯"
154
- out = [rgb(ACCENT, top)]
155
- for text, color in lines_with_colors:
156
- padded = text.ljust(content_width)
157
- out.append(rgb(ACCENT, "│") + " " + rgb(color, padded) + " " + rgb(ACCENT, "│"))
158
- out.append(rgb(ACCENT, bottom))
159
- return "\n".join(out)
160
-
161
-
162
- # ----------------------------------------------------------------------------
163
- # La mascotte — un poulpe construit à partir de gabarits (tête fixe, yeux /
164
- # bouche / tentacules interchangeables) pour composer beaucoup de frames
165
- # différentes sans tout redessiner à la main à chaque fois.
166
- # ----------------------------------------------------------------------------
167
-
168
- FRAME_HEIGHT = 13
169
-
170
- EYES_OPEN = ("◉", "◉")
171
- EYES_BLINK = ("─", "─")
172
- EYES_WINK = ("◉", "◕")
173
- EYES_SAD = ("×", "×")
174
-
175
- MOUTH_NEUTRAL = "⌣"
176
- MOUTH_SAD = "⌢"
177
- MOUTH_OPEN = "○"
178
-
179
- TENTACLES_REST = [
180
- " / / | \\ \\",
181
- " ( ( | ) )",
182
- " \\ \\ | / /",
183
- " `._\\ | /_.'",
184
- ]
185
- TENTACLES_REST_CUPS = [
186
- " / / o|o \\ \\",
187
- " ( ( | ) )",
188
- " \\ \\ o|o / /",
189
- " `._\\ | /_.'",
190
- ]
191
- TENTACLES_LEFT = [
192
- " \\ / | \\ /",
193
- " ( ( | ) )",
194
- " / \\ | / \\",
195
- " -' `._ | _.' `- ",
196
- ]
197
- TENTACLES_RIGHT = [
198
- " / \\ | / \\",
199
- " ( ) | ( ) ",
200
- " \\ / | \\ /",
201
- " `-' _|_ `-'",
202
- ]
203
- TENTACLES_DROOP = [
204
- " ~ / \\ | / \\ ~",
205
- " ~ ( ) | ( ) ~",
206
- " ~ \\ / | \\ / ~",
207
- " * `.|.' *",
208
- ]
209
-
210
-
211
- def build_frame(top, eyes, mouth, tentacles, bottom=""):
212
- left_eye, right_eye = eyes
213
- return [
214
- top,
215
- " .───────.",
216
- " .─' '─.",
217
- f" / {left_eye} {right_eye} \\",
218
- " | |",
219
- f" | {mouth} |",
220
- " \\ /",
221
- " '─..-----..─'",
222
- tentacles[0],
223
- tentacles[1],
224
- tentacles[2],
225
- tentacles[3],
226
- bottom,
227
- ]
228
-
229
-
230
- # --- Respiration au repos (2 frames) ---------------------------------------
231
- FRAMES_BREATHE = [
232
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
233
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
234
- ]
235
-
236
- # --- Clin d'œil, inséré de temps en temps dans les cycles -------------------
237
- FRAME_BLINK = build_frame("", EYES_BLINK, MOUTH_NEUTRAL, TENTACLES_REST)
238
-
239
- # --- Ondulation des tentacules (4 frames) -----------------------------------
240
- FRAMES_WAVE = [
241
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_LEFT),
242
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
243
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_RIGHT),
244
- FRAME_BLINK,
245
- ]
246
-
247
- # --- Bulles de réflexion qui montent (utilisées pendant l'appel réseau) ----
248
- _BUBBLE_STAGES = ["", " .", " o",
249
- " O", " 0",
250
- " °", ""]
251
- _THINKING_TENTACLES = itertools.cycle([TENTACLES_REST, TENTACLES_LEFT, TENTACLES_REST_CUPS, TENTACLES_RIGHT])
252
- FRAMES_THINKING = [
253
- build_frame(bubble, EYES_BLINK if i == 3 else EYES_OPEN, MOUTH_NEUTRAL, next(_THINKING_TENTACLES))
254
- for i, bubble in enumerate(_BUBBLE_STAGES)
255
- ]
256
-
257
- # --- Salut d'accueil : un tentacule se lève et fait coucou -----------------
258
- FRAMES_GREETING = [
259
- [
260
- " o",
261
- " .───────. /",
262
- " .─' '─. _/",
263
- " / ◉ ◕ \\",
264
- " | |",
265
- " | ⌣ |",
266
- " \\ /",
267
- " '─..-----..─'",
268
- " / / | \\ \\",
269
- " ( ( | ) )",
270
- " \\ \\ | / /",
271
- " `._\\ | /_.'",
272
- "",
273
- ],
274
- [
275
- " \\ o",
276
- " .───────. \\",
277
- " .─' '─.",
278
- " / ◕ ◉ \\",
279
- " | |",
280
- " | ⌣ |",
281
- " \\ /",
282
- " '─..-----..─'",
283
- " / / | \\ \\",
284
- " ( ( | ) )",
285
- " \\ \\ | / /",
286
- " `._\\ | /_.'",
287
- "",
288
- ],
289
- build_frame("", EYES_WINK, MOUTH_NEUTRAL, TENTACLES_REST),
290
- build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
291
- ]
292
-
293
- # --- Nuage d'encre + mine déconfite (erreur réseau) -------------------------
294
- FRAMES_ERROR = [
295
- build_frame(" . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " * ~ ."),
296
- build_frame(" * . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " . ~ * ~ ."),
297
- ]
298
-
299
- # --- Petite étincelle affichée brièvement après une réponse réussie --------
300
- SPARKLE_FRAMES = [" ✦", " · ✦ ·", " ˚ · ✦ · ˚"]
301
-
302
- TITLE = [
303
- " ██████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗",
304
- "██╔═══██╗██╔══██╗██╔════╝██║██╔═══██╗████╗ ████║",
305
- "██║ ██║██████╔╝███████╗██║██║ ██║██╔████╔██║",
306
- "██║ ██║██╔═══╝ ╚════██║██║██║ ██║██║╚██╔╝██║",
307
- "╚██████╔╝██║ ███████║██║╚██████╔╝██║ ╚═╝ ██║",
308
- " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝",
309
- ]
310
-
311
-
312
- # ----------------------------------------------------------------------------
313
- # Moteur d'animation générique — anime n'importe quelle liste de frames,
314
- # avec ou sans étiquette de texte sous le poulpe.
315
- # ----------------------------------------------------------------------------
316
-
317
- class OctopusAnimation:
318
- def __init__(self, frames, label=None, interval=0.2, loop=True, dynamic=None):
319
- self.frames = frames
320
- self.label = label
321
- self.interval = interval
322
- self.loop = loop
323
- self.dynamic = dynamic # callable optionnel -> suffixe texte (ex: minuteur en direct)
324
- self._stop_event = threading.Event()
325
- self._thread = None
326
-
327
- def _iter_frames(self):
328
- return itertools.cycle(self.frames) if self.loop else iter(self.frames)
329
-
330
- def _run(self):
331
- dots = itertools.cycle([" ", ". ", ".. ", "..."]) if (self.label and not self.dynamic) else None
332
- n_lines = FRAME_HEIGHT + (1 if self.label else 0)
333
- print(HIDE_CURSOR, end="")
334
- first = True
335
- for frame in self._iter_frames():
336
- if self._stop_event.is_set():
337
- break
338
- if not first:
339
- sys.stdout.write(f"\033[{n_lines}A")
340
- first = False
341
- for line in gradient_lines(frame):
342
- sys.stdout.write(line + "\033[K\n")
343
- if self.label:
344
- suffix = self.dynamic() if self.dynamic else next(dots)
345
- sys.stdout.write(rgb(ACCENT, f" {self.label}{suffix}") + "\033[K\n")
346
- sys.stdout.flush()
347
- time.sleep(self.interval)
348
- sys.stdout.write(f"\033[{n_lines}A")
349
- for _ in range(n_lines):
350
- sys.stdout.write("\033[K\n")
351
- sys.stdout.write(f"\033[{n_lines}A")
352
- sys.stdout.write(SHOW_CURSOR)
353
- sys.stdout.flush()
354
-
355
- def start(self):
356
- self._stop_event.clear()
357
- self._thread = threading.Thread(target=self._run, daemon=True)
358
- self._thread.start()
359
-
360
- def stop(self):
361
- self._stop_event.set()
362
- if self._thread:
363
- self._thread.join()
364
-
365
- def play_once_blocking(self):
366
- """Joue la séquence une fois, sans thread, puis rend la main (pour l'intro)."""
367
- self.loop = False
368
- print(HIDE_CURSOR, end="")
369
- for frame in self.frames:
370
- sys.stdout.write("\033[H\033[J")
371
- sys.stdout.write("\n" + gradient_block(frame) + "\n")
372
- sys.stdout.flush()
373
- time.sleep(self.interval)
374
- print(SHOW_CURSOR, end="")
375
-
376
-
377
- def print_intro_animation():
378
- OctopusAnimation(FRAMES_GREETING + [FRAMES_BREATHE[0]], interval=0.22).play_once_blocking()
379
- sys.stdout.write("\033[H\033[J")
380
- print()
381
- print(gradient_block(TITLE))
382
- print(rgb(MUTED, f" — assistant IA francophone · v{VERSION} —\n"))
383
-
384
-
385
- def flash_sparkle():
386
- """Étincelle furtive affichée juste avant une réponse réussie."""
387
- print(HIDE_CURSOR, end="")
388
- for frame in SPARKLE_FRAMES:
389
- sys.stdout.write("\r" + rgb(SUCCESS_COLOR, frame) + "\033[K")
390
- sys.stdout.flush()
391
- time.sleep(0.08)
392
- sys.stdout.write("\r\033[K")
393
- sys.stdout.write(SHOW_CURSOR)
394
- sys.stdout.flush()
395
-
396
-
397
- def show_error_animation():
398
- """Nuage d'encre + mine déconfite, joué deux fois puis nettoyé."""
399
- OctopusAnimation(FRAMES_ERROR, interval=0.3, loop=False)._run()
400
-
401
-
402
- # ----------------------------------------------------------------------------
403
- # Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
404
- # ----------------------------------------------------------------------------
405
-
406
- def load_config():
407
- if CONFIG_PATH.exists():
408
- try:
409
- return json.loads(CONFIG_PATH.read_text())
410
- except (json.JSONDecodeError, OSError):
411
- return {}
412
- return {}
413
-
414
-
415
- def save_config(config):
416
- CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
417
- CONFIG_PATH.write_text(json.dumps(config, indent=2))
418
-
419
-
420
- def prompt_for_config():
421
- print(rgb(ACCENT, "Configuration initiale d'Opsiom CLI", bold=True))
422
- print(rgb(MUTED, " (ces informations te sont normalement transmises par la personne qui héberge Opsiom)\n"))
423
- url = input(rgb(USER_COLOR, "URL du serveur Opsiom : ")).strip().rstrip("/")
424
- api_key = input(rgb(USER_COLOR, "Clé API (laisser vide si aucune) : ")).strip()
425
- save = input(rgb(MUTED, "Sauvegarder ces informations localement pour la prochaine fois ? [O/n] ")).strip().lower()
426
- config = {"url": url, "api_key": api_key}
427
- if save in ("", "o", "oui", "y", "yes"):
428
- save_config(config)
429
- print(rgb(MUTED, f" → enregistré dans {CONFIG_PATH}\n"))
430
- return config
431
-
432
-
433
- def resolve_settings(args):
434
- config = {} if args.configure else load_config()
435
- url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
436
- api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
437
- if args.configure or not url:
438
- new_config = prompt_for_config()
439
- url = new_config["url"] or url
440
- api_key = new_config["api_key"] or api_key
441
- return url, api_key
442
-
443
-
444
- # ----------------------------------------------------------------------------
445
- # Client API
446
- # ----------------------------------------------------------------------------
447
-
448
- class OpsiomClient:
449
- def __init__(self, base_url, api_key=None, timeout=60):
450
- self.base_url = base_url.rstrip("/")
451
- self.timeout = timeout
452
- self.session = requests.Session()
453
- headers = {"ngrok-skip-browser-warning": "true"}
454
- if api_key:
455
- headers["X-API-Key"] = api_key
456
- self.session.headers.update(headers)
457
-
458
- def health(self):
459
- r = self.session.get(f"{self.base_url}/api/health", timeout=10)
460
- r.raise_for_status()
461
- return r.json()
462
-
463
- def list_models(self):
464
- r = self.session.get(f"{self.base_url}/api/models", timeout=10)
465
- r.raise_for_status()
466
- return r.json()
467
-
468
- def chat(self, message, model=None, **params):
469
- payload = {"message": message}
470
- if model:
471
- payload["model"] = model
472
- payload.update(params)
473
- r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
474
- r.raise_for_status()
475
- return r.json()
476
-
477
-
478
- # ----------------------------------------------------------------------------
479
- # Interface interactive
480
- # ----------------------------------------------------------------------------
481
-
482
- HELP_TEXT = """
483
- Commandes disponibles :
484
- /model menu numéroté pour choisir le modèle
485
- /model <id|num> change directement de modèle (ex: /model small, /model 2)
486
- /models liste les modèles disponibles
487
- /status réaffiche le panneau de connexion
488
- /save [nom] exporte la conversation en Markdown (~/opsiom-conversations/)
489
- /clear efface l'écran
490
- /help affiche cette aide
491
- /quit, /exit quitte le CLI (Ctrl+C pendant une réponse : annule juste cette requête)
492
- """
493
-
494
-
495
- def term_width(default=76):
496
- return shutil.get_terminal_size((default, 20)).columns
497
-
498
-
499
- def print_separator():
500
- print(rgb(MUTED, "─" * min(term_width(), 76)))
501
-
502
-
503
- def print_status_panel(url, api_key, connected, models_loaded):
504
- status_text = f"● Connecté — {url}" if connected else f"⨯ Non connecté — {url}"
505
- status_color = ACCENT if connected else ERROR_COLOR
506
- lines = [
507
- (f"OPSIOM CLI · v{VERSION}", ACCENT),
508
- (status_text, status_color),
509
- (f"Authentification : {'clé API fournie' if api_key else 'aucune'}", MUTED),
510
- (f"Modèles chargés : {', '.join(models_loaded)}" if models_loaded else "Modèles : indisponible", MUTED),
511
- ]
512
- print(draw_panel(lines))
513
-
514
-
515
- def print_assistant_message(text, model_id, elapsed_ms=None):
516
- prefix_plain = f"Opsiom ({model_id}) › "
517
- prefix_colored = rgb(ACCENT, "Opsiom", bold=True) + rgb(MUTED, f" ({model_id})") + rgb(MUTED, " › ")
518
- width = max(term_width() - len(prefix_plain) - 12, 24)
519
- wrapped = textwrap.fill(text, width=width, subsequent_indent=" " * len(prefix_plain))
520
- suffix = rgb(MUTED, f" [{elapsed_ms} ms]") if elapsed_ms is not None else ""
521
- print(prefix_colored + wrapped + suffix)
522
-
523
-
524
- def print_error(text):
525
- print(rgb(ERROR_COLOR, f"⨯ {text}", bold=True))
526
-
527
-
528
- def choose_default_model(models_payload):
529
- default = models_payload.get("default")
530
- models = models_payload.get("models", [])
531
- if default:
532
- return default
533
- return models[0]["id"] if models else None
534
-
535
-
536
- def print_models(models_payload, current_model):
537
- print(rgb(ACCENT, "Modèles disponibles :", bold=True))
538
- for i, m in enumerate(models_payload.get("models", []), start=1):
539
- marker = rgb(ACCENT, "●") if m["id"] == current_model else rgb(MUTED, "○")
540
- detail = f"({m['params']} — id: {m['id']})"
541
- print(f" {rgb(MUTED, f'[{i}]')} {marker} {gradient_line(m['label'])} {rgb(MUTED, detail)}")
542
-
543
-
544
- def resolve_model_choice(choice, models):
545
- if choice.isdigit():
546
- idx = int(choice) - 1
547
- if 0 <= idx < len(models):
548
- return models[idx]["id"]
549
- return None
550
- ids = {m["id"] for m in models}
551
- if choice in ids:
552
- return choice
553
- matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
554
- return matches[0] if len(matches) == 1 else None
555
-
556
-
557
- def select_model_interactively(models_payload, current_model):
558
- models = models_payload.get("models", [])
559
- if not models:
560
- print_error("aucun modèle disponible côté serveur.")
561
- return current_model
562
- print_models(models_payload, current_model)
563
- choice = input(rgb(USER_COLOR, "Choisis un numéro (ou Entrée pour annuler) : ")).strip()
564
- if not choice:
565
- return current_model
566
- resolved = resolve_model_choice(choice, models)
567
- if resolved is None:
568
- print_error(f"choix invalide : '{choice}'")
569
- return current_model
570
- print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
571
- return resolved
572
-
573
-
574
- def run_chat(client: OpsiomClient, url, api_key):
575
- setup_history()
576
- transcript = []
577
- current_model = None
578
- models_payload = {"models": []}
579
- connected = False
580
- models_loaded = []
581
- try:
582
- health = client.health()
583
- connected = health.get("status") == "ok"
584
- models_loaded = health.get("models_loaded", [])
585
- except requests.RequestException:
586
- connected = False
587
-
588
- print_status_panel(url, api_key, connected, models_loaded)
589
- print()
590
-
591
- try:
592
- models_payload = client.list_models()
593
- current_model = choose_default_model(models_payload)
594
- print_models(models_payload, current_model)
595
- print()
596
- except requests.RequestException as e:
597
- print_error(f"impossible de récupérer la liste des modèles ({e})")
598
- print()
599
-
600
- print(rgb(MUTED, "Tape ton message, ou /help pour la liste des commandes.\n"))
601
-
602
- while True:
603
- try:
604
- user_input = input(rgb(USER_COLOR, "vous", bold=True) + rgb(MUTED, " › "))
605
- except (EOFError, KeyboardInterrupt):
606
- print()
607
- break
608
-
609
- user_input = user_input.strip()
610
- if not user_input:
611
- continue
612
-
613
- if user_input.startswith("/"):
614
- cmd, *rest = user_input[1:].split(maxsplit=1)
615
- arg = rest[0].strip() if rest else ""
616
- cmd = cmd.lower()
617
-
618
- if cmd in ("quit", "exit"):
619
- break
620
- elif cmd == "help":
621
- print(rgb(MUTED, HELP_TEXT))
622
- elif cmd == "clear":
623
- sys.stdout.write("\033[H\033[J")
624
- sys.stdout.flush()
625
- elif cmd == "status":
626
- print_status_panel(url, api_key, connected, models_loaded)
627
- elif cmd == "save":
628
- if not transcript:
629
- print(rgb(MUTED, " rien à enregistrer pour l'instant."))
630
- else:
631
- path = save_transcript(transcript, arg or None)
632
- print(rgb(ACCENT, f"→ conversation enregistrée dans {path}"))
633
- elif cmd == "models":
634
- try:
635
- models_payload = client.list_models()
636
- print_models(models_payload, current_model)
637
- except requests.RequestException as e:
638
- print_error(f"requête échouée ({e})")
639
- elif cmd == "model":
640
- if not arg:
641
- current_model = select_model_interactively(models_payload, current_model)
642
- else:
643
- resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
644
- current_model = resolved
645
- print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
646
- else:
647
- print_error(f"commande inconnue : /{cmd}")
648
- continue
649
-
650
- transcript.append({"role": "user", "text": user_input})
651
-
652
- start_time = time.perf_counter()
653
- anim = OctopusAnimation(
654
- FRAMES_THINKING, label="Opsiom réfléchit", interval=0.2,
655
- dynamic=lambda: f" {time.perf_counter() - start_time:0.1f}s",
656
- )
657
- anim.start()
658
- try:
659
- result = client.chat(user_input, model=current_model)
660
- except KeyboardInterrupt:
661
- anim.stop()
662
- print(rgb(MUTED, " (requête annulée — le serveur continue peut-être de calculer en arrière-plan)"))
663
- continue
664
- except requests.RequestException as e:
665
- anim.stop()
666
- show_error_animation()
667
- print_error(f"la requête a échoué ({e})")
668
- continue
669
- anim.stop()
670
- elapsed_ms = int((time.perf_counter() - start_time) * 1000)
671
-
672
- flash_sparkle()
673
- response_text = result.get("response", "(réponse vide)")
674
- used_model = result.get("model", current_model or "?")
675
- print_assistant_message(response_text, used_model, elapsed_ms)
676
- print_separator()
677
- transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
678
-
679
- save_history()
680
-
681
-
682
- # ----------------------------------------------------------------------------
683
- # Point d'entrée
684
- # ----------------------------------------------------------------------------
685
-
686
- def main():
687
- parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
688
- parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
689
- parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
690
- parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
691
- parser.add_argument("--no-animation", action="store_true", help="désactive l'animation d'intro")
692
- parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
693
- args = parser.parse_args()
694
-
695
- if not args.no_animation:
696
- print_intro_animation()
697
- else:
698
- print(gradient_block(TITLE))
699
- print()
700
-
701
- url, api_key = resolve_settings(args)
702
- if not url:
703
- print_error("aucune URL configurée, abandon.")
704
- sys.exit(1)
705
-
706
- client = OpsiomClient(url, api_key=api_key)
707
- run_chat(client, url, api_key)
708
- print(rgb(MUTED, "\nÀ bientôt.\n"))
709
-
710
-
711
- if __name__ == "__main__":
712
- main()
File without changes
File without changes