Skip to content

Commit 024dfd2

Browse files
committed
fix: detect APM-installed skills #400
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6157165 commit 024dfd2

8 files changed

Lines changed: 299 additions & 13 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,18 @@ project/
230230
└── fixtures/
231231
```
232232

233+
*APM-managed skills* are detected from their compiled output without symlinks:
234+
```
235+
project/
236+
├── skills/{skill-name}/apm.yml
237+
├── skills/{skill-name}/.apm/skills/{skill-name}/SKILL.md
238+
└── skills/{skill-name}/eval.yaml
239+
```
240+
241+
When both `skills/{skill-name}/SKILL.md` and the APM compiled
242+
`.apm/skills/{skill-name}/SKILL.md` exist for the same skill, the top-level
243+
`SKILL.md` takes precedence.
244+
233245
*Standalone mode* (no `skills/` detected):
234246
```
235247
{skill-name}/
@@ -729,8 +741,9 @@ With two refs, compares the first ref to the second.
729741
| `--threshold <n>` | Fail when any existing file increases by more than n percent (0 = disabled) |
730742

731743
Use `--skills` to restrict comparison to SKILL.md files under configured skill
732-
roots (`skills/`, `.github/skills/`, and `paths.skills` from `.waza.yaml`). In
733-
skills mode the default base ref is `origin/main` (falling back to `main`).
744+
roots (`skills/`, `.github/skills/`, APM `.apm/skills/` outputs, and
745+
`paths.skills` from `.waza.yaml`). In skills mode the default base ref is
746+
`origin/main` (falling back to `main`).
734747

735748
Use `--threshold` for CI gating — newly added files are exempt from threshold
736749
checks (no baseline) but still subject to absolute limit checks with `--strict`.

