hiair-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hiair_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
hiair_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from hiair_cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
hiair_cli/auth.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ CONFIG_DIR = Path.home() / ".hiair"
9
+ CREDENTIALS_PATH = CONFIG_DIR / "credentials"
10
+
11
+
12
+ def default_api_url() -> str:
13
+ return (os.environ.get("HIAIR_API_URL") or "https://api.hiair.ai").rstrip("/")
14
+
15
+
16
+ def load_credentials() -> dict[str, Any]:
17
+ if not CREDENTIALS_PATH.is_file():
18
+ return {}
19
+ try:
20
+ data = json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+
26
+ def save_credentials(*, token: str, api_url: str, device_id: str, device_name: str) -> None:
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ payload = {
29
+ "token": token,
30
+ "api_url": api_url.rstrip("/"),
31
+ "device_id": device_id,
32
+ "device_name": device_name,
33
+ }
34
+ CREDENTIALS_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
35
+ try:
36
+ CREDENTIALS_PATH.chmod(0o600)
37
+ except OSError:
38
+ pass
39
+
40
+
41
+ def clear_credentials() -> None:
42
+ if CREDENTIALS_PATH.is_file():
43
+ CREDENTIALS_PATH.unlink()
44
+
45
+
46
+ def require_token() -> tuple[str, str]:
47
+ data = load_credentials()
48
+ token = str(data.get("token") or "").strip()
49
+ api_url = str(data.get("api_url") or default_api_url()).rstrip("/")
50
+ if not token:
51
+ raise SystemExit("Not logged in. Run `hiair login` first.")
52
+ return token, api_url
hiair_cli/chrome.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import signal
7
+ import socket
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ DEFAULT_CDP_PORT = 9222
17
+ CDP_URL = f"http://127.0.0.1:{DEFAULT_CDP_PORT}"
18
+
19
+
20
+ @dataclass
21
+ class ChromeSession:
22
+ endpoint: str
23
+ process: subprocess.Popen[bytes]
24
+ profile_dir: str
25
+
26
+
27
+ def cdp_ready(url: str = f"{CDP_URL}/json/version") -> bool:
28
+ try:
29
+ with urllib.request.urlopen(url, timeout=1.5) as response:
30
+ return 200 <= response.status < 300
31
+ except (urllib.error.URLError, TimeoutError, OSError):
32
+ return False
33
+
34
+
35
+ def find_chrome() -> Path:
36
+ system = platform.system()
37
+ candidates: list[Path] = []
38
+ if system == "Darwin":
39
+ candidates.append(Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"))
40
+ elif system == "Windows":
41
+ for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
42
+ root = os.environ.get(env)
43
+ if root:
44
+ candidates.append(Path(root) / "Google/Chrome/Application/chrome.exe")
45
+ else:
46
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
47
+ found = shutil.which(name)
48
+ if found:
49
+ return Path(found)
50
+ for path in candidates:
51
+ if path.is_file():
52
+ return path
53
+ raise SystemExit(
54
+ "Google Chrome was not found. Install Chrome and try `hiair apply` again."
55
+ )
56
+
57
+
58
+ def _port_open(port: int) -> bool:
59
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
60
+ sock.settimeout(0.3)
61
+ return sock.connect_ex(("127.0.0.1", port)) == 0
62
+
63
+
64
+ def _wait_for_cdp(endpoint: str, *, seconds: float = 20) -> bool:
65
+ deadline = time.monotonic() + seconds
66
+ while time.monotonic() < deadline:
67
+ if cdp_ready(f"{endpoint}/json/version"):
68
+ return True
69
+ time.sleep(0.25)
70
+ return False
71
+
72
+
73
+ def _pick_cdp_port(preferred: int) -> int:
74
+ if not _port_open(preferred):
75
+ return preferred
76
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
77
+ sock.bind(("127.0.0.1", 0))
78
+ return int(sock.getsockname()[1])
79
+
80
+
81
+ def ensure_chrome(*, cdp_port: int = DEFAULT_CDP_PORT) -> ChromeSession:
82
+ """Launch a fresh Chrome that exposes CDP.
83
+
84
+ Chrome 136+ ignores --remote-debugging-port on the default profile, so each
85
+ run gets a unique temp user-data-dir. Everyday Chrome can stay open.
86
+ """
87
+
88
+ port = _pick_cdp_port(cdp_port)
89
+ endpoint = f"http://127.0.0.1:{port}"
90
+ profile_dir = tempfile.mkdtemp(prefix="hiair-chrome-")
91
+ chrome = find_chrome()
92
+ args = [
93
+ str(chrome),
94
+ f"--remote-debugging-port={port}",
95
+ "--remote-allow-origins=*",
96
+ f"--user-data-dir={profile_dir}",
97
+ "--no-first-run",
98
+ "--no-default-browser-check",
99
+ ]
100
+ print("Opening a fresh Chrome. Your everyday Chrome can stay open.")
101
+ process = subprocess.Popen(
102
+ args,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ start_new_session=True,
106
+ )
107
+ session = ChromeSession(endpoint=endpoint, process=process, profile_dir=profile_dir)
108
+ if _wait_for_cdp(endpoint):
109
+ print(f"Chrome is ready on {endpoint}")
110
+ return session
111
+ close_chrome(session)
112
+ raise RuntimeError(
113
+ "Chrome started but remote debugging did not come up. "
114
+ "Quit leftover HiAir Chrome windows and try again."
115
+ )
116
+
117
+
118
+ def close_chrome(session: ChromeSession) -> None:
119
+ proc = session.process
120
+ if proc.poll() is not None:
121
+ return
122
+ try:
123
+ os.killpg(proc.pid, signal.SIGTERM)
124
+ except (ProcessLookupError, PermissionError, OSError):
125
+ proc.terminate()
126
+ try:
127
+ proc.wait(timeout=5)
128
+ except subprocess.TimeoutExpired:
129
+ try:
130
+ os.killpg(proc.pid, signal.SIGKILL)
131
+ except (ProcessLookupError, PermissionError, OSError):
132
+ proc.kill()
133
+ try:
134
+ proc.wait(timeout=3)
135
+ except subprocess.TimeoutExpired:
136
+ pass
hiair_cli/client.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from hiair_cli.auth import default_api_url
8
+
9
+
10
+ class CliApiError(RuntimeError):
11
+ def __init__(self, status_code: int, detail: str) -> None:
12
+ super().__init__(detail)
13
+ self.status_code = status_code
14
+ self.detail = detail
15
+
16
+
17
+ class CliClient:
18
+ def __init__(self, *, api_url: str | None = None, token: str | None = None) -> None:
19
+ self.api_url = (api_url or default_api_url()).rstrip("/")
20
+ self.token = token
21
+ self._http = httpx.Client(timeout=60.0)
22
+
23
+ def close(self) -> None:
24
+ self._http.close()
25
+
26
+ def _headers(self, *, auth: bool = False) -> dict[str, str]:
27
+ headers = {"Accept": "application/json"}
28
+ if auth:
29
+ if not self.token:
30
+ raise SystemExit("Not logged in. Run `hiair login` first.")
31
+ headers["Authorization"] = f"Bearer {self.token}"
32
+ return headers
33
+
34
+ def _request(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ *,
39
+ auth: bool = False,
40
+ json: Any = None,
41
+ params: dict[str, Any] | None = None,
42
+ timeout: float | None = None,
43
+ accept_empty: bool = False,
44
+ ) -> Any:
45
+ response = self._http.request(
46
+ method,
47
+ f"{self.api_url}{path}",
48
+ headers=self._headers(auth=auth),
49
+ json=json,
50
+ params=params,
51
+ timeout=timeout,
52
+ )
53
+ if response.status_code == 202:
54
+ return {"pending": True}
55
+ if accept_empty and response.status_code in {200, 204} and not response.content:
56
+ return None
57
+ if response.status_code >= 400:
58
+ detail = response.text
59
+ try:
60
+ payload = response.json()
61
+ raw = payload.get("detail")
62
+ if isinstance(raw, str):
63
+ detail = raw
64
+ elif isinstance(raw, dict):
65
+ detail = str(raw.get("message") or raw)
66
+ except Exception:
67
+ pass
68
+ raise CliApiError(response.status_code, detail)
69
+ if not response.content:
70
+ return None
71
+ return response.json()
72
+
73
+ def pair_start(self, name: str) -> dict[str, Any]:
74
+ return self._request("POST", "/cli/pair/start", json={"name": name})
75
+
76
+ def pair_token(self, device_code: str) -> dict[str, Any]:
77
+ return self._request("POST", "/cli/pair/token", json={"device_code": device_code})
78
+
79
+ def me(self) -> dict[str, Any]:
80
+ return self._request("GET", "/cli/me", auth=True)
81
+
82
+ def heartbeat(self) -> dict[str, Any]:
83
+ return self._request("POST", "/cli/heartbeat", auth=True)
84
+
85
+ def logout(self) -> None:
86
+ self._request("POST", "/cli/logout", auth=True, accept_empty=True)
87
+
88
+ def next_run(self, *, wait: float = 25) -> dict[str, Any] | None:
89
+ return self._request(
90
+ "GET",
91
+ "/cli/runs/next",
92
+ auth=True,
93
+ params={"wait": wait},
94
+ timeout=wait + 10,
95
+ accept_empty=True,
96
+ )
97
+
98
+ def turn(
99
+ self,
100
+ run_id: int,
101
+ *,
102
+ tools: list[dict[str, Any]] | None = None,
103
+ resume_path: str | None = None,
104
+ results: list[dict[str, Any]] | None = None,
105
+ ) -> dict[str, Any]:
106
+ return self._request(
107
+ "POST",
108
+ f"/cli/runs/{run_id}/turn",
109
+ auth=True,
110
+ json={
111
+ "tools": tools or [],
112
+ "resume_path": resume_path,
113
+ "results": results or [],
114
+ },
115
+ timeout=180,
116
+ )
117
+
118
+ def upload_artifacts(self, run_id: int, items: list[dict[str, Any]]) -> dict[str, Any]:
119
+ return self._request(
120
+ "POST",
121
+ f"/cli/runs/{run_id}/artifacts",
122
+ auth=True,
123
+ json=items,
124
+ )
hiair_cli/daemon.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from hiair_cli.chrome import close_chrome, ensure_chrome
12
+ from hiair_cli.client import CliApiError, CliClient
13
+ from hiair_cli.mcp import LocalPlaywrightMcp
14
+
15
+ CAPTURE_AFTER = frozenset(
16
+ {
17
+ "browser_click",
18
+ "browser_click_element",
19
+ "browser_type",
20
+ "browser_press_key",
21
+ "browser_select_option",
22
+ "browser_fill_form",
23
+ "browser_navigate",
24
+ }
25
+ )
26
+ HEARTBEAT_SECONDS = 20
27
+
28
+
29
+ async def _download_resume(url: str, dest: Path) -> None:
30
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
31
+ response = await client.get(url)
32
+ response.raise_for_status()
33
+ dest.write_bytes(response.content)
34
+
35
+
36
+ def _artifact(filename: str, data: bytes, kind: str) -> dict[str, Any]:
37
+ return {
38
+ "filename": filename,
39
+ "content_base64": base64.b64encode(data).decode("ascii"),
40
+ "kind": kind,
41
+ }
42
+
43
+
44
+ async def _run_one(client: CliClient, claim: dict[str, Any], mcp: LocalPlaywrightMcp) -> None:
45
+ run_id = int(claim["run_id"])
46
+ resume_name = str(claim.get("resume_file_name") or "resume.pdf")
47
+ resume_path = mcp.output_dir / resume_name
48
+ resume_url = (claim.get("resume_url") or "").strip()
49
+ if resume_url:
50
+ try:
51
+ await _download_resume(resume_url, resume_path)
52
+ except Exception as exc:
53
+ print(f" could not download resume: {exc}")
54
+ print(f"Applying to {claim.get('job_title') or claim.get('job_url')} (run {run_id})")
55
+ results: list[dict[str, Any]] = []
56
+ tools = mcp.tool_specs()
57
+ first = True
58
+ frames: list[dict[str, Any]] = []
59
+ seq = 0
60
+ while True:
61
+ turn = client.turn(
62
+ run_id,
63
+ tools=tools if first else None,
64
+ resume_path=str(resume_path) if first else None,
65
+ results=results,
66
+ )
67
+ first = False
68
+ if turn.get("finished"):
69
+ status = turn.get("status") or "unknown"
70
+ print(f" finished: {status}")
71
+ if turn.get("error"):
72
+ print(f" {turn['error']}")
73
+ try:
74
+ proof = await mcp.screenshot(full_page=True)
75
+ items = list(frames)
76
+ if proof:
77
+ items.append(_artifact("filled.png", proof, "proof"))
78
+ if items:
79
+ client.upload_artifacts(run_id, items)
80
+ except Exception as exc:
81
+ print(f" could not upload proof: {exc}")
82
+ return
83
+ results = []
84
+ for call in turn.get("tool_calls") or []:
85
+ name = str(call.get("name") or "")
86
+ args = call.get("args") or {}
87
+ print(f" → {name}")
88
+ output = await mcp.call(name, args)
89
+ results.append({"id": str(call.get("id") or ""), "output": output})
90
+ if name in CAPTURE_AFTER:
91
+ seq += 1
92
+ try:
93
+ png = await mcp.screenshot(full_page=True)
94
+ if png:
95
+ frames.append(_artifact(f"{seq:04d}-{name}.png", png, "screenshot"))
96
+ except Exception:
97
+ pass
98
+
99
+
100
+ async def run_daemon(client: CliClient) -> None:
101
+ heartbeat = asyncio.create_task(_heartbeat_loop(client))
102
+ try:
103
+ while True:
104
+ try:
105
+ claim = client.next_run(wait=25)
106
+ except CliApiError as exc:
107
+ print(f"API error: {exc.detail}")
108
+ await asyncio.sleep(5)
109
+ continue
110
+ if not claim:
111
+ continue
112
+ chrome = None
113
+ mcp = None
114
+ try:
115
+ chrome = ensure_chrome()
116
+ output_dir = Path(tempfile.mkdtemp(prefix=f"hiair-apply-{claim['run_id']}-"))
117
+ mcp = LocalPlaywrightMcp(output_dir=output_dir, cdp_endpoint=chrome.endpoint)
118
+ await mcp.start()
119
+ await _run_one(client, claim, mcp)
120
+ except CliApiError as exc:
121
+ print(f" apply failed: {exc.detail}")
122
+ except Exception as exc:
123
+ print(f" apply crashed: {exc}")
124
+ finally:
125
+ if mcp is not None:
126
+ await mcp.close()
127
+ if chrome is not None:
128
+ close_chrome(chrome)
129
+ finally:
130
+ heartbeat.cancel()
131
+ try:
132
+ await heartbeat
133
+ except (asyncio.CancelledError, Exception):
134
+ pass
135
+
136
+
137
+ async def _heartbeat_loop(client: CliClient) -> None:
138
+ while True:
139
+ try:
140
+ client.heartbeat()
141
+ except Exception:
142
+ pass
143
+ await asyncio.sleep(HEARTBEAT_SECONDS)
hiair_cli/main.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import socket
6
+ import time
7
+
8
+ from hiair_cli import __version__
9
+ from hiair_cli.auth import (
10
+ clear_credentials,
11
+ default_api_url,
12
+ load_credentials,
13
+ require_token,
14
+ save_credentials,
15
+ )
16
+ from hiair_cli.chrome import cdp_ready
17
+ from hiair_cli.client import CliApiError, CliClient
18
+ from hiair_cli.daemon import run_daemon
19
+ from hiair_cli.service import describe as service_describe
20
+ from hiair_cli.service import install as service_install
21
+ from hiair_cli.service import uninstall as service_uninstall
22
+
23
+
24
+ def _device_name() -> str:
25
+ return socket.gethostname() or "My computer"
26
+
27
+
28
+ def cmd_login(args: argparse.Namespace) -> None:
29
+ api_url = (args.api_url or default_api_url()).rstrip("/")
30
+ client = CliClient(api_url=api_url)
31
+ try:
32
+ pairing = client.pair_start(_device_name())
33
+ except CliApiError as exc:
34
+ raise SystemExit(f"Could not start pairing: {exc.detail}") from exc
35
+ user_code = pairing["user_code"]
36
+ verify = pairing["verification_url"]
37
+ print(f"Open {verify}")
38
+ print(f"Enter this code: {user_code}")
39
+ print("Waiting for confirmation…")
40
+ started = time.monotonic()
41
+ while time.monotonic() - started < 600:
42
+ try:
43
+ result = client.pair_token(pairing["device_code"])
44
+ except CliApiError as exc:
45
+ if exc.status_code == 202:
46
+ time.sleep(2)
47
+ continue
48
+ if exc.status_code == 410:
49
+ raise SystemExit("That pairing code expired. Run `hiair login` again.") from exc
50
+ raise SystemExit(f"Pairing failed: {exc.detail}") from exc
51
+ if result.get("pending") or result.get("status") == "pending":
52
+ time.sleep(2)
53
+ continue
54
+ token = result.get("token")
55
+ if not token:
56
+ time.sleep(2)
57
+ continue
58
+ save_credentials(
59
+ token=token,
60
+ api_url=api_url,
61
+ device_id=str(result.get("device_id") or ""),
62
+ device_name=str(result.get("device_name") or _device_name()),
63
+ )
64
+ print("Paired. Run `hiair service install` to fill applications at login.")
65
+ return
66
+ raise SystemExit("Timed out waiting for confirmation.")
67
+
68
+
69
+ def cmd_logout(_args: argparse.Namespace) -> None:
70
+ data = load_credentials()
71
+ token = str(data.get("token") or "")
72
+ api_url = str(data.get("api_url") or default_api_url())
73
+ if token:
74
+ try:
75
+ CliClient(api_url=api_url, token=token).logout()
76
+ except Exception:
77
+ pass
78
+ clear_credentials()
79
+ print("Logged out.")
80
+
81
+
82
+ def cmd_status(_args: argparse.Namespace) -> None:
83
+ token, api_url = require_token()
84
+ client = CliClient(api_url=api_url, token=token)
85
+ try:
86
+ me = client.me()
87
+ except CliApiError as exc:
88
+ raise SystemExit(f"Status failed: {exc.detail}") from exc
89
+ print(f"API: {api_url}")
90
+ print(f"Device: {me.get('device_name') or '—'}")
91
+ print(f"Online: {'yes' if me.get('online') else 'no (run `hiair service install`)'}")
92
+ print(f"Service: {service_describe()}")
93
+ print(f"Chrome: {'debug port open' if cdp_ready() else 'idle'}")
94
+
95
+
96
+ def cmd_service_install(_args: argparse.Namespace) -> None:
97
+ require_token()
98
+ service_install()
99
+
100
+
101
+ def cmd_service_uninstall(_args: argparse.Namespace) -> None:
102
+ service_uninstall()
103
+
104
+
105
+ def cmd_service_status(_args: argparse.Namespace) -> None:
106
+ data = load_credentials()
107
+ if not str(data.get("token") or "").strip():
108
+ print(f"Service: {service_describe()}")
109
+ print("Pairing: not logged in")
110
+ return
111
+ cmd_status(_args)
112
+
113
+
114
+ def cmd_apply(args: argparse.Namespace) -> None:
115
+ token, api_url = require_token()
116
+ if args.api_url:
117
+ api_url = args.api_url.rstrip("/")
118
+ client = CliClient(api_url=api_url, token=token)
119
+ print(f"Listening for applies at {api_url}")
120
+ print("Queue jobs from the HiAir site. Chrome opens when a job arrives.")
121
+ try:
122
+ asyncio.run(run_daemon(client))
123
+ except KeyboardInterrupt:
124
+ print("\nStopped.")
125
+
126
+
127
+ def _add_api_url(parser: argparse.ArgumentParser) -> None:
128
+ parser.add_argument(
129
+ "--api-url",
130
+ default=None,
131
+ help="Jobs API base URL (default: $HIAIR_API_URL or https://api.hiair.ai)",
132
+ )
133
+
134
+
135
+ def main() -> None:
136
+ parser = argparse.ArgumentParser(prog="hiair", description="Apply to jobs from your Chrome")
137
+ parser.add_argument(
138
+ "-V",
139
+ "--version",
140
+ action="version",
141
+ version=f"%(prog)s {__version__}",
142
+ )
143
+ sub = parser.add_subparsers(dest="command", required=True)
144
+
145
+ login = sub.add_parser("login", help="Pair this computer with your HiAir account")
146
+ _add_api_url(login)
147
+ login.set_defaults(func=cmd_login)
148
+
149
+ logout = sub.add_parser("logout", help="Forget the saved CLI token")
150
+ logout.set_defaults(func=cmd_logout)
151
+
152
+ status = sub.add_parser("status", help="Show pairing and Chrome status")
153
+ status.set_defaults(func=cmd_status)
154
+
155
+ apply_cmd = sub.add_parser("apply", help="Claim queued applies and fill them in Chrome")
156
+ _add_api_url(apply_cmd)
157
+ apply_cmd.set_defaults(func=cmd_apply)
158
+
159
+ service = sub.add_parser("service", help="Install a login worker (macOS)")
160
+ service_sub = service.add_subparsers(dest="service_command", required=True)
161
+
162
+ service_install_cmd = service_sub.add_parser(
163
+ "install", help="Start the apply worker at login"
164
+ )
165
+ service_install_cmd.set_defaults(func=cmd_service_install)
166
+
167
+ service_uninstall_cmd = service_sub.add_parser(
168
+ "uninstall", help="Stop and remove the login worker"
169
+ )
170
+ service_uninstall_cmd.set_defaults(func=cmd_service_uninstall)
171
+
172
+ service_status_cmd = service_sub.add_parser(
173
+ "status", help="Show whether the login worker is loaded"
174
+ )
175
+ service_status_cmd.set_defaults(func=cmd_service_status)
176
+
177
+ args = parser.parse_args()
178
+ args.func(args)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
hiair_cli/mcp.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from mcp import ClientSession, StdioServerParameters
8
+ from mcp.client.stdio import stdio_client
9
+
10
+ from contextlib import AsyncExitStack
11
+
12
+ MCP_PACKAGE = "@playwright/mcp@0.0.79"
13
+
14
+
15
+ def _mcp_text(result: Any) -> str:
16
+ parts: list[str] = []
17
+ for item in getattr(result, "content", None) or []:
18
+ kind = getattr(item, "type", None)
19
+ if kind == "text":
20
+ text = getattr(item, "text", None)
21
+ if text:
22
+ parts.append(str(text))
23
+ elif kind == "image":
24
+ parts.append("[image]")
25
+ text = "\n".join(parts).strip()
26
+ if getattr(result, "isError", False):
27
+ return f"error: {text or 'tool failed'}"
28
+ return text or "ok"
29
+
30
+
31
+ def _mcp_image_bytes(result: Any) -> bytes | None:
32
+ import base64
33
+
34
+ for item in getattr(result, "content", None) or []:
35
+ if getattr(item, "type", None) != "image":
36
+ continue
37
+ data = getattr(item, "data", None)
38
+ if not data:
39
+ continue
40
+ try:
41
+ return base64.b64decode(data)
42
+ except Exception:
43
+ return None
44
+ return None
45
+
46
+
47
+ class LocalPlaywrightMcp:
48
+ def __init__(self, *, output_dir: Path, cdp_endpoint: str) -> None:
49
+ self.output_dir = Path(output_dir)
50
+ self.cdp_endpoint = cdp_endpoint
51
+ self._stack: AsyncExitStack | None = None
52
+ self._session: Any = None
53
+ self._specs: list[dict[str, Any]] = []
54
+
55
+ def tool_specs(self) -> list[dict[str, Any]]:
56
+ return self._specs
57
+
58
+ async def start(self) -> None:
59
+ self.output_dir.mkdir(parents=True, exist_ok=True)
60
+ params = StdioServerParameters(
61
+ command="npx",
62
+ args=[
63
+ "-y",
64
+ MCP_PACKAGE,
65
+ "--allow-unrestricted-file-access",
66
+ "--output-dir",
67
+ str(self.output_dir),
68
+ "--cdp-endpoint",
69
+ self.cdp_endpoint,
70
+ ],
71
+ cwd=str(self.output_dir),
72
+ env=dict(os.environ),
73
+ )
74
+ stack = AsyncExitStack()
75
+ try:
76
+ read, write = await stack.enter_async_context(stdio_client(params))
77
+ session = await stack.enter_async_context(ClientSession(read, write))
78
+ await session.initialize()
79
+ listed = await session.list_tools()
80
+ except Exception:
81
+ await stack.aclose()
82
+ raise
83
+ self._stack = stack
84
+ self._session = session
85
+ self._specs = [
86
+ {
87
+ "name": getattr(tool, "name", None),
88
+ "description": getattr(tool, "description", None),
89
+ "inputSchema": (
90
+ getattr(tool, "input_schema", None)
91
+ or getattr(tool, "inputSchema", None)
92
+ ),
93
+ }
94
+ for tool in (listed.tools or [])
95
+ if getattr(tool, "name", None)
96
+ ]
97
+
98
+ async def close(self) -> None:
99
+ stack = self._stack
100
+ self._stack = None
101
+ self._session = None
102
+ if stack is not None:
103
+ try:
104
+ await stack.aclose()
105
+ except Exception:
106
+ pass
107
+
108
+ async def call(self, name: str, arguments: dict[str, Any]) -> str:
109
+ if self._session is None:
110
+ raise RuntimeError("Playwright MCP is not started")
111
+ try:
112
+ result = await self._session.call_tool(name, arguments or {})
113
+ except Exception as exc:
114
+ return f"error: {exc}"
115
+ return _mcp_text(result)
116
+
117
+ async def screenshot(self, *, full_page: bool = True) -> bytes | None:
118
+ if self._session is None:
119
+ return None
120
+ result = await self._session.call_tool(
121
+ "browser_take_screenshot", {"fullPage": full_page}
122
+ )
123
+ return _mcp_image_bytes(result)
hiair_cli/service.py ADDED
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import plistlib
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from hiair_cli.auth import CONFIG_DIR
12
+
13
+ LABEL = "ai.hiair.apply"
14
+ PLIST_PATH = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
15
+ LOG_PATH = CONFIG_DIR / "logs" / "apply.log"
16
+ _LAUNCHD_PATH = (
17
+ "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
18
+ )
19
+
20
+
21
+ def _require_macos() -> None:
22
+ if platform.system() != "Darwin":
23
+ raise SystemExit(
24
+ "hiair service is only supported on macOS. Run `hiair apply` in the foreground."
25
+ )
26
+
27
+
28
+ def _gui_target() -> str:
29
+ return f"gui/{os.getuid()}"
30
+
31
+
32
+ def _service_target() -> str:
33
+ return f"{_gui_target()}/{LABEL}"
34
+
35
+
36
+ def _hiair_executable() -> Path:
37
+ raw = Path(sys.argv[0]).expanduser()
38
+ if raw.exists() and raw.is_file():
39
+ return raw.resolve()
40
+ found = shutil.which("hiair")
41
+ if found:
42
+ return Path(found)
43
+ raise SystemExit(
44
+ "Could not find the hiair executable. Install with `pipx install hiair-cli`."
45
+ )
46
+
47
+
48
+ def _launchctl(*args: str) -> subprocess.CompletedProcess[str]:
49
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True)
50
+
51
+
52
+ def is_loaded() -> bool:
53
+ return _launchctl("print", _service_target()).returncode == 0
54
+
55
+
56
+ def _write_plist(exe: Path) -> None:
57
+ PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
58
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
59
+ payload = {
60
+ "Label": LABEL,
61
+ "ProgramArguments": [str(exe), "apply"],
62
+ "RunAtLoad": True,
63
+ "KeepAlive": True,
64
+ "LimitLoadToSessionType": "Aqua",
65
+ "StandardOutPath": str(LOG_PATH),
66
+ "StandardErrorPath": str(LOG_PATH),
67
+ "EnvironmentVariables": {
68
+ "PATH": _LAUNCHD_PATH,
69
+ "HOME": str(Path.home()),
70
+ },
71
+ }
72
+ PLIST_PATH.write_bytes(plistlib.dumps(payload))
73
+
74
+
75
+ def install() -> None:
76
+ _require_macos()
77
+ exe = _hiair_executable()
78
+ _write_plist(exe)
79
+ if is_loaded():
80
+ _launchctl("bootout", _service_target())
81
+ result = _launchctl("bootstrap", _gui_target(), str(PLIST_PATH))
82
+ if result.returncode != 0:
83
+ detail = (result.stderr or result.stdout or "").strip()
84
+ raise SystemExit(f"Could not load the HiAir login worker: {detail or 'launchctl failed'}")
85
+ _launchctl("kickstart", "-k", _service_target())
86
+ print(f"Installed login worker ({exe} apply).")
87
+ print("It starts at login and opens Chrome only when a job is queued.")
88
+ print(f"Logs: {LOG_PATH}")
89
+
90
+
91
+ def uninstall() -> None:
92
+ _require_macos()
93
+ if is_loaded():
94
+ result = _launchctl("bootout", _service_target())
95
+ if result.returncode != 0:
96
+ detail = (result.stderr or result.stdout or "").strip()
97
+ raise SystemExit(f"Could not stop the HiAir login worker: {detail or 'launchctl failed'}")
98
+ if PLIST_PATH.is_file():
99
+ PLIST_PATH.unlink()
100
+ print("Removed the HiAir login worker.")
101
+
102
+
103
+ def describe() -> str:
104
+ if platform.system() != "Darwin":
105
+ return "not supported (macOS only)"
106
+ if is_loaded():
107
+ return "loaded"
108
+ if PLIST_PATH.is_file():
109
+ return "installed but not loaded"
110
+ return "not installed"
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: hiair-cli
3
+ Version: 0.1.0
4
+ Summary: Run HiAir applies in your own Chrome
5
+ Project-URL: Homepage, https://hiair.ai
6
+ Project-URL: Repository, https://github.com/hiair-ai/hiair-cli
7
+ Project-URL: Issues, https://github.com/hiair-ai/hiair-cli/issues
8
+ Author-email: HiAir <admin@hiair.ai>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: apply,chrome,hiair,jobs
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: mcp>=2.0.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # HiAir CLI
29
+
30
+ Fill job applications in **your** Chrome, then store them in the HiAir tracker.
31
+
32
+ ## Install
33
+
34
+ You need [Python 3.11+](https://www.python.org/downloads/) and [pipx](https://pipx.pypa.io/). Google Chrome must be installed for applies.
35
+
36
+ ```bash
37
+ pipx install hiair-cli
38
+ # or: uv tool install hiair-cli
39
+
40
+ hiair login
41
+ hiair service install
42
+ ```
43
+
44
+ After that, the worker starts at login. Queue jobs from the website or copilot; when one arrives, this Mac opens a disposable Chrome, fills the application, and writes the result back. Everyday Chrome can stay open. Logins in the HiAir window do not persist.
45
+
46
+ `hiair service` is macOS only. To watch a run in the foreground (any OS), use `hiair apply`.
47
+
48
+ ```bash
49
+ hiair --version
50
+ hiair status
51
+ hiair service status
52
+ hiair service uninstall
53
+ ```
@@ -0,0 +1,14 @@
1
+ hiair_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ hiair_cli/__main__.py,sha256=q7ysFUPkiS6DqZV6MBXcuilDqJm0s8lkpIlPFmc-CaI,71
3
+ hiair_cli/auth.py,sha256=6dFQAJ5diMb06TD9rRDY96aj3GBrtVr0UExOZBI_uPg,1452
4
+ hiair_cli/chrome.py,sha256=QGLDJFwnia_8r8GB5DR4aw55tKHTjlLrmpcScsg_F-o,4130
5
+ hiair_cli/client.py,sha256=A7skNs1K-jWmEm4C_pTJj2px5TVbcQJ9SYGaY7dATrY,3853
6
+ hiair_cli/daemon.py,sha256=JzUVl_8zrAoa9CjLAdZcrLcfzugf2WOp9spUjxp4EKY,4748
7
+ hiair_cli/main.py,sha256=Yovspk4TT3GX1XdQSxSMWEn8qZJMWlQ7_KYtOQtLqCg,5967
8
+ hiair_cli/mcp.py,sha256=3Bem06VBPlgge6qiukcJNLt4tdyVSLLggW63LAHZvxw,3878
9
+ hiair_cli/service.py,sha256=L2VLee8c9e3XyIcJFQ0b_ps_Jd7AXW-iFOqGAqAGOgU,3238
10
+ hiair_cli-0.1.0.dist-info/METADATA,sha256=uUkug-UdTtYbZii4zdigxTyGwh35hsRKqy7beojRUPg,1786
11
+ hiair_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ hiair_cli-0.1.0.dist-info/entry_points.txt,sha256=ILDPC4v3MBqzC6ecyBNLB8HtmWMpOKFxAv8kk52mD34,46
13
+ hiair_cli-0.1.0.dist-info/licenses/LICENSE,sha256=y05rLv3VU7r8vCmFxdTCr3yfHZJA1zoAZfYDkAjE-U8,1062
14
+ hiair_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hiair = hiair_cli.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HiAir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.