dooers-cli 0.8.2__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/PKG-INFO +21 -2
  2. dooers_cli-0.9.0/README.md +59 -0
  3. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/pyproject.toml +2 -2
  4. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/cli.py +13 -4
  5. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/config.py +20 -0
  6. dooers_cli-0.9.0/src/dooers/cli/logs.py +266 -0
  7. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/push.py +6 -10
  8. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/push_client.py +29 -0
  9. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_env_prod.py +2 -1
  10. dooers_cli-0.9.0/tests/test_logs.py +439 -0
  11. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/uv.lock +2 -2
  12. dooers_cli-0.8.2/README.md +0 -40
  13. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/.gitignore +0 -0
  14. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/__init__.py +0 -0
  15. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/agent_store.py +0 -0
  16. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/agents.py +0 -0
  17. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/auth.py +0 -0
  18. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/core_client.py +0 -0
  19. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/env_prod.py +0 -0
  20. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/ignore.py +0 -0
  21. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/manifest_sync.py +0 -0
  22. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/org.py +0 -0
  23. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/settings.py +0 -0
  24. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/token_store.py +0 -0
  25. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/user_config.py +0 -0
  26. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/validate.py +0 -0
  27. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/py.typed +0 -0
  28. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/__init__.py +0 -0
  29. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agent_store.py +0 -0
  30. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agents_delete.py +0 -0
  31. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agents_update.py +0 -0
  32. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_config.py +0 -0
  33. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_core_client.py +0 -0
  34. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_encoding.py +0 -0
  35. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_ignore.py +0 -0
  36. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_manifest_sync.py +0 -0
  37. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_org.py +0 -0
  38. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_client_async.py +0 -0
  39. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_failure_display.py +0 -0
  40. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_hosting_guard.py +0 -0
  41. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_poll.py +0 -0
  42. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_teardown.py +0 -0
  43. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_smoke.py +0 -0
  44. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_token_store.py +0 -0
  45. {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dooers-cli
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal
5
5
  Project-URL: Repository, https://github.com/Dooers-ai/dooers-cli
6
6
  Author-email: Dooers <dev@dooers.ai>
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.10
18
- Requires-Dist: dooers-protocol>=0.7.0
18
+ Requires-Dist: dooers-protocol>=0.8.0
19
19
  Requires-Dist: httpx>=0.28.1
20
20
  Requires-Dist: pydantic>=2.13.2
21
21
  Requires-Dist: pyyaml>=6.0.2
@@ -57,8 +57,27 @@ dooers agents show ag_8h2k
57
57
  # push (reads dooers.yaml in cwd unless agent_id given)
58
58
  dooers validate
59
59
  dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
60
+
61
+ # logs (same agent resolution as push)
62
+ dooers logs [<agent_id>] [--since 15m] [--limit 100] [--json]
63
+ dooers logs --follow # tail; Ctrl-C to stop
60
64
  ```
61
65
 
66
+ ### `dooers logs`
67
+
68
+ Reads the deployment logs of an agent from `dooers-push`.
69
+
70
+ | Flag | Default | Meaning |
71
+ |---|---|---|
72
+ | `--since` | `15m` | How far back to start — a duration: `45s`, `15m`, `1h`, `2h30m`. |
73
+ | `--limit` | `100` | Max entries per request (the server caps at 1000). |
74
+ | `--follow`, `-f` | off | Poll every 2s and print new entries until interrupted. |
75
+ | `--json` | off | One compact JSON object per line (`timestamp`, `severity`, `message`) instead of formatted text. |
76
+
77
+ Default output is `HH:MM:SS LEVEL message`, with the level colourised
78
+ (ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim). Times are shown in the
79
+ timestamp's own zone (UTC, as emitted) — never converted to the local clock.
80
+
62
81
  ## Global config (precedence: flag > env > default)
63
82
 
64
83
  | Setting | Flag | Env var | Default |
@@ -0,0 +1,59 @@
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
+ # logs (same agent resolution as push)
27
+ dooers logs [<agent_id>] [--since 15m] [--limit 100] [--json]
28
+ dooers logs --follow # tail; Ctrl-C to stop
29
+ ```
30
+
31
+ ### `dooers logs`
32
+
33
+ Reads the deployment logs of an agent from `dooers-push`.
34
+
35
+ | Flag | Default | Meaning |
36
+ |---|---|---|
37
+ | `--since` | `15m` | How far back to start — a duration: `45s`, `15m`, `1h`, `2h30m`. |
38
+ | `--limit` | `100` | Max entries per request (the server caps at 1000). |
39
+ | `--follow`, `-f` | off | Poll every 2s and print new entries until interrupted. |
40
+ | `--json` | off | One compact JSON object per line (`timestamp`, `severity`, `message`) instead of formatted text. |
41
+
42
+ Default output is `HH:MM:SS LEVEL message`, with the level colourised
43
+ (ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim). Times are shown in the
44
+ timestamp's own zone (UTC, as emitted) — never converted to the local clock.
45
+
46
+ ## Global config (precedence: flag > env > default)
47
+
48
+ | Setting | Flag | Env var | Default |
49
+ |---|---|---|---|
50
+ | Core API host | `--core-url` | `DOOERS_CORE_URL` | `https://api.dooers.ai` |
51
+ | dooers-push host | `--push-url` | `DOOERS_PUSH_URL` | `https://host.dooers.ai` |
52
+ | Target environment | `--env` | `DOOERS_ENV` | `prod` |
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ uv sync --extra dev
58
+ uv run poe dev
59
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dooers-cli"
3
- version = "0.8.2"
3
+ version = "0.9.0"
4
4
  description = "Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  "pydantic>=2.13.2",
27
27
  "pyyaml>=6.0.2",
28
28
  "tqdm>=4.66.4",
29
- "dooers-protocol>=0.7.0",
29
+ "dooers-protocol>=0.8.0",
30
30
  ]
