@@ -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
9799func 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).
218240func 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+
266310func 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+
384475func 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