← スキル一覧に戻る

extension-development
by jhl-labs
like caude desktop
⭐ 42🍴 1📅 2026年1月17日
SKILL.md
name: Extension Development description: > SEPilot Desktop의 Extension 시스템 개발 가이드. Extension 구조, Manifest 정의, Agent 통합, Store slice, Components, IPC 핸들러 패턴을 다룹니다. Browser, Editor, Presentation extension 사례 기반.
Extension Development Skill
Extension 시스템 개요
SEPilot Desktop은 플러그인 아키텍처를 통해 기능을 모듈화합니다:
- 독립적: 각 extension은 별도의 디렉토리에 격리됨
- 모드 기반: Extension이 활성화하는 앱 모드 정의
- 타입 안전: TypeScript로 완전한 타입 체크
- 자동 로딩: Extension registry가 자동으로 검색 및 로드
Extension 구조
extensions/
└── my-extension/
├── index.ts # 메인 진입점 (모든 export 통합)
├── manifest.ts # Extension 메타데이터 정의
├── README.md # 문서
├── types/
│ └── index.ts # 타입 정의
├── agents/
│ └── my-agent.ts # LangGraph Agent 구현
├── tools/
│ └── my-tools.ts # Tool 정의 (MCP 또는 builtin)
├── lib/
│ └── index.ts # 비즈니스 로직
├── components/
│ ├── index.ts # 컴포넌트 export
│ ├── MyStudio.tsx # 메인 워크스페이스
│ └── MySettings.tsx # 설정 UI
├── store/
│ └── index.ts # Zustand slice
├── ipc/
│ └── handlers.ts # IPC 핸들러 (Main Process)
└── locales/
├── en.json # 영어
└── ko.json # 한국어
Manifest 정의
필수 Manifest
// extensions/my-extension/manifest.ts
import type { ExtensionManifest } from '@/lib/extensions/types';
export const manifest: ExtensionManifest = {
// 고유 ID (URL safe, lowercase)
id: 'my-extension',
// 표시 이름
name: 'My Extension',
// 설명
description: 'Extension 설명...',
// Semantic Versioning
version: '1.0.0',
// 작성자
author: 'Your Name',
// lucide-react 아이콘 이름
icon: 'Puzzle',
// 활성화할 앱 모드 (app/[mode] 경로에 대응)
mode: 'my-mode',
// 사이드바 표시 여부
showInSidebar: true,
// 모드 드롭다운 표시 순서 (낮을수록 위)
order: 5,
// 기본 활성화 여부
enabled: true,
// 의존하는 다른 extension
dependencies: [],
};
export default manifest;
ExtensionManifest 타입
export interface ExtensionManifest {
id: string;
name: string;
description: string;
version: string;
author: string;
icon: string; // lucide-react icon name
mode: string;
showInSidebar: boolean;
order?: number;
enabled?: boolean;
dependencies?: string[];
betaFlag?: string; // Beta 기능 플래그 (예: 'enablePresentationMode')
settingsSchema?: Record<string, unknown>;
ipcChannels?: {
handlers: string[];
};
settingsTab?: {
id: string;
label: string;
description: string;
icon: string;
};
}
Settings Schema 예시
export const manifest: ExtensionManifest = {
// ... other fields
settingsSchema: {
fontSize: {
type: 'number',
default: 14,
description: 'Editor font size',
},
wordWrap: {
type: 'boolean',
default: false,
description: 'Word wrap',
},
theme: {
type: 'string',
enum: ['light', 'dark', 'auto'],
default: 'auto',
description: 'Editor theme',
},
},
settingsTab: {
id: 'my-extension',
label: 'settings.myExtension.title', // i18n key
description: 'settings.myExtension.description',
icon: 'Settings',
},
};
Extension Definition
ExtensionDefinition 인터페이스
export interface ExtensionDefinition {
manifest: ExtensionManifest;
// 메인 컴포넌트 (Studio/Workspace)
MainComponent?: ComponentType;
// 사이드바 컴포넌트
SidebarComponent?: ComponentType;
// 사이드바 헤더 액션 버튼
HeaderActionsComponent?: ComponentType<any>;
// Beta Settings 컴포넌트
SettingsComponent?: ComponentType<{
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
}>;
// Settings Dialog 탭 컴포넌트
SettingsTabComponent?: ComponentType<{
onSave: () => void;
isSaving: boolean;
message: { type: 'success' | 'error'; text: string } | null;
}>;
// Store slice 생성
createStoreSlice?: (set: any, get: any) => any;
// IPC Handler 등록 (Main Process)
setupIpcHandlers?: () => void;
// 세션 초기화
clearSession?: () => void;
// Extension 활성화/비활성화
activate?: () => void | Promise<void>;
deactivate?: () => void | Promise<void>;
}
메인 진입점 (index.ts)
// extensions/my-extension/index.ts
export { manifest } from './manifest';
export * from './types';
export * from './lib';
export * from './components';
export * from './store';
import type { ExtensionDefinition } from '@/lib/extensions/types';
import { manifest } from './manifest';
import { MyStudio } from './components/MyStudio';
import { MySidebar } from './components/MySidebar';
import { createMyExtensionSlice } from './store';
import { setupMyExtensionHandlers } from './ipc/handlers';
export const definition: ExtensionDefinition = {
manifest,
MainComponent: MyStudio,
SidebarComponent: MySidebar,
createStoreSlice: createMyExtensionSlice,
setupIpcHandlers: setupMyExtensionHandlers,
};
Agent 통합
Agent State 정의
// extensions/my-extension/types/index.ts
import type { AgentState } from '@/lib/langgraph/state';
export interface MyExtensionAgentState extends AgentState {
// Extension 전용 상태
myData?: {
items: Array<{ id: string; content: string }>;
currentStep: number;
completed: boolean;
};
// RAG 지원
useRag?: boolean;
ragDocuments?: Array<{
id: string;
content: string;
metadata: Record<string, any>;
}>;
}
Agent 구현 (LangGraph)
// extensions/my-extension/agents/my-agent.ts
import { AgentState } from '@/lib/langgraph/state';
import type { Message, ToolCall } from '@/types';
import { getLLMClient } from '@/lib/llm/client';
import { emitStreamingChunk } from '@/lib/llm/streaming-callback';
import { logger } from '@/lib/utils/logger';
import type { MyExtensionAgentState } from '../types';
export class MyExtensionAgent {
private maxIterations: number;
constructor(maxIterations = 50) {
this.maxIterations = maxIterations;
}
/**
* Agent 스트리밍 실행
*/
async *stream(
initialState: MyExtensionAgentState,
toolApprovalCallback?: (toolCalls: ToolCall[]) => Promise<boolean>
): AsyncGenerator<any, void, unknown> {
let state = { ...initialState };
logger.info('[MyExtensionAgent] Starting with state:', {
useRag: state.useRag,
dataItems: state.myData?.items?.length,
});
// Add system message
const systemMessage: Message = {
id: `system-${Date.now()}`,
role: 'system',
content: await this.buildSystemPrompt(state),
created_at: Date.now(),
};
state = {
...state,
messages: [systemMessage, ...state.messages],
};
let iterations = 0;
while (iterations < this.maxIterations) {
logger.info(`[MyExtensionAgent] Iteration ${iterations + 1}/${this.maxIterations}`);
// 1. Generate response
const generateResult = await this.generateNode(state);
state = { ...state, ...generateResult };
const lastMessage = state.messages[state.messages.length - 1];
yield {
type: 'message',
message: lastMessage,
};
// 2. Check if should use tools
const decision = this.shouldUseTool(state);
if (decision === 'end') {
logger.info('[MyExtensionAgent] No more tools to call, ending');
break;
}
// 3. Tool approval (if needed)
if (toolApprovalCallback && lastMessage.tool_calls) {
const approved = await toolApprovalCallback(lastMessage.tool_calls);
if (!approved) {
logger.info('[MyExtensionAgent] Tool calls not approved');
break;
}
}
// 4. Execute tools
const toolsResult = await this.toolsNode(state);
state = {
...state,
messages: [
...state.messages,
...toolsResult.toolResults.map((result: any) => ({
id: `tool-result-${result.toolCallId}`,
role: 'tool' as const,
content: result.content,
name: result.toolName,
tool_call_id: result.toolCallId,
created_at: Date.now(),
})),
],
};
yield {
type: 'tool_results',
toolResults: toolsResult.toolResults,
};
iterations++;
}
}
/**
* Generate node
*/
private async generateNode(
state: MyExtensionAgentState
): Promise<Partial<MyExtensionAgentState>> {
const client = getLLMClient();
const provider = client.getProvider();
let content = '';
const toolCalls: ToolCall[] = [];
try {
for await (const chunk of provider.streamChat(state.messages, {
tools: this.getTools(),
toolChoice: 'auto',
})) {
if (typeof chunk === 'string') {
content += chunk;
emitStreamingChunk(chunk, state.conversationId);
} else if (chunk.type === 'tool_use') {
toolCalls.push({
id: chunk.id,
name: chunk.name,
arguments: chunk.input,
});
}
}
const message: Message = {
id: `msg-${Date.now()}`,
role: 'assistant',
content,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
created_at: Date.now(),
};
return {
messages: [...state.messages, message],
};
} catch (error: any) {
logger.error('[MyExtensionAgent] Generate error:', error);
throw error;
}
}
/**
* Tools node
*/
private async toolsNode(state: MyExtensionAgentState): Promise<{ toolResults: any[] }> {
const lastMessage = state.messages[state.messages.length - 1];
if (!lastMessage.tool_calls) {
return { toolResults: [] };
}
const toolResults = [];
for (const toolCall of lastMessage.tool_calls) {
try {
const result = await this.executeTool(toolCall.name, toolCall.arguments, state);
toolResults.push({
toolCallId: toolCall.id,
toolName: toolCall.name,
content: JSON.stringify(result),
});
} catch (error: any) {
logger.error(`[MyExtensionAgent] Tool ${toolCall.name} failed:`, error);
toolResults.push({
toolCallId: toolCall.id,
toolName: toolCall.name,
content: JSON.stringify({ error: error.message }),
error: true,
});
}
}
return { toolResults };
}
/**
* Execute tool
*/
private async executeTool(name: string, args: any, state: MyExtensionAgentState): Promise<any> {
switch (name) {
case 'my_tool_1':
return await this.myTool1(args);
case 'my_tool_2':
return await this.myTool2(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
/**
* Tool implementations
*/
private async myTool1(args: any): Promise<any> {
// Tool logic
return { success: true, result: 'Tool 1 executed' };
}
private async myTool2(args: any): Promise<any> {
// Tool logic
return { success: true, result: 'Tool 2 executed' };
}
/**
* Get available tools
*/
private getTools(): any[] {
return [
{
name: 'my_tool_1',
description: 'Tool 1 description',
input_schema: {
type: 'object',
properties: {
param1: { type: 'string', description: 'Parameter 1' },
},
required: ['param1'],
},
},
{
name: 'my_tool_2',
description: 'Tool 2 description',
input_schema: {
type: 'object',
properties: {
param2: { type: 'number', description: 'Parameter 2' },
},
required: ['param2'],
},
},
];
}
/**
* Should use tool decision
*/
private shouldUseTool(state: MyExtensionAgentState): 'continue' | 'end' {
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage.role === 'assistant' && lastMessage.tool_calls) {
return 'continue';
}
return 'end';
}
/**
* Build system prompt
*/
private async buildSystemPrompt(state: MyExtensionAgentState): Promise<string> {
const parts = [
'You are My Extension AI Assistant.',
'You can help users with tasks specific to this extension.',
'',
'# Available Tools:',
'- my_tool_1: Tool 1 description',
'- my_tool_2: Tool 2 description',
'',
'# Guidelines:',
'- Be helpful and proactive',
'- Use tools when appropriate',
'- Explain your actions clearly',
];
if (state.myData) {
parts.push('');
parts.push('# Current Data:');
parts.push(`Items: ${state.myData.items.length}`);
parts.push(`Step: ${state.myData.currentStep}`);
}
return parts.join('\n');
}
}
Components
Main Studio Component
// extensions/my-extension/components/MyStudio.tsx
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useChatStore } from '@/lib/store/chat-store';
export function MyStudio() {
const [data, setData] = useState<any>(null);
const currentConversationId = useChatStore((state) => state.currentConversationId);
useEffect(() => {
// Load data when conversation changes
if (currentConversationId) {
loadData(currentConversationId);
}
}, [currentConversationId]);
const loadData = async (conversationId: string) => {
const result = await window.electron.invoke('my-extension:load-data', {
conversationId,
});
if (result.success) {
setData(result.data);
}
};
const handleAction = async () => {
const result = await window.electron.invoke('my-extension:perform-action', {
conversationId: currentConversationId,
action: 'do-something',
});
if (result.success) {
setData(result.data);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-semibold">My Extension</h2>
<Button onClick={handleAction}>Perform Action</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{data ? (
<Card className="p-4">
<pre>{JSON.stringify(data, null, 2)}</pre>
</Card>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
No data yet
</div>
)}
</div>
</div>
);
}
Settings Component
// extensions/my-extension/components/MySettings.tsx
'use client';
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
interface MySettingsProps {
onSave: () => void;
isSaving: boolean;
message: { type: 'success' | 'error'; text: string } | null;
}
export function MySettings({ onSave, isSaving, message }: MySettingsProps) {
const [fontSize, setFontSize] = useState(14);
const [wordWrap, setWordWrap] = useState(false);
const handleSave = async () => {
await window.electron.invoke('my-extension:save-settings', {
fontSize,
wordWrap,
});
onSave();
};
return (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="fontSize">Font Size</Label>
<Input
id="fontSize"
type="number"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="wordWrap">Word Wrap</Label>
<Switch id="wordWrap" checked={wordWrap} onCheckedChange={setWordWrap} />
</div>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Settings'}
</Button>
{message && (
<div
className={`rounded-md p-3 ${message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}
>
{message.text}
</div>
)}
</div>
);
}
Store Slice
// extensions/my-extension/store/index.ts
import { StateCreator } from 'zustand';
export interface MyExtensionSlice {
myData: Array<{ id: string; content: string }>;
currentStep: number;
completed: boolean;
// Actions
setMyData: (data: Array<{ id: string; content: string }>) => void;
setCurrentStep: (step: number) => void;
setCompleted: (completed: boolean) => void;
resetMyExtension: () => void;
}
export const createMyExtensionSlice: StateCreator<MyExtensionSlice, [], [], MyExtensionSlice> = (
set
) => ({
myData: [],
currentStep: 0,
completed: false,
setMyData: (data) => set({ myData: data }),
setCurrentStep: (step) => set({ currentStep: step }),
setCompleted: (completed) => set({ completed }),
resetMyExtension: () =>
set({
myData: [],
currentStep: 0,
completed: false,
}),
});
IPC 핸들러
// extensions/my-extension/ipc/handlers.ts
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import { logger } from '@/lib/utils/logger';
export function setupMyExtensionHandlers() {
ipcMain.handle(
'my-extension:load-data',
async (event: IpcMainInvokeEvent, data: { conversationId: string }) => {
try {
logger.info('[MyExtension] Loading data for conversation:', data.conversationId);
// Load data logic
const result = {
items: [
{ id: '1', content: 'Item 1' },
{ id: '2', content: 'Item 2' },
],
currentStep: 0,
completed: false,
};
return { success: true, data: result };
} catch (error: any) {
logger.error('[MyExtension] Load data error:', error);
return {
success: false,
error: error.message,
};
}
}
);
ipcMain.handle(
'my-extension:perform-action',
async (event: IpcMainInvokeEvent, data: { conversationId: string; action: string }) => {
try {
logger.info('[MyExtension] Performing action:', data.action);
// Action logic
const result = {
success: true,
message: `Action ${data.action} completed`,
};
return { success: true, data: result };
} catch (error: any) {
logger.error('[MyExtension] Action error:', error);
return {
success: false,
error: error.message,
};
}
}
);
ipcMain.handle('my-extension:save-settings', async (event: IpcMainInvokeEvent, settings: any) => {
try {
logger.info('[MyExtension] Saving settings:', settings);
// Save settings logic
// Use databaseService.setSetting() or file system
return { success: true };
} catch (error: any) {
logger.error('[MyExtension] Save settings error:', error);
return {
success: false,
error: error.message,
};
}
});
}
다국어 지원 (Locales)
// extensions/my-extension/locales/ko.json
{
"myExtension": {
"title": "나의 확장 기능",
"description": "확장 기능 설명",
"actions": {
"performAction": "작업 수행",
"loadData": "데이터 로드"
},
"messages": {
"success": "작업이 성공적으로 완료되었습니다",
"error": "작업 중 오류가 발생했습니다"
}
},
"settings": {
"myExtension": {
"title": "나의 확장 기능 설정",
"description": "확장 기능 설정을 관리합니다",
"fontSize": "글꼴 크기",
"wordWrap": "자동 줄바꿈"
}
}
}
// extensions/my-extension/locales/en.json
{
"myExtension": {
"title": "My Extension",
"description": "Extension description",
"actions": {
"performAction": "Perform Action",
"loadData": "Load Data"
},
"messages": {
"success": "Action completed successfully",
"error": "An error occurred"
}
},
"settings": {
"myExtension": {
"title": "My Extension Settings",
"description": "Manage extension settings",
"fontSize": "Font Size",
"wordWrap": "Word Wrap"
}
}
}
Extension 등록
자동 등록 (권장)
Extension registry가 extensions/ 디렉토리를 자동으로 스캔합니다.
// Extension이 올바른 구조를 가지고 있으면 자동으로 로드됨
// - manifest.ts 존재
// - index.ts에서 definition export
수동 등록 (Advanced)
// lib/extensions/registry.ts에서 수동 등록
import { registerExtension } from '@/lib/extensions/registry';
import { definition as myExtensionDefinition } from '@/extensions/my-extension';
registerExtension(myExtensionDefinition);
Best Practices
1. Extension 격리
// ✅ Good - Extension 내부에서 모든 것 관리
extensions/my-extension/
├── agents/
├── components/
├── store/
└── ipc/
// ❌ Bad - 외부 의존성이 많음
// Extension 코드가 lib/, components/ 등에 흩어져 있음
2. TypeScript Strict Mode
// ✅ Good - 타입 안전
export interface MyExtensionAgentState extends AgentState {
myData?: {
items: Array<{ id: string; content: string }>;
};
}
// ❌ Bad - any 남발
export interface MyExtensionAgentState {
myData?: any;
}
3. IPC 에러 처리
// ✅ Good - 명확한 에러 처리
ipcMain.handle('my-extension:action', async (event, data) => {
try {
const result = await performAction(data);
return { success: true, data: result };
} catch (error: any) {
logger.error('[MyExtension] Action error:', error);
return {
success: false,
error: error.message,
};
}
});
// ❌ Bad - 에러 무시
ipcMain.handle('my-extension:action', async (event, data) => {
const result = await performAction(data);
return result;
});
4. Store Slice 분리
// ✅ Good - Extension slice 분리
export const createMyExtensionSlice: StateCreator<MyExtensionSlice> = (set) => ({
myData: [],
setMyData: (data) => set({ myData: data }),
});
// ❌ Bad - Global store에 직접 추가
// chat-store.ts에 extension 상태 직접 추가
5. Component 명명
// ✅ Good - 명확한 이름
export function MyExtensionStudio() {}
export function MyExtensionSidebar() {}
export function MyExtensionSettings() {}
// ❌ Bad - 모호한 이름
export function Studio() {} // 어느 extension의 Studio?
export function Sidebar() {}
실제 예제
기존 구현 참고:
extensions/browser/- Browser Agent, Vision Fallback, Toolsextensions/editor/- Monaco Editor, File Tree, Terminalextensions/presentation/- PPT Agent, Templates, Exporterslib/extensions/types.ts- Extension System 타입 정의lib/extensions/registry.ts- Extension 등록 및 관리
체크리스트
Extension 생성 시:
- manifest.ts 정의 (id, name, description, version, icon, mode)
- index.ts에서 모든 export 통합
- README.md 작성
- types/ 디렉토리에 타입 정의
- components/ 디렉토리에 UI 컴포넌트
- store/ 디렉토리에 Zustand slice
- locales/ 디렉토리에 다국어 파일 (ko.json, en.json)
- IPC 핸들러 필요 시 ipc/ 디렉토리 생성
- Agent 필요 시 agents/ 디렉토리 생성
- Tools 필요 시 tools/ 디렉토리 생성
테스트:
- Extension이 모드 드롭다운에 표시되는지
- 사이드바 컴포넌트가 올바르게 렌더링되는지
- IPC 통신이 정상 작동하는지
- 설정이 저장되고 로드되는지
- 다국어 전환이 올바르게 동작하는지
- Agent가 정상적으로 실행되는지
スコア
総合スコア
65/100
リポジトリの品質指標に基づく評価
✓SKILL.md
SKILL.mdファイルが含まれている
+20
✓LICENSE
ライセンスが設定されている
+10
○説明文
100文字以上の説明がある
0/10
○人気
GitHub Stars 100以上
0/15
○最近の活動
3ヶ月以内に更新がある
0/10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
✓タグ
1つ以上のタグが設定されている
+5
レビュー
💬
レビュー機能は近日公開予定です


