From 442356c3560a677d0d16455690faa8c6b654d886 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:51:51 +0200 Subject: [PATCH 1/2] use versioned PyPI endpoint for pinned packages to avoid huge release-index downloads --- cmd/dotagents/package_age.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmd/dotagents/package_age.go b/cmd/dotagents/package_age.go index 2dba176..d6b9c37 100644 --- a/cmd/dotagents/package_age.go +++ b/cmd/dotagents/package_age.go @@ -310,6 +310,9 @@ func resolvePackageRelease(ref packageReference) (packageRelease, error) { } func resolvePyPIRelease(ref packageReference) (packageRelease, error) { + if ref.Version != "" && ref.Version != packageVersionLatest { + return resolvePyPIVersionedRelease(ref) + } endpoint := "https://pypi.org/pypi/" + url.PathEscape(ref.Package) + "/json" var doc struct { Info struct { @@ -337,6 +340,28 @@ func resolvePyPIRelease(ref packageReference) (packageRelease, error) { return packageRelease{Version: version, Released: released, SourceURL: endpoint}, nil } +// resolvePyPIVersionedRelease uses the per-version endpoint, which omits the +// full release index that can exceed the HTTP timeout for large packages. +func resolvePyPIVersionedRelease(ref packageReference) (packageRelease, error) { + endpoint := "https://pypi.org/pypi/" + url.PathEscape(ref.Package) + "/" + url.PathEscape(ref.Version) + "/json" + var doc struct { + URLs []struct { + UploadTime string `json:"upload_time_iso_8601"` + } `json:"urls"` + } + if err := getJSON(endpoint, &doc); err != nil { + return packageRelease{}, err + } + if len(doc.URLs) == 0 { + return packageRelease{}, fmt.Errorf("version %q not found", ref.Version) + } + released, err := time.Parse(time.RFC3339, strings.TrimSuffix(doc.URLs[0].UploadTime, "Z")+"Z") + if err != nil { + return packageRelease{}, fmt.Errorf("parse upload time: %w", err) + } + return packageRelease{Version: ref.Version, Released: released, SourceURL: endpoint}, nil +} + func resolveNPMRelease(ref packageReference) (packageRelease, error) { endpoint := "https://registry.npmjs.org/" + url.PathEscape(ref.Package) endpoint = strings.ReplaceAll(endpoint, "%2F", "/") From 4a4e90219ca445e691ab79fb9337e2550feb6476 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:11:54 +0200 Subject: [PATCH 2/2] clean up superseded plugin version links and manage plugin skills under claude plugin delivery --- cmd/dotagents/delivery.go | 61 +++++++++++++++++++++++++++++++--- cmd/dotagents/delivery_test.go | 53 +++++++++++++++++++++++++++++ cmd/dotagents/inspect.go | 44 +++++++++++++++--------- cmd/dotagents/plugins.go | 21 ++++++++++++ cmd/dotagents/plugins_test.go | 43 ++++++++++++++++++++++++ cmd/dotagents/setup.go | 35 ++++++++++++++++--- cmd/dotagents/setup_test.go | 55 ++++++++++++++++++++++++++++++ cmd/dotagents/sync.go | 46 ++++++++++++++++--------- 8 files changed, 320 insertions(+), 38 deletions(-) diff --git a/cmd/dotagents/delivery.go b/cmd/dotagents/delivery.go index 657ff12..d67f4a6 100644 --- a/cmd/dotagents/delivery.go +++ b/cmd/dotagents/delivery.go @@ -32,13 +32,66 @@ func inspectPluginDeliveryAgent(agent agentConfig, repoRoot string, agentsSkillR return report, nil } - skills, external, err := claudeManagedSkillArtifacts(agent.SkillRoot, repoRoot, agentsSkillRoot) + // Repo skills arrive through the native Claude plugin, so their symlinks + // are pruned; codex-plugin skills still need symlink delivery. + expected, err := discoverPluginSkills(cfg.Plugins, home, agent.Name) if err != nil { return agentReport{}, err } - report.StaleManaged = append(report.StaleManaged, skills...) - report.Removes = append(report.Removes, skills...) - report.External = append(report.External, external...) + report.ExpectedSkills = expected + sourceRoots := pluginSourceRootsForAgent(cfg.Plugins, home, agent.Name) + + seen := make(map[string]bool) + entries, err := os.ReadDir(agent.SkillRoot) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err) + } + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + seen[name] = true + path := filepath.Join(agent.SkillRoot, name) + if entry.Type()&os.ModeSymlink == 0 { + if _, ok := expected[name]; ok { + report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but is not a symlink", path)) + continue + } + report.External = append(report.External, name) + continue + } + rawTarget, err := os.Readlink(path) + if err != nil { + return agentReport{}, fmt.Errorf("readlink %s: %w", path, err) + } + if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) { + report.StaleManaged = append(report.StaleManaged, name) + report.Removes = append(report.Removes, name) + continue + } + if target, ok := expected[name]; ok { + if linkMatches(path, rawTarget, target) { + report.Managed = append(report.Managed, name) + } else { + report.Drifted = append(report.Drifted, name) + report.Updates = append(report.Updates, name) + } + continue + } + if isPluginSkillLink(path, rawTarget, sourceRoots) { + report.StaleManaged = append(report.StaleManaged, name) + report.Removes = append(report.Removes, name) + continue + } + report.External = append(report.External, name) + } + for _, name := range sortedKeys(expected) { + if !seen[name] { + report.Missing = append(report.Missing, name) + report.Adds = append(report.Adds, name) + } + } agentFiles, err := claudeManagedAgentArtifacts(agent.AgentRoot) if err != nil { diff --git a/cmd/dotagents/delivery_test.go b/cmd/dotagents/delivery_test.go index 07b9049..48500e2 100644 --- a/cmd/dotagents/delivery_test.go +++ b/cmd/dotagents/delivery_test.go @@ -98,6 +98,59 @@ instructions: Test helper. } } +func TestRunSyncManagesPluginSkillsForClaudePluginDelivery(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DOTAGENTS_ROOT", repoRoot) + + pluginSource := filepath.Join(home, "plugins", "browser") + currentSkillDir := filepath.Join(pluginSource, "2.0.0", "skills", "browser-skill") + writeSyncTestFile(t, filepath.Join(currentSkillDir, "SKILL.md"), []byte("---\nname: browser-skill\n---\n")) + + writeSyncTestFile(t, filepath.Join(repoRoot, "dotagents.yaml"), []byte(`version: 1 +agents: + - name: claude-code + enabled: true + delivery: plugin + skill_root: ~/.claude/skills + agent_root: ~/.claude/agents +plugins: + - name: browser + enabled: true + source: `+pluginSource+` + format: codex-plugin + surfaces: + - skills + agents: + - claude-code +`)) + + claudeSkillRoot := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeSkillRoot, 0o755); err != nil { + t.Fatal(err) + } + staleLink := filepath.Join(claudeSkillRoot, "dropped-skill") + if err := os.Symlink(filepath.Join(pluginSource, "1.0.0", "skills", "dropped-skill"), staleLink); err != nil { + t.Fatal(err) + } + + if err := runSync(runOptions{Agents: agentClaudeCode}); err != nil { + t.Fatal(err) + } + + linked, err := os.Readlink(filepath.Join(claudeSkillRoot, "browser-skill")) + if err != nil { + t.Fatalf("expected plugin skill symlink: %v", err) + } + if linked != currentSkillDir { + t.Fatalf("browser-skill -> %s, want %s", linked, currentSkillDir) + } + if _, err := os.Lstat(staleLink); !os.IsNotExist(err) { + t.Fatalf("stale plugin link still exists, stat err = %v", err) + } +} + func TestClaudeDeliveryCheckPluginInstalled(t *testing.T) { home := t.TempDir() repoRoot := t.TempDir() diff --git a/cmd/dotagents/inspect.go b/cmd/dotagents/inspect.go index 3b5764d..b3a3efd 100644 --- a/cmd/dotagents/inspect.go +++ b/cmd/dotagents/inspect.go @@ -217,6 +217,7 @@ func inspectAgent(agent agentConfig, expected map[string]string, repoRoot string if err != nil { return agentReport{}, err } + pluginSkillBases = append(pluginSkillBases, pluginSourceRootsForAgent(cfg.Plugins, home, agent.Name)...) if !rootMissing { for name, entry := range entryMap { if _, ok := expected[name]; ok { @@ -386,7 +387,7 @@ func inspectHermesAgent(agent agentConfig, expected map[string]string, agentsSki if err != nil { return agentReport{}, err } - ok, err := hermesHasExternalSkillsDirs(expectedDirs) + ok, stale, err := hermesExternalSkillsDirsState(expectedDirs, cfg, home) if err != nil { return agentReport{}, err } @@ -394,6 +395,10 @@ func inspectHermesAgent(agent agentConfig, expected map[string]string, agentsSki report.Missing = append(report.Missing, "config skills.external_dirs") report.Adds = append(report.Adds, "config skills.external_dirs") } + for _, dir := range stale { + report.StaleManaged = append(report.StaleManaged, "config dir "+dir) + report.Removes = append(report.Removes, "config dir "+dir) + } report.Managed = append(report.Managed, sortedKeys(report.ExpectedSkills)...) if err := augmentMCPReport(&report, agent, cfg, home); err != nil { return agentReport{}, err @@ -417,39 +422,46 @@ func hermesExternalSkillDirs(agentsSkillRoot string, cfg config, home string) ([ return dirs, nil } -func hermesHasExternalSkillsDirs(expected []string) (bool, error) { +func hermesExternalSkillsDirsState(expected []string, cfg config, configHome string) (bool, []string, error) { home, err := os.UserHomeDir() if err != nil { - return false, fmt.Errorf("resolve home: %w", err) + return false, nil, fmt.Errorf("resolve home: %w", err) } configPath := filepath.Join(home, ".hermes", "config.yaml") data, err := os.ReadFile(configPath) if err != nil { - return false, fmt.Errorf("read %s: %w", configPath, err) + return false, nil, fmt.Errorf("read %s: %w", configPath, err) } var raw map[string]interface{} if err := yaml.Unmarshal(data, &raw); err != nil { - return false, fmt.Errorf("parse %s: %w", configPath, err) + return false, nil, fmt.Errorf("parse %s: %w", configPath, err) } skillsRaw, ok := raw["skills"] if !ok { - return false, nil + return false, nil, nil } skills, ok := skillsRaw.(map[string]interface{}) if !ok { - return false, nil + return false, nil, nil } dirsRaw, ok := skills["external_dirs"] if !ok { - return false, nil + return false, nil, nil } dirs, ok := dirsRaw.([]interface{}) if !ok { - return false, nil + return false, nil, nil + } + + expectedSet := make(map[string]bool, len(expected)) + for _, dir := range expected { + expectedSet[dir] = true } + pruneRoots := hermesStaleDirPruneRoots(cfg, configHome) + var stale []string found := make(map[string]bool, len(expected)) for _, d := range dirs { s, ok := d.(string) @@ -457,19 +469,21 @@ func hermesHasExternalSkillsDirs(expected []string) (bool, error) { continue } expanded := expandPath(strings.TrimSpace(s), home) - for _, dir := range expected { - if expanded == dir { - found[dir] = true - } + if expectedSet[expanded] { + found[expanded] = true + continue + } + if pathUnderAny(expanded, pruneRoots) { + stale = append(stale, expanded) } } for _, dir := range expected { if !found[dir] { - return false, nil + return false, stale, nil } } - return true, nil + return true, stale, nil } func linkMatches(linkPath string, rawTarget string, expectedTarget string) bool { diff --git a/cmd/dotagents/plugins.go b/cmd/dotagents/plugins.go index 2168d26..0f490bb 100644 --- a/cmd/dotagents/plugins.go +++ b/cmd/dotagents/plugins.go @@ -123,6 +123,27 @@ func allPluginSkillBasesForAgent(plugins []pluginConfig, home string, agentName return pluginSkillBasesForAgentMode(plugins, home, agentName, false) } +// pluginSourceRootsForAgent returns plugin source paths without version +// resolution, so links into superseded version dirs still classify as +// plugin-managed instead of external. +func pluginSourceRootsForAgent(plugins []pluginConfig, home string, agentName string) []string { + var roots []string + for _, plugin := range plugins { + if strings.TrimSpace(plugin.Source) == "" { + continue + } + if !pluginTargetsAgent(plugin, agentName) || !pluginHasSurface(plugin, pluginSurfaceSkills) { + continue + } + root, err := pluginSourcePath(plugin, home) + if err != nil { + continue + } + roots = append(roots, root) + } + return roots +} + func pluginSkillBasesForAgentMode(plugins []pluginConfig, home string, agentName string, enabledOnly bool) ([]string, error) { var bases []string for _, plugin := range plugins { diff --git a/cmd/dotagents/plugins_test.go b/cmd/dotagents/plugins_test.go index 64ac88c..3cfe313 100644 --- a/cmd/dotagents/plugins_test.go +++ b/cmd/dotagents/plugins_test.go @@ -208,6 +208,49 @@ func TestInspectAgentRemovesDisabledPluginSkillLinks(t *testing.T) { } } +func TestInspectAgentRemovesSupersededPluginVersionLinks(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + agentsSkillRoot := filepath.Join(home, ".agents", "skills") + source := filepath.Join(home, "plugins", "browser") + + currentSkillDir := filepath.Join(source, "2.0.0", "skills", "browser") + if err := os.MkdirAll(currentSkillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(currentSkillDir, "SKILL.md"), []byte("---\nname: browser\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + + agentSkillRoot := filepath.Join(home, ".codex", "skills") + if err := os.MkdirAll(agentSkillRoot, 0o755); err != nil { + t.Fatal(err) + } + // Link into a superseded version dir that no longer exists on disk. + staleTarget := filepath.Join(source, "1.0.0", "skills", "dropped-skill") + if err := os.Symlink(staleTarget, filepath.Join(agentSkillRoot, "dropped-skill")); err != nil { + t.Fatal(err) + } + + cfg := config{Plugins: []pluginConfig{{ + Name: "browser", + Enabled: true, + Source: source, + Surfaces: []string{pluginSurfaceSkills}, + Agents: []string{agentCodex}, + }}} + report, err := inspectAgent(agentConfig{Name: agentCodex, SkillRoot: agentSkillRoot}, map[string]string{}, repoRoot, agentsSkillRoot, cfg, home) + if err != nil { + t.Fatal(err) + } + if len(report.StaleManaged) != 1 || report.StaleManaged[0] != "dropped-skill" { + t.Fatalf("stale managed = %#v, want dropped-skill", report.StaleManaged) + } + if len(report.External) != 0 { + t.Fatalf("external = %#v, want none", report.External) + } +} + func TestAllPluginSkillBasesSkipsMissingDisabledPluginSource(t *testing.T) { home := t.TempDir() diff --git a/cmd/dotagents/setup.go b/cmd/dotagents/setup.go index c9dbc7c..a213f0d 100644 --- a/cmd/dotagents/setup.go +++ b/cmd/dotagents/setup.go @@ -260,21 +260,31 @@ func patchHermesConfig(home string, cfg config) (bool, error) { } targets = append(targets, pluginTargets...) + targetSet := make(map[string]bool, len(targets)) + for _, target := range targets { + targetSet[target] = true + } + pruneRoots := hermesStaleDirPruneRoots(cfg, home) + existing := make(map[string]bool) dirsRaw, ok := skills["external_dirs"] var dirs []interface{} + changed := !ok if ok { if existingDirs, ok := dirsRaw.([]interface{}); ok { - dirs = append(dirs, existingDirs...) - for _, d := range dirs { - if s, ok := d.(string); ok { + for _, d := range existingDirs { + if s, isStr := d.(string); isStr { expanded := expandPath(strings.TrimSpace(s), home) + if !targetSet[expanded] && pathUnderAny(expanded, pruneRoots) { + changed = true + continue + } existing[expanded] = true } + dirs = append(dirs, d) } } } - changed := !ok for _, target := range targets { if existing[target] { continue @@ -297,6 +307,23 @@ func patchHermesConfig(home string, cfg config) (bool, error) { return true, nil } +// hermesStaleDirPruneRoots returns roots under which non-target external_dirs +// entries are dotagents-managed leftovers (superseded plugin version dirs and +// the legacy ~/.agents/plugin-roots location) and safe to prune. +func hermesStaleDirPruneRoots(cfg config, home string) []string { + roots := pluginSourceRootsForAgent(cfg.Plugins, home, agentHermes) + return append(roots, filepath.Join(home, ".agents", "plugin-roots")) +} + +func pathUnderAny(path string, roots []string) bool { + for _, root := range roots { + if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) { + return true + } + } + return false +} + func hermesExternalDirValue(home string, target string) string { if target == filepath.Join(home, ".agents", "skills") { return dotagentsSkillsPathValue diff --git a/cmd/dotagents/setup_test.go b/cmd/dotagents/setup_test.go index 8fe8bd5..c86dfe1 100644 --- a/cmd/dotagents/setup_test.go +++ b/cmd/dotagents/setup_test.go @@ -137,6 +137,61 @@ func TestPatchHermesConfigAddsPluginSkillDirs(t *testing.T) { } } +func TestPatchHermesConfigPrunesStalePluginVersionDirs(t *testing.T) { + home := t.TempDir() + configPath := filepath.Join(home, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + + pluginSource := filepath.Join(home, "plugins", "browser") + currentSkillDir := filepath.Join(pluginSource, "2.0.0", "skills", "browser") + if err := os.MkdirAll(currentSkillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(currentSkillDir, "SKILL.md"), []byte("---\nname: browser\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + + staleVersionDir := filepath.Join(pluginSource, "1.0.0", "skills") + legacyRootDir := filepath.Join(home, ".agents", "plugin-roots", "claude", "frontend-design", "skills") + configBody := "skills:\n external_dirs:\n - ~/keep\n - " + staleVersionDir + "\n - " + legacyRootDir + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o644); err != nil { + t.Fatal(err) + } + + patched, err := patchHermesConfig(home, config{Plugins: []pluginConfig{{ + Name: "browser", + Enabled: true, + Source: pluginSource, + Surfaces: []string{pluginSurfaceSkills}, + Agents: []string{agentHermes}, + }}}) + if err != nil { + t.Fatal(err) + } + if !patched { + t.Fatal("patchHermesConfig reported no changes") + } + + out, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(out, &raw); err != nil { + t.Fatal(err) + } + skills := raw["skills"].(map[string]interface{}) + dirs := skills["external_dirs"].([]interface{}) + if containsInterfaceString(dirs, staleVersionDir) || containsInterfaceString(dirs, legacyRootDir) { + t.Fatalf("stale dirs not pruned: %#v", dirs) + } + if !containsInterfaceString(dirs, "~/keep") || !containsInterfaceString(dirs, filepath.Join(pluginSource, "2.0.0", "skills")) { + t.Fatalf("external_dirs = %#v", dirs) + } +} + func containsInterfaceString(items []interface{}, want string) bool { for _, item := range items { if item == want { diff --git a/cmd/dotagents/sync.go b/cmd/dotagents/sync.go index 67cfc22..8a8d566 100644 --- a/cmd/dotagents/sync.go +++ b/cmd/dotagents/sync.go @@ -177,6 +177,9 @@ func applyAgentSync(reports []agentReport, cfg config, home string) error { if err := pruneManagedSkillLinks(report.SkillRoot, report.Removes); err != nil { return err } + if err := linkExpectedSkills(report); err != nil { + return err + } continue } h := harnessFor(report.Name) @@ -198,22 +201,35 @@ func applyAgentSync(reports []agentReport, cfg config, home string) error { } } - for _, name := range append(append([]string{}, report.Adds...), report.Updates...) { - path := filepath.Join(report.SkillRoot, name) - if _, err := os.Lstat(path); err == nil { - if err := os.Remove(path); err != nil { - return fmt.Errorf("remove %s before relink: %w", path, err) - } - } else if !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("stat %s: %w", path, err) - } - target, ok := report.ExpectedSkills[name] - if !ok { - return fmt.Errorf("%s expected skill %q has no target", report.Name, name) - } - if err := os.Symlink(target, path); err != nil { - return fmt.Errorf("symlink %s -> %s: %w", path, target, err) + if err := linkExpectedSkills(report); err != nil { + return err + } + } + return nil +} + +func linkExpectedSkills(report agentReport) error { + if len(report.Adds)+len(report.Updates) == 0 { + return nil + } + if err := os.MkdirAll(report.SkillRoot, 0o755); err != nil { + return fmt.Errorf("create %s: %w", report.SkillRoot, err) + } + for _, name := range append(append([]string{}, report.Adds...), report.Updates...) { + path := filepath.Join(report.SkillRoot, name) + if _, err := os.Lstat(path); err == nil { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove %s before relink: %w", path, err) } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("stat %s: %w", path, err) + } + target, ok := report.ExpectedSkills[name] + if !ok { + return fmt.Errorf("%s expected skill %q has no target", report.Name, name) + } + if err := os.Symlink(target, path); err != nil { + return fmt.Errorf("symlink %s -> %s: %w", path, target, err) } } return nil