cmd/waza/cmd_run.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ func runCommandE(cmd *cobra.Command, args []string) error {
446446
type skillSpecPath struct {
447447
evalSpecPath string
448448
skillName string
449+
skillDir string
449450
}
450451

451452
type skillRunResult struct {
@@ -474,7 +475,11 @@ func resolveSpecPaths(args []string) ([]skillSpecPath, error) {
474475
if err != nil {
475476
return nil, err
476477
}
477-
return []skillSpecPath{{evalSpecPath: evalPath, skillName: wsCtx.Skills[0].Name}}, nil
478+
return []skillSpecPath{{
479+
evalSpecPath: evalPath,
480+
skillName: wsCtx.Skills[0].Name,
481+
skillDir: wsCtx.Skills[0].Dir,
482+
}}, nil
478483
}
479484

480485
// No args — workspace detection
@@ -493,7 +498,7 @@ func resolveSpecPaths(args []string) ([]skillSpecPath, error) {
493498
fmt.Printf("⚠️ Skipping %s: %v\n", si.Name, err)
494499
continue
495500
}
496-
paths = append(paths, skillSpecPath{evalSpecPath: evalPath, skillName: si.Name})
501+
paths = append(paths, skillSpecPath{evalSpecPath: evalPath, skillName: si.Name, skillDir: si.Dir})
497502
}
498503

499504
if len(paths) == 0 {
@@ -553,6 +558,9 @@ func printSkillRunSummary(results []skillRunResult) {
553558
// defaultSkills - skills found under the workspace folder, specified by .waza.yaml
554559
func runCommandForSpec(cmd *cobra.Command, sp skillSpecPath, defaultSkills []string) ([]modelResult, error) {
555560
specPath := sp.evalSpecPath
561+
if sp.skillDir != "" && !slices.Contains(defaultSkills, sp.skillDir) {
562+
defaultSkills = append(defaultSkills, sp.skillDir)
563+
}
556564

557565
// Load spec
558566
spec, err := models.LoadEvalSpec(specPath)

cmd/waza/cmd_run_workspace_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,46 @@ tasks:
8686
assert.NoError(t, err, "should find eval.yaml for the named skill")
8787
}
8888

89+
func TestRunCommand_WorkspaceDetection_APMInstalledSkill(t *testing.T) {
90+
resetRunGlobals()
91+
92+
dir := t.TempDir()
93+
sourceDir := filepath.Join(dir, "skills", "my-skill")
94+
compiledDir := filepath.Join(sourceDir, ".apm", "skills", "my-skill")
95+
require.NoError(t, os.MkdirAll(compiledDir, 0o755))
96+
97+
skillContent := "---\nname: my-skill\ndescription: \"test\"\n---\n# Body\n"
98+
require.NoError(t, os.WriteFile(filepath.Join(compiledDir, "SKILL.md"), []byte(skillContent), 0o644))
99+
100+
taskDir := filepath.Join(sourceDir, "tasks")
101+
require.NoError(t, os.MkdirAll(taskDir, 0o755))
102+
task := "id: t1\nname: Task One\ninputs:\n prompt: \"test\"\n"
103+
require.NoError(t, os.WriteFile(filepath.Join(taskDir, "task.yaml"), []byte(task), 0o644))
104+
105+
spec := `name: apm-eval
106+
skill: my-skill
107+
version: "1.0"
108+
config:
109+
trials_per_task: 1
110+
timeout_seconds: 30
111+
executor: mock
112+
model: test-model
113+
tasks:
114+
- "tasks/*.yaml"
115+
`
116+
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "eval.yaml"), []byte(spec), 0o644))
117+
118+
t.Chdir(dir)
119+
120+
cmd := newRunCommand()
121+
cmd.SetArgs(nil) // no args — workspace detection
122+
cmd.SetOut(io.Discard)
123+
cmd.SetErr(io.Discard)
124+
125+
err := cmd.Execute()
126+
assert.NoError(t, err, "workspace detection should find APM-installed skill eval.yaml")
127+
}
128+
89129
func TestRunCommand_ExplicitPath_BackwardCompat(t *testing.T) {
90130
resetRunGlobals()
91131

internal/workspace/workspace.go

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ type SkillInfo struct {
7777
Dir string // absolute path to the skill directory (containing SKILL.md)
7878
SkillPath string // absolute path to SKILL.md
7979
EvalPath string // absolute path to the eval file (empty if not found)
80+
SourceDir string // absolute path to the source skill directory when SKILL.md is compiled elsewhere
8081
}
8182

8283
// WorkspaceContext represents the detected workspace.
@@ -92,8 +93,9 @@ type WorkspaceContext struct {
9293
// It checks:
9394
// 1. CWD for SKILL.md → single-skill
9495
// 2. Walk up parents for SKILL.md → single-skill (nested inside skill dir)
95-
// 3. Check for skills/ directory with SKILL.md descendants → multi-skill
96-
// 4. Scan CWD for child dirs containing SKILL.md → multi-skill
96+
// 3. Check known compiled skill output directories such as .apm/skills/
97+
// 4. Check for skills/ directory with SKILL.md descendants → multi-skill
98+
// 5. Scan CWD for child dirs containing SKILL.md → multi-skill
9799
func DetectContext(dir string, opts ...DetectOption) (*WorkspaceContext, error) {
98100
o := defaultDetectOptions()
99101
for _, fn := range opts {
@@ -116,7 +118,7 @@ func DetectContext(dir string, opts ...DetectOption) (*WorkspaceContext, error)
116118
}, nil
117119
}
118120

119-
// 2. Walk up parent directories looking for SKILL.md
121+
// 2. Walk up parent directories looking for SKILL.md or known compiled outputs
120122
current := absDir
121123
for i := 0; i < maxParentWalk; i++ {
122124
parent := filepath.Dir(current)
@@ -134,6 +136,11 @@ func DetectContext(dir string, opts ...DetectOption) (*WorkspaceContext, error)
134136
EvalFile: o.evalFile,
135137
}, nil
136138
}
139+
if skills, err := scanDirectAPMSkills(current); err != nil {
140+
return nil, fmt.Errorf("scanning APM skills directory %s: %w", filepath.Join(current, ".apm", "skills"), err)
141+
} else if len(skills) > 0 {
142+
return contextFromSkills(current, skills, o), nil
143+
}
137144
}
138145

139146
// 3. Check for configured skills subdirectory with SKILL.md children
@@ -165,6 +172,20 @@ func DetectContext(dir string, opts ...DetectOption) (*WorkspaceContext, error)
165172
skills = mergeSkillsByName(skills, githubSkills)
166173
}
167174

