748 lines
25 KiB
Go
748 lines
25 KiB
Go
package app
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/huh"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// skillSetupAgentHomes is the ordered list of agent home subdirectories
|
||
// where dws skills get installed. Mirrors install.sh / install.ps1 /
|
||
// build/npm/install.js so that `dws skill setup` and the install scripts
|
||
// agree on the install footprint.
|
||
var skillSetupAgentHomes = []string{
|
||
".agents/skills",
|
||
".claude/skills",
|
||
".cursor/skills",
|
||
".qoder/skills",
|
||
".qoderwork/skills",
|
||
".gemini/skills",
|
||
".codex/skills",
|
||
".github/skills",
|
||
".windsurf/skills",
|
||
".augment/skills",
|
||
".cline/skills",
|
||
".amp/skills",
|
||
".kiro/skills",
|
||
".trae/skills",
|
||
".openclaw/skills",
|
||
".hermes/skills",
|
||
}
|
||
|
||
const (
|
||
skillSetupModeMono = "mono"
|
||
skillSetupModeMulti = "multi"
|
||
)
|
||
|
||
var (
|
||
skillSetupResolveMode = resolveSkillSetupMode
|
||
skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded
|
||
skillSetupResolveTargets = resolveSkillSetupTargets
|
||
skillSetupListMulti = listMultiSkillNames
|
||
skillSetupFilterMulti = filterMultiSkillNames
|
||
skillSetupConfirm = confirmSkillSetup
|
||
skillSetupInstallMono = installSkillToHomes
|
||
skillSetupInstallMulti = installMultiSkillToHomes
|
||
skillSetupCopyDir = copyDir
|
||
skillSetupRunForm = (*huh.Form).Run
|
||
skillSetupInteractive = isInteractiveTerminal
|
||
skillSetupReadDir = os.ReadDir
|
||
skillSetupStat = os.Stat
|
||
skillSetupExecutable = os.Executable
|
||
skillSetupGetwd = os.Getwd
|
||
skillSetupUserHomeDir = os.UserHomeDir
|
||
skillSetupRemoveAll = os.RemoveAll
|
||
skillSetupMkdirAll = os.MkdirAll
|
||
skillSetupWalk = filepath.Walk
|
||
skillSetupRel = filepath.Rel
|
||
skillSetupReadlink = os.Readlink
|
||
skillSetupOpen = os.Open
|
||
skillSetupOpenFile = os.OpenFile
|
||
skillSetupCopy = io.Copy
|
||
)
|
||
|
||
func newSkillSetupCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "setup",
|
||
Short: "安装 dws 自身 skill 到 Agent 目录",
|
||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||
|
||
支持两种模式:
|
||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||
生产前请评估,问题请提 issue 反馈
|
||
|
||
multi 模式支持按产品挑选:
|
||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||
|
||
不带 --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/
|
||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||
dws skill setup --mode multi -x live -x devdoc # 安装除 live、devdoc 外的其余 skill
|
||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||
DisableAutoGenTag: true,
|
||
RunE: runSkillSetup,
|
||
}
|
||
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
|
||
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
|
||
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 互斥)")
|
||
return cmd
|
||
}
|
||
|
||
func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||
mode, _ := cmd.Flags().GetString("mode")
|
||
target, _ := cmd.Flags().GetString("target")
|
||
source, _ := cmd.Flags().GetString("source")
|
||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||
includeRaw, _ := cmd.Flags().GetStringSlice("skill")
|
||
excludeRaw, _ := cmd.Flags().GetStringSlice("exclude")
|
||
|
||
out := cmd.OutOrStdout()
|
||
errOut := cmd.ErrOrStderr()
|
||
|
||
mode, err := skillSetupResolveMode(mode, autoYes, out)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) {
|
||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||
}
|
||
|
||
skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer srcCleanup()
|
||
|
||
dests, err := skillSetupResolveTargets(target, mode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||
var multiSkillNames []string
|
||
if mode == skillSetupModeMulti {
|
||
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||
if listErr != nil {
|
||
return listErr
|
||
}
|
||
if len(allMultiSkillNames) == 0 {
|
||
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
|
||
}
|
||
filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw)
|
||
if filterErr != nil {
|
||
return filterErr
|
||
}
|
||
// dws-shared carries the global rules every product skill declares as a
|
||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||
}
|
||
|
||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
|
||
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
|
||
fmt.Fprintln(out, "将安装到:")
|
||
for _, d := range dests {
|
||
fmt.Fprintf(out, " - %s\n", d)
|
||
}
|
||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
if !autoYes {
|
||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
fmt.Fprintln(out, "已取消。")
|
||
return nil
|
||
}
|
||
} else if mode == skillSetupModeMulti {
|
||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||
}
|
||
|
||
var installed, skipped int
|
||
switch mode {
|
||
case skillSetupModeMono:
|
||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||
case skillSetupModeMulti:
|
||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||
default:
|
||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||
return nil
|
||
}
|
||
|
||
// multiSkillPrefix is the canonical prefix for every per-product skill
|
||
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
|
||
const multiSkillPrefix = "dingtalk-"
|
||
|
||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||
// dws-shared that was never installed.
|
||
const multiSharedSkill = "dws-shared"
|
||
|
||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||
hasShared := false
|
||
for _, n := range all {
|
||
if n == multiSharedSkill {
|
||
hasShared = true
|
||
break
|
||
}
|
||
}
|
||
if !hasShared {
|
||
return selected
|
||
}
|
||
for _, n := range selected {
|
||
if n == multiSharedSkill {
|
||
return selected
|
||
}
|
||
}
|
||
return append([]string{multiSharedSkill}, selected...)
|
||
}
|
||
|
||
// normalizeMultiSkillName accepts either the short form (aitable) or the
|
||
// full form (dingtalk-aitable) and returns the canonical full form.
|
||
// Empty input returns "". Comparison is case-insensitive.
|
||
func normalizeMultiSkillName(name string) string {
|
||
n := strings.ToLower(strings.TrimSpace(name))
|
||
if n == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(n, multiSkillPrefix) {
|
||
return n
|
||
}
|
||
return multiSkillPrefix + n
|
||
}
|
||
|
||
// filterMultiSkillNames narrows `all` by include / exclude lists:
|
||
//
|
||
// - include + exclude are mutually exclusive (both → error)
|
||
// - names accept short or full form; normalized before matching
|
||
// - unknown names → error, with the available list inlined for discovery
|
||
// - both lists empty → return `all` (install everything)
|
||
// - exclude that drops every name → error (avoid silent no-op install)
|
||
//
|
||
// The caller is responsible for additive installation: install only the
|
||
// returned names, leaving any other already-installed dingtalk-* siblings
|
||
// untouched (handled by installMultiSkillToHomes which does not enumerate
|
||
// the destination).
|
||
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||
if len(include) > 0 && len(exclude) > 0 {
|
||
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
|
||
}
|
||
|
||
available := make(map[string]struct{}, len(all))
|
||
for _, n := range all {
|
||
available[n] = struct{}{}
|
||
}
|
||
|
||
validate := func(raw []string, flagName string) ([]string, error) {
|
||
var normalized []string
|
||
var unknown []string
|
||
seen := make(map[string]bool)
|
||
for _, r := range raw {
|
||
n := normalizeMultiSkillName(r)
|
||
if n == "" {
|
||
continue
|
||
}
|
||
if _, ok := available[n]; !ok {
|
||
unknown = append(unknown, r)
|
||
continue
|
||
}
|
||
if !seen[n] {
|
||
seen[n] = true
|
||
normalized = append(normalized, n)
|
||
}
|
||
}
|
||
if len(unknown) > 0 {
|
||
return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s",
|
||
flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", "))
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
if len(include) > 0 {
|
||
names, err := validate(include, "--skill")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sort.Strings(names)
|
||
return names, nil
|
||
}
|
||
if len(exclude) > 0 {
|
||
excluded, err := validate(exclude, "--exclude")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
excludedSet := make(map[string]bool, len(excluded))
|
||
for _, n := range excluded {
|
||
excludedSet[n] = true
|
||
}
|
||
var out []string
|
||
for _, n := range all {
|
||
if !excludedSet[n] {
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all))
|
||
}
|
||
return out, nil
|
||
}
|
||
return all, nil
|
||
}
|
||
|
||
// listMultiSkillNames returns sorted names of subdirectories under src that
|
||
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
|
||
func listMultiSkillNames(src string) ([]string, error) {
|
||
entries, err := skillSetupReadDir(src)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
|
||
}
|
||
var names []string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
||
names = append(names, e.Name())
|
||
}
|
||
}
|
||
sort.Strings(names)
|
||
return names, nil
|
||
}
|
||
|
||
// resolveSkillSetupMode resolves the mode either from the flag or via an
|
||
// interactive prompt. If no TTY is available and no mode was given, returns
|
||
// an error rather than silently picking a default.
|
||
func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) {
|
||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||
switch mode {
|
||
case skillSetupModeMono, skillSetupModeMulti:
|
||
return mode, nil
|
||
case "":
|
||
// fall through to interactive prompt
|
||
default:
|
||
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
|
||
}
|
||
|
||
if autoYes || !skillSetupInteractive() {
|
||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||
return skillSetupModeMono, nil
|
||
}
|
||
|
||
var choice string
|
||
form := huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewSelect[string]().
|
||
Title("选择 dws skill 安装模式").
|
||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||
Options(
|
||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||
).
|
||
Value(&choice),
|
||
),
|
||
)
|
||
if err := skillSetupRunForm(form); err != nil {
|
||
return "", fmt.Errorf("交互式选择中止: %w", err)
|
||
}
|
||
return choice, nil
|
||
}
|
||
|
||
// resolveSkillSetupSource finds the local skill source directory for the
|
||
// given mode. PR 1 supports only mono; multi is reserved for a later PR
|
||
// and currently returns an error before reaching this function.
|
||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||
subdir := mode // "mono" or "multi"
|
||
|
||
// 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)
|
||
}
|
||
|
||
// No explicit override: legacy fallback only — embedded materialization
|
||
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
|
||
// the wrapper that callers use. This branch is reachable only when the
|
||
// wrapper passes through with an empty explicit/env (legacy direct call).
|
||
candidates := skillSourceCandidates("", subdir)
|
||
for _, c := range candidates {
|
||
if isSkillSourceRoot(c, mode) {
|
||
return c, nil
|
||
}
|
||
}
|
||
|
||
hint := strings.Join(candidates, "\n - ")
|
||
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
|
||
}
|
||
|
||
// 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).
|
||
func skillSourceCandidates(explicit, subdir string) []string {
|
||
var roots []string
|
||
if explicit != "" {
|
||
// allow either repo root or already-resolved skills/<mode> dir
|
||
roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir))
|
||
}
|
||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||
roots = append(roots, env, filepath.Join(env, "skills", subdir))
|
||
}
|
||
if exe, err := skillSetupExecutable(); err == nil {
|
||
exeDir := filepath.Dir(exe)
|
||
roots = append(roots,
|
||
filepath.Join(exeDir, "skills", subdir),
|
||
filepath.Join(exeDir, "..", "skills", subdir),
|
||
filepath.Join(exeDir, "..", "share", "skills", "dws"),
|
||
)
|
||
}
|
||
if wd, err := skillSetupGetwd(); err == nil {
|
||
roots = append(roots, filepath.Join(wd, "skills", subdir))
|
||
}
|
||
// User-level cache populated by install.sh / install.ps1 / npm install.js
|
||
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
|
||
// source even when the user has no source checkout on disk.
|
||
if home, err := skillSetupUserHomeDir(); err == nil {
|
||
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
|
||
}
|
||
return roots
|
||
}
|
||
|
||
func isSkillSourceRoot(path, mode string) bool {
|
||
if path == "" {
|
||
return false
|
||
}
|
||
switch mode {
|
||
case skillSetupModeMono:
|
||
fi, err := skillSetupStat(filepath.Join(path, "SKILL.md"))
|
||
return err == nil && !fi.IsDir()
|
||
case skillSetupModeMulti:
|
||
entries, err := skillSetupReadDir(path)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
return false
|
||
}
|
||
|
||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||
// If target == "all", returns every agent home whose parent directory exists.
|
||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||
//
|
||
// 末段约定:
|
||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
|
||
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||
home, err := skillSetupUserHomeDir()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
|
||
}
|
||
|
||
target = strings.ToLower(strings.TrimSpace(target))
|
||
if target == "" || target == "all" {
|
||
return detectExistingAgentHomes(home, mode), nil
|
||
}
|
||
|
||
rel, ok := agentSkillPaths[target]
|
||
if !ok {
|
||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||
}
|
||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||
}
|
||
|
||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||
func agentHomeForMode(base, mode string) string {
|
||
if mode == skillSetupModeMulti {
|
||
return base
|
||
}
|
||
return filepath.Join(base, "dws")
|
||
}
|
||
|
||
func detectExistingAgentHomes(home, mode string) []string {
|
||
var out []string
|
||
for i, rel := range skillSetupAgentHomes {
|
||
base := filepath.Join(home, rel)
|
||
parent := filepath.Dir(base)
|
||
if i > 0 {
|
||
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, agentHomeForMode(base, mode))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||
if mode == skillSetupModeMulti {
|
||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||
}
|
||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||
if mode == skillSetupModeMulti {
|
||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||
for _, n := range multiSkillNames {
|
||
fmt.Fprintf(out, " · %s\n", n)
|
||
}
|
||
}
|
||
fmt.Fprintln(out, " destinations:")
|
||
for _, d := range dests {
|
||
fmt.Fprintf(out, " - %s\n", d)
|
||
}
|
||
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
|
||
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
|
||
for _, d := range dests {
|
||
for _, victim := range mutualExclusionVictims(d, mode) {
|
||
fmt.Fprintf(out, " × 将删除 %s\n", victim)
|
||
}
|
||
}
|
||
|
||
if !skillSetupInteractive() {
|
||
return true, nil
|
||
}
|
||
|
||
var confirm bool
|
||
form := huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewConfirm().
|
||
Title("确认安装?").
|
||
Affirmative("继续").
|
||
Negative("取消").
|
||
Value(&confirm),
|
||
),
|
||
)
|
||
if err := skillSetupRunForm(form); err != nil {
|
||
return false, fmt.Errorf("确认中止: %w", err)
|
||
}
|
||
return confirm, nil
|
||
}
|
||
|
||
// mutualExclusionVictims returns the paths that should be removed before
|
||
// installing into dest under the given mode, to prevent leftover files from
|
||
// the opposite mode from co-existing.
|
||
//
|
||
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
|
||
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
|
||
func mutualExclusionVictims(dest, mode string) []string {
|
||
switch mode {
|
||
case skillSetupModeMono:
|
||
// dest = <agent-home>/dws → agent-home = parent
|
||
agentHome := filepath.Dir(dest)
|
||
entries, err := skillSetupReadDir(agentHome)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var victims []string
|
||
for _, e := range entries {
|
||
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
||
victims = append(victims, filepath.Join(agentHome, e.Name()))
|
||
}
|
||
}
|
||
sort.Strings(victims)
|
||
return victims
|
||
case skillSetupModeMulti:
|
||
// dest = <agent-home> → mono 残留是 dest/dws
|
||
monoPath := filepath.Join(dest, "dws")
|
||
if _, err := skillSetupStat(monoPath); err == nil {
|
||
return []string{monoPath}
|
||
}
|
||
return nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
|
||
// Failures emit a warning to errOut but never abort the install.
|
||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||
if err := skillSetupRemoveAll(victim); err != nil {
|
||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||
continue
|
||
}
|
||
fmt.Fprintf(out, " × 已清理对面模式残留 %s\n", victim)
|
||
}
|
||
}
|
||
|
||
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||
sort.Strings(dests)
|
||
for _, dest := range dests {
|
||
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
|
||
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
|
||
|
||
if err := skillSetupRemoveAll(dest); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
|
||
skipped++
|
||
continue
|
||
}
|
||
if err := skillSetupMkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
|
||
skipped++
|
||
continue
|
||
}
|
||
if err := skillSetupCopyDir(src, dest); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
|
||
skipped++
|
||
continue
|
||
}
|
||
fmt.Fprintf(out, " ✓ %s\n", dest)
|
||
installed++
|
||
}
|
||
return installed, skipped, nil
|
||
}
|
||
|
||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||
// dest as a sibling skill directory. installed/skipped is counted per
|
||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||
sort.Strings(dests)
|
||
for _, dest := range dests {
|
||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||
|
||
if err := skillSetupMkdirAll(dest, 0o755); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
|
||
skipped += len(skillNames)
|
||
continue
|
||
}
|
||
|
||
for _, name := range skillNames {
|
||
subSrc := filepath.Join(src, name)
|
||
subDest := filepath.Join(dest, name)
|
||
if err := skillSetupRemoveAll(subDest); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
|
||
skipped++
|
||
continue
|
||
}
|
||
if err := skillSetupCopyDir(subSrc, subDest); err != nil {
|
||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
|
||
skipped++
|
||
continue
|
||
}
|
||
fmt.Fprintf(out, " ✓ %s\n", subDest)
|
||
installed++
|
||
}
|
||
}
|
||
return installed, skipped, nil
|
||
}
|
||
|
||
func copyDir(src, dst string) error {
|
||
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
rel, err := skillSetupRel(src, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target := filepath.Join(dst, rel)
|
||
|
||
if info.IsDir() {
|
||
return skillSetupMkdirAll(target, info.Mode())
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 {
|
||
// resolve symlink target and copy the underlying file
|
||
resolved, err := skillSetupReadlink(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !filepath.IsAbs(resolved) {
|
||
resolved = filepath.Join(filepath.Dir(path), resolved)
|
||
}
|
||
return copyFileContent(resolved, target, info.Mode())
|
||
}
|
||
return copyFileContent(path, target, info.Mode())
|
||
})
|
||
}
|
||
|
||
func copyFileContent(src, dst string, mode os.FileMode) error {
|
||
if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
return err
|
||
}
|
||
in, err := skillSetupOpen(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
_, err = skillSetupCopy(out, in)
|
||
return err
|
||
}
|
||
|
||
func isInteractiveTerminal() bool {
|
||
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
|
||
}
|
||
|
||
func isCharDevice(file *os.File) bool {
|
||
if file == nil {
|
||
return false
|
||
}
|
||
fi, err := file.Stat()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||
}
|