dooers-cli 0.5.0__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.
- dooers/cli/__init__.py +3 -0
- dooers/cli/agent_store.py +66 -0
- dooers/cli/agents.py +100 -0
- dooers/cli/auth.py +80 -0
- dooers/cli/cli.py +40 -0
- dooers/cli/config.py +28 -0
- dooers/cli/core_client.py +105 -0
- dooers/cli/ignore.py +89 -0
- dooers/cli/manifest_sync.py +73 -0
- dooers/cli/org.py +82 -0
- dooers/cli/push.py +139 -0
- dooers/cli/push_client.py +52 -0
- dooers/cli/settings.py +29 -0
- dooers/cli/token_store.py +46 -0
- dooers/cli/user_config.py +28 -0
- dooers/cli/validate.py +115 -0
- dooers/py.typed +0 -0
- dooers_cli-0.5.0.dist-info/METADATA +75 -0
- dooers_cli-0.5.0.dist-info/RECORD +21 -0
- dooers_cli-0.5.0.dist-info/WHEEL +4 -0
- dooers_cli-0.5.0.dist-info/entry_points.txt +2 -0
dooers/cli/__init__.py
ADDED
|
@@ -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))
|
dooers/cli/agents.py
ADDED
|
@@ -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 '—'}")
|
dooers/cli/auth.py
ADDED
|
@@ -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.")
|
dooers/cli/cli.py
ADDED
|
@@ -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()
|
dooers/cli/config.py
ADDED
|
@@ -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
|
dooers/cli/ignore.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`.dooersignore` parsing + cwd archiving with default ignore patterns.
|
|
2
|
+
|
|
3
|
+
Ported from v1 cli.py — refactored into pure functions for testability.
|
|
4
|
+
Reference: ../../../deploy-service/cli/dooers/cli.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
import os
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
|
14
|
+
".git/",
|
|
15
|
+
".gitignore",
|
|
16
|
+
".venv/",
|
|
17
|
+
"venv/",
|
|
18
|
+
"node_modules/",
|
|
19
|
+
"__pycache__/",
|
|
20
|
+
"*.pyc",
|
|
21
|
+
".DS_Store",
|
|
22
|
+
"dist/",
|
|
23
|
+
"build/",
|
|
24
|
+
"*.log",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_ignore_patterns(directory: Path | None = None) -> list[str]:
|
|
29
|
+
"""Return default patterns merged with any from `.dooersignore`."""
|
|
30
|
+
directory = directory or Path.cwd()
|
|
31
|
+
patterns = list(DEFAULT_IGNORE_PATTERNS)
|
|
32
|
+
ignore_file = directory / ".dooersignore"
|
|
33
|
+
if ignore_file.exists():
|
|
34
|
+
for raw in ignore_file.read_text().splitlines():
|
|
35
|
+
line = raw.strip()
|
|
36
|
+
if not line or line.startswith("#"):
|
|
37
|
+
continue
|
|
38
|
+
patterns.append(line)
|
|
39
|
+
return patterns
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_ignored(rel_path: str, patterns: list[str]) -> bool:
|
|
43
|
+
"""Check whether `rel_path` matches any pattern (gitignore-style)."""
|
|
44
|
+
posix_path = rel_path.replace(os.sep, "/")
|
|
45
|
+
for pat in patterns:
|
|
46
|
+
pat = pat.strip()
|
|
47
|
+
if not pat:
|
|
48
|
+
continue
|
|
49
|
+
if pat.endswith("/"):
|
|
50
|
+
prefix = pat[:-1]
|
|
51
|
+
if posix_path == prefix or posix_path.startswith(prefix + "/"):
|
|
52
|
+
return True
|
|
53
|
+
if pat.startswith("/"):
|
|
54
|
+
if fnmatch.fnmatch(posix_path, pat.lstrip("/")):
|
|
55
|
+
return True
|
|
56
|
+
if fnmatch.fnmatch(posix_path, pat):
|
|
57
|
+
return True
|
|
58
|
+
if "/" not in pat and pat in posix_path.split("/"):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_archive(directory: str = ".") -> str:
|
|
64
|
+
"""Create a temp .tar.gz of `directory` respecting ignore patterns.
|
|
65
|
+
|
|
66
|
+
Returns the absolute path to the temp archive (caller is responsible
|
|
67
|
+
for cleanup).
|
|
68
|
+
"""
|
|
69
|
+
patterns = load_ignore_patterns(Path(directory))
|
|
70
|
+
tmpfd, tmppath = tempfile.mkstemp(suffix=".tar.gz", prefix="dooers-")
|
|
71
|
+
os.close(tmpfd)
|
|
72
|
+
with tarfile.open(tmppath, "w:gz") as tar:
|
|
73
|
+
for root, dirs, files in os.walk(directory):
|
|
74
|
+
relroot = os.path.relpath(root, directory)
|
|
75
|
+
if relroot == ".":
|
|
76
|
+
relroot = ""
|
|
77
|
+
# prune ignored dirs in-place
|
|
78
|
+
pruned = [
|
|
79
|
+
d for d in dirs
|
|
80
|
+
if is_ignored((os.path.join(relroot, d) if relroot else d) + "/", patterns)
|
|
81
|
+
]
|
|
82
|
+
for d in pruned:
|
|
83
|
+
dirs.remove(d)
|
|
84
|
+
for name in files:
|
|
85
|
+
rel = os.path.join(relroot, name) if relroot else name
|
|
86
|
+
if is_ignored(rel, patterns):
|
|
87
|
+
continue
|
|
88
|
+
tar.add(os.path.join(root, name), arcname=rel)
|
|
89
|
+
return tmppath
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Build the core PATCH body from a manifest + the deployed URL."""
|
|
2
|
+
|
|
3
|
+
from dooers.protocol.agents import AgentManifest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _host_and_seg(deployed_url: str) -> str:
|
|
7
|
+
"""'https://agents.dooers.ai/ag-x' -> 'agents.dooers.ai/ag-x' (no scheme, no trailing /)."""
|
|
8
|
+
return deployed_url.split("://", 1)[-1].rstrip("/")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _norm_path(p: str) -> str:
|
|
12
|
+
return p if p.startswith("/") else "/" + p
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_agent_patch(manifest: AgentManifest, deployed_url: str) -> dict:
|
|
16
|
+
"""Map the declarative manifest to a core v2 PATCH /agents/:id body.
|
|
17
|
+
|
|
18
|
+
Derives serverConfig.apiMessagesUrl (and whatsapp inbound) from the
|
|
19
|
+
deployed host + the declared paths. Only includes keys the creator set.
|
|
20
|
+
"""
|
|
21
|
+
host = _host_and_seg(deployed_url)
|
|
22
|
+
patch: dict = {}
|
|
23
|
+
|
|
24
|
+
# Only sync a non-empty description. An empty/absent one means "leave as-is"
|
|
25
|
+
# — never send null, which would wipe a description set elsewhere (e.g. Studio).
|
|
26
|
+
if manifest.description:
|
|
27
|
+
patch["description"] = manifest.description
|
|
28
|
+
|
|
29
|
+
if manifest.message_path:
|
|
30
|
+
path = _norm_path(manifest.message_path)
|
|
31
|
+
url = f"{manifest.message_scheme}://{host}{path}".rstrip("/")
|
|
32
|
+
patch["serverConfig"] = {"apiMessagesUrl": url}
|
|
33
|
+
|
|
34
|
+
if manifest.whatsapp and manifest.whatsapp.enabled:
|
|
35
|
+
wpath = _norm_path(manifest.whatsapp.path or "/whatsapp/inbound")
|
|
36
|
+
patch.setdefault("settings", {}).setdefault("integration_settings", {})["whatsapp"] = {
|
|
37
|
+
"enabled": True,
|
|
38
|
+
"inbound_http_url": f"https://{host}{wpath}",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if manifest.profile:
|
|
42
|
+
prof: dict = {}
|
|
43
|
+
p = manifest.profile
|
|
44
|
+
if p.summary is not None:
|
|
45
|
+
prof["summary"] = p.summary or None
|
|
46
|
+
if p.image_url is not None:
|
|
47
|
+
prof["imageUrl"] = p.image_url or None
|
|
48
|
+
if p.capabilities:
|
|
49
|
+
prof["capabilities"] = p.capabilities
|
|
50
|
+
if p.tools:
|
|
51
|
+
prof["tools"] = p.tools
|
|
52
|
+
if p.usage_limits:
|
|
53
|
+
prof["usageLimits"] = p.usage_limits
|
|
54
|
+
if prof:
|
|
55
|
+
patch["profile"] = prof
|
|
56
|
+
|
|
57
|
+
if manifest.ui:
|
|
58
|
+
ui: dict = {}
|
|
59
|
+
u = manifest.ui
|
|
60
|
+
if u.hide_mic is not None:
|
|
61
|
+
ui["hide_mic"] = u.hide_mic
|
|
62
|
+
if u.hide_textinput is not None:
|
|
63
|
+
ui["hide_textinput"] = u.hide_textinput
|
|
64
|
+
if u.hide_attachments is not None:
|
|
65
|
+
ui["hide_attachments"] = u.hide_attachments
|
|
66
|
+
if u.suggested_prompts:
|
|
67
|
+
ui["suggested_prompts"] = [
|
|
68
|
+
{"title": sp.title, "prompt": sp.prompt} for sp in u.suggested_prompts
|
|
69
|
+
]
|
|
70
|
+
if ui:
|
|
71
|
+
patch.setdefault("settings", {})["dooersUi_settings"] = ui
|
|
72
|
+
|
|
73
|
+
return patch
|
dooers/cli/org.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Org resolution + `dooers org list|use`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from dooers.cli.core_client import CoreClient, CoreClientError
|
|
8
|
+
from dooers.cli.settings import Settings
|
|
9
|
+
from dooers.cli.token_store import TokenStore, is_token_expired
|
|
10
|
+
from dooers.cli.user_config import UserConfig
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_org(
|
|
16
|
+
*,
|
|
17
|
+
orgs: list[dict],
|
|
18
|
+
explicit: str | None,
|
|
19
|
+
default: str | None,
|
|
20
|
+
prompt: Callable[[list[dict]], str],
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Precedence: explicit flag > saved default > single-org auto > prompt."""
|
|
23
|
+
if explicit:
|
|
24
|
+
return explicit
|
|
25
|
+
ids = {o["organizationId"] for o in orgs}
|
|
26
|
+
if default and default in ids:
|
|
27
|
+
return default
|
|
28
|
+
if len(orgs) == 1:
|
|
29
|
+
return orgs[0]["organizationId"]
|
|
30
|
+
return prompt(orgs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _token(settings: Settings) -> str:
|
|
34
|
+
store = TokenStore()
|
|
35
|
+
token = store.load()
|
|
36
|
+
if not token or is_token_expired(token, store=store):
|
|
37
|
+
typer.echo("Not authenticated. Run `dooers login`.", err=True)
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
return token
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_org_for_cli(settings: Settings, explicit: str | None) -> str:
|
|
43
|
+
"""Fetch orgs, apply precedence, persist the chosen default when prompting."""
|
|
44
|
+
token = _token(settings)
|
|
45
|
+
try:
|
|
46
|
+
orgs = CoreClient(base_url=settings.core_url, token=token).list_organizations()
|
|
47
|
+
except CoreClientError as e:
|
|
48
|
+
typer.echo(f"could not list organizations: {e}", err=True)
|
|
49
|
+
raise typer.Exit(code=1) from e
|
|
50
|
+
if not orgs:
|
|
51
|
+
typer.echo("You don't belong to any organization.", err=True)
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
cfg = UserConfig()
|
|
54
|
+
|
|
55
|
+
def _prompt(options: list[dict]) -> str:
|
|
56
|
+
typer.echo("Multiple organizations — choose one:")
|
|
57
|
+
for i, o in enumerate(options, 1):
|
|
58
|
+
typer.echo(f" {i}. {o.get('name', o['organizationId'])} ({o['organizationId']})")
|
|
59
|
+
idx = typer.prompt("Number", type=int)
|
|
60
|
+
chosen = options[idx - 1]["organizationId"]
|
|
61
|
+
cfg.set_default_org(chosen)
|
|
62
|
+
typer.echo(f"Saved default org: {chosen}")
|
|
63
|
+
return chosen
|
|
64
|
+
|
|
65
|
+
return resolve_org(orgs=orgs, explicit=explicit, default=cfg.get_default_org(), prompt=_prompt)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command(name="list")
|
|
69
|
+
def list_orgs(ctx: typer.Context) -> None:
|
|
70
|
+
settings: Settings = ctx.obj
|
|
71
|
+
token = _token(settings)
|
|
72
|
+
orgs = CoreClient(base_url=settings.core_url, token=token).list_organizations()
|
|
73
|
+
default = UserConfig().get_default_org()
|
|
74
|
+
for o in orgs:
|
|
75
|
+
mark = " (default)" if o["organizationId"] == default else ""
|
|
76
|
+
typer.echo(f"{o['organizationId']} {o.get('name', '')}{mark}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def use(ctx: typer.Context, organization_id: str = typer.Argument(...)) -> None:
|
|
81
|
+
UserConfig().set_default_org(organization_id)
|
|
82
|
+
typer.echo(f"Default org set to {organization_id}")
|
dooers/cli/push.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""`dooers push` — archive cwd and POST to dooers-push."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from dooers.protocol.push import format_push_failure
|
|
12
|
+
|
|
13
|
+
from dooers.cli import config, ignore
|
|
14
|
+
from dooers.cli.manifest_sync import build_agent_patch
|
|
15
|
+
from dooers.cli.push_client import PushClient, PushClientError
|
|
16
|
+
from dooers.cli.settings import Settings
|
|
17
|
+
from dooers.cli.token_store import TokenStore, is_token_expired
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _spinner(message: str) -> Callable[[], None]:
|
|
21
|
+
"""Background spinner on stderr. Returns a stopper function."""
|
|
22
|
+
stop = threading.Event()
|
|
23
|
+
frames = "|/-\\"
|
|
24
|
+
|
|
25
|
+
def run() -> None:
|
|
26
|
+
i = 0
|
|
27
|
+
while not stop.is_set():
|
|
28
|
+
sys.stderr.write(f"\r{message} {frames[i % 4]}")
|
|
29
|
+
sys.stderr.flush()
|
|
30
|
+
i += 1
|
|
31
|
+
time.sleep(0.1)
|
|
32
|
+
sys.stderr.write("\r" + " " * (len(message) + 4) + "\r")
|
|
33
|
+
sys.stderr.flush()
|
|
34
|
+
|
|
35
|
+
t = threading.Thread(target=run, daemon=True)
|
|
36
|
+
t.start()
|
|
37
|
+
|
|
38
|
+
def cancel() -> None:
|
|
39
|
+
stop.set()
|
|
40
|
+
t.join(timeout=1)
|
|
41
|
+
|
|
42
|
+
return cancel
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def push(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
agent_id: str | None = typer.Argument(
|
|
48
|
+
None,
|
|
49
|
+
help="Agent ID. If omitted, reads agent_id from ./dooers.yaml.",
|
|
50
|
+
),
|
|
51
|
+
tag: str = typer.Option("latest", help="Docker image tag."),
|
|
52
|
+
env: str | None = typer.Option(
|
|
53
|
+
None, help="Target environment: prod | stg | dev (overrides --env on the root)."
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Push the current directory as a new build of an agent."""
|
|
57
|
+
settings: Settings = ctx.obj
|
|
58
|
+
target_env = env or settings.env
|
|
59
|
+
|
|
60
|
+
# Resolve agent_id from arg or manifest.
|
|
61
|
+
manifest = config.read_manifest()
|
|
62
|
+
if agent_id is None:
|
|
63
|
+
if manifest is None:
|
|
64
|
+
typer.echo(
|
|
65
|
+
f"Missing {config.MANIFEST_FILENAME}. Run `dooers agents create` first "
|
|
66
|
+
f"or pass an agent_id explicitly.",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
agent_id = manifest.agent_id
|
|
71
|
+
|
|
72
|
+
# Client-side hosting guard. The dooers-push server is the authoritative
|
|
73
|
+
# gate (it checks the org's hosting plan feature); this is a fast local
|
|
74
|
+
# check so `hosting: false` in dooers.yaml never even uploads.
|
|
75
|
+
if manifest is not None and not manifest.hosting:
|
|
76
|
+
typer.echo(
|
|
77
|
+
"hosting is disabled in dooers.yaml (hosting: false). Aborting push.",
|
|
78
|
+
err=True,
|
|
79
|
+
)
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
# Auth.
|
|
83
|
+
store = TokenStore()
|
|
84
|
+
token = store.load()
|
|
85
|
+
if not token or is_token_expired(token, store=store):
|
|
86
|
+
typer.echo("Not authenticated. Run `dooers login`.", err=True)
|
|
87
|
+
raise typer.Exit(code=1)
|
|
88
|
+
|
|
89
|
+
# Archive cwd.
|
|
90
|
+
typer.echo("Archiving …")
|
|
91
|
+
archive_path = Path(ignore.make_archive("."))
|
|
92
|
+
size_mb = archive_path.stat().st_size / (1024 * 1024)
|
|
93
|
+
typer.echo(f"Archive: {archive_path.name} ({size_mb:.1f} MB)")
|
|
94
|
+
|
|
95
|
+
# Push.
|
|
96
|
+
client = PushClient(base_url=settings.push_url, token=token)
|
|
97
|
+
cancel_spinner = _spinner(f"Pushing {agent_id} (this can take 3-5 min)")
|
|
98
|
+
try:
|
|
99
|
+
resp = client.push(agent_id=agent_id, archive_path=archive_path, tag=tag, env=target_env)
|
|
100
|
+
except PushClientError as e:
|
|
101
|
+
cancel_spinner()
|
|
102
|
+
typer.echo(f"Push failed: {e}", err=True)
|
|
103
|
+
raise typer.Exit(code=1) from e
|
|
104
|
+
finally:
|
|
105
|
+
cancel_spinner()
|
|
106
|
+
os.unlink(archive_path)
|
|
107
|
+
|
|
108
|
+
# Report.
|
|
109
|
+
if resp.audit and resp.audit.required_infra.detected_endpoints:
|
|
110
|
+
endpoints = resp.audit.required_infra.detected_endpoints
|
|
111
|
+
typer.echo(f"\nAudit: {len(endpoints)} endpoint(s) detected:")
|
|
112
|
+
for ep in endpoints[:10]:
|
|
113
|
+
typer.echo(f" - {ep}")
|
|
114
|
+
if len(endpoints) > 10:
|
|
115
|
+
typer.echo(f" … and {len(endpoints) - 10} more")
|
|
116
|
+
elif resp.audit:
|
|
117
|
+
typer.echo("\nAudit: 0 endpoints detected.")
|
|
118
|
+
if resp.status.value == "succeeded" and resp.url:
|
|
119
|
+
typer.echo(f"\nLive at: {resp.url}")
|
|
120
|
+
if target_env == "prod":
|
|
121
|
+
try:
|
|
122
|
+
manifest = config.read_manifest()
|
|
123
|
+
if manifest is not None:
|
|
124
|
+
patch = build_agent_patch(manifest, resp.url)
|
|
125
|
+
if patch:
|
|
126
|
+
from dooers.cli.agent_store import HTTPCoreAgentStore
|
|
127
|
+
|
|
128
|
+
HTTPCoreAgentStore(settings.core_url, token).update(
|
|
129
|
+
manifest.agent_id, patch
|
|
130
|
+
)
|
|
131
|
+
typer.echo("Synced agent config to core.")
|
|
132
|
+
except Exception as e: # noqa: BLE001 — non-fatal: agent is live, sync is best-effort
|
|
133
|
+
typer.echo(f"Warning: could not sync agent config: {e}", err=True)
|
|
134
|
+
elif resp.status.value == "failed":
|
|
135
|
+
typer.echo(f"\n{format_push_failure(resp)}", err=True)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
else:
|
|
138
|
+
typer.echo(f"\nStatus: {resp.status.value}")
|
|
139
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""HTTP client for dooers-push (multipart upload + synchronous wait)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from dooers.protocol.errors import ErrorEnvelope
|
|
7
|
+
from dooers.protocol.push import PushResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PushClientError(RuntimeError):
|
|
11
|
+
def __init__(self, message: str, envelope: ErrorEnvelope | None = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.envelope = envelope
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PushClient:
|
|
17
|
+
def __init__(self, base_url: str, token: str, timeout: float = 600.0) -> None:
|
|
18
|
+
self.base_url = base_url.rstrip("/")
|
|
19
|
+
self.token = token
|
|
20
|
+
self._timeout = timeout
|
|
21
|
+
|
|
22
|
+
def push(
|
|
23
|
+
self,
|
|
24
|
+
agent_id: str,
|
|
25
|
+
archive_path: Path,
|
|
26
|
+
tag: str = "latest",
|
|
27
|
+
env: str = "prod",
|
|
28
|
+
) -> PushResponse:
|
|
29
|
+
url = f"{self.base_url}/v1/push/{agent_id}"
|
|
30
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
31
|
+
params = {"tag": tag, "env": env}
|
|
32
|
+
with archive_path.open("rb") as f:
|
|
33
|
+
files = {"archive": (archive_path.name, f, "application/gzip")}
|
|
34
|
+
try:
|
|
35
|
+
r = httpx.post(
|
|
36
|
+
url,
|
|
37
|
+
headers=headers,
|
|
38
|
+
params=params,
|
|
39
|
+
files=files,
|
|
40
|
+
timeout=self._timeout,
|
|
41
|
+
)
|
|
42
|
+
except httpx.HTTPError as e:
|
|
43
|
+
raise PushClientError(f"push request failed: {e}") from e
|
|
44
|
+
|
|
45
|
+
if r.status_code >= 400:
|
|
46
|
+
try:
|
|
47
|
+
envelope = ErrorEnvelope.model_validate(r.json())
|
|
48
|
+
raise PushClientError(envelope.message, envelope=envelope)
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
raise PushClientError(f"push failed (HTTP {r.status_code}): {r.text}")
|
|
51
|
+
|
|
52
|
+
return PushResponse.model_validate(r.json())
|
dooers/cli/settings.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Global CLI configuration: core URL, push URL, env.
|
|
2
|
+
|
|
3
|
+
Precedence: explicit CLI flag > env var > built-in default.
|
|
4
|
+
The top-level Typer callback resolves this once and stashes it on the
|
|
5
|
+
Typer context so every subcommand sees the same values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
core_url: str
|
|
15
|
+
push_url: str
|
|
16
|
+
env: str
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def resolve(
|
|
20
|
+
cls,
|
|
21
|
+
core_url: str | None = None,
|
|
22
|
+
push_url: str | None = None,
|
|
23
|
+
env: str | None = None,
|
|
24
|
+
) -> "Settings":
|
|
25
|
+
return cls(
|
|
26
|
+
core_url=(core_url or os.environ.get("DOOERS_CORE_URL") or "https://api.dooers.ai").rstrip("/"),
|
|
27
|
+
push_url=(push_url or os.environ.get("DOOERS_PUSH_URL") or "https://host.dooers.ai").rstrip("/"),
|
|
28
|
+
env=(env or os.environ.get("DOOERS_ENV") or "prod"),
|
|
29
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Persisted auth token at ~/.dooers/token.json (0600). Stores token + expiry."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_TOKEN_PATH = Path.home() / ".dooers" / "token.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenStore:
|
|
11
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
12
|
+
self.path = path or DEFAULT_TOKEN_PATH
|
|
13
|
+
|
|
14
|
+
def _read(self) -> dict:
|
|
15
|
+
if not self.path.exists():
|
|
16
|
+
return {}
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(self.path.read_text())
|
|
19
|
+
except (OSError, ValueError):
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
def load(self) -> str | None:
|
|
23
|
+
return self._read().get("token") or None
|
|
24
|
+
|
|
25
|
+
def expires_at(self) -> int:
|
|
26
|
+
return int(self._read().get("expires_at", 0))
|
|
27
|
+
|
|
28
|
+
def save(self, token: str, expires_at: int) -> None:
|
|
29
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.path.write_text(json.dumps({"token": token, "expires_at": expires_at}))
|
|
31
|
+
self.path.chmod(0o600)
|
|
32
|
+
|
|
33
|
+
def clear(self) -> None:
|
|
34
|
+
try:
|
|
35
|
+
self.path.unlink()
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_token_expired(token: str | None, store: "TokenStore | None" = None) -> bool:
|
|
41
|
+
"""True if no token or the stored expiry has passed."""
|
|
42
|
+
if not token:
|
|
43
|
+
return True
|
|
44
|
+
store = store or TokenStore()
|
|
45
|
+
exp = store.expires_at()
|
|
46
|
+
return exp == 0 or time.time() >= exp
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""~/.dooers/config.json — non-secret CLI prefs (default org)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".dooers" / "config.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UserConfig:
|
|
10
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
11
|
+
self.path = path or DEFAULT_CONFIG_PATH
|
|
12
|
+
|
|
13
|
+
def _read(self) -> dict:
|
|
14
|
+
if not self.path.exists():
|
|
15
|
+
return {}
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(self.path.read_text())
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
def get_default_org(self) -> str | None:
|
|
22
|
+
return self._read().get("default_org")
|
|
23
|
+
|
|
24
|
+
def set_default_org(self, org_id: str) -> None:
|
|
25
|
+
data = self._read()
|
|
26
|
+
data["default_org"] = org_id
|
|
27
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self.path.write_text(json.dumps(data))
|
dooers/cli/validate.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Pre-push validation for agent projects (`dooers validate`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from dooers.cli.config import MANIFEST_FILENAME, read_manifest
|
|
13
|
+
|
|
14
|
+
_LEGACY_SDK_DEP = re.compile(r"dooers-agents(?!-server|-client)")
|
|
15
|
+
_LEGACY_IMPORT = re.compile(
|
|
16
|
+
r"^\s*(?:"
|
|
17
|
+
r"from dooers_agents(?:\.|\s+import)|"
|
|
18
|
+
r"from dooers import|"
|
|
19
|
+
r"import dooers_agents(?:\s|$)|"
|
|
20
|
+
r"import dooers(?:\s|$)"
|
|
21
|
+
r")",
|
|
22
|
+
re.MULTILINE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ValidationIssue:
|
|
28
|
+
level: str # "error" | "warning"
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def collect_issues(root: Path | None = None) -> list[ValidationIssue]:
|
|
33
|
+
"""Return validation findings for an agent repo rooted at `root` (default: cwd)."""
|
|
34
|
+
base = root or Path.cwd()
|
|
35
|
+
issues: list[ValidationIssue] = []
|
|
36
|
+
|
|
37
|
+
manifest_path = base / MANIFEST_FILENAME
|
|
38
|
+
if not manifest_path.exists():
|
|
39
|
+
issues.append(
|
|
40
|
+
ValidationIssue(
|
|
41
|
+
"error",
|
|
42
|
+
f"missing {MANIFEST_FILENAME} — run `dooers agents create`",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
try:
|
|
47
|
+
read_manifest(directory=base)
|
|
48
|
+
except ValidationError as e:
|
|
49
|
+
issues.append(ValidationIssue("error", f"{MANIFEST_FILENAME} is invalid: {e}"))
|
|
50
|
+
|
|
51
|
+
dockerfile = base / "Dockerfile"
|
|
52
|
+
if not dockerfile.exists():
|
|
53
|
+
issues.append(ValidationIssue("error", "missing Dockerfile"))
|
|
54
|
+
|
|
55
|
+
pyproject = base / "pyproject.toml"
|
|
56
|
+
if not pyproject.exists():
|
|
57
|
+
issues.append(ValidationIssue("warning", "missing pyproject.toml"))
|
|
58
|
+
else:
|
|
59
|
+
text = pyproject.read_text()
|
|
60
|
+
if _LEGACY_SDK_DEP.search(text):
|
|
61
|
+
issues.append(
|
|
62
|
+
ValidationIssue(
|
|
63
|
+
"error",
|
|
64
|
+
"pyproject.toml still depends on legacy `dooers-agents` — "
|
|
65
|
+
"use `dooers-agents-server` with `from dooers.agents.server import ...`",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
elif "dooers-agents-server" not in text:
|
|
69
|
+
issues.append(
|
|
70
|
+
ValidationIssue(
|
|
71
|
+
"warning",
|
|
72
|
+
"pyproject.toml has no `dooers-agents-server` dependency — "
|
|
73
|
+
"agent projects usually depend on the SDK",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
scan_roots = [p for p in (base / "src", base) if p.is_dir()]
|
|
78
|
+
py_files: list[Path] = []
|
|
79
|
+
for scan_root in scan_roots:
|
|
80
|
+
py_files.extend(scan_root.rglob("*.py"))
|
|
81
|
+
seen: set[Path] = set()
|
|
82
|
+
for path in sorted(py_files):
|
|
83
|
+
if path in seen or ".venv" in path.parts:
|
|
84
|
+
continue
|
|
85
|
+
seen.add(path)
|
|
86
|
+
if _LEGACY_IMPORT.search(path.read_text()):
|
|
87
|
+
rel = path.relative_to(base)
|
|
88
|
+
issues.append(
|
|
89
|
+
ValidationIssue(
|
|
90
|
+
"error",
|
|
91
|
+
f"{rel} uses legacy SDK imports — "
|
|
92
|
+
"update to `from dooers.agents.server import ...`",
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return issues
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate(ctx: typer.Context) -> None:
|
|
100
|
+
"""Validate dooers.yaml, Dockerfile, and SDK dependency/import conventions."""
|
|
101
|
+
_ = ctx
|
|
102
|
+
issues = collect_issues()
|
|
103
|
+
errors = [i for i in issues if i.level == "error"]
|
|
104
|
+
warnings = [i for i in issues if i.level == "warning"]
|
|
105
|
+
|
|
106
|
+
for issue in issues:
|
|
107
|
+
prefix = "Error" if issue.level == "error" else "Warning"
|
|
108
|
+
typer.echo(f"{prefix}: {issue.message}", err=issue.level == "error")
|
|
109
|
+
|
|
110
|
+
if errors:
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
if warnings:
|
|
113
|
+
typer.echo("Validation passed with warnings.")
|
|
114
|
+
else:
|
|
115
|
+
typer.echo("Validation passed.")
|
dooers/py.typed
ADDED
|
File without changes
|
|
@@ -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,21 @@
|
|
|
1
|
+
dooers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
dooers/cli/__init__.py,sha256=qAXc0sh11tQ5IrNt43LvhiFLequXFxczawSmimZnm3Y,56
|
|
3
|
+
dooers/cli/agent_store.py,sha256=GCZ30pa7JkuWuAro5ZpH_7ko8J448Ku_Dg96IgakRmQ,2253
|
|
4
|
+
dooers/cli/agents.py,sha256=R4FIAH2HLRTCAC2iDmf-8OBTCaxZ_FyyOaZ4E-m6E6A,3537
|
|
5
|
+
dooers/cli/auth.py,sha256=CTldHElS05jmhbQYzR5fCTorXTrSMIqJjz_P1Z40LWA,2557
|
|
6
|
+
dooers/cli/cli.py,sha256=sbui1MJEdtz3gHdnqxmjq__Y-nxRuT4JTrQF_Hl2kEc,1546
|
|
7
|
+
dooers/cli/config.py,sha256=1JuYa1qR5r6WNaqjpmyxegZN4y-u4iFLRdHOODtvXxM,983
|
|
8
|
+
dooers/cli/core_client.py,sha256=bO7fawkaXrF7X_bdvAY1RRb_GmmCy3dUw22HZBI-4WQ,4068
|
|
9
|
+
dooers/cli/ignore.py,sha256=Mez7nW7ap7nz58aSQR0c9tBhkb7ByyTJ0DW1k4LrBqY,2853
|
|
10
|
+
dooers/cli/manifest_sync.py,sha256=QaY2Y-xSuboo4_8mwQnlD1iAx-dOHKjbLDZeHSchFEc,2666
|
|
11
|
+
dooers/cli/org.py,sha256=2AEeakY1NNUX1YyxLBLctDY_YG07kkKVvIIXwy8ZECU,2836
|
|
12
|
+
dooers/cli/push.py,sha256=-qIWYcZWcJLqsm6YIKWsPTiPOSNRUIqXeYfkIy8en1g,4952
|
|
13
|
+
dooers/cli/push_client.py,sha256=J0SpxN6_tUzyKbLW7DTu6yPBVdPwVhovDzVM0GsMuhQ,1770
|
|
14
|
+
dooers/cli/settings.py,sha256=ZPwpcsRm679q9Thnd3Qj5GJYr5QO2N8a27lsvDce0FY,870
|
|
15
|
+
dooers/cli/token_store.py,sha256=CigyeyFCMAZV_gkYlPsuLnX2KV0Af1aXi-PGyAK-7E0,1351
|
|
16
|
+
dooers/cli/user_config.py,sha256=ek3H_rJ-Lvgu6My_1u5otw9BFn9h9ZL9__kRNcaCmJU,826
|
|
17
|
+
dooers/cli/validate.py,sha256=jtXB4xSMxBWhd3ZkjwhjnOMJ9nDP8P7sA3EpicR3qV0,3666
|
|
18
|
+
dooers_cli-0.5.0.dist-info/METADATA,sha256=j2psdjHIkzcPMp60cBZWURwQrw29pV9CLXG7rBAo60o,2221
|
|
19
|
+
dooers_cli-0.5.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
dooers_cli-0.5.0.dist-info/entry_points.txt,sha256=2D_sdksnDQmQsoVZnnhrvthjPuxGZY4nsCr66ORq1Ao,46
|
|
21
|
+
dooers_cli-0.5.0.dist-info/RECORD,,
|