ltcai 0.1.17 → 0.1.18
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.
- package/README.md +15 -6
- package/docs/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/server.py +527 -31
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
|
|
|
14
14
|
|
|
15
15
|
### 현재 배포 버전
|
|
16
16
|
|
|
17
|
-
- `PyPI`: `ltcai==0.1.
|
|
18
|
-
- `npm`: `ltcai@0.1.
|
|
19
|
-
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.
|
|
20
|
-
- `Open VSX`: `parktaesoo.ltcai@0.1.
|
|
17
|
+
- `PyPI`: `ltcai==0.1.18`
|
|
18
|
+
- `npm`: `ltcai@0.1.18`
|
|
19
|
+
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.18`
|
|
20
|
+
- `Open VSX`: `parktaesoo.ltcai@0.1.18`
|
|
21
21
|
|
|
22
22
|
### 왜 Lattice AI인가
|
|
23
23
|
|
|
@@ -129,6 +129,9 @@ LATTICEAI_TELEGRAM_BOT_TOKEN=your-token LTCAI
|
|
|
129
129
|
| **VS Code / Cursor 확장** | 채팅, Edit Selection, Diff 뷰, 파일 첨부 |
|
|
130
130
|
| **Telegram 봇** | 로컬 AI 미러 + Codex 클라우드 봇 |
|
|
131
131
|
| **MCP 서버** | Claude Desktop / Cursor에서 직접 도구 사용 |
|
|
132
|
+
| **MCP 레지스트리** | [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io) 원격 목록 실시간 통합 — stdio MCP 서버 원클릭 설치 |
|
|
133
|
+
| **Skills 마켓플레이스** | Anthropic + Adobe · Airtable · Auth0 · Expo · Pydantic 공식 skills 77개 (Apache-2.0 / MIT) |
|
|
134
|
+
| **플러그인 디렉터리** | 오픈소스 플러그인 149개 메타데이터 브라우저 (검색 · 카테고리 · 라이선스 필터) |
|
|
132
135
|
| **MLX 로컬 추론** | Apple Silicon에서 Gemma, Qwen, DeepSeek 등 |
|
|
133
136
|
| **클라우드 모델** | OpenAI, Groq, Together, OpenRouter |
|
|
134
137
|
| **Graph RAG** | 채팅·문서를 SQLite 지식 그래프로 자동 구조화 |
|
|
@@ -317,7 +320,13 @@ npm install && npm run build && npm run package:vsix
|
|
|
317
320
|
| POST | `/agent` | 파일 생성/수정 에이전트 |
|
|
318
321
|
| GET | `/tools/list_dir` | 디렉토리 목록 |
|
|
319
322
|
| POST | `/tools/run_command` | 터미널 명령 실행 |
|
|
320
|
-
| GET | `/mcp/installed` | 설치된 MCP 목록 |
|
|
323
|
+
| GET | `/mcp/installed` | 설치된 MCP 목록 (로컬 + 원격 레지스트리) |
|
|
324
|
+
| POST | `/mcp/install` | MCP 설치 (bundled / pip / npm / pypi) |
|
|
325
|
+
| POST | `/mcp/registry/refresh` | 원격 MCP 레지스트리 캐시 갱신 |
|
|
326
|
+
| GET | `/skills/marketplace` | Skills 마켓플레이스 (Anthropic + 서드파티, `?category=` `?author=`) |
|
|
327
|
+
| POST | `/skills/install` | Skill 설치 (`{ "plugin": "...", "skill": "..." }`) |
|
|
328
|
+
| GET | `/skills/list` | 로컬 설치 skills 목록 |
|
|
329
|
+
| GET | `/plugins/directory` | 오픈소스 플러그인 디렉터리 149개 (`?q=` `?category=` `?license=`) |
|
|
321
330
|
|
|
322
331
|
자세한 MCP 도구 목록: [docs/mcp-tools.md](docs/mcp-tools.md)
|
|
323
332
|
|
|
@@ -359,7 +368,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
|
|
|
359
368
|
|
|
360
369
|
## 릴리스 노트
|
|
361
370
|
|
|
362
|
-
현재 버전: **0.1.
|
|
371
|
+
현재 버전: **0.1.18** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
|
|
363
372
|
|
|
364
373
|
## 라이선스
|
|
365
374
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.18] - 2026-05-23
|
|
4
|
+
|
|
5
|
+
### MCP Registry 통합
|
|
6
|
+
|
|
7
|
+
- **`GET /mcp/tools` · `GET /mcp/installed`** — 기존 로컬 목록에 [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io) 원격 목록을 실시간 병합
|
|
8
|
+
- **`POST /mcp/install`** — `npm` / `pypi` 설치 모드 추가 — 원격 레지스트리 MCP 서버를 클릭 한 번으로 설치 (`npm install -g` / `pip install`)
|
|
9
|
+
- **`POST /mcp/registry/refresh`** — 원격 레지스트리 캐시 강제 갱신
|
|
10
|
+
- `mcp_public_item` 응답에 `package` · `homepage` · `source` 필드 추가
|
|
11
|
+
- 원격 레지스트리는 1시간 TTL 인메모리 캐시, 서버 재시작 없이 최신 목록 유지
|
|
12
|
+
- `connector_info` 함수 인라인화 — `mcp_connector` 엔드포인트에서 combined registry 직접 조회
|
|
13
|
+
|
|
14
|
+
### Skills 마켓플레이스 (신규)
|
|
15
|
+
|
|
16
|
+
- **`GET /skills/marketplace`** — Apache-2.0 / MIT 검증 skills 목록 (Anthropic 18개 + 서드파티 59개 = 약 77개)
|
|
17
|
+
- `?category=` · `?author=` 필터 파라미터 지원
|
|
18
|
+
- 응답에 `authors` · `categories` 열거 포함
|
|
19
|
+
- **`POST /skills/install`** — `{ "plugin": "...", "skill": "..." }` 로 SKILL.md 런타임 fetch 후 로컬 `skills/` 에 저장
|
|
20
|
+
- 파일 상단에 출처·라이선스 주석 자동 삽입 (`<!-- Source: ..., Apache-2.0 -->`)
|
|
21
|
+
- `risk.json` 없으면 기본값 자동 생성
|
|
22
|
+
- **`GET /skills/list`** — 로컬 설치 skills 목록 (`source`: local / anthropic / third-party 구분)
|
|
23
|
+
- **`POST /skills/marketplace/refresh`** — 캐시 강제 갱신, author별 집계 반환
|
|
24
|
+
- 서드파티 소스 (모두 라이선스 검증 완료): Adobe (Apache-2.0) · Airtable (MIT) · Auth0 (Apache-2.0) · Expo (MIT) · Pydantic/Logfire (MIT)
|
|
25
|
+
|
|
26
|
+
### 플러그인 디렉터리 (신규)
|
|
27
|
+
|
|
28
|
+
- **`GET /plugins/directory`** — marketplace.json 기반 오픈소스 플러그인 149개 메타데이터 브라우저
|
|
29
|
+
- `?q=` 전문 검색 · `?category=` · `?license=` 필터 지원
|
|
30
|
+
- 응답에 `categories` · `licenses` 열거 포함
|
|
31
|
+
- **`POST /plugins/directory/refresh`** — 캐시 강제 갱신, license별 집계 반환
|
|
32
|
+
- `_KNOWN_REPO_LICENSES` 맵 — GitHub API 호출 없이 검증된 라이선스 즉시 조회
|
|
33
|
+
- 미확인 레포는 GitHub API fallback + 인메모리 per-repo 캐시
|
|
34
|
+
- Apache-2.0 / MIT / MIT-0 / CC-BY-4.0 플러그인만 노출, 라이선스 없는 34개 자동 제외
|
|
35
|
+
|
|
36
|
+
### Release
|
|
37
|
+
- 배포 버전을 `0.1.18`로 상향
|
|
38
|
+
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
39
|
+
|
|
3
40
|
## [0.1.17] - 2026-05-22
|
|
4
41
|
|
|
5
42
|
### Multi-LLM Pipeline
|
package/package.json
CHANGED
package/server.py
CHANGED
|
@@ -104,7 +104,8 @@ try:
|
|
|
104
104
|
except Exception:
|
|
105
105
|
keyring = None
|
|
106
106
|
|
|
107
|
-
from datetime import datetime
|
|
107
|
+
from datetime import datetime, timedelta
|
|
108
|
+
import httpx
|
|
108
109
|
|
|
109
110
|
def detect_language(text: str) -> str:
|
|
110
111
|
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
@@ -371,6 +372,10 @@ class McpRecommendRequest(BaseModel):
|
|
|
371
372
|
class McpInstallRequest(BaseModel):
|
|
372
373
|
mcp_id: str
|
|
373
374
|
|
|
375
|
+
class SkillInstallRequest(BaseModel):
|
|
376
|
+
plugin: str
|
|
377
|
+
skill: str
|
|
378
|
+
|
|
374
379
|
class KnowledgeGraphIngestRequest(BaseModel):
|
|
375
380
|
type: str
|
|
376
381
|
content: str = ""
|
|
@@ -655,6 +660,341 @@ MCP_REGISTRY = [
|
|
|
655
660
|
},
|
|
656
661
|
]
|
|
657
662
|
|
|
663
|
+
# ── Remote MCP Registry (registry.modelcontextprotocol.io) ───────────────────
|
|
664
|
+
_REMOTE_REGISTRY_CACHE: List[Dict] = []
|
|
665
|
+
_REMOTE_REGISTRY_FETCHED_AT: Optional[datetime] = None
|
|
666
|
+
_REMOTE_REGISTRY_TTL = timedelta(hours=1)
|
|
667
|
+
_REMOTE_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"
|
|
668
|
+
_LOCAL_IDS = {e["id"] for e in MCP_REGISTRY}
|
|
669
|
+
|
|
670
|
+
async def _fetch_remote_mcp_registry() -> List[Dict]:
|
|
671
|
+
global _REMOTE_REGISTRY_CACHE, _REMOTE_REGISTRY_FETCHED_AT
|
|
672
|
+
now = datetime.now()
|
|
673
|
+
if _REMOTE_REGISTRY_FETCHED_AT and (now - _REMOTE_REGISTRY_FETCHED_AT) < _REMOTE_REGISTRY_TTL:
|
|
674
|
+
return _REMOTE_REGISTRY_CACHE
|
|
675
|
+
try:
|
|
676
|
+
result: List[Dict] = []
|
|
677
|
+
cursor = None
|
|
678
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
679
|
+
while True:
|
|
680
|
+
params: Dict = {"limit": 100}
|
|
681
|
+
if cursor:
|
|
682
|
+
params["cursor"] = cursor
|
|
683
|
+
resp = await client.get(_REMOTE_REGISTRY_URL, params=params)
|
|
684
|
+
resp.raise_for_status()
|
|
685
|
+
data = resp.json()
|
|
686
|
+
for s in data.get("servers", []):
|
|
687
|
+
srv = s["server"]
|
|
688
|
+
meta = s.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
|
|
689
|
+
if not meta.get("isLatest", True):
|
|
690
|
+
continue
|
|
691
|
+
pkg = next(
|
|
692
|
+
(p for p in srv.get("packages", [])
|
|
693
|
+
if p.get("transport", {}).get("type") == "stdio"
|
|
694
|
+
and p.get("registryType") in ("npm", "pypi")),
|
|
695
|
+
None,
|
|
696
|
+
)
|
|
697
|
+
if not pkg:
|
|
698
|
+
continue
|
|
699
|
+
entry_id = srv["name"].replace("/", "-").replace(".", "-")
|
|
700
|
+
if entry_id in _LOCAL_IDS:
|
|
701
|
+
continue
|
|
702
|
+
result.append({
|
|
703
|
+
"id": entry_id,
|
|
704
|
+
"name": srv.get("title") or srv["name"],
|
|
705
|
+
"category": "MCP Registry",
|
|
706
|
+
"install_mode": pkg["registryType"],
|
|
707
|
+
"package": pkg["identifier"],
|
|
708
|
+
"package_version": pkg.get("version"),
|
|
709
|
+
"description": srv.get("description", ""),
|
|
710
|
+
"keywords": [],
|
|
711
|
+
"capabilities": [],
|
|
712
|
+
"source": "registry",
|
|
713
|
+
"homepage": (srv.get("repository") or {}).get("url"),
|
|
714
|
+
})
|
|
715
|
+
cursor = data.get("nextCursor")
|
|
716
|
+
if not cursor:
|
|
717
|
+
break
|
|
718
|
+
_REMOTE_REGISTRY_CACHE = result
|
|
719
|
+
_REMOTE_REGISTRY_FETCHED_AT = now
|
|
720
|
+
logging.info("Fetched %d stdio MCP servers from remote registry", len(result))
|
|
721
|
+
except Exception as e:
|
|
722
|
+
logging.warning("Failed to fetch remote MCP registry: %s", e)
|
|
723
|
+
return _REMOTE_REGISTRY_CACHE
|
|
724
|
+
|
|
725
|
+
async def _get_combined_registry() -> List[Dict]:
|
|
726
|
+
remote = await _fetch_remote_mcp_registry()
|
|
727
|
+
return MCP_REGISTRY + remote
|
|
728
|
+
|
|
729
|
+
# ── Anthropic Skills Marketplace (Apache 2.0) ─────────────────────────────────
|
|
730
|
+
_MARKETPLACE_RAW = "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main"
|
|
731
|
+
_MARKETPLACE_API = "https://api.github.com/repos/anthropics/claude-plugins-official/contents"
|
|
732
|
+
|
|
733
|
+
# 검증된 서드파티 skills 소스 (Apache-2.0 / MIT)
|
|
734
|
+
_THIRD_PARTY_SKILL_SOURCES: List[Dict] = [
|
|
735
|
+
{
|
|
736
|
+
"plugin": "adobe-for-creativity", "author": "Adobe", "license": "Apache-2.0",
|
|
737
|
+
"repo": "adobe/skills", "branch": "main",
|
|
738
|
+
"plugin_path": "plugins/creative-cloud/adobe-for-creativity",
|
|
739
|
+
"category": "design",
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
"plugin": "airtable", "author": "Airtable", "license": "MIT",
|
|
743
|
+
"repo": "Airtable/skills", "branch": "main",
|
|
744
|
+
"plugin_path": "plugins/airtable",
|
|
745
|
+
"category": "productivity",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
"plugin": "auth0", "author": "Auth0", "license": "Apache-2.0",
|
|
749
|
+
"repo": "auth0/agent-skills", "branch": "main",
|
|
750
|
+
"plugin_path": "plugins/auth0",
|
|
751
|
+
"category": "security",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
"plugin": "expo", "author": "Expo", "license": "MIT",
|
|
755
|
+
"repo": "expo/skills", "branch": "main",
|
|
756
|
+
"plugin_path": "plugins/expo",
|
|
757
|
+
"category": "development",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
"plugin": "logfire", "author": "Pydantic", "license": "MIT",
|
|
761
|
+
"repo": "pydantic/skills", "branch": "main",
|
|
762
|
+
"plugin_path": "plugins/logfire",
|
|
763
|
+
"category": "monitoring",
|
|
764
|
+
},
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
# 검증된 레포 라이선스 맵 (GitHub API 없이 빠르게 조회)
|
|
768
|
+
_KNOWN_REPO_LICENSES: Dict[str, str] = {
|
|
769
|
+
# Apache-2.0
|
|
770
|
+
"adobe/skills": "Apache-2.0", "awslabs/agent-plugins": "Apache-2.0",
|
|
771
|
+
"auth0/agent-skills": "Apache-2.0", "aws/agent-toolkit-for-aws": "Apache-2.0",
|
|
772
|
+
"carta/plugins": "Apache-2.0", "circlefin/skills": "Apache-2.0",
|
|
773
|
+
"clickhouse/clickhouse-docs": "Apache-2.0", "cloudflare/agents": "Apache-2.0",
|
|
774
|
+
"cockroachdb/claude-code": "Apache-2.0", "codspeed-hq/codspeed-claude": "Apache-2.0",
|
|
775
|
+
"DataDog/datadog-claude-code": "Apache-2.0", "datahub-project/datahub-skills": "Apache-2.0",
|
|
776
|
+
"neondatabase/agent-skills": "Apache-2.0", "PagerDuty/pd-ai-agents-plugins": "Apache-2.0",
|
|
777
|
+
"getpostman/postman-mcp-server": "Apache-2.0", "qdrant/qdrant-skills": "Apache-2.0",
|
|
778
|
+
"rootlyhq/rootly-plugins": "Apache-2.0", "snowflake-labs/snowflake-claude": "Apache-2.0",
|
|
779
|
+
"sumup/sumup-claude": "Apache-2.0", "zilliz-labs/zilliz-skills": "Apache-2.0",
|
|
780
|
+
"mercadopago/mercadopago-claude-marketplace": "Apache-2.0",
|
|
781
|
+
# MIT
|
|
782
|
+
"Airtable/skills": "MIT", "endorlabs/ai-plugins": "MIT",
|
|
783
|
+
"apollographql/apollo-claude-skills": "MIT", "appwrite/skills": "MIT",
|
|
784
|
+
"atlan-inc/claude-code-skills": "MIT", "boxer/boxerbox": "MIT",
|
|
785
|
+
"buildkite/claude-code": "MIT", "coderabbitai/coderabbit-skills": "MIT",
|
|
786
|
+
"CrowdStrike/crowdstrike-skills": "MIT", "microsoft/Dataverse-skills": "MIT",
|
|
787
|
+
"duckdb/duckdb-skills": "MIT", "expo/skills": "MIT",
|
|
788
|
+
"intercom/intercom-skills": "MIT", "pydantic/skills": "MIT",
|
|
789
|
+
"mapbox/mapbox-skills": "MIT", "mintlify/mintlify-skills": "MIT",
|
|
790
|
+
"miroapp/miro-ai": "MIT", "netlify/netlify-skills": "MIT",
|
|
791
|
+
"pinecone-io/pinecone-skills": "MIT", "railwayapp/railway-skills": "MIT",
|
|
792
|
+
"resend/resend-skills": "MIT", "sanity-io/sanity-skills": "MIT",
|
|
793
|
+
"getsentry/sentry-ai-skills": "MIT", "Shopify/liquid-skills": "MIT",
|
|
794
|
+
"slackapi/slack-skills": "MIT", "stripe/stripe-skills": "MIT",
|
|
795
|
+
"twilio-labs/twilio-skills": "MIT", "workos/workos-skills": "MIT",
|
|
796
|
+
"zoom/zoom-skills": "MIT", "aws-samples/sample-claude-code-plugins-for-startups": "MIT-0",
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
_SKILLS_MARKETPLACE_CACHE: List[Dict] = []
|
|
800
|
+
_SKILLS_MARKETPLACE_FETCHED_AT: Optional[datetime] = None
|
|
801
|
+
_SKILLS_MARKETPLACE_TTL = timedelta(hours=1)
|
|
802
|
+
|
|
803
|
+
def _extract_skill_desc(skill_md: str, fallback: str) -> str:
|
|
804
|
+
for line in skill_md.splitlines():
|
|
805
|
+
if line.startswith("description:"):
|
|
806
|
+
return line.split(":", 1)[1].strip()
|
|
807
|
+
return fallback
|
|
808
|
+
|
|
809
|
+
async def _fetch_plugin_skills(client: httpx.AsyncClient, source: Dict) -> List[Dict]:
|
|
810
|
+
"""단일 소스에서 skill 목록을 fetch해 반환"""
|
|
811
|
+
repo, branch, plugin_path = source["repo"], source["branch"], source["plugin_path"]
|
|
812
|
+
raw_base = f"https://raw.githubusercontent.com/{repo}/{branch}"
|
|
813
|
+
api_base = f"https://api.github.com/repos/{repo}/contents"
|
|
814
|
+
homepage_base = f"https://github.com/{repo}/tree/{branch}"
|
|
815
|
+
|
|
816
|
+
dir_resp = await client.get(f"{api_base}/{plugin_path}/skills")
|
|
817
|
+
if dir_resp.status_code != 200:
|
|
818
|
+
return []
|
|
819
|
+
skill_dirs = [f["name"] for f in dir_resp.json() if f["type"] == "dir"]
|
|
820
|
+
|
|
821
|
+
skills: List[Dict] = []
|
|
822
|
+
for skill_name in skill_dirs:
|
|
823
|
+
skill_md_url = f"{raw_base}/{plugin_path}/skills/{skill_name}/SKILL.md"
|
|
824
|
+
sm_resp = await client.get(skill_md_url)
|
|
825
|
+
if sm_resp.status_code != 200:
|
|
826
|
+
continue
|
|
827
|
+
skills.append({
|
|
828
|
+
"plugin": source["plugin"],
|
|
829
|
+
"skill": skill_name,
|
|
830
|
+
"category": source.get("category", "development"),
|
|
831
|
+
"description": _extract_skill_desc(sm_resp.text, source.get("description", "")),
|
|
832
|
+
"skill_md_url": skill_md_url,
|
|
833
|
+
"homepage": f"{homepage_base}/{plugin_path}/skills/{skill_name}",
|
|
834
|
+
"license": source["license"],
|
|
835
|
+
"author": source["author"],
|
|
836
|
+
})
|
|
837
|
+
return skills
|
|
838
|
+
|
|
839
|
+
async def _fetch_skills_marketplace() -> List[Dict]:
|
|
840
|
+
global _SKILLS_MARKETPLACE_CACHE, _SKILLS_MARKETPLACE_FETCHED_AT
|
|
841
|
+
now = datetime.now()
|
|
842
|
+
if _SKILLS_MARKETPLACE_FETCHED_AT and (now - _SKILLS_MARKETPLACE_FETCHED_AT) < _SKILLS_MARKETPLACE_TTL:
|
|
843
|
+
return _SKILLS_MARKETPLACE_CACHE
|
|
844
|
+
try:
|
|
845
|
+
result: List[Dict] = []
|
|
846
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
847
|
+
# ── Anthropic 공식 skills (Apache-2.0) ──────────────────────────
|
|
848
|
+
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
849
|
+
mp_resp.raise_for_status()
|
|
850
|
+
marketplace_json = mp_resp.json()
|
|
851
|
+
anthropic_plugins = [
|
|
852
|
+
p for p in marketplace_json.get("plugins", [])
|
|
853
|
+
if (p.get("author") or {}).get("name") == "Anthropic"
|
|
854
|
+
and isinstance(p.get("source"), str)
|
|
855
|
+
and p["source"].startswith("./")
|
|
856
|
+
]
|
|
857
|
+
for plugin in anthropic_plugins:
|
|
858
|
+
plugin_path = plugin["source"].lstrip("./")
|
|
859
|
+
result.extend(await _fetch_plugin_skills(client, {
|
|
860
|
+
"plugin": plugin["name"],
|
|
861
|
+
"author": "Anthropic",
|
|
862
|
+
"license": "Apache-2.0",
|
|
863
|
+
"repo": "anthropics/claude-plugins-official",
|
|
864
|
+
"branch": "main",
|
|
865
|
+
"plugin_path": plugin_path,
|
|
866
|
+
"category": plugin.get("category", "development"),
|
|
867
|
+
"description": plugin.get("description", ""),
|
|
868
|
+
}))
|
|
869
|
+
# ── 검증된 서드파티 skills ────────────────────────────────────────
|
|
870
|
+
for source in _THIRD_PARTY_SKILL_SOURCES:
|
|
871
|
+
result.extend(await _fetch_plugin_skills(client, source))
|
|
872
|
+
|
|
873
|
+
_SKILLS_MARKETPLACE_CACHE = result
|
|
874
|
+
_SKILLS_MARKETPLACE_FETCHED_AT = now
|
|
875
|
+
logging.info("Fetched %d skills from marketplace (%d sources)",
|
|
876
|
+
len(result), len(anthropic_plugins) + len(_THIRD_PARTY_SKILL_SOURCES))
|
|
877
|
+
except Exception as e:
|
|
878
|
+
logging.warning("Failed to fetch skills marketplace: %s", e)
|
|
879
|
+
return _SKILLS_MARKETPLACE_CACHE
|
|
880
|
+
|
|
881
|
+
# ── Plugin Directory ──────────────────────────────────────────────────────────
|
|
882
|
+
_PLUGIN_DIRECTORY_CACHE: List[Dict] = []
|
|
883
|
+
_PLUGIN_DIRECTORY_FETCHED_AT: Optional[datetime] = None
|
|
884
|
+
_PLUGIN_DIRECTORY_TTL = timedelta(hours=1)
|
|
885
|
+
_OPEN_LICENSES = {"Apache-2.0", "MIT", "MIT-0", "CC-BY-4.0"}
|
|
886
|
+
_REPO_LICENSE_CACHE: Dict[str, str] = {}
|
|
887
|
+
|
|
888
|
+
async def _get_repo_license(client: httpx.AsyncClient, repo: str) -> str:
|
|
889
|
+
if repo in _REPO_LICENSE_CACHE:
|
|
890
|
+
return _REPO_LICENSE_CACHE[repo]
|
|
891
|
+
if repo in _KNOWN_REPO_LICENSES:
|
|
892
|
+
_REPO_LICENSE_CACHE[repo] = _KNOWN_REPO_LICENSES[repo]
|
|
893
|
+
return _KNOWN_REPO_LICENSES[repo]
|
|
894
|
+
try:
|
|
895
|
+
r = await client.get(f"https://api.github.com/repos/{repo}", timeout=5.0)
|
|
896
|
+
lic = (r.json().get("license") or {}).get("spdx_id", "") if r.status_code == 200 else ""
|
|
897
|
+
except Exception:
|
|
898
|
+
lic = ""
|
|
899
|
+
_REPO_LICENSE_CACHE[repo] = lic
|
|
900
|
+
return lic
|
|
901
|
+
|
|
902
|
+
async def _fetch_plugin_directory() -> List[Dict]:
|
|
903
|
+
global _PLUGIN_DIRECTORY_CACHE, _PLUGIN_DIRECTORY_FETCHED_AT
|
|
904
|
+
now = datetime.now()
|
|
905
|
+
if _PLUGIN_DIRECTORY_FETCHED_AT and (now - _PLUGIN_DIRECTORY_FETCHED_AT) < _PLUGIN_DIRECTORY_TTL:
|
|
906
|
+
return _PLUGIN_DIRECTORY_CACHE
|
|
907
|
+
try:
|
|
908
|
+
result: List[Dict] = []
|
|
909
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
910
|
+
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
911
|
+
mp_resp.raise_for_status()
|
|
912
|
+
plugins = mp_resp.json().get("plugins", [])
|
|
913
|
+
|
|
914
|
+
for p in plugins:
|
|
915
|
+
author = (p.get("author") or {}).get("name", "")
|
|
916
|
+
src = p.get("source", {})
|
|
917
|
+
|
|
918
|
+
# Anthropic 같은 레포 플러그인 → Apache-2.0 확인됨
|
|
919
|
+
if isinstance(src, str) and src.startswith("./") and author == "Anthropic":
|
|
920
|
+
plugin_path = src.lstrip("./")
|
|
921
|
+
result.append({
|
|
922
|
+
"name": p["name"],
|
|
923
|
+
"description": p.get("description", ""),
|
|
924
|
+
"category": p.get("category", ""),
|
|
925
|
+
"author": author,
|
|
926
|
+
"license": "Apache-2.0",
|
|
927
|
+
"homepage": p.get("homepage") or f"https://github.com/anthropics/claude-plugins-official/tree/main/{plugin_path}",
|
|
928
|
+
"source_type": "anthropic",
|
|
929
|
+
})
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
# 외부 레포 플러그인 → 라이선스 확인
|
|
933
|
+
if not isinstance(src, dict):
|
|
934
|
+
continue
|
|
935
|
+
repo_url = src.get("url", "").replace("https://github.com/", "").replace(".git", "").split("/tree/")[0]
|
|
936
|
+
if not repo_url:
|
|
937
|
+
continue
|
|
938
|
+
license_id = await _get_repo_license(client, repo_url)
|
|
939
|
+
if license_id not in _OPEN_LICENSES:
|
|
940
|
+
continue
|
|
941
|
+
result.append({
|
|
942
|
+
"name": p["name"],
|
|
943
|
+
"description": p.get("description", ""),
|
|
944
|
+
"category": p.get("category", ""),
|
|
945
|
+
"author": author or repo_url.split("/")[0],
|
|
946
|
+
"license": license_id,
|
|
947
|
+
"homepage": p.get("homepage") or f"https://github.com/{repo_url}",
|
|
948
|
+
"source_type": "third-party",
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
_PLUGIN_DIRECTORY_CACHE = result
|
|
952
|
+
_PLUGIN_DIRECTORY_FETCHED_AT = now
|
|
953
|
+
logging.info("Fetched plugin directory: %d open-source plugins", len(result))
|
|
954
|
+
except Exception as e:
|
|
955
|
+
logging.warning("Failed to fetch plugin directory: %s", e)
|
|
956
|
+
return _PLUGIN_DIRECTORY_CACHE
|
|
957
|
+
|
|
958
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
|
961
|
+
|
|
962
|
+
async def install_skill(plugin: str, skill: str) -> Dict:
|
|
963
|
+
marketplace = await _fetch_skills_marketplace()
|
|
964
|
+
entry = next((s for s in marketplace if s["plugin"] == plugin and s["skill"] == skill), None)
|
|
965
|
+
if not entry:
|
|
966
|
+
raise HTTPException(status_code=404, detail=f"Skill '{plugin}/{skill}' not found in marketplace")
|
|
967
|
+
skill_dir = SKILLS_DIR / skill
|
|
968
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
969
|
+
skill_md_path = skill_dir / "SKILL.md"
|
|
970
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
971
|
+
resp = await client.get(entry["skill_md_url"])
|
|
972
|
+
resp.raise_for_status()
|
|
973
|
+
content = resp.text
|
|
974
|
+
# 출처 표기 (Apache-2.0 / MIT 공통)
|
|
975
|
+
repo_hint = entry.get("homepage", "")
|
|
976
|
+
attribution = f"<!-- Source: {repo_hint}, {entry['license']} -->\n"
|
|
977
|
+
if not content.startswith("<!--"):
|
|
978
|
+
content = attribution + content
|
|
979
|
+
skill_md_path.write_text(content, encoding="utf-8")
|
|
980
|
+
risk_path = skill_dir / "risk.json"
|
|
981
|
+
if not risk_path.exists():
|
|
982
|
+
risk_path.write_text(json.dumps({
|
|
983
|
+
"risk": "read", "destructive": False,
|
|
984
|
+
"shell": False, "network": False,
|
|
985
|
+
"auto_approve": True, "sandbox": "workspace", "rollback": "none"
|
|
986
|
+
}, indent=2), encoding="utf-8")
|
|
987
|
+
return {
|
|
988
|
+
"status": "installed",
|
|
989
|
+
"plugin": plugin,
|
|
990
|
+
"skill": skill,
|
|
991
|
+
"path": str(skill_dir),
|
|
992
|
+
"license": entry["license"],
|
|
993
|
+
"author": entry["author"],
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
997
|
+
|
|
658
998
|
def load_users():
|
|
659
999
|
if not os.path.exists(USERS_FILE):
|
|
660
1000
|
return {}
|
|
@@ -705,26 +1045,42 @@ def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
|
|
|
705
1045
|
connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
|
|
706
1046
|
authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
|
|
707
1047
|
return {
|
|
708
|
-
|
|
1048
|
+
"id": item["id"],
|
|
1049
|
+
"name": item["name"],
|
|
1050
|
+
"category": item.get("category", ""),
|
|
1051
|
+
"install_mode": item["install_mode"],
|
|
1052
|
+
"description": item.get("description", ""),
|
|
1053
|
+
"capabilities": item.get("capabilities", []),
|
|
709
1054
|
"connector_url": item.get("connector_url"),
|
|
710
1055
|
"external_url": item.get("external_url"),
|
|
1056
|
+
"package": item.get("package"),
|
|
1057
|
+
"homepage": item.get("homepage"),
|
|
1058
|
+
"source": item.get("source", "local"),
|
|
711
1059
|
"installed": installed,
|
|
712
1060
|
"status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
|
|
713
1061
|
"authenticated": authenticated,
|
|
714
1062
|
"updated_at": state.get("updated_at"),
|
|
715
1063
|
}
|
|
716
1064
|
|
|
717
|
-
def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
1065
|
+
async def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
718
1066
|
text = (query or "").lower()
|
|
719
1067
|
installed = load_mcp_installs().get("installed", {})
|
|
1068
|
+
registry = await _get_combined_registry()
|
|
720
1069
|
scored = []
|
|
721
|
-
for item in
|
|
1070
|
+
for item in registry:
|
|
722
1071
|
score = 0
|
|
723
1072
|
hits = []
|
|
724
|
-
for keyword in item
|
|
1073
|
+
for keyword in item.get("keywords", []):
|
|
725
1074
|
if keyword.lower() in text:
|
|
726
1075
|
score += 3 if len(keyword) > 2 else 1
|
|
727
1076
|
hits.append(keyword)
|
|
1077
|
+
# description 키워드 매칭 (remote 항목 보완)
|
|
1078
|
+
if not hits and text:
|
|
1079
|
+
desc_words = item.get("description", "").lower().split()
|
|
1080
|
+
for word in text.split():
|
|
1081
|
+
if len(word) > 2 and word in desc_words:
|
|
1082
|
+
score += 1
|
|
1083
|
+
hits.append(word)
|
|
728
1084
|
if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
|
|
729
1085
|
score += 2
|
|
730
1086
|
if score:
|
|
@@ -736,13 +1092,14 @@ def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
|
736
1092
|
fallback_ids = ["filesystem", "browser", "documents"]
|
|
737
1093
|
scored = [
|
|
738
1094
|
{**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
|
|
739
|
-
for item in
|
|
1095
|
+
for item in registry
|
|
740
1096
|
if item["id"] in fallback_ids
|
|
741
1097
|
]
|
|
742
1098
|
return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
|
|
743
1099
|
|
|
744
|
-
def install_mcp(mcp_id: str) -> Dict:
|
|
745
|
-
|
|
1100
|
+
async def install_mcp(mcp_id: str) -> Dict:
|
|
1101
|
+
registry = await _get_combined_registry()
|
|
1102
|
+
item = next((entry for entry in registry if entry["id"] == mcp_id), None)
|
|
746
1103
|
if not item:
|
|
747
1104
|
raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
|
|
748
1105
|
data = load_mcp_installs()
|
|
@@ -757,15 +1114,33 @@ def install_mcp(mcp_id: str) -> Dict:
|
|
|
757
1114
|
for pkg in packages:
|
|
758
1115
|
completed = subprocess.run(
|
|
759
1116
|
[sys.executable, "-m", "pip", "install", "--upgrade", pkg],
|
|
760
|
-
capture_output=True,
|
|
761
|
-
text=True,
|
|
762
|
-
timeout=900,
|
|
763
|
-
check=False,
|
|
1117
|
+
capture_output=True, text=True, timeout=900, check=False,
|
|
764
1118
|
)
|
|
765
1119
|
if completed.returncode != 0:
|
|
766
1120
|
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
767
|
-
status = "active"
|
|
768
1121
|
message = f"필수 패키지 설치 완료: {', '.join(packages)}"
|
|
1122
|
+
elif item["install_mode"] == "pypi":
|
|
1123
|
+
pkg = item.get("package", "")
|
|
1124
|
+
version = item.get("package_version")
|
|
1125
|
+
pkg_str = f"{pkg}=={version}" if version else pkg
|
|
1126
|
+
completed = subprocess.run(
|
|
1127
|
+
[sys.executable, "-m", "pip", "install", pkg_str],
|
|
1128
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
1129
|
+
)
|
|
1130
|
+
if completed.returncode != 0:
|
|
1131
|
+
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
1132
|
+
message = f"pip 패키지 설치 완료: {pkg_str}"
|
|
1133
|
+
elif item["install_mode"] == "npm":
|
|
1134
|
+
pkg = item.get("package", "")
|
|
1135
|
+
version = item.get("package_version")
|
|
1136
|
+
pkg_str = f"{pkg}@{version}" if version else pkg
|
|
1137
|
+
completed = subprocess.run(
|
|
1138
|
+
["npm", "install", "-g", pkg_str],
|
|
1139
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
1140
|
+
)
|
|
1141
|
+
if completed.returncode != 0:
|
|
1142
|
+
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
1143
|
+
message = f"npm 패키지 설치 완료: {pkg_str}"
|
|
769
1144
|
state[mcp_id] = {
|
|
770
1145
|
"installed": True,
|
|
771
1146
|
"status": status,
|
|
@@ -777,19 +1152,6 @@ def install_mcp(mcp_id: str) -> Dict:
|
|
|
777
1152
|
public["message"] = message
|
|
778
1153
|
return public
|
|
779
1154
|
|
|
780
|
-
def connector_info(mcp_id: str) -> Dict:
|
|
781
|
-
item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
|
|
782
|
-
if not item or item.get("install_mode") != "connector":
|
|
783
|
-
raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
|
|
784
|
-
installed = load_mcp_installs().get("installed", {})
|
|
785
|
-
public = mcp_public_item(item, installed)
|
|
786
|
-
public["instructions"] = [
|
|
787
|
-
"Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
|
|
788
|
-
f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
|
|
789
|
-
"인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
|
|
790
|
-
]
|
|
791
|
-
return public
|
|
792
|
-
|
|
793
1155
|
_history_lock = threading.Lock()
|
|
794
1156
|
|
|
795
1157
|
def get_audit_log() -> List[Dict]:
|
|
@@ -5542,6 +5904,7 @@ async def tools_permissions(request: Request):
|
|
|
5542
5904
|
@app.get("/mcp/tools")
|
|
5543
5905
|
async def mcp_tools():
|
|
5544
5906
|
installed = load_mcp_installs().get("installed", {})
|
|
5907
|
+
registry = await _get_combined_registry()
|
|
5545
5908
|
tools = []
|
|
5546
5909
|
for name, description in _MCP_TOOL_DESCRIPTIONS.items():
|
|
5547
5910
|
policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
|
|
@@ -5562,7 +5925,7 @@ async def mcp_tools():
|
|
|
5562
5925
|
return {
|
|
5563
5926
|
"status": "ok",
|
|
5564
5927
|
"workspace": str(AGENT_ROOT),
|
|
5565
|
-
"installed_mcps": [mcp_public_item(item, installed) for item in
|
|
5928
|
+
"installed_mcps": [mcp_public_item(item, installed) for item in registry],
|
|
5566
5929
|
"tools": tools,
|
|
5567
5930
|
}
|
|
5568
5931
|
|
|
@@ -5570,26 +5933,159 @@ async def mcp_tools():
|
|
|
5570
5933
|
@app.post("/mcp/recommend")
|
|
5571
5934
|
async def mcp_recommend(req: McpRecommendRequest, request: Request):
|
|
5572
5935
|
require_user(request)
|
|
5573
|
-
return {"recommendations": recommend_mcps(req.query, req.limit)}
|
|
5936
|
+
return {"recommendations": await recommend_mcps(req.query, req.limit)}
|
|
5574
5937
|
|
|
5575
5938
|
|
|
5576
5939
|
@app.post("/mcp/install")
|
|
5577
5940
|
async def mcp_install(req: McpInstallRequest, request: Request):
|
|
5578
5941
|
require_user(request)
|
|
5579
|
-
return install_mcp(req.mcp_id)
|
|
5942
|
+
return await install_mcp(req.mcp_id)
|
|
5580
5943
|
|
|
5581
5944
|
|
|
5582
5945
|
@app.get("/mcp/installed")
|
|
5583
5946
|
async def mcp_installed(request: Request):
|
|
5584
5947
|
require_user(request)
|
|
5585
5948
|
installed = load_mcp_installs().get("installed", {})
|
|
5586
|
-
|
|
5949
|
+
registry = await _get_combined_registry()
|
|
5950
|
+
return {"installed": [mcp_public_item(item, installed) for item in registry]}
|
|
5587
5951
|
|
|
5588
5952
|
|
|
5589
5953
|
@app.get("/mcp/connectors/{mcp_id}")
|
|
5590
5954
|
async def mcp_connector(mcp_id: str, request: Request):
|
|
5591
5955
|
require_user(request)
|
|
5592
|
-
|
|
5956
|
+
registry = await _get_combined_registry()
|
|
5957
|
+
item = next((e for e in registry if e["id"] == mcp_id), None)
|
|
5958
|
+
if not item or item.get("install_mode") != "connector":
|
|
5959
|
+
raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
|
|
5960
|
+
installed = load_mcp_installs().get("installed", {})
|
|
5961
|
+
public = mcp_public_item(item, installed)
|
|
5962
|
+
public["instructions"] = [
|
|
5963
|
+
"Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
|
|
5964
|
+
f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
|
|
5965
|
+
"인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
|
|
5966
|
+
]
|
|
5967
|
+
return public
|
|
5968
|
+
|
|
5969
|
+
|
|
5970
|
+
@app.post("/mcp/registry/refresh")
|
|
5971
|
+
async def mcp_registry_refresh(request: Request):
|
|
5972
|
+
require_user(request)
|
|
5973
|
+
global _REMOTE_REGISTRY_FETCHED_AT
|
|
5974
|
+
_REMOTE_REGISTRY_FETCHED_AT = None
|
|
5975
|
+
registry = await _get_combined_registry()
|
|
5976
|
+
return {"status": "ok", "total": len(registry), "remote": len(_REMOTE_REGISTRY_CACHE)}
|
|
5977
|
+
|
|
5978
|
+
|
|
5979
|
+
# ── Skills & Plugin Directory endpoints ───────────────────────────────────────
|
|
5980
|
+
|
|
5981
|
+
@app.get("/skills/marketplace")
|
|
5982
|
+
async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
|
|
5983
|
+
"""Skills 마켓플레이스 (Anthropic Apache-2.0 + 검증된 서드파티 MIT/Apache-2.0)"""
|
|
5984
|
+
require_user(request)
|
|
5985
|
+
skills = await _fetch_skills_marketplace()
|
|
5986
|
+
installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
|
|
5987
|
+
filtered = skills
|
|
5988
|
+
if category:
|
|
5989
|
+
filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
|
|
5990
|
+
if author:
|
|
5991
|
+
filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
|
|
5992
|
+
return {
|
|
5993
|
+
"skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
|
|
5994
|
+
"total": len(filtered),
|
|
5995
|
+
"authors": sorted({s["author"] for s in skills}),
|
|
5996
|
+
"categories": sorted({s["category"] for s in skills}),
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5999
|
+
|
|
6000
|
+
@app.post("/skills/install")
|
|
6001
|
+
async def skills_install(req: SkillInstallRequest, request: Request):
|
|
6002
|
+
"""skill을 로컬 skills 디렉터리에 설치 (Apache-2.0 / MIT)"""
|
|
6003
|
+
require_user(request)
|
|
6004
|
+
return await install_skill(req.plugin, req.skill)
|
|
6005
|
+
|
|
6006
|
+
|
|
6007
|
+
@app.get("/skills/list")
|
|
6008
|
+
async def skills_list(request: Request):
|
|
6009
|
+
"""로컬에 설치된 skills 목록"""
|
|
6010
|
+
require_user(request)
|
|
6011
|
+
if not SKILLS_DIR.exists():
|
|
6012
|
+
return {"skills": []}
|
|
6013
|
+
skills = []
|
|
6014
|
+
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
6015
|
+
if not skill_dir.is_dir():
|
|
6016
|
+
continue
|
|
6017
|
+
skill_md = skill_dir / "SKILL.md"
|
|
6018
|
+
if not skill_md.exists():
|
|
6019
|
+
continue
|
|
6020
|
+
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
6021
|
+
desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
|
|
6022
|
+
comment = lines[0] if lines else ""
|
|
6023
|
+
if "anthropics/claude-plugins-official" in comment:
|
|
6024
|
+
source = "anthropic"
|
|
6025
|
+
elif "Source:" in comment:
|
|
6026
|
+
source = "third-party"
|
|
6027
|
+
else:
|
|
6028
|
+
source = "local"
|
|
6029
|
+
skills.append({"name": skill_dir.name, "description": desc, "source": source})
|
|
6030
|
+
return {"skills": skills, "total": len(skills)}
|
|
6031
|
+
|
|
6032
|
+
|
|
6033
|
+
@app.post("/skills/marketplace/refresh")
|
|
6034
|
+
async def skills_marketplace_refresh(request: Request):
|
|
6035
|
+
"""Skills 마켓플레이스 캐시 강제 갱신"""
|
|
6036
|
+
require_user(request)
|
|
6037
|
+
global _SKILLS_MARKETPLACE_FETCHED_AT
|
|
6038
|
+
_SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
6039
|
+
skills = await _fetch_skills_marketplace()
|
|
6040
|
+
by_author = {}
|
|
6041
|
+
for s in skills:
|
|
6042
|
+
by_author[s["author"]] = by_author.get(s["author"], 0) + 1
|
|
6043
|
+
return {"status": "ok", "total": len(skills), "by_author": by_author}
|
|
6044
|
+
|
|
6045
|
+
|
|
6046
|
+
@app.get("/plugins/directory")
|
|
6047
|
+
async def plugins_directory(
|
|
6048
|
+
request: Request,
|
|
6049
|
+
category: Optional[str] = None,
|
|
6050
|
+
license: Optional[str] = None,
|
|
6051
|
+
q: Optional[str] = None,
|
|
6052
|
+
):
|
|
6053
|
+
"""오픈소스 플러그인 디렉터리 (Apache-2.0 / MIT / MIT-0, 런타임 fetch)"""
|
|
6054
|
+
require_user(request)
|
|
6055
|
+
plugins = await _fetch_plugin_directory()
|
|
6056
|
+
filtered = plugins
|
|
6057
|
+
if category:
|
|
6058
|
+
filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
|
|
6059
|
+
if license:
|
|
6060
|
+
filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
|
|
6061
|
+
if q:
|
|
6062
|
+
q_lower = q.lower()
|
|
6063
|
+
filtered = [
|
|
6064
|
+
p for p in filtered
|
|
6065
|
+
if q_lower in p.get("name", "").lower()
|
|
6066
|
+
or q_lower in p.get("description", "").lower()
|
|
6067
|
+
or q_lower in p.get("author", "").lower()
|
|
6068
|
+
]
|
|
6069
|
+
return {
|
|
6070
|
+
"plugins": filtered,
|
|
6071
|
+
"total": len(filtered),
|
|
6072
|
+
"categories": sorted({p["category"] for p in plugins if p.get("category")}),
|
|
6073
|
+
"licenses": sorted({p["license"] for p in plugins if p.get("license")}),
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
|
|
6077
|
+
@app.post("/plugins/directory/refresh")
|
|
6078
|
+
async def plugins_directory_refresh(request: Request):
|
|
6079
|
+
"""플러그인 디렉터리 캐시 강제 갱신"""
|
|
6080
|
+
require_user(request)
|
|
6081
|
+
global _PLUGIN_DIRECTORY_FETCHED_AT
|
|
6082
|
+
_PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
6083
|
+
plugins = await _fetch_plugin_directory()
|
|
6084
|
+
by_license = {}
|
|
6085
|
+
for p in plugins:
|
|
6086
|
+
lic = p.get("license", "unknown")
|
|
6087
|
+
by_license[lic] = by_license.get(lic, 0) + 1
|
|
6088
|
+
return {"status": "ok", "total": len(plugins), "by_license": by_license}
|
|
5593
6089
|
|
|
5594
6090
|
|
|
5595
6091
|
@app.post("/mcp/call")
|