palabre 0.6.0 → 0.6.3

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
@@ -50,6 +50,7 @@ palabre -s "Compare ces deux approches" -t 2
50
50
  palabre codex-claude "Relis cette architecture" --context src docs
51
51
  palabre claude-ollama "Critique ce fichier" --files README.md
52
52
  palabre codex-claude "Preview" --context src --show-prompt
53
+ palabre context scan src docs --json
53
54
  ```
54
55
 
55
56
  ### Agents supportés
@@ -63,10 +64,22 @@ palabre codex-claude "Preview" --context src --show-prompt
63
64
 
64
65
  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é.
65
66
 
67
+ ### Intégrations
68
+
69
+ PALABRE expose des sorties JSON versionnées pour les clients externes :
70
+
71
+ - `palabre presets --json` pour lire les paires d'agents disponibles ;
72
+ - `palabre context scan --json` pour prévisualiser le contexte que `--context` retiendrait ;
73
+ - `--renderer ndjson` ou `--json` pour suivre un débat événement par événement.
74
+
75
+ Le flux NDJSON v1 est traité comme une API publique d'intégration. Les ajouts compatibles se font sans casser v1 ; les changements cassants doivent changer le champ `v`.
76
+
66
77
  ### Confidentialité
67
78
 
68
79
  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, Ollama ou de tout autre agent configuré.
69
80
 
81
+ 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.
82
+
70
83
  ### Développement local
71
84
 
72
85
  ```bash
@@ -80,7 +93,9 @@ palabre --version
80
93
 
81
94
  Commandes utiles : `pnpm check`, `pnpm test`, `pnpm build`.
82
95
 
83
- 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).
84
99
 
85
100
  ### Licence
86
101
 
@@ -97,7 +112,7 @@ It does not replace your tools: it drives them. You keep your subscriptions, def
97
112
  - https://palab.re
98
113
  - https://palabre.netlify.app
99
114
 
