MagicCode/packages/cli/src/index.ts

271 lines
9 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 {
ModeRegistry,
activateExtensions,
createSession,
discoverUserExtensions,
findSkill,
parseShellInvocation,
2026-10-11 00:11:20 +00:00
parseSkillInvocation,
runTurn,
type AgentSession,
} from "@magiccode/core";
import { claudeCodeExtension } from "@magiccode/ext-claudecode";
2026-10-11 00:11:20 +00:00
import { createProvider, loadModelChoices } from "@magiccode/providers";
2026-10-11 22:41:17 +00:00
import { InkUI } from "@magiccode/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
const HELP = `MagicCode — assistant IA en ligne de commande
Usage : magiccode [options]
--mode <id> Mode de fonctionnement (défaut : claudecode)
--model <spec> Modèle de la session, ex. sapaicore:anthropic--claude-4.5-sonnet
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
-h, --help Affiche cette aide
Commandes en session : /help /mode /model /clear /exit
2026-10-11 22:41:17 +00:00
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)`;
2026-10-11 00:11:20 +00:00
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
mode: { type: "string" },
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("MagicCode nécessite un terminal interactif (l'entrée standard n'est pas un TTY).");
process.exitCode = 1;
return;
}
const ui = new InkUI();
try {
await run(ui, { mode: values.mode, model: values.model });
} finally {
await ui.close(); // restaure le terminal et affiche les derniers messages
}
}
2026-10-11 00:11:20 +00:00
2026-10-11 22:41:17 +00:00
async function run(ui: InkUI, flags: { mode?: string; 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();
// 1. Extensions : ClaudeCode est embarquée et toujours activée, les autres viennent de ~/.magiccode/extensions
const registry = new ModeRegistry();
const userExtensions = await discoverUserExtensions(join(homeDir, ".magiccode", "extensions"));
await activateExtensions(registry, [claudeCodeExtension, ...userExtensions], (m) => ui.error(m));
// 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({ baseDir: cwd }),
"Modèles chargés",
);
for (const e of errors) ui.warn(e);
if (choices.length === 0) {
ui.error(
"Aucun modèle disponible. Renseignez ./btp_AI.json (SAP AI Core) ou définissez ANTHROPIC_API_KEY.",
);
return null;
}
return ui.select("Modèle", choices, current);
};
const startSession = async (modeId: string, modelSpec: string): Promise<AgentSession> => {
const mode = registry.getMode(modeId);
if (!mode) {
const ids = registry.listModes().map((m) => m.id).join(", ");
throw new Error(`Mode inconnu : "${modeId}" (disponibles : ${ids})`);
}
const init = await mode.initialize({
cwd,
homeDir,
log: (m) => ui.info(m),
warn: (m) => ui.warn(m),
});
return createSession({ provider: createProvider(modelSpec, { baseDir: cwd }), init, ui, cwd });
};
ui.intro("MagicCode");
// Dossier de lancement : base de tous les chemins relatifs (fichiers lus, créés, configuration)
ui.info(`Dossier de travail : ${cwd}`);
// 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 modeId = config.mode;
let session: AgentSession;
try {
session = await startSession(modeId, modelSpec);
} catch (err) {
ui.error((err as Error).message);
process.exitCode = 1;
return;
}
ui.info(`Mode : ${modeId} · Modèle : ${modelSpec}`);
// 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 pour ce mode.",
);
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;
}
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 === "mode") {
const choice = await ui.select(
"Mode de fonctionnement",
registry.listModes().map((m) => ({ value: m.id, label: m.name, hint: m.description })),
modeId,
);
if (choice) {
try {
session = await startSession(choice, modelSpec);
modeId = choice;
ui.info(`Mode actif : ${modeId} (conversation réinitialisée)`);
} catch (err) {
ui.error((err as Error).message);
}
}
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, { baseDir: cwd });
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-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
}
}
ui.outro("À bientôt");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});