
Community Publish
- 3.9k installs
- 21 repo stars
- Updated August 2, 2026
- starchild-ai-agent/official-skills
community-publish is a Starchild agent skill that publishes service preview URLs, lists projects on the community dashboard, and open-sources code to GitHub.
About
Community Publish is a Starchild agent skill with three independent sharing actions for projects built on the platform. publish_preview allocates a public URL on community.iamstarchild.com for a running HTTP service without automatically listing it on the dashboard. list_in_dashboard makes an existing preview discoverable in the public Project Dashboard with optional cover, tags, and description. open_source pushes project source to the Starchild community GitHub catalog with semver versioning and validation gates. Visibility is two orthogonal switches: URL access and dashboard discoverability, so agents must call get_listing_status for state questions instead of inferring from past publish calls. The publisher binding in project.yaml cross-links live demos and code repos automatically regardless of publish order. Behavioral rules require showing diffs before open_source, batching env collection on fork, and never conflating list_published_previews with list_open_source datasets. Developers use it when users want to publish, share, distribute, open-source, or check visibility of Starchild projects.
- Three actions: publish_preview URL, list_in_dashboard discovery, open_source GitHub push.
- URL access and dashboard listing are separate switches with get_listing_status checks.
- publisher block in project.yaml auto cross-links live demos and code repos.
- open_source requires project.yaml, PROJECT.md, .env.example, and validate_open_source gates.
- publish_preview defaults listings to private until list_in_dashboard is called.
Community Publish by the numbers
- 3,928 all-time installs (skills.sh)
- +622 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #6 of 248 Release Management skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 3, 2026 (Skillselion catalog sync)
community-publish capabilities & compatibility
- Capabilities
- preview publish · dashboard listing · open source push · listing status · publisher crosslink · fork install · validation gates
- Works with
- github
- Use cases
- orchestration · documentation
- Runs
- Hosted SaaS
What community-publish says it does
Public URL ≠ public discovery.
Three independent actions
npx skills add https://github.com/starchild-ai-agent/official-skills --skill community-publishAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.9k |
|---|---|
| repo stars | ★ 21 |
| Security audit | 1 / 3 scanners passed |
| Last updated | August 2, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
How do I share a Starchild project URL, make it discoverable, or publish the source without writing custom deploy scripts?
Publish running service previews to public URLs, list them on the community dashboard, or open-source project code to GitHub.
Who is it for?
Starchild developers sharing running services or open-sourcing tasks and scripts on the community gateway.
Skip if: Non-Starchild deployments or local development outside the Fly container where FLY_MACHINE_ID is required.
When should I use this skill?
Use when the user wants to publish, share, make public, list, distribute, deploy publicly, or open-source a Starchild project.
What you get
A live preview URL, optional dashboard listing, or versioned GitHub catalog entry with cross-links when publisher binding is set.
- Public preview URL
- Dashboard listing
- Community GitHub repository push
By the numbers
- Skill version 0.13.1 with script delivery mode
- Exposes three independent publish actions
Files
Three independent actions
This skill handles three completely different kinds of sharing. They are NOT stages of one flow and NOT mutually exclusive — a project can be in any combination.
| Action | What "share" means here | Audience can | Applies to |
|---|---|---|---|
publish_preview(preview_id) | Allocate a public URL https://community.iamstarchild.com/{user_id}-{slug} | Open the URL if they know it — point-to-point access | Any running service |
list_in_dashboard(slug) | Show the listing on the public Project Dashboard | Discover and browse to it from the gallery | A previously-published preview |
open_source(project_dir) | Push project source to the community GitHub repo | Fork the code and run their own copy | Any project (task, service, script) |
Critical: do NOT auto-list when publishing. publish_preview() only allocates the URL. Listing is a separate, deliberate user decision. If the user just says "publish my preview" / "公开" without mentioning the dashboard, only call publish_preview(). After it succeeds, you may mention that list_in_dashboard() exists if they want others to discover it.
Service lifecycle (start / stop / health check) lives in the preview tool. This skill only handles the share side.---
Visibility model — read this before answering anything about who can see a project
A project's "publicness" is two orthogonal switches, not one:
| Switch | Off state | On state | Flipped by |
|---|---|---|---|
| URL access | Visiting the URL returns 404 / no service | URL works for anyone who has the link | publish_preview / unpublish_preview |
| Dashboard discoverability | Listing row has is_public=false (or doesn't exist) — invisible in the public gallery | Listing row has is_public=true — appears in /projects | list_in_dashboard / unlist_from_dashboard |
Public URL ≠ public discovery. A preview can be URL-reachable but undiscoverable (the default state right after publish_preview), or listed but URL-down (preview stopped after being listed), or any other combination. Never collapse these into "is it public yet".
Status questions are read-only operations. Whenever the user asks anything like:
- "is it visible / public / discoverable yet?"
- "can other people find it?"
- "上架了吗 / 在 dashboard 上吗 / 别人能看到吗"
- "is the listing live?"
The authoritative answer comes ONLY from a fresh get_listing_status(slug) call. Do NOT infer the answer from "I called publish_preview earlier so it must be visible" — that's exactly the trap (publish_preview leaves is_public=false). Treat your own past actions as suggestive but never authoritative for a state question.
---
Project types — three only
| type | What it is | Eligible for publish_preview()? |
|---|---|---|
task | Scheduled cron/interval job | No (no HTTP port) |
service | Long-running HTTP service (dashboard, API, page) | Yes — any service can be exposed at a public URL |
script | One-shot script | No (no HTTP port) |
Note: there is no preview type. If you encounter older docs mentioning it, treat as service.
---
Routing — match user intent to the right surface
User requests fall into two fundamentally different categories. Mixing them up is the #1 source of wrong answers in this skill.
A. Status intents — user wants to know current state
The user is asking a question about how things stand right now. The answer is data, not an action. Always reach for a read endpoint first; never answer from memory of past actions.
| Sample phrasing | Action |
|---|---|
| "is it visible / public / discoverable / live for others?" | get_listing_status(slug) |
| "上架了吗 / 在 dashboard 上吗 / 别人能不能看到 / 别人能搜到吗" | get_listing_status(slug) |
| "what URLs do I have published?" / "我发布了哪些" | list_published_previews() |
| "what's open-sourced?" / "都有哪些开源代码" | list_open_source(...) |
B. Action intents — user wants to change state
| Sample phrasing | Action | Notes |
|---|---|---|
| "publish" / "share" / "make public" / "公开" / "发布" (no qualifier) | publish_preview(preview_id) | Allocates the URL only. Listing is NOT auto-flipped. |
| "publish the URL" / "share the link" / "let people visit" / "公开访问" | publish_preview(preview_id) | Same. |
| "list on the dashboard" / "上架" / "show on community" / "make discoverable" / "let people find this" / "发到广场" | list_in_dashboard(slug) | Requires the preview to already exist. |
| "publish AND list" / "publish and put on dashboard" / "发布并上架" | publish_preview() THEN list_in_dashboard() | Two separate calls in order. |
| "remove from dashboard" / "下架" / "unlist" / "hide from gallery" | unlist_from_dashboard(slug) | Preview URL stays alive. |
| "open source" / "open-source the code" / "share the code" / "let others fork" / "开源代码" | open_source(project_dir) | Explicit code-sharing intent. |
| "unpublish the URL" / "take down the link" | unpublish_preview(slug) | Listing row stays. |
| "remove the open source" / "delete from GitHub" | remove_open_source(slug) | |
| "fork" / "install someone's project" | fork(source) | |
| Ambiguous after rereading | Ask one question, don't guess | "Do you want it (a) just shareable by URL, (b) also discoverable on the public dashboard, or (c) also have the code open-sourced on GitHub?" |
---
Cross-link via publisher: binding
When the same project has BOTH a public URL AND open-sourced code, you want them paired so the frontend renders "View Source" on the listing card and "Visit Live Demo" on the code card. This skill drives that pairing through one explicit binding in project.yaml — no name guessing, no fuzzy matching, no follow-up dialogues.
How to declare the binding
Add a publisher: block to project.yaml:
name: my-app # GitHub catalog slug + default for both sides
type: service
version: 1.0.0
publisher:
code_slug: my-app # OPTIONAL — defaults to manifest.name
public_slug: my-app-pub # OPTIONAL — URL suffix; defaults to code_slugBoth fields are optional. If omitted, both default to manifest.name. Set them only when you want different slugs on each side (e.g. short URL dash for code my-detailed-dashboard).
Either side can be published first
The gateway holds a pending entry until the second side arrives. No ordering requirement, no manual link step.
| Order | What happens |
|---|---|
open_source first → publish_preview second | open_source records pending entry; publish_preview consumes it and links |
publish_preview first → open_source second | publish_preview records pending entry (needs publisher_code_slug arg); open_source consumes it and links |
| Both at once / rename later | Re-run either side with updated binding; gateway re-links |
Calling publish_preview with binding
When publishing the URL FIRST and the code will follow:
publish_preview(
preview_id="my-app-a3f1",
slug="my-app-pub",
publisher_code_slug="my-app", # so future open_source(my-app) auto-links here
)If publisher_code_slug is omitted, no pending entry is recorded — the code side will need to declare publisher.public_slug itself in project.yaml to wire the link.
Return value
Both functions return a publisher field showing what binding was sent and a hint describing the cross-link state:
{"ok": True, "url": "...", "publisher": {"code_slug": "my-app"},
"hint": "Cross-link binding declared. If that code is already open-sourced, it's now linked. If not, the link is pending..."}You don't need to react to the hint — it's informational. The gateway handles wiring automatically.
Manual repair (rare)
If a pairing was wired wrong (e.g. after a rename or a slug typo), use:
link_to_listing(listing_slug="2004-my-app-pub", code_slug="my-app")This skips the binding flow and writes the link directly. Don't use this for normal publishing — fix project.yaml's publisher: block instead so the binding survives future republishes.
---
Architecture
community.iamstarchild.com (single gateway domain)
│
┌─────────────────┴─────────────────┐
│ │
┌────────▼─────────┐ ┌────────▼─────────┐
│ /api/register │ │/api/code-projects│
│ /api/unregister │ │ /publish, /list, │
│ /api/list │ │ /unpublish, ... │
└────────┬─────────┘ └────────┬─────────┘
│ │
┌────────▼─────────┐ ┌────────▼─────────┐
│ DB: route table │ │ GitHub: │
│ + project_ │ │ Starchild-ai- │
│ listings │ │ agent/community- │
│ + publisher_ │ │ projects │
│ pending_links │ │ │
│ │ │ Permanent. │
│ Service stays up │ │ │
│ as long as your │ │ │
│ container runs. │ │ │
└──────────────────┘ └──────────────────┘
publish_preview() open_source()publisher_pending_links is the cross-link table. Either side writes a pending row, the other side consumes it on arrival.
---
publish_preview() — public URL
publish_preview(preview_id, slug="", title="", publisher_code_slug="")
Map a running service to https://community.iamstarchild.com/{user_id}-{slug}.
preview_id: frompreview(action='serve'). Must bestatus=running.slug: URL suffix only (lowercase alphanumeric + hyphens, 3-50 chars). User_id prefix is added automatically — pass'my-app', NOT'1463-my-app'.title: display name for the listing.publisher_code_slug: optional cross-link binding to a code project's slug. Sets up the pending entry so the eventualopen_source()call auto-links.
Returns {"ok": True, "url": "...", "publisher": {...}, "hint": "..."}.
Constraints:
- Max 20 published previews per user (gateway returns 429 over).
- Service must be running. Stops working when the container goes down (visitors see offline page).
- Slug stays bound to the port — stop and re-serve, the URL stays valid.
- Only works inside the Starchild Fly container (needs
FLY_MACHINE_ID). - Listing visibility default is `is_public=false`. A successful
publish_previewallocates the URL but creates the listing row in PRIVATE state — strangers cannot discover the project on the dashboard. Discovery requires a separatelist_in_dashboard()call. Do NOT tell the user "your project is now public" after only callingpublish_preview— say "the URL is live" and offerlist_in_dashboardas the next step if they want it discoverable.
Companions:
unpublish_preview(slug)— remove the public URL. Slug accepts full{user_id}-{suffix}or just suffix.list_published_previews()— all currently published preview URLs for this user.
---
list_in_dashboard() — show on Project Dashboard
list_in_dashboard(slug, name=None, description="", cover_url=None, tags=None)
Make a published preview discoverable in the public Project Dashboard at https://community.iamstarchild.com/projects. Without this, the preview URL works but is invisible to anyone who doesn't already know it.
slug: the full slug returned bypublish_preview()(i.e.{user_id}-{suffix}). The gateway's ownership check uses this exact value.name: dashboard card display name. Defaults toslug.description: ≤500 chars.cover_url: must be onstorage.googleapis.com,image.thum.io, orapi.microlink.io. Other domains rejected with 400. If omitted, the gateway captures a screenshot asynchronously.tags: ≤5 tags, ≤20 chars each.
Returns {"ok": True, "listing": {...}, "url": "...", "dashboard_url": "..."}.
Constraints:
- Requires
publish_preview()to have run first for the same slug — returns 404 with a clear error otherwise. - Idempotent: calling again with different name/tags updates the existing listing.
- Listings created via
publish_preview()start as private (not on dashboard) —list_in_dashboard()is the ONLY way to make them discoverable.
Companions:
unlist_from_dashboard(slug)— remove from dashboard, keep URL alive.get_listing_status(slug)— read-only check: returns{ok, exists, is_public, listing}. Note: only public listings are observable through this — if the gateway returns 404, the listing is either nonexistent OR private (no way to distinguish).
---
open_source() — push code to GitHub
open_source(project_dir, version_bump="patch", message="")
Push project source to community-projects/projects/{user_id}/{slug}/ on GitHub. Versioning is delegated entirely to git history — there's no {version}/ snapshot directory and no per-type bucket above the slug. The type field inside project.yaml stays around as runtime metadata (so forks know whether to schedule, run-once, or expose a service) but no longer affects the storage path.
project_dir: e.g.output/projects/my-taskversion_bump:patch|minor|major|nonemessage: commit message body describing what this version changed.
You (the agent) should always compose this based on the actual code changes you made in this session — never leave it blank if you know what changed, never ask the user to write it. Aim for one to three short lines. Don't list every file; describe the user-visible change. If the user explicitly said "just publish", use a one-line summary like "Initial publish" or "Re-publish without changes".
- Returns
{"ok": True, "github_url": ..., "version": ..., "publisher": {...}, "hint": "..."}
Commit message style — write like a normal git commit body:
✅ "Add WebSocket reconnect on dropped connections; refactor prompt builder for shared state." ✅ "Fix funding-rate sign convention; add unit test for negative-funding path." ❌ "Updated 3 files" (uninformative) ❌ "Modified src/index.html, src/main.py, project.yaml" (lists files instead of intent) ❌ "User asked to publish" (describes the request, not the change)
Companions:
fork(source, dest_dir=None)— install someone else's open-sourced project locallysource:"user_id/slug"(always pulls current state — older snapshots live in GitHub commit history)- For
tasktype: registers as paused, returnsnext_stepinstructions - For
servicetype: returns ready-to-serve info list_open_source(type=None, tag=None, user=None, q=None)— browse the GitHub catalogget_open_source(source)— fetch one project's full metadataremove_open_source(slug)— delete project directory from GitHub catalog (owner only). Git history of the deletion + previous commits is preserved in the repo's commit log.validate_open_source(project_dir)— pre-flight check before publishing
Project structure
Every project under output/projects/{slug}/:
project.yaml # metadata (name, version, type, env_required, sc_proxy, publisher)
PROJECT.md # required 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=service (or app.py + frontend)
└── main.py # for type=scriptDon't conflate the two list functions
list_published_previews() returns live URLs (preview side). list_open_source() returns open-sourced code (GitHub side). Different datasets — never quote one number to answer a question about the other.
---
Usage from a bash block
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/community-publish")
from exports import (
# Public URL
publish_preview, unpublish_preview, list_published_previews,
# Open source code
open_source, remove_open_source, fork,
list_open_source, get_open_source, validate_open_source,
# Manual repair (rare)
link_to_listing,
)
# Cross-linked publish: declare publisher in project.yaml first, then both
# sides auto-pair regardless of order.
print(publish_preview(preview_id="my-app-a3f1", slug="my-app",
publisher_code_slug="my-app"))
print(open_source("output/projects/my-app", version_bump="patch"))
EOF---
Behavioral rules
- Show the diff before `open_source()`. After
validate_open_source, summarize what's about to be pushed (file list, version, type, tags, env_required) and ask for confirmation. Exception: explicit "publish without confirmation" or re-publish of a known good project. - Never auto-run setup.sh on fork. Show the command, let the user confirm.
- Always collect env in one batch on fork. Read project's
env_required, diff againstworkspace/.env, 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. Skill auto-strips duplicate
{user_id}-prefix if you accidentally include it. - Version rules (
open_source): strict semver. Re-publishing same version is rejected. New version must be > current latest. - Type immutability (
open_source): once published astask, can't change toservicelater. Pick a different slug. - URL ≠ code: a public URL going down (container off) does NOT remove the open-source code, and vice versa. They're independent.
- Don't manually call `link_to_listing()` in the normal flow. The
publisher:binding handles cross-linking. Manual link is only for repair.
---
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
publish_preview: Preview not found | Wrong preview_id, or service was stopped | Check /data/previews.json, restart with preview(action='serve') |
publish_preview: 429 Too many published previews | Hit 20-per-user gateway cap | unpublish_preview() something old first |
publish_preview: FLY_MACHINE_ID not set | Running locally, not in Starchild container | URL publish only works in the production container |
open_source: 400 Validation failed: env names not in .env.example | Listed MY_KEY in env_required but forgot .env.example | Add the missing key to .env.example |
open_source: 400 Possible secret detected | Secret scanner found a real-looking API key | Move to env var; .env.example value should be your-key-here |
| open_source: 400 Type cannot change after publish | Trying to switch task ↔ service ↔ script | Pick a different slug | | remove_open_source: 403 Permission denied | Trying to remove someone else's project | Only the owner can remove | | Cross-link not appearing on frontend | Binding mismatch between sides | Check both sides' slugs match the publisher: block; or use link_to_listing to repair | | Forked task doesn't run | Auto-registered as paused | Tell user: scheduled_task(action='activate', job_id={id}) |
---
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/service/script)lib/gateway.py— HTTP client for/api/register(URL side) and/api/code-projects/*(code side)
"""community-publish skill exports.
Two independent kinds of sharing, optionally cross-linked via project.yaml's
`publisher:` block:
Open-source side (any code, GitHub-backed):
open_source, remove_open_source, list_open_source,
get_open_source, fork, validate_open_source
Public URL side (any running HTTP service, in-memory route table):
publish_preview, unpublish_preview, list_published_previews
Cross-link binding lives in project.yaml under `publisher:`. Either side can
register the binding first; the gateway holds a pending entry until the
counterpart arrives. No manual link step needed in the typical flow.
Manual escape hatch (for repair scenarios after rename):
link_to_listing(listing_slug, code_slug)
Usage from a bash block:
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/community-publish")
from exports import open_source, publish_preview
print(open_source("output/projects/my-app"))
EOF
"""
from __future__ import annotations
import base64
import os
import re
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
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$")
# ── 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 _machine_id() -> str:
mid = os.environ.get("FLY_MACHINE_ID", "")
if not mid:
raise RuntimeError(
"FLY_MACHINE_ID not set — preview publish only works inside "
"the Starchild Fly container."
)
return mid
def _public_url_base() -> str:
return os.environ.get(
"COMMUNITY_PUBLIC_URL", "https://community.iamstarchild.com"
).rstrip("/")
def _abspath(p: str) -> str:
if os.path.isabs(p):
return p
return os.path.abspath(os.path.join("/data/workspace", p))
def _parse_source(source: str) -> tuple[str, str]:
"""Parse 'user_id/slug'.
NOTE: 'user_id/slug@version' is no longer supported — only the latest
state of a project lives on disk. To inspect or fork an older snapshot,
look at the GitHub commit history for the project directory and check
out the desired commit manually.
"""
s = source.strip()
if "@" in s:
raise ValueError(
f"Invalid source: {source!r} — versioned references are no longer supported. "
"Only the latest state of a project is published; use 'user_id/slug' and "
"consult GitHub history for older snapshots."
)
if "/" not in s:
raise ValueError(f"Invalid source: {source!r} — expected 'user_id/slug'")
user_id, slug = s.split("/", 1)
return user_id.strip(), slug.strip()
_LOCAL_AGENT_BASE = os.environ.get("STARCHILD_LOCAL_API_BASE", "http://127.0.0.1:8000")
def _notify_local_publish(port: int, preview_id: str | None) -> tuple[bool, str | None]:
"""Tell the local agent process to whitelist this port for /community/{port}/.
Calls the loopback-only /community/_internal/publish endpoint that lives in
the same process as CommunityRegistry. Without this call, the agent's
`/community/{port}/` proxy returns 403 "Port not published" until the next
container restart re-syncs from the gateway via populate_from_gateway.
Best-effort: returns (ok, error_message). Caller should treat failure as a
soft warning, not a hard publish failure (gateway DB has the slug, restart
will eventually self-heal).
"""
import json as _json
import urllib.request
import urllib.error
payload = {"port": int(port)}
if preview_id:
payload["preview_id"] = preview_id
try:
req = urllib.request.Request(
f"{_LOCAL_AGENT_BASE}/community/_internal/publish",
data=_json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
body = _json.loads(resp.read())
return (bool(body.get("ok")), None)
except urllib.error.HTTPError as e:
try:
detail = _json.loads(e.read()).get("detail", "")
except Exception:
detail = ""
return (False, f"HTTP {e.code}: {detail}")
except Exception as e:
return (False, str(e))
def _notify_local_unpublish(port: int) -> tuple[bool, str | None]:
"""Tell the local agent process to remove this port from the whitelist."""
import json as _json
import urllib.request
import urllib.error
try:
req = urllib.request.Request(
f"{_LOCAL_AGENT_BASE}/community/_internal/unpublish",
data=_json.dumps({"port": int(port)}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
body = _json.loads(resp.read())
return (bool(body.get("ok")), None)
except urllib.error.HTTPError as e:
try:
detail = _json.loads(e.read()).get("detail", "")
except Exception:
detail = ""
return (False, f"HTTP {e.code}: {detail}")
except Exception as e:
return (False, str(e))
def _verify_public_url(url: str, attempts: int = 5, delay: float = 2.0) -> tuple[bool, int | None]:
"""Post-flight: HEAD the public URL to confirm it actually serves traffic.
Returns (success, last_status). success=True if any attempt returns < 500.
"""
import time
import urllib.request
import urllib.error
last_status: int | None = None
for i in range(max(1, attempts)):
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=5) as resp:
last_status = resp.status
if last_status < 500:
return (True, last_status)
except urllib.error.HTTPError as e:
last_status = e.code
if last_status < 500 and last_status != 403:
# Anything that isn't a 403 (whitelist issue) or 5xx is "alive"
return (True, last_status)
except Exception:
last_status = None
if i < attempts - 1:
time.sleep(delay)
return (False, last_status)
def _read_preview_registry(preview_id: str) -> dict[str, Any] | None:
"""Read /data/previews.json to find a preview's port + status."""
import json as _json
path = "/data/previews.json"
if not os.path.exists(path):
return None
try:
with open(path) as f:
data = _json.load(f)
except Exception:
return None
items = data.get("previews") if isinstance(data, dict) else data
if not isinstance(items, list):
return None
for p in items:
if p.get("id") == preview_id or p.get("preview_id") == preview_id:
return p
return None
# ════════════════════════════════════════════════════════════════════════
# OPEN SOURCE — push code to GitHub. Works for any project type.
# ════════════════════════════════════════════════════════════════════════
def validate_open_source(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 open_source(project_dir: str, version_bump: str = "patch",
message: str = "") -> dict[str, Any]:
"""Validate, bump version, and push project source to the community GitHub repo.
Args:
project_dir: path to the project (e.g. "output/projects/my-app")
version_bump: "patch" | "minor" | "major" | "none" (use existing version)
message: free-form commit message describing what this version
changed. The agent should compose this based on actual
code changes in the session — it becomes the body of the
GitHub commit and is what people read when browsing
history. If blank, gateway uses a generic template.
"""
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}"}
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
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)
errors, warnings = V.validate(pd, manifest)
if errors:
return {"ok": False, "error": "Local validation failed", "errors": errors, "warnings": warnings}
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,
}
if message and message.strip():
body["commit_message"] = message.strip()
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,
}
# Surface the binding back to the caller so the agent can show what was
# wired (or what's pending).
publisher = manifest.get("publisher") or {}
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,
"publisher": {
"code_slug": publisher.get("code_slug") or manifest["name"],
"public_slug": publisher.get("public_slug"),
},
"hint": _publisher_hint_for_open_source(uid, manifest, publisher),
}
def _publisher_hint_for_open_source(uid: str, manifest: dict, publisher: dict) -> str:
"""Tell the user what cross-link state to expect after open_source."""
public_slug = publisher.get("public_slug")
if public_slug:
full = public_slug if public_slug.startswith(f"{uid}-") else f"{uid}-{public_slug}"
return (
f"Cross-link binding declared: publisher.public_slug='{public_slug}'. "
f"If a public listing '{full}' exists, it's now linked. "
f"If not, the link is pending and will wire automatically when you "
f"publish_preview() with publisher.code_slug='{manifest['name']}' in project.yaml."
)
return (
"No publisher.public_slug set in project.yaml. To pair this code with a "
"public preview URL, add `publisher: { public_slug: \"<your-slug>\" }` "
"to project.yaml and re-run open_source(), OR call publish_preview() "
f"with publisher.code_slug='{manifest['name']}' in your project.yaml so "
"the listing-side picks up this code."
)
def link_to_listing(listing_slug: str, code_slug: str) -> dict[str, Any]:
"""Manual escape hatch: directly wire a code project to a listing.
Normally not needed — cross-link happens automatically via the
publisher binding in project.yaml. Use this only for repair scenarios
(e.g. relinking after a manual rename).
Args:
listing_slug: full preview listing slug (e.g. '2004-my-dashboard').
User_id prefix is added if missing.
code_slug: open-sourced code project slug (no user_id prefix).
"""
uid = _user_id()
final_listing = listing_slug if listing_slug.startswith(f"{uid}-") else f"{uid}-{listing_slug}"
status, body = gateway.get(uid, code_slug)
if status != 200 or not body.get("ok"):
return {
"ok": False,
"error": (f"Code project '{uid}/{code_slug}' not found. "
f"Open-source it first with open_source(project_dir)."),
}
project = body.get("project") or {}
try:
st, b = gateway.link_listing(
public_slug=final_listing,
code_user_id=uid,
code_slug=code_slug,
version=project.get("version", ""),
github_url=project["github_url"],
)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if st == 200 and b.get("ok"):
return {
"ok": True,
"listing_slug": final_listing,
"code_slug": code_slug,
"message": f"Linked '{final_listing}' → code '{uid}/{code_slug}'.",
}
return {"ok": False, "error": b.get("error", f"HTTP {st}")}
def remove_open_source(slug: str) -> dict[str, Any]:
"""Remove your open-sourced project from the community GitHub repo.
Deletes the entire slug directory in one commit. Cannot remove someone
else's project. Git history of the deletion + prior versions stays in
the repo's commit log — only the working tree is cleaned.
"""
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
def list_open_source(type: str | None = None, tag: str | None = None,
user: str | None = None, q: str | None = None) -> dict[str, Any]:
"""Browse open-sourced projects in the community GitHub repo.
Filters: type ('task'|'service'|'script'), tag, user_id, free-text q.
"""
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}")}
if isinstance(resp, dict):
resp.setdefault("source", "community-projects (github-backed code repo)")
return resp
def get_open_source(source: str) -> dict[str, Any]:
"""Get one open-sourced project's full detail (manifest + readme).
source: 'user_id/slug'. Always returns the current state — historical
snapshots are not addressable through this skill (use GitHub history).
"""
user_id, slug = _parse_source(source)
status, resp = gateway.get(user_id, slug)
if status != 200:
return {"ok": False, "error": resp.get("error", f"HTTP {status}"), "http_status": status}
return resp
def fork(source: str, dest_dir: str | None = None) -> dict[str, Any]:
"""Fork an open-sourced project into output/projects/{slug}/.
source: 'user_id/slug' (always pulls current state — for older snapshots
check the GitHub commit history yourself)
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 = _parse_source(source)
detail_status, detail = gateway.get(user_id, slug)
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 {}
file_list = _enumerate_project_files(user_id, slug, project["type"])
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)
downloaded: list[str] = []
for rel_path in file_list:
try:
content = gateway.fetch_raw_file(raw_url_prefix, rel_path)
except Exception as e:
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)
try:
manifest = M.load_manifest(dest_abs)
except Exception:
manifest = manifest_dict
missing_envs = I.diff_env_required(manifest)
install_result = I.install(dest_abs, manifest)
return {
"ok": True,
"source": f"{user_id}/{slug}",
"version": project.get("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 _enumerate_project_files(user_id: str, slug: str, project_type: str) -> list[str]:
"""Enumerate files in a project's current state via GitHub Trees API.
`project_type` is accepted for signature stability but no longer affects
the path. The community-projects layout was flattened in 2026-05-14:
`projects/{user_id}/{slug}/...`, with type kept only as runtime metadata
inside project.yaml. Old `projects/{type}s/...` paths are migrated
in-place by the gateway.
"""
import urllib.request
import json
repo = "Starchild-ai-agent/community-projects"
prefix = f"projects/{user_id}/{slug}/"
url = f"https://api.github.com/repos/{repo}/git/trees/main?recursive=1"
req = urllib.request.Request(url, headers={"User-Agent": "community-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
# ════════════════════════════════════════════════════════════════════════
# PUBLISH PREVIEW — map a running HTTP service to a public URL.
# Works for any service (regardless of project type). Lives in an in-memory
# route table on the gateway.
# ════════════════════════════════════════════════════════════════════════
def publish_preview(preview_id: str, slug: str = "",
title: str = "",
publisher_code_slug: str = "") -> dict[str, Any]:
"""Expose a running service at a public URL.
Maps the preview to https://community.iamstarchild.com/{user_id}-{slug}.
Stays online while your container is running; visitors see an offline
page if the container is down.
Args:
preview_id: ID returned by preview(action='serve'). Must be running.
slug: URL suffix (lowercase alphanumeric + hyphens, 3-50 chars).
Pass only the suffix — user_id prefix is added automatically.
If omitted, preview_id is used as fallback.
title: Display name for the community listing.
publisher_code_slug: Optional binding to a code project (when the
source code lives under a different slug than the URL). The
gateway either links immediately if the code is already
open-sourced, or holds a pending entry that wires up when
the code is later open-sourced.
"""
user_id = _user_id()
try:
machine_id = _machine_id()
except RuntimeError as e:
return {"ok": False, "error": str(e)}
preview = _read_preview_registry(preview_id)
if not preview:
return {
"ok": False,
"error": f"Preview not found: {preview_id}. "
f"Check /data/previews.json for valid IDs.",
}
port = preview.get("port")
if not port:
return {"ok": False, "error": f"Preview {preview_id} has no port recorded."}
# Liveness: probe the port. If the preview was stopped, the port is closed.
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
try:
sock.connect(("127.0.0.1", int(port)))
sock.close()
except Exception:
return {
"ok": False,
"error": f"Preview {preview_id} is registered but port {port} "
f"is not accepting connections. Restart it via "
f"preview(action='serve') first.",
}
slug_suffix = slug if slug else preview_id
prefix = f"{user_id}-"
if slug_suffix.startswith(prefix):
slug_suffix = slug_suffix[len(prefix):]
final_slug = f"{user_id}-{slug_suffix}"
if not SLUG_RE.match(final_slug):
return {
"ok": False,
"error": f"Invalid slug '{final_slug}': must be 3-50 chars, "
f"lowercase alphanumeric + hyphens, "
f"cannot start or end with a hyphen.",
}
title_final = title or preview.get("title", "")
binding_code_slug = publisher_code_slug.strip() or None
try:
status, body = gateway.preview_register(
slug=final_slug, machine_id=machine_id, port=int(port),
owner_user_id=user_id, title=title_final,
publisher_code_slug=binding_code_slug,
)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status == 429:
return {"ok": False, "error": body.get("error", "Too many published previews.")}
if status != 200:
return {"ok": False, "error": body.get("error", f"Gateway returned {status}")}
# Gateway DB has the slug now. Two more steps must succeed for the public
# URL to actually serve traffic:
# (1) Local agent process must whitelist this port in CommunityRegistry,
# otherwise its /community/{port}/ proxy returns 403 "Port not
# published" until the next container restart.
# (2) The full path public URL → gateway → agent should round-trip.
sync_ok, sync_err = _notify_local_publish(int(port), preview_id)
public_url = f"{_public_url_base()}/{final_slug}"
# Post-flight verify: HEAD the public URL with retries. Skip if local sync
# failed — no point waiting 10s for something we know will 403.
verify_ok: bool | None = None
verify_status: int | None = None
if sync_ok:
verify_ok, verify_status = _verify_public_url(public_url, attempts=4, delay=2.0)
# If either local sync or post-flight failed, roll back the gateway
# registration and surface a clear error. We DO NOT want a half-published
# state where the gateway points at a port the agent rejects.
if not sync_ok or verify_ok is False:
try:
gateway.preview_unregister(slug=final_slug, owner_user_id=user_id)
except Exception:
pass
if not sync_ok:
return {
"ok": False,
"error": (
"Gateway registered the slug but the local agent process could not "
"whitelist port "
f"{port} (sync error: {sync_err}). Rolled back. "
"If this persists, restart the container — the registry will "
"re-sync from the gateway on startup."
),
}
return {
"ok": False,
"error": (
f"Gateway registered the slug and the local agent whitelisted port {port}, "
f"but the public URL still returns HTTP {verify_status} after 4 attempts. "
"Rolled back. This usually means the gateway routing or upstream is "
"misconfigured — check community.iamstarchild.com health."
),
}
return {
"ok": True,
"slug": final_slug,
"url": public_url,
"port": port,
"verified_status": verify_status,
"publisher": {"code_slug": binding_code_slug},
"hint": (
f"Cross-link binding declared (publisher.code_slug='{binding_code_slug}'). "
f"If that code project is already open-sourced, it's now linked. "
f"If not, the link is pending and will wire when you "
f"open_source() the code."
) if binding_code_slug else (
"No publisher.code_slug binding set. To pair this URL with "
"open-source code, pass publisher_code_slug='<code-slug>' on the "
"next call, OR add publisher: { public_slug: '" + final_slug[len(f"{user_id}-"):] + "' } "
"to the code project's project.yaml and run open_source()."
),
"message": f"Published! Anyone can view at: {public_url}",
}
def unpublish_preview(slug: str) -> dict[str, Any]:
"""Remove a preview's public URL.
Args:
slug: The full slug as listed by list_published_previews()
(e.g. '1463-my-dashboard'). User_id prefix may be omitted —
it will be added if missing.
Returns:
{"ok": True, "message": ...} on success
{"ok": False, "error": ...} on failure
"""
user_id = _user_id()
final_slug = slug if slug.startswith(f"{user_id}-") else f"{user_id}-{slug}"
try:
status, body = gateway.preview_unregister(
slug=final_slug, owner_user_id=user_id,
)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status == 404:
return {
"ok": False,
"error": body.get("error", f"Slug '{final_slug}' not found or not owned by you."),
}
if status != 200:
return {"ok": False, "error": body.get("error", f"Gateway returned {status}")}
# Mirror the publish-side fix: tell the local agent process to drop the
# port from its in-memory CommunityRegistry too. The gateway response
# carries the deleted port — we reuse it instead of re-querying.
deleted = body.get("deleted") or {}
deleted_port = deleted.get("port")
sync_note = ""
if isinstance(deleted_port, int) and deleted_port > 0:
sync_ok, sync_err = _notify_local_unpublish(deleted_port)
if not sync_ok:
sync_note = (
f" (note: local registry sync failed: {sync_err}; "
"port will be removed from the in-process whitelist on next restart)"
)
return {
"ok": True,
"slug": final_slug,
"message": f"Unpublished '{final_slug}'. The URL is no longer accessible.{sync_note}",
}
def list_published_previews() -> dict[str, Any]:
"""List current user's published preview URLs.
Returns:
{"ok": True, "previews": [...], "count": N} on success
{"ok": False, "error": ...} on failure
"""
user_id = _user_id()
try:
status, body = gateway.preview_list(owner_user_id=user_id)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status != 200:
return {"ok": False, "error": body.get("error", f"Gateway returned {status}")}
return {"ok": True, **body}
# ─── Dashboard listing (third action — discoverability) ─────────────
#
# These are the third independent share action, distinct from
# publish_preview (URL access) and open_source (code release):
#
# publish_preview → Audience can VISIT if they know the URL
# list_in_dashboard→ Audience can DISCOVER via the public Project
# Dashboard (browseable gallery)
# open_source → Audience can FORK the code
#
# A preview is created with a private listing by default
# (publish_preview's ensureDefaultListing). The user must explicitly
# call list_in_dashboard() to make it discoverable. We do NOT
# auto-list — keeping the three actions orthogonal so users always
# know exactly what they're sharing.
def list_in_dashboard(
slug: str,
name: str | None = None,
description: str = "",
cover_url: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Show this preview on the public Project Dashboard.
Requires publish_preview() to have run for `slug` first — gateway
rejects with 404 if no listing row exists yet.
Args:
slug: Public slug (the same one returned by publish_preview).
name: Display name on the dashboard card. Defaults to slug.
description: Short description shown on the card (≤500 chars).
cover_url: Optional cover image URL. Must be on an allowed
domain (storage.googleapis.com, image.thum.io, api.microlink.io)
— gateway rejects others with 400. If omitted, gateway
captures a screenshot of the live preview asynchronously.
tags: Up to 5 short tags (≤20 chars each).
Returns:
{"ok": True, "listing": {...}, "url": "https://..."} on success
{"ok": False, "error": ...} on failure
"""
user_id = _user_id()
if not name:
name = slug
try:
status, body = gateway.listing_publish(
slug=slug,
owner_user_id=user_id,
name=name,
description=description,
cover_url=cover_url,
tags=tags,
is_public=True,
)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status == 404:
return {
"ok": False,
"error": (
f"No preview found for slug '{slug}'. "
f"Call publish_preview() first to allocate the URL, "
f"then list_in_dashboard() to make it discoverable."
),
}
if status == 403:
return {
"ok": False,
"error": f"You don't own slug '{slug}'.",
}
if status != 200:
return {
"ok": False,
"error": body.get("error", f"Gateway returned {status}"),
}
listing = body.get("listing", {})
return {
"ok": True,
"listing": listing,
"url": f"{_public_url_base()}/{slug}",
"dashboard_url": f"{_public_url_base()}/projects",
}
def unlist_from_dashboard(slug: str) -> dict[str, Any]:
"""Remove this preview from the Project Dashboard.
The preview URL keeps working — only the dashboard listing row
is deleted, along with view/favorite counts. To temporarily hide
instead, use list_in_dashboard with a separate is_public toggle
(currently always publishes; use the lower-level
gateway.listing_publish(is_public=False) if needed).
Args:
slug: Public slug to unlist.
Returns:
{"ok": True} on success
{"ok": False, "error": ...} on failure (404 if not listed)
"""
user_id = _user_id()
try:
status, body = gateway.listing_unlist(slug=slug, owner_user_id=user_id)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status == 404:
return {
"ok": False,
"error": f"Slug '{slug}' is not listed on the dashboard.",
}
if status != 200:
return {
"ok": False,
"error": body.get("error", f"Gateway returned {status}"),
}
return {"ok": True}
def get_listing_status(slug: str) -> dict[str, Any]:
"""Return current dashboard listing state for a slug.
Used to answer 'is this on the dashboard yet?' before deciding
whether to call list_in_dashboard() or unlist_from_dashboard().
Returns:
{"ok": True, "exists": True, "is_public": bool, "listing": {...}}
{"ok": True, "exists": False} — never published
{"ok": False, "error": ...} — gateway error
"""
try:
status, body = gateway.listing_get(slug=slug)
except Exception as e:
return {"ok": False, "error": f"Failed to reach gateway: {e}"}
if status == 404:
return {"ok": True, "exists": False}
if status != 200:
return {
"ok": False,
"error": body.get("error", f"Gateway returned {status}"),
}
# /by-slug endpoint hard-filters is_public=true (private rows return
# 404), so any 200 response means the listing is currently public.
# Private listings cannot be observed through this path — use
# gateway.listing_publish(is_public=False) to flip a public listing
# back to private if needed (no read-back support today).
project = body.get("project", {}) if isinstance(body, dict) else {}
return {
"ok": True,
"exists": True,
"is_public": True,
"listing": project,
}
"""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]:
"""POST /api/code-projects/publish.
req_body may include `commit_message` (free-form string). When present,
gateway uses it as the body of the GitHub commit; otherwise falls back
to an auto-generated template.
"""
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]:
"""GET /api/code-projects/list — flat catalog, query param name is 'user_id'.
Note: /api/code-projects/explore uses 'user' instead. Two endpoints,
two param names. Source of truth: scg/src/routes/code-projects.ts.
"""
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) -> tuple[int, dict]:
"""Fetch the current state of an open-sourced project.
Versioned snapshots are no longer addressable — git is the version
control, so the gateway always serves the latest committed state.
"""
return _request("GET", f"/api/code-projects/{user_id}/{slug}")
def link_listing(public_slug: str, code_user_id: str, code_slug: str,
version: str, github_url: str) -> tuple[int, dict]:
"""Manual escape hatch: directly wire a code project to a listing.
Normally not needed — cross-link happens automatically via the
publisher: { code_slug, public_slug } binding in project.yaml. Use this
only for repair scenarios (e.g. relinking after a manual rename).
"""
return _request("POST", "/api/code-projects/link-listing", {
"public_slug": public_slug,
"code_user_id": code_user_id,
"code_slug": code_slug,
"version": version,
"github_url": github_url,
})
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-publish-skill"})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read()
# ── Stage 1: Service URL Publish (preview registry on community gateway) ──
# These hit /api/register, /api/unregister, /api/list — the in-memory
# preview-slug ↔ machine ↔ port routing table on sc-community-gateway.
# Distinct from /api/code-projects/* which is GitHub-backed code archive.
def preview_register(slug: str, machine_id: str, port: int,
owner_user_id: str, title: str = "",
publisher_code_slug: str | None = None) -> tuple[int, dict]:
"""Register a preview slug → public URL mapping.
publisher_code_slug: optional binding to a code project this listing is
paired with. When set, gateway either links immediately (if code exists)
or records a pending entry consumed when the code is open-sourced.
"""
body: dict = {
"slug": slug,
"machine_id": machine_id,
"port": port,
"owner_user_id": owner_user_id,
"title": title,
}
if publisher_code_slug:
body["publisher"] = {"code_slug": publisher_code_slug}
return _request("POST", "/api/register", body, timeout=10)
def preview_unregister(slug: str, owner_user_id: str) -> tuple[int, dict]:
return _request("POST", "/api/unregister", {
"slug": slug,
"owner_user_id": owner_user_id,
}, timeout=10)
def preview_list(owner_user_id: str) -> tuple[int, dict]:
from urllib.parse import quote
return _request("GET", f"/api/list?owner_user_id={quote(owner_user_id)}", timeout=10)
# ─── Listing CRUD (preview-side dashboard visibility) ──────────────
# These talk to /api/projects-query/listing — the internal-key version
# of the JWT-protected /api/projects/listing routes used by the web
# frontend. Same DB row, same ownership checks; the gateway exposes
# both surfaces because clawd containers don't carry user JWTs.
#
# What this controls: whether a published preview is DISCOVERABLE on
# the public Project Dashboard. It does NOT control whether the URL
# is reachable — that lives on /api/register / /api/unregister
# (preview_register / preview_unregister above). The two are
# completely orthogonal:
#
# publish_preview() → URL works, others can visit if they know it
# list_in_dashboard()→ URL is browseable from the public gallery
#
# A preview can be in any combination: URL-only (default after
# publish_preview), URL + listed, URL + listed + open-sourced.
def listing_publish(
slug: str,
owner_user_id: str,
name: str,
description: str = "",
cover_url: str | None = None,
tags: list[str] | None = None,
is_public: bool = True,
) -> tuple[int, dict]:
"""Create or update a project listing on the public dashboard.
Defaults is_public=True: callers reach this function specifically
to put a preview on the dashboard, so the common path is publish.
Pass is_public=False to convert a public listing back to private
without deleting it (preserves view_count / favorite_count).
"""
body: dict = {
"slug": slug,
"owner_user_id": owner_user_id,
"name": name,
"is_public": is_public,
}
if description:
body["description"] = description
if cover_url:
body["cover_url"] = cover_url
if tags:
body["tags"] = tags
return _request("POST", "/api/projects-query/listing", body, timeout=15)
def listing_unlist(slug: str, owner_user_id: str) -> tuple[int, dict]:
"""Remove a listing from the public dashboard.
Preview URL keeps working — only the dashboard row is deleted,
along with view/favorite counts. To temporarily hide instead of
permanently remove, use listing_publish(..., is_public=False).
"""
from urllib.parse import quote
return _request(
"DELETE",
f"/api/projects-query/listing/{quote(slug)}?owner_user_id={quote(owner_user_id)}",
timeout=10,
)
def listing_get(slug: str) -> tuple[int, dict]:
"""Return current listing state — used to answer 'is this listed?'.
Reuses the existing /api/projects-query/by-slug/:slug endpoint
which is_public-agnostic (returns the row regardless of visibility).
"""
from urllib.parse import quote
return _request(
"GET",
f"/api/projects-query/by-slug/{quote(slug)}",
timeout=10,
)
"""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_service(project_dir: str, manifest: dict[str, Any]) -> dict[str, Any]:
"""Service = HTTP-listening project. Use preview(action='serve') to host it
in the container; pair with publish_preview() to expose at a public URL."""
port = manifest.get("port")
entry = manifest.get("entry") or "src/index.html"
is_static = entry.endswith(".html")
if is_static:
return {
"next_step": (
f"Service (static) ready. Host with:\n"
f" preview(action='serve', "
f"title={json.dumps(manifest.get('description', 'Service'))}, "
f"dir={json.dumps(project_dir + '/' + os.path.dirname(entry))})"
),
"type": "service", "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"Service ready. Host with:\n"
f" preview(action='serve', "
f"title={json.dumps(manifest.get('description', 'Service'))}, "
f"dir={json.dumps(project_dir)}, command={json.dumps(cmd)}, port={port})"
),
"type": "service", "port": port, "command": cmd, "is_static": False,
}
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, "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", "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 == "service" and not manifest.get("port"):
errors.append("manifest.port required for type=service (HTTP listening port)")
# 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
FAQ
Does publish_preview automatically list on the dashboard?
No. publish_preview only allocates the URL; list_in_dashboard is a separate deliberate call.
How do I check if others can discover my project?
Call get_listing_status(slug) because URL reachability does not imply dashboard visibility.
How do live demos link to open-source code?
Add a publisher block in project.yaml so the gateway cross-links URL and code slugs automatically.
Is Community Publish safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.