Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions cmd/dotagents/delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions cmd/dotagents/delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
44 changes: 29 additions & 15 deletions cmd/dotagents/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -386,14 +387,18 @@ 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
}
if !ok {
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
Expand All @@ -417,59 +422,68 @@ 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)
if !ok {
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 {
Expand Down
25 changes: 25 additions & 0 deletions cmd/dotagents/package_age.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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", "/")
Expand Down
21 changes: 21 additions & 0 deletions cmd/dotagents/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions cmd/dotagents/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading