MagicCode/packages/cli/src/index.ts

426 lines
15 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
import { homedir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import {
2026-10-12 23:23:59 +00:00
SessionManager,
SessionStore,
2026-10-11 00:11:20 +00:00
createSession,
2026-10-12 23:23:59 +00:00
ensureMaiaHome,
2026-10-11 00:11:20 +00:00
findSkill,
2026-10-12 23:23:59 +00:00
normalizeSessionName,
parseShellInvocation,
2026-10-12 23:23:59 +00:00
resolveFileReferences,
2026-10-11 00:11:20 +00:00
parseSkillInvocation,
runTurn,
type AgentSession,
2026-10-12 23:23:59 +00:00
type Message,
} from "@maia/core";
import { initializeAgent } from "@maia/agent";
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
import { InkUI } from "@maia/ui";
2026-10-11 00:11:20 +00:00
import { resolveConfig } from "./config";
import { resolveLaunchDir } from "./launch-dir";
import { runUserShell } from "./user-shell";
2026-10-11 00:11:20 +00:00
2026-10-12 23:23:59 +00:00
const REPLAY_RESULT_MAX = 600;
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints
/** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */
function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void {
const toolNames = new Map<string, string>();
for (const m of messages) {
for (const b of m.content) {
if (b.type === "text") {
if (!b.text.trim()) continue;
if (m.role === "user") ui.showUser(b.text.length > REPLAY_USER_MAX ? `${b.text.slice(0, REPLAY_USER_MAX)}… (tronqué)` : b.text);
else {
ui.onText(b.text);
ui.onTextEnd();
}
} else if (b.type === "tool_use") {
toolNames.set(b.id, b.name);
const tool = session.tools.get(b.name);
let summary: string;
try {
summary = tool?.describe?.(b.input) ?? JSON.stringify(b.input).slice(0, 120);
} catch {
summary = JSON.stringify(b.input).slice(0, 120);
}
ui.onToolCall(b.name, summary);
} else {
const out =
b.content.length > REPLAY_RESULT_MAX
? `${b.content.slice(0, REPLAY_RESULT_MAX)}… (tronqué)`
: b.content;
ui.onToolResult(toolNames.get(b.toolUseId) ?? "outil", out, b.isError === true);
}
}
}
}
const HELP = `MAIA — assistant IA en ligne de commande
2026-10-11 00:11:20 +00:00
Usage : maia [options]
--model <spec> Modèle de la session, ex. litellm:gpt-4o
2026-10-11 00:11:20 +00:00
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
-h, --help Affiche cette aide
Commandes en session : /help /model /clear /exit
2026-10-12 23:23:59 +00:00
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
2026-10-11 22:41:17 +00:00
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
2026-10-12 23:23:59 +00:00
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
2026-10-11 00:11:20 +00:00
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
model: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(HELP);
return;
}
2026-10-11 22:41:17 +00:00
if (!process.stdin.isTTY) {
console.error("MAIA nécessite un terminal interactif (l'entrée standard n'est pas un TTY).");
2026-10-11 22:41:17 +00:00
process.exitCode = 1;
return;
}
const ui = new InkUI();
try {
await run(ui, { model: values.model });
2026-10-11 22:41:17 +00:00
} finally {
await ui.close(); // restaure le terminal et affiche les derniers messages
}
}
2026-10-11 00:11:20 +00:00
async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
2026-10-11 00:11:20 +00:00
// Dossier de lancement (voir launch-dir.ts) : base de tous les chemins relatifs
const cwd = resolveLaunchDir();
try {
process.chdir(cwd);
} catch (err) {
console.error(`Dossier de lancement inaccessible : ${cwd} (${(err as Error).message})`);
process.exitCode = 1;
return;
}
const homeDir = homedir();
// 0. Configuration de l'application : ~/.maia/settings.json (serveur LiteLLM)
let settings;
try {
settings = await loadSettings(homeDir);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
return;
}
2026-10-11 00:11:20 +00:00
// 2. Configuration
const config = await resolveConfig({
cwd,
homeDir,
2026-10-11 22:41:17 +00:00
flags,
2026-10-11 00:11:20 +00:00
});
/**
* Charge les modèles disponibles auprès de tous les providers, puis demande à l'utilisateur
* d'en choisir un. Retourne null si rien n'est disponible ou si l'utilisateur annule.
*/
const pickModel = async (current?: string): Promise<string | null> => {
const { choices, errors } = await ui.withSpinner(
"Chargement des modèles disponibles…",
() => loadModelChoices(settings),
2026-10-11 00:11:20 +00:00
"Modèles chargés",
);
for (const e of errors) ui.warn(e);
if (choices.length === 0) {
ui.error(
`Aucun modèle disponible auprès de LiteLLM. Vérifiez LiteLLM_URL et LiteLLM_Models dans ${settingsPath(homeDir)}.`,
2026-10-11 00:11:20 +00:00
);
return null;
}
return ui.select("Modèle", choices, current);
};
const startSession = async (modelSpec: string): Promise<AgentSession> => {
const init = await initializeAgent({
2026-10-11 00:11:20 +00:00
cwd,
homeDir,
log: (m) => ui.info(m),
warn: (m) => ui.warn(m),
});
return createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd });
2026-10-11 00:11:20 +00:00
};
2026-10-12 23:23:59 +00:00
ui.intro("Maia");
2026-10-11 00:11:20 +00:00
// Dossier de lancement : base de tous les chemins relatifs (fichiers lus, créés, configuration)
ui.info(`Dossier de travail : ${cwd}`);
2026-10-12 23:23:59 +00:00
// Dossier ./.maia (créé s'il n'existe pas) et ./.maia/.sessions : sessions communes à tous les agents
let sessions: SessionManager | null = null;
try {
const home = ensureMaiaHome(cwd);
if (home.created) ui.info(`Dossier créé : ${home.dir}`);
sessions = new SessionManager(new SessionStore(home.sessionsDir));
} catch (err) {
ui.warn(`Sessions désactivées : impossible de créer ./.maia/.sessions (${(err as Error).message})`);
}
2026-10-11 00:11:20 +00:00
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
let modelSpec = config.model;
if (!modelSpec) {
const picked = await pickModel();
if (!picked) {
ui.outro("Aucun modèle choisi");
process.exitCode = 1;
return;
}
modelSpec = picked;
}
let session: AgentSession;
try {
session = await startSession(modelSpec);
2026-10-11 00:11:20 +00:00
} catch (err) {
ui.error((err as Error).message);
process.exitCode = 1;
return;
}
ui.info(`Modèle : ${modelSpec}`);
2026-10-11 00:11:20 +00:00
2026-10-12 23:23:59 +00:00
// Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite.
const saveSession = (): string | null => {
if (!sessions) return null;
try {
const r = sessions.save(session, { model: modelSpec, cwd });
2026-10-12 23:23:59 +00:00
return r?.written ? r.name : null;
} catch (err) {
ui.warn(`Sauvegarde de la session impossible : ${(err as Error).message}`);
return null;
}
};
// Filet de sécurité : sortie par signal ou fermeture du terminal (l'écriture est synchrone)
const onExit = (): void => void saveSession();
const onSignal = (): void => {
saveSession();
process.exit(0);
};
process.on("exit", onExit);
process.on("SIGTERM", onSignal);
process.on("SIGHUP", onSignal);
// Quitte la session courante pour une nouvelle : l'utilisateur est prévenu, la session est sauvegardée.
const announceLeaving = (): void => {
if (!sessions) return;
if (sessions.isEmpty(session)) return;
const saved = saveSession();
const name = saved ?? sessions.name;
ui.warn(`Vous quittez la session « ${name} » : elle est sauvegardée (./.maia/.sessions/${name}.json).`);
};
2026-10-11 00:11:20 +00:00
// 4. Boucle de conversation
for (;;) {
const input = await ui.readInput();
if (input === null || input === "/exit") break;
if (!input) continue;
// Commande shell directe (commune à tous les agents) : !commande
const shellCall = parseShellInvocation(input);
if (shellCall) {
if ("error" in shellCall) ui.error(shellCall.error);
else await runUserShell(ui, session, shellCall.command);
continue;
}
2026-10-11 00:11:20 +00:00
let prompt = input;
const skillCall = parseSkillInvocation(input);
if (skillCall) {
// Appel direct d'un skill de l'agent chargé : §nom [arguments]
if (!skillCall.name) {
ui.info(
session.skills.length > 0
? ["Skills disponibles :", ...session.skills.map((s) => ` §${s.name} — ${s.description}`)].join("\n")
: "Aucun skill disponible disponible.",
2026-10-11 00:11:20 +00:00
);
continue;
}
const skill = findSkill(session.skills, skillCall.name);
if (!skill) {
const names = session.skills.map((s) => `§${s.name}`).join(", ") || "aucun";
ui.error(`Skill inconnu : §${skillCall.name} (disponibles : ${names})`);
continue;
}
try {
prompt = await skill.invoke(skillCall.args);
} catch (err) {
ui.error(`Échec du lancement du skill §${skill.name} : ${(err as Error).message}`);
continue;
}
ui.info(`Skill lancé : §${skill.name}`);
} else if (input.startsWith("/")) {
const [name = "", ...rest] = input.slice(1).split(" ");
const args = rest.join(" ");
if (name === "help") {
const custom = session.commands.map(
(c) => `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""} — ${c.description}`,
);
const skills = session.skills.map((s) => `§${s.name} — ${s.description}`);
ui.info([HELP, ...custom, ...skills].join("\n"));
continue;
}
2026-10-12 23:23:59 +00:00
if (name === "rename") {
if (!sessions) ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
else if (!args.trim()) ui.info(`Session courante : ${sessions.name} (usage : /rename <nouveau nom>)`);
else {
const r = sessions.rename(args);
if (!r.ok) ui.error(r.error);
else ui.info(r.changed ? `Session renommée : ${r.name}` : `La session s'appelle déjà « ${r.name} »`);
}
continue;
}
if (name === "new") {
if (!sessions) {
ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
continue;
}
announceLeaving();
session.messages.length = 0;
session.pendingContext.length = 0;
sessions.startNew();
ui.info(`Nouvelle session : ${sessions.name} (nouveau contexte)`);
continue;
}
if (name === "load") {
if (!sessions) {
ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
continue;
}
let target = args.trim();
if (!target) {
const { sessions: list, unreadable } = await sessions.store.list();
for (const u of unreadable) ui.warn(`Session illisible ignorée : ${u}`);
if (list.length === 0) {
ui.info("Aucune session enregistrée.");
continue;
}
const picked = await ui.select(
"Sessions enregistrées",
list.map((x) => ({
value: x.name,
label: x.name,
hint: `${x.updatedAt.slice(0, 16).replace("T", " ")} · ${x.turns} échange${x.turns > 1 ? "s" : ""}`,
2026-10-12 23:23:59 +00:00
})),
sessions.name,
);
if (!picked) continue;
target = picked;
}
const found = sessions.store.find(normalizeSessionName(target));
if (!found) {
const near = sessions.store.suggest(normalizeSessionName(target));
ui.error(`Session introuvable : ${target}${near.length ? ` (proches : ${near.join(", ")})` : ""}`);
continue;
}
if (found.toLowerCase() === sessions.name.toLowerCase() && !sessions.isEmpty(session)) {
ui.info(`« ${found} » est déjà la session courante.`);
continue;
}
let saved;
try {
saved = await sessions.store.read(found);
} catch (err) {
ui.error((err as Error).message);
continue;
}
announceLeaving();
session.messages.length = 0;
session.messages.push(...saved.messages);
session.pendingContext.length = 0;
session.pendingContext.push(...saved.pendingContext);
sessions.restore(saved);
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
2026-10-12 23:23:59 +00:00
if (session.pendingContext.length > 0) {
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
}
replayMessages(ui, session, saved.messages);
ui.info("— fin de la session rechargée, vous pouvez continuer —");
continue;
}
2026-10-11 00:11:20 +00:00
if (name === "clear") {
session.messages.length = 0;
session.pendingContext.length = 0;
2026-10-11 00:11:20 +00:00
ui.info("Conversation réinitialisée");
continue;
}
if (name === "model") {
// Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée.
const choice = await pickModel(modelSpec);
if (choice && choice !== modelSpec) {
try {
session.provider = createProvider(choice, settings);
2026-10-11 00:11:20 +00:00
modelSpec = choice;
ui.info(`Modèle actif : ${modelSpec}`);
} catch (err) {
ui.error((err as Error).message);
}
}
continue;
}
const custom = session.commands.find((c) => c.name === name);
if (!custom) {
ui.error(`Commande inconnue : /${name}`);
continue;
}
prompt = custom.prompt(args);
2026-10-11 00:11:20 +00:00
}
2026-10-11 22:41:17 +00:00
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
const mark = session.messages.length;
const contextBackup = [...session.pendingContext];
2026-10-12 23:23:59 +00:00
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
// l'utilisateur a tapé est joint à la demande. Ajouté APRÈS la sauvegarde : une demande interrompue
// ne le conserve pas (on le re-citera en la relançant).
const refs = await resolveFileReferences(input, { cwd: session.cwd });
for (const w of refs.warnings) ui.warn(w);
if (refs.context) {
session.pendingContext.push(refs.context);
ui.info(
`Fichiers ajoutés au contexte : ${refs.files
.map((f) => `${f.path}${f.kind === "dir" ? "/" : ""}${f.truncated ? " (tronqué)" : ""}`)
.join(", ")}`,
);
}
2026-10-11 22:41:17 +00:00
const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort());
2026-10-11 00:11:20 +00:00
try {
2026-10-11 22:41:17 +00:00
await runTurn(session, prompt, controller.signal);
2026-10-11 00:11:20 +00:00
} catch (err) {
2026-10-11 22:41:17 +00:00
if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
2026-10-11 22:41:17 +00:00
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
} else {
ui.error((err as Error).message);
}
} finally {
ui.setInterruptHandler(null);
2026-10-11 00:11:20 +00:00
}
}
2026-10-12 23:23:59 +00:00
const finalName = saveSession();
if (finalName) ui.info(`Session sauvegardée : ${finalName}`);
2026-10-11 00:11:20 +00:00
ui.outro("À bientôt");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});