goofish-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.
Files changed (41) hide show
  1. goofish_cli/__init__.py +1 -0
  2. goofish_cli/cli.py +119 -0
  3. goofish_cli/commands/__init__.py +0 -0
  4. goofish_cli/commands/auth/__init__.py +0 -0
  5. goofish_cli/commands/auth/login.py +66 -0
  6. goofish_cli/commands/auth/reset_guard.py +16 -0
  7. goofish_cli/commands/auth/status.py +40 -0
  8. goofish_cli/commands/category/__init__.py +0 -0
  9. goofish_cli/commands/category/recommend.py +64 -0
  10. goofish_cli/commands/item/__init__.py +0 -0
  11. goofish_cli/commands/item/delete.py +32 -0
  12. goofish_cli/commands/item/get.py +34 -0
  13. goofish_cli/commands/item/publish.py +175 -0
  14. goofish_cli/commands/location/__init__.py +0 -0
  15. goofish_cli/commands/location/default.py +40 -0
  16. goofish_cli/commands/media/__init__.py +0 -0
  17. goofish_cli/commands/media/upload.py +53 -0
  18. goofish_cli/commands/message/__init__.py +0 -0
  19. goofish_cli/commands/message/history.py +23 -0
  20. goofish_cli/commands/message/send.py +102 -0
  21. goofish_cli/commands/message/watch.py +58 -0
  22. goofish_cli/core/__init__.py +26 -0
  23. goofish_cli/core/errors.py +31 -0
  24. goofish_cli/core/guard.py +63 -0
  25. goofish_cli/core/limiter.py +60 -0
  26. goofish_cli/core/mtop.py +131 -0
  27. goofish_cli/core/output.py +82 -0
  28. goofish_cli/core/registry.py +78 -0
  29. goofish_cli/core/session.py +88 -0
  30. goofish_cli/core/sign.py +37 -0
  31. goofish_cli/core/strategy.py +10 -0
  32. goofish_cli/core/token.py +88 -0
  33. goofish_cli/core/ws.py +493 -0
  34. goofish_cli/mcp_server.py +56 -0
  35. goofish_cli/static/goofish_js_version_2.js +568 -0
  36. goofish_cli-0.1.0.dist-info/METADATA +279 -0
  37. goofish_cli-0.1.0.dist-info/RECORD +41 -0
  38. goofish_cli-0.1.0.dist-info/WHEEL +4 -0
  39. goofish_cli-0.1.0.dist-info/entry_points.txt +3 -0
  40. goofish_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
  41. goofish_cli-0.1.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,23 @@
