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:
parent
a0ea8c92bb
commit
ddeb352143
3 changed files with 186 additions and 29 deletions
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue