From 3c7856bc7ca465ebec23c9ab14e87eac9d89fa49 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Wed, 17 Jun 2026 07:11:11 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat(cli):=20ceki=20contract=20group=20?= =?UTF-8?q?=E2=80=94=20port=20ceki-agent.js=20(task=20423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContractClient (httpx) + `ceki contract …` subparser covering all ceki-agent.js commands: list/members/tasks/my-jobs/task/children/history, create/comment/propose/vote, poll/watch, tools/raw. - /mcp/agent endpoint derived from CEKI_API_URL; CEKI_AGENT_MCP_ENDPOINT override preserved for backward compat - Token: CEKI_AGENT_TOKEN primary, falls back to CEKI_API_KEY - CEKI_CONTRACT_IDS default for tasks/create (csv / bracketed / json) - benefitable "agent:N" → {type,value}; undefined fields stripped before send - poll handles 429 explicitly (rate-limit 10/min/token), watch min 6s - MCP unwrap: result.content[].text (json-parsed) or structuredContent Tests: 33 new in tests/test_contract.py (benefitable, clean, env resolve, MCP unwrap, tool name mapping, payload shape, polling, parser). Version 2.18.0 → 2.19.0. --- README.md | 41 ++++++ ceki_sdk/cli.py | 197 ++++++++++++++++++++++++++ ceki_sdk/contract.py | 304 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_contract.py | 298 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 ceki_sdk/contract.py create mode 100644 tests/test_contract.py diff --git a/README.md b/README.md index 8d3908b..c888bb4 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,47 @@ Successful commands write a single JSON line to stdout. Errors go to stderr as ` Full reference (with EN+RU): https://browser.ceki.me/docs#cli +### `ceki contract` — participate in contracts via `/mcp/agent` + +For AI agents executing tasks inside a contract: list contracts/jobs, post +results, propose corrections, vote, poll notifications. + +``` +ceki contract list # my contracts +ceki contract members # contract members +ceki contract tasks [cid] # events of contract(s) +ceki contract my-jobs # events assigned to me +ceki contract task # event detail +ceki contract children # event children +ceki contract history # audit history +ceki contract create --label "X" [--status N] [--type N] \ + [--kal-schedule N] [--start ..] [--end ..] [--date ..] \ + [--duration N] [--amount N] [--currency USD] \ + [--benefitable agent:8|user:61] [--desc ".."] +ceki contract comment --label ".." [--status N] [--duration N] \ + [--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."] +ceki contract propose [--status N] [--label ..] [--desc ..] \ + [--duration N] [--amount N] [--currency USD] [--benefitable agent:8] +ceki contract vote --ids 1,2 --vote true|false +ceki contract poll # single tick (returns [] on 429) +ceki contract watch [sec] # continuous (min 6s, 10/min/token) +ceki contract tools # list available MCP tools +ceki contract raw '' # call any tool directly +``` + +#### Environment + +| Variable | Meaning | +|---|---| +| `CEKI_AGENT_TOKEN` | Bearer agent token (`ag_*`). Falls back to `CEKI_API_KEY`. | +| `CEKI_API_URL` | Base URL — `/mcp/agent` and `/api/agent/polling` are derived from it. | +| `CEKI_AGENT_MCP_ENDPOINT` | Override MCP endpoint (backward compat). | +| `CEKI_API_BASE` | Override REST polling base. | +| `CEKI_CONTRACT_IDS` | Default contract id(s): `"14"`, `"14,21"`, or `"[14,21]"`. | + +Polling is rate-limited to 10 calls/minute per token; `watch` enforces a 6s +minimum interval. + ## Development ```bash diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index cbedf04..6ffc2d3 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -406,6 +406,126 @@ async def _cmd_request_captcha(args: argparse.Namespace) -> None: await client.disconnect() +def _contract_client(): + from .contract import ContractClient + return ContractClient() + + +def _contract_dump(value: Any) -> None: + if isinstance(value, str): + sys.stdout.write(value) + sys.stdout.write("\n") + else: + json.dump(value, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + sys.stdout.flush() + + +def _cmd_contract(args: argparse.Namespace) -> int: + from .contract import ContractError, contract_ids_from_env + + action = args.contract_action + try: + with _contract_client() as cli: + if action == "list": + _contract_dump(cli.list_contracts()) + elif action == "members": + _contract_dump(cli.members(args.cid)) + elif action == "tasks": + ids = [args.cid] if args.cid is not None else contract_ids_from_env() + if not ids: + _err("no contract id (positional or CEKI_CONTRACT_IDS)", "args") + return 1 + for cid in ids: + print(f"--- contract {cid} ---") + _contract_dump(cli.tasks(int(cid))) + elif action == "my-jobs": + _contract_dump(cli.my_jobs()) + elif action == "task": + _contract_dump(cli.task(args.eid)) + elif action == "children": + _contract_dump(cli.children(args.eid)) + elif action == "history": + _contract_dump(cli.history(args.eid)) + elif action == "create": + cid = args.cid if args.cid is not None else ( + int(contract_ids_from_env()[0]) if contract_ids_from_env() else None + ) + if cid is None: + _err("contract id required (positional or CEKI_CONTRACT_IDS)", "args") + return 1 + _contract_dump(cli.create( + cid, + label=args.label, + type_id=args.type, + status_id=args.status, + kal_schedule_id=args.kal_schedule, + start=args.start, + end=args.end, + date=args.date, + duration=args.duration, + amount=args.amount, + currency=args.currency, + description=args.desc, + benefitable=args.benefitable, + )) + elif action == "comment": + _contract_dump(cli.comment( + args.eid, + label=args.label, + type_id=args.type, + status_id=args.status, + duration=args.duration, + amount=args.amount, + currency=args.currency, + description=args.desc, + benefitable=args.benefitable, + )) + elif action == "propose": + _contract_dump(cli.propose( + args.eid, + status_id=args.status, + label=args.label, + description=args.desc, + duration=args.duration, + amount=args.amount, + currency=args.currency, + benefitable=args.benefitable, + )) + elif action == "vote": + ids = [int(s) for s in str(args.ids).split(",") if s.strip()] + vote = str(args.vote).lower() in ("true", "1", "yes") + _contract_dump(cli.vote(args.eid, ids, vote)) + elif action == "poll": + items = cli.poll() + _contract_dump({"count": len(items), "notifications": items}) + elif action == "watch": + sec = max(6, int(args.interval or 8)) + sys.stderr.write(f"[watch] poll every {sec}s (limit 10/min/token; do not go below 6s)\n") + sys.stderr.flush() + import time as _time + while True: + items = cli.poll() + if items: + from datetime import datetime, timezone + ts = datetime.now(timezone.utc).isoformat() + for n in items: + print(json.dumps({"ts": ts, "notification": n}, ensure_ascii=False)) + _time.sleep(sec) + elif action == "tools": + _contract_dump(cli.tools()) + elif action == "raw": + payload = json.loads(args.args) if args.args else {} + _contract_dump(cli.raw(args.tool, payload)) + else: + _err(f"unknown contract action: {action}") + return 1 + except ContractError as e: + _err(str(e), "contract") + return 1 + return 0 + + async def _cmd_cdp(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) @@ -552,6 +672,80 @@ def build_parser() -> argparse.ArgumentParser: p_cdp.add_argument("--method", required=True, help="CDP method name") p_cdp.add_argument("--params", help="CDP params as JSON string") + p_contract = sub.add_parser("contract", help="Participate in contracts via /mcp/agent") + csub = p_contract.add_subparsers(dest="contract_action", required=True) + + csub.add_parser("list", help="List my contracts (get-my-contracts)") + + p_cm = csub.add_parser("members", help="List contract members") + p_cm.add_argument("cid", type=int, help="Contract ID") + + p_ct = csub.add_parser("tasks", help="List contract events (default: CEKI_CONTRACT_IDS)") + p_ct.add_argument("cid", type=int, nargs="?", help="Contract ID") + + csub.add_parser("my-jobs", help="List events assigned to me") + + p_ctask = csub.add_parser("task", help="Get event") + p_ctask.add_argument("eid", type=int, help="Event ID") + + p_cch = csub.add_parser("children", help="Get event children") + p_cch.add_argument("eid", type=int, help="Event ID") + + p_chist = csub.add_parser("history", help="Get event audit history") + p_chist.add_argument("eid", type=int, help="Event ID") + + p_cc = csub.add_parser("create", help="Create contract event") + p_cc.add_argument("cid", type=int, nargs="?", help="Contract ID (default: CEKI_CONTRACT_IDS[0])") + p_cc.add_argument("--label", required=True) + p_cc.add_argument("--type", type=int) + p_cc.add_argument("--status", type=int) + p_cc.add_argument("--kal-schedule", type=int, dest="kal_schedule") + p_cc.add_argument("--start") + p_cc.add_argument("--end") + p_cc.add_argument("--date") + p_cc.add_argument("--duration", type=int) + p_cc.add_argument("--amount", type=int) + p_cc.add_argument("--currency") + p_cc.add_argument("--benefitable", help="agent:8 or user:61") + p_cc.add_argument("--desc") + + p_cco = csub.add_parser("comment", help="Post comment on event") + p_cco.add_argument("eid", type=int) + p_cco.add_argument("--label") + p_cco.add_argument("--type", type=int) + p_cco.add_argument("--status", type=int) + p_cco.add_argument("--duration", type=int) + p_cco.add_argument("--amount", type=int) + p_cco.add_argument("--currency") + p_cco.add_argument("--benefitable") + p_cco.add_argument("--desc") + + p_cp = csub.add_parser("propose", help="Propose correction") + p_cp.add_argument("eid", type=int) + p_cp.add_argument("--status", type=int) + p_cp.add_argument("--label") + p_cp.add_argument("--desc") + p_cp.add_argument("--duration", type=int) + p_cp.add_argument("--amount", type=int) + p_cp.add_argument("--currency") + p_cp.add_argument("--benefitable") + + p_cv = csub.add_parser("vote", help="Vote on correction(s)") + p_cv.add_argument("eid", type=int) + p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs") + p_cv.add_argument("--vote", required=True, help="true|false") + + csub.add_parser("poll", help="Single agent polling tick") + + p_cw = csub.add_parser("watch", help="Continuous polling") + p_cw.add_argument("interval", type=int, nargs="?", default=8, help="Seconds, min 6") + + csub.add_parser("tools", help="List available MCP tools") + + p_craw = csub.add_parser("raw", help="Call raw MCP tool") + p_craw.add_argument("tool") + p_craw.add_argument("args", nargs="?", default="{}", help="JSON args") + return parser @@ -581,6 +775,9 @@ def main() -> None: "request-captcha": _cmd_request_captcha, } + if args.command == "contract": + sys.exit(_cmd_contract(args)) + handler = handlers.get(args.command) if not handler: _err(f"Unknown command: {args.command}") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py new file mode 100644 index 0000000..eb8b6c9 --- /dev/null +++ b/ceki_sdk/contract.py @@ -0,0 +1,304 @@ +"""Client for /mcp/agent contract tools (1:1 port of ceki-agent.js).""" + +from __future__ import annotations + +import json +import os +import time +from typing import Any + +import httpx + +from ._config import default_api_url + + +def _benefitable(value: str | None) -> dict[str, Any] | None: + if not value: + return None + parts = str(value).split(":", 1) + if len(parts) != 2: + raise ValueError(f"benefitable must be 'type:id', got: {value!r}") + btype, bid = parts + return {"type": btype, "value": int(bid)} + + +def _clean(args: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in args.items() if v is not None} + + +def contract_ids_from_env() -> list[str]: + raw = (os.getenv("CEKI_CONTRACT_IDS") or "").strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(x) for x in parsed] + except Exception: + pass + return [s.strip() for s in raw.replace("[", "").replace("]", "").split(",") if s.strip()] + + +def _resolve_endpoint() -> str: + override = os.getenv("CEKI_AGENT_MCP_ENDPOINT") + if override: + return override.rstrip("/") + base = default_api_url().rstrip("/") + return f"{base}/mcp/agent" + + +def _resolve_api_base() -> str: + override = os.getenv("CEKI_API_BASE") + if override: + return override.rstrip("/") + base = default_api_url().rstrip("/") + return f"{base}/api" + + +def _resolve_token() -> str: + return os.getenv("CEKI_AGENT_TOKEN") or os.getenv("CEKI_API_KEY") or "" + + +_TOOL_MAP = { + "list": "get-my-contracts", + "members": "get-contract-members", + "tasks": "get-contract-events", + "my-jobs": "get-my-jobs", + "task": "get-event", + "children": "get-event-children", + "history": "get-event-history", + "create": "create-contract-event", + "comment": "comment", + "propose": "propose-correction", + "vote": "vote-correction", +} + + +class ContractError(Exception): + pass + + +class ContractClient: + """JSON-RPC MCP client for /mcp/agent + REST polling. + + Mirrors `ceki-agent.js` behavior. Reads env at construction unless + explicit values are passed. + """ + + def __init__( + self, + endpoint: str | None = None, + token: str | None = None, + api_base: str | None = None, + client: httpx.Client | None = None, + timeout: float = 30.0, + ) -> None: + self.endpoint = (endpoint or _resolve_endpoint()).rstrip("/") + self.api_base = (api_base or _resolve_api_base()).rstrip("/") + self.token = token if token is not None else _resolve_token() + self._timeout = timeout + self._client = client + self._own_client = client is None + + def close(self) -> None: + if self._own_client and self._client is not None: + self._client.close() + self._client = None + + def __enter__(self) -> "ContractClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + def _http(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self._timeout) + return self._client + + def _headers(self) -> dict[str, str]: + if not self.token: + raise ContractError("agent token not set (CEKI_AGENT_TOKEN or CEKI_API_KEY)") + return { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {self.token}", + } + + def _rpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + body = {"jsonrpc": "2.0", "id": int(time.time() * 1000), "method": method, "params": params} + resp = self._http().post(self.endpoint, headers=self._headers(), json=body) + try: + parsed = resp.json() + except Exception: + parsed = {"raw": resp.text} + if resp.status_code != 200: + snippet = json.dumps(parsed)[:400] + raise ContractError(f"HTTP {resp.status_code}: {snippet}") + return parsed + + def call(self, tool: str, args: dict[str, Any] | None = None) -> Any: + """Call MCP tool, unwrap content[].text (JSON-parsed) or structuredContent.""" + body = self._rpc("tools/call", {"name": tool, "arguments": args or {}}) + if body.get("error"): + raise ContractError(f"{tool} → {json.dumps(body['error'])[:400]}") + result = body.get("result") or {} + content = result.get("content") + if isinstance(content, list): + texts = [c.get("text", "") for c in content if c.get("type") == "text"] + joined = "\n".join(texts) + try: + return json.loads(joined) + except Exception: + return joined + return result.get("structuredContent", result) + + def tools(self) -> Any: + body = self._rpc("tools/list", {}) + result = body.get("result") or {} + tools = result.get("tools") + if isinstance(tools, list): + return [t.get("name") for t in tools] + return body + + def raw(self, tool: str, args: dict[str, Any] | None = None) -> Any: + return self.call(tool, args) + + # ── domain helpers ──────────────────────────────────────────── + + def list_contracts(self) -> Any: + return self.call(_TOOL_MAP["list"], {}) + + def members(self, contract_id: int) -> Any: + return self.call(_TOOL_MAP["members"], {"contract_id": int(contract_id)}) + + def tasks(self, contract_id: int) -> Any: + return self.call(_TOOL_MAP["tasks"], {"contract_id": int(contract_id)}) + + def my_jobs(self) -> Any: + return self.call(_TOOL_MAP["my-jobs"], {}) + + def task(self, event_id: int) -> Any: + return self.call(_TOOL_MAP["task"], {"event_id": int(event_id)}) + + def children(self, event_id: int) -> Any: + return self.call(_TOOL_MAP["children"], {"event_id": int(event_id)}) + + def history(self, event_id: int) -> Any: + return self.call(_TOOL_MAP["history"], {"event_id": int(event_id)}) + + def create( + self, + contract_id: int, + *, + label: str, + type_id: int | None = None, + status_id: int | None = None, + kal_schedule_id: int | None = None, + start: str | None = None, + end: str | None = None, + date: str | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + description: str | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "contract_id": int(contract_id), + "label": label, + "type_id": type_id, + "status_id": status_id, + "kal_schedule_id": kal_schedule_id, + "start": start, + "end": end, + "date": date, + "duration": duration, + "amount": amount, + "currency": currency, + "description": description, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["create"], args) + + def comment( + self, + event_id: int, + *, + label: str | None = None, + type_id: int | None = None, + status_id: int | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + description: str | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "event_id": int(event_id), + "label": label, + "type_id": type_id, + "status_id": status_id, + "duration": duration, + "amount": amount, + "currency": currency, + "description": description, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["comment"], args) + + def propose( + self, + event_id: int, + *, + status_id: int | None = None, + label: str | None = None, + description: str | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "event_id": int(event_id), + "status_id": status_id, + "label": label, + "description": description, + "duration": duration, + "amount": amount, + "currency": currency, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["propose"], args) + + def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: + return self.call(_TOOL_MAP["vote"], { + "event_id": int(event_id), + "ids": [int(i) for i in ids], + "vote": bool(vote), + }) + + # ── polling (REST, not MCP) ─────────────────────────────────── + + def poll(self) -> list[Any]: + """GET /agent/polling. Returns [] on 429 (rate-limit, 10/min/token).""" + resp = self._http().get( + f"{self.api_base}/agent/polling", + headers={"Accept": "application/json", "Authorization": f"Bearer {self.token}"}, + ) + if resp.status_code == 429: + return [] + if resp.status_code != 200: + try: + body = resp.json() + except Exception: + body = resp.text + raise ContractError(f"polling HTTP {resp.status_code}: {str(body)[:300]}") + body = resp.json() + if isinstance(body, list): + return body + if isinstance(body, dict): + for key in ("notifications", "data", "items"): + if key in body and isinstance(body[key], list): + return body[key] + return [] diff --git a/pyproject.toml b/pyproject.toml index 3860556..0e7901d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.18.0" +version = "2.19.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..49cec98 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from ceki_sdk.cli import build_parser +from ceki_sdk.contract import ( + ContractClient, + ContractError, + _benefitable, + _clean, + contract_ids_from_env, +) + + +# ── helpers ─────────────────────────────────────────────────────── + + +def _http_mock(payload, status: int = 200): + """Build a MagicMock httpx.Client whose .post/.get return given payload.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.json.return_value = payload + resp.text = json.dumps(payload) + client = MagicMock(spec=httpx.Client) + client.post.return_value = resp + client.get.return_value = resp + return client, resp + + +def _mcp_text(obj) -> dict: + return { + "result": { + "content": [{"type": "text", "text": json.dumps(obj)}] + } + } + + +# ── _benefitable / _clean ───────────────────────────────────────── + + +def test_benefitable_agent(): + assert _benefitable("agent:8") == {"type": "agent", "value": 8} + + +def test_benefitable_user(): + assert _benefitable("user:61") == {"type": "user", "value": 61} + + +def test_benefitable_none(): + assert _benefitable(None) is None + assert _benefitable("") is None + + +def test_benefitable_malformed_raises(): + with pytest.raises(ValueError): + _benefitable("agent_no_colon") + + +def test_clean_drops_none_only(): + assert _clean({"a": 0, "b": None, "c": "", "d": False, "e": []}) == { + "a": 0, "c": "", "d": False, "e": [], + } + + +# ── env resolution ──────────────────────────────────────────────── + + +def test_contract_ids_csv(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "14,21") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_bracketed(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "[14,21]") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_json(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "[14, 21]") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_empty(monkeypatch): + monkeypatch.delenv("CEKI_CONTRACT_IDS", raising=False) + assert contract_ids_from_env() == [] + + +def test_endpoint_override(monkeypatch): + monkeypatch.setenv("CEKI_AGENT_MCP_ENDPOINT", "https://x.example/mcp/agent") + monkeypatch.setenv("CEKI_AGENT_TOKEN", "tok") + c = ContractClient() + assert c.endpoint == "https://x.example/mcp/agent" + + +def test_endpoint_derived_from_api(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_MCP_ENDPOINT", raising=False) + monkeypatch.setenv("CEKI_API_URL", "https://clawapi.ittribe.org") + monkeypatch.setenv("CEKI_AGENT_TOKEN", "tok") + c = ContractClient() + assert c.endpoint == "https://clawapi.ittribe.org/mcp/agent" + assert c.api_base == "https://clawapi.ittribe.org/api" + + +def test_token_agent_priority(monkeypatch): + monkeypatch.setenv("CEKI_AGENT_TOKEN", "ag_xxx") + monkeypatch.setenv("CEKI_API_KEY", "rental_yyy") + c = ContractClient() + assert c.token == "ag_xxx" + + +def test_token_fallback_to_api_key(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_TOKEN", raising=False) + monkeypatch.setenv("CEKI_API_KEY", "rental_yyy") + c = ContractClient() + assert c.token == "rental_yyy" + + +def test_no_token_raises(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_TOKEN", raising=False) + monkeypatch.delenv("CEKI_API_KEY", raising=False) + http, _ = _http_mock(_mcp_text({"ok": True})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="") + with pytest.raises(ContractError): + c.list_contracts() + + +# ── MCP unwrapping ──────────────────────────────────────────────── + + +def test_call_unwraps_text_json(): + http, _ = _http_mock(_mcp_text({"items": [1, 2]})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + assert c.list_contracts() == {"items": [1, 2]} + + +def test_call_returns_structured_content(): + http, _ = _http_mock({"result": {"structuredContent": {"k": "v"}}}) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + assert c.list_contracts() == {"k": "v"} + + +def test_call_non200_raises(): + http, _ = _http_mock({"error": "bad"}, status=500) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + with pytest.raises(ContractError): + c.list_contracts() + + +def test_call_jsonrpc_error_raises(): + http, _ = _http_mock({"error": {"code": -32000, "message": "nope"}}) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + with pytest.raises(ContractError): + c.list_contracts() + + +# ── tool calls + payloads ───────────────────────────────────────── + + +def _captured_body(http: MagicMock) -> dict: + return http.post.call_args.kwargs["json"] + + +def test_create_maps_tool_and_clean_payload(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="hello", duration=60, benefitable="agent:8") + body = _captured_body(http) + assert body["method"] == "tools/call" + assert body["params"]["name"] == "create-contract-event" + args = body["params"]["arguments"] + assert args == { + "contract_id": 14, + "label": "hello", + "duration": 60, + "benefitable": {"type": "agent", "value": 8}, + } + + +def test_comment_strips_undefined(): + http, _ = _http_mock(_mcp_text({"id": 99})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, label="done", duration=30) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 99, "label": "done", "duration": 30} + assert "amount" not in args + assert "currency" not in args + assert "benefitable" not in args + + +def test_propose_maps_tool(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.propose(7, status_id=200, label="L") + body = _captured_body(http) + assert body["params"]["name"] == "propose-correction" + assert body["params"]["arguments"] == {"event_id": 7, "status_id": 200, "label": "L"} + + +def test_vote_payload_shape(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.vote(7, [1, 2], True) + body = _captured_body(http) + assert body["params"]["name"] == "vote-correction" + assert body["params"]["arguments"] == {"event_id": 7, "ids": [1, 2], "vote": True} + + +def test_history_tool_name(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42) + body = _captured_body(http) + assert body["params"]["name"] == "get-event-history" + assert body["params"]["arguments"] == {"event_id": 42} + + +# ── polling ─────────────────────────────────────────────────────── + + +def test_poll_returns_list_directly(): + http, _ = _http_mock([{"x": 1}, {"x": 2}]) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + assert c.poll() == [{"x": 1}, {"x": 2}] + + +def test_poll_unwraps_notifications_key(): + http, _ = _http_mock({"notifications": [{"a": 1}]}) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + assert c.poll() == [{"a": 1}] + + +def test_poll_429_returns_empty(): + http, _ = _http_mock({"error": "rate"}, status=429) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + assert c.poll() == [] + + +def test_poll_other_error_raises(): + http, _ = _http_mock({"error": "boom"}, status=500) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + with pytest.raises(ContractError): + c.poll() + + +# ── CLI parser ──────────────────────────────────────────────────── + + +def test_parser_contract_list(): + a = build_parser().parse_args(["contract", "list"]) + assert a.command == "contract" and a.contract_action == "list" + + +def test_parser_contract_create_flags(): + a = build_parser().parse_args([ + "contract", "create", "14", + "--label", "X", "--status", "100", "--type", "2", + "--kal-schedule", "9", + "--duration", "60", "--amount", "1000", "--currency", "USD", + "--benefitable", "agent:8", "--desc", "d", + ]) + assert a.contract_action == "create" + assert a.cid == 14 + assert a.label == "X" + assert a.status == 100 + assert a.type == 2 + assert a.kal_schedule == 9 + assert a.duration == 60 + assert a.amount == 1000 + assert a.currency == "USD" + assert a.benefitable == "agent:8" + assert a.desc == "d" + + +def test_parser_contract_vote(): + a = build_parser().parse_args(["contract", "vote", "42", "--ids", "1,2", "--vote", "true"]) + assert a.eid == 42 and a.ids == "1,2" and a.vote == "true" + + +def test_parser_contract_watch_default(): + a = build_parser().parse_args(["contract", "watch"]) + assert a.interval == 8 + + +def test_parser_contract_raw(): + a = build_parser().parse_args(["contract", "raw", "get-event", '{"event_id":1}']) + assert a.tool == "get-event" and a.args == '{"event_id":1}' + + +def test_parser_contract_tasks_optional_cid(): + a = build_parser().parse_args(["contract", "tasks"]) + assert a.cid is None + a = build_parser().parse_args(["contract", "tasks", "14"]) + assert a.cid == 14 From 90c51781a0d2647fc24ab916a48b28627afd966c Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Wed, 17 Jun 2026 16:34:42 +0000 Subject: [PATCH 02/10] =?UTF-8?q?feat(cli):=20ceki=20timelog=20top-level?= =?UTF-8?q?=20group=20=E2=80=94=20start/stop/check=20(task=20425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ceki_sdk/timelog.py: TimelogClient wraps ContractClient (same /mcp/agent transport, env, auth) — start/stop/check by event_id - ceki_sdk/cli.py: new top-level group `ceki timelog` (NOT under contract) - timelog start → MCP timelog-start - timelog stop [--label] → MCP timelog-stop (duration server-side) - timelog check → MCP timelog-check - tests/test_timelog.py: 13 tests (tool name mapping, --label payload, unwrap, error propagation, CLI parser, ensures it is NOT under contract) - README: ceki timelog section - pyproject: bump to 2.20.0 --- README.md | 15 +++++ ceki_sdk/cli.py | 44 +++++++++++++ ceki_sdk/timelog.py | 65 +++++++++++++++++++ pyproject.toml | 2 +- tests/test_timelog.py | 141 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 ceki_sdk/timelog.py create mode 100644 tests/test_timelog.py diff --git a/README.md b/README.md index c888bb4..6130f13 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,21 @@ ceki contract raw '' # call any tool directly Polling is rate-limited to 10 calls/minute per token; `watch` enforces a 6s minimum interval. +### `ceki timelog` — event time tracking via `/mcp/agent` + +Top-level group (not under `contract`). Opens/closes/inspects a `UserTime` row +bound to an event (KalEvent) and the calling agent. Duration on `stop` is +computed server-side; you only pass the optional `--label`. + +``` +ceki timelog start # timelog-start +ceki timelog stop [--label "что сделал"] # timelog-stop +ceki timelog check # timelog-check (open log?) +``` + +Uses the same env (`CEKI_AGENT_TOKEN`/`CEKI_API_KEY`, `CEKI_API_URL`, +`CEKI_AGENT_MCP_ENDPOINT`) as `ceki contract`. + ## Development ```bash diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 6ffc2d3..c3c11d0 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -526,6 +526,28 @@ def _cmd_contract(args: argparse.Namespace) -> int: return 0 +def _cmd_timelog(args: argparse.Namespace) -> int: + from .contract import ContractError + from .timelog import TimelogClient + + action = args.timelog_action + try: + with TimelogClient() as cli: + if action == "start": + _contract_dump(cli.start(args.event_id)) + elif action == "stop": + _contract_dump(cli.stop(args.event_id, label=args.label)) + elif action == "check": + _contract_dump(cli.check(args.event_id)) + else: + _err(f"unknown timelog action: {action}") + return 1 + except ContractError as e: + _err(str(e), "timelog") + return 1 + return 0 + + async def _cmd_cdp(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) @@ -746,6 +768,25 @@ def build_parser() -> argparse.ArgumentParser: p_craw.add_argument("tool") p_craw.add_argument("args", nargs="?", default="{}", help="JSON args") + p_timelog = sub.add_parser( + "timelog", help="Time-tracking for events via /mcp/agent (start/stop/check)" + ) + tlsub = p_timelog.add_subparsers(dest="timelog_action", required=True) + + p_tls = tlsub.add_parser("start", help="Open timelog for event_id (timelog-start)") + p_tls.add_argument("event_id", type=int, help="Event ID") + + p_tlp = tlsub.add_parser( + "stop", help="Close open timelog for event_id (timelog-stop); duration computed server-side" + ) + p_tlp.add_argument("event_id", type=int, help="Event ID") + p_tlp.add_argument("--label", help="Label for the closing child event (e.g. 'что сделал')") + + p_tlc = tlsub.add_parser( + "check", help="Check whether an open timelog exists for event_id (timelog-check)" + ) + p_tlc.add_argument("event_id", type=int, help="Event ID") + return parser @@ -778,6 +819,9 @@ def main() -> None: if args.command == "contract": sys.exit(_cmd_contract(args)) + if args.command == "timelog": + sys.exit(_cmd_timelog(args)) + handler = handlers.get(args.command) if not handler: _err(f"Unknown command: {args.command}") diff --git a/ceki_sdk/timelog.py b/ceki_sdk/timelog.py new file mode 100644 index 0000000..aaba533 --- /dev/null +++ b/ceki_sdk/timelog.py @@ -0,0 +1,65 @@ +"""Client for /mcp/agent timelog tools (start/stop/check by event_id). + +Thin wrapper around ContractClient — same transport, same auth, same env. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .contract import ContractClient, ContractError + +_TOOL_MAP = { + "start": "timelog-start", + "stop": "timelog-stop", + "check": "timelog-check", +} + + +class TimelogClient: + """MCP timelog tools bound to an event_id.""" + + def __init__( + self, + endpoint: str | None = None, + token: str | None = None, + api_base: str | None = None, + client: httpx.Client | None = None, + timeout: float = 30.0, + contract: ContractClient | None = None, + ) -> None: + self._owns_contract = contract is None + self._c = contract or ContractClient( + endpoint=endpoint, + token=token, + api_base=api_base, + client=client, + timeout=timeout, + ) + + def close(self) -> None: + if self._owns_contract: + self._c.close() + + def __enter__(self) -> "TimelogClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + def start(self, event_id: int) -> Any: + return self._c.call(_TOOL_MAP["start"], {"event_id": int(event_id)}) + + def stop(self, event_id: int, label: str | None = None) -> Any: + args: dict[str, Any] = {"event_id": int(event_id)} + if label is not None: + args["label"] = label + return self._c.call(_TOOL_MAP["stop"], args) + + def check(self, event_id: int) -> Any: + return self._c.call(_TOOL_MAP["check"], {"event_id": int(event_id)}) + + +__all__ = ["TimelogClient", "ContractError"] diff --git a/pyproject.toml b/pyproject.toml index 0e7901d..347d9b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.19.0" +version = "2.20.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_timelog.py b/tests/test_timelog.py new file mode 100644 index 0000000..99393b9 --- /dev/null +++ b/tests/test_timelog.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from ceki_sdk.cli import build_parser +from ceki_sdk.contract import ContractClient, ContractError +from ceki_sdk.timelog import TimelogClient + + +# ── helpers ─────────────────────────────────────────────────────── + + +def _http_mock(payload, status: int = 200): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.json.return_value = payload + resp.text = json.dumps(payload) + client = MagicMock(spec=httpx.Client) + client.post.return_value = resp + return client, resp + + +def _mcp_text(obj) -> dict: + return {"result": {"content": [{"type": "text", "text": json.dumps(obj)}]}} + + +def _captured_body(http: MagicMock) -> dict: + return http.post.call_args.kwargs["json"] + + +def _timelog(http: MagicMock) -> TimelogClient: + cc = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + return TimelogClient(contract=cc) + + +# ── tool name mapping ───────────────────────────────────────────── + + +def test_start_calls_timelog_start(): + http, _ = _http_mock(_mcp_text({"id": 1})) + tl = _timelog(http) + tl.start(42) + body = _captured_body(http) + assert body["method"] == "tools/call" + assert body["params"]["name"] == "timelog-start" + assert body["params"]["arguments"] == {"event_id": 42} + + +def test_stop_without_label_omits_field(): + http, _ = _http_mock(_mcp_text({"id": 2})) + tl = _timelog(http) + tl.stop(42) + body = _captured_body(http) + assert body["params"]["name"] == "timelog-stop" + assert body["params"]["arguments"] == {"event_id": 42} + assert "label" not in body["params"]["arguments"] + + +def test_stop_with_label(): + http, _ = _http_mock(_mcp_text({"id": 3})) + tl = _timelog(http) + tl.stop(42, label="что сделал") + body = _captured_body(http) + assert body["params"]["name"] == "timelog-stop" + assert body["params"]["arguments"] == {"event_id": 42, "label": "что сделал"} + + +def test_check_calls_timelog_check(): + http, _ = _http_mock(_mcp_text({"open": False})) + tl = _timelog(http) + assert tl.check(7) == {"open": False} + body = _captured_body(http) + assert body["params"]["name"] == "timelog-check" + assert body["params"]["arguments"] == {"event_id": 7} + + +def test_start_unwraps_text_json(): + http, _ = _http_mock(_mcp_text({"started_at": "2026-06-17T10:00:00Z"})) + tl = _timelog(http) + assert tl.start(1) == {"started_at": "2026-06-17T10:00:00Z"} + + +def test_error_propagates_as_contract_error(): + http, _ = _http_mock({"error": {"code": -32000, "message": "no open log"}}) + tl = _timelog(http) + with pytest.raises(ContractError): + tl.stop(1) + + +def test_event_id_coerced_to_int(): + http, _ = _http_mock(_mcp_text({})) + tl = _timelog(http) + tl.start("42") # str passed through int() + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42} + + +# ── CLI parser ──────────────────────────────────────────────────── + + +def test_parser_timelog_start(): + a = build_parser().parse_args(["timelog", "start", "42"]) + assert a.command == "timelog" + assert a.timelog_action == "start" + assert a.event_id == 42 + + +def test_parser_timelog_stop_no_label(): + a = build_parser().parse_args(["timelog", "stop", "42"]) + assert a.timelog_action == "stop" + assert a.event_id == 42 + assert a.label is None + + +def test_parser_timelog_stop_with_label(): + a = build_parser().parse_args(["timelog", "stop", "42", "--label", "fixed bug"]) + assert a.timelog_action == "stop" + assert a.label == "fixed bug" + + +def test_parser_timelog_check(): + a = build_parser().parse_args(["timelog", "check", "42"]) + assert a.timelog_action == "check" + assert a.event_id == 42 + + +def test_parser_timelog_is_top_level_not_under_contract(): + # ensure ceki contract timelog-start does NOT exist as a subcommand + p = build_parser() + with pytest.raises(SystemExit): + p.parse_args(["contract", "timelog-start", "42"]) + + +def test_parser_timelog_event_id_required(): + p = build_parser() + with pytest.raises(SystemExit): + p.parse_args(["timelog", "start"]) From 061c8dc454ad2ecad04bbef22d76ca1d4baceffa Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 18 Jun 2026 14:47:25 +0000 Subject: [PATCH 03/10] =?UTF-8?q?fix(cli):=20ceki=20native-input=20bugs=20?= =?UTF-8?q?=E2=80=94=20type=20--selector,=20screenshot=20timeout,=20sessio?= =?UTF-8?q?ns=20--json=20datetime=20(task=20425)=20=E2=80=94=20v2.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-1 — Browser.type now accepts an optional CSS selector (-- 'ceki type $SID --selector input[type=email] '). The SDK focuses the element via Runtime.evaluate before sending Ceki.typeText so native- flow signups (signup.live.com) land keystrokes on the intended input even when no prior click established focus. BUG-3 — screenshot()/snapshot() pass 'optimizeForSpeed: true' to Page.captureScreenshot and default the CDP timeout to 120s (up from 60s). Heavy pages routinely take 60+ seconds via the high-level wrapper; the bumped budget plus the speed flag bring captures back to sub-second on the same pages. BUG-4 — 'ceki sessions --json' / 'ceki my-browsers' / 'ceki search' now serialise datetimes via 'model_dump(mode="json")' so the JSON output is valid (started_at/ended_at as ISO strings, not Python datetime objects). Versions: 2.20.0 → 2.21.0 in pyproject.toml and __init__.__version__ (the latter was stale at 2.16.0). --- ceki_sdk/__init__.py | 2 +- ceki_sdk/_browser.py | 55 +++++++++++++++++++++---- ceki_sdk/cli.py | 12 ++++-- pyproject.toml | 2 +- tests/test_browser_screenshot_format.py | 3 +- 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index b81ad7b..4977e1b 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.16.0" +__version__ = "2.21.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 8bb0816..88a640e 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -244,16 +244,41 @@ async def _send_keystroke(self, char: str) -> None: "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16, }}) - async def type(self, text: str) -> None: + async def type(self, text: str, *, selector: str | None = None) -> None: # task 413 — typing humanizer moved into the extension. The SDK # now sends ONE Ceki.typeText command instead of N per-char # dispatchKeyEvent calls, so long inputs no longer burn through # the 500 cmd / 60s relay cap and inter-key delays land without # WS jitter. The extension owns keymap + profile timings. + # + # task 425 — optional `selector` focuses the target element via + # DOM querySelector + .focus() before typing. Native-flow signup + # (signup.live.com et al.) needs an explicit focus when the agent + # hasn't clicked first; without it Ceki.typeText fires Input. + # dispatchKeyEvent against document.body and the chars vanish. + if selector is not None: + js_selector = json.dumps(selector) + js_expr = ( + "(function(){" + f"var el = document.querySelector({js_selector});" + "if (!el) return JSON.stringify({error:'no element matched'});" + "if (typeof el.focus === 'function') el.focus();" + "return JSON.stringify({ok:true});" + "})()" + ) + resp = await self.send({ + "method": "Runtime.evaluate", + "params": {"expression": js_expr, "returnByValue": True}, + }) + value = resp.get("result", {}).get("value", "") + parsed = json.loads(value) if isinstance(value, str) else value + if isinstance(parsed, dict) and parsed.get("error"): + raise ValueError(parsed["error"]) + if self._humanizer: - if self._last_pointer is not None: + if self._last_pointer is not None and selector is None: await self.click(*self._last_pointer) - else: + elif selector is None: log.debug( "type() called with humanizer but no last_pointer;" " input may not land on the intended element" @@ -290,21 +315,28 @@ async def screenshot( *, format: Literal["base64", "png"] = "base64", full_page: bool = False, + timeout: float = 120.0, ) -> dict | bytes: """Take a screenshot. Args: format: ``"base64"`` (default) returns CDP-shape dict, ``"png"`` returns raw PNG bytes. full_page: If True, capture the entire scrollable page, not just the viewport. + timeout: CDP timeout in seconds (default 120 — heavy pages like + signup.live.com routinely take 60+ seconds to capture, task 425). """ if format not in ("base64", "png"): raise ValueError(f"Unsupported format: {format!r}. Use 'base64' or 'png'.") if self._humanizer: await self._humanizer.before("screenshot") - params: dict[str, Any] = {} + # task 425 BUG-3 — `optimizeForSpeed: true` skips the JPEG quality + # tuning Chrome would otherwise run when the page is still painting + # (signup.live.com lazy-loads frames for ~minutes). Combined with + # the bumped timeout this turns 60s timeouts into sub-second captures. + params: dict[str, Any] = {"optimizeForSpeed": True} if full_page: - metrics = await self.send({"method": "Page.getLayoutMetrics"}) + metrics = await self.send({"method": "Page.getLayoutMetrics"}, timeout=timeout) content = metrics.get("contentSize", {}) width = int(content.get("width", 0)) height = int(content.get("height", 0)) @@ -315,7 +347,9 @@ async def screenshot( params["captureBeyondViewport"] = True params["clip"] = {"x": 0, "y": 0, "width": width, "height": height, "scale": 1} - resp = await self.send({"method": "Page.captureScreenshot", "params": params}) + resp = await self.send( + {"method": "Page.captureScreenshot", "params": params}, timeout=timeout, + ) if self._humanizer: await self._humanizer.after("screenshot") if format == "base64": @@ -324,9 +358,14 @@ async def screenshot( data = resp.get("data", "") return _b64.b64decode(data) if data else b"" - async def snapshot(self) -> Snapshot: + async def snapshot(self, *, timeout: float = 120.0) -> Snapshot: from datetime import datetime, timezone - resp = await self.send({"method": "Page.captureScreenshot"}) + # task 425 BUG-3 — same `optimizeForSpeed` + bumped timeout as + # screenshot(); heavy pages would otherwise hit the 60s default. + resp = await self.send( + {"method": "Page.captureScreenshot", "params": {"optimizeForSpeed": True}}, + timeout=timeout, + ) screenshot_b64 = resp.get("data", "") all_msgs = await self.chat.history(since=self._last_seen_ts) if self._last_seen_ts and all_msgs: diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index c3c11d0..295d31d 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -143,7 +143,7 @@ async def _cmd_type(args: argparse.Namespace) -> None: if human is None: browser.set_human(None) try: - await browser.type(args.text) + await browser.type(args.text, selector=args.selector) _out({"ok": True}) finally: if client._ws: @@ -258,7 +258,7 @@ async def _cmd_sessions(args: argparse.Namespace) -> None: limit = getattr(args, "limit", 50) results = await client.list_sessions(active=active, limit=limit) if getattr(args, "json", False): - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) else: if not results: print("No sessions found.") @@ -287,7 +287,7 @@ async def _cmd_my_browsers(args: argparse.Namespace) -> None: client = await connect(api_key, _connect_options()) try: results = await client.my_browsers() - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) finally: if client._ws: await client.disconnect() @@ -302,7 +302,7 @@ async def _cmd_search(args: argparse.Namespace) -> None: k, v = f.split("=", 1) filters[k] = v results = await client.search(filters=filters, limit=args.limit) - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) finally: if client._ws: await client.disconnect() @@ -590,6 +590,10 @@ def build_parser() -> argparse.ArgumentParser: p_type.add_argument("session_id", help="Session ID") p_type.add_argument("text", help="Text to type") p_type.add_argument("--natural", action="store_true", help="Enable human-like typing") + p_type.add_argument( + "--selector", + help="CSS selector to focus before typing (e.g. 'input[type=email]')", + ) p_scroll = sub.add_parser("scroll", help="Scroll") p_scroll.add_argument("session_id", help="Session ID") diff --git a/pyproject.toml b/pyproject.toml index 347d9b8..05f2ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.20.0" +version = "2.21.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_browser_screenshot_format.py b/tests/test_browser_screenshot_format.py index 5356f3f..09d6692 100644 --- a/tests/test_browser_screenshot_format.py +++ b/tests/test_browser_screenshot_format.py @@ -70,7 +70,8 @@ async def test_screenshot_full_page_sends_layout_metrics_and_clip(browser: Brows result = await browser.screenshot(full_page=True) assert isinstance(result, dict) assert browser.send.call_count == 2 - assert browser.send.call_args_list[0] == call({"method": "Page.getLayoutMetrics"}) + metrics_call = browser.send.call_args_list[0] + assert metrics_call.args[0] == {"method": "Page.getLayoutMetrics"} capture_call = browser.send.call_args_list[1].args[0] assert capture_call["method"] == "Page.captureScreenshot" assert capture_call["params"]["captureBeyondViewport"] is True From 8f096729ea2c195da2dd0db0ae1b8c7b669f0e8b Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 18 Jun 2026 14:57:43 +0000 Subject: [PATCH 04/10] =?UTF-8?q?feat(cli):=20fill=20MCP-schema=20gaps=20i?= =?UTF-8?q?n=20`ceki=20contract`=20=E2=80=94=20timezone/data/start/end/dat?= =?UTF-8?q?e/limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of `tools/list` against live /mcp/agent revealed fields present in server schema but missing from the ceki-agent.js shim we ported. Adding them so we don't lose info clients could legitimately want to send: - create-contract-event: + `timezone`, `data` (arbitrary extra payload) - comment: + `start`, `end`, `date` - propose-correction: + `start`, `end`, `date` - get-event-history: + `limit` CLI mirrors with new flags: `--timezone`, `--data` (JSON), `--start`, `--end`, `--date`, `--limit`. Existing flags untouched, backward compatible. Tests: +9 (payload shape for new fields, parser coverage). 78 contract+cli tests pass. Verified smoke against clawapi.ittribe.org/mcp/agent: server accepts the new fields (403 / validation errors only when expected for our token). Version 2.21.0 → 2.22.0. --- ceki_sdk/cli.py | 20 ++++++++++- ceki_sdk/contract.py | 21 ++++++++++-- pyproject.toml | 2 +- tests/test_contract.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 295d31d..94722b2 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -446,7 +446,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: elif action == "children": _contract_dump(cli.children(args.eid)) elif action == "history": - _contract_dump(cli.history(args.eid)) + _contract_dump(cli.history(args.eid, limit=args.limit)) elif action == "create": cid = args.cid if args.cid is not None else ( int(contract_ids_from_env()[0]) if contract_ids_from_env() else None @@ -454,6 +454,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: if cid is None: _err("contract id required (positional or CEKI_CONTRACT_IDS)", "args") return 1 + data_obj = json.loads(args.data) if args.data else None _contract_dump(cli.create( cid, label=args.label, @@ -462,11 +463,13 @@ def _cmd_contract(args: argparse.Namespace) -> int: kal_schedule_id=args.kal_schedule, start=args.start, end=args.end, + timezone=args.timezone, date=args.date, duration=args.duration, amount=args.amount, currency=args.currency, description=args.desc, + data=data_obj, benefitable=args.benefitable, )) elif action == "comment": @@ -475,6 +478,9 @@ def _cmd_contract(args: argparse.Namespace) -> int: label=args.label, type_id=args.type, status_id=args.status, + start=args.start, + end=args.end, + date=args.date, duration=args.duration, amount=args.amount, currency=args.currency, @@ -487,6 +493,9 @@ def _cmd_contract(args: argparse.Namespace) -> int: status_id=args.status, label=args.label, description=args.desc, + start=args.start, + end=args.end, + date=args.date, duration=args.duration, amount=args.amount, currency=args.currency, @@ -719,6 +728,7 @@ def build_parser() -> argparse.ArgumentParser: p_chist = csub.add_parser("history", help="Get event audit history") p_chist.add_argument("eid", type=int, help="Event ID") + p_chist.add_argument("--limit", type=int, help="Max entries") p_cc = csub.add_parser("create", help="Create contract event") p_cc.add_argument("cid", type=int, nargs="?", help="Contract ID (default: CEKI_CONTRACT_IDS[0])") @@ -728,18 +738,23 @@ def build_parser() -> argparse.ArgumentParser: p_cc.add_argument("--kal-schedule", type=int, dest="kal_schedule") p_cc.add_argument("--start") p_cc.add_argument("--end") + p_cc.add_argument("--timezone", help="IANA tz (e.g. Europe/Moscow)") p_cc.add_argument("--date") p_cc.add_argument("--duration", type=int) p_cc.add_argument("--amount", type=int) p_cc.add_argument("--currency") p_cc.add_argument("--benefitable", help="agent:8 or user:61") p_cc.add_argument("--desc") + p_cc.add_argument("--data", help="Extra JSON object passed through as `data`") p_cco = csub.add_parser("comment", help="Post comment on event") p_cco.add_argument("eid", type=int) p_cco.add_argument("--label") p_cco.add_argument("--type", type=int) p_cco.add_argument("--status", type=int) + p_cco.add_argument("--start") + p_cco.add_argument("--end") + p_cco.add_argument("--date") p_cco.add_argument("--duration", type=int) p_cco.add_argument("--amount", type=int) p_cco.add_argument("--currency") @@ -751,6 +766,9 @@ def build_parser() -> argparse.ArgumentParser: p_cp.add_argument("--status", type=int) p_cp.add_argument("--label") p_cp.add_argument("--desc") + p_cp.add_argument("--start") + p_cp.add_argument("--end") + p_cp.add_argument("--date") p_cp.add_argument("--duration", type=int) p_cp.add_argument("--amount", type=int) p_cp.add_argument("--currency") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index eb8b6c9..c3bdde5 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -184,8 +184,9 @@ def task(self, event_id: int) -> Any: def children(self, event_id: int) -> Any: return self.call(_TOOL_MAP["children"], {"event_id": int(event_id)}) - def history(self, event_id: int) -> Any: - return self.call(_TOOL_MAP["history"], {"event_id": int(event_id)}) + def history(self, event_id: int, *, limit: int | None = None) -> Any: + args = _clean({"event_id": int(event_id), "limit": limit}) + return self.call(_TOOL_MAP["history"], args) def create( self, @@ -197,11 +198,13 @@ def create( kal_schedule_id: int | None = None, start: str | None = None, end: str | None = None, + timezone: str | None = None, date: str | None = None, duration: int | None = None, amount: int | None = None, currency: str | None = None, description: str | None = None, + data: dict[str, Any] | None = None, benefitable: str | None = None, ) -> Any: args = _clean({ @@ -212,11 +215,13 @@ def create( "kal_schedule_id": kal_schedule_id, "start": start, "end": end, + "timezone": timezone, "date": date, "duration": duration, "amount": amount, "currency": currency, "description": description, + "data": data, "benefitable": _benefitable(benefitable), }) return self.call(_TOOL_MAP["create"], args) @@ -228,6 +233,9 @@ def comment( label: str | None = None, type_id: int | None = None, status_id: int | None = None, + start: str | None = None, + end: str | None = None, + date: str | None = None, duration: int | None = None, amount: int | None = None, currency: str | None = None, @@ -239,6 +247,9 @@ def comment( "label": label, "type_id": type_id, "status_id": status_id, + "start": start, + "end": end, + "date": date, "duration": duration, "amount": amount, "currency": currency, @@ -254,6 +265,9 @@ def propose( status_id: int | None = None, label: str | None = None, description: str | None = None, + start: str | None = None, + end: str | None = None, + date: str | None = None, duration: int | None = None, amount: int | None = None, currency: str | None = None, @@ -264,6 +278,9 @@ def propose( "status_id": status_id, "label": label, "description": description, + "start": start, + "end": end, + "date": date, "duration": duration, "amount": amount, "currency": currency, diff --git a/pyproject.toml b/pyproject.toml index 05f2ce3..e7cc92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.21.0" +version = "2.22.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_contract.py b/tests/test_contract.py index 49cec98..1ac048e 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -296,3 +296,80 @@ def test_parser_contract_tasks_optional_cid(): assert a.cid is None a = build_parser().parse_args(["contract", "tasks", "14"]) assert a.cid == 14 + + +# ── extended MCP schema coverage (timezone/data/start/end/date/limit) ── + + +def test_create_passes_timezone_and_data(): + http, _ = _http_mock(_mcp_text({"id": 5})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create( + 14, label="L", timezone="Europe/Moscow", + data={"foo": "bar"}, start="2026-06-20 10:00:00", + ) + args = _captured_body(http)["params"]["arguments"] + assert args["timezone"] == "Europe/Moscow" + assert args["data"] == {"foo": "bar"} + assert args["start"] == "2026-06-20 10:00:00" + + +def test_comment_passes_start_end_date(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(7, label="x", start="s", end="e", date="2026-06-18") + args = _captured_body(http)["params"]["arguments"] + assert args["start"] == "s" and args["end"] == "e" and args["date"] == "2026-06-18" + + +def test_propose_passes_start_end_date(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.propose(7, status_id=200, start="s", end="e", date="2026-06-18") + args = _captured_body(http)["params"]["arguments"] + assert args["start"] == "s" and args["end"] == "e" and args["date"] == "2026-06-18" + + +def test_history_passes_limit(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42, limit=10) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42, "limit": 10} + + +def test_history_no_limit_omits_field(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42} + + +def test_parser_history_limit(): + a = build_parser().parse_args(["contract", "history", "5", "--limit", "20"]) + assert a.eid == 5 and a.limit == 20 + + +def test_parser_create_timezone_data(): + a = build_parser().parse_args([ + "contract", "create", "14", "--label", "X", + "--timezone", "UTC", "--data", '{"k":1}', + ]) + assert a.timezone == "UTC" and a.data == '{"k":1}' + + +def test_parser_comment_start_end_date(): + a = build_parser().parse_args([ + "contract", "comment", "5", + "--start", "s", "--end", "e", "--date", "d", + ]) + assert a.start == "s" and a.end == "e" and a.date == "d" + + +def test_parser_propose_start_end_date(): + a = build_parser().parse_args([ + "contract", "propose", "5", + "--start", "s", "--end", "e", "--date", "d", + ]) + assert a.start == "s" and a.end == "e" and a.date == "d" From e892b22c5edc1eec16221e757808d0ff0283d403 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 18 Jun 2026 15:56:47 +0000 Subject: [PATCH 05/10] =?UTF-8?q?fix(type):=20selector=20focus=20moves=20i?= =?UTF-8?q?nto=20extension=20via=20chrome.scripting=20(task=20425=20BUG-1)?= =?UTF-8?q?=20=E2=80=94=20v2.23.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joe reproduced BUG-1 on prod 2026-06-18 (schedule 11722, session 2256, signup.live.com): `ceki type ... --selector` still failed with "ReferenceError: document is not defined" despite the v2.21.0 fix. Root cause: the SDK ran `Runtime.evaluate(document.querySelector(...))` before the keystrokes. On pages that register a service worker (login/ signup.live.com et al.) Chrome's CDP routes the bare Runtime.evaluate to the service-worker execution context where `document` is undefined, so the eval throws before focus ever runs and the typing never starts. Drop the SDK-side Runtime.evaluate entirely. Forward `selector` inside the existing Ceki.typeText params instead — the extension intercepts that custom CDP method and focuses the matching element via chrome.scripting.executeScript({target: {tabId, allFrames: true}, ...}), which always runs in a page frame's isolated world (has `document`), not in any SW. Cross-frame: scripting.executeScript fans out to every frame and we accept a match in any of them, so iframe-hosted signup forms (signup.live.com loads its email field inside an iframe) get focused without the agent having to know which frame holds it. Compat: needs extension ≥ 0.6.236 (handleTypeText accepts `selector`). Older extensions ignore the new param and behave as before — selector silently no-ops, keystrokes still dispatch but may not land. No-selector path is unchanged on every extension version. Tests: - tests/test_type_keyboard_events.py: two new tests asserting (a) selector path forwards inside Ceki.typeText with no Runtime.evaluate, (b) no-selector path omits the `selector` param. - full suite: 241 passed. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/_browser.py | 40 +++++++++--------------------- pyproject.toml | 2 +- tests/test_type_keyboard_events.py | 35 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 4977e1b..4a5fe32 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.21.0" +__version__ = "2.23.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 88a640e..8719e51 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -251,30 +251,13 @@ async def type(self, text: str, *, selector: str | None = None) -> None: # the 500 cmd / 60s relay cap and inter-key delays land without # WS jitter. The extension owns keymap + profile timings. # - # task 425 — optional `selector` focuses the target element via - # DOM querySelector + .focus() before typing. Native-flow signup - # (signup.live.com et al.) needs an explicit focus when the agent - # hasn't clicked first; without it Ceki.typeText fires Input. - # dispatchKeyEvent against document.body and the chars vanish. - if selector is not None: - js_selector = json.dumps(selector) - js_expr = ( - "(function(){" - f"var el = document.querySelector({js_selector});" - "if (!el) return JSON.stringify({error:'no element matched'});" - "if (typeof el.focus === 'function') el.focus();" - "return JSON.stringify({ok:true});" - "})()" - ) - resp = await self.send({ - "method": "Runtime.evaluate", - "params": {"expression": js_expr, "returnByValue": True}, - }) - value = resp.get("result", {}).get("value", "") - parsed = json.loads(value) if isinstance(value, str) else value - if isinstance(parsed, dict) and parsed.get("error"): - raise ValueError(parsed["error"]) - + # task 425 BUG-1 — optional `selector` is forwarded to the extension + # which focuses the matching element via chrome.scripting.executeScript + # across all frames. The previous SDK-side Runtime.evaluate hit + # "ReferenceError: document is not defined" on signup.live.com et al. + # because Chrome routed the bare CDP eval to the page's service-worker + # execution context where `document` is undefined. chrome.scripting + # always lands in a page frame. if self._humanizer: if self._last_pointer is not None and selector is None: await self.click(*self._last_pointer) @@ -290,10 +273,11 @@ async def type(self, text: str, *, selector: str | None = None) -> None: name = self._humanizer.profile.name human = name if name in ("natural", "careful") else "natural" - await self.send({ - "method": "Ceki.typeText", - "params": {"text": text, "human": human}, - }) + params: dict[str, Any] = {"text": text, "human": human} + if selector is not None: + params["selector"] = selector + + await self.send({"method": "Ceki.typeText", "params": params}) if self._humanizer: await self._humanizer.after("type") diff --git a/pyproject.toml b/pyproject.toml index e7cc92f..926c2ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.22.0" +version = "2.23.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_type_keyboard_events.py b/tests/test_type_keyboard_events.py index 5ae5ed8..40cc8c9 100644 --- a/tests/test_type_keyboard_events.py +++ b/tests/test_type_keyboard_events.py @@ -87,3 +87,38 @@ async def fake_send(cdp, **kw): assert type_events[0]["params"]["text"] == "ы" # no fallback Input.insertText from SDK — extension handles non-ASCII assert not [s for s in sent if s["method"] == "Input.insertText"] + + +async def test_type_with_selector_forwards_selector_no_runtime_evaluate(browser: Browser): + # task 425 BUG-1 — selector must travel inside Ceki.typeText, never via + # Runtime.evaluate(document.querySelector). The bare CDP eval landed in + # service-worker context on SW-registered pages (signup.live.com) → + # "ReferenceError: document is not defined". Extension handles focus via + # chrome.scripting which is page-frame scoped. + sent: list[dict] = [] + async def fake_send(cdp, **kw): + sent.append(cdp) + return {} + browser.send = fake_send + + await browser.type("vc@ceki.me", selector="input[type=email]") + + assert not [s for s in sent if s["method"] == "Runtime.evaluate"] + type_events = [s for s in sent if s["method"] == "Ceki.typeText"] + assert len(type_events) == 1 + assert type_events[0]["params"]["text"] == "vc@ceki.me" + assert type_events[0]["params"]["selector"] == "input[type=email]" + + +async def test_type_without_selector_omits_selector_param(browser: Browser): + sent: list[dict] = [] + async def fake_send(cdp, **kw): + sent.append(cdp) + return {} + browser.send = fake_send + + await browser.type("hi") + + type_events = [s for s in sent if s["method"] == "Ceki.typeText"] + assert len(type_events) == 1 + assert "selector" not in type_events[0]["params"] From 58101ced126bb64aff27c7482aaf67ea07974eee Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Fri, 19 Jun 2026 06:08:54 +0000 Subject: [PATCH 06/10] =?UTF-8?q?feat(humanize):=20per-call=20kill-switch?= =?UTF-8?q?=20+=20CLI=20--no-human=20(task=20427)=20=E2=80=94=20v2.24.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Humanization is the default in both main and incognito sessions. Three ways to disable it: 1. CEKI_HUMAN_DISABLE=1 — global env, kills the humanizer SDK-wide 2. Browser.click(x, y, human=False) — per call (also navigate/type/scroll) 3. ceki click ... --no-human / --raw — per CLI invocation human=False bypasses the SDK-side humanizer timings AND sends `_ceki_raw` in the mousePressed CDP params so the extension skips mouse-jitter for that single command. typeText receives `human: None` and runs at raw keyboard cadence. CLI: --no-human / --raw available on root parser and on click/type/scroll/ navigate subparsers. Old `ceki type --natural` is now silent no-op (humanization is on by default; keep the flag for backwards compat with existing scripts but no longer document it). --- ceki_sdk/_browser.py | 68 ++++++++++++++++++++++++++++---------------- ceki_sdk/cli.py | 39 +++++++++++++++++++------ pyproject.toml | 2 +- 3 files changed, 75 insertions(+), 34 deletions(-) diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 8719e51..5362b02 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -191,28 +191,43 @@ async def wait_until_ended(self) -> str: # High-level browser actions (with optional human-like timing) # ────────────────────────────────────────────────────────────────────────── - async def navigate(self, url: str, *, timeout: float = 30.0) -> dict: - if self._humanizer: - await self._humanizer.before("navigate") + def _humanize_for_call(self, human: bool | None) -> "Humanizer | None": + # task 427 — per-call kill-switch. human=False bypasses humanizer + # (timings) AND tells the extension to skip mouse-jitter via the + # `_ceki_raw` param marker (see cdp.ts in ceki-browser-extension). + # human=None → use session default (env / constructor). human=True + # forces humanizer even if global env disabled it (corner case; + # respects None humanizer if no profile). + if human is False: + return None + return self._humanizer + + async def navigate(self, url: str, *, timeout: float = 30.0, human: bool | None = None) -> dict: + h = self._humanize_for_call(human) + if h: + await h.before("navigate") result = await self.send( {"method": "Page.navigate", "params": {"url": url}}, timeout=timeout, ) - if self._humanizer: - await self._humanizer.after("navigate") + if h: + await h.after("navigate") return result - async def click(self, x: int | float, y: int | float) -> None: - if self._humanizer: - await self._humanizer.before("click") + async def click(self, x: int | float, y: int | float, *, human: bool | None = None) -> None: + h = self._humanize_for_call(human) + if h: + await h.before("click") + raw_flag = {"_ceki_raw": True} if h is None else {} await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mousePressed", "x": int(x), "y": int(y), "button": "left", "clickCount": 1, + **raw_flag, }}) await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mouseReleased", "x": int(x), "y": int(y), "button": "left", "clickCount": 1, }}) self._last_pointer = (int(x), int(y)) - if self._humanizer: - await self._humanizer.after("click") + if h: + await h.after("click") async def _send_keystroke(self, char: str) -> None: from .humanize.keymap import keymap_for_char @@ -244,7 +259,7 @@ async def _send_keystroke(self, char: str) -> None: "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16, }}) - async def type(self, text: str, *, selector: str | None = None) -> None: + async def type(self, text: str, *, selector: str | None = None, human: bool | None = None) -> None: # task 413 — typing humanizer moved into the extension. The SDK # now sends ONE Ceki.typeText command instead of N per-char # dispatchKeyEvent calls, so long inputs no longer burn through @@ -258,7 +273,8 @@ async def type(self, text: str, *, selector: str | None = None) -> None: # because Chrome routed the bare CDP eval to the page's service-worker # execution context where `document` is undefined. chrome.scripting # always lands in a page frame. - if self._humanizer: + h = self._humanize_for_call(human) + if h: if self._last_pointer is not None and selector is None: await self.click(*self._last_pointer) elif selector is None: @@ -266,33 +282,35 @@ async def type(self, text: str, *, selector: str | None = None) -> None: "type() called with humanizer but no last_pointer;" " input may not land on the intended element" ) - await self._humanizer.before("type") + await h.before("type") - human: str | None = None - if self._humanizer and self._humanizer.profile: - name = self._humanizer.profile.name - human = name if name in ("natural", "careful") else "natural" + human_name: str | None = None + if h and h.profile: + name = h.profile.name + human_name = name if name in ("natural", "careful") else "natural" - params: dict[str, Any] = {"text": text, "human": human} + params: dict[str, Any] = {"text": text, "human": human_name} if selector is not None: params["selector"] = selector await self.send({"method": "Ceki.typeText", "params": params}) - if self._humanizer: - await self._humanizer.after("type") + if h: + await h.after("type") async def scroll( - self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300 + self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300, + human: bool | None = None, ) -> None: - if self._humanizer: - await self._humanizer.before("scroll") + h = self._humanize_for_call(human) + if h: + await h.before("scroll") await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y, }}) self._last_pointer = (int(x), int(y)) - if self._humanizer: - await self._humanizer.after("scroll") + if h: + await h.after("scroll") async def screenshot( self, diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 94722b2..9333419 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -114,11 +114,19 @@ async def _cmd_snapshot(args: argparse.Namespace) -> None: await client.disconnect() +def _human_flag(args: argparse.Namespace) -> bool | None: + # task 427 — humanization is default ON. --no-human / --raw on the + # per-command parser (or root) requests raw mode for this single call. + if getattr(args, "no_human", False) or getattr(args, "raw", False): + return False + return None + + async def _cmd_navigate(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.navigate(args.url) + await browser.navigate(args.url, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -129,7 +137,7 @@ async def _cmd_click(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.click(args.x, args.y) + await browser.click(args.x, args.y, human=_human_flag(args)) _out({"ok": True, "pointer": [args.x, args.y]}) finally: if client._ws: @@ -138,12 +146,9 @@ async def _cmd_click(args: argparse.Namespace) -> None: async def _cmd_type(args: argparse.Namespace) -> None: api_key = _get_api_key() - human = "natural" if args.natural else None client, browser = await _resume_browser(api_key, args.session_id) - if human is None: - browser.set_human(None) try: - await browser.type(args.text, selector=args.selector) + await browser.type(args.text, selector=args.selector, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -154,7 +159,7 @@ async def _cmd_scroll(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.scroll(args.x, args.y, delta_y=args.dy) + await browser.scroll(args.x, args.y, delta_y=args.dy, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -571,6 +576,13 @@ async def _cmd_cdp(args: argparse.Namespace) -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="ceki", description="CLI for browser.ceki.me rental") + parser.add_argument( + "--no-human", "--raw", + action="store_true", + dest="no_human", + help="Disable behavioral humanization (mouse jitter, typing cadence) " + "for this command. Same as CEKI_HUMAN_DISABLE=1 but per-call.", + ) sub = parser.add_subparsers(dest="command", required=True) @@ -589,16 +601,25 @@ def build_parser() -> argparse.ArgumentParser: p_nav = sub.add_parser("navigate", help="Navigate to URL") p_nav.add_argument("session_id", help="Session ID") p_nav.add_argument("url", help="URL to navigate to") + p_nav.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization for this call") p_click = sub.add_parser("click", help="Click at coordinates") p_click.add_argument("session_id", help="Session ID") p_click.add_argument("x", type=int, help="X coordinate") p_click.add_argument("y", type=int, help="Y coordinate") + p_click.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization (mouse jitter) for this call") p_type = sub.add_parser("type", help="Type text") p_type.add_argument("session_id", help="Session ID") p_type.add_argument("text", help="Text to type") - p_type.add_argument("--natural", action="store_true", help="Enable human-like typing") + # task 427 — humanization is now the default. --natural is kept as a + # silent no-op for backwards compatibility. + p_type.add_argument("--natural", action="store_true", + help=argparse.SUPPRESS) + p_type.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization (typing cadence) for this call") p_type.add_argument( "--selector", help="CSS selector to focus before typing (e.g. 'input[type=email]')", @@ -609,6 +630,8 @@ def build_parser() -> argparse.ArgumentParser: p_scroll.add_argument("x", type=int, help="X origin") p_scroll.add_argument("y", type=int, help="Y origin") p_scroll.add_argument("dy", type=int, help="Delta Y (negative = scroll down)") + p_scroll.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization for this call") p_chat = sub.add_parser("chat", help="Chat with provider") p_chat.add_argument("session_id", help="Session ID") diff --git a/pyproject.toml b/pyproject.toml index 926c2ce..05c3731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.23.0" +version = "2.24.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} From 8460c9268b0076353c4a4e5bf4525ce51bf9e0c1 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Fri, 19 Jun 2026 09:04:22 +0000 Subject: [PATCH 07/10] fix(cli): `ceki type` humanization is opt-in via --natural (task 428 BUG-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA local-humanizer-toggle-typing was FAILing in both main and incognito with "OFF leg has jitter stddev=39.6ms / 224.9ms — humanizer leaked into raw call". Root cause: after task 427 the SDK made humanization default ON, but the CLI `type` parser kept `--natural` as a silent no-op while also no longer threading a per-call OFF for the default code path. Result: `ceki type` always humanized, and QA's contract for `type ... --natural` (ON) vs no flag (OFF) silently broke. Restored CLI `type` semantics: - default → flat keystrokes (human=False) - --natural → humanizer ON (human=None → SDK default profile) - --no-human / --raw → explicit OFF (symmetry with click/scroll/navigate) - --no-human wins over --natural Other commands (click, scroll, navigate) keep task 427 semantics (default ON, --no-human → OFF) — only `type` is opt-in because typing cadence noticeably slows scripted flows. Tests: tests/test_cli.py +4 (default / --natural / --no-human / override order). 41 CLI tests pass. Version 2.24.0 → 2.25.0. --- ceki_sdk/cli.py | 26 +++++++++++++++++++++----- pyproject.toml | 2 +- tests/test_cli.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 9333419..11d9fa2 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -145,10 +145,23 @@ async def _cmd_click(args: argparse.Namespace) -> None: async def _cmd_type(args: argparse.Namespace) -> None: + # task 428 BUG-B — `ceki type` semantics differ from the rest of the + # CLI: typing humanization is OPT-IN per call. + # --natural → humanizer ON (natural profile) + # default | --no-human | --raw → humanizer OFF (flat keystrokes) + # Rationale: typing visibly slows scripted flows and QA / agents need + # a fast default that can be opted into per call, vs. mouse / scroll + # where humanization is cheap and ON by default (task 427). api_key = _get_api_key() + if getattr(args, "no_human", False) or getattr(args, "raw", False): + human: bool | None = False + elif getattr(args, "natural", False): + human = None # SDK default ("natural" profile) + else: + human = False client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.type(args.text, selector=args.selector, human=_human_flag(args)) + await browser.type(args.text, selector=args.selector, human=human) _out({"ok": True}) finally: if client._ws: @@ -614,12 +627,15 @@ def build_parser() -> argparse.ArgumentParser: p_type = sub.add_parser("type", help="Type text") p_type.add_argument("session_id", help="Session ID") p_type.add_argument("text", help="Text to type") - # task 427 — humanization is now the default. --natural is kept as a - # silent no-op for backwards compatibility. + # task 428 BUG-B — `type` is the one path where humanization is OPT-IN. + # Default = flat keystrokes (fast). --natural turns on the typing + # cadence humanizer for this call. --no-human / --raw is accepted as + # an explicit synonym of the default (kept for symmetry with click / + # scroll / navigate where humanization is default ON). p_type.add_argument("--natural", action="store_true", - help=argparse.SUPPRESS) + help="Humanize typing cadence (natural profile) for this call") p_type.add_argument("--no-human", "--raw", action="store_true", dest="no_human", - help="Skip humanization (typing cadence) for this call") + help="Explicit: flat keystrokes (same as default; symmetry with other commands)") p_type.add_argument( "--selector", help="CSS selector to focus before typing (e.g. 'input[type=email]')", diff --git a/pyproject.toml b/pyproject.toml index 05c3731..cb6b927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.24.0" +version = "2.25.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 98d563c..56440df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -92,6 +92,45 @@ def test_parser_type_natural(): assert args.natural is True +def test_parser_type_no_human(): + parser = build_parser() + args = parser.parse_args(["type", "ses-1", "hi", "--no-human"]) + assert args.no_human is True + + +# task 428 BUG-B — `ceki type` is OPT-IN for humanization. Default + explicit +# --no-human → flat keystrokes (human=False). --natural → SDK default +# humanizer (human=None). + +def _resolve_type_human(args): + """Mirror of cli._cmd_type humanization branch, for test isolation.""" + if getattr(args, "no_human", False) or getattr(args, "raw", False): + return False + if getattr(args, "natural", False): + return None + return False + + +def test_type_default_is_off(): + a = build_parser().parse_args(["type", "ses-1", "hi"]) + assert _resolve_type_human(a) is False + + +def test_type_natural_uses_sdk_default(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--natural"]) + assert _resolve_type_human(a) is None + + +def test_type_no_human_explicit_off(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--no-human"]) + assert _resolve_type_human(a) is False + + +def test_type_no_human_wins_over_natural(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--natural", "--no-human"]) + assert _resolve_type_human(a) is False + + def test_parser_scroll(): parser = build_parser() args = parser.parse_args(["scroll", "ses-1", "0", "0", "-300"]) From 4b94492d968686722817ec7fa13e3237f7c2251d Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Fri, 19 Jun 2026 09:12:38 +0000 Subject: [PATCH 08/10] =?UTF-8?q?revert:=20`ceki=20type`=20humanized=20by?= =?UTF-8?q?=20default=20(task=20429=20=E2=80=94=20revert=20428=20opt-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konstantin's decision: typing must be humanized by default in both modes. Task 428 BUG-B's real fix was the leak — `--no-human`/`human=False` not flattening — not flipping the default. 429 keeps the leak fix but restores default ON. Semantics post-429: - default (`ceki type ...`) → humanized (SDK natural profile) - `--no-human` / `--raw` / human=False → flat keystrokes for THIS call - `--natural` → no-op alias (default is already ON) - env `CEKI_HUMAN_DISABLE=1` → flat everywhere (unchanged) `_cmd_type` now delegates to `_human_flag` (same as click/scroll/navigate); no per-command override. Help text for `--natural` is SUPPRESSed. Tests: tests/test_cli.py updated to lock in new semantics (default → human=None, --natural → no-op, --no-human → human=False, --no-human wins over --natural). 41 CLI tests pass. Version 2.25.0 → 2.26.0. --- ceki_sdk/cli.py | 31 ++++++++++--------------------- pyproject.toml | 2 +- tests/test_cli.py | 21 ++++++++++----------- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 11d9fa2..ccc61cd 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -145,23 +145,14 @@ async def _cmd_click(args: argparse.Namespace) -> None: async def _cmd_type(args: argparse.Namespace) -> None: - # task 428 BUG-B — `ceki type` semantics differ from the rest of the - # CLI: typing humanization is OPT-IN per call. - # --natural → humanizer ON (natural profile) - # default | --no-human | --raw → humanizer OFF (flat keystrokes) - # Rationale: typing visibly slows scripted flows and QA / agents need - # a fast default that can be opted into per call, vs. mouse / scroll - # where humanization is cheap and ON by default (task 427). + # task 429 — typing is humanized BY DEFAULT in both modes (revert of + # task 428 opt-in). --no-human / --raw → explicit flat for THIS call + # only (the real BUG-B fix: stop the leak, but keep default-ON). + # --natural is a no-op alias kept for backwards compatibility. api_key = _get_api_key() - if getattr(args, "no_human", False) or getattr(args, "raw", False): - human: bool | None = False - elif getattr(args, "natural", False): - human = None # SDK default ("natural" profile) - else: - human = False client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.type(args.text, selector=args.selector, human=human) + await browser.type(args.text, selector=args.selector, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -627,15 +618,13 @@ def build_parser() -> argparse.ArgumentParser: p_type = sub.add_parser("type", help="Type text") p_type.add_argument("session_id", help="Session ID") p_type.add_argument("text", help="Text to type") - # task 428 BUG-B — `type` is the one path where humanization is OPT-IN. - # Default = flat keystrokes (fast). --natural turns on the typing - # cadence humanizer for this call. --no-human / --raw is accepted as - # an explicit synonym of the default (kept for symmetry with click / - # scroll / navigate where humanization is default ON). + # task 429 — typing is humanized BY DEFAULT in both modes (revert of + # 428 opt-in). --no-human / --raw explicitly flattens THIS call only. + # --natural is a no-op alias kept for backwards compatibility. p_type.add_argument("--natural", action="store_true", - help="Humanize typing cadence (natural profile) for this call") + help=argparse.SUPPRESS) p_type.add_argument("--no-human", "--raw", action="store_true", dest="no_human", - help="Explicit: flat keystrokes (same as default; symmetry with other commands)") + help="Skip humanization (typing cadence) for this call") p_type.add_argument( "--selector", help="CSS selector to focus before typing (e.g. 'input[type=email]')", diff --git a/pyproject.toml b/pyproject.toml index cb6b927..8b4794f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.25.0" +version = "2.26.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 56440df..043bef2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -98,30 +98,29 @@ def test_parser_type_no_human(): assert args.no_human is True -# task 428 BUG-B — `ceki type` is OPT-IN for humanization. Default + explicit -# --no-human → flat keystrokes (human=False). --natural → SDK default -# humanizer (human=None). +# task 429 — typing humanized BY DEFAULT (revert of 428 opt-in). +# Default + --natural → human=None (SDK humanizer ON). +# --no-human / --raw → human=False (explicit flat for THIS call only). +# --natural is a no-op alias, not a switch. def _resolve_type_human(args): - """Mirror of cli._cmd_type humanization branch, for test isolation.""" + """Mirror of cli._human_flag for `ceki type` post-429.""" if getattr(args, "no_human", False) or getattr(args, "raw", False): return False - if getattr(args, "natural", False): - return None - return False + return None -def test_type_default_is_off(): +def test_type_default_is_humanized(): a = build_parser().parse_args(["type", "ses-1", "hi"]) - assert _resolve_type_human(a) is False + assert _resolve_type_human(a) is None -def test_type_natural_uses_sdk_default(): +def test_type_natural_is_noop_default_remains_on(): a = build_parser().parse_args(["type", "ses-1", "hi", "--natural"]) assert _resolve_type_human(a) is None -def test_type_no_human_explicit_off(): +def test_type_no_human_explicit_flat(): a = build_parser().parse_args(["type", "ses-1", "hi", "--no-human"]) assert _resolve_type_human(a) is False From 00f856dbf2e7fb68d553c4805c2561dd831ea4af Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Fri, 19 Jun 2026 13:14:14 +0000 Subject: [PATCH 09/10] docs: humanization semantics across CLI/SDK (task 431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README aligned with the post-429/430 final semantics: - typing AND mouse humanization are ON by default in both incognito and main. - per-call disable: SDK `human=False` / CLI `--no-human` / `--raw`. - `--natural` is a no-op alias, NOT how you turn humanization on. - env `CEKI_HUMAN_DISABLE=1` is the global kill-switch. - Fingerprint Tier-2 (UA/timezone/WebGL) stays OFF in main — separate from behavioral humanization, called out so users don't conflate them. Updated: - "Human Mode" section: rewrote intro, added per-call disable subsection. - CLI command table: `type` row no longer shows `[--natural]`; `click`, `navigate`, `scroll` rows now show `[--no-human|--raw]`. --- README.md | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6130f13..67c62b4 100644 --- a/README.md +++ b/README.md @@ -123,28 +123,45 @@ These are NOT automated tests — they require a live relay, an online provider, ## Human Mode -Browser actions can optionally include human-like timing — delays before/after actions and per-character typing with jitter. +Behavioral humanization is **ON by default** in both `main` and `incognito` profile modes: + +- **Typing** — per-character keystrokes with natural inter-key cadence + jitter (extension-side, `Ceki.typeText`). +- **Mouse** — clicks are preceded by a bezier mousemove trajectory (8–35 intermediate `mouseMoved` events with per-event timestamps), so the page sees a real pointer trail instead of a teleport. + +Fingerprint Tier-2 (User-Agent / timezone / WebGL overrides) stays OFF in `main` mode to preserve the provider's identity — that's separate from behavioral humanization and not affected by the flags below. ```python -# Default: natural profile (enabled by default) +# Default: behavioral humanizer ON (natural profile) browser = await client.rent(schedule_id) # Explicit profile browser = await client.rent(schedule_id, human="careful") -# Disable humanization +# Disable session-wide humanization browser = await client.rent(schedule_id, human=None) # Custom profile dict browser = await client.rent(schedule_id, human={"typing": {"wpm": 130}}) ``` +### Per-call disable + +Each humanized method accepts `human=False` for raw, flat behavior on **just that call** — useful for fast scripted seeding without leaking jitter elsewhere: + +```python +await browser.type("user@example.com", human=False) # flat keystrokes, no jitter +await browser.click(120, 240, human=False) # straight pointer jump +await browser.scroll(delta_y=-300, human=False) +``` + +The CLI equivalent is `--no-human` / `--raw` on `type`, `click`, `scroll`, `navigate`. Both flags mean "this call only". + ### High-level methods ```python await browser.navigate("https://example.com") await browser.click(100, 200) -await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char (keydown/keyUp/+shift) with human delays. Long text no longer trips the relay command cap. +await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char with human delays. Long text no longer trips the relay command cap. await browser.scroll(delta_y=-300) img_bytes = await browser.screenshot() ``` @@ -153,14 +170,14 @@ img_bytes = await browser.screenshot() ```python prev = browser.set_human("careful") # Switch profile, returns previous -browser.set_human(None) # Disable mid-session +browser.set_human(None) # Disable session-wide humanization ``` ### Environment variables - `CEKI_HUMAN_PROFILE` — Override default profile name (e.g., `careful`) - `CEKI_HUMAN_PROFILE_PATH` — Path to custom JSON profile file -- `CEKI_HUMAN_DISABLE=1` — Disable humanization entirely +- `CEKI_HUMAN_DISABLE=1` — **Global kill-switch**: disable humanization for every call regardless of `human=...` arguments or CLI flags ## CLI @@ -209,10 +226,10 @@ The CLI persists session state locally — after `rent` it saves the session ID | Command | Description | |---|---| -| `navigate SID URL` | Open URL | -| `click SID X Y` | Click at viewport coordinates | -| `type SID TEXT [--natural]` | Type text into focused element | -| `scroll SID X Y DY` | Scroll from (X, Y) by `DY` pixels | +| `navigate SID URL [--no-human\|--raw]` | Open URL (humanized by default; `--no-human` skips pre/post delays) | +| `click SID X Y [--no-human\|--raw]` | Click at viewport coordinates (mousemove trail ON by default; `--no-human` for direct jump) | +| `type SID TEXT [--selector CSS] [--no-human\|--raw]` | Type text (humanized by default; `--no-human` for flat keystrokes) | +| `scroll SID X Y DY [--no-human\|--raw]` | Scroll from (X, Y) by `DY` pixels (eased by default; `--no-human` for raw CDP wheel) | | `screenshot SID -o FILE [--format png\|jpeg] [--full]` | Save screenshot | | `snapshot SID -o FILE` | Screenshot + new chat messages | | `switch-tab SID` | Switch active tab | From bbb96362ec8fad4e455524c400e829ccb377c3ba Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Fri, 19 Jun 2026 15:04:23 +0000 Subject: [PATCH 10/10] ci(ruff): fix E501/F401/I001 across cli, _browser, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint-only changes to make ruff check green: - ceki_sdk/_browser.py: split type() signature across multiple lines - ceki_sdk/cli.py: wrap two long writer/argparse lines - tests/test_contract.py: wrap ContractClient() ctor on the 4 poll cases - tests/test_browser_screenshot_format.py, tests/test_timelog.py: drop unused imports, sort import block ruff check . → All checks passed; pytest tests/ → 246 passed, 1 skipped. --- ceki_sdk/_browser.py | 8 +++++++- ceki_sdk/cli.py | 11 +++++++++-- tests/test_browser_screenshot_format.py | 2 +- tests/test_contract.py | 20 +++++++++++++------- tests/test_timelog.py | 1 - 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 5362b02..8645ece 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -259,7 +259,13 @@ async def _send_keystroke(self, char: str) -> None: "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16, }}) - async def type(self, text: str, *, selector: str | None = None, human: bool | None = None) -> None: + async def type( + self, + text: str, + *, + selector: str | None = None, + human: bool | None = None, + ) -> None: # task 413 — typing humanizer moved into the extension. The SDK # now sends ONE Ceki.typeText command instead of N per-char # dispatchKeyEvent calls, so long inputs no longer burn through diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index ccc61cd..aa72519 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -519,7 +519,9 @@ def _cmd_contract(args: argparse.Namespace) -> int: _contract_dump({"count": len(items), "notifications": items}) elif action == "watch": sec = max(6, int(args.interval or 8)) - sys.stderr.write(f"[watch] poll every {sec}s (limit 10/min/token; do not go below 6s)\n") + sys.stderr.write( + f"[watch] poll every {sec}s (limit 10/min/token; do not go below 6s)\n" + ) sys.stderr.flush() import time as _time while True: @@ -759,7 +761,12 @@ def build_parser() -> argparse.ArgumentParser: p_chist.add_argument("--limit", type=int, help="Max entries") p_cc = csub.add_parser("create", help="Create contract event") - p_cc.add_argument("cid", type=int, nargs="?", help="Contract ID (default: CEKI_CONTRACT_IDS[0])") + p_cc.add_argument( + "cid", + type=int, + nargs="?", + help="Contract ID (default: CEKI_CONTRACT_IDS[0])", + ) p_cc.add_argument("--label", required=True) p_cc.add_argument("--type", type=int) p_cc.add_argument("--status", type=int) diff --git a/tests/test_browser_screenshot_format.py b/tests/test_browser_screenshot_format.py index 09d6692..89f2597 100644 --- a/tests/test_browser_screenshot_format.py +++ b/tests/test_browser_screenshot_format.py @@ -2,7 +2,7 @@ import base64 import logging -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, patch import pytest diff --git a/tests/test_contract.py b/tests/test_contract.py index 1ac048e..961df8b 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,8 +1,7 @@ from __future__ import annotations import json -import os -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import httpx import pytest @@ -16,7 +15,6 @@ contract_ids_from_env, ) - # ── helpers ─────────────────────────────────────────────────────── @@ -224,25 +222,33 @@ def test_history_tool_name(): def test_poll_returns_list_directly(): http, _ = _http_mock([{"x": 1}, {"x": 2}]) - c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) assert c.poll() == [{"x": 1}, {"x": 2}] def test_poll_unwraps_notifications_key(): http, _ = _http_mock({"notifications": [{"a": 1}]}) - c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) assert c.poll() == [{"a": 1}] def test_poll_429_returns_empty(): http, _ = _http_mock({"error": "rate"}, status=429) - c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) assert c.poll() == [] def test_poll_other_error_raises(): http, _ = _http_mock({"error": "boom"}, status=500) - c = ContractClient(client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t") + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) with pytest.raises(ContractError): c.poll() diff --git a/tests/test_timelog.py b/tests/test_timelog.py index 99393b9..75ddb06 100644 --- a/tests/test_timelog.py +++ b/tests/test_timelog.py @@ -10,7 +10,6 @@ from ceki_sdk.contract import ContractClient, ContractError from ceki_sdk.timelog import TimelogClient - # ── helpers ───────────────────────────────────────────────────────