Add gcontext add <workflow-id> install command (marketplace task 4)

Fetches one approved template bundle from the workflows API and
installs it as a new module. Frontmatter id names the folder, unsafe
paths rejected before any write, existing modules are never
overwritten (installs are snapshots). API base overridable with the
GCONTEXT_API_URL env var; stdlib only. 7 tests against a local HTTP
stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-08 17:51:17 +02:00
co-authored by Claude Fable 5
parent ff9a6a256d
commit 7466c0dbfc
2 changed files with 234 additions and 0 deletions
+84
View File
@@ -2,6 +2,7 @@
import argparse
import json
import os
import socket
import sys
import urllib.error
@@ -22,6 +23,7 @@ YELLOW = "\033[33m"
RESET = "\033[0m"
DEFAULT_PORT = 4242
DEFAULT_API_URL = "https://api.gcontext.ai"
STATUS_COLOR = {
"loaded": GREEN,
@@ -389,6 +391,83 @@ def cmd_context(args):
print_ledger(project_dir)
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}"
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)
sys.exit(1)
except (urllib.error.URLError, OSError, ValueError):
print(f"Error: could not reach the workflows API at {url}.", file=sys.stderr)
sys.exit(1)
def validate_bundle(files) -> dict:
"""Check paths and the index.md manifest; return the parsed frontmatter.
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:
raise ValueError("the bundle has no files")
for f in files:
path = f.get("path") or ""
parts = PurePosixPath(path).parts
if not path or path.startswith("/") or "\\" in path or ".." in parts:
raise ValueError(f"unsafe file path in bundle: {path!r}")
index = next((f for f in files if f["path"] == "index.md"), None)
if index is None:
raise ValueError("the bundle has no index.md")
try:
meta, _ = parse_command(index["content"])
except ValueError as e:
raise ValueError(f"index.md frontmatter: {e}")
for field in ("id", "name", "description"):
if not meta.get(field):
raise ValueError(f"index.md frontmatter is missing '{field}'")
return meta
def cmd_add(args):
project_dir = find_project_dir(args.project)
bundle = fetch_workflow(args.workflow_id)
try:
meta = validate_bundle(bundle.get("files"))
except ValueError as e:
print(f"Error: invalid workflow bundle: {e}", file=sys.stderr)
sys.exit(1)
module_dir = project_dir / "modules" / meta["id"]
if module_dir.exists():
print(f"Error: module '{meta['id']}' already exists at {module_dir}.", file=sys.stderr)
print("Installs are snapshots: your copy is personalized and is never overwritten.", file=sys.stderr)
sys.exit(1)
for f in bundle["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()
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}")
def main():
parser = argparse.ArgumentParser(
prog="gcontext",
@@ -423,6 +502,10 @@ 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.add_argument("project", nargs="?", help="Path to gcontext project directory")
args = parser.parse_args()
commands = {
@@ -431,6 +514,7 @@ def main():
"status": cmd_status,
"connect": cmd_connect,
"context": cmd_context,
"add": cmd_add,
}
if args.command in commands:
commands[args.command](args)
+150
View File
@@ -0,0 +1,150 @@
"""Tests for `gcontext add <workflow-id>`: install a workflow template from the API."""
import json
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
INDEX_MD = """---
id: demo-flow
name: Demo Flow
description: A tiny demo workflow for tests.
tags: [demo]
---
Objective paragraph.
"""
SETUP_MD = """---
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"},
],
}
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 for the workflows API. Yields a dict: path -> (status, body)."""
responses = {}
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 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
server.shutdown()
@pytest.fixture
def agent(tmp_path):
"""A fresh scaffolded instance; returns its directory."""
result = run_cli("init", "a", cwd=tmp_path)
assert result.returncode == 0, result.stderr
return tmp_path / "a"
def test_add_installs_bundle_into_modules(api, agent):
api["/api/workflows/demo-flow"] = (200, BUNDLE)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 0, result.stderr
module = agent / "modules" / "demo-flow"
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)
marker = agent / "modules" / "demo-flow" / "personal.md"
marker.parent.mkdir(parents=True)
marker.write_text("mine")
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "already exists" in result.stderr
assert "never overwritten" in result.stderr
assert marker.read_text() == "mine"
assert not (agent / "modules" / "demo-flow" / "index.md").exists()
def test_add_unknown_id_reports_404(api, agent):
result = run_cli("add", "nope", cwd=agent)
assert result.returncode == 1
assert "no published workflow" 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)
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)
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)
result = run_cli("add", "demo-flow", cwd=agent)
assert result.returncode == 1
assert "unsafe file path" in result.stderr
assert not (agent / "modules" / "demo-flow").exists()
assert not (agent / "modules" / "evil.md").exists()
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)
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()