feat(skills): use ~/.agents/skills/ as primary skills directory with curated catalog

Stop force-syncing bundled skills to ~/.gsd/agent/skills/ on every launch.
Instead, use ~/.agents/skills/ (the industry-standard skills.sh directory)
as the primary global skills location, and .agents/skills/ for project-local
skills.

Changes:
- loadSkills() now scans ~/.agents/skills/ (global) and .agents/skills/ (project)
  instead of ~/.gsd/agent/skills/ and .gsd/skills/
- initResources() no longer syncs src/resources/skills/ → ~/.gsd/agent/skills/
- skill-discovery, skill-telemetry, skill-health, preferences-skills all updated
  to use the ecosystem directory
- New skill-catalog.ts: curated skill packs mapped to tech stacks, with
  brownfield auto-detection and greenfield tech stack selection
- Init wizard gains a skill installation step that presents relevant packs
  and installs via `npx skills add`
- Export ECOSYSTEM_SKILLS_DIR and ECOSYSTEM_PROJECT_SKILLS_DIR from pi-coding-agent

Fixes #2004
This commit is contained in:
Derek Pearson 2026-03-22 05:03:36 -04:00
parent 3c9c6817dc
commit aaed0ab796
10 changed files with 407 additions and 25 deletions

View file

@ -2,11 +2,22 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "f
import ignore from "ignore";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { toPosixPath } from "../utils/path-display.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
/**
* The standard ecosystem skills directory used by skills.sh and the
* Agent Skills standard. All agents share this location for globally
* installed skills.
*/
export const ECOSYSTEM_SKILLS_DIR = join(homedir(), ".agents", "skills");
/**
* The standard project-level skills directory (`.agents/skills/` relative to cwd).
*/
export const ECOSYSTEM_PROJECT_SKILLS_DIR = ".agents";
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
@ -331,7 +342,7 @@ function escapeXml(str: string): string {
export interface LoadSkillsOptions {
/** Working directory for project-local skills. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global skills. Default: ~/.pi/agent */
/** @deprecated Skills now use ~/.agents/skills/ exclusively. This option is ignored. */
agentDir?: string;
/** Explicit skill paths (files or directories) */
skillPaths?: string[];
@ -357,10 +368,7 @@ function resolveSkillPath(p: string, cwd: string): string {
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const { cwd = process.cwd(), agentDir, skillPaths = [], includeDefaults = true } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
const { cwd = process.cwd(), skillPaths = [], includeDefaults = true } = options;
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
@ -404,12 +412,14 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
}
if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
// Primary: ~/.agents/skills/ — the industry-standard skills.sh location
addSkills(loadSkillsFromDirInternal(ECOSYSTEM_SKILLS_DIR, "user", true));
// Primary project: .agents/skills/ — standard project-level location
addSkills(loadSkillsFromDirInternal(resolve(cwd, ECOSYSTEM_PROJECT_SKILLS_DIR, "skills"), "project", true));
}
const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills");
const userSkillsDir = ECOSYSTEM_SKILLS_DIR;
const projectSkillsDir = resolve(cwd, ECOSYSTEM_PROJECT_SKILLS_DIR, "skills");
const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root);

View file

