Skip to content

Commit 9024913

Browse files
kulvirgitclaude
andcommitted
feat: auto-discover skills/commands from Claude Code, Codex, and Gemini configs
Scans .claude/commands/*.md, .claude/skills/*/SKILL.md, .codex/skills/*/SKILL.md, .gemini/skills/*/SKILL.md, and .gemini/commands/*.toml for external skills. - Opt-in via config: experimental.auto_skill_discovery: true - Security: rejects symlinks, prototype pollution names, path traversal - Dedup: first-wins with warnings, existing skills never overwritten - TOML: {{args}} converted to $ARGUMENTS, nested paths preserved - 24 tests including adversarial security tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 76e9c07 commit 9024913

6 files changed

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