gcontext 0.3.0: agent state in a folder, served at a URL

Local-first state manager for AI agents. One HTTP server (gcontext up),
any MCP runtime attaches by URL. Context ledger (nothing reaches the agent
invisibly), secrets isolation (names visible, values injected and scrubbed),
flows (multi-step work computed purely from files), archive convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-07-24 01:55:02 +02:00
co-authored by Claude Opus 4.7
commit 63a38fb08e
13 changed files with 3145 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
dist/
*.egg-info/
.pytest_cache/
# secret values never leave the machine
secrets.env
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bleak-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
# gcontext
**Your agent's state lives in a folder. It's served at a URL. Any runtime becomes your agent.**
Claude Code, Codex, Cursor, Claude Desktop: these are runtimes. They read, reason, and act, but they forget everything between sessions. gcontext is the part that persists: the context your agent has learned, the services it can operate, the secrets it can use, the multi-step work in progress. All of it in a plain directory you can version with git.
gcontext is not a runtime. It has no chat loop, no LLM client, no orchestration engine. It serves your agent's state over MCP, and any MCP client that attaches becomes your agent.
```bash
uv tool install gcontext-ai # or: uv tool install git+https://github.com/bleak-ai/gcontext
gcontext init my-agent # scaffold the state folder
gcontext up my-agent # serve it at http://127.0.0.1:4242/mcp
```
Connect a harness by pasting the URL, once, from any directory:
```bash
claude mcp add --transport http my-agent http://127.0.0.1:4242/mcp
```
The server prints every harness as it attaches. `Ctrl+C` and every harness cleanly loses access. That's the whole model: a server running, and harnesses that connect to it.
## What's in the folder
```
my-agent/
gcontext.yaml # identity: name, description, optional port
instructions.md # standing instructions for whatever runtime attaches
secrets.env # secret VALUES, gitignored, never leave your machine
connections/ # services the agent can operate
stripe/
connection.yaml # declares secret NAMEs and Python deps
index.md # how to use the API, patterns that work
modules/ # knowledge the agent accumulates
flows/ # multi-step work, tracked as files (see below)
archive/ # anything moved here is out of context, still readable
```
Markdown is the context. YAML is the config. The folder is the agent.
## The three ideas
### 1. Nothing reaches the agent invisibly
The **context ledger** enumerates every pipe that inserts context into the agent, each marked `loaded`, `on demand`, `skipped`, or `UNCONTROLLED` (runtime-owned). See it anytime:
```bash
gcontext context my-agent
```
If gcontext feeds something to the agent, it's on that list. No hidden injection, ever.
### 2. Secrets: names visible, values never
The agent sees secret NAMEs only. Values live in `secrets.env`, get injected as environment variables when a script runs (`run_script` tool, deps preinstalled via uv), and are scrubbed from all output. This never changes.
### 3. Flows: workflows as files, not engines
A flow declares which files each step needs and produces:
```yaml
steps:
- id: draft
needs: [flows/brief/brief.md]
produces: [flows/brief/draft.md]
instructions: Read the brief, write the draft.
```
Step status is computed purely from the filesystem, make-style: `blocked` (a need is missing), `ready` (needs exist, produces don't), `stale` (a need changed after the produces), `done`. A runtime completes a step by writing the declared files. There is no executor, no checkpointer, no stored run state: change an upstream file and downstream steps light up as stale. Progress is git-diffable because progress is files.
```bash
gcontext flows my-agent # the board, per step
```
Attached runtimes get the same board via the `flows()` tool, with step instructions surfacing only when a step is actionable.
## Commands
| Command | What it does |
|---|---|
| `gcontext init <dir>` | Scaffold a new agent state folder |
| `gcontext up [dir]` | Serve the folder over MCP at a local URL |
| `gcontext status [dir]` | Server up? Who is connected? State overview |
| `gcontext connect [client]` | Attach instructions for claude, desktop, codex, cursor |
| `gcontext context [dir]` | The context ledger |
| `gcontext flows [dir]` | The flow boards |
| `gcontext chat [dir]` | A dedicated, fully controlled claude session |
The tools an attached runtime gets: `overview`, `read_context`, `write_context`, `run_script`, `list_connections`, `flows`.
## Housekeeping without magic
When accumulated state starts polluting context, move folders into `archive/`:
```
mv my-agent/modules/old-onboarding my-agent/archive/modules/
```
Archived items are never scanned into overviews or counts, stay readable by path, and every summary reports that they exist. The folder move is the entire mechanism. gcontext never archives, deletes, or reorganizes anything by itself.
## Scope
Local-first, by design. The server binds `127.0.0.1` with no auth: everything on your machine, nothing exposed. A remote/deployed story (same shape, a URL with a token) is planned but deliberately not in this release.
## License
MIT
+30
View File
@@ -0,0 +1,30 @@
[project]
name = "gcontext-ai"
version = "0.3.0"
description = "Your agent's state in a folder, served at a URL. Any MCP runtime attaches and becomes your agent."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "bleak-ai" }]
requires-python = ">=3.11"
keywords = ["mcp", "agents", "ai", "state", "context"]
dependencies = [
"fastmcp>=2.0.0",
"pyyaml>=6.0",
]
[project.urls]
Homepage = "https://github.com/bleak-ai/gcontext"
Repository = "https://github.com/bleak-ai/gcontext"
[project.scripts]
gcontext = "gcontext.cli:main"
[dependency-groups]
dev = ["pytest>=8.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/gcontext"]
View File
+572
View File
@@ -0,0 +1,572 @@
"""gcontext CLI. One server you start, harnesses connect to its URL. State is files."""
import argparse
import json
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from . import flows as flows_mod
from . import server
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RESET = "\033[0m"
DEFAULT_PORT = 4242
STATUS_COLOR = {
"loaded": GREEN,
"on demand": DIM,
"skipped": DIM,
"uncontrolled": YELLOW,
}
def print_ledger(mode: str):
for i, pipe in enumerate(server.build_ledger(mode), 1):
color = STATUS_COLOR.get(pipe["status"], "")
label = f"{pipe['label']} ".ljust(36, ".")
status = pipe["status"].upper() if pipe["status"] == "uncontrolled" else pipe["status"]
print(f" {i}. [{pipe['id']}] {label} {color}{status}{RESET} {DIM}{pipe['detail']}{RESET}")
INIT_GCONTEXT_YAML = """\
name: {name}
description: Describe what this agent is for.
# port: 4242
"""
INIT_INSTRUCTIONS = """\
# Instructions
You are the agent for this gcontext project. Your state lives in this folder:
read it with read_context, keep it current with write_context.
- Call overview() first to see connections, modules, flows, and the context ledger.
- Call flows() to see multi-step work and what is actionable right now.
- Use run_script for anything that needs an API: secrets are injected as env
vars (you only ever see their names), deps are preinstalled.
- Record what you learn: update the relevant index.md or module so the next
session starts smarter than this one.
"""
INIT_SECRETS = """\
# Secret VALUES live here and never leave this machine (this file is gitignored).
# Each connection's connection.yaml declares which NAMEs it needs.
# EXAMPLE_API_KEY=...
"""
INIT_AGENT_GITIGNORE = """\
secrets.env
.venv/
"""
INIT_CONNECTION_YAML = """\
name: httpbin
description: Example connection with no secrets, for trying run_script.
secrets: []
deps:
- requests
"""
INIT_CONNECTION_INDEX = """\
# httpbin
A dummy connection to try run_script without needing any secret. Replace it
with a real service: declare secret NAMEs and deps in connection.yaml, put
values in secrets.env, and document usage patterns here.
```python
import requests
print(requests.get("https://httpbin.org/get").json()["url"])
```
"""
INIT_FLOW_YAML = """\
name: demo-brief
description: Demo flow. Capture a brief, draft from it, then finalize.
steps:
- id: capture
description: Capture what the user wants covered
produces:
- flows/demo-brief/brief.md
instructions: |
Ask the user what they want a short write-up about and save the answers
to flows/demo-brief/brief.md as a short markdown brief.
- id: draft
description: Draft the write-up from the brief
needs:
- flows/demo-brief/brief.md
produces:
- flows/demo-brief/draft.md
instructions: |
Read the brief and write a first draft to flows/demo-brief/draft.md.
- id: finalize
description: Polish the draft into the final version
needs:
- flows/demo-brief/brief.md
- flows/demo-brief/draft.md
produces:
- flows/demo-brief/final.md
instructions: |
Tighten the draft into flows/demo-brief/final.md. If the brief changed
since the draft, this step shows as stale: redo it from the current brief.
"""
def cmd_init(args):
target = Path(args.directory).resolve()
if target.exists() and any(target.iterdir()):
print(f"Error: {target} already exists and is not empty.", file=sys.stderr)
sys.exit(1)
name = target.name
files = {
"gcontext.yaml": INIT_GCONTEXT_YAML.format(name=name),
"instructions.md": INIT_INSTRUCTIONS,
"secrets.env": INIT_SECRETS,
".gitignore": INIT_AGENT_GITIGNORE,
"connections/httpbin/connection.yaml": INIT_CONNECTION_YAML,
"connections/httpbin/index.md": INIT_CONNECTION_INDEX,
"flows/demo-brief/flow.yaml": INIT_FLOW_YAML,
"modules/.gitkeep": "",
"archive/.gitkeep": "",
}
for rel, content in files.items():
f = target / rel
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(content)
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.")
print()
print("Next steps:")
print(f" 1. gcontext up {args.directory} start the server")
print(f" 2. gcontext connect claude attach a harness (or: desktop, codex, cursor)")
print(f" 3. gcontext chat {args.directory} or talk to a dedicated, fully controlled session")
print()
print(f"{DIM}See what reaches the agent, anytime: gcontext context {args.directory}{RESET}")
def find_project_dir(path: str | None) -> Path:
p = Path(path).resolve() if path else Path.cwd()
if (p / "gcontext.yaml").exists():
return p
print(f"Error: no gcontext.yaml found in {p}", file=sys.stderr)
print("Run from a gcontext project directory or pass the path as an argument.", file=sys.stderr)
sys.exit(1)
def resolve_port(args) -> int:
if getattr(args, "port", None):
return args.port
config = server._load_gcontext_yaml()
return int(config.get("port", DEFAULT_PORT))
def server_url(port: int) -> str:
return f"http://127.0.0.1:{port}/mcp"
def fetch_status(port: int) -> dict | None:
"""Query the running server. None means nothing is listening."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/status", timeout=2) as resp:
return json.loads(resp.read())
except (urllib.error.URLError, OSError, ValueError):
return None
def cmd_up(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
port = resolve_port(args)
url = server_url(port)
running = fetch_status(port)
if running is not None:
print(f"Error: something already listens on port {port}", file=sys.stderr)
print(f" ({running.get('name', 'unknown')} serving {running.get('project_dir', '?')})", file=sys.stderr)
sys.exit(1)
server.ensure_venv()
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}")
print(f"{DIM}State: {project_dir}{RESET}")
print()
print(f"Serving at {BOLD}{url}{RESET}")
print()
print("Connect a harness (once per harness, 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')
print(f' Codex: [mcp_servers.{name}] url = "{url}" in ~/.codex/config.toml')
print(" Details: gcontext connect")
print()
print("Connections appear below as harnesses attach. Ctrl+C stops the server,")
print("and every harness cleanly loses access.")
print()
server.mcp.run(
transport="http", host="127.0.0.1", port=port, path="/mcp",
show_banner=False, log_level="warning",
)
def cmd_status(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
connections = server._load_connections()
secrets = server._load_secrets_env()
modules = server._discover_modules()
port = resolve_port(args)
name = config.get("name", project_dir.name)
desc = config.get("description", "")
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}")
if desc:
print(f"{DIM}{desc}{RESET}")
print(f"{DIM}State: {project_dir}{RESET}")
print()
live = fetch_status(port)
if live is None:
print(f"Server: {YELLOW}not running{RESET} {DIM}(start it: gcontext up){RESET}")
elif live.get("project_dir") != str(project_dir.resolve()):
print(f"Server: {YELLOW}port {port} is serving a different project{RESET}")
print(f" {DIM}{live.get('name', '?')} at {live.get('project_dir', '?')}{RESET}")
else:
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}")
for s in sessions:
print(f" {GREEN}{s['client']}{RESET} {DIM}{s['version']}{RESET} connected {s['connected']} last activity {s['last_seen']}")
print()
instructions = project_dir / "instructions.md"
if instructions.exists():
lines = len(instructions.read_text().splitlines())
print(f"Instructions: instructions.md ({lines} lines)")
print()
print("Connections:")
if not connections:
print(f" {DIM}none defined{RESET}")
for cname, conn in connections.items():
missing = [s for s in conn.secrets if s not in secrets or not secrets[s]]
if missing:
print(f" {cname}: {YELLOW}missing {', '.join(missing)}{RESET}")
else:
filled = len(conn.secrets)
print(f" {cname}: {GREEN}ready{RESET} {DIM}({filled}/{filled} secrets){RESET}")
print()
if modules:
print("Modules:")
for mname, mod in modules.items():
suffix = f" {DIM}- {mod.description}{RESET}" if mod.description else ""
print(f" {mname}{suffix}")
print()
all_flows = flows_mod.load_flows(project_dir)
if all_flows:
print("Flows:")
for fname, flow in all_flows.items():
board = flows_mod.flow_board(project_dir, flow)
done = sum(1 for s in board if s["status"] == "done")
ready = [s["id"] for s in flows_mod.actionable(board)]
if ready:
print(f" {fname}: {done}/{len(board)} done, {GREEN}actionable: {', '.join(ready)}{RESET}")
else:
print(f" {fname}: {done}/{len(board)} done")
print(f" {DIM}details: gcontext flows{RESET}")
print()
archived_line = server._archived_line()
if archived_line:
print(f"{DIM}{archived_line}{RESET}")
print()
print(f"{DIM}No runtime included. Point any MCP client at the URL above.{RESET}")
def cmd_connect(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
port = resolve_port(args)
url = server_url(port)
live = fetch_status(port)
if live is None:
print(f"{YELLOW}Server not running.{RESET} Start it first, in this or another terminal:")
print()
print(f" gcontext up {project_dir}")
print()
client = args.client
if client == "claude":
print(f"{BOLD}Claude Code{RESET}")
print()
print("Run once, from the directory where you use claude (or add --scope user")
print("to make it available everywhere):")
print()
print(f" claude mcp add --transport http {name} {url}")
elif client == "desktop":
print(f"{BOLD}Claude Desktop{RESET}")
print()
print("Settings -> Connectors -> Add custom connector, then paste:")
print()
print(f" {url}")
elif client == "codex":
print(f"{BOLD}Codex{RESET}")
print()
print("Add to ~/.codex/config.toml:")
print()
print(f"[mcp_servers.{name}]")
print(f'url = "{url}"')
elif client == "cursor":
print(f"{BOLD}Cursor{RESET}")
print()
print("Add to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):")
print()
print(json.dumps({"mcpServers": {name: {"url": url}}}, indent=2))
else:
print(f"{BOLD}Any MCP client{RESET}")
print()
print("gcontext speaks MCP over streamable HTTP. Point your client at:")
print()
print(f" {url}")
print()
print("Context this client will receive:")
print_ledger("mcp")
print()
print(f"{DIM}Verify anytime with: gcontext status{RESET}")
def cmd_context(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}")
print(f"{DIM}Every pipe that inserts context into the agent, per mode.{RESET}")
print()
print(f"{BOLD}gcontext chat{RESET} {DIM}(dedicated claude, fully controlled){RESET}")
print_ledger("chat")
print()
print(f"{BOLD}MCP attach{RESET} {DIM}(any harness pointed at the URL, shared agent){RESET}")
print_ledger("mcp")
FLOW_STATUS_COLOR = {
"ready": GREEN,
"stale": YELLOW,
"blocked": DIM,
"done": DIM,
}
def cmd_flows(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
all_flows = flows_mod.load_flows(project_dir)
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}")
print(f"{DIM}Flow state is computed from files, nothing else tracks progress.{RESET}")
print()
if not all_flows:
print(f"{DIM}No flows defined in flows/*/flow.yaml{RESET}")
return
if args.flow:
if args.flow not in all_flows:
print(f"Error: no flow named {args.flow}. Available: {', '.join(all_flows)}", file=sys.stderr)
sys.exit(1)
all_flows = {args.flow: all_flows[args.flow]}
for flow in all_flows.values():
board = flows_mod.flow_board(project_dir, flow)
done = sum(1 for s in board if s["status"] == "done")
print(f"{BOLD}{flow.name}{RESET} {DIM}({done}/{len(board)} done){RESET}")
if flow.description:
print(f"{DIM}{flow.description}{RESET}")
for step in board:
color = FLOW_STATUS_COLOR.get(step["status"], "")
status = f"{color}{step['status']:<7}{RESET}"
print(f" {status} {step['id']}: {step['description']}")
if step["status"] == "blocked":
print(f" {DIM}waiting on: {', '.join(step['missing'])}{RESET}")
elif step["status"] == "ready":
print(f" {DIM}complete by writing: {', '.join(step['missing'])}{RESET}")
elif step["status"] == "stale":
print(f" {YELLOW}{', '.join(step['stale_needs'])} changed after the produces were written{RESET}")
print()
CHAT_TOOLS = ",".join(
f"mcp__gcontext__{t}"
for t in ["overview", "read_context", "write_context", "run_script", "list_connections", "flows"]
)
def wait_for_server(port: int, project_dir: Path, timeout: float = 15.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
live = fetch_status(port)
if live is not None and live.get("project_dir") == str(project_dir.resolve()):
return True
time.sleep(0.3)
return False
def cmd_chat(args):
project_dir = find_project_dir(args.project)
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
port = resolve_port(args)
url = server_url(port)
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}")
print()
print("Context loaded into this session:")
print_ledger("chat")
print()
own_server = None
live = fetch_status(port)
if live is not None and live.get("project_dir") != str(project_dir.resolve()):
print(f"Error: port {port} is serving a different project ({live.get('name', '?')})", file=sys.stderr)
sys.exit(1)
if live is None:
print(f"{DIM}Starting server at {url}...{RESET}")
own_server = subprocess.Popen(
[sys.executable, "-m", "gcontext.cli", "up", str(project_dir), "--port", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not wait_for_server(port, project_dir):
own_server.terminate()
print("Error: server did not come up.", file=sys.stderr)
sys.exit(1)
else:
print(f"{DIM}Using the already running server at {url}{RESET}")
mcp_config = {"mcpServers": {"gcontext": {"type": "http", "url": url}}}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, prefix="gcontext-mcp-"
) as f:
json.dump(mcp_config, f)
mcp_config_path = f.name
cmd = [
"claude",
"--mcp-config", mcp_config_path,
"--strict-mcp-config",
"--setting-sources", "",
"--allowedTools", CHAT_TOOLS,
]
instructions = project_dir / "instructions.md"
if instructions.exists():
cmd.extend(["--system-prompt", instructions.read_text()])
print(f"{DIM}Starting claude...{RESET}")
try:
subprocess.run(cmd, cwd=project_dir)
finally:
Path(mcp_config_path).unlink(missing_ok=True)
if own_server is not None:
own_server.terminate()
try:
own_server.wait(timeout=5)
except subprocess.TimeoutExpired:
own_server.kill()
print(f"{DIM}Stopped the session's server.{RESET}")
def main():
parser = argparse.ArgumentParser(
prog="gcontext",
description="Agent state in a folder, served at a URL. Bring your own runtime.",
)
subparsers = parser.add_subparsers(dest="command")
init_parser = subparsers.add_parser("init", help="Scaffold a new agent state folder")
init_parser.add_argument("directory", help="Directory to create (its name becomes the agent name)")
def add_common(p):
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")
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.add_argument(
"client",
nargs="?",
default="generic",
choices=["claude", "desktop", "codex", "cursor", "generic"],
help="Which MCP client to show instructions for",
)
add_common(connect_parser)
context_parser = subparsers.add_parser("context", help="Show the context ledger: every pipe into the agent, per mode")
add_common(context_parser)
flows_parser = subparsers.add_parser("flows", help="Show flow boards: step status computed from the files")
flows_parser.add_argument("--flow", help="Show a single flow by name")
add_common(flows_parser)
chat_parser = subparsers.add_parser("chat", help="Launch a dedicated claude session against this project")
add_common(chat_parser)
args = parser.parse_args()
commands = {
"init": cmd_init,
"up": cmd_up,
"status": cmd_status,
"connect": cmd_connect,
"context": cmd_context,
"flows": cmd_flows,
"chat": cmd_chat,
}
if args.command in commands:
commands[args.command](args)
else:
parser.print_help()
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
"""Flows: declarative information dependencies, computed from the filesystem.
A flow is data, not a program. Each step declares which files it needs and
which files it produces. Status is a pure function of the filesystem:
blocked some needed file does not exist yet
ready all needs exist, some produced file is missing
stale everything exists, but a need is newer than a produce (make semantics)
done all produces exist and are up to date
gcontext never executes a step. A runtime completes a step by writing the
declared produces (write_context, or any editor); status recomputes from the
files on the next read. There is no run state stored anywhere else.
"""
from pathlib import Path
import yaml
from .models import FlowManifest, FlowStep
def load_flows(project_dir: Path) -> dict[str, FlowManifest]:
"""Scan flows/ for subdirectories containing flow.yaml."""
flows_dir = project_dir / "flows"
if not flows_dir.is_dir():
return {}
result = {}
for item in sorted(flows_dir.iterdir()):
flow_file = item / "flow.yaml"
if not item.is_dir() or not flow_file.exists():
continue
data = yaml.safe_load(flow_file.read_text()) or {}
manifest = FlowManifest(**data)
result[manifest.name] = manifest
return result
def step_state(project_dir: Path, step: FlowStep) -> dict:
"""Compute a step's status purely from the files it declares."""
needs = [(p, project_dir / p) for p in step.needs]
produces = [(p, project_dir / p) for p in step.produces]
missing_needs = [p for p, f in needs if not f.is_file()]
if missing_needs:
return {"status": "blocked", "missing": missing_needs}
missing_produces = [p for p, f in produces if not f.is_file()]
if missing_produces:
return {"status": "ready", "missing": missing_produces}
if needs and produces:
oldest_produce = min(f.stat().st_mtime for _, f in produces)
stale_needs = [p for p, f in needs if f.stat().st_mtime > oldest_produce]
if stale_needs:
return {"status": "stale", "stale_needs": stale_needs}
return {"status": "done"}
def flow_board(project_dir: Path, flow: FlowManifest) -> list[dict]:
"""Every step of a flow with its computed state."""
board = []
for step in flow.steps:
state = step_state(project_dir, step)
board.append({
"id": step.id,
"description": step.description,
"needs": step.needs,
"produces": step.produces,
"instructions": step.instructions,
**state,
})
return board
def actionable(board: list[dict]) -> list[dict]:
return [s for s in board if s["status"] in ("ready", "stale")]
def render_flow(project_dir: Path, flow: FlowManifest, with_instructions: bool = True) -> list[str]:
"""Plain-text board for one flow. Instructions surface only for actionable steps."""
board = flow_board(project_dir, flow)
done = sum(1 for s in board if s["status"] == "done")
lines = [f"## {flow.name} ({done}/{len(board)} done)"]
if flow.description:
lines.append(flow.description)
lines.append("")
for step in board:
lines.append(f"- [{step['status']}] {step['id']}: {step['description']}")
if step["needs"]:
lines.append(f" needs: {', '.join(step['needs'])}")
if step["produces"]:
lines.append(f" produces: {', '.join(step['produces'])}")
if step["status"] == "blocked":
lines.append(f" waiting on: {', '.join(step['missing'])}")
if step["status"] == "stale":
lines.append(f" stale: {', '.join(step['stale_needs'])} changed after the produces were written")
ready = actionable(board)
if ready and with_instructions:
lines.append("")
lines.append("Actionable now:")
for step in ready:
lines.append(f"### {step['id']}")
if step["instructions"]:
lines.append(step["instructions"].rstrip())
missing = step.get("missing") or step["produces"]
lines.append(f"Complete it by writing: {', '.join(missing)}")
return lines
+32
View File
@@ -0,0 +1,32 @@
"""Schema definitions for gcontext manifests."""
from pydantic import BaseModel
class ModuleManifest(BaseModel):
name: str
description: str
version: str = "0.1.0"
author: str = ""
tags: list[str] = []
class ConnectionManifest(BaseModel):
name: str
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] = []
+525
View File
@@ -0,0 +1,525 @@
"""gcontext MCP server. Reads a project directory and exposes it to any MCP client."""
import os
import sys
from datetime import datetime
from pathlib import Path
import subprocess
import tempfile
import yaml
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from . import flows as flows_mod
from .models import ConnectionManifest, ModuleManifest
mcp = FastMCP("gcontext")
# Set by cli.py before the server starts
PROJECT_DIR: Path = Path(".")
# Live MCP sessions, keyed by session id: {"client": ..., "connected": ..., "last_seen": ...}
SESSIONS: dict[str, dict] = {}
def _session_id(context) -> str:
ctx = getattr(context, "fastmcp_context", None)
return getattr(ctx, "session_id", None) or "session"
class ConnectionTracker(Middleware):
"""Records who is connected, straight from the MCP initialize handshake."""
async def on_initialize(self, context, call_next):
params = getattr(context.message, "params", None) or context.message
info = getattr(params, "clientInfo", None)
client = getattr(info, "name", None) or "unknown client"
version = getattr(info, "version", "") or ""
now = datetime.now().isoformat(timespec="seconds")
SESSIONS[_session_id(context)] = {
"client": client,
"version": version,
"connected": now,
"last_seen": now,
}
print(f" + {client} {version} connected ({now})", file=sys.stderr)
return await call_next(context)
async def on_message(self, context, call_next):
session = SESSIONS.get(_session_id(context))
if session:
session["last_seen"] = datetime.now().isoformat(timespec="seconds")
return await call_next(context)
mcp.add_middleware(ConnectionTracker())
@mcp.custom_route("/status", methods=["GET"])
async def status_route(request: Request) -> JSONResponse:
config = _load_gcontext_yaml()
flow_summary = {}
for fname, flow in flows_mod.load_flows(PROJECT_DIR).items():
board = flows_mod.flow_board(PROJECT_DIR, flow)
flow_summary[fname] = {
"done": sum(1 for s in board if s["status"] == "done"),
"total": len(board),
"actionable": [s["id"] for s in flows_mod.actionable(board)],
}
return JSONResponse({
"name": config.get("name", PROJECT_DIR.name),
"project_dir": str(PROJECT_DIR.resolve()),
"sessions": list(SESSIONS.values()),
"flows": flow_summary,
})
def _load_gcontext_yaml() -> dict:
p = PROJECT_DIR / "gcontext.yaml"
if p.exists():
return yaml.safe_load(p.read_text()) or {}
return {}
def _load_connections() -> dict[str, ConnectionManifest]:
"""Scan connections/ for subdirectories containing connection.yaml."""
conns_dir = PROJECT_DIR / "connections"
if not conns_dir.is_dir():
return {}
result = {}
for item in sorted(conns_dir.iterdir()):
if not item.is_dir():
continue
conn_file = item / "connection.yaml"
if not conn_file.exists():
continue
data = yaml.safe_load(conn_file.read_text()) or {}
manifest = ConnectionManifest(**data)
result[manifest.name] = manifest
return result
def _connection_files(name: str) -> list[str]:
"""List non-yaml files in a connection folder."""
conn_dir = PROJECT_DIR / "connections" / name
if not conn_dir.is_dir():
return []
files = []
for f in sorted(conn_dir.rglob("*")):
if f.is_file() and f.name != "connection.yaml":
files.append(str(f.relative_to(PROJECT_DIR)))
return files
def _load_secrets_env() -> dict[str, str]:
env_file = PROJECT_DIR / "secrets.env"
if not env_file.exists():
return {}
pairs = {}
for line in env_file.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
pairs[key.strip()] = value.strip()
return pairs
def _archived() -> dict[str, list[str]]:
"""Names of archived items per category, from archive/{connections,modules,flows}/.
Anything under archive/ is never scanned into overview, the ledger counts,
or the flow boards. It stays readable by path via read_context. Archiving
is a plain folder move; there is no metadata and no automatic behavior.
"""
result = {}
for category in ("connections", "modules", "flows"):
d = PROJECT_DIR / "archive" / category
if d.is_dir():
items = [i.name for i in sorted(d.iterdir()) if i.is_dir()]
if items:
result[category] = items
return result
def _archived_line() -> str:
archived = _archived()
if not archived:
return ""
parts = [f"{len(items)} {cat}" for cat, items in archived.items()]
return f"archive/: {', '.join(parts)} (not scanned, readable by path)"
def _discover_modules() -> dict[str, ModuleManifest]:
"""Scan modules/ for folders with module.yaml."""
modules_dir = PROJECT_DIR / "modules"
if not modules_dir.is_dir():
return {}
result = {}
for item in sorted(modules_dir.iterdir()):
if not item.is_dir():
continue
manifest_file = item / "module.yaml"
if manifest_file.exists():
data = yaml.safe_load(manifest_file.read_text()) or {}
manifest = ModuleManifest(**data)
else:
manifest = ModuleManifest(name=item.name, description="")
result[manifest.name] = manifest
return result
def _module_files(name: str) -> list[str]:
"""List content files in a module folder."""
mod_dir = PROJECT_DIR / "modules" / name
if not mod_dir.is_dir():
return []
files = []
for f in sorted(mod_dir.rglob("*")):
if f.is_file() and f.name not in ("module.yaml", ".gitkeep"):
files.append(str(f.relative_to(PROJECT_DIR)))
return files
SCRIPT_TIMEOUT = 60
def _scrub_output(text: str, secrets: dict[str, str]) -> str:
for value in secrets.values():
if value and len(value) > 3:
text = text.replace(value, "***")
return text
def _venv_dir() -> Path:
return PROJECT_DIR.resolve() / ".venv"
def _venv_python() -> Path:
venv = _venv_dir()
if sys.platform == "win32":
return venv / "Scripts" / "python.exe"
return venv / "bin" / "python"
def _collect_deps() -> set[str]:
connections = _load_connections()
all_deps = set()
for conn in connections.values():
for dep in conn.deps:
all_deps.add(dep)
return all_deps
def ensure_venv() -> None:
"""Create the project venv if missing and sync connection deps into it."""
venv_dir = _venv_dir()
if not venv_dir.is_dir():
subprocess.run(
["uv", "venv", str(venv_dir), "--quiet"],
check=True,
cwd=str(PROJECT_DIR),
)
all_deps = _collect_deps()
if all_deps:
subprocess.run(
["uv", "pip", "install", "--quiet", "--python", str(_venv_python())]
+ sorted(all_deps),
check=True,
cwd=str(PROJECT_DIR),
)
def build_ledger(mode: str) -> list[dict]:
"""Every pipe that inserts context into the agent for a mode ('chat' or 'mcp').
Statuses: loaded (pushed at start), on demand (agent pulls, visible as a
tool call), skipped (closed by a launch flag), uncontrolled (runtime-owned).
"""
instructions = PROJECT_DIR / "instructions.md"
connections = _load_connections()
modules = _discover_modules()
n_files = sum(len(_connection_files(c)) for c in connections)
n_files += sum(len(_module_files(m)) for m in modules)
ledger = []
if mode == "chat":
if instructions.exists():
n = len(instructions.read_text().splitlines())
ledger.append({"id": "G0", "label": "instructions.md", "detail": f"system prompt ({n} lines)", "status": "loaded"})
else:
ledger.append({"id": "G0", "label": "instructions.md", "detail": "file missing, no system prompt", "status": "skipped"})
else:
ledger.append({"id": "G0", "label": "instructions.md", "detail": "not auto-loaded in MCP mode, read it via read_context", "status": "on demand"})
ledger.append({"id": "G1", "label": "tool descriptions", "detail": "6 gcontext tools, pushed at connect", "status": "loaded"})
ledger.append({"id": "G2", "label": "overview()", "detail": "project map, secret status", "status": "on demand"})
g3_detail = f"{n_files} files in connections/ + modules/"
if _archived():
g3_detail += "; archive/ not scanned, readable by path"
ledger.append({"id": "G3", "label": "read_context()", "detail": g3_detail, "status": "on demand"})
ledger.append({"id": "G4", "label": "list_connections()", "detail": f"{len(connections)} connection(s)", "status": "on demand"})
ledger.append({"id": "G5", "label": "run_script() output", "detail": "secret values scrubbed", "status": "on demand"})
all_flows = flows_mod.load_flows(PROJECT_DIR)
ledger.append({"id": "G6", "label": "flows()", "detail": f"{len(all_flows)} flow(s); step instructions surface only when the step is actionable", "status": "on demand"})
if mode == "chat":
ledger.append({"id": "R1", "label": "claude default system prompt", "detail": "replaced by --system-prompt", "status": "skipped"})
ledger.append({"id": "R2", "label": "~/.claude/CLAUDE.md + settings", "detail": "closed via --setting-sources ''", "status": "skipped"})
ledger.append({"id": "R3", "label": "other MCP servers", "detail": "closed via --strict-mcp-config", "status": "skipped"})
ledger.append({"id": "R4", "label": "claude tool harness", "detail": "runtime-owned", "status": "uncontrolled"})
else:
ledger.append({"id": "R1", "label": "runtime system prompt", "detail": "runtime-owned", "status": "uncontrolled"})
ledger.append({"id": "R2", "label": "user/project CLAUDE.md", "detail": "runtime-owned", "status": "uncontrolled"})
ledger.append({"id": "R3", "label": "other MCP servers, skills, memory", "detail": "runtime-owned", "status": "uncontrolled"})
return ledger
def render_ledger_plain(mode: str) -> list[str]:
lines = []
for i, pipe in enumerate(build_ledger(mode), 1):
label = f"{pipe['label']} ".ljust(36, ".")
lines.append(f"{i}. [{pipe['id']}] {label} {pipe['status']}: {pipe['detail']}")
return lines
@mcp.tool
def overview() -> str:
"""Show project info, all connections with their secret status, and all modules with descriptions."""
config = _load_gcontext_yaml()
connections = _load_connections()
secrets = _load_secrets_env()
modules = _discover_modules()
lines = []
name = config.get("name", PROJECT_DIR.name)
desc = config.get("description", "")
lines.append(f"# {name}")
if desc:
lines.append(desc)
lines.append("")
lines.append("## Context ledger")
lines.append("Everything that enters your context from this server, and how:")
lines.extend(render_ledger_plain("mcp"))
lines.append("")
instructions = PROJECT_DIR / "instructions.md"
if instructions.exists():
lines.append(f"System prompt: instructions.md ({len(instructions.read_text().splitlines())} lines)")
lines.append("")
lines.append("## Connections")
if not connections:
lines.append("No connections defined.")
for cname, conn in connections.items():
filled = sum(1 for s in conn.secrets if s in secrets and secrets[s])
total = len(conn.secrets)
status = "ready" if filled == total else f"missing {total - filled} secret(s)"
missing = [s for s in conn.secrets if s not in secrets or not secrets[s]]
lines.append(f"- **{cname}**: {status}")
if conn.description:
lines.append(f" {conn.description}")
if missing:
lines.append(f" Missing: {', '.join(missing)}")
if conn.deps:
lines.append(f" Deps: {', '.join(conn.deps)}")
for f in _connection_files(cname):
lines.append(f" - {f}")
lines.append("")
if modules:
lines.append("## Modules")
for mname, mod in modules.items():
tag_str = f" [{', '.join(mod.tags)}]" if mod.tags else ""
lines.append(f"- **{mname}** (v{mod.version}){tag_str}")
if mod.description:
lines.append(f" {mod.description}")
for f in _module_files(mname):
lines.append(f" - {f}")
lines.append("")
all_flows = flows_mod.load_flows(PROJECT_DIR)
if all_flows:
lines.append("## Flows")
for fname, flow in all_flows.items():
board = flows_mod.flow_board(PROJECT_DIR, flow)
done = sum(1 for s in board if s["status"] == "done")
ready = [s["id"] for s in flows_mod.actionable(board)]
ready_str = f", actionable: {', '.join(ready)}" if ready else ""
lines.append(f"- **{fname}**: {done}/{len(board)} done{ready_str}")
if flow.description:
lines.append(f" {flow.description}")
lines.append("Call flows() for step details and instructions.")
lines.append("")
archived = _archived()
if archived:
lines.append("## Archive")
for cat, items in archived.items():
lines.append(f"- archive/{cat}/: {', '.join(items)}")
lines.append("Archived items are never scanned or listed above; read them by path if needed.")
return "\n".join(lines).rstrip()
@mcp.tool
def read_context(path: str) -> str:
"""Read a file from the project. Use overview() first to see available files."""
target = (PROJECT_DIR / path).resolve()
if not target.is_relative_to(PROJECT_DIR.resolve()):
return f"Error: path {path} is outside the project directory."
if not target.exists():
return f"Error: {path} does not exist."
if not target.is_file():
return f"Error: {path} is not a file."
return target.read_text()
@mcp.tool
def write_context(path: str, content: str) -> str:
"""Write or update a file in the project. Creates parent directories if needed.
Use this to update connection context docs, create playbooks, write logs, etc.
Cannot write to secrets.env or connection.yaml files.
Args:
path: Relative path within the project (e.g. 'modules/support-workflow/playbooks/refund.md')
content: The full file content to write.
"""
target = (PROJECT_DIR / path).resolve()
if not target.is_relative_to(PROJECT_DIR.resolve()):
return f"Error: path {path} is outside the project directory."
if target.name == "secrets.env":
return "Error: cannot write to secrets.env through the agent."
if target.name == "connection.yaml":
return "Error: cannot write to connection.yaml through the agent."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"Written: {path} ({len(content)} bytes)"
@mcp.tool
def run_script(code: str) -> str:
"""Run a Python script in the project's .venv with secrets as env vars.
The .venv has all connection deps pre-installed.
Access secrets with os.environ["SECRET_NAME"].
Secret values are scrubbed from stdout/stderr before returning.
Args:
code: Python source code to execute.
"""
secrets = _load_secrets_env()
python = _venv_python()
if not python.exists():
ensure_venv()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=PROJECT_DIR
) as f:
f.write(code)
script_path = f.name
try:
env = os.environ.copy()
env.update(secrets)
result = subprocess.run(
[str(python), script_path],
capture_output=True,
text=True,
timeout=SCRIPT_TIMEOUT,
env=env,
cwd=str(PROJECT_DIR),
)
output_parts = []
if result.stdout.strip():
output_parts.append(result.stdout.strip())
if result.stderr.strip():
output_parts.append(f"[stderr]\n{result.stderr.strip()}")
if result.returncode != 0:
output_parts.append(f"[exit code: {result.returncode}]")
output = "\n".join(output_parts) if output_parts else "(no output)"
return _scrub_output(output, secrets)
except subprocess.TimeoutExpired:
return f"Error: script timed out after {SCRIPT_TIMEOUT} seconds."
finally:
Path(script_path).unlink(missing_ok=True)
@mcp.tool
def flows(name: str = "") -> str:
"""Show flows: declarative multi-step work whose state lives in files.
Each step declares which files it needs and which it produces. Status is
computed purely from the filesystem: blocked (a needed file is missing),
ready (needs exist, produces missing), stale (a need changed after the
produces were written), done. Instructions are shown only for actionable
(ready or stale) steps.
You complete a step by writing its declared produces with write_context.
Nothing else tracks progress; the files are the state.
Args:
name: Optional flow name to show just one flow.
"""
all_flows = flows_mod.load_flows(PROJECT_DIR)
if not all_flows:
return "No flows defined in flows/*/flow.yaml"
if name:
if name not in all_flows:
return f"Error: no flow named {name}. Available: {', '.join(all_flows)}"
all_flows = {name: all_flows[name]}
lines = []
for flow in all_flows.values():
lines.extend(flows_mod.render_flow(PROJECT_DIR, flow))
lines.append("")
return "\n".join(lines).rstrip()
@mcp.tool
def list_connections() -> str:
"""Show all connections with their secrets, deps, context files, and whether each secret has a value."""
connections = _load_connections()
secrets = _load_secrets_env()
if not connections:
return "No connections defined in connections/*/connection.yaml"
lines = []
for cname, conn in connections.items():
lines.append(f"## {cname}")
if conn.description:
lines.append(conn.description)
lines.append("")
lines.append("Secrets:")
for s in conn.secrets:
has_value = s in secrets and bool(secrets[s])
icon = "filled" if has_value else "MISSING"
lines.append(f" - {s}: {icon}")
if conn.deps:
lines.append(f"Deps: {', '.join(conn.deps)}")
context_files = _connection_files(cname)
if context_files:
lines.append("Context:")
for f in context_files:
lines.append(f" - {f}")
lines.append("")
return "\n".join(lines)
+71
View File
@@ -0,0 +1,71 @@
import os
import yaml
from gcontext.flows import flow_board, load_flows, step_state
from gcontext.models import FlowStep
FLOW = {
"name": "f",
"steps": [
{"id": "capture", "produces": ["brief.md"]},
{"id": "draft", "needs": ["brief.md"], "produces": ["draft.md"]},
],
}
def make_flow(project, data=FLOW):
d = project / "flows" / data["name"]
d.mkdir(parents=True)
(d / "flow.yaml").write_text(yaml.safe_dump(data))
def test_load_flows(tmp_path):
make_flow(tmp_path)
flows = load_flows(tmp_path)
assert list(flows) == ["f"]
assert [s.id for s in flows["f"].steps] == ["capture", "draft"]
def test_no_needs_is_ready(tmp_path):
state = step_state(tmp_path, FlowStep(id="s", produces=["out.md"]))
assert state["status"] == "ready"
assert state["missing"] == ["out.md"]
def test_blocked_then_ready_then_done(tmp_path):
step = FlowStep(id="s", needs=["brief.md"], produces=["draft.md"])
state = step_state(tmp_path, step)
assert state["status"] == "blocked"
assert state["missing"] == ["brief.md"]
(tmp_path / "brief.md").write_text("brief")
assert step_state(tmp_path, step)["status"] == "ready"
(tmp_path / "draft.md").write_text("draft")
assert step_state(tmp_path, step)["status"] == "done"
def test_stale_when_need_changes_after_produce(tmp_path):
step = FlowStep(id="s", needs=["brief.md"], produces=["draft.md"])
brief = tmp_path / "brief.md"
draft = tmp_path / "draft.md"
brief.write_text("brief")
draft.write_text("draft")
now = draft.stat().st_mtime
os.utime(brief, (now + 10, now + 10))
state = step_state(tmp_path, step)
assert state["status"] == "stale"
assert state["stale_needs"] == ["brief.md"]
def test_board_statuses(tmp_path):
make_flow(tmp_path)
flow = load_flows(tmp_path)["f"]
assert [s["status"] for s in flow_board(tmp_path, flow)] == ["ready", "blocked"]
(tmp_path / "brief.md").write_text("brief")
assert [s["status"] for s in flow_board(tmp_path, flow)] == ["done", "ready"]
+43
View File
@@ -0,0 +1,43 @@
import subprocess
import sys
def run_cli(*args, cwd):
return subprocess.run(
[sys.executable, "-m", "gcontext.cli", *args],
capture_output=True, text=True, cwd=cwd,
)
def test_init_scaffolds_agent(tmp_path):
result = run_cli("init", "my-agent", cwd=tmp_path)
assert result.returncode == 0, result.stderr
agent = tmp_path / "my-agent"
for rel in [
"gcontext.yaml",
"instructions.md",
"secrets.env",
".gitignore",
"connections/httpbin/connection.yaml",
"flows/demo-brief/flow.yaml",
]:
assert (agent / rel).is_file(), rel
assert "name: my-agent" in (agent / "gcontext.yaml").read_text()
assert "secrets.env" in (agent / ".gitignore").read_text()
def test_init_refuses_non_empty_dir(tmp_path):
(tmp_path / "taken").mkdir()
(tmp_path / "taken" / "x").write_text("x")
result = run_cli("init", "taken", cwd=tmp_path)
assert result.returncode == 1
assert "not empty" in result.stderr
def test_scaffolded_agent_works_with_cli(tmp_path):
run_cli("init", "a", cwd=tmp_path)
result = run_cli("flows", "a", cwd=tmp_path)
assert result.returncode == 0, result.stderr
assert "demo-brief" in result.stdout
assert "capture" in result.stdout
+69
View File
@@ -0,0 +1,69 @@
import pytest
from gcontext import server
@pytest.fixture
def project(tmp_path, monkeypatch):
(tmp_path / "gcontext.yaml").write_text("name: t\n")
monkeypatch.setattr(server, "PROJECT_DIR", tmp_path)
return tmp_path
def test_scrub_output():
secrets = {"API_KEY": "sk-verysecret", "SHORT": "ab"}
out = server._scrub_output("token sk-verysecret used, ab kept", secrets)
assert "sk-verysecret" not in out
assert "***" in out
assert "ab kept" in out # values of length <= 3 are not scrubbed
def test_read_context_blocks_traversal(project):
assert "outside the project" in server.read_context("../gcontext.yaml")
assert "outside the project" in server.read_context("/etc/hosts")
def test_write_context_blocks_traversal_and_protected_files(project):
assert "outside the project" in server.write_context("../x.md", "hi")
assert "Error" in server.write_context("secrets.env", "STOLEN=1")
assert "Error" in server.write_context("connections/a/connection.yaml", "nope")
def test_write_then_read_roundtrip(project):
server.write_context("modules/notes/index.md", "hello")
assert server.read_context("modules/notes/index.md") == "hello"
def test_archive_not_scanned_but_reported(project):
(project / "modules" / "active").mkdir(parents=True)
(project / "modules" / "active" / "index.md").write_text("x")
(project / "archive" / "modules" / "old").mkdir(parents=True)
(project / "archive" / "modules" / "old" / "index.md").write_text("x")
modules = server._discover_modules()
assert "active" in modules and "old" not in modules
assert server._archived() == {"modules": ["old"]}
overview = server.overview()
assert "## Archive" in overview
assert "old" in overview
def test_archive_readable_by_path(project):
(project / "archive").mkdir()
(project / "archive" / "note.md").write_text("kept")
assert server.read_context("archive/note.md") == "kept"
def test_flows_tool_and_ledger(project):
d = project / "flows" / "f"
d.mkdir(parents=True)
(d / "flow.yaml").write_text(
"name: f\nsteps:\n - id: s\n produces: [flows/f/out.md]\n instructions: write it\n"
)
out = server.flows()
assert "[ready] s" in out
assert "write it" in out # actionable steps expose instructions
g6 = [p for p in server.build_ledger("mcp") if p["id"] == "G6"]
assert g6 and "1 flow(s)" in g6[0]["detail"]
Generated
+1550
View File
File diff suppressed because it is too large Load Diff