diff --git a/README.md b/README.md index ada28247..ece03923 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index e43d29fc..f091b4a2 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -109,6 +109,21 @@ function resolvedAgentTargets(homeDir) { }); } +function agentTargetDetected(target) { + const parent = path.dirname(target.baseDir); + switch (target.id) { + case "kimchi": + case "tabnine-cli": + return fs.existsSync(path.dirname(parent)); + case "zcode": + return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app"); + case "minimax-code": + return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app"); + default: + return fs.existsSync(parent); + } +} + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -347,7 +362,7 @@ function installSkillsToHomes(skillRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -819,7 +834,7 @@ function installMultiSkillsToHomes(multiRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -979,6 +994,7 @@ if (require.main === module) { module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index bad9f3f9..0f530c5d 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -140,21 +140,24 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的 -Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的 +升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 +universal 的 Agent 不再保留 Agent 私有副本;检测到的 非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical 的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 `GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 `.moltbot` 必须按 Agent 实际优先级解析。 -Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交 `c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent 解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 `.amp/skills`、`.cline/skills` 与 `.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 +对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过 +Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native +路径,不作为 universal symlink 模式的发布目标。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index e43484f6..e0087209 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -258,11 +258,28 @@ func formatAgentSkillPathsForHelp() string { sort.Strings(names) var b strings.Builder for _, n := range names { - fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + installPath := agentSkillPaths[n] + if isUniversalSkillInstallTarget(n) { + installPath = ".agents/skills" + } + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath) } return b.String() } +// Universal Agents discover the shared ~/.agents/skills store directly. A +// marketplace install addressed to one of those Agent IDs must therefore +// publish to canonical instead of recreating an Agent-private duplicate. +func isUniversalSkillInstallTarget(target string) bool { + rel, ok := agentSkillPaths[target] + if !ok { + return false + } + base := filepath.Join("__home__", rel) + canonical := filepath.Join("__home__", ".agents", "skills") + return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base) +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -577,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return resolveSkillSetupBase(homeDir, target), nil + destination := resolveSkillSetupBase(homeDir, target) + if isUniversalSkillInstallTarget(target) { + destination = filepath.Join(homeDir, ".agents", "skills") + } + return destination, nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 4fcdb38d..20288bae 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -28,6 +28,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestResolveSkillTargetPath(t *testing.T) { @@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) { { name: "cursor target", target: "cursor", - wantSuffix: filepath.Join(".cursor", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "codex target", target: "codex", - wantSuffix: filepath.Join(".codex", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "opencode target", target: "opencode", - wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { @@ -118,6 +119,34 @@ func TestResolveSkillTargetPath(t *testing.T) { } } +func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + for _, target := range []string{ + "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", + "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", + "github-copilot", "kimi-code-cli", "loaf", "opencode", "replit", + "universal", "warp", "zed", + } { + got, err := resolveSkillTargetPath(target) + if err != nil { + t.Fatalf("resolve %s: %v", target, err) + } + if want := filepath.Join(home, ".agents", "skills"); got != want { + t.Errorf("resolve %s = %s, want canonical %s", target, got, want) + } + } + for target, want := range map[string]string{ + "claude": filepath.Join(home, ".claude", "skills"), + "qoder": filepath.Join(home, ".qoder", "skills"), + "zcode": filepath.Join(home, ".zcode", "skills"), + } { + if got, err := resolveSkillTargetPath(target); err != nil || got != want { + t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want) + } + } +} + func TestResolveSkillTargetPathCurrentDir(t *testing.T) { // Test "." target returns current working directory cwd, err := os.Getwd() diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 94c8cbf5..ef728093 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,6 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir + skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupMkdirAll = os.MkdirAll @@ -379,6 +380,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 { + retiredNames := append([]string(nil), multiSkillNames...) + if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) { + retiredNames = append(retiredNames, multiMiscSkill) + } + if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { + return retireErr + } + } if skipped > 0 { return fmt.Errorf( "Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill", @@ -1086,9 +1096,32 @@ func detectExistingAgentHomes(home, mode string) []string { dests := []string{canonical} canonicalKey := skillSetupPathKey(canonical) seen := map[string]bool{canonicalKey: true} - addDetected := func(base string) { - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { + systemHome, _ := skillSetupSystemHomeDir() + allowSystemApps := sameSkillSetupPath(home, systemHome) + addDetected := func(rel, base string) { + detectedDir := filepath.Dir(base) + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".config/kimchi/harness/skills", ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + } + if detectedDir != "" { + if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() { + return + } + } + if base == "" { return } dest := agentHomeForMode(base, mode) @@ -1131,10 +1164,10 @@ func detectExistingAgentHomes(home, mode string) []string { case ".config/kimchi/harness/skills": base = resolveSkillSetupBase(home, "kimchi") } - addDetected(base) + addDetected(rel, base) } for _, target := range []string{"github-copilot", "windsurf"} { - addDetected(resolveSkillSetupBase(home, target)) + addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target)) } return dests } @@ -1394,6 +1427,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins } } +func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error { + home, err := skillSetupUserHomeDir() + if err != nil { + return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err) + } + seen := map[string]bool{} + var victims []skillSetupBackup + for _, target := range targets { + if !isUniversalSkillSetupBase(target) { + continue + } + for _, name := range names { + path := filepath.Join(target, name) + if seen[path] { + continue + } + seen[path] = true + victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + } + sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path }) + if _, err := backupSkillSetupTarget(home, victims, out); err != nil { + return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err) + } + return nil +} + func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source) if plan.Mode == skillSetupModeMulti { @@ -1580,8 +1640,12 @@ func installMultiSkillsWithEventMigration( } migrationSet := make(map[string]struct{}, len(migrationTargets)) + physicalMigrationTargets := make([]string, 0, len(migrationTargets)) for _, dest := range migrationTargets { migrationSet[dest] = struct{}{} + if !isUniversalSkillSetupBase(dest) { + physicalMigrationTargets = append(physicalMigrationTargets, dest) + } } var ordinaryTargets []string for _, dest := range dests { @@ -1590,23 +1654,47 @@ func installMultiSkillsWithEventMigration( } } - if len(ordinaryTargets) > 0 { + var canonicalTargets, otherOrdinaryTargets []string + for _, dest := range ordinaryTargets { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti))) + if strings.HasSuffix(base, "/.agents/skills") { + canonicalTargets = append(canonicalTargets, dest) + } else { + otherOrdinaryTargets = append(otherOrdinaryTargets, dest) + } + } + installOrdinary := func(names, targets []string) error { + if len(targets) == 0 { + return nil + } var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered) + n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered) installed += n skipped += nSkipped if err != nil { - return installed, skipped, err + return err } if nSkipped > 0 { - return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) + return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) } + return nil + } + canonicalNames := append([]string(nil), skillNames...) + if !containsSkillName(canonicalNames, multiMiscSkill) { + canonicalNames = append(canonicalNames, multiMiscSkill) + sort.Strings(canonicalNames) + } + if err := installOrdinary(canonicalNames, canonicalTargets); err != nil { + return installed, skipped, err + } + if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil { + return installed, skipped, err } // The folded pair is excluded from the ordinary best-effort installer. All // other selected skills (especially dingtalk-shared) must succeed before the // old Event route is touched. - for _, dest := range migrationTargets { + for _, dest := range physicalMigrationTargets { if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil { return installed, skipped + len(skillNames), cleanupErr } @@ -1617,9 +1705,9 @@ func installMultiSkillsWithEventMigration( prerequisiteNames = append(prerequisiteNames, name) } } - if len(prerequisiteNames) > 0 { + if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 { var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true) + n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true) installed += n skipped += nSkipped if err != nil { @@ -1630,7 +1718,7 @@ func installMultiSkillsWithEventMigration( } } - migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut) + migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut) installed += migrated if migrationErr != nil { return installed, skipped, migrationErr @@ -2025,10 +2113,6 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() - perTarget := 1 - if plan.Mode == skillSetupModeMulti { - perTarget = len(plan.MultiSkillNames) - } hasCanonicalDependents := false for _, candidate := range plan.Targets { if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { @@ -2037,6 +2121,10 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } } for _, target := range plan.Targets { + perTarget := 1 + if plan.Mode == skillSetupModeMulti { + perTarget = len(plan.MultiSkillNames) + } isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 85f823a3..45f9aff4 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -100,6 +100,42 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * } } +func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := skillSetupStat + testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + dests := detectExistingAgentHomes(home, skillSetupModeMulti) + for _, target := range []string{ + filepath.Join(home, ".config", "kimchi", "harness", "skills"), + filepath.Join(home, ".tabnine", "agent", "skills"), + filepath.Join(home, ".zcode", "skills"), + filepath.Join(home, ".minimax", "skills"), + } { + if !containsSkillName(dests, target) { + t.Errorf("detected targets %v missing %s", dests, target) + } + } +} + func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { home := t.TempDir() customClaude := filepath.Join(t.TempDir(), "claude") diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go new file mode 100644 index 00000000..7800bb74 --- /dev/null +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies +// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal +// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the +// Event/misc migration must not leave physical duplicates there. Universal +// homes read ~/.agents/skills directly, so their old folded copies are retired +// (backed up) and the split standalone event + clean misc land in canonical. +func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + // beta.6 physical copies alongside the folded misc. + for _, name := range []string{multiEventSkill, multiSharedSkill} { + if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + canonical := filepath.Join(home, ".agents", "skills") + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + + // P0: the universal home must not retain any physical dingtalk-* copy. + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) { + t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err) + } + } + // canonical owns the new standalone event + clean misc (+ shared). + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical missing %s: %v", name, err) + } + } + if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil { + t.Fatalf("canonical misc still contains folded Event content: %v", err) + } + if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil { + t.Fatalf("idempotent rerun failed: %v", err) + } +} + +func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + canonical := filepath.Join(home, ".agents", "skills") + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + writeOldStandaloneEvent(t, codexSkills) + chat := filepath.Join(codexSkills, "dingtalk-chat") + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil { + t.Fatal(err) + } + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"}) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes") + if err != nil { + t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if strings.Contains(stdout, "Event 原子迁移") { + t.Fatalf("unrelated selection migrated Event/misc: %s", stdout) + } + assertOldEventMiscPair(t, codexSkills) + if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" { + t.Fatalf("unselected chat changed: body=%q err=%v", body, err) + } +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index 02da441f..b5e38e9c 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -523,15 +523,25 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) - t.Run("ordinary and prerequisite install errors", func(t *testing.T) { + t.Run("ordinary install error", func(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill}) + copyCalls := 0 + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail }) + migration := filepath.Join(t.TempDir(), "migration") + ordinary := filepath.Join(t.TempDir(), "ordinary") + if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") { + t.Fatalf("ordinary install failure = %v", err) + } + if copyCalls == 0 { + t.Fatal("ordinary staging failure seam was not exercised") + } + }) + + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }) migration := filepath.Join(t.TempDir(), "migration") - ordinary := filepath.Join(t.TempDir(), "ordinary") - if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { - t.Fatalf("ordinary install failure = %v", err) - } if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { t.Fatalf("prerequisite install failure = %v", err) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index c08aa96a..79c0bbbe 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -161,6 +161,48 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) } } +func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := upgradeStat + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + want := map[string]bool{ + ".config/kimchi/harness/skills": false, + ".tabnine/agent/skills": false, + ".zcode/skills": false, + ".minimax/skills": false, + } + for _, root := range configuredSkillRoots(home) { + label := filepath.ToSlash(root.label) + if _, ok := want[label]; ok { + want[label] = skillRootDetectedBase(home, root) + } + } + for label, detected := range want { + if !detected { + t.Errorf("root %s was not detected", label) + } + } +} + func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { home := t.TempDir() custom := map[string]string{ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index c922a428..644b3f96 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -36,7 +36,7 @@ const ( // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // // The first entry (.agents/skills) is the canonical global Skill directory, -// matching the mechanism used by lark-cli's upstream `skills` installer. +// following the universal .agents/skills convention. // Agents that understand the universal .agents convention read it directly; // detected non-universal Agents receive relative links to this canonical // copy (with a direct-copy fallback when links are unavailable). @@ -141,6 +141,7 @@ var universalSkillDirs = map[string]bool{ var ( upgradeUserHomeDir = os.UserHomeDir + upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir @@ -689,9 +690,29 @@ func configuredSkillRoots(homeDir string) []resolvedSkillRoot { return roots } -func skillRootDetectedBase(base string) bool { - parentGate := filepath.Dir(base) - info, err := upgradeStat(parentGate) +func skillRootDetectedBase(homeDir string, root resolvedSkillRoot) bool { + detectedDir := filepath.Dir(root.base) + systemHome, _ := upgradeSystemHomeDir() + allowSystemApps := skillRootPathKey(homeDir) == skillRootPathKey(systemHome) + switch filepath.ToSlash(filepath.Clean(root.label)) { + case ".config/kimchi/harness/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true + } + } + } + info, err := upgradeStat(detectedDir) return err == nil && info.IsDir() } @@ -714,7 +735,7 @@ func skillRootPathKey(path string) string { func hasDependentSkillRoot(homeDir, canonicalBase string) bool { for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(homeDir, root) { continue } if !samePhysicalSkillRoot(root.base, canonicalBase) { @@ -824,7 +845,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -896,7 +917,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e238b0ca..dfee1cae 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -22,6 +22,16 @@ func withFakeHome(t *testing.T) string { t.Helper() home := t.TempDir() testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil }) + // Host-installed macOS apps must not make a fake HOME appear to have extra + // detected Agents. Tests that exercise application-based detection can + // override this seam explicitly. + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + applications := filepath.Join(string(filepath.Separator), "Applications") + if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") { + return nil, os.ErrNotExist + } + return os.Stat(path) + }) return home } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index c5fdb3b4..2ed13bee 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -90,6 +90,17 @@ function Resolve-AgentBase([string]$row) { return Join-Path $HOME $parts[2] } +function Test-AgentBaseDetected([string]$id, [string]$base) { + $parent = Split-Path $base -Parent + switch ($id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-PathLexically([string]$path) { try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf @@ -194,7 +205,7 @@ if (-not $NoSkills) { if ([string]::IsNullOrWhiteSpace($base)) { continue } $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } - if (-not (Test-Path (Split-Path $base -Parent))) { continue } + if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 4a360638..cb4c6802 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -85,6 +85,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -162,7 +172,7 @@ install_skill() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a45870e7..a046b865 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -155,6 +155,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -178,7 +188,7 @@ install_skill_to_homes() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 563582ec..2659cd60 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -408,8 +408,8 @@ multi_tree_has_skills() { return 1 } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -469,6 +469,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -557,8 +567,7 @@ install_multi_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -750,8 +759,7 @@ install_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/install.ps1 b/scripts/install.ps1 index eb90752f..7d6cb4c5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -582,6 +582,18 @@ function Resolve-AgentSkillBase { return (Join-Path $Root $Agent.Dir) } +function Test-AgentSkillBaseDetected { + param([string]$BaseDir, $Agent) + $parent = Split-Path $BaseDir -Parent + switch ($Agent.Id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-SamePhysicalSkillRoot { param([string]$Left, [string]$Right) if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } @@ -1005,8 +1017,7 @@ function Install-SkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { @@ -1073,8 +1084,7 @@ function Install-MultiSkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { diff --git a/scripts/install.sh b/scripts/install.sh index 3475b866..84df3ea5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -643,8 +643,8 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -708,6 +708,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -810,8 +820,7 @@ install_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -875,8 +884,7 @@ install_multi_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 4bfd2fb3..3e2cd6b4 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -188,6 +188,26 @@ resolve_agent_base() { esac } +# Package verification must never inherit the runner's real Agent roots. In +# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two +# parallel verification jobs would then publish links into the same directory +# and make one another's links appear broken. Keep every supported override +# inside the scenario-specific temporary HOME instead. +with_isolated_agent_env() ( + isolated_home=$1 + shift + HOME="$isolated_home" + AUTOHAND_HOME="$isolated_home/.autohand" + CLAUDE_CONFIG_DIR="$isolated_home/.claude" + CODEX_HOME="$isolated_home/.codex" + GROK_HOME="$isolated_home/.grok" + HERMES_HOME="$isolated_home/.hermes" + VIBE_HOME="$isolated_home/.vibe" + XDG_CONFIG_HOME="$isolated_home/.config" + export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME + "$@" +) + verify_skill_base() { home_root="$1" base="$2" @@ -217,8 +237,8 @@ verify_compatible_skill_base() { verify_skill_targets() { home_root="$1" - # lark-cli/skills-compatible canonical store is always present, regardless - # of which concrete Agent homes existed at install time. + # The universal-convention canonical store is always present, regardless of + # which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" seen='' for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do @@ -251,15 +271,15 @@ verify_npm_install() { npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" if [ "$scenario" = "specific-agent-roots" ]; then - seed_specific_agent_homes "$npm_home" + with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home" fi say "==> verifying npm package install ($scenario)" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" @@ -270,9 +290,9 @@ verify_npm_install() { if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" fi - [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null } diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go index 35a647b0..4b414380 100644 --- a/test/scripts/agent_registry_test.go +++ b/test/scripts/agent_registry_test.go @@ -123,6 +123,27 @@ func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing. } } +func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) { + t.Parallel() + checks := map[string][]string{ + "build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + } + for rel, wants := range checks { + body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel))) + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s missing detection contract %q", rel, want) + } + } + } +} + func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 945a8e61..04533c5a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -64,6 +64,7 @@ const require = createRequire(import.meta.url); const { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, @@ -76,6 +77,14 @@ assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, " assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); +const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-")); +fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true }); +fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true }); +const detectionTargets = resolvedAgentTargets(detectionHome); +for (const id of ["kimchi", "tabnine-cli"]) { + assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`); +} +fs.rmSync(detectionHome, { recursive: true, force: true }); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -203,15 +212,32 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => { +scenario("pinned universal global topology installs canonical and retires private copies", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", "multi/dws-shared/SKILL.md": "# dws-shared\n", }); try { - writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); - writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n"); + // The pinned upstream installer treats global universal Agents as + // canonical-only even when their registry also retains a distinct + // globalSkillsDir for legacy discovery/removal. Exercise every unique + // non-canonical universal root so this distinction cannot drift again. + const retiredUniversalRoots = [ + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".codex/skills", + ".cursor/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".gemini/skills", + ".copilot/skills", + ".config/opencode/skills", + ]; + for (const root of retiredUniversalRoots) { + writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`); + } writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -223,10 +249,12 @@ scenario("Codex reads the universal canonical root and beta.6 copies are migrate fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), "universal canonical Skill installed", ); - assert.ok( - !fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")), - "beta.6 Codex duplicate migrated", - ); + for (const root of retiredUniversalRoots) { + assert.ok( + !fs.existsSync(path.join(home, root, "dingtalk-chat")), + `beta.6 universal duplicate migrated from ${root}`, + ); + } assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy nested duplicate retired", diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index cc43e8cf..97ee3235 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -135,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } verifyCmd := exec.Command("sh", verifierPath, "--npm-only") - verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + hostXDG := filepath.Join(t.TempDir(), "host-xdg") + verifyCmd.Env = replaceTestEnv(os.Environ(), + "DWS_PACKAGE_DIST_DIR", distDir, + "XDG_CONFIG_HOME", hostXDG, + ) output, err := verifyCmd.CombinedOutput() if err != nil { t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) } + if _, err := os.Stat(hostXDG); !os.IsNotExist(err) { + t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err) + } for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)