Merge pull request #1669 from entireio/feat/codex-skill-discovery · Entire

Merge pull request #1669 from entireio/feat/codex-skill-discovery

ccf8498→main·

peyton-alt·4d ago·11 files·+700 added/-302 removed

feat(review): codex runs real skills — on-disk $name discovery + native invocation

Changes

11

1 unmodified line

2  
3  
4  
5  
5  
6  
7  
9  
10  
11  
12  
8  
9  
10  
5 unmodified lines

16  
17  
18  
24  
25  
26  
27  
19  
20  
21  
22  
23  
24  
29  
30  
31  
32  
33  
25  
26  
27  
28  
29  
30  
31  
32  
33  
3 unmodified lines

37  
38  
39  
40  
41  
44  
45  
46  
47  
48  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
194  
195  
196  
197  
198  
199  
200  
201  
202  
203  
204  
205  
206  
207  
208  
209  
210  
211  
212  
213  
214  
215  
216  
217  
218  
219  
220  
221  
222  
223  
224  
225  
226  
227  
228  
229  
230  
231  
232  
233  
234  
235  
236  
237  
238  
239  
240  
241  
242  
243  
244  
245  
246  
247  
248  
249  
250  
251  
252  
253  
254  
255  
256  
257  
258  
259  
260  
261  
262  
263  
264  
265  
266  
267  
268  
269  
270  
271  
272  
273  
274  
275  
276  
277  
278  
279  
280  
281  
282  
283  
284  
285  
286  
287  
288  
289  
290  
291  
292  
293  
294

1 unmodified line

import (
    "context"
    "errors"
    "log/slog"
    "os"
    "path/filepath"
    "sort"
    "strings"

"golang.org/x/mod/semver"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery"
5 unmodified lines

// (nil, nil) when HOME is unreadable or directories are missing — discovery
// is best-effort.
//
// Claude Code exposes three kinds of invocable content per plugin:
//   - skills:   <plugin>/skills/<name>/SKILL.md   (YAML frontmatter with name + description)
//   - commands: <plugin>/commands/<name>.md       (YAML frontmatter with description; name = filename)
//   - agents:   <plugin>/agents/<name>.md         (YAML frontmatter with description; name = filename)
// Claude Code exposes three kinds of invocable content per plugin, all invoked
// via the same slash-prefix syntax (`/name`, `/plugin:name`):
//   - skills:   <plugin>/skills/<name>/SKILL.md   (frontmatter: name + description)
//   - commands: <plugin>/commands/<name>.md       (frontmatter: description; name = filename)
//   - agents:   <plugin>/agents/<name>.md         (frontmatter: description; name = filename)
//
// All three are walked because users invoke them via the same slash-prefix
// syntax (`/plugin:name`) and any of them can be a review tool. The
// pr-review-toolkit plugin, for example, ships its review skills as
// commands/agents (not skills/), and was silently missed by a skills-only
// walker.
// All three are walked because any can be a review tool — the pr-review-toolkit
// plugin, for example, ships its review skills as commands/agents (not skills/).
//
// The generic SKILL.md / markdown scanning, version dedupe, and frontmatter
// parsing live in the shared skilldiscovery package; this method supplies the
// Claude-specific roots and slash invocation form.
//
//nolint:unparam // error return is part of SkillDiscoverer contract; future implementations may report hard failures
func (c *ClaudeCodeAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.DiscoveredSkill, error) {
3 unmodified lines

return nil, nil
}

form := skilldiscovery.SlashForm
var found []agent.DiscoveredSkill
found = append(found, scanPluginCache(ctx, filepath.Join(home, ".claude", "plugins", "cache"))...)
found = append(found, scanUserSkills(ctx, filepath.Join(home, ".claude", "skills"))...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "commands"), "")...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "agents"), "")...)
found = dedupeByInvocation(found)
found = append(found, skilldiscovery.ScanPluginCache(ctx, filepath.Join(home, ".claude", "plugins", "cache",
    func(versionRoot, pluginName string) []agent.DiscoveredSkill {
        var out []agent.DiscoveredSkill
        out = append(out, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName, form)...)
        out = append(out, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "commands"), pluginName, form)...)
        out = append(out, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "agents"), pluginName, form)...)
        return out
    })...)
