dooers-cli 0.8.0__tar.gz → 0.8.2__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 (42) hide show
  1. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/PKG-INFO +1 -1
  2. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/pyproject.toml +1 -1
  3. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/agents.py +65 -2
  4. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/cli.py +1 -1
  5. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/config.py +5 -2
  6. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/ignore.py +1 -1
  7. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/token_store.py +4 -2
  8. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/user_config.py +2 -2
  9. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/validate.py +11 -4
  10. dooers_cli-0.8.2/tests/test_agents_update.py +115 -0
  11. dooers_cli-0.8.2/tests/test_encoding.py +191 -0
  12. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_manifest_sync.py +6 -0
  13. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_smoke.py +1 -1
  14. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/uv.lock +1 -1
  15. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/.gitignore +0 -0
  16. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/README.md +0 -0
  17. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/__init__.py +0 -0
  18. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/agent_store.py +0 -0
  19. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/auth.py +0 -0
  20. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/core_client.py +0 -0
  21. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/env_prod.py +0 -0
  22. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/manifest_sync.py +0 -0
  23. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/org.py +0 -0
  24. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/push.py +0 -0
  25. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/push_client.py +0 -0
  26. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/settings.py +0 -0
  27. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/py.typed +0 -0
  28. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/__init__.py +0 -0
  29. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_agent_store.py +0 -0
  30. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_agents_delete.py +0 -0
  31. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_config.py +0 -0
  32. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_core_client.py +0 -0
  33. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_env_prod.py +0 -0
  34. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_ignore.py +0 -0
  35. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_org.py +0 -0
  36. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_client_async.py +0 -0
  37. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_failure_display.py +0 -0
  38. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_hosting_guard.py +0 -0
  39. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_poll.py +0 -0
  40. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_teardown.py +0 -0
  41. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_token_store.py +0 -0
  42. {dooers_cli-0.8.0 → dooers_cli-0.8.2}/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.0
