From 02e1375557db771c076edd0763e3553c5585d920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:12:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20=E5=86=85=E5=B5=8C=20skills/=20?= =?UTF-8?q?=E8=BF=9B=E4=BA=8C=E8=BF=9B=E5=88=B6=EF=BC=8Cskill=20setup=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=94=A8=E5=86=85=E5=B5=8C=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复升级二进制后 dws skill setup 仍装旧 skill 的问题:此前 setup 从 当前工作目录/二进制旁探测 skills/ 源,二进制不内嵌,导致升级后已装 skill 不刷新、agent 读到陈旧路由。 - 根包 go:embed all:skills(all: 以含 _common 等下划线目录) - skill setup 默认从内嵌源解到临时目录安装;--source / DWS_SKILL_SOURCE 仅作 dev 覆盖 - 新增内嵌提取 + 默认回退单测;go build/vet/test 全过;干净环境(无 cwd skills/、假 HOME)端到端验证装出含 connect 路由的 skill --- internal/app/skill_setup.go | 3 +- internal/app/skill_setup_embed.go | 86 ++++++++++++++++++++++++++ internal/app/skill_setup_embed_test.go | 68 ++++++++++++++++++++ skills_embed.go | 27 ++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 internal/app/skill_setup_embed.go create mode 100644 internal/app/skill_setup_embed_test.go create mode 100644 skills_embed.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 3d9f8169..f4ba1a15 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -97,10 +97,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)") } - skillSrc, err := resolveSkillSetupSource(source, mode) + skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode) if err != nil { return err } + defer srcCleanup() dests, err := resolveSkillSetupTargets(target, mode) if err != nil { diff --git a/internal/app/skill_setup_embed.go b/internal/app/skill_setup_embed.go new file mode 100644 index 00000000..635c4b6b --- /dev/null +++ b/internal/app/skill_setup_embed.go @@ -0,0 +1,86 @@ +// 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 app + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli" +) + +// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill +// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer +// override (validated as an on-disk dir). Otherwise it falls back to the skill +// bundle embedded in THIS binary, so a plain `dws skill setup` always installs +// the version shipped with the running binary — upgrading the binary therefore +// refreshes the installed skill, instead of silently reusing a stale copy from +// the current working directory. +// +// The returned cleanup func removes any temp dir created for the embedded +// bundle; it is a no-op when an on-disk source is used. Always call it. +func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) { + noop := func() {} + explicit = strings.TrimSpace(explicit) + env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")) + if explicit != "" || env != "" { + dir, err := resolveSkillSetupSource(explicit, mode) + return dir, noop, err + } + return materializeEmbeddedSkillSource(mode) +} + +// materializeEmbeddedSkillSource extracts the embedded skills/ subtree +// into a fresh temp dir and returns its path plus a cleanup func. Reusing a +// real directory lets the existing dir-based install/copy logic stay unchanged. +func materializeEmbeddedSkillSource(mode string) (string, func(), error) { + noop := func() {} + sub := "skills/" + mode // embed.FS always uses forward slashes + if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil { + return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err) + } + + tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-") + if err != nil { + return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err) + } + cleanup := func() { _ = os.RemoveAll(tmp) } + + walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/") + dst := filepath.Join(tmp, filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(dst, 0o755) + } + data, readErr := dwsroot.EmbeddedSkills.ReadFile(p) + if readErr != nil { + return readErr + } + if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil { + return mkErr + } + return os.WriteFile(dst, data, 0o644) + }) + if walkErr != nil { + cleanup() + return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr) + } + return tmp, cleanup, nil +} diff --git a/internal/app/skill_setup_embed_test.go b/internal/app/skill_setup_embed_test.go new file mode 100644 index 00000000..c932b42c --- /dev/null +++ b/internal/app/skill_setup_embed_test.go @@ -0,0 +1,68 @@ +// 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 app + +import ( + "os" + "path/filepath" + "testing" +) + +// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle +// baked into the binary can be extracted to a temp dir and is a valid skill +// source root (so `dws skill setup` works with zero local checkout). The +// connect.md / _common checks guard against the embed dropping the connect +// routing docs or the `all:` prefix being lost (which would silently skip +// dot/underscore dirs). +func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) { + dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono) + if err != nil { + t.Fatalf("materializeEmbeddedSkillSource: %v", err) + } + defer cleanup() + + if !isSkillSourceRoot(dir, skillSetupModeMono) { + t.Fatalf("extracted dir %s is not a valid mono skill source root", dir) + } + for _, rel := range []string{ + "SKILL.md", + filepath.Join("references", "connect.md"), + filepath.Join("references", "best_practices", "_common"), + } { + if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { + t.Errorf("expected embedded skill to contain %s: %v", rel, err) + } + } + + // cleanup must actually remove the temp dir. + cleanup() + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err) + } +} + +// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with +// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle +// rather than probing the current working directory (the stale-skill footgun). +func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) { + t.Setenv("DWS_SKILL_SOURCE", "") + dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono) + if err != nil { + t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err) + } + defer cleanup() + if !isSkillSourceRoot(dir, skillSetupModeMono) { + t.Fatalf("embedded fallback returned non-source-root dir %s", dir) + } +} diff --git a/skills_embed.go b/skills_embed.go new file mode 100644 index 00000000..9b8bd791 --- /dev/null +++ b/skills_embed.go @@ -0,0 +1,27 @@ +// 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 dws is the module-root package. Its sole purpose is to bake the +// skills/ documentation tree into the binary at build time so that +// `dws skill setup` installs the skill version shipped with THIS binary, +// independent of any local checkout. See internal/app/skill_setup.go. +package dws + +import "embed" + +// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into +// the binary. The `all:` prefix is required so dot/underscore entries — e.g. +// references/best_practices/_common — are included rather than skipped. +// +//go:embed all:skills +var EmbeddedSkills embed.FS