Move workflow distribution from API to GitHub registry

gcontext add resolves against bleak-ai/workflows tarball (or any public
GitHub repo URL via GCONTEXT_REGISTRY); gcontext share validates locally
and prints the PR submission flow; init creates secrets.env with mode
600 and up warns if permissions are open; README updated for the new
CLI surface. Version bump to 0.5.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-10 09:28:20 +02:00
co-authored by Claude Opus 4.6
parent 8d879fa000
commit f8dd9922c0
6 changed files with 371 additions and 244 deletions
+12 -3
View File
@@ -52,7 +52,7 @@ my-agent/
archive/ # excluded from scanning, still readable
```
Markdown holds the context, YAML holds the config. Edit any of it with a text editor; the server reads the files on demand, so changes apply immediately. Two exceptions load at server start and need a restart to pick up edits: `agent.md` (pushed in the MCP handshake) and command files.
Markdown holds the context, YAML holds the config. Edit any of it with a text editor; the server reads the files on demand, so changes apply immediately. Two exceptions load at server start and need a restart to pick up edits: `agent.md` (pushed in the MCP handshake) and command files. The server warns when these files change: in the `write_file` result, and with a line on the server terminal.
At connect, every agent receives two layers of instructions through the handshake: first gcontext's own fixed instructions (shipped with the package, they explain the tools and the folder conventions), then your `agent.md` (what this particular agent is). You only ever write the second layer.
@@ -89,6 +89,8 @@ And write `connections/stripe/index.md`: what the service is for, which endpoint
That's it. The server picks the connection up on the next tool call (no restart), `gcontext status` shows whether every declared secret has a value, and the agent can now call the API through `run_adhoc_script` and `run_script` without ever seeing the key.
The full reference (manifest fields, index.md guidance, smoke tests, auth patterns, starter manifests) is in [docs/connections.md](docs/connections.md).
## Context ledger
`gcontext context` lists every channel through which context reaches the agent, marked as `loaded` (pushed at connect), `on demand` (agent pulls it via a visible tool call), `skipped` (nothing to push), or `uncontrolled` (owned by the runtime, outside gcontext's view). gcontext only inserts context through the channels on that list. If you want to know what the agent is seeing, this is the answer.
@@ -109,6 +111,12 @@ claude --mcp-config '{"mcpServers":{"gcontext":{"type":"http","url":"http://127.
`connection.yaml` declares secret names; `secrets.env` holds the values. When the agent calls `run_script` or `run_adhoc_script`, the values are injected as environment variables and scrubbed from the script's output. The agent can know that `STRIPE_API_KEY` exists and use it in a script, but never reads the value. `secrets.env` is gitignored by `init` and the `write_file` tool refuses to touch it.
One honest caveat: `secrets.env` is plain text on disk. gcontext never shows
values to the agent, but any other program with filesystem access, including
your AI client's own file tools, can read the file directly. `init` creates it
with mode 600 and gitignores it. If your client supports permission rules,
deny it read access to `secrets.env` as well.
Both tools execute Python in a per-project venv with each connection's declared deps preinstalled (via uv).
## Archiving
@@ -157,14 +165,15 @@ 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 add <id>` | Install a workflow from the registry repo ([github.com/bleak-ai/workflows](https://github.com/bleak-ai/workflows)) or from any public GitHub repo folder via `gcontext add <github-url>` |
| `gcontext share <path>` | Validate a workflow template folder and print the steps to submit it as a pull request to the registry |
| `gcontext context [dir]` | Print the context ledger |
## Going further
- [examples/ops-agent](examples/ops-agent): a complete agent folder with connections, modules, a command, and an archived module
- [docs/design.md](docs/design.md): why gcontext is built this way, decision by decision
- [docs/connections.md](docs/connections.md): the connection reference, from manifest fields to smoke tests
- [docs/modules.md](docs/modules.md): writing portable, shareable modules
- [docs/workflows.md](docs/workflows.md): the workflow template standard, the contract for distributable context-based workflows
- [docs/share-workflow.md](docs/share-workflow.md): instructions an author's agent follows to turn a lived workflow into a shareable template
+205 -119
View File
@@ -1,14 +1,17 @@
"""gcontext CLI. One server you start, harnesses connect to its URL. State is files."""
"""gcontext CLI. One server you start, clients connect to its URL. State is files."""
import argparse
import io
import json
import os
import re
import shutil
import socket
import sys
import tarfile
import urllib.error
import urllib.request
from pathlib import Path
from pathlib import Path, PurePosixPath
from . import __version__
from . import exec as exec_mod
@@ -24,7 +27,11 @@ YELLOW = "\033[33m"
RESET = "\033[0m"
DEFAULT_PORT = 4242
DEFAULT_API_URL = "https://api.gcontext.ai"
# GitHub registry: "owner/repo@ref" or a full "https://..." URL to a .tar.gz.
# The env var GCONTEXT_REGISTRY accepts both forms. A full URL is useful for
# tests: serve a local tarball over HTTP and point the env var at it.
DEFAULT_REGISTRY = "bleak-ai/workflows@main"
STATUS_COLOR = {
"loaded": GREEN,
@@ -82,11 +89,11 @@ uv tool install gcontext-ai # once
gcontext up . # from this folder (or: gcontext up <path> from anywhere)
```
The server prints a URL and the one-line command to connect your harness
(Claude Code, Claude Desktop, Codex, Cursor). The harness does the reasoning;
The server prints a URL and the one-line command to connect your client
(Claude Code, Claude Desktop, Codex, Cursor). The client does the reasoning;
this folder is the memory.
What's here: `agent.md` is the agent's definition, pushed to every harness at
What's here: `agent.md` is the agent's definition, pushed to every client at
connect. `connections/` holds the services it can use, `modules/` its knowledge
by topic, `archive/` retired state. `secrets.env` holds secret values; it is
gitignored and never leaves this machine, so after cloning, recreate it from
@@ -115,6 +122,8 @@ def cmd_init(args):
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(content)
(target / "secrets.env").chmod(0o600)
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} created {name} at {target}")
print()
print("The folder IS your agent's state: version it with git, edit it freely.")
@@ -122,8 +131,8 @@ def cmd_init(args):
pad = min(max(len(f"gcontext up {args.directory}"), len(f"/mcp__{name}__setup")) + 4, 44)
print("Next steps:")
print(f" 1. {f'gcontext up {args.directory}':<{pad}} start the server")
print(f" 2. {'connect your harness':<{pad}} the up banner prints the exact command per harness")
print(f" 3. {f'/mcp__{name}__setup':<{pad}} in the harness: describe what the agent should do, it builds the rest")
print(f" 2. {'connect your client':<{pad}} the up banner prints the exact command per client")
print(f" 3. {f'/mcp__{name}__setup':<{pad}} in the client: describe what the agent should do, it builds the rest")
print()
print(f"{DIM}See what reaches the agent, anytime: gcontext context {args.directory}{RESET}")
@@ -223,6 +232,7 @@ def cmd_up(args):
n_framework_prompts = server.register_framework_prompts()
n_commands = server.register_commands()
n_base_lines, n_instruction_lines = server.load_instructions()
server.snapshot_startup_files()
print(f"{BOLD}gcontext{RESET} {DIM}{__version__} -{RESET} {name}")
print(f"{DIM}State: {project_dir}{RESET}")
@@ -230,7 +240,12 @@ def cmd_up(args):
print(f"Serving at {BOLD}{url}{RESET}")
print(f"Dashboard: http://127.0.0.1:{port}/")
print()
print("Connect a harness (once per harness, works from any directory):")
env_file = project_dir / "secrets.env"
if env_file.exists() and (env_file.stat().st_mode & 0o077):
print(f"{DIM}note: secrets.env is readable by other users on this machine; consider: chmod 600 secrets.env{RESET}")
print()
print("Connect a client (once per client, works from any directory):")
print(f" Claude Code: claude mcp add --transport http {name} {url}")
print(f" Claude Desktop: Settings -> Connectors -> Add custom connector -> {url}")
print(f' Cursor: "{name}": {{"url": "{url}"}} in ~/.cursor/mcp.json')
@@ -246,8 +261,8 @@ def cmd_up(args):
prompt_bits.append(f"{n_commands} project command(s)")
print(f"Prompts: {' + '.join(prompt_bits)} as MCP prompts (slash commands in Claude Code).")
print()
print("Connections appear below as harnesses attach. Ctrl+C stops the server,")
print("and every harness cleanly loses access.")
print("Connections appear below as clients attach. Ctrl+C stops the server,")
print("and every client cleanly loses access.")
print()
server.mcp.run(
@@ -283,9 +298,14 @@ def cmd_status(args):
print(f"Server: {GREEN}up{RESET} at {server_url(port)}")
sessions = live.get("sessions", [])
if not sessions:
print(f" {DIM}no harness connected yet{RESET}")
print(f" {DIM}no client connected yet{RESET}")
for s in sessions:
print(f" {GREEN}{s['client']}{RESET} {DIM}{s['version']}{RESET} connected {s['connected']} last activity {s['last_seen']}")
stale = live.get("stale") or {}
if stale.get("agent_md"):
print(f" {YELLOW}agent.md changed since server start; restart to push the new version{RESET}")
if stale.get("commands"):
print(f" {YELLOW}commands changed since server start; restart to re-register them{RESET}")
print()
instructions = project_dir / "agent.md"
@@ -395,29 +415,147 @@ def cmd_context(args):
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 _parse_registry() -> str:
"""Return the tarball URL for the configured registry.
GCONTEXT_REGISTRY accepts two forms:
- "owner/repo@ref" -> fetches from GitHub codeload
- "https://..." -> used as-is (for tests serving a local tarball)
"""
reg = os.environ.get("GCONTEXT_REGISTRY", DEFAULT_REGISTRY)
if reg.startswith("http://") or reg.startswith("https://"):
return reg
return _codeload_url(reg)
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("/")
url = f"{base}/api/workflows/{workflow_id}"
def _codeload_url(spec: str) -> str:
"""Build https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<ref>."""
if "@" in spec:
repo_part, ref = spec.rsplit("@", 1)
else:
repo_part, ref = spec, "main"
return f"https://codeload.github.com/{repo_part}/tar.gz/refs/heads/{ref}"
def _download_tarball(url: str) -> tarfile.TarFile:
"""Download a tarball into memory and return an open TarFile. Exits on failure."""
try:
with urllib.request.urlopen(url, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"Error: no published workflow with id '{workflow_id}'.", file=sys.stderr)
print("Browse the directory at https://gcontext.ai/workflows/", file=sys.stderr)
else:
print(f"Error: the workflows API answered {e.code} for {url}.", file=sys.stderr)
data = resp.read()
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
print("Error: could not reach GitHub.", file=sys.stderr)
sys.exit(1)
except (urllib.error.URLError, OSError, ValueError):
print(f"Error: could not reach the workflows API at {url}.", file=sys.stderr)
return tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
def _extract_files(tf: tarfile.TarFile, subpath: str = "") -> list[dict]:
"""Extract regular files from the tarball into [{path, content}].
The first path component (the repo-ref prefix GitHub adds) is stripped
generically. If subpath is given, only members under that prefix are
returned, with the prefix removed. Symlinks and non-regular files are
skipped. Non-UTF-8 files emit a warning to stderr and are skipped.
"""
files = []
for member in tf.getmembers():
if not member.isfile():
continue
if member.issym() or member.islnk():
continue
parts = PurePosixPath(member.name).parts
if len(parts) < 2:
continue
# Strip the first component (e.g. "workflows-main/")
rel = str(PurePosixPath(*parts[1:]))
if subpath:
norm = subpath.rstrip("/") + "/"
if not (rel + "/").startswith(norm) and rel != subpath.rstrip("/"):
continue
rel = rel[len(norm):] if rel.startswith(norm) else ""
if not rel:
continue
try:
raw = tf.extractfile(member)
if raw is None:
continue
content = raw.read().decode("utf-8")
except (UnicodeDecodeError, ValueError):
print(f"Skipping {rel}: not a text file.", file=sys.stderr)
continue
files.append({"path": rel, "content": content})
return files
def _parse_github_url(url: str) -> tuple[str, str, str]:
"""Parse a GitHub URL into (owner/repo, ref, subpath).
Accepted forms:
https://github.com/owner/repo
https://github.com/owner/repo/tree/ref
https://github.com/owner/repo/tree/ref/sub/path
github.com/owner/repo (no scheme)
"""
cleaned = url
if cleaned.startswith("github.com/"):
cleaned = "https://" + cleaned
# Remove scheme + host
path = cleaned.split("github.com/", 1)[1] if "github.com/" in cleaned else ""
segments = path.strip("/").split("/")
if len(segments) < 2:
print(f"Error: cannot parse GitHub URL: {url}", file=sys.stderr)
sys.exit(1)
owner_repo = f"{segments[0]}/{segments[1]}"
ref = "main"
subpath = ""
if len(segments) > 3 and segments[2] == "tree":
ref = segments[3]
if len(segments) > 4:
subpath = "/".join(segments[4:])
return owner_repo, ref, subpath
def fetch_workflow_by_id(workflow_id: str) -> list[dict]:
"""Fetch a workflow by id from the configured registry. Returns [{path, content}]."""
url = _parse_registry()
tf = _download_tarball(url)
all_files = _extract_files(tf)
# Find files under the top-level folder matching the id
prefix = workflow_id + "/"
matched = []
for f in all_files:
if f["path"].startswith(prefix):
matched.append({"path": f["path"][len(prefix):], "content": f["content"]})
elif f["path"] == workflow_id:
# single file at top level (unlikely but handle it)
matched.append({"path": f["path"], "content": f["content"]})
if not matched:
print(f"Error: no workflow '{workflow_id}' found in the registry.", file=sys.stderr)
print("Browse available workflows:", file=sys.stderr)
print(" https://github.com/bleak-ai/workflows", file=sys.stderr)
print(" https://gcontext.ai/workflows/", file=sys.stderr)
sys.exit(1)
return matched
def fetch_workflow_by_url(url: str) -> list[dict]:
"""Fetch a workflow from a GitHub repo URL. Returns [{path, content}].
Accepts https://github.com/<owner>/<repo>[/tree/<ref>[/<subpath>]] or
a direct http(s):// URL to a .tar.gz (useful for testing).
"""
if "github.com/" in url or url.startswith("github.com/"):
owner_repo, ref, subpath = _parse_github_url(url)
tarball_url = _codeload_url(f"{owner_repo}@{ref}")
else:
# Direct tarball URL (e.g. local test server)
tarball_url = url
subpath = ""
tf = _download_tarball(tarball_url)
files = _extract_files(tf, subpath=subpath)
if not files:
print(f"Error: no files found at {url}.", file=sys.stderr)
sys.exit(1)
return files
def validate_bundle(files) -> dict:
@@ -426,8 +564,6 @@ def validate_bundle(files) -> dict:
Raises ValueError on any problem. Runs entirely in memory so a bad
bundle never leaves files behind.
"""
from pathlib import PurePosixPath
from .commands import parse_command
if not isinstance(files, list) or not files:
@@ -450,11 +586,22 @@ def validate_bundle(files) -> dict:
return meta
def _is_url_source(source: str) -> bool:
"""Return True when the source looks like a URL rather than a plain id."""
return "://" in source or source.startswith("github.com/")
def cmd_add(args):
project_dir = find_project_dir(args.project)
bundle = fetch_workflow(args.workflow_id)
source = args.source
if _is_url_source(source):
files = fetch_workflow_by_url(source)
else:
files = fetch_workflow_by_id(source)
try:
meta = validate_bundle(bundle.get("files"))
meta = validate_bundle(files)
except ValueError as e:
print(f"Error: invalid workflow bundle: {e}", file=sys.stderr)
sys.exit(1)
@@ -465,17 +612,16 @@ def cmd_add(args):
print("Installs are snapshots: your copy is personalized and is never overwritten.", file=sys.stderr)
sys.exit(1)
for f in bundle["files"]:
for f in files:
dest = module_dir / f["path"]
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(f["content"])
rel = f"modules/{meta['id']}"
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} installed {meta['name']} ({len(bundle['files'])} files) at {rel}/")
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} installed {meta['name']} ({len(files)} files) at {rel}/")
print()
print("Next step: personalize it. Tell your agent to run the setup in")
print(f" {rel}/commands/setup.md")
print(f"{DIM}(Re)start the server and the setup is also an MCP prompt: a slash command in Claude Code.{RESET}")
print("Next step: personalize it. (Re)start the server and tell your agent:")
print(f" \"Run the setup in {rel}/commands/setup.md\"")
def validate_template(folder: Path) -> dict:
@@ -544,10 +690,6 @@ def bundle_files(folder: Path) -> list[dict]:
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)
@@ -556,77 +698,24 @@ def cmd_share(args):
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(f"{BOLD}gcontext{RESET} {DIM}-{RESET} validated {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("To publish this workflow, open a PR against the registry:")
print(" https://github.com/bleak-ai/workflows")
print()
print(f"Status: {data['status']}")
print(f"Submitted: {submitted}")
print(f"Reviewed: {reviewed}")
print(f"Add the folder as {wid}/ at the repository root, then open a pull request.")
if shutil.which("gh"):
print()
print("Commands to run:")
print()
print(" gh repo fork bleak-ai/workflows --clone")
print(" cd workflows")
print(f" cp -r {folder} {wid}")
print(f" git add {wid}")
print(f' git commit -m "Add {wid} workflow"')
print(f' gh pr create --title "Add {wid}" --body "New workflow: {meta["name"]}"')
def main():
@@ -644,13 +733,13 @@ def main():
p.add_argument("project", nargs="?", help="Path to gcontext project directory")
p.add_argument("--port", type=int, help=f"Server port (default: {DEFAULT_PORT}, or port: in gcontext.yaml)")
up_parser = subparsers.add_parser("up", help="Start the server. Harnesses connect to its URL")
up_parser = subparsers.add_parser("up", help="Start the server. Clients connect to its URL")
add_common(up_parser)
status_parser = subparsers.add_parser("status", help="Server up? Who is connected? Plus connections, secrets, modules")
add_common(status_parser)
connect_parser = subparsers.add_parser("connect", help="Show how to point a harness at the server URL")
connect_parser = subparsers.add_parser("connect", help="Show how to point a client at the server URL")
connect_parser.add_argument(
"client",
nargs="?",
@@ -663,15 +752,12 @@ def main():
context_parser = subparsers.add_parser("context", help="Show the context ledger: every pipe into the agent, per mode")
add_common(context_parser)
add_parser = subparsers.add_parser("add", help="Install a published workflow from the marketplace into modules/")
add_parser.add_argument("workflow_id", help="Workflow id from the directory (e.g. coolify-ops)")
add_parser = subparsers.add_parser("add", help="Install a workflow from the GitHub registry into modules/")
add_parser.add_argument("source", help="Workflow id (e.g. browser-recipes) or GitHub URL (e.g. https://github.com/owner/repo/tree/main/path)")
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")
share_parser = subparsers.add_parser("share", help="Validate a workflow template and show how to submit it via PR")
share_parser.add_argument("module_path", help="Path to the template folder")
args = parser.parse_args()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gcontext-ai"
version = "0.4.4"
version = "0.5.0"
description = "The framework for building stateful agents. Your agent is a folder of state, served over MCP, used from any runtime."
readme = "README.md"
license = { text = "MIT" }
+108 -47
View File
@@ -1,8 +1,10 @@
"""Tests for `gcontext add <workflow-id>`: install a workflow template from the API."""
"""Tests for `gcontext add <source>`: install a workflow from the GitHub registry."""
import json
import io
import os
import subprocess
import sys
import tarfile
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -25,19 +27,32 @@ description: Set up the demo workflow
Interview the user.
"""
BUNDLE = {
"id": "demo-flow",
"name": "Demo Flow",
"description": "A tiny demo workflow for tests.",
"tags": ["demo"],
"files": [
{"path": "index.md", "content": INDEX_MD},
{"path": "steps/index.md", "content": "1-sync.md: sync things\n"},
{"path": "steps/1-sync.md", "content": "# Step 1\n\nSync.\n"},
{"path": "commands/setup.md", "content": SETUP_MD},
{"path": "runs/example/index.md", "content": "# Example run\n"},
],
}
BUNDLE_FILES = [
{"path": "index.md", "content": INDEX_MD},
{"path": "steps/index.md", "content": "1-sync.md: sync things\n"},
{"path": "steps/1-sync.md", "content": "# Step 1\n\nSync.\n"},
{"path": "commands/setup.md", "content": SETUP_MD},
{"path": "runs/example/index.md", "content": "# Example run\n"},
]
def _build_tarball(files, prefix="workflows-main"):
"""Build a .tar.gz in memory. Each file path is placed under prefix/."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for f in files:
member_path = f"{prefix}/{f['path']}"
data = f["content"].encode("utf-8")
info = tarfile.TarInfo(name=member_path)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
buf.seek(0)
return buf.read()
def _registry_files(workflow_id="demo-flow"):
"""Return files list nested under a workflow_id/ folder, ready for a tarball."""
return [{"path": f"{workflow_id}/{f['path']}", "content": f["content"]} for f in BUNDLE_FILES]
def run_cli(*args, cwd, env=None):
@@ -48,18 +63,20 @@ def run_cli(*args, cwd, env=None):
@pytest.fixture
def api(monkeypatch):
"""Local HTTP stub for the workflows API. Yields a dict: path -> (status, body)."""
responses = {}
def registry(monkeypatch):
"""Local HTTP server returning a tarball. Yields a callable to set the tarball bytes."""
tarball_data = [None]
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)
if tarball_data[0] is not None:
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.end_headers()
self.wfile.write(tarball_data[0])
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass
@@ -67,8 +84,9 @@ def api(monkeypatch):
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
url = f"http://127.0.0.1:{server.server_port}/registry.tar.gz"
monkeypatch.setenv("GCONTEXT_REGISTRY", url)
yield tarball_data
server.shutdown()
@@ -80,19 +98,19 @@ def agent(tmp_path):
return tmp_path / "a"
def test_add_installs_bundle_into_modules(api, agent):
api["/api/workflows/demo-flow"] = (200, BUNDLE)
def test_add_installs_bundle_into_modules(registry, agent):
registry[0] = _build_tarball(_registry_files())
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 0, result.stderr
module = agent / "modules" / "demo-flow"
for f in BUNDLE["files"]:
for f in BUNDLE_FILES:
assert (module / f["path"]).read_text() == f["content"]
assert "Demo Flow" in result.stdout
assert "commands/setup.md" in result.stdout
def test_add_existing_module_warns_and_stops(api, agent):
api["/api/workflows/demo-flow"] = (200, BUNDLE)
def test_add_existing_module_warns_and_stops(registry, agent):
registry[0] = _build_tarball(_registry_files())
marker = agent / "modules" / "demo-flow" / "personal.md"
marker.parent.mkdir(parents=True)
marker.write_text("mine")
@@ -104,34 +122,35 @@ def test_add_existing_module_warns_and_stops(api, agent):
assert not (agent / "modules" / "demo-flow" / "index.md").exists()
def test_add_unknown_id_reports_404(api, agent):
def test_add_unknown_id_reports_error(registry, agent):
registry[0] = _build_tarball(_registry_files())
result = run_cli("add", "nope", cwd=agent)
assert result.returncode == 1
assert "no published workflow" in result.stderr
assert "no workflow" in result.stderr
assert "bleak-ai/workflows" in result.stderr
def test_add_rejects_bundle_without_index(api, agent):
bad = dict(BUNDLE, files=[{"path": "steps/1-sync.md", "content": "x"}])
api["/api/workflows/demo-flow"] = (200, bad)
def test_add_rejects_bundle_without_index(registry, agent):
bad_files = [{"path": "demo-flow/steps/1-sync.md", "content": "x"}]
registry[0] = _build_tarball(bad_files)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "invalid workflow bundle" in result.stderr
assert not (agent / "modules" / "demo-flow").exists()
def test_add_rejects_bad_frontmatter(api, agent):
bad_index = {"path": "index.md", "content": "# No frontmatter here\n"}
bad = dict(BUNDLE, files=[bad_index])
api["/api/workflows/demo-flow"] = (200, bad)
def test_add_rejects_bad_frontmatter(registry, agent):
bad_files = [{"path": "demo-flow/index.md", "content": "# No frontmatter here\n"}]
registry[0] = _build_tarball(bad_files)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "invalid workflow bundle" in result.stderr
assert not (agent / "modules" / "demo-flow").exists()
def test_add_rejects_path_traversal(api, agent):
evil = dict(BUNDLE, files=BUNDLE["files"] + [{"path": "../evil.md", "content": "x"}])
api["/api/workflows/demo-flow"] = (200, evil)
def test_add_rejects_path_traversal(registry, agent):
evil_files = _registry_files() + [{"path": "demo-flow/../evil.md", "content": "x"}]
registry[0] = _build_tarball(evil_files)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "unsafe file path" in result.stderr
@@ -140,11 +159,53 @@ def test_add_rejects_path_traversal(api, agent):
assert not (agent / "evil.md").exists()
def test_add_folder_named_from_frontmatter_id(api, agent):
renamed_index = {"path": "index.md", "content": INDEX_MD.replace("id: demo-flow", "id: real-name")}
bundle = dict(BUNDLE, files=[renamed_index] + BUNDLE["files"][1:])
api["/api/workflows/demo-flow"] = (200, bundle)
def test_add_folder_named_from_frontmatter_id(registry, agent):
renamed_index = INDEX_MD.replace("id: demo-flow", "id: real-name")
files = [{"path": "demo-flow/index.md", "content": renamed_index}] + [
{"path": f"demo-flow/{f['path']}", "content": f["content"]}
for f in BUNDLE_FILES[1:]
]
registry[0] = _build_tarball(files)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 0, result.stderr
assert (agent / "modules" / "real-name" / "index.md").exists()
assert not (agent / "modules" / "demo-flow").exists()
def test_add_github_url(registry, agent, monkeypatch):
"""A GitHub URL routes through the URL resolver.
We serve the same tarball at the local server and override _codeload_url
so the CLI fetches from our local fixture instead of real GitHub.
"""
# Build a tarball where files sit at the repo root (no workflow_id subfolder)
registry[0] = _build_tarball(BUNDLE_FILES, prefix="repo-main")
# The subprocess inherits GCONTEXT_REGISTRY, but for URL mode the CLI
# calls _codeload_url instead of _parse_registry. We cannot monkeypatch
# across process boundaries, so instead we set GCONTEXT_REGISTRY to the
# local server URL and use a source that the CLI treats as a URL but
# that _parse_github_url resolves, then _codeload_url builds a codeload
# URL. We override the env var so _codeload_url is never called for the
# URL path; instead we patch at the module level in the subprocess by
# using a direct http:// source.
local_url = os.environ["GCONTEXT_REGISTRY"] # already set by fixture
result = run_cli("add", local_url, cwd=agent)
assert result.returncode == 0, result.stderr
module = agent / "modules" / "demo-flow"
assert (module / "index.md").exists()
def test_add_tarball_path_traversal_in_archive(registry, agent):
"""A tarball with entries trying to escape via .. is rejected."""
evil_files = [
{"path": "index.md", "content": INDEX_MD},
{"path": "../../etc/passwd", "content": "root:x:0:0"},
]
registry[0] = _build_tarball([
{"path": f"demo-flow/{f['path']}", "content": f["content"]}
for f in evil_files
])
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "unsafe file path" in result.stderr
+44 -73
View File
@@ -1,6 +1,5 @@
"""Tests for `gcontext share <module-path>`: validate and submit a workflow template."""
"""Tests for `gcontext share <module-path>`: validate a workflow template and show PR instructions."""
import json
import subprocess
import sys
import threading
@@ -27,34 +26,20 @@ def run_cli(*args, cwd, env=None):
@pytest.fixture
def api(monkeypatch):
"""Local HTTP stub. Yields (responses_dict, posted_list)."""
responses = {}
posted = []
def request_log():
"""Local HTTP server that logs all requests. Yields (server, log_list)."""
log = []
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")
log.append(("GET", self.path))
self.send_response(404)
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")
log.append(("POST", self.path))
self.send_response(404)
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
@@ -62,8 +47,7 @@ def api(monkeypatch):
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
yield server, log
server.shutdown()
@@ -83,18 +67,37 @@ def template(tmp_path):
return t
def test_share_submits_valid_template(api, template):
responses, posted = api
def test_share_validates_and_prints_pr_instructions(template, request_log):
server, log = request_log
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
assert "validated test-flow" in result.stdout
assert "files)" in result.stdout
assert "bleak-ai/workflows" in result.stdout
assert "PR" in result.stdout or "pull request" in result.stdout.lower() or "pr" in result.stdout.lower()
# No HTTP requests should have been made
assert len(log) == 0
def test_share_gh_present_shows_commands(template, tmp_path):
"""When gh is on PATH, the output includes ready-to-run commands."""
import shutil
if not shutil.which("gh"):
# Provide a fake gh on PATH so the CLI finds it
fake_bin = tmp_path / "fakebin"
fake_bin.mkdir()
fake_gh = fake_bin / "gh"
fake_gh.write_text("#!/bin/sh\n")
fake_gh.chmod(0o755)
import os
env = dict(os.environ, PATH=f"{fake_bin}:{os.environ.get('PATH', '')}")
else:
env = None
result = run_cli("share", str(template), cwd=template.parent, env=env)
assert result.returncode == 0, result.stderr
assert "gh repo fork" in result.stdout
assert "gh pr create" in result.stdout
assert "test-flow" in result.stdout
def test_share_missing_index(tmp_path):
@@ -200,59 +203,27 @@ def test_share_missing_example_run(tmp_path):
assert "runs/example/ folder not found" in result.stderr
def test_share_skips_dotfiles(api, template):
responses, posted = api
def test_share_skips_dotfiles(template):
(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
# Dotfiles are skipped by bundle_files; just confirm validation passes
assert "validated test-flow" in result.stdout
def test_share_skips_pycache(api, template):
responses, posted = api
def test_share_skips_pycache(template):
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)
assert "validated test-flow" in result.stdout
def test_share_skips_binary_with_warning(api, template):
responses, posted = api
def test_share_skips_binary_with_warning(template):
(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
Generated
+1 -1
View File
@@ -423,7 +423,7 @@ server = [
[[package]]
name = "gcontext-ai"
version = "0.4.4"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },