From d7226fce03e46692df1eaf94d72954a63464df38 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:27:35 +0200 Subject: [PATCH 1/7] Add restart staleness warnings and resource listing improvements write_file notes when agent.md or command files change; lazy stderr warning (once per class per lifetime); /status reports stale fields; resource listing adds parent entries for modules/ and connections/. Co-Authored-By: Claude Opus 4.6 (1M context) --- gcontext/fs.py | 28 ++++++++++++++ gcontext/server.py | 83 ++++++++++++++++++++++++++++++++++++++--- tests/test_dashboard.py | 20 ++++++++++ tests/test_server.py | 26 +++++++++++++ 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/gcontext/fs.py b/gcontext/fs.py index 27b80d9..80e59ff 100644 --- a/gcontext/fs.py +++ b/gcontext/fs.py @@ -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 "") ) diff --git a/gcontext/server.py b/gcontext/server.py index b8cac6c..b778eeb 100644 --- a/gcontext/server.py +++ b/gcontext/server.py @@ -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), }) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3235a00..70c46da 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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}") diff --git a/tests/test_server.py b/tests/test_server.py index 4c21e35..e4243af 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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") From a01fcef941f3f963a2a89bc95786d74690cefe2e Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:27:50 +0200 Subject: [PATCH 2/7] Remove dead model code: FlowManifest, FlowStep, module version field Co-Authored-By: Claude Opus 4.6 (1M context) --- gcontext/dashboard.py | 1 - gcontext/models.py | 15 --------------- web/src/Modules.jsx | 1 - 3 files changed, 17 deletions(-) diff --git a/gcontext/dashboard.py b/gcontext/dashboard.py index 0117cfb..9359695 100644 --- a/gcontext/dashboard.py +++ b/gcontext/dashboard.py @@ -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), }) diff --git a/gcontext/models.py b/gcontext/models.py index 07be592..055c9c6 100644 --- a/gcontext/models.py +++ b/gcontext/models.py @@ -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] = [] diff --git a/web/src/Modules.jsx b/web/src/Modules.jsx index 4adad41..71186aa 100644 --- a/web/src/Modules.jsx +++ b/web/src/Modules.jsx @@ -24,7 +24,6 @@ function ModuleCard({ mod }) {
{mod.name} - v{mod.version} {(mod.tags || []).map((t) => {t})}
{mod.description &&

{mod.description}

} From 893ff9f796490e26a101010c4da76cfb53d6183f Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:27:54 +0200 Subject: [PATCH 3/7] Rename "harness" to "client" in docs, update share-workflow for registry Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design.md | 8 ++++---- docs/share-workflow.md | 18 ++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/design.md b/docs/design.md index 8892473..d39ca69 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/docs/share-workflow.md b/docs/share-workflow.md index 4243fc9..0ecbae2 100644 --- a/docs/share-workflow.md +++ b/docs/share-workflow.md @@ -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 `-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 ` 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 -``` - -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 `. +A maintainer reviews and merges the pull request. The directory on gcontext.ai renders from the registry. From 0793f441b6bde7a4bfb48f58eded799da338566f Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:28:03 +0200 Subject: [PATCH 4/7] Add ask prompt and connections reference doc Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/connections.md | 139 ++++++++++++++++++++++++++++++++++++++++ gcontext/prompts/ask.md | 22 +++++++ 2 files changed, 161 insertions(+) create mode 100644 docs/connections.md create mode 100644 gcontext/prompts/ask.md diff --git a/docs/connections.md b/docs/connections.md new file mode 100644 index 0000000..cb34acd --- /dev/null +++ b/docs/connections.md @@ -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//connection.yaml - the generic shape +name: my-service +description: +secrets: + - MY_SERVICE_API_KEY +deps: + - requests +``` diff --git a/gcontext/prompts/ask.md b/gcontext/prompts/ask.md new file mode 100644 index 0000000..86bd5fb --- /dev/null +++ b/gcontext/prompts/ask.md @@ -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. From 8d879fa0008daa637cf85d734ea771941dc5e7f9 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:28:10 +0200 Subject: [PATCH 5/7] Add rate limiting to API submit endpoint and expose download count 5/hour/IP on submit, downloads field in admin output. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/main.py | 5 ++ api/app/ratelimit.py | 19 ++++++ api/app/routes_admin.py | 1 + api/app/routes_public.py | 14 ++++- api/app/schemas.py | 1 + api/pyproject.toml | 1 + api/tests/conftest.py | 2 + api/tests/test_api.py | 32 ++++++++++ api/tests/test_ratelimit.py | 39 ++++++++++++ api/uv.lock | 115 ++++++++++++++++++++++++++++++++++++ 10 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 api/app/ratelimit.py create mode 100644 api/tests/test_ratelimit.py diff --git a/api/app/main.py b/api/app/main.py index c9935b9..6a07225 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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=[ diff --git a/api/app/ratelimit.py b/api/app/ratelimit.py new file mode 100644 index 0000000..5950a9a --- /dev/null +++ b/api/app/ratelimit.py @@ -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) diff --git a/api/app/routes_admin.py b/api/app/routes_admin.py index 66f9fc5..b34ae27 100644 --- a/api/app/routes_admin.py +++ b/api/app/routes_admin.py @@ -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, ) diff --git a/api/app/routes_public.py b/api/app/routes_public.py index 4e8ec4d..538de6a 100644 --- a/api/app/routes_public.py +++ b/api/app/routes_public.py @@ -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) diff --git a/api/app/schemas.py b/api/app/schemas.py index 934c62a..fe26c81 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -36,6 +36,7 @@ class AdminWorkflowOut(BaseModel): submitted_at: datetime reviewed_at: datetime | None file_count: int + downloads: int class StatusOut(BaseModel): diff --git a/api/pyproject.toml b/api/pyproject.toml index dd5334f..077344a 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "sqlalchemy>=2.0", "psycopg[binary]>=3.2", "pyyaml>=6.0", + "slowapi>=0.1.9", ] [dependency-groups] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 3d0053e..ac882fc 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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: diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 7e8c2cb..413c349 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -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"} diff --git a/api/tests/test_ratelimit.py b/api/tests/test_ratelimit.py new file mode 100644 index 0000000..f7e61a3 --- /dev/null +++ b/api/tests/test_ratelimit.py @@ -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 diff --git a/api/uv.lock b/api/uv.lock index 5e8ba20..1d31ab3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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" }, +] From f8dd9922c0ed0c12503d0c773c89ea85079fab46 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:28:20 +0200 Subject: [PATCH 6/7] Move workflow distribution from API to GitHub registry gcontext add resolves against bleak-ai/workflows tarball (or any public GitHub repo URL via GCONTEXT_REGISTRY); gcontext share validates locally and prints the PR submission flow; init creates secrets.env with mode 600 and up warns if permissions are open; README updated for the new CLI surface. Version bump to 0.5.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 15 +- gcontext/cli.py | 324 ++++++++++++++++++++++++++++---------------- pyproject.toml | 2 +- tests/test_add.py | 155 ++++++++++++++------- tests/test_share.py | 117 ++++++---------- uv.lock | 2 +- 6 files changed, 371 insertions(+), 244 deletions(-) diff --git a/README.md b/README.md index d3eacfb..6c2b198 100644 --- a/README.md +++ b/README.md @@ -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 ` | Install a published workflow from the marketplace | -| `gcontext share ` | Submit a workflow template to the marketplace for review | +| `gcontext add ` | 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 ` | +| `gcontext share ` | 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 diff --git a/gcontext/cli.py b/gcontext/cli.py index 25ea1fd..940c2e2 100644 --- a/gcontext/cli.py +++ b/gcontext/cli.py @@ -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 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///tar.gz/refs/heads/.""" + 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//[/tree/[/]] 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() diff --git a/pyproject.toml b/pyproject.toml index c8e843d..e974e59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gcontext-ai" -version = "0.4.4" +version = "0.5.0" description = "The framework for building stateful agents. Your agent is a folder of state, served over MCP, used from any runtime." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_add.py b/tests/test_add.py index dcb13ab..ae0144e 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -1,8 +1,10 @@ -"""Tests for `gcontext add `: install a workflow template from the API.""" +"""Tests for `gcontext add `: 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 diff --git a/tests/test_share.py b/tests/test_share.py index 954a005..032350c 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -1,6 +1,5 @@ -"""Tests for `gcontext share `: validate and submit a workflow template.""" +"""Tests for `gcontext share `: 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 diff --git a/uv.lock b/uv.lock index 3aab463..bfea2cf 100644 --- a/uv.lock +++ b/uv.lock @@ -423,7 +423,7 @@ server = [ [[package]] name = "gcontext-ai" -version = "0.4.4" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, From 9216806ac49481aaccf1242f8cbae00f34886338 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Mon, 10 Aug 2026 09:46:30 +0200 Subject: [PATCH 7/7] Bump version to 0.6.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e974e59..034531b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gcontext-ai" -version = "0.5.0" +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" }