diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 16771ad9..9e366eac 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -5,6 +5,7 @@ import type { AgentConfig } from "@opencode-ai/sdk" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils" +import { resolveSkillPathReferences } from "../../shared/skill-path-resolver" import { log } from "../../shared/logger" import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" import { transformMcpServer } from "../claude-code-mcp-loader/transformer" @@ -297,11 +298,12 @@ export function loadPluginSkillsAsCommands( const originalDescription = data.description || "" const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}` + const resolvedBody = resolveSkillPathReferences(body.trim(), resolvedPath) const wrappedTemplate = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. -${body.trim()} +${resolvedBody} diff --git a/src/features/opencode-skill-loader/async-loader.ts b/src/features/opencode-skill-loader/async-loader.ts index 54f04b8c..55148bca 100644 --- a/src/features/opencode-skill-loader/async-loader.ts +++ b/src/features/opencode-skill-loader/async-loader.ts @@ -5,6 +5,7 @@ import yaml from "js-yaml" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils" +import { resolveSkillPathReferences } from "../../shared/skill-path-resolver" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkill } from "./types" import type { SkillMcpConfig } from "../skill-mcp-manager/types" @@ -90,11 +91,12 @@ export async function loadSkillFromPathAsync( const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const formattedDescription = `(${scope} - Skill) ${originalDescription}` + const resolvedBody = resolveSkillPathReferences(body.trim(), resolvedPath) const wrappedTemplate = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. -${body.trim()} +${resolvedBody} diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 59522475..f3d22d57 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -4,6 +4,7 @@ import yaml from "js-yaml" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" +import { resolveSkillPathReferences } from "../../shared/skill-path-resolver" import { getClaudeConfigDir } from "../../shared" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import type { CommandDefinition } from "../claude-code-command-loader/types" @@ -84,11 +85,12 @@ async function loadSkillFromPath( const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const formattedDescription = `(${scope} - Skill) ${originalDescription}` + const resolvedBody = resolveSkillPathReferences(body.trim(), resolvedPath) const templateContent = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. -${body.trim()} +${resolvedBody} diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts index cace1a22..e40c2752 100644 --- a/src/features/opencode-skill-loader/merger.ts +++ b/src/features/opencode-skill-loader/merger.ts @@ -8,6 +8,7 @@ import { homedir } from "os" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { deepMerge } from "../../shared/deep-merge" +import { resolveSkillPathReferences } from "../../shared/skill-path-resolver" function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined { if (!allowedTools) return undefined @@ -105,11 +106,12 @@ function configEntryToLoaded( const description = entry.description || fileMetadata.description || "" const resolvedPath = entry.from ? dirname(resolveFilePath(entry.from, configDir)) : configDir || process.cwd() + const resolvedTemplate = resolveSkillPathReferences(template.trim(), resolvedPath) const wrappedTemplate = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. -${template.trim()} +${resolvedTemplate} diff --git a/src/shared/skill-path-resolver.test.ts b/src/shared/skill-path-resolver.test.ts new file mode 100644 index 00000000..e0e3b854 --- /dev/null +++ b/src/shared/skill-path-resolver.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "bun:test" +import { resolveSkillPathReferences } from "./skill-path-resolver" + +describe("resolveSkillPathReferences", () => { + it("resolves @path references containing a slash to absolute paths", () => { + //#given + const content = "Run `python3 @scripts/search.py` to search" + const basePath = "/home/user/.config/opencode/skills/frontend-ui-ux" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe( + "Run `python3 /home/user/.config/opencode/skills/frontend-ui-ux/scripts/search.py` to search" + ) + }) + + it("resolves multiple @path references in the same content", () => { + //#given + const content = "Script: @scripts/search.py\nData: @data/styles.csv" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe( + "Script: /skills/frontend/scripts/search.py\nData: /skills/frontend/data/styles.csv" + ) + }) + + it("resolves directory references with trailing slash", () => { + //#given + const content = "Data files: @data/" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("Data files: /skills/frontend/data/") + }) + + it("does not resolve single-segment @references without slash", () => { + //#given + const content = "@param value @ts-ignore @path" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("@param value @ts-ignore @path") + }) + + it("does not resolve email addresses", () => { + //#given + const content = "Contact user@example.com for help" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("Contact user@example.com for help") + }) + + it("handles deeply nested path references", () => { + //#given + const content = "@data/stacks/html-tailwind.csv" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("/skills/frontend/data/stacks/html-tailwind.csv") + }) + + it("returns content unchanged when no @path references exist", () => { + //#given + const content = "No path references here" + const basePath = "/skills/frontend" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("No path references here") + }) + + it("handles basePath with trailing slash", () => { + //#given + const content = "@scripts/search.py" + const basePath = "/skills/frontend/" + + //#when + const result = resolveSkillPathReferences(content, basePath) + + //#then + expect(result).toBe("/skills/frontend/scripts/search.py") + }) +}) diff --git a/src/shared/skill-path-resolver.ts b/src/shared/skill-path-resolver.ts new file mode 100644 index 00000000..0ac8465e --- /dev/null +++ b/src/shared/skill-path-resolver.ts @@ -0,0 +1,17 @@ +import { join } from "path" + +/** + * Resolves @path references in skill content to absolute paths. + * + * Matches @references that contain at least one slash (e.g., @scripts/search.py, @data/) + * to avoid false positives with decorators (@param), JSDoc tags (@ts-ignore), etc. + * + * Email addresses are excluded since they have alphanumeric characters before @. + */ +export function resolveSkillPathReferences(content: string, basePath: string): string { + const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath + return content.replace( + /(? join(normalizedBase, relativePath) + ) +}