왜 Pi Agent에 확장을 작성해야 할까?
Pi Coding Agent를 사용해 본 사람이라면 알겠지만, 이 도구는 "작지만 아름다운" 터미널 AI 프로그래밍 도우미입니다. 핵심은 단 하나, LLM의 능력을 터미널 워크플로우에 연결하는 것입니다. 하지만 이를 진정으로 "나만의 도구"로 만들어주는 것은 바로 확장 시스템(Extensions) 입니다.
Pi의 확장은 TypeScript 모듈로, 라이프사이클 이벤트를 구독하고, LLM이 호출할 수 있는 커스텀 도구를 등록하고, 슬래시 명령을 추가하고, TUI 컴포넌트를 커스터마이징할 수도 있습니다. 공식 생태계에는 이미 pi-hosts(23 pts), Parallel Pi agents(8 pts) 등 커뮤니티 프로젝트가 있지만, 한국어 심층 기술 자료는 여전히 드문 편입니다.
아직 Pi를 설치하지 않았다면, 먼저 이 입문 글을 읽어보시길 권장합니다: 2026년 5대 오픈소스 AI 코딩 에이전트 비교. Pi의 포지셔닝 비교가 포함되어 있습니다. 이 글에서는 확장 개발에 집중하여, 처음부터 4가지 실전 확장을 만들어보겠습니다.
Pi Agent 아키텍처 분석
핵심 구성 요소
Pi의 아키텍처는 한 마디로 요약할 수 있습니다: 최소 커널 + 확장 가능한 경계.
┌─────────────────────────────────────────────┐
│ Pi 핵심 커널 │
│ ├─ LLM Provider 레이어 (Anthropic/OpenAI/로컬) │
│ ├─ Session 관리 (세션, 압축, 브랜치) │
│ ├─ 내장 도구 (bash, read, write, search…) │
│ └─ TUI 렌더링 (Ink/React) │
└─────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
Extensions Skills Packages
(TypeScript) (Markdown) (npm/git)
- Extensions: TypeScript 모듈로, 런타임에 로드되며 이벤트를 가로채고, 도구를 등록하고, 명령을 추가할 수 있습니다.
- Skills: Markdown 파일로, 필요할 때 컨텍스트에 로드되어 워크플로우와 프롬프트를 정의합니다.
- Packages: npm이나 git을 통해 배포되는 확장 모음입니다.
플러그인 시스템 원리
Pi의 확장은 jiti를 기반으로 로드되어, 컴파일 없이 TypeScript를 직접 실행할 수 있습니다. 확장의 진입점은 기본 export되는 팩토리 함수이며, ExtensionAPI 인스턴스를 받습니다:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 여기서 이벤트 구독, 도구 등록, 명령 추가
}
확장은 다음 두 위치에 배치하면 자동으로 감지됩니다:
| 위치 | 범위 |
|---|---|
~/.pi/agent/extensions/*.ts |
전역 (모든 프로젝트) |
.pi/extensions/*.ts |
프로젝트 로컬 (신뢰할 수 있는 프로젝트 필요) |
API 인터페이스 설명
ExtensionAPI는 세 가지 기능을 제공합니다:
- 이벤트 구독:
pi.on("event_name", handler)—session_start부터tool_call까지 전체 라이프사이클을 커버합니다. - 도구 등록:
pi.registerTool({ name, description, parameters, execute })— LLM이 커스텀 함수를 호출할 수 있게 합니다. - 명령 등록:
pi.registerCommand("name", { handler })—/name슬래시 명령을 추가합니다.
개발 환경 설정
사전 요구 사항
- Node.js 18+ (20 LTS 권장)
- npm 또는 pnpm
- Pi Coding Agent 설치 완료:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
확장 프로젝트 생성
# 전역 확장 디렉토리 생성
mkdir -p ~/.pi/agent/extensions/my-first-ext
cd ~/.pi/agent/extensions/my-first-ext
# package.json 초기화
npm init -y
# 타입 정의 설치
npm install @earendil-works/pi-coding-agent typebox
# 진입 파일 생성
touch index.ts
디렉토리 구조
my-first-ext/
├── package.json
├── node_modules/
└── src/
└── index.ts # 진입점
package.json에 확장 진입점을 선언해야 합니다:
{
"name": "my-first-ext",
"dependencies": {
"@earendil-works/pi-coding-agent": "^1.0.0",
"typebox": "^0.34.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}
첫 번째 확장: Hello World
기본 버전: 알림 + 명령
// src/index.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 세션 시작 시 알림 표시
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Hello World 확장이 로드되었습니다!", "info");
});
// /hello 명령 등록
pi.registerCommand("hello", {
description: "Pi에게 인사하기",
handler: async (args, ctx) => {
const name = args?.trim() || "World";
ctx.ui.notify(`Hello, ${name}! 👋`, "info");
},
});
}
테스트 실행
# 방법 1: 자동 감지 (~/.pi/agent/extensions/ 에 배치)
pi
# 방법 2: 명시적 지정 (임시 테스트)
pi -e ./src/index.ts
Pi에 진입한 후 /hello Kevin을 입력하면, 알림을 확인할 수 있습니다: Hello, Kevin! 👋.
심화: 위험한 명령 가로채기
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("⚠️ 위험한 작업", "rm -rf 실행을 확인하시겠습니까?");
if (!ok) return { block: true, reason: "사용자가 취소했습니다" };
}
});
이 확장은 tool_call 이벤트의 가로채기 능력을 보여줍니다 — LLM이 도구를 실행하기 전에 권한 검사를 수행할 수 있습니다.
실전 프로젝트 1: 커스텀 코드 템플릿 생성기
요구 사항
팀에 정해진 코드 템플릿(React 컴포넌트, Express 라우트, Python 스크립트 등)이 있고, 새 파일을 만들 때마다 복사-붙여넣기를 반복해야 합니다. 확장을 작성하여 LLM이 generate_template 도구를 자동으로 호출하도록 해봅시다.
구현
// 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
"""{{Name}} module."""
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: "템플릿 생성기",
description: "템플릿 이름과 컴포넌트 이름에 따라 코드 파일을 생성합니다. 사용 가능한 템플릿: react-component, express-route, python-script",
parameters: Type.Object({
template: Type.String({ description: "템플릿 이름" }),
name: Type.String({ description: "컴포넌트/모듈 이름 (PascalCase)" }),
outputPath: Type.String({ description: "출력 파일 경로" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const tpl = TEMPLATES[params.template];
if (!tpl) {
return {
content: [{ type: "text", text: `❌ 알 수 없는 템플릿: ${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: `✅ ${outPath} 생성 완료\n\n${code}` }],
details: { path: outPath, template: params.template },
};
},
});
}
사용 방법
Pi에서 직접 말합니다:
react-component 템플릿으로 UserProfile 컴포넌트를 생성해서 src/components/UserProfile.tsx에 저장해줘
Pi가 generate_template 도구를 호출하여 변수 치환과 파일 쓰기를 자동으로 수행합니다.
실전 프로젝트 2: Git 워크플로우 자동화
요구 사항
커밋 전에 자동으로 lint를 실행하고, commit message를 생성하고, 푸시 후 팀에 알림을 보냅니다. 확장을 사용하여 bash 도구를 가로채고, git commit이 감지될 때 사전 검사를 주입합니다.
구현
// src/git-workflow.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execSync } from "node:child_process";
export default function (pi: ExtensionAPI) {
// git commit 가로채기, 사전 lint 실행
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", "🔍 pre-commit 검사 실행 중...");
try {
execSync("npm run lint --if-present", { stdio: "pipe" });
execSync("npm run test --if-present", { stdio: "pipe" });
ctx.ui.setStatus("git-workflow", "✅ 검사 통과, 커밋 계속");
} catch (err) {
ctx.ui.notify("❌ Lint/Test 실패, 커밋이 차단되었습니다", "error");
return { block: true, reason: "pre-commit checks failed" };
}
}
// git push 가로채기, 알림 전송
if (cmd.startsWith("git push")) {
ctx.ui.setStatus("git-workflow", "📤 푸시 중...");
// 여기서 webhook을 호출하여 팀에 알림 가능
}
});
// /git-summary 명령 등록
pi.registerCommand("git-summary", {
description: "현재 브랜치의 커밋 요약 표시",
handler: async (_args, ctx) => {
try {
const log = execSync(
"git log --oneline -10 --format='%h %s (%an, %ar)'",
{ encoding: "utf-8" }
);
ctx.ui.notify(`최근 10개 커밋:\n${log}`, "info");
} catch {
ctx.ui.notify("❌ git 기록을 읽을 수 없습니다", "error");
}
},
});
}
효과
- Pi가
git commit을 실행하려고 할 때, 확장이 자동으로 lint와 테스트를 실행하고, 실패 시 커밋을 차단합니다. /git-summary명령으로 최근 커밋을 빠르게 확인할 수 있습니다.
실전 프로젝트 3: 코드 품질 검사 통합
요구 사항
LLM이 코드를 작성할 때 외부 품질 검사 도구(eslint, pylint, semgrep 등)를 자동으로 호출하고, 결과에 수정 제안을 포함하도록 합니다.
구현
// 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: "코드 품질 검사",
description: "지정된 파일에 대해 lint/정적 분석을 실행하고, 문제 목록과 수정 제안을 반환합니다",
parameters: Type.Object({
filePath: Type.String({ description: "검사할 파일 경로" }),
linter: Type.Optional(
Type.String({ description: "linter 지정 (eslint/pylint/semgrep), 비워두면 자동 감지" })
),
}),
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: "⚠️ 일치하는 linter를 찾을 수 없습니다" }],
details: {},
};
}
ctx.ui.setStatus("quality", `🔍 ${linter.cmd.split(" ")[0]} 실행 중...`);
try {
const output = execSync(`${linter.cmd} ${file}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return {
content: [{ type: "text", text: `✅ 문제 없음\n${output}` }],
details: { file, linter: linter.cmd },
};
} catch (err: any) {
// linter는 보통 0이 아닌 종료 코드로 문제를 반환
const output = err.stdout || err.stderr || "";
return {
content: [{ type: "text", text: `⚠️ 문제 발견:\n${output.slice(0, 3000)}` }],
details: { file, linter: linter.cmd, hasIssues: true },
};
}
},
});
}
사용 방법
src/auth/login.ts의 코드 품질을 검사해줘
Pi가 check_code_quality를 호출하여 lint 결과를 반환하고, 자동으로 수정 제안을 제공합니다.
실전 프로젝트 4: 커스텀 검색 도구 통합
요구 사항
Pi가 팀 내부 지식 베이스(Notion, Confluence, 또는 로컬 Markdown 파일 등)를 검색할 수 있도록 합니다. 여기서는 로컬 Markdown 지식 베이스를 예로 듭니다.
구현
// 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: "지식 베이스 검색",
description: "로컬 Markdown 지식 베이스에서 관련 내용을 검색합니다",
parameters: Type.Object({
query: Type.String({ description: "검색 키워드" }),
kbRoot: Type.Optional(Type.String({ description: "지식 베이스 루트 디렉토리, 기본값 ~/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: `❌ 지식 베이스 디렉토리가 존재하지 않습니다: ${resolved}` }],
details: {},
};
}
const results = searchMarkdown(params.query, resolved);
if (results.length === 0) {
return {
content: [{ type: "text", text: "🔍 관련 내용을 찾을 수 없습니다" }],
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}건 일치:\n\n${formatted}` }],
details: { query: params.query, count: results.length },
};
},
});
}
사용 방법
환경 변수 KB_ROOT=~/my-docs를 설정한 후, Pi에서:
지식 베이스에서 API 인증에 관한 내용을 검색해줘
Pi가 search_knowledge_base를 호출하여 일치하는 파일 스니펫을 반환합니다.
고급 주제
다중 Agent 협업
Pi는 확장을 통해 다중 Agent 모드를 지원합니다. 핵심 아이디어는: 메인 Agent가 작업을 받아 분할한 후, 서브 Agent에게 위임하는 것입니다(bash 도구로 새 pi 프로세스를 시작하고, --resume이나 세션 파일로 컨텍스트를 공유).
pi.registerTool({
name: "delegate_to_specialist",
label: "전문가 위임",
description: "하위 작업을 전문 Agent에게 위임합니다",
parameters: Type.Object({
task: Type.String({ description: "하위 작업 설명" }),
specialist: Type.String({ description: "전문가 유형: 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", `🤖 ${params.specialist}에게 위임 중...`);
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: `❌ 위임 실패: ${err.message}` }],
details: {},
};
}
},
});
성능 최적화
- 지연 로딩: 팩토리 함수에서 백그라운드 프로세스를 시작하지 말고,
session_start나 첫 도구 호출 시점으로 미루십시오. - 결과 캐싱: 자주 호출되는 도구(lint 등)는 Map으로 파일 해시와 결과를 캐시하여 중복 실행을 방지하십시오.
- 스트리밍 업데이트:
onUpdate콜백으로 실시간 진행 상황을 전달하여 UI 멈춤을 방지하십시오.
// 캐시 예시
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 } };
// ... 검사 실행 ...
cache.set(key, result);
return { content: [{ type: "text", text: result }], details: {} };
}
확장 배포 및 공유
Pi 확장은 npm이나 git 저장소를 통해 pi packages로 배포할 수 있습니다:
// package.json
{
"name": "pi-ext-my-tools",
"version": "1.0.0",
"pi": {
"extensions": ["./src/index.ts"]
}
}
사용자 설치 방법:
# npm에서
pi install npm:pi-ext-my-tools@1.0.0
# git에서
pi install git:github.com/user/repo@v1
pi.dev/packages에서 공식 패키지 디렉토리에 배포할 수도 있습니다.
커뮤니티 추천 확장
| 확장 | 기능 | 링크 |
|---|---|---|
| pi-hosts | /etc/hosts 관리, 환경 빠른 전환 | GitHub |
| Parallel Pi agents | 다중 Agent 병렬 작업 실행 | GitHub |
| pi-extensions (narumiruna) | 자동화, 플래닝, 브라우저 제어, Git 워크플로우 모음 | GitHub |
| pi-extensions-skill (Dwsy) | 단계별 확장 개발 학습 가이드 | GitHub |
| pi-extension-builder (LobeHub) | 확장 스캐폴드 생성기 | LobeHub |
자주 묻는 질문 FAQ
Pi Agent 확장과 Skills의 차이점은 무엇인가요?
Extensions는 TypeScript 모듈로, 런타임에 로드되며 이벤트를 가로채고, 도구를 등록하고, 명령을 추가할 수 있어 완전한 시스템 권한을 가집니다. Skills는 Markdown 파일로, 필요할 때 LLM 컨텍스트에 로드되어 워크플로우와 프롬프트를 정의하지만, 코드를 직접 실행할 수는 없습니다. 간단히 말하면: Extension은 "코드 수준 확장", Skill은 "프롬프트 수준 확장"입니다.
확장 개발에 TypeScript를 알아야 하나요?
기본적인 TypeScript 능력이 필요합니다. Pi는 jiti를 사용하여 .ts 파일을 직접 로드하므로 컴파일 단계가 없습니다. JavaScript만 알아도 작성할 수 있습니다 — 파일 확장자를 .js로 변경하면 됩니다. 타입 정의는 선택 사항입니다.
확장은 어떻게 디버깅하나요?
pi -e ./my-ext.ts로 시작하고, 코드에 console.log를 추가하면 출력이 Pi의 TUI 하단에 표시됩니다. ctx.ui.notify()로 알림을 띄울 수도 있습니다. 복잡한 로직은 단위 테스트를 작성하여 Node.js로 직접 실행하는 것을 권장합니다.
확장이 Pi의 시작 속도에 영향을 주나요?
동기적으로 로드되는 확장은 시작을 차단합니다. 시간이 걸리는 작업(네트워크 요청, 파일 스캔)은 session_start 이벤트나 첫 도구 호출 시점으로 미루는 것이 좋습니다. 팩토리 함수에서는 pi.on()과 pi.registerTool() 같은 가벼운 등록만 수행하십시오.
확장에서 외부 API를 호출할 수 있나요?
가능합니다. 확장은 Node.js 환경에서 실행되므로 fetch, http, 모든 npm 패키지를 사용할 수 있습니다. package.json의 dependencies에 의존성을 선언하는 것을 잊지 마십시오.
요약
Pi Agent의 확장 시스템은 개발자에게 "AI 도우미를 개조"할 수 있는 완전한 능력을 제공합니다. 위험한 명령 가로채기부터 커스텀 도구 등록, Git 자동화부터 지식 베이스 검색까지, TypeScript + 이벤트 드리븐 조합은 확장을 유연하면서도 제어 가능하게 만듭니다.
이 글의 4가지 실전 프로젝트는 가장 일반적인 시나리오를 다룹니다: 템플릿 생성, Git 워크플로우, 코드 품질, 지식 검색. 바로 가져다 쓸 수도 있고, 템플릿으로 활용하여 더 많은 확장을 만들 수도 있습니다.
다음 단계 권장:
- 공식 Extensions 문서 읽기
- examples/extensions/의 예제 확인
- pi.dev/packages에서 영감을 얻거나, 첫 확장을 배포해보기