From df1e33442b61a204e1de36536ff0f4fbe17c2ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:57:48 +0800 Subject: [PATCH] fix(skill): embed skill sources in the binary and cover qoder dirs in upgrade refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distribution defects left installed skills stale (issue PeterGuy326#8): 1. skill setup resolved its source from cwd / exe-adjacent checkouts, so an upgraded binary happily re-installed whatever stale checkout it was run from, and agents kept routing 'create robot' into the chat dead end. The skills/mono and skills/multi trees are now embedded (go:embed) and setup defaults to that copy, materialized into ~/.dws/skills/ — upgrading the binary upgrades what setup installs. --source and DWS_SKILL_SOURCE stay as explicit overrides, and an override that has no skill root errors out instead of silently falling back. 2. dws upgrade's skill refresh (knownSkillDirs) skipped ~/.qoder and ~/.qoderwork, so Qoder/QoderWork hosts never got refreshed routing. Added both to the dir table and its seven mirrors (npm install.js, install.sh/ps1, install-skills.sh, homebrew template, package test, verify-package-managers.sh). setup-side detection and --target qoder/qoderwork already work on this branch; this closes the remaining upgrade-side and source-side gaps. --- build/homebrew.rb.tmpl | 2 + build/npm/install.js | 2 + internal/app/skill_setup.go | 71 +++++++++++++++++++++- internal/app/skill_setup_test.go | 48 ++++++++++++--- internal/upgrade/paths.go | 2 + scripts/install-skills.sh | 2 + scripts/install.ps1 | 2 + scripts/install.sh | 2 + scripts/release/verify-package-managers.sh | 4 ++ skills/embed.go | 28 +++++++++ test/scripts/package_script_test.go | 2 + 11 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 skills/embed.go diff --git a/build/homebrew.rb.tmpl b/build/homebrew.rb.tmpl index d81f5fa1..6895802e 100644 --- a/build/homebrew.rb.tmpl +++ b/build/homebrew.rb.tmpl @@ -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")), diff --git a/build/npm/install.js b/build/npm/install.js index 7935c27e..a4e7fe47 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -12,6 +12,8 @@ const AGENT_DIRS = [ ".agents/skills", ".claude/skills", ".cursor/skills", + ".qoder/skills", + ".qoderwork/skills", ".gemini/skills", ".codex/skills", ".github/skills", diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 3d9f8169..865f89de 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -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/ tree +// into the user-level cache (~/.dws/skills/) 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). diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 6d28c9f9..34a55fe4 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -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) } } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 9c1363e2..5412d947 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -34,6 +34,8 @@ var knownSkillDirs = []string{ ".agents/skills", ".claude/skills", ".cursor/skills", + ".qoder/skills", + ".qoderwork/skills", ".gemini/skills", ".codex/skills", ".github/skills", diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 745a498f..dfc73fab 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -108,6 +108,8 @@ install_skills_to_root() { ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ + ".qoder/skills" \ + ".qoderwork/skills" \ ".gemini/skills" \ ".codex/skills" \ ".github/skills" \ diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5d1cbb1c..bf54ae65 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -37,6 +37,8 @@ $AgentDirs = @( ".agents\skills", ".claude\skills", ".cursor\skills", + ".qoder\skills", + ".qoderwork\skills", ".gemini\skills", ".codex\skills", ".github\skills", diff --git a/scripts/install.sh b/scripts/install.sh index 87512e95..74ac431c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -301,6 +301,8 @@ install_skills_to_homes() { ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ + ".qoder/skills" \ + ".qoderwork/skills" \ ".gemini/skills" \ ".codex/skills" \ ".github/skills" \ diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index a98afa42..6944dd74 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -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 diff --git a/skills/embed.go b/skills/embed.go new file mode 100644 index 00000000..54a23693 --- /dev/null +++ b/skills/embed.go @@ -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 diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 3117768c..9809673c 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -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",