From ddeb352143096c191203b6c732987f9ef5adb0d7 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 06:03:42 -0400 Subject: [PATCH] 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 --- src/resource-loader.ts | 47 +++++++-- src/resources/extensions/gsd/detection.ts | 98 +++++++++++++++---- src/resources/extensions/gsd/skill-catalog.ts | 70 ++++++++++++- 3 files changed, 186 insertions(+), 29 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d5d3fafb6..d99cece1e 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -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 */ } } } diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index cd751a67a..fbed6b89b 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -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 = { @@ -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 = { 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(); - 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 = ; — 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 = ; — 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]; } diff --git a/src/resources/extensions/gsd/skill-catalog.ts b/src/resources/extensions/gsd/skill-catalog.ts index 23f435de0..f24cbb5b3 100644 --- a/src/resources/extensions/gsd/skill-catalog.ts +++ b/src/resources/extensions/gsd/skill-catalog.ts @@ -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 { 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((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);