11import { homedir } from "node:os" ;
22import { realpath } from "node:fs/promises" ;
33import { readdir , readFile } from "node:fs/promises" ;
4- import { basename , isAbsolute , join , resolve } from "node:path" ;
4+ import { basename , dirname , isAbsolute , join , resolve } from "node:path" ;
55import { pathIsInsideOrEqual } from "../util/path-contain.js" ;
66
77const SKILL_RELATIVE_DIRS = [
@@ -52,6 +52,14 @@ function skillBaseDirs(
5252 return [ ...pluginBases , ...projectBases , ...userBases ] ;
5353}
5454
55+ // User-global skill base dirs (`~/.corbits/skills`, …). Project-local skill
56+ // dirs resolve under the session cwd (already inside the workspace); these
57+ // home-based dirs live outside it, so read-only file tools need them as
58+ // trusted roots to open sibling files (see assembleSessionGate).
59+ export function userSkillBaseDirs ( home : string = homedir ( ) ) : string [ ] {
60+ return SKILL_RELATIVE_DIRS . map ( ( rel ) => join ( home , rel ) ) ;
61+ }
62+
5563function parseSkillRef ( ref : string ) : string {
5664 const idx = ref . indexOf ( ":" ) ;
5765 return idx === - 1 ? ref : ref . slice ( idx + 1 ) ;
@@ -119,16 +127,26 @@ async function bodyFromSkillPath(path: string): Promise<string | undefined> {
119127 return body . length > 0 ? body : undefined ;
120128}
121129
130+ /**
131+ * A resolved skill: the SKILL.md body (frontmatter stripped) plus the skill
132+ * directory that won, so callers can point the model at sibling files
133+ * (e.g. brand-guidelines.md) living next to SKILL.md.
134+ */
135+ export interface ResolvedSkill {
136+ body : string ;
137+ dir : string ;
138+ }
139+
122140/**
123141 * Resolve a path-like skill ref against `pluginRoot`. Absolute refs and
124142 * escapes outside the root are rejected. Accepts a SKILL.md file path or a
125143 * directory that contains SKILL.md. When the candidate exists, both sides are
126144 * realpath'd so a symlink under the root cannot escape to outside content.
127145 */
128- async function resolvePathLikeSkillBody (
146+ async function resolvePathLikeSkill (
129147 pluginRoot : string ,
130148 ref : string ,
131- ) : Promise < string | undefined > {
149+ ) : Promise < ResolvedSkill | undefined > {
132150 if ( isAbsolute ( ref ) ) return undefined ;
133151 const root = resolve ( pluginRoot ) ;
134152 const resolved = resolve ( root , ref ) ;
@@ -146,7 +164,9 @@ async function resolvePathLikeSkillBody(
146164 realpath ( root ) ,
147165 ] ) ;
148166 if ( ! pathIsInsideOrEqual ( realSkill , realRoot ) ) return undefined ;
149- return bodyFromSkillPath ( realSkill ) ;
167+ const body = await bodyFromSkillPath ( realSkill ) ;
168+ if ( body === undefined ) return undefined ;
169+ return { body, dir : dirname ( realSkill ) } ;
150170 } catch {
151171 // Missing path (or unreadable root) → not a resolvable skill.
152172 return undefined ;
@@ -160,31 +180,42 @@ async function resolvePathLikeSkillBody(
160180// project-local then user-global fallbacks). Path-like refs (`./skills/style`,
161181// `skills/foo`) resolve only under `options.pluginRoot` with containment
162182// checks; absolute and escape paths fail.
163- export async function resolveSkillBody (
183+ export async function resolveSkillWithDir (
164184 cwd : string ,
165185 ref : string ,
166186 pluginDirs : string [ ] = [ ] ,
167187 options ?: ResolveSkillBodyOptions ,
168- ) : Promise < string | undefined > {
188+ ) : Promise < ResolvedSkill | undefined > {
169189 const name = parseSkillRef ( ref ) ;
170190 // Bare `.` / `..` are not skill names and must not fall through to directory search.
171191 if ( name === "." || name === ".." ) return undefined ;
172192 if ( isPathLikeSkillRef ( name ) ) {
173193 const pluginRoot = options ?. pluginRoot ;
174194 if ( pluginRoot === undefined ) return undefined ;
175- return resolvePathLikeSkillBody ( pluginRoot , name ) ;
195+ return resolvePathLikeSkill ( pluginRoot , name ) ;
176196 }
177197 for ( const base of skillBaseDirs (
178198 cwd ,
179199 pluginDirs ,
180200 options ?. pluginDirsOnly !== true ,
181201 ) ) {
182- const body = await bodyFromSkillPath ( join ( base , name , "SKILL.md" ) ) ;
183- if ( body !== undefined ) return body ;
202+ const dir = join ( base , name ) ;
203+ const body = await bodyFromSkillPath ( join ( dir , "SKILL.md" ) ) ;
204+ if ( body !== undefined ) return { body, dir } ;
184205 }
185206 return undefined ;
186207}
187208
209+ export async function resolveSkillBody (
210+ cwd : string ,
211+ ref : string ,
212+ pluginDirs : string [ ] = [ ] ,
213+ options ?: ResolveSkillBodyOptions ,
214+ ) : Promise < string | undefined > {
215+ const resolved = await resolveSkillWithDir ( cwd , ref , pluginDirs , options ) ;
216+ return resolved ?. body ;
217+ }
218+
188219// Discover every available skill (name + one-line description). Deduped by name:
189220// the first base dir that provides a skill wins, so a higher-precedence dir
190221// shadows a lower one. Descriptions feed skill_search and the slash picker; the
0 commit comments