`skill` 与 `skill_login` 不是一回事
翻一个 CLI 项目的源码目录时,最容易犯的错是「按文件名猜命令」。deeptutor_cli/ 里就摆着一组会骗人的文件名:skill.py 563 行、skill_login.py 134 行,两个文件挨着放,看上去像是「skill 命令组」和「skill login 子命令」各占一个文件。
实际不是。这两个文件不在同一层:一个是命令层,一个是流程层,后者一个 Typer 命令都没有注册。
以下行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名与函数名是稳的,照着搜即可。
一、先用一条 grep 把关系定死
deeptutor_cli/ 一共 20 个文件(19 个 .py 加 1 个 README.md),wc -l 合计 5421 行。全目录 grep -h "@app.command(" deeptutor_cli/*.py | wc -l 数出来是 43 处,其中 skill.py 占 8 处,是全目录里子命令最多的一个文件。
而 skill_login.py 的这个计数是 0。
这就是本文标题那句话的全部证据:skill_login.py 里没有任何命令定义,它不出现在命令树的任何一个位置上。你在终端里敲不出一条叫 skill_login 的命令,也不存在 deeptutor skill_login 这种写法。
skill.py 里的 8 个子命令依次是 search、install、login、logout、publish、update、list、remove(分别在 skill.py:75/108/171/228/242/365/523/543)。login 这条命令本身就在 skill.py 里,行号 171。它在运行时才去 from .skill_login import hub_origin_from_base, run_login(skill.py:186)。
顺带一个容易被忽略的点:这个命令组在顶层挂了两个名字。main.py 注册了 12 个 Typer 子应用对象,却挂出 13 个命令组名,多出来的那个就是 skill 的别名 skills(main.py:48-59,别名注释在 main.py:52)。所以同一件事有两种敲法,看别人的命令行片段时别以为是两个不同的东西。
二、skill_login.py 里到底装了什么
这个文件的对外面貌,__all__ 一行就说清楚了(skill_login.py:134):LoginResult、hub_origin_from_base、oauth_start_url、run_login。一个数据类加三个函数,没有别的。
LoginResult(skill_login.py:37)是__all__里唯一的数据类,承载登录结果,另外三个都是函数。hub_origin_from_base()(:44)负责从 hub 的 base URL 推导出 web origin,规则是剥掉/api/v1或/api后缀。oauth_start_url()(:56-59)负责拼那个授权起始地址,形状是{origin}/api/auth/oauth/{provider}?cli_port=&cli_state=。run_login()(:62)是唯一一个有副作用的,它把整个 loopback 流程跑完。
这种切法值得单独说一句。可核的是切分本身:hub_origin_from_base 与 oauth_start_url 不碰网络、不碰浏览器,只做字符串推导,副作用全部集中在 run_login 一处。带来的好处很直接——你要复现「我这个 hub 地址会被推导成什么 origin」,不需要真的走一遍登录,把 hub_origin_from_base 单独拿出来喂字符串就行,这是这个文件最实用的地方。
三、loopback 那一段:四个可核查的取值
run_login() 的流程是在本机起一个一次性的 HTTP 服务,等 hub 把令牌重定向回来。四个值得记住的取值:
| 项 | 取值 | 位置 |
|---|---|---|
| 监听地址 | ("127.0.0.1", 0),端口随机 | skill_login.py:111 |
| 接受的路径 | 只接受 /callback,其余不认 | skill_login.py:83-96 |
state 不匹配 | 返回 400,响应体 state mismatch | skill_login.py:83-96 |
| 默认超时 | timeout: float = 300.0 秒 | skill_login.py:69 |
端口写 0 是让操作系统分配一个临时端口,所以那条授权 URL 里的 cli_port 每次都不一样——这也解释了为什么 oauth_start_url() 必须把端口作为参数拼进去,而不能写成常量。
state 那一步是这段代码里唯一带校验语义的分支:回调带回来的 state 与本次生成的不一致,直接 400,不往下走。需要说清楚边界:这是我们从源码里读到的控制流写法,不是安全结论。这条链路上还有 hub 侧的行为、本机其它进程能否访问回环端口、令牌落盘之后归谁管等等,我们一样都没有核实,所以这里只陈述「代码里有这么一个校验分支,不匹配时返回 400」,不写「因此就安全了」。
卡内可以支撑的一点是:_resolve_token() 的第四级就是「skill login 的本地存储」,而 publish 又带一个 --token 选项——本地存储这一级正是 publish 取令牌链条的最后一环。既然令牌会以某种形式落在本机,它就是本机上的敏感数据。怎么保管、要不要限制文件权限,属于你自己环境的运维范畴,项目文档没给通用方案,我们也不替它给。
命令这一侧的入口是 skill login [provider](skill.py:171-180、:201-207):provider 取值 github | google,不填则在终端里问你选哪个;另有 --hub 和 --no-browser 两个选项,后者按选项名的语义是不自动打开浏览器、只把授权链接打印出来。
四、真正反直觉的一处:登录存下来的令牌排在最后
如果说文件名的错位只是读源码时的绊脚石,那这一处是会在实际使用里咬人的。
skill.py:26-37 的 _resolve_token() 定义了令牌的取值顺序:
--token → $DEEPTUTOR_HUB_TOKEN → $EDUHUB_TOKEN → skill login 的本地存储
skill login 辛辛苦苦走完浏览器授权拿到的那个令牌,在这条链上排最后一位。
按直觉,「我刚刚登录过」应该是最强的信号。这里恰好相反:只要环境里有 DEEPTUTOR_HUB_TOKEN 或 EDUHUB_TOKEN 中的任意一个非空,本地存储的那份就轮不到。这个顺序本身没有对错——显式参数压环境变量、环境变量压持久化存储,是命令行工具里很常见的排法,它服务的是 CI 场景。但它带来一个具体后果:你在某台机器上排查「为什么发布用的还是旧账号」时,光看有没有登录过是查不出来的,得先去看这两个环境变量。
对应的排查动作很短:
- 先确认这两个变量的状态。Linux / macOS 下
echo $DEEPTUTOR_HUB_TOKEN与echo $EDUHUB_TOKEN;Windows PowerShell 下$env:DEEPTUTOR_HUB_TOKEN、$env:EDUHUB_TOKEN。别把值贴到任何地方,判断非空即可。 - 有值的话,问题多半就在这里,跟你有没有登录无关;把变量清掉再试,或者反过来用
--token显式指定。 - 两个变量都是空的,那就落回本地存储这一级。此时如果仍然报未登录,说明不是优先级的问题——本地存储由
deeptutor.services.skill.credentials负责,那个模块我们只按调用签名记录过,没有读实现,这一步往下我们给不出可核查的结论。
第 3 步是这条排查路径的终点,也是它相对「报错大全」的价值所在:能明确说出「什么情况下不是这个原因」。
五、hub 相关命令各自设了什么门
既然 login 只是为了拿令牌,那令牌是给谁用的,也一并说清。skill.py 这 8 个子命令的门槛并不一致:
skill install <ref>(:108-169):ref 格式是<hub>:<slug>[@version],选项有--name、--force、--allow-unverified,安装完会打印一个 verdict(ok/suspicious/ 其他)。模块 docstring 把安装链路写成四步:hub security verdict → safe extraction → frontmatter adaptation → 写入.hub-lock.json记录来源(skill.py:1-16)。这里要如实说明一件事:verdict 会打出suspicious这一档,而选项里同时有一个--allow-unverified;这两件事放在一起看,装的是第三方 hub 上的包,落到你本机的工作区。用不用这个选项,请自己评估。- 多用户部署下的可见性:同一段 docstring 说明,这个 CLI 操作的是 owner(admin)工作区,装进来的技能落在 admin 的目录里,在授权(grant)分配之前对其他用户不可见。
skill publish <directory>(:242-263):选项--version、--slug、--track、--hub、--token、--yes/-y。是否走交互由(not yes) and sys.stdin.isatty()决定(:303);非交互模式下缺track直接报错退出 1(:326-334)。预检不过时会提示格式规范地址https://eduhub.deeptutor.info/skill-format.md(:295)。skill update(:365):这条强制交互,sys.stdin.isatty()为假时直接报「update 是交互式命令,请在终端中运行。」并退出 1(:395-397)。它有两种动作:rollback把latest指针指回旧版本、不新建版本;upgrade发布新版本(:435-441,docstring 在:375-379)。所以publish能进 CI 而update不能,这是写在代码里的硬区别。skill remove <name>(:543):docstring 是Remove a user-layer skill (builtin skills are read-only).,只能删 user 层,内置技能只读。
打标那一环单独放在 skill_prompts.py(208 行),提供 select_one / select_many / select_domains / collect_classification 四个交互函数,返回逗号拼接的 overrides,字段是 track、language、domains、stages、forms、audiences、tags(skill_prompts.py:55/78/102/148,返回体在 :197-205)。它的默认 locale 是 "zh",语言默认值也是 "zh"(:148、:163)。
六、README 里查不到这一组
按纪律,这里只陈述差异、标位置,不推断原因,也不据此评价项目。
deeptutor_cli/README.md 共 274 行,其中「资源管理命令」一节(README.md:168-229)列出的是 kb / session / notebook / memory / plugin / config / provider,没有 skill 或 skills 的章节。而代码侧 main.py:51-52 注册了这个组,skill.py 里有 8 个子命令,还额外挂了 skills 别名。
以我们实读的仓库状态为准。落到操作上,这个差异对你只有一个具体后果:这一组命令不能靠翻 README 学,只能读源码或者敲 --help。本文里出现的所有选项名与默认值都来自源码文本,我们没有安装或运行过这个项目,请以你本机 --help 的实际输出为准。
七、别和另一个 login 搞混
这个 CLI 里有两个带 login 的东西,作用完全不同:
deeptutor skill login:登录 skill hub,为了拿发布/更新用的令牌,就是本文讲的这条。deeptutor provider login <provider>:provider_cmd.py里的另一条线,用于模型侧的账号,取值只接受openai-codex与github-copilot,其余抛typer.BadParameter(provider_cmd.py:16-33)。
两者不共用令牌,也不在同一个命令组里。provider 这条线上还有几处值得单独说的细节,我们另有一篇专门讲,这里不展开。
八、你可以照着核的五步
- 在
deeptutor_cli/下跑grep -c "@app.command(" skill.py skill_login.py,确认前者 8、后者 0。 - 打开
skill.py:186,确认login命令是在这里 importskill_login的函数,而不是反过来。 - 打开
skill_login.py:134看__all__,确认对外只有那一个数据类加三个函数。 - 打开
skill_login.py:111与:69,把("127.0.0.1", 0)和timeout: float = 300.0两处对上。 - 打开
skill.py:26-37,把令牌四级优先级抄下来,再回自己的环境里核那两个变量是否为空。
最后交代边界:本文只读了 skill.py 的命令签名段、docstring 与几处分支,以及 skill_login.py 全文,没有逐行读完 563 行;deeptutor.services.skill 下的 hub.py、credentials.py、taxonomy.py 我们只按调用签名记录过,没有读实现,default_hub() 的实际返回值也没有核实(模块 docstring 说 hub 前缀默认 eduhub,我们没有在代码里验证到这一点)。凡是涉及「跑起来会怎样」的部分,本文一律不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。