175+
if isDir(skillsDir) {
176+
apmSkills, err := scanForAPMSkillsUnder(skillsDir)
177+
if err != nil {
178+
return nil, fmt.Errorf("scanning APM skills under %s: %w", skillsDir, err)
179+
}
180+
skills = mergeSkillsByName(skills, apmSkills)
181+
}
182+
183+
rootAPMSkills, err := scanDirectAPMSkills(absDir)
184+
if err != nil {
185+
return nil, fmt.Errorf("scanning APM skills directory %s: %w", filepath.Join(absDir, ".apm", "skills"), err)
186+
}
187+
skills = mergeSkillsByName(skills, rootAPMSkills)
188+
168189
if len(skills) > 0 {
169190
return &WorkspaceContext{
170191
Type: ContextMultiSkill,
@@ -212,8 +233,9 @@ func FindSkill(ctx *WorkspaceContext, name string) (*SkillInfo, error) {
212233

213234
// FindEval finds an eval file for a skill using priority order:
214235
// 1. {root}/evals/{skill-name}/{eval-file} (separated convention)
215-
// 2. {skill-dir}/evals/{eval-file} (nested subdir)
216-
// 3. {skill-dir}/{eval-file} (co-located/legacy)
236+
// 2. {source-or-skill-dir}/evals/{eval-file} (nested subdir)
237+
// 3. {source-or-skill-dir}/{eval-file} (co-located/legacy)
238+
// 4. {skill-dir}/evals/{eval-file} and {skill-dir}/{eval-file} for compiled skills
217239
// Returns empty string if none found (not an error).
218240
func FindEval(wsCtx *WorkspaceContext, skillName string) (string, error) {
219241
si, err := FindSkill(wsCtx, skillName)
@@ -246,23 +268,45 @@ func FindEval(wsCtx *WorkspaceContext, skillName string) (string, error) {
246268

247269
// Priority 2: nested subdir inside skill directory
248270
for _, evalFile := range evalFiles {
249-
nested := filepath.Join(si.Dir, "evals", evalFile)
271+
nested := filepath.Join(evalLookupDir(si), "evals", evalFile)
250272
if isFile(nested) {
251273
return nested, nil
252274
}
253275
}
254276

255277
// Priority 3: co-located / legacy
256278
for _, evalFile := range evalFiles {
257-
colocated := filepath.Join(si.Dir, evalFile)
279+
colocated := filepath.Join(evalLookupDir(si), evalFile)
258280
if isFile(colocated) {
259281
return colocated, nil
260282
}
261283
}
262284

285+
if si.SourceDir != "" && !samePath(si.SourceDir, si.Dir) {
286+
for _, evalFile := range evalFiles {
287+
nested := filepath.Join(si.Dir, "evals", evalFile)
288+
if isFile(nested) {
289+
return nested, nil
290+
}
291+
}
292+
for _, evalFile := range evalFiles {
293+
colocated := filepath.Join(si.Dir, evalFile)
294+
if isFile(colocated) {
295+
return colocated, nil
296+
}
297+
}
298+
}
299+
263300
return "", nil
264301
}
265302

303+
func evalLookupDir(si *SkillInfo) string {
304+
if si.SourceDir != "" {
305+
return si.SourceDir
306+
}
307+
return si.Dir
308+
}
309+
266310
func evalFilenames(configured string) []string {
267311
if configured == "" {
268312
configured = projectconfig.DefaultEvalFile
@@ -381,6 +425,53 @@ func scanForSkillsRecursive(parentDir string) ([]SkillInfo, error) {
381425
return skills, nil
382426
}
383427

428+
func scanDirectAPMSkills(dir string) ([]SkillInfo, error) {
429+
apmSkillsDir := filepath.Join(dir, ".apm", "skills")
430+
if !isDir(apmSkillsDir) {
431+
return nil, nil
432+
}
433+
skills, err := scanForSkills(apmSkillsDir, true)
434+
if err != nil {
435+
return nil, err
436+
}
437+
for i := range skills {
438+
skills[i].SourceDir = dir
439+
}
440+
return skills, nil
441+
}
442+
443+
func scanForAPMSkillsUnder(parentDir string) ([]SkillInfo, error) {
444+
var skills []SkillInfo
445+
err := filepath.WalkDir(parentDir, func(path string, d fs.DirEntry, err error) error {
446+
if err != nil {
447+
return fmt.Errorf("error walking %s: %w", path, err)
448+
}
449+
if !d.IsDir() {
450+
return nil
451+
}
452+
if path == parentDir {
453+
return nil
454+
}
455+
name := d.Name()
456+
if name == ".apm" {
457+
apmSkills, scanErr := scanDirectAPMSkills(filepath.Dir(path))
458+
if scanErr != nil {
459+
return scanErr
460+
}
461+
skills = append(skills, apmSkills...)
462+
return fs.SkipDir
463+
}
464+
if shouldSkipSkillScanDir(name) {
465+
return fs.SkipDir
466+
}
467+
return nil
468+
})
469+
if err != nil {
470+
return nil, err
471+
}
472+
return skills, nil
473+
}
474+
384475
func shouldSkipSkillScanDir(name string) bool {
385476
return strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor"
386477
}
@@ -438,6 +529,20 @@ func mergeSkillsByName(base, additional []SkillInfo) []SkillInfo {
438529
})
439530
}
440531

532+
func contextFromSkills(root string, skills []SkillInfo, o detectOptions) *WorkspaceContext {
533+
ctxType := ContextMultiSkill
534+
if len(skills) == 1 {
535+
ctxType = ContextSingleSkill
536+
}
537+
return &WorkspaceContext{
538+
Type: ctxType,
539+
Root: root,
540+
Skills: skills,
541+
EvalsDir: o.evalsDir,
542+
EvalFile: o.evalFile,
543+
}
544+
}
545+
441546
// LooksLikePath returns true if the string appears to be a file path
442547
// rather than a skill name. Exported so that CLI packages (cmd/waza,
443548
// cmd/waza/dev) can share the same heuristic without duplication.

0 commit comments

Comments
 (0)