mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
Merge github-registry: staleness warnings, registry migration, v0.6.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,6 +36,7 @@ class AdminWorkflowOut(BaseModel):
|
||||
submitted_at: datetime
|
||||
reviewed_at: datetime | None
|
||||
file_count: int
|
||||
downloads: int
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0",
|
||||
"psycopg[binary]>=3.2",
|
||||
"pyyaml>=6.0",
|
||||
"slowapi>=0.1.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 "")
|
||||
)
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -423,7 +423,7 @@ server = [
|
||||
|
||||
[[package]]
|
||||
name = "gcontext-ai"
|
||||
version = "0.4.4"
|
||||
version = "0.5.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user