tytcli 0.2.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.
- tytcli/__init__.py +7 -0
- tytcli/cli.py +174 -0
- tytcli/client.py +832 -0
- tytcli/commands/__init__.py +52 -0
- tytcli/commands/auth.py +147 -0
- tytcli/commands/config.py +61 -0
- tytcli/commands/env.py +103 -0
- tytcli/commands/help.py +105 -0
- tytcli/commands/roadshow.py +898 -0
- tytcli/commands/sentiment.py +200 -0
- tytcli/commands/shared.py +210 -0
- tytcli/commands/upgrade.py +95 -0
- tytcli/commands/wechat.py +253 -0
- tytcli/commands/whoami.py +76 -0
- tytcli/config.py +578 -0
- tytcli/errors.py +71 -0
- tytcli/output.py +82 -0
- tytcli/selfupdate.py +639 -0
- tytcli/skill/SKILL.md +122 -0
- tytcli/skill/references/output-contract.md +93 -0
- tytcli/skill/references/roadshow.md +176 -0
- tytcli/skill/references/selfupdate.md +62 -0
- tytcli/skill/references/sentiment.md +68 -0
- tytcli/skill/references/setup.md +105 -0
- tytcli/skill/references/wechat.md +87 -0
- tytcli/skill/references/whoami.md +42 -0
- tytcli/skill.py +118 -0
- tytcli-0.2.0.dist-info/METADATA +437 -0
- tytcli-0.2.0.dist-info/RECORD +32 -0
- tytcli-0.2.0.dist-info/WHEEL +4 -0
- tytcli-0.2.0.dist-info/entry_points.txt +2 -0
- tytcli-0.2.0.dist-info/licenses/LICENSE.txt +16 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""一个命令组一个模块。
|
|
2
|
+
|
|
3
|
+
``cli.py`` 刻意保持为入口:console script 是 ``tytcli.cli:entrypoint``,而自更新的
|
|
4
|
+
re-exec 跑的是 ``python -m tytcli.cli``。把 ``cli`` 变成包会**静默**破坏后者(那时
|
|
5
|
+
它需要一个 ``__main__.py``),所以拆分放在这里,``cli.py`` 仍然是一个普通模块。
|
|
6
|
+
|
|
7
|
+
命令模块**绝不能** import ``tytcli.cli``——那是循环依赖。它们改为 import
|
|
8
|
+
``tytcli.output`` 与 ``tytcli.commands.shared``。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from . import (
|
|
16
|
+
auth,
|
|
17
|
+
config,
|
|
18
|
+
env,
|
|
19
|
+
help,
|
|
20
|
+
roadshow,
|
|
21
|
+
sentiment,
|
|
22
|
+
upgrade,
|
|
23
|
+
wechat,
|
|
24
|
+
whoami,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
#: 全部顶层命令,由 ``cli.py`` 注册到根 group 上。
|
|
28
|
+
#: click 显示时会自己排序,所以这里的顺序只影响注册次序。
|
|
29
|
+
ALL_COMMANDS: tuple[click.Command, ...] = (
|
|
30
|
+
whoami.whoami,
|
|
31
|
+
auth.auth,
|
|
32
|
+
env.env,
|
|
33
|
+
config.config,
|
|
34
|
+
upgrade.upgrade,
|
|
35
|
+
wechat.wechat,
|
|
36
|
+
sentiment.sentiment,
|
|
37
|
+
roadshow.roadshow,
|
|
38
|
+
help.help_command,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"ALL_COMMANDS",
|
|
43
|
+
"auth",
|
|
44
|
+
"config",
|
|
45
|
+
"env",
|
|
46
|
+
"help",
|
|
47
|
+
"roadshow",
|
|
48
|
+
"sentiment",
|
|
49
|
+
"upgrade",
|
|
50
|
+
"wechat",
|
|
51
|
+
"whoami",
|
|
52
|
+
]
|
tytcli/commands/auth.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""``tytcli auth`` —— 写入并校验一个 TYT_Token。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..client import TytClient
|
|
12
|
+
from ..config import (
|
|
13
|
+
BASE_URLS,
|
|
14
|
+
ENV_VAR_ENV,
|
|
15
|
+
ENV_VAR_TOKEN,
|
|
16
|
+
KNOWN_ENVS,
|
|
17
|
+
ensure_config_file,
|
|
18
|
+
load_config,
|
|
19
|
+
mask_token,
|
|
20
|
+
set_env_token,
|
|
21
|
+
validate_token,
|
|
22
|
+
visible_envs,
|
|
23
|
+
)
|
|
24
|
+
from ..errors import ConfigError, TytCliError, UsageError
|
|
25
|
+
from ..output import emit_ok
|
|
26
|
+
from .shared import raise_for_envelope, require_config
|
|
27
|
+
from .whoami import extract_identity
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.command()
|
|
31
|
+
@click.option(
|
|
32
|
+
"--token",
|
|
33
|
+
"token_option",
|
|
34
|
+
required=True,
|
|
35
|
+
metavar="TOKEN",
|
|
36
|
+
help="要写入的 TYT_Token;传 '-' 从 stdin 读(不进 shell 历史,推荐)。",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--env",
|
|
40
|
+
"env_name",
|
|
41
|
+
default=None,
|
|
42
|
+
metavar="ENV",
|
|
43
|
+
# **刻意不用 click.Choice**:那会把每一个合法环境名打进 --help 和它自己的
|
|
44
|
+
# 报错信息里,等于对外宣传那个开发环境。改为在命令体里手工对着 KNOWN_ENVS 校验。
|
|
45
|
+
help="写入哪个环境(默认:当前生效环境)。Token 按环境隔离。",
|
|
46
|
+
)
|
|
47
|
+
@click.option("--verify", is_flag=True, help="写入后立即调用 whoami 验证 Token 是否可用。")
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def auth(ctx: click.Context, token_option: str, env_name: str | None, verify: bool) -> None:
|
|
50
|
+
"""把 TYT_Token 写入配置文件(0600)。
|
|
51
|
+
|
|
52
|
+
\b
|
|
53
|
+
安全提示:--token 的值会留在 shell 历史和 `ps` 进程列表里。
|
|
54
|
+
交互式使用请改用管道:
|
|
55
|
+
tytcli auth --token - # 从 stdin 读
|
|
56
|
+
或先用 shell 的隐藏输入:
|
|
57
|
+
read -rs TOK && tytcli auth --token "$TOK"
|
|
58
|
+
"""
|
|
59
|
+
pretty: bool = ctx.obj["pretty"]
|
|
60
|
+
config = require_config(allow_missing_token=True)
|
|
61
|
+
|
|
62
|
+
target_env = (env_name or config.active_env).strip().lower()
|
|
63
|
+
if target_env not in KNOWN_ENVS:
|
|
64
|
+
visible = visible_envs(config.active_env)
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
f"未知环境 {target_env!r}。可用环境:{', '.join(visible)}",
|
|
67
|
+
code="CONFIG_UNKNOWN_ENV",
|
|
68
|
+
details={"env": target_env, "known_envs": list(visible)},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
token = validate_token(_read_token(token_option))
|
|
72
|
+
|
|
73
|
+
path = ensure_config_file(config)
|
|
74
|
+
set_env_token(path, target_env, token)
|
|
75
|
+
|
|
76
|
+
# 往返校验:把 TYTCLI_TOKEN 从环境里剔掉后再回读文件,这样我们证明的是
|
|
77
|
+
# **真正落盘的内容**,而不是把自己刚传进来的值回显一遍。
|
|
78
|
+
readback_env = {k: v for k, v in os.environ.items() if k != ENV_VAR_TOKEN}
|
|
79
|
+
readback_env[ENV_VAR_ENV] = target_env
|
|
80
|
+
fresh = load_config(readback_env)
|
|
81
|
+
if fresh.token != token or fresh.token_source != "config":
|
|
82
|
+
raise ConfigError(
|
|
83
|
+
f"Token 已写入 {path} 但回读校验失败,请检查文件内容。",
|
|
84
|
+
code="CONFIG_WRITE_VERIFY_FAILED",
|
|
85
|
+
details={
|
|
86
|
+
"env": target_env,
|
|
87
|
+
"config_path": str(path),
|
|
88
|
+
"token_source": fresh.token_source,
|
|
89
|
+
"token_hint": fresh.masked_token(),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# TYTCLI_TOKEN 的优先级高于配置文件,所以一个忘了 unset 的 export 会**静默**
|
|
94
|
+
# 遮蔽我们刚写进去的值。这件事报在 JSON 文档里而不是 stderr 上,这样自动化
|
|
95
|
+
# 可以按字段分支,而不必去刮一句给人看的警告。
|
|
96
|
+
shadowed = os.environ.get(ENV_VAR_TOKEN)
|
|
97
|
+
|
|
98
|
+
result: dict[str, Any] = {
|
|
99
|
+
"env": target_env,
|
|
100
|
+
"base_url": BASE_URLS[target_env],
|
|
101
|
+
"config_path": str(path),
|
|
102
|
+
"token_hint": mask_token(token),
|
|
103
|
+
"token_source": "config",
|
|
104
|
+
"verified": None,
|
|
105
|
+
"token_shadowed": shadowed is not None,
|
|
106
|
+
}
|
|
107
|
+
if shadowed is not None:
|
|
108
|
+
result["shadow_warning"] = (
|
|
109
|
+
f"{ENV_VAR_TOKEN} 已设置,它会覆盖刚写入配置文件的 Token(当前生效:"
|
|
110
|
+
f"{mask_token(shadowed.strip()) or '<empty>'})。"
|
|
111
|
+
f"unset {ENV_VAR_TOKEN} 后才会使用文件里的值。"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if verify:
|
|
115
|
+
client = TytClient(BASE_URLS[target_env], token, timeout=config.timeout_seconds)
|
|
116
|
+
try:
|
|
117
|
+
envelope = client.whoami()
|
|
118
|
+
raise_for_envelope(
|
|
119
|
+
envelope, env_name=target_env, base_url=BASE_URLS[target_env]
|
|
120
|
+
)
|
|
121
|
+
except TytCliError as exc:
|
|
122
|
+
# Token **确实**已经在磁盘上了——包括失败来自传输层而不是 API 的情形。
|
|
123
|
+
# 把这一点说得毫无歧义,agent 才会去修凭证,而不是重试写入。
|
|
124
|
+
exc.details = {**exc.details, "written": True, "config_path": str(path)}
|
|
125
|
+
raise
|
|
126
|
+
result["verified"] = True
|
|
127
|
+
result["identity"] = extract_identity(envelope.payload)
|
|
128
|
+
|
|
129
|
+
emit_ok(result, pretty=pretty)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_token(value: str) -> str:
|
|
133
|
+
"""解析 --token 的值,其中 '-' 表示"从 stdin 读"。"""""
|
|
134
|
+
if value != "-":
|
|
135
|
+
return value
|
|
136
|
+
if sys.stdin.isatty():
|
|
137
|
+
# tytcli 绝不阻塞在交互式输入上:一个传了 '-' 却没有接管道的 agent 会
|
|
138
|
+
# 永远挂住。所以直接快速失败。
|
|
139
|
+
raise UsageError(
|
|
140
|
+
"--token - 需要从 stdin 管道输入,但当前 stdin 是终端。"
|
|
141
|
+
"请改用:echo \"$TOK\" | tytcli auth --token - ,或直接传 --token <TOKEN>。",
|
|
142
|
+
code="USAGE_STDIN_REQUIRED",
|
|
143
|
+
)
|
|
144
|
+
data = sys.stdin.read()
|
|
145
|
+
if not data.strip():
|
|
146
|
+
raise UsageError("从 stdin 未读到任何 Token。", code="USAGE_STDIN_EMPTY")
|
|
147
|
+
return data.strip()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``tytcli config`` —— 定位与初始化配置文件。
|
|
2
|
+
|
|
3
|
+
不要与 ``tytcli.config``(配置**解析器**)混淆。本模块是命令组;下面的 import 一律
|
|
4
|
+
用显式相对路径(``from ..config import ...``),这样两者不可能被误当成同一个东西。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..config import default_config_path, write_default_config
|
|
12
|
+
from ..output import emit_ok
|
|
13
|
+
from .shared import effective_config_payload, require_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def config() -> None:
|
|
18
|
+
"""配置文件相关操作。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@config.command("path")
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def config_path(ctx: click.Context) -> None:
|
|
24
|
+
"""打印配置文件路径以及是否存在。"""
|
|
25
|
+
cfg = require_config(allow_missing_token=True)
|
|
26
|
+
emit_ok(
|
|
27
|
+
{
|
|
28
|
+
"path": str(cfg.path),
|
|
29
|
+
"source": cfg.path_source,
|
|
30
|
+
"exists": cfg.path_exists,
|
|
31
|
+
"default_path": str(default_config_path()),
|
|
32
|
+
},
|
|
33
|
+
pretty=ctx.obj["pretty"],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@config.command("show")
|
|
38
|
+
@click.pass_context
|
|
39
|
+
def config_show(ctx: click.Context) -> None:
|
|
40
|
+
"""显示当前生效配置:API 地址、Token 是否已配置及其来源(Token 脱敏)。"""
|
|
41
|
+
emit_ok(effective_config_payload(require_config(allow_missing_token=True)),
|
|
42
|
+
pretty=ctx.obj["pretty"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@config.command("init")
|
|
46
|
+
@click.option("--force", is_flag=True, help="覆盖已存在的配置文件。")
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def config_init(ctx: click.Context, force: bool) -> None:
|
|
49
|
+
"""在默认位置创建一个 0600 权限的配置模板。"""
|
|
50
|
+
path = default_config_path()
|
|
51
|
+
created = not path.exists()
|
|
52
|
+
write_default_config(path, force=force)
|
|
53
|
+
emit_ok(
|
|
54
|
+
{
|
|
55
|
+
"path": str(path),
|
|
56
|
+
"created": created,
|
|
57
|
+
"overwritten": force and not created,
|
|
58
|
+
"next_step": "运行 tytcli auth --token <TOKEN> 写入凭证",
|
|
59
|
+
},
|
|
60
|
+
pretty=ctx.obj["pretty"],
|
|
61
|
+
)
|
tytcli/commands/env.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``tytcli env`` —— 环境的查看与切换。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..config import (
|
|
10
|
+
BASE_URLS,
|
|
11
|
+
KNOWN_ENVS,
|
|
12
|
+
ensure_config_file,
|
|
13
|
+
env_section,
|
|
14
|
+
load_config,
|
|
15
|
+
set_active_env,
|
|
16
|
+
visible_envs,
|
|
17
|
+
)
|
|
18
|
+
from ..errors import ConfigError, UsageError
|
|
19
|
+
from ..output import emit_ok
|
|
20
|
+
from .shared import effective_config_payload, require_config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#: hidden=True — 环境切换是**开发人员**的配置面,不对外宣传(产品决定)。
|
|
24
|
+
#: 命令本身完整可用,只是不出现在 `tytcli --help` 与随包文档里;说明留在
|
|
25
|
+
#: AGENTS.md。别把它删掉,也别把它改成可见。
|
|
26
|
+
@click.group(hidden=True)
|
|
27
|
+
def env() -> None:
|
|
28
|
+
"""查看与切换目标环境(开发用,不在 --help 中列出)。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@env.command("list")
|
|
32
|
+
@click.pass_context
|
|
33
|
+
def env_list(ctx: click.Context) -> None:
|
|
34
|
+
"""列出两个环境及其固定的 API base URL。"""
|
|
35
|
+
config = require_config(allow_missing_token=True)
|
|
36
|
+
rows: list[dict[str, Any]] = []
|
|
37
|
+
# visible_envs() 会隐藏开发环境,除非它正是当前生效的那个。
|
|
38
|
+
for name in visible_envs(config.active_env):
|
|
39
|
+
section = env_section(config.raw, name)
|
|
40
|
+
rows.append(
|
|
41
|
+
{
|
|
42
|
+
"name": name,
|
|
43
|
+
"base_url": BASE_URLS[name],
|
|
44
|
+
"active": name == config.active_env,
|
|
45
|
+
"token_configured": bool(str(section.get("tyt_token") or "").strip()),
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
emit_ok(
|
|
49
|
+
rows,
|
|
50
|
+
pretty=ctx.obj["pretty"],
|
|
51
|
+
active_env=config.active_env,
|
|
52
|
+
env_source=config.env_source,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@env.command("show")
|
|
57
|
+
@click.pass_context
|
|
58
|
+
def env_show(ctx: click.Context) -> None:
|
|
59
|
+
"""显示当前生效的环境、base URL 与配置来源(Token 已脱敏)。"""
|
|
60
|
+
config = require_config(allow_missing_token=True)
|
|
61
|
+
# 与公开的 `config show` 是同一份 payload;见 shared.effective_config_payload。
|
|
62
|
+
emit_ok(effective_config_payload(config), pretty=ctx.obj["pretty"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@env.command("use")
|
|
66
|
+
@click.argument("name")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def env_use(ctx: click.Context, name: str) -> None:
|
|
69
|
+
"""把 NAME 设为默认环境(写入配置文件的 active_env)。"""
|
|
70
|
+
target = name.strip()
|
|
71
|
+
if not target:
|
|
72
|
+
raise UsageError("环境名不能为空。")
|
|
73
|
+
|
|
74
|
+
config = require_config(allow_missing_token=True)
|
|
75
|
+
# 校验接受**每一个**真实环境(所以开发者的 `env use test` 仍然可用);
|
|
76
|
+
# 但报错文案只列出面向用户的那几个。
|
|
77
|
+
if target not in KNOWN_ENVS:
|
|
78
|
+
visible = visible_envs(config.active_env)
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
f"未知环境 {target!r}。可用环境:{', '.join(visible)}",
|
|
81
|
+
code="CONFIG_UNKNOWN_ENV",
|
|
82
|
+
details={"env": target, "known_envs": list(visible)},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
path = ensure_config_file(config)
|
|
86
|
+
set_active_env(path, target)
|
|
87
|
+
|
|
88
|
+
# 重新读一遍,让汇报的状态反映磁盘上现在真实的内容。
|
|
89
|
+
fresh = load_config()
|
|
90
|
+
payload: dict[str, Any] = {
|
|
91
|
+
"active_env": fresh.active_env,
|
|
92
|
+
"base_url": fresh.base_url,
|
|
93
|
+
"config_path": str(path),
|
|
94
|
+
"token_configured": fresh.token is not None,
|
|
95
|
+
}
|
|
96
|
+
if fresh.token is None:
|
|
97
|
+
# 建议要放进 JSON 文档,而不是 stderr:一个命令只输出恰好一个机器可读的
|
|
98
|
+
# 结果,并保持 stderr 静默。
|
|
99
|
+
payload["hint"] = (
|
|
100
|
+
f"环境 '{target}' 尚未配置 TYT_Token,whoami 会失败。"
|
|
101
|
+
f"运行:tytcli auth --env {target} --token <TOKEN>"
|
|
102
|
+
)
|
|
103
|
+
emit_ok(payload, pretty=ctx.obj["pretty"])
|
tytcli/commands/help.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""``help`` 命令:把随包发布的 skill 文档与命令关联起来。
|
|
2
|
+
|
|
3
|
+
``tytcli help`` 打印命令摘要加路由文档(``SKILL.md``);``tytcli help <command>``
|
|
4
|
+
打印该命令的用法加记录它的那份参考文档。命令 -> 文档的映射只由这一处拥有
|
|
5
|
+
(见 :mod:`tytcli.skill`)。
|
|
6
|
+
|
|
7
|
+
刻意做成顶层子命令,既不是 ``--help`` 的扩展,也不是 ``<command> help`` 后缀:
|
|
8
|
+
|
|
9
|
+
* ``--help`` 必须保持"快而可扫"的用法摘要;把上百行文档塞在选项下面会损害最常见
|
|
10
|
+
的用法,而且文档一改,每个 help 面都会跟着变。
|
|
11
|
+
* ``<command> help`` 与位置参数冲突。已实测:``tytcli env use help`` 会报
|
|
12
|
+
``CONFIG_UNKNOWN_ENV``,因为 ``help`` 被当成环境名吃掉了。将来任何带位置参数的
|
|
13
|
+
命令都会以同样的方式**静默**坏掉。
|
|
14
|
+
|
|
15
|
+
每个命令的 ``--help`` 仍然带一行指针(在 ``cli.py`` 里追加),这样文档是可发现的,
|
|
16
|
+
又不必被内嵌进去。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from .. import skill
|
|
24
|
+
|
|
25
|
+
#: 命令用法与其长篇文档之间的视觉分隔线。
|
|
26
|
+
_SEPARATOR = "\n────────────────────────────────────────\n\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _help_text(command: click.Command, name: str) -> str:
|
|
30
|
+
"""按 ``--help`` 的原样渲染一个命令的帮助文本。"""
|
|
31
|
+
ctx = click.Context(command, info_name=f"tytcli {name}")
|
|
32
|
+
formatter = ctx.make_formatter()
|
|
33
|
+
command.format_help(ctx, formatter)
|
|
34
|
+
return formatter.getvalue()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _root_summary() -> str:
|
|
38
|
+
"""``tytcli help`` 开头那份命令清单。
|
|
39
|
+
|
|
40
|
+
从命令注册表构造,而不是从根 group 对象:根 group 住在 ``cli.py`` 里,而
|
|
41
|
+
``cli.py`` 又 import 本模块,所以在这里引用它就是循环依赖。
|
|
42
|
+
"""
|
|
43
|
+
from . import ALL_COMMANDS
|
|
44
|
+
|
|
45
|
+
lines = ["命令:"]
|
|
46
|
+
for command in ALL_COMMANDS:
|
|
47
|
+
# hidden=True 是 click 自己的标志位;而这份摘要是手工拼的,所以必须自己
|
|
48
|
+
# 尊重它。忘掉这一点,正是一个标着"不在 --help 中列出"的命令出现在这里
|
|
49
|
+
# 的原因。
|
|
50
|
+
if command.hidden:
|
|
51
|
+
continue
|
|
52
|
+
first_line = (command.help or command.short_help or "").strip().splitlines()
|
|
53
|
+
summary = first_line[0] if first_line else ""
|
|
54
|
+
lines.append(f" {command.name:<10} {summary}")
|
|
55
|
+
lines.append("")
|
|
56
|
+
lines.append(skill.pointer_line(""))
|
|
57
|
+
return "\n".join(lines) + "\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _command_by_name() -> dict[str, click.Command]:
|
|
61
|
+
"""延迟解析命令名,避免在加载期就构成循环 import。"""
|
|
62
|
+
from . import ALL_COMMANDS
|
|
63
|
+
|
|
64
|
+
return {command.name: command for command in ALL_COMMANDS if command.name}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.command("help")
|
|
68
|
+
@click.argument("topic", required=False)
|
|
69
|
+
def help_command(topic: str | None) -> None:
|
|
70
|
+
"""打印命令用法与随包发布的 skill 文档。
|
|
71
|
+
|
|
72
|
+
\b
|
|
73
|
+
tytcli help 命令摘要 + SKILL.md(路由文档)
|
|
74
|
+
tytcli help <command> 该命令用法 + 对应的子模块文档
|
|
75
|
+
tytcli help output 输出契约 / 错误码文档
|
|
76
|
+
|
|
77
|
+
文档随 wheel 一起发布(tytcli/skill/),与仓库 skills/tytcli/ 同源。
|
|
78
|
+
"""
|
|
79
|
+
if topic is None:
|
|
80
|
+
body = skill.read_skill_doc(skill.SKILL_FILENAME)
|
|
81
|
+
if body is None:
|
|
82
|
+
raise click.UsageError("随包文档缺失:SKILL.md")
|
|
83
|
+
click.echo(_root_summary() + _SEPARATOR + skill.strip_frontmatter(body))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# 一个主题只要能解析就行:它是已注册命令,**或**它拥有一份参考文档。这两个
|
|
87
|
+
# 集合并不相同:`output` 有文档却没有命令,而 `env` 这类隐藏命令有用法却刻意
|
|
88
|
+
# 不带随包文档。接受两者的并集,才能让每一个 `--help` 指针都诚实——指向
|
|
89
|
+
# `tytcli help X` 的指针绝不能走进死胡同。
|
|
90
|
+
command = _command_by_name().get(topic)
|
|
91
|
+
resolved = skill.doc_for_topic(topic)
|
|
92
|
+
if command is None and resolved is None:
|
|
93
|
+
raise click.UsageError(
|
|
94
|
+
f"未知主题 '{topic}'。可用:{', '.join(skill.available_topics())}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
parts: list[str] = []
|
|
98
|
+
if command is not None:
|
|
99
|
+
parts.append(_help_text(command, topic))
|
|
100
|
+
if resolved is not None:
|
|
101
|
+
parts.append(resolved[1])
|
|
102
|
+
click.echo(_SEPARATOR.join(parts))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["help_command"]
|