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.
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/PKG-INFO +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/pyproject.toml +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/agents.py +65 -2
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/cli.py +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/config.py +5 -2
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/ignore.py +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/token_store.py +4 -2
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/user_config.py +2 -2
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/validate.py +11 -4
- dooers_cli-0.8.2/tests/test_agents_update.py +115 -0
- dooers_cli-0.8.2/tests/test_encoding.py +191 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_manifest_sync.py +6 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_smoke.py +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/uv.lock +1 -1
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/.gitignore +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/README.md +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/__init__.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/agent_store.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/auth.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/core_client.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/env_prod.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/manifest_sync.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/org.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/push.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/push_client.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/cli/settings.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/src/dooers/py.typed +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/__init__.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_agent_store.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_agents_delete.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_config.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_core_client.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_env_prod.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_ignore.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_org.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_client_async.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_failure_display.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_hosting_guard.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_poll.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_push_teardown.py +0 -0
- {dooers_cli-0.8.0 → dooers_cli-0.8.2}/tests/test_token_store.py +0 -0
- {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.
|
|
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>
|
|
@@ -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 (
|
|
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(
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|