palabre 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -7,13 +7,13 @@
7
7
  <a href="https://palab.re"><img src="https://img.shields.io/badge/docs-palab.re-18181B?logo=netlify&logoColor=7C3AED" alt="Documentation"></a>
8
8
  </p>
9
9
 
10
- ![PALABRE](docs/assets/palabre-logo-text-og.png)
10
+ ![Palabre CLI orchestrating a debate between AI agents](docs/assets/palabre-cli-orchestre-debates-between-AI-agents.png)
11
11
 
12
12
  [English](#english) | [Français](#français)
13
13
 
14
14
  ## English
15
15
 
16
- PALABRE is a CLI/TUI orchestrator that lets multiple AI agents installed on your machine work together: Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, OpenCode, Mistral Vibe, and Ollama.
16
+ PALABRE is a CLI/TUI orchestrator that lets multiple AI agents installed on your machine work together: Claude Code, Codex CLI, Antigravity CLI, OpenCode, Mistral Vibe, and Ollama.
17
17
 
18
18
  It does not replace your tools: it drives them. You keep your subscriptions, default models, terminal habits, and local files. PALABRE can run a debate between two agents or an Ask request where several agents answer independently before a comparative summary. It then exports the session as Markdown.
19
19
 
@@ -37,7 +37,6 @@ palabre --help
37
37
  ### Quick Start
38
38
 
39
39
  ```bash
40
- palabre init
41
40
  palabre doctor
42
41
  palabre
43
42
  ```
@@ -54,16 +53,15 @@ palabre codex-claude "Preview" --context src --show-prompt
54
53
  palabre context scan src docs --json
55
54
  ```
56
55
 
57
- In an interactive terminal, Palabre uses the TUI by default. `palabre` opens the home screen, `/ask` switches from debate to independent answers, `/agents` and `/roles` help you choose the active setup, and `--terminal` forces the older raw rendering suitable for logs.
56
+ In an interactive terminal, Palabre uses the TUI by default. `palabre` opens the home screen, creates the global config on first launch when needed, and refreshes detected known agents before showing the UI. `/ask` switches from debate to independent answers, `/agents` and `/roles` help you choose the active setup, `/history` shows recent exports, and `/home` returns to the home screen. `--terminal` forces the older raw rendering suitable for logs. `palabre init` remains available for explicit setup, especially with `--local`.
58
57
 
59
58
  ### Supported Agents
60
59
 
61
60
  - Claude Code via `claude --print`
62
61
  - Codex CLI via `codex exec`
63
- - Gemini CLI via `gemini --prompt -`
64
62
  - Antigravity CLI via `agy --print` in a pseudo-terminal
65
63
  - OpenCode via `opencode run`
66
- - Mistral Vibe via `vibe --output text --agent plan --trust --prompt`
64
+ - Mistral Vibe via `vibe --output text --trust --prompt`
67
65
  - Ollama via the local HTTP API
68
66
 
69
67
  PALABRE does not list models: they change often and depend on each CLI or user account. `--model-a`, `--model-b`, and `--summary-model` simply pass the raw value to the selected agent.
@@ -80,7 +78,7 @@ The NDJSON v1 stream is treated as a public integration API. Compatible addition
80
78
 
81
79
  ### Skill for AI agents
82
80
 
83
- PALABRE ships a ready-to-use skill that teaches an AI agent when and how to run Palabre sessions. It follows the open [agentskills.io](https://agentskills.io) standard, so it is portable across Hermes Agent, Claude, Codex, Gemini CLI, and any skills-compatible agent.
81
+ PALABRE ships a ready-to-use skill that teaches an AI agent when and how to run Palabre sessions. It follows the open [agentskills.io](https://agentskills.io) standard, so it is portable across Hermes Agent, Claude, Codex, and any skills-compatible agent.
84
82
 
85
83
  Install it in **Hermes Agent**:
86
84
 
@@ -94,7 +92,7 @@ The skill is versioned under [skills/palabre](./skills/palabre).
94
92
 
95
93
  ### Privacy
96
94
 
97
- PALABRE runs locally and does not send data to a PALABRE-owned server. Data sent to agents depends on the tools you use: check the privacy policies of Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, OpenCode, Mistral Vibe, Ollama, or any custom agent you configure.
95
+ PALABRE runs locally and does not send data to a PALABRE-owned server. Data sent to agents depends on the tools you use: check the privacy policies of Claude Code, Codex CLI, Antigravity CLI, OpenCode, Mistral Vibe, Ollama, or any custom agent you configure.
98
96
 
99
97
  If an agent fails during the debate or final summary, PALABRE keeps the partial Markdown export with an interruption section whenever possible.
100
98
 
@@ -119,9 +117,11 @@ Public roadmap: [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Changes:
119
117
 
120
118
  MIT. See [LICENSE](./LICENSE).
121
119
 
120
+ ![PALABRE](docs/assets/palabre-logo-text-og.png)
121
+
122
122
  ## Français
123
123
 
124
- PALABRE est un orchestrateur CLI/TUI qui fait travailler plusieurs agents IA installés sur votre machine : Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, OpenCode, Mistral Vibe et Ollama.
124
+ PALABRE est un orchestrateur CLI/TUI qui fait travailler plusieurs agents IA installés sur votre machine : Claude Code, Codex CLI, Antigravity CLI, OpenCode, Mistral Vibe et Ollama.
125
125
 
126
126
  Il ne remplace pas vos outils : il les pilote. Vous gardez vos abonnements, vos modèles par défaut, vos habitudes de terminal et vos fichiers en local. PALABRE peut lancer un débat entre deux agents ou une demande Ask où plusieurs agents répondent indépendamment avant une synthèse comparative. Il exporte ensuite la session en Markdown.
127
127
 
@@ -145,7 +145,6 @@ palabre --help
145
145
  ### Démarrage rapide
146
146
 
147
147
  ```bash
148
- palabre init
149
148
  palabre doctor
150
149
  palabre
151
150
  ```
@@ -162,16 +161,15 @@ palabre codex-claude "Preview" --context src --show-prompt
162
161
  palabre context scan src docs --json
163
162
  ```
164
163
 
165
- Dans un terminal interactif, Palabre utilise l'interface TUI par défaut. `palabre` ouvre l'accueil, `/ask` passe du débat aux réponses indépendantes, `/agents` et `/roles` aident à choisir la configuration courante, et `--terminal` force l'ancien rendu brut adapté aux logs.
164
+ Dans un terminal interactif, Palabre utilise l'interface TUI par défaut. `palabre` ouvre l'accueil, crée la config globale au premier lancement si nécessaire, et rafraîchit les agents connus détectés avant d'afficher l'interface. `/ask` passe du débat aux réponses indépendantes, `/agents` et `/roles` aident à choisir la configuration courante, `/history` affiche les derniers exports, et `/home` revient à l'accueil. `--terminal` force l'ancien rendu brut adapté aux logs. `palabre init` reste disponible pour un setup explicite, notamment avec `--local`.
166
165
 
167
166
  ### Agents supportés
168
167
 
169
168
  - Claude Code via `claude --print`
170
169
  - Codex CLI via `codex exec`
171
- - Gemini CLI via `gemini --prompt -`
172
170
  - Antigravity CLI via `agy --print` en pseudo-terminal
173
171
  - OpenCode via `opencode run`
174
- - Mistral Vibe via `vibe --output text --agent plan --trust --prompt`
172
+ - Mistral Vibe via `vibe --output text --trust --prompt`
175
173
  - Ollama via l'API locale HTTP
176
174
 
177
175
  PALABRE ne liste pas les modèles : ils changent souvent et dépendent de chaque CLI ou compte utilisateur. `--model-a`, `--model-b` et `--summary-model` transmettent simplement la valeur brute à l'agent concerné.
@@ -188,7 +186,7 @@ Le flux NDJSON v1 est traité comme une API publique d'intégration. Les ajouts
188
186
 
189
187
  ### Skill pour agents IA
190
188
 
191
- PALABRE fournit un skill prêt à l'emploi qui apprend à un agent IA quand et comment lancer des sessions Palabre. Il suit le standard ouvert [agentskills.io](https://agentskills.io) : il est donc portable entre Hermes Agent, Claude, Codex, Gemini CLI et tout agent compatible skills.
189
+ PALABRE fournit un skill prêt à l'emploi qui apprend à un agent IA quand et comment lancer des sessions Palabre. Il suit le standard ouvert [agentskills.io](https://agentskills.io) : il est donc portable entre Hermes Agent, Claude, Codex et tout agent compatible skills.
192
190
 
193
191
  Installation dans **Hermes Agent** :
194
192
 
@@ -202,7 +200,7 @@ Le skill est versionné dans [skills/palabre](./skills/palabre).
202
200
 
203
201
  ### Confidentialité
204
202
 
205
- PALABRE tourne localement et n'envoie aucune donnée à un serveur appartenant à PALABRE. Les données envoyées aux agents dépendent des outils que vous utilisez : vérifiez les politiques de confidentialité de Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, OpenCode, Mistral Vibe, Ollama ou de tout autre agent configuré.
203
+ PALABRE tourne localement et n'envoie aucune donnée à un serveur appartenant à PALABRE. Les données envoyées aux agents dépendent des outils que vous utilisez : vérifiez les politiques de confidentialité de Claude Code, Codex CLI, Antigravity CLI, OpenCode, Mistral Vibe, Ollama ou de tout autre agent configuré.
206
204
 
207
205
  Si un agent échoue pendant le débat ou la synthèse, PALABRE conserve l'export Markdown partiel avec une section d'interruption quand c'est possible.
208
206
 
@@ -82,7 +82,7 @@ export class CliPtyAdapter {
82
82
  return;
83
83
  }
84
84
  const content = cleanTerminalOutput(output);
85
- if (exitCode && exitCode !== 0 && !content) {
85
+ if (exitCode && exitCode !== 0) {
86
86
  reject(createPtyExitError(this.name, exitCode, output));
87
87
  return;
88
88
  }
@@ -4,7 +4,7 @@ import { formatAgentPrompt } from "../prompt.js";
4
4
  import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, withModelArgs } from "./cli-shared.js";
5
5
  import { cleanTerminalOutput } from "./terminal.js";
6
6
  /**
7
- * Adapter pour les CLIs batch (Codex, Claude, Gemini…).
7
+ * Adapter pour les CLIs batch (Codex, Claude, OpenCode, Vibe...).
8
8
  * Lance un sous-processus, injecte le prompt via stdin ou argument, capture stdout.
9
9
  * Garantit : rejection des sorties vides (sauf `allowEmptyOutput`), des timeouts et des exit codes non nuls sans stdout.
10
10
  */
@@ -47,7 +47,8 @@ export class CliAdapter {
47
47
  ? [...baseArgs, renderedPrompt]
48
48
  : baseArgs;
49
49
  return new Promise((resolve, reject) => {
50
- const child = spawn(this.config.command, args, {
50
+ const spawnCommand = shellCommandForSpawn(this.config.command, args, this.config.shell ?? false);
51
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
51
52
  stdio: ["pipe", "pipe", "pipe"],
52
53
  shell: this.config.shell ?? false
53
54
  });
@@ -147,7 +148,7 @@ export class CliAdapter {
147
148
  }));
148
149
  });
