Skip to content

feat: 增加技能包项目作用域与全局继承 - #517

Open
lj64212585 wants to merge 1 commit into
xintaofei:mainfrom
lj64212585:feat/project-skill-scope
Open

feat: 增加技能包项目作用域与全局继承#517
lj64212585 wants to merge 1 commit into
xintaofei:mainfrom
lj64212585:feat/project-skill-scope

Conversation

@lj64212585

Copy link
Copy Markdown

No description provided.

@xintaofei

Copy link
Copy Markdown
Owner

先说结论:这个功能该做,方案方向也对,但目前还不建议直接合。

技能包(experts / science / office / custom)以前只能链到全局技能目录,而隔壁 skills-settings.tsx 的 Skills 页早就有 global/folder 作用域了 —— 这个不对称本身就是个真实的产品缺口。而且这个 PR 顺手修掉了一个真 bug:以前只链在 <project>/.claude/skills 里的技能,在输入框里会被判定成"未启用"而锁掉。复用既有的 AgentSkillScope / scoped_skill_dirs() 而不是另起一套,也是正确的选择。

我在本地把 PR 拉下来跑了一遍,工程质量是过关的

  • cargo check --features test-utils --all-targets
  • cargo clippy --all-targets --features test-utils -- -D warnings
  • cargo clippy --no-default-features --bin codeg-server --lib -- -D warnings
  • npx tsc --noEmit ✅ / pnpm eslint . ✅(CI 那条)
  • pnpm test → 315 files / 4255 tests 全绿 ✅
  • cargo test --features test-utils → 全绿(主 suite 2689 passed / 0 failed)✅
  • 10 个语种的 SkillMatrix.globalInheritedscopeUnavailableSkillPacksSettings.scope.* 都补齐了 ✅
  • useComposerShortcuts / QuickActions 的调用点也都跟上了,没有遗漏 ✅

下面是我认为需要在合并前处理的部分。先说明一点:其中第 1 条和第 5 条是主分支上就存在的老问题,不是这个 PR 写出来的,但这个 PR 把写入目标从"用户自己的家目录"改成了"用户的代码仓库",把它们的爆炸半径放大了一个量级,所以我把它们放在这里一起讨论。


一、【Critical・老问题被放大】Windows 上链接失败会回退成整目录 copy,直接覆盖仓库里的同名内容

create_link_rawsrc-tauri/src/commands/experts.rs:402,本 PR 未改动)在 Windows 上是:

match junction::create(src, dst) {
    Ok(_) => Ok(false),
    Err(junction_err) => {
        copy_dir_recursive(src, dst)?;   // ← 任何错误都会走到这里
        Ok(true)
    }
}

而 junction crate 的 create() 在拿到 full path 之后第一件事就是 fs::create_dir(junction)?(junction-1.4.2 internals.rs:37)—— 目标目录已存在时它返回 AlreadyExists。于是链条变成:

  1. <repo>/skills/<skill-id> 已经是用户自己的真实目录 → junction::createAlreadyExists
  2. catch-all 分支吞掉这个错误,copy_dir_recursivecreate_dir_all(dst) 直接成功,然后 fs::copy 把中央库的文件合并进用户目录、同名文件原地覆盖
  3. 因为返回的是 Ok(true)link_one_locked 里那段 Err(err) if err.kind() == AlreadyExists => ... BlockedByRealDirectory => NameCollision 的碰撞保护(experts.rs:838-851在 Windows 上永远走不到,是死代码

四个 pack 共用这个 helper。在全局作用域下这个坑碰上的概率还低(谁的 ~/.claude/skills/ 下正好有同名真实目录),但项目作用域是往用户仓库里写,同名目录的概率高得多 —— 尤其 OpenClaw 的 project_rel_dirs 是裸的 vec!["skills"]acp.rs:7580),任何有 skills/ 目录的仓库都在射程内,而且覆盖的是版本管理下的内容。

