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.
- agentcoopdb/__init__.py +44 -0
- agentcoopdb/cli/__init__.py +5 -0
- agentcoopdb/cli/config.py +66 -0
- agentcoopdb/cli/doctor.py +119 -0
- agentcoopdb/cli/init.py +177 -0
- agentcoopdb/cli/key.py +48 -0
- agentcoopdb/cli/main.py +80 -0
- agentcoopdb/cli/queue.py +58 -0
- agentcoopdb/client.py +327 -0
- agentcoopdb/errors.py +70 -0
- agentcoopdb/queue.py +202 -0
- agentcoopdb/retry.py +47 -0
- agentcoopdb/vector.py +16 -0
- agentic_coop_db-0.3.1.dist-info/METADATA +71 -0
- agentic_coop_db-0.3.1.dist-info/RECORD +17 -0
- agentic_coop_db-0.3.1.dist-info/WHEEL +4 -0
- agentic_coop_db-0.3.1.dist-info/entry_points.txt +2 -0
agentcoopdb/__init__.py
ADDED
|
@@ -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,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)
|
agentcoopdb/cli/init.py
ADDED
|
@@ -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
|
agentcoopdb/cli/main.py
ADDED
|
@@ -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()
|
agentcoopdb/cli/queue.py
ADDED
|
@@ -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")
|