mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
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:
co-authored by
Claude Opus 4.6
parent
8d879fa000
commit
f8dd9922c0
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user