3
+ Version: 0.8.2
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dooers-cli"
3
- version = "0.8.0"
3
+ version = "0.8.2"
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"
@@ -57,7 +57,7 @@ def create(
57
57
  name=rec.name,
58
58
  organization_id=rec.organization_id or organization_id,
59
59
  description=description,
60
- message_path="/",
60
+ message_path="/ws",
61
61
  message_scheme="wss",
62
62
  whatsapp=WhatsAppConfig(enabled=False, path=None),
63
63
  profile=ProfileConfig(),
@@ -76,10 +76,73 @@ def create(
76
76
  err=True,
77
77
  )
78
78
  typer.echo(
79
- "Edit dooers.yaml (message_path, whatsapp, profile, database) then run dooers push."
79
+ "Edit dooers.yaml (profile, tools, whatsapp, database) then "
80
+ "`dooers push` to deploy, or `dooers agents update` to sync config only."
80
81
  )
81
82
 
82
83
 
84
+ @app.command()
85
+ def update(ctx: typer.Context) -> None:
86
+ """Apply dooers.yaml settings to the core agent record (no deploy).
87
+
88
+ Syncs description, profile (capabilities/tools), UI, WhatsApp, and
89
+ serverConfig.apiMessagesUrl derived from message_path + the agent's
90
+ existing hostUrl. Requires a prior successful prod push so hostUrl is set.
91
+ """
92
+ store, _, _ = _store(ctx)
93
+ try:
94
+ manifest = config.read_manifest()
95
+ except Exception as e: # noqa: BLE001 — pydantic ValidationError etc.
96
+ typer.echo(f"Invalid {config.MANIFEST_FILENAME}: {e}", err=True)
97
+ raise typer.Exit(code=1) from e
98
+ if manifest is None:
99
+ typer.echo(
100
+ f"Missing {config.MANIFEST_FILENAME}. Run `dooers agents create` first.",
101
+ err=True,
102
+ )
103
+ raise typer.Exit(code=1)
104
+
105
+ try:
106
+ rec = store.get(manifest.agent_id)
107
+ except KeyError:
108
+ typer.echo(f"Agent {manifest.agent_id} not found in core.", err=True)
109
+ raise typer.Exit(code=1)
110
+ except AgentStoreError as e:
111
+ typer.echo(f"update failed: {e}", err=True)
112
+ raise typer.Exit(code=1) from e
113
+
114
+ if not rec.host_url:
115
+ typer.echo(
116
+ "Agent has no hostUrl yet. Run `dooers push` once to deploy, "
117
+ "then `dooers agents update` to sync config.",
118
+ err=True,
119
+ )
120
+ raise typer.Exit(code=1)
121
+
122
+ from dooers.cli.manifest_sync import build_agent_patch
123
+
124
+ patch = build_agent_patch(manifest, rec.host_url)
125
+ if not patch:
126
+ typer.echo("Nothing to sync from dooers.yaml.")
127
+ return
128
+
129
+ try:
130
+ updated = store.update(manifest.agent_id, patch)
131
+ except AgentStoreError as e:
132
+ typer.echo(f"update failed: {e}", err=True)
133
+ raise typer.Exit(code=1) from e
134
+
135
+ typer.echo(f"Updated agent {updated.agent_id} from {config.MANIFEST_FILENAME}.")
136
+ if "serverConfig" in patch:
137
+ typer.echo(f" apiMessagesUrl: {patch['serverConfig'].get('apiMessagesUrl')}")
138
+ if "description" in patch:
139
+ typer.echo(f" description: {patch['description']}")
140
+ if "profile" in patch:
141
+ typer.echo(f" profile keys: {', '.join(patch['profile'])}")
142
+ if "settings" in patch:
143
+ typer.echo(f" settings keys: {', '.join(patch['settings'])}")
144
+
145
+
83
146
  @app.command("rotate-key")
84
147
  def rotate_key(
85
148
  ctx: typer.Context,
@@ -41,7 +41,7 @@ app.command(name="logout", help="Clear local credentials.")(auth.logout)
41
41
  app.add_typer(
42
42
  agents.app,
43
43
  name="agents",
44
- help="Manage agents — subcommands: list | create | rotate-key | show | delete.",
44
+ help="Manage agents — subcommands: list | create | update | rotate-key | show | delete.",
45
45
  )
46
46
  app.add_typer(org.app, name="org", help="Manage organization — subcommands: list | use.")
47
47
  app.command(
@@ -16,7 +16,7 @@ def read_manifest(directory: Path | None = None) -> AgentManifest | None:
16
16
  target = (directory or Path.cwd()) / MANIFEST_FILENAME
17
17
  if not target.exists():
18
18
  return None
19
- raw = yaml.safe_load(target.read_text()) or {}
19
+ raw = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
20
20
  return AgentManifest.model_validate(raw)
21
21
 
22
22
 
@@ -24,5 +24,8 @@ def write_manifest(manifest: AgentManifest, directory: Path | None = None) -> Pa
24
24
  """Write `manifest` to `directory/dooers.yaml`. Returns the path written."""
25
25
  target = (directory or Path.cwd()) / MANIFEST_FILENAME
26
26
  target.parent.mkdir(parents=True, exist_ok=True)
27
- target.write_text(yaml.safe_dump(manifest.model_dump(mode="json"), sort_keys=False))
27
+ target.write_text(
28
+ yaml.safe_dump(manifest.model_dump(mode="json"), sort_keys=False),
29
+ encoding="utf-8",
30
+ )
28
31
  return target
@@ -33,7 +33,7 @@ def load_ignore_patterns(directory: Path | None = None) -> list[str]:
33
33
  patterns = list(DEFAULT_IGNORE_PATTERNS)
34
34
  ignore_file = directory / ".dooersignore"
35
35
  if ignore_file.exists():
36
- for raw in ignore_file.read_text().splitlines():
36
+ for raw in ignore_file.read_text(encoding="utf-8", errors="replace").splitlines():
37
37
  line = raw.strip()
38
38
  if not line or line.startswith("#"):
39
39
  continue
@@ -15,7 +15,7 @@ class TokenStore:
15
15
  if not self.path.exists():
16
16
  return {}
17
17
  try:
18
- return json.loads(self.path.read_text())
18
+ return json.loads(self.path.read_text(encoding="utf-8"))
19
19
  except (OSError, ValueError):
20
20
  return {}
21
21
 
@@ -27,7 +27,9 @@ class TokenStore:
27
27
 
28
28
  def save(self, token: str, expires_at: int) -> None:
29
29
  self.path.parent.mkdir(parents=True, exist_ok=True)
30
- self.path.write_text(json.dumps({"token": token, "expires_at": expires_at}))
30
+ self.path.write_text(
31
+ json.dumps({"token": token, "expires_at": expires_at}), encoding="utf-8"
32
+ )
31
33
  self.path.chmod(0o600)
32
34
 
33
35
  def clear(self) -> None:
@@ -14,7 +14,7 @@ class UserConfig:
14
14
  if not self.path.exists():
15
15
  return {}
16
16
  try:
17
- return json.loads(self.path.read_text())
17
+ return json.loads(self.path.read_text(encoding="utf-8"))
18
18
  except (OSError, ValueError):
19
19
  return {}
20
20
 
@@ -25,4 +25,4 @@ class UserConfig:
25
25
  data = self._read()
26
26
  data["default_org"] = org_id
27
27
  self.path.parent.mkdir(parents=True, exist_ok=True)
28
- self.path.write_text(json.dumps(data))
28
+ self.path.write_text(json.dumps(data), encoding="utf-8")
@@ -1,4 +1,11 @@
1
- """Pre-push validation for agent projects (`dooers validate`)."""
1
+ """Pre-push validation for agent projects (`dooers validate`).
2
+
3
+ Every file read here belongs to the user, so its bytes are not ours to trust. They are
4
+ decoded as UTF-8 explicitly — never via the platform locale, which is cp1252 on Windows
5
+ and raises UnicodeDecodeError on any source file with accents or emoji — and undecodable
6
+ bytes are replaced rather than fatal: every pattern matched below is ASCII, so U+FFFD
7
+ cannot mask a hit, and validation must never abort over an oddly-encoded file.
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
@@ -56,7 +63,7 @@ def collect_issues(root: Path | None = None) -> list[ValidationIssue]:
56
63
  if not pyproject.exists():
57
64
  issues.append(ValidationIssue("warning", "missing pyproject.toml"))
58
65
  else:
59
- text = pyproject.read_text()
66
+ text = pyproject.read_text(encoding="utf-8", errors="replace")
60
67
  if _LEGACY_SDK_DEP.search(text):
61
68
  issues.append(
62
69
  ValidationIssue(
@@ -83,7 +90,7 @@ def collect_issues(root: Path | None = None) -> list[ValidationIssue]:
83
90
  if path in seen or ".venv" in path.parts:
84
91
  continue
85
92
  seen.add(path)
86
- if _LEGACY_IMPORT.search(path.read_text()):
93
+ if _LEGACY_IMPORT.search(path.read_text(encoding="utf-8", errors="replace")):
87
94
  rel = path.relative_to(base)
88
95
  issues.append(
89
96
  ValidationIssue(
@@ -102,7 +109,7 @@ def collect_issues(root: Path | None = None) -> list[ValidationIssue]:
102
109
  for env_path in [base / ".env", *base.glob("env.*")]:
103
110
  if not env_path.is_file():
104
111
  continue
105
- for raw in env_path.read_text().splitlines():
112
+ for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
106
113
  line = raw.strip()
107
114
  if not line or line.startswith("#") or "=" not in line:
108
115
  continue
@@ -0,0 +1,115 @@
1
+ """`dooers agents update` — sync dooers.yaml to core without deploying."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+ import respx
8
+ import yaml
9
+ from typer.testing import CliRunner
10
+
11
+ from dooers.cli.cli import app
12
+ from dooers.cli.config import MANIFEST_FILENAME
13
+
14
+ runner = CliRunner()
15
+
16
+ CORE = "https://core.test"
17
+ PUSH = "https://push.test"
18
+ A = "550e8400-e29b-41d4-a716-446655440000"
19
+ HOST = f"https://agents.dooers.ai/{A}"
20
+ ROOT = ["--core-url", CORE, "--push-url", PUSH, "--env", "prod"]
21
+
22
+
23
+ def _auth(monkeypatch):
24
+ class _Tok:
25
+ def load(self):
26
+ return "tok"
27
+
28
+ monkeypatch.setattr("dooers.cli.agents.TokenStore", _Tok)
29
+ monkeypatch.setattr("dooers.cli.agents.is_token_expired", lambda token, store: False)
30
+
31
+
32
+ def _write_manifest(tmp_path: Path, **extra) -> Path:
33
+ body = {
34
+ "protocol_version": "0.7.0",
35
+ "agent_id": A,
36
+ "name": "revisor",
37
+ "organization_id": "o1",
38
+ "message_path": "/ws",
39
+ "message_scheme": "wss",
40
+ "description": "Azure DevOps reviewer",
41
+ "profile": {
42
+ "summary": "Reviews PRs",
43
+ "capabilities": ["code-review"],
44
+ "tools": ["ado"],
45
+ },
46
+ **extra,
47
+ }
48
+ path = tmp_path / MANIFEST_FILENAME
49
+ path.write_text(yaml.safe_dump(body, sort_keys=False))
50
+ return path
51
+
52
+
53
+ def _agent_get(*, host_url: str | None = HOST):
54
+ data = {
55
+ "agentId": A,
56
+ "name": "revisor",
57
+ "organizationId": "o1",
58
+ "hostUrl": host_url,
59
+ }
60
+ return httpx.Response(200, json={"success": True, "data": data})
61
+
62
+
63
+ @respx.mock
64
+ def test_update_patches_core_from_manifest(monkeypatch, tmp_path):
65
+ _auth(monkeypatch)
66
+ _write_manifest(tmp_path)
67
+ monkeypatch.chdir(tmp_path)
68
+ get_route = respx.get(f"{CORE}/api/v2/agents/{A}").mock(return_value=_agent_get())
69
+ patch_route = respx.patch(f"{CORE}/api/v2/agents/{A}").mock(
70
+ return_value=httpx.Response(
71
+ 200,
72
+ json={
73
+ "success": True,
74
+ "data": {
75
+ "agentId": A,
76
+ "name": "revisor",
77
+ "organizationId": "o1",
78
+ "hostUrl": HOST,
79
+ },
80
+ },
81
+ )
82
+ )
83
+
84
+ result = runner.invoke(app, [*ROOT, "agents", "update"])
85
+ assert result.exit_code == 0, result.output
86
+ assert get_route.called
87
+ assert patch_route.called
88
+ body = json.loads(patch_route.calls.last.request.content)
89
+ assert body["hostUrl"] == HOST
90
+ assert body["description"] == "Azure DevOps reviewer"
91
+ assert body["serverConfig"]["apiMessagesUrl"] == f"wss://agents.dooers.ai/{A}/ws"
92
+ assert body["profile"]["capabilities"] == ["code-review"]
93
+ assert body["profile"]["tools"] == ["ado"]
94
+ assert "Updated agent" in result.output
95
+
96
+
97
+ @respx.mock
98
+ def test_update_requires_host_url(monkeypatch, tmp_path):
99
+ _auth(monkeypatch)
100
+ _write_manifest(tmp_path)
101
+ monkeypatch.chdir(tmp_path)
102
+ respx.get(f"{CORE}/api/v2/agents/{A}").mock(return_value=_agent_get(host_url=None))
103
+
104
+ result = runner.invoke(app, [*ROOT, "agents", "update"])
105
+ assert result.exit_code == 1
106
+ assert "hostUrl" in result.output
107
+ assert "dooers push" in result.output
108
+
109
+
110
+ def test_update_requires_manifest(monkeypatch, tmp_path):
111
+ _auth(monkeypatch)
112
+ monkeypatch.chdir(tmp_path)
113
+ result = runner.invoke(app, [*ROOT, "agents", "update"])
114
+ assert result.exit_code == 1
115
+ assert MANIFEST_FILENAME in result.output
@@ -0,0 +1,191 @@
1
+ """Text I/O must be UTF-8 regardless of the platform's locale encoding.
2
+
3
+ `Path.read_text()` / `Path.write_text()` / `open()` without an explicit `encoding`
4
+ fall back to `locale.getpreferredencoding(False)`. That is UTF-8 on most Linux/macOS
5
+ setups but the ANSI codepage on Windows (cp1252 in pt-BR/en-US), so any UTF-8 source
6
+ file with accented characters or emoji blows up with `UnicodeDecodeError` there while
7
+ passing everywhere else.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import NamedTuple
18
+
19
+ import pytest
20
+
21
+ import dooers.cli
22
+
23
+ SRC_ROOT = Path(dooers.cli.__file__).parent
24
+
25
+ # Force a non-UTF-8 default encoding so the locale-dependent path is exercised on
26
+ # CI. On POSIX this yields ASCII, which fails on the same bytes Windows cp1252 does.
27
+ _NON_UTF8_ENV = {
28
+ "LC_ALL": "C",
29
+ "LANG": "C",
30
+ "LC_CTYPE": "C",
31
+ "PYTHONUTF8": "0",
32
+ "PYTHONCOERCECLOCALE": "0",
33
+ }
34
+
35
+ _ACCENTED_SOURCE = "# configuração do modelo 🚀\nfrom dooers.agents.server import AgentServer\n"
36
+
37
+
38
+ def _write_utf8(path: Path, content: str) -> None:
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ path.write_bytes(content.encode("utf-8"))
41
+
42
+
43
+ class _Run(NamedTuple):
44
+ returncode: int
45
+ stdout: bytes
46
+ stderr: str
47
+
48
+
49
+ def _run_in_non_utf8_locale(code: str, *args: str) -> _Run:
50
+ """Run `code` in a child process whose default encoding is not UTF-8.
51
+
52
+ Output is captured as bytes and decoded explicitly: text mode would decode using
53
+ the *parent's* locale, which is itself cp1252 on Windows and would corrupt the
54
+ comparison this module exists to make. The child's command line is decoded by the
55
+ child using that same non-UTF-8 locale, so `code` and `args` must stay ASCII.
56
+ """
57
+ proc = subprocess.run(
58
+ [sys.executable, "-c", code, *args],
59
+ env={**os.environ, **_NON_UTF8_ENV},
60
+ capture_output=True,
61
+ )
62
+ return _Run(proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors="replace"))
63
+
64
+
65
+ def _make_agent_project(root: Path) -> None:
66
+ _write_utf8(
67
+ root / "dooers.yaml",
68
+ 'protocol_version: "2"\n'
69
+ 'agent_id: "550e8400-e29b-41d4-a716-446655440000"\n'
70
+ "name: test\n"
71
+ 'organization_id: "org_1"\n'
72
+ "description: agente de gestão de tarefas 🚀\n",
73
+ )
74
+ _write_utf8(root / "Dockerfile", "FROM python:3.12-slim\n")
75
+ _write_utf8(
76
+ root / "pyproject.toml",
77
+ '[project]\ndependencies = ["dooers-agents-server>=0.12.0"]\n',
78
+ )
79
+ _write_utf8(root / "src" / "modules" / "llm" / "factory.py", _ACCENTED_SOURCE)
80
+
81
+
82
+ @pytest.fixture
83
+ def non_utf8_locale_available() -> None:
84
+ probe = _run_in_non_utf8_locale(
85
+ "import locale, sys; sys.stdout.write(locale.getpreferredencoding(False))"
86
+ )
87
+ encoding = probe.stdout.decode("ascii", errors="replace").strip().lower()
88
+ if encoding.replace("-", "").replace("_", "") in {"utf8", ""}:
89
+ pytest.skip(f"cannot force a non-UTF-8 locale here (got {encoding!r})")
90
+
91
+
92
+ @pytest.mark.usefixtures("non_utf8_locale_available")
93
+ def test_validate_reads_utf8_sources_under_non_utf8_locale(tmp_path: Path) -> None:
94
+ """`dooers validate` must not crash on a .py file with accents/emoji."""
95
+ _make_agent_project(tmp_path)
96
+
97
+ result = _run_in_non_utf8_locale(
98
+ "import sys;"
99
+ "from dooers.cli.validate import collect_issues;"
100
+ "issues = collect_issues(__import__('pathlib').Path(sys.argv[1]));"
101
+ "sys.stdout.write(repr([i.message for i in issues if i.level == 'error']))",
102
+ str(tmp_path),
103
+ )
104
+
105
+ assert result.returncode == 0, f"validate crashed under a non-UTF-8 locale:\n{result.stderr}"
106
+ assert result.stdout.strip() == b"[]", f"unexpected errors: {result.stdout!r}"
107
+
108
+
109
+ @pytest.mark.usefixtures("non_utf8_locale_available")
110
+ def test_manifest_roundtrip_under_non_utf8_locale(tmp_path: Path) -> None:
111
+ """dooers.yaml with non-ASCII text must read back byte-identical."""
112
+ _make_agent_project(tmp_path)
113
+
114
+ result = _run_in_non_utf8_locale(
115
+ "import sys, pathlib;"
116
+ "from dooers.cli.config import read_manifest, write_manifest;"
117
+ "d = pathlib.Path(sys.argv[1]);"
118
+ "m = read_manifest(directory=d);"
119
+ "write_manifest(m, directory=d);"
120
+ "sys.stdout.buffer.write((read_manifest(directory=d).description or '').encode('utf-8'))",
121
+ str(tmp_path),
122
+ )
123
+
124
+ assert result.returncode == 0, f"manifest roundtrip crashed:\n{result.stderr}"
125
+ assert result.stdout == "agente de gestão de tarefas 🚀".encode()
126
+
127
+
128
+ @pytest.mark.usefixtures("non_utf8_locale_available")
129
+ def test_dooersignore_read_under_non_utf8_locale(tmp_path: Path) -> None:
130
+ """.dooersignore may contain non-ASCII paths."""
131
+ _write_utf8(tmp_path / ".dooersignore", "# ignorar anotações\nrelatórios/\n")
132
+
133
+ # The child's command line is itself decoded with the C locale, so it must stay
134
+ # pure ASCII — the non-ASCII pattern goes in as an escape sequence.
135
+ result = _run_in_non_utf8_locale(
136
+ "import sys, pathlib;"
137
+ "from dooers.cli.ignore import load_ignore_patterns;"
138
+ "pats = load_ignore_patterns(pathlib.Path(sys.argv[1]));"
139
+ "sys.stdout.buffer.write(b'OK' if 'relat\\u00f3rios/' in pats else b'MISSING')",
140
+ str(tmp_path),
141
+ )
142
+
143
+ assert result.returncode == 0, f"load_ignore_patterns crashed:\n{result.stderr}"
144
+ assert result.stdout == b"OK"
145
+
146
+
147
+ def _text_io_calls_without_encoding(tree: ast.AST) -> list[tuple[str, int]]:
148
+ """Find text-mode I/O calls that rely on the platform's default encoding."""
149
+ offenders: list[tuple[str, int]] = []
150
+ for node in ast.walk(tree):
151
+ if not isinstance(node, ast.Call):
152
+ continue
153
+ func = node.func
154
+
155
+ if isinstance(func, ast.Attribute):
156
+ name = func.attr
157
+ # tarfile.open() takes no `encoding` in the text sense — it is an archive.
158
+ if isinstance(func.value, ast.Name) and func.value.id == "tarfile":
159
+ continue
160
+ elif isinstance(func, ast.Name):
161
+ name = func.id
162
+ else:
163
+ continue
164
+
165
+ if name not in {"read_text", "write_text", "open"}:
166
+ continue
167
+
168
+ # Binary mode has no encoding to specify.
169
+ mode = next(
170
+ (a for a in node.args if isinstance(a, ast.Constant) and isinstance(a.value, str)),
171
+ None,
172
+ )
173
+ if name == "open" and mode is not None and "b" in mode.value:
174
+ continue
175
+
176
+ if not any(kw.arg == "encoding" for kw in node.keywords):
177
+ offenders.append((name, node.lineno))
178
+ return offenders
179
+
180
+
181
+ def test_no_text_io_relies_on_platform_default_encoding() -> None:
182
+ """Regression guard: every text read/write in the CLI pins encoding explicitly."""
183
+ failures: list[str] = []
184
+ for path in sorted(SRC_ROOT.rglob("*.py")):
185
+ tree = ast.parse(path.read_text(encoding="utf-8"))
186
+ for name, lineno in _text_io_calls_without_encoding(tree):
187
+ failures.append(f"{path.relative_to(SRC_ROOT.parent.parent)}:{lineno}: {name}()")
188
+
189
+ assert not failures, "text I/O without encoding='utf-8' (breaks on Windows cp1252):\n" + (
190
+ "\n".join(failures)
191
+ )
@@ -56,6 +56,12 @@ def test_message_path_root_slash():
56
56
  assert url == "wss://agents.dooers.ai/ag-x"
57
57
 
58
58
 
59
+ def test_message_path_ws_default_convention():
60
+ m = _manifest(message_path="/ws")
61
+ patch = build_agent_patch(m, DEPLOYED)
62
+ assert patch["serverConfig"]["apiMessagesUrl"] == "wss://agents.dooers.ai/ag-x/ws"
63
+
64
+
59
65
  def test_no_message_path_no_server_config():
60
66
  m = _manifest()
61
67
  patch = build_agent_patch(m, DEPLOYED)
@@ -24,7 +24,7 @@ def test_login_help_has_positional_email() -> None:
24
24
  def test_agents_help() -> None:
25
25
  result = runner.invoke(app, ["agents", "--help"])
26
26
  assert result.exit_code == 0
27
- for cmd in ("list", "create", "show"):
27
+ for cmd in ("list", "create", "update", "show"):
28
28
  assert cmd in result.stdout
29
29
 
30
30
 
@@ -232,7 +232,7 @@ toml = [
232
232
 
233
233
  [[package]]
234
234
  name = "dooers-cli"
235
- version = "0.8.0"
235
+ version = "0.8.2"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "dooers-protocol" },
File without changes
File without changes
File without changes
File without changes