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:
1 parent
d8a82c31e2
commit
39e206ca86
5 files changed
+203
-4
No files matched your search
+3
-1
@@ -102,4 +102,6 @@ temp/
|
||||
.grok/
|
||||
|
||||
# Cursor config (tracked — do not ignore)
|
||||
# .cursor/
|
||||
# .cursor/
|
||||
.agents
|
||||
skills-lock.json
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, ">").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 <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");
|
||||
}
|
||||
Reference in new issue
Block a user