我的建议是:link_one_locked 里加一道 preflight,动手前先 classify_link,是 BlockedByRealDirectory 就直接返回 NameCollision,别把判断留给 create_link_raw 的返回值;同时把 copy fallback 限制成"仅当目标不存在"。这个不修的话,项目作用域这个入口我觉得不宜开。

二、【Blocking】拆链路没跟着项目作用域走,会在用户仓库里留下无法从 UI 清理的孤儿链接

两处:

  • custom_skills.rs:757delete_one_locked() 对每个 agent 只做 unlink_one_locked(..., AgentSkillScope::Global, None),然后删掉中央目录
  • office_tools.rs:915officecli_uninstall() 只扫 scoped_skill_dirs(agent, Global, None),然后 fs::remove_dir_all(&central):953

所以删除自定义技能 / 卸载 OfficeCLI 之后,所有 <project>/.claude/skills/<id><project>/.codex/skills/<id> … 全都留在原地变成悬空链(Windows 上则是残留的 junction 或整份 copy)。更麻烦的是删除后该技能从 custom_list() 里消失,矩阵不再渲染这一行,技能包这个页面里就再也没有入口能清掉它了

而且隔壁 Skills 页也救不了:它的枚举要求 path.is_dir() 且目录里有 SKILL.mdacp.rs:8020-8029),而悬空软链上 is_dir() 会跟随链接、返回 false,于是这些残留在 Skills 页里根本不会被列出来,自然也删不掉。唯一能在 UI 里看见并清掉的只有 Windows 那种"真的 copy 了一份"的残留(它是个带 SKILL.md 的真实目录)。所以在 Unix 上,用户只能自己去文件系统里 rm

顺带一提,"重建一个同名 id 再删一次"这条自救路线也是不管用的:删除路径依然只走 Global,得先重建、再在项目作用域里逐个显式取消勾选、然后才能删。

代码里目前没有项目链接的台账、GC、启动自检或迁移能兜住这个(我找过了)。最小可接受的做法:拿 loadFolderHistory() 用的同一张 folder 表遍历做 best-effort 清理,失败降级成警告;更稳的做法是启用项目链接时落一条记录,拆的时候按记录走。

三、【Important】scopeUnavailable 这个文案会误报 —— Science 页现在每次打开都有 3 列是错的

skill-agent-matrix.tsx:1080 附近:const unavailable = !status && !inherited,为真就渲染成 Lock + "This agent does not support project-scoped skills"。但 !status 的来源不止"该 agent 不支持项目作用域"这一种:

(a) 正常路径就会误报。 列是前端 acpListAgents()skills_capable 过出来的(science-settings.tsx:75),行是后端 science_list_all_install_statusessupported_agents() 生成的,而 Science 的 supported_agents()science.rs:614)刻意不含 Grok / Cursor / DeepSeek。两边集合对不上 → 这 3 列恒定拿不到 status → 在全局作用域下、每次正常加载,都会显示成"此 agent 不支持项目级技能"。main 上这 3 列是中性的

(b) 加载失败也会误报。 挂载 effect(:240)是 Promise.all([...]).catch(toast).finally(() => setStatusLoading(false)),失败时 statuses 保持空 Map 但 loading 照样清掉,于是整张表全变 Lock + 同一句文案。我写了个临时 vitest 验过(跑完已删):loadAllStatuses reject 且不传 loadInheritedStatuses 时,格子的 aria-label 是 Brainstorming, Claude Code: This agent does not support project-scoped skills。remote / server 模式下一次网络抖动就够了。

(c) 还有个自相矛盾的地方。 computeLinkDelta:151)里 isEnabled(undefined) === falseisBlockedForEnable(undefined) === false,所以缺 status 的格子在批量启用时照样会被下发 op(单击进不去,但行/列批量和批量条会绕过格子的可交互判断)。结果就是:UI 说"不支持",批量却真的把链接建出来了,而且因为快照里永远没有这一行,后续批量禁用也删不掉它。