149
150
  const finishFromExitCode = (code) => {
150
- if (code && code !== 0 && !stdout.trim()) {
151
+ if (code && code !== 0) {
151
152
  finish(createCliExitError(this.name, code, stderr));
152
153
  return;
153
154
  }
@@ -307,6 +308,35 @@ function clipLine(value, maxLength) {
307
308
  ? value
308
309
  : `${value.slice(0, maxLength - 1)}…`;
309
310
  }
311
+ function shellCommandForSpawn(command, args, shell) {
312
+ if (!shell) {
313
+ return { command, args };
314
+ }
315
+ if (process.platform !== "win32") {
316
+ return {
317
+ command: [command, ...args].map(quotePosixShellArg).join(" "),
318
+ args: []
319
+ };
320
+ }
321
+ return {
322
+ command: [command, ...args].map(quoteWindowsShellArg).join(" "),
323
+ args: []
324
+ };
325
+ }
326
+ function quoteWindowsShellArg(value) {
327
+ if (value.length === 0) {
328
+ return "\"\"";
329
+ }
330
+ return `"${value
331
+ .replace(/(\\*)"/g, "$1$1\\\"")
332
+ .replace(/(\\+)$/g, "$1$1")}"`;
333
+ }
334
+ function quotePosixShellArg(value) {
335
+ if (value.length === 0) {
336
+ return "''";
337
+ }
338
+ return `'${value.replace(/'/g, "'\\''")}'`;
339
+ }
310
340
  function cancelledError(adapterName) {
311
341
  return new AdapterError("cancelled", adapterName, `${adapterName} cancelled by user.`);
312
342
  }
@@ -2,13 +2,13 @@ import { CliAdapter } from "./cli.js";
2
2
  import { CliPtyAdapter } from "./cli-pty.js";
3
3
  import { OllamaAdapter } from "./ollama.js";
4
4
  /** Factory qui instancie l'adapter approprié selon `config.type`. Exhaustive : tout `AgentConfig` valide produit un adapter. */
5
- export function createAgent(name, config) {
5
+ export function createAgent(name, config, runtime = {}) {
6
6
  switch (config.type) {
7
7
  case "cli":
8
8
  return new CliAdapter(name, config);
9
9
  case "cli-pty":
10
10
  return new CliPtyAdapter(name, config);
11
11
  case "ollama":
12
- return new OllamaAdapter(name, config);
12
+ return new OllamaAdapter(name, config, runtime);
13
13
  }
14
14
  }
@@ -1,6 +1,7 @@
1
1
  import { AdapterError } from "../errors.js";
2
2
  import { createTranslator } from "../i18n.js";
3
3
  import { formatAgentPrompt } from "../prompt.js";
4
+ import { resolveOllamaBaseUrl } from "../ollamaUrl.js";
4
5
  /**
5
6
  * Adapter pour Ollama via l'API HTTP locale (`POST /api/chat`).
6
7
  * N'accède jamais au filesystem : ne voit que le prompt et le transcript fournis par l'orchestrateur.
@@ -9,11 +10,13 @@ import { formatAgentPrompt } from "../prompt.js";
9
10
  export class OllamaAdapter {
10
11
  name;
11
12
  config;
13
+ runtime;
12
14
  role;
13
15
  contract;
14
- constructor(name, config) {
16
+ constructor(name, config, runtime = {}) {
15
17
  this.name = name;
16
18
  this.config = config;
19
+ this.runtime = runtime;
17
20
  this.role = config.role;
18
21
  this.contract = {
19
22
  name,
@@ -38,7 +41,10 @@ export class OllamaAdapter {
38
41
  if (prompt.signal?.aborted) {
39
42
  throw cancelledError(this.name);
40
43
  }
41
- const baseUrl = normalizeBaseUrl(this.config.baseUrl ?? "http://localhost:11434");
44
+ const baseUrl = resolveOllamaBaseUrl({
45
+ cliUrl: this.runtime.ollamaUrl,
46
+ configUrl: this.config.baseUrl
47
+ });
42
48
  if (this.config.validateModel !== false) {
43
49
  await this.ensureModelAvailable(baseUrl);
44
50
  }
@@ -116,7 +122,7 @@ export class OllamaAdapter {
116
122
  throw new AdapterError("model-unavailable", this.name, `Modele Ollama indisponible: ${this.config.model}. Modeles detectes: ${models.join(", ") || "aucun"}. ` +
117
123
  "Utilise --pull-models ou autoPullModel: true pour autoriser le telechargement.", { model: this.config.model, availableModels: models });
118
124
  }
119
- process.stdout.write(`\n[ollama] Modele absent, telechargement: ${this.config.model}\n`);
125
+ process.stderr.write(`\n[ollama] Modele absent, telechargement: ${this.config.model}\n`);
120
126
  await this.pullModel(baseUrl);
121
127
  if (!(await this.isModelAvailable(baseUrl))) {
122
128
  throw new AdapterError("model-pull-failed", this.name, `Le modele Ollama ${this.config.model} reste indisponible apres telechargement.`);
@@ -225,10 +231,6 @@ async function unloadModel(baseUrl, model, signal) {
225
231
  });
226
232
  }
227
233
  }
228
- /** Supprime le slash final de `baseUrl` pour éviter les doubles slashs dans les URLs construites. */
229
- function normalizeBaseUrl(baseUrl) {
230
- return baseUrl.replace(/\/$/, "");
231
- }
232
234
  function cancelledError(adapterName) {
233
235
  return new AdapterError("cancelled", adapterName, `${adapterName} cancelled by user.`);
234
236
  }
@@ -6,11 +6,16 @@
6
6
  const KNOWN_CLI_AGENTS = [
7
7
  { configKey: "codex", commandAliases: ["codex"], discoveryKey: "codex" },
8
8
  { configKey: "claude", commandAliases: ["claude"], discoveryKey: "claude" },
9
- { configKey: "gemini", commandAliases: ["gemini"], discoveryKey: "gemini" },
10
9
  { configKey: "antigravity", commandAliases: ["agy", "antigravity"], discoveryKey: "antigravity" },
11
10
  { configKey: "opencode", commandAliases: ["opencode"], discoveryKey: "opencode" },
12
11
  { configKey: "vibe", commandAliases: ["vibe"], discoveryKey: "vibe" }
13
12
  ];
13
+ /** Agents retirés conservés uniquement pour lire les anciennes configurations. */
14
+ const RETIRED_AGENT_NAMES = new Set(["gemini"]);
15
+ /** Indique qu'un nom d'agent ne doit plus être proposé ni exposé aux intégrations. */
16
+ export function isRetiredAgentName(name) {
17
+ return RETIRED_AGENT_NAMES.has(name.toLowerCase());
18
+ }
14
19
  /** Clé de config de l'agent Ollama local par défaut. */
15
20
  export const OLLAMA_AGENT_KEY = "ollama-local";
16
21
  /**
@@ -37,7 +42,7 @@ export function detectionForCommand(command, discovery) {
37
42
  }
38
43
  /**
39
44
  * Liste les clés d'agents connus effectivement détectés localement, dans
40
- * l'ordre canonique (`codex`, `claude`, `gemini`, `antigravity`, `opencode`, `vibe`,
45
+ * l'ordre canonique (`codex`, `claude`, `antigravity`, `opencode`, `vibe`,
41
46
  * puis `ollama-local`).
42
47
  */
43
48
  export function detectedAgentNames(discovery) {
package/dist/args.js CHANGED
@@ -35,6 +35,7 @@ const FLAG_SPECS = {
35
35
  language: { arity: "single" },
36
36
  "model-a": { arity: "single" },
37
37
  "model-b": { arity: "single" },
38
+ "ollama-url": { arity: "single" },
38
39
  mode: { arity: "single" },
39
40
  "set-ollama-model": { arity: "single" },
40
41
  preset: { arity: "single" },
@@ -45,8 +46,8 @@ const FLAG_SPECS = {
45
46
  turns: { arity: "single" },
46
47
  renderer: { arity: "single" },
47
48
  // Valeurs multiples.
48
- agents: { arity: "multi", max: 4 },
49
- "ask-agents": { arity: "multi", max: 4 },
49
+ agents: { arity: "multi" },
50
+ "ask-agents": { arity: "multi" },
50
51
  "set-defaults": { arity: "multi", max: 2 },
51
52
  files: { arity: "multi" },
52
53
  context: { arity: "multi" }
@@ -67,6 +68,8 @@ const COMMANDS = new Set([
67
68
  "agents",
68
69
  "preset",
69
70
  "presets",
71
+ "history",
72
+ "historique",
70
73
  "context"
71
74
  ]);
72
75
  /**
package/dist/config.js CHANGED
@@ -54,23 +54,6 @@ export const exampleConfig = {
54
54
  role: "reviewer",
55
55
  tier: "primary"
56
56
  },
57
- gemini: {
58
- type: "cli",
59
- command: "gemini",
60
- args: [
61
- "--output-format",
62
- "text",
63
- "--approval-mode",
64
- "plan",
65
- "--skip-trust",
66
- "--prompt",
67
- "-"
68
- ],
69
- promptMode: "stdin",
70
- shell: process.platform === "win32",
71
- role: "reviewer",
72
- tier: "primary"
73
- },
74
57
  antigravity: {
75
58
  type: "cli-pty",
76
59
  command: "agy",
@@ -102,8 +85,6 @@ export const exampleConfig = {
102
85
  args: [
103
86
  "--output",
104
87
  "text",
105
- "--agent",
106
- "plan",
107
88
  "--trust",
108
89
  "--prompt"
109
90
  ],
@@ -234,11 +215,34 @@ export function syncDetectedAgents(config, discovery) {
234
215
  const discoveredConfig = createConfigFromDiscovery(discovery);
235
216
  const missingAgents = detectedAgentNames(discovery).filter((agentName) => !config.agents[agentName]);
236
217
  applyDetectedCommands(config, discovery);
218
+ migrateKnownAgentDefaults(config);
237
219
  for (const agentName of missingAgents) {
238
220
  config.agents[agentName] = discoveredConfig.agents[agentName];
239
221
  }
240
222
  return missingAgents;
241
223
  }
224
+ function migrateKnownAgentDefaults(config) {
225
+ migrateVibePlanAgent(config);
226
+ }
227
+ function migrateVibePlanAgent(config) {
228
+ const agent = config.agents.vibe;
229
+ if (agent?.type !== "cli" || !isLegacyVibePlanArgs(agent.args)) {
230
+ return;
231
+ }
232
+ agent.args = ["--output", "text", "--trust", "--prompt"];
233
+ }
234
+ function isLegacyVibePlanArgs(args) {
235
+ return JSON.stringify(args) === JSON.stringify(["--output", "text", "--agent", "plan", "--trust", "--prompt"]);
236
+ }
237
+ export function syncDetectedAgentsDetailed(config, discovery) {
238
+ const before = JSON.stringify(config.agents);
239
+ const addedAgents = syncDetectedAgents(config, discovery);
240
+ const changed = JSON.stringify(config.agents) !== before;
241
+ return {
242
+ addedAgents,
243
+ changed
244
+ };
245
+ }
242
246
  export function syncOllamaModel(config, discovery) {
243
247
  const agent = config.agents["ollama-local"];
244
248
  if (agent?.type !== "ollama" || discovery.ollama.models.length === 0) {
@@ -266,6 +270,14 @@ export function setOllamaModel(config, model) {
266
270
  nextModel: agent.model
267
271
  };
268
272
  }
273
+ /** Met à jour l'adresse persistante de tous les agents Ollama configurés. */
274
+ export function setOllamaBaseUrl(config, baseUrl) {
275
+ const agents = Object.values(config.agents).filter((agent) => agent.type === "ollama");
276
+ for (const agent of agents) {
277
+ agent.baseUrl = baseUrl;
278
+ }
279
+ return agents.length;
280
+ }
269
281
  /** Écrit `config` sérialisé en JSON dans `configPath`. Crée le répertoire parent si nécessaire. */
270
282
  export async function writeExampleConfig(configPath = DEFAULT_CONFIG_PATH, config = exampleConfig) {
271
283
  const resolved = path.resolve(configPath);
@@ -279,7 +291,7 @@ function chooseDefaultOllamaModel(discovery) {
279
291
  return discovery.ollama.models[0] ?? DEFAULT_OLLAMA_MODEL;
280
292
  }
281
293
  function chooseDefaultSummaryAgent(pair) {
282
- for (const preferred of ["claude", "codex", "antigravity", "vibe", "gemini"]) {
294
+ for (const preferred of ["claude", "codex", "antigravity", "vibe"]) {
283
295
  if (pair.includes(preferred)) {
284
296
  return preferred;
285
297
  }
@@ -305,16 +317,12 @@ function chooseDefaultPair(discovery) {
305
317
  if (discovery.antigravity.available && discovery.ollama.available) {
306
318
  return ["antigravity", "ollama-local"];
307
319
  }
308
- if (discovery.gemini.available && discovery.ollama.available) {
309
- return ["gemini", "ollama-local"];
310
- }
311
320
  const cliAgents = [
312
321
  discovery.codex.available ? "codex" : undefined,
313
322
  discovery.claude.available ? "claude" : undefined,
314
323
  discovery.antigravity.available ? "antigravity" : undefined,
315
324
  discovery.opencode.available ? "opencode" : undefined,
316
- discovery.vibe.available ? "vibe" : undefined,
317
- discovery.gemini.available ? "gemini" : undefined
325
+ discovery.vibe.available ? "vibe" : undefined
318
326
  ].filter((agent) => Boolean(agent));
319
327
  if (cliAgents.length >= 2) {
320
328
  return [cliAgents[0], cliAgents[1]];
package/dist/context.js CHANGED
@@ -80,7 +80,11 @@ async function addContextPaths(paths, cwd, state) {
80
80
  const uniquePaths = [...new Set(paths.map((item) => item.trim()).filter(Boolean))];
81
81
  for (const inputPath of uniquePaths) {
82
82
  const absolutePath = path.resolve(cwd, inputPath);
83
- const fileStat = await stat(absolutePath);
83
+ const fileStat = await stat(absolutePath).catch(() => undefined);
84
+ if (!fileStat) {
85
+ state.warnings.push(state.messages.context.ignoredNotFileOrDirectory(inputPath));
86
+ continue;
87
+ }
84
88
  if (fileStat.isFile()) {
85
89
  await addContextFile(absolutePath, cwd, state);
86
90
  continue;
package/dist/discovery.js CHANGED
@@ -1,33 +1,54 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { executableExtensions } from "./exec.js";
4
+ import { resolveOllamaBaseUrl } from "./ollamaUrl.js";
4
5
  /**
5
6
  * Détecte en parallèle toutes les CLIs supportées et le serveur Ollama local.
6
7
  * Sur Windows, tente `claude.exe` avant `claude`.
7
8
  * Antigravity est exposé selon les installations sous `agy` ou `antigravity`.
8
9
  */
9
- export async function discoverLocalTools() {
10
- const [codex, claude, gemini, antigravity, opencode, vibe, ollamaCommand] = await Promise.all([
10
+ export async function discoverLocalTools(options = {}) {
11
+ const [codex, claude, antigravity, opencode, vibe, ollamaCommand] = await Promise.all([
11
12
  detectCommand("codex"),
12
13
  detectFirstCommand(process.platform === "win32" ? ["claude.exe", "claude"] : ["claude"]),
13
- detectCommand("gemini"),
14
14
  detectFirstCommand(["agy", "antigravity"]),
15
15
  detectCommand("opencode"),
16
16
  detectCommand("vibe"),
17
17
  detectCommand("ollama")
18
18
  ]);
19
- const ollamaServer = await detectOllamaServer();
19
+ const configuredTargets = Object.entries(options.ollamaTargets ?? {});
20
+ const targets = configuredTargets.length > 0
21
+ ? configuredTargets
22
+ : [["ollama-local", options.ollamaConfigUrl]];
23
+ const resolvedTargets = targets.map(([name, configUrl]) => ({
24
+ name,
25
+ baseUrl: resolveOllamaBaseUrl({
26
+ cliUrl: options.ollamaUrl,
27
+ configUrl
28
+ })
29
+ }));
30
+ const uniqueUrls = resolvedTargets
31
+ .map((target) => target.baseUrl)
32
+ .filter((baseUrl, index, urls) => urls.indexOf(baseUrl) === index);
33
+ const servers = await Promise.all(uniqueUrls.map(async (baseUrl) => [
34
+ baseUrl,
35
+ await detectOllamaServer(baseUrl)
36
+ ]));
37
+ const serversByUrl = new Map(servers);
38
+ const ollamaAgents = Object.fromEntries(resolvedTargets.map(({ name, baseUrl }) => [name, {
39
+ ...serversByUrl.get(baseUrl),
40
+ commandAvailable: ollamaCommand.available
41
+ }]));
42
+ const primaryName = ollamaAgents["ollama-local"] ? "ollama-local" : resolvedTargets[0].name;
43
+ const ollamaServer = ollamaAgents[primaryName];
20
44
  return {
21
45
  codex,
22
46
  claude,
23
- gemini,
24
47
  antigravity,
25
48
  opencode,
26
49
  vibe,
27
- ollama: {
28
- ...ollamaServer,
29
- commandAvailable: ollamaCommand.available
30
- }
50
+ ollama: ollamaServer,
51
+ ollamaAgents
31
52
  };
32
53
  }
33
54
  async function detectFirstCommand(commands) {
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { detectedAgentNames, detectionForCommand } from "./agentRegistry.js";
5
5
  import { discoverLocalTools } from "./discovery.js";
6
6
  import { createTranslator, resolveLanguage } from "./i18n.js";
7
7
  import { DEFAULT_TURNS, MAX_TURNS } from "./limits.js";
8
+ import { configuredOllamaTargets, normalizeOllamaBaseUrl } from "./ollamaUrl.js";
8
9
  import { compareSemver, getLatestPackageVersion, getPackageVersion } from "./version.js";
9
10
  /**
10
11
  * Exécute le diagnostic complet : config, outils locaux et agents.
@@ -39,11 +40,14 @@ export async function runDoctor(explicitConfigPath, plain = false, explicitLangu
39
40
  lines.push(ok(t.doctor.configReadable));
40
41
  lines.push(ok(t.doctor.interfaceLanguage(language)));
41
42
  await inspectConfig(config, lines, t);
42
- const discovery = await discoverLocalTools();
43
+ const ollamaTargets = Object.fromEntries(Object.entries(configuredOllamaTargets(config))
44
+ .map(([name, value]) => [name, value && isValidOllamaBaseUrl(value) ? value : undefined]));
45
+ const discovery = await discoverLocalTools({
46
+ ollamaTargets
47
+ });
43
48
  lines.push(info(t.doctor.localTools, "tools"));
44
49
  lines.push(formatCommand("Codex CLI", discovery.codex.available, discovery.codex.command, discovery.codex.path, t));
45
50
  lines.push(formatCommand("Claude CLI", discovery.claude.available, discovery.claude.command, discovery.claude.path, t));
46
- lines.push(formatCommand("Gemini CLI", discovery.gemini.available, discovery.gemini.command, discovery.gemini.path, t));
47
51
  lines.push(formatCommand("Antigravity CLI", discovery.antigravity.available, discovery.antigravity.command, discovery.antigravity.path, t));
48
52
  lines.push(formatCommand("OpenCode CLI", discovery.opencode.available, discovery.opencode.command, discovery.opencode.path, t));
49
53
  lines.push(formatCommand("Mistral Vibe CLI", discovery.vibe.available, discovery.vibe.command, discovery.vibe.path, t));
@@ -205,13 +209,22 @@ function inspectAgentShape(name, agent, lines, t) {
205
209
  if (!agent.model || !agent.model.trim()) {
206
210
  lines.push(error(t.doctor.ollamaModelMissing(name)));
207
211
  }
208
- if (agent.baseUrl && !/^https?:\/\//.test(agent.baseUrl)) {
212
+ if (agent.baseUrl && !isValidOllamaBaseUrl(agent.baseUrl)) {
209
213
  lines.push(error(t.doctor.ollamaBaseUrlInvalid(name, agent.baseUrl)));
210
214
  }
211
215
  if (agent.timeoutMs !== undefined && (!Number.isFinite(agent.timeoutMs) || agent.timeoutMs <= 0)) {
212
216
  lines.push(error(t.doctor.positiveTimeout(name, "timeoutMs")));
213
217
  }
214
218
  }
219
+ function isValidOllamaBaseUrl(value) {
220
+ try {
221
+ normalizeOllamaBaseUrl(value);
222
+ return true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
215
228
  function inspectCliAgent(name, agent, discovery, lines, t) {
216
229
  const known = detectionForCommand(agent.command, discovery);
217
230
  const prefix = `${name} [cli:${agent.role}] command=${agent.command}`;
@@ -225,7 +238,8 @@ function inspectCliAgent(name, agent, discovery, lines, t) {
225
238
  }
226
239
  function inspectOllamaAgent(name, agent, discovery, lines, t) {
227
240
  const prefix = `${name} [ollama:${agent.role}] model=${agent.model}`;
228
- if (!discovery.ollama.available) {
241
+ const ollama = discovery.ollamaAgents?.[name] ?? discovery.ollama;
242
+ if (!ollama.available) {
229
243
  lines.push(warn(t.doctor.ollamaNotVerifiable(prefix)));
230
244
  return;
231
245
  }
@@ -233,7 +247,7 @@ function inspectOllamaAgent(name, agent, discovery, lines, t) {
233
247
  lines.push(info(t.doctor.ollamaValidateFalse(prefix)));
234
248
  return;
235
249
  }
236
- const installed = discovery.ollama.models.includes(agent.model);
250
+ const installed = ollama.models.includes(agent.model);
237
251
  lines.push(installed
238
252
  ? ok(t.doctor.ollamaInstalled(prefix))
239
253
  : warn(t.doctor.ollamaMissing(prefix, agent.model)));
@@ -0,0 +1,85 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const maxHeaderBytes = 12_000;
4
+ export async function listHistoryEntries(outputDir, limit = 10) {
5
+ const resolved = path.resolve(outputDir);
6
+ let entries;
7
+ try {
8
+ entries = await readdir(resolved, { withFileTypes: true });
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ const markdownFiles = entries
14
+ .filter((entry) => entry.isFile() && /\.(debate|ask)\.md$/i.test(entry.name))
15
+ .map((entry) => path.join(resolved, entry.name));
16
+ const history = await Promise.all(markdownFiles.map(readHistoryFile));
17
+ return history
18
+ .filter((entry) => Boolean(entry))
19
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
20
+ .slice(0, limit);
21
+ }
22
+ async function readHistoryFile(filePath) {
23
+ try {
24
+ const [metadata, raw] = await Promise.all([
25
+ stat(filePath),
26
+ readFile(filePath, "utf8")
27
+ ]);
28
+ const header = raw.slice(0, maxHeaderBytes);
29
+ const table = parseMetadataTable(header);
30
+ const fileName = path.basename(filePath);
31
+ const mode = fileName.endsWith(".ask.md") ? "ask" : "debate";
32
+ return {
33
+ fileName,
34
+ path: filePath,
35
+ mode,
36
+ topic: table.Sujet ?? table.Subject ?? topicFromFileName(fileName),
37
+ agents: table.Agents ?? "",
38
+ date: table["Date locale"] ?? table["Local date"] ?? table["Session demarree a"] ?? table["Session started at"] ?? "",
39
+ count: countFromTable(mode, table),
40
+ mtimeMs: metadata.mtimeMs
41
+ };
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ function countFromTable(mode, table) {
48
+ if (mode === "ask") {
49
+ const received = table["Reponses recues"] ?? table["Received responses"];
50
+ const requested = table["Reponses attendues"] ?? table["Expected responses"];
51
+ return received && requested ? `${received}/${requested}` : received ?? requested ?? "";
52
+ }
53
+ const played = table["Tours joues"] ?? table["Played turns"];
54
+ const requested = table["Tours demandes"] ?? table["Requested turns"];
55
+ return played && requested ? `${played}/${requested}` : played ?? requested ?? "";
56
+ }
57
+ function parseMetadataTable(markdown) {
58
+ const fields = {};
59
+ const lines = markdown.split(/\r?\n/);
60
+ for (const line of lines) {
61
+ const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|$/);
62
+ if (!match)
63
+ continue;
64
+ const key = stripMarkdown(match[1] ?? "");
65
+ const value = stripMarkdown(match[2] ?? "");
66
+ if (!key || key === "Champ" || key === "Field" || /^-+$/.test(key))
67
+ continue;
68
+ fields[key] = value;
69
+ }
70
+ return fields;
71
+ }
72
+ function stripMarkdown(value) {
73
+ return value
74
+ .replace(/\*\*/g, "")
75
+ .replace(/`/g, "")
76
+ .trim();
77
+ }
78
+ function topicFromFileName(fileName) {
79
+ return fileName
80
+ .replace(/^palabre-/, "")
81
+ .replace(/\.(debate|ask)\.md$/i, "")
82
+ .replace(/-\d{4}-\d{2}-\d{2}t.*$/i, "")
83
+ .replace(/-/g, " ")
84
+ .trim() || fileName;
85
+ }