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:
@@ -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")),
|
||||
|
||||
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ var knownSkillDirs = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
|
||||
@@ -108,6 +108,8 @@ install_skills_to_root() {
|
||||
".agents/skills" \
|
||||
".claude/skills" \
|
||||
".cursor/skills" \
|
||||
".qoder/skills" \
|
||||
".qoderwork/skills" \
|
||||
".gemini/skills" \
|
||||
".codex/skills" \
|
||||
".github/skills" \
|
||||
|
||||
@@ -37,6 +37,8 @@ $AgentDirs = @(
|
||||
".agents\skills",
|
||||
".claude\skills",
|
||||
".cursor\skills",
|
||||
".qoder\skills",
|
||||
".qoderwork\skills",
|
||||
".gemini\skills",
|
||||
".codex\skills",
|
||||
".github\skills",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user