feat: add agent skills (#169)

* feat: Agent Skills discovery and prompt catalog

- Discover skills in ~/.agents/skills and project .agents/skills (project wins on name)
- Inject OpenCode-style <available_skills> XML + read_file instructions in system prompt
- /skills slash command lists installed skills in the TUI
- Document layout and usage in README; gitignore .agents for local skill installs

* chore: ignore skills-lock.json
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-19 23:18:22 +01:00
1 parent d8a82c31e2
commit 39e206ca86
5 files changed
+203 -4

No files matched your search

+3 -1
View File
@@ -102,4 +102,6 @@ temp/
.grok/
# Cursor config (tracked — do not ignore)
# .cursor/
# .cursor/
.agents
skills-lock.json
+19
View File
@@ -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-name>/SKILL.md` (under your project directory)
- **User skills:** `~/.agents/skills/<skill-name>/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.
+5 -1
View File
@@ -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}`;
}
+7 -2
View File
@@ -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;
+169
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 <projectRoot>/.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<string, DiscoveredSkill>();
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 <available_skills> 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 <location>, 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) =>
` <skill>\n <name>${escapeXml(s.name)}</name>\n <description>${escapeXml(s.description)}</description>\n <location>${escapeXml(s.skillMdPath)}</location>\n </skill>`,
);
return `${SKILLS_INSTRUCTIONS}\n\n<available_skills>\n${parts.join("\n")}\n</available_skills>`;
}
/** 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", "<name>", "SKILL.md")} or ~/.agents/skills/<name>/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");
}