Merge pull request #12 from PeterGuy326/fix/skill-setup-embedded-source

fix(skill): embed skill sources in binary + cover qoder dirs in upgrade refresh
This commit is contained in:
修雨
2026-06-12 18:05:49 +08:00
committed by GitHub
11 changed files with 152 additions and 13 deletions
+2
View File
@@ -43,6 +43,8 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
+2
View File
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".github/skills",
+68 -3
View File
@@ -4,11 +4,13 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
embeddedskills "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/skills"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -58,7 +60,8 @@ multi 模式支持按产品挑选:
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`,
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup # 交互式
dws skill setup --mode mono --yes # 非交互装 mono
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
@@ -70,7 +73,7 @@ multi 模式支持按产品挑选:
}
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)")
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
cmd.Flags().Bool("yes", false, "跳过所有确认提示")
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
@@ -315,7 +318,37 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
func resolveSkillSetupSource(explicit, mode string) (string, error) {
subdir := mode // "mono" or "multi"
candidates := skillSourceCandidates(explicit, subdir)
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
// override that does not contain a skill root is an error — never a
// silent fallback to another source the user did not ask for.
var overrides []string
if explicit != "" {
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
}
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
}
if len(overrides) > 0 {
for _, c := range overrides {
if isSkillSourceRoot(c, mode) {
return c, nil
}
}
hint := strings.Join(overrides, "\n - ")
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
}
// Default: the copy embedded in this binary, materialized into the
// user-level cache. Upgrading the binary therefore upgrades what setup
// installs — a stale checkout in cwd can no longer poison the install
// (issue PeterGuy326#8).
if src, err := materializeEmbeddedSkillSource(subdir); err == nil && isSkillSourceRoot(src, mode) {
return src, nil
}
// Legacy fallbacks (exe-adjacent, cwd, user cache) — only reachable when
// embed materialization fails (e.g. unwritable HOME and temp dir).
candidates := skillSourceCandidates("", subdir)
for _, c := range candidates {
if isSkillSourceRoot(c, mode) {
return c, nil
@@ -326,6 +359,38 @@ func resolveSkillSetupSource(explicit, mode string) (string, error) {
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
}
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> tree
// into the user-level cache (~/.dws/skills/<mode>) and returns that path.
// The cache is wiped and rewritten on every call so its content always
// matches the running binary; a temp directory is used when HOME is not
// resolvable.
func materializeEmbeddedSkillSource(mode string) (string, error) {
sub, err := fs.Sub(embeddedskills.FS, mode)
if err != nil {
return "", err
}
var dest string
if home, herr := os.UserHomeDir(); herr == nil && home != "" {
dest = filepath.Join(home, ".dws", "skills", mode)
} else {
tmp, terr := os.MkdirTemp("", "dws-skill-src-")
if terr != nil {
return "", terr
}
dest = filepath.Join(tmp, mode)
}
if err := os.RemoveAll(dest); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if err := os.CopyFS(dest, sub); err != nil {
return "", err
}
return dest, nil
}
// skillSourceCandidates returns the ordered list of paths to probe for a
// skill source root, given an optional explicit override and the mode
// subdir (mono or multi).
+38 -10
View File
@@ -318,19 +318,21 @@ func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
// successfully discovers ~/.dws/skills/multi/ as the source.
func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
func TestResolveSkillSetupSourceDefaultsToEmbedded(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("DWS_SKILL_SOURCE", "")
// Pre-seed a stale user cache: the embedded default must overwrite it,
// not serve it (issue PeterGuy326#8 — stale installed sources poisoned
// every later setup run).
cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
t.Fatal(err)
}
staleMarker := filepath.Join(cacheRoot, "stale-marker", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(staleMarker), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(staleMarker, []byte("# stale"), 0o644); err != nil {
t.Fatal(err)
}
// Run resolver from a tempdir that has no skills/ on disk, simulating a
@@ -340,10 +342,36 @@ func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
got, err := resolveSkillSetupSource("", skillSetupModeMulti)
if err != nil {
t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
t.Fatalf("expected embedded default to succeed, got err=%v", err)
}
if got != cacheRoot {
t.Fatalf("expected %s, got %s", cacheRoot, got)
t.Fatalf("expected materialized cache %s, got %s", cacheRoot, got)
}
if _, err := os.Stat(staleMarker); !os.IsNotExist(err) {
t.Fatalf("stale cache content must be wiped by embedded materialization")
}
if !isSkillSourceRoot(got, skillSetupModeMulti) {
t.Fatalf("materialized source %s is not a valid multi root", got)
}
}
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
got, err := materializeEmbeddedSkillSource(skillSetupModeMono)
if err != nil {
t.Fatal(err)
}
if got != filepath.Join(fakeHome, ".dws", "skills", "mono") {
t.Fatalf("unexpected dest %s", got)
}
if !isSkillSourceRoot(got, skillSetupModeMono) {
t.Fatalf("materialized mono source missing SKILL.md")
}
// The underscore-shared references must survive embedding (all: pattern).
if _, err := os.Stat(filepath.Join(got, "references", "best_practices", "_common")); err != nil {
t.Fatalf("_common references missing from embedded mono tree: %v", err)
}
}
+2
View File
@@ -34,6 +34,8 @@ var knownSkillDirs = []string{
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".github/skills",
+2
View File
@@ -108,6 +108,8 @@ install_skills_to_root() {
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".github/skills" \
+2
View File
@@ -37,6 +37,8 @@ $AgentDirs = @(
".agents\skills",
".claude\skills",
".cursor\skills",
".qoder\skills",
".qoderwork\skills",
".gemini\skills",
".codex\skills",
".github\skills",
+2
View File
@@ -301,6 +301,8 @@ install_skills_to_homes() {
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".github/skills" \
@@ -27,6 +27,8 @@ TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_AGENT_PARENTS="
.claude
.cursor
.qoder
.qoderwork
.gemini
.codex
.github
@@ -43,6 +45,8 @@ HOME_SKILL_TARGETS="
.agents/skills/dws
.claude/skills/dws
.cursor/skills/dws
.qoder/skills/dws
.qoderwork/skills/dws
.gemini/skills/dws
.codex/skills/dws
.github/skills/dws
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package skills embeds the dws skill sources into the binary so that
// `dws skill setup` always installs the skill set matching the running
// binary version, regardless of the working directory. Before this, setup
// resolved its source from cwd/exe-adjacent checkouts, so an upgraded
// binary could silently re-install stale skills (issue PeterGuy326#8).
package skills
import "embed"
// FS holds the mono and multi skill trees. The all: prefix keeps
// underscore-prefixed directories (e.g. references/best_practices/_common)
// that plain embed patterns would skip.
//
//go:embed all:mono all:multi
var FS embed.FS
+2
View File
@@ -13,6 +13,8 @@ var expectedPackagedSkillTargets = []string{
".agents/skills/dws",
".claude/skills/dws",
".cursor/skills/dws",
".qoder/skills/dws",
".qoderwork/skills/dws",
".gemini/skills/dws",
".codex/skills/dws",
".github/skills/dws",