hipowork-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.
@@ -0,0 +1,116 @@
1
+ """HiPo Work CLI — 面向求职者和招聘方的命令行工具集。
2
+
3
+ 安装后提供 `hipo` 命令(等价于仓库根目录时代的 hipo.py):
4
+
5
+ hipo authorize --role candidate
6
+ hipo match-jobs
7
+ hipo publish-job --json examples/job.example.json
8
+
9
+ scripts/ 下 16 个子命令模块通过 hipo CLI 统一调度。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ SCRIPTS_DIR = Path(__file__).resolve().parent / "scripts"
21
+
22
+ # 命令名 → 对应脚本文件(不含 .py)
23
+ COMMANDS = {
24
+ "authorize": "hipo_authorize",
25
+ "status": "hipo_token_status",
26
+ "refresh": "hipo_token_refresh",
27
+ "token-sync": "hipo_token_sync",
28
+ "accounts": "hipo_accounts",
29
+ "match-jobs": "hipo_match_jobs",
30
+ "publish-job": "hipo_publish_job",
31
+ "search": "hipo_search_candidates",
32
+ "match-candidates": "hipo_match_candidates",
33
+ "market": "hipo_market",
34
+ "stats": "hipo_stats",
35
+ "resume-extract": "hipo_resume_extract",
36
+ "resume-validate": "hipo_resume_validate",
37
+ "resume-import": "hipo_resume_import",
38
+ "healthcheck": "hipo_healthcheck",
39
+ "e2e": "hipo_e2e",
40
+ }
41
+
42
+ # 各命令的一句话说明(help 输出用)
43
+ HELP = {
44
+ "authorize": "OAuth 授权(打开浏览器完成邮箱验证码登录)",
45
+ "status": "查看 token 状态(角色/scope/过期)",
46
+ "refresh": "强制刷新 token",
47
+ "token-sync": "导出 token 到浏览器 localStorage(4 个 base64)",
48
+ "accounts": "多账户管理(list/switch/delete)",
49
+ "match-jobs": "求职者:匹配岗位",
50
+ "publish-job": "招聘方:发布岗位",
51
+ "search": "招聘方:自然语言搜索候选人",
52
+ "match-candidates": "招聘方:结构化/自然语言匹配候选人",
53
+ "market": "招聘方:人才市场分析",
54
+ "stats": "平台统计",
55
+ "resume-extract": "简历文本提取(PDF/DOCX/TXT)",
56
+ "resume-validate": "简历 JSON 结构校验",
57
+ "resume-import": "简历导入(校验 + 写入)",
58
+ "healthcheck": "服务连通性检查(API/MCP/Embedding)",
59
+ "e2e": "端到端冒烟测试",
60
+ }
61
+
62
+ # 需要前置授权的命令(帮助提示用)
63
+ AUTH_REQUIRED = {
64
+ "status", "refresh", "token-sync", "accounts", "match-jobs", "publish-job",
65
+ "search", "match-candidates", "market", "stats", "resume-import", "e2e",
66
+ }
67
+
68
+
69
+ def _print_help() -> None:
70
+ print("HiPo Work CLI — 统一入口")
71
+ print("用法: hipo <command> [args...]\n")
72
+ print("认证与令牌:")
73
+ for k in ("authorize", "status", "refresh", "token-sync", "accounts"):
74
+ print(f" {k:<16} {HELP[k]}")
75
+ print("\n招聘方:")
76
+ for k in ("publish-job", "search", "match-candidates", "market", "stats"):
77
+ print(f" {k:<16} {HELP[k]}")
78
+ print("\n求职者:")
79
+ for k in ("match-jobs", "resume-extract", "resume-validate", "resume-import"):
80
+ print(f" {k:<16} {HELP[k]}")
81
+ print("\n运维:")
82
+ for k in ("healthcheck", "e2e"):
83
+ print(f" {k:<16} {HELP[k]}")
84
+ print("\n任何命令都可用 --help 查看详细参数,例如:")
85
+ print(" hipo publish-job --help")
86
+ print("\n也可以直接运行 src/hipowork_cli/scripts/ 下的独立脚本(等价)。")
87
+
88
+
89
+ def main() -> int:
90
+ # 允许从任意目录运行:把 scripts/ 加进 PYTHONPATH
91
+ env = dict(os.environ)
92
+ pythonpath = env.get("PYTHONPATH", "")
93
+ env["PYTHONPATH"] = str(SCRIPTS_DIR) + (os.pathsep + pythonpath if pythonpath else "")
94
+
95
+ args = sys.argv[1:]
96
+ if not args or args[0] in ("-h", "--help", "help"):
97
+ _print_help()
98
+ return 0
99
+
100
+ cmd = args[0]
101
+ if cmd not in COMMANDS:
102
+ print(f"❌ 未知命令: {cmd}\n", file=sys.stderr)
103
+ _print_help()
104
+ return 2
105
+
106
+ script = SCRIPTS_DIR / f"{COMMANDS[cmd]}.py"
107
+ if not script.exists():
108
+ print(f"❌ 脚本缺失: {script}", file=sys.stderr)
109
+ return 2
110
+
111
+ # 转发剩余参数;子进程继承 PYTHONPATH,保证 scripts 内部互相 import 正常
112
+ return subprocess.call([sys.executable, str(script), *args[1:]], env=env)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ {
2
+ "title": "Python/FastAPI 后端工程师",
3
+ "raw_text": "成都 Python/FastAPI 后端开发,3 年以上经验,要求高并发系统经验,熟悉 PostgreSQL,有区块链经验优先",
4
+ "required": [
5
+ {"field": "location", "operator": "eq", "value": "成都"},
6
+ {"field": "experience_years", "operator": "gte", "value": 3}
7
+ ],
8
+ "preferred": {
9
+ "semantic_query": "高并发系统设计,FastAPI 后端,区块链 Web3",
10
+ "skills": [
11
+ {"name": "FastAPI", "min_years": 2, "weight": 1.0},
12
+ {"name": "PostgreSQL", "min_years": 2, "weight": 1.0},
13
+ {"name": "Python", "min_years": 3, "weight": 1.0}
14
+ ],
15
+ "keywords": [
16
+ {"text": "高并发", "weight": 1.0},
17
+ {"text": "智能合约", "weight": 0.8}
18
+ ]
19
+ },
20
+ "salary_min": 20,
21
+ "salary_max": 35,
22
+ "salary_unit": "monthly",
23
+ "benefits": ["五险一金", "弹性工作", "远程办公", "年终奖"]
24
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "basic_info": {
3
+ "name": "张三",
4
+ "location": "成都",
5
+ "headline": "区块链全栈工程师",
6
+ "summary": "5 年全栈开发经验,专注区块链与 Web3,熟悉 Solidity 智能合约、Python/FastAPI 后端和 React 前端,主导过多个 DeFi 项目从 0 到 1。",
7
+ "industry": "tech"
8
+ },
9
+ "work_experiences": [
10
+ {
11
+ "company": "某科技公司",
12
+ "role": "高级全栈工程师",
13
+ "start_date": "2022-03",
14
+ "end_date": "2026-08",
15
+ "duration_months": 53,
16
+ "industry": "tech",
17
+ "responsibilities": ["负责核心后端服务设计与开发", "主导智能合约安全审计流程"],
18
+ "achievements": ["支撑百万级用户的高并发系统,QPS 提升 40%"],
19
+ "tech_stack": ["Python", "FastAPI", "PostgreSQL", "Redis", "Solidity"],
20
+ "team_size": 8,
21
+ "ai_summary": "5 年全栈经验,主导多个区块链项目"
22
+ }
23
+ ],
24
+ "projects": [
25
+ {
26
+ "name": "DeFi 借贷协议",
27
+ "role": "核心开发者",
28
+ "start_date": "2023-01",
29
+ "end_date": "2024-06",
30
+ "description": "基于 Solidity 的链上借贷协议,支持多币种抵押借贷与清算",
31
+ "responsibilities": ["设计智能合约架构", "实现清算机器人"],
32
+ "achievements": ["TVL 峰值 5000 万美元", "通过第三方安全审计"],
33
+ "tech_stack": ["Solidity", "Hardhat", "Web3.js"],
34
+ "project_url": "https://github.com/example/defi-lending",
35
+ "ai_summary": "链上借贷协议,TVL 峰值 5000 万美元"
36
+ }
37
+ ],
38
+ "education": [
39
+ {
40
+ "school": "某大学",
41
+ "degree": "本科",
42
+ "major": "计算机科学与技术",
43
+ "start_year": 2015,
44
+ "end_year": 2019,
45
+ "gpa": 3.6,
46
+ "school_type": "211",
47
+ "ai_summary": "计算机本科,GPA 3.6"
48
+ }
49
+ ],
50
+ "skills": [
51
+ {"name": "Python", "category": "programming_language", "level": 5, "years": 5, "context": "后端开发,AI 服务"},
52
+ {"name": "Solidity", "category": "programming_language", "level": 4, "years": 3, "context": "智能合约开发"},
53
+ {"name": "FastAPI", "category": "framework", "level": 4, "years": 3, "context": "后端 API 服务"},
54
+ {"name": "PostgreSQL", "category": "tool", "level": 4, "years": 4, "context": "数据存储,pgvector 向量检索"}
55
+ ],
56
+ "certificates": [
57
+ {"name": "区块链工程师认证", "issuer": "某机构", "year": 2023}
58
+ ],
59
+ "languages": [
60
+ {"language": "中文", "level": "native"},
61
+ {"language": "英语", "level": "business"}
62
+ ]
63
+ }
@@ -0,0 +1,116 @@
1
+ """HiPo Work 多账户管理。
2
+
3
+ token 仓库(~/.hipo_mcp_tokens.json)按多账户设计:每个邮箱/角色一条,
4
+ 可列出、切换默认账户、删除账户。
5
+
6
+ 用法:
7
+ python3 hipo_accounts.py list
8
+ python3 hipo_accounts.py current
9
+ python3 hipo_accounts.py switch <account_id>
10
+ python3 hipo_accounts.py delete <account_id>
11
+ python3 hipo_accounts.py --file /path/to/tokens.json list
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from hipo_auth import TokenStore, require_py310
20
+
21
+
22
+ def _short_id(account_id: str) -> str:
23
+ return account_id if len(account_id) <= 40 else account_id[:40] + "…"
24
+
25
+
26
+ def cmd_list(store: TokenStore) -> int:
27
+ accounts = store.accounts()
28
+ default = store.default_account()
29
+ if not accounts:
30
+ print("(空)暂无账户。请先运行 hipo_authorize.py / hipo.py authorize 完成授权。")
31
+ return 0
32
+ print(f"token 仓库: {store.path}")
33
+ print(f"默认账户: {default or '(未设置)'}\n")
34
+ print(f"{'账户':<44} {'角色':<10} {'邮箱'}")
35
+ print("-" * 88)
36
+ for aid, acc in accounts.items():
37
+ marker = "→" if aid == default else " "
38
+ role = acc.get("role") or "?"
39
+ email = acc.get("email") or "?"
40
+ print(f"{marker} {_short_id(aid):<42} {role:<10} {email}")
41
+ print("\n提示: 用 `switch <account_id>` 切换默认账户;用 `delete <account_id>` 删除。")
42
+ return 0
43
+
44
+
45
+ def cmd_current(store: TokenStore) -> int:
46
+ default = store.default_account()
47
+ if not default:
48
+ print("(未设置默认账户)")
49
+ return 1
50
+ acc = store.get_account()
51
+ if not acc:
52
+ print(f"默认账户 {default} 不存在于仓库。")
53
+ return 1
54
+ print(f"默认账户: {default}")
55
+ print(f" 邮箱: {acc.get('email') or '?'}")
56
+ print(f" 角色: {acc.get('role') or '?'}")
57
+ tokens = acc.get("tokens") or {}
58
+ print(f" access_token: {'有' if tokens.get('access_token') else '无'}")
59
+ print(f" refresh_token: {'有' if tokens.get('refresh_token') else '无'}")
60
+ return 0
61
+
62
+
63
+ def cmd_switch(store: TokenStore, account_id: str) -> int:
64
+ if account_id not in store.accounts():
65
+ print(f"❌ 账户 {account_id} 不存在。可运行 `list` 查看现有账户。")
66
+ return 1
67
+ store.set_account(account_id, store.accounts()[account_id], make_default=True)
68
+ print(f"✅ 默认账户已切换为 {account_id}")
69
+ return 0
70
+
71
+
72
+ def cmd_delete(store: TokenStore, account_id: str) -> int:
73
+ if account_id not in store.accounts():
74
+ print(f"❌ 账户 {account_id} 不存在。")
75
+ return 1
76
+ acc = store.accounts()[account_id]
77
+ if input(f"确认删除账户 {account_id}({acc.get('email') or '?'})?[y/N] ").strip().lower() != "y":
78
+ print("已取消。")
79
+ return 0
80
+ del store._data["accounts"][account_id] # noqa: SLF001
81
+ if store.default_account() == account_id:
82
+ rest = list(store.accounts().keys())
83
+ store._data["default_account"] = rest[0] if rest else "" # noqa: SLF001
84
+ store._save()
85
+ print(f"✅ 已删除账户 {account_id}")
86
+ return 0
87
+
88
+
89
+ def main() -> int:
90
+ require_py310()
91
+ parser = argparse.ArgumentParser(description="HiPo Work 多账户管理")
92
+ parser.add_argument("--file", default=None, help="token 仓库文件路径(默认 ~/.hipo_mcp_tokens.json)")
93
+ sub = parser.add_subparsers(dest="action", required=True)
94
+ sub.add_parser("list", help="列出所有账户")
95
+ sub.add_parser("current", help="显示当前默认账户")
96
+ p_switch = sub.add_parser("switch", help="切换默认账户")
97
+ p_switch.add_argument("account_id", help="目标账户 ID(可为邮箱)")
98
+ p_delete = sub.add_parser("delete", help="删除账户")
99
+ p_delete.add_argument("account_id", help="要删除的账户 ID")
100
+ args = parser.parse_args()
101
+
102
+ store = TokenStore(args.file)
103
+ if args.action == "list":
104
+ return cmd_list(store)
105
+ if args.action == "current":
106
+ return cmd_current(store)
107
+ if args.action == "switch":
108
+ return cmd_switch(store, args.account_id)
109
+ if args.action == "delete":
110
+ return cmd_delete(store, args.account_id)
111
+ parser.print_help()
112
+ return 1
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -0,0 +1,289 @@
1
+ """HiPo Work 共享认证库:token 仓库读写 / 过期判断 / 刷新 / 多账户预留。
2
+
3
+ 所有 CLI 脚本都通过本模块管理令牌,避免重复实现。
4
+ 数据结构(~/.hipo_mcp_tokens.json,多账户预留):
5
+
6
+ {
7
+ "default_account": "sexylin2010+eng2@gmail.com",
8
+ "accounts": {
9
+ "<account_id>": {
10
+ "email": "...", "role": "candidate" | "employer",
11
+ "client_info": { "client_id": "...", "client_name": "..." },
12
+ "tokens": { "access_token": "...", "refresh_token": "...", "scope": "...", "expires_in": 3600 }
13
+ }
14
+ }
15
+ }
16
+
17
+ 第一版只维护单账户(default_account),但存储结构已按多账户设计,
18
+ 后续扩展只需在 accounts 下新增条目即可。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from pathlib import Path
30
+
31
+ # ---- 默认配置(可用 config.yaml 或环境变量覆盖)----
32
+ DEFAULT_TOKEN_FILE = Path.home() / ".hipo_mcp_tokens.json"
33
+ DEFAULT_TOKEN_ENDPOINT = "https://mcp.hipowork.com/token"
34
+ DEFAULT_MCP_URL = "https://mcp.hipowork.com/mcp"
35
+ DEFAULT_API_BASE = "https://api.hipowork.com/api/v1"
36
+ DEFAULT_CALLBACK_PORT = 8765
37
+
38
+
39
+ # ============ Token 仓库 ============
40
+
41
+ class TokenStore:
42
+ """极简 JSON 文件 token 仓库(线程安全 + 原子写)。"""
43
+
44
+ def __init__(self, path: str | Path | None = None):
45
+ self.path = Path(path or DEFAULT_TOKEN_FILE)
46
+ self._data = self._load()
47
+
48
+ def _load(self) -> dict:
49
+ try:
50
+ data = json.loads(self.path.read_text(encoding="utf-8"))
51
+ except (FileNotFoundError, json.JSONDecodeError):
52
+ return {}
53
+ if not isinstance(data, dict):
54
+ return {}
55
+ # 兼容旧版平铺结构 {client_info, tokens} → 自动迁移为多账户结构
56
+ if "tokens" in data and "accounts" not in data:
57
+ data = self._migrate_flat(data)
58
+ return data
59
+
60
+ def _migrate_flat(self, data: dict) -> dict:
61
+ """旧版平铺 token 文件 → 多账户结构(account_id 取 email,缺省用 default)。"""
62
+ account_id = str(data.get("email") or "default")
63
+ account = {}
64
+ if data.get("tokens"):
65
+ account["tokens"] = data["tokens"]
66
+ if data.get("client_info"):
67
+ account["client_info"] = data["client_info"]
68
+ if data.get("email"):
69
+ account["email"] = data["email"]
70
+ if data.get("role"):
71
+ account["role"] = data["role"]
72
+ migrated = {"default_account": account_id, "accounts": {account_id: account}}
73
+ # 迁移即写回,保证下次 save 直接落盘多账户结构
74
+ tmp = self.path.with_suffix(".tmp")
75
+ tmp.write_text(json.dumps(migrated, indent=2, ensure_ascii=False), encoding="utf-8")
76
+ tmp.replace(self.path)
77
+ return migrated
78
+
79
+ def _save(self) -> None:
80
+ self.path.parent.mkdir(parents=True, exist_ok=True)
81
+ # 原子写:先写临时文件再 rename,避免中断导致仓库损坏
82
+ tmp = self.path.with_suffix(".tmp")
83
+ tmp.write_text(json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8")
84
+ tmp.replace(self.path)
85
+ try:
86
+ self.path.chmod(0o600) # 仅 owner 可读写
87
+ except OSError:
88
+ pass
89
+
90
+ # ---- 账户级 API ----
91
+
92
+ def default_account(self) -> str:
93
+ return self._data.get("default_account", "")
94
+
95
+ def accounts(self) -> dict:
96
+ return self._data.get("accounts", {})
97
+
98
+ def get_account(self, account_id: str | None = None) -> dict | None:
99
+ account_id = account_id or self.default_account()
100
+ if not account_id:
101
+ return None
102
+ return self.accounts().get(account_id)
103
+
104
+ def set_account(self, account_id: str, payload: dict, make_default: bool = True) -> None:
105
+ self._data.setdefault("accounts", {})[account_id] = payload
106
+ if make_default:
107
+ self._data["default_account"] = account_id
108
+ self._save()
109
+
110
+ def save_tokens(self, tokens: dict, client_info: dict | None = None,
111
+ account_id: str | None = None, email: str = "", role: str = "") -> None:
112
+ account_id = account_id or email or self.default_account() or "default"
113
+ acc = self.get_account(account_id) or {}
114
+ acc.setdefault("tokens", {}).update(tokens)
115
+ if client_info:
116
+ acc["client_info"] = client_info
117
+ if email:
118
+ acc["email"] = email
119
+ if role:
120
+ acc["role"] = role
121
+ self.set_account(account_id, acc, make_default=True)
122
+
123
+ # ---- 便捷访问 ----
124
+
125
+ def tokens(self, account_id: str | None = None) -> dict:
126
+ acc = self.get_account(account_id)
127
+ return (acc or {}).get("tokens", {})
128
+
129
+ def client_info(self, account_id: str | None = None) -> dict:
130
+ acc = self.get_account(account_id)
131
+ return (acc or {}).get("client_info", {})
132
+
133
+ def email(self, account_id: str | None = None) -> str:
134
+ acc = self.get_account(account_id)
135
+ return (acc or {}).get("email", "")
136
+
137
+ def role(self, account_id: str | None = None) -> str:
138
+ acc = self.get_account(account_id)
139
+ return (acc or {}).get("role", "")
140
+
141
+ def exists(self) -> bool:
142
+ return self.path.exists() and bool(self.tokens().get("access_token"))
143
+
144
+
145
+ # ============ Token 工具 ============
146
+
147
+ def decode_jwt_payload(token: str) -> dict:
148
+ """解析 JWT 的 payload(不校验签名,仅用于读取 exp/scope/role)。"""
149
+ try:
150
+ payload = token.split(".")[1]
151
+ payload += "=" * (-len(payload) % 4)
152
+ return json.loads(base64.urlsafe_b64decode(payload))
153
+ except Exception:
154
+ return {}
155
+
156
+
157
+ def is_expired(token: str, margin: int = 120) -> bool:
158
+ """判断 access_token 是否在 margin 秒内过期。解析失败视为过期。"""
159
+ exp = decode_jwt_payload(token).get("exp", 0)
160
+ return exp - margin <= time.time()
161
+
162
+
163
+ def refresh_tokens(tokens: dict, client_info: dict, endpoint: str = DEFAULT_TOKEN_ENDPOINT) -> dict:
164
+ """用 refresh_token 轮换出一对新 token。失败抛异常,由调用方决定如何处理。"""
165
+ if not tokens.get("refresh_token"):
166
+ raise ValueError("仓库中没有 refresh_token,需要重新走 OAuth 授权")
167
+ if not client_info.get("client_id"):
168
+ raise ValueError("仓库中没有 client_info.client_id,需要重新走 OAuth 授权")
169
+
170
+ body = urllib.parse.urlencode({
171
+ "grant_type": "refresh_token",
172
+ "refresh_token": tokens["refresh_token"],
173
+ "client_id": client_info["client_id"],
174
+ }).encode()
175
+ req = urllib.request.Request(
176
+ endpoint, data=body,
177
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
178
+ )
179
+ try:
180
+ with urllib.request.urlopen(req, timeout=30) as resp:
181
+ new = json.loads(resp.read())
182
+ except urllib.error.HTTPError as exc:
183
+ detail = exc.read().decode()[:300]
184
+ if exc.code in (400, 401):
185
+ raise ValueError(f"refresh_token 已失效(HTTP {exc.code}),需要重新授权: {detail}")
186
+ raise RuntimeError(f"刷新失败 HTTP {exc.code}: {detail}")
187
+ except Exception as exc:
188
+ raise RuntimeError(f"网络错误: {exc}")
189
+
190
+ if "access_token" not in new:
191
+ raise ValueError(f"刷新响应缺少 access_token: {new}")
192
+
193
+ tokens["access_token"] = new["access_token"]
194
+ if new.get("refresh_token"):
195
+ tokens["refresh_token"] = new["refresh_token"]
196
+ if new.get("scope"):
197
+ tokens["scope"] = new["scope"]
198
+ tokens["expires_in"] = new.get("expires_in", tokens.get("expires_in", 3600))
199
+ return tokens
200
+
201
+
202
+ def ensure_valid_token(store: TokenStore, account_id: str | None = None,
203
+ force: bool = False) -> dict:
204
+ """确保拿到未过期的 access_token。过期则自动刷新;刷新失败抛异常。
205
+
206
+ 返回 tokens dict(可能已刷新)。
207
+ """
208
+ tokens = store.tokens(account_id)
209
+ client_info = store.client_info(account_id)
210
+ if not tokens.get("access_token"):
211
+ raise ValueError("未找到 access_token,请先运行 hipo_authorize.py 完成授权")
212
+
213
+ if force or is_expired(tokens["access_token"]):
214
+ refreshed = refresh_tokens(tokens, client_info)
215
+ store.save_tokens(refreshed, client_info, account_id=account_id)
216
+ return refreshed
217
+ return tokens
218
+
219
+
220
+ # ============ MCP / API 客户端入口 ============
221
+
222
+ def build_authorizer(store: TokenStore, account_id: str | None = None):
223
+ """构造 mcp SDK 的 OAuthClientProvider,复用仓库中的 token。
224
+
225
+ 若仓库已有有效 token,SDK 会直接使用而不再弹授权。
226
+ 依赖 mcp SDK;未安装时抛 ImportError。
227
+ """
228
+ try:
229
+ from mcp.client.auth import OAuthClientProvider
230
+ except ImportError as exc: # pragma: no cover
231
+ raise ImportError("需要 mcp SDK:pip install -r requirements.txt") from exc
232
+ raise NotImplementedError("多账户/复用式 authorizer 在 v1.1 提供;v1.0 请直接用 hipo_mcp_client.py")
233
+
234
+
235
+ def api_request(method: str, path: str, token: str | None = None, body: dict | None = None,
236
+ api_base: str = DEFAULT_API_BASE, timeout: int = 30):
237
+ """对后端 REST API 发请求(/api/v1 之下),返回解析后的 JSON。
238
+
239
+ path 示例:"/candidate/matches"、"/candidate/profile"。
240
+ token 传 None 时(公开接口,如发送验证码)不携带 Authorization 头。
241
+ """
242
+ url = f"{api_base}{path}"
243
+ headers = {
244
+ "Content-Type": "application/json",
245
+ }
246
+ if token:
247
+ headers["Authorization"] = f"Bearer {token}"
248
+ data = json.dumps(body).encode() if body is not None else None
249
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
250
+ try:
251
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
252
+ return json.loads(resp.read())
253
+ except urllib.error.HTTPError as exc:
254
+ detail = exc.read().decode()[:500]
255
+ raise RuntimeError(f"API {method} {path} 失败 HTTP {exc.code}: {detail}")
256
+
257
+
258
+ # ============ CLI 公共工具 ============
259
+
260
+ def require_py310() -> None:
261
+ """Python 版本检查:最低 3.10(PEP 604 联合类型)。"""
262
+ if sys.version_info < (3, 10):
263
+ sys.stderr.write(
264
+ f"需要 Python 3.10+(当前 {sys.version_info.major}.{sys.version_info.minor})。\n"
265
+ "请升级 Python 后重试。\n"
266
+ )
267
+ sys.exit(1)
268
+
269
+
270
+ def print_token_summary(store: TokenStore, account_id: str | None = None) -> None:
271
+ """打印当前账户的 token 状态摘要(不打印 token 本身)。"""
272
+ tokens = store.tokens(account_id)
273
+ client_info = store.client_info(account_id)
274
+ email = store.email(account_id) or "?"
275
+ role = store.role(account_id) or "?"
276
+ if not tokens.get("access_token"):
277
+ print(f" 账户: {email}({role})— 未授权")
278
+ return
279
+
280
+ claims = decode_jwt_payload(tokens["access_token"])
281
+ exp = claims.get("exp", 0)
282
+ now = time.time()
283
+ expires_in_min = max(0, int((exp - now) // 60)) if exp else -1
284
+ status = "有效" if exp - 120 > now else ("已过期" if exp and exp <= now else "即将过期")
285
+ print(f" 账户: {email}({role})")
286
+ print(f" scope: {tokens.get('scope', '?')}")
287
+ print(f" access_token: {status}({expires_in_min} 分钟后过期)")
288
+ print(f" refresh_token: {'有' if tokens.get('refresh_token') else '无'}")
289
+ print(f" client_id: {client_info.get('client_id', '?')[:12]}..." if client_info.get("client_id") else " client_id: 无")