mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
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:
co-authored by
Claude Fable 5
parent
ff9a6a256d
commit
7466c0dbfc
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user