From 4562484f51e0c218bef1d0fd57686120d58774d4 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 21 Oct 2026 01:11:44 +0200 Subject: [PATCH] Ajout des modifications --- .ressources/AGENT.md | 70 +++++++++++++++ README.md | 6 +- SPECS_TECH.md | 9 +- packages/agent/src/agents.ts | 3 +- packages/agent/src/index.ts | 1 + packages/agent/src/new-agent.test.ts | 129 +++++++++++++++++++++++++++ packages/agent/src/new-agent.ts | 71 +++++++++++++++ packages/agent/src/yaml-header.ts | 21 ++++- packages/cli/src/index.ts | 50 ++++++++++- packages/cli/src/resources.test.ts | 21 +++++ packages/cli/src/resources.ts | 24 +++++ 11 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 .ressources/AGENT.md create mode 100644 packages/agent/src/new-agent.test.ts create mode 100644 packages/agent/src/new-agent.ts create mode 100644 packages/cli/src/resources.test.ts create mode 100644 packages/cli/src/resources.ts diff --git a/.ressources/AGENT.md b/.ressources/AGENT.md new file mode 100644 index 0000000..f0796dd --- /dev/null +++ b/.ressources/AGENT.md @@ -0,0 +1,70 @@ +--- +# ════════════════════════════════════════════════════════════════════════════ +# MODÈLE D'AGENT MAIA — créé par /newAgent +# Modifiez ce fichier : changez la description et les trois sections du bas. +# Les lignes qui commencent par # dans l'en-tête sont des commentaires. +# Pour activer une propriété facultative (exemples collés au #), supprimez simplement le # au début de sa ligne. +# ════════════════════════════════════════════════════════════════════════════ + +# name (OBLIGATOIRE) — le nom de l'agent. Il doit être identique au nom du dossier. +# /newAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier. +name: code_reviewer + +# description (OBLIGATOIRE) — une phrase courte : à quoi sert l'agent, et quand NE PAS l'utiliser. +# C'est ce que lit l'agent principal pour décider s'il doit lui confier une tâche. +description: Révise du code (sécurité, anti-patterns). Ne pas l'utiliser pour écrire du code. + +# model (facultatif) — le modèle de langage de cet agent. Un nom exact ou un morceau de nom +# (« sonnet » prend un modèle dont le nom contient « sonnet »). +# Sans cette ligne : le modèle de l'agent principal. +#model: sonnet + +# tools (facultatif) — les outils que l'agent a le droit d'utiliser, séparés par des virgules. +# Outils : Read, Write, Edit, Glob, Grep, Bash, TodoWrite, WebFetch, Skill (+ vos outils personnalisés). +# Sans cette ligne : les mêmes outils que l'agent principal (sauf Agent : un sous-agent ne lance pas d'agent). +#tools: Read, Grep, Write + +# permissions (facultatif) — règles ajoutées à celles de l'agent principal (voir GUIDE_PERMISSIONS.md). +# allow : autorisé sans demander · ask : toujours demander · deny : interdit. +# Un deny ou un ask hérité de l'agent principal n'est jamais levé ici. +#permissions: +# allow: [Read, Grep] +# deny: [Bash] + +# effort (facultatif) — le soin apporté à la tâche : low (rapide, 10 étapes au plus), +# medium (équilibré, 25 étapes) ou high (approfondi, 50 étapes). Défaut : medium. +effort: medium + +# skills (facultatif) — les skills (./.maia/skills) que l'agent peut utiliser. Sans cette ligne : tous. +# Une liste vide [] n'en autorise aucun. +#skills: [api-docs, testing-guide] + +# omitMd (facultatif) — false : le fichier MAIA.md (ou CLAUDE.md) du projet est ajouté au contexte de l'agent. +# true : l'agent ne le voit pas (utile pour un agent qui n'a pas besoin du contexte du projet). Défaut : false. +omitMd: false + +# trace (facultatif) — true : la conversation complète de l'agent est enregistrée dans ./.maia/.sessions +# (fichier « session_…-nom_de_l_agent.json »), pratique pour comprendre ce qu'il a fait. Défaut : false. +trace: false +--- +# Instructions system + +Tu es un expert en révision de code. + +# Missions + +- identifier les problèmes de sécurité +- détecter les anti-patterns +- suggérer des améliorations + +# Output + +Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description, code concerné, solution recommandée. diff --git a/README.md b/README.md index ded7b6c..fc60daa 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC | `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles | | `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) | | `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) | +| `/newAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom | | `/agents` | liste les sous-agents chargés (nom, origine, description) | | `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») | | `/rename ` · `/load [nom]` · `/new` | sessions (voir plus bas) | @@ -343,7 +344,9 @@ Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `star MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle. -Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents//` (dossier de lancement) ou `~/.maia/agents//` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`. +Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents//` (dossier de lancement) ou `~/.maia/agents//` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/newAgent` (ou `/newAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `//AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`). + +Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`. ```markdown --- @@ -438,3 +441,4 @@ Journal des évolutions, dans l'ordre : 40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites). 41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27). 42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte. +43. **`/newAgent`** : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés. diff --git a/SPECS_TECH.md b/SPECS_TECH.md index 4dc6431..9888b49 100644 --- a/SPECS_TECH.md +++ b/SPECS_TECH.md @@ -527,9 +527,10 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ | `./.maia/skills/` · `/.maia/skills/` | skills | | `./.maia/tools//TOOL.json` | outils personnalisés | | `./.maia/agents//AGENT.md` · `~/.maia/agents//AGENT.md` | sous-agents | +| `/.ressources/AGENT.md` | modèle d'agent de `/newAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) | | `./.maia/.sessions/.json` | sessions (et traces de sous-agents `-.json`) | | `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ | -| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN` | variables d'environnement | +| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement | ## 17. Sécurité @@ -578,6 +579,12 @@ En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table im Corps : trois sections obligatoires, repérées par leurs titres Markdown (niveau libre, casse et accents ignorés ; « Instructions system(e) », « Missions », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre des trois sections ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`. +### 20.1b Création : `/newAgent` et le modèle `.ressources/AGENT.md` + +`/newAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section. + +Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`. + ### 20.2 Lancement Outil `Agent` (permission `ask`, règles `Agent` ou `Agent(nom)`), ajouté à l'agent principal quand au moins un agent existe ; sa description et le prompt système listent les agents. Paramètres : `agent` (énumération des noms), `task`. L'appel est bloquant et retourne « Résumé du sous-agent … » ; plusieurs appels `Agent` dans la même réponse passent par l'exécuteur parallèle (§ 6). diff --git a/packages/agent/src/agents.ts b/packages/agent/src/agents.ts index 354848a..62e2aae 100644 --- a/packages/agent/src/agents.ts +++ b/packages/agent/src/agents.ts @@ -117,7 +117,8 @@ export function parseAgentSections(body: string): Partial/g, "").trim(); } return found; } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 97e7b4a..bd2b291 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -12,3 +12,4 @@ export { export { createAgentTool, type SubAgentLauncher } from "./subagent-tool"; export { CONTEXT_FILES, loadContextFile } from "./memory"; export { customToolsDir, loadCustomTools } from "./custom-tools"; +export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent"; diff --git a/packages/agent/src/new-agent.test.ts b/packages/agent/src/new-agent.test.ts new file mode 100644 index 0000000..dae05fe --- /dev/null +++ b/packages/agent/src/new-agent.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadAgents, parseAgentFile } from "./agents"; +import { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName } from "./new-agent"; + +const TEMPLATE = `--- +# commentaire du modèle +name: code_reviewer # doit être le nom du dossier +description: Révise du code. Ne pas l'utiliser pour écrire du code. +# model: sonnet +effort: medium +omitMd: false +trace: false +--- +# Instructions system + +Tu es un expert. + +# Missions +- vérifier + +# Output +Un résumé. name: ne change pas dans le corps +`; + +async function setup() { + const root = await mkdtemp(join(tmpdir(), "maia-na-")); + const home = join(root, "home"); + const cwd = join(root, "proj"); + await mkdir(home, { recursive: true }); + await mkdir(cwd, { recursive: true }); + const templatePath = join(root, "AGENT.md"); + await writeFile(templatePath, TEMPLATE); + return { home, cwd, templatePath }; +} + +describe("/newAgent : création depuis le modèle", () => { + it("dossiers : global = ~/.maia/agents, projet = ./.maia/agents", () => { + expect(agentsDirFor("global", "/p", "/h")).toBe("/h/.maia/agents"); + expect(agentsDirFor("projet", "/p", "/h")).toBe("/p/.maia/agents"); + }); + + it("nom valide : lettres, chiffres, _ . - ; sinon message clair", () => { + expect(validateAgentName("agent_1")).toBeNull(); + expect(validateAgentName("Revue-Code.v2")).toBeNull(); + expect(validateAgentName("")).toContain("vide"); + expect(validateAgentName("mon agent")).toContain("invalide"); + expect(validateAgentName("a/b")).toContain("invalide"); + expect(validateAgentName(".caché")).toContain("point"); + }); + + it("applyAgentName : remplace seulement le « name: » de l'en-tête, commentaires et corps conservés", () => { + const out = applyAgentName(TEMPLATE, "mon_agent"); + expect(out).toContain("name: mon_agent\n"); + expect(out).not.toContain("code_reviewer"); + expect(out).toContain("# commentaire du modèle"); + expect(out).toContain("# model: sonnet"); + expect(out).toContain("name: ne change pas dans le corps"); + expect(() => applyAgentName("---\ndescription: x\n---\ncorps", "a")).toThrow("name"); + }); + + it("projet : crée ./.maia/agents//AGENT.md, chargé ensuite sans erreur ni avertissement", async () => { + const { home, cwd, templatePath } = await setup(); + const file = await createAgentFromTemplate({ scope: "projet", name: "agent_1", cwd, homeDir: home, templatePath }); + expect(file).toBe(join(cwd, ".maia", "agents", "agent_1", "AGENT.md")); + expect(await readFile(file, "utf8")).toContain("name: agent_1"); + const { agents, warnings } = await loadAgents(cwd, home); + expect(warnings).toEqual([]); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ name: "agent_1", source: "projet", effort: "medium", omitMd: false, trace: false }); + expect(agents[0]!.tools).toBeUndefined(); + expect(agents[0]!.instructions).toBe("Tu es un expert."); // commentaire HTML retiré + }); + + it("global : crée ~/.maia/agents//AGENT.md", async () => { + const { home, cwd, templatePath } = await setup(); + const file = await createAgentFromTemplate({ scope: "global", name: "g", cwd, homeDir: home, templatePath }); + expect(file).toBe(join(home, ".maia", "agents", "g", "AGENT.md")); + const { agents } = await loadAgents(cwd, home); + expect(agents[0]).toMatchObject({ name: "g", source: "utilisateur" }); + }); + + it("n'écrase jamais un agent existant", async () => { + const { home, cwd, templatePath } = await setup(); + const opts = { scope: "projet" as const, name: "a", cwd, homeDir: home, templatePath }; + const file = await createAgentFromTemplate(opts); + await writeFile(file, "MES MODIFICATIONS"); + await expect(createAgentFromTemplate(opts)).rejects.toThrow("existe déjà"); + expect(await readFile(file, "utf8")).toBe("MES MODIFICATIONS"); + }); + + it("nom invalide ou modèle introuvable : erreur claire, rien de créé", async () => { + const { home, cwd, templatePath } = await setup(); + await expect(createAgentFromTemplate({ scope: "projet", name: "a b", cwd, homeDir: home, templatePath })).rejects.toThrow("invalide"); + await expect( + createAgentFromTemplate({ scope: "projet", name: "a", cwd, homeDir: home, templatePath: join(cwd, "absent.md") }), + ).rejects.toThrow("introuvable"); + expect((await loadAgents(cwd, home)).agents).toEqual([]); + }); +}); + +describe("commentaires de fin de ligne dans AGENT.md", () => { + it("name, description et listes avec ` # …` sont lus sans le commentaire", () => { + const raw = `--- +name: a # doit être le nom du dossier +description: "Avec # dièse entre guillemets" # commentaire +tools: Read, Grep # outils +permissions: # règles + allow: [Read, Grep] # lecture + deny: + - Bash # jamais +effort: high # soin +--- +# Instructions system +x +# Missions +y +# Output +z +`; + const r = parseAgentFile("a", raw, { dir: "/d", source: "projet" }); + expect(r.errors).toEqual([]); + expect(r.agent).toMatchObject({ name: "a", description: "Avec # dièse entre guillemets", effort: "high" }); + expect(r.agent!.tools).toEqual(["Read", "Grep"]); + expect(r.agent!.permissions).toEqual({ allow: ["Read", "Grep"], ask: [], deny: ["Bash"] }); + }); +}); diff --git a/packages/agent/src/new-agent.ts b/packages/agent/src/new-agent.ts new file mode 100644 index 0000000..ce1f487 --- /dev/null +++ b/packages/agent/src/new-agent.ts @@ -0,0 +1,71 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { AGENT_NAME_RE } from "./agents"; + +export type AgentScope = "global" | "projet"; + +/** Dossier des agents d'un niveau : ~/.maia/agents (global) ou ./.maia/agents (projet). */ +export function agentsDirFor(scope: AgentScope, cwd: string, homeDir: string): string { + return join(resolve(scope === "global" ? homeDir : cwd), ".maia", "agents"); +} + +/** Retourne un message d'erreur si le nom ne peut pas être celui d'un agent (c'est aussi le nom de son dossier). */ +export function validateAgentName(name: string): string | null { + if (!name) return "Le nom de l'agent est vide."; + if (name.length > 64) return "Nom trop long (64 caractères au maximum)."; + if (!AGENT_NAME_RE.test(name)) return "Nom invalide : lettres, chiffres, _ . - uniquement (pas d'espace)."; + if (name.startsWith(".")) return "Un nom d'agent ne peut pas commencer par un point."; + return null; +} + +/** Remplace la valeur de `name:` dans l'en-tête du modèle (les autres lignes, commentaires compris, sont conservées). */ +export function applyAgentName(template: string, name: string): string { + const end = template.indexOf("\n---", 4); + const head = end === -1 ? template : template.slice(0, end); + const rest = end === -1 ? "" : template.slice(end); + const lines = head.split("\n"); + let done = false; + const out = lines.map((line) => { + if (!done && /^name[ \t]*:/.test(line)) { + done = true; + return `name: ${name}`; + } + return line; + }); + if (!done) throw new Error('Le modèle d\'agent ne contient pas de ligne « name: » dans son en-tête.'); + return out.join("\n") + rest; +} + +/** + * Crée //AGENT.md à partir du modèle. Refuse d'écraser un agent existant. + * Retourne le chemin du fichier créé. + */ +export async function createAgentFromTemplate(opts: { + scope: AgentScope; + name: string; + cwd: string; + homeDir: string; + templatePath: string; +}): Promise { + const invalid = validateAgentName(opts.name); + if (invalid) throw new Error(invalid); + + let template: string; + try { + template = await readFile(opts.templatePath, "utf8"); + } catch (err) { + throw new Error(`Modèle d'agent introuvable : ${opts.templatePath} (${(err as Error).message})`); + } + const content = applyAgentName(template.replace(/\r\n/g, "\n"), opts.name); + + const dir = join(agentsDirFor(opts.scope, opts.cwd, opts.homeDir), opts.name); + const file = join(dir, "AGENT.md"); + await mkdir(dir, { recursive: true }); + try { + await writeFile(file, content, { encoding: "utf8", flag: "wx" }); // wx : échoue si le fichier existe + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`L'agent « ${opts.name} » existe déjà : ${file}`); + throw err; + } + return file; +} diff --git a/packages/agent/src/yaml-header.ts b/packages/agent/src/yaml-header.ts index 8341982..1edfa6b 100644 --- a/packages/agent/src/yaml-header.ts +++ b/packages/agent/src/yaml-header.ts @@ -9,6 +9,23 @@ interface Line { text: string; } +/** Retire un commentaire de fin de ligne (` # …`, le # doit être précédé d'un espace et hors guillemets). */ +export function stripComment(text: string): string { + let quote: string | null = null; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + // une apostrophe au milieu d'un mot (l'agent) n'ouvre pas de guillemets + if (i === 0 || /[\s:,\[(]/.test(text[i - 1]!)) quote = ch; + } else if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]!))) { + return text.slice(0, i).trimEnd(); + } + } + return text; +} + function unquote(v: string): string { const m = /^(["'])(.*)\1$/.exec(v.trim()); return m ? (m[2] ?? "") : v.trim(); @@ -42,7 +59,7 @@ function parseList(lines: Line[], pos: { i: number }, indent: number): string[] while (pos.i < lines.length) { const line = lines[pos.i]!; if (line.indent !== indent || !/^-(\s|$)/.test(line.text)) break; - const item = unquote(line.text.replace(/^-\s*/, "")); + const item = unquote(stripComment(line.text.replace(/^-\s*/, ""))); if (item) items.push(item); pos.i++; } @@ -62,7 +79,7 @@ function parseMap(lines: Line[], pos: { i: number }, indent: number): YamlMap { pos.i++; if (!m) continue; const key = m[1]!.toLowerCase(); - const rest = (m[2] ?? "").trim(); + const rest = stripComment((m[2] ?? "").trim()); if (rest !== "" && !/^[>|][+-]?$/.test(rest)) { map[key] = inlineValue(rest); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 77d0368..8092188 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -26,12 +26,20 @@ import { type AgentSession, type Message, } from "@maia/core"; -import { initializeAgent, type AgentDefinition } from "@maia/agent"; +import { + agentsDirFor, + createAgentFromTemplate, + initializeAgent, + validateAgentName, + type AgentDefinition, + type AgentScope, +} from "@maia/agent"; import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers"; import { InkUI } from "@maia/ui"; import { resolveConfig } from "./config"; import { resolveLaunchDir } from "./launch-dir"; import { runUserShell } from "./user-shell"; +import { findResource } from "./resources"; import { createSubAgentLauncher } from "./subagent-parent"; import { SUBAGENT_FLAG } from "./subagent-protocol"; @@ -79,7 +87,7 @@ Usage : maia [options] Sans cette option, la liste des modèles disponibles est proposée au démarrage. -h, --help Affiche cette aide -Commandes en session : /help /model /permissions /agents /clear /compact /exit +Commandes en session : /help /model /permissions /agents /newAgent /clear /compact /exit Sessions : /rename /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions) Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills) Sous-agents : définis dans ./.maia/agents//AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste @@ -486,6 +494,44 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { ui.showPermissions(session.permissionSources); continue; } + if (name.toLowerCase() === "newagent") { + // /newAgent [nom] : crée ./.maia/agents//AGENT.md (projet) ou ~/.maia/agents//AGENT.md (global) depuis le modèle + const template = findResource("AGENT.md"); + if (!template) { + ui.error("Modèle d'agent introuvable : .ressources/AGENT.md (dans le dossier de MAIA, ou dossier indiqué par MAIA_RESSOURCES)."); + continue; + } + const scope = await ui.select("Où créer le nouvel agent ?", [ + { value: "projet", label: "Projet", hint: agentsDirFor("projet", cwd, homeDir) }, + { value: "global", label: "Global (utilisateur)", hint: agentsDirFor("global", cwd, homeDir) }, + ]); + if (!scope) continue; + let agentName = args.trim(); + if (!agentName) { + ui.info("Nom du nouvel agent (lettres, chiffres, _ . - ; ce sera aussi le nom de son dossier) — saisie vide ou Ctrl+C : annuler"); + agentName = (await ui.readInput()) ?? ""; + if (!agentName) { + ui.info("Création annulée."); + continue; + } + } + const invalid = validateAgentName(agentName); + if (invalid) { + ui.error(invalid); + continue; + } + try { + const file = await createAgentFromTemplate({ scope, name: agentName, cwd, homeDir, templatePath: template }); + ui.info( + `Agent « ${agentName} » créé (${scope === "global" ? "global" : "projet"}) : ${file}\n` + + "Éditez ce fichier (description, instructions, missions, output ; les propriétés facultatives sont expliquées en commentaires), " + + "puis tapez /clear pour le charger. Vous le lancerez avec &" + agentName + " ou l'agent principal le proposera au besoin.", + ); + } catch (err) { + ui.error((err as Error).message); + } + continue; + } if (name === "agents") { ui.info( agents.length > 0 diff --git a/packages/cli/src/resources.test.ts b/packages/cli/src/resources.test.ts new file mode 100644 index 0000000..4e36329 --- /dev/null +++ b/packages/cli/src/resources.test.ts @@ -0,0 +1,21 @@ +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 { findResource } from "./resources"; + +describe("findResource", () => { + it("racine de MAIA = 3 niveaux au-dessus du programme ; MAIA_RESSOURCES prioritaire ; absent : null", async () => { + const root = await mkdtemp(join(tmpdir(), "maia-res-")); + await mkdir(join(root, ".ressources"), { recursive: true }); + await writeFile(join(root, ".ressources", "AGENT.md"), "x"); + const from = join(root, "packages", "cli", "dist", "index.js"); + expect(findResource("AGENT.md", from, {})).toBe(join(root, ".ressources", "AGENT.md")); + + const other = await mkdtemp(join(tmpdir(), "maia-res2-")); + await writeFile(join(other, "AGENT.md"), "y"); + expect(findResource("AGENT.md", from, { MAIA_RESSOURCES: other })).toBe(join(other, "AGENT.md")); + + expect(findResource("INCONNU.md", from, {})).toBeNull(); + }); +}); diff --git a/packages/cli/src/resources.ts b/packages/cli/src/resources.ts new file mode 100644 index 0000000..2db1ea0 --- /dev/null +++ b/packages/cli/src/resources.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Nom du dossier des ressources privées de MAIA (à la racine du projet MAIA, pas du dossier de travail de l'utilisateur). */ +export const RESOURCES_DIR = ".ressources"; + +/** + * Fichier de ressource privée de MAIA (ex. le modèle `AGENT.md`), cherché dans l'ordre : + * 1. $MAIA_RESSOURCES (dossier) ; + * 2. /.ressources, la racine étant 3 niveaux au-dessus du programme (packages/cli/dist ou packages/cli/src) ; + * 3. juste à côté du programme (installation à plat). + * Retourne le chemin du premier fichier existant, sinon null. + */ +export function findResource(file: string, from: string = fileURLToPath(import.meta.url), env = process.env): string | null { + const here = dirname(from); + const dirs = [env.MAIA_RESSOURCES, join(here, "..", "..", "..", RESOURCES_DIR), join(here, RESOURCES_DIR)]; + for (const dir of dirs) { + if (!dir) continue; + const path = join(dir, file); + if (existsSync(path)) return path; + } + return null; +}