Coverage for agentos/server/marketplace_platform.py: 0%
360 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
1"""
2AgentOS Skill Marketplace Platform (v1.8.1)
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 shutil
24import sqlite3
25import tempfile
26import time
27import zipfile
28from datetime import datetime, timedelta
29from pathlib import Path
30from typing import Optional, List, Dict, Any
32import jwt as pyjwt
33try:
34 from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form, Depends, Request
35 from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, StreamingResponse
36 from fastapi.staticfiles import StaticFiles
37 from fastapi.middleware.cors import CORSMiddleware
38 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
39except ImportError:
40 raise RuntimeError("FastAPI, pyjwt required. Run: pip install fastapi uvicorn pyjwt")
42try:
43 import bcrypt
44except ImportError:
45 bcrypt = None
47STATIC_DIR = Path(__file__).parent / "static"
48PLATFORM_DIR = Path.home() / ".agentos" / "marketplace"
49PLATFORM_DIR.mkdir(parents=True, exist_ok=True)
50DB_PATH = PLATFORM_DIR / "platform.db"
51UPLOAD_DIR = PLATFORM_DIR / "uploads"
52UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
54JWT_SECRET = os.environ.get("MARKETPLACE_SECRET", secrets.token_hex(32))
55JWT_ALGORITHM = "HS256"
56JWT_EXPIRY_HOURS = 72
59# ── 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 ─────────────────────────
153class SecurityScanner:
154 """Scans uploaded skill packages for security issues."""
156 DANGEROUS_IMPORTS = {
157 "os.system", "subprocess", "eval(", "exec(", "compile(",
158 "__import__", "importlib", "builtins", "ctypes",
159 "socket", "requests", "urllib", "http.client",
160 "shutil.rmtree", "shutil.copy", "pathlib.Path.unlink",
161 "pickle", "marshal", "dill",
162 }
164 DANGEROUS_SHELL = {" rm ", "rm -rf", "sudo ", "chmod 777", "chown ",
165 " | sh", " | bash", "$(", "`", "; rm", "wget ", "curl ",
166 "/dev/null", "> /etc/", "> ~/.ssh/"}
168 OBFUSCATION_SIGNALS = {"base64.b64decode", "base64.b64encode", "exec(base64",
169 "decode('utf-8')", "eval(compile", "globals()", "__builtins__",
170 "lambda.*exec", "lambda.*eval", "getattr.*__"}
172 @classmethod
173 def scan_zip(cls, zip_path: str) -> Dict[str, Any]:
174 """Scan a skill zip package and return security report."""
175 findings = []
176 score = 100
177 files_scanned = 0
178 total_size = 0
180 try:
181 with zipfile.ZipFile(zip_path, "r") as zf:
182 for info in zf.infolist():
183 if info.is_dir():
184 continue
185 total_size += info.file_size
186 name = info.filename.lower()
188 # Skip binary files
189 if any(name.endswith(ext) for ext in (".pyc", ".so", ".dll", ".exe", ".png", ".jpg", ".ico")):
190 continue
192 try:
193 content = zf.read(info.filename).decode("utf-8", errors="replace")
194 files_scanned += 1
195 except Exception:
196 continue
198 lines = content.split("\n")
200 # Check for dangerous imports
201 for imp in cls.DANGEROUS_IMPORTS:
202 if imp in content:
203 findings.append({"file": info.filename, "severity": "high",
204 "rule": f"dangerous_import:{imp}", "line": content.find(imp)})
205 score -= 20
207 # Check for shell injection
208 for pat in cls.DANGEROUS_SHELL:
209 if pat in content:
210 findings.append({"file": info.filename, "severity": "critical",
211 "rule": f"shell_injection:{pat.strip()}"})
212 score -= 30
214 # Check for obfuscation
215 for pat in cls.OBFUSCATION_SIGNALS:
216 if re.search(pat, content):
217 findings.append({"file": info.filename, "severity": "medium",
218 "rule": f"obfuscation:{pat}"})
219 score -= 15
221 # Check for hardcoded secrets
222 if re.search(r'(api_key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}', content):
223 findings.append({"file": info.filename, "severity": "high",
224 "rule": "hardcoded_secret"})
225 score -= 25
226 except zipfile.BadZipFile:
227 return {"score": 0, "findings": [{"severity": "critical", "rule": "invalid_zip"}]}
229 return {
230 "score": max(0, score),
231 "findings": findings,
232 "files_scanned": files_scanned,
233 "total_size": total_size,
234 "risk_level": "low" if score >= 80 else "medium" if score >= 50 else "high" if score >= 20 else "critical",
235 }
237 @classmethod
238 def validate_manifest(cls, manifest: Dict) -> List[str]:
239 """Validate skill manifest structure. Returns list of errors."""
240 errors = []
241 required = ["name", "version", "description"]
242 for field in required:
243 if not manifest.get(field):
244 errors.append(f"Missing required field: {field}")
246 if "name" in manifest:
247 name = manifest["name"]
248 if not re.match(r'^[a-zA-Z][a-zA-Z0-9_\-]*$', name):
249 errors.append(f"Invalid skill name: {name}. Use alphanumeric, hyphens, underscores.")
251 if "version" in manifest:
252 version = manifest["version"]
253 if not re.match(r'^\d+\.\d+\.\d+$', version):
254 errors.append(f"Invalid version format: {version}. Use semver (e.g., 0.1.0).")
256 return errors
259# ── Auth Utilities ───────────────────────────
261security_scheme = HTTPBearer(auto_error=False)
264def create_token(user_id: int, username: str, is_admin: bool) -> str:
265 payload = {
266 "user_id": user_id,
267 "username": username,
268 "is_admin": is_admin,
269 "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS),
270 "iat": datetime.utcnow(),
271 }
272 return pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
275def decode_token(token: str) -> Optional[Dict]:
276 try:
277 return pyjwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
278 except Exception:
279 return None
282async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)):
283 if not credentials:
284 raise HTTPException(status_code=401, detail="Authentication required")
285 payload = decode_token(credentials.credentials)
286 if not payload:
287 raise HTTPException(status_code=401, detail="Invalid or expired token")
288 db = get_db()
289 user = db.execute("SELECT * FROM users WHERE id = ?", (payload["user_id"],)).fetchone()
290 db.close()
291 if not user:
292 raise HTTPException(status_code=401, detail="User not found")
293 return dict(user)
296async def get_admin_user(user: dict = Depends(get_current_user)):
297 if not user.get("is_admin"):
298 raise HTTPException(status_code=403, detail="Admin privileges required")
299 return user
302# ── FastAPI App ──────────────────────────────
304def create_marketplace_app() -> FastAPI:
305 init_db()
307 app = FastAPI(
308 title="AgentOS Skill Marketplace",
309 version="1.8.1",
310 description="Open developer marketplace for AgentOS skills. Upload, review, and discover AI agent skills.",
311 )
313 app.add_middleware(
314 CORSMiddleware,
315 allow_origins=["*"],
316 allow_methods=["*"],
317 allow_headers=["*"],
318 )
320 if STATIC_DIR.exists():
321 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
323 # ── Web UI ──
324 @app.get("/", response_class=HTMLResponse)
325 async def web_ui():
326 html_path = STATIC_DIR / "platform.html"
327 if html_path.exists():
328 return HTMLResponse(html_path.read_text(encoding="utf-8"))
329 return HTMLResponse("<h1>Marketplace Platform</h1>", status_code=404)
331 # ── Auth Endpoints ──
333 @app.post("/api/auth/register")
334 async def register(username: str = Form(...), email: str = Form(...),
335 password: str = Form(...), display_name: str = Form("")):
336 if len(password) < 6:
337 raise HTTPException(400, "Password must be at least 6 characters")
338 if not re.match(r'^[a-zA-Z0-9_]{3,30}$', username):
339 raise HTTPException(400, "Username: 3-30 chars, alphanumeric/underscore")
341 db = get_db()
342 existing = db.execute("SELECT id FROM users WHERE username=? OR email=?",
343 (username, email)).fetchone()
344 if existing:
345 db.close()
346 raise HTTPException(409, "Username or email already exists")
348 pw_hash = _hash_password(password)
349 try:
350 db.execute("INSERT INTO users (username, email, password_hash, display_name) VALUES (?,?,?,?)",
351 (username, email, pw_hash, display_name or username))
352 db.commit()
353 user_id = db.lastrowid
354 finally:
355 db.close()
357 token = create_token(user_id, username, False)
358 return {"token": token, "user": {"id": user_id, "username": username, "email": email, "is_admin": False}}
360 @app.post("/api/auth/login")
361 async def login(username: str = Form(...), password: str = Form(...)):
362 db = get_db()
363 user = db.execute("SELECT * FROM users WHERE username = ? OR email = ?",
364 (username, username)).fetchone()
365 if not user or not _verify_password(password, user["password_hash"]):
366 db.close()
367 raise HTTPException(401, "Invalid credentials")
368 db.close()
370 token = create_token(user["id"], user["username"], bool(user["is_admin"]))
371 return {
372 "token": token,
373 "user": {
374 "id": user["id"], "username": user["username"], "email": user["email"],
375 "display_name": user["display_name"], "is_admin": bool(user["is_admin"]),
376 "github_username": user["github_username"],
377 }
378 }
380 @app.get("/api/auth/me")
381 async def me(user: dict = Depends(get_current_user)):
382 return {"user": {k: v for k, v in user.items() if k != "password_hash"}}
384 # ── Skill Upload ──
386 @app.post("/api/skills/upload")
387 async def upload_skill(
388 file: UploadFile = File(...),
389 name: str = Form(""),
390 version: str = Form("0.1.0"),
391 description: str = Form(""),
392 category: str = Form("uncategorized"),
393 tags: str = Form("[]"),
394 user: dict = Depends(get_current_user),
395 ):
396 """Upload a skill package (.zip containing skill files + manifest.json)."""
397 if not file.filename or not file.filename.endswith(".zip"):
398 raise HTTPException(400, "Only .zip files are accepted")
400 # Save uploaded file
401 ts = int(time.time())
402 safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', file.filename)
403 file_id = f"{user['id']}_{ts}_{safe_name}"
404 file_path = UPLOAD_DIR / file_id
405 content = await file.read()
406 file_path.write_bytes(content)
408 # Validate zip
409 if not zipfile.is_zipfile(str(file_path)):
410 file_path.unlink()
411 raise HTTPException(400, "Invalid zip file")
413 # Extract and validate manifest
414 manifest = {}
415 with zipfile.ZipFile(str(file_path)) as zf:
416 if "skill.yaml" in zf.namelist():
417 manifest_text = zf.read("skill.yaml").decode("utf-8")
418 manifest = _parse_yaml_simple(manifest_text)
419 elif "skill.json" in zf.namelist():
420 manifest = json.loads(zf.read("skill.json"))
421 elif "manifest.json" in zf.namelist():
422 manifest = json.loads(zf.read("manifest.json"))
424 # Use form fields as fallback
425 skill_name = manifest.get("name") or name or file.filename.replace(".zip", "")
426 skill_version = manifest.get("version") or version
427 skill_desc = manifest.get("description") or description
428 skill_category = manifest.get("category") or category
429 skill_tags = manifest.get("tags", []) if isinstance(manifest.get("tags"), list) else []
431 # Use form tags if manifest has none
432 if not skill_tags:
433 try:
434 skill_tags = json.loads(tags) if isinstance(tags, str) else tags
435 except json.JSONDecodeError:
436 skill_tags = []
438 # Validate
439 manifest_errors = SecurityScanner.validate_manifest({
440 "name": skill_name, "version": skill_version, "description": skill_desc,
441 })
442 if manifest_errors:
443 file_path.unlink()
444 raise HTTPException(400, f"Manifest validation failed: {'; '.join(manifest_errors)}")
446 # Security scan
447 security = SecurityScanner.scan_zip(str(file_path))
448 auto_status = "published" if security["risk_level"] == "low" else "flagged"
449 if security["score"] <= 20:
450 auto_status = "rejected"
451 file_path.unlink()
452 raise HTTPException(400, f"Security scan failed (score: {security['score']}/100). "
453 f"Risk: {security['risk_level']}. Findings: {len(security['findings'])}")
455 # Compute hash
456 file_hash = hashlib.sha256(content).hexdigest()
458 db = get_db()
459 try:
460 db.execute(
461 """INSERT INTO skills (author_id, name, version, description, category, tags,
462 format, entrypoint, manifest_json, file_path, file_size, file_hash,
463 status, security_score, security_report)
464 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
465 (user["id"], skill_name, skill_version, skill_desc, skill_category,
466 json.dumps(skill_tags), manifest.get("format", "agentos"),
467 manifest.get("entrypoint", ""), json.dumps(manifest, ensure_ascii=False),
468 str(file_path.absolute()), os.path.getsize(str(file_path)), file_hash,
469 auto_status, security["score"], json.dumps(security, ensure_ascii=False)),
470 )
471 db.commit()
472 skill_id = db.lastrowid
473 except sqlite3.IntegrityError:
474 file_path.unlink()
475 db.close()
476 raise HTTPException(409, "You already have a skill with this name")
477 db.close()
479 return {
480 "id": skill_id, "name": skill_name, "version": skill_version,
481 "status": auto_status, "security_score": security["score"],
482 "risk_level": security["risk_level"],
483 "findings_count": len(security["findings"]),
484 }
486 # ── Public Browse ──
488 @app.get("/api/skills")
489 async def list_skills(
490 q: str = Query(""),
491 category: str = Query(""),
492 status: str = Query("published"),
493 sort: str = Query("downloads"),
494 page: int = Query(1),
495 limit: int = Query(30),
496 ):
497 db = get_db()
498 where = ["s.status = ?"]
499 params: list = [status]
501 if q:
502 where.append("(s.name LIKE ? OR s.description LIKE ?)")
503 params.extend([f"%{q}%", f"%{q}%"])
504 if category:
505 where.append("s.category = ?")
506 params.append(category)
508 order = "s.download_count DESC" if sort == "downloads" else "s.created_at DESC"
509 offset = (page - 1) * limit
511 skills = db.execute(
512 f"""SELECT s.*, u.username as author_name, u.display_name as author_display
513 FROM skills s JOIN users u ON s.author_id = u.id
514 WHERE {' AND '.join(where)}
515 ORDER BY {order}
516 LIMIT ? OFFSET ?""",
517 params + [limit, offset],
518 ).fetchall()
520 total = db.execute(
521 f"SELECT COUNT(*) FROM skills s WHERE {' AND '.join(where)}", params
522 ).fetchone()[0]
523 db.close()
525 return {
526 "total": total, "page": page, "limit": limit,
527 "skills": [
528 {"id": s["id"], "name": s["name"], "version": s["version"],
529 "description": s["description"], "category": s["category"],
530 "tags": json.loads(s["tags"]), "format": s["format"],
531 "download_count": s["download_count"], "status": s["status"],
532 "security_score": s["security_score"],
533 "author": {"username": s["author_name"], "display_name": s["author_display"]},
534 "created_at": s["created_at"]}
535 for s in skills
536 ],
537 }
539 @app.get("/api/skills/{skill_id}")
540 async def get_skill_detail(skill_id: int):
541 db = get_db()
542 skill = db.execute(
543 """SELECT s.*, u.username as author_name, u.display_name as author_display
544 FROM skills s JOIN users u ON s.author_id = u.id
545 WHERE s.id = ? AND s.status = 'published'""",
546 (skill_id,),
547 ).fetchone()
548 if not skill:
549 db.close()
550 raise HTTPException(404, "Skill not found")
552 # Get review stats
553 review_stats = db.execute(
554 "SELECT COUNT(*) as count, AVG(rating) as avg_rating FROM reviews WHERE skill_id = ?",
555 (skill_id,),
556 ).fetchone()
557 db.close()
559 return {
560 "id": skill["id"], "name": skill["name"], "version": skill["version"],
561 "description": skill["description"], "category": skill["category"],
562 "tags": json.loads(skill["tags"]), "format": skill["format"],
563 "entrypoint": skill["entrypoint"], "manifest": json.loads(skill["manifest_json"] or "{}"),
564 "download_count": skill["download_count"], "status": skill["status"],
565 "security_score": skill["security_score"],
566 "author": {"username": skill["author_name"], "display_name": skill["author_display"]},
567 "created_at": skill["created_at"],
568 "reviews": {"count": review_stats["count"] or 0,
569 "avg_rating": round(review_stats["avg_rating"] or 0, 1)},
570 }
572 # ── Download ──
574 @app.get("/api/skills/{skill_id}/download")
575 async def download_skill(skill_id: int):
576 db = get_db()
577 skill = db.execute(
578 "SELECT * FROM skills WHERE id = ? AND status = 'published'", (skill_id,)
579 ).fetchone()
580 if not skill:
581 db.close()
582 raise HTTPException(404, "Skill not found")
584 db.execute("UPDATE skills SET download_count = download_count + 1 WHERE id = ?", (skill_id,))
585 db.commit()
586 db.close()
588 file_path = Path(skill["file_path"])
589 if not file_path.exists():
590 raise HTTPException(404, "Skill file not found on server")
592 return FileResponse(
593 path=str(file_path),
594 filename=f"{skill['name']}-{skill['version']}.zip",
595 media_type="application/zip",
596 )
598 # ── Admin: Review Queue ──
600 @app.get("/api/admin/review-queue")
601 async def review_queue(user: dict = Depends(get_admin_user)):
602 db = get_db()
603 skills = db.execute(
604 """SELECT s.*, u.username as author_name
605 FROM skills s JOIN users u ON s.author_id = u.id
606 WHERE s.status IN ('pending', 'flagged')
607 ORDER BY s.created_at DESC"""
608 ).fetchall()
609 db.close()
610 return {
611 "count": len(skills),
612 "skills": [
613 {"id": s["id"], "name": s["name"], "version": s["version"],
614 "description": s["description"], "category": s["category"],
615 "status": s["status"], "security_score": s["security_score"],
616 "security_report": json.loads(s["security_report"] or "{}"),
617 "author": s["author_name"], "created_at": s["created_at"]}
618 for s in skills
619 ],
620 }
622 @app.post("/api/admin/review/{skill_id}")
623 async def review_skill(
624 skill_id: int,
625 action: str = Form(...), # "approve" or "reject"
626 comment: str = Form(""),
627 user: dict = Depends(get_admin_user),
628 ):
629 if action not in ("approve", "reject"):
630 raise HTTPException(400, "Action must be 'approve' or 'reject'")
632 db = get_db()
633 skill = db.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
634 if not skill:
635 db.close()
636 raise HTTPException(404, "Skill not found")
638 new_status = "published" if action == "approve" else "rejected"
639 db.execute(
640 "UPDATE skills SET status=?, review_comment=?, reviewed_by=?, reviewed_at=CURRENT_TIMESTAMP WHERE id=?",
641 (new_status, comment, user["id"], skill_id),
642 )
643 db.commit()
644 db.close()
646 return {"id": skill_id, "status": new_status, "action": action}
648 # ── Categories ──
650 @app.get("/api/categories")
651 async def list_categories():
652 db = get_db()
653 cats = db.execute(
654 "SELECT category, COUNT(*) as count FROM skills WHERE status='published' GROUP BY category ORDER BY count DESC"
655 ).fetchall()
656 db.close()
657 return {"categories": [{"name": c["category"], "count": c["count"]} for c in cats]}
659 # ── Developer Profile ──
661 @app.get("/api/developers/{username}")
662 async def developer_profile(username: str):
663 db = get_db()
664 user = db.execute("SELECT id, username, display_name, avatar_url, github_username, created_at FROM users WHERE username = ?", (username,)).fetchone()
665 if not user:
666 db.close()
667 raise HTTPException(404, "Developer not found")
669 skills = db.execute(
670 "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",
671 (user["id"],),
672 ).fetchall()
673 db.close()
675 return {
676 "developer": dict(user),
677 "skills": [dict(s) for s in skills],
678 "total_skills": len(skills),
679 "total_downloads": sum(s["download_count"] for s in skills),
680 }
682 # ── My Skills ──
684 @app.get("/api/my/skills")
685 async def my_skills(user: dict = Depends(get_current_user)):
686 db = get_db()
687 skills = db.execute(
688 "SELECT * FROM skills WHERE author_id = ? ORDER BY created_at DESC",
689 (user["id"],),
690 ).fetchall()
691 db.close()
692 return {"skills": [dict(s) for s in skills]}
694 # ── Health ──
696 @app.get("/api/health")
697 async def health_check():
698 db = get_db()
699 skill_count = db.execute("SELECT COUNT(*) FROM skills WHERE status='published'").fetchone()[0]
700 user_count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
701 db.close()
702 return {
703 "status": "healthy",
704 "version": "1.8.1",
705 "published_skills": skill_count,
706 "registered_developers": user_count,
707 }
709 return app
712def _parse_yaml_simple(text: str) -> Dict[str, Any]:
713 """Simple YAML parser for skill manifests. Handles basic key: value + lists."""
714 result: Dict[str, Any] = {}
715 current_key = None
716 for line in text.split("\n"):
717 stripped = line.strip()
718 if not stripped or stripped.startswith("#"):
719 continue
720 if ":" in stripped and not stripped.startswith("- "):
721 key, _, val = stripped.partition(":")
722 key = key.strip()
723 val = val.strip().strip('"').strip("'")
724 if val:
725 result[key] = val
726 else:
727 result[key] = []
728 current_key = key
729 elif stripped.startswith("- ") and current_key:
730 item = stripped[2:].strip().strip('"').strip("'")
731 result[current_key].append(item)
732 return result
735def start_marketplace_platform(host: str = "0.0.0.0", port: int = 8911) -> None:
736 """Start the marketplace platform server (blocking)."""
737 import uvicorn
738 app = create_marketplace_app()
739 print(f"\n AgentOS Skill Marketplace Platform v1.8.1")
740 print(f" Local: http://{host}:{port}")
741 print(f" Admin: admin / admin123")
742 print(f" Upload: POST /api/skills/upload | Browse: GET /api/skills")
743 print()
744 uvicorn.run(app, host=host, port=port, log_level="info")