mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
Add skills system with Claude Code compatibility (#171)
* Add skills system with Claude Code compatibility * consolidate skills into single module, merge loaders, add <available_skills> XML tags * add Codex CLI skills compatibility, skip hidden/symlinks
This commit is contained in:
parent
4d9a06b931
commit
09bca9672f
7 changed files with 376 additions and 11 deletions
|
|
@ -14,6 +14,10 @@ export interface RetrySettings {
|
|||
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
|
||||
}
|
||||
|
||||
export interface SkillsSettings {
|
||||
enabled?: boolean; // default: true
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
lastChangelogVersion?: string;
|
||||
defaultProvider?: string;
|
||||
|
|
@ -28,6 +32,7 @@ export interface Settings {
|
|||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
hooks?: string[]; // Array of hook file paths
|
||||
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
|
||||
skills?: SkillsSettings;
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
|
|
@ -220,4 +225,16 @@ export class SettingsManager {
|
|||
this.settings.hookTimeout = timeout;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getSkillsEnabled(): boolean {
|
||||
return this.settings.skills?.enabled ?? true;
|
||||
}
|
||||
|
||||
setSkillsEnabled(enabled: boolean): void {
|
||||
if (!this.settings.skills) {
|
||||
this.settings.skills = {};
|
||||
}
|
||||
this.settings.skills.enabled = enabled;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
packages/coding-agent/src/core/skills.ts
Normal file
199
packages/coding-agent/src/core/skills.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME } from "../config.js";
|
||||
|
||||
export interface SkillFrontmatter {
|
||||
name?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type SkillSource = "user" | "project" | "claude-user" | "claude-project" | "codex-user";
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: SkillSource;
|
||||
}
|
||||
|
||||
type SkillFormat = "pi" | "claude" | "codex";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
|
||||
const frontmatter: SkillFrontmatter = { description: "" };
|
||||
|
||||
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
if (!normalizedContent.startsWith("---")) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
}
|
||||
|
||||
const endIndex = normalizedContent.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
}
|
||||
|
||||
const frontmatterBlock = normalizedContent.slice(4, endIndex);
|
||||
const body = normalizedContent.slice(endIndex + 4).trim();
|
||||
|
||||
for (const line of frontmatterBlock.split("\n")) {
|
||||
const match = line.match(/^(\w+):\s*(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
if (key === "name") {
|
||||
frontmatter.name = value;
|
||||
} else if (key === "description") {
|
||||
frontmatter.description = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
function loadSkillsFromDir(dir: string, source: SkillSource, format: SkillFormat, subdir: string = ""): Skill[] {
|
||||
const skills: Skill[] = [];
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (format === "pi") {
|
||||
if (entry.isDirectory()) {
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
skills.push(...loadSkillsFromDir(fullPath, source, format, newSubdir));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameFromFile = entry.name.slice(0, -3);
|
||||
const name = frontmatter.name || (subdir ? `${subdir}:${nameFromFile}` : nameFromFile);
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: fullPath,
|
||||
baseDir: dirname(fullPath),
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} else if (format === "claude") {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = fullPath;
|
||||
const skillFile = join(skillDir, "SKILL.md");
|
||||
|
||||
if (!existsSync(skillFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(skillFile, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = frontmatter.name || entry.name;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: skillFile,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
} else if (format === "codex") {
|
||||
if (entry.isDirectory()) {
|
||||
skills.push(...loadSkillsFromDir(fullPath, source, format));
|
||||
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = dirname(fullPath);
|
||||
const name = frontmatter.name || basename(skillDir);
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: fullPath,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
|
||||
const codexUserDir = join(homedir(), ".codex", "skills");
|
||||
for (const skill of loadSkillsFromDir(codexUserDir, "codex-user", "codex")) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
const claudeUserDir = join(homedir(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDir(claudeUserDir, "claude-user", "claude")) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
const claudeProjectDir = resolve(process.cwd(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDir(claudeProjectDir, "claude-project", "claude")) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills");
|
||||
for (const skill of loadSkillsFromDir(globalSkillsDir, "user", "pi")) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills");
|
||||
for (const skill of loadSkillsFromDir(projectSkillsDir, "project", "pi")) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values());
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import chalk from "chalk";
|
|||
import { existsSync, readFileSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
||||
import { loadSkills, type Skill } from "./skills.js";
|
||||
import type { ToolName } from "./tools/index.js";
|
||||
|
||||
/** Tool descriptions for system prompt */
|
||||
|
|
@ -101,12 +102,39 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string
|
|||
return contextFiles;
|
||||
}
|
||||
|
||||
function buildSkillsSection(skills: Skill[]): string {
|
||||
if (skills.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"\n\n<available_skills>",
|
||||
"The following skills provide specialized instructions for specific tasks.",
|
||||
"Use the read tool to load a skill's file when the task matches its description.",
|
||||
"Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n",
|
||||
];
|
||||
|
||||
for (const skill of skills) {
|
||||
lines.push(`- ${skill.name}: ${skill.description}`);
|
||||
lines.push(` File: ${skill.filePath}`);
|
||||
lines.push(` Base directory: ${skill.baseDir}`);
|
||||
}
|
||||
|
||||
lines.push("</available_skills>");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
customPrompt?: string;
|
||||
selectedTools?: ToolName[];
|
||||
appendSystemPrompt?: string;
|
||||
skillsEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Build the system prompt with tools, guidelines, and context */
|
||||
export function buildSystemPrompt(
|
||||
customPrompt?: string,
|
||||
selectedTools?: ToolName[],
|
||||
appendSystemPrompt?: string,
|
||||
): string {
|
||||
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
|
||||
const { customPrompt, selectedTools, appendSystemPrompt, skillsEnabled = true } = options;
|
||||
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
|
||||
|
||||
|
|
@ -141,6 +169,13 @@ export function buildSystemPrompt(
|
|||
}
|
||||
}
|
||||
|
||||
// Append skills section (only if read tool is available)
|
||||
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
||||
if (skillsEnabled && customPromptHasRead) {
|
||||
const skills = loadSkills();
|
||||
prompt += buildSkillsSection(skills);
|
||||
}
|
||||
|
||||
// Add date/time and working directory last
|
||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
||||
|
|
@ -241,6 +276,12 @@ Documentation:
|
|||
}
|
||||
}
|
||||
|
||||
// Append skills section (only if read tool is available)
|
||||
if (skillsEnabled && hasRead) {
|
||||
const skills = loadSkills();
|
||||
prompt += buildSkillsSection(skills);
|
||||
}
|
||||
|
||||
// Add date/time and working directory last
|
||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue