agentic-coop-db 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ """agentcoopdb — Python client for the Agentic Coop DB auth gateway.
2
+
3
+ This package is intentionally tiny. The public surface is:
4
+
5
+ from agentcoopdb import connect
6
+ db = connect("https://db.example.com", api_key="acd_live_...")
7
+ db.execute(sql, params)
8
+ db.select(sql, params)
9
+ db.transaction()
10
+ db.vector_upsert(...)
11
+ db.vector_search(...)
12
+ db.rotate_key()
13
+ db.health()
14
+ db.me()
15
+
16
+ Plus error classes (errors.py) and the offline retry queue (queue.py).
17
+ """
18
+
19
+ from agentcoopdb.client import AgentCoopDBClient, connect
20
+ from agentcoopdb.errors import (
21
+ AgentCoopDBError,
22
+ AuthError,
23
+ IdempotencyConflict,
24
+ NetworkError,
25
+ QueueFullError,
26
+ RateLimited,
27
+ ServerError,
28
+ ValidationError,
29
+ )
30
+
31
+ __all__ = [
32
+ "connect",
33
+ "AgentCoopDBClient",
34
+ "AgentCoopDBError",
35
+ "AuthError",
36
+ "ValidationError",
37
+ "IdempotencyConflict",
38
+ "RateLimited",
39
+ "ServerError",
40
+ "NetworkError",
41
+ "QueueFullError",
42
+ ]
43
+
44
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """agentcoopdb CLI package.
2
+
3
+ The entry point is `agentcoopdb.cli.main:app`. Each subcommand lives in its
4
+ own module and is registered in main.py.
5
+ """
@@ -0,0 +1,66 @@
1
+ """Local user config for the CLI.
2
+
3
+ The CLI persists a tiny TOML file under the platform config directory
4
+ (e.g. ~/.config/agentcoopdb/config.toml on Linux, ~/Library/Application
5
+ Support/agentcoopdb/config.toml on macOS) so that subsequent commands don't
6
+ need --url / --api-key flags. The wizard (`agentic-coop-db init`) writes
7
+ this file; `agentic-coop-db me` and friends read it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import platformdirs
19
+
20
+ CONFIG_FILENAME = "config.toml"
21
+
22
+
23
+ def config_dir() -> Path:
24
+ return Path(platformdirs.user_config_dir("agentcoopdb", appauthor=False, ensure_exists=True))
25
+
26
+
27
+ def config_path() -> Path:
28
+ return config_dir() / CONFIG_FILENAME
29
+
30
+
31
+ @dataclass
32
+ class CLIConfig:
33
+ base_url: str
34
+ api_key: str
35
+
36
+ def write(self) -> Path:
37
+ path = config_path()
38
+ path.write_text(
39
+ f'base_url = "{self.base_url}"\napi_key = "{self.api_key}"\n',
40
+ encoding="utf-8",
41
+ )
42
+ # Best-effort chmod — on Windows this raises and we don't care.
43
+ with contextlib.suppress(OSError):
44
+ os.chmod(path, 0o600)
45
+ return path
46
+
47
+
48
+ def load() -> CLIConfig | None:
49
+ path = config_path()
50
+ if not path.exists():
51
+ return None
52
+ text = path.read_text(encoding="utf-8")
53
+ data: dict[str, Any] = {}
54
+ for line in text.splitlines():
55
+ line = line.strip()
56
+ if not line or line.startswith("#"):
57
+ continue
58
+ if "=" not in line:
59
+ continue
60
+ k, v = line.split("=", 1)
61
+ k = k.strip()
62
+ v = v.strip().strip('"')
63
+ data[k] = v
64
+ if "base_url" not in data or "api_key" not in data:
65
+ return None
66
+ return CLIConfig(base_url=data["base_url"], api_key=data["api_key"])
@@ -0,0 +1,119 @@
1
+ """`agentic-coop-db doctor` — verify the local install end to end.
2
+
3
+ Checks (in order):
4
+
5
+ 1. Config file exists and is parseable
6
+ 2. /healthz reachable
7
+ 3. /readyz returns 200 (DB + migrations OK)
8
+ 4. /v1/me succeeds (auth works)
9
+ 5. SELECT 1 round trip
10
+ 6. Idempotency replay probe — same key returns cached response
11
+ 7. pgvector available (CREATE EXTENSION query)
12
+ 8. Local queue path writable
13
+ 9. Server vs SDK version skew
14
+
15
+ Exits 0 on success, non-zero on the first failure.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import NoReturn
23
+
24
+ import typer
25
+
26
+ from agentcoopdb import __version__ as sdk_version
27
+ from agentcoopdb import connect
28
+ from agentcoopdb.cli.config import config_dir
29
+ from agentcoopdb.cli.config import load as load_config
30
+ from agentcoopdb.errors import AgentCoopDBError
31
+ from agentcoopdb.queue import Queue
32
+
33
+
34
+ def doctor() -> None:
35
+ cfg = load_config()
36
+ if cfg is None:
37
+ _fail("config not found — run `agentic-coop-db init` first")
38
+
39
+ _ok("config", f"loaded from {Path(config_dir()) / 'config.toml'}")
40
+
41
+ db = connect(cfg.base_url, api_key=cfg.api_key, timeout=5.0)
42
+
43
+ try:
44
+ h = db.health()
45
+ _ok("/healthz", str(h.get("status", "?")))
46
+ except AgentCoopDBError as e:
47
+ _fail(f"/healthz failed: {e}")
48
+
49
+ try:
50
+ rz = db._get("/readyz")
51
+ _ok("/readyz", str(rz.get("status", "?")))
52
+ except AgentCoopDBError as e:
53
+ _fail(f"/readyz failed (DB or migrations unhealthy): {e}")
54
+
55
+ try:
56
+ info = db.me()
57
+ _ok("/v1/me", f"workspace={info.get('workspace_id')} role={info.get('role')}")
58
+ except AgentCoopDBError as e:
59
+ _fail(f"/v1/me failed: {e}")
60
+
61
+ try:
62
+ res = db.execute("SELECT 1")
63
+ if res.command != "SELECT" or res.rows_affected != 1:
64
+ _fail(f"SELECT 1 returned unexpected result: {res}")
65
+ _ok("SELECT 1", "round trip ok")
66
+ except AgentCoopDBError as e:
67
+ _fail(f"SELECT 1 failed: {e}")
68
+
69
+ # Idempotency replay probe — uses a temp uuid as the key and verifies
70
+ # the server's X-Idempotent-Replayed header to confirm a cached replay.
71
+ key = str(uuid.uuid4())
72
+ try:
73
+ db.execute("SELECT pg_sleep(0)", idempotency_key=key)
74
+ r2 = db.execute("SELECT pg_sleep(0)", idempotency_key=key)
75
+ if r2.replayed:
76
+ _ok("idempotency", "replay returned cached response (X-Idempotent-Replayed confirmed)")
77
+ else:
78
+ _fail("idempotency replay succeeded but X-Idempotent-Replayed header missing — caching may be broken")
79
+ except AgentCoopDBError as e:
80
+ _fail(f"idempotency probe failed: {e}")
81
+
82
+ try:
83
+ rows = db.select("SELECT extname FROM pg_extension WHERE extname = 'vector'")
84
+ if rows:
85
+ _ok("pgvector", "extension installed")
86
+ else:
87
+ _warn("pgvector", "extension not installed (run migration 0005)")
88
+ except AgentCoopDBError as e:
89
+ _warn("pgvector", f"could not query pg_extension: {e}")
90
+
91
+ try:
92
+ Queue(config_dir() / "queue.db").depth()
93
+ _ok("queue", "local sqlite path writable")
94
+ except Exception as e:
95
+ _warn("queue", f"could not open local queue: {e}")
96
+
97
+ server_version = info.get("server", {}).get("version", "unknown")
98
+ if server_version != sdk_version:
99
+ _warn(
100
+ "version skew",
101
+ f"sdk={sdk_version} server={server_version} — keep them in lockstep when possible",
102
+ )
103
+ else:
104
+ _ok("version", f"sdk={sdk_version} server={server_version}")
105
+
106
+ typer.echo("\nall checks passed")
107
+
108
+
109
+ def _ok(name: str, detail: str = "") -> None:
110
+ typer.echo(f" [OK] {name}: {detail}")
111
+
112
+
113
+ def _warn(name: str, detail: str) -> None:
114
+ typer.echo(f" [WARN] {name}: {detail}")
115
+
116
+
117
+ def _fail(msg: str) -> NoReturn:
118
+ typer.echo(f" [FAIL] {msg}", err=True)
119
+ raise typer.Exit(code=1)
@@ -0,0 +1,177 @@
1
+ """`agentic-coop-db init` — interactive onboarding wizard.
2
+
3
+ Goal: get a brand-new user from zero to a working CRUD round trip in
4
+ under five minutes without reading any docs beyond the README.
5
+
6
+ Decision tree:
7
+
8
+ 1. Detect docker. If missing, link to install instructions.
9
+ 2. Ask which profile they want: local / pi-lite / cloud / "I have a server".
10
+ 3. For self-hosted profiles: run `make up-<profile>`, wait for /readyz,
11
+ mint an admin API key via scripts/gen-key.sh, capture it once.
12
+ 4. For "existing server": prompt for URL + key, call /v1/me to validate.
13
+ 5. Write ~/.agentic-coop-db/config.toml.
14
+ 6. Run `agentic-coop-db doctor` and print a Python + curl snippet.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import typer
26
+
27
+ from agentcoopdb import connect
28
+ from agentcoopdb.cli import config as cli_config
29
+ from agentcoopdb.errors import AgentCoopDBError
30
+
31
+
32
+ def init() -> None:
33
+ """Run the wizard."""
34
+ typer.echo(_BANNER)
35
+
36
+ if not _have("docker"):
37
+ typer.echo(
38
+ "Docker is not on PATH. Install it from https://docs.docker.com/engine/install/ "
39
+ "and re-run `agentic-coop-db init`."
40
+ )
41
+ raise typer.Exit(code=2)
42
+
43
+ profile = typer.prompt(
44
+ "Which profile? [local / pi-lite / cloud / existing]",
45
+ default="local",
46
+ ).strip().lower()
47
+
48
+ if profile == "existing":
49
+ base_url = typer.prompt("Server base URL (e.g. https://db.example.com)").strip()
50
+ api_key = typer.prompt("API key (will not be echoed)", hide_input=True).strip()
51
+ _validate_and_save(base_url, api_key)
52
+ return
53
+
54
+ repo_root = _find_repo_root()
55
+ if repo_root is None:
56
+ typer.echo(
57
+ "Could not find an Agentic Coop DB checkout in the current or parent directories. "
58
+ "Either run this from inside the repo, or pick `existing` and point at a remote server."
59
+ )
60
+ raise typer.Exit(code=2)
61
+
62
+ target = {
63
+ "local": "up-local",
64
+ "pi-lite": "up-pi",
65
+ "cloud": "up-cloud",
66
+ }.get(profile)
67
+ if target is None:
68
+ typer.echo(f"unknown profile: {profile}")
69
+ raise typer.Exit(code=2)
70
+
71
+ typer.echo(f"\n→ make {target}")
72
+ res = subprocess.run(["make", target], cwd=repo_root)
73
+ if res.returncode != 0:
74
+ typer.echo("`make` failed — fix the error above and re-run `agentic-coop-db init`")
75
+ raise typer.Exit(code=res.returncode)
76
+
77
+ base_url = "http://localhost:8080"
78
+ typer.echo(f"\n→ waiting for {base_url}/readyz ...")
79
+ if not _wait_ready(base_url, timeout=120):
80
+ typer.echo("the server did not become ready within 120s — check `make logs`")
81
+ raise typer.Exit(code=1)
82
+
83
+ typer.echo("→ minting an admin API key via scripts/gen-key.sh ...")
84
+ key_proc = subprocess.run(
85
+ ["./scripts/gen-key.sh", "default", "dbadmin"],
86
+ cwd=repo_root,
87
+ capture_output=True,
88
+ text=True,
89
+ )
90
+ if key_proc.returncode != 0:
91
+ typer.echo(key_proc.stderr)
92
+ raise typer.Exit(code=key_proc.returncode)
93
+ api_key = _extract_token(key_proc.stdout)
94
+ if not api_key:
95
+ typer.echo("could not parse the minted key from gen-key.sh output")
96
+ typer.echo(key_proc.stdout)
97
+ raise typer.Exit(code=1)
98
+
99
+ _validate_and_save(base_url, api_key)
100
+
101
+
102
+ # ---- helpers -----------------------------------------------------------------
103
+
104
+ def _validate_and_save(base_url: str, api_key: str) -> None:
105
+ db = connect(base_url, api_key=api_key, timeout=10.0)
106
+ try:
107
+ info = db.me()
108
+ except AgentCoopDBError as e:
109
+ typer.echo(f"could not authenticate against {base_url}: {e}")
110
+ raise typer.Exit(code=1) from e
111
+
112
+ cfg = cli_config.CLIConfig(base_url=base_url, api_key=api_key)
113
+ path = cfg.write()
114
+ typer.echo(f"\nconfig saved to {path}")
115
+ typer.echo(f"workspace: {info.get('workspace_id', '?')}")
116
+ typer.echo(f"role: {info.get('role', '?')}")
117
+ typer.echo(f"server: {info.get('server', {}).get('version', '?')}")
118
+ typer.echo("\nyou are ready. try this:\n")
119
+ typer.echo(f" curl -H 'Authorization: Bearer {api_key}' {base_url}/v1/me")
120
+ typer.echo(" agentic-coop-db sql 'SELECT 1'")
121
+ typer.echo("\nor in Python:\n")
122
+ typer.echo(" from agentcoopdb import connect")
123
+ typer.echo(f" db = connect('{base_url}', api_key='acd_...')")
124
+ typer.echo(" db.execute('SELECT 1')")
125
+
126
+
127
+ def _have(cmd: str) -> bool:
128
+ return shutil.which(cmd) is not None
129
+
130
+
131
+ def _find_repo_root() -> Path | None:
132
+ here = Path.cwd().resolve()
133
+ for candidate in [here, *here.parents]:
134
+ if (candidate / "Makefile").exists() and (candidate / "deploy").is_dir():
135
+ return candidate
136
+ return None
137
+
138
+
139
+ def _wait_ready(base_url: str, timeout: int) -> bool:
140
+ import requests
141
+
142
+ deadline = time.time() + timeout
143
+ while time.time() < deadline:
144
+ try:
145
+ r = requests.get(base_url + "/readyz", timeout=2.0)
146
+ if r.status_code == 200:
147
+ return True
148
+ except requests.RequestException:
149
+ pass
150
+ time.sleep(1)
151
+ return False
152
+
153
+
154
+ def _extract_token(stdout: str) -> str | None:
155
+ for line in stdout.splitlines():
156
+ line = line.strip()
157
+ if line.startswith("acd_"):
158
+ return line
159
+ return None
160
+
161
+
162
+ _BANNER = """\
163
+ agentic-coop-db init — interactive onboarding wizard
164
+
165
+ This wizard will:
166
+ 1. Verify Docker is up
167
+ 2. Start the stack with your chosen profile
168
+ 3. Mint an admin API key
169
+ 4. Save your config (run `agentic-coop-db doctor` to see the path)
170
+ 5. Print a Python + curl snippet you can copy/paste
171
+
172
+ Press Ctrl-C at any time to abort.
173
+ """
174
+
175
+ if __name__ == "__main__":
176
+ init()
177
+ sys.exit(0)
agentcoopdb/cli/key.py ADDED
@@ -0,0 +1,48 @@
1
+ """`agentic-coop-db key` — manage API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from agentcoopdb import connect
10
+ from agentcoopdb.cli.config import CLIConfig
11
+ from agentcoopdb.cli.config import load as load_config
12
+
13
+ key_app = typer.Typer(no_args_is_help=True)
14
+
15
+
16
+ @key_app.command("create")
17
+ def create(
18
+ pg_role: str = typer.Option(..., "--role", help="Postgres role to attach (e.g. dbuser, dbadmin, custom_role)"),
19
+ workspace: str = typer.Option("", "--workspace", help="Override the workspace id (defaults to caller's)"),
20
+ name: str = typer.Option("", "--name", help="Human label for the key"),
21
+ ) -> None:
22
+ """Mint a new API key. The caller must be a dbadmin key."""
23
+ cfg = _cfg()
24
+ db = connect(cfg.base_url, api_key=cfg.api_key)
25
+ body = {"pg_role": pg_role, "name": name}
26
+ if workspace:
27
+ body["workspace_id"] = workspace
28
+ res = db._post("/v1/auth/keys", body) # noqa: SLF001 — intentional internal use
29
+ typer.echo(json.dumps(res, indent=2))
30
+ typer.echo("\nstore the token above NOW — it is shown exactly once")
31
+
32
+
33
+ @key_app.command("rotate")
34
+ def rotate() -> None:
35
+ """Rotate the calling key. The old key remains active for the configured overlap window."""
36
+ cfg = _cfg()
37
+ db = connect(cfg.base_url, api_key=cfg.api_key)
38
+ res = db.rotate_key()
39
+ typer.echo(json.dumps(res, indent=2))
40
+ typer.echo("\nstore the token above NOW — it is shown exactly once")
41
+
42
+
43
+ def _cfg() -> CLIConfig:
44
+ cfg = load_config()
45
+ if cfg is None:
46
+ typer.echo("no config found — run `agentic-coop-db init` first", err=True)
47
+ raise typer.Exit(code=2)
48
+ return cfg
@@ -0,0 +1,80 @@
1
+ """agentcoopdb — top-level CLI.
2
+
3
+ Subcommands:
4
+
5
+ agentic-coop-db init interactive onboarding wizard (start the stack, mint a key)
6
+ agentic-coop-db sql run a one-shot SQL statement
7
+ agentic-coop-db me show the calling key's workspace + role
8
+ agentic-coop-db key manage API keys (create, rotate)
9
+ agentic-coop-db queue inspect / flush the local offline retry queue
10
+ agentic-coop-db doctor verify that everything is wired correctly
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ import typer
19
+
20
+ from agentcoopdb import connect
21
+ from agentcoopdb.cli import config as cli_config
22
+ from agentcoopdb.cli.doctor import doctor as doctor_cmd
23
+ from agentcoopdb.cli.init import init as init_cmd
24
+ from agentcoopdb.cli.key import key_app
25
+ from agentcoopdb.cli.queue import queue_app
26
+
27
+ app = typer.Typer(
28
+ name="agentcoopdb",
29
+ help="Agentic Coop DB CLI — auth gateway client for shared PostgreSQL.",
30
+ no_args_is_help=True,
31
+ )
32
+
33
+ app.add_typer(key_app, name="key", help="Manage API keys.")
34
+ app.add_typer(queue_app, name="queue", help="Inspect / flush the local offline retry queue.")
35
+ app.command(name="init", help="Interactive onboarding wizard.")(init_cmd)
36
+ app.command(name="doctor", help="Verify config, network, auth, db, and migrations.")(doctor_cmd)
37
+
38
+
39
+ @app.command()
40
+ def me() -> None:
41
+ """Print { workspace, role, server } for the configured key."""
42
+ cfg = _require_config()
43
+ db = connect(cfg.base_url, api_key=cfg.api_key)
44
+ typer.echo(json.dumps(db.me(), indent=2))
45
+
46
+
47
+ @app.command()
48
+ def sql(
49
+ statement: str = typer.Argument(..., help="The SQL statement (use $1, $2, ... for params)"),
50
+ param: list[str] = typer.Option([], "--param", "-p", help="Repeatable; positional values for $1, $2, ..."),
51
+ ) -> None:
52
+ """Run a one-shot SQL statement against the configured server."""
53
+ cfg = _require_config()
54
+ db = connect(cfg.base_url, api_key=cfg.api_key)
55
+ parsed_params: list[Any] = list(param)
56
+ res = db.execute(statement, parsed_params)
57
+ typer.echo(
58
+ json.dumps(
59
+ {
60
+ "command": res.command,
61
+ "columns": res.columns,
62
+ "rows": res.rows,
63
+ "rows_affected": res.rows_affected,
64
+ "duration_ms": res.duration_ms,
65
+ },
66
+ indent=2,
67
+ )
68
+ )
69
+
70
+
71
+ def _require_config() -> cli_config.CLIConfig:
72
+ cfg = cli_config.load()
73
+ if cfg is None:
74
+ typer.echo("no config found — run `agentic-coop-db init` first", err=True)
75
+ raise typer.Exit(code=2)
76
+ return cfg
77
+
78
+
79
+ if __name__ == "__main__":
80
+ app()
@@ -0,0 +1,58 @@
1
+ """`agentic-coop-db queue` — inspect / flush the local offline retry queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from agentcoopdb import connect
10
+ from agentcoopdb.cli.config import config_dir
11
+ from agentcoopdb.cli.config import load as load_config
12
+ from agentcoopdb.queue import Queue, QueueItem
13
+
14
+ queue_app = typer.Typer(no_args_is_help=True)
15
+
16
+
17
+ def queue_path() -> Path:
18
+ return config_dir() / "queue.db"
19
+
20
+
21
+ @queue_app.command("status")
22
+ def status() -> None:
23
+ """Show the current depth + dead-letter count."""
24
+ q = Queue(queue_path())
25
+ typer.echo(f"pending: {q.depth()}")
26
+ typer.echo(f"dead-letter: {q.dead_letter_count()}")
27
+ typer.echo(f"db: {q.path}")
28
+
29
+
30
+ @queue_app.command("flush")
31
+ def flush() -> None:
32
+ """Drain the queue against the configured server."""
33
+ cfg = load_config()
34
+ if cfg is None:
35
+ typer.echo("no config found — run `agentic-coop-db init` first", err=True)
36
+ raise typer.Exit(code=2)
37
+ db = connect(cfg.base_url, api_key=cfg.api_key)
38
+ q = Queue(queue_path())
39
+
40
+ def send(item: QueueItem) -> None:
41
+ db.execute(item.sql, item.params, idempotency_key=item.idempotency_key)
42
+
43
+ sent, failed = q.flush(send)
44
+ typer.echo(f"sent={sent} failed={failed} remaining={q.depth()}")
45
+
46
+
47
+ @queue_app.command("clear-dead")
48
+ def clear_dead() -> None:
49
+ """Truncate the dead-letter table after manual review."""
50
+ import sqlite3
51
+
52
+ path = queue_path()
53
+ if not path.exists():
54
+ typer.echo("no queue db")
55
+ return
56
+ with sqlite3.connect(path) as c:
57
+ c.execute("DELETE FROM dead")
58
+ typer.echo("dead letters cleared")