Add gcontext share command and submission status endpoint

CLI command validates a local template folder against the workflow standard
(frontmatter, steps/, runs/example/), bundles files, and POSTs to the
marketplace API. --status mode queries GET /api/workflows/{id}/status.
16 new tests (suite 68 -> 84).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-09 18:16:12 +02:00
co-authored by Claude Opus 4.6
parent 3b3e7da2a3
commit 5fa3f6bffd
6 changed files with 464 additions and 4 deletions
+2
View File
@@ -157,6 +157,8 @@ Developing the dashboard itself needs node: `make web-dev` runs a Vite dev serve
| `gcontext up [dir]` | Serve the folder over MCP |
| `gcontext status [dir]` | Server state, connected clients, state overview |
| `gcontext connect [client]` | Connection steps for claude, desktop, codex, cursor |
| `gcontext add <id>` | Install a published workflow from the marketplace |
| `gcontext share <path>` | Submit a workflow template to the marketplace for review |
| `gcontext context [dir]` | Print the context ledger |
## Going further
+18 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session, selectinload
from .db import get_session
from .manifest import BundleError, parse_manifest, validate_files
from .models import APPROVED, PENDING, Template, TemplateFile
from .schemas import FileIn, ManifestOut, SubmitIn, SubmitOut, TemplateOut
from .schemas import FileIn, ManifestOut, StatusOut, SubmitIn, SubmitOut, TemplateOut
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@@ -39,6 +39,23 @@ def get_workflow(workflow_id: str, session: Session = Depends(get_session)):
)
@router.get("/{workflow_id}/status", response_model=StatusOut)
def workflow_status(workflow_id: str, session: Session = Depends(get_session)):
template = session.scalars(
select(Template)
.where(Template.id == workflow_id)
.order_by(Template.submitted_at.desc())
).first()
if template is None:
raise HTTPException(status_code=404, detail="workflow not found")
return StatusOut(
id=template.id,
status=template.status,
submitted_at=template.submitted_at,
reviewed_at=template.reviewed_at,
)
@router.post("", response_model=SubmitOut, status_code=201)
def submit_workflow(body: SubmitIn, session: Session = Depends(get_session)):
files = [f.model_dump() for f in body.files]
+7
View File
@@ -38,6 +38,13 @@ class AdminWorkflowOut(BaseModel):
file_count: int
class StatusOut(BaseModel):
id: str
status: str
submitted_at: datetime
reviewed_at: datetime | None
class AdminUpdateIn(BaseModel):
name: str | None = None
description: str | None = None
+11 -3
View File
@@ -27,8 +27,8 @@ Walk `index.md`, every file in `steps/`, and `functions/` if present. Collect ev
Classify each element as exactly one of:
- **(a) Parameter slot**: a value a new user supplies at setup or per run. Becomes a `parameters` entry in the manifest.
- **(b) Connection requirement**: a service capability the workflow needs. Becomes a structured `connections` entry (`kind` + `description`), described generically ("the hosting panel API", not "Coolify").
- **(a) Parameter slot**: a value that changes the scope or input of a single run. The test: "does this value appear in `0-parameters.*` and does it change what the run does?" If yes, it is a parameter. If the value configures which service to talk to, which queue to pull from, or how to authenticate, it belongs in the connection, not here. Becomes a `parameters` entry in the manifest.
- **(b) Connection requirement**: a service capability the workflow needs, including all configuration that identifies *which* instance or account to use (team, project, queue, environment). Becomes a structured `connections` entry (`kind` + `description`), described generically ("the hosting panel API", not "Coolify").
- **(c) Personal state**: files or content that must not ship (playbooks learned from the author's systems, configs, credentials references, logs). Excluded from the template; the setup command will regenerate the empty shapes.
- **(d) Generic rewrite**: a concrete-service mention inside a step that stays in the text but must be reworded to the capability kind.
@@ -38,7 +38,7 @@ Present the full classification as one list and get the author's confirmation be
Create the template folder next to the source module (for example `<workflow-id>-template/`). Build:
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. The `parameters` and `connections` fields are critical: the marketplace site reads them from the frontmatter and renders them as two separate sections on the workflow's page. Connections show the services the workflow talks to (mapped once at setup). Parameters show the values the user provides per run (scope, target, input). Every entry must have a clear, user-facing `description`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
- **`steps/`**: the same files as the source, with the classified specifics replaced by parameter references and generic capability wording. Keep the structure untouched: the shapes were proven by use; you strip, you do not redesign. Every step file must state Purpose, Input, Output (with schema when tabular), How to execute, and Done when; if a source step lacks one of these, derive it from what the lived runs show and confirm with the author.
- **`functions/`**: same treatment, only if the source has it.
- **`commands/setup.md`**: generate it from the slots, following the setup contract in the spec: read index.md and steps/index.md first; bind every setup-time parameter; map each connection requirement to a real service in the user's environment; generate the personal state (list in the command exactly what it creates); smoke-test the critical path; never edit steps/. Give it command frontmatter (`description`, optional `parameters`) and a self-contained prose body that assumes only file access, so it works in gcontext as an MCP prompt and standalone in any agent.
@@ -64,3 +64,11 @@ Run three checks and show the results:
The finished template is a local folder. Submission: the marketplace accepts templates through its API with a review step (submitted entries stay pending until approved). If the submission endpoint is not yet available, tell the author the template is ready and where it lives, and stop there.
Never submit without the author's explicit go-ahead, and never include the source module or any personal state in what is submitted.
When the template passes all checks, submit it with the CLI:
```
gcontext share <template-folder>
```
The command validates the template against the standard, bundles the files, and submits them to the marketplace API. The submission enters the review queue. Check its status with `gcontext share --status <workflow-id>`.
+168
View File
@@ -3,6 +3,7 @@
import argparse
import json
import os
import re
import socket
import sys
import urllib.error
@@ -391,6 +392,15 @@ def cmd_context(args):
print_ledger(project_dir)
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
def resolve_api_url(args) -> str:
if getattr(args, "api_url", None):
return args.api_url.rstrip("/")
return os.environ.get("GCONTEXT_API_URL", DEFAULT_API_URL).rstrip("/")
def fetch_workflow(workflow_id: str) -> dict:
"""Fetch one approved template bundle from the workflows API. Exits on failure."""
base = os.environ.get("GCONTEXT_API_URL", DEFAULT_API_URL).rstrip("/")
@@ -468,6 +478,157 @@ def cmd_add(args):
print(f"{DIM}(Re)start the server and the setup is also an MCP prompt: a slash command in Claude Code.{RESET}")
def validate_template(folder: Path) -> dict:
"""Validate a local template folder against the workflow standard.
Returns parsed frontmatter on success. Prints an error and exits on failure.
"""
from .commands import parse_command
index_path = folder / "index.md"
if not index_path.exists():
print(f"Error: {folder}/index.md not found.", file=sys.stderr)
sys.exit(1)
try:
meta, _ = parse_command(index_path.read_text(encoding="utf-8"))
except ValueError:
print("Error: index.md has no YAML frontmatter.", file=sys.stderr)
sys.exit(1)
for field in ("id", "name", "description"):
if not meta.get(field):
print(f"Error: index.md frontmatter is missing '{field}'.", file=sys.stderr)
sys.exit(1)
tags = meta.get("tags")
if not isinstance(tags, list) or len(tags) == 0:
print("Error: index.md frontmatter is missing 'tags' (at least one tag required).", file=sys.stderr)
sys.exit(1)
wid = meta["id"]
if not isinstance(wid, str) or not ID_RE.match(wid):
print("Error: id must be lowercase letters, digits, and hyphens.", file=sys.stderr)
sys.exit(1)
if not (folder / "steps").is_dir():
print("Error: steps/ folder not found.", file=sys.stderr)
sys.exit(1)
if not (folder / "runs" / "example").is_dir():
print("Error: runs/example/ folder not found.", file=sys.stderr)
sys.exit(1)
return meta
def bundle_files(folder: Path) -> list[dict]:
"""Walk a template folder and return [{path, content}] for all text files.
Skips dotfiles/dirs and __pycache__. Warns and skips non-UTF-8 files.
"""
files = []
for filepath in sorted(folder.rglob("*")):
if not filepath.is_file():
continue
rel_parts = filepath.relative_to(folder).parts
if any(p.startswith(".") or p.startswith("__") for p in rel_parts):
continue
try:
content = filepath.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
print(f"Skipping {filepath.relative_to(folder)}: not a text file.", file=sys.stderr)
continue
files.append({"path": str(filepath.relative_to(folder)), "content": content})
return files
def cmd_share(args):
if args.status:
_share_status(args)
return
folder = Path(args.module_path).resolve()
if not folder.is_dir():
print(f"Error: {args.module_path} is not a directory.", file=sys.stderr)
sys.exit(1)
meta = validate_template(folder)
files = bundle_files(folder)
wid = meta["id"]
base = resolve_api_url(args)
existing = False
try:
with urllib.request.urlopen(f"{base}/api/workflows/{wid}", timeout=10) as resp:
if resp.status == 200:
existing = True
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
pass
if existing and not args.yes:
print(f"Warning: '{wid}' is already published. A new submission replaces any")
print("pending entry and enters the review queue.")
answer = input("Continue? [y/N] ").strip()
if answer.lower() != "y":
print("Aborted.", file=sys.stderr)
sys.exit(1)
payload = json.dumps({"files": files}).encode("utf-8")
req = urllib.request.Request(
f"{base}/api/workflows",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
detail = json.loads(body).get("detail", body)
except (json.JSONDecodeError, AttributeError):
detail = body
print(f"Error: the API rejected the submission: {detail}", file=sys.stderr)
sys.exit(1)
except (urllib.error.URLError, OSError):
print(f"Error: could not reach the API at {base}.", file=sys.stderr)
sys.exit(1)
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} submitted {wid} ({len(files)} files)")
print()
print(f"Status: {result.get('status', 'pending')} (under review)")
print(f"Check status: gcontext share --status {wid}")
def _share_status(args):
wid = args.module_path
base = resolve_api_url(args)
url = f"{base}/api/workflows/{wid}/status"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"Error: no submission found for '{wid}'.", file=sys.stderr)
else:
print(f"Error: the API answered {e.code}.", file=sys.stderr)
sys.exit(1)
except (urllib.error.URLError, OSError):
print(f"Error: could not reach the API at {base}.", file=sys.stderr)
sys.exit(1)
submitted = data.get("submitted_at", "")[:16].replace("T", " ") + " UTC" if data.get("submitted_at") else "-"
reviewed = data.get("reviewed_at", "")[:16].replace("T", " ") + " UTC" if data.get("reviewed_at") else "-"
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {wid}")
print()
print(f"Status: {data['status']}")
print(f"Submitted: {submitted}")
print(f"Reviewed: {reviewed}")
def main():
parser = argparse.ArgumentParser(
prog="gcontext",
@@ -506,6 +667,12 @@ def main():
add_parser.add_argument("workflow_id", help="Workflow id from the directory (e.g. coolify-ops)")
add_parser.add_argument("project", nargs="?", help="Path to gcontext project directory")
share_parser = subparsers.add_parser("share", help="Submit a workflow template to the marketplace for review")
share_parser.add_argument("module_path", help="Path to the template folder (or workflow id when used with --status)")
share_parser.add_argument("--api-url", dest="api_url", help="Override the API base URL")
share_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation when the workflow already exists")
share_parser.add_argument("--status", action="store_true", help="Query submission status instead of submitting")
args = parser.parse_args()
commands = {
@@ -515,6 +682,7 @@ def main():
"connect": cmd_connect,
"context": cmd_context,
"add": cmd_add,
"share": cmd_share,
}
if args.command in commands:
commands[args.command](args)
+258
View File
@@ -0,0 +1,258 @@
"""Tests for `gcontext share <module-path>`: validate and submit a workflow template."""
import json
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
INDEX_MD = """---
id: test-flow
name: Test Flow
description: A test workflow.
tags: [test]
---
Objective paragraph.
"""
def run_cli(*args, cwd, env=None):
return subprocess.run(
[sys.executable, "-m", "gcontext.cli", *args],
capture_output=True, text=True, cwd=cwd, env=env,
)
@pytest.fixture
def api(monkeypatch):
"""Local HTTP stub. Yields (responses_dict, posted_list)."""
responses = {}
posted = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
status, body = responses.get(self.path, (404, {"detail": "not found"}))
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(length)) if length else {}
posted.append({"path": self.path, "body": data})
result = {
"id": "test-flow", "name": "Test Flow",
"description": "A test workflow.", "tags": ["test"],
"status": "pending",
}
payload = json.dumps(result).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
monkeypatch.setenv("GCONTEXT_API_URL", f"http://127.0.0.1:{server.server_port}")
yield responses, posted
server.shutdown()
@pytest.fixture
def template(tmp_path):
"""A valid template folder."""
t = tmp_path / "test-flow"
t.mkdir()
(t / "index.md").write_text(INDEX_MD)
steps = t / "steps"
steps.mkdir()
(steps / "index.md").write_text("1-do.md: do things\n")
(steps / "1-do.md").write_text("# Step 1\n")
example = t / "runs" / "example"
example.mkdir(parents=True)
(example / "index.md").write_text("# Example\n")
return t
def test_share_submits_valid_template(api, template):
responses, posted = api
result = run_cli("share", str(template), cwd=template.parent)
assert result.returncode == 0, result.stderr
assert "submitted test-flow" in result.stdout
assert "pending" in result.stdout
assert len(posted) == 1
assert posted[0]["path"] == "/api/workflows"
files = posted[0]["body"]["files"]
paths = [f["path"] for f in files]
assert "index.md" in paths
assert "steps/index.md" in paths
def test_share_missing_index(tmp_path):
folder = tmp_path / "empty"
folder.mkdir()
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "index.md not found" in result.stderr
def test_share_missing_frontmatter(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("# No frontmatter\n")
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "no YAML frontmatter" in result.stderr
def test_share_missing_id(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nname: X\ndescription: Y\ntags: [a]\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "missing 'id'" in result.stderr
def test_share_missing_name(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nid: x\ndescription: Y\ntags: [a]\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "missing 'name'" in result.stderr
def test_share_missing_description(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nid: x\nname: X\ntags: [a]\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "missing 'description'" in result.stderr
def test_share_missing_tags(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nid: x\nname: X\ndescription: Y\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "missing 'tags'" in result.stderr
def test_share_empty_tags(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nid: x\nname: X\ndescription: Y\ntags: []\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "at least one tag" in result.stderr
def test_share_bad_id_format(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text("---\nid: Bad_Id\nname: X\ndescription: Y\ntags: [a]\n---\n")
(folder / "steps").mkdir()
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "lowercase letters, digits, and hyphens" in result.stderr
def test_share_missing_steps(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text(INDEX_MD)
(folder / "runs" / "example").mkdir(parents=True)
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "steps/ folder not found" in result.stderr
def test_share_missing_example_run(tmp_path):
folder = tmp_path / "bad"
folder.mkdir()
(folder / "index.md").write_text(INDEX_MD)
(folder / "steps").mkdir()
result = run_cli("share", str(folder), cwd=tmp_path)
assert result.returncode == 1
assert "runs/example/ folder not found" in result.stderr
def test_share_skips_dotfiles(api, template):
responses, posted = api
(template / ".hidden").write_text("secret")
(template / ".git").mkdir()
(template / ".git" / "config").write_text("x")
result = run_cli("share", str(template), cwd=template.parent)
assert result.returncode == 0, result.stderr
files = posted[0]["body"]["files"]
paths = [f["path"] for f in files]
assert ".hidden" not in paths
assert ".git/config" not in paths
def test_share_skips_pycache(api, template):
responses, posted = api
cache = template / "__pycache__"
cache.mkdir()
(cache / "mod.pyc").write_bytes(b"\x00\x01")
result = run_cli("share", str(template), cwd=template.parent)
assert result.returncode == 0, result.stderr
files = posted[0]["body"]["files"]
paths = [f["path"] for f in files]
assert not any("__pycache__" in p for p in paths)
def test_share_skips_binary_with_warning(api, template):
responses, posted = api
(template / "image.bin").write_bytes(b"\x80\x81\x82\xff\xfe")
result = run_cli("share", str(template), cwd=template.parent)
assert result.returncode == 0, result.stderr
assert "Skipping image.bin" in result.stderr
files = posted[0]["body"]["files"]
paths = [f["path"] for f in files]
assert "image.bin" not in paths
def test_share_status_mode(api, tmp_path):
responses, _ = api
responses["/api/workflows/test-flow/status"] = (200, {
"id": "test-flow",
"status": "approved",
"submitted_at": "2026-08-09T12:00:00Z",
"reviewed_at": "2026-08-09T14:30:00Z",
})
result = run_cli("share", "--status", "test-flow", cwd=tmp_path)
assert result.returncode == 0, result.stderr
assert "approved" in result.stdout
assert "2026-08-09 12:00 UTC" in result.stdout
assert "2026-08-09 14:30 UTC" in result.stdout
def test_share_status_not_found(api, tmp_path):
_, _ = api
result = run_cli("share", "--status", "nope", cwd=tmp_path)
assert result.returncode == 1
assert "no submission found" in result.stderr