opsiom-cli 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: opsiom-cli
3
+ Version: 1.0.0
4
+ Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
5
+ Author-email: Ton nom <toi@example.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ton-compte/opsiom-cli
8
+ Keywords: opsiom,cli,chatbot,ia
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.31
12
+
13
+ # Opsiom CLI
14
+
15
+ Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA
16
+ francophone — avec une mascotte poulpe animée et un thème violet/rose/rouge.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install opsiom-cli
22
+ ```
23
+
24
+ (ou, en isolant proprement l'outil : `pipx install opsiom-cli`)
25
+
26
+ ## Utilisation
27
+
28
+ ```bash
29
+ opsiom
30
+ ```
31
+
32
+ Au premier lancement, le CLI te demande l'URL du serveur Opsiom (et une
33
+ éventuelle clé API) puis les enregistre dans `~/.config/opsiom/config.json`.
34
+ **Ce paquet ne contient aucune URL de serveur codée en dur** — il te faut
35
+ l'URL et, si le serveur en exige une, la clé API fournie par la personne qui
36
+ héberge l'instance à laquelle tu veux te connecter.
37
+
38
+ Alternative sans fichier de config :
39
+
40
+ ```bash
41
+ opsiom --url https://xxxx.ngrok-free.dev --api-key ta_cle
42
+ ```
43
+
44
+ Reconfigurer :
45
+
46
+ ```bash
47
+ opsiom --configure
48
+ ```
49
+
50
+ ## Commandes du chat
51
+
52
+ - `/model` — menu numéroté pour choisir le modèle
53
+ - `/model <id|numéro>` — change directement de modèle
54
+ - `/models` — liste les modèles disponibles
55
+ - `/status` — réaffiche le panneau de connexion
56
+ - `/clear` — efface l'écran
57
+ - `/help` — aide
58
+ - `/quit`, `/exit` — quitte
59
+
60
+ ## Licence
61
+
62
+ MIT
@@ -0,0 +1,50 @@
1
+ # Opsiom CLI
2
+
3
+ Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA
4
+ francophone — avec une mascotte poulpe animée et un thème violet/rose/rouge.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install opsiom-cli
10
+ ```
11
+
12
+ (ou, en isolant proprement l'outil : `pipx install opsiom-cli`)
13
+
14
+ ## Utilisation
15
+
16
+ ```bash
17
+ opsiom
18
+ ```
19
+
20
+ Au premier lancement, le CLI te demande l'URL du serveur Opsiom (et une
21
+ éventuelle clé API) puis les enregistre dans `~/.config/opsiom/config.json`.
22
+ **Ce paquet ne contient aucune URL de serveur codée en dur** — il te faut
23
+ l'URL et, si le serveur en exige une, la clé API fournie par la personne qui
24
+ héberge l'instance à laquelle tu veux te connecter.
25
+
26
+ Alternative sans fichier de config :
27
+
28
+ ```bash
29
+ opsiom --url https://xxxx.ngrok-free.dev --api-key ta_cle
30
+ ```
31
+
32
+ Reconfigurer :
33
+
34
+ ```bash
35
+ opsiom --configure
36
+ ```
37
+
38
+ ## Commandes du chat
39
+
40
+ - `/model` — menu numéroté pour choisir le modèle
41
+ - `/model <id|numéro>` — change directement de modèle
42
+ - `/models` — liste les modèles disponibles
43
+ - `/status` — réaffiche le panneau de connexion
44
+ - `/clear` — efface l'écran
45
+ - `/help` — aide
46
+ - `/quit`, `/exit` — quitte
47
+
48
+ ## Licence
49
+
50
+ MIT
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opsiom-cli"
7
+ version = "1.0.0"
8
+ description = "CLI de chat pour Opsiom, l'assistant IA francophone."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Ton nom", email = "toi@example.com" }]
13
+ dependencies = ["requests>=2.31"]
14
+ keywords = ["opsiom", "cli", "chatbot", "ia"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/ton-compte/opsiom-cli"
18
+
19
+ [project.scripts]
20
+ opsiom = "opsiom_cli.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .cli import VERSION
2
+
3
+ __all__ = ["VERSION"]
4
+ __version__ = VERSION
@@ -0,0 +1,644 @@
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
+ VERSION = "1.0.0"
37
+ CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
38
+
39
+
40
+ # ----------------------------------------------------------------------------
41
+ # Palette — dégradé violet -> rose -> rouge, en vraies couleurs 24 bits (ANSI)
42
+ # ----------------------------------------------------------------------------
43
+
44
+ RESET = "\033[0m"
45
+ BOLD = "\033[1m"
46
+ HIDE_CURSOR = "\033[?25l"
47
+ SHOW_CURSOR = "\033[?25h"
48
+
49
+ GRADIENT_STOPS = [
50
+ (124, 58, 199),
51
+ (168, 60, 178),
52
+ (214, 62, 140),
53
+ (235, 74, 96),
54
+ ]
55
+ ACCENT = (222, 90, 176)
56
+ MUTED = (150, 110, 150)
57
+ USER_COLOR = (235, 130, 190)
58
+ ERROR_COLOR = (235, 90, 90)
59
+ SUCCESS_COLOR = (180, 230, 190)
60
+
61
+
62
+ def rgb(color, text, bold=False):
63
+ r, g, b = color
64
+ prefix = BOLD if bold else ""
65
+ return f"{prefix}\033[38;2;{r};{g};{b}m{text}{RESET}"
66
+
67
+
68
+ def _lerp(a, b, t):
69
+ return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
70
+
71
+
72
+ def gradient_line(text, stops=GRADIENT_STOPS, bold=False):
73
+ n = max(len(text) - 1, 1)
74
+ segments = len(stops) - 1
75
+ out = []
76
+ prefix = BOLD if bold else ""
77
+ for i, ch in enumerate(text):
78
+ t = (i / n) * segments
79
+ seg = min(int(t), segments - 1)
80
+ local_t = t - seg
81
+ r, g, b = _lerp(stops[seg], stops[seg + 1], local_t)
82
+ out.append(f"{prefix}\033[38;2;{r};{g};{b}m{ch}")
83
+ return "".join(out) + RESET
84
+
85
+
86
+ def gradient_lines(lines, stops=GRADIENT_STOPS):
87
+ n = max(len(lines) - 1, 1)
88
+ segments = len(stops) - 1
89
+ out = []
90
+ for i, line in enumerate(lines):
91
+ t = (i / n) * segments
92
+ seg = min(int(t), segments - 1)
93
+ local_t = t - seg
94
+ color = _lerp(stops[seg], stops[seg + 1], local_t)
95
+ out.append(rgb(color, line))
96
+ return out
97
+
98
+
99
+ def gradient_block(lines, stops=GRADIENT_STOPS):
100
+ return "\n".join(gradient_lines(lines, stops))
101
+
102
+
103
+ def draw_panel(lines_with_colors, min_width=46):
104
+ """Panneau encadré (box-drawing) : une couleur unie par ligne, largeur auto."""
105
+ plain_texts = [t for t, _ in lines_with_colors]
106
+ content_width = max(min_width, max((len(t) for t in plain_texts), default=0) + 2)
107
+ top = "╭" + "─" * (content_width + 2) + "╮"
108
+ bottom = "╰" + "─" * (content_width + 2) + "╯"
109
+ out = [rgb(ACCENT, top)]
110
+ for text, color in lines_with_colors:
111
+ padded = text.ljust(content_width)
112
+ out.append(rgb(ACCENT, "│") + " " + rgb(color, padded) + " " + rgb(ACCENT, "│"))
113
+ out.append(rgb(ACCENT, bottom))
114
+ return "\n".join(out)
115
+
116
+
117
+ # ----------------------------------------------------------------------------
118
+ # La mascotte — un poulpe construit à partir de gabarits (tête fixe, yeux /
119
+ # bouche / tentacules interchangeables) pour composer beaucoup de frames
120
+ # différentes sans tout redessiner à la main à chaque fois.
121
+ # ----------------------------------------------------------------------------
122
+
123
+ FRAME_HEIGHT = 13
124
+
125
+ EYES_OPEN = ("◉", "◉")
126
+ EYES_BLINK = ("─", "─")
127
+ EYES_WINK = ("◉", "◕")
128
+ EYES_SAD = ("×", "×")
129
+
130
+ MOUTH_NEUTRAL = "⌣"
131
+ MOUTH_SAD = "⌢"
132
+ MOUTH_OPEN = "○"
133
+
134
+ TENTACLES_REST = [
135
+ " / / | \\ \\",
136
+ " ( ( | ) )",
137
+ " \\ \\ | / /",
138
+ " `._\\ | /_.'",
139
+ ]
140
+ TENTACLES_REST_CUPS = [
141
+ " / / o \\ \\",
142
+ " ( ( | ) )",
143
+ " \\ \\ o / /",
144
+ " `._\\ | /_.'",
145
+ ]
146
+ TENTACLES_LEFT = [
147
+ " \\ / | \\ /",
148
+ " ) ( | ) (",
149
+ " / \\ | / \\",
150
+ " -' `._ | _.' `-",
151
+ ]
152
+ TENTACLES_RIGHT = [
153
+ " / \\ | / \\",
154
+ " ( ) | ( )",
155
+ " \\ / | \\ /",
156
+ " `-' ._|_. `-'",
157
+ ]
158
+ TENTACLES_DROOP = [
159
+ " ~ / \\ | / \\ ~",
160
+ " ~ ( ) | ( ) ~",
161
+ " ~ \\ / | \\ / ~",
162
+ " * `.|.` *",
163
+ ]
164
+
165
+
166
+ def build_frame(top, eyes, mouth, tentacles, bottom=""):
167
+ left_eye, right_eye = eyes
168
+ return [
169
+ top,
170
+ " .───────.",
171
+ " .─' '─.",
172
+ f" / {left_eye} {right_eye} \\",
173
+ " | |",
174
+ f" | {mouth} |",
175
+ " \\ /",
176
+ " '─..-----..─'",
177
+ tentacles[0],
178
+ tentacles[1],
179
+ tentacles[2],
180
+ tentacles[3],
181
+ bottom,
182
+ ]
183
+
184
+
185
+ # --- Respiration au repos (2 frames) ---------------------------------------
186
+ FRAMES_BREATHE = [
187
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
188
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
189
+ ]
190
+
191
+ # --- Clin d'œil, inséré de temps en temps dans les cycles -------------------
192
+ FRAME_BLINK = build_frame("", EYES_BLINK, MOUTH_NEUTRAL, TENTACLES_REST)
193
+
194
+ # --- Ondulation des tentacules (4 frames) -----------------------------------
195
+ FRAMES_WAVE = [
196
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_LEFT),
197
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
198
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_RIGHT),
199
+ FRAME_BLINK,
200
+ ]
201
+
202
+ # --- Bulles de réflexion qui montent (utilisées pendant l'appel réseau) ----
203
+ _BUBBLE_STAGES = ["", " .", " o",
204
+ " O", " 0",
205
+ " °", ""]
206
+ _THINKING_TENTACLES = itertools.cycle([TENTACLES_REST, TENTACLES_LEFT, TENTACLES_REST_CUPS, TENTACLES_RIGHT])
207
+ FRAMES_THINKING = [
208
+ build_frame(bubble, EYES_BLINK if i == 3 else EYES_OPEN, MOUTH_NEUTRAL, next(_THINKING_TENTACLES))
209
+ for i, bubble in enumerate(_BUBBLE_STAGES)
210
+ ]
211
+
212
+ # --- Salut d'accueil : un tentacule se lève et fait coucou -----------------
213
+ FRAMES_GREETING = [
214
+ [
215
+ " o",
216
+ " .───────. /",
217
+ " .─' '─. _/",
218
+ " / ◉ ◕ \\",
219
+ " | |",
220
+ " | ⌣ |",
221
+ " \\ /",
222
+ " '─..-----..─'",
223
+ " / / | \\ \\",
224
+ " ( ( | ) )",
225
+ " \\ \\ | / /",
226
+ " `._\\ | /_.'",
227
+ "",
228
+ ],
229
+ [
230
+ " \\ o",
231
+ " .───────. \\",
232
+ " .─' '─.",
233
+ " / ◕ ◉ \\",
234
+ " | |",
235
+ " | ⌣ |",
236
+ " \\ /",
237
+ " '─..-----..─'",
238
+ " / / | \\ \\",
239
+ " ( ( | ) )",
240
+ " \\ \\ | / /",
241
+ " `._\\ | /_.'",
242
+ "",
243
+ ],
244
+ build_frame("", EYES_WINK, MOUTH_NEUTRAL, TENTACLES_REST),
245
+ build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
246
+ ]
247
+
248
+ # --- Nuage d'encre + mine déconfite (erreur réseau) -------------------------
249
+ FRAMES_ERROR = [
250
+ build_frame(" . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " * ~ ."),
251
+ build_frame(" * . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " . ~ * ~ ."),
252
+ ]
253
+
254
+ # --- Petite étincelle affichée brièvement après une réponse réussie --------
255
+ SPARKLE_FRAMES = [" ✦", " · ✦ ·", " ˚ · ✦ · ˚"]
256
+
257
+ TITLE = [
258
+ " ██████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗",
259
+ "██╔═══██╗██╔══██╗██╔════╝██║██╔═══██╗████╗ ████║",
260
+ "██║ ██║██████╔╝███████╗██║██║ ██║██╔████╔██║",
261
+ "██║ ██║██╔═══╝ ╚════██║██║██║ ██║██║╚██╔╝██║",
262
+ "╚██████╔╝██║ ███████║██║╚██████╔╝██║ ╚═╝ ██║",
263
+ " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝",
264
+ ]
265
+
266
+
267
+ # ----------------------------------------------------------------------------
268
+ # Moteur d'animation générique — anime n'importe quelle liste de frames,
269
+ # avec ou sans étiquette de texte sous le poulpe.
270
+ # ----------------------------------------------------------------------------
271
+
272
+ class OctopusAnimation:
273
+ def __init__(self, frames, label=None, interval=0.2, loop=True):
274
+ self.frames = frames
275
+ self.label = label
276
+ self.interval = interval
277
+ self.loop = loop
278
+ self._stop_event = threading.Event()
279
+ self._thread = None
280
+
281
+ def _iter_frames(self):
282
+ return itertools.cycle(self.frames) if self.loop else iter(self.frames)
283
+
284
+ def _run(self):
285
+ dots = itertools.cycle([" ", ". ", ".. ", "..."]) if self.label else None
286
+ n_lines = FRAME_HEIGHT + (1 if self.label else 0)
287
+ print(HIDE_CURSOR, end="")
288
+ first = True
289
+ for frame in self._iter_frames():
290
+ if self._stop_event.is_set():
291
+ break
292
+ if not first:
293
+ sys.stdout.write(f"\033[{n_lines}A")
294
+ first = False
295
+ for line in gradient_lines(frame):
296
+ sys.stdout.write(line + "\033[K\n")
297
+ if self.label:
298
+ sys.stdout.write(rgb(ACCENT, f" {self.label}{next(dots)}") + "\033[K\n")
299
+ sys.stdout.flush()
300
+ time.sleep(self.interval)
301
+ sys.stdout.write(f"\033[{n_lines}A")
302
+ for _ in range(n_lines):
303
+ sys.stdout.write("\033[K\n")
304
+ sys.stdout.write(f"\033[{n_lines}A")
305
+ sys.stdout.write(SHOW_CURSOR)
306
+ sys.stdout.flush()
307
+
308
+ def start(self):
309
+ self._stop_event.clear()
310
+ self._thread = threading.Thread(target=self._run, daemon=True)
311
+ self._thread.start()
312
+
313
+ def stop(self):
314
+ self._stop_event.set()
315
+ if self._thread:
316
+ self._thread.join()
317
+
318
+ def play_once_blocking(self):
319
+ """Joue la séquence une fois, sans thread, puis rend la main (pour l'intro)."""
320
+ self.loop = False
321
+ print(HIDE_CURSOR, end="")
322
+ for frame in self.frames:
323
+ sys.stdout.write("\033[H\033[J")
324
+ sys.stdout.write("\n" + gradient_block(frame) + "\n")
325
+ sys.stdout.flush()
326
+ time.sleep(self.interval)
327
+ print(SHOW_CURSOR, end="")
328
+
329
+
330
+ def print_intro_animation():
331
+ OctopusAnimation(FRAMES_GREETING + [FRAMES_BREATHE[0]], interval=0.22).play_once_blocking()
332
+ sys.stdout.write("\033[H\033[J")
333
+ print()
334
+ print(gradient_block(TITLE))
335
+ print(rgb(MUTED, f" — assistant IA francophone · v{VERSION} —\n"))
336
+
337
+
338
+ def flash_sparkle():
339
+ """Étincelle furtive affichée juste avant une réponse réussie."""
340
+ print(HIDE_CURSOR, end="")
341
+ for frame in SPARKLE_FRAMES:
342
+ sys.stdout.write("\r" + rgb(SUCCESS_COLOR, frame) + "\033[K")
343
+ sys.stdout.flush()
344
+ time.sleep(0.08)
345
+ sys.stdout.write("\r\033[K")
346
+ sys.stdout.write(SHOW_CURSOR)
347
+ sys.stdout.flush()
348
+
349
+
350
+ def show_error_animation():
351
+ """Nuage d'encre + mine déconfite, joué deux fois puis nettoyé."""
352
+ OctopusAnimation(FRAMES_ERROR, interval=0.3, loop=False)._run()
353
+
354
+
355
+ # ----------------------------------------------------------------------------
356
+ # Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
357
+ # ----------------------------------------------------------------------------
358
+
359
+ def load_config():
360
+ if CONFIG_PATH.exists():
361
+ try:
362
+ return json.loads(CONFIG_PATH.read_text())
363
+ except (json.JSONDecodeError, OSError):
364
+ return {}
365
+ return {}
366
+
367
+
368
+ def save_config(config):
369
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
370
+ CONFIG_PATH.write_text(json.dumps(config, indent=2))
371
+
372
+
373
+ def prompt_for_config():
374
+ print(rgb(ACCENT, "Configuration initiale d'Opsiom CLI", bold=True))
375
+ print(rgb(MUTED, " (ces informations te sont normalement transmises par la personne qui héberge Opsiom)\n"))
376
+ url = input(rgb(USER_COLOR, "URL du serveur Opsiom : ")).strip().rstrip("/")
377
+ api_key = input(rgb(USER_COLOR, "Clé API (laisser vide si aucune) : ")).strip()
378
+ save = input(rgb(MUTED, "Sauvegarder ces informations localement pour la prochaine fois ? [O/n] ")).strip().lower()
379
+ config = {"url": url, "api_key": api_key}
380
+ if save in ("", "o", "oui", "y", "yes"):
381
+ save_config(config)
382
+ print(rgb(MUTED, f" → enregistré dans {CONFIG_PATH}\n"))
383
+ return config
384
+
385
+
386
+ def resolve_settings(args):
387
+ config = {} if args.configure else load_config()
388
+ url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
389
+ api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
390
+ if args.configure or not url:
391
+ new_config = prompt_for_config()
392
+ url = new_config["url"] or url
393
+ api_key = new_config["api_key"] or api_key
394
+ return url, api_key
395
+
396
+
397
+ # ----------------------------------------------------------------------------
398
+ # Client API
399
+ # ----------------------------------------------------------------------------
400
+
401
+ class OpsiomClient:
402
+ def __init__(self, base_url, api_key=None, timeout=60):
403
+ self.base_url = base_url.rstrip("/")
404
+ self.timeout = timeout
405
+ self.session = requests.Session()
406
+ headers = {"ngrok-skip-browser-warning": "true"}
407
+ if api_key:
408
+ headers["X-API-Key"] = api_key
409
+ self.session.headers.update(headers)
410
+
411
+ def health(self):
412
+ r = self.session.get(f"{self.base_url}/api/health", timeout=10)
413
+ r.raise_for_status()
414
+ return r.json()
415
+
416
+ def list_models(self):
417
+ r = self.session.get(f"{self.base_url}/api/models", timeout=10)
418
+ r.raise_for_status()
419
+ return r.json()
420
+
421
+ def chat(self, message, model=None, **params):
422
+ payload = {"message": message}
423
+ if model:
424
+ payload["model"] = model
425
+ payload.update(params)
426
+ r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
427
+ r.raise_for_status()
428
+ return r.json()
429
+
430
+
431
+ # ----------------------------------------------------------------------------
432
+ # Interface interactive
433
+ # ----------------------------------------------------------------------------
434
+
435
+ HELP_TEXT = """
436
+ Commandes disponibles :
437
+ /model menu numéroté pour choisir le modèle
438
+ /model <id|num> change directement de modèle (ex: /model small, /model 2)
439
+ /models liste les modèles disponibles
440
+ /status réaffiche le panneau de connexion
441
+ /clear efface l'écran
442
+ /help affiche cette aide
443
+ /quit, /exit quitte le CLI
444
+ """
445
+
446
+
447
+ def term_width(default=76):
448
+ return shutil.get_terminal_size((default, 20)).columns
449
+
450
+
451
+ def print_separator():
452
+ print(rgb(MUTED, "─" * min(term_width(), 76)))
453
+
454
+
455
+ def print_status_panel(url, api_key, connected, models_loaded):
456
+ status_text = f"● Connecté — {url}" if connected else f"⨯ Non connecté — {url}"
457
+ status_color = ACCENT if connected else ERROR_COLOR
458
+ lines = [
459
+ (f"OPSIOM CLI · v{VERSION}", ACCENT),
460
+ (status_text, status_color),
461
+ (f"Authentification : {'clé API fournie' if api_key else 'aucune'}", MUTED),
462
+ (f"Modèles chargés : {', '.join(models_loaded)}" if models_loaded else "Modèles : indisponible", MUTED),
463
+ ]
464
+ print(draw_panel(lines))
465
+
466
+
467
+ def print_assistant_message(text, model_id, elapsed_ms=None):
468
+ prefix_plain = f"Opsiom ({model_id}) › "
469
+ prefix_colored = rgb(ACCENT, "Opsiom", bold=True) + rgb(MUTED, f" ({model_id})") + rgb(MUTED, " › ")
470
+ width = max(term_width() - len(prefix_plain) - 12, 24)
471
+ wrapped = textwrap.fill(text, width=width, subsequent_indent=" " * len(prefix_plain))
472
+ suffix = rgb(MUTED, f" [{elapsed_ms} ms]") if elapsed_ms is not None else ""
473
+ print(prefix_colored + wrapped + suffix)
474
+
475
+
476
+ def print_error(text):
477
+ print(rgb(ERROR_COLOR, f"⨯ {text}", bold=True))
478
+
479
+
480
+ def choose_default_model(models_payload):
481
+ default = models_payload.get("default")
482
+ models = models_payload.get("models", [])
483
+ if default:
484
+ return default
485
+ return models[0]["id"] if models else None
486
+
487
+
488
+ def print_models(models_payload, current_model):
489
+ print(rgb(ACCENT, "Modèles disponibles :", bold=True))
490
+ for i, m in enumerate(models_payload.get("models", []), start=1):
491
+ marker = rgb(ACCENT, "●") if m["id"] == current_model else rgb(MUTED, "○")
492
+ detail = f"({m['params']} — id: {m['id']})"
493
+ print(f" {rgb(MUTED, f'[{i}]')} {marker} {gradient_line(m['label'])} {rgb(MUTED, detail)}")
494
+
495
+
496
+ def resolve_model_choice(choice, models):
497
+ if choice.isdigit():
498
+ idx = int(choice) - 1
499
+ if 0 <= idx < len(models):
500
+ return models[idx]["id"]
501
+ return None
502
+ ids = {m["id"] for m in models}
503
+ if choice in ids:
504
+ return choice
505
+ matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
506
+ return matches[0] if len(matches) == 1 else None
507
+
508
+
509
+ def select_model_interactively(models_payload, current_model):
510
+ models = models_payload.get("models", [])
511
+ if not models:
512
+ print_error("aucun modèle disponible côté serveur.")
513
+ return current_model
514
+ print_models(models_payload, current_model)
515
+ choice = input(rgb(USER_COLOR, "Choisis un numéro (ou Entrée pour annuler) : ")).strip()
516
+ if not choice:
517
+ return current_model
518
+ resolved = resolve_model_choice(choice, models)
519
+ if resolved is None:
520
+ print_error(f"choix invalide : '{choice}'")
521
+ return current_model
522
+ print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
523
+ return resolved
524
+
525
+
526
+ def run_chat(client: OpsiomClient, url, api_key):
527
+ current_model = None
528
+ models_payload = {"models": []}
529
+ connected = False
530
+ models_loaded = []
531
+ try:
532
+ health = client.health()
533
+ connected = health.get("status") == "ok"
534
+ models_loaded = health.get("models_loaded", [])
535
+ except requests.RequestException:
536
+ connected = False
537
+
538
+ print_status_panel(url, api_key, connected, models_loaded)
539
+ print()
540
+
541
+ try:
542
+ models_payload = client.list_models()
543
+ current_model = choose_default_model(models_payload)
544
+ print_models(models_payload, current_model)
545
+ print()
546
+ except requests.RequestException as e:
547
+ print_error(f"impossible de récupérer la liste des modèles ({e})")
548
+ print()
549
+
550
+ print(rgb(MUTED, "Tape ton message, ou /help pour la liste des commandes.\n"))
551
+
552
+ while True:
553
+ try:
554
+ user_input = input(rgb(USER_COLOR, "vous", bold=True) + rgb(MUTED, " › "))
555
+ except (EOFError, KeyboardInterrupt):
556
+ print()
557
+ break
558
+
559
+ user_input = user_input.strip()
560
+ if not user_input:
561
+ continue
562
+
563
+ if user_input.startswith("/"):
564
+ cmd, *rest = user_input[1:].split(maxsplit=1)
565
+ arg = rest[0].strip() if rest else ""
566
+ cmd = cmd.lower()
567
+
568
+ if cmd in ("quit", "exit"):
569
+ break
570
+ elif cmd == "help":
571
+ print(rgb(MUTED, HELP_TEXT))
572
+ elif cmd == "clear":
573
+ sys.stdout.write("\033[H\033[J")
574
+ sys.stdout.flush()
575
+ elif cmd == "status":
576
+ print_status_panel(url, api_key, connected, models_loaded)
577
+ elif cmd == "models":
578
+ try:
579
+ models_payload = client.list_models()
580
+ print_models(models_payload, current_model)
581
+ except requests.RequestException as e:
582
+ print_error(f"requête échouée ({e})")
583
+ elif cmd == "model":
584
+ if not arg:
585
+ current_model = select_model_interactively(models_payload, current_model)
586
+ else:
587
+ resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
588
+ current_model = resolved
589
+ print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
590
+ else:
591
+ print_error(f"commande inconnue : /{cmd}")
592
+ continue
593
+
594
+ anim = OctopusAnimation(FRAMES_THINKING, label="Opsiom réfléchit", interval=0.2)
595
+ anim.start()
596
+ start_time = time.perf_counter()
597
+ try:
598
+ result = client.chat(user_input, model=current_model)
599
+ except requests.RequestException as e:
600
+ anim.stop()
601
+ show_error_animation()
602
+ print_error(f"la requête a échoué ({e})")
603
+ continue
604
+ anim.stop()
605
+ elapsed_ms = int((time.perf_counter() - start_time) * 1000)
606
+
607
+ flash_sparkle()
608
+ response_text = result.get("response", "(réponse vide)")
609
+ used_model = result.get("model", current_model or "?")
610
+ print_assistant_message(response_text, used_model, elapsed_ms)
611
+ print_separator()
612
+
613
+
614
+ # ----------------------------------------------------------------------------
615
+ # Point d'entrée
616
+ # ----------------------------------------------------------------------------
617
+
618
+ def main():
619
+ parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
620
+ parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
621
+ parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
622
+ parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
623
+ parser.add_argument("--no-animation", action="store_true", help="désactive l'animation d'intro")
624
+ parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
625
+ args = parser.parse_args()
626
+
627
+ if not args.no_animation:
628
+ print_intro_animation()
629
+ else:
630
+ print(gradient_block(TITLE))
631
+ print()
632
+
633
+ url, api_key = resolve_settings(args)
634
+ if not url:
635
+ print_error("aucune URL configurée, abandon.")
636
+ sys.exit(1)
637
+
638
+ client = OpsiomClient(url, api_key=api_key)
639
+ run_chat(client, url, api_key)
640
+ print(rgb(MUTED, "\nÀ bientôt.\n"))
641
+
642
+
643
+ if __name__ == "__main__":
644
+ main()
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: opsiom-cli
3
+ Version: 1.0.0
4
+ Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
5
+ Author-email: Ton nom <toi@example.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ton-compte/opsiom-cli
8
+ Keywords: opsiom,cli,chatbot,ia
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.31
12
+
13
+ # Opsiom CLI
14
+
15
+ Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA
16
+ francophone — avec une mascotte poulpe animée et un thème violet/rose/rouge.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install opsiom-cli
22
+ ```
23
+
24
+ (ou, en isolant proprement l'outil : `pipx install opsiom-cli`)
25
+
26
+ ## Utilisation
27
+
28
+ ```bash
29
+ opsiom
30
+ ```
31
+
32
+ Au premier lancement, le CLI te demande l'URL du serveur Opsiom (et une
33
+ éventuelle clé API) puis les enregistre dans `~/.config/opsiom/config.json`.
34
+ **Ce paquet ne contient aucune URL de serveur codée en dur** — il te faut
35
+ l'URL et, si le serveur en exige une, la clé API fournie par la personne qui
36
+ héberge l'instance à laquelle tu veux te connecter.
37
+
38
+ Alternative sans fichier de config :
39
+
40
+ ```bash
41
+ opsiom --url https://xxxx.ngrok-free.dev --api-key ta_cle
42
+ ```
43
+
44
+ Reconfigurer :
45
+
46
+ ```bash
47
+ opsiom --configure
48
+ ```
49
+
50
+ ## Commandes du chat
51
+
52
+ - `/model` — menu numéroté pour choisir le modèle
53
+ - `/model <id|numéro>` — change directement de modèle
54
+ - `/models` — liste les modèles disponibles
55
+ - `/status` — réaffiche le panneau de connexion
56
+ - `/clear` — efface l'écran
57
+ - `/help` — aide
58
+ - `/quit`, `/exit` — quitte
59
+
60
+ ## Licence
61
+
62
+ MIT
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/opsiom_cli/__init__.py
4
+ src/opsiom_cli/cli.py
5
+ src/opsiom_cli.egg-info/PKG-INFO
6
+ src/opsiom_cli.egg-info/SOURCES.txt
7
+ src/opsiom_cli.egg-info/dependency_links.txt
8
+ src/opsiom_cli.egg-info/entry_points.txt
9
+ src/opsiom_cli.egg-info/requires.txt
10
+ src/opsiom_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opsiom = opsiom_cli.cli:main
@@ -0,0 +1 @@
1
+ requests>=2.31
@@ -0,0 +1 @@
1
+ opsiom_cli