palabre 0.6.1 → 0.6.4

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
@@ -93,7 +93,9 @@ palabre --version
93
93
 
94
94
  Commandes utiles : `pnpm check`, `pnpm test`, `pnpm build`.
95
95
 
96
- Roadmap publique : [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Guide agents/contributeurs : [AGENTS.md](./AGENTS.md).
96
+ Avant une publication, `pnpm smoke:real-presets -- --keep-going` lance des débats réels sur les presets prioritaires disponibles afin de vérifier le flux complet agent → NDJSON → export. Ce smoke test appelle de vraies CLIs IA et peut consommer des quotas ; il n'est donc pas lancé par `pnpm test`.
97
+
98
+ Roadmap publique : [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Changements : [CHANGELOG.md](./CHANGELOG.md). Guide agents/contributeurs : [AGENTS.md](./AGENTS.md).
97
99
 
98
100
  ### Licence
99
101
 
@@ -181,7 +183,9 @@ palabre --version
181
183
 
182
184
  Useful commands: `pnpm check`, `pnpm test`, `pnpm build`.
183
185
 
184
- Public roadmap: [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Agent/contributor guide: [AGENTS.md](./AGENTS.md).
186
+ Before publishing, `pnpm smoke:real-presets -- --keep-going` runs real debates for the available priority presets to validate the full agent → NDJSON → export flow. This smoke test calls real AI CLIs and may consume quota, so it is not part of `pnpm test`.
187
+
188
+ Public roadmap: [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Changes: [CHANGELOG.md](./CHANGELOG.md). Agent/contributor guide: [AGENTS.md](./AGENTS.md).
185
189
 
186
190
  ### License
187
191
 
@@ -1,10 +1,10 @@
1
- import { spawn as spawnPty } from "node-pty";
2
1
  import { existsSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import { AdapterError } from "../errors.js";
4
+ import { executableExtensions } from "../exec.js";
5
5
  import { formatAgentPrompt } from "../prompt.js";
6
+ import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, withModelArgs } from "./cli-shared.js";
6
7
  import { cleanTerminalOutput } from "./terminal.js";
7
- const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
8
8
  /**
9
9
  * Adapter pour les CLIs qui exigent un vrai terminal.
10
10
  * Contrairement à `CliAdapter`, stdout/stderr sont fusionnés dans le flux PTY.
@@ -44,6 +44,7 @@ export class CliPtyAdapter {
44
44
  const args = promptMode === "argument"
45
45
  ? [...baseArgs, renderedPrompt]
46
46
  : baseArgs;
47
+ const { spawn: spawnPty } = await import("node-pty");
47
48
  return new Promise((resolve, reject) => {
48
49
  let output = "";
49
50
  let outputBytes = 0;
@@ -107,10 +108,10 @@ export class CliPtyAdapter {
107
108
  return;
108
109
  }
109
110
  hardTimer = setTimeout(() => {
110
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
111
- timeoutMs: this.config.timeoutMs ?? 180_000
111
+ finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
112
+ timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
112
113
  }));
113
- }, this.config.timeoutMs ?? 180_000);
114
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
114
115
  dataSubscription = term.onData((chunk) => {
115
116
  outputBytes += Buffer.byteLength(chunk, "utf8");
116
117
  if (outputBytes > maxOutputBytes) {
@@ -158,30 +159,6 @@ function cleanupPty(term) {
158
159
  // Best-effort cleanup for Windows ConPTY internals.
159
160
  }
160
161
  }
161
- function executableExtensions(command) {
162
- if (path.extname(command) || process.platform !== "win32") {
163
- return [""];
164
- }
165
- return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
166
- .split(";")
167
- .map((extension) => extension.toLowerCase())
168
- .concat(".ps1", "");
169
- }
170
- function withModelArgs(args, model, modelArg) {
171
- if (!model) {
172
- return [...args];
173
- }
174
- const promptStdinIndex = args.lastIndexOf("-");
175
- if (promptStdinIndex === args.length - 1) {
176
- return [
177
- ...args.slice(0, promptStdinIndex),
178
- modelArg,
179
- model,
180
- ...args.slice(promptStdinIndex)
181
- ];
182
- }
183
- return [...args, modelArg, model];
184
- }
185
162
  function createPtyExitError(adapterName, exitCode, raw) {
186
163
  return new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizePtyOutput(raw)}`, {
187
164
  exitCode,
@@ -0,0 +1,24 @@
1
+ /** Limite de sortie par défaut des adapters CLI/PTY : 50 Mio avant `output-too-large`. */
2
+ export const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
3
+ /** Timeout dur par défaut d'un appel d'agent CLI/PTY (3 minutes). */
4
+ export const DEFAULT_TIMEOUT_MS = 180_000;
5
+ /**
6
+ * Insère `modelArg model` dans la liste d'arguments d'une commande CLI.
7
+ * Si le dernier argument est `-` (marqueur stdin), insère avant lui pour
8
+ * préserver l'ordre attendu par les CLIs qui lisent le prompt sur stdin.
9
+ */
10
+ export function withModelArgs(args, model, modelArg) {
11
+ if (!model) {
12
+ return [...args];
13
+ }
14
+ const promptStdinIndex = args.lastIndexOf("-");
15
+ if (promptStdinIndex === args.length - 1) {
16
+ return [
17
+ ...args.slice(0, promptStdinIndex),
18
+ modelArg,
19
+ model,
20
+ ...args.slice(promptStdinIndex)
21
+ ];
22
+ }
23
+ return [...args, modelArg, model];
24
+ }
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { AdapterError } from "../errors.js";
3
3
  import { formatAgentPrompt } from "../prompt.js";
4
+ import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, withModelArgs } from "./cli-shared.js";
4
5
  import { cleanTerminalOutput } from "./terminal.js";
5
- const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
6
6
  /**
7
7
  * Adapter pour les CLIs batch (Codex, Claude, Gemini…).
8
8
  * Lance un sous-processus, injecte le prompt via stdin ou argument, capture stdout.
@@ -68,6 +68,11 @@ export class CliAdapter {
68
68
  }
69
69
  const content = cleanCliOutput(stdout);
70
70
  if (!content && !this.config.allowEmptyOutput) {
71
+ const knownError = createKnownCliError(this.name, undefined, stderr);
72
+ if (knownError) {
73
+ reject(knownError);
74
+ return;
75
+ }
71
76
  const detail = stderr.trim() ? ` Stderr: ${stderr.trim()}` : "";
72
77
  reject(new AdapterError("empty-output", this.name, `${this.name} produced empty output.${detail}`, {
73
78
  stderr: stderr.trim()
@@ -81,10 +86,10 @@ export class CliAdapter {
81
86
  };
82
87
  hardTimer = setTimeout(() => {
83
88
  child.kill();
84
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
85
- timeoutMs: this.config.timeoutMs ?? 180_000
89
+ finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
90
+ timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
86
91
  }));
87
- }, this.config.timeoutMs ?? 180_000);
92
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
88
93
  const bumpIdleTimer = () => {
89
94
  if (!this.config.idleTimeoutMs)
90
95
  return;
@@ -144,53 +149,74 @@ export class CliAdapter {
144
149
  });
145
150
  }
146
151
  }
147
- /**
148
- * Insère `modelArg model` dans la liste d'arguments.
149
- * Si le dernier argument est `-` (stdin marker), insère avant lui pour préserver l'ordre attendu par les CLIs.
150
- */
151
- function withModelArgs(args, model, modelArg) {
152
- if (!model) {
153
- return [...args];
154
- }
155
- const promptStdinIndex = args.lastIndexOf("-");
156
- if (promptStdinIndex === args.length - 1) {
157
- return [
158
- ...args.slice(0, promptStdinIndex),
159
- modelArg,
160
- model,
161
- ...args.slice(promptStdinIndex)
162
- ];
163
- }
164
- return [...args, modelArg, model];
165
- }
166
152
  /** Retire les séquences ANSI et les espaces en tête/fin. */
167
153
  function cleanCliOutput(output) {
168
- return cleanTerminalOutput(output);
154
+ return stripWindowsTaskkillNoise(cleanTerminalOutput(output));
155
+ }
156
+ function stripWindowsTaskkillNoise(output) {
157
+ const lines = output.split("\n");
158
+ const kept = [];
159
+ let skipNextFrenchContinuation = false;
160
+ for (const line of lines) {
161
+ const trimmed = line.trim();
162
+ const normalized = normalizeForWindowsStatus(trimmed);
163
+ if (skipNextFrenchContinuation && /^arr.*t.*\.$/i.test(normalized)) {
164
+ skipNextFrenchContinuation = false;
165
+ continue;
166
+ }
167
+ skipNextFrenchContinuation = false;
168
+ if (isWindowsTaskkillStatusLine(trimmed)) {
169
+ skipNextFrenchContinuation = true;
170
+ continue;
171
+ }
172
+ kept.push(line);
173
+ }
174
+ return kept.join("\n").trim();
175
+ }
176
+ function isWindowsTaskkillStatusLine(line) {
177
+ const normalized = normalizeForWindowsStatus(line);
178
+ const lower = line.toLowerCase();
179
+ return (/^SUCCESS:\s+The process with PID \d+ .* has been terminated\.$/i.test(line) ||
180
+ /^operation reussie.*processus de pid \d+ .* a ete$/.test(normalized) ||
181
+ (lower.startsWith("op") &&
182
+ lower.includes("processus de pid ") &&
183
+ lower.includes("processus enfant de pid") &&
184
+ lower.includes(" a ")));
185
+ }
186
+ function normalizeForWindowsStatus(line) {
187
+ return line
188
+ .normalize("NFD")
189
+ .replace(/\p{Diacritic}/gu, "")
190
+ .toLowerCase();
169
191
  }
170
192
  /**
171
193
  * Construit une `AdapterError` typée depuis un exit code non nul.
172
194
  * Élève en `usage-limit` si le stderr contient un signal de quota/rate-limit connu.
173
195
  */
174
196
  function createCliExitError(adapterName, exitCode, stderr) {
197
+ return createKnownCliError(adapterName, exitCode, stderr)
198
+ ?? new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizeCliError(cleanCliOutput(stderr))}`, {
199
+ exitCode,
200
+ stderr: cleanCliOutput(stderr)
201
+ });
202
+ }
203
+ function createKnownCliError(adapterName, exitCode, stderr) {
175
204
  const cleanedStderr = cleanCliOutput(stderr);
176
205
  const usageLimitMessage = extractUsageLimitMessage(cleanedStderr);
177
206
  const unsupportedModelMessage = extractUnsupportedModelMessage(cleanedStderr);
178
207
  if (usageLimitMessage) {
179
208
  return new AdapterError("usage-limit", adapterName, `${adapterName} a atteint une limite d'utilisation: ${usageLimitMessage}`, {
180
- exitCode,
209
+ ...(exitCode === undefined ? {} : { exitCode }),
181
210
  stderr: cleanedStderr
182
211
  });
183
212
  }
