dooers-cli 0.5.0__tar.gz

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.
Files changed (34) hide show
  1. dooers_cli-0.5.0/.gitignore +7 -0
  2. dooers_cli-0.5.0/PKG-INFO +75 -0
  3. dooers_cli-0.5.0/README.md +40 -0
  4. dooers_cli-0.5.0/pyproject.toml +83 -0
  5. dooers_cli-0.5.0/src/dooers/cli/__init__.py +3 -0
  6. dooers_cli-0.5.0/src/dooers/cli/agent_store.py +66 -0
  7. dooers_cli-0.5.0/src/dooers/cli/agents.py +100 -0
  8. dooers_cli-0.5.0/src/dooers/cli/auth.py +80 -0
  9. dooers_cli-0.5.0/src/dooers/cli/cli.py +40 -0
  10. dooers_cli-0.5.0/src/dooers/cli/config.py +28 -0
  11. dooers_cli-0.5.0/src/dooers/cli/core_client.py +105 -0
  12. dooers_cli-0.5.0/src/dooers/cli/ignore.py +89 -0
  13. dooers_cli-0.5.0/src/dooers/cli/manifest_sync.py +73 -0
  14. dooers_cli-0.5.0/src/dooers/cli/org.py +82 -0
  15. dooers_cli-0.5.0/src/dooers/cli/push.py +139 -0
  16. dooers_cli-0.5.0/src/dooers/cli/push_client.py +52 -0
  17. dooers_cli-0.5.0/src/dooers/cli/settings.py +29 -0
  18. dooers_cli-0.5.0/src/dooers/cli/token_store.py +46 -0
  19. dooers_cli-0.5.0/src/dooers/cli/user_config.py +28 -0
  20. dooers_cli-0.5.0/src/dooers/cli/validate.py +115 -0
  21. dooers_cli-0.5.0/src/dooers/py.typed +0 -0
  22. dooers_cli-0.5.0/tests/__init__.py +0 -0
  23. dooers_cli-0.5.0/tests/test_agent_store.py +44 -0
  24. dooers_cli-0.5.0/tests/test_config.py +50 -0
  25. dooers_cli-0.5.0/tests/test_core_client.py +79 -0
  26. dooers_cli-0.5.0/tests/test_ignore.py +56 -0
  27. dooers_cli-0.5.0/tests/test_manifest_sync.py +227 -0
  28. dooers_cli-0.5.0/tests/test_org.py +21 -0
  29. dooers_cli-0.5.0/tests/test_push_failure_display.py +22 -0
  30. dooers_cli-0.5.0/tests/test_push_hosting_guard.py +46 -0
  31. dooers_cli-0.5.0/tests/test_smoke.py +34 -0
  32. dooers_cli-0.5.0/tests/test_token_store.py +48 -0
  33. dooers_cli-0.5.0/tests/test_validate.py +82 -0
  34. dooers_cli-0.5.0/uv.lock +1057 -0
