mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
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:
co-authored by
Claude Opus 4.7
parent
38617cc4c4
commit
0fcabc2614
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user