Files
oh-my-pi/packages/coding-agent/src/discovery/github.ts
T
roboomp a4be51f08c fix(discovery): scan .github/skills via the github provider
The github provider registered context-files (.github/copilot-instructions.md)
and instructions (.github/instructions/*.instructions.md), but no skills
capability — so .github/skills/<name>/SKILL.md, the layout GitHub documents
for Copilot Agent Skills, was silently never discovered. The skill://
URL resolved to 'Available: none' and nothing surfaced in the system prompt.

Register a skill capability on the github provider (priority 30, project-only)
pointing at .github/skills/ and reuse scanSkillsFromDir with
requireDescription: true to match the Agent Skills spec and the sibling
native/omp-plugins providers. Pin the wiring with a discovery test that
loads the skills capability scoped to the github provider against a temp
cwd containing a SKILL.md, and a negative case that drops a skill missing
a description.

Fixes #1906
2026-06-05 05:36:05 +00:00

155 lines
5.1 KiB
TypeScript

/**
* GitHub Copilot Provider
*
* Loads configuration from GitHub Copilot's config directories.
* Priority: 30 (shared standard provider)
*
* Sources:
* - Project: .github/ (project-only, no user-level discovery)
*
* Capabilities:
* - context-files: copilot-instructions.md in .github/
* - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter
* - skills: <name>/SKILL.md in .github/skills/ (GitHub Agent Skills layout)
*/
import * as path from "node:path";
import { parseFrontmatter } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { readFile } from "../capability/fs";
import { type Instruction, instructionCapability } from "../capability/instruction";
import { type Skill, skillCapability } from "../capability/skill";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
const PROVIDER_ID = "github";
const DISPLAY_NAME = "GitHub Copilot";
const PRIORITY = 30;
// =============================================================================
// Context Files
// =============================================================================
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
const copilotInstructionsPath = getProjectPath(ctx, "github", "copilot-instructions.md");
if (copilotInstructionsPath) {
const content = await readFile(copilotInstructionsPath);
if (content) {
const fileDir = path.dirname(copilotInstructionsPath);
const depth = calculateDepth(ctx.cwd, fileDir, path.sep);
items.push({
path: copilotInstructionsPath,
content,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, copilotInstructionsPath, "project"),
});
}
}
return { items, warnings };
}
// =============================================================================
// Instructions
// =============================================================================
async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instruction>> {
const items: Instruction[] = [];
const warnings: string[] = [];
const instructionsDir = getProjectPath(ctx, "github", "instructions");
if (instructionsDir) {
const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: transformInstruction,
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
function transformInstruction(name: string, content: string, filePath: string, source: SourceMeta): Instruction | null {
// Only process .instructions.md files
if (!name.endsWith(".instructions.md")) {
return null;
}
const { frontmatter, body } = parseFrontmatter(content, { source: filePath });
// Extract applyTo glob pattern from frontmatter
const applyTo = typeof frontmatter.applyTo === "string" ? frontmatter.applyTo : undefined;
// Derive name from filename (strip .instructions.md suffix)
const instructionName = path.basename(name, ".instructions.md");
return {
name: instructionName,
path: filePath,
content: body,
applyTo,
_source: source,
};
}
// =============================================================================
// Skills
// =============================================================================
/**
* Load skills from `.github/skills/<name>/SKILL.md`.
*
* GitHub documents this layout for Copilot Agent Skills and matches the
* non-recursive shape `scanSkillsFromDir` already expects. `requireDescription`
* is on to match the Agent Skills spec (name + description are mandatory) and
* the sibling `native`/`omp-plugins` providers.
*
* @see https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
*/
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const skillsDir = getProjectPath(ctx, "github", "skills");
if (!skillsDir) return { items: [], warnings: [] };
return scanSkillsFromDir(ctx, {
dir: skillsDir,
providerId: PROVIDER_ID,
level: "project",
requireDescription: true,
});
}
// =============================================================================
// Provider Registration
// =============================================================================
registerProvider(contextFileCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load copilot-instructions.md from .github/",
priority: PRIORITY,
load: loadContextFiles,
});
registerProvider(instructionCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load *.instructions.md from .github/instructions/ with applyTo frontmatter",
priority: PRIORITY,
load: loadInstructions,
});
registerProvider<Skill>(skillCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load skills from .github/skills/*/SKILL.md",
priority: PRIORITY,
load: loadSkills,
});