sa-assistant 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agents/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # SA 에이전트 모듈
2
+ # Orchestrator + Guru Sub-agents + Specialist Sub-agents 아키텍처
3
+
4
+ from .orchestrator import (
5
+ create_orchestrator,
6
+ consult_guru,
7
+ list_gurus,
8
+ get_available_gurus,
9
+ )
10
+
11
+ from .specialists import (
12
+ consult_specialist,
13
+ list_specialists,
14
+ get_available_specialists,
15
+ parallel_research,
16
+ run_specialists_parallel,
17
+ )
18
+
19
+ __all__ = [
20
+ # Orchestrator
21
+ "create_orchestrator",
22
+ # Guru
23
+ "consult_guru",
24
+ "list_gurus",
25
+ "get_available_gurus",
26
+ # Specialist
27
+ "consult_specialist",
28
+ "list_specialists",
29
+ "get_available_specialists",
30
+ "parallel_research",
31
+ "run_specialists_parallel",
32
+ ]
agents/orchestrator.py ADDED
@@ -0,0 +1,283 @@
1
+ # SA Orchestrator - AWS Solutions Architect Professional Agent
2
+ # Guru Sub-agents를 활용한 전문가 자문 시스템
3
+ # ThreadPoolExecutor를 사용하여 Sub-agent를 별도 스레드에서 실행 (OpenTelemetry 컨텍스트 격리)
4
+
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from typing import List, Any
7
+ from strands import Agent, tool
8
+
9
+ # 로컬 모듈 임포트
10
+ import sys
11
+ from pathlib import Path
12
+ sys.path.insert(0, str(Path(__file__).parent.parent))
13
+
14
+ from model.load import load_opus, load_sonnet
15
+ from prompts.system_prompts import (
16
+ ORCHESTRATOR_PROMPT,
17
+ GURU_PROMPTS,
18
+ get_orchestrator_prompt,
19
+ )
20
+
21
+
22
+ # =============================================================================
23
+ # Guru Sub-agent 도구 (ThreadPoolExecutor로 별도 스레드에서 실행)
24
+ # =============================================================================
25
+
26
+ def get_available_gurus() -> List[str]:
27
+ """사용 가능한 Guru 목록을 반환합니다."""
28
+ return list(GURU_PROMPTS.keys())
29
+
30
+
31
+ def _run_guru_in_thread(guru_name: str, question: str) -> str:
32
+ """
33
+ 별도 스레드에서 Guru Agent 실행 (OpenTelemetry 컨텍스트 격리)
34
+
35
+ ThreadPoolExecutor에서 호출되어 독립적인 스레드에서 실행됩니다.
36
+ OpenTelemetry 컨텍스트를 명시적으로 분리하여
37
+ "Failed to detach context" 오류를 방지합니다.
38
+
39
+ Args:
40
+ guru_name: Guru 이름 (소문자)
41
+ question: 자문을 구할 질문
42
+
43
+ Returns:
44
+ str: Guru의 응답 텍스트
45
+ """
46
+ # OpenTelemetry 컨텍스트 명시적 분리
47
+ try:
48
+ from opentelemetry import context as otel_context
49
+ # 새로운 빈 컨텍스트에서 실행
50
+ token = otel_context.attach(otel_context.Context())
51
+ except ImportError:
52
+ token = None
53
+
54
+ try:
55
+ guru_agent = Agent(
56
+ model=load_sonnet(enable_thinking=False),
57
+ system_prompt=GURU_PROMPTS[guru_name],
58
+ )
59
+ result = guru_agent(question)
60
+ return str(result)
61
+ finally:
62
+ # 컨텍스트 복원
63
+ if token is not None:
64
+ try:
65
+ otel_context.detach(token)
66
+ except Exception:
67
+ pass # detach 실패 무시
68
+
69
+
70
+ @tool
71
+ def consult_guru(guru_name: str, question: str) -> str:
72
+ """
73
+ 전문가 Guru에게 자문을 구합니다.
74
+
75
+ 복잡한 아키텍처 결정, 비즈니스 전략, 학습 방법 등에 대해
76
+ 저명한 전문가들의 사고방식을 기반으로 조언을 받습니다.
77
+
78
+ Args:
79
+ guru_name: Guru 이름
80
+ - "bezos": Jeff Bezos - 고객 중심 사고, 장기적 비전, Day 1 문화
81
+ - "vogels": Werner Vogels - 분산 시스템, 확장성, 운영 우수성
82
+ - "naval": Naval Ravikant - 레버리지, 비대칭적 결과, 시스템적 사고
83
+ - "feynman": Richard Feynman - 복잡한 개념 단순화, 학습 최적화
84
+ question: 자문을 구할 질문
85
+
86
+ Returns:
87
+ str: Guru의 조언
88
+
89
+ Example:
90
+ consult_guru("bezos", "이 아키텍처가 고객에게 어떤 가치를 제공하나요?")
91
+ consult_guru("vogels", "이 시스템의 확장성을 어떻게 개선할 수 있을까요?")
92
+ """
93
+ guru_name_lower = guru_name.lower()
94
+
95
+ if guru_name_lower not in GURU_PROMPTS:
96
+ available = ", ".join(get_available_gurus())
97
+ return f"알 수 없는 Guru입니다: {guru_name}. 사용 가능한 Guru: {available}"
98
+
99
+ try:
100
+ # ThreadPoolExecutor로 별도 스레드에서 Guru Agent 실행
101
+ # OpenTelemetry 컨텍스트가 스레드별로 격리되어 충돌 방지
102
+ with ThreadPoolExecutor(max_workers=1) as executor:
103
+ future = executor.submit(_run_guru_in_thread, guru_name_lower, question)
104
+ response_text = future.result()
105
+
106
+ return f"[{guru_name.upper()} GURU 조언]\n\n{response_text}"
107
+ except Exception as e:
108
+ return f"Guru 자문 중 오류 발생: {str(e)}"
109
+
110
+
111
+ @tool
112
+ def list_gurus() -> str:
113
+ """
114
+ 사용 가능한 Guru 목록과 전문 분야를 반환합니다.
115
+
116
+ Returns:
117
+ str: Guru 목록 및 설명
118
+ """
119
+ guru_descriptions = {
120
+ "bezos": "Jeff Bezos - 고객 중심 사고, 장기적 비전, Day 1 문화, 역방향 사고",
121
+ "vogels": "Werner Vogels - 분산 시스템, 확장성, 운영 우수성, 장애 대응",
122
+ "naval": "Naval Ravikant - 레버리지, 비대칭적 결과, 시스템적 사고, 복리 효과",
123
+ "feynman": "Richard Feynman - 복잡한 개념 단순화, 학습 최적화, First Principles"
124
+ }
125
+
126
+ result = "## 사용 가능한 Guru 전문가\n\n"
127
+ for name, desc in guru_descriptions.items():
128
+ result += f"- **{name}**: {desc}\n"
129
+
130
+ result += "\n`consult_guru(guru_name, question)` 도구로 자문을 구할 수 있습니다."
131
+ return result
132
+
133
+
134
+ # =============================================================================
135
+ # Orchestrator 생성
136
+ # =============================================================================
137
+
138
+ def create_orchestrator(
139
+ tools: List[Any] = None,
140
+ system_prompt: str = None,
141
+ enable_thinking: bool = True
142
+ ) -> Agent:
143
+ """
144
+ SA Orchestrator 에이전트를 생성합니다.
145
+
146
+ Args:
147
+ tools: 추가할 도구 목록 (MCP 도구 등)
148
+ system_prompt: 커스텀 시스템 프롬프트 (None이면 기본 프롬프트 사용)
149
+ enable_thinking: Extended Thinking 활성화 여부
150
+
151
+ Returns:
152
+ Agent: SA Orchestrator 에이전트
153
+ """
154
+ # 시스템 프롬프트 구성
155
+ if system_prompt is None:
156
+ system_prompt = get_orchestrator_prompt()
157
+
158
+ # 기본 도구 목록
159
+ default_tools = [
160
+ consult_guru,
161
+ list_gurus,
162
+ ]
163
+
164
+ # 추가 도구 병합
165
+ all_tools = default_tools + (tools or [])
166
+
167
+ # Conversation Manager 설정
168
+ conversation_manager = SlidingWindowConversationManager(
169
+ window_size=20 # 최근 20개 메시지 유지
170
+ )
171
+
172
+ # Orchestrator 에이전트 생성
173
+ orchestrator = Agent(
174
+ model=load_opus(enable_thinking=enable_thinking),
175
+ system_prompt=system_prompt,
176
+ tools=all_tools,
177
+ conversation_manager=conversation_manager
178
+ )
179
+
180
+ return orchestrator
181
+
182
+
183
+ # =============================================================================
184
+ # CLI 실행 지원
185
+ # =============================================================================
186
+
187
+ # ANSI 색상 코드
188
+ class Colors:
189
+ """터미널 색상 코드"""
190
+ CYAN = '\033[96m'
191
+ GREEN = '\033[92m'
192
+ YELLOW = '\033[93m'
193
+ RED = '\033[91m'
194
+ BLUE = '\033[94m'
195
+ MAGENTA = '\033[95m'
196
+ WHITE = '\033[97m'
197
+ RESET = '\033[0m'
198
+ BOLD = '\033[1m'
199
+ DIM = '\033[2m'
200
+ ORANGE = '\033[38;5;208m' # AWS Orange
201
+
202
+
203
+ def print_banner():
204
+ """SA Assistant 배너를 출력합니다."""
205
+ banner = f"""
206
+ {Colors.ORANGE}╔══════════════════════════════════════════════════════════════╗
207
+ ║ ║
208
+ ║ {Colors.WHITE}AWS SA Assistant{Colors.ORANGE} ║
209
+ ║ {Colors.DIM}Solutions Architect Professional Agent{Colors.ORANGE} ║
210
+ ║ ║
211
+ ║ {Colors.CYAN}Powered by Claude Opus 4.5 with Extended Thinking{Colors.ORANGE} ║
212
+ ║ ║
213
+ ╚══════════════════════════════════════════════════════════════╝{Colors.RESET}
214
+ """
215
+ print(banner)
216
+
217
+
218
+ def tool_callback_handler(**kwargs):
219
+ """도구 호출 콜백 핸들러"""
220
+ if "current_tool_use" in kwargs:
221
+ tool_use = kwargs["current_tool_use"]
222
+ if isinstance(tool_use, dict):
223
+ tool_name = tool_use.get("name", "")
224
+ if tool_name:
225
+ if tool_name == "consult_guru":
226
+ guru = tool_use.get("input", {}).get("guru_name", "")
227
+ print(f"\n{Colors.MAGENTA}🧙 Consulting {guru.upper()} Guru...{Colors.RESET}", flush=True)
228
+ elif tool_name == "list_gurus":
229
+ print(f"\n{Colors.BLUE}📋 Listing available Gurus...{Colors.RESET}", flush=True)
230
+ else:
231
+ print(f"\n{Colors.CYAN}🔧 [{tool_name}]{Colors.RESET}", flush=True)
232
+
233
+ if "data" in kwargs:
234
+ print(kwargs["data"], end="", flush=True)
235
+
236
+
237
+ def run_cli(tools: List[Any] = None):
238
+ """CLI 모드로 실행합니다."""
239
+ print_banner()
240
+
241
+ print(f"{Colors.WHITE}안녕하세요! AWS Solutions Architect Assistant입니다.{Colors.RESET}")
242
+ print(f"{Colors.DIM}아키텍처 설계, 비용 분석, 보안 검토 등을 도와드립니다.{Colors.RESET}")
243
+ print(f"\n{Colors.YELLOW}💡 Guru 전문가 자문: 'list_gurus'로 사용 가능한 전문가를 확인하세요.{Colors.RESET}")
244
+ print(f"{Colors.DIM}종료하려면 'exit' 또는 'quit'을 입력하세요.{Colors.RESET}\n")
245
+
246
+ # Orchestrator 생성 (Progressive Disclosure: 스킬은 main.py에서 처리)
247
+ orchestrator = create_orchestrator(
248
+ tools=tools,
249
+ enable_thinking=True
250
+ )
251
+
252
+ # 콜백 핸들러 설정
253
+ orchestrator.callback_handler = tool_callback_handler
254
+
255
+ while True:
256
+ try:
257
+ user_input = input(f"\n{Colors.GREEN}You: {Colors.RESET}").strip()
258
+
259
+ if not user_input:
260
+ continue
261
+
262
+ if user_input.lower() in ["exit", "quit", "종료"]:
263
+ print(f"\n{Colors.ORANGE}감사합니다. 좋은 하루 되세요! 👋{Colors.RESET}")
264
+ break
265
+
266
+ print(f"\n{Colors.ORANGE}{Colors.BOLD}SA Assistant:{Colors.RESET} ", end="", flush=True)
267
+
268
+ try:
269
+ response = orchestrator(user_input)
270
+ print() # 줄바꿈
271
+ except Exception as e:
272
+ print(f"\n{Colors.RED}오류 발생: {e}{Colors.RESET}")
273
+ continue
274
+
275
+ except KeyboardInterrupt:
276
+ print(f"\n\n{Colors.YELLOW}중단되었습니다.{Colors.RESET}")
277
+ break
278
+ except Exception as e:
279
+ print(f"\n{Colors.RED}오류: {e}{Colors.RESET}")
280
+
281
+
282
+ if __name__ == "__main__":
283
+ run_cli()
agents/specialists.py ADDED
@@ -0,0 +1,230 @@
1
+ """Specialist Sub-agents - 전문 영역별 서브 에이전트
2
+
3
+ Explorer, Researcher, Reviewer 등 전문 분야별 서브에이전트를 제공합니다.
4
+ ThreadPoolExecutor를 사용하여 OpenTelemetry 컨텍스트 격리를 보장합니다.
5
+ """
6
+
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from typing import List, Dict, Any, Optional
9
+ from strands import Agent, tool
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+
16
+ from model.load import load_sonnet, load_haiku
17
+ from prompts.system_prompts import SPECIALIST_PROMPTS, get_specialist_prompt
18
+
19
+
20
+ def get_available_specialists() -> List[str]:
21
+ return list(SPECIALIST_PROMPTS.keys())
22
+
23
+
24
+ def _isolate_otel_context():
25
+ """OpenTelemetry 컨텍스트를 격리합니다."""
26
+ try:
27
+ from opentelemetry import context as otel_context
28
+
29
+ return otel_context.attach(otel_context.Context())
30
+ except ImportError:
31
+ return None
32
+
33
+
34
+ def _restore_otel_context(token):
35
+ """OpenTelemetry 컨텍스트를 복원합니다."""
36
+ if token is not None:
37
+ try:
38
+ from opentelemetry import context as otel_context
39
+
40
+ otel_context.detach(token)
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ def _run_specialist_in_thread(
46
+ specialist_name: str, task: str, context: str = ""
47
+ ) -> str:
48
+ """
49
+ 별도 스레드에서 Specialist Agent 실행
50
+ """
51
+ token = _isolate_otel_context()
52
+
53
+ try:
54
+ prompt = get_specialist_prompt(specialist_name)
55
+ if not prompt:
56
+ return f"알 수 없는 Specialist: {specialist_name}"
57
+
58
+ # Specialist는 Haiku 사용 (빠른 응답)
59
+ specialist_agent = Agent(
60
+ model=load_haiku(enable_thinking=False),
61
+ system_prompt=prompt,
62
+ )
63
+
64
+ full_task = task
65
+ if context:
66
+ full_task = f"## Context\n{context}\n\n## Task\n{task}"
67
+
68
+ result = specialist_agent(full_task)
69
+ return str(result)
70
+ finally:
71
+ _restore_otel_context(token)
72
+
73
+
74
+ @tool
75
+ def consult_specialist(specialist_name: str, task: str, context: str = "") -> str:
76
+ """
77
+ 전문 Specialist 에이전트에게 작업을 위임합니다.
78
+
79
+ Specialist는 특정 도메인에 특화된 분석/조사 작업을 수행합니다.
80
+
81
+ Args:
82
+ specialist_name: Specialist 이름
83
+ - "explorer": 기존 아키텍처 분석, 다이어그램 해석
84
+ - "researcher": AWS 서비스/가격/Best Practice 조사
85
+ - "reviewer": Well-Architected Framework 기반 검토
86
+ task: 수행할 작업 설명
87
+ context: 추가 컨텍스트 (선택)
88
+
89
+ Returns:
90
+ str: Specialist의 분석 결과
91
+
92
+ Example:
93
+ consult_specialist("explorer", "이 아키텍처 다이어그램을 분석해주세요")
94
+ consult_specialist("researcher", "DynamoDB vs Aurora 비교")
95
+ consult_specialist("reviewer", "이 설계의 보안 취약점을 검토해주세요")
96
+ """
97
+ specialist_name_lower = specialist_name.lower()
98
+
99
+ if specialist_name_lower not in SPECIALIST_PROMPTS:
100
+ available = ", ".join(get_available_specialists())
101
+ return f"알 수 없는 Specialist: {specialist_name}. 사용 가능: {available}"
102
+
103
+ try:
104
+ with ThreadPoolExecutor(max_workers=1) as executor:
105
+ future = executor.submit(
106
+ _run_specialist_in_thread, specialist_name_lower, task, context
107
+ )
108
+ response_text = future.result(timeout=120)
109
+
110
+ return f"[{specialist_name.upper()} SPECIALIST 결과]\n\n{response_text}"
111
+ except Exception as e:
112
+ return f"Specialist 실행 중 오류 발생: {str(e)}"
113
+
114
+
115
+ @tool
116
+ def list_specialists() -> str:
117
+ """
118
+ 사용 가능한 Specialist 목록과 전문 분야를 반환합니다.
119
+
120
+ Returns:
121
+ str: Specialist 목록 및 설명
122
+ """
123
+ specialist_descriptions = {
124
+ "explorer": "🔍 Architecture Explorer - 기존 아키텍처 분석, 다이어그램 해석, 코드 탐색",
125
+ "researcher": "📚 AWS Researcher - 서비스 정보, 가격, Best Practice 조사",
126
+ "reviewer": "✅ Architecture Reviewer - Well-Architected 검토, 보안 분석, 개선 권장",
127
+ }
128
+
129
+ result = "## 사용 가능한 Specialist 에이전트\n\n"
130
+ for name, desc in specialist_descriptions.items():
131
+ result += f"- **{name}**: {desc}\n"
132
+
133
+ result += "\n`consult_specialist(specialist_name, task)` 도구로 작업을 위임할 수 있습니다."
134
+ return result
135
+
136
+
137
+ def run_specialists_parallel(
138
+ tasks: List[Dict[str, str]], max_workers: int = 3
139
+ ) -> List[Dict[str, Any]]:
140
+ """
141
+ 여러 Specialist를 병렬로 실행합니다.
142
+
143
+ Args:
144
+ tasks: 작업 목록 [{"specialist": "name", "task": "...", "context": "..."}]
145
+ max_workers: 최대 동시 실행 수
146
+
147
+ Returns:
148
+ 결과 목록 [{"specialist": "name", "result": "...", "success": bool}]
149
+ """
150
+ results = []
151
+
152
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
153
+ future_to_task = {}
154
+ for task_info in tasks:
155
+ future = executor.submit(
156
+ _run_specialist_in_thread,
157
+ task_info["specialist"],
158
+ task_info["task"],
159
+ task_info.get("context", ""),
160
+ )
161
+ future_to_task[future] = task_info
162
+
163
+ for future in as_completed(future_to_task):
164
+ task_info = future_to_task[future]
165
+ try:
166
+ result = future.result(timeout=120)
167
+ results.append(
168
+ {
169
+ "specialist": task_info["specialist"],
170
+ "result": result,
171
+ "success": True,
172
+ }
173
+ )
174
+ except Exception as e:
175
+ results.append(
176
+ {
177
+ "specialist": task_info["specialist"],
178
+ "result": str(e),
179
+ "success": False,
180
+ }
181
+ )
182
+
183
+ return results
184
+
185
+
186
+ @tool
187
+ def parallel_research(tasks_json: str) -> str:
188
+ """
189
+ 여러 Specialist에게 병렬로 작업을 위임합니다.
190
+
191
+ 복잡한 분석을 위해 여러 전문가를 동시에 활용할 때 사용합니다.
192
+
193
+ Args:
194
+ tasks_json: JSON 형식의 작업 목록
195
+ 예: '[{"specialist": "researcher", "task": "DynamoDB 가격 조사"},
196
+ {"specialist": "reviewer", "task": "보안 검토"}]'
197
+
198
+ Returns:
199
+ str: 모든 Specialist의 결과를 종합한 보고서
200
+
201
+ Example:
202
+ parallel_research('[
203
+ {"specialist": "explorer", "task": "현재 아키텍처 분석"},
204
+ {"specialist": "researcher", "task": "대안 서비스 조사"},
205
+ {"specialist": "reviewer", "task": "개선안 검토"}
206
+ ]')
207
+ """
208
+ import json
209
+
210
+ try:
211
+ tasks = json.loads(tasks_json)
212
+ except json.JSONDecodeError as e:
213
+ return f"JSON 파싱 오류: {e}"
214
+
215
+ if not isinstance(tasks, list):
216
+ return "tasks_json은 배열 형식이어야 합니다."
217
+
218
+ results = run_specialists_parallel(tasks)
219
+
220
+ # 결과 종합
221
+ output = "## 병렬 분석 결과\n\n"
222
+
223
+ for r in results:
224
+ specialist = r["specialist"].upper()
225
+ if r["success"]:
226
+ output += f"### {specialist} 결과\n{r['result']}\n\n"
227
+ else:
228
+ output += f"### {specialist} (실패)\n오류: {r['result']}\n\n"
229
+
230
+ return output
@@ -0,0 +1,38 @@
1
+ """Agent Skills - SA Agent용 스킬 시스템
2
+
3
+ Progressive Disclosure 패턴을 따르는 스킬 로딩 시스템:
4
+ - Phase 1: 메타데이터만 로드 (discovery)
5
+ - Phase 2: 필요 시 전체 instructions 로드 (skill tool)
6
+ - Phase 3: 리소스 파일 로드 (file_read)
7
+ """
8
+
9
+ from .models import SkillProperties
10
+ from .parser import find_skill_md, load_metadata, load_instructions, load_resource
11
+ from .discovery import discover_skills
12
+ from .prompt import generate_skills_prompt
13
+ from .tool import create_skill_tool
14
+ from .errors import (
15
+ SkillError,
16
+ ParseError,
17
+ ValidationError,
18
+ SkillNotFoundError,
19
+ SkillActivationError,
20
+ )
21
+
22
+ __version__ = "1.0.0"
23
+
24
+ __all__ = [
25
+ "SkillProperties",
26
+ "find_skill_md",
27
+ "load_metadata",
28
+ "load_instructions",
29
+ "load_resource",
30
+ "discover_skills",
31
+ "generate_skills_prompt",
32
+ "create_skill_tool",
33
+ "SkillError",
34
+ "ParseError",
35
+ "ValidationError",
36
+ "SkillNotFoundError",
37
+ "SkillActivationError",
38
+ ]
@@ -0,0 +1,107 @@
1
+ """스킬 디스커버리 - Progressive Disclosure Phase 1
2
+
3
+ 스킬 디렉토리를 스캔하여 경량 메타데이터만 로드합니다.
4
+ """
5
+
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from .parser import find_skill_md, load_metadata
11
+ from .errors import ParseError, ValidationError
12
+ from .models import SkillProperties
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def is_safe_path(path: Path, base_dir: Path) -> bool:
18
+ """경로가 base_dir 내부에 안전하게 포함되어 있는지 확인
19
+
20
+ 심볼릭 링크나 경로 조작을 통한 디렉토리 탐색 공격 방지
21
+ """
22
+ try:
23
+ resolved_path = path.resolve()
24
+ resolved_base = base_dir.resolve()
25
+ resolved_path.relative_to(resolved_base)
26
+ return True
27
+ except (ValueError, OSError, RuntimeError):
28
+ return False
29
+
30
+
31
+ def discover_skills(skills_dir: str | Path) -> List[SkillProperties]:
32
+ """디렉토리에서 모든 스킬 발견
33
+
34
+ 스킬 디렉토리를 스캔하고 각 스킬의 SKILL.md frontmatter에서
35
+ 메타데이터를 로드합니다. (Progressive Disclosure Phase 1)
36
+
37
+ 디렉토리 구조:
38
+ skills/
39
+ ├── pptx/
40
+ │ ├── SKILL.md # 필수
41
+ │ └── references/ # 선택
42
+ │ └── html2pptx.md
43
+ ├── docx/
44
+ │ └── SKILL.md
45
+ └── mcp/
46
+ └── SKILL.md
47
+
48
+ Args:
49
+ skills_dir: 스킬 하위 디렉토리들이 있는 경로
50
+
51
+ Returns:
52
+ SkillProperties 리스트 (이름순 정렬)
53
+ """
54
+ skills_dir = Path(skills_dir).expanduser().resolve()
55
+
56
+ if not skills_dir.exists():
57
+ logger.info(f"스킬 디렉토리가 존재하지 않습니다: {skills_dir}")
58
+ return []
59
+
60
+ if not skills_dir.is_dir():
61
+ logger.warning(f"스킬 경로가 디렉토리가 아닙니다: {skills_dir}")
62
+ return []
63
+
64
+ skills: List[SkillProperties] = []
65
+
66
+ # 각 하위 디렉토리 스캔
67
+ for skill_dir in skills_dir.iterdir():
68
+ if not skill_dir.is_dir():
69
+ continue
70
+
71
+ # 보안: 경로 검증
72
+ if not is_safe_path(skill_dir, skills_dir):
73
+ logger.warning(f"안전하지 않은 경로 건너뜀: {skill_dir}")
74
+ continue
75
+
76
+ # SKILL.md 찾기
77
+ skill_md_path = find_skill_md(skill_dir)
78
+ if skill_md_path is None:
79
+ logger.debug(f"SKILL.md를 찾을 수 없음: {skill_dir}")
80
+ continue
81
+
82
+ # 보안: SKILL.md 경로 검증
83
+ if not is_safe_path(skill_md_path, skills_dir):
84
+ logger.warning(f"안전하지 않은 SKILL.md 건너뜀: {skill_md_path}")
85
+ continue
86
+
87
+ # 메타데이터 파싱
88
+ try:
89
+ skill_props = load_metadata(skill_dir)
90
+ skills.append(skill_props)
91
+ logger.debug(f"스킬 발견: {skill_props.name}")
92
+
93
+ except (ParseError, ValidationError) as e:
94
+ logger.warning(f"유효하지 않은 스킬 건너뜀 ({skill_dir}): {e}")
95
+ continue
96
+ except Exception as e:
97
+ logger.error(f"예상치 못한 오류 ({skill_dir}): {e}")
98
+ continue
99
+
100
+ # 이름순 정렬
101
+ skills.sort(key=lambda s: s.name)
102
+
103
+ logger.info(f"{skills_dir}에서 {len(skills)}개 스킬 발견")
104
+ return skills
105
+
106
+
107
+ __all__ = ["discover_skills"]