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.
- dooers_cli-0.5.0/.gitignore +7 -0
- dooers_cli-0.5.0/PKG-INFO +75 -0
- dooers_cli-0.5.0/README.md +40 -0
- dooers_cli-0.5.0/pyproject.toml +83 -0
- dooers_cli-0.5.0/src/dooers/cli/__init__.py +3 -0
- dooers_cli-0.5.0/src/dooers/cli/agent_store.py +66 -0
- dooers_cli-0.5.0/src/dooers/cli/agents.py +100 -0
- dooers_cli-0.5.0/src/dooers/cli/auth.py +80 -0
- dooers_cli-0.5.0/src/dooers/cli/cli.py +40 -0
- dooers_cli-0.5.0/src/dooers/cli/config.py +28 -0
- dooers_cli-0.5.0/src/dooers/cli/core_client.py +105 -0
- dooers_cli-0.5.0/src/dooers/cli/ignore.py +89 -0
- dooers_cli-0.5.0/src/dooers/cli/manifest_sync.py +73 -0
- dooers_cli-0.5.0/src/dooers/cli/org.py +82 -0
- dooers_cli-0.5.0/src/dooers/cli/push.py +139 -0
- dooers_cli-0.5.0/src/dooers/cli/push_client.py +52 -0
- dooers_cli-0.5.0/src/dooers/cli/settings.py +29 -0
- dooers_cli-0.5.0/src/dooers/cli/token_store.py +46 -0
- dooers_cli-0.5.0/src/dooers/cli/user_config.py +28 -0
- dooers_cli-0.5.0/src/dooers/cli/validate.py +115 -0
- dooers_cli-0.5.0/src/dooers/py.typed +0 -0
- dooers_cli-0.5.0/tests/__init__.py +0 -0
- dooers_cli-0.5.0/tests/test_agent_store.py +44 -0
- dooers_cli-0.5.0/tests/test_config.py +50 -0
- dooers_cli-0.5.0/tests/test_core_client.py +79 -0
- dooers_cli-0.5.0/tests/test_ignore.py +56 -0
- dooers_cli-0.5.0/tests/test_manifest_sync.py +227 -0
- dooers_cli-0.5.0/tests/test_org.py +21 -0
- dooers_cli-0.5.0/tests/test_push_failure_display.py +22 -0
- dooers_cli-0.5.0/tests/test_push_hosting_guard.py +46 -0
- dooers_cli-0.5.0/tests/test_smoke.py +34 -0
- dooers_cli-0.5.0/tests/test_token_store.py +48 -0
- dooers_cli-0.5.0/tests/test_validate.py +82 -0
- dooers_cli-0.5.0/uv.lock +1057 -0
|
@@ -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,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
|