Pourquoi écrire des extensions pour Pi Agent ?
Si vous avez déjà utilisé Pi Coding Agent, vous avez remarqué que c'est un assistant de programmation IA terminal « petit mais élégant » — son rôle principal se résume à une chose : intégrer les capacités d'un LLM dans le flux de travail du terminal. Mais ce qui le rend vraiment « personnalisé », c'est son système d'extensions (Extensions).
Les extensions de Pi sont des modules TypeScript qui peuvent s'abonner aux événements du cycle de vie, enregistrer des outils personnalisés appelables par le LLM, ajouter des commandes slash, et même personnaliser les composants TUI. L'écosystème officiel compte déjà des projets communautaires comme pi-hosts (23 pts) et les agents Parallel Pi (8 pts), mais le contenu technique approfondi en français reste rare.
Si vous n'avez pas encore installé Pi, consultez d'abord cet article d'introduction : Top 5 des agents de programmation IA open source en 2026, qui compare le positionnement de Pi. Cet article se concentre quant à lui sur le développement d'extensions et vous guide pour créer 4 extensions pratiques de zéro.
Architecture de Pi Agent
Composants principaux
L'architecture de Pi se résume en une phrase : noyau minimal + périmètre extensible.
┌─────────────────────────────────────────────┐
│ Noyau Pi │
│ ├─ Couche LLM Provider (Anthropic/OpenAI/ │
│ │ local) │
│ ├─ Gestion de session (conversations, │
│ │ compression, branches) │
│ ├─ Outils intégrés (bash, read, write, │
│ │ search…) │
│ └─ Rendu TUI (Ink/React) │
└─────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
Extensions Skills Packages
(TypeScript) (Markdown) (npm/git)
- Extensions : modules TypeScript chargés à l'exécution, capables d'intercepter des événements, d'enregistrer des outils et d'ajouter des commandes.
- Skills : fichiers Markdown chargés à la demande dans le contexte, définissant des workflows et des prompts.
- Packages : collections d'extensions distribuées via npm ou git.
Fonctionnement du système de plugins
Le chargement des extensions de Pi repose sur jiti, qui permet d'exécuter du TypeScript directement sans compilation. Le point d'entrée d'une extension est une fonction factory exportée par défaut, qui reçoit une instance ExtensionAPI :
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Abonnez-vous aux événements, enregistrez des outils, ajoutez des commandes ici
}
Les extensions peuvent être placées à deux endroits pour être découvertes automatiquement :
| Emplacement | Portée |
|---|---|
~/.pi/agent/extensions/*.ts |
Global (tous les projets) |
.pi/extensions/*.ts |
Projet local (projet de confiance requis) |
Référence de l'API
ExtensionAPI fournit trois types de capacités :
- Abonnement aux événements :
pi.on("event_name", handler)— couvre l'ensemble du cycle de vie, desession_startàtool_call. - Enregistrement d'outils :
pi.registerTool({ name, description, parameters, execute })— permet au LLM d'appeler vos fonctions personnalisées. - Enregistrement de commandes :
pi.registerCommand("name", { handler })— ajoute une commande slash/name.
Configuration de l'environnement de développement
Prérequis
- Node.js 18+ (20 LTS recommandé)
- npm ou pnpm
- Pi Coding Agent installé :
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
Création du projet d'extension
# Créer le répertoire global des extensions
mkdir -p ~/.pi/agent/extensions/my-first-ext
cd ~/.pi/agent/extensions/my-first-ext
# Initialiser package.json
npm init -y
# Installer les définitions de types
npm install @earendil-works/pi-coding-agent typebox
# Créer le fichier d'entrée
touch index.ts
Structure des répertoires
my-first-ext/
├── package.json
├── node_modules/
└── src/
└── index.ts # Point d'entrée
package.json doit déclarer le point d'entrée de l'extension :
{
"name": "my-first-ext",
"dependencies": {
"@earendil-works/pi-coding-agent": "^1.0.0",
"typebox": "^0.34.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}
Première extension : Hello World
Version de base : notification + commande
// src/index.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Afficher une notification au démarrage de la session
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("L'extension Hello World est chargée !", "info");
});
// Enregistrer la commande /hello
pi.registerCommand("hello", {
description: "Dire bonjour à Pi",
handler: async (args, ctx) => {
const name = args?.trim() || "World";
ctx.ui.notify(`Bonjour, ${name} ! 👋`, "info");
},
});
}
Test d'exécution
# Méthode 1 : découverte automatique (placée dans ~/.pi/agent/extensions/)
pi
# Méthode 2 : spécification explicite (test temporaire)
pi -e ./src/index.ts
Une fois dans Pi, tapez /hello Kevin — vous devriez voir la notification : Bonjour, Kevin ! 👋.
Niveau avancé : interception des commandes dangereuses
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("⚠️ Opération dangereuse", "Confirmer l'exécution de rm -rf ?");
if (!ok) return { block: true, reason: "Annulé par l'utilisateur" };
}
});
Cette extension illustre la capacité d'interception de l'événement tool_call — vous pouvez effectuer des vérifications de permissions avant que le LLM n'exécute un outil.
Projet pratique n°1 : Générateur de modèles de code personnalisé
Besoin
L'équipe dispose de modèles de code standard (composants React, routes Express, scripts Python, etc.), et chaque nouveau fichier nécessite un copier-coller. Écrivons une extension pour que le LLM puisse appeler l'outil generate_template et générer automatiquement le code.
Implémentation
// src/template-generator.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";
const TEMPLATES: Record<string, string> = {
"react-component": `import React from 'react';
interface {{Name}}Props {
title: string;
}
export const {{Name}}: React.FC<{{Name}}Props> = ({ title }) => {
return <div className="{{name}}">{title}</div>;
};
`,
"express-route": `import { Router, Request, Response } from 'express';
const router = Router();
router.get('/', (req: Request, res: Response) => {
res.json({ message: '{{Name}} endpoint' });
});
export default router;
`,
"python-script": `#!/usr/bin/env python3
"""Module {{Name}}."""
import argparse
import sys
def main(args: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="{{Name}}")
parser.add_argument("--name", default="World")
parsed = parser.parse_args(args)
print(f"Hello, {parsed.name}!")
return 0
if __name__ == "__main__":
raise SystemExit(main())
`,
};
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "generate_template",
label: "Générateur de modèles",
description: "Génère un fichier de code à partir d'un nom de modèle et d'un nom de composant. Modèles disponibles : react-component, express-route, python-script",
parameters: Type.Object({
template: Type.String({ description: "Nom du modèle" }),
name: Type.String({ description: "Nom du composant/module (PascalCase)" }),
outputPath: Type.String({ description: "Chemin du fichier de sortie" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const tpl = TEMPLATES[params.template];
if (!tpl) {
return {
content: [{ type: "text", text: `❌ Modèle inconnu : ${params.template}` }],
details: {},
};
}
const pascalName = params.name.replace(/(^|[-_])(\w)/g, (_, __, c) => c.toUpperCase());
const kebabName = pascalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
const code = tpl
.replace(/\{\{Name\}\}/g, pascalName)
.replace(/\{\{name\}\}/g, kebabName);
const outPath = path.resolve(params.outputPath);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, code, "utf-8");
return {
content: [{ type: "text", text: `✅ Généré : ${outPath}\n\n${code}` }],
details: { path: outPath, template: params.template },
};
},
});
}
Utilisation
Dans Pi, dites simplement :
Génère un composant UserProfile avec le modèle react-component, place-le dans src/components/UserProfile.tsx
Pi appellera l'outil generate_template, effectuera automatiquement le remplacement des variables et l'écriture du fichier.
Projet pratique n°2 : Automatisation du workflow Git
Besoin
Avant chaque commit, lancer automatiquement le lint, générer un message de commit, et notifier l'équipe après le push. Nous utilisons une extension pour intercepter l'outil bash et injecter des vérifications préalables lorsque git commit est détecté.
Implémentation
// src/git-workflow.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execSync } from "node:child_process";
export default function (pi: ExtensionAPI) {
// Intercepter git commit, lancer le lint préalable
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd: string = event.input.command || "";
if (cmd.startsWith("git commit")) {
ctx.ui.setStatus("git-workflow", "🔍 Vérifications pre-commit en cours...");
try {
execSync("npm run lint --if-present", { stdio: "pipe" });
execSync("npm run test --if-present", { stdio: "pipe" });
ctx.ui.setStatus("git-workflow", "✅ Vérifications réussies, commit en cours");
} catch (err) {
ctx.ui.notify("❌ Lint/Test échoué, commit bloqué", "error");
return { block: true, reason: "pre-commit checks failed" };
}
}
// Intercepter git push, envoyer une notification
if (cmd.startsWith("git push")) {
ctx.ui.setStatus("git-workflow", "📤 Push en cours...");
// Possibilité d'appeler un webhook pour notifier l'équipe
}
});
// Enregistrer la commande /git-summary
pi.registerCommand("git-summary", {
description: "Afficher le résumé des commits de la branche actuelle",
handler: async (_args, ctx) => {
try {
const log = execSync(
"git log --oneline -10 --format='%h %s (%an, %ar)'",
{ encoding: "utf-8" }
);
ctx.ui.notify(`10 derniers commits :\n${log}`, "info");
} catch {
ctx.ui.notify("❌ Impossible de lire l'historique git", "error");
}
},
});
}
Résultat
- Lorsque Pi tente d'exécuter
git commit, l'extension lance automatiquement le lint et les tests, et bloque le commit en cas d'échec. - La commande
/git-summarypermet de consulter rapidement les derniers commits.
Projet pratique n°3 : Intégration de la vérification de qualité du code
Besoin
Permettre au LLM d'appeler automatiquement des outils externes de vérification de qualité (comme eslint, pylint, semgrep) lors de l'écriture de code, avec des suggestions de correction dans les résultats.
Implémentation
// src/quality-checker.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { execSync } from "node:child_process";
type Linter = {
cmd: string;
pattern: RegExp;
};
const LINTERS: Linter[] = [
{ cmd: "npx eslint --format json", pattern: /\.(ts|tsx|js|jsx)$/ },
{ cmd: "pylint --output-format=json", pattern: /\.py$/ },
{ cmd: "semgrep --json", pattern: /.*/ },
];
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "check_code_quality",
label: "Vérification de qualité du code",
description: "Exécute le lint/l'analyse statique sur un fichier spécifié, retourne la liste des problèmes et les suggestions de correction",
parameters: Type.Object({
filePath: Type.String({ description: "Chemin du fichier à vérifier" }),
linter: Type.Optional(
Type.String({ description: "Linter spécifique (eslint/pylint/semgrep), auto-détection si vide" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const file = params.filePath;
let linter = LINTERS.find((l) => l.pattern.test(file));
if (params.linter) {
linter = LINTERS.find((l) => l.cmd.startsWith(params.linter!));
}
if (!linter) {
return {
content: [{ type: "text", text: "⚠️ Aucun linter correspondant trouvé" }],
details: {},
};
}
ctx.ui.setStatus("quality", `🔍 Exécution de ${linter.cmd.split(" ")[0]}...`);
try {
const output = execSync(`${linter.cmd} ${file}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return {
content: [{ type: "text", text: `✅ Aucun problème\n${output}` }],
details: { file, linter: linter.cmd },
};
} catch (err: any) {
// Les linters retournent généralement les problèmes avec un code de sortie non nul
const output = err.stdout || err.stderr || "";
return {
content: [{ type: "text", text: `⚠️ Problèmes détectés :\n${output.slice(0, 3000)}` }],
details: { file, linter: linter.cmd, hasIssues: true },
};
}
},
});
}
Utilisation
Vérifie la qualité du code de src/auth/login.ts
Pi appellera check_code_quality, retournera les résultats du lint, puis fournira automatiquement des suggestions de correction.
Projet pratique n°4 : Intégration d'un outil de recherche personnalisé
Besoin
Permettre à Pi de rechercher dans la base de connaissances interne de l'équipe (Notion, Confluence ou fichiers Markdown locaux). Prenons l'exemple d'une base de connaissances Markdown locale.
Implémentation
// src/knowledge-search.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";
const KB_ROOT = process.env.KB_ROOT || "~/knowledge-base";
interface SearchResult {
file: string;
line: number;
context: string;
}
function searchMarkdown(query: string, root: string): SearchResult[] {
const results: SearchResult[] = [];
const terms = query.toLowerCase().split(/\s+/);
function walk(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.name.endsWith(".md")) {
const content = fs.readFileSync(full, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const lower = lines[i].toLowerCase();
if (terms.every((t) => lower.includes(t))) {
results.push({
file: path.relative(root, full),
line: i + 1,
context: lines.slice(Math.max(0, i - 1), i + 2).join("\n"),
});
}
}
}
}
}
walk(path.resolve(root));
return results.slice(0, 10);
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "search_knowledge_base",
label: "Recherche dans la base de connaissances",
description: "Recherche du contenu pertinent dans la base de connaissances Markdown locale",
parameters: Type.Object({
query: Type.String({ description: "Mots-clés de recherche" }),
kbRoot: Type.Optional(Type.String({ description: "Répertoire racine de la base de connaissances, par défaut ~/knowledge-base" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const root = params.kbRoot || KB_ROOT;
const resolved = path.resolve(root.replace("~/", process.env.HOME + "/"));
if (!fs.existsSync(resolved)) {
return {
content: [{ type: "text", text: `❌ Répertoire de la base de connaissances introuvable : ${resolved}` }],
details: {},
};
}
const results = searchMarkdown(params.query, resolved);
if (results.length === 0) {
return {
content: [{ type: "text", text: "🔍 Aucun résultat trouvé" }],
details: { query: params.query },
};
}
const formatted = results
.map((r) => `📄 ${r.file}:${r.line}\n${r.context}`)
.join("\n\n");
return {
content: [{ type: "text", text: `${results.length} correspondance(s) trouvée(s) :\n\n${formatted}` }],
details: { query: params.query, count: results.length },
};
},
});
}
Utilisation
Définissez la variable d'environnement KB_ROOT=~/my-docs, puis dans Pi :
Recherche dans la base de connaissances le contenu sur l'authentification API
Pi appellera search_knowledge_base et retournera les extraits de fichiers correspondants.
Sujets avancés
Collaboration multi-agents
Pi prend en charge le mode multi-agents via les extensions. L'idée principale est : l'agent principal reçoit la tâche, la décompose et la délègue à des sous-agents (en lançant de nouveaux processus pi via l'outil bash, en utilisant --resume ou un fichier de session pour partager le contexte).
pi.registerTool({
name: "delegate_to_specialist",
label: "Déléguer à un spécialiste",
description: "Délègue une sous-tâche à un agent spécialisé",
parameters: Type.Object({
task: Type.String({ description: "Description de la sous-tâche" }),
specialist: Type.String({ description: "Type de spécialiste : frontend/backend/test" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const sessionFile = `/tmp/pi-${params.specialist}-${Date.now()}.json`;
const prompt = encodeURIComponent(params.task);
ctx.ui.setStatus("delegate", `🤖 Délégation à ${params.specialist} en cours...`);
try {
const output = execSync(
`pi --session ${sessionFile} --print "${params.task}"`,
{ encoding: "utf-8", timeout: 120_000 }
);
return {
content: [{ type: "text", text: output }],
details: { specialist: params.specialist, session: sessionFile },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `❌ Échec de la délégation : ${err.message}` }],
details: {},
};
}
},
});
Optimisation des performances
- Chargement différé : ne lancez pas de processus en arrière-plan dans la fonction factory, reportez-les à l'événement
session_startou au premier appel d'outil. - Mise en cache des résultats : pour les outils fréquemment appelés (comme le lint), utilisez un Map pour mettre en cache les hachages de fichiers et les résultats, afin d'éviter les exécutions répétées.
- Mises à jour en flux : utilisez le callback
onUpdatepour fournir un retour en temps réel et éviter le blocage de l'interface.
// Exemple de mise en cache
const cache = new Map<string, string>();
async execute(_toolCallId, params) {
const hash = crypto.createHash("md5").update(fs.readFileSync(params.filePath)).digest("hex");
const key = `${params.filePath}:${hash}`;
if (cache.has(key)) return { content: [{ type: "text", text: cache.get(key)! }], details: { cached: true } };
// ... exécuter la vérification ...
cache.set(key, result);
return { content: [{ type: "text", text: result }], details: {} };
}
Publication et partage d'extensions
Les extensions Pi peuvent être distribuées en tant que pi packages via npm ou un dépôt git :
// package.json
{
"name": "pi-ext-my-tools",
"version": "1.0.0",
"pi": {
"extensions": ["./src/index.ts"]
}
}
Installation par l'utilisateur :
# Depuis npm
pi install npm:pi-ext-my-tools@1.0.0
# Depuis git
pi install git:github.com/user/repo@v1
Vous pouvez également publier sur le répertoire officiel de packages sur pi.dev/packages.
Extensions communautaires recommandées
| Extension | Fonction | Lien |
|---|---|---|
| pi-hosts | Gestion de /etc/hosts, basculement rapide entre environnements | GitHub |
| Parallel Pi agents | Exécution parallèle de tâches par plusieurs agents | GitHub |
| pi-extensions (narumiruna) | Automatisation, planification, contrôle du navigateur, collection de workflows Git | GitHub |
| pi-extensions-skill (Dwsy) | Guide d'apprentissage progressif du développement d'extensions | GitHub |
| pi-extension-builder (LobeHub) | Générateur de scaffold d'extensions | LobeHub |
FAQ
Quelle est la différence entre les extensions et les Skills de Pi Agent ?
Les Extensions sont des modules TypeScript chargés à l'exécution, capables d'intercepter des événements, d'enregistrer des outils, d'ajouter des commandes, avec un accès complet au système. Les Skills sont des fichiers Markdown chargés à la demande dans le contexte du LLM, définissant des workflows et des prompts, sans capacité d'exécution directe de code. En résumé : l'Extension est une « extension au niveau du code », le Skill est une « extension au niveau du prompt ».
Faut-il connaître TypeScript pour développer des extensions ?
Des compétences de base en TypeScript sont nécessaires. Pi utilise jiti pour charger directement les fichiers .ts, sans étape de compilation. Si vous ne connaissez que JavaScript, vous pouvez aussi écrire — changez simplement l'extension du fichier en .js, les définitions de types sont optionnelles.
Comment déboguer une extension ?
Lancez avec pi -e ./my-ext.ts, ajoutez des console.log dans le code — les sorties s'afficheront en bas du TUI de Pi. Vous pouvez aussi utiliser ctx.ui.notify() pour afficher des notifications. Pour les logiques complexes, il est recommandé d'écrire des tests unitaires et de les exécuter directement avec Node.js.
Les extensions affectent-elles la vitesse de démarrage de Pi ?
Les extensions chargées de manière synchrone bloquent le démarrage. Il est recommandé de reporter les opérations coûteuses (requêtes réseau, analyse de fichiers) à l'événement session_start ou au premier appel d'outil. La fonction factory ne devrait contenir que des enregistrements légers comme pi.on() et pi.registerTool().
Peut-on appeler des API externes depuis une extension ?
Oui. Les extensions s'exécutent dans un environnement Node.js, vous pouvez utiliser fetch, http, ou n'importe quel package npm. Pensez à déclarer les dépendances dans le dependencies du package.json.
Conclusion
Le système d'extensions de Pi Agent offre aux développeurs la capacité complète de « transformer leur assistant IA ». De l'interception de commandes dangereuses à l'enregistrement d'outils personnalisés, de l'automatisation Git à la recherche dans la base de connaissances, la combinaison TypeScript + événementiel rend les extensions à la fois flexibles et contrôlables.
Les 4 projets pratiques de cet article couvrent les scénarios les plus courants : génération de modèles, workflow Git, qualité du code, recherche de connaissances. Vous pouvez les utiliser directement ou les adapter comme modèles pour créer davantage d'extensions.
Prochaines étapes suggérées :
- Lire la documentation officielle sur les Extensions
- Consulter les exemples dans examples/extensions/
- Trouver de l'inspiration sur pi.dev/packages, ou publier votre première extension