diff --git a/packages/pi-coding-agent/src/core/skills.ts b/packages/pi-coding-agent/src/core/skills.ts index 9868b1546..f53b78bbc 100644 --- a/packages/pi-coding-agent/src/core/skills.ts +++ b/packages/pi-coding-agent/src/core/skills.ts @@ -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(); const realPathSet = new Set(); @@ -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); diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index 882f92e5b..5a164daf1 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -212,6 +212,8 @@ export { } from "./core/settings-manager.js"; // Skills export { + ECOSYSTEM_SKILLS_DIR, + ECOSYSTEM_PROJECT_SKILLS_DIR, formatSkillsForPrompt, getLoadedSkills, type LoadSkillsFromDirOptions, diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 0571ac272..63cfe0aac 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -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 `) 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). diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index c83cda4a6..de634ce99 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -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 diff --git a/src/resources/extensions/gsd/preferences-skills.ts b/src/resources/extensions/gsd/preferences-skills.ts index b449af8b4..4587503ad 100644 --- a/src/resources/extensions/gsd/preferences-skills.ts +++ b/src/resources/extensions/gsd/preferences-skills.ts @@ -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" }, ]; } diff --git a/src/resources/extensions/gsd/skill-catalog.ts b/src/resources/extensions/gsd/skill-catalog.ts new file mode 100644 index 000000000..c52f96bd4 --- /dev/null +++ b/src/resources/extensions/gsd/skill-catalog.ts @@ -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 --skill --skill -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(); + + 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 --skill ... -y + * + * Returns true if installation succeeded. + */ +export function installSkillPack(pack: SkillPack): Promise { + 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 { + 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; +} diff --git a/src/resources/extensions/gsd/skill-discovery.ts b/src/resources/extensions/gsd/skill-discovery.ts index f623c1a21..e8c224ea4 100644 --- a/src/resources/extensions/gsd/skill-discovery.ts +++ b/src/resources/extensions/gsd/skill-discovery.ts @@ -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; diff --git a/src/resources/extensions/gsd/skill-health.ts b/src/resources/extensions/gsd/skill-health.ts index e08ce3352..4aea63bd1 100644 --- a/src/resources/extensions/gsd/skill-health.ts +++ b/src/resources/extensions/gsd/skill-health.ts @@ -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(""); diff --git a/src/resources/extensions/gsd/skill-telemetry.ts b/src/resources/extensions/gsd/skill-telemetry.ts index ac99e4e83..edf6ff81b 100644 --- a/src/resources/extensions/gsd/skill-telemetry.ts +++ b/src/resources/extensions/gsd/skill-telemetry.ts @@ -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(); * 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) { diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index abf1b582e..a2d8f66a3 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -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);