Coverage for agentos/server/marketplace_platform.py: 0%
358 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10: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 sqlite3
24import time
25import zipfile
26from datetime import datetime, timedelta
27from pathlib import Path
28from typing import Optional, List, Dict, Any
30import jwt as pyjwt
31try:
32 from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form, Depends, Request
33 from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, StreamingResponse
34 from fastapi.staticfiles import StaticFiles
35 from fastapi.middleware.cors import CORSMiddleware
36 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
37except ImportError:
38 raise RuntimeError("FastAPI, pyjwt required. Run: pip install fastapi uvicorn pyjwt")
40try:
41 import bcrypt
42except ImportError:
43 bcrypt = None
45STATIC_DIR = Path(__file__).parent / "static"
46PLATFORM_DIR = Path.home() / ".agentos" / "marketplace"
47PLATFORM_DIR.mkdir(parents=True, exist_ok=True)
48DB_PATH = PLATFORM_DIR / "platform.db"
49UPLOAD_DIR = PLATFORM_DIR / "uploads"
50UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
52JWT_SECRET = os.environ.get("MARKETPLACE_SECRET", secrets.token_hex(32))
53JWT_ALGORITHM = "HS256"
54JWT_EXPIRY_HOURS = 72
57# ── Database ─────────────────────────────────
59def get_db() -> sqlite3.Connection:
60 conn = sqlite3.connect(str(DB_PATH))
61 conn.row_factory = sqlite3.Row
62 conn.execute("PRAGMA journal_mode=WAL")
63 conn.execute("PRAGMA foreign_keys=ON")
64 return conn
67def init_db():
68 db = get_db()
69 db.executescript("""
70 CREATE TABLE IF NOT EXISTS users (
71 id INTEGER PRIMARY KEY AUTOINCREMENT,
72 username TEXT UNIQUE NOT NULL,
73 email TEXT UNIQUE NOT NULL,
74 password_hash TEXT NOT NULL,
75 display_name TEXT,
76 avatar_url TEXT,
77 github_username TEXT,
78 role TEXT DEFAULT 'developer',
79 is_admin INTEGER DEFAULT 0,
80 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
81 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
82 );
83 CREATE TABLE IF NOT EXISTS skills (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 author_id INTEGER NOT NULL REFERENCES users(id),
86 name TEXT NOT NULL,
87 version TEXT NOT NULL DEFAULT '0.1.0',
88 description TEXT,
89 category TEXT DEFAULT 'uncategorized',
90 tags TEXT DEFAULT '[]',
91 format TEXT DEFAULT 'agentos',
92 entrypoint TEXT,
93 manifest_json TEXT,
94 file_path TEXT,
95 file_size INTEGER,
96 file_hash TEXT,
97 download_count INTEGER DEFAULT 0,
98 status TEXT DEFAULT 'pending',
99 security_score INTEGER,
100 security_report TEXT,
101 review_comment TEXT,
102 reviewed_by INTEGER REFERENCES users(id),
103 reviewed_at TIMESTAMP,
104 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
105 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
106 UNIQUE(name, author_id)
107 );
108 CREATE TABLE IF NOT EXISTS reviews (
109 id INTEGER PRIMARY KEY AUTOINCREMENT,
110 skill_id INTEGER NOT NULL REFERENCES skills(id),
111 user_id INTEGER NOT NULL REFERENCES users(id),
112 rating INTEGER CHECK(rating >= 1 AND rating <= 5),
113 comment TEXT,
114 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
115 );
116 CREATE TABLE IF NOT EXISTS api_tokens (
117 id INTEGER PRIMARY KEY AUTOINCREMENT,
118 user_id INTEGER NOT NULL REFERENCES users(id),
119 token_hash TEXT NOT NULL,
120 name TEXT,
121 last_used TIMESTAMP,
122 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
123 );
124 """)
125 # Ensure admin user exists
126 admin = db.execute("SELECT id FROM users WHERE username = ?", ("admin",)).fetchone()
127 if not admin:
128 _hash = _hash_password("admin123")
129 db.execute(
130 "INSERT INTO users (username, email, password_hash, role, is_admin) VALUES (?,?,?,?,?)",
131 ("admin", "admin@agentos.dev", _hash, "admin", 1),
132 )
133 db.commit()
134 db.close()
137def _hash_password(password: str) -> str:
138 if bcrypt:
139 return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
140 return hashlib.sha256(f"agentos:{password}".encode()).hexdigest()
143def _verify_password(password: str, password_hash: str) -> bool:
144 if bcrypt and password_hash.startswith("$2"):
145 return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
146 return _hash_password(password) == password_hash
149# ── Security Scanner ─────────────────────────
151class SecurityScanner:
152 """Scans uploaded skill packages for security issues."""
154 DANGEROUS_IMPORTS = {
155 "os.system", "subprocess", "eval(", "exec(", "compile(",
156 "__import__", "importlib", "builtins", "ctypes",
157 "socket", "requests", "urllib", "http.client",
158 "shutil.rmtree", "shutil.copy", "pathlib.Path.unlink",
159 "pickle", "marshal", "dill",
160 }
162 DANGEROUS_SHELL = {" rm ", "rm -rf", "sudo ", "chmod 777", "chown ",
163 " | sh", " | bash", "$(", "`", "; rm", "wget ", "curl ",
164 "/dev/null", "> /etc/", "> ~/.ssh/"}
166 OBFUSCATION_SIGNALS = {"base64.b64decode", "base64.b64encode", "exec(base64",
167 "decode('utf-8')", "eval(compile", "globals()", "__builtins__",
168 "lambda.*exec", "lambda.*eval", "getattr.*__"}
170 @classmethod
171 def scan_zip(cls, zip_path: str) -> Dict[str, Any]:
172 """Scan a skill zip package and return security report."""
173 findings = []
174 score = 100
175 files_scanned = 0
176 total_size = 0
178 try:
179 with zipfile.ZipFile(zip_path, "r") as zf:
180 for info in zf.infolist():
181 if info.is_dir():
182 continue
183 total_size += info.file_size
184 name = info.filename.lower()
186 # Skip binary files
187 if any(name.endswith(ext) for ext in (".pyc", ".so", ".dll", ".exe", ".png", ".jpg", ".ico")):
188 continue
190 try:
191 content = zf.read(info.filename).decode("utf-8", errors="replace")
192 files_scanned += 1
193 except Exception:
194 continue
196 lines = content.split("\n")
198 # Check for dangerous imports
199 for imp in cls.DANGEROUS_IMPORTS:
200 if imp in content:
201 findings.append({"file": info.filename, "severity": "high",
202 "rule": f"dangerous_import:{imp}", "line": content.find(imp)})
203 score -= 20
205 # Check for shell injection
206 for pat in cls.DANGEROUS_SHELL:
207 if pat in content:
208 findings.append({"file": info.filename, "severity": "critical",
209 "rule": f"shell_injection:{pat.strip()}"})
210 score -= 30
212 # Check for obfuscation
213 for pat in cls.OBFUSCATION_SIGNALS:
214 if re.search(pat, content):
215 findings.append({"file": info.filename, "severity": "medium",
216 "rule": f"obfuscation:{pat}"})
217 score -= 15
219 # Check for hardcoded secrets
220 if re.search(r'(api_key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}', content):
221 findings.append({"file": info.filename, "severity": "high",
222 "rule": "hardcoded_secret"})
223 score -= 25
224 except zipfile.BadZipFile:
225 return {"score": 0, "findings": [{"severity": "critical", "rule": "invalid_zip"}]}
227 return {
228 "score": max(0, score),
229 "findings": findings,
230 "files_scanned": files_scanned,
231 "total_size": total_size,
232 "risk_level": "low" if score >= 80 else "medium" if score >= 50 else "high" if score >= 20 else "critical",
233 }
235 @classmethod
236 def validate_manifest(cls, manifest: Dict) -> List[str]:
237 """Validate skill manifest structure. Returns list of errors."""
238 errors = []
239 required = ["name", "version", "description"]
240 for field in required:
241 if not manifest.get(field):
242 errors.append(f"Missing required field: {field}")
244 if "name" in manifest:
245 name = manifest["name"]
246 if not re.match(r'^[a-zA-Z][a-zA-Z0-9_\-]*$', name):
247 errors.append(f"Invalid skill name: {name}. Use alphanumeric, hyphens, underscores.")
249 if "version" in manifest:
250 version = manifest["version"]
251 if not re.match(r'^\d+\.\d+\.\d+$', version):
252 errors.append(f"Invalid version format: {version}. Use semver (e.g., 0.1.0).")
254 return errors
257# ── Auth Utilities ───────────────────────────
259security_scheme = HTTPBearer(auto_error=False)
262def create_token(user_id: int, username: str, is_admin: bool) -> str:
263 payload = {
264 "user_id": user_id,
265 "username": username,
266 "is_admin": is_admin,
267 "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS),
268 "iat": datetime.utcnow(),
269 }
270 return pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
273def decode_token(token: str) -> Optional[Dict]:
274 try:
275 return pyjwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
276 except Exception:
277 return None
280async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)):
281 if not credentials:
282 raise HTTPException(status_code=401, detail="Authentication required")
283 payload = decode_token(credentials.credentials)
284 if not payload:
285 raise HTTPException(status_code=401, detail="Invalid or expired token")
286 db = get_db()
287 user = db.execute("SELECT * FROM users WHERE id = ?", (payload["user_id"],)).fetchone()
288 db.close()
289 if not user:
290 raise HTTPException(status_code=401, detail="User not found")
291 return dict(user)
294async def get_admin_user(user: dict = Depends(get_current_user)):
295 if not user.get("is_admin"):
296 raise HTTPException(status_code=403, detail="Admin privileges required")
297 return user
300# ── FastAPI App ──────────────────────────────
302def create_marketplace_app() -> FastAPI:
303 init_db()
305 app = FastAPI(
306 title="AgentOS Skill Marketplace",
307 version="1.8.1",
308 description="Open developer marketplace for AgentOS skills. Upload, review, and discover AI agent skills.",
309 )
311 app.add_middleware(
312 CORSMiddleware,
313 allow_origins=["*"],
314 allow_methods=["*"],
315 allow_headers=["*"],
316 )
318 if STATIC_DIR.exists():
319 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
321 # ── Web UI ──
322 @app.get("/", response_class=HTMLResponse)
323 async def web_ui():
324 html_path = STATIC_DIR / "platform.html"
325 if html_path.exists():
326 return HTMLResponse(html_path.read_text(encoding="utf-8"))
327 return HTMLResponse("<h1>Marketplace Platform</h1>", status_code=404)
329 # ── Auth Endpoints ──
331 @app.post("/api/auth/register")
332 async def register(username: str = Form(...), email: str = Form(...),
333 password: str = Form(...), display_name: str = Form("")):
334 if len(password) < 6:
335 raise HTTPException(400, "Password must be at least 6 characters")
336 if not re.match(r'^[a-zA-Z0-9_]{3,30}$', username):
337 raise HTTPException(400, "Username: 3-30 chars, alphanumeric/underscore")
339 db = get_db()
340 existing = db.execute("SELECT id FROM users WHERE username=? OR email=?",
341 (username, email)).fetchone()
342 if existing:
343 db.close()
344 raise HTTPException(409, "Username or email already exists")
346 pw_hash = _hash_password(password)
347 try:
348 db.execute("INSERT INTO users (username, email, password_hash, display_name) VALUES (?,?,?,?)",
349 (username, email, pw_hash, display_name or username))
350 db.commit()
351 user_id = db.lastrowid
352 finally:
353 db.close()
355 token = create_token(user_id, username, False)
356 return {"token": token, "user": {"id": user_id, "username": username, "email": email, "is_admin": False}}
358 @app.post("/api/auth/login")
359 async def login(username: str = Form(...), password: str = Form(...)):
360 db = get_db()
361 user = db.execute("SELECT * FROM users WHERE username = ? OR email = ?",
362 (username, username)).fetchone()
363 if not user or not _verify_password(password, user["password_hash"]):
364 db.close()
365 raise HTTPException(401, "Invalid credentials")
366 db.close()
368 token = create_token(user["id"], user["username"], bool(user["is_admin"]))
369 return {
370 "token": token,
371 "user": {
372 "id": user["id"], "username": user["username"], "email": user["email"],
373 "display_name": user["display_name"], "is_admin": bool(user["is_admin"]),
374 "github_username": user["github_username"],
375 }
376 }
378 @app.get("/api/auth/me")
379 async def me(user: dict = Depends(get_current_user)):
380 return {"user": {k: v for k, v in user.items() if k != "password_hash"}}
382 # ── Skill Upload ──
384 @app.post("/api/skills/upload")
385 async def upload_skill(
386 file: UploadFile = File(...),
387 name: str = Form(""),
388 version: str = Form("0.1.0"),
389 description: str = Form(""),
390 category: str = Form("uncategorized"),
391 tags: str = Form("[]"),
392 user: dict = Depends(get_current_user),
393 ):
394 """Upload a skill package (.zip containing skill files + manifest.json)."""
395 if not file.filename or not file.filename.endswith(".zip"):
396 raise HTTPException(400, "Only .zip files are accepted")
398 # Save uploaded file
399 ts = int(time.time())
400 safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', file.filename)
401 file_id = f"{user['id']}_{ts}_{safe_name}"
402 file_path = UPLOAD_DIR / file_id
403 content = await file.read()
404 file_path.write_bytes(content)
406 # Validate zip
407 if not zipfile.is_zipfile(str(file_path)):
408 file_path.unlink()
409 raise HTTPException(400, "Invalid zip file")
411 # Extract and validate manifest
412 manifest = {}
413 with zipfile.ZipFile(str(file_path)) as zf:
414 if "skill.yaml" in zf.namelist():
415 manifest_text = zf.read("skill.yaml").decode("utf-8")
416 manifest = _parse_yaml_simple(manifest_text)
417 elif "skill.json" in zf.namelist():
418 manifest = json.loads(zf.read("skill.json"))
419 elif "manifest.json" in zf.namelist():
420 manifest = json.loads(zf.read("manifest.json"))
422 # Use form fields as fallback
423 skill_name = manifest.get("name") or name or file.filename.replace(".zip", "")
424 skill_version = manifest.get("version") or version
425 skill_desc = manifest.get("description") or description
426 skill_category = manifest.get("category") or category
427 skill_tags = manifest.get("tags", []) if isinstance(manifest.get("tags"), list) else []
429 # Use form tags if manifest has none
430 if not skill_tags:
431 try:
432 skill_tags = json.loads(tags) if isinstance(tags, str) else tags
433 except json.JSONDecodeError:
434 skill_tags = []
436 # Validate
437 manifest_errors = SecurityScanner.validate_manifest({
438 "name": skill_name, "version": skill_version, "description": skill_desc,
439 })
440 if manifest_errors:
441 file_path.unlink()
442 raise HTTPException(400, f"Manifest validation failed: {'; '.join(manifest_errors)}")
444 # Security scan
445 security = SecurityScanner.scan_zip(str(file_path))
446 auto_status = "published" if security["risk_level"] == "low" else "flagged"
447 if security["score"] <= 20:
448 auto_status = "rejected"
449 file_path.unlink()
450 raise HTTPException(400, f"Security scan failed (score: {security['score']}/100). "
451 f"Risk: {security['risk_level']}. Findings: {len(security['findings'])}")
453 # Compute hash
454 file_hash = hashlib.sha256(content).hexdigest()
456 db = get_db()
457 try:
458 db.execute(
459 """INSERT INTO skills (author_id, name, version, description, category, tags,
460 format, entrypoint, manifest_json, file_path, file_size, file_hash,
461 status, security_score, security_report)
462 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
463 (user["id"], skill_name, skill_version, skill_desc, skill_category,
464 json.dumps(skill_tags), manifest.get("format", "agentos"),
465 manifest.get("entrypoint", ""), json.dumps(manifest, ensure_ascii=False),
466 str(file_path.absolute()), os.path.getsize(str(file_path)), file_hash,
467 auto_status, security["score"], json.dumps(security, ensure_ascii=False)),
468 )
469 db.commit()
470 skill_id = db.lastrowid
471 except sqlite3.IntegrityError:
472 file_path.unlink()
473 db.close()
474 raise HTTPException(409, "You already have a skill with this name")
475 db.close()
477 return {
478 "id": skill_id, "name": skill_name, "version": skill_version,
479 "status": auto_status, "security_score": security["score"],
480 "risk_level": security["risk_level"],
481 "findings_count": len(security["findings"]),
482 }
484 # ── Public Browse ──
486 @app.get("/api/skills")
487 async def list_skills(
488 q: str = Query(""),
489 category: str = Query(""),
490 status: str = Query("published"),
491 sort: str = Query("downloads"),
492 page: int = Query(1),
493 limit: int = Query(30),
494 ):
495 db = get_db()
496 where = ["s.status = ?"]
497 params: list = [status]
499 if q:
500 where.append("(s.name LIKE ? OR s.description LIKE ?)")
501 params.extend([f"%{q}%", f"%{q}%"])
502 if category:
503 where.append("s.category = ?")
504 params.append(category)
506 order = "s.download_count DESC" if sort == "downloads" else "s.created_at DESC"
507 offset = (page - 1) * limit
509 skills = db.execute(
510 f"""SELECT s.*, u.username as author_name, u.display_name as author_display
511 FROM skills s JOIN users u ON s.author_id = u.id
512 WHERE {' AND '.join(where)}
513 ORDER BY {order}
514 LIMIT ? OFFSET ?""",
515 params + [limit, offset],
516 ).fetchall()
518 total = db.execute(
519 f"SELECT COUNT(*) FROM skills s WHERE {' AND '.join(where)}", params
520 ).fetchone()[0]
521 db.close()
523 return {
524 "total": total, "page": page, "limit": limit,
525 "skills": [
526 {"id": s["id"], "name": s["name"], "version": s["version"],
527 "description": s["description"], "category": s["category"],
528 "tags": json.loads(s["tags"]), "format": s["format"],
529 "download_count": s["download_count"], "status": s["status"],
530 "security_score": s["security_score"],
531 "author": {"username": s["author_name"], "display_name": s["author_display"]},
532 "created_at": s["created_at"]}
533 for s in skills
534 ],
535 }
537 @app.get("/api/skills/{skill_id}")
538 async def get_skill_detail(skill_id: int):
539 db = get_db()
540 skill = db.execute(
541 """SELECT s.*, u.username as author_name, u.display_name as author_display
542 FROM skills s JOIN users u ON s.author_id = u.id
543 WHERE s.id = ? AND s.status = 'published'""",
544 (skill_id,),
545 ).fetchone()
546 if not skill:
547 db.close()
548 raise HTTPException(404, "Skill not found")
550 # Get review stats
551 review_stats = db.execute(
552 "SELECT COUNT(*) as count, AVG(rating) as avg_rating FROM reviews WHERE skill_id = ?",
553 (skill_id,),
554 ).fetchone()
555 db.close()
557 return {
558 "id": skill["id"], "name": skill["name"], "version": skill["version"],
559 "description": skill["description"], "category": skill["category"],
560 "tags": json.loads(skill["tags"]), "format": skill["format"],
561 "entrypoint": skill["entrypoint"], "manifest": json.loads(skill["manifest_json"] or "{}"),
562 "download_count": skill["download_count"], "status": skill["status"],
563 "security_score": skill["security_score"],
564 "author": {"username": skill["author_name"], "display_name": skill["author_display"]},
565 "created_at": skill["created_at"],
566 "reviews": {"count": review_stats["count"] or 0,
567 "avg_rating": round(review_stats["avg_rating"] or 0, 1)},
568 }
570 # ── Download ──
572 @app.get("/api/skills/{skill_id}/download")
573 async def download_skill(skill_id: int):
574 db = get_db()
575 skill = db.execute(
576 "SELECT * FROM skills WHERE id = ? AND status = 'published'", (skill_id,)
577 ).fetchone()
578 if not skill:
579 db.close()
580 raise HTTPException(404, "Skill not found")
582 db.execute("UPDATE skills SET download_count = download_count + 1 WHERE id = ?", (skill_id,))
583 db.commit()
584 db.close()
586 file_path = Path(skill["file_path"])
587 if not file_path.exists():
588 raise HTTPException(404, "Skill file not found on server")
590 return FileResponse(
591 path=str(file_path),
592 filename=f"{skill['name']}-{skill['version']}.zip",
593 media_type="application/zip",
594 )
596 # ── Admin: Review Queue ──
598 @app.get("/api/admin/review-queue")
599 async def review_queue(user: dict = Depends(get_admin_user)):
600 db = get_db()
601 skills = db.execute(
602 """SELECT s.*, u.username as author_name
603 FROM skills s JOIN users u ON s.author_id = u.id
604 WHERE s.status IN ('pending', 'flagged')
605 ORDER BY s.created_at DESC"""
606 ).fetchall()
607 db.close()
608 return {
609 "count": len(skills),
610 "skills": [
611 {"id": s["id"], "name": s["name"], "version": s["version"],
612 "description": s["description"], "category": s["category"],
613 "status": s["status"], "security_score": s["security_score"],
614 "security_report": json.loads(s["security_report"] or "{}"),
615 "author": s["author_name"], "created_at": s["created_at"]}
616 for s in skills
617 ],
618 }
620 @app.post("/api/admin/review/{skill_id}")
621 async def review_skill(
622 skill_id: int,
623 action: str = Form(...), # "approve" or "reject"
624 comment: str = Form(""),
625 user: dict = Depends(get_admin_user),
626 ):
627 if action not in ("approve", "reject"):
628 raise HTTPException(400, "Action must be 'approve' or 'reject'")
630 db = get_db()
631 skill = db.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
632 if not skill:
633 db.close()
634 raise HTTPException(404, "Skill not found")
636 new_status = "published" if action == "approve" else "rejected"
637 db.execute(
638 "UPDATE skills SET status=?, review_comment=?, reviewed_by=?, reviewed_at=CURRENT_TIMESTAMP WHERE id=?",
639 (new_status, comment, user["id"], skill_id),
640 )
641 db.commit()
642 db.close()
644 return {"id": skill_id, "status": new_status, "action": action}
646 # ── Categories ──
648 @app.get("/api/categories")
649 async def list_categories():
650 db = get_db()
651 cats = db.execute(
652 "SELECT category, COUNT(*) as count FROM skills WHERE status='published' GROUP BY category ORDER BY count DESC"
653 ).fetchall()
654 db.close()
655 return {"categories": [{"name": c["category"], "count": c["count"]} for c in cats]}
657 # ── Developer Profile ──
659 @app.get("/api/developers/{username}")
660 async def developer_profile(username: str):
661 db = get_db()
662 user = db.execute("SELECT id, username, display_name, avatar_url, github_username, created_at FROM users WHERE username = ?", (username,)).fetchone()
663 if not user:
664 db.close()
665 raise HTTPException(404, "Developer not found")
667 skills = db.execute(
668 "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",
669 (user["id"],),
670 ).fetchall()
671 db.close()
673 return {
674 "developer": dict(user),
675 "skills": [dict(s) for s in skills],
676 "total_skills": len(skills),
677 "total_downloads": sum(s["download_count"] for s in skills),
678 }
680 # ── My Skills ──
682 @app.get("/api/my/skills")
683 async def my_skills(user: dict = Depends(get_current_user)):
684 db = get_db()
685 skills = db.execute(
686 "SELECT * FROM skills WHERE author_id = ? ORDER BY created_at DESC",
687 (user["id"],),
688 ).fetchall()
689 db.close()
690 return {"skills": [dict(s) for s in skills]}
692 # ── Health ──
694 @app.get("/api/health")
695 async def health_check():
696 db = get_db()
697 skill_count = db.execute("SELECT COUNT(*) FROM skills WHERE status='published'").fetchone()[0]
698 user_count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
699 db.close()
700 return {
701 "status": "healthy",
702 "version": "1.8.1",
703 "published_skills": skill_count,
704 "registered_developers": user_count,
705 }
707 return app
710def _parse_yaml_simple(text: str) -> Dict[str, Any]:
711 """Simple YAML parser for skill manifests. Handles basic key: value + lists."""
712 result: Dict[str, Any] = {}
713 current_key = None
714 for line in text.split("\n"):
715 stripped = line.strip()
716 if not stripped or stripped.startswith("#"):
717 continue
718 if ":" in stripped and not stripped.startswith("- "):
719 key, _, val = stripped.partition(":")
720 key = key.strip()
721 val = val.strip().strip('"').strip("'")
722 if val:
723 result[key] = val
724 else:
725 result[key] = []
726 current_key = key
727 elif stripped.startswith("- ") and current_key:
728 item = stripped[2:].strip().strip('"').strip("'")
729 result[current_key].append(item)
730 return result
733def start_marketplace_platform(host: str = "0.0.0.0", port: int = 8911) -> None:
734 """Start the marketplace platform server (blocking)."""
735 import uvicorn
736 app = create_marketplace_app()
737 print("\n AgentOS Skill Marketplace Platform v1.8.1")
738 print(f" Local: http://{host}:{port}")
739 print(" Admin: admin / admin123")
740 print(" Upload: POST /api/skills/upload | Browse: GET /api/skills")
741 print()
742 uvicorn.run(app, host=host, port=port, log_level="info")