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 ADDED
@@ -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))
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dooers = dooers.cli.cli:app