hiair-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hiair_cli-0.1.0/.github/workflows/publish.yml +50 -0
- hiair_cli-0.1.0/.gitignore +9 -0
- hiair_cli-0.1.0/LICENSE +21 -0
- hiair_cli-0.1.0/PKG-INFO +53 -0
- hiair_cli-0.1.0/README.md +26 -0
- hiair_cli-0.1.0/pyproject.toml +44 -0
- hiair_cli-0.1.0/src/hiair_cli/__init__.py +1 -0
- hiair_cli-0.1.0/src/hiair_cli/__main__.py +4 -0
- hiair_cli-0.1.0/src/hiair_cli/auth.py +52 -0
- hiair_cli-0.1.0/src/hiair_cli/chrome.py +136 -0
- hiair_cli-0.1.0/src/hiair_cli/client.py +124 -0
- hiair_cli-0.1.0/src/hiair_cli/daemon.py +143 -0
- hiair_cli-0.1.0/src/hiair_cli/main.py +182 -0
- hiair_cli-0.1.0/src/hiair_cli/mcp.py +123 -0
- hiair_cli-0.1.0/src/hiair_cli/service.py +110 -0
- hiair_cli-0.1.0/uv.lock +764 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: astral-sh/setup-uv@v6
|
|
16
|
+
|
|
17
|
+
- name: Build sdist and wheel
|
|
18
|
+
run: uv build
|
|
19
|
+
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment:
|
|
30
|
+
name: pypi
|
|
31
|
+
url: https://pypi.org/p/hiair-cli
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write
|
|
34
|
+
contents: write
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist
|
|
40
|
+
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
42
|
+
|
|
43
|
+
- name: GitHub Release
|
|
44
|
+
env:
|
|
45
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
46
|
+
run: |
|
|
47
|
+
gh release create "${{ github.ref_name }}" dist/* \
|
|
48
|
+
--title "${{ github.ref_name }}" \
|
|
49
|
+
--generate-notes \
|
|
50
|
+
--verify-tag
|
hiair_cli-0.1.0/LICENSE
ADDED
|
@@ -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.
|
hiair_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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,26 @@
|
|
|
1
|
+
# HiAir CLI
|
|
2
|
+
|
|
3
|
+
Fill job applications in **your** Chrome, then store them in the HiAir tracker.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
You need [Python 3.11+](https://www.python.org/downloads/) and [pipx](https://pipx.pypa.io/). Google Chrome must be installed for applies.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install hiair-cli
|
|
11
|
+
# or: uv tool install hiair-cli
|
|
12
|
+
|
|
13
|
+
hiair login
|
|
14
|
+
hiair service install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
`hiair service` is macOS only. To watch a run in the foreground (any OS), use `hiair apply`.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
hiair --version
|
|
23
|
+
hiair status
|
|
24
|
+
hiair service status
|
|
25
|
+
hiair service uninstall
|
|
26
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hiair-cli"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Run HiAir applies in your own Chrome"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "HiAir", email = "admin@hiair.ai" }]
|
|
9
|
+
keywords = ["hiair", "jobs", "apply", "chrome"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: MacOS",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Office/Business",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27.0",
|
|
25
|
+
"mcp>=2.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://hiair.ai"
|
|
30
|
+
Repository = "https://github.com/hiair-ai/hiair-cli"
|
|
31
|
+
Issues = "https://github.com/hiair-ai/hiair-cli/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
hiair = "hiair_cli.main:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/hiair_cli/__init__.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/hiair_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -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)
|