found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".claude", "skills"), "", form)...)
found = append(found, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "commands"), "", form)...)
found = append(found, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "agents"), "", form)...)
found = skilldiscovery.DedupeByInvocation(found)
if len(found) == 0 {
    return nil, nil
}
return found, nil
}

// dedupeByInvocation collapses entries sharing an invocation name. Plugins
// can ship a skill and a same-named command wrapper that forwards to it;
// scan order keeps the skill over its wrapper.
func dedupeByInvocation(in []agent.DiscoveredSkill) []agent.DiscoveredSkill {
    if len(in) < 2 {
        return in
    }
    seen := make(map[string]struct{}, len(in))
    out := make([]agent.DiscoveredSkill, 0, len(in))
    for _, s := range in {
        if _, dup := seen[s.Name]; dup {
            continue
        }
        seen[s.Name] = struct{}{}
        out = append(out, s)
    }
    return out
}

// scanPluginCache walks <root>/<marketplace>/<plugin>/<version>/
// One plugin can contribute through any or all three directories.
//
// Multiple version directories per plugin are common after upgrades. Walking
// every version produces duplicate skills (same invocation name, same
// description) — confusing in the picker and wasteful in the prompt. We pick
// a single version per plugin via pickLatestVersion: prefer valid semver
// (highest), fall back to lexicographic max.
func scanPluginCache(ctx context.Context, root string) []agent.DiscoveredSkill {
    entries, err := os.ReadDir(root)
    if err != nil {
        logging.Debug(ctx, "claude-code discovery: plugin cache unreadable",
            slog.String("root", root), slog.String("error", err.Error()))
        return nil
    }
    var found []agent.DiscoveredSkill
    for _, marketEntry := range entries {
        if !marketEntry.IsDir() {
            continue
        }
        marketRoot := filepath.Join(root, marketEntry.Name())
        pluginEntries, err := os.ReadDir(marketRoot)
        if err != nil {
            continue
        }
        for _, pluginEntry := range pluginEntries {
            if !pluginEntry.IsDir() {
                continue
            }
            pluginName := pluginEntry.Name()
            pluginRoot := filepath.Join(marketRoot, pluginName)
            versionEntries, err := os.ReadDir(pluginRoot)
            if err != nil {
                continue
            }
            versionDir, ok := pickLatestVersion(versionEntries)
            if !ok {
                continue
            }
            discoveredSkills := readSkillsDir(ctx, filepath.Join(pluginRoot, versionDir), pluginName)
            found = append(found, discoveredSkills...)
        }
    }
    return found
}

// pickLatestVersion returns the name of the "newest" version directory among
// entries. Strategy:
//
//   - If any entry name parses as semver (with or without a leading "v"), pick
//     the highest semver among those that parse. Non-semver entries are
//     ignored when at least one semver entry exists.
//   - Otherwise, fall back to the lexicographic max of all directory names.
//     This handles the "unknown" sentinel some plugins ship and one-off names.
//
// Returns ("", false) if no usable directory entry exists.
func pickLatestVersion(entries []os.DirEntry) (string, bool) {
    var dirs []string
    for _, e := range entries {
        if e.IsDir() {
            dirs = append(dirs, e.Name())
        }
    }
    if len(dirs) == 0 {
        return "", false
    }
    var semverDirs []string
    for _, d := range dirs {
        if semver.IsValid(semverWithV(d)) {
            semverDirs = append(semverDirs, d)
        }
    }
    if len(semverDirs) > 0 {
        sort.Slice(semverDirs, func(i, j int) bool {
            return semver.Compare(semverWithV(semverDirs[i]), semverWithV(semverDirs[j])) > 0
        })
        return semverDirs[0], true
    }
    sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
    return dirs[0], true
}

// semverWithV ensures a version string has the "v" prefix that
// golang.org/x/mod/semver requires. Plugin version dirs are usually bare
// (e.g. "0.1.0"), but we tolerate either form.
func semverWithV(s string) string {
    if strings.HasPrefix(s, "v") {
        return s
    }
    return "v" + s
}

// scanUserSkills walks ~/.claude/skills/<skill>/SKILL.md.
func scanUserSkills(ctx context.Context, root string) []agent.DiscoveredSkill {
    return readSkillsDir(ctx, root, "" /* no plugin prefix */)
}