建议:unavailable 只在 project 作用域成立(加个 scope prop),加载失败要有独立的错误态而不是退化成"不支持",并且把行列集合的真值统一到一处(要么后端 supported_agents() 补齐,要么前端按后端快照裁列)。

四、【Important】Work Task 在 worktree 里跑,项目作用域的技能在那儿是不存在的

这条是 codeg 特有的。触发条件是任务还没有 worktree 的时候

task-editor-dialog.tsx:201folderPathfolders.find(f => f.id === folderId)?.path,即所选项目路径,然后在 :390 传给 TaskMessageComposer,本 PR 又把它当 workspacePath 传进了 useComposerShortcuts。但任务真正执行时 engine.rs:955ensure_worktree()(我看了 :1225,这条路径没有 inline/不建 worktree 的分支),然后 engine.rs:1028Some(wt.path.clone()) 起 agent —— cwd 是 worktree,不是主目录。而 <project>/.claude/skills/<id> 这类链接是 untracked 的,git worktree add 不会带过去。

结果就是:编辑器里说这个技能可用、允许插进去,任务跑起来 agent 那边根本发现不了它。

task-detail-sheet.tsx:860 / task-restart-dialog.tsx:133 走的是 followUpComposerTarget()folderPath = worktreeFolder?.path ?? projectFolder?.pathtask-follow-up.ts:150)—— worktree 已经建出来之后这两处是对的,会如实显示成不可用;但 task.worktree_folder_id 还是 null 时(比如任务在建 worktree 之前就失败/取消了,重启仍然会新建 worktree)它同样回退到项目路径,于是重启这条路上也是同一个错误展示。

要么建 worktree 时把项目级技能目录同步过去,要么按"将来实际执行的路径"判定,至少也该在 UI 上说明这个限制。

五、【Important・老问题被放大】Broken 链接不校验指向就删

experts.rs:926(science、custom 同):

if matches!(state, ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken) {
    remove_skill_entry(&candidate)?;   // Broken 不看指向哪儿
}

office 是对的,它多一层 read_link_target(&candidate).starts_with(&central)office_tools.rs:1191)。所以 experts/science/custom 会把用户自己的、指向别处的悬空链接一并删掉。本 PR 顺手把这段的注释改成了 "Remove only links managed by codeg" —— 但 Broken 这一支实际上并不满足这句话。全局目录下影响有限,指到用户仓库里就不太一样了。顺手对齐成 office 的写法就行。

六、【Important】"继承"的展示会掩盖项目侧的真实状态

computeLinkDelta 里对 inherited 目标一律 continueskill-agent-matrix.tsx:166),MatrixCell:1080 里 inherited 也压过实际 status。于是:

  • 一个技能同时有 global + project 链接时,项目作用域下这格显示成"全局继承"且不可操作,用户既看不见也删不掉那条 project 链接;之后把 global 关掉,这个项目里技能仍然生效,很反直觉
  • 更实际的是 DeepSeek:它的搜索顺序是 <project>/.dsh/skills<project>/.agents/skills$DSH_HOME/skillsacp.rs:7657 的注释写得很清楚,项目根优先于全局根)。所以项目里如果有个同名的外部技能,它会遮蔽全局那个 codeg 技能,而 UI 和输入框还在说"已从全局继承、已启用"

"两边取并集"这个前提对大多数 agent 成立,但不是对所有 agent 都成立,值得在模型里把优先级也表达出来。

七、【Important】项目作用域的批量启用没有确认

dispatch(ops, !enable)skill-agent-matrix.tsx:428)—— 只有禁用走确认,启用直接执行。项目作用域下点一次"全部 agent 启用",会在用户仓库里一次性 create_dir_all11 个目录.claude/skills.codex/skills.agents/skills.gemini/skillsskills.codebuddy/skills.kimi-code/skills.pi/skills.grok/skills.cursor/skills.dsh/skills

