Merge github-registry: staleness warnings, registry migration, v0.6.0

This commit is contained in:
bernatsampera
2026-08-10 10:09:06 +02:00
27 changed files with 919 additions and 286 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
+5
View File
@@ -2,8 +2,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from .db import init_db
from .ratelimit import limiter
from .routes_admin import router as admin_router
from .routes_moderation import router as moderation_router
from .routes_public import router as public_router
@@ -16,6 +19,8 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="gcontext workflows API", lifespan=lifespan)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=[
+19
View File
@@ -0,0 +1,19 @@
"""Shared rate limiter. Lives in its own module so routes and main can both
import it without a circular import."""
from fastapi import Request
from slowapi import Limiter
def client_ip(request: Request) -> str:
# The API runs behind exactly one trusted reverse proxy (Coolify's
# Traefik), which appends the real client IP as the last entry of
# X-Forwarded-For. Earlier entries are client-supplied and spoofable,
# so key on the last one. Fall back to the direct peer for local runs.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.rsplit(",", 1)[-1].strip()
return request.client.host if request.client else "unknown"
limiter = Limiter(key_func=client_ip)
+1
View File
@@ -24,6 +24,7 @@ def _to_out(t: Template) -> AdminWorkflowOut:
submitted_at=t.submitted_at,
reviewed_at=t.reviewed_at,
file_count=len(t.files),
downloads=t.downloads,
)
+11 -3
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from .db import get_session
from .manifest import BundleError, parse_manifest, validate_files
from .ratelimit import limiter
from .models import APPROVED, PENDING, Template, TemplateFile
from .schemas import FileIn, ManifestOut, StatusOut, SubmitIn, SubmitOut, TemplateOut
@@ -22,7 +23,9 @@ def list_workflows(session: Session = Depends(get_session)):
@router.get("/{workflow_id}", response_model=TemplateOut)
def get_workflow(workflow_id: str, session: Session = Depends(get_session)):
def get_workflow(
workflow_id: str, request: Request, session: Session = Depends(get_session)
):
template = session.scalars(
select(Template)
.where(Template.id == workflow_id, Template.status == APPROVED)
@@ -30,6 +33,10 @@ def get_workflow(workflow_id: str, session: Session = Depends(get_session)):
).first()
if template is None:
raise HTTPException(status_code=404, detail="workflow not found")
# The landing's own page renders send X-Source: site and do not count.
if request.headers.get("x-source") != "site":
template.downloads += 1
session.commit()
return TemplateOut(
id=template.id,
name=template.name,
@@ -57,7 +64,8 @@ def workflow_status(workflow_id: str, session: Session = Depends(get_session)):
@router.post("", response_model=SubmitOut, status_code=201)
def submit_workflow(body: SubmitIn, session: Session = Depends(get_session)):
@limiter.limit("5/hour")
def submit_workflow(request: Request, body: SubmitIn, session: Session = Depends(get_session)):
files = [f.model_dump() for f in body.files]
try:
validate_files(files)
+1
View File
@@ -36,6 +36,7 @@ class AdminWorkflowOut(BaseModel):
submitted_at: datetime
reviewed_at: datetime | None
file_count: int
downloads: int
class StatusOut(BaseModel):
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
"pyyaml>=6.0",
"slowapi>=0.1.9",
]
[dependency-groups]
+2
View File
@@ -61,7 +61,9 @@ def client(postgres):
from app.db import engine
from app.main import app
from app.models import Base
from app.ratelimit import limiter
limiter.reset()
Base.metadata.drop_all(engine())
Base.metadata.create_all(engine())
with TestClient(app) as test_client:
+32
View File
@@ -157,6 +157,38 @@ def test_admin_list_all_statuses(client, admin):
assert by_id["alt-flow"]["status"] == "rejected"
def test_download_counter_increments_on_fetch(client, admin):
submit(client)
client.post("/api/moderation/workflows/demo-flow/approve", headers=admin)
client.get("/api/workflows/demo-flow")
client.get("/api/workflows/demo-flow")
listed = client.get("/api/admin/workflows", headers=admin).json()
assert listed[0]["downloads"] == 2
def test_download_counter_skips_site_fetches(client, admin):
submit(client)
client.post("/api/moderation/workflows/demo-flow/approve", headers=admin)
client.get("/api/workflows/demo-flow", headers={"X-Source": "site"})
listed = client.get("/api/admin/workflows", headers=admin).json()
assert listed[0]["downloads"] == 0
client.get("/api/workflows/demo-flow")
listed = client.get("/api/admin/workflows", headers=admin).json()
assert listed[0]["downloads"] == 1
def test_download_counter_ignores_missing_and_pending(client, admin):
submit(client)
# Pending: the fetch 404s and must not create a count once approved.
client.get("/api/workflows/demo-flow")
client.post("/api/moderation/workflows/demo-flow/approve", headers=admin)
listed = client.get("/api/admin/workflows", headers=admin).json()
assert listed[0]["downloads"] == 0
def test_admin_list_requires_token(client):
assert client.get("/api/admin/workflows").status_code == 401
bad = {"Authorization": "Bearer wrong"}
+39
View File
@@ -0,0 +1,39 @@
"""The submit endpoint is rate limited per client IP."""
from app.ratelimit import limiter
INDEX_MD = """---
id: ratelimit-test
name: Rate Limit Test
description: >
A workflow used by the rate limit test.
tags: [test]
---
# ratelimit-test
Body text.
"""
def _bundle():
return {
"files": [
{"path": "index.md", "content": INDEX_MD},
{"path": "steps/index.md", "content": "one line per step"},
{"path": "steps/1-do.md", "content": "do the thing"},
{"path": "commands/setup.md", "content": "the install interview"},
{"path": "runs/example/index.md", "content": "example run"},
]
}
def test_submit_rate_limited(client):
limiter.reset()
for i in range(5):
resp = client.post("/api/workflows", json=_bundle())
assert resp.status_code in (201, 422), (
f"request {i + 1} returned {resp.status_code}"
)
resp = client.post("/api/workflows", json=_bundle())
assert resp.status_code == 429
Generated
+115
View File
@@ -63,6 +63,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -87,6 +99,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyyaml" },
{ name = "slowapi" },
{ name = "sqlalchemy" },
{ name = "uvicorn" },
]
@@ -102,6 +115,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "uvicorn", specifier = ">=0.30" },
]
@@ -244,6 +258,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "limits"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -528,6 +556,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "slowapi"
version = "0.1.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "limits" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/52/24527cf25a8b508926aff53350b0136561dfe86c7125f61526653666e1b2/slowapi-0.1.10.tar.gz", hash = "sha256:d320d5bc04d9f171a77fb16700faf3036d85b00f420f22924c8a225f95bd14f9", size = 13841, upload-time = "2026-06-13T11:59:31.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
@@ -631,3 +671,78 @@ sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
]
[[package]]
name = "wrapt"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" },
{ url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" },
{ url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" },
{ url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" },
{ url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" },
{ url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" },
{ url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" },
{ url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" },
{ url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" },
{ url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" },
{ url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" },
{ url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" },
{ url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" },
{ url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" },
{ url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" },
{ url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" },
{ url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" },
{ url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" },
{ url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" },
{ url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" },
{ url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" },
{ url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" },
{ url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" },
{ url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" },
{ url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" },
{ url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" },
{ url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" },
{ url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" },
{ url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" },
{ url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" },
{ url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" },
{ url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" },
{ url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" },
{ url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" },
{ url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" },
{ url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" },
{ url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" },
]
+139
View File
@@ -0,0 +1,139 @@
# Connections
A connection gives the agent access to one service. It is one folder under
`connections/`, and it needs at most three things:
connections/github/
connection.yaml what the connection needs, by name
index.md how the API works in practice
scripts/ procedures that already worked
The agent normally writes all of this itself through the setup prompt. This
page is the reference for when you want to write or review one by hand.
## connection.yaml
The manifest declares what the connection needs. All fields:
```yaml
name: github # folder name, lowercase
description: GitHub REST API - repos, issues, pull requests
secrets: # secret NAMES only, never values
- GITHUB_TOKEN
deps: # Python packages the scripts import
- requests
```
`secrets` lists names. The values live in `secrets.env` at the folder root,
one `NAME=value` per line, gitignored. The server reads `secrets.env` live,
so adding a value needs no restart. When a script runs, the server injects
the values as environment variables and scrubs them from the output, so they
never enter the context window. `gcontext status` shows which declared
secrets have values.
`deps` are installed into the project's virtual environment on demand (via
uv) when a script needs them. Prefer plain HTTPS with `requests` over a service
SDK unless the SDK genuinely helps; one dependency that covers every
endpoint beats a heavy client library.
## index.md
The agent reads `index.md` before writing any script against the service.
Write what a fresh session needs to use the API, not marketing:
- what the service is used for in this agent
- base URL and auth style: which header, which token type
- the endpoints that matter for what this agent does
- gotchas learned in practice: rate limits, response shapes, error formats
Keep it current: when a script run teaches something (an endpoint quirk, a
pagination rule), record it in `index.md` right away. The file is the
connection's accumulated experience.
A complete example lives at
[examples/ops-agent/connections/stripe](../examples/ops-agent/connections/stripe):
a manifest, and an `index.md` with auth, gotchas, and patterns recorded from
real use.
## scripts/
Proven procedures. When a call works, save it as a script so the next
session runs it by path with `run_script` instead of rewriting it. The first
script of every connection should be the smoke test that proved it.
## Smoke test
Before trusting a new connection, verify it end to end with
`run_adhoc_script`:
1. Check the secret is injected: `os.environ.get("GITHUB_TOKEN")` is set.
Print present or missing, never the value.
2. Make one harmless authenticated call: whoami, list, or similar.
3. If it fails: check the value is in `secrets.env` (no restart needed),
then the header format, then the base URL.
4. When it works, save it under `scripts/` and note in `index.md` anything
the test taught you.
## Common auth shapes
Most APIs fit one of these:
```python
# Bearer token (GitHub, Linear, most SaaS APIs)
headers = {"Authorization": f"Bearer {os.environ['SERVICE_TOKEN']}"}
# API key header (Stripe-style: key as the user in basic auth, or a
# custom header like X-Api-Key)
headers = {"X-Api-Key": os.environ["SERVICE_API_KEY"]}
# DSN in one secret (databases)
conn = psycopg2.connect(os.environ["POSTGRES_DSN"])
```
When a service offers several auth models (personal token vs OAuth app,
cloud vs self-hosted), pick the simplest one that covers the agent's job;
that is almost always a personal token.
## Starter manifests
Copy, adjust, and add the secret value to `secrets.env`.
```yaml
# connections/github/connection.yaml
name: github
description: GitHub REST API - repos, issues, pull requests
secrets:
- GITHUB_TOKEN
deps:
- requests
```
```yaml
# connections/linear/connection.yaml
name: linear
description: Linear GraphQL API - issues, projects, cycles
secrets:
- LINEAR_API_KEY
deps:
- requests
```
```yaml
# connections/postgres/connection.yaml
name: postgres
description: Main Postgres database
secrets:
- POSTGRES_DSN
deps:
- psycopg2-binary
```
```yaml
# connections/<service>/connection.yaml - the generic shape
name: my-service
description: <what this service does for the agent>
secrets:
- MY_SERVICE_API_KEY
deps:
- requests
```
+4 -4
View File
@@ -42,13 +42,13 @@ This is the invariant that never changes: secrets never enter the context window
## One server, one URL
`gcontext up` serves the folder at one local HTTP URL. Every harness connects to that URL. There is no stdio mode.
`gcontext up` serves the folder at one local HTTP URL. Every client connects to that URL. There is no stdio mode.
The first MCP version used stdio, and a day of real use produced a catalog of failures with one root cause: stdio inverts the mental model. There is no "server running"; every harness silently spawns its own private copy from a long registration command. Commands paste-truncate silently and fail minutes later as a bare "not connected". "Is it connected?" has no answer without scanning config files across harnesses and scopes. People run the server by hand, see it "hang", and assume it is broken.
The first MCP version used stdio, and a day of real use produced a catalog of failures with one root cause: stdio inverts the mental model. There is no "server running"; every client silently spawns its own private copy from a long registration command. Commands paste-truncate silently and fail minutes later as a bare "not connected". "Is it connected?" has no answer without scanning config files across clients and scopes. People run the server by hand, see it "hang", and assume it is broken.
With one server at a URL: connecting is pasting a URL, which cannot half-truncate into something that almost works. Up or down is observable; Ctrl+C revokes access everywhere at once. Because all harnesses share the server, the server knows who is connected (the MCP handshake carries `clientInfo`, and `gcontext status` shows it). And local vs deployed becomes "local URL vs remote URL", the same shape.
With one server at a URL: connecting is pasting a URL, which cannot half-truncate into something that almost works. Up or down is observable; Ctrl+C revokes access everywhere at once. Because all clients share the server, the server knows who is connected (the MCP handshake carries `clientInfo`, and `gcontext status` shows it). And local vs deployed becomes "local URL vs remote URL", the same shape.
Rejected along the way: per-harness adapters that write each client's config (scope creep into files gcontext doesn't own), a machine-wide agent name registry (another layer of state; the URL already is the handle), and config-scanning diagnostics (detective work compensating for a transport that hides the truth).
Rejected along the way: per-client adapters that write each client's config (scope creep into files gcontext doesn't own), a machine-wide agent name registry (another layer of state; the URL already is the handle), and config-scanning diagnostics (detective work compensating for a transport that hides the truth).
The accepted tradeoff: something must be running.
+6 -12
View File
@@ -1,6 +1,6 @@
# share-workflow
Instructions for an AI agent that turns a private, lived workflow into a marketplace template. You are the agent; the human in the conversation is the author. The input is their workflow module, personal state included. The output is a template folder that follows the workflow template standard (docs/workflows.md) and contains zero personal data.
Instructions for an AI agent that turns a private, lived workflow into a distributable template. You are the agent; the human in the conversation is the author. The input is their workflow module, personal state included. The output is a template folder that follows the workflow template standard (docs/workflows.md) and contains zero personal data.
Work through the phases in order. Propose, let the author confirm, then act. Do not skip a phase.
@@ -19,7 +19,7 @@ Check the workflow against the five tests for shareable workflows and report the
4. **Lived first.** At least one real, completed run exists in `runs/`. The shapes must have been discovered by use, not designed on paper.
5. **Low trust barrier.** Few parameter slots, few secrets. Every credential a stranger must grant raises the install cost.
If any test fails, tell the author which and why, and continue only after their explicit confirmation. The marketplace is open; you warn, the author decides.
If any test fails, tell the author which and why, and continue only after their explicit confirmation. The registry is open; you warn, the author decides.
## Phase 2: extract the slots
@@ -38,7 +38,7 @@ Present the full classification as one list and get the author's confirmation be
Create the template folder next to the source module (for example `<workflow-id>-template/`). Build:
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. The `parameters` and `connections` fields are critical: the marketplace site reads them from the frontmatter and renders them as two separate sections on the workflow's page. Connections show the services the workflow talks to (mapped once at setup). Parameters show the values the user provides per run (scope, target, input). Every entry must have a clear, user-facing `description`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. The `parameters` and `connections` fields are critical: the directory page on gcontext.ai reads them from the frontmatter and renders them as two separate sections on the workflow's page. Connections show the services the workflow talks to (mapped once at setup). Parameters show the values the user provides per run (scope, target, input). Every entry must have a clear, user-facing `description`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
- **`steps/`**: the same files as the source, with the classified specifics replaced by parameter references and generic capability wording. Keep the structure untouched: the shapes were proven by use; you strip, you do not redesign. Every step file must state Purpose, Input, Output (with schema when tabular), How to execute, and Done when; if a source step lacks one of these, derive it from what the lived runs show and confirm with the author.
- **`functions/`**: same treatment, only if the source has it.
- **`commands/setup.md`**: generate it from the slots, following the setup contract in the spec: read index.md and steps/index.md first; bind every setup-time parameter; map each connection requirement to a real service in the user's environment; generate the personal state (list in the command exactly what it creates); smoke-test the critical path; never edit steps/. Give it command frontmatter (`description`, optional `parameters`) and a self-contained prose body that assumes only file access, so it works in gcontext as an MCP prompt and standalone in any agent.
@@ -49,7 +49,7 @@ Build `runs/example/` inside the template, in the exact runs/ shape: `index.md`
- Default: start from the author's most representative real run and replace every real value with a coherent fake: invented names, plausible numbers, same schemas, same story arc.
- Fallback: if the author's runs are too sensitive to anonymize confidently, fabricate the example fully from the step definitions. Say so to the author.
- Keep the fake data internally consistent: the same invented name must flow through all steps, so a site visitor can follow one item from parameters to done. This example is what the marketplace renders on the workflow's page; it is the template's sales pitch.
- Keep the fake data internally consistent: the same invented name must flow through all steps, so a site visitor can follow one item from parameters to done. This example is what the directory page renders on the workflow's page; it is the template's showcase.
## Phase 5: verify
@@ -61,14 +61,8 @@ Run three checks and show the results:
## Phase 6: hand off
The finished template is a local folder. Submission: the marketplace accepts templates through its API with a review step (submitted entries stay pending until approved). If the submission endpoint is not yet available, tell the author the template is ready and where it lives, and stop there.
The finished template is a local folder. Run `gcontext share <template-folder>` to validate it against the template standard. Then submit it by opening a pull request against [github.com/bleak-ai/workflows](https://github.com/bleak-ai/workflows), adding the folder at the repo root. The share command prints the exact steps.
Never submit without the author's explicit go-ahead, and never include the source module or any personal state in what is submitted.
When the template passes all checks, submit it with the CLI:
```
gcontext share <template-folder>
```
The command validates the template against the standard, bundles the files, and submits them to the marketplace API. The submission enters the review queue. Check its status with `gcontext share --status <workflow-id>`.
A maintainer reviews and merges the pull request. The directory on gcontext.ai renders from the registry.
+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
View File
@@ -86,7 +86,6 @@ async def api_modules(request: Request) -> JSONResponse:
result.append({
"name": mname,
"description": mod.description,
"version": mod.version,
"tags": mod.tags,
"files": state.module_files(root, mname),
})
+28
View File
@@ -121,6 +121,33 @@ def _index_warning(root: Path, target: Path, content: str, existed: bool) -> str
return ""
def _restart_note(root: Path, target: Path) -> str:
"""Note text for files that only load at server start, or '' otherwise.
agent.md is pushed in the MCP handshake and command files register as
prompts at startup; a write through this tool takes effect only after a
restart. Advisory only, same contract as _index_warning.
"""
parts = target.relative_to(root.resolve()).parts
if parts == ("agent.md",):
return (
" Note: agent.md is pushed at connect; this change reaches clients "
"only after a restart (stop the server, gcontext up, reconnect the client)."
)
if (
len(parts) == 4
and parts[0] in ("connections", "modules")
and parts[2] == "commands"
and target.suffix in (".md", ".py")
):
return (
" Note: commands are registered at server start; this command appears "
"(or updates) only after a restart (stop the server, gcontext up, "
"reconnect the client)."
)
return ""
DIFF_MAX_LINES = 200
@@ -163,6 +190,7 @@ def write_file(root: Path, path: str, content: str) -> str:
return (
line
+ _index_warning(root, target, content, existed)
+ _restart_note(root, target)
+ (_write_diff(path, before, content) if existed else "")
)
-15
View File
@@ -6,7 +6,6 @@ from pydantic import BaseModel
class ModuleManifest(BaseModel):
name: str
description: str
version: str = "0.1.0"
author: str = ""
tags: list[str] = []
@@ -16,17 +15,3 @@ class ConnectionManifest(BaseModel):
description: str = ""
secrets: list[str] = []
deps: list[str] = []
class FlowStep(BaseModel):
id: str
description: str = ""
needs: list[str] = []
produces: list[str] = []
instructions: str = ""
class FlowManifest(BaseModel):
name: str
description: str = ""
steps: list[FlowStep] = []
+22
View File
@@ -0,0 +1,22 @@
---
description: Load this agent's context and answer a question using its state
parameters:
- name: question
description: What you want to know or do (e.g. "what is the current storage capacity of coolify")
required: false
---
The user is asking this agent a question. Use the gcontext tools (read_file,
list_dir, grep) to find the answer in the agent's state folder.
The question: "$question"
## How to answer
1. Start by reading agent.md if you have not already.
2. Use list_dir on connections/ and modules/ to see what is available.
3. Search the relevant modules and connections for the answer using grep
and read_file.
4. Answer concisely based on what you find. If the state folder does not
contain enough information, say so and suggest what the user could add.
5. If the question is empty, introduce yourself: say what you are, what
modules and connections you have, and what you can help with.
+77 -6
View File
@@ -51,6 +51,61 @@ def _tool_doc(name: str) -> str:
# Live MCP sessions, keyed by session id: {"client": ..., "connected": ..., "last_seen": ...}
SESSIONS: dict[str, dict] = {}
# Two file classes load only at server start: agent.md (pushed in the MCP
# handshake) and command files (registered as prompts). No watchers, per the
# no-background-behavior design: a startup snapshot of their mtimes, compared
# lazily on tool calls, with one stderr line per class per server lifetime.
STARTUP_SNAPSHOT: dict = {"agent_md": None, "commands": {}}
_STALE = {"agent_md": False, "commands": False}
_STALE_WARNED = {"agent_md": False, "commands": False}
_STALE_CHECK_INTERVAL = 5.0
_last_stale_check = 0.0
def _mtime(path: Path) -> float | None:
try:
return path.stat().st_mtime
except OSError:
return None
def snapshot_startup_files():
"""Record the state of the start-time-loaded files. Call once, after
load_instructions() and register_commands() have run."""
STARTUP_SNAPSHOT["agent_md"] = _mtime(PROJECT_DIR / "agent.md")
STARTUP_SNAPSHOT["commands"] = {
str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR)
}
_STALE.update(agent_md=False, commands=False)
_STALE_WARNED.update(agent_md=False, commands=False)
def check_staleness(force: bool = False) -> dict:
"""Compare the current files against the startup snapshot.
Once a class is stale it stays stale until restart, so the comparison for
it stops. Throttled to one filesystem check per few seconds unless forced.
"""
global _last_stale_check
now = time.monotonic()
if not force and now - _last_stale_check < _STALE_CHECK_INTERVAL:
return dict(_STALE)
_last_stale_check = now
if not _STALE["agent_md"]:
_STALE["agent_md"] = _mtime(PROJECT_DIR / "agent.md") != STARTUP_SNAPSHOT["agent_md"]
if not _STALE["commands"]:
current = {str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR)}
_STALE["commands"] = current != STARTUP_SNAPSHOT["commands"]
if _STALE["agent_md"] and not _STALE_WARNED["agent_md"]:
_STALE_WARNED["agent_md"] = True
print(" ! agent.md changed since start; restart to push the new version "
"(stop, gcontext up, reconnect the client)", file=sys.stderr)
if _STALE["commands"] and not _STALE_WARNED["commands"]:
_STALE_WARNED["commands"] = True
print(" ! commands changed since start; restart to re-register them",
file=sys.stderr)
return dict(_STALE)
# Activity feed for the dashboard: in-memory ring buffer, gone on restart.
EVENTS: deque = deque(maxlen=300)
_EVENT_SEQ = itertools.count(1)
@@ -125,6 +180,7 @@ class ConnectionTracker(Middleware):
return await call_next(context)
async def on_call_tool(self, context, call_next):
check_staleness()
name = getattr(context.message, "name", "?")
arguments = getattr(context.message, "arguments", None) or {}
detail = _event_detail(name, arguments)
@@ -166,18 +222,32 @@ class ConnectionTracker(Middleware):
name=agent_name,
mime_type="text/markdown",
))
for name in state.discover_modules(PROJECT_DIR):
modules = state.discover_modules(PROJECT_DIR)
if modules:
result.append(Resource(
uri=f"agent://{agent_name}/modules/{name}",
name=f"modules / {name}",
uri=f"agent://{agent_name}/modules",
name="modules",
mime_type="text/markdown",
))
for name in state.load_connections(PROJECT_DIR):
for name in modules:
result.append(Resource(
uri=f"agent://{agent_name}/modules/{name}",
name=f"modules / {name}",
mime_type="text/markdown",
))
connections = state.load_connections(PROJECT_DIR)
if connections:
result.append(Resource(
uri=f"agent://{agent_name}/connections/{name}",
name=f"connections / {name}",
uri=f"agent://{agent_name}/connections",
name="connections",
mime_type="text/markdown",
))
for name in connections:
result.append(Resource(
uri=f"agent://{agent_name}/connections/{name}",
name=f"connections / {name}",
mime_type="text/markdown",
))
return result
async def on_read_resource(self, context, call_next):
@@ -210,6 +280,7 @@ async def status_route(request: Request) -> JSONResponse:
"name": config.get("name", PROJECT_DIR.name),
"project_dir": str(PROJECT_DIR.resolve()),
"sessions": list(SESSIONS.values()),
"stale": check_staleness(force=True),
})
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gcontext-ai"
version = "0.4.4"
version = "0.6.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
+20
View File
@@ -83,6 +83,26 @@ def test_api_tree_excludes_machine_and_secret_files(client):
assert not any(p.startswith(".venv") for p in paths)
def test_status_reports_staleness(client, project):
import os
server.snapshot_startup_files()
stale = client.get("/status").json()["stale"]
assert stale == {"agent_md": False, "commands": False}
agent_md = project / "agent.md"
os.utime(agent_md, (agent_md.stat().st_mtime + 10,) * 2)
stale = client.get("/status").json()["stale"]
assert stale["agent_md"] is True
assert stale["commands"] is False
cmd = project / "modules" / "notes" / "commands" / "report.md"
cmd.parent.mkdir(parents=True)
cmd.write_text("---\ndescription: d\n---\nbody\n")
stale = client.get("/status").json()["stale"]
assert stale["commands"] is True
def test_api_events_limit_since_and_ring_cap(client):
for i in range(350):
server.record_event("s", "tool", f"tool{i}")
+26
View File
@@ -98,6 +98,32 @@ def test_new_file_without_parent_index_does_not_warn(project):
assert "Warning" not in out
def test_agent_md_write_notes_restart(project):
out = server.write_file("agent.md", "# Agent\nbe useful\n")
assert "Note: agent.md is pushed at connect" in out
assert "restart" in out
assert "Warning" not in out # agent.md stays exempt from the index check
def test_command_write_notes_restart(project):
out = server.write_file(
"connections/gmail/commands/send.md", "---\ndescription: d\n---\nbody\n"
)
assert "Note: commands are registered at server start" in out
out = server.write_file(
"modules/notes/commands/report.py", "# ---\n# description: d\n# ---\n"
)
assert "Note: commands are registered at server start" in out
# Not a command file: wrong folder or wrong extension.
assert "Note:" not in server.write_file("modules/notes/scripts/report.py", "x")
assert "Note:" not in server.write_file("modules/notes/commands/notes.txt", "x")
def test_ordinary_write_has_no_restart_note(project):
out = server.write_file("modules/notes/index.md", "summary")
assert "Note:" not in out
def test_write_new_file_reports_size_and_lines(project):
out = server.write_file("modules/notes/note.md", "one\ntwo\n")
assert out.startswith("Created: modules/notes/note.md")
+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" },
-1
View File
@@ -24,7 +24,6 @@ function ModuleCard({ mod }) {
<div {...hp} style={{ ...cardBase, ...(h ? cardHover : null), padding: 15, display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap" }}>
<span style={{ fontFamily: mono, fontSize: 14.5, fontWeight: 600, color: C.ink }}>{mod.name}</span>
<Chip>v{mod.version}</Chip>
{(mod.tags || []).map((t) => <Chip key={t} tone="stat">{t}</Chip>)}
</div>
{mod.description && <p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: C.tMuted }}>{mod.description}</p>}