184
213
  if (unsupportedModelMessage) {
185
214
  return new AdapterError("unsupported-model", adapterName, `${adapterName} ne peut pas utiliser ce modèle: ${unsupportedModelMessage}`, {
186
- exitCode,
215
+ ...(exitCode === undefined ? {} : { exitCode }),
187
216
  stderr: cleanedStderr
188
217
  });
189
218
  }
190
- return new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizeCliError(cleanedStderr)}`, {
191
- exitCode,
192
- stderr: cleanedStderr
193
- });
219
+ return undefined;
194
220
  }
195
221
  function extractUnsupportedModelMessage(stderr) {
196
222
  const lines = uniqueNonEmptyLines(stderr);
@@ -1,4 +1,5 @@
1
1
  import { AdapterError } from "../errors.js";
2
+ import { createTranslator } from "../i18n.js";
2
3
  import { formatAgentPrompt } from "../prompt.js";
3
4
  /**
4
5
  * Adapter pour Ollama via l'API HTTP locale (`POST /api/chat`).
@@ -57,7 +58,7 @@ export class OllamaAdapter {
57
58
  {
58
59
  role: "system",
59
60
  content: this.config.systemPrompt ??
60
- "Tu participes a un debat technique orchestre. Reste precis, utile et honnete sur tes limites."
61
+ createTranslator(prompt.language ?? "fr").prompt.ollamaSystemPrompt
61
62
  },
62
63
  {
63
64
  role: "user",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Agents CLI connus, dans l'ordre d'affichage canonique.
3
+ * `ollama-local` n'est pas listé ici : ce n'est pas une commande CLI, il est
4
+ * géré séparément via `discovery.ollama`.
5
+ */
6
+ const KNOWN_CLI_AGENTS = [
7
+ { configKey: "codex", commandAliases: ["codex"], discoveryKey: "codex" },
8
+ { configKey: "claude", commandAliases: ["claude"], discoveryKey: "claude" },
9
+ { configKey: "gemini", commandAliases: ["gemini"], discoveryKey: "gemini" },
10
+ { configKey: "antigravity", commandAliases: ["agy", "antigravity"], discoveryKey: "antigravity" },
11
+ { configKey: "opencode", commandAliases: ["opencode"], discoveryKey: "opencode" }
12
+ ];
13
+ /** Clé de config de l'agent Ollama local par défaut. */
14
+ export const OLLAMA_AGENT_KEY = "ollama-local";
15
+ /**
16
+ * Extrait le nom de base d'une commande en supprimant le chemin et l'extension
17
+ * exécutable Windows éventuelle (ex. `C:\bin\claude.cmd` → `claude`).
18
+ */
19
+ export function normalizeCommandName(command) {
20
+ return command
21
+ .split(/[\\/]/)
22
+ .pop()
23
+ ?.toLowerCase()
24
+ .replace(/\.(exe|cmd|bat|ps1)$/i, "") ?? command.toLowerCase();
25
+ }
26
+ /**
27
+ * Résout l'entrée de découverte d'une commande d'agent CLI connue.
28
+ * Retourne `undefined` pour une commande custom non reconnue : Palabre ne peut
29
+ * pas connaître sa sémantique sans la lancer, donc l'appelant la considère
30
+ * généralement comme disponible.
31
+ */
32
+ export function detectionForCommand(command, discovery) {
33
+ const normalized = normalizeCommandName(command);
34
+ const known = KNOWN_CLI_AGENTS.find((agent) => agent.commandAliases.includes(normalized));
35
+ return known ? discovery[known.discoveryKey] : undefined;
36
+ }
37
+ /**
38
+ * Liste les clés d'agents connus effectivement détectés localement, dans
39
+ * l'ordre canonique (`codex`, `claude`, `gemini`, `antigravity`, `opencode`,
40
+ * puis `ollama-local`).
41
+ */
42
+ export function detectedAgentNames(discovery) {
43
+ const names = KNOWN_CLI_AGENTS
44
+ .filter((agent) => discovery[agent.discoveryKey].available)
45
+ .map((agent) => agent.configKey);
46
+ if (discovery.ollama.available) {
47
+ names.push(OLLAMA_AGENT_KEY);
48
+ }
49
+ return names;
50
+ }
51
+ /**
52
+ * Applique les chemins de commande résolus localement aux agents CLI connus
53
+ * d'une config. Mute `config` : l'appelant est responsable de la cloner au besoin.
54
+ * Sans détection disponible, l'agent garde la commande déjà déclarée.
55
+ */
56
+ export function applyDetectedCommands(config, discovery) {
57
+ for (const agent of KNOWN_CLI_AGENTS) {
58
+ const detection = discovery[agent.discoveryKey];
59
+ const cfg = config.agents[agent.configKey];
60
+ if (detection.available && cfg && (cfg.type === "cli" || cfg.type === "cli-pty")) {
61
+ cfg.command = detection.command;
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Indique si un agent de config est détecté localement.
67
+ * Pour Ollama, reflète l'accessibilité du serveur ; pour les CLIs connues, l'état
68
+ * de découverte ; pour une CLI custom inconnue, retourne `true` (faute de pouvoir vérifier).
69
+ */
70
+ export function isAgentDetected(name, config, discovery) {
71
+ if (config.type === "ollama") {
72
+ return discovery.ollama.available;
73
+ }
74
+ const detection = detectionForCommand(config.command || name, discovery);
75
+ return detection ? detection.available : true;
76
+ }
package/dist/args.js ADDED
@@ -0,0 +1,265 @@
1
+ import { listPresetNames } from "./presets.js";
2
+ /**
3
+ * Table centrale décrivant l'arité de chaque flag long canonique.
4
+ *
5
+ * C'est la source de vérité du parser : un flag `boolean` ne consomme jamais le
6
+ * token suivant (évite que `--plain codex-claude "x"` avale le preset), un flag
7
+ * `single` exige une valeur, un flag `multi` collecte plusieurs valeurs.
8
+ *
9
+ * Les noms sont les noms canoniques après `normalizeFlagName` (ex. `subject`
10
+ * est normalisé en `topic` avant la recherche dans cette table).
11
+ */
12
+ const FLAG_SPECS = {
13
+ // Booléens : présence = vrai, aucune valeur consommée.
14
+ help: { arity: "boolean" },
15
+ version: { arity: "boolean" },
16
+ plain: { arity: "boolean" },
17
+ json: { arity: "boolean" },
18
+ "no-summary": { arity: "boolean" },
19
+ "no-early-stop": { arity: "boolean" },
20
+ "show-prompt": { arity: "boolean" },
21
+ "pull-models": { arity: "boolean" },
22
+ local: { arity: "boolean" },
23
+ apply: { arity: "boolean" },
24
+ "clear-defaults": { arity: "boolean" },
25
+ "sync-agents": { arity: "boolean" },
26
+ // Valeur unique.
27
+ "agent-a": { arity: "single" },
28
+ "agent-b": { arity: "single" },
29
+ config: { arity: "single" },
30
+ language: { arity: "single" },
31
+ "model-a": { arity: "single" },
32
+ "model-b": { arity: "single" },
33
+ preset: { arity: "single" },
34
+ "summary-agent": { arity: "single" },
35
+ "summary-model": { arity: "single" },
36
+ topic: { arity: "single" },
37
+ turns: { arity: "single" },
38
+ renderer: { arity: "single" },
39
+ // Valeurs multiples.
40
+ "set-defaults": { arity: "multi", max: 2 },
41
+ files: { arity: "multi" },
42
+ context: { arity: "multi" }
43
+ };
44
+ /** Commandes acceptées comme premier argument positionnel. */
45
+ const COMMANDS = new Set([
46
+ "run",
47
+ "new",
48
+ "init",
49
+ "setup",
50
+ "help",
51
+ "version",
52
+ "update",
53
+ "doctor",
54
+ "config",
55
+ "agent",
56
+ "agents",
57
+ "preset",
58
+ "presets",
59
+ "context"
60
+ ]);
61
+ /**
62
+ * Parse `process.argv` en une structure typée `ParsedArgs`.
63
+ * Gère les flags courts (-h, -v, -s, -t, -a), les flags longs pilotés par
64
+ * `FLAG_SPECS`, les flags multi-valeurs (--files, --context, --set-defaults) et
65
+ * les positionnels.
66
+ * @param args - Tableau d'arguments (généralement `process.argv.slice(2)`).
67
+ * @returns Commande détectée, indicateur d'explicitation et map de flags.
68
+ */
69
+ export function parseArgs(args, messages) {
70
+ const flags = {};
71
+ let command = "run";
72
+ let commandExplicit = false;
73
+ const positionals = [];
74
+ const presets = new Set(listPresetNames());
75
+ for (let index = 0; index < args.length; index += 1) {
76
+ const value = args[index];
77
+ if (!value.startsWith("-") && !commandExplicit && positionals.length === 0 && COMMANDS.has(value)) {
78
+ command = value;
79
+ commandExplicit = true;
80
+ continue;
81
+ }
82
+ if (!value.startsWith("-") && index === 0) {
83
+ if (COMMANDS.has(value)) {
84
+ command = value;
85
+ commandExplicit = true;
86
+ }
87
+ else if (isLikelyCommandTypo(value, COMMANDS)) {
88
+ throw new Error(messages.common.unknownCommand(value, Array.from(COMMANDS).join(", ")));
89
+ }
90
+ else {
91
+ positionals.push(value);
92
+ }
93
+ continue;
94
+ }
95
+ if (!value.startsWith("-")) {
96
+ positionals.push(value);
97
+ continue;
98
+ }
99
+ if (value === "-h") {
100
+ flags.help = true;
101
+ continue;
102
+ }
103
+ if (value === "-v") {
104
+ flags.version = true;
105
+ continue;
106
+ }
107
+ if (value === "-a") {
108
+ command = "agents";
109
+ commandExplicit = true;
110
+ continue;
111
+ }
112
+ if (value === "-s") {
113
+ const next = args[index + 1];
114
+ if (!next || next.startsWith("-")) {
115
+ throw new Error(messages.common.optionRequiresValue("-s"));
116
+ }
117
+ flags.topic = next;
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (value === "-t") {
122
+ const next = args[index + 1];
123
+ if (!next || next.startsWith("-")) {
124
+ throw new Error(messages.common.optionRequiresValue("-t"));
125
+ }
126
+ flags.turns = next;
127
+ index += 1;
128
+ continue;
129
+ }
130
+ if (value.startsWith("--")) {
131
+ const rawKey = value.slice(2);
132
+ const key = normalizeFlagName(rawKey);
133
+ const spec = FLAG_SPECS[key];
134
+ if (spec?.arity === "multi") {
135
+ const values = [];
136
+ while (args[index + 1] && !args[index + 1].startsWith("-") && (spec.max === undefined || values.length < spec.max)) {
137
+ values.push(args[index + 1]);
138
+ index += 1;
139
+ }
140
+ if (key === "set-defaults" && values.length !== 2) {
141
+ throw new Error(messages.common.setDefaultsRequiresTwo);
142
+ }
143
+ flags[key] = key === "set-defaults"
144
+ ? values
145
+ : [...getStringListFlag(flags[key]), ...values];
146
+ continue;
147
+ }
148
+ if (spec?.arity === "boolean") {
149
+ flags[key] = true;
150
+ continue;
151
+ }
152
+ const next = args[index + 1];
153
+ const wantsValue = spec?.arity === "single";
154
+ if (!next || next.startsWith("-")) {
155
+ if (wantsValue) {
156
+ throw new Error(messages.common.optionRequiresValue(`--${rawKey}`));
157
+ }
158
+ flags[key] = true;
159
+ }
160
+ else if (wantsValue) {
161
+ flags[key] = next;
162
+ index += 1;
163
+ }
164
+ else {
165
+ // Flag inconnu : traité comme booléen pour ne jamais avaler un positionnel.
166
+ flags[key] = true;
167
+ }
168
+ }
169
+ }
170
+ if (command === "run") {
171
+ applyRunPositionals(positionals, flags, presets, commandExplicit, COMMANDS, messages);
172
+ }
173
+ return { command, commandExplicit, positionals, flags };
174
+ }
175
+ /**
176
+ * Détecte si une valeur ressemble à une faute de frappe d'une commande connue
177
+ * (même première lettre et distance de Levenshtein ≤ 2).
178
+ * @param value - Token saisi par l'utilisateur.
179
+ * @param commands - Ensemble des commandes valides.
180
+ */
181
+ export function isLikelyCommandTypo(value, commands) {
182
+ const normalized = value.toLowerCase();
183
+ for (const command of commands) {
184
+ if (normalized[0] === command[0] && levenshteinDistance(normalized, command) <= 2) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+ /**
191
+ * Calcule la distance de Levenshtein entre deux chaînes (insertions, suppressions, substitutions).
192
+ * @param left - Première chaîne.
193
+ * @param right - Deuxième chaîne.
194
+ * @returns Distance entière ≥ 0.
195
+ */
196
+ function levenshteinDistance(left, right) {
197
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
198
+ for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
199
+ let diagonal = previous[0];
200
+ previous[0] = leftIndex + 1;
201
+ for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
202
+ const insertCost = previous[rightIndex + 1] + 1;
203
+ const deleteCost = previous[rightIndex] + 1;
204
+ const replaceCost = diagonal + (left[leftIndex] === right[rightIndex] ? 0 : 1);
205
+ diagonal = previous[rightIndex + 1];
206
+ previous[rightIndex + 1] = Math.min(insertCost, deleteCost, replaceCost);
207
+ }
208
+ }
209
+ return previous[right.length] ?? 0;
210
+ }
211
+ /**
212
+ * Interprète les arguments positionnels pour la commande `run` :
213
+ * premier positionnel = preset si connu, sinon sujet complet concaténé.
214
+ * @param positionals - Arguments positionnels extraits du parseur.
215
+ * @param flags - Map de flags à muter si un preset ou un sujet est détecté.
216
+ * @param presets - Ensemble des noms de presets valides.
217
+ * @param commandExplicit - `true` si l'utilisateur a tapé `palabre run` explicitement.
218
+ */
219
+ function applyRunPositionals(positionals, flags, presets, commandExplicit, commands, messages) {
220
+ if (positionals.length === 0) {
221
+ return;
222
+ }
223
+ const [first, ...rest] = positionals;
224
+ if (presets.has(first)) {
225
+ flags.preset ??= first;
226
+ if (rest.length > 0) {
227
+ flags.topic ??= rest.join(" ");
228
+ }
229
+ return;
230
+ }
231
+ if (!commandExplicit && positionals.length === 1 && !positionals[0]?.includes(" ")) {
232
+ if (isLikelyCommandTypo(positionals[0], commands)) {
233
+ throw new Error(messages.common.unknownCommand(positionals[0], Array.from(commands).join(", ")));
234
+ }
235
+ throw new Error(messages.common.ambiguousSubject(positionals[0]));
236
+ }
237
+ flags.topic ??= positionals.join(" ");
238
+ }
239
+ /**
240
+ * Normalise un nom de flag long en son alias canonique (ex. `subject` → `topic`).
241
+ * @param value - Nom brut extrait après `--`.
242
+ */
243
+ export function normalizeFlagName(value) {
244
+ const aliases = {
245
+ lang: "language",
246
+ s: "topic",
247
+ subject: "topic",
248
+ t: "turns"
249
+ };
250
+ return aliases[value] ?? value;
251
+ }
252
+ /**
253
+ * Normalise une valeur de flag multi-valeur en tableau de chaînes.
254
+ * @param value - Valeur brute (tableau, chaîne unique ou absent).
255
+ * @returns Tableau de chaînes, vide si la valeur n'est pas applicable.
256
+ */
257
+ export function getStringListFlag(value) {
258
+ if (Array.isArray(value)) {
259
+ return value;
260
+ }
261
+ if (typeof value === "string") {
262
+ return [value];
263
+ }
264
+ return [];
265
+ }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { applyDetectedCommands } from "./agentRegistry.js";
4
5
  export const DEFAULT_CONFIG_PATH = "palabre.config.json";
5
6
  export const LEGACY_CONFIG_PATH = "chicane.config.json";
6
7
  export const CONFIG_DIR_NAME = ".palabre";
@@ -118,6 +119,30 @@ export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
118
119
  const raw = await readFile(resolved, "utf8");
119
120
  return JSON.parse(raw);
120
121
  }
122
+ /**
123
+ * Valide qu'une config chargée est exploitable pour lancer un débat.
124
+ *
125
+ * `loadConfig` se contente de parser le JSON ; cette garde attrape les configs
126
+ * structurellement cassées (racine non-objet, bloc `agents` absent ou vide)
127
+ * avant qu'elles ne provoquent un `TypeError` opaque dans l'orchestrateur.
128
+ * Volontairement minimale : la validation sémantique fine (agents par défaut
129
+ * inconnus, timeouts invalides, etc.) reste du ressort de `palabre doctor`.
130
+ *
131
+ * @throws {Error} message actionnable si la config ne peut pas faire tourner un débat.
132
+ */
133
+ export function assertRunnableConfig(config, messages, configPath = DEFAULT_CONFIG_PATH) {
134
+ const root = config;
135
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
136
+ throw new Error(messages.common.configInvalidShape(configPath));
137
+ }
138
+ const agents = root.agents;
139
+ if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
140
+ throw new Error(messages.common.configMissingAgents(configPath));
141
+ }
142
+ if (Object.keys(agents).length === 0) {
143
+ throw new Error(messages.common.configEmptyAgents(configPath));
144
+ }
145
+ }
121
146
  /** Retourne `true` si le fichier de config est accessible en lecture. Silencieux sur toute erreur filesystem. */
122
147
  export async function configExists(configPath = DEFAULT_CONFIG_PATH) {
123
148
  try {
@@ -156,26 +181,7 @@ export async function resolveDefaultConfigPath() {
156
181
  export function createConfigFromDiscovery(discovery) {
157
182
  const config = cloneConfig(exampleConfig);
158
183
  const pair = chooseDefaultPair(discovery);
159
- config.agents.codex = {
160
- ...config.agents.codex,
161
- ...(discovery.codex.available ? { command: discovery.codex.command } : {})
162
- };
163
- config.agents.claude = {
164
- ...config.agents.claude,
165
- ...(discovery.claude.available ? { command: discovery.claude.command } : {})
166
- };
167
- config.agents.gemini = {
168
- ...config.agents.gemini,
169
- ...(discovery.gemini.available ? { command: discovery.gemini.command } : {})
170
- };
171
- config.agents.antigravity = {
172
- ...config.agents.antigravity,
173
- ...(discovery.antigravity.available ? { command: discovery.antigravity.command } : {})
174
- };
175
- config.agents.opencode = {
176
- ...config.agents.opencode,
177
- ...(discovery.opencode.available ? { command: discovery.opencode.command } : {})
178
- };
184
+ applyDetectedCommands(config, discovery);
179
185
  const ollamaAgent = config.agents["ollama-local"];
180
186
  if (ollamaAgent?.type === "ollama") {
181
187
  ollamaAgent.model = chooseDefaultOllamaModel(discovery);