补充几点免得我说过头:链接只用 preferred_scope_skill_dir第一个根(acp.rs:7811),所以每个 agent 只建一个目录,不会把 project_rel_dirs 里列的次级目录全建出来(比如 Cline 的 .cline/skills.clinerules/skills.claude/skills 都不会被建);12 个有项目根的内置 agent 里 OpenCode 和 Cline 首选的都是 .agents/skills,所以去重后是 11 个而不是 12 个;Unix 上也不会覆盖已有的 skills/ 目录本身,冲突点是 skills/<skill-id> 这一层。

但仓库根凭空多 11 个目录仍然不小,而且这些链接指向 ~/.codeg/skills/... 的绝对路径,提交进 git 对同事就是坏链。

建议项目作用域下批量启用也走确认,并在确认框里列出将要创建的路径;文档里提一句这些路径建议进 .gitignore

八、性能与生命周期(非阻塞)

  • 全局快照被按 workspace 重复拉。 use-enabled-skill-ids.ts:64 里每个 workspace entry 都会发 3 个 global + 3 个 project 请求,refreshSnapshotOnFocus:130)又会遍历所有有订阅者的 entry。K 个不同 folder 的 composer 挂着时,每次窗口聚焦是 6K 次调用,其中 3(K-1) 次 global 扫描是纯重复的(global 快照与 workspace 无关)。main 上是固定 3 次。拆成"一个共享的 global entry + 每 workspace 一个 project entry、读时合并"就能回到原注释里强调的 one coalesced refresh per focus。
  • 渲染期写模块级 Map + 只增不删。 getSnapshotEntry() 在组件体内被调用(:190),render 阶段就往模块 Map 里插;清理时(:217)只删 subscriber 不删 entry。逛过的每个 folder 会在整个会话生命周期里留一份完整快照数组。挪进 useMemo/effect,并在 subscribers.size === 0 时回收即可。

九、其它小项

  • MatrixCell 外面包的 <span tabIndex={notInteractive ? 0 : undefined} aria-label={...}>:1112)是为了让 disabled 按钮能出 tooltip,可以理解,但两个副作用:无 role 的裸 <span> 上的 aria-label 各家读屏行为不一致,不保证读得到;更实际的是每个不可交互格子都成了一个 Tab 停靠点,几十技能 × 13 agent 的矩阵键盘穿越会变成几百次 Tab —— 而且这条影响的是所有作用域,包括现在的全局矩阵。改成按钮自身保留 aria-disabled、去掉原生 disabled(onClick 里早退)能解决语义问题;tab 数量那部分可能得配合 roving tabindex 之类的网格导航。
  • ScopeParams.scope / ApplyLinksParams.scope 没有 #[serde(default)]。server 模式下浏览器揣着旧 JS bundle 打新后端会直接反序列化失败,给个 Global 默认值零成本。
  • experts_get_install_status / science_get_install_status / officecli_skill_get_install_status 以及 *_link_to_agent / *_unlink_from_agent 仍写死 Global。我 grep 过 src/(除 api.ts)零调用,所以现在没有用户可见影响,但它们确实挂在 Axum router 上,语义已经和矩阵不一致了,加个"legacy global API"的注释说明一下比较好。
  • 作用域选择器和隔壁 Skills 页不是一个交互:那边是「Global / Folder」分段按钮 + 独立 folder Select,且懒加载 folder 列表(skills-settings.tsx:713);这边是一个把 Global 和所有 folder 混在一起的扁平 Select,mount 就拉。同一个设置窗口里两种范式,建议对齐。
  • 作用域选择是组件内 state,每次重开设置都回到 Global,考虑持久化。

整体我的判断是:项目作用域这个方向保留,不要改。合并前建议至少处理 1(Windows preflight)、2(拆链路)、3(状态真值与文案),4 和 7 至少先在 UI 上把限制说清楚;剩下的可以排后续。辛苦了 🙏 —— 这个 PR 的 i18n、测试、双模式编译都做得很干净,inherited 用只读态而不是允许项目内"反向禁用"也是对的取舍,主要问题集中在生命周期和状态模型这两块。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants