Auto-pick a free port and drop the httpbin scaffold connection

When neither --port nor gcontext.yaml pin a port and the default is taken,
up picks the next free one and persists it to gcontext.yaml so the URL stays
stable. init no longer ships a toy connection; the README and init output now
walk through adding a real first connection instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-07-26 16:05:07 +02:00
co-authored by Claude Opus 4.7
parent 38617cc4c4
commit 0fcabc2614
4 changed files with 133 additions and 31 deletions
+29
View File
@@ -49,6 +49,35 @@ Markdown holds the context, YAML holds the config. Edit any of it with a text ed
Connected clients get six tools: `overview`, `read_context`, `write_context`, `run_script`, `list_connections`, `flows`.
## Your first connection
`init` creates no connections: a connection is worth having when it points at a service you actually use. Adding one is three files, no command needed:
```bash
mkdir -p my-agent/connections/stripe
```
`connections/stripe/connection.yaml` declares what the connection needs, by name only:
```yaml
name: stripe
description: Payments, test mode.
secrets:
- STRIPE_API_KEY
deps:
- stripe
```
Put the value in `secrets.env` (gitignored, never leaves your machine):
```bash
echo 'STRIPE_API_KEY=sk_test_...' >> my-agent/secrets.env
```
And write `connections/stripe/index.md`: what the service is for, which endpoints matter, any usage patterns worth remembering. The agent reads this before writing scripts, and updates it as it learns.
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_script` without ever seeing the key.
## Context ledger
`gcontext context` lists every channel through which context reaches the agent, marked as `loaded` (pushed at start), `on demand` (agent pulls it via a visible tool call), `skipped` (closed by a launch flag), 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.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gcontext-ai"
version = "0.3.0"
version = "0.3.1"
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" }
+61 -29
View File
@@ -2,6 +2,7 @@
import argparse
import json
import socket
import subprocess
import sys
import tempfile
@@ -68,27 +69,6 @@ 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.
@@ -135,8 +115,7 @@ def cmd_init(args):
"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,
"connections/.gitkeep": "",
"flows/demo-brief/flow.yaml": INIT_FLOW_YAML,
"modules/.gitkeep": "",
"archive/.gitkeep": "",
@@ -155,6 +134,11 @@ def cmd_init(args):
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("Give the agent its first connection (any service with an API):")
print(f" {args.directory}/connections/<service>/connection.yaml secret NAMEs + Python deps")
print(f" {args.directory}/connections/<service>/index.md how to use the API, in your words")
print(f" {args.directory}/secrets.env secret VALUES, stays on this machine")
print()
print(f"{DIM}See what reaches the agent, anytime: gcontext context {args.directory}{RESET}")
@@ -178,6 +162,41 @@ def server_url(port: int) -> str:
return f"http://127.0.0.1:{port}/mcp"
def port_is_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
def find_free_port(start: int, attempts: int = 50) -> int:
for port in range(start, start + attempts):
if port_is_free(port):
return port
print(f"Error: no free port found in {start}-{start + attempts - 1}.", file=sys.stderr)
sys.exit(1)
def persist_port(project_dir: Path, port: int):
"""Write port: into gcontext.yaml, replacing an existing (or commented) port line."""
path = project_dir / "gcontext.yaml"
lines = path.read_text().splitlines() if path.exists() else []
out, replaced = [], False
for line in lines:
stripped = line.strip()
if not replaced and (stripped.startswith("port:") or stripped.startswith("# port:")):
out.append(f"port: {port}")
replaced = True
else:
out.append(line)
if not replaced:
out.append(f"port: {port}")
path.write_text("\n".join(out) + "\n")
def fetch_status(port: int) -> dict | None:
"""Query the running server. None means nothing is listening."""
try:
@@ -192,14 +211,27 @@ def cmd_up(args):
server.PROJECT_DIR = project_dir
config = server._load_gcontext_yaml()
name = config.get("name", project_dir.name)
configured = config.get("port")
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)
if not port_is_free(port):
running = fetch_status(port)
who = f" ({running.get('name', '?')} serving {running.get('project_dir', '?')})" if running else ""
if getattr(args, "port", None) or configured:
source = "--port" if getattr(args, "port", None) else "gcontext.yaml"
print(f"Error: port {port} (from {source}) is already in use{who}.", file=sys.stderr)
print("Free it, or pick another port with --port.", file=sys.stderr)
sys.exit(1)
chosen = find_free_port(port + 1)
print(f"{YELLOW}{BOLD}Port {port} is taken{who}.{RESET}")
print(f"{YELLOW}{BOLD}Using port {chosen} instead. Saved port: {chosen} to gcontext.yaml so this URL stays stable.{RESET}")
print()
port = chosen
if port != int(configured or DEFAULT_PORT):
persist_port(project_dir, port)
url = server_url(port)
server.ensure_venv()
+42 -1
View File
@@ -19,10 +19,11 @@ def test_init_scaffolds_agent(tmp_path):
"instructions.md",
"secrets.env",
".gitignore",
"connections/httpbin/connection.yaml",
"flows/demo-brief/flow.yaml",
]:
assert (agent / rel).is_file(), rel
assert (agent / "connections").is_dir()
assert not any((agent / "connections").glob("*/connection.yaml"))
assert "name: my-agent" in (agent / "gcontext.yaml").read_text()
assert "secrets.env" in (agent / ".gitignore").read_text()
@@ -41,3 +42,43 @@ def test_scaffolded_agent_works_with_cli(tmp_path):
assert result.returncode == 0, result.stderr
assert "demo-brief" in result.stdout
assert "capture" in result.stdout
def test_persist_port_replaces_commented_template_line(tmp_path):
from gcontext.cli import persist_port
yaml_file = tmp_path / "gcontext.yaml"
yaml_file.write_text("name: a\ndescription: d\n# port: 4242\n")
persist_port(tmp_path, 4243)
text = yaml_file.read_text()
assert "port: 4243\n" in text
assert "# port:" not in text
assert "name: a" in text
def test_persist_port_updates_existing_and_appends_when_missing(tmp_path):
from gcontext.cli import persist_port
yaml_file = tmp_path / "gcontext.yaml"
yaml_file.write_text("name: a\nport: 4243\n")
persist_port(tmp_path, 5000)
assert yaml_file.read_text() == "name: a\nport: 5000\n"
yaml_file.write_text("name: a\n")
persist_port(tmp_path, 4244)
assert yaml_file.read_text() == "name: a\nport: 4244\n"
def test_find_free_port_skips_taken_port():
import socket
from gcontext.cli import find_free_port, port_is_free
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
s.listen(1)
taken = s.getsockname()[1]
assert not port_is_free(taken)
chosen = find_free_port(taken)
assert chosen > taken
assert port_is_free(chosen)