
Community Project Publish
- 6 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
community-project-publish is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- community-project-publish
- AI & Agent Building
- AI-coding skill
Community Project Publish by the numbers
- 6 all-time installs (skills.sh)
- Ranked #12,756 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill community-project-publishAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
What this is
Skill-side client for the community-projects ecosystem. Backed by:
- Gateway:
https://community.iamstarchild.com/api/code-projects/*(X-Internal-Key auth) - Storage:
Starchild-ai-agent/community-projectsGitHub repo - Install target:
output/projects/{slug}/
Project ≠ Skill. Skills are workflow instructions (this thing). Projects are runnable code (what this thing publishes/forks).
When to use
| User says | Action |
|---|---|
| "Share / publish / 发布 my project" | publish_project(project_dir) |
| "Fork / install / 拉取 a project" | fork_project(source) |
| "Browse / list / search projects" | list_projects(...) |
| "I have some scattered code, make it a project" | tidy_project(any_dir) |
| "Update my published project" | update_project(project_dir) |
Project structure (mandatory)
Every project lives in output/projects/{slug}/ with this layout:
project.yaml # metadata (name, version, type, env_required, sc_proxy)
PROJECT.md # required 4 sections: What / Required env / How to start / Outputs / Troubleshooting
.env.example # all env vars with placeholder values
.gitignore # secrets blacklist
src/
├── run.py # for type=task (must start: # -*- task-system: v3 -*-)
├── index.html # for type=preview (or app.py + frontend)
├── server.py # for type=service
└── main.py # for type=scriptProject types
| type | What it is | Auto-install behavior on fork |
|---|---|---|
task | Scheduled cron/interval job | scheduled_task(register, paused=true) — user activates manually |
preview | Web dashboard / app | preview(serve) and return URL |
service | Long-running background process | Show command, ask user to confirm starting in background |
script | One-shot script | Show command for user to run |
Usage from a bash block
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/community-project-publish")
from exports import publish_project, fork_project, list_projects
# Publish
result = publish_project("output/projects/my-thing", version_bump="patch")
print(result)
EOFFunction reference
publish_project(project_dir, version_bump="patch")
project_dir: path to the project folder (e.g.output/projects/my-task)version_bump:patch|minor|major— increments the version inproject.yaml- Returns:
{"ok": True, "github_url": ..., "version": ..., "commit_sha": ...}on success
fork_project(source, dest_dir=None)
source:"user_id/slug"or"user_id/slug@version"(defaults to latest)dest_dir: where to install (defaultoutput/projects/{slug}/)- Returns:
{"ok": True, "installed_at": ..., "type": ..., "next_step": ...} - For
task: also returnsjob_idof the registered (paused) task - For
preview: also returnspreview_url
list_projects(type=None, tag=None, user=None, q=None)
- Filter by type (
task/preview/service/script), tag, user_id, or text query - Returns:
{"ok": True, "count": N, "projects": [...]}
update_project(project_dir, version_bump="patch")
- Alias for
publish_project— same behavior, semantically clearer when bumping an existing project
tidy_project(any_dir, type=None)
- Inspects an existing folder, infers the project type if not given, and reorganizes into the standard structure
- Creates missing files (PROJECT.md skeleton, .env.example, .gitignore)
- Does NOT publish — you call
publish_projectafter reviewing
validate_project(project_dir)
- Pre-flight check before publishing — catches schema errors, missing files, secret patterns
- Returns:
{"ok": True/False, "errors": [...], "warnings": [...]}
Behavioral rules
- Never auto-publish without showing the user the diff first. After validation, summarize what's about to be pushed (file list, version, type, tags, env_required) and ask for confirmation. Exception: if the user explicitly says "publish without confirmation" or this is a re-publish of a known good project.
- Never auto-run setup.sh on fork. Show the command, let user confirm.
- Always collect env in one batch. After fork, read the project's
env_required, diff againstworkspace/.env, and callrequest_env_inputONCE with the missing keys. Don't ask one-by-one. - Slug rules: lowercase alphanumeric + hyphens, 3-50 chars, no leading/trailing hyphen, must match the project folder name.
- Version rules: strict semver. Re-publishing same version is rejected. New version must be > current latest.
- Type immutability: once published as
task, can't change topreviewlater. Pick a different slug if you need to change type.
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
400 Validation failed: env names not in .env.example | Listed MY_KEY in env_required but forgot to add it to .env.example | Edit .env.example to add the missing key |
400 Possible secret detected | Secret scanner found a real-looking API key in source | Move to env var, ensure .env.example value is a placeholder like your-key-here |
400 Version X must be greater than current latest Y | Tried to republish same version, or downgrade | Bump version in project.yaml (version_bump="minor" etc.) |
403 Permission denied: only owner can unpublish | Trying to unpublish someone else's project | Ask the original author |
| Fork installs but task doesn't start | Task is auto-registered as paused | Tell user: "Run scheduled_task(action='activate', job_id={id}) to start" |
References
lib/manifest.py— project.yaml parser/writer + semver helperslib/validate.py— local pre-publish validation (mirrors gateway-side checks)lib/install.py— type-specific install handlers (task/preview/service/script)lib/gateway.py— HTTP client for /api/code-projects/* endpoints
"""community-project-publish skill exports.
Usage from a bash block:
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/community-project-publish")
from exports import publish_project, fork_project, list_projects
print(list_projects())
EOF
"""
from __future__ import annotations
import base64
import os
import shutil
from typing import Any
# Make sibling lib/ importable
_SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
import sys
if _SKILL_DIR not in sys.path:
sys.path.insert(0, _SKILL_DIR)
from lib import gateway, manifest as M, validate as V, install as I # noqa: E402
# ── Helpers ──
def _user_id() -> str:
uid = os.environ.get("USER_ID", "")
if not uid:
raise RuntimeError("USER_ID not set in environment — cannot publish")
return uid
def _abspath(p: str) -> str:
if os.path.isabs(p):
return p
return os.path.abspath(os.path.join("/data/workspace", p))
# ── Public API ──
def validate_project(project_dir: str) -> dict[str, Any]:
"""Pre-flight check: validates manifest + files. Returns ok/errors/warnings."""
pd = _abspath(project_dir)
if not os.path.isdir(pd):
return {"ok": False, "errors": [f"Directory not found: {pd}"], "warnings": []}
try:
manifest = M.load_manifest(pd)
except Exception as e:
return {"ok": False, "errors": [f"Failed to load project.yaml: {e}"], "warnings": []}
errors, warnings = V.validate(pd, manifest)
return {
"ok": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"manifest": manifest,
}
def publish_project(project_dir: str, version_bump: str = "patch") -> dict[str, Any]:
"""Validate, bump version, and publish to gateway.
version_bump: "patch" | "minor" | "major" | "none" (use existing version)
"""
pd = _abspath(project_dir)
if not os.path.isdir(pd):
return {"ok": False, "error": f"Directory not found: {pd}"}
try:
manifest = M.load_manifest(pd)
except Exception as e:
return {"ok": False, "error": f"Failed to load project.yaml: {e}"}
# Bump version
current = manifest.get("version", "0.0.0")
if version_bump != "none":
try:
new_version = M.bump_semver(current, version_bump)
except ValueError as e:
return {"ok": False, "error": str(e)}
manifest["version"] = new_version
M.save_manifest(pd, manifest)
else:
new_version = current
# Set author from USER_ID if blank or "user-XXXX"-style placeholder
uid = _user_id()
if not manifest.get("author") or manifest.get("author", "").startswith("user-XXXX"):
manifest["author"] = f"user-{uid}"
M.save_manifest(pd, manifest)
# Validate locally first
errors, warnings = V.validate(pd, manifest)
if errors:
return {"ok": False, "error": "Local validation failed", "errors": errors, "warnings": warnings}
# Build publish payload
files = V.collect_files(pd)
payload_files = [
{"path": rel, "content_base64": base64.b64encode(content).decode("ascii")}
for rel, content in files
]
body = {
"user_id": uid,
"slug": manifest["name"],
"type": manifest["type"],
"version": new_version,
"manifest": manifest,
"files": payload_files,
}
status, resp = gateway.publish(body)
if status != 200 or not resp.get("ok"):
return {
"ok": False,
"error": resp.get("error", f"Gateway returned HTTP {status}"),
"validation_errors": resp.get("validation_errors"),
"http_status": status,
}
return {
"ok": True,
"user_id": uid,
"slug": manifest["name"],
"type": manifest["type"],
"version": new_version,
"github_url": resp.get("github_url"),
"commit_sha": resp.get("commit_sha"),
"warnings": warnings,
}
def update_project(project_dir: str, version_bump: str = "patch") -> dict[str, Any]:
"""Alias for publish_project — semantically clearer when bumping an existing project."""
return publish_project(project_dir, version_bump)
def list_projects(type: str | None = None, tag: str | None = None,
user: str | None = None, q: str | None = None) -> dict[str, Any]:
"""Browse the catalog. Filters: type, tag, user_id, free-text query."""
status, resp = gateway.list_(type=type, tag=tag, user_id=user, q=q)
if status != 200:
return {"ok": False, "error": resp.get("error", f"HTTP {status}")}
return resp
def get_project(source: str) -> dict[str, Any]:
"""Get project detail (manifest + readme). source: 'user_id/slug' or 'user_id/slug@version'."""
user_id, slug, version = _parse_source(source)
status, resp = gateway.get(user_id, slug, version)
if status != 200:
return {"ok": False, "error": resp.get("error", f"HTTP {status}"), "http_status": status}
return resp
def fork_project(source: str, dest_dir: str | None = None) -> dict[str, Any]:
"""Fork a project from the catalog into output/projects/{slug}/.
source: 'user_id/slug' or 'user_id/slug@version' (default: latest)
dest_dir: where to install (default: output/projects/{slug}/)
Returns project metadata + missing_envs (caller should request_env_input these)
+ next_step (instructions for type-specific install).
"""
user_id, slug, version = _parse_source(source)
detail_status, detail = gateway.get(user_id, slug, version)
if detail_status != 200 or not detail.get("ok"):
return {"ok": False, "error": detail.get("error", f"HTTP {detail_status}"), "http_status": detail_status}
project = detail["project"]
raw_url_prefix = project["raw_url_prefix"]
manifest_dict = project.get("manifest") or {}
# Determine which files to fetch — list contents via GitHub Trees API by hitting raw URLs
# We don't have an API to list files; rely on the manifest's entry + standard files
target_version = project["latest_version"]
file_list = _enumerate_project_files(user_id, slug, target_version)
# Decide destination
if dest_dir is None:
dest_dir = f"output/projects/{slug}"
dest_abs = _abspath(dest_dir)
if os.path.exists(dest_abs):
if os.listdir(dest_abs):
return {
"ok": False,
"error": f"Destination not empty: {dest_abs}. Remove it or pick a different dest_dir.",
}
else:
os.makedirs(dest_abs, exist_ok=True)
# Download files
downloaded: list[str] = []
for rel_path in file_list:
try:
content = gateway.fetch_raw_file(raw_url_prefix, rel_path)
except Exception as e:
# Cleanup on failure
shutil.rmtree(dest_abs, ignore_errors=True)
return {"ok": False, "error": f"Failed to fetch {rel_path}: {e}"}
target = os.path.join(dest_abs, rel_path)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as f:
f.write(content)
downloaded.append(rel_path)
# Re-load manifest from disk (more authoritative than gateway-parsed dict)
try:
manifest = M.load_manifest(dest_abs)
except Exception:
manifest = manifest_dict
# Diff env
missing_envs = I.diff_env_required(manifest)
# Type-specific install plan
install_result = I.install(dest_abs, manifest)
return {
"ok": True,
"source": f"{user_id}/{slug}@{target_version}",
"type": project["type"],
"installed_at": dest_abs,
"files_downloaded": downloaded,
"manifest": manifest,
"missing_envs": missing_envs,
"next_step": install_result.get("next_step"),
"install_plan": install_result,
"env_action_required": (
f"Call request_env_input with: {missing_envs}"
if missing_envs else "All required env vars already set."
),
}
def unpublish_project(slug: str) -> dict[str, Any]:
"""Unpublish ALL versions of YOUR own project. Cannot unpublish someone else's."""
uid = _user_id()
status, resp = gateway.unpublish(uid, slug, uid)
if status != 200 or not resp.get("ok"):
return {"ok": False, "error": resp.get("error", f"HTTP {status}"), "http_status": status}
return resp
# ── Internal helpers ──
def _parse_source(source: str) -> tuple[str, str, str | None]:
"""Parse 'user_id/slug' or 'user_id/slug@version'."""
s = source.strip()
version = None
if "@" in s:
s, version = s.rsplit("@", 1)
if "/" not in s:
raise ValueError(f"Invalid source: {source!r} — expected 'user_id/slug[@version]'")
user_id, slug = s.split("/", 1)
return user_id.strip(), slug.strip(), version
def _enumerate_project_files(user_id: str, slug: str, version: str) -> list[str]:
"""Enumerate files in a project version via GitHub Trees API.
The community-projects repo is public; we hit:
https://api.github.com/repos/Starchild-ai-agent/community-projects/git/trees/main?recursive=1
and filter to the project version dir.
We use the project type from gateway response to know the folder.
"""
import urllib.request
import json
repo = "Starchild-ai-agent/community-projects"
# Get the type from gateway since we don't know it locally
status, detail = gateway.get(user_id, slug, version)
if status != 200 or not detail.get("ok"):
# Fallback to standard file list
return ["project.yaml", "PROJECT.md", ".env.example"]
project_type = detail["project"]["type"]
type_folder = project_type + "s" # task → tasks
prefix = f"projects/{type_folder}/{user_id}/{slug}/{version}/"
url = f"https://api.github.com/repos/{repo}/git/trees/main?recursive=1"
req = urllib.request.Request(url, headers={"User-Agent": "community-project-publish-skill"})
with urllib.request.urlopen(req, timeout=30) as resp:
tree = json.loads(resp.read())
items = tree.get("tree", [])
files = []
for item in items:
if item.get("type") == "blob" and item["path"].startswith(prefix):
files.append(item["path"][len(prefix):])
return files
"""HTTP client for community-projects gateway endpoints."""
from __future__ import annotations
import os
import json
import urllib.request
import urllib.error
from typing import Any
def _gateway_url() -> str:
return os.environ.get(
"COMMUNITY_GATEWAY_URL",
os.environ.get("COMMUNITY_PUBLIC_URL", "https://community.iamstarchild.com"),
).rstrip("/")
def _gateway_key() -> str:
key = os.environ.get("COMMUNITY_GATEWAY_KEY", "")
if not key:
raise RuntimeError("COMMUNITY_GATEWAY_KEY not set in environment")
return key
def _request(method: str, path: str, body: dict | None = None, timeout: int = 60) -> tuple[int, dict]:
url = f"{_gateway_url()}{path}"
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"X-Internal-Key": _gateway_key()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8"))
except Exception:
return e.code, {"error": str(e)}
def publish(req_body: dict) -> tuple[int, dict]:
return _request("POST", "/api/code-projects/publish", req_body)
def unpublish(user_id: str, slug: str, requesting_user_id: str) -> tuple[int, dict]:
return _request("POST", "/api/code-projects/unpublish", {
"user_id": user_id,
"slug": slug,
"requesting_user_id": requesting_user_id,
})
def list_(type: str | None = None, tag: str | None = None, user_id: str | None = None, q: str | None = None) -> tuple[int, dict]:
qs = []
if type: qs.append(f"type={type}")
if tag: qs.append(f"tag={tag}")
if user_id: qs.append(f"user_id={user_id}")
if q:
from urllib.parse import quote
qs.append(f"q={quote(q)}")
qstr = "?" + "&".join(qs) if qs else ""
return _request("GET", f"/api/code-projects/list{qstr}")
def get(user_id: str, slug: str, version: str | None = None) -> tuple[int, dict]:
qstr = f"?version={version}" if version else ""
return _request("GET", f"/api/code-projects/{user_id}/{slug}{qstr}")
def fetch_raw_file(raw_url_prefix: str, file_path: str) -> bytes:
"""Fetch a single file from raw.githubusercontent.com — no auth needed for public repo."""
url = f"{raw_url_prefix.rstrip('/')}/{file_path.lstrip('/')}"
req = urllib.request.Request(url, headers={"User-Agent": "community-project-publish-skill"})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read()
"""Type-specific install handlers."""
from __future__ import annotations
import json
import os
from typing import Any
def install_task(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
schedule = manifest.get("schedule") or "0 * * * *"
entry_rel = manifest.get("entry") or "src/run.py"
entry_abs = os.path.join(project_dir, entry_rel)
return {
"next_step": (
f"Task installed (paused). To activate as a scheduled job:\n"
f" scheduled_task(action='register', "
f"title={json.dumps(manifest.get('description', 'Forked task'))}, "
f"schedule={json.dumps(schedule)}, "
f"description='Forked from community projects')\n"
f"Then edit the generated run.py to invoke entry: {entry_abs}\n"
f"Or activate directly if a job is already registered."
),
"entry_abs": entry_abs,
"schedule": schedule,
"type": "task",
}
def install_preview(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
port = manifest.get("port")
entry = manifest.get("entry") or "src/index.html"
is_static = entry.endswith(".html")
if is_static:
return {
"next_step": (
f"Preview ready. Use:\n"
f" preview(action='serve', "
f"title={json.dumps(manifest.get('description', 'Preview'))}, "
f"dir={json.dumps(project_dir + '/' + os.path.dirname(entry))})"
),
"type": "preview", "port": port, "is_static": True,
}
runtime = manifest.get("runtime") or {}
cmd = (
f"python {entry}" if runtime.get("python")
else f"node {entry}" if runtime.get("node")
else f"./{entry}"
)
return {
"next_step": (
f"Preview ready. Use:\n"
f" preview(action='serve', "
f"title={json.dumps(manifest.get('description', 'Preview'))}, "
f"dir={json.dumps(project_dir)}, command={json.dumps(cmd)}, port={port})"
),
"type": "preview", "port": port, "command": cmd, "is_static": False,
}
def install_service(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
entry_rel = manifest.get("entry") or "src/server.py"
entry_abs = os.path.join(project_dir, entry_rel)
runtime = manifest.get("runtime") or {}
cmd = (
f"python {entry_abs}" if runtime.get("python")
else f"node {entry_abs}" if runtime.get("node")
else entry_abs
)
port = manifest.get("port")
return {
"next_step": (
f"Service ready. To start in background:\n"
f" bash(command={json.dumps(cmd)}, background=True)\n"
+ (f"It will listen on port {port}.\n" if port else "")
+ "Track via bash_process(action='list')."
),
"type": "service", "command": cmd, "port": port,
}
def install_script(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
entry_rel = manifest.get("entry") or "src/main.py"
entry_abs = os.path.join(project_dir, entry_rel)
runtime = manifest.get("runtime") or {}
cmd = (
f"python {entry_abs}" if runtime.get("python")
else f"node {entry_abs}" if runtime.get("node")
else entry_abs
)
return {
"next_step": f"Script ready. Run with:\n bash(command={json.dumps(cmd)})",
"type": "script", "command": cmd,
}
INSTALLERS = {"task": install_task, "preview": install_preview, "service": install_service, "script": install_script}
def install(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
fn = INSTALLERS.get(manifest.get("type"))
if not fn:
return {"next_step": f"Unknown type: {manifest.get('type')}", "type": manifest.get("type")}
return fn(project_dir, manifest)
def diff_env_required(manifest: dict[str, Any]) -> list[str]:
"""env names declared in manifest.env_required not present in workspace/.env."""
env_required = manifest.get("env_required") or []
if not isinstance(env_required, list):
return []
have: set[str] = set(os.environ.keys())
for path in ("/data/workspace/.env",):
if os.path.isfile(path):
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key = line.split("=", 1)[0].strip()
if key:
have.add(key)
return [e for e in env_required if e not in have]
"""project.yaml parsing/writing + semver helpers.
We use a minimal YAML approach (PyYAML if available, fallback to manual parser)
so the skill works even on stripped-down environments.
"""
from __future__ import annotations
import os
import re
from typing import Any
try:
import yaml # type: ignore
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
VALID_TYPES = ("task", "preview", "service", "script")
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def parse_semver(v: str) -> tuple[int, int, int]:
m = SEMVER_RE.match(v.strip())
if not m:
raise ValueError(f"Invalid semver: {v}")
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def bump_semver(v: str, kind: str) -> str:
major, minor, patch = parse_semver(v)
if kind == "major":
return f"{major + 1}.0.0"
if kind == "minor":
return f"{major}.{minor + 1}.0"
if kind == "patch":
return f"{major}.{minor}.{patch + 1}"
raise ValueError(f"Invalid bump kind: {kind} (want patch|minor|major)")
def compare_semver(a: str, b: str) -> int:
"""Returns 1 if a > b, -1 if a < b, 0 if equal."""
aa = parse_semver(a)
bb = parse_semver(b)
if aa > bb:
return 1
if aa < bb:
return -1
return 0
def load_manifest(project_dir: str) -> dict[str, Any]:
path = os.path.join(project_dir, "project.yaml")
if not os.path.isfile(path):
raise FileNotFoundError(f"project.yaml not found in {project_dir}")
with open(path, "r", encoding="utf-8") as f:
text = f.read()
if _HAS_YAML:
return yaml.safe_load(text) or {}
return _parse_yaml_lite(text)
def save_manifest(project_dir: str, manifest: dict[str, Any]) -> None:
path = os.path.join(project_dir, "project.yaml")
if _HAS_YAML:
text = yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True, default_flow_style=False)
else:
text = _dump_yaml_lite(manifest)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
def _parse_yaml_lite(text: str) -> dict[str, Any]:
"""Minimal YAML parser supporting key:value, lists, single-level nesting."""
result: dict[str, Any] = {}
lines = text.split("\n")
current_key: str | None = None
current_obj_key: str | None = None
for raw in lines:
# Strip comments outside quotes (cheap heuristic)
line = raw.split("#", 1)[0].rstrip() if not _line_in_quotes(raw, "#") else raw.rstrip()
if not line.strip():
continue
# Indented list item: " - foo"
m = re.match(r"^\s+-\s+(.+)$", line)
if m and current_key is not None and isinstance(result.get(current_key), list):
result[current_key].append(_parse_scalar(m.group(1).strip()))
continue
# Indented nested key: " python: '>=3.10'"
m = re.match(r"^\s+([a-zA-Z_]\w*):\s*(.*)$", line)
if m and current_obj_key is not None and isinstance(result.get(current_obj_key), dict):
result[current_obj_key][m.group(1)] = _parse_scalar(m.group(2).strip())
continue
# Top-level key
m = re.match(r"^([a-zA-Z_]\w*):\s*(.*)$", line)
if m:
key, val = m.group(1), m.group(2).strip()
current_key = key
current_obj_key = None
if val == "":
# Could be list or dict — peek ahead
# We'll create a list by default; if first nested item is "key: value", convert to dict
result[key] = []
elif val == "[]":
result[key] = []
current_key = None
elif val == "{}":
result[key] = {}
current_key = None
current_obj_key = key
else:
result[key] = _parse_scalar(val)
current_key = None
# Post-process: if a "list" actually got dict items, convert
# (this happens when key has nested object below it)
# Best-effort — for full schema, install pyyaml
return _normalize_lite(result)
def _line_in_quotes(line: str, ch: str) -> bool:
in_quote = False
quote_char = None
for c in line:
if c in ('"', "'"):
if not in_quote:
in_quote = True
quote_char = c
elif c == quote_char:
in_quote = False
elif c == ch and in_quote:
return True
return False
def _normalize_lite(d: dict[str, Any]) -> dict[str, Any]:
"""Empty list values are ambiguous; leave as-is (caller can interpret)."""
return d
def _parse_scalar(s: str) -> Any:
s = s.strip()
if s == "" or s == "~" or s == "null":
return None
if s == "true":
return True
if s == "false":
return False
# Strip surrounding quotes
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
return s[1:-1]
if re.fullmatch(r"-?\d+", s):
return int(s)
if re.fullmatch(r"-?\d+\.\d+", s):
return float(s)
return s
def _dump_yaml_lite(d: dict[str, Any], indent: int = 0) -> str:
"""Minimal YAML serializer (used only when PyYAML missing)."""
pad = " " * indent
out: list[str] = []
for k, v in d.items():
if isinstance(v, dict):
out.append(f"{pad}{k}:")
out.append(_dump_yaml_lite(v, indent + 1))
elif isinstance(v, list):
if not v:
out.append(f"{pad}{k}: []")
else:
out.append(f"{pad}{k}:")
for item in v:
out.append(f"{pad} - {_dump_scalar(item)}")
else:
out.append(f"{pad}{k}: {_dump_scalar(v)}")
return "\n".join(out) + ("\n" if indent == 0 else "")
def _dump_scalar(v: Any) -> str:
if v is None:
return "~"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
s = str(v)
if any(c in s for c in (':', '#', '[', ']', '{', '}', ',', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`')):
return f'"{s}"'
if s == "" or s.lower() in ("true", "false", "null", "yes", "no", "~"):
return f'"{s}"'
return s
"""Pre-publish validation — mirrors gateway-side checks so we fail fast locally."""
from __future__ import annotations
import os
import re
from typing import Any
from .manifest import VALID_TYPES, SLUG_RE, SEMVER_RE
# Hard-block these path patterns
BLOCKED_PATHS = [
re.compile(r"(^|/)\.env$"),
re.compile(r"(^|/)\.env\.(local|production|development)$"),
re.compile(r"(^|/)secrets/"),
re.compile(r"\.(key|pem|pfx|p12|der)$"),
re.compile(r"(^|/)id_rsa(\.pub)?$"),
re.compile(r"(^|/)id_ed25519(\.pub)?$"),
re.compile(r"(^|/)\.ssh/"),
re.compile(r"(^|/)\.aws/credentials"),
re.compile(r"(^|/)__pycache__/"),
re.compile(r"\.pyc$"),
re.compile(r"(^|/)\.git/"),
re.compile(r"(^|/)node_modules/"),
re.compile(r"(^|/)\.venv/"),
]
# Patterns of secrets we scan inside file content
SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"sk-[A-Za-z0-9]{20,}"), "OpenAI/Anthropic-style API key (sk-...)"),
(re.compile(r"sk-ant-[A-Za-z0-9_\-]{40,}"), "Anthropic API key"),
(re.compile(r"github_pat_[A-Za-z0-9_]{40,}"), "GitHub fine-grained PAT"),
(re.compile(r"ghp_[A-Za-z0-9]{36,}"), "GitHub classic PAT"),
(re.compile(r"gho_[A-Za-z0-9]{36,}"), "GitHub OAuth token"),
(re.compile(r"glpat-[A-Za-z0-9_\-]{20,}"), "GitLab PAT"),
(re.compile(r"xox[baprs]-[0-9]+-[0-9]+-[A-Za-z0-9]+"), "Slack token"),
(re.compile(r"AIza[0-9A-Za-z_\-]{35}"), "Google API key"),
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key ID"),
(re.compile(r"-----BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY-----"), "Private key"),
(re.compile(r"eyJ[A-Za-z0-9_\-]{20,}\.eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]+"), "JWT token"),
]
# Skip secret scanning for these binary/noise file types
SKIP_SCAN_EXTS = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".mp3", ".mp4", ".webm", ".ogg",
".zip", ".tar", ".gz", ".tgz", ".bz2",
".pdf",
}
REQUIRED_README_SECTIONS = [
"## What",
"## Required env",
"## How to start",
"## Outputs", # accepts "## Outputs / Behavior" too
"## Troubleshooting",
]
MAX_FILE_BYTES = 1_048_576 # 1 MB
MAX_BUNDLE_BYTES = 10_485_760 # 10 MB
def collect_files(project_dir: str) -> list[tuple[str, bytes]]:
"""Walk project_dir, return (relative_path, content_bytes) for each non-blocked file."""
files: list[tuple[str, bytes]] = []
for root, dirs, names in os.walk(project_dir):
# Skip blocked subdirs early
dirs[:] = [d for d in dirs if not _is_blocked_path(os.path.relpath(os.path.join(root, d), project_dir) + "/")]
for name in names:
full = os.path.join(root, name)
rel = os.path.relpath(full, project_dir).replace("\\", "/")
if _is_blocked_path(rel):
continue
try:
with open(full, "rb") as f:
files.append((rel, f.read()))
except OSError:
continue
return files
def _is_blocked_path(rel: str) -> bool:
for pat in BLOCKED_PATHS:
if pat.search(rel):
return True
return False
def validate(project_dir: str, manifest: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Returns (errors, warnings).
Caller should refuse to publish if errors is non-empty.
"""
errors: list[str] = []
warnings: list[str] = []
# Manifest top-level
name = manifest.get("name")
version = manifest.get("version")
ptype = manifest.get("type")
description = manifest.get("description")
license_ = manifest.get("license")
entry = manifest.get("entry")
if not name or not SLUG_RE.match(str(name)):
errors.append(f"manifest.name invalid (must be lowercase alphanumeric + hyphen, 3-50 chars): {name!r}")
folder_name = os.path.basename(os.path.abspath(project_dir))
if name and name != folder_name:
warnings.append(f"manifest.name '{name}' differs from folder name '{folder_name}' — gateway requires they match")
if not version or not SEMVER_RE.match(str(version)):
errors.append(f"manifest.version must be semver (x.y.z), got: {version!r}")
if ptype not in VALID_TYPES:
errors.append(f"manifest.type must be one of {VALID_TYPES}, got: {ptype!r}")
if not description or len(str(description)) < 5:
errors.append("manifest.description must be at least 5 chars")
if not license_:
errors.append("manifest.license required (use SPDX identifier like MIT, Apache-2.0)")
if not entry:
errors.append("manifest.entry required (relative path to main file)")
# Type-specific
if ptype == "task" and not manifest.get("schedule"):
errors.append("manifest.schedule required for type=task (cron expression in UTC)")
if ptype in ("preview", "service") and not manifest.get("port"):
errors.append(f"manifest.port required for type={ptype}")
# Files on disk
files = collect_files(project_dir)
file_paths = {p for p, _ in files}
# Required files
for req in ("project.yaml", "PROJECT.md", ".env.example"):
if req not in file_paths:
errors.append(f"Missing required file: {req}")
# Entry must exist
if entry and entry not in file_paths:
errors.append(f"manifest.entry '{entry}' not found in project directory")
# PROJECT.md sections check
readme_path = os.path.join(project_dir, "PROJECT.md")
if os.path.isfile(readme_path):
with open(readme_path, "r", encoding="utf-8", errors="replace") as f:
readme = f.read()
missing_sections = []
for section in REQUIRED_README_SECTIONS:
# Accept "## Outputs / Behavior" or "## Outputs"
if section == "## Outputs":
if not re.search(r"^## Outputs", readme, re.M):
missing_sections.append("## Outputs (or '## Outputs / Behavior')")
else:
if section not in readme:
missing_sections.append(section)
if missing_sections:
errors.append(f"PROJECT.md missing required sections: {', '.join(missing_sections)}")
# env_required must be in .env.example
env_example_path = os.path.join(project_dir, ".env.example")
declared_envs: set[str] = set()
if os.path.isfile(env_example_path):
with open(env_example_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
declared_envs.add(line.split("=", 1)[0].strip())
env_required = manifest.get("env_required") or []
if isinstance(env_required, list):
for env in env_required:
if env not in declared_envs:
errors.append(f"env_required '{env}' not declared in .env.example")
# Sizes + secret scan
total_bytes = 0
for rel, content in files:
if len(content) > MAX_FILE_BYTES:
errors.append(f"File too large: {rel} ({len(content)} > {MAX_FILE_BYTES} bytes)")
total_bytes += len(content)
ext = os.path.splitext(rel)[1].lower()
if ext in SKIP_SCAN_EXTS:
continue
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
continue
is_env_example = rel == ".env.example" or rel.endswith("/.env.example")
for pat, label in SECRET_PATTERNS:
matches = pat.findall(text)
if not matches:
continue
if is_env_example:
# Allow only obvious placeholders in .env.example
real = [m for m in matches if not re.search(r"(your|example|placeholder|xxx|todo|change[_-]?me|<.*>)", m, re.I)]
if not real:
continue
errors.append(f"Possible secret in {rel}: {label}")
break
if total_bytes > MAX_BUNDLE_BYTES:
errors.append(f"Bundle too large: {total_bytes} > {MAX_BUNDLE_BYTES} bytes")
return errors, warnings
Related skills
AI & Agent Buildingagents