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 +1 -0
- hiair_cli/__main__.py +4 -0
- hiair_cli/auth.py +52 -0
- hiair_cli/chrome.py +136 -0
- hiair_cli/client.py +124 -0
- hiair_cli/daemon.py +143 -0
- hiair_cli/main.py +182 -0
- hiair_cli/mcp.py +123 -0
- hiair_cli/service.py +110 -0
- hiair_cli-0.1.0.dist-info/METADATA +53 -0
- hiair_cli-0.1.0.dist-info/RECORD +14 -0
- hiair_cli-0.1.0.dist-info/WHEEL +4 -0
- hiair_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hiair_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
hiair_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
hiair_cli/__main__.py
ADDED
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,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.
|