31
31
 
32
32
  [tool.uv.sources]
@@ -7,14 +7,19 @@ import warnings
7
7
  # frames; suppress so every command stays quiet when both packages share `dooers.*`.
8
8
  warnings.filterwarnings(
9
9
  "ignore",
10
- message=r'Field name "schema" in "SettingsPublicSchemaResultPayload" shadows an attribute in parent "BaseModel"',
10
+ message=(
11
+ r'Field name "schema" in "SettingsPublicSchemaResultPayload" '
12
+ r'shadows an attribute in parent "BaseModel"'
13
+ ),
11
14
  category=UserWarning,
12
15
  )
13
16
 
14
- import typer
17
+ # E402 below is deliberate: the filter above must be installed before anything
18
+ # imports the models that trigger the warning.
19
+ import typer # noqa: E402
15
20
 
16
- from . import agents, auth, org, push, validate
17
- from .settings import Settings
21
+ from . import agents, auth, logs, org, push, validate # noqa: E402
22
+ from .settings import Settings # noqa: E402
18
23
 
19
24
  app = typer.Typer(
20
25
  name="dooers",
@@ -49,6 +54,10 @@ app.command(
49
54
  help="Validate dooers.yaml, Dockerfile, and SDK dependency/import conventions.",
50
55
  )(validate.validate)
51
56
  app.command(name="push", help="Archive cwd and push it as a new build of an agent.")(push.push)
57
+ app.command(
58
+ name="logs",
59
+ help="Show an agent's deployment logs; --follow to tail them.",
60
+ )(logs.logs)
52
61
 
53
62
 
54
63
  if __name__ == "__main__":
@@ -8,6 +8,26 @@ from dooers.protocol.agents import AgentManifest
8
8
  MANIFEST_FILENAME = "dooers.yaml"
9
9
 
10
10
 
11
+ class MissingAgentIdError(RuntimeError):
12
+ """No agent id was given and no dooers.yaml was found to read one from."""
13
+
14
+
15
+ def resolve_agent_id(explicit: str | None, manifest: AgentManifest | None) -> str:
16
+ """Resolve which agent a command targets: the explicit argument wins, else dooers.yaml.
17
+
18
+ The single rule shared by every command that takes an optional agent id
19
+ (`dooers push`, `dooers logs`) — do not re-derive it per command.
20
+ """
21
+ if explicit is not None:
22
+ return explicit
23
+ if manifest is None:
24
+ raise MissingAgentIdError(
25
+ f"Missing {MANIFEST_FILENAME}. Run `dooers agents create` first "
26
+ f"or pass an agent_id explicitly."
27
+ )
28
+ return manifest.agent_id
29
+
30
+
11
31
  def read_manifest(directory: Path | None = None) -> AgentManifest | None:
12
32
  """Read and validate dooers.yaml from `directory` (default: cwd).
13
33
 
@@ -0,0 +1,266 @@
1
+ """`dooers logs` — read (and optionally tail) the deployment logs of an agent.
2
+
3
+ Reads `GET /v1/agents/{id}/logs` on dooers-push. In `--follow` mode the command
4
+ re-polls with `since` pinned to the newest timestamp already printed, so each
5
+ poll fetches a small overlapping window; the overlap is filtered client-side by
6
+ `_Cursor` rather than trusting the server to be exclusive on the boundary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import sys
14
+ import time
15
+ from collections.abc import Callable, Iterable
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+
19
+ import typer
20
+ from dooers.protocol.logs import LogEntry
21
+ from pydantic import ValidationError
22
+
23
+ from dooers.cli import config
24
+ from dooers.cli.push_client import (
25
+ PushClient,
26
+ PushClientError,
27
+ PushTransientError,
28
+ friendly_push_error,
29
+ )
30
+ from dooers.cli.settings import Settings
31
+ from dooers.cli.token_store import TokenStore, is_token_expired
32
+
33
+ #: How long `--follow` waits between polls.
34
+ POLL_INTERVAL_S = 2.0
35
+
36
+ #: `--since` grammar: an optional hours/minutes/seconds run, in that order ("2h30m").
37
+ _DURATION = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", re.IGNORECASE)
38
+
39
+ #: Fractional seconds in an RFC3339 timestamp. Cloud Logging emits nanoseconds,
40
+ #: which `datetime.fromisoformat` rejects before Python 3.11.
41
+ _FRACTION = re.compile(r"\.(\d+)")
42
+
43
+
44
+ def _parse_since(value: str) -> str:
45
+ """Validate a `--since` duration ("15m", "1h", "2h30m", "45s") and return it.
46
+
47
+ Returned unchanged (lowercased) rather than converted: the endpoint takes the
48
+ same duration grammar, and passing the user's own window back keeps the
49
+ request self-describing in logs.
50
+ """
51
+ text = value.strip().lower()
52
+ match = _DURATION.fullmatch(text)
53
+ if not match or not any(match.groups()):
54
+ raise typer.BadParameter(
55
+ f"invalid duration {value!r} — use a window like 15m, 45s, 1h, or 2h30m"
56
+ )
57
+ return text
58
+
59
+
60
+ def _parse_timestamp(timestamp: str) -> datetime | None:
61
+ """Parse an RFC3339 timestamp, or None if it is not one.
62
+
63
+ Normalizes the two shapes `datetime.fromisoformat` cannot take on Python 3.10:
64
+ a trailing `Z`, and sub-microsecond precision.
65
+ """
66
+ text = timestamp.strip()
67
+ if text.endswith(("Z", "z")):
68
+ text = text[:-1] + "+00:00"
69
+ text = _FRACTION.sub(lambda m: "." + m.group(1)[:6], text)
70
+ try:
71
+ return datetime.fromisoformat(text)
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ def _printable(text: str) -> str:
77
+ """Make `text` safe for the active stdout encoding.
78
+
79
+ Log messages are arbitrary agent output. On a non-UTF-8 console (cp1252 on
80
+ Windows) an emoji in one line would otherwise raise UnicodeEncodeError and
81
+ kill the tail; replacing the character is the right trade here.
82
+ """
83
+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
84
+ try:
85
+ return text.encode(encoding, errors="replace").decode(encoding, errors="replace")
86
+ except LookupError: # pragma: no cover — unknown codec name on stdout
87
+ return text
88
+
89
+
90
+ def _style_severity(severity: str) -> str:
91
+ """Colourise the level column: ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim."""
92
+ label = f"{severity:<8}"
93
+ level = severity.upper()
94
+ if level in ("ERROR", "CRITICAL"):
95
+ return typer.style(label, fg=typer.colors.RED)
96
+ if level == "WARNING":
97
+ return typer.style(label, fg=typer.colors.YELLOW)
98
+ if level in ("DEBUG", "DEFAULT"):
99
+ return typer.style(label, dim=True)
100
+ return label
101
+
102
+
103
+ def format_entry(entry: LogEntry) -> str:
104
+ """One human-readable line: `HH:MM:SS LEVEL message`.
105
+
106
+ The clock is shown in the timestamp's own zone (UTC, as Cloud Logging emits
107
+ it) — never converted — so two people reading the same tail see the same time.
108
+ """
109
+ parsed = _parse_timestamp(entry.timestamp)
110
+ clock = parsed.strftime("%H:%M:%S") if parsed else entry.timestamp
111
+ return f"{clock} {_style_severity(entry.severity)} {_printable(entry.message)}"
112
+
113
+
114
+ def format_entry_json(entry: LogEntry) -> str:
115
+ """One compact JSON object per line, ASCII-escaped so any console can print it."""
116
+ return json.dumps(entry.model_dump(), separators=(",", ":"))
117
+
118
+
119
+ @dataclass
120
+ class _Cursor:
121
+ """High-water mark of what has already been printed.
122
+
123
+ Entries older than `newest` are dropped; entries *at* `newest` are dropped only
124
+ if that exact `(timestamp, message)` was already printed, so a burst sharing one
125
+ timestamp survives an overlapping poll window.
126
+ """
127
+
128
+ newest: str | None = None
129
+ printed_at_newest: set[tuple[str, str]] = field(default_factory=set)
130
+
131
+ def take_new(self, entries: Iterable[LogEntry]) -> list[LogEntry]:
132
+ """Return the not-yet-printed entries, oldest first, advancing the mark."""
133
+ fresh: list[LogEntry] = []
134
+ for entry in sorted(entries, key=lambda e: e.timestamp):
135
+ key = (entry.timestamp, entry.message)
136
+ if self.newest is not None:
137
+ if entry.timestamp < self.newest:
138
+ continue
139
+ if entry.timestamp == self.newest and key in self.printed_at_newest:
140
+ continue
141
+ if self.newest is None or entry.timestamp > self.newest:
142
+ self.newest = entry.timestamp
143
+ self.printed_at_newest = {key}
144
+ else:
145
+ self.printed_at_newest.add(key)
146
+ fresh.append(entry)
147
+ return fresh
148
+
149
+
150
+ def emit(entries: Iterable[LogEntry], cursor: _Cursor, *, json_output: bool) -> int:
151
+ """Print the entries `cursor` has not seen yet. Returns how many were printed."""
152
+ fresh = cursor.take_new(entries)
153
+ for entry in fresh:
154
+ typer.echo(format_entry_json(entry) if json_output else format_entry(entry))
155
+ return len(fresh)
156
+
157
+
158
+ def follow_logs(
159
+ client: PushClient,
160
+ agent_id: str,
161
+ *,
162
+ cursor: _Cursor,
163
+ since: str,
164
+ limit: int,
165
+ json_output: bool = False,
166
+ sleep: Callable[[float], object] = time.sleep,
167
+ interval_s: float = POLL_INTERVAL_S,
168
+ max_polls: int | None = None,
169
+ ) -> None:
170
+ """Poll for new entries forever (or `max_polls` times, for tests).
171
+
172
+ Transient failures are tolerated — a tail must outlive a 503 — and warned about
173
+ once per outage. A PushClientError is a real 4xx and propagates to the caller.
174
+ """
175
+ warned = False
176
+ polls = 0
177
+ while max_polls is None or polls < max_polls:
178
+ polls += 1
179
+ sleep(interval_s)
180
+ try:
181
+ resp = client.get_logs(agent_id, since=cursor.newest or since, limit=limit)
182
+ except PushTransientError as e:
183
+ if not warned:
184
+ typer.echo(f"Warning: log stream interrupted ({e}); retrying…", err=True)
185
+ warned = True
186
+ continue
187
+ warned = False
188
+ emit(resp.entries, cursor, json_output=json_output)
189
+
190
+
191
+ def logs(
192
+ ctx: typer.Context,
193
+ agent_id: str | None = typer.Argument(
194
+ None,
195
+ help="Agent ID. If omitted, reads agent_id from ./dooers.yaml.",
196
+ ),
197
+ follow: bool = typer.Option(
198
+ False, "--follow", "-f", help="Keep polling and print new entries as they arrive."
199
+ ),
200
+ since: str = typer.Option(
201
+ "15m", "--since", help="How far back to start: a duration like 15m, 45s, 1h, 2h30m."
202
+ ),
203
+ limit: int = typer.Option(
204
+ 100, "--limit", help="Max entries per request (server caps at 1000)."
205
+ ),
206
+ json_output: bool = typer.Option(
207
+ False, "--json", help="Print one compact JSON object per line instead of formatted text."
208
+ ),
209
+ ) -> None:
210
+ """Show the deployment logs of an agent."""
211
+ settings: Settings = ctx.obj
212
+ window = _parse_since(since)
213
+
214
+ # Resolve agent_id from arg or manifest (same rule as `dooers push`). The
215
+ # manifest is only read when it is actually needed, so an explicit id still
216
+ # works from a directory with a broken dooers.yaml.
217
+ manifest = None
218
+ if agent_id is None:
219
+ try:
220
+ manifest = config.read_manifest()
221
+ except ValidationError as e:
222
+ typer.echo(f"Invalid {config.MANIFEST_FILENAME}: {e}", err=True)
223
+ raise typer.Exit(code=1) from e
224
+ try:
225
+ target_agent_id = config.resolve_agent_id(agent_id, manifest)
226
+ except config.MissingAgentIdError as e:
227
+ typer.echo(str(e), err=True)
228
+ raise typer.Exit(code=1) from e
229
+
230
+ store = TokenStore()
231
+ token = store.load()
232
+ if not token or is_token_expired(token, store=store):
233
+ typer.echo("Not authenticated. Run `dooers login`.", err=True)
234
+ raise typer.Exit(code=1)
235
+
236
+ client = PushClient(base_url=settings.push_url, token=token)
237
+ cursor = _Cursor()
238
+
239
+ try:
240
+ resp = client.get_logs(target_agent_id, since=window, limit=limit)
241
+ except PushClientError as e:
242
+ code = e.envelope.error_code if e.envelope else None
243
+ typer.echo(f"Logs failed: {friendly_push_error(code, str(e))}", err=True)
244
+ raise typer.Exit(code=1) from e
245
+
246
+ printed = emit(resp.entries, cursor, json_output=json_output)
247
+ if not follow:
248
+ if printed == 0 and not json_output:
249
+ typer.echo(f"No log entries in the last {window}.", err=True)
250
+ return
251
+
252
+ try:
253
+ follow_logs(
254
+ client,
255
+ target_agent_id,
256
+ cursor=cursor,
257
+ since=window,
258
+ limit=limit,
259
+ json_output=json_output,
260
+ )
261
+ except KeyboardInterrupt:
262
+ raise typer.Exit(code=0) from None
263
+ except PushClientError as e:
264
+ code = e.envelope.error_code if e.envelope else None
265
+ typer.echo(f"\nLogs failed: {friendly_push_error(code, str(e))}", err=True)
266
+ raise typer.Exit(code=1) from e
@@ -116,17 +116,13 @@ def push(
116
116
  settings: Settings = ctx.obj
117
117
  target_env = env or settings.env
118
118
 
119
- # Resolve agent_id from arg or manifest.
119
+ # Resolve agent_id from arg or manifest (same rule as `dooers logs`).
120
120
  manifest = config.read_manifest()
121
- if agent_id is None:
122
- if manifest is None:
123
- typer.echo(
124
- f"Missing {config.MANIFEST_FILENAME}. Run `dooers agents create` first "
125
- f"or pass an agent_id explicitly.",
126
- err=True,
127
- )
128
- raise typer.Exit(code=1)
129
- agent_id = manifest.agent_id
121
+ try:
122
+ agent_id = config.resolve_agent_id(agent_id, manifest)
123
+ except config.MissingAgentIdError as e:
124
+ typer.echo(str(e), err=True)
125
+ raise typer.Exit(code=1) from e
130
126
 
131
127
  # Client-side hosting guard. The dooers-push server is the authoritative
132
128
  # gate (it checks the org's hosting plan feature); this is a fast local
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import httpx
6
6
  from dooers.protocol.errors import ErrorCode, ErrorEnvelope
7
+ from dooers.protocol.logs import LogsResponse
7
8
  from dooers.protocol.push import BuildStatusResponse, PushAcceptedResponse
8
9
  from dooers.protocol.teardown import TeardownResponse
9
10
 
@@ -82,6 +83,34 @@ class PushClient:
82
83
  self._raise_for_status(r)
83
84
  return BuildStatusResponse.model_validate(r.json())
84
85
 
86
+ def get_logs(
87
+ self,
88
+ agent_id: str,
89
+ *,
90
+ since: str | None = None,
91
+ limit: int = 100,
92
+ page_token: str | None = None,
93
+ ) -> LogsResponse:
94
+ """Read recent log entries for an agent.
95
+
96
+ `since` is an RFC3339 timestamp or a duration like `15m`. Raises
97
+ PushTransientError on 5xx/network so a `--follow` caller survives blips;
98
+ raises PushClientError on a 4xx envelope.
99
+ """
100
+ url = f"{self.base_url}/v1/agents/{agent_id}/logs"
101
+ params: dict[str, str] = {"limit": str(limit)}
102
+ if since is not None:
103
+ params["since"] = since
104
+ if page_token is not None:
105
+ params["page_token"] = page_token
106
+ try:
107
+ r = httpx.get(url, headers=self._headers, params=params, timeout=self._timeout)
108
+ except httpx.HTTPError as e:
109
+ raise PushTransientError(f"logs request failed: {e}") from e
110
+
111
+ self._raise_for_status(r)
112
+ return LogsResponse.model_validate(r.json())
113
+
85
114
  def teardown(self, agent_id: str, env: str = "prod") -> TeardownResponse:
86
115
  url = f"{self.base_url}/v1/agents/{agent_id}"
87
116
  headers = {"Authorization": f"Bearer {self.token}"}
@@ -6,7 +6,8 @@ from dooers.cli.env_prod import upsert_agent_seed_secret
6
6
  def test_upsert_creates_env_prod(tmp_path: Path) -> None:
7
7
  path = upsert_agent_seed_secret(tmp_path, "secret-one-time-key-abc")
8
8
  assert path.name == "env.prod"
9
- assert path.read_text(encoding="utf-8").strip().endswith("AGENT_SEED_SECRET=secret-one-time-key-abc")
9
+ written = path.read_text(encoding="utf-8").strip()
10
+ assert written.endswith("AGENT_SEED_SECRET=secret-one-time-key-abc")
10
11
 
11
12
 
12
13
  def test_upsert_replaces_existing_line(tmp_path: Path) -> None:
@@ -0,0 +1,439 @@
1
+ """`dooers logs` — one-shot render, --json shape, --since parsing, follow-mode dedupe.
2
+
3
+ Follow mode is driven through the `sleep` / `max_polls` seam on `follow_logs`, so no
4
+ test here ever waits on a real clock.
5
+ """
6
+
7
+ from collections.abc import Iterator
8
+
9
+ import httpx
10
+ import pytest
11
+ import respx
12
+ import typer
13
+ from dooers.protocol.logs import LogEntry, LogsResponse
14
+ from typer.testing import CliRunner
15
+
16
+ from dooers.cli.cli import app
17
+ from dooers.cli.logs import _Cursor, _parse_since, emit, follow_logs
18
+ from dooers.cli.push_client import PushClient, PushClientError, PushTransientError
19
+
20
+ runner = CliRunner()
21
+
22
+ PUSH = "https://push.test"
23
+ A = "550e8400-e29b-41d4-a716-446655440000"
24
+ ROOT = ["--push-url", PUSH, "--env", "dev"]
25
+
26
+
27
+ def _auth(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ class _Tok:
29
+ def load(self) -> str:
30
+ return "tok"
31
+
32
+ monkeypatch.setattr("dooers.cli.logs.TokenStore", _Tok)
33
+ monkeypatch.setattr("dooers.cli.logs.is_token_expired", lambda token, store: False)
34
+
35
+
36
+ def _entry(timestamp: str, severity: str = "INFO", message: str = "hello") -> dict[str, str]:
37
+ return {"timestamp": timestamp, "severity": severity, "message": message}
38
+
39
+
40
+ def _logs_response(*entries: dict[str, str], next_page_token: str | None = None) -> httpx.Response:
41
+ return httpx.Response(
42
+ 200,
43
+ json={
44
+ "agent_id": A,
45
+ "service_name": "agent-svc",
46
+ "region": "us-central1",
47
+ "entries": list(entries),
48
+ "next_page_token": next_page_token,
49
+ },
50
+ )
51
+
52
+
53
+ # --------------------------------------------------------------------------- wiring
54
+
55
+
56
+ def test_root_help_lists_logs() -> None:
57
+ result = runner.invoke(app, ["--help"])
58
+ assert result.exit_code == 0
59
+ assert "logs" in result.stdout
60
+
61
+
62
+ def test_logs_help_lists_flags() -> None:
63
+ result = runner.invoke(app, ["logs", "--help"])
64
+ assert result.exit_code == 0
65
+ out = result.stdout
66
+ for flag in ("--follow", "--since", "--limit", "--json"):
67
+ assert flag in out
68
+
69
+
70
+ # ----------------------------------------------------------------------- one-shot
71
+
72
+
73
+ @respx.mock
74
+ def test_one_shot_renders_entries(monkeypatch: pytest.MonkeyPatch) -> None:
75
+ _auth(monkeypatch)
76
+ route = respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
77
+ return_value=_logs_response(
78
+ _entry("2026-08-07T12:00:00Z", "INFO", "server started"),
79
+ _entry("2026-08-07T12:00:05Z", "ERROR", "boom"),
80
+ )
81
+ )
82
+
83
+ result = runner.invoke(app, ROOT + ["logs", A])
84
+
85
+ assert result.exit_code == 0, result.output
86
+ assert route.called
87
+ assert "12:00:00" in result.output
88
+ assert "INFO" in result.output
89
+ assert "server started" in result.output
90
+ assert "12:00:05" in result.output
91
+ assert "ERROR" in result.output
92
+ assert "boom" in result.output
93
+
94
+
95
+ @respx.mock
96
+ def test_one_shot_sends_default_since_and_limit(monkeypatch: pytest.MonkeyPatch) -> None:
97
+ _auth(monkeypatch)
98
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
99
+ return_value=_logs_response(_entry("2026-08-07T12:00:00Z"))
100
+ )
101
+
102
+ result = runner.invoke(app, ROOT + ["logs", A])
103
+
104
+ assert result.exit_code == 0, result.output
105
+ params = respx.calls.last.request.url.params
106
+ assert params["since"] == "15m"
107
+ assert params["limit"] == "100"
108
+
109
+
110
+ @respx.mock
111
+ def test_entries_print_oldest_first_even_if_server_returns_newest_first(
112
+ monkeypatch: pytest.MonkeyPatch,
113
+ ) -> None:
114
+ _auth(monkeypatch)
115
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
116
+ return_value=_logs_response(
117
+ _entry("2026-08-07T12:00:09Z", "INFO", "second"),
118
+ _entry("2026-08-07T12:00:01Z", "INFO", "first"),
119
+ )
120
+ )
121
+
122
+ result = runner.invoke(app, ROOT + ["logs", A])
123
+
124
+ assert result.exit_code == 0, result.output
125
+ assert result.output.index("first") < result.output.index("second")
126
+
127
+
128
+ @respx.mock
129
+ def test_json_output_shape(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ _auth(monkeypatch)
131
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
132
+ return_value=_logs_response(
133
+ _entry("2026-08-07T12:00:00Z", "INFO", "one"),
134
+ _entry("2026-08-07T12:00:01Z", "ERROR", "two"),
135
+ )
136
+ )
137
+
138
+ result = runner.invoke(app, ROOT + ["logs", A, "--json"])
139
+
140
+ assert result.exit_code == 0, result.output
141
+ lines = [line for line in result.output.splitlines() if line.strip()]
142
+ assert lines == [
143
+ '{"timestamp":"2026-08-07T12:00:00Z","severity":"INFO","message":"one"}',
144
+ '{"timestamp":"2026-08-07T12:00:01Z","severity":"ERROR","message":"two"}',
145
+ ]
146
+
147
+
148
+ @respx.mock
149
+ def test_agent_id_falls_back_to_manifest(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
150
+ from dooers.protocol import PROTOCOL_VERSION
151
+ from dooers.protocol.agents import AgentManifest
152
+
153
+ from dooers.cli import config
154
+
155
+ _auth(monkeypatch)
156
+ config.write_manifest(
157
+ AgentManifest(
158
+ protocol_version=PROTOCOL_VERSION,
159
+ agent_id=A,
160
+ name="A",
161
+ organization_id="o1",
162
+ ),
163
+ directory=tmp_path,
164
+ )
165
+ monkeypatch.chdir(tmp_path)
166
+ route = respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
167
+ return_value=_logs_response(_entry("2026-08-07T12:00:00Z", "INFO", "from manifest"))
168
+ )
169
+
170
+ result = runner.invoke(app, ROOT + ["logs"])
171
+
172
+ assert result.exit_code == 0, result.output
173
+ assert route.called
174
+ assert "from manifest" in result.output
175
+
176
+
177
+ def test_missing_agent_id_and_manifest_exits_1(
178
+ monkeypatch: pytest.MonkeyPatch, tmp_path
179
+ ) -> None:
180
+ _auth(monkeypatch)
181
+ monkeypatch.chdir(tmp_path)
182
+
183
+ result = runner.invoke(app, ROOT + ["logs"])
184
+
185
+ assert result.exit_code == 1
186
+ assert "dooers.yaml" in result.output
187
+
188
+
189
+ @respx.mock
190
+ def test_client_error_exits_1(monkeypatch: pytest.MonkeyPatch) -> None:
191
+ _auth(monkeypatch)
192
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
193
+ return_value=httpx.Response(
194
+ 404,
195
+ json={"error_code": "not_found", "message": "agent not found", "correlation_id": "c1"},
196
+ )
197
+ )
198
+
199
+ result = runner.invoke(app, ROOT + ["logs", A])
200
+
201
+ assert result.exit_code == 1
202
+ assert "agent not found" in result.output
203
+
204
+
205
+ # ------------------------------------------------------------------------- --since
206
+
207
+
208
+ @pytest.mark.parametrize("value", ["15m", "1h", "2h30m", "45s", "1h30m15s", "90S"])
209
+ def test_parse_since_accepts_durations(value: str) -> None:
210
+ assert _parse_since(value) == value.strip().lower()
211
+
212
+
213
+ @pytest.mark.parametrize("value", ["", "banana", "15", "m15", "5x", "1d", "-5m", "30m2h"])
214
+ def test_parse_since_rejects_garbage(value: str) -> None:
215
+ with pytest.raises(typer.BadParameter):
216
+ _parse_since(value)
217
+
218
+
219
+ def test_bad_since_exits_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
220
+ _auth(monkeypatch)
221
+ result = runner.invoke(app, ROOT + ["logs", A, "--since", "banana"])
222
+ assert result.exit_code != 0
223
+ assert "banana" in result.output
224
+
225
+
226
+ # -------------------------------------------------------------------------- follow
227
+
228
+
229
+ class _FakeClient:
230
+ """Yields a scripted sequence of get_logs outcomes (response or exception)."""
231
+
232
+ def __init__(self, script: list[object]) -> None:
233
+ self._it: Iterator[object] = iter(script)
234
+ self.calls: list[str | None] = []
235
+
236
+ def get_logs(
237
+ self,
238
+ agent_id: str,
239
+ *,
240
+ since: str | None = None,
241
+ limit: int = 100,
242
+ page_token: str | None = None,
243
+ ) -> LogsResponse:
244
+ self.calls.append(since)
245
+ item = next(self._it, None)
246
+ if isinstance(item, Exception):
247
+ raise item
248
+ if item is None:
249
+ return _response([])
250
+ assert isinstance(item, LogsResponse)
251
+ return item
252
+
253
+
254
+ def _response(entries: list[LogEntry]) -> LogsResponse:
255
+ return LogsResponse(
256
+ agent_id=A, service_name="agent-svc", region="us-central1", entries=entries
257
+ )
258
+
259
+
260
+ def _e(timestamp: str, message: str, severity: str = "INFO") -> LogEntry:
261
+ return LogEntry(timestamp=timestamp, severity=severity, message=message)
262
+
263
+
264
+ def test_follow_prints_only_new_entries_from_overlapping_window(
265
+ capsys: pytest.CaptureFixture[str],
266
+ ) -> None:
267
+ cursor = _Cursor()
268
+ first = [_e("2026-08-07T12:00:00Z", "one"), _e("2026-08-07T12:00:01Z", "two")]
269
+ # Second poll re-returns the tail of the first window plus two new lines.
270
+ second = [
271
+ _e("2026-08-07T12:00:01Z", "two"),
272
+ _e("2026-08-07T12:00:02Z", "three"),
273
+ _e("2026-08-07T12:00:03Z", "four"),
274
+ ]
275
+ emit(first, cursor, json_output=False)
276
+ capsys.readouterr()
277
+
278
+ client = _FakeClient([_response(second)])
279
+ follow_logs(
280
+ client, # type: ignore[arg-type]
281
+ A,
282
+ cursor=cursor,
283
+ since="15m",
284
+ limit=100,
285
+ sleep=lambda *_: None,
286
+ max_polls=1,
287
+ )
288
+
289
+ out = capsys.readouterr().out
290
+ assert "three" in out
291
+ assert "four" in out
292
+ assert "two" not in out # already printed by the first batch
293
+ # The poll narrowed its window to the newest timestamp already printed.
294
+ assert client.calls == ["2026-08-07T12:00:01Z"]
295
+
296
+
297
+ def test_follow_keeps_distinct_messages_sharing_one_timestamp(
298
+ capsys: pytest.CaptureFixture[str],
299
+ ) -> None:
300
+ cursor = _Cursor()
301
+ emit([_e("2026-08-07T12:00:00Z", "first")], cursor, json_output=False)
302
+ capsys.readouterr()
303
+
304
+ client = _FakeClient(
305
+ [
306
+ _response(
307
+ [
308
+ _e("2026-08-07T12:00:00Z", "first"), # dupe: same ts + message
309
+ _e("2026-08-07T12:00:00Z", "second"), # same ts, new message
310
+ ]
311
+ )
312
+ ]
313
+ )
314
+ follow_logs(
315
+ client, # type: ignore[arg-type]
316
+ A,
317
+ cursor=cursor,
318
+ since="15m",
319
+ limit=100,
320
+ sleep=lambda *_: None,
321
+ max_polls=1,
322
+ )
323
+
324
+ out = capsys.readouterr().out
325
+ assert "second" in out
326
+ assert out.count("first") == 0
327
+
328
+
329
+ def test_follow_tolerates_transient_error(capsys: pytest.CaptureFixture[str]) -> None:
330
+ cursor = _Cursor()
331
+ client = _FakeClient(
332
+ [
333
+ PushTransientError("transient upstream error (HTTP 503)"),
334
+ PushTransientError("transient upstream error (HTTP 503)"),
335
+ _response([_e("2026-08-07T12:00:02Z", "recovered")]),
336
+ ]
337
+ )
338
+
339
+ follow_logs(
340
+ client, # type: ignore[arg-type]
341
+ A,
342
+ cursor=cursor,
343
+ since="15m",
344
+ limit=100,
345
+ sleep=lambda *_: None,
346
+ max_polls=3,
347
+ )
348
+
349
+ captured = capsys.readouterr()
350
+ assert "recovered" in captured.out
351
+ assert len(client.calls) == 3 # the blips did not abort the loop
352
+ assert captured.err.count("Warning:") == 1 # warned once per outage, not per poll
353
+
354
+
355
+ def test_follow_aborts_on_client_error() -> None:
356
+ cursor = _Cursor()
357
+ client = _FakeClient([PushClientError("agent not found")])
358
+
359
+ with pytest.raises(PushClientError):
360
+ follow_logs(
361
+ client, # type: ignore[arg-type]
362
+ A,
363
+ cursor=cursor,
364
+ since="15m",
365
+ limit=100,
366
+ sleep=lambda *_: None,
367
+ max_polls=3,
368
+ )
369
+
370
+
371
+ def test_follow_never_sleeps_for_real() -> None:
372
+ """The poll interval goes through the injected sleep, so tests stay instant."""
373
+ slept: list[float] = []
374
+ client = _FakeClient([_response([])])
375
+
376
+ follow_logs(
377
+ client, # type: ignore[arg-type]
378
+ A,
379
+ cursor=_Cursor(),
380
+ since="15m",
381
+ limit=100,
382
+ sleep=lambda s: slept.append(s),
383
+ max_polls=1,
384
+ )
385
+
386
+ assert slept == [2.0]
387
+
388
+
389
+ # -------------------------------------------------------------- PushClient.get_logs
390
+
391
+
392
+ @respx.mock
393
+ def test_get_logs_parses_response() -> None:
394
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
395
+ return_value=_logs_response(
396
+ _entry("2026-08-07T12:00:00Z", "WARNING", "slow"), next_page_token="tok"
397
+ )
398
+ )
399
+
400
+ resp = PushClient(PUSH, token="t").get_logs(A, since="15m", limit=50, page_token="p1")
401
+
402
+ assert isinstance(resp, LogsResponse)
403
+ assert resp.service_name == "agent-svc"
404
+ assert resp.next_page_token == "tok"
405
+ assert [e.severity for e in resp.entries] == ["WARNING"]
406
+ params = respx.calls.last.request.url.params
407
+ assert params["since"] == "15m"
408
+ assert params["limit"] == "50"
409
+ assert params["page_token"] == "p1"
410
+
411
+
412
+ @respx.mock
413
+ def test_get_logs_503_raises_transient() -> None:
414
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
415
+ return_value=httpx.Response(503, text="service unavailable")
416
+ )
417
+ with pytest.raises(PushTransientError):
418
+ PushClient(PUSH, token="t").get_logs(A)
419
+
420
+
421
+ @respx.mock
422
+ def test_get_logs_network_error_raises_transient() -> None:
423
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(side_effect=httpx.ConnectError("boom"))
424
+ with pytest.raises(PushTransientError):
425
+ PushClient(PUSH, token="t").get_logs(A)
426
+
427
+
428
+ @respx.mock
429
+ def test_get_logs_404_raises_client_error() -> None:
430
+ respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
431
+ return_value=httpx.Response(
432
+ 404,
433
+ json={"error_code": "not_found", "message": "agent not found", "correlation_id": "c1"},
434
+ )
435
+ )
436
+ with pytest.raises(PushClientError) as exc:
437
+ PushClient(PUSH, token="t").get_logs(A)
438
+ assert not isinstance(exc.value, PushTransientError)
439
+ assert "agent not found" in str(exc.value)
@@ -232,7 +232,7 @@ toml = [
232
232
 
233
233
  [[package]]
234
234
  name = "dooers-cli"
235
- version = "0.8.2"
235
+ version = "0.9.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "dooers-protocol" },
@@ -278,7 +278,7 @@ provides-extras = ["dev"]
278
278
 
279
279
  [[package]]
280
280
  name = "dooers-protocol"
281
- version = "0.7.0"
281
+ version = "0.8.0"
282
282
  source = { editable = "../dooers-protocol" }
283
283
  dependencies = [
284
284
  { name = "pydantic" },
@@ -1,40 +0,0 @@
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
- ```
File without changes
File without changes
File without changes