@ -212,6 +212,8 @@ export {
} from "./core/settings-manager.js";
// Skills
export {
ECOSYSTEM_SKILLS_DIR,
ECOSYSTEM_PROJECT_SKILLS_DIR,
formatSkillsForPrompt,
getLoadedSkills,
type LoadSkillsFromDirOptions,

View file

@ -390,7 +390,9 @@ export function initResources(agentDir: string): void {
syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'))
syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'))
syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'))
// Skills are no longer force-synced here. Users install skills via the
// skills.sh CLI (`npx skills add <repo>`) into ~/.agents/skills/ which
// is the industry-standard Agent Skills ecosystem directory.
// Sync GSD-WORKFLOW.md to agentDir as a fallback for when GSD_WORKFLOW_PATH
// env var is not set (e.g. fork/dev builds, alternative entry points).

View file

@ -15,6 +15,7 @@ import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
import { gsdRoot } from "./paths.js";
import { assertSafeDirectory } from "./validate-directory.js";
import type { ProjectDetection, ProjectSignals } from "./detection.js";
import { runSkillInstallStep } from "./skill-catalog.js";
// ─── Types ──────────────────────────────────────────────────────────────────────
@ -223,7 +224,14 @@ export async function showProjectInit(
await customizeAdvancedPrefs(ctx, prefs);
}
// ── Step 8: Bootstrap .gsd/ ────────────────────────────────────────────────
// ── Step 8: Skill Installation ─────────────────────────────────────────────
try {
await runSkillInstallStep(ctx, signals);
} catch {
// Non-fatal — skill installation failure should never block project init
}
// ── Step 9: Bootstrap .gsd/ ────────────────────────────────────────────────
bootstrapGsdDirectory(basePath, prefs, signals);
// Ensure .gitignore

View file

@ -8,7 +8,6 @@
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { statSync } from "node:fs";
import type {
@ -25,12 +24,12 @@ export type { GSDSkillRule, SkillDiscoveryMode, SkillResolution, SkillResolution
/**
* Known skill directories, in priority order.
* User skills (~/.gsd/agent/skills/) take precedence over project skills.
* Global skills (~/.agents/skills/) take precedence over project skills.
*/
export function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
return [
{ dir: join(getAgentDir(), "skills"), method: "user-skill" },
{ dir: join(cwd, ".pi", "agent", "skills"), method: "project-skill" },
{ dir: join(homedir(), ".agents", "skills"), method: "user-skill" },
{ dir: join(cwd, ".agents", "skills"), method: "project-skill" },
];
}

View file

@ -0,0 +1,361 @@
/**
* GSD Skill Catalog Curated skill packs mapped to tech stacks.
*
* Each pack maps a detected (or user-chosen) tech stack to a skills.sh
* repo + specific skill names. The init wizard uses this catalog to
* install relevant skills during project onboarding.
*
* Installation is delegated entirely to the skills.sh CLI:
* npx skills add <repo> --skill <name> --skill <name> -y
*
* Skills are installed into ~/.agents/skills/ (the industry-standard
* ecosystem directory shared across all agents).
*/
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { showNextAction } from "../shared/tui.js";
import type { ProjectSignals } from "./detection.js";
// ─── Catalog Types ────────────────────────────────────────────────────────────
export interface SkillPack {
/** Human-readable name shown in the wizard */
label: string;
/** Short description */
description: string;
/** skills.sh repo identifier (owner/repo) */
repo: string;
/** Specific skill names to install from the repo */
skills: string[];
/** Which detected primaryLanguage values trigger this pack */
matchLanguages?: string[];
/** Which detected project files trigger this pack */
matchFiles?: string[];
}
// ─── Curated Catalog ──────────────────────────────────────────────────────────
export const SKILL_CATALOG: SkillPack[] = [
// ── iOS / Swift ───────────────────────────────────────────────────────────
{
label: "Swift / iOS",
description: "SwiftUI, Swift concurrency, SwiftData, iOS frameworks",
repo: "dpearson2699/swift-ios-skills",
skills: ["*"],
matchLanguages: ["swift"],
matchFiles: ["Package.swift"],
},
// ── React / Next.js ───────────────────────────────────────────────────────
{
label: "React & Web Frontend",
description: "React best practices, web design, accessibility, core web vitals",
repo: "vercel-labs/agent-skills",
skills: [
"vercel-react-best-practices",
"web-design-guidelines",
"vercel-composition-patterns",
],
matchLanguages: ["javascript/typescript"],
},
// ── React Native ──────────────────────────────────────────────────────────
{
label: "React Native",
description: "React Native patterns and cross-platform mobile development",
repo: "vercel-labs/agent-skills",
skills: ["vercel-react-native-skills"],
matchLanguages: ["javascript/typescript"],
},
// ── General Frontend ──────────────────────────────────────────────────────
{
label: "Frontend Design & UX",
description: "Frontend design, accessibility, and browser automation",
repo: "anthropics/skills",
skills: ["frontend-design"],
matchLanguages: ["javascript/typescript"],
},
// ── Rust ──────────────────────────────────────────────────────────────────
{
label: "Rust",
description: "Rust language patterns and best practices",
repo: "anthropics/skills",
skills: ["rust-best-practices"],
matchLanguages: ["rust"],
matchFiles: ["Cargo.toml"],
},
// ── Python ────────────────────────────────────────────────────────────────
{
label: "Python",
description: "Python patterns and best practices",
repo: "anthropics/skills",
skills: ["python-best-practices"],
matchLanguages: ["python"],
matchFiles: ["pyproject.toml", "setup.py"],
},
// ── Go ────────────────────────────────────────────────────────────────────
{
label: "Go",
description: "Go language patterns and best practices",
repo: "anthropics/skills",
skills: ["go-best-practices"],
matchLanguages: ["go"],
matchFiles: ["go.mod"],
},
// ── General Tooling ───────────────────────────────────────────────────────
{
label: "Document Handling",
description: "PDF, DOCX, XLSX, PPTX creation and manipulation",
repo: "anthropics/skills",
skills: ["pdf", "docx", "xlsx", "pptx"],
},
];
// ─── Greenfield Tech Stack Choices ────────────────────────────────────────────
/**
* Choices shown to users when no tech stack can be auto-detected
* (greenfield repos or empty directories).
*/
export const GREENFIELD_STACKS: Array<{
id: string;
label: string;
description: string;
packs: string[];
}> = [
{
id: "ios",
label: "iOS / Swift",
description: "SwiftUI, Swift, iOS frameworks",
packs: ["Swift / iOS"],
},
{
id: "react-web",
label: "React Web",
description: "React, Next.js, web frontend",
packs: ["React & Web Frontend", "Frontend Design & UX"],
},
{
id: "react-native",
label: "React Native",
description: "Cross-platform mobile with React Native",
packs: ["React Native", "React & Web Frontend"],
},
{
id: "fullstack-js",
label: "Full-Stack JavaScript/TypeScript",
description: "Node.js backend + React frontend",
packs: ["React & Web Frontend", "Frontend Design & UX"],
},
{
id: "rust",
label: "Rust",
description: "Systems programming with Rust",
packs: ["Rust"],
},
{
id: "python",
label: "Python",
description: "Python applications, scripts, or ML",
packs: ["Python"],
},
{
id: "go",
label: "Go",
description: "Go services and CLIs",
packs: ["Go"],
},
{
id: "other",
label: "Other / Skip",
description: "Install skills later with npx skills add",
packs: [],
},
];
// ─── Detection → Pack Matching ────────────────────────────────────────────────
/**
* Match project signals to relevant skill packs.
* Returns packs ordered by relevance (language match first, then file match).
*/
export function matchPacksForProject(signals: ProjectSignals): SkillPack[] {
const matched = new Set<SkillPack>();
for (const pack of SKILL_CATALOG) {
// Language match
if (pack.matchLanguages && signals.primaryLanguage) {
if (pack.matchLanguages.includes(signals.primaryLanguage)) {
matched.add(pack);
continue;
}
}
// File match
if (pack.matchFiles) {
for (const file of pack.matchFiles) {
if (signals.detectedFiles.includes(file)) {
matched.add(pack);
break;
}
}
}
}
return [...matched];
}
// ─── Installation ─────────────────────────────────────────────────────────────
/**
* Install a skill pack via the skills.sh CLI.
* Runs: npx skills add <repo> --skill <name> ... -y
*
* Returns true if installation succeeded.
*/
export function installSkillPack(pack: SkillPack): Promise<boolean> {
return new Promise((resolve) => {
const args = ["--yes", "skills", "add", pack.repo];
if (pack.skills.length === 1 && pack.skills[0] === "*") {
args.push("--all");
} else {
for (const skill of pack.skills) {
args.push("--skill", skill);
}
args.push("-y");
}
execFile("npx", args, { timeout: 120_000 }, (error) => {
resolve(!error);
});
});
}
/**
* Check if any skills from a pack are already installed.
*/
export function isPackInstalled(pack: SkillPack): boolean {
const skillsDir = join(homedir(), ".agents", "skills");
if (!existsSync(skillsDir)) return false;
if (pack.skills.length === 1 && pack.skills[0] === "*") {
// For wildcard packs, check if the repo name appears as a skill dir prefix
// This is a heuristic — can't know all skill names without querying the repo
return false;
}
return pack.skills.every((name) =>
existsSync(join(skillsDir, name, "SKILL.md")),
);
}
// ─── Init Wizard Integration ──────────────────────────────────────────────────
/**
* Run skill installation step during project init.
*
* Brownfield (signals.detectedFiles.length > 0):
* Auto-detects tech stack shows matched packs installs accepted ones.
*
* Greenfield (no files detected):
* Asks user what tech stack they're using maps to packs installs.
*
* Returns the list of installed pack labels.
*/
export async function runSkillInstallStep(
ctx: ExtensionCommandContext,
signals: ProjectSignals,
): Promise<string[]> {
const installed: string[] = [];
const isBrownfield = signals.detectedFiles.length > 0;
if (isBrownfield) {
// ── Brownfield: auto-detect and confirm ─────────────────────────────────
const matched = matchPacksForProject(signals);
if (matched.length === 0) return installed;
// Filter out already-installed packs
const toInstall = matched.filter((p) => !isPackInstalled(p));
if (toInstall.length === 0) return installed;
const packNames = toInstall.map((p) => `${p.label}: ${p.description}`);
const choice = await showNextAction(ctx, {
title: "GSD — Install Skills",
summary: [
`Detected: ${signals.primaryLanguage ?? "unknown"} project`,
"",
"Recommended skill packs:",
...packNames.map((n) => `${n}`),
],
actions: [
{
id: "install",
label: "Install recommended skills",
description: `Install ${toInstall.length} skill pack${toInstall.length > 1 ? "s" : ""} via skills.sh`,
recommended: true,
},
{
id: "skip",
label: "Skip",
description: "Install skills later with npx skills add",
},
],
notYetMessage: "Run /gsd init when ready.",
});
if (choice === "install") {
for (const pack of toInstall) {
ctx.ui.notify(`Installing ${pack.label} skills...`, "info");
const ok = await installSkillPack(pack);
if (ok) {
installed.push(pack.label);
} else {
ctx.ui.notify(`Failed to install ${pack.label} — try manually: npx skills add ${pack.repo}`, "info");
}
}
}
} else {
// ── Greenfield: ask user what they're building ──────────────────────────
const stackChoice = await showNextAction(ctx, {
title: "GSD — Project Skills",
summary: [
"What are you building? GSD will install relevant agent skills.",
"Skills are installed globally via skills.sh and shared across agents.",
],
actions: GREENFIELD_STACKS.map((s) => ({
id: s.id,
label: s.label,
description: s.description,
})),
notYetMessage: "Run /gsd init when ready.",
});
if (stackChoice === "not_yet" || stackChoice === "other") return installed;
const stack = GREENFIELD_STACKS.find((s) => s.id === stackChoice);
if (!stack) return installed;
const packsToInstall = SKILL_CATALOG.filter((p) =>
stack.packs.includes(p.label),
).filter((p) => !isPackInstalled(p));
for (const pack of packsToInstall) {
ctx.ui.notify(`Installing ${pack.label} skills...`, "info");
const ok = await installSkillPack(pack);
if (ok) {
installed.push(pack.label);
} else {
ctx.ui.notify(`Failed to install ${pack.label} — try manually: npx skills add ${pack.repo}`, "info");
}
}
}
if (installed.length > 0) {
ctx.ui.notify(`Installed: ${installed.join(", ")}`, "info");
}
return installed;
}

View file

@ -10,9 +10,10 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { homedir } from "node:os";
const SKILLS_DIR = join(getAgentDir(), "skills");
/** Industry-standard skills.sh global skills directory */
const SKILLS_DIR = join(homedir(), ".agents", "skills");
export interface DiscoveredSkill {
name: string;

View file

@ -15,7 +15,7 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { homedir } from "node:os";
import type { UnitMetrics, MetricsLedger } from "./metrics.js";
import { formatCost, formatTokenCount, loadLedgerFromDisk } from "./metrics.js";
import { getSkillLastUsed, detectStaleSkills } from "./skill-telemetry.js";
@ -208,7 +208,7 @@ export function formatSkillDetail(basePath: string, skillName: string): string {
}
// Check for SKILL.md existence
const skillPath = join(getAgentDir(), "skills", skillName, "SKILL.md");
const skillPath = join(homedir(), ".agents", "skills", skillName, "SKILL.md");
if (existsSync(skillPath)) {
const stat = require("node:fs").statSync(skillPath);
lines.push("");

View file

@ -13,7 +13,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { homedir } from "node:os";
// ─── In-memory state ──────────────────────────────────────────────────────────
@ -30,7 +30,7 @@ const activelyLoadedSkills = new Set<string>();
* Called before each unit starts.
*/
export function captureAvailableSkills(): void {
const skillsDir = join(getAgentDir(), "skills");
const skillsDir = join(homedir(), ".agents", "skills");
availableSkills = listSkillNames(skillsDir);
activelyLoadedSkills.clear();
}
@ -99,7 +99,7 @@ export function detectStaleSkills(
const stale: string[] = [];
// Check all installed skills, not just those with usage data
const skillsDir = join(getAgentDir(), "skills");
const skillsDir = join(homedir(), ".agents", "skills");
const installed = listSkillNames(skillsDir);
for (const skill of installed) {

View file

@ -150,8 +150,7 @@ test("initResources syncs extensions, agents, and skills to target dir", async (
// Agents synced
assert.ok(existsSync(join(fakeAgentDir, "agents", "scout.md")), "scout agent synced");
// Skills synced
assert.ok(existsSync(join(fakeAgentDir, "skills")), "skills directory synced");
// Skills are NOT synced here — they use ~/.agents/skills/ via skills.sh
// Version manifest synced
const managedVersion = readManagedResourceVersion(fakeAgentDir);