diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 28af2f9b43..c82a5406cf 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -192,7 +192,7 @@ export namespace Command { get template() { return skill.content }, - hints: [], + hints: hints(skill.content), } } } catch (e) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 12e3730e43..05366b1780 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1297,6 +1297,12 @@ export namespace Config { .default(true) .describe("Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup. Set to false to disable."), // altimate_change end + // altimate_change start - auto skill/command discovery toggle + auto_skill_discovery: z + .boolean() + .default(true) + .describe("Auto-discover skills and commands from Claude Code, Codex, and Gemini configs at startup. Set to false to disable."), + // altimate_change end }) .optional(), }) diff --git a/packages/opencode/src/skill/discover-external.ts b/packages/opencode/src/skill/discover-external.ts new file mode 100644 index 0000000000..57608a8c5b --- /dev/null +++ b/packages/opencode/src/skill/discover-external.ts @@ -0,0 +1,228 @@ +// altimate_change start — auto-discover skills/commands from external AI tool configs +import path from "path" +import { pathToFileURL } from "url" +import { Log } from "../util/log" +import { Filesystem } from "../util/filesystem" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" +import { Global } from "@/global" +import { Instance } from "@/project/instance" +import { Skill } from "./skill" + +const log = Log.create({ service: "skill.discover" }) + +interface ExternalSkillSource { + tool: string + dir: string + pattern: string + scope: "project" | "home" | "both" + format: "skill-md" | "command-md" | "command-toml" +} + +const SOURCES: ExternalSkillSource[] = [ + { tool: "claude-code", dir: ".claude", pattern: "commands/**/*.md", scope: "both", format: "command-md" }, + { tool: "codex", dir: ".codex", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" }, + { tool: "gemini", dir: ".gemini", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" }, + { tool: "gemini", dir: ".gemini", pattern: "commands/**/*.toml", scope: "both", format: "command-toml" }, +] + +/** + * Parse a standard SKILL.md file (Codex, Gemini) using ConfigMarkdown.parse(). + * Returns a Skill.Info or undefined if the file is malformed. + */ +async function transformSkillMd(filePath: string): Promise { + const md = await ConfigMarkdown.parse(filePath).catch((err) => { + log.debug("failed to parse external skill", { path: filePath, err }) + return undefined + }) + if (!md) return undefined + + const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return undefined + + return { + name: parsed.data.name, + description: parsed.data.description, + location: filePath, + content: md.content, + } +} + +/** + * Parse a Claude Code command markdown file (.claude/commands/*.md). + * Supports optional YAML frontmatter with name/description. + * Name derived from path relative to `commands/` root if not in frontmatter. + * Nested paths are preserved: `team/review.md` → `team/review`. + */ +async function transformCommandMd(filePath: string, commandsRoot: string): Promise { + const md = await ConfigMarkdown.parse(filePath).catch((err) => { + log.debug("failed to parse command markdown", { path: filePath, err }) + return undefined + }) + if (!md) return undefined + + // Derive name from frontmatter or path relative to the commands/ root + const frontmatter = md.data as Record + let name: string + if (typeof frontmatter.name === "string" && frontmatter.name.trim()) { + name = frontmatter.name.trim() + } else { + // e.g. /home/user/.claude/commands/team/review.md → team/review + const rel = path.relative(commandsRoot, filePath) + name = rel.replace(/\.md$/i, "").replace(/\\/g, "/") + } + + const description = typeof frontmatter.description === "string" ? frontmatter.description : "" + + return { + name, + description, + location: filePath, + content: md.content, + } +} + +/** + * Parse a Gemini CLI command TOML file (.gemini/commands/*.toml). + * Expects `prompt` field for content, optional `description`. + * Converts `{{args}}` / `{{ args }}` → `$ARGUMENTS`. + */ +async function transformCommandToml(filePath: string): Promise { + try { + const mod = await import(pathToFileURL(filePath).href, { with: { type: "toml" } }) + const data = (mod.default || mod) as Record + + if (typeof data.prompt !== "string" || !data.prompt.trim()) { + log.debug("TOML command missing prompt field", { path: filePath }) + return undefined + } + + const name = path.basename(filePath, ".toml") + const description = typeof data.description === "string" ? data.description : "" + // Convert Gemini's {{args}} / {{ args }} placeholder to $ARGUMENTS + const content = data.prompt.replace(/\{\{\s*args\s*\}\}/g, "$ARGUMENTS") + + return { + name, + description, + location: filePath, + content, + } + } catch (err) { + log.debug("failed to parse TOML command", { path: filePath, err }) + return undefined + } +} + +/** + * Scan a single directory for skills/commands matching a source pattern. + */ +async function scanSource( + root: string, + source: ExternalSkillSource, +): Promise { + const baseDir = path.join(root, source.dir) + if (!(await Filesystem.isDir(baseDir))) return [] + + const matches = await Glob.scan(source.pattern, { + cwd: baseDir, + absolute: true, + include: "file", + dot: true, + symlink: true, + }).catch(() => [] as string[]) + + const results: Skill.Info[] = [] + for (const match of matches) { + let skill: Skill.Info | undefined + switch (source.format) { + case "skill-md": + skill = await transformSkillMd(match) + break + case "command-md": + skill = await transformCommandMd(match, path.join(baseDir, "commands")) + break + case "command-toml": + skill = await transformCommandToml(match) + break + } + if (skill) results.push(skill) + } + return results +} + +/** + * Discover skills and commands from external AI tool configs + * (Claude Code, Codex CLI, Gemini CLI). + * + * Searches both home directory and project directory (walking up from CWD to worktree root). + * Returns discovered skills and contributing source labels. + */ +export async function discoverExternalSkills(worktree: string): Promise<{ + skills: Skill.Info[] + sources: string[] +}> { + log.info("Discovering skills/commands from external AI tool configs...") + const allSkills: Skill.Info[] = [] + const sources: string[] = [] + const seen = new Set() + const homedir = Global.Path.home + + const addSkills = (skills: Skill.Info[], sourceLabel: string) => { + let added = 0 + for (const skill of skills) { + if (seen.has(skill.name)) continue + seen.add(skill.name) + allSkills.push(skill) + added++ + } + if (added > 0) sources.push(sourceLabel) + } + + for (const source of SOURCES) { + // Project-scoped: walk from Instance.directory up to worktree root + if ((source.scope === "project" || source.scope === "both") && worktree !== "/") { + for await (const foundDir of Filesystem.up({ + targets: [source.dir], + start: Instance.directory, + stop: worktree, + })) { + const root = path.dirname(foundDir) + const skills = await scanSource(root, source) + addSkills(skills, `${source.dir}/${source.pattern} (project)`) + } + } + + // Home-scoped: scan home directory (skip if home === worktree to avoid duplicates) + if ((source.scope === "home" || source.scope === "both") && homedir !== worktree) { + const skills = await scanSource(homedir, source) + addSkills(skills, `~/${source.dir}/${source.pattern}`) + } + } + + if (allSkills.length > 0) { + log.info(`Discovered ${allSkills.length} skill(s)/command(s) from ${sources.join(", ")}: ${allSkills.map((s) => s.name).join(", ")}`) + } else { + log.info("No external skills/commands found") + } + + return { skills: allSkills, sources } +} + +/** Stored after skill merge — only contains skills that were actually new. */ +let _lastDiscovery: { skillNames: string[]; sources: string[] } | null = null + +/** Called from skill.ts after merge with only the names that were actually added. */ +export function setSkillDiscoveryResult(skillNames: string[], sources: string[]) { + if (skillNames.length > 0) { + _lastDiscovery = { skillNames, sources } + } +} + +/** Returns and clears the last discovery result (for one-time notification). */ +export function consumeSkillDiscoveryResult() { + const result = _lastDiscovery + _lastDiscovery = null + return result +} +// altimate_change end diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index ceba0e791e..e8479f34e7 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -225,6 +225,26 @@ export namespace Skill { } } + // altimate_change start — auto-discover skills/commands from external AI tool configs + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS && config.experimental?.auto_skill_discovery !== false) { + try { + const { discoverExternalSkills, setSkillDiscoveryResult } = await import("./discover-external") + const { skills: externalSkills, sources } = await discoverExternalSkills(Instance.worktree) + const added: string[] = [] + for (const skill of externalSkills) { + if (!skills[skill.name]) { + skills[skill.name] = skill + dirs.add(path.dirname(skill.location)) + added.push(skill.name) + } + } + setSkillDiscoveryResult(added, sources) + } catch (error) { + log.error("external skill discovery failed", { error }) + } + } + // altimate_change end + return { skills, dirs: Array.from(dirs), diff --git a/packages/opencode/test/skill/discover-external.test.ts b/packages/opencode/test/skill/discover-external.test.ts new file mode 100644 index 0000000000..6145961fd3 --- /dev/null +++ b/packages/opencode/test/skill/discover-external.test.ts @@ -0,0 +1,327 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdtemp, rm, mkdir, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { discoverExternalSkills } from "../../src/skill/discover-external" +import { Instance } from "../../src/project/instance" + +let tempDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), "skill-discover-")) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +describe("discoverExternalSkills", () => { + // Helper to run discovery with tempDir as both worktree and Instance.directory + async function discover(worktree?: string) { + return Instance.provide({ + directory: worktree ?? tempDir, + fn: () => discoverExternalSkills(worktree ?? tempDir), + }) + } + + // --- Claude Code commands --- + + test("discovers Claude Code command with frontmatter", async () => { + await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".claude", "commands", "review.md"), + `--- +name: review +description: Review the code changes +--- + +Please review the following code changes: $ARGUMENTS +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "review") + expect(skill).toBeDefined() + expect(skill!.description).toBe("Review the code changes") + expect(skill!.content).toContain("$ARGUMENTS") + }) + + test("derives name from filename when no frontmatter name", async () => { + await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".claude", "commands", "test-cmd.md"), + `# Test Command + +Run the tests for $ARGUMENTS +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "test-cmd") + expect(skill).toBeDefined() + expect(skill!.description).toBe("") + expect(skill!.content).toContain("$ARGUMENTS") + }) + + test("preserves nested command path as name", async () => { + await mkdir(path.join(tempDir, ".claude", "commands", "team"), { recursive: true }) + await writeFile( + path.join(tempDir, ".claude", "commands", "team", "review.md"), + `--- +description: Team review command +--- + +Review for team: $ARGUMENTS +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "team/review") + expect(skill).toBeDefined() + expect(skill!.description).toBe("Team review command") + }) + + // --- Codex skills --- + + test("discovers Codex skill from .codex/skills/", async () => { + await mkdir(path.join(tempDir, ".codex", "skills", "my-skill"), { recursive: true }) + await writeFile( + path.join(tempDir, ".codex", "skills", "my-skill", "SKILL.md"), + `--- +name: my-codex-skill +description: A skill from Codex CLI +--- + +# Codex Skill + +Do the codex thing. +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "my-codex-skill") + expect(skill).toBeDefined() + expect(skill!.description).toBe("A skill from Codex CLI") + }) + + // --- Gemini skills --- + + test("discovers Gemini skill from .gemini/skills/", async () => { + await mkdir(path.join(tempDir, ".gemini", "skills", "gem-skill"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "skills", "gem-skill", "SKILL.md"), + `--- +name: gem-skill +description: A Gemini CLI skill +--- + +# Gemini Skill + +Instructions for the Gemini skill. +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "gem-skill") + expect(skill).toBeDefined() + expect(skill!.description).toBe("A Gemini CLI skill") + }) + + // --- Gemini TOML commands --- + + test("discovers Gemini TOML command and converts {{args}} to $ARGUMENTS", async () => { + await mkdir(path.join(tempDir, ".gemini", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "commands", "deploy.toml"), + `description = "Deploy the application" +prompt = "Deploy the app to {{ args }} environment" +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "deploy") + expect(skill).toBeDefined() + expect(skill!.description).toBe("Deploy the application") + expect(skill!.content).toBe("Deploy the app to $ARGUMENTS environment") + expect(skill!.content).not.toContain("{{") + }) + + test("skips Gemini TOML command without prompt field", async () => { + await mkdir(path.join(tempDir, ".gemini", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "commands", "bad.toml"), + `description = "Missing prompt field" +`, + ) + + const { skills } = await discover() + expect(skills.find((s) => s.name === "bad")).toBeUndefined() + }) + + // --- Deduplication --- + + test("first discovered skill wins on name conflict", async () => { + // Claude Code command (discovered first) + await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".claude", "commands", "deploy.md"), + `--- +name: deploy +description: Claude deploy +--- + +Claude deploy content +`, + ) + + // Gemini TOML command with same name + await mkdir(path.join(tempDir, ".gemini", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "commands", "deploy.toml"), + `description = "Gemini deploy" +prompt = "Gemini deploy content" +`, + ) + + const { skills } = await discover() + const deploySkills = skills.filter((s) => s.name === "deploy") + expect(deploySkills.length).toBe(1) + expect(deploySkills[0].description).toBe("Claude deploy") + }) + + // --- Missing directories --- + + test("returns empty result for missing directories", async () => { + const { skills, sources } = await discover() + expect(skills).toEqual([]) + expect(sources).toEqual([]) + }) + + // --- Malformed files --- + + test("skips malformed frontmatter gracefully", async () => { + await mkdir(path.join(tempDir, ".codex", "skills", "bad-skill"), { recursive: true }) + await writeFile( + path.join(tempDir, ".codex", "skills", "bad-skill", "SKILL.md"), + `--- +name: [invalid yaml +description: broken +--- + +Content +`, + ) + + // Should not throw + const { skills } = await discover() + // The malformed skill should be skipped + expect(skills.find((s) => s.name === "invalid yaml")).toBeUndefined() + }) + + test("skips SKILL.md without required frontmatter fields", async () => { + await mkdir(path.join(tempDir, ".codex", "skills", "no-meta"), { recursive: true }) + await writeFile( + path.join(tempDir, ".codex", "skills", "no-meta", "SKILL.md"), + `# No Frontmatter + +Just content without metadata. +`, + ) + + const { skills } = await discover() + expect(skills).toEqual([]) + }) + + // --- Multiple sources --- + + test("discovers skills from multiple tools simultaneously", async () => { + // Claude Code command + await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".claude", "commands", "cc-cmd.md"), + `--- +name: cc-cmd +description: Claude Code command +--- + +Claude Code command content +`, + ) + + // Codex skill + await mkdir(path.join(tempDir, ".codex", "skills", "codex-skill"), { recursive: true }) + await writeFile( + path.join(tempDir, ".codex", "skills", "codex-skill", "SKILL.md"), + `--- +name: codex-skill +description: Codex skill +--- + +Codex skill content +`, + ) + + // Gemini skill + await mkdir(path.join(tempDir, ".gemini", "skills", "gem-skill"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "skills", "gem-skill", "SKILL.md"), + `--- +name: gem-skill +description: Gemini skill +--- + +Gemini skill content +`, + ) + + // Gemini TOML command + await mkdir(path.join(tempDir, ".gemini", "commands"), { recursive: true }) + await writeFile( + path.join(tempDir, ".gemini", "commands", "gem-cmd.toml"), + `description = "Gemini TOML command" +prompt = "Run {{args}}" +`, + ) + + const { skills, sources } = await discover() + expect(skills.length).toBe(4) + expect(skills.find((s) => s.name === "cc-cmd")).toBeDefined() + expect(skills.find((s) => s.name === "codex-skill")).toBeDefined() + expect(skills.find((s) => s.name === "gem-skill")).toBeDefined() + expect(skills.find((s) => s.name === "gem-cmd")).toBeDefined() + expect(sources.length).toBeGreaterThan(0) + }) + + // --- Worktree edge cases --- + + test("skips project scan when worktree is /", async () => { + // Should not throw and should return empty (no dirs at /) + const result = await Instance.provide({ + directory: tempDir, + fn: () => discoverExternalSkills("/"), + }) + expect(result.skills).toEqual([]) + }) + + // --- Location tracking --- + + test("sets correct location path for discovered skills", async () => { + await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true }) + const cmdPath = path.join(tempDir, ".claude", "commands", "my-cmd.md") + await writeFile( + cmdPath, + `--- +name: my-cmd +description: Test location +--- + +Content here +`, + ) + + const { skills } = await discover() + const skill = skills.find((s) => s.name === "my-cmd") + expect(skill).toBeDefined() + expect(skill!.location).toBe(cmdPath) + }) +}) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index b80610c635..88b1a85f28 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -190,6 +190,7 @@ Use this skill. experimental: { env_fingerprint_skill_selection: false, auto_mcp_discovery: true, + auto_skill_discovery: true, }, }, init: async (dir) => { @@ -227,6 +228,7 @@ Use this skill. experimental: { env_fingerprint_skill_selection: true, auto_mcp_discovery: true, + auto_skill_discovery: true, }, }, init: async (dir) => {