Skip to content

Commit a89aec9

Browse files
kulvirgitclaude
andcommitted
feat: auto-discover skills/commands from Claude Code, Codex, and Gemini configs
Scans external AI tool configs for skills/commands not already covered by the existing skill loader: - .claude/commands/**/*.md (Claude Code commands with optional frontmatter) - .codex/skills/**/SKILL.md (Codex CLI skills) - .gemini/skills/**/SKILL.md (Gemini CLI skills) - .gemini/commands/**/*.toml (Gemini CLI commands, {{args}} → $ARGUMENTS) Note: .claude/skills/ and .agents/skills/ are already scanned by the main skill loader in skill.ts — not duplicated here. - Opt-in via config: experimental.auto_skill_discovery: true (default false) - Security: rejects symlinks (lstat check), prototype pollution names, path traversal in derived names - Dedup: first-wins with log.warn on conflicts, existing skills never overwritten - Nested paths preserved: team/review.md → skill name "team/review" - Sanity resilience test verifies module loads without errors - 24 unit tests including adversarial security tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 76e9c07 commit a89aec9

7 files changed

Lines changed: 813 additions & 1 deletion

File tree

packages/opencode/src/command/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export namespace Command {
206206
get template() {
207207
return skill.content
208208
},
209-
hints: [],
209+
hints: hints(skill.content),
210210
}
211211
}
212212
} catch (e) {

packages/opencode/src/config/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,12 @@ export namespace Config {
13001300
.default(true)
13011301
.describe("Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup. Set to false to disable."),
13021302
// altimate_change end
1303+
// altimate_change start - auto skill/command discovery toggle
1304+
auto_skill_discovery: z
1305+
.boolean()
1306+
.default(false)
1307+
.describe("Auto-discover skills and commands from Claude Code, Codex, and Gemini configs at startup. Opt-in — set to true to enable."),
1308+
// altimate_change end
13031309
})
13041310
.optional(),
13051311
})
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// altimate_change start — auto-discover skills/commands from external AI tool configs
2+
import path from "path"
3+
import fs from "fs/promises"
4+
import { pathToFileURL } from "url"
5+
import { Log } from "../util/log"
6+
import { Filesystem } from "../util/filesystem"
7+
import { ConfigMarkdown } from "../config/markdown"
8+
import { Glob } from "../util/glob"
9+
import { Global } from "@/global"
10+
import { Instance } from "@/project/instance"
11+
import { Skill } from "./skill"
12+
13+
const log = Log.create({ service: "skill.discover" })
14+
15+
interface ExternalSkillSource {
16+
tool: string
17+
dir: string
18+
pattern: string
19+
scope: "project" | "home" | "both"
20+
format: "skill-md" | "command-md" | "command-toml"
21+
}
22+
23+
// Discovery priority: Claude Code commands → Codex skills → Gemini skills → Gemini commands.
24+
// Within each source: project-deep → project-shallow → home. First skill with a given name wins.
25+
//
26+
// NOTE: .claude/skills/ and .agents/skills/ are already scanned by the main skill loader
27+
// in skill.ts (EXTERNAL_DIRS). We only discover formats NOT covered there:
28+
// - Claude Code "commands" (markdown with optional frontmatter, not SKILL.md)
29+
// - Codex CLI skills (.codex/skills/)
30+
// - Gemini CLI skills and TOML commands (.gemini/skills/, .gemini/commands/)
31+
const SOURCES: ExternalSkillSource[] = [
32+
{ tool: "claude-code", dir: ".claude", pattern: "commands/**/*.md", scope: "both", format: "command-md" },
33+
{ tool: "codex", dir: ".codex", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" },
34+
{ tool: "gemini", dir: ".gemini", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" },
35+
{ tool: "gemini", dir: ".gemini", pattern: "commands/**/*.toml", scope: "both", format: "command-toml" },
36+
]
37+
38+
// Names that would pollute Object.prototype — must never be used as skill keys
39+
const POISONED_NAMES = new Set(["__proto__", "constructor", "prototype"])
40+
41+
/**
42+
* Parse a standard SKILL.md file (Codex, Gemini) using ConfigMarkdown.parse().
43+
* Returns a Skill.Info or undefined if the file is malformed.
44+
*/
45+
async function transformSkillMd(filePath: string): Promise<Skill.Info | undefined> {
46+
const md = await ConfigMarkdown.parse(filePath).catch((err) => {
47+
log.debug("failed to parse external skill", { path: filePath, err })
48+
return undefined
49+
})
50+
if (!md) return undefined
51+
52+
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
53+
if (!parsed.success) return undefined
54+
55+
return {
56+
name: parsed.data.name,
57+
description: parsed.data.description,
58+
location: filePath,
59+
content: md.content,
60+
}
61+
}
62+
63+
/**
64+
* Parse a Claude Code command markdown file (.claude/commands/*.md).
65+
* Supports optional YAML frontmatter with name/description.
66+
* Name derived from path relative to `commands/` root if not in frontmatter.
67+
* Nested paths are preserved: `team/review.md` → `team/review`.
68+
*/
69+
async function transformCommandMd(filePath: string, commandsRoot: string): Promise<Skill.Info | undefined> {
70+
const md = await ConfigMarkdown.parse(filePath).catch((err) => {
71+
log.debug("failed to parse command markdown", { path: filePath, err })
72+
return undefined
73+
})
74+
if (!md) return undefined
75+
76+
// Derive name from frontmatter or path relative to the commands/ root
77+
const frontmatter = md.data as Record<string, unknown>
78+
let name: string
79+
if (typeof frontmatter.name === "string" && frontmatter.name.trim()) {
80+
name = frontmatter.name.trim()
81+
} else {
82+
// e.g. /home/user/.claude/commands/team/review.md → team/review
83+
const rel = path.relative(commandsRoot, filePath)
84+
name = rel.replace(/\.md$/i, "").replace(/\\/g, "/")
85+
}
86+
87+
const description = typeof frontmatter.description === "string" ? frontmatter.description : ""
88+
89+
return {
90+
name,
91+
description,
92+
location: filePath,
93+
content: md.content,
94+
}
95+
}
96+
97+
/**
98+
* Parse a Gemini CLI command TOML file (.gemini/commands/*.toml).
99+
* Expects `prompt` field for content, optional `description`.
100+
* Converts `{{args}}` / `{{ args }}` → `$ARGUMENTS`.
101+
*/
102+
async function transformCommandToml(filePath: string, commandsRoot: string): Promise<Skill.Info | undefined> {
103+
try {
104+
// Bun-specific: native TOML import support via import attributes (not available in Node.js)
105+
const mod = await import(pathToFileURL(filePath).href, { with: { type: "toml" } })
106+
const data = (mod.default || mod) as Record<string, unknown>
107+
108+
if (typeof data.prompt !== "string" || !data.prompt.trim()) {
109+
log.warn("TOML command missing prompt field", { path: filePath })
110+
return undefined
111+
}
112+
113+
// Derive name from relative path (preserving nested directories), matching transformCommandMd
114+
const rel = path.relative(commandsRoot, filePath)
115+
const name = rel.replace(/\.toml$/i, "").replace(/\\/g, "/")
116+
const description = typeof data.description === "string" ? data.description : ""
117+
// Convert Gemini's {{args}} / {{ args }} placeholder to $ARGUMENTS
118+
const content = data.prompt.replace(/\{\{\s*args\s*\}\}/g, "$ARGUMENTS")
119+
120+
return {
121+
name,
122+
description,
123+
location: filePath,
124+
content,
125+
}
126+
} catch (err) {
127+
log.warn("failed to parse TOML command", { path: filePath, err })
128+
return undefined
129+
}
130+
}
131+
132+
/**
133+
* Scan a single directory for skills/commands matching a source pattern.
134+
*/
135+
async function scanSource(
136+
root: string,
137+
source: ExternalSkillSource,
138+
): Promise<Skill.Info[]> {
139+
const baseDir = path.join(root, source.dir)
140+
if (!(await Filesystem.isDir(baseDir))) return []
141+
142+
const matches = await Glob.scan(source.pattern, {
143+
cwd: baseDir,
144+
absolute: true,
145+
include: "file",
146+
dot: true,
147+
symlink: false, // Security: don't follow symlinks — prevents reading arbitrary files via crafted repos
148+
}).catch(() => [] as string[])
149+
150+
const results: Skill.Info[] = []
151+
for (const match of matches) {
152+
// Security: reject symlinks — prevents reading arbitrary files via crafted repos
153+
try {
154+
const stat = await fs.lstat(match)
155+
if (stat.isSymbolicLink()) {
156+
log.warn("skipping symlinked skill file", { path: match })
157+
continue
158+
}
159+
} catch {
160+
continue
161+
}
162+
let skill: Skill.Info | undefined
163+
switch (source.format) {
164+
case "skill-md":
165+
skill = await transformSkillMd(match)
166+
break
167+
case "command-md":
168+
skill = await transformCommandMd(match, path.join(baseDir, "commands"))
169+
break
170+
case "command-toml":
171+
skill = await transformCommandToml(match, path.join(baseDir, "commands"))
172+
break
173+
}
174+
if (skill) results.push(skill)
175+
}
176+
return results
177+
}
178+
179+
/**
180+
* Discover skills and commands from external AI tool configs
181+
* (Claude Code, Codex CLI, Gemini CLI).
182+
*
183+
* Searches both home directory and project directory (walking up from CWD to worktree root).
184+
* Returns discovered skills and contributing source labels.
185+
*/
186+
export async function discoverExternalSkills(worktree: string, homeDir?: string): Promise<{
187+
skills: Skill.Info[]
188+
sources: string[]
189+
}> {
190+
log.info("Discovering skills/commands from external AI tool configs...")
191+
const allSkills: Skill.Info[] = []
192+
const sources: string[] = []
193+
const seen = new Set<string>()
194+
const homedir = homeDir ?? Global.Path.home
195+
196+
const addSkills = (skills: Skill.Info[], sourceLabel: string) => {
197+
let added = 0
198+
for (const skill of skills) {
199+
// Guard against prototype pollution
200+
if (POISONED_NAMES.has(skill.name)) {
201+
log.warn("rejecting skill with reserved name", { name: skill.name, source: sourceLabel })
202+
continue
203+
}
204+
// Reject path traversal in derived names
205+
if (skill.name.includes("..")) {
206+
log.warn("rejecting skill with path traversal in name", { name: skill.name, source: sourceLabel })
207+
continue
208+
}
209+
if (seen.has(skill.name)) {
210+
log.warn("duplicate external skill name, skipping", { name: skill.name, source: sourceLabel, existing: allSkills.find((s) => s.name === skill.name)?.location })
211+
continue
212+
}
213+
seen.add(skill.name)
214+
allSkills.push(skill)
215+
added++
216+
}
217+
if (added > 0) sources.push(sourceLabel)
218+
}
219+
220+
for (const source of SOURCES) {
221+
// Project-scoped: walk from Instance.directory up to worktree root
222+
if ((source.scope === "project" || source.scope === "both") && worktree !== "/") {
223+
for await (const foundDir of Filesystem.up({
224+
targets: [source.dir],
225+
start: Instance.directory,
226+
stop: worktree,
227+
})) {
228+
const root = path.dirname(foundDir)
229+
const skills = await scanSource(root, source)
230+
addSkills(skills, `${source.dir}/${source.pattern} (project)`)
231+
}
232+
}
233+
234+
// Home-scoped: scan home directory (skip if home === worktree to avoid duplicates)
235+
if ((source.scope === "home" || source.scope === "both") && homedir !== worktree) {
236+
const skills = await scanSource(homedir, source)
237+
addSkills(skills, `~/${source.dir}/${source.pattern}`)
238+
}
239+
}
240+
241+
if (allSkills.length > 0) {
242+
log.info(`Discovered ${allSkills.length} skill(s)/command(s) from ${sources.join(", ")}: ${allSkills.map((s) => s.name).join(", ")}`)
243+
} else {
244+
log.info("No external skills/commands found")
245+
}
246+
247+
return { skills: allSkills, sources }
248+
}
249+
250+
/** Stored after skill merge — only contains skills that were actually new. */
251+
let _lastDiscovery: { skillNames: string[]; sources: string[] } | null = null
252+
253+
/** Called from skill.ts after merge with only the names that were actually added. */
254+
export function setSkillDiscoveryResult(skillNames: string[], sources: string[]) {
255+
_lastDiscovery = skillNames.length > 0 ? { skillNames, sources } : null
256+
}
257+
258+
/** Returns and clears the last discovery result (for one-time notification). */
259+
export function consumeSkillDiscoveryResult() {
260+
const result = _lastDiscovery
261+
_lastDiscovery = null
262+
return result
263+
}
264+
// altimate_change end

packages/opencode/src/skill/skill.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,26 @@ export namespace Skill {
230230
}
231231
}
232232

233+
// altimate_change start — auto-discover skills/commands from external AI tool configs
234+
if (config.experimental?.auto_skill_discovery === true) {
235+
try {
236+
const { discoverExternalSkills, setSkillDiscoveryResult } = await import("./discover-external")
237+
const { skills: externalSkills, sources } = await discoverExternalSkills(Instance.worktree)
238+
const added: string[] = []
239+
for (const skill of externalSkills) {
240+
if (!skills[skill.name]) {
241+
skills[skill.name] = skill
242+
dirs.add(path.dirname(skill.location))
243+
added.push(skill.name)
244+
}
245+
}
246+
setSkillDiscoveryResult(added, sources)
247+
} catch (error) {
248+
log.error("external skill discovery failed", { error })
249+
}
250+
}
251+
// altimate_change end
252+
233253
return {
234254
skills,
235255
dirs: Array.from(dirs),

0 commit comments

Comments
 (0)