feat(skills): add cloud platform packs (Firebase, Azure, AWS) and improve detection

- Add Firebase, Azure, and AWS skill packs to curated catalog
- Add cloud config files (firebase.json, cdk.json, samconfig.toml, serverless.yml)
  and React Native markers (metro.config.js/ts, react-native.config.js) to detection
- Fix React Native detection to use matchFiles instead of broad language match
- Add matchAlways flag for essential packs so they are offered to all projects
- Add Firebase/AWS/Azure to greenfield tech stack wizard
- Improve Xcode detection: scan ios/macos/app subdirs, use bounded fd read,
  set primaryLanguage=swift when xcodeproj found, handle SDKROOT=auto via
  SUPPORTED_PLATFORMS fallback
- Fix migration: handle symlinked skill dirs, use atomic wx flag for race safety
This commit is contained in:
Derek Pearson 2026-03-22 06:03:42 -04:00
parent a0ea8c92bb
commit ddeb352143
3 changed files with 186 additions and 29 deletions

View file

@ -1,7 +1,7 @@
import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readlinkSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { compareSemver } from './update-check.js'
@ -437,7 +437,16 @@ function migrateSkillsToEcosystemDir(agentDir: string): void {
const markerPath = join(legacyDir, '.migrated-to-agents')
// Already migrated or no legacy dir — nothing to do
if (!existsSync(legacyDir) || existsSync(markerPath)) return
if (!existsSync(legacyDir)) return
// Atomic marker check — 'wx' fails if file already exists, preventing races
// when two GSD processes start simultaneously.
let markerFd: number
try {
markerFd = openSync(markerPath, 'wx')
} catch {
return // marker already exists (another process won the race, or already migrated)
}
const ecosystemDir = join(homedir(), '.agents', 'skills')
mkdirSync(ecosystemDir, { recursive: true })
@ -446,25 +455,49 @@ function migrateSkillsToEcosystemDir(agentDir: string): void {
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')
// Handle both real directories and symlinks pointing to directories
const isDir = entry.isDirectory()
const isSymlink = entry.isSymbolicLink()
if (!isDir && !isSymlink) continue
const sourcePath = join(legacyDir, entry.name)
// For symlinks, verify the target is a directory
if (isSymlink) {
try {
const stat = statSync(sourcePath)
if (!stat.isDirectory()) continue
} catch {
continue // broken symlink — skip
}
}
const skillMd = join(sourcePath, '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 })
if (isSymlink) {
// Recreate the symlink in the ecosystem directory
const linkTarget = readlinkSync(sourcePath)
symlinkSync(linkTarget, target)
} else {
cpSync(sourcePath, 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 */ }
// Write migration info to the marker
try { writeFileSync(markerFd, `Migrated ${migrated} skill(s) to ${ecosystemDir} on ${new Date().toISOString()}\n`) } catch { /* non-fatal */ }
} catch {
// can't read legacy dir — skip silently
} finally {
try { closeSync(markerFd) } catch { /* non-fatal */ }
}
}

View file

@ -6,7 +6,7 @@
* flow to show when entering a project directory.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, openSync, readSync, closeSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { gsdRoot } from "./paths.js";
@ -92,6 +92,15 @@ export const PROJECT_FILES = [
"mix.exs",
"deno.json",
"deno.jsonc",
// Cloud platform config files
"firebase.json",
"cdk.json",
"samconfig.toml",
"serverless.yml",
// React Native markers
"metro.config.js",
"metro.config.ts",
"react-native.config.js",
] as const;
const LANGUAGE_MAP: Record<string, string> = {
@ -272,6 +281,12 @@ export function detectProjectSignals(basePath: string): ProjectSignals {
// Xcode platform detection — parse SDKROOT from project.pbxproj
const xcodePlatforms = detectXcodePlatforms(basePath);
// Set primaryLanguage to swift when an Xcode project is found but no
// Package.swift was detected (CocoaPods or SPM-less projects).
if (!primaryLanguage && xcodePlatforms.length > 0) {
primaryLanguage = "swift";
}
// Monorepo detection
let isMonorepo = false;
for (const marker of MONOREPO_MARKERS) {
@ -337,36 +352,81 @@ const SDKROOT_MAP: Record<string, XcodePlatform> = {
xrsimulator: "xros",
};
/** Regex for SUPPORTED_PLATFORMS — fallback when SDKROOT = auto (Xcode 15+). */
const SUPPORTED_PLATFORMS_RE = /SUPPORTED_PLATFORMS\s*=\s*"([^"]+)"/gi;
/** Read at most `maxBytes` from a file without loading the full file into memory. */
function readBounded(filePath: string, maxBytes: number): string {
const buf = Buffer.alloc(maxBytes);
const fd = openSync(filePath, "r");
try {
const bytesRead = readSync(fd, buf, 0, maxBytes, 0);
return buf.toString("utf-8", 0, bytesRead);
} finally {
closeSync(fd);
}
}
/** Common subdirectories where .xcodeproj may live in monorepos / standard layouts. */
const XCODE_SUBDIRS = ["ios", "macos", "app", "apps"] as const;
/**
* Scan *.xcodeproj directories for project.pbxproj and extract SDKROOT values.
* Returns deduplicated, canonical platform list (e.g. ["iphoneos"]).
*
* Reading the pbxproj is a lightweight regex scan no full plist parsing needed.
* We read at most 256 KB per file to keep detection fast.
* Searches both the project root and common subdirectories (ios/, macos/, app/).
*/
function detectXcodePlatforms(basePath: string): XcodePlatform[] {
const platforms = new Set<XcodePlatform>();
try {
const entries = readdirSync(basePath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith(".xcodeproj")) continue;
const pbxprojPath = join(basePath, entry.name, "project.pbxproj");
try {
// Read a bounded slice — pbxproj files can be large in huge projects
const content = readFileSync(pbxprojPath, { encoding: "utf-8", flag: "r" }).slice(0, 256 * 1024);
// Match SDKROOT = <value>; — both quoted and unquoted forms
const re = /SDKROOT\s*=\s*"?([a-z]+)"?\s*;/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const canonical = SDKROOT_MAP[m[1].toLowerCase()];
if (canonical) platforms.add(canonical);
// Directories to scan: project root + common subdirs
const dirsToScan = [basePath];
for (const sub of XCODE_SUBDIRS) {
const subPath = join(basePath, sub);
if (existsSync(subPath)) dirsToScan.push(subPath);
}
for (const dir of dirsToScan) {
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith(".xcodeproj")) continue;
const pbxprojPath = join(dir, entry.name, "project.pbxproj");
try {
const content = readBounded(pbxprojPath, 256 * 1024);
// Match SDKROOT = <value>; — both quoted and unquoted forms
const sdkRe = /SDKROOT\s*=\s*"?([a-z]+)"?\s*;/gi;
let m: RegExpExecArray | null;
let foundExplicit = false;
while ((m = sdkRe.exec(content)) !== null) {
const val = m[1].toLowerCase();
if (val === "auto") continue; // handled below via SUPPORTED_PLATFORMS
const canonical = SDKROOT_MAP[val];
if (canonical) {
platforms.add(canonical);
foundExplicit = true;
}
}
// Xcode 15+ defaults SDKROOT to "auto"; fall back to SUPPORTED_PLATFORMS
if (!foundExplicit) {
let sp: RegExpExecArray | null;
while ((sp = SUPPORTED_PLATFORMS_RE.exec(content)) !== null) {
for (const tok of sp[1].split(/\s+/)) {
const canonical = SDKROOT_MAP[tok.toLowerCase()];
if (canonical) platforms.add(canonical);
}
}
SUPPORTED_PLATFORMS_RE.lastIndex = 0;
}
} catch {
// unreadable pbxproj — skip
}
} catch {
// unreadable pbxproj — skip
}
} catch {
// unreadable directory
}
} catch {
// unreadable directory
}
return [...platforms];
}

View file

@ -37,6 +37,8 @@ export interface SkillPack {
matchFiles?: string[];
/** Trigger when Xcode project targets one of these platforms */
matchXcodePlatforms?: XcodePlatform[];
/** Always include this pack in brownfield recommendations */
matchAlways?: boolean;
}
// ─── Curated Catalog ──────────────────────────────────────────────────────────
@ -169,7 +171,7 @@ export const SKILL_CATALOG: SkillPack[] = [
// ── React / Next.js ───────────────────────────────────────────────────────
{
label: "React & Web Frontend",
description: "React best practices, composition patterns, shadcn/ui components",
description: "React best practices and composition patterns",
repo: "vercel-labs/agent-skills",
skills: [
"vercel-react-best-practices",
@ -187,10 +189,10 @@ export const SKILL_CATALOG: SkillPack[] = [
// ── React Native ──────────────────────────────────────────────────────────
{
label: "React Native",
description: "React Native patterns and cross-platform mobile development",
description: "React Native and Expo best practices for performant mobile apps",
repo: "vercel-labs/agent-skills",
skills: ["vercel-react-native-skills"],
matchLanguages: ["javascript/typescript"],
matchFiles: ["metro.config.js", "metro.config.ts", "react-native.config.js"],
},
// ── General Frontend ──────────────────────────────────────────────────────
{
@ -227,24 +229,60 @@ export const SKILL_CATALOG: SkillPack[] = [
matchLanguages: ["go"],
matchFiles: ["go.mod"],
},
// ── Cloud Platforms ────────────────────────────────────────────────────────
{
label: "Firebase",
description: "Firebase setup, auth, Firestore, hosting, and AI Logic",
repo: "firebase/agent-skills",
skills: [
"firebase-basics",
"firebase-auth-basics",
"firebase-firestore-basics",
"firebase-hosting-basics",
"firebase-ai-logic",
],
matchFiles: ["firebase.json"],
},
{
label: "Azure",
description: "Azure deployment, AI services, storage, cost optimization, and diagnostics",
repo: "microsoft/github-copilot-for-azure",
skills: [
"azure-deploy",
"azure-ai",
"azure-storage",
"azure-cost-optimization",
"azure-diagnostics",
],
},
{
label: "AWS",
description: "AWS deployment, Lambda, and serverless patterns",
repo: "awslabs/agent-plugins",
skills: ["deploy", "aws-lambda", "aws-serverless-deployment"],
matchFiles: ["cdk.json", "samconfig.toml", "serverless.yml"],
},
// ── Essential (all projects) ────────────────────────────────────────────
{
label: "Skill Discovery",
description: "Find and install new agent skills from the ecosystem",
repo: "vercel-labs/skills",
skills: ["find-skills"],
matchAlways: true,
},
{
label: "Skill Authoring",
description: "Create, audit, and refine SKILL.md files",
repo: "anthropics/skills",
skills: ["skill-creator"],
matchAlways: true,
},
{
label: "Browser Automation",
description: "Browser automation for web scraping, testing, and interaction",
repo: "vercel-labs/agent-browser",
skills: ["agent-browser"],
matchAlways: true,
},
// ── General Tooling ───────────────────────────────────────────────────────
{
@ -252,6 +290,7 @@ export const SKILL_CATALOG: SkillPack[] = [
description: "PDF, DOCX, XLSX, PPTX creation and manipulation",
repo: "anthropics/skills",
skills: ["pdf", "docx", "xlsx", "pptx"],
matchAlways: true,
},
];
@ -324,6 +363,24 @@ export const GREENFIELD_STACKS: Array<{
description: "Go services and CLIs",
packs: ["Go"],
},
{
id: "firebase",
label: "Firebase",
description: "Firebase backend — auth, Firestore, hosting, AI",
packs: ["Firebase"],
},
{
id: "aws",
label: "AWS",
description: "AWS deployment, Lambda, serverless",
packs: ["AWS"],
},
{
id: "azure",
label: "Azure",
description: "Azure deployment, AI, storage, diagnostics",
packs: ["Azure"],
},
{
id: "other",
label: "Other / Skip",
@ -365,6 +422,11 @@ export function matchPacksForProject(signals: ProjectSignals): SkillPack[] {
const hasMatch = pack.matchXcodePlatforms.some((p) => signals.xcodePlatforms.includes(p));
if (hasMatch) matched.add(pack);
}
// Always-include packs (essentials)
if (pack.matchAlways) {
matched.add(pack);
}
}
return [...matched];
@ -380,6 +442,7 @@ export function matchPacksForProject(signals: ProjectSignals): SkillPack[] {
*/
export function installSkillPack(pack: SkillPack): Promise<boolean> {
return new Promise((resolve) => {
// --yes = npx auto-install, -y = skills.sh non-interactive
const args = ["--yes", "skills", "add", pack.repo];
for (const skill of pack.skills) {
@ -414,6 +477,7 @@ export async function installPacksBatched(
for (const [repo, { skills, labels }] of byRepo) {
onProgress?.(labels.join(", "));
const ok = await new Promise<boolean>((resolve) => {
// --yes = npx auto-install, -y = skills.sh non-interactive
const args = ["--yes", "skills", "add", repo];
for (const skill of skills) {
args.push("--skill", skill);