import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" import type { AgentProvider, GlobalSkillSummary, HarnessSkill, HarnessSkillSource } from "../shared/types" /** * Harness-agnostic skill plumbing for the composer's "/" menu. * * Discovery has two tiers per provider: * - live: ask the running harness (claude supportedCommands, codex skills/list, * pi resource loader). Authoritative — includes built-ins/plugins/enabled flags. * - filesystem: Kanna scans the same directories the harness itself reads. * Used for cold start (no session yet) and for cursor, which has no * enumeration protocol at all. * * Invocation is translated per provider at the adapter boundary (the transcript * always keeps the user's typed text verbatim): * - claude/pi: passthrough — both expand a message that *starts* with "/name". * - codex: structured `{type:"skill", name, path}` input item + failsafe block. * - cursor: failsafe block only (no headless expansion exists). */ /** Leading-slash invocation: `/name` optionally followed by whitespace + args. */ const SKILL_INVOCATION_PATTERN = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/ export interface SkillInvocation { name: string args: string } /** * Parse a `/name args` invocation from prompt content. Anchored to the start * (after trimming) because every harness that expands slash text requires it * there — claude checks `trim().startsWith("/")`, pi checks `startsWith("/")`. */ export function parseSkillInvocation(content: string): SkillInvocation | null { const match = content.trim().match(SKILL_INVOCATION_PATTERN) if (!match?.[1]) return null return { name: match[1], args: match[2]?.trim() ?? "" } } /** * The non-deterministic failsafe appended (never prepended — that would break * claude/pi slash expansion) to the harness-bound prompt when invoking a skill * on providers without a fully deterministic path (codex, cursor). Mirrors the * steered-message pattern: visible to the harness, hidden from the transcript UI * (the transcript stores the user's typed text, not the wire text). */ export function buildSkillSystemMessage(skillPath: string): string { return `the user would like to use the skill available at ${skillPath}` } export function appendSystemMessageBlock(content: string, block: string): string { const trimmed = content.trim() return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block } // --------------------------------------------------------------------------- // Frontmatter // --------------------------------------------------------------------------- /** * Minimal YAML frontmatter reader: single-line `key: value` pairs only, which * covers SKILL.md and command markdown in practice (name, description, * argument-hint). Not a YAML parser on purpose. */ export function parseFrontmatter(markdown: string): Record { const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/) if (!match?.[1]) return {} const fields: Record = {} for (const line of match[1].split(/\r?\n/)) { const separator = line.indexOf(":") if (separator <= 0) continue const key = line.slice(0, separator).trim().toLowerCase() if (!key || /\s/.test(key)) continue let value = line.slice(separator + 1).trim() if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } if (value) fields[key] = value } return fields } // --------------------------------------------------------------------------- // Filesystem scanners // --------------------------------------------------------------------------- /** * Codex preinstalls a "chronicle" skill whose SKILL.md declares its * description as a YAML block scalar (`description: |` + indented lines). * Kanna's single-line frontmatter reader captures the literal "|", so this * one known skill gets a hardcoded subtitle — guarded tightly to the exact * name + the "|" artifact so a real description would win if it ever appears. */ const CHRONICLE_DESCRIPTION = "Chronicle is Codex’s local screen-context feature. When it’s running, it keeps a short rolling record of your screen so I can answer ambiguous requests like “what is this?” by looking at your current screen or recent screen history." export function normalizeSkillDescription(name: string, description: string): string { if (name === "chronicle" && description.trim() === "|") { return CHRONICLE_DESCRIPTION } return description } function readSkillFile(skillFilePath: string, fallbackName: string): HarnessSkill | null { let markdown: string try { markdown = readFileSync(skillFilePath, "utf8") } catch { return null } const frontmatter = parseFrontmatter(markdown) const name = frontmatter.name || fallbackName if (!name || name.startsWith("._")) return null return { name, description: normalizeSkillDescription(name, frontmatter.description ?? ""), source: "skill", path: skillFilePath, } } /** Scan a `//SKILL.md` directory (the Agent Skills layout). */ export function scanSkillsRoot(root: string): HarnessSkill[] { let entries: string[] try { entries = readdirSync(root) } catch { return [] } const skills: HarnessSkill[] = [] for (const entry of entries.sort()) { if (entry.startsWith(".")) continue const skillFile = path.join(root, entry, "SKILL.md") if (!existsSync(skillFile)) continue const skill = readSkillFile(skillFile, entry) if (skill) skills.push(skill) } return skills } /** Scan a directory of `*.md` command files (claude `.claude/commands` layout). */ export function scanCommandsRoot(root: string): HarnessSkill[] { let entries: string[] try { entries = readdirSync(root) } catch { return [] } const commands: HarnessSkill[] = [] for (const entry of entries.sort()) { if (entry.startsWith(".") || !entry.endsWith(".md")) continue const filePath = path.join(root, entry) let markdown: string try { if (!statSync(filePath).isFile()) continue markdown = readFileSync(filePath, "utf8") } catch { continue } const frontmatter = parseFrontmatter(markdown) const name = entry.slice(0, -3) if (name.startsWith("._")) continue commands.push({ name, description: frontmatter.description ?? "", ...(frontmatter["argument-hint"] ? { argumentHint: frontmatter["argument-hint"] } : {}), source: "command", path: filePath, }) } return commands } /** First occurrence of a name wins — pass roots in precedence order. */ export function dedupeSkillsByName(skills: HarnessSkill[]): HarnessSkill[] { const seen = new Set() const result: HarnessSkill[] = [] for (const skill of skills) { if (seen.has(skill.name)) continue seen.add(skill.name) result.push(skill) } return result } /** Walk from `cwd` up to the enclosing git repo root (inclusive), or just `cwd` when not in a repo. */ export function collectAncestorDirsToRepoRoot(cwd: string): string[] { const dirs: string[] = [] let dir = path.resolve(cwd) let repoRoot: string | null = null for (let probe = dir; ; ) { if (existsSync(path.join(probe, ".git"))) { repoRoot = probe break } const parent = path.dirname(probe) if (parent === probe) break probe = parent } while (true) { dirs.push(dir) if (repoRoot === null || dir === repoRoot) break const parent = path.dirname(dir) if (parent === dir) break dir = parent } return repoRoot === null ? [path.resolve(cwd)] : dirs } const WALK_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", "vendor", ".venv", "target"]) const WALK_MAX_DEPTH = 8 /** * Find every `//skills` root nested anywhere under `base` (cursor * treats `.cursor/skills` and `.agents/skills` as valid in any subdirectory of * the repo). Bounded walk: skips dependency/build dirs and caps depth. */ export function findNestedSkillRoots(base: string, markers: string[]): string[] { const roots: string[] = [] const walk = (dir: string, depth: number) => { for (const marker of markers) { const candidate = path.join(dir, marker, "skills") if (existsSync(candidate)) roots.push(candidate) } if (depth >= WALK_MAX_DEPTH) return let entries: import("node:fs").Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name.startsWith(".") || WALK_SKIP_DIRS.has(entry.name)) continue walk(path.join(dir, entry.name), depth + 1) } } walk(path.resolve(base), 0) return roots } export interface ScanArgs { cwd: string /** Overridable in tests. */ home?: string } /** * Claude cold-start fallback (no live session to ask): project + user * `.claude/skills` and `.claude/commands`. Built-ins, plugins, and bundled * skills only appear once the live session's supportedCommands() is available. */ export function scanClaudeSkills(args: ScanArgs): HarnessSkill[] { const home = args.home ?? homedir() return dedupeSkillsByName([ ...scanSkillsRoot(path.join(args.cwd, ".claude", "skills")), ...scanCommandsRoot(path.join(args.cwd, ".claude", "commands")), ...scanSkillsRoot(path.join(home, ".claude", "skills")), ...scanCommandsRoot(path.join(home, ".claude", "commands")), ]) } /** * Codex discovery roots, mirrored from codex-rs/core-skills loader: repo-scope * `.agents/skills` (cwd → git root), user `~/.agents/skills` and the deprecated * `~/.codex/skills`, admin `/etc/codex/skills`. Used when no app-server process * is running for the chat; paths match server discovery, so structured skill * input items still resolve on capable codex versions. */ export function scanCodexSkills(args: ScanArgs): HarnessSkill[] { const home = args.home ?? homedir() const repoRoots = collectAncestorDirsToRepoRoot(args.cwd) .map((dir) => path.join(dir, ".agents", "skills")) return dedupeSkillsByName([ ...repoRoots.flatMap(scanSkillsRoot), ...scanSkillsRoot(path.join(home, ".agents", "skills")), ...scanSkillsRoot(path.join(home, ".codex", "skills")), ...scanSkillsRoot("/etc/codex/skills"), ]) } /** * Cursor has no enumeration protocol — this scan mirrors the CLI's own * AgentSkillsCursorRulesService roots: `.cursor/skills` and `.agents/skills` * anywhere in the repo, plus the user-level equivalents. */ export function scanCursorSkills(args: ScanArgs): HarnessSkill[] { const home = args.home ?? homedir() return dedupeSkillsByName([ ...findNestedSkillRoots(args.cwd, [".cursor", ".agents"]).flatMap(scanSkillsRoot), ...scanSkillsRoot(path.join(home, ".cursor", "skills")), ...scanSkillsRoot(path.join(home, ".agents", "skills")), ]) } /** Resolve a typed `/name` against a skill list (exact match on the namespaced name). */ export function findSkillByName(skills: HarnessSkill[], name: string): HarnessSkill | null { return skills.find((skill) => skill.name === name) ?? null } const PROVIDER_ORDER: readonly AgentProvider[] = ["claude", "codex", "cursor", "pi"] /** * User-level skill roots and the harnesses that read each of them. This is the * settings "Installed" view: what's globally available, attributed per harness. * Adding a harness that reads a new global dir = one entry here. */ export function globalSkillRoots(home: string): { dir: string; providers: AgentProvider[] }[] { return [ // The cross-harness standard dir: codex, cursor, and pi all read it natively. { dir: path.join(home, ".agents", "skills"), providers: ["codex", "cursor", "pi"] }, { dir: path.join(home, ".claude", "skills"), providers: ["claude"] }, { dir: path.join(home, ".cursor", "skills"), providers: ["cursor"] }, // Deprecated codex location, but current codex builds still scan it. { dir: path.join(home, ".codex", "skills"), providers: ["codex"] }, ] } /** * Scan every global root and merge by skill name: a skill present in multiple * roots (e.g. the marketplace installs to both ~/.agents and ~/.claude) shows * once with the union of supporting harnesses. */ export function listGlobalSkills(args: { home?: string } = {}): GlobalSkillSummary[] { const home = args.home ?? homedir() const byName = new Map; paths: string[] }>() for (const root of globalSkillRoots(home)) { for (const skill of scanSkillsRoot(root.dir)) { const existing = byName.get(skill.name) if (existing) { for (const provider of root.providers) existing.providers.add(provider) if (skill.path && !existing.paths.includes(skill.path)) existing.paths.push(skill.path) if (!existing.description && skill.description) existing.description = skill.description continue } byName.set(skill.name, { name: skill.name, description: skill.description, providers: new Set(root.providers), paths: skill.path ? [skill.path] : [], }) } } return [...byName.values()] .sort((left, right) => left.name.localeCompare(right.name)) .map((entry) => ({ name: entry.name, description: entry.description, providers: PROVIDER_ORDER.filter((provider) => entry.providers.has(provider)), paths: entry.paths, })) } export function toHarnessSkillSource(value: string): HarnessSkillSource { switch (value) { case "builtin": case "command": case "skill": case "plugin": case "extension": return value default: return "command" } }