1
+ """message history — 拉取指定会话的历史消息。
2
+
3
+ 走 WebSocket LWP `/r/MessageManager/listUserMessages`(官方没有对应的 HTTP 版)。
4
+ 一次性命令:连 → reg → 翻页直到 hasMore=0 → 断开。
5
+ """
6
+
7
+ import asyncio
8
+ from typing import Any
9
+
10
+ from goofish_cli.core import Session, Strategy, command
11
+ from goofish_cli.core.ws import list_user_messages
12
+
13
+
14
+ @command(
15
+ namespace="message",
16
+ name="history",
17
+ description="拉取指定 cid 会话的历史消息(翻页到底)",
18
+ strategy=Strategy.COOKIE,
19
+ columns=["send_user_id", "send_user_name", "message"],
20
+ )
21
+ def history(cid: str, limit_per_page: int = 20) -> list[dict[str, Any]]:
22
+ session = Session.load()
23
+ return asyncio.run(list_user_messages(session, cid, limit_per_page=limit_per_page))
@@ -0,0 +1,102 @@
1
+ """message send — 向指定会话发送一条消息(文本/图片)。
2
+
3
+ 写操作,走限流 + 熔断。未知 cid 时可传 --item-id 自动创建单聊。
4
+ """
5
+
6
+ import asyncio
7
+ from typing import Any, Literal
8
+
9
+ from goofish_cli.core import Session, Strategy, command
10
+ from goofish_cli.core.guard import watch
11
+ from goofish_cli.core.limiter import acquire
12
+ from goofish_cli.core.token import get_access_token
13
+ from goofish_cli.core.ws import (
14
+ connect,
15
+ create_chat,
16
+ heartbeat_loop,
17
+ register,
18
+ send_image,
19
+ send_text,
20
+ )
21
+
22
+
23
+ @command(
24
+ namespace="message",
25
+ name="send",
26
+ description="向会话发送消息(text/image)。text 必填,image 走 url+wh",
27
+ strategy=Strategy.COOKIE,
28
+ columns=["cid", "toid", "kind", "ok", "mid"],
29
+ write=True,
30
+ )
31
+ def send(
32
+ cid: str,
33
+ toid: str,
34
+ text: str = "",
35
+ *,
36
+ kind: Literal["text", "image"] = "text",
37
+ image_url: str = "",
38
+ image_width: int = 0,
39
+ image_height: int = 0,
40
+ item_id: str = "",
41
+ ) -> dict[str, Any]:
42
+ session = Session.load()
43
+ with acquire("message.write"), watch():
44
+ return asyncio.run(_send(
45
+ session,
46
+ cid=cid,
47
+ toid=toid,
48
+ text=text,
49
+ kind=kind,
50
+ image_url=image_url,
51
+ image_width=image_width,
52
+ image_height=image_height,
53
+ item_id=item_id,
54
+ ))
55
+
56
+
57
+ async def _send(
58
+ session: Session,
59
+ *,
60
+ cid: str,
61
+ toid: str,
62
+ text: str,
63
+ kind: str,
64
+ image_url: str,
65
+ image_width: int,
66
+ image_height: int,
67
+ item_id: str,
68
+ ) -> dict[str, Any]:
69
+ token = get_access_token(session)
70
+ async with connect(session) as ws:
71
+ await register(ws, session, token)
72
+ hb = asyncio.create_task(heartbeat_loop(ws))
73
+ try:
74
+ if item_id:
75
+ await create_chat(ws, myid=session.unb, toid=toid, item_id=item_id)
76
+ await asyncio.sleep(0.5)
77
+
78
+ if kind == "text":
79
+ if not text:
80
+ raise ValueError("kind=text 需要 --text")
81
+ mid = await send_text(
82
+ ws, myid=session.unb, cid=cid, toid=toid, text=text
83
+ )
84
+ elif kind == "image":
85
+ if not (image_url and image_width and image_height):
86
+ raise ValueError("kind=image 需要 --image-url/--image-width/--image-height")
87
+ mid = await send_image(
88
+ ws,
89
+ myid=session.unb,
90
+ cid=cid,
91
+ toid=toid,
92
+ url=image_url,
93
+ width=image_width,
94
+ height=image_height,
95
+ )
96
+ else:
97
+ raise ValueError(f"不支持的 kind: {kind}")
98
+ # 等一轮 ack 回包,避免 WS 提前关
99
+ await asyncio.sleep(1.0)
100
+ finally:
101
+ hb.cancel()
102
+ return {"cid": cid, "toid": toid, "kind": kind, "ok": True, "mid": mid}
@@ -0,0 +1,58 @@
1
+ """message watch — 常驻接收消息,每条解密后以 JSONL 打到 stdout。
2
+
3
+ 不做自动回复;Agent 自己拿 JSONL 决策再调 message send。
4
+ Ctrl-C 退出。
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from goofish_cli.core import Session, Strategy, command
15
+ from goofish_cli.core.ws import (
16
+ extract_incoming_text,
17
+ extract_meta_event,
18
+ extract_push_messages,
19
+ run_forever,
20
+ )
21
+
22
+
23
+ @command(
24
+ namespace="message",
25
+ name="watch",
26
+ description="常驻 IM 长连接,下行事件以 JSONL 输出到 stdout(Ctrl-C 退出)",
27
+ strategy=Strategy.COOKIE,
28
+ columns=[],
29
+ )
30
+ def watch() -> dict[str, Any]:
31
+ session = Session.load()
32
+ logger.info(f"[watch] unb={session.unb} tracknick={session.tracknick}")
33
+
34
+ async def _handler(msg: dict[str, Any], _ws) -> None:
35
+ # /s/para 是对方"正在输入"状态通知,跳过避免噪音
36
+ if msg.get("lwp") == "/s/para":
37
+ return
38
+ for decoded in extract_push_messages(msg):
39
+ # 优先识别元事件(read / new_msg 通知)
40
+ meta = extract_meta_event(decoded)
41
+ if meta is not None:
42
+ sys.stdout.write(json.dumps(meta, ensure_ascii=False) + "\n")
43
+ sys.stdout.flush()
44
+ continue
45
+ # 再尝试嵌套消息正文
46
+ item = extract_incoming_text(decoded)
47
+ if item is None:
48
+ continue
49
+ # 真消息:send_message 非空 或 contentType=1
50
+ if item.get("send_message") or item.get("content_type") == 1:
51
+ sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
52
+ sys.stdout.flush()
53
+
54
+ try:
55
+ asyncio.run(run_forever(session, handler=_handler))
56
+ except KeyboardInterrupt:
57
+ logger.info("[watch] 用户中断")
58
+ return {"ok": True}
@@ -0,0 +1,26 @@
1
+ from goofish_cli.core.errors import (
2
+ AuthRequiredError,
3
+ GoofishError,
4
+ NotFoundError,
5
+ RateLimitedError,
6
+ RiskControlError,
7
+ SignError,
8
+ )
9
+ from goofish_cli.core.registry import Command, command, iter_commands, registry
10
+ from goofish_cli.core.session import Session
11
+ from goofish_cli.core.strategy import Strategy
12
+
13
+ __all__ = [
14
+ "AuthRequiredError",
15
+ "Command",
16
+ "GoofishError",
17
+ "NotFoundError",
18
+ "RateLimitedError",
19
+ "RiskControlError",
20
+ "Session",
21
+ "SignError",
22
+ "Strategy",
23
+ "command",
24
+ "iter_commands",
25
+ "registry",
26
+ ]
@@ -0,0 +1,31 @@
1
+ """统一异常体系。driver 层根据响应体 ret 或状态自动抛对应异常。"""
2
+ from __future__ import annotations
3
+
4
+
5
+ class GoofishError(Exception):
6
+ exit_code = 1
7
+
8
+ def __init__(self, message: str, *, raw: dict | None = None):
9
+ super().__init__(message)
10
+ self.raw = raw
11
+
12
+
13
+ class AuthRequiredError(GoofishError):
14
+ exit_code = 77
15
+
16
+
17
+ class SignError(GoofishError):
18
+ exit_code = 78
19
+
20
+
21
+ class RateLimitedError(GoofishError):
22
+ exit_code = 75
23
+
24
+
25
+ class RiskControlError(GoofishError):
26
+ """触发风控:RGV587 / punish / FAIL_SYS_USER_VALIDATE 等。"""
27
+ exit_code = 76
28
+
29
+
30
+ class NotFoundError(GoofishError):
31
+ exit_code = 79
@@ -0,0 +1,63 @@
1
+ """风控熔断。检测到 RiskControlError 后写入熔断时间戳,后续请求直接拒绝。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import time
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ from goofish_cli.core.errors import RiskControlError
11
+
12
+ STATE_PATH = Path.home() / ".goofish-cli" / "circuit.json"
13
+ DEFAULT_BREAK_MINUTES = 10
14
+
15
+
16
+ def _break_seconds() -> int:
17
+ try:
18
+ return max(60, int(os.environ.get("GOOFISH_CIRCUIT_BREAK_MINUTES", DEFAULT_BREAK_MINUTES)) * 60)
19
+ except ValueError:
20
+ return DEFAULT_BREAK_MINUTES * 60
21
+
22
+
23
+ def _load() -> float:
24
+ if not STATE_PATH.exists():
25
+ return 0.0
26
+ try:
27
+ return float(json.loads(STATE_PATH.read_text()).get("until", 0))
28
+ except (json.JSONDecodeError, OSError, ValueError):
29
+ return 0.0
30
+
31
+
32
+ def _save(until: float) -> None:
33
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ STATE_PATH.write_text(json.dumps({"until": until}))
35
+
36
+
37
+ def check() -> None:
38
+ until = _load()
39
+ if until and time.time() < until:
40
+ remain = int(until - time.time())
41
+ raise RiskControlError(
42
+ f"风控熔断中,剩余 {remain}s。触发后自动冷却,可通过 `goofish auth reset-guard` 手动解除。"
43
+ )
44
+
45
+
46
+ def trip(reason: str = "") -> None:
47
+ _save(time.time() + _break_seconds())
48
+
49
+
50
+ def reset() -> None:
51
+ if STATE_PATH.exists():
52
+ STATE_PATH.unlink()
53
+
54
+
55
+ @contextmanager
56
+ def watch():
57
+ """包住写操作:命中 RGV587 自动熔断。"""
58
+ check()
59
+ try:
60
+ yield
61
+ except RiskControlError:
62
+ trip()
63
+ raise
@@ -0,0 +1,60 @@
1
+ """令牌桶限流。单账号 + 单命名空间,默认 1 写/分钟(可配)。
2
+
3
+ 写入 ~/.goofish-cli/limiter.json 做进程间共享(单机多进程场景)。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import time
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ from goofish_cli.core.errors import RateLimitedError
14
+
15
+ STATE_PATH = Path.home() / ".goofish-cli" / "limiter.json"
16
+ DEFAULT_WRITE_RPM = 1
17
+
18
+
19
+ def _rpm() -> int:
20
+ try:
21
+ return max(1, int(os.environ.get("GOOFISH_WRITE_RPM", DEFAULT_WRITE_RPM)))
22
+ except ValueError:
23
+ return DEFAULT_WRITE_RPM
24
+
25
+
26
+ def _load() -> dict[str, list[float]]:
27
+ if not STATE_PATH.exists():
28
+ return {}
29
+ try:
30
+ return json.loads(STATE_PATH.read_text())
31
+ except (json.JSONDecodeError, OSError):
32
+ return {}
33
+
34
+
35
+ def _save(state: dict[str, list[float]]) -> None:
36
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
37
+ STATE_PATH.write_text(json.dumps(state))
38
+
39
+
40
+ def check(bucket: str) -> None:
41
+ """消耗一个令牌。超限抛 RateLimitedError。"""
42
+ now = time.time()
43
+ window = 60.0
44
+ rpm = _rpm()
45
+ state = _load()
46
+ hits = [t for t in state.get(bucket, []) if now - t < window]
47
+ if len(hits) >= rpm:
48
+ wait = window - (now - hits[0])
49
+ raise RateLimitedError(
50
+ f"限流:bucket={bucket} 每 {window:.0f}s 上限 {rpm},再等 {wait:.1f}s"
51
+ )
52
+ hits.append(now)
53
+ state[bucket] = hits
54
+ _save(state)
55
+
56
+
57
+ @contextmanager
58
+ def acquire(bucket: str):
59
+ check(bucket)
60
+ yield
@@ -0,0 +1,131 @@
1
+ """统一 mtop 调用模板。抽出 headers/params/sign 流程。
2
+
3
+ 要点:
4
+ - `t` 取真实毫秒:`int(time.time() * 1000)`,而不是 `int(time.time()) * 1000`(后者末三位恒为 0)
5
+ - 自动识别风控关键字抛 RiskControlError
6
+ - 自动识别令牌过期抛 AuthRequiredError
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from typing import Any
13
+
14
+ from goofish_cli.core.errors import (
15
+ AuthRequiredError,
16
+ GoofishError,
17
+ NotFoundError,
18
+ RiskControlError,
19
+ SignError,
20
+ )
21
+ from goofish_cli.core.session import USER_AGENT, Session
22
+ from goofish_cli.core.sign import generate_sign
23
+
24
+ APP_KEY = "34839810"
25
+ MTOP_HOST = "https://h5api.m.goofish.com"
26
+
27
+ _RISK_KEYWORDS = (
28
+ "RGV587_ERROR",
29
+ "FAIL_SYS_USER_VALIDATE",
30
+ "哎哟喂",
31
+ "/punish",
32
+ )
33
+ _AUTH_KEYWORDS = (
34
+ "FAIL_SYS_SESSION_EXPIRED",
35
+ "FAIL_SYS_TOKEN_EXOIRED",
36
+ "FAIL_SYS_TOKEN_EMPTY",
37
+ "令牌过期",
38
+ "FAIL_SYS_ILLEGAL_ACCESS",
39
+ )
40
+
41
+
42
+ def default_headers() -> dict[str, str]:
43
+ return {
44
+ "accept": "application/json",
45
+ "accept-language": "en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7,ja;q=0.6",
46
+ "cache-control": "no-cache",
47
+ "content-type": "application/x-www-form-urlencoded",
48
+ "origin": "https://www.goofish.com",
49
+ "pragma": "no-cache",
50
+ "priority": "u=1, i",
51
+ "referer": "https://www.goofish.com/",
52
+ "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
53
+ "sec-ch-ua-mobile": "?0",
54
+ "sec-ch-ua-platform": '"macOS"',
55
+ "sec-fetch-dest": "empty",
56
+ "sec-fetch-mode": "cors",
57
+ "sec-fetch-site": "same-site",
58
+ "user-agent": USER_AGENT,
59
+ }
60
+
61
+
62
+ def call(
63
+ session: Session,
64
+ api: str,
65
+ data: dict[str, Any] | list[Any] | str,
66
+ *,
67
+ version: str = "1.0",
68
+ spm_cnt: str = "a21ybx.home.0.0",
69
+ extra_params: dict[str, str] | None = None,
70
+ headers: dict[str, str] | None = None,
71
+ ) -> dict[str, Any]:
72
+ """调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。"""
73
+ url = f"{MTOP_HOST}/h5/{api}/{version}/"
74
+ t_ms = str(int(time.time() * 1000))
75
+ data_val = data if isinstance(data, str) else json.dumps(data, separators=(",", ":"))
76
+
77
+ token = session.h5_token
78
+ if not token:
79
+ raise AuthRequiredError("_m_h5_tk 缺失,请重新登录并导出 cookie")
80
+ sign = generate_sign(t_ms, token, data_val)
81
+
82
+ params = {
83
+ "jsv": "2.7.2",
84
+ "appKey": APP_KEY,
85
+ "t": t_ms,
86
+ "sign": sign,
87
+ "v": version,
88
+ "type": "originaljson",
89
+ "accountSite": "xianyu",
90
+ "dataType": "json",
91
+ "timeout": "20000",
92
+ "api": api,
93
+ "sessionOption": "AutoLoginOnly",
94
+ "spm_cnt": spm_cnt,
95
+ }
96
+ if extra_params:
97
+ params.update(extra_params)
98
+
99
+ resp = session.http.post(
100
+ url,
101
+ params=params,
102
+ headers=headers or default_headers(),
103
+ data={"data": data_val},
104
+ timeout=30,
105
+ )
106
+ raw = resp.json()
107
+ _classify_error(raw, api)
108
+ return raw
109
+
110
+
111
+ def _classify_error(raw: dict[str, Any], api: str) -> None:
112
+ """根据 ret 字段分类抛异常。成功则不抛。"""
113
+ ret = raw.get("ret") or []
114
+ ret_str = " | ".join(ret) if isinstance(ret, list) else str(ret)
115
+ if not ret_str or "SUCCESS" in ret_str:
116
+ return
117
+
118
+ for kw in _RISK_KEYWORDS:
119
+ if kw in ret_str:
120
+ raise RiskControlError(
121
+ f"[{api}] 触发风控:{ret_str}",
122
+ raw=raw,
123
+ )
124
+ for kw in _AUTH_KEYWORDS:
125
+ if kw in ret_str:
126
+ raise AuthRequiredError(f"[{api}] 登录态失效:{ret_str}", raw=raw)
127
+ if "ILLEGAL_REQUEST" in ret_str or "sign" in ret_str.lower():
128
+ raise SignError(f"[{api}] 签名错误:{ret_str}", raw=raw)
129
+ if "NOT_FOUND" in ret_str or "不存在" in ret_str:
130
+ raise NotFoundError(f"[{api}] 未找到:{ret_str}", raw=raw)
131
+ raise GoofishError(f"[{api}] 调用失败:{ret_str}", raw=raw)
@@ -0,0 +1,82 @@
1
+ """统一输出渲染器。支持 json/yaml/table/md/csv。非 TTY 场景 table → json 降级。"""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+ import json
7
+ import sys
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+
16
+ class Format(StrEnum):
17
+ JSON = "json"
18
+ YAML = "yaml"
19
+ TABLE = "table"
20
+ MD = "md"
21
+ CSV = "csv"
22
+
23
+
24
+ def _as_rows(data: Any) -> tuple[list[str], list[dict[str, Any]]]:
25
+ if isinstance(data, dict):
26
+ return list(data.keys()), [data]
27
+ if isinstance(data, list) and data and isinstance(data[0], dict):
28
+ cols: list[str] = []
29
+ for item in data:
30
+ for k in item:
31
+ if k not in cols:
32
+ cols.append(k)
33
+ return cols, data
34
+ return [], []
35
+
36
+
37
+ def render(data: Any, fmt: Format = Format.JSON, columns: list[str] | None = None) -> None:
38
+ # 非 TTY 且用户没显式指定 table → 走 json(便于管道处理)
39
+ if fmt is Format.TABLE and not sys.stdout.isatty():
40
+ fmt = Format.JSON
41
+
42
+ if fmt is Format.JSON:
43
+ print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
44
+ return
45
+
46
+ if fmt is Format.YAML:
47
+ print(yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
48
+ return
49
+
50
+ cols, rows = _as_rows(data)
51
+ if columns:
52
+ cols = columns
53
+ if not rows:
54
+ # 标量或空:降级为 JSON
55
+ print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
56
+ return
57
+
58
+ if fmt is Format.TABLE:
59
+ console = Console()
60
+ table = Table(show_header=True, header_style="bold cyan")
61
+ for c in cols:
62
+ table.add_column(c)
63
+ for row in rows:
64
+ table.add_row(*[str(row.get(c, "")) for c in cols])
65
+ console.print(table)
66
+ return
67
+
68
+ if fmt is Format.MD:
69
+ print("| " + " | ".join(cols) + " |")
70
+ print("| " + " | ".join("---" for _ in cols) + " |")
71
+ for row in rows:
72
+ print("| " + " | ".join(str(row.get(c, "")) for c in cols) + " |")
73
+ return
74
+
75
+ if fmt is Format.CSV:
76
+ buf = io.StringIO()
77
+ writer = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore")
78
+ writer.writeheader()
79
+ for row in rows:
80
+ writer.writerow({c: row.get(c, "") for c in cols})
81
+ sys.stdout.write(buf.getvalue())
82
+ return
@@ -0,0 +1,78 @@
1
+ """命令注册中心。参照 opencli cli({...}) —— 单一 registry → CLI / MCP / Skill 共享。"""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import pkgutil
6
+ from collections.abc import Callable, Iterator
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from goofish_cli.core.strategy import Strategy
11
+
12
+
13
+ @dataclass
14
+ class Command:
15
+ namespace: str
16
+ name: str
17
+ description: str
18
+ func: Callable[..., Any]
19
+ strategy: Strategy = Strategy.COOKIE
20
+ columns: list[str] = field(default_factory=list)
21
+ write: bool = False
22
+
23
+ @property
24
+ def full_name(self) -> str:
25
+ return f"{self.namespace}.{self.name}"
26
+
27
+
28
+ _REGISTRY: dict[str, Command] = {}
29
+
30
+
31
+ def command(
32
+ *,
33
+ namespace: str,
34
+ name: str,
35
+ description: str,
36
+ strategy: Strategy = Strategy.COOKIE,
37
+ columns: list[str] | None = None,
38
+ write: bool = False,
39
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
40
+ """装饰器:将函数注册为命令。"""
41
+
42
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
43
+ cmd = Command(
44
+ namespace=namespace,
45
+ name=name,
46
+ description=description,
47
+ func=func,
48
+ strategy=strategy,
49
+ columns=columns or [],
50
+ write=write,
51
+ )
52
+ if cmd.full_name in _REGISTRY:
53
+ raise RuntimeError(f"重复注册命令:{cmd.full_name}")
54
+ _REGISTRY[cmd.full_name] = cmd
55
+ return func
56
+
57
+ return decorator
58
+
59
+
60
+ def registry() -> dict[str, Command]:
61
+ return dict(_REGISTRY)
62
+
63
+
64
+ def iter_commands() -> Iterator[Command]:
65
+ return iter(_REGISTRY.values())
66
+
67
+
68
+ def discover() -> None:
69
+ """自动扫描 goofish_cli.commands.* 所有模块,触发装饰器注册。"""
70
+ from goofish_cli import commands # noqa: WPS433
71
+
72
+ def _walk(pkg: Any) -> None:
73
+ for info in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
74
+ module = importlib.import_module(info.name)
75
+ if info.ispkg:
76
+ _walk(module)
77
+
78
+ _walk(commands)