diff --git a/.gitignore b/.gitignore index 2bb579e..c6fc0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,6 @@ temp/ .grok/ # Cursor config (tracked — do not ignore) -# .cursor/ \ No newline at end of file +# .cursor/ +.agents +skills-lock.json diff --git a/README.md b/README.md index 3dcfc25..a15bc9b 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,25 @@ If both files exist, project instructions will be used. If neither exists, Grok The custom instructions are added to Grok's system prompt and influence its responses across all interactions in the respective context. +### Agent Skills + +[Agent Skills](https://agentskills.io) are folders of instructions (and optional scripts and assets) that Grok can load when they match the task. Grok discovers skills on disk and includes a compact catalog in the system prompt; the model should `read_file` the listed `SKILL.md` when a skill might help. + +#### Layout + +Each skill is a directory containing a `SKILL.md` file with YAML frontmatter (`name` and `description` required), per the [specification](https://agentskills.io/specification). + +- **Project skills:** `.agents/skills//SKILL.md` (under your project directory) +- **User skills:** `~/.agents/skills//SKILL.md` + +If the same `name` appears in both places, the **project** skill wins. + +Install skills by copying or cloning them into those paths, or use tooling such as `skills.sh` — Grok CLI does not download skills for you. + +#### Listing skills in the TUI + +Open the slash command palette and choose **skills**, or type **`/skills`**, to print installed skills (name, scope, description, and path). + ## Morph Fast Apply (Optional) Grok CLI supports Morph's Fast Apply model for high-speed code editing at **4,500+ tokens/sec with 98% accuracy**. This is an optional feature that provides lightning-fast file editing capabilities. diff --git a/src/agent/agent.ts b/src/agent/agent.ts index f993c20..177b914 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -26,6 +26,7 @@ import type { WorkspaceInfo, } from "../types/index"; import { loadCustomInstructions } from "../utils/instructions"; +import { discoverSkills, formatSkillsForPrompt } from "../utils/skills"; import { DelegationManager } from "./delegations"; const MAX_TOOL_ROUNDS = 400; @@ -138,11 +139,14 @@ function buildSystemPrompt(cwd: string, mode: AgentMode, planContext?: string | ? `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above alongside standard instructions.\n` : ""; + const skillsText = formatSkillsForPrompt(discoverSkills(cwd)); + const skillsSection = skillsText ? `\n\n${skillsText}\n` : ""; + const planSection = planContext ? `\n\nAPPROVED PLAN:\nThe following plan has been approved by the user. Execute it now.\n${planContext}\n` : ""; - return `${MODE_PROMPTS[mode]}${customSection}${planSection} + return `${MODE_PROMPTS[mode]}${customSection}${skillsSection}${planSection} Current working directory: ${cwd}`; } diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 38872fa..42a314b 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -17,6 +17,7 @@ import type { } from "../types/index"; import { MODES } from "../types/index"; import { saveProjectSettings, saveUserSettings } from "../utils/settings"; +import { discoverSkills, formatSkillsForChat } from "../utils/skills"; import { Markdown } from "./markdown"; import { formatPlanAnswers, @@ -603,7 +604,11 @@ export function App({ agent, initialMessage, onExit }: AppProps) { case "skills": setMessages((p) => [ ...p, - { type: "assistant", content: "Skills management coming soon.", timestamp: new Date() }, + { + type: "assistant", + content: formatSkillsForChat(discoverSkills(agent.getCwd()), agent.getCwd()), + timestamp: new Date(), + }, ]); break; case "mcps": @@ -620,7 +625,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { break; } }, - [onExit, resetToNewSession], + [agent, onExit, resetToNewSession], ); const showPlanPanel = !!activePlan?.questions?.length; diff --git a/src/utils/skills.ts b/src/utils/skills.ts new file mode 100644 index 0000000..7d466c4 --- /dev/null +++ b/src/utils/skills.ts @@ -0,0 +1,169 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +export type SkillScope = "project" | "user"; + +export interface DiscoveredSkill { + name: string; + description: string; + skillMdPath: string; + rootDir: string; + scope: SkillScope; +} + +function escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function stripQuotes(s: string): string { + const t = s.trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1).replace(/\\n/g, "\n"); + } + return t; +} + +/** Minimal YAML frontmatter key extraction for flat `key: value` and optional `|` / `>` blocks after description. */ +function parseSkillFrontmatter(raw: string): { name?: string; description?: string } { + const lines = raw.split(/\r?\n/); + const out: { name?: string; description?: string } = {}; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const nameM = line.match(/^name:\s*(.*)$/); + if (nameM) { + const rest = nameM[1].trim(); + out.name = stripQuotes(rest); + continue; + } + const descM = line.match(/^description:\s*(.*)$/); + if (descM) { + const rest = descM[1].trim(); + if (rest === "|" || rest === ">" || rest === "|-" || rest === ">-") { + i++; + const block: string[] = []; + while (i < lines.length) { + const L = lines[i]; + if (L.match(/^[a-zA-Z0-9_-]+:\s/) && !/^\s/.test(L)) break; + if (/^\s/.test(L) || (block.length > 0 && L === "")) { + block.push(L.replace(/^\s+/, "")); + i++; + } else if (block.length > 0) { + break; + } else { + break; + } + } + i--; + out.description = block.join("\n").trim(); + } else { + out.description = stripQuotes(rest); + } + } + } + return out; +} + +function extractFrontmatter(fileContent: string): { frontmatter: string; ok: boolean } { + const trimmed = fileContent.trimStart(); + if (!trimmed.startsWith("---")) return { frontmatter: "", ok: false }; + const afterFirst = trimmed.slice(3).split(/\r?\n/); + const restLines = afterFirst.slice(1); + const end = restLines.findIndex((l) => l.trim() === "---"); + if (end < 0) return { frontmatter: "", ok: false }; + return { frontmatter: restLines.slice(0, end).join("\n"), ok: true }; +} + +function loadSkillFromDir(rootDir: string, scope: SkillScope): DiscoveredSkill | null { + const skillMdPath = path.join(rootDir, "SKILL.md"); + try { + if (!fs.existsSync(skillMdPath) || !fs.statSync(skillMdPath).isFile()) return null; + const content = fs.readFileSync(skillMdPath, "utf-8"); + const { frontmatter, ok } = extractFrontmatter(content); + if (!ok) return null; + const meta = parseSkillFrontmatter(frontmatter); + const name = meta.name?.trim(); + const description = meta.description?.trim(); + if (!name || !description) return null; + return { + name, + description, + skillMdPath: path.resolve(skillMdPath), + rootDir: path.resolve(rootDir), + scope, + }; + } catch { + return null; + } +} + +function listSkillDirectories(skillsRoot: string): string[] { + try { + if (!fs.existsSync(skillsRoot) || !fs.statSync(skillsRoot).isDirectory()) return []; + const entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); + const dirs: string[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + if (e.name.startsWith(".")) continue; + dirs.push(path.join(skillsRoot, e.name)); + } + return dirs.sort(); + } catch { + return []; + } +} + +/** + * Discover Agent Skills under ~/.agents/skills and /.agents/skills. + * Project-level skills override user-level skills with the same `name` (frontmatter). + */ +export function discoverSkills(projectRoot: string): DiscoveredSkill[] { + const userRoot = path.join(os.homedir(), ".agents", "skills"); + const projectSkillsRoot = path.join(projectRoot, ".agents", "skills"); + + const byName = new Map(); + + for (const dir of listSkillDirectories(userRoot)) { + const s = loadSkillFromDir(dir, "user"); + if (s) byName.set(s.name, s); + } + for (const dir of listSkillDirectories(projectSkillsRoot)) { + const s = loadSkillFromDir(dir, "project"); + if (s) byName.set(s.name, s); + } + + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +const SKILLS_INSTRUCTIONS = `AGENT SKILLS (optional): +The following list specialized workflows. Use them when they might help the user's request — not only on exact keyword matches. +If a skill's description fits the task or could improve consistency, read that skill's instructions first using read_file with the path from , then follow the SKILL.md body. +Paths inside a skill (scripts/, references/, assets/) are relative to the skill directory (the folder containing SKILL.md); prefer absolute paths in tool calls.`; + +/** OpenCode-style XML catalog plus activation instructions for read_file. Returns null if no skills. */ +export function formatSkillsForPrompt(skills: DiscoveredSkill[]): string | null { + if (skills.length === 0) return null; + const parts = skills.map( + (s) => + ` \n ${escapeXml(s.name)}\n ${escapeXml(s.description)}\n ${escapeXml(s.skillMdPath)}\n `, + ); + return `${SKILLS_INSTRUCTIONS}\n\n\n${parts.join("\n")}\n`; +} + +/** Plain-text listing for /skills slash command in the TUI. */ +export function formatSkillsForChat(skills: DiscoveredSkill[], projectRoot: string): string { + if (skills.length === 0) { + return [ + "No Agent Skills found.", + "", + `Add skills under ${path.join(projectRoot, ".agents", "skills", "", "SKILL.md")} or ~/.agents/skills//SKILL.md`, + "Install manually or use scripts such as skills.sh; see https://agentskills.io", + ].join("\n"); + } + const lines = [ + `Agent Skills (${skills.length})`, + "", + ...skills.map((s) => `- ${s.name} [${s.scope}]\n ${s.description}\n ${s.skillMdPath}`), + ]; + return lines.join("\n"); +}