Coverage for agentos/server/marketplace_platform.py: 0%
358 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:17 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:17 +0800
1"""
2AgentOS Skill Marketplace Platform (v1.8.1) # noqa: E501
4Full-stack developer marketplace:
5 - User registration & JWT authentication
6 - Skill upload with manifest validation
7 - Automated security scanning (dangerous imports, shell injection, obfuscation)
8 - Admin review queue (approve/reject with reason)
9 - Public skill browsing with search/filter
10 - Skill download & version management
11 - GitHub-style developer profiles
13Tech: FastAPI + SQLite + JWT + bcrypt
14"""
16from __future__ import annotations
18import hashlib
19import json
20import os
21import re
22import secrets
23import sqlite3
24import time
25import zipfile
26from datetime import datetime, timedelta
27from pathlib import Path
28from typing import Any
30import jwt as pyjwt
32try:
33 from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
34 from fastapi.middleware.cors import CORSMiddleware
35 from fastapi.responses import FileResponse, HTMLResponse
36 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
37 from fastapi.staticfiles import StaticFiles
38except ImportError:
39 raise RuntimeError("FastAPI, pyjwt required. Run: pip install fastapi uvicorn pyjwt")
41try:
42 import bcrypt
43except ImportError:
44 bcrypt = None
46STATIC_DIR = Path(__file__).parent / "static"
47PLATFORM_DIR = Path.home() / ".agentos" / "marketplace"
48PLATFORM_DIR.mkdir(parents=True, exist_ok=True)
49DB_PATH = PLATFORM_DIR / "platform.db"
50UPLOAD_DIR = PLATFORM_DIR / "uploads"
51UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
53JWT_SECRET = os.environ.get("MARKETPLACE_SECRET", secrets.token_hex(32))
54JWT_ALGORITHM = "HS256"
55JWT_EXPIRY_HOURS = 72
58# ── Database ─────────────────────────────────
61def get_db() -> sqlite3.Connection:
62 conn = sqlite3.connect(str(DB_PATH))
63 conn.row_factory = sqlite3.Row
64 conn.execute("PRAGMA journal_mode=WAL")
65 conn.execute("PRAGMA foreign_keys=ON")
66 return conn
69def init_db():
70 db = get_db()
71 db.executescript("""
72 CREATE TABLE IF NOT EXISTS users (
73 id INTEGER PRIMARY KEY AUTOINCREMENT,
74 username TEXT UNIQUE NOT NULL,
75 email TEXT UNIQUE NOT NULL,
76 password_hash TEXT NOT NULL,
77 display_name TEXT,
78 avatar_url TEXT,
79 github_username TEXT,
80 role TEXT DEFAULT 'developer',
81 is_admin INTEGER DEFAULT 0,
82 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
83 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
84 );
85 CREATE TABLE IF NOT EXISTS skills (
86 id INTEGER PRIMARY KEY AUTOINCREMENT,
87 author_id INTEGER NOT NULL REFERENCES users(id),
88 name TEXT NOT NULL,
89 version TEXT NOT NULL DEFAULT '0.1.0',
90 description TEXT,
91 category TEXT DEFAULT 'uncategorized',
92 tags TEXT DEFAULT '[]',
93 format TEXT DEFAULT 'agentos',
94 entrypoint TEXT,
95 manifest_json TEXT,
96 file_path TEXT,
97 file_size INTEGER,
98 file_hash TEXT,
99 download_count INTEGER DEFAULT 0,
100 status TEXT DEFAULT 'pending',
101 security_score INTEGER,
102 security_report TEXT,
103 review_comment TEXT,
104 reviewed_by INTEGER REFERENCES users(id),
105 reviewed_at TIMESTAMP,
106 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
107 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
108 UNIQUE(name, author_id)
109 );
110 CREATE TABLE IF NOT EXISTS reviews (
111 id INTEGER PRIMARY KEY AUTOINCREMENT,
112 skill_id INTEGER NOT NULL REFERENCES skills(id),
113 user_id INTEGER NOT NULL REFERENCES users(id),
114 rating INTEGER CHECK(rating >= 1 AND rating <= 5),
115 comment TEXT,
116 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
117 );
118 CREATE TABLE IF NOT EXISTS api_tokens (
119 id INTEGER PRIMARY KEY AUTOINCREMENT,
120 user_id INTEGER NOT NULL REFERENCES users(id),
121 token_hash TEXT NOT NULL,
122 name TEXT,
123 last_used TIMESTAMP,
124 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
125 );
126 """)
127 # Ensure admin user exists
128 admin = db.execute("SELECT id FROM users WHERE username = ?", ("admin",)).fetchone()
129 if not admin:
130 _hash = _hash_password("admin123")
131 db.execute(
132 "INSERT INTO users (username, email, password_hash, role, is_admin) VALUES (?,?,?,?,?)",
133 ("admin", "admin@agentos.dev", _hash, "admin", 1),
134 )
135 db.commit()
136 db.close()
139def _hash_password(password: str) -> str:
140 if bcrypt:
141 return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
142 return hashlib.sha256(f"agentos:{password}".encode()).hexdigest()
145def _verify_password(password: str, password_hash: str) -> bool:
146 if bcrypt and password_hash.startswith("$2"):
147 return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
148 return _hash_password(password) == password_hash
151# ── Security Scanner ─────────────────────────
154class SecurityScanner:
155 """Scans uploaded skill packages for security issues."""
157 DANGEROUS_IMPORTS = {
158 "os.system",
159 "subprocess",
160 "eval(",
161 "exec(",
162 "compile(",
163 "__import__",
164 "importlib",
165 "builtins",
166 "ctypes",
167 "socket",
168 "requests",
169 "urllib",
170 "http.client",
171 "shutil.rmtree",
172 "shutil.copy",
173 "pathlib.Path.unlink",
174 "pickle",
175 "marshal",
176 "dill",
177 }
179 DANGEROUS_SHELL = {
180 " rm ",
181 "rm -rf",
182 "sudo ",
183 "chmod 777",
184 "chown ",
185 " | sh",
186 " | bash",
187 "$(",
188 "`",
189 "; rm",
190 "wget ",
191 "curl ",
192 "/dev/null",
193 "> /etc/",
194 "> ~/.ssh/",
195 }
197 OBFUSCATION_SIGNALS = {
198 "base64.b64decode",
199 "base64.b64encode",
200 "exec(base64",
201 "decode('utf-8')",
202 "eval(compile",
203 "globals()",
204 "__builtins__",
205 "lambda.*exec",
206 "lambda.*eval",
207 "getattr.*__",
208 }
210 @classmethod
211 def scan_zip(cls, zip_path: str) -> dict[str, Any]:
212 """Scan a skill zip package and return security report."""
213 findings = []
214 score = 100
215 files_scanned = 0
216 total_size = 0
218 try:
219 with zipfile.ZipFile(zip_path, "r") as zf:
220 for info in zf.infolist():
221 if info.is_dir():
222 continue
223 total_size += info.file_size
224 name = info.filename.lower()
226 # Skip binary files
227 if any(
228 name.endswith(ext)
229 for ext in (".pyc", ".so", ".dll", ".exe", ".png", ".jpg", ".ico")
230 ):
231 continue
233 try:
234 content = zf.read(info.filename).decode("utf-8", errors="replace")
235 files_scanned += 1
236 except Exception:
237 continue
239 content.split("\n")
241 # Check for dangerous imports
242 for imp in cls.DANGEROUS_IMPORTS:
243 if imp in content:
244 findings.append(
245 {
246 "file": info.filename,
247 "severity": "high",
248 "rule": f"dangerous_import:{imp}",
249 "line": content.find(imp),
250 }
251 )
252 score -= 20
254 # Check for shell injection
255 for pat in cls.DANGEROUS_SHELL:
256 if pat in content:
257 findings.append(
258 {
259 "file": info.filename,
260 "severity": "critical",
261 "rule": f"shell_injection:{pat.strip()}",
262 }
263 )
264 score -= 30
266 # Check for obfuscation
267 for pat in cls.OBFUSCATION_SIGNALS:
268 if re.search(pat, content):
269 findings.append(
270 {
271 "file": info.filename,
272 "severity": "medium",
273 "rule": f"obfuscation:{pat}",
274 }
275 )
276 score -= 15
278 # Check for hardcoded secrets
279 if re.search(
280 r'(api_key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}',
281 content,
282 ):
283 findings.append(
284 {"file": info.filename, "severity": "high", "rule": "hardcoded_secret"}
285 )
286 score -= 25
287 except zipfile.BadZipFile:
288 return {"score": 0, "findings": [{"severity": "critical", "rule": "invalid_zip"}]}
290 return {
291 "score": max(0, score),
292 "findings": findings,
293 "files_scanned": files_scanned,
294 "total_size": total_size,
295 "risk_level": (
296 "low"
297 if score >= 80
298 else "medium" if score >= 50 else "high" if score >= 20 else "critical"
299 ),
300 }
302 @classmethod
303 def validate_manifest(cls, manifest: dict) -> list[str]:
304 """Validate skill manifest structure. Returns list of errors."""
305 errors = []
306 required = ["name", "version", "description"]
307 for field in required:
308 if not manifest.get(field):
309 errors.append(f"Missing required field: {field}")
311 if "name" in manifest:
312 name = manifest["name"]
313 if not re.match(r"^[a-zA-Z][a-zA-Z0-9_\-]*$", name):
314 errors.append(
315 f"Invalid skill name: {name}. Use alphanumeric, hyphens, underscores."
316 )
318 if "version" in manifest:
319 version = manifest["version"]
320 if not re.match(r"^\d+\.\d+\.\d+$", version):
321 errors.append(f"Invalid version format: {version}. Use semver (e.g., 0.1.0).")
323 return errors
326# ── Auth Utilities ───────────────────────────
328security_scheme = HTTPBearer(auto_error=False)
331def create_token(user_id: int, username: str, is_admin: bool) -> str:
332 payload = {
333 "user_id": user_id,
334 "username": username,
335 "is_admin": is_admin,
336 "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS),
337 "iat": datetime.utcnow(),
338 }
339 return pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
342def decode_token(token: str) -> dict | None:
343 try:
344 return pyjwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
345 except Exception:
346 return None
349async def get_current_user(
350 credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
351):
352 if not credentials:
353 raise HTTPException(status_code=401, detail="Authentication required")
354 payload = decode_token(credentials.credentials)
355 if not payload:
356 raise HTTPException(status_code=401, detail="Invalid or expired token")
357 db = get_db()
358 user = db.execute("SELECT * FROM users WHERE id = ?", (payload["user_id"],)).fetchone()
359 db.close()
360 if not user:
361 raise HTTPException(status_code=401, detail="User not found")
362 return dict(user)
365async def get_admin_user(user: dict = Depends(get_current_user)):
366 if not user.get("is_admin"):
367 raise HTTPException(status_code=403, detail="Admin privileges required")
368 return user
371# ── FastAPI App ──────────────────────────────
374def create_marketplace_app() -> FastAPI:
375 init_db()
377 app = FastAPI(
378 title="AgentOS Skill Marketplace",
379 version="1.8.1",
380 description="Open developer marketplace for AgentOS skills. Upload, review, and discover AI agent skills.",
381 )
383 app.add_middleware(
384 CORSMiddleware,
385 allow_origins=["*"],
386 allow_methods=["*"],
387 allow_headers=["*"],
388 )
390 if STATIC_DIR.exists():
391 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
393 # ── Web UI ──
394 @app.get("/", response_class=HTMLResponse)
395 async def web_ui():
396 html_path = STATIC_DIR / "platform.html"
397 if html_path.exists():
398 return HTMLResponse(html_path.read_text(encoding="utf-8"))
399 return HTMLResponse("<h1>Marketplace Platform</h1>", status_code=404)
401 # ── Auth Endpoints ──
403 @app.post("/api/auth/register")
404 async def register(
405 username: str = Form(...),
406 email: str = Form(...),
407 password: str = Form(...),
408 display_name: str = Form(""),
409 ):
410 if len(password) < 6:
411 raise HTTPException(400, "Password must be at least 6 characters")
412 if not re.match(r"^[a-zA-Z0-9_]{3,30}$", username):
413 raise HTTPException(400, "Username: 3-30 chars, alphanumeric/underscore")
415 db = get_db()
416 existing = db.execute(
417 "SELECT id FROM users WHERE username=? OR email=?", (username, email)
418 ).fetchone()
419 if existing:
420 db.close()
421 raise HTTPException(409, "Username or email already exists")
423 pw_hash = _hash_password(password)
424 try:
425 db.execute(
426 "INSERT INTO users (username, email, password_hash, display_name) VALUES (?,?,?,?)",
427 (username, email, pw_hash, display_name or username),
428 )
429 db.commit()
430 user_id = db.lastrowid
431 finally:
432 db.close()
434 token = create_token(user_id, username, False)
435 return {
436 "token": token,
437 "user": {"id": user_id, "username": username, "email": email, "is_admin": False},
438 }
440 @app.post("/api/auth/login")
441 async def login(username: str = Form(...), password: str = Form(...)):
442 db = get_db()
443 user = db.execute(
444 "SELECT * FROM users WHERE username = ? OR email = ?", (username, username)
445 ).fetchone()
446 if not user or not _verify_password(password, user["password_hash"]):
447 db.close()
448 raise HTTPException(401, "Invalid credentials")
449 db.close()
451 token = create_token(user["id"], user["username"], bool(user["is_admin"]))
452 return {
453 "token": token,
454 "user": {
455 "id": user["id"],
456 "username": user["username"],
457 "email": user["email"],
458 "display_name": user["display_name"],
459 "is_admin": bool(user["is_admin"]),
460 "github_username": user["github_username"],
461 },
462 }
464 @app.get("/api/auth/me")
465 async def me(user: dict = Depends(get_current_user)):
466 return {"user": {k: v for k, v in user.items() if k != "password_hash"}}
468 # ── Skill Upload ──
470 @app.post("/api/skills/upload")
471 async def upload_skill(
472 file: UploadFile = File(...),
473 name: str = Form(""),
474 version: str = Form("0.1.0"),
475 description: str = Form(""),
476 category: str = Form("uncategorized"),
477 tags: str = Form("[]"),
478 user: dict = Depends(get_current_user),
479 ):
480 """Upload a skill package (.zip containing skill files + manifest.json)."""
481 if not file.filename or not file.filename.endswith(".zip"):
482 raise HTTPException(400, "Only .zip files are accepted")
484 # Save uploaded file
485 ts = int(time.time())
486 safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", file.filename)
487 file_id = f"{user['id']}_{ts}_{safe_name}"
488 file_path = UPLOAD_DIR / file_id
489 content = await file.read()
490 file_path.write_bytes(content)
492 # Validate zip
493 if not zipfile.is_zipfile(str(file_path)):
494 file_path.unlink()
495 raise HTTPException(400, "Invalid zip file")
497 # Extract and validate manifest
498 manifest = {}
499 with zipfile.ZipFile(str(file_path)) as zf:
500 if "skill.yaml" in zf.namelist():
501 manifest_text = zf.read("skill.yaml").decode("utf-8")
502 manifest = _parse_yaml_simple(manifest_text)
503 elif "skill.json" in zf.namelist():
504 manifest = json.loads(zf.read("skill.json"))
505 elif "manifest.json" in zf.namelist():
506 manifest = json.loads(zf.read("manifest.json"))
508 # Use form fields as fallback
509 skill_name = manifest.get("name") or name or file.filename.replace(".zip", "")
510 skill_version = manifest.get("version") or version
511 skill_desc = manifest.get("description") or description
512 skill_category = manifest.get("category") or category
513 skill_tags = manifest.get("tags", []) if isinstance(manifest.get("tags"), list) else []
515 # Use form tags if manifest has none
516 if not skill_tags:
517 try:
518 skill_tags = json.loads(tags) if isinstance(tags, str) else tags
519 except json.JSONDecodeError:
520 skill_tags = []
522 # Validate
523 manifest_errors = SecurityScanner.validate_manifest(
524 {
525 "name": skill_name,
526 "version": skill_version,
527 "description": skill_desc,
528 }
529 )
530 if manifest_errors:
531 file_path.unlink()
532 raise HTTPException(400, f"Manifest validation failed: {'; '.join(manifest_errors)}")
534 # Security scan
535 security = SecurityScanner.scan_zip(str(file_path))
536 auto_status = "published" if security["risk_level"] == "low" else "flagged"
537 if security["score"] <= 20:
538 auto_status = "rejected"
539 file_path.unlink()
540 raise HTTPException(
541 400,
542 f"Security scan failed (score: {security['score']}/100). "
543 f"Risk: {security['risk_level']}. Findings: {len(security['findings'])}",
544 )
546 # Compute hash
547 file_hash = hashlib.sha256(content).hexdigest()
549 db = get_db()
550 try:
551 db.execute(
552 """INSERT INTO skills (author_id, name, version, description, category, tags,
553 format, entrypoint, manifest_json, file_path, file_size, file_hash,
554 status, security_score, security_report)
555 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
556 (
557 user["id"],
558 skill_name,
559 skill_version,
560 skill_desc,
561 skill_category,
562 json.dumps(skill_tags),
563 manifest.get("format", "agentos"),
564 manifest.get("entrypoint", ""),
565 json.dumps(manifest, ensure_ascii=False),
566 str(file_path.absolute()),
567 os.path.getsize(str(file_path)),
568 file_hash,
569 auto_status,
570 security["score"],
571 json.dumps(security, ensure_ascii=False),
572 ),
573 )
574 db.commit()
575 skill_id = db.lastrowid
576 except sqlite3.IntegrityError:
577 file_path.unlink()
578 db.close()
579 raise HTTPException(409, "You already have a skill with this name")
580 db.close()
582 return {
583 "id": skill_id,
584 "name": skill_name,
585 "version": skill_version,
586 "status": auto_status,
587 "security_score": security["score"],
588 "risk_level": security["risk_level"],
589 "findings_count": len(security["findings"]),
590 }
592 # ── Public Browse ──
594 @app.get("/api/skills")
595 async def list_skills(
596 q: str = Query(""),
597 category: str = Query(""),
598 status: str = Query("published"),
599 sort: str = Query("downloads"),
600 page: int = Query(1),
601 limit: int = Query(30),
602 ):
603 db = get_db()
604 where = ["s.status = ?"]
605 params: list = [status]
607 if q:
608 where.append("(s.name LIKE ? OR s.description LIKE ?)")
609 params.extend([f"%{q}%", f"%{q}%"])
610 if category:
611 where.append("s.category = ?")
612 params.append(category)
614 order = "s.download_count DESC" if sort == "downloads" else "s.created_at DESC"
615 offset = (page - 1) * limit
617 skills = db.execute(
618 f"""SELECT s.*, u.username as author_name, u.display_name as author_display
619 FROM skills s JOIN users u ON s.author_id = u.id
620 WHERE {' AND '.join(where)}
621 ORDER BY {order}
622 LIMIT ? OFFSET ?""",
623 params + [limit, offset],
624 ).fetchall()
626 total = db.execute(
627 f"SELECT COUNT(*) FROM skills s WHERE {' AND '.join(where)}", params
628 ).fetchone()[0]
629 db.close()
631 return {
632 "total": total,
633 "page": page,
634 "limit": limit,
635 "skills": [
636 {
637 "id": s["id"],
638 "name": s["name"],
639 "version": s["version"],
640 "description": s["description"],
641 "category": s["category"],
642 "tags": json.loads(s["tags"]),
643 "format": s["format"],
644 "download_count": s["download_count"],
645 "status": s["status"],
646 "security_score": s["security_score"],
647 "author": {"username": s["author_name"], "display_name": s["author_display"]},
648 "created_at": s["created_at"],
649 }
650 for s in skills
651 ],
652 }
654 @app.get("/api/skills/{skill_id}")
655 async def get_skill_detail(skill_id: int):
656 db = get_db()
657 skill = db.execute(
658 """SELECT s.*, u.username as author_name, u.display_name as author_display
659 FROM skills s JOIN users u ON s.author_id = u.id
660 WHERE s.id = ? AND s.status = 'published'""",
661 (skill_id,),
662 ).fetchone()
663 if not skill:
664 db.close()
665 raise HTTPException(404, "Skill not found")
667 # Get review stats
668 review_stats = db.execute(
669 "SELECT COUNT(*) as count, AVG(rating) as avg_rating FROM reviews WHERE skill_id = ?",
670 (skill_id,),
671 ).fetchone()
672 db.close()
674 return {
675 "id": skill["id"],
676 "name": skill["name"],
677 "version": skill["version"],
678 "description": skill["description"],
679 "category": skill["category"],
680 "tags": json.loads(skill["tags"]),
681 "format": skill["format"],
682 "entrypoint": skill["entrypoint"],
683 "manifest": json.loads(skill["manifest_json"] or "{}"),
684 "download_count": skill["download_count"],
685 "status": skill["status"],
686 "security_score": skill["security_score"],
687 "author": {"username": skill["author_name"], "display_name": skill["author_display"]},
688 "created_at": skill["created_at"],
689 "reviews": {
690 "count": review_stats["count"] or 0,
691 "avg_rating": round(review_stats["avg_rating"] or 0, 1),
692 },
693 }
695 # ── Download ──
697 @app.get("/api/skills/{skill_id}/download")
698 async def download_skill(skill_id: int):
699 db = get_db()
700 skill = db.execute(
701 "SELECT * FROM skills WHERE id = ? AND status = 'published'", (skill_id,)
702 ).fetchone()
703 if not skill:
704 db.close()
705 raise HTTPException(404, "Skill not found")
707 db.execute(
708 "UPDATE skills SET download_count = download_count + 1 WHERE id = ?", (skill_id,)
709 )
710 db.commit()
711 db.close()
713 file_path = Path(skill["file_path"])
714 if not file_path.exists():
715 raise HTTPException(404, "Skill file not found on server")
717 return FileResponse(
718 path=str(file_path),
719 filename=f"{skill['name']}-{skill['version']}.zip",
720 media_type="application/zip",
721 )
723 # ── Admin: Review Queue ──
725 @app.get("/api/admin/review-queue")
726 async def review_queue(user: dict = Depends(get_admin_user)):
727 db = get_db()
728 skills = db.execute("""SELECT s.*, u.username as author_name
729 FROM skills s JOIN users u ON s.author_id = u.id
730 WHERE s.status IN ('pending', 'flagged')
731 ORDER BY s.created_at DESC""").fetchall()
732 db.close()
733 return {
734 "count": len(skills),
735 "skills": [
736 {
737 "id": s["id"],
738 "name": s["name"],
739 "version": s["version"],
740 "description": s["description"],
741 "category": s["category"],
742 "status": s["status"],
743 "security_score": s["security_score"],
744 "security_report": json.loads(s["security_report"] or "{}"),
745 "author": s["author_name"],
746 "created_at": s["created_at"],
747 }
748 for s in skills
749 ],
750 }
752 @app.post("/api/admin/review/{skill_id}")
753 async def review_skill(
754 skill_id: int,
755 action: str = Form(...), # "approve" or "reject"
756 comment: str = Form(""),
757 user: dict = Depends(get_admin_user),
758 ):
759 if action not in ("approve", "reject"):
760 raise HTTPException(400, "Action must be 'approve' or 'reject'")
762 db = get_db()
763 skill = db.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
764 if not skill:
765 db.close()
766 raise HTTPException(404, "Skill not found")
768 new_status = "published" if action == "approve" else "rejected"
769 db.execute(
770 "UPDATE skills SET status=?, review_comment=?, reviewed_by=?, reviewed_at=CURRENT_TIMESTAMP WHERE id=?",
771 (new_status, comment, user["id"], skill_id),
772 )
773 db.commit()
774 db.close()
776 return {"id": skill_id, "status": new_status, "action": action}
778 # ── Categories ──
780 @app.get("/api/categories")
781 async def list_categories():
782 db = get_db()
783 cats = db.execute(
784 "SELECT category, COUNT(*) as count FROM skills WHERE status='published' GROUP BY category ORDER BY count DESC" # noqa: E501
785 ).fetchall()
786 db.close()
787 return {"categories": [{"name": c["category"], "count": c["count"]} for c in cats]}
789 # ── Developer Profile ──
791 @app.get("/api/developers/{username}")
792 async def developer_profile(username: str):
793 db = get_db()
794 user = db.execute(
795 "SELECT id, username, display_name, avatar_url, github_username, created_at FROM users WHERE username = ?",
796 (username,),
797 ).fetchone()
798 if not user:
799 db.close()
800 raise HTTPException(404, "Developer not found")
802 skills = db.execute(
803 "SELECT id, name, version, description, category, tags, download_count, status, created_at FROM skills WHERE author_id = ? AND status = 'published' ORDER BY download_count DESC", # noqa: E501
804 (user["id"],),
805 ).fetchall()
806 db.close()
808 return {
809 "developer": dict(user),
810 "skills": [dict(s) for s in skills],
811 "total_skills": len(skills),
812 "total_downloads": sum(s["download_count"] for s in skills),
813 }
815 # ── My Skills ──
817 @app.get("/api/my/skills")
818 async def my_skills(user: dict = Depends(get_current_user)):
819 db = get_db()
820 skills = db.execute(
821 "SELECT * FROM skills WHERE author_id = ? ORDER BY created_at DESC",
822 (user["id"],),
823 ).fetchall()
824 db.close()
825 return {"skills": [dict(s) for s in skills]}
827 # ── Health ──
829 @app.get("/api/health")
830 async def health_check():
831 db = get_db()
832 skill_count = db.execute("SELECT COUNT(*) FROM skills WHERE status='published'").fetchone()[
833 0
834 ]
835 user_count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
836 db.close()
837 return {
838 "status": "healthy",
839 "version": "1.8.1",
840 "published_skills": skill_count,
841 "registered_developers": user_count,
842 }
844 return app
847def _parse_yaml_simple(text: str) -> dict[str, Any]:
848 """Simple YAML parser for skill manifests. Handles basic key: value + lists."""
849 result: dict[str, Any] = {}
850 current_key = None
851 for line in text.split("\n"):
852 stripped = line.strip()
853 if not stripped or stripped.startswith("#"):
854 continue
855 if ":" in stripped and not stripped.startswith("- "):
856 key, _, val = stripped.partition(":")
857 key = key.strip()
858 val = val.strip().strip('"').strip("'")
859 if val:
860 result[key] = val
861 else:
862 result[key] = []
863 current_key = key
864 elif stripped.startswith("- ") and current_key:
865 item = stripped[2:].strip().strip('"').strip("'")
866 result[current_key].append(item)
867 return result
870def start_marketplace_platform(host: str = "0.0.0.0", port: int = 8911) -> None:
871 """Start the marketplace platform server (blocking)."""
872 import uvicorn
874 app = create_marketplace_app()
875 print("\n AgentOS Skill Marketplace Platform v1.8.1")
876 print(f" Local: http://{host}:{port}")
877 print(" Admin: admin / admin123")
878 print(" Upload: POST /api/skills/upload | Browse: GET /api/skills")
879 print()
880 uvicorn.run(app, host=host, port=port, log_level="info")