diff --git a/gcontext/cli.py b/gcontext/cli.py index e96fb7e..7611f43 100644 --- a/gcontext/cli.py +++ b/gcontext/cli.py @@ -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) diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..dcb13ab --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,150 @@ +"""Tests for `gcontext add `: 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()