fix(skills): add migration from ~/.gsd/agent/skills/ to ~/.agents/skills/

Existing GSD users have skills in ~/.gsd/agent/skills/ that would
silently vanish after the directory switch.  This adds:

1. One-time migration in initResources() — copies skill directories
   from ~/.gsd/agent/skills/ to ~/.agents/skills/ (collision-safe,
   writes .migrated-to-agents marker so it runs at most once).

2. Legacy fallback reads in loadSkills() and getSkillSearchDirs() —
   the old directory is scanned as a low-priority fallback so skills
   work immediately, even before the migration runs on next restart.

The old directory is NOT deleted — users can safely downgrade to a
pre-migration GSD version without losing skills.
This commit is contained in:
Derek Pearson 2026-03-22 05:30:29 -04:00
parent 4020828260
commit e706876114
3 changed files with 79 additions and 1 deletions

View file

@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "pat
import { parseFrontmatter } from "../utils/frontmatter.js";
import { toPosixPath } from "../utils/path-display.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { CONFIG_DIR_NAME } from "../config.js";
/**
* The standard ecosystem skills directory used by skills.sh and the
@ -18,6 +19,12 @@ export const ECOSYSTEM_SKILLS_DIR = join(homedir(), ".agents", "skills");
*/
export const ECOSYSTEM_PROJECT_SKILLS_DIR = ".agents";
/**
* Legacy skills directory (~/.gsd/agent/skills/ or ~/.pi/agent/skills/).
* Read as a fallback so existing installs don't lose skills before migration runs.
*/
const LEGACY_SKILLS_DIR = join(homedir(), CONFIG_DIR_NAME, "agent", "skills");
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
@ -416,6 +423,14 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
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));
// Legacy fallback: read skills from ~/.gsd/agent/skills/ so existing
// installs keep working until the one-time migration in resource-loader
// copies them to ~/.agents/skills/. Collision dedup above means already-
// migrated skills won't load twice.
if (LEGACY_SKILLS_DIR !== ECOSYSTEM_SKILLS_DIR && existsSync(LEGACY_SKILLS_DIR)) {
addSkills(loadSkillsFromDirInternal(LEGACY_SKILLS_DIR, "user", true));
}
}
const userSkillsDir = ECOSYSTEM_SKILLS_DIR;

View file

@ -393,6 +393,10 @@ export function initResources(agentDir: string): void {
// 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.
//
// Migrate any user-customized skills from the legacy ~/.gsd/agent/skills/
// directory into ~/.agents/skills/ so they aren't silently lost on upgrade.
migrateSkillsToEcosystemDir(agentDir)
// 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).
@ -409,6 +413,58 @@ export function initResources(agentDir: string): void {
ensureRegistryEntries(join(agentDir, 'extensions'))
}
// ─── Legacy Skill Migration ──────────────────────────────────────────────────────
/**
* One-time migration: copy user-customized skills from the old
* ~/.gsd/agent/skills/ directory into ~/.agents/skills/.
*
* The migration is conservative:
* - Only skill directories containing a SKILL.md are considered.
* - Copies, does not move the old directory stays intact so downgrading
* to a pre-migration GSD version still works.
* - Collision-safe if a skill name already exists in the target, the
* existing ecosystem skill wins (user may have already installed a newer
* version via skills.sh).
* - Writes a `.migrated-to-agents` marker inside the legacy directory so
* the migration runs at most once.
*/
function migrateSkillsToEcosystemDir(agentDir: string): void {
const legacyDir = join(agentDir, 'skills')
const markerPath = join(legacyDir, '.migrated-to-agents')
// Already migrated or no legacy dir — nothing to do
if (!existsSync(legacyDir) || existsSync(markerPath)) return
const ecosystemDir = join(homedir(), '.agents', 'skills')
mkdirSync(ecosystemDir, { recursive: true })
try {
const entries = readdirSync(legacyDir, { withFileTypes: true })
let migrated = 0
for (const entry of entries) {
if (!entry.isDirectory()) continue
const skillMd = join(legacyDir, entry.name, 'SKILL.md')
if (!existsSync(skillMd)) continue
const target = join(ecosystemDir, entry.name)
if (existsSync(target)) continue // ecosystem version wins
try {
cpSync(join(legacyDir, entry.name), target, { recursive: true })
migrated++
} catch {
// non-fatal — skip this skill
}
}
// Drop marker whether or not anything was copied, so we don't re-scan
try { writeFileSync(markerPath, `Migrated ${migrated} skill(s) to ${ecosystemDir} on ${new Date().toISOString()}\n`) } catch { /* non-fatal */ }
} catch {
// can't read legacy dir — skip silently
}
}
export function hasStaleCompiledExtensionSiblings(extensionsDir: string): boolean {
if (!existsSync(extensionsDir)) return false
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {

View file

@ -25,12 +25,19 @@ export type { GSDSkillRule, SkillDiscoveryMode, SkillResolution, SkillResolution
/**
* Known skill directories, in priority order.
* Global skills (~/.agents/skills/) take precedence over project skills.
* Legacy ~/.gsd/agent/skills/ is included as a fallback for pre-migration installs.
*/
export function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
return [
const dirs: Array<{ dir: string; method: SkillResolution["method"] }> = [
{ dir: join(homedir(), ".agents", "skills"), method: "user-skill" },
{ dir: join(cwd, ".agents", "skills"), method: "project-skill" },
];
// Legacy fallback — read skills from old GSD directory until migration completes
const legacyDir = join(homedir(), ".gsd", "agent", "skills");
if (existsSync(legacyDir)) {
dirs.push({ dir: legacyDir, method: "user-skill" });
}
return dirs;
}
/**