palabre 0.1.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 +377 -0
- package/dist/adapters/cli.js +250 -0
- package/dist/adapters/index.js +11 -0
- package/dist/adapters/ollama.js +218 -0
- package/dist/config.js +199 -0
- package/dist/configWizard.js +196 -0
- package/dist/context.js +198 -0
- package/dist/discovery.js +121 -0
- package/dist/doctor.js +341 -0
- package/dist/errors.js +49 -0
- package/dist/index.js +635 -0
- package/dist/limits.js +41 -0
- package/dist/new.js +312 -0
- package/dist/orchestrator.js +195 -0
- package/dist/output.js +100 -0
- package/dist/presets.js +98 -0
- package/dist/prompt.js +122 -0
- package/dist/renderers/console.js +171 -0
- package/dist/session.js +35 -0
- package/dist/types.js +1 -0
- package/dist/update.js +68 -0
- package/package.json +46 -0
- package/palabre.config.example.json +81 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
4
|
+
const MAX_TOTAL_BYTES = 192 * 1024;
|
|
5
|
+
const DEFAULT_EXCLUDED_NAMES = new Set([
|
|
6
|
+
".git",
|
|
7
|
+
".gitignore",
|
|
8
|
+
".tmp",
|
|
9
|
+
".pnpm-store",
|
|
10
|
+
"node_modules",
|
|
11
|
+
"dist"
|
|
12
|
+
]);
|
|
13
|
+
const TEXT_EXTENSIONS = new Set([
|
|
14
|
+
".cjs",
|
|
15
|
+
".css",
|
|
16
|
+
".cts",
|
|
17
|
+
".html",
|
|
18
|
+
".js",
|
|
19
|
+
".json",
|
|
20
|
+
".jsx",
|
|
21
|
+
".md",
|
|
22
|
+
".mjs",
|
|
23
|
+
".mts",
|
|
24
|
+
".toml",
|
|
25
|
+
".ts",
|
|
26
|
+
".tsx",
|
|
27
|
+
".txt",
|
|
28
|
+
".yaml",
|
|
29
|
+
".yml"
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Mode strict (`--files`) : charge uniquement les fichiers explicitement listés.
|
|
33
|
+
* Lève une erreur si un chemin est un dossier, un binaire, ou dépasse 64 KiB / 192 KiB au total.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadProjectFiles(paths, cwd = process.cwd()) {
|
|
36
|
+
const result = await loadProjectInputs(paths, [], cwd);
|
|
37
|
+
return result.files;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Combine le chargement strict (`--files`) et tolérant (`--context`).
|
|
41
|
+
* Les fichiers explicites sont chargés en premier et comptent dans le budget total.
|
|
42
|
+
* Les chemins de contexte acceptent fichiers et dossiers ; les fichiers ignorés génèrent des warnings, pas des erreurs.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadProjectInputs(filePaths, contextPaths, cwd = process.cwd()) {
|
|
45
|
+
const state = {
|
|
46
|
+
files: [],
|
|
47
|
+
warnings: [],
|
|
48
|
+
seen: new Set(),
|
|
49
|
+
totalBytes: 0,
|
|
50
|
+
gitignoreRules: await loadGitignoreRules(cwd)
|
|
51
|
+
};
|
|
52
|
+
await addExplicitFiles(filePaths, cwd, state);
|
|
53
|
+
await addContextPaths(contextPaths, cwd, state);
|
|
54
|
+
return {
|
|
55
|
+
files: state.files,
|
|
56
|
+
warnings: state.warnings
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function addExplicitFiles(paths, cwd, state) {
|
|
60
|
+
const uniquePaths = [...new Set(paths.map((item) => item.trim()).filter(Boolean))];
|
|
61
|
+
for (const inputPath of uniquePaths) {
|
|
62
|
+
const absolutePath = path.resolve(cwd, inputPath);
|
|
63
|
+
const fileStat = await stat(absolutePath);
|
|
64
|
+
if (!fileStat.isFile()) {
|
|
65
|
+
throw new Error(`Le contexte fichier doit pointer vers un fichier: ${inputPath}`);
|
|
66
|
+
}
|
|
67
|
+
if (fileStat.size > MAX_FILE_BYTES) {
|
|
68
|
+
throw new Error(`Fichier trop gros pour le contexte: ${inputPath} (${fileStat.size} bytes, max ${MAX_FILE_BYTES})`);
|
|
69
|
+
}
|
|
70
|
+
const content = await readFile(absolutePath, "utf8");
|
|
71
|
+
if (content.includes("\u0000")) {
|
|
72
|
+
throw new Error(`Fichier binaire ou non texte refuse: ${inputPath}`);
|
|
73
|
+
}
|
|
74
|
+
addFileToState(cwd, state, absolutePath, content, fileStat.size, "explicit");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function addContextPaths(paths, cwd, state) {
|
|
78
|
+
const uniquePaths = [...new Set(paths.map((item) => item.trim()).filter(Boolean))];
|
|
79
|
+
for (const inputPath of uniquePaths) {
|
|
80
|
+
const absolutePath = path.resolve(cwd, inputPath);
|
|
81
|
+
const fileStat = await stat(absolutePath);
|
|
82
|
+
if (fileStat.isFile()) {
|
|
83
|
+
await addContextFile(absolutePath, cwd, state);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!fileStat.isDirectory()) {
|
|
87
|
+
state.warnings.push(`Contexte ignore (ni fichier ni dossier): ${inputPath}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
await walkContextDirectory(absolutePath, cwd, state);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function walkContextDirectory(dir, cwd, state) {
|
|
94
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
95
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
96
|
+
const absolutePath = path.join(dir, entry.name);
|
|
97
|
+
const relativePath = normalizePath(path.relative(cwd, absolutePath));
|
|
98
|
+
if (shouldIgnore(relativePath, entry.name, state.gitignoreRules)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
await walkContextDirectory(absolutePath, cwd, state);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (entry.isFile()) {
|
|
106
|
+
await addContextFile(absolutePath, cwd, state);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function addContextFile(absolutePath, cwd, state) {
|
|
111
|
+
const relativePath = normalizePath(path.relative(cwd, absolutePath));
|
|
112
|
+
if (state.seen.has(absolutePath)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isLikelyTextFile(absolutePath)) {
|
|
116
|
+
state.warnings.push(`Contexte ignore (extension non texte): ${relativePath}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const fileStat = await stat(absolutePath);
|
|
120
|
+
if (fileStat.size > MAX_FILE_BYTES) {
|
|
121
|
+
state.warnings.push(`Contexte ignore (fichier trop gros): ${relativePath} (${fileStat.size} bytes)`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (state.totalBytes + fileStat.size > MAX_TOTAL_BYTES) {
|
|
125
|
+
state.warnings.push(`Contexte ignore (limite totale atteinte): ${relativePath}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const content = await readFile(absolutePath, "utf8");
|
|
129
|
+
if (content.includes("\u0000")) {
|
|
130
|
+
state.warnings.push(`Contexte ignore (binaire detecte): ${relativePath}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
addFileToState(cwd, state, absolutePath, content, fileStat.size, "context");
|
|
134
|
+
}
|
|
135
|
+
function addFileToState(cwd, state, absolutePath, content, sizeBytes, source) {
|
|
136
|
+
if (state.seen.has(absolutePath)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (state.totalBytes + sizeBytes > MAX_TOTAL_BYTES) {
|
|
140
|
+
if (source === "explicit") {
|
|
141
|
+
throw new Error(`Contexte fichiers trop gros (${state.totalBytes + sizeBytes} bytes, max ${MAX_TOTAL_BYTES})`);
|
|
142
|
+
}
|
|
143
|
+
state.warnings.push(`Contexte ignore (limite totale atteinte): ${normalizePath(path.relative(cwd, absolutePath))}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
state.seen.add(absolutePath);
|
|
147
|
+
state.totalBytes += sizeBytes;
|
|
148
|
+
state.files.push({
|
|
149
|
+
path: normalizePath(path.relative(cwd, absolutePath)),
|
|
150
|
+
absolutePath,
|
|
151
|
+
content,
|
|
152
|
+
sizeBytes
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async function loadGitignoreRules(cwd) {
|
|
156
|
+
try {
|
|
157
|
+
const content = await readFile(path.resolve(cwd, ".gitignore"), "utf8");
|
|
158
|
+
return content
|
|
159
|
+
.split(/\r?\n/)
|
|
160
|
+
.map((line) => line.trim())
|
|
161
|
+
.filter((line) => line && !line.startsWith("#") && !line.startsWith("!"));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function shouldIgnore(relativePath, basename, gitignoreRules) {
|
|
168
|
+
if (DEFAULT_EXCLUDED_NAMES.has(basename)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return gitignoreRules.some((rule) => matchesGitignoreRule(relativePath, basename, rule));
|
|
172
|
+
}
|
|
173
|
+
function matchesGitignoreRule(relativePath, basename, rule) {
|
|
174
|
+
const normalizedRule = normalizePath(rule).replace(/\/$/, "");
|
|
175
|
+
if (normalizedRule.includes("*")) {
|
|
176
|
+
const pattern = `^${globToRegex(normalizedRule)}$`;
|
|
177
|
+
return new RegExp(pattern).test(relativePath) || new RegExp(pattern).test(basename);
|
|
178
|
+
}
|
|
179
|
+
return relativePath === normalizedRule ||
|
|
180
|
+
relativePath.startsWith(`${normalizedRule}/`) ||
|
|
181
|
+
basename === normalizedRule;
|
|
182
|
+
}
|
|
183
|
+
function isLikelyTextFile(filePath) {
|
|
184
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
185
|
+
return TEXT_EXTENSIONS.has(extension);
|
|
186
|
+
}
|
|
187
|
+
function normalizePath(value) {
|
|
188
|
+
return value.replace(/\\/g, "/");
|
|
189
|
+
}
|
|
190
|
+
function escapeRegex(value) {
|
|
191
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
+
}
|
|
193
|
+
function globToRegex(value) {
|
|
194
|
+
return value
|
|
195
|
+
.split("*")
|
|
196
|
+
.map((part) => escapeRegex(part))
|
|
197
|
+
.join(".*");
|
|
198
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Détecte en parallèle toutes les CLIs supportées et le serveur Ollama local.
|
|
5
|
+
* Sur Windows, tente `claude.exe` avant `claude`.
|
|
6
|
+
*/
|
|
7
|
+
export async function discoverLocalTools() {
|
|
8
|
+
const [codex, claude, gemini, opencode, ollamaCommand] = await Promise.all([
|
|
9
|
+
detectCommand("codex"),
|
|
10
|
+
detectFirstCommand(process.platform === "win32" ? ["claude.exe", "claude"] : ["claude"]),
|
|
11
|
+
detectCommand("gemini"),
|
|
12
|
+
detectCommand("opencode"),
|
|
13
|
+
detectCommand("ollama")
|
|
14
|
+
]);
|
|
15
|
+
const ollamaServer = await detectOllamaServer();
|
|
16
|
+
return {
|
|
17
|
+
codex,
|
|
18
|
+
claude,
|
|
19
|
+
gemini,
|
|
20
|
+
opencode,
|
|
21
|
+
ollama: {
|
|
22
|
+
...ollamaServer,
|
|
23
|
+
commandAvailable: ollamaCommand.available
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function detectFirstCommand(commands) {
|
|
28
|
+
for (const command of commands) {
|
|
29
|
+
const detection = await detectCommand(command);
|
|
30
|
+
if (detection.available) {
|
|
31
|
+
return detection;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
available: false,
|
|
36
|
+
command: commands[0] ?? ""
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function detectCommand(command) {
|
|
40
|
+
const executablePath = await findExecutable(command);
|
|
41
|
+
return {
|
|
42
|
+
available: Boolean(executablePath),
|
|
43
|
+
command,
|
|
44
|
+
...(executablePath ? { path: executablePath } : {})
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async function detectOllamaServer(baseUrl = "http://localhost:11434") {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timeout = setTimeout(() => controller.abort(), 2_000);
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`${baseUrl}/api/tags`, {
|
|
52
|
+
signal: controller.signal
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
return {
|
|
56
|
+
available: false,
|
|
57
|
+
baseUrl,
|
|
58
|
+
models: [],
|
|
59
|
+
error: `HTTP ${response.status}`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
const models = data.models
|
|
64
|
+
?.map((model) => model.name ?? model.model)
|
|
65
|
+
.filter((model) => Boolean(model))
|
|
66
|
+
.sort((a, b) => a.localeCompare(b)) ?? [];
|
|
67
|
+
return {
|
|
68
|
+
available: true,
|
|
69
|
+
baseUrl,
|
|
70
|
+
models
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
available: false,
|
|
76
|
+
baseUrl,
|
|
77
|
+
models: [],
|
|
78
|
+
error: error instanceof Error ? error.message : String(error)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function findExecutable(command) {
|
|
86
|
+
const pathEntries = (process.env.PATH ?? "")
|
|
87
|
+
.split(path.delimiter)
|
|
88
|
+
.map((entry) => entry.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
const extensions = executableExtensions(command);
|
|
91
|
+
for (const entry of pathEntries) {
|
|
92
|
+
for (const extension of extensions) {
|
|
93
|
+
const candidate = path.join(entry, `${command}${extension}`);
|
|
94
|
+
if (await isAccessible(candidate)) {
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
function executableExtensions(command) {
|
|
102
|
+
if (path.extname(command)) {
|
|
103
|
+
return [""];
|
|
104
|
+
}
|
|
105
|
+
if (process.platform !== "win32") {
|
|
106
|
+
return [""];
|
|
107
|
+
}
|
|
108
|
+
return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
109
|
+
.split(";")
|
|
110
|
+
.map((extension) => extension.toLowerCase())
|
|
111
|
+
.concat(".ps1", "");
|
|
112
|
+
}
|
|
113
|
+
async function isAccessible(filePath) {
|
|
114
|
+
try {
|
|
115
|
+
await access(filePath);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { configExists, loadConfig, resolveDefaultConfigPath } from "./config.js";
|
|
4
|
+
import { discoverLocalTools } from "./discovery.js";
|
|
5
|
+
import { DEFAULT_TURNS, MAX_TURNS } from "./limits.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exécute le diagnostic complet : config, outils locaux et agents.
|
|
8
|
+
* Retourne toujours un résultat (pas de throw) ; les erreurs de config sont reportées comme lignes `error`.
|
|
9
|
+
*/
|
|
10
|
+
export async function runDoctor(explicitConfigPath, plain = false) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
const configPath = explicitConfigPath ?? await resolveDefaultConfigPath();
|
|
13
|
+
const hasConfig = await configExists(configPath);
|
|
14
|
+
lines.push(info("PALABRE doctor"));
|
|
15
|
+
lines.push(info(`Dossier courant: ${process.cwd()}`));
|
|
16
|
+
lines.push(hasConfig
|
|
17
|
+
? ok(`Config trouvee: ${configPath}`)
|
|
18
|
+
: error(`Config absente: ${configPath}`));
|
|
19
|
+
if (!hasConfig) {
|
|
20
|
+
lines.push(info("Action: lance `palabre init` pour creer la config globale, ou `palabre init --local` pour une config projet."));
|
|
21
|
+
return render(lines, plain);
|
|
22
|
+
}
|
|
23
|
+
const config = await loadConfigSafely(configPath, lines);
|
|
24
|
+
if (!config) {
|
|
25
|
+
return render(lines, plain);
|
|
26
|
+
}
|
|
27
|
+
await inspectConfig(config, lines);
|
|
28
|
+
const discovery = await discoverLocalTools();
|
|
29
|
+
lines.push(info("Outils locaux:"));
|
|
30
|
+
lines.push(formatCommand("Codex CLI", discovery.codex.available, discovery.codex.command, discovery.codex.path));
|
|
31
|
+
lines.push(formatCommand("Claude CLI", discovery.claude.available, discovery.claude.command, discovery.claude.path));
|
|
32
|
+
lines.push(formatCommand("Gemini CLI", discovery.gemini.available, discovery.gemini.command, discovery.gemini.path));
|
|
33
|
+
lines.push(formatCommand("OpenCode CLI", discovery.opencode.available, discovery.opencode.command, discovery.opencode.path));
|
|
34
|
+
lines.push(discovery.ollama.available
|
|
35
|
+
? ok(`Ollama API joignable: ${discovery.ollama.baseUrl} (${discovery.ollama.models.length} modele(s))`)
|
|
36
|
+
: warn(discovery.ollama.commandAvailable
|
|
37
|
+
? `Ollama installe mais API non joignable: ${discovery.ollama.baseUrl}${formatErrorSuffix(discovery.ollama.error)}`
|
|
38
|
+
: `Ollama non detecte et API non joignable: ${discovery.ollama.baseUrl}${formatErrorSuffix(discovery.ollama.error)}`));
|
|
39
|
+
inspectDetectedMissingAgents(config, discovery, lines);
|
|
40
|
+
inspectAgents(config, discovery, lines);
|
|
41
|
+
return render(lines, plain);
|
|
42
|
+
}
|
|
43
|
+
async function loadConfigSafely(configPath, lines) {
|
|
44
|
+
try {
|
|
45
|
+
const config = await loadConfig(configPath);
|
|
46
|
+
lines.push(ok("Config JSON lisible."));
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
catch (loadError) {
|
|
50
|
+
const message = loadError instanceof Error ? loadError.message : String(loadError);
|
|
51
|
+
lines.push(error(`Config illisible: ${message}`));
|
|
52
|
+
lines.push(info("Action: corrige le JSON ou relance `palabre init --config <path>` vers un nouveau fichier."));
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function inspectConfig(config, lines) {
|
|
57
|
+
const agentNames = Object.keys(config.agents ?? {});
|
|
58
|
+
if (agentNames.length === 0) {
|
|
59
|
+
lines.push(error("Aucun agent configure."));
|
|
60
|
+
}
|
|
61
|
+
else if (agentNames.length === 1) {
|
|
62
|
+
lines.push(warn(`1 agent configure: ${agentNames[0]}. Palabre fonctionne mieux avec au moins deux agents.`));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
lines.push(ok(`${agentNames.length} agent(s) configure(s): ${agentNames.join(", ")}`));
|
|
66
|
+
}
|
|
67
|
+
inspectDefaultAgent("defaults.agentA", config.defaults?.agentA, config, lines);
|
|
68
|
+
inspectDefaultAgent("defaults.agentB", config.defaults?.agentB, config, lines);
|
|
69
|
+
inspectDefaultPair(config, lines);
|
|
70
|
+
inspectDefaultTurns(config.defaults?.turns, lines);
|
|
71
|
+
if (config.defaults?.summaryAgent) {
|
|
72
|
+
inspectDefaultAgent("defaults.summaryAgent", config.defaults.summaryAgent, config, lines);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lines.push(warn("defaults.summaryAgent absent: la synthese utilisera agentB."));
|
|
76
|
+
}
|
|
77
|
+
await inspectOutputDir(config.outputDir, lines);
|
|
78
|
+
}
|
|
79
|
+
function inspectDefaultAgent(label, agentName, config, lines) {
|
|
80
|
+
if (!agentName) {
|
|
81
|
+
lines.push(warn(`${label} absent.`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!config.agents[agentName]) {
|
|
85
|
+
lines.push(error(`${label} pointe vers un agent inconnu: ${agentName}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
lines.push(ok(`${label}: ${agentName}`));
|
|
89
|
+
}
|
|
90
|
+
function inspectDefaultPair(config, lines) {
|
|
91
|
+
const { agentA, agentB } = config.defaults ?? {};
|
|
92
|
+
if (!agentA || !agentB) {
|
|
93
|
+
lines.push(warn("Paire par defaut incomplete. Action: `palabre config --set-defaults <agentA> <agentB>`."));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (agentA === agentB) {
|
|
97
|
+
lines.push(warn(`defaults.agentA et defaults.agentB pointent vers le meme agent (${agentA}). C'est possible, mais souvent moins utile qu'une vraie paire.`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function inspectDefaultTurns(turns, lines) {
|
|
101
|
+
const value = turns ?? DEFAULT_TURNS;
|
|
102
|
+
if (turns === undefined) {
|
|
103
|
+
lines.push(info(`defaults.turns absent: Palabre utilisera ${DEFAULT_TURNS} reponses.`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_TURNS) {
|
|
107
|
+
lines.push(error(`defaults.turns invalide: ${String(turns)}. Action: choisis un entier entre 1 et ${MAX_TURNS}.`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
lines.push(ok(`defaults.turns: ${value}`));
|
|
111
|
+
}
|
|
112
|
+
async function inspectOutputDir(outputDir, lines) {
|
|
113
|
+
const resolved = path.resolve(outputDir ?? ".");
|
|
114
|
+
if (!outputDir) {
|
|
115
|
+
lines.push(info(`outputDir absent: les exports seront ecrits dans le dossier courant (${resolved}).`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const stats = await stat(resolved);
|
|
120
|
+
if (!stats.isDirectory()) {
|
|
121
|
+
lines.push(error(`outputDir pointe vers un fichier, pas un dossier: ${resolved}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
lines.push(ok(`outputDir configure: ${resolved}`));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
lines.push(warn(`outputDir n'existe pas encore: ${resolved}. Palabre tentera de le creer au moment de l'export.`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function inspectDetectedMissingAgents(config, discovery, lines) {
|
|
131
|
+
const missing = detectedAgentNames(discovery).filter((name) => !config.agents[name]);
|
|
132
|
+
if (missing.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
lines.push(warn(`Agent(s) detecte(s) mais absent(s) de la config: ${missing.join(", ")}. Action: lance ` + "`palabre config --sync-agents`."));
|
|
136
|
+
}
|
|
137
|
+
function inspectAgents(config, discovery, lines) {
|
|
138
|
+
lines.push(info("Agents configures:"));
|
|
139
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
140
|
+
inspectAgentShape(name, agent, lines);
|
|
141
|
+
if (agent.type === "cli") {
|
|
142
|
+
inspectCliAgent(name, agent, discovery, lines);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
inspectOllamaAgent(name, agent, discovery, lines);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function inspectAgentShape(name, agent, lines) {
|
|
149
|
+
if (!agent.role) {
|
|
150
|
+
lines.push(error(`${name}: role absent.`));
|
|
151
|
+
}
|
|
152
|
+
if (agent.type === "cli") {
|
|
153
|
+
if (!agent.command || !agent.command.trim()) {
|
|
154
|
+
lines.push(error(`${name}: command CLI absent.`));
|
|
155
|
+
}
|
|
156
|
+
if (agent.promptMode && !["stdin", "argument"].includes(agent.promptMode)) {
|
|
157
|
+
lines.push(error(`${name}: promptMode invalide (${agent.promptMode}). Valeurs attendues: stdin ou argument.`));
|
|
158
|
+
}
|
|
159
|
+
if (agent.timeoutMs !== undefined && (!Number.isFinite(agent.timeoutMs) || agent.timeoutMs <= 0)) {
|
|
160
|
+
lines.push(error(`${name}: timeoutMs doit etre un nombre positif.`));
|
|
161
|
+
}
|
|
162
|
+
if (agent.idleTimeoutMs !== undefined && (!Number.isFinite(agent.idleTimeoutMs) || agent.idleTimeoutMs <= 0)) {
|
|
163
|
+
lines.push(error(`${name}: idleTimeoutMs doit etre un nombre positif.`));
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!agent.model || !agent.model.trim()) {
|
|
168
|
+
lines.push(error(`${name}: modele Ollama absent.`));
|
|
169
|
+
}
|
|
170
|
+
if (agent.baseUrl && !/^https?:\/\//.test(agent.baseUrl)) {
|
|
171
|
+
lines.push(error(`${name}: baseUrl Ollama invalide (${agent.baseUrl}). Attendu: http://... ou https://...`));
|
|
172
|
+
}
|
|
173
|
+
if (agent.timeoutMs !== undefined && (!Number.isFinite(agent.timeoutMs) || agent.timeoutMs <= 0)) {
|
|
174
|
+
lines.push(error(`${name}: timeoutMs doit etre un nombre positif.`));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function inspectCliAgent(name, agent, discovery, lines) {
|
|
178
|
+
const known = knownCliDetection(agent.command, discovery);
|
|
179
|
+
const prefix = `${name} [cli:${agent.role}] command=${agent.command}`;
|
|
180
|
+
if (!known) {
|
|
181
|
+
lines.push(info(`${prefix} (commande custom non verifiee par doctor)`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
lines.push(known.available
|
|
185
|
+
? ok(`${prefix} detectee (${known.path ?? known.command})`)
|
|
186
|
+
: warn(`${prefix} non detectee dans PATH. Action: installe/authentifie la CLI ou corrige command dans la config.`));
|
|
187
|
+
}
|
|
188
|
+
function inspectOllamaAgent(name, agent, discovery, lines) {
|
|
189
|
+
const prefix = `${name} [ollama:${agent.role}] model=${agent.model}`;
|
|
190
|
+
if (!discovery.ollama.available) {
|
|
191
|
+
lines.push(warn(`${prefix} non verifiable: API Ollama non joignable. Action: demarre Ollama ou corrige baseUrl.`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (agent.validateModel === false) {
|
|
195
|
+
lines.push(info(`${prefix} non valide car validateModel=false.`));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const installed = discovery.ollama.models.includes(agent.model);
|
|
199
|
+
lines.push(installed
|
|
200
|
+
? ok(`${prefix} installe.`)
|
|
201
|
+
: warn(`${prefix} absent. Action: lance ` + "`ollama pull " + agent.model + "`" + " ou utilise `--pull-models`."));
|
|
202
|
+
}
|
|
203
|
+
function detectedAgentNames(discovery) {
|
|
204
|
+
return [
|
|
205
|
+
discovery.codex.available ? "codex" : undefined,
|
|
206
|
+
discovery.claude.available ? "claude" : undefined,
|
|
207
|
+
discovery.gemini.available ? "gemini" : undefined,
|
|
208
|
+
discovery.opencode.available ? "opencode" : undefined,
|
|
209
|
+
discovery.ollama.available ? "ollama-local" : undefined
|
|
210
|
+
].filter((name) => Boolean(name));
|
|
211
|
+
}
|
|
212
|
+
function formatCommand(label, available, command, resolvedPath) {
|
|
213
|
+
return available
|
|
214
|
+
? ok(`${label}: detecte (${resolvedPath ?? command})`)
|
|
215
|
+
: warn(`${label}: non detecte dans PATH.`);
|
|
216
|
+
}
|
|
217
|
+
function knownCliDetection(command, discovery) {
|
|
218
|
+
const normalized = path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/i, "");
|
|
219
|
+
if (normalized === "codex")
|
|
220
|
+
return discovery.codex;
|
|
221
|
+
if (normalized === "claude")
|
|
222
|
+
return discovery.claude;
|
|
223
|
+
if (normalized === "gemini")
|
|
224
|
+
return discovery.gemini;
|
|
225
|
+
if (normalized === "opencode")
|
|
226
|
+
return discovery.opencode;
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
function render(lines, plain) {
|
|
230
|
+
const hasErrors = lines.some((line) => line.level === "error");
|
|
231
|
+
return {
|
|
232
|
+
ok: !hasErrors,
|
|
233
|
+
output: plain ? renderPlain(lines) : renderPretty(lines)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function renderPlain(lines) {
|
|
237
|
+
return lines.map(formatLine).join("\n");
|
|
238
|
+
}
|
|
239
|
+
function renderPretty(lines) {
|
|
240
|
+
const configLines = [];
|
|
241
|
+
const toolLines = [];
|
|
242
|
+
const agentLines = [];
|
|
243
|
+
const actionLines = [];
|
|
244
|
+
let current = "config";
|
|
245
|
+
let cwd = process.cwd();
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
if (line.text === "PALABRE doctor")
|
|
248
|
+
continue;
|
|
249
|
+
if (line.text.startsWith("Dossier courant: ")) {
|
|
250
|
+
cwd = line.text.replace("Dossier courant: ", "");
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (line.text === "Outils locaux:") {
|
|
254
|
+
current = "tools";
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (line.text === "Agents configures:") {
|
|
258
|
+
current = "agents";
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (line.level === "error" || line.level === "warn") {
|
|
262
|
+
actionLines.push(line);
|
|
263
|
+
}
|
|
264
|
+
if (current === "tools") {
|
|
265
|
+
toolLines.push(line);
|
|
266
|
+
}
|
|
267
|
+
else if (current === "agents") {
|
|
268
|
+
agentLines.push(line);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
configLines.push(line);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const errorCount = lines.filter((line) => line.level === "error").length;
|
|
275
|
+
const warnCount = lines.filter((line) => line.level === "warn").length;
|
|
276
|
+
const status = errorCount > 0
|
|
277
|
+
? `${errorCount} erreur(s), ${warnCount} avertissement(s)`
|
|
278
|
+
: warnCount > 0 ? `${warnCount} avertissement(s)` : "OK";
|
|
279
|
+
return [
|
|
280
|
+
...renderDoctorHeader(status),
|
|
281
|
+
"",
|
|
282
|
+
...renderSection("Configuration", [info(`Dossier courant: ${cwd}`), ...configLines]),
|
|
283
|
+
"",
|
|
284
|
+
...renderSection("Outils locaux", toolLines),
|
|
285
|
+
"",
|
|
286
|
+
...renderSection("Agents", agentLines),
|
|
287
|
+
...(actionLines.length > 0 ? ["", ...renderSection("A verifier", actionLines)] : []),
|
|
288
|
+
""
|
|
289
|
+
].join("\n");
|
|
290
|
+
}
|
|
291
|
+
function renderDoctorHeader(status) {
|
|
292
|
+
const title = "PALABRE doctor";
|
|
293
|
+
return [
|
|
294
|
+
`┌─ ${title} ${"─".repeat(Math.max(1, 58 - title.length))}`,
|
|
295
|
+
`│ Statut: ${status}`,
|
|
296
|
+
`└${"─".repeat(73)}`
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
function renderSection(title, lines) {
|
|
300
|
+
if (lines.length === 0) {
|
|
301
|
+
return [title, " INFO Rien a afficher."];
|
|
302
|
+
}
|
|
303
|
+
return [
|
|
304
|
+
title,
|
|
305
|
+
"─".repeat(Math.max(16, title.length + 8)),
|
|
306
|
+
...lines.map((line) => ` ${formatPrettyLine(line)}`)
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
function formatPrettyLine(line) {
|
|
310
|
+
const labels = {
|
|
311
|
+
ok: "OK ",
|
|
312
|
+
warn: "WARN ",
|
|
313
|
+
error: "ERREUR",
|
|
314
|
+
info: "INFO "
|
|
315
|
+
};
|
|
316
|
+
return `${labels[line.level]} ${line.text}`;
|
|
317
|
+
}
|
|
318
|
+
function formatLine(line) {
|
|
319
|
+
const labels = {
|
|
320
|
+
ok: "OK",
|
|
321
|
+
warn: "WARN",
|
|
322
|
+
error: "ERREUR",
|
|
323
|
+
info: "INFO"
|
|
324
|
+
};
|
|
325
|
+
return `[${labels[line.level]}] ${line.text}`;
|
|
326
|
+
}
|
|
327
|
+
function ok(text) {
|
|
328
|
+
return { level: "ok", text };
|
|
329
|
+
}
|
|
330
|
+
function warn(text) {
|
|
331
|
+
return { level: "warn", text };
|
|
332
|
+
}
|
|
333
|
+
function error(text) {
|
|
334
|
+
return { level: "error", text };
|
|
335
|
+
}
|
|
336
|
+
function info(text) {
|
|
337
|
+
return { level: "info", text };
|
|
338
|
+
}
|
|
339
|
+
function formatErrorSuffix(errorMessage) {
|
|
340
|
+
return errorMessage ? ` (${errorMessage})` : "";
|
|
341
|
+
}
|