@@ -0,0 +1,7 @@
1
+ dist/
2
+ *.egg-info
3
+ .pytest_cache
4
+ .ruff_cache
5
+ .mypy_cache
6
+ __pycache__/
7
+ .venv/
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: dooers-cli
3
+ Version: 0.5.0
4
+ Summary: Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal
5
+ Project-URL: Repository, https://github.com/Dooers-ai/dooers-cli
6
+ Author-email: Dooers <dev@dooers.ai>
7
+ License-Expression: MIT
8
+ Keywords: agents,cli,deployment,docker,dooers,gcp
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: dooers-protocol>=0.4.0
19
+ Requires-Dist: httpx>=0.28.1
20
+ Requires-Dist: pydantic>=2.13.2
21
+ Requires-Dist: pyyaml>=6.0.2
22
+ Requires-Dist: tqdm>=4.66.4
23
+ Requires-Dist: typer>=0.12.3
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.4.3; extra == 'dev'
26
+ Requires-Dist: mypy>=1.20.1; extra == 'dev'
27
+ Requires-Dist: poethepoet>=0.44.0; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
29
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
30
+ Requires-Dist: respx>=0.21.1; extra == 'dev'
31
+ Requires-Dist: ruff>=0.15.11; extra == 'dev'
32
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
33
+ Requires-Dist: types-tqdm>=4.66.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # dooers (CLI)
37
+
38
+ The Dooers CLI. Installed by agent creators.
39
+
40
+ ```bash
41
+ pip install dooers
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ```bash
47
+ # auth (email prompts if omitted)
48
+ dooers login you@example.com
49
+ dooers whoami
50
+ dooers logout
51
+
52
+ # agents
53
+ dooers agents list
54
+ dooers agents create --name my-agent
55
+ dooers agents show ag_8h2k
56
+
57
+ # push (reads dooers.yaml in cwd unless agent_id given)
58
+ dooers validate
59
+ dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
60
+ ```
61
+
62
+ ## Global config (precedence: flag > env > default)
63
+
64
+ | Setting | Flag | Env var | Default |
65
+ |---|---|---|---|
66
+ | Core API host | `--core-url` | `DOOERS_CORE_URL` | `https://api.dooers.ai` |
67
+ | dooers-push host | `--push-url` | `DOOERS_PUSH_URL` | `https://host.dooers.ai` |
68
+ | Target environment | `--env` | `DOOERS_ENV` | `prod` |
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ uv sync --extra dev
74
+ uv run poe dev
75
+ ```
@@ -0,0 +1,40 @@
1
+ # dooers (CLI)
2
+
3
+ The Dooers CLI. Installed by agent creators.
4
+
5
+ ```bash
6
+ pip install dooers
7
+ ```
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # auth (email prompts if omitted)
13
+ dooers login you@example.com
14
+ dooers whoami
15
+ dooers logout
16
+
17
+ # agents
18
+ dooers agents list
19
+ dooers agents create --name my-agent
20
+ dooers agents show ag_8h2k
21
+
22
+ # push (reads dooers.yaml in cwd unless agent_id given)
23
+ dooers validate
24
+ dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
25
+ ```
26
+
27
+ ## Global config (precedence: flag > env > default)
28
+
29
+ | Setting | Flag | Env var | Default |
30
+ |---|---|---|---|
31
+ | Core API host | `--core-url` | `DOOERS_CORE_URL` | `https://api.dooers.ai` |
32
+ | dooers-push host | `--push-url` | `DOOERS_PUSH_URL` | `https://host.dooers.ai` |
33
+ | Target environment | `--env` | `DOOERS_ENV` | `prod` |
34
+
35
+ ## Development
36
+
37
+ ```bash
38
+ uv sync --extra dev
39
+ uv run poe dev
40
+ ```
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "dooers-cli"
3
+ version = "0.5.0"
4
+ description = "Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ {name = "Dooers", email = "dev@dooers.ai"},
10
+ ]
11
+ keywords = ["dooers", "cli", "deployment", "agents", "docker", "gcp"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ dependencies = [
24
+ "typer>=0.12.3",
25
+ "httpx>=0.28.1",
26
+ "pydantic>=2.13.2",
27
+ "pyyaml>=6.0.2",
28
+ "tqdm>=4.66.4",
29
+ "dooers-protocol>=0.4.0",
30
+ ]
31
+
32
+ [tool.uv.sources]
33
+ dooers-protocol = { path = "../dooers-protocol", editable = true }
34
+
35
+ [project.urls]
36
+ Repository = "https://github.com/Dooers-ai/dooers-cli"
37
+
38
+ [project.scripts]
39
+ dooers = "dooers.cli.cli:app"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/dooers"]
47
+
48
+ [project.optional-dependencies]
49
+ dev = [
50
+ "mypy>=1.20.1",
51
+ "poethepoet>=0.44.0",
52
+ "pytest>=9.0.3",
53
+ "pytest-cov>=7.1.0",
54
+ "respx>=0.21.1",
55
+ "ruff>=0.15.11",
56
+ "build>=1.4.3",
57
+ "types-pyyaml>=6.0.0",
58
+ "types-tqdm>=4.66.0",
59
+ ]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py310"
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I", "UP", "BLE"]
67
+
68
+ [tool.ruff.format]
69
+ quote-style = "double"
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests"]
73
+
74
+ [tool.poe.tasks]
75
+ format = "ruff format ."
76
+ "format:imports" = "ruff check --select I --fix ."
77
+ check = "ruff check ."
78
+ "check:fix" = "ruff check --fix ."
79
+ typecheck = "mypy src/dooers/cli"
80
+ test = "pytest"
81
+ "test:cov" = "pytest --cov=src --cov-report=term-missing"
82
+ dev = ["check", "typecheck", "test"]
83
+ build = "python -m build"
@@ -0,0 +1,3 @@
1
+ """dooers — the Dooers CLI."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,66 @@
1
+ """v2 core-backed agent store. Talks to /api/v2/agents with {success,data}."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+ from dooers.protocol.agents import AgentRecord, CreateAgentRequest
7
+
8
+
9
+ class AgentStoreError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def _data(resp: httpx.Response):
14
+ body = resp.json()
15
+ if isinstance(body, dict) and body.get("success") is False:
16
+ raise AgentStoreError(body.get("error", {}).get("message", f"HTTP {resp.status_code}"))
17
+ if resp.status_code >= 400:
18
+ raise AgentStoreError(f"HTTP {resp.status_code}")
19
+ return body.get("data", body)
20
+
21
+
22
+ def _record(d: dict) -> AgentRecord:
23
+ return AgentRecord(
24
+ agent_id=d["agentId"],
25
+ name=d.get("name", ""),
26
+ owner_user_id=d.get("ownerUserId"),
27
+ organization_id=d.get("organizationId"),
28
+ host_url=d.get("hostUrl"),
29
+ )
30
+
31
+
32
+ class HTTPCoreAgentStore:
33
+ def __init__(self, base_url: str, token: str, timeout: float = 15.0) -> None:
34
+ self.api = base_url.rstrip("/") + "/api/v2"
35
+ self.token = token
36
+ self._timeout = timeout
37
+
38
+ def _h(self) -> dict[str, str]:
39
+ return {"Authorization": f"Bearer {self.token}"}
40
+
41
+ def create(self, req: CreateAgentRequest) -> AgentRecord:
42
+ body = json.dumps({"organizationId": req.organization_id, "name": req.name})
43
+ r = httpx.post(
44
+ f"{self.api}/agents",
45
+ headers={**self._h(), "content-type": "application/json"},
46
+ content=body,
47
+ timeout=self._timeout,
48
+ )
49
+ return _record(_data(r))
50
+
51
+ def list_by_org(self, organization_id: str) -> list[AgentRecord]:
52
+ url = f"{self.api}/agents/organization/{organization_id}"
53
+ r = httpx.get(url, headers=self._h(), timeout=self._timeout)
54
+ return [_record(d) for d in _data(r)]
55
+
56
+ def get(self, agent_id: str) -> AgentRecord:
57
+ r = httpx.get(f"{self.api}/agents/{agent_id}", headers=self._h(), timeout=self._timeout)
58
+ if r.status_code == 404:
59
+ raise KeyError(agent_id)
60
+ return _record(_data(r))
61
+
62
+ def update(self, agent_id: str, patch: dict) -> AgentRecord:
63
+ r = httpx.patch(
64
+ f"{self.api}/agents/{agent_id}", headers=self._h(), json=patch, timeout=self._timeout
65
+ )
66
+ return _record(_data(r))
@@ -0,0 +1,100 @@
1
+ """`dooers agents` subcommands: list, create, show (core v2)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from dooers.protocol import PROTOCOL_VERSION
7
+ from dooers.protocol.agents import (
8
+ AgentManifest,
9
+ CreateAgentRequest,
10
+ ProfileConfig,
11
+ UiConfig,
12
+ WhatsAppConfig,
13
+ )
14
+
15
+ from dooers.cli import config
16
+ from dooers.cli.agent_store import AgentStoreError, HTTPCoreAgentStore
17
+ from dooers.cli.org import resolve_org_for_cli
18
+ from dooers.cli.settings import Settings
19
+ from dooers.cli.token_store import TokenStore, is_token_expired
20
+
21
+ app = typer.Typer(no_args_is_help=True)
22
+
23
+
24
+ def _store(ctx: typer.Context) -> tuple[HTTPCoreAgentStore, Settings]:
25
+ settings: Settings = ctx.obj
26
+ store_token = TokenStore()
27
+ token = store_token.load()
28
+ if not token or is_token_expired(token, store=store_token):
29
+ typer.echo("Not authenticated. Run `dooers login`.", err=True)
30
+ raise typer.Exit(code=1)
31
+ return HTTPCoreAgentStore(settings.core_url, token), settings
32
+
33
+
34
+ @app.command()
35
+ def create(
36
+ ctx: typer.Context,
37
+ name: str = typer.Option(..., help="Display name for the new agent."),
38
+ org: str | None = typer.Option(None, "--org", help="Organization id (else resolved/prompted)."),
39
+ description: str | None = typer.Option(
40
+ None, "--description", help="Short description of the agent."
41
+ ),
42
+ ) -> None:
43
+ store, settings = _store(ctx)
44
+ organization_id = resolve_org_for_cli(settings, org)
45
+ try:
46
+ rec = store.create(CreateAgentRequest(organization_id=organization_id, name=name))
47
+ except AgentStoreError as e:
48
+ typer.echo(f"create failed: {e}", err=True)
49
+ raise typer.Exit(code=1) from e
50
+ config.write_manifest(
51
+ AgentManifest(
52
+ protocol_version=PROTOCOL_VERSION,
53
+ agent_id=rec.agent_id,
54
+ name=rec.name,
55
+ organization_id=rec.organization_id or organization_id,
56
+ description=description,
57
+ message_path="/",
58
+ message_scheme="wss",
59
+ whatsapp=WhatsAppConfig(enabled=False, path=None),
60
+ profile=ProfileConfig(),
61
+ ui=UiConfig(),
62
+ ),
63
+ directory=Path.cwd(),
64
+ )
65
+ typer.echo(f"Created {rec.agent_id}. {config.MANIFEST_FILENAME} written.")
66
+ typer.echo("Edit dooers.yaml (message_path, whatsapp, profile) then run dooers push.")
67
+
68
+
69
+ @app.command(name="list")
70
+ def list_agents(ctx: typer.Context, org: str | None = typer.Option(None, "--org")) -> None:
71
+ store, settings = _store(ctx)
72
+ organization_id = resolve_org_for_cli(settings, org)
73
+ try:
74
+ records = store.list_by_org(organization_id)
75
+ except AgentStoreError as e:
76
+ typer.echo(f"Error: {e}", err=True)
77
+ raise typer.Exit(code=1) from e
78
+ if not records:
79
+ typer.echo("No agents yet. Try `dooers agents create --name my-agent`.")
80
+ return
81
+ typer.echo(f"{'ID':<38}{'NAME':<24}URL")
82
+ for r in records:
83
+ typer.echo(f"{r.agent_id:<38}{r.name:<24}{r.host_url or '—'}")
84
+
85
+
86
+ @app.command()
87
+ def show(ctx: typer.Context, agent_id: str = typer.Argument(...)) -> None:
88
+ store, _ = _store(ctx)
89
+ try:
90
+ r = store.get(agent_id)
91
+ except KeyError:
92
+ typer.echo(f"Agent {agent_id} not found.", err=True)
93
+ raise typer.Exit(code=1)
94
+ except AgentStoreError as e:
95
+ typer.echo(f"Error: {e}", err=True)
96
+ raise typer.Exit(code=1) from e
97
+ typer.echo(f"ID: {r.agent_id}")
98
+ typer.echo(f"Name: {r.name}")
99
+ typer.echo(f"Org: {r.organization_id}")
100
+ typer.echo(f"URL: {r.host_url or '—'}")
@@ -0,0 +1,80 @@
1
+ """`dooers login` / `whoami` / `logout` command implementations.
2
+
3
+ Registered as top-level commands from cli.py.
4
+ """
5
+
6
+ import typer
7
+
8
+ from dooers.cli.core_client import CoreClient, CoreClientError
9
+ from dooers.cli.settings import Settings
10
+ from dooers.cli.token_store import TokenStore, is_token_expired
11
+
12
+
13
+ def _settings(ctx: typer.Context) -> Settings:
14
+ s = ctx.obj
15
+ if not isinstance(s, Settings):
16
+ typer.echo("internal: settings not resolved", err=True)
17
+ raise typer.Exit(code=1)
18
+ return s
19
+
20
+
21
+ def login(
22
+ ctx: typer.Context,
23
+ email: str | None = typer.Argument(None, help="Your email address. Prompted if omitted."),
24
+ ) -> None:
25
+ """Authenticate with Dooers via OTP sent to email."""
26
+ settings = _settings(ctx)
27
+ if not email:
28
+ email = typer.prompt("Email")
29
+ store = TokenStore()
30
+
31
+ existing = store.load()
32
+ if existing and not is_token_expired(existing, store=store):
33
+ typer.echo("Already authenticated. Run `dooers logout` first to re-login.")
34
+ raise typer.Exit(code=0)
35
+
36
+ client = CoreClient(base_url=settings.core_url)
37
+ try:
38
+ typer.echo("Requesting verification code…")
39
+ client.send_otp(email)
40
+ code = typer.prompt("Enter the code emailed to you")
41
+ token, expires_at = client.verify_otp(email, code)
42
+ except CoreClientError as e:
43
+ typer.echo(f"Authentication failed: {e}", err=True)
44
+ raise typer.Exit(code=1) from e
45
+
46
+ store.save(token, expires_at=expires_at)
47
+ typer.echo("Authenticated.")
48
+
49
+
50
+ def whoami(ctx: typer.Context) -> None:
51
+ """Show the currently authenticated user."""
52
+ settings = _settings(ctx)
53
+ store = TokenStore()
54
+ token = store.load()
55
+ if not token:
56
+ typer.echo("Not authenticated. Run `dooers login`.", err=True)
57
+ raise typer.Exit(code=1)
58
+ if is_token_expired(token, store=store):
59
+ typer.echo("Session expired. Run `dooers login`.", err=True)
60
+ store.clear()
61
+ raise typer.Exit(code=1)
62
+
63
+ client = CoreClient(base_url=settings.core_url, token=token)
64
+ try:
65
+ me = client.me()
66
+ except CoreClientError as e:
67
+ typer.echo(f"whoami failed: {e}", err=True)
68
+ raise typer.Exit(code=1) from e
69
+ typer.echo(f"Authenticated as {me.email} (user_id={me.user_id})")
70
+
71
+
72
+ def logout(ctx: typer.Context) -> None:
73
+ """Clear local credentials."""
74
+ settings = _settings(ctx)
75
+ store = TokenStore()
76
+ token = store.load()
77
+ if token:
78
+ CoreClient(base_url=settings.core_url, token=token).revoke()
79
+ store.clear()
80
+ typer.echo("Logged out.")
@@ -0,0 +1,40 @@
1
+ """Top-level Typer app — login/whoami/logout + agents group + push."""
2
+
3
+ import typer
4
+
5
+ from . import agents, auth, org, push, validate
6
+ from .settings import Settings
7
+
8
+ app = typer.Typer(
9
+ name="dooers",
10
+ add_completion=False,
11
+ no_args_is_help=True,
12
+ help="Dooers CLI — push agents to Dooers, manage agent records, authenticate.",
13
+ )
14
+
15
+
16
+ @app.callback()
17
+ def _root(
18
+ ctx: typer.Context,
19
+ core_url: str | None = typer.Option(None, "--core-url", help="Override core API URL."),
20
+ push_url: str | None = typer.Option(None, "--push-url", help="Override dooers-push URL."),
21
+ env: str | None = typer.Option(None, "--env", help="Target environment: prod | stg | dev."),
22
+ ) -> None:
23
+ """Resolve global settings once per invocation."""
24
+ ctx.obj = Settings.resolve(core_url=core_url, push_url=push_url, env=env)
25
+
26
+
27
+ app.command(name="login", help="Authenticate with Dooers (OTP via email).")(auth.login)
28
+ app.command(name="whoami", help="Show the currently authenticated user.")(auth.whoami)
29
+ app.command(name="logout", help="Clear local credentials.")(auth.logout)
30
+ app.add_typer(agents.app, name="agents", help="Manage agents — subcommands: list | create | show.")
31
+ app.add_typer(org.app, name="org", help="Manage organization — subcommands: list | use.")
32
+ app.command(
33
+ name="validate",
34
+ help="Validate dooers.yaml, Dockerfile, and SDK dependency/import conventions.",
35
+ )(validate.validate)
36
+ app.command(name="push", help="Archive cwd and push it as a new build of an agent.")(push.push)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ app()
@@ -0,0 +1,28 @@
1
+ """dooers.yaml reader/writer."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from dooers.protocol.agents import AgentManifest
7
+
8
+ MANIFEST_FILENAME = "dooers.yaml"
9
+
10
+
11
+ def read_manifest(directory: Path | None = None) -> AgentManifest | None:
12
+ """Read and validate dooers.yaml from `directory` (default: cwd).
13
+
14
+ Returns None if missing. Raises pydantic.ValidationError on schema violations.
15
+ """
16
+ target = (directory or Path.cwd()) / MANIFEST_FILENAME
17
+ if not target.exists():
18
+ return None
19
+ raw = yaml.safe_load(target.read_text()) or {}
20
+ return AgentManifest.model_validate(raw)
21
+
22
+
23
+ def write_manifest(manifest: AgentManifest, directory: Path | None = None) -> Path:
24
+ """Write `manifest` to `directory/dooers.yaml`. Returns the path written."""
25
+ target = (directory or Path.cwd()) / MANIFEST_FILENAME
26
+ target.parent.mkdir(parents=True, exist_ok=True)
27
+ target.write_text(yaml.safe_dump(manifest.model_dump(mode="json"), sort_keys=False))
28
+ return target
@@ -0,0 +1,105 @@
1
+ """HTTP client for Dooers core v2 (better-auth OTP + agents)."""
2
+
3
+ import time
4
+
5
+ import httpx
6
+ from dooers.protocol.auth import WhoamiResponse
7
+
8
+ ACCESS_TOKEN_FALLBACK_TTL = 60 * 60 * 24 * 7 # 7d if core doesn't tell us
9
+
10
+
11
+ class CoreClientError(RuntimeError):
12
+ """CLI-friendly error."""
13
+
14
+
15
+ def _data(resp: httpx.Response) -> dict:
16
+ body = resp.json()
17
+ if isinstance(body, dict) and body.get("success") is False:
18
+ raise CoreClientError(body.get("error", {}).get("message", f"HTTP {resp.status_code}"))
19
+ if resp.status_code >= 400:
20
+ raise CoreClientError(f"HTTP {resp.status_code}")
21
+ return body.get("data", body) if isinstance(body, dict) else body
22
+
23
+
24
+ class CoreClient:
25
+ def __init__(self, base_url: str, token: str | None = None, timeout: float = 15.0) -> None:
26
+ self.api = base_url.rstrip("/") + "/api/v2"
27
+ self.token = token
28
+ self._timeout = timeout
29
+
30
+ def _headers(self) -> dict[str, str]:
31
+ return {"Authorization": f"Bearer {self.token}"} if self.token else {}
32
+
33
+ # ---- auth ----
34
+ def auth_method(self) -> str:
35
+ try:
36
+ r = httpx.get(f"{self.api}/identity/auth-method", timeout=self._timeout)
37
+ return _data(r).get("method", "otp")
38
+ except httpx.HTTPError as e:
39
+ raise CoreClientError(f"auth-method failed: {e}") from e
40
+
41
+ def send_otp(self, email: str) -> None:
42
+ try:
43
+ r = httpx.post(
44
+ f"{self.api}/auth/email-otp/send-verification-otp",
45
+ json={"email": email, "type": "sign-in"},
46
+ timeout=self._timeout,
47
+ )
48
+ _data(r)
49
+ except httpx.HTTPError as e:
50
+ raise CoreClientError(f"failed to send code: {e}") from e
51
+
52
+ def verify_otp(self, email: str, code: str) -> tuple[str, int]:
53
+ """Returns (bearer_token, expires_at_epoch)."""
54
+ try:
55
+ r = httpx.post(
56
+ f"{self.api}/auth/sign-in/email-otp",
57
+ json={"email": email, "otp": code},
58
+ timeout=self._timeout,
59
+ )
60
+ _data(r) # raises on error envelope
61
+ token = r.headers.get("set-auth-token")
62
+ if not token:
63
+ # fallback: mint via /identity/token using the session cookie just set
64
+ tr = httpx.post(
65
+ f"{self.api}/identity/token", cookies=r.cookies, timeout=self._timeout
66
+ )
67
+ d = _data(tr)
68
+ token = d.get("accessToken")
69
+ if not token:
70
+ raise CoreClientError("core returned no access token")
71
+ return token, int(time.time()) + int(d.get("expiresIn", ACCESS_TOKEN_FALLBACK_TTL))
72
+ return token, int(time.time()) + ACCESS_TOKEN_FALLBACK_TTL
73
+ except httpx.HTTPError as e:
74
+ raise CoreClientError(f"failed to verify code: {e}") from e
75
+
76
+ def me(self) -> WhoamiResponse:
77
+ try:
78
+ r = httpx.get(f"{self.api}/identity/me", headers=self._headers(), timeout=self._timeout)
79
+ d = _data(r)
80
+ # core v2 /identity/me → data.user.{id,email}; tolerate a flat shape too.
81
+ user = d.get("user", d)
82
+ return WhoamiResponse(
83
+ user_id=user.get("id") or user.get("userId") or "",
84
+ email=user.get("email", ""),
85
+ )
86
+ except httpx.HTTPError as e:
87
+ raise CoreClientError(f"me failed: {e}") from e
88
+
89
+ def revoke(self) -> None:
90
+ try:
91
+ httpx.post(
92
+ f"{self.api}/identity/revoke", headers=self._headers(), timeout=self._timeout
93
+ )
94
+ except httpx.HTTPError:
95
+ pass # best-effort
96
+
97
+ def list_organizations(self) -> list[dict]:
98
+ try:
99
+ r = httpx.get(
100
+ f"{self.api}/organizations", headers=self._headers(), timeout=self._timeout
101
+ )
102
+ result = _data(r)
103
+ return list(result) if isinstance(result, list) else []
104
+ except httpx.HTTPError as e:
105
+ raise CoreClientError(f"list organizations failed: {e}") from e