`skill` 与 `skill_login` 不是一回事

2026-08-10

翻一个 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 个子命令依次是 searchinstallloginlogoutpublishupdatelistremove(分别在 skill.py:75/108/171/228/242/365/523/543)。login 这条命令本身就在 skill.py,行号 171。它在运行时才去 from .skill_login import hub_origin_from_base, run_loginskill.py:186)。

顺带一个容易被忽略的点:这个命令组在顶层挂了两个名字。main.py 注册了 12 个 Typer 子应用对象,却挂出 13 个命令组名,多出来的那个就是 skill 的别名 skillsmain.py:48-59,别名注释在 main.py:52)。所以同一件事有两种敲法,看别人的命令行片段时别以为是两个不同的东西。

二、skill_login.py 里到底装了什么

这个文件的对外面貌,__all__ 一行就说清楚了(skill_login.py:134):LoginResulthub_origin_from_baseoauth_start_urlrun_login。一个数据类加三个函数,没有别的。

  • LoginResultskill_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_baseoauth_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 mismatchskill_login.py:83-96
默认超时timeout: float = 300.0skill_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_TOKENEDUHUB_TOKEN 中的任意一个非空,本地存储的那份就轮不到。这个顺序本身没有对错——显式参数压环境变量、环境变量压持久化存储,是命令行工具里很常见的排法,它服务的是 CI 场景。但它带来一个具体后果:你在某台机器上排查「为什么发布用的还是旧账号」时,光看有没有登录过是查不出来的,得先去看这两个环境变量。

对应的排查动作很短:

  1. 先确认这两个变量的状态。Linux / macOS 下 echo $DEEPTUTOR_HUB_TOKENecho $EDUHUB_TOKEN;Windows PowerShell 下 $env:DEEPTUTOR_HUB_TOKEN$env:EDUHUB_TOKEN别把值贴到任何地方,判断非空即可。
  2. 有值的话,问题多半就在这里,跟你有没有登录无关;把变量清掉再试,或者反过来用 --token 显式指定。
  3. 两个变量都是空的,那就落回本地存储这一级。此时如果仍然报未登录,说明不是优先级的问题——本地存储由 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)。它有两种动作:rollbacklatest 指针指回旧版本、不新建版本;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,没有 skillskills 的章节。而代码侧 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-codexgithub-copilot,其余抛 typer.BadParameterprovider_cmd.py:16-33)。

两者不共用令牌,也不在同一个命令组里。provider 这条线上还有几处值得单独说的细节,我们另有一篇专门讲,这里不展开。

八、你可以照着核的五步

  1. deeptutor_cli/ 下跑 grep -c "@app.command(" skill.py skill_login.py,确认前者 8、后者 0。
  2. 打开 skill.py:186,确认 login 命令是在这里 import skill_login 的函数,而不是反过来。
  3. 打开 skill_login.py:134__all__,确认对外只有那一个数据类加三个函数。
  4. 打开 skill_login.py:111:69,把 ("127.0.0.1", 0)timeout: float = 300.0 两处对上。
  5. 打开 skill.py:26-37,把令牌四级优先级抄下来,再回自己的环境里核那两个变量是否为空。

最后交代边界:本文只读了 skill.py 的命令签名段、docstring 与几处分支,以及 skill_login.py 全文,没有逐行读完 563 行;deeptutor.services.skill 下的 hub.pycredentials.pytaxonomy.py 我们只按调用签名记录过,没有读实现,default_hub() 的实际返回值也没有核实(模块 docstring 说 hub 前缀默认 eduhub,我们没有在代码里验证到这一点)。凡是涉及「跑起来会怎样」的部分,本文一律不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证。


本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.mdpyproject.tomldeeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。 本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API, 因此不涉及生成质量、响应速度与教学效果的任何描述。 参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。