// readSkillsDir reads each skill subdirectory's SKILL.md, parses frontmatter,
// and emits a DiscoveredSkill if Matches() returns true.
func readSkillsDir(ctx context.Context, dir, pluginName string) []agent.DiscoveredSkill {
    entries, err := os.ReadDir(dir)
    if err != nil {
        return nil
    }
    var found []agent.DiscoveredSkill
    for _, skillEntry := range entries {
        if !skillEntry.IsDir() {
            continue
        }
        skillDir := filepath.Join(dir, skillEntry.Name())
        skillFile := filepath.Join(skillDir, "SKILL.md")
        data, err := os.ReadFile(skillFile) //nolint:gosec // G304: skillFile is constructed from a ReadDir walk under HOME, not user input
        if err != nil {
            continue
        }
        name, description, parseErr := parseSkillFrontmatter(data)
        if parseErr != nil {
            logging.Debug(ctx, "claude-code discovery: skipping malformed SKILL.md",
                slog.String("path", skillFile), slog.String("error", parseErr.Error()))
            continue
        }
        if name == "" {
            name = skillEntry.Name()
        }
        invocation := invocationName(name, pluginName)
        if !skilldiscovery.Matches(invocation, description) {
            continue
        }
        found = append(found, agent.DiscoveredSkill{
            Name:        invocation,
            Description: description,
            SourcePath:  skillFile,
        })
    }
    return found
}

// scanFlatMarkdownDir reads *.md files directly under dir (no nesting), parses
// their YAML frontmatter for `description:`, and derives the invocation name
// from the filename (stripping the .md suffix). Used for both plugin
// commands/agents and user-level ~/.claude/commands and ~/.claude/agents.
//
// Frontmatter shape differs from SKILL.md — no `name:` field, so the
// filename is the source of truth for the invocation name.
func scanFlatMarkdownDir(ctx context.Context, dir, pluginName string) []agent.DiscoveredSkill {
    entries, err := os.ReadDir(dir)
    if err != nil {
        return nil
    }
    var found []agent.DiscoveredSkill
    for _, entry := range entries {
        if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
            continue
        }
        baseName := strings.TrimSuffix(entry.Name(), ".md")
        if strings.EqualFold(baseName, "README") {
            continue
        }
        filePath := filepath.Join(dir, entry.Name())
        data, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is constructed from a ReadDir walk under HOME, not user input
        if err != nil {
            continue
        }
        _, description, parseErr := parseSkillFrontmatter(data)
        if parseErr != nil {
            logging.Debug(ctx, "claude-code discovery: skipping malformed command/agent",
                slog.String("path", filePath), slog.String("error", parseErr.Error()))
            continue
        }
        invocation := invocationName(baseName, pluginName)
        if !skilldiscovery.Matches(invocation, description) {
            continue
        }
        found = append(found, agent.DiscoveredSkill{
            Name:        invocation,
            Description: description,
            SourcePath:  filePath,
        })
    }
    return found
}

// invocationName builds the slash-prefixed invocation form. Plugin-prefixed
// names use "/plugin:name"; bare names use "/name".
func invocationName(name, pluginName string) string {
    if pluginName == "" {
        return "/" + name
    }
    return "/" + pluginName + ":" + name
}

// parseSkillFrontmatter extracts `name:` and `description:` from a minimal
// YAML frontmatter block. Purpose-built for the tiny subset of YAML these
// SKILL.md / command / agent files actually use.
//
// Trims surrounding double-quotes from values so `description: "foo bar"
// is returned as `foo bar` — the command/agent frontmatter quotes values;
// SKILL.md files usually don't.
func parseSkillFrontmatter(data []byte) (name, description string, err error) {
    s := string(data)
    if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
        return "", "", errors.New("no frontmatter delimiter")
    }
    body := strings.TrimPrefix(strings.TrimPrefix(s, "---\r\n"), "---\n")
    end := strings.Index(body, "\n---")
    if end < 0 {
        return "", "", errors.New("no closing frontmatter delimiter")
    }
    for _, line := range strings.Split(body[:end], "\n") {
        line = strings.TrimSpace(line)
        switch {
        case strings.HasPrefix(line, "name:"):
            name = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "name:")), `"`)
        case strings.HasPrefix(line, "description:"):
            description = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "description:")), `"`)
        }
    }
    return name, description, nil
}