100
- Useful pages: [Installation](https://palab.re/fr/get-started/installation), [Configuration](https://palab.re/fr/get-started/configuration), [First debate](https://palab.re/fr/get-started/first-debate), [CLI reference](https://palab.re/fr/reference/cli), [Troubleshooting](https://palab.re/fr/troubleshooting), [Roadmap](https://palab.re/fr/roadmap).
115
+ Useful pages: [Installation](https://palab.re/en/get-started/installation), [Configuration](https://palab.re/en/get-started/configuration), [First debate](https://palab.re/en/get-started/first-debate), [CLI reference](https://palab.re/en/reference/cli), [Troubleshooting](https://palab.re/en/troubleshooting), [Roadmap](https://palab.re/en/roadmap).
101
116
 
102
117
  ### Installation
103
118
 
@@ -125,6 +140,7 @@ palabre -s "Compare these two approaches" -t 2
125
140
  palabre codex-claude "Review this architecture" --context src docs
126
141
  palabre claude-ollama "Review this file" --files README.md
127
142
  palabre codex-claude "Preview" --context src --show-prompt
143
+ palabre context scan src docs --json
128
144
  ```
129
145
 
130
146
  ### Supported Agents
@@ -138,10 +154,22 @@ palabre codex-claude "Preview" --context src --show-prompt
138
154
 
139
155
  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.
140
156
 
157
+ ### Integrations
158
+
159
+ PALABRE exposes versioned JSON outputs for external clients:
160
+
161
+ - `palabre presets --json` to read available agent pairs;
162
+ - `palabre context scan --json` to preview the context `--context` would retain;
163
+ - `--renderer ndjson` or `--json` to follow a debate event by event.
164
+
165
+ The NDJSON v1 stream is treated as a public integration API. Compatible additions do not break v1; breaking changes must change the `v` field.
166
+
141
167
  ### Privacy
142
168
 
143
169
  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, Ollama, or any custom agent you configure.
144
170
 
171
+ If an agent fails during the debate or final summary, PALABRE keeps the partial Markdown export with an interruption section whenever possible.
172
+
145
173
  ### Local Development
146
174
 
147
175
  ```bash
@@ -155,7 +183,9 @@ palabre --version
155
183
 
156
184
  Useful commands: `pnpm check`, `pnpm test`, `pnpm build`.
157
185
 
158
- 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).
159
189
 
160
190
  ### License
161
191
 
@@ -1,8 +1,9 @@
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
8
  /**
8
9
  * Adapter pour les CLIs qui exigent un vrai terminal.
@@ -43,13 +44,16 @@ export class CliPtyAdapter {
43
44
  const args = promptMode === "argument"
44
45
  ? [...baseArgs, renderedPrompt]
45
46
  : baseArgs;
47
+ const { spawn: spawnPty } = await import("node-pty");
46
48
  return new Promise((resolve, reject) => {
47
49
  let output = "";
50
+ let outputBytes = 0;
48
51
  let settled = false;
49
52
  let hardTimer;
50
53
  let term;
51
54
  let dataSubscription;
52
55
  let exitSubscription;
56
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
53
57
  const finish = (error, exitCode, kill = true) => {
54
58
  if (settled)
55
59
  return;
@@ -104,11 +108,19 @@ export class CliPtyAdapter {
104
108
  return;
105
109
  }
106
110
  hardTimer = setTimeout(() => {
107
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
108
- 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
109
113
  }));
110
- }, this.config.timeoutMs ?? 180_000);
114
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
111
115
  dataSubscription = term.onData((chunk) => {
116
+ outputBytes += Buffer.byteLength(chunk, "utf8");
117
+ if (outputBytes > maxOutputBytes) {
118
+ finish(new AdapterError("output-too-large", this.name, `${this.name} produced more than ${maxOutputBytes} bytes of PTY output`, {
119
+ maxOutputBytes,
120
+ outputBytes
121
+ }));
122
+ return;
123
+ }
112
124
  output += chunk;
113
125
  });
114
126
  exitSubscription = term.onExit(({ exitCode }) => {
@@ -147,30 +159,6 @@ function cleanupPty(term) {
147
159
  // Best-effort cleanup for Windows ConPTY internals.
148
160
  }
149
161
  }
150
- function executableExtensions(command) {
151
- if (path.extname(command) || process.platform !== "win32") {
152
- return [""];
153
- }
154
- return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
155
- .split(";")
156
- .map((extension) => extension.toLowerCase())
157
- .concat(".ps1", "");
158
- }
159
- function withModelArgs(args, model, modelArg) {
160
- if (!model) {
161
- return [...args];
162
- }
163
- const promptStdinIndex = args.lastIndexOf("-");
164
- if (promptStdinIndex === args.length - 1) {
165
- return [
166
- ...args.slice(0, promptStdinIndex),
167
- modelArg,
168
- model,
169
- ...args.slice(promptStdinIndex)
170
- ];
171
- }
172
- return [...args, modelArg, model];
173
- }
174
162
  function createPtyExitError(adapterName, exitCode, raw) {
175
163
  return new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizePtyOutput(raw)}`, {
176
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,6 +1,7 @@
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
6
  /**
6
7
  * Adapter pour les CLIs batch (Codex, Claude, Gemini…).
@@ -50,8 +51,10 @@ export class CliAdapter {
50
51
  let stdout = "";
51
52
  let stderr = "";
52
53
  let settled = false;
54
+ let outputBytes = 0;
53
55
  let hardTimer;
54
56
  let idleTimer;
57
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
55
58
  const finish = (error) => {
56
59
  if (settled)
57
60
  return;
@@ -78,10 +81,10 @@ export class CliAdapter {
78
81
  };
79
82
  hardTimer = setTimeout(() => {
80
83
  child.kill();
81
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
82
- timeoutMs: this.config.timeoutMs ?? 180_000
84
+ finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
85
+ timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
83
86
  }));
84
- }, this.config.timeoutMs ?? 180_000);
87
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
85
88
  const bumpIdleTimer = () => {
86
89
  if (!this.config.idleTimeoutMs)
87
90
  return;
@@ -94,10 +97,28 @@ export class CliAdapter {
94
97
  };
95
98
  bumpIdleTimer();
96
99
  child.stdout.on("data", (chunk) => {
100
+ outputBytes += chunk.length;
101
+ if (outputBytes > maxOutputBytes) {
102
+ child.kill();
103
+ finish(new AdapterError("output-too-large", this.name, `${this.name} produced more than ${maxOutputBytes} bytes of output`, {
104
+ maxOutputBytes,
105
+ outputBytes
106
+ }));
107
+ return;
108
+ }
97
109
  stdout += chunk.toString("utf8");
98
110
  bumpIdleTimer();
99
111
  });
100
112
  child.stderr.on("data", (chunk) => {
113
+ outputBytes += chunk.length;
114
+ if (outputBytes > maxOutputBytes) {
115
+ child.kill();
116
+ finish(new AdapterError("output-too-large", this.name, `${this.name} produced more than ${maxOutputBytes} bytes of output`, {
117
+ maxOutputBytes,
118
+ outputBytes
119
+ }));
120
+ return;
121
+ }
101
122
  stderr += chunk.toString("utf8");
102
123
  bumpIdleTimer();
103
124
  });
@@ -123,28 +144,45 @@ export class CliAdapter {
123
144
  });
124
145
  }
125
146
  }
126
- /**
127
- * Insère `modelArg model` dans la liste d'arguments.
128
- * Si le dernier argument est `-` (stdin marker), insère avant lui pour préserver l'ordre attendu par les CLIs.
129
- */
130
- function withModelArgs(args, model, modelArg) {
131
- if (!model) {
132
- return [...args];
133
- }
134
- const promptStdinIndex = args.lastIndexOf("-");
135
- if (promptStdinIndex === args.length - 1) {
136
- return [
137
- ...args.slice(0, promptStdinIndex),
138
- modelArg,
139
- model,
140
- ...args.slice(promptStdinIndex)
141
- ];
142
- }
143
- return [...args, modelArg, model];
144
- }
145
147
  /** Retire les séquences ANSI et les espaces en tête/fin. */
146
148
  function cleanCliOutput(output) {
147
- return cleanTerminalOutput(output);
149
+ return stripWindowsTaskkillNoise(cleanTerminalOutput(output));
150
+ }
151
+ function stripWindowsTaskkillNoise(output) {
152
+ const lines = output.split("\n");
153
+ const kept = [];
154
+ let skipNextFrenchContinuation = false;
155
+ for (const line of lines) {
156
+ const trimmed = line.trim();
157
+ const normalized = normalizeForWindowsStatus(trimmed);
158
+ if (skipNextFrenchContinuation && /^arr.*t.*\.$/i.test(normalized)) {
159
+ skipNextFrenchContinuation = false;
160
+ continue;
161
+ }
162
+ skipNextFrenchContinuation = false;
163
+ if (isWindowsTaskkillStatusLine(trimmed)) {
164
+ skipNextFrenchContinuation = true;
165
+ continue;
166
+ }
167
+ kept.push(line);
168
+ }
169
+ return kept.join("\n").trim();
170
+ }
171
+ function isWindowsTaskkillStatusLine(line) {
172
+ const normalized = normalizeForWindowsStatus(line);
173
+ const lower = line.toLowerCase();
174
+ return (/^SUCCESS:\s+The process with PID \d+ .* has been terminated\.$/i.test(line) ||
175
+ /^operation reussie.*processus de pid \d+ .* a ete$/.test(normalized) ||
176
+ (lower.startsWith("op") &&
177
+ lower.includes("processus de pid ") &&
178
+ lower.includes("processus enfant de pid") &&
179
+ lower.includes(" a ")));
180
+ }
181
+ function normalizeForWindowsStatus(line) {
182
+ return line
183
+ .normalize("NFD")
184
+ .replace(/\p{Diacritic}/gu, "")
185
+ .toLowerCase();
148
186
  }
149
187
  /**
150
188
  * Construit une `AdapterError` typée depuis un exit code non nul.
@@ -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
+ }