|
| 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 |
0 commit comments