@nosserb/prmt 0.0.0-stage → 2.0.0

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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,409 @@
1
+ """Politique d'exécution, périmètre, journal, recherche web, état de la plateforme et fond de l'interface."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from rich.text import Text
10
+ from textual import work
11
+ from textual.widgets import Static
12
+
13
+ from .. import journal, preferences, recherche
14
+ from ..etat import SIGNES, collecter, pluriel, rendre
15
+ from ..politique import MODES, Indisponible, Refus
16
+ from ..theme import DIM, FONDS, GREEN, RED, YELLOW
17
+
18
+ ISSUES = {"ok": ("ok", "réussie"), "error": ("probleme", "en échec"), "refused": ("probleme", "refusée"),
19
+ "cancelled": ("inactif", "annulée"), "info": ("info", "")}
20
+ HOTE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$|^[0-9a-fA-F:]{2,39}$")
21
+
22
+
23
+ class Plateforme:
24
+ """Partie de PrometheeApp : politique d'exécution, périmètre, journal, recherche web, état de la plateforme et fond de
25
+ l'interface."""
26
+
27
+ # ------------------------------------------------------------------ politique (fils de travail)
28
+ @work(thread=True, exclusive=True, group="politique")
29
+ def demarrer_politique(self) -> None:
30
+ """Lance le moteur de politique dès l'ouverture : le premier verdict n'attend pas son démarrage."""
31
+ ready = self.politique.demarrer()
32
+ self.call_from_thread(self._politique_demarree, ready)
33
+
34
+ def _politique_demarree(self, ready: bool) -> None:
35
+ self.update_status()
36
+ if not ready and self.politique.reliee:
37
+ self._policy_warned = True
38
+ self.add_notice(f"Politique indisponible ({self.politique.raison}) : tu valides chaque commande toi-même, "
39
+ "sans liste blanche. /etat pour le détail.", YELLOW)
40
+
41
+ def _verdict(self, command: str):
42
+ """Verdict de la politique pour une commande, ou None (validation manuelle). Appelé depuis un fil de travail."""
43
+ before = self.politique.etat
44
+ verdict = self.politique.verdict(command, self.cwd)
45
+ if verdict is None and self.politique.reliee and not self._policy_warned:
46
+ self._policy_warned = True
47
+ self.call_from_thread(self.add_notice, f"Politique indisponible ({self.politique.raison}) : tu valides chaque "
48
+ "commande toi-même, sans liste blanche.", YELLOW)
49
+ elif verdict is not None:
50
+ self._policy_warned = False
51
+ if self.politique.etat != before:
52
+ self.call_from_thread(self.update_status)
53
+ return verdict
54
+
55
+ # ------------------------------------------------------------------ journal
56
+ def noter_commande(self, command: str, code: int | None, output: str, note: str, started: float, cwd: Path,
57
+ verdict, confirmation: str, source: str) -> None:
58
+ """Une ligne de journal par commande : exécutée, en échec, arrêtée, ou refusée par toi."""
59
+ duration = round((time.monotonic() - started) * 1000)
60
+ if confirmation == "refus" or note.startswith("arrêtée par toi"):
61
+ outcome, detail = "cancelled", (note[:1].upper() + note[1:] + ".") if note else "Annulée."
62
+ elif note:
63
+ outcome, detail = "error", note[:1].upper() + note[1:] + "."
64
+ else:
65
+ outcome, detail = ("ok" if code == 0 else "error"), f"Code de sortie {code} en {duration} ms."
66
+ journal.noter("command", outcome, command, detail, command=command, cwd=str(cwd),
67
+ level=verdict.issue if verdict is not None else None,
68
+ policy=self.politique.mode if verdict is not None else "manuelle",
69
+ confirmation=confirmation, source=source, agent=self._agent_name or None, session=self.session_id,
70
+ constrained=True if verdict is not None and verdict.mode_contraint and confirmation != "refus" else None,
71
+ exitCode=code, durationMs=duration if confirmation != "refus" else None,
72
+ timedOut=True if "délai dépassé" in note else None,
73
+ tail=output[-journal.QUEUE_SORTIE:] if output else None)
74
+
75
+ def noter_refus(self, command: str, verdict, source: str) -> None:
76
+ journal.noter("refusal", "refused", command, "\n".join(r.message for r in verdict.blocages), command=command,
77
+ cwd=str(self.cwd), codes=[r.code for r in verdict.blocages], policy=verdict.mode, source=source,
78
+ agent=self._agent_name or None, session=self.session_id)
79
+
80
+ def noter_fichier(self, tool: str, path: Path, outcome: str, detail: str, **data) -> None:
81
+ journal.noter("command", outcome, f"{tool} {path}", detail, tool=tool, path=str(path), source="modèle",
82
+ agent=self._agent_name or None, session=self.session_id, **data)
83
+
84
+ def noter_recherche(self, plan: dict, search: dict) -> None:
85
+ """Une ligne de journal par recherche web, au format de la console : la console la lit aussi."""
86
+ place = search.get("place")
87
+ if search["state"] == "done":
88
+ n = len(search["sources"])
89
+ detail = f"{n} source{'s' if n > 1 else ''}" + (f", lieu : {place['name']}" if place else "")
90
+ else:
91
+ detail = search.get("error") or ""
92
+ outcome = "ok" if search["state"] == "done" else "cancelled" if self.cancel_event.is_set() else "error"
93
+ journal.noter("search", outcome, f"Recherche web : {recherche.cut(plan['query'], 100)}", detail,
94
+ donnees={"query": plan["query"], "place": place["name"] if place else plan.get("place"),
95
+ "sources": [s["url"] for s in search["sources"]]},
96
+ session=self.session_id)
97
+
98
+ # ------------------------------------------------------------------ recherche web
99
+ def reglage_recherche(self) -> dict:
100
+ """{enabled, url} : dans les réglages de la console si la TUI y est reliée, sinon dans ses préférences."""
101
+ return recherche.lire_reglage(self.politique.lien)
102
+
103
+ def client_recherche(self, reglage: dict | None = None) -> recherche.Searxng:
104
+ lien = self.politique.lien or {}
105
+ return recherche.Searxng((reglage or self.reglage_recherche())["url"], str(lien.get("racine") or "") or None)
106
+
107
+ @work(thread=True, group="plateforme")
108
+ def cmd_recherche(self, arg: str = "") -> None:
109
+ word = arg.strip().lower()
110
+ lien = self.politique.lien
111
+ fichier = recherche.fichier_partage(lien)
112
+ where = f"réglage partagé avec la console : {fichier}" if fichier else "réglage gardé dans les préférences de la TUI"
113
+ if word in ("on", "oui", "activer", "active"):
114
+ enabled = True
115
+ elif word in ("off", "non", "couper", "coupe", "desactiver", "désactiver"):
116
+ enabled = False
117
+ elif word:
118
+ self.call_from_thread(self.add_notice, "Usage : /recherche · /recherche on · /recherche off", YELLOW)
119
+ return
120
+ else:
121
+ reglage = self.reglage_recherche()
122
+ self.call_from_thread(self._montrer_recherche, reglage, self.client_recherche(reglage).health(), where)
123
+ return
124
+ try:
125
+ reglage = recherche.ecrire_reglage(lien, enabled=enabled)
126
+ except (OSError, ValueError, recherche.ErreurRecherche) as err:
127
+ self.call_from_thread(self.add_notice, f"Recherche web inchangée : {err}", RED)
128
+ return
129
+ journal.noter("settings", "info", f"Recherche web {'activée' if enabled else 'coupée'}", session=self.session_id,
130
+ search=reglage)
131
+ if not enabled:
132
+ self.call_from_thread(self.add_notice, f"Recherche web coupée : aucune requête ne part sur internet ({where}). "
133
+ "/recherche on pour la rétablir.")
134
+ return
135
+ self.call_from_thread(self.add_notice, "Recherche web activée : quand une question le demande, Prométhée cherche "
136
+ f"sur internet par SearXNG et te montre la requête et les sources ({where}).", GREEN)
137
+ sante = self.client_recherche(reglage).health()
138
+ if not sante["available"]:
139
+ self.call_from_thread(self.add_notice, f"Attention : {sante['error']}", YELLOW)
140
+
141
+ def _montrer_recherche(self, reglage: dict, sante: dict, where: str) -> None:
142
+ """/recherche seul : active ou non, l'adresse de SearXNG, et s'il répond."""
143
+ text = Text()
144
+ text.append("Recherche web : ", style="bold")
145
+ text.append("active" if reglage["enabled"] else "coupée", style=GREEN if reglage["enabled"] else YELLOW)
146
+ text.append(f" · {where}", style=DIM)
147
+ if sante["available"]:
148
+ text.append(f"\n ● SearXNG {reglage['url']} répond en {sante['latencyMs']} ms", style=GREEN)
149
+ else:
150
+ text.append(f"\n ✕ SearXNG {reglage['url']} ne répond pas : {sante['error']}", style=RED)
151
+ text.append("\n Quand une question demande une information récente ou un lieu, Prométhée cherche de lui-même, te "
152
+ "montre la requête et les sources, puis répond sans proposer de commande.", style=DIM)
153
+ text.append("\n /recherche on · /recherche off", style=f"italic {DIM}")
154
+ self.chat.mount(Static(text, classes="notice"))
155
+ self.call_after_refresh(self._follow)
156
+
157
+ # ------------------------------------------------------------------ /politique
158
+ @work(thread=True, group="plateforme")
159
+ def cmd_politique(self, arg: str = "") -> None:
160
+ politique = self.politique
161
+ word = arg.strip().lower()
162
+ if word in MODES:
163
+ if word != politique.mode:
164
+ politique.mode = word
165
+ try:
166
+ preferences.ecrire(politique=word)
167
+ except OSError:
168
+ pass
169
+ journal.noter("settings", "info", f"Politique de la TUI : {word}", session=self.session_id, policy=word)
170
+ self.call_from_thread(self.update_status)
171
+ self.call_from_thread(self.add_notice, (
172
+ "Politique stricte : tout refus de la liste blanche est appliqué, comme dans la console."
173
+ if word == "stricte" else
174
+ "Politique souple : ce que l'analyse ne sait pas vérifier (scripts, compilateurs, programmes lancés par "
175
+ "leur chemin) t'est montré comme « hors politique » et tu le valides. Restent refusés : commande "
176
+ "destructrice non débloquée, cible hors périmètre, protections de Windows, chemin réseau."),
177
+ GREEN if word == "stricte" else YELLOW)
178
+ return
179
+ if word in ("relancer", "restart"):
180
+ politique.fermer()
181
+ politique._retenter_a = 0.0
182
+ ready = politique.demarrer()
183
+ self.call_from_thread(self.update_status)
184
+ self.call_from_thread(self.add_notice, "Moteur de politique relancé." if ready
185
+ else f"Le moteur de politique ne démarre pas : {politique.raison}", GREEN if ready else RED)
186
+ return
187
+ if word:
188
+ self.call_from_thread(self.add_notice, "Usage : /politique · /politique stricte · /politique souple · "
189
+ "/politique relancer", YELLOW)
190
+ return
191
+ resume = politique.resume()
192
+ self.call_from_thread(self.update_status)
193
+ if resume is None:
194
+ self.call_from_thread(self.add_notice_md, (
195
+ f"**Politique : validation manuelle**\n\nLe moteur de la console n'est pas disponible "
196
+ f"({politique.raison or 'raison inconnue'}). Chaque commande t'est montrée et tu la valides, sans liste "
197
+ "blanche ni contrôle du périmètre.\n\n*`/politique relancer` pour réessayer · `/etat` pour le détail*"))
198
+ return
199
+ counts = resume.get("counts") or {}
200
+ custom = ", ".join(f"`{c.get('name')}`" for c in resume.get("custom") or []) or "aucune"
201
+ unlocked = ", ".join(f"`{u}`" for u in resume.get("unlocked") or []) or "aucune"
202
+ self.call_from_thread(self.add_notice_md, (
203
+ f"**Politique {politique.mode}** (moteur de la console {politique.version})\n\n"
204
+ f"- Liste blanche : {counts.get('read', 0)} commandes de lecture, {counts.get('sensitive', 0)} sensibles, "
205
+ f"{counts.get('destructive', 0)} destructrices, {counts.get('forbidden', 0)} interdites.\n"
206
+ f"- Débloquées par toi : {unlocked} · ajoutées par toi : {custom}\n"
207
+ f"- Écriture permise dans : {', '.join(f'`{r}`' for r in resume.get('roots') or [])}\n"
208
+ f"- Cibles réseau déclarées : {len(resume.get('targets') or [])} (`/perimetre`)\n\n"
209
+ "`stricte` : tout refus de la liste blanche est appliqué. `souple` : le hors-liste passe à ta validation ; "
210
+ "commande destructrice non débloquée, cible hors périmètre, protections de Windows et chemin réseau restent "
211
+ "refusés.\n\n"
212
+ "*`/politique stricte` · `/politique souple` · `/permissions autoriser <commande>` · `/perimetre ajouter <hôte>`*"))
213
+
214
+ # ------------------------------------------------------------------ /permissions
215
+ @work(thread=True, group="plateforme")
216
+ def cmd_permissions(self, arg: str = "") -> None:
217
+ action, _, rest = arg.partition(" ")
218
+ action, names = action.lower(), rest.split()
219
+ if action in ("reset", "effacer", "clear"):
220
+ self.allowed.clear()
221
+ self.allowed_lines.clear()
222
+ self.allow_edits = False
223
+ self.call_from_thread(self.add_notice, "Toutes les commandes et toutes les écritures de fichiers demanderont "
224
+ "de nouveau ta validation.", GREEN)
225
+ return
226
+ if action in ("autoriser", "allow", "ajouter", "debloquer", "débloquer") and names:
227
+ self._changer_liste(names, True)
228
+ return
229
+ if action in ("retirer", "revoke", "verrouiller", "enlever") and names:
230
+ self._changer_liste(names, False)
231
+ return
232
+ if action:
233
+ self.call_from_thread(self.add_notice, "Usage : /permissions · /permissions reset · /permissions autoriser "
234
+ "<commande…> · /permissions retirer <commande…>", YELLOW)
235
+ return
236
+ session = sorted(self.allowed) + [f"« {line} »" for line in sorted(self.allowed_lines)]
237
+ if session:
238
+ self.call_from_thread(self.add_notice, "Autorisées sans validation (session) : " + ", ".join(session)
239
+ + " · /permissions reset pour tout redemander")
240
+ else:
241
+ self.call_from_thread(self.add_notice, "Aucune commande autorisée automatiquement : chaque commande demande "
242
+ "ta validation.")
243
+ if self.allow_edits:
244
+ self.call_from_thread(self.add_notice, "Écritures de fichiers autorisées pour la session (sauf fichiers sensibles "
245
+ "ou hors de ton dossier) · /permissions reset pour tout redemander")
246
+ resume = self.politique.resume()
247
+ if resume is not None:
248
+ custom = [str(c.get("name")) for c in resume.get("custom") or []]
249
+ unlocked = [str(u) for u in resume.get("unlocked") or []]
250
+ self.call_from_thread(self.add_notice, "Liste blanche (partagée avec la console) : débloquées "
251
+ + (", ".join(unlocked) or "aucune") + " · ajoutées " + (", ".join(custom) or "aucune")
252
+ + " · /permissions autoriser <commande> · /permissions retirer <commande>")
253
+
254
+ def _changer_liste(self, names: list[str], allow: bool) -> None:
255
+ try:
256
+ result = self.politique.autoriser(names) if allow else self.politique.retirer(names)
257
+ except (Indisponible, Refus) as err:
258
+ self.call_from_thread(self.add_notice, f"Liste blanche inchangée : {err}", RED)
259
+ return
260
+ if not allow:
261
+ removed = result.get("removed") or []
262
+ self.call_from_thread(self.add_notice, ("Autorisation retirée : " + ", ".join(removed) + ".") if removed
263
+ else "Aucune de ces commandes n'était autorisée par toi.", GREEN if removed else YELLOW)
264
+ return
265
+ if result.get("unlocked"):
266
+ self.call_from_thread(self.add_notice, "Débloquée : " + ", ".join(result["unlocked"]) + ". Chaque exécution "
267
+ "te sera montrée et signalée en rouge. /permissions retirer pour la reverrouiller.", YELLOW)
268
+ if result.get("added"):
269
+ self.call_from_thread(self.add_notice, "Ajoutée à ta liste blanche : " + ", ".join(result["added"])
270
+ + ". Ses arguments ne sont pas contrôlés : chaque exécution te sera montrée.", YELLOW)
271
+ if result.get("already"):
272
+ self.call_from_thread(self.add_notice, "Déjà autorisée : " + ", ".join(result["already"]) + ".")
273
+ for refused in result.get("refused") or []:
274
+ self.call_from_thread(self.add_notice, f"Non autorisée : {refused.get('name')} ({refused.get('reason')}).", RED)
275
+
276
+ # ------------------------------------------------------------------ /perimetre
277
+ def cmd_perimetre(self, arg: str = "") -> None:
278
+ action, _, rest = arg.partition(" ")
279
+ action, rest = action.lower(), rest.strip()
280
+ if action in ("ajouter", "add", "declarer", "déclarer") and rest:
281
+ host, _, note = rest.partition(" ")
282
+ self._demander_attestation(host.strip().lower(), note.strip())
283
+ elif action in ("retirer", "remove", "enlever", "supprimer") and rest:
284
+ self._retirer_cible(rest.split()[0].lower())
285
+ elif action:
286
+ self.add_notice("Usage : /perimetre · /perimetre ajouter <hôte> [note] · /perimetre retirer <hôte>", YELLOW)
287
+ else:
288
+ self._montrer_perimetre()
289
+
290
+ @work(thread=True, group="plateforme")
291
+ def _montrer_perimetre(self) -> None:
292
+ resume = self.politique.resume()
293
+ if resume is None:
294
+ self.call_from_thread(self.add_notice, "Périmètre non contrôlé : la politique n'est pas disponible "
295
+ f"({self.politique.raison or 'raison inconnue'}).", YELLOW)
296
+ return
297
+ targets = resume.get("targets") or []
298
+ lines = "\n".join(f"- `{t.get('host')}`" + (" (sous-domaines compris)" if t.get("includeSubdomains") else "")
299
+ + (f" · {t.get('note')}" if t.get("note") else "") + f" · attestée le {str(t.get('attestedAt') or '')[:10]}"
300
+ for t in targets)
301
+ self.call_from_thread(self.add_notice_md, (
302
+ "**Périmètre autorisé**\n\n"
303
+ "Les tests de ports et les audits ne sont permis que sur cette machine et sur les cibles que tu as déclarées, "
304
+ "parce qu'elles t'appartiennent ou que tu as une autorisation écrite.\n\n"
305
+ + (lines if lines else "Aucune cible déclarée : seuls les tests sur cette machine (127.0.0.1) sont permis.")
306
+ + "\n\n*`/perimetre ajouter <hôte> [note]` · `/perimetre retirer <hôte>` · la console web montre la même liste*"))
307
+
308
+ def _demander_attestation(self, host: str, note: str) -> None:
309
+ if not HOTE.match(host) or "/" in host:
310
+ self.add_notice(f"« {host} » n'est pas un hôte unique (nom ou adresse IP, sans plage ni masque).", RED)
311
+ return
312
+ domain = not re.fullmatch(r"[0-9.]+|[0-9a-fA-F:]+", host)
313
+ body = Text.assemble((f" {host}\n", "bold"), (f" {note}\n" if note else "", DIM),
314
+ ("\n Les tests ne sont permis que sur des systèmes qui t'appartiennent ou pour lesquels tu as une\n"
315
+ " autorisation écrite. Cette attestation est datée et inscrite au journal.", DIM))
316
+ options = [("yes", "J'atteste que cette cible m'appartient ou que j'ai une autorisation écrite")]
317
+ if domain:
318
+ options.append(("sub", "J'atteste la même chose pour ce domaine et ses sous-domaines"))
319
+ options.append(("no", "Annuler (Esc)"))
320
+
321
+ def chosen(decision: str) -> None:
322
+ if decision in ("yes", "sub"):
323
+ self._ajouter_cible(host, note, decision == "sub")
324
+
325
+ self.ask("Déclarer une cible", body, "Ajouter cette cible à ton périmètre ?", options, chosen)
326
+
327
+ @work(thread=True, group="plateforme")
328
+ def _ajouter_cible(self, host: str, note: str, subdomains: bool) -> None:
329
+ try:
330
+ self.politique.ajouter_cible(host, note, subdomains)
331
+ except (Indisponible, Refus) as err:
332
+ self.call_from_thread(self.add_notice, f"Cible non ajoutée : {err}", RED)
333
+ return
334
+ self.call_from_thread(self.add_notice, f"Cible ajoutée au périmètre : {host}"
335
+ + (" et ses sous-domaines" if subdomains else "") + ". /perimetre retirer pour l'enlever.", GREEN)
336
+
337
+ @work(thread=True, group="plateforme")
338
+ def _retirer_cible(self, host: str) -> None:
339
+ try:
340
+ self.politique.retirer_cible(host)
341
+ except (Indisponible, Refus) as err:
342
+ self.call_from_thread(self.add_notice, f"Périmètre inchangé : {err}", RED)
343
+ return
344
+ self.call_from_thread(self.add_notice, f"Cible retirée du périmètre : {host}.", GREEN)
345
+
346
+ # ------------------------------------------------------------------ /journal
347
+ def cmd_journal(self, arg: str = "") -> None:
348
+ fichiers = [journal.FICHIER]
349
+ if self.politique.lien is not None:
350
+ fichiers.append(Path(str(self.politique.lien["donnees"])) / "journal.jsonl")
351
+ entries = journal.lire(fichiers)
352
+ word = arg.strip().lower()
353
+ if word in ("refus", "refusees", "refusées"):
354
+ entries = [e for e in entries if e["kind"] == "refusal"]
355
+ count = int(word) if word.isdigit() else 15
356
+ shown = entries[-max(1, min(count, 60)):]
357
+ if not shown:
358
+ self.add_notice("Journal vide : il se remplit dès la première commande." if not word.startswith("refus")
359
+ else "Aucun refus dans le journal.")
360
+ return
361
+ text = Text()
362
+ text.append(f"Journal · {pluriel(len(shown), 'dernière entrée', 'dernières entrées')} sur {len(entries)}", style=f"bold {DIM}")
363
+ for entry in shown:
364
+ when = journal._moment(entry)
365
+ state, label = ISSUES[entry["outcome"]]
366
+ sign, color = SIGNES[state]
367
+ origin = "TUI" if (entry.get("data") or {}).get("origin") == "tui" else "console"
368
+ stamp = f"{when:%d/%m %H:%M}" if when and when.date() != dt.date.today() else f"{when:%H:%M}" if when else "?"
369
+ text.append(f"\n {stamp:<11}", style=DIM)
370
+ text.append(f"{sign} ", style=color)
371
+ text.append(f"{label:<9}" if label else " " * 9, style=color)
372
+ text.append(entry["title"][:90])
373
+ text.append(f" ({origin})", style=DIM)
374
+ text.append(f"\n fichier : {journal.FICHIER} · /journal 40 · /journal refus", style=f"italic {DIM}")
375
+ self.chat.mount(Static(text, classes="notice"))
376
+ self.call_after_refresh(self._follow)
377
+
378
+ # ------------------------------------------------------------------ /etat
379
+ @work(thread=True, exclusive=True, group="etat")
380
+ def cmd_etat(self, arg: str = "") -> None:
381
+ self.call_from_thread(self.set_hints, "Mesure de l'état de la plateforme…")
382
+ sections = collecter(self)
383
+ self.call_from_thread(self._montrer_etat, sections)
384
+
385
+ def _montrer_etat(self, sections) -> None:
386
+ panel = Static(rendre(sections), classes="etat")
387
+ panel.border_title = "État de la plateforme"
388
+ panel.border_subtitle = f"mesuré à {dt.datetime.now():%H:%M:%S}"
389
+ self.chat.mount(panel)
390
+ self.set_hints()
391
+ self.update_status()
392
+ self.call_after_refresh(self._follow)
393
+
394
+ # ------------------------------------------------------------------ /theme
395
+ def cmd_theme(self, arg: str = "") -> None:
396
+ word = arg.strip().lower()
397
+ if word in ("promethee", "prométhée", "nuit"):
398
+ word = "promethee"
399
+ if word not in FONDS:
400
+ current = next((k for k, v in FONDS.items() if v == self.theme), "promethee")
401
+ self.add_notice(f"Fond actuel : {current}. /theme promethee : le fond de Prométhée (la nuit) · "
402
+ "/theme terminal : le fond de ton terminal (transparence, image).")
403
+ return
404
+ self.theme = FONDS[word]
405
+ try:
406
+ preferences.ecrire(fond=word)
407
+ except OSError:
408
+ pass
409
+ self.add_notice("Fond de Prométhée appliqué." if word == "promethee" else "Fond de ton terminal appliqué.", GREEN)
@@ -0,0 +1,100 @@
1
+ """Autocompletion de la saisie ( / et @ )."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from rich.text import Text
7
+ from textual.widgets import Static
8
+
9
+ from ..commandes import MENU_ROWS
10
+ from ..completion import (
11
+ AT_TOKEN, agent_completions, command_completions, custom_command_completions, file_completions,
12
+ )
13
+ from ..theme import DIM, ORANGE
14
+ from ..widgets import PromptInput
15
+
16
+
17
+ class Saisie:
18
+ """Partie de PrometheeApp : autocompletion de la saisie ( / et @ )."""
19
+
20
+ # ------------------------------------------------------------------ autocomplétion ( / et @ )
21
+ def at_token(self) -> tuple[str, int] | None:
22
+ """« @chemin » en cours de frappe juste avant le curseur : (chemin, colonne du @)."""
23
+ row, col = self.input.cursor_location
24
+ match = AT_TOKEN.search(self.input.document.get_line(row)[:col])
25
+ return (match.group(1), match.start()) if match else None
26
+
27
+ def update_completions(self) -> None:
28
+ items: list[tuple[str, str, str]] = []
29
+ kind = ""
30
+ text = self.input.text
31
+ if self._perm is None and text != self._comp_dismissed:
32
+ self._comp_dismissed = None
33
+ if text.startswith("/") and not re.search(r"\s", text):
34
+ items, kind = command_completions(text) + custom_command_completions(text, self.cwd), "/"
35
+ else:
36
+ token = self.at_token()
37
+ if token is not None:
38
+ # Fichiers d'abord (le plus courant) ; les agents passent devant dès « @ag… »
39
+ agents, files = agent_completions(token[0], self.cwd), file_completions(token[0], self.cwd)
40
+ first = len(token[0]) >= 2 and "agent-".startswith(token[0].lower()[:6])
41
+ items, kind = (agents + files if first else files + agents), "@"
42
+ if [i[0] for i in items] != [i[0] for i in self._comp_items]:
43
+ self._comp_index = 0
44
+ self._comp_items, self._comp_kind = items, kind
45
+ self.render_completions()
46
+ self.set_hints()
47
+
48
+ def render_completions(self) -> None:
49
+ menu = self.query_one("#completions", Static)
50
+ items = self._comp_items
51
+ if not items:
52
+ menu.display = False
53
+ return
54
+ start = max(0, min(self._comp_index - MENU_ROWS // 2, len(items) - MENU_ROWS))
55
+ shown = items[start:start + MENU_ROWS]
56
+ width = max(len(label) for _, label, _ in shown) + 3
57
+ text = Text()
58
+ for k, (_, label, desc) in enumerate(shown):
59
+ selected = start + k == self._comp_index
60
+ text.append(("❯ " if selected else " ") + label.ljust(width), style=f"bold {ORANGE}" if selected else "")
61
+ text.append(desc, style=ORANGE if selected else DIM)
62
+ if k < len(shown) - 1:
63
+ text.append("\n")
64
+ if len(items) > len(shown):
65
+ text.append(f"\n … {len(items)} au total", style=f"italic {DIM}")
66
+ menu.update(text)
67
+ menu.display = True
68
+
69
+ def move_completion(self, delta: int) -> None:
70
+ if self._comp_items:
71
+ self._comp_index = (self._comp_index + delta) % len(self._comp_items)
72
+ self.render_completions()
73
+
74
+ def hide_completions(self) -> None:
75
+ self._comp_dismissed = self.input.text
76
+ self._comp_items = []
77
+ self.render_completions()
78
+ self.set_hints()
79
+
80
+ def accept_completion(self, submit: bool = False) -> None:
81
+ if not self._comp_items:
82
+ return
83
+ value = self._comp_items[self._comp_index][0]
84
+ if self._comp_kind == "/":
85
+ self.input.load_text(value if submit else value + " ")
86
+ self.input.move_cursor(self.input.document.end)
87
+ if submit:
88
+ self.input.post_message(PromptInput.Submitted(value))
89
+ return
90
+ token = self.at_token()
91
+ if token is None:
92
+ return
93
+ row, col = self.input.cursor_location
94
+ insert = "@" + value + ("" if value.endswith("\\") else " ") # dossier : on continue la complétion
95
+ self.input.replace(insert, (row, token[1]), (row, col))
96
+ self.input.move_cursor((row, token[1] + len(insert)))
97
+
98
+ def action_complete(self) -> None:
99
+ if self._comp_items and self._perm is None:
100
+ self.accept_completion(submit=False)
@@ -0,0 +1,92 @@
1
+ """Mode plan et liste de tâches."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from rich.text import Text
7
+ from textual.widgets import Static
8
+
9
+ from ..plan import PLAN_APPROVED, command_keywords, plan_steps
10
+ from ..theme import DIM, GREEN, ORANGE
11
+
12
+
13
+ class Taches:
14
+ """Partie de PrometheeApp : mode plan et liste de tâches."""
15
+
16
+ # ------------------------------------------------------------------ mode plan
17
+ def action_toggle_plan(self) -> None:
18
+ if self._perm is None:
19
+ self.set_plan_mode(not self.plan_mode)
20
+
21
+ def set_plan_mode(self, on: bool) -> None:
22
+ self.plan_mode = on
23
+ self.query_one("#input-box").set_class(on, "-plan")
24
+ self.set_hints()
25
+
26
+ def offer_plan(self) -> None:
27
+ """Après un plan : l'exécuter (retour en mode normal) ou continuer à l'affiner."""
28
+ steps = plan_steps(self.last_answer)
29
+
30
+ def chosen(decision: str) -> None:
31
+ if decision != "go":
32
+ return
33
+ self.set_plan_mode(False)
34
+ if len(steps) >= 2:
35
+ self.start_tasks("plan", [{"title": t, "text": full, "action": action} for t, full, action in steps])
36
+ self.send_message("Exécute le plan.", PLAN_APPROVED)
37
+
38
+ options = [("go", "Oui : quitter le mode plan et l'exécuter (tu valideras chaque commande)"),
39
+ ("keep", "Non : rester en mode plan pour l'affiner (Esc)")]
40
+ detail = Text(f" {len(steps)} étapes : " + " · ".join(t for t, _, _ in steps), style=DIM) if steps else None
41
+ self.ask("Plan prêt", detail, "Exécuter ce plan ?", options, chosen, esc="continuer à planifier")
42
+
43
+ # ------------------------------------------------------------------ liste de tâches
44
+ def start_tasks(self, kind: str, tasks: list[dict]) -> None:
45
+ self.task_kind = kind
46
+ self.tasks = [{"title": t["title"], "text": t.get("text", t["title"]), "pattern": t.get("pattern"),
47
+ "action": t.get("action", True), "state": "todo"} for t in tasks]
48
+ self.render_tasks()
49
+
50
+ def plan_steps_left(self) -> int:
51
+ """Étapes du plan accepté qui demandent encore une commande ou un fichier (lu depuis le thread de l'agent)."""
52
+ if self.task_kind != "plan":
53
+ return 0
54
+ return sum(1 for t in list(self.tasks) if t["state"] != "done" and t["action"])
55
+
56
+ def task_progress(self, command: str, ok: bool) -> None:
57
+ """Coche l'étape correspondant à une commande (ou un fichier) réellement exécutée avec succès."""
58
+ todo = [t for t in self.tasks if t["state"] != "done"]
59
+ if not ok or not todo:
60
+ return
61
+ if self.task_kind == "audit":
62
+ hit = next((t for t in todo if t["pattern"] and re.search(t["pattern"], command, re.I)), None)
63
+ else:
64
+ words = command_keywords(command)
65
+ steps = [t for t in todo if t["action"]] or todo
66
+ hit = next((t for t in steps if any(w in t["text"].lower() for w in words)), None) or steps[0]
67
+ if hit is not None:
68
+ hit["state"] = "done"
69
+ self.render_tasks()
70
+
71
+ def render_tasks(self) -> None:
72
+ box = self.query_one("#tasks", Static)
73
+ if not self.tasks:
74
+ box.display = False
75
+ return
76
+ done = sum(t["state"] == "done" for t in self.tasks)
77
+ current = next((t for t in self.tasks if t["state"] != "done"), None) if self.generating else None
78
+ text = Text()
79
+ text.append(f"Tâches {done}/{len(self.tasks)}", style=f"bold {DIM}")
80
+ for t in self.tasks:
81
+ text.append("\n")
82
+ if t["state"] == "done":
83
+ text.append(" ☑ ", style=GREEN)
84
+ text.append(t["title"], style=f"strike {DIM}")
85
+ elif t is current:
86
+ text.append(" ☐ ", style=ORANGE)
87
+ text.append(t["title"], style=f"bold {ORANGE}")
88
+ else:
89
+ text.append(" ☐ ", style=DIM)
90
+ text.append(t["title"])
91
+ box.update(text)
92
+ box.display = True