feat(skills): parse SDKROOT from pbxproj for platform-aware iOS skill matching
Replace boolean hasXcodeProject with xcodePlatforms array that reads
SDKROOT values from *.xcodeproj/project.pbxproj files. iOS skill packs
now only match when SDKROOT=iphoneos, so macOS / watchOS / visionOS /
tvOS Xcode projects won't get iOS-specific skills.
Also splits the monolithic "Swift / iOS" pack into 8 granular bundles
matching dpearson2699/swift-ios-skills plugin structure:
- SwiftUI + Swift Core (any Swift project)
- iOS App Frameworks, Data Frameworks, AI & ML, Engineering,
Hardware, Platform (iphoneos projects only)
Adds batched installation to minimize npx invocations when multiple
packs share the same repo.
This commit is contained in:
parent
aaed0ab796
commit
4020828260
2 changed files with 285 additions and 45 deletions
|
|
@ -48,6 +48,9 @@ export interface V2Detection {
|
|||
hasContext: boolean;
|
||||
}
|
||||
|
||||
/** Apple platform SDKROOTs found in Xcode project.pbxproj files. */
|
||||
export type XcodePlatform = "iphoneos" | "macosx" | "watchos" | "appletvos" | "xros";
|
||||
|
||||
export interface ProjectSignals {
|
||||
/** Detected project/package files */
|
||||
detectedFiles: string[];
|
||||
|
|
@ -57,6 +60,8 @@ export interface ProjectSignals {
|
|||
isMonorepo: boolean;
|
||||
/** Primary language hint */
|
||||
primaryLanguage?: string;
|
||||
/** Apple platform SDKROOTs detected from *.xcodeproj/project.pbxproj */
|
||||
xcodePlatforms: XcodePlatform[];
|
||||
/** Has existing CI configuration? */
|
||||
hasCI: boolean;
|
||||
/** Has existing test setup? */
|
||||
|
|
@ -264,6 +269,9 @@ export function detectProjectSignals(basePath: string): ProjectSignals {
|
|||
// Git repo detection
|
||||
const isGitRepo = existsSync(join(basePath, ".git"));
|
||||
|
||||
// Xcode platform detection — parse SDKROOT from project.pbxproj
|
||||
const xcodePlatforms = detectXcodePlatforms(basePath);
|
||||
|
||||
// Monorepo detection
|
||||
let isMonorepo = false;
|
||||
for (const marker of MONOREPO_MARKERS) {
|
||||
|
|
@ -306,6 +314,7 @@ export function detectProjectSignals(basePath: string): ProjectSignals {
|
|||
isGitRepo,
|
||||
isMonorepo,
|
||||
primaryLanguage,
|
||||
xcodePlatforms,
|
||||
hasCI,
|
||||
hasTests,
|
||||
packageManager,
|
||||
|
|
@ -313,6 +322,55 @@ export function detectProjectSignals(basePath: string): ProjectSignals {
|
|||
};
|
||||
}
|
||||
|
||||
// ─── Xcode Platform Detection ───────────────────────────────────────────────────
|
||||
|
||||
/** Known SDKROOT values → canonical platform names. */
|
||||
const SDKROOT_MAP: Record<string, XcodePlatform> = {
|
||||
iphoneos: "iphoneos",
|
||||
iphonesimulator: "iphoneos", // simulator builds still target iOS
|
||||
macosx: "macosx",
|
||||
watchos: "watchos",
|
||||
watchsimulator: "watchos",
|
||||
appletvos: "appletvos",
|
||||
appletvsimulator: "appletvos",
|
||||
xros: "xros",
|
||||
xrsimulator: "xros",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// unreadable pbxproj — skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable directory
|
||||
}
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
// ─── Package Manager Detection ──────────────────────────────────────────────────
|
||||
|
||||
function detectPackageManager(basePath: string): string | undefined {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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";
|
||||
import type { ProjectSignals, XcodePlatform } from "./detection.js";
|
||||
|
||||
// ─── Catalog Types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -35,20 +35,137 @@ export interface SkillPack {
|
|||
matchLanguages?: string[];
|
||||
/** Which detected project files trigger this pack */
|
||||
matchFiles?: string[];
|
||||
/** Trigger when Xcode project targets one of these platforms */
|
||||
matchXcodePlatforms?: XcodePlatform[];
|
||||
}
|
||||
|
||||
// ─── Curated Catalog ──────────────────────────────────────────────────────────
|
||||
|
||||
export const SKILL_CATALOG: SkillPack[] = [
|
||||
// ── iOS / Swift ───────────────────────────────────────────────────────────
|
||||
// ── Swift (language-level — any Swift project) ────────────────────────────
|
||||
{
|
||||
label: "Swift / iOS",
|
||||
description: "SwiftUI, Swift concurrency, SwiftData, iOS frameworks",
|
||||
label: "SwiftUI",
|
||||
description: "SwiftUI layout, navigation, animations, gestures, Liquid Glass",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: ["*"],
|
||||
skills: [
|
||||
"swiftui-animation",
|
||||
"swiftui-gestures",
|
||||
"swiftui-layout-components",
|
||||
"swiftui-liquid-glass",
|
||||
"swiftui-navigation",
|
||||
"swiftui-patterns",
|
||||
"swiftui-performance",
|
||||
"swiftui-uikit-interop",
|
||||
],
|
||||
matchLanguages: ["swift"],
|
||||
matchFiles: ["Package.swift"],
|
||||
},
|
||||
{
|
||||
label: "Swift Core",
|
||||
description: "Swift language, concurrency, Codable, Charts, Testing, SwiftData",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"swift-codable",
|
||||
"swift-charts",
|
||||
"swift-concurrency",
|
||||
"swift-language",
|
||||
"swift-testing",
|
||||
"swiftdata",
|
||||
],
|
||||
matchLanguages: ["swift"],
|
||||
matchFiles: ["Package.swift"],
|
||||
},
|
||||
// ── iOS (Xcode project targeting iphoneos required) ───────────────────────
|
||||
{
|
||||
label: "iOS App Frameworks",
|
||||
description: "App Intents, Widgets, StoreKit, MapKit, Live Activities, push notifications",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"alarmkit",
|
||||
"app-clips",
|
||||
"app-intents",
|
||||
"live-activities",
|
||||
"mapkit-location",
|
||||
"photos-camera-media",
|
||||
"push-notifications",
|
||||
"storekit",
|
||||
"tipkit",
|
||||
"widgetkit",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
{
|
||||
label: "iOS Data Frameworks",
|
||||
description: "CloudKit, HealthKit, MusicKit, WeatherKit, Contacts, Calendar",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"cloudkit-sync",
|
||||
"contacts-framework",
|
||||
"eventkit-calendar",
|
||||
"healthkit",
|
||||
"musickit-audio",
|
||||
"passkit-wallet",
|
||||
"weatherkit",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
{
|
||||
label: "iOS AI & ML",
|
||||
description: "Core ML, Vision, on-device AI, speech recognition, NLP",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"apple-on-device-ai",
|
||||
"coreml",
|
||||
"natural-language",
|
||||
"speech-recognition",
|
||||
"vision-framework",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
{
|
||||
label: "iOS Engineering",
|
||||
description: "Networking, security, accessibility, localization, Instruments, App Store review",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"app-store-review",
|
||||
"authentication",
|
||||
"background-processing",
|
||||
"debugging-instruments",
|
||||
"device-integrity",
|
||||
"ios-accessibility",
|
||||
"ios-localization",
|
||||
"ios-networking",
|
||||
"ios-security",
|
||||
"metrickit-diagnostics",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
{
|
||||
label: "iOS Hardware",
|
||||
description: "Bluetooth, CoreMotion, NFC, PencilKit, RealityKit AR",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"core-bluetooth",
|
||||
"core-motion",
|
||||
"core-nfc",
|
||||
"pencilkit-drawing",
|
||||
"realitykit-ar",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
{
|
||||
label: "iOS Platform",
|
||||
description: "CallKit, EnergyKit, HomeKit, SharePlay, PermissionKit",
|
||||
repo: "dpearson2699/swift-ios-skills",
|
||||
skills: [
|
||||
"callkit-voip",
|
||||
"energykit",
|
||||
"homekit-matter",
|
||||
"permissionkit",
|
||||
"shareplay-activities",
|
||||
],
|
||||
matchXcodePlatforms: ["iphoneos"],
|
||||
},
|
||||
// ── React / Next.js ───────────────────────────────────────────────────────
|
||||
{
|
||||
label: "React & Web Frontend",
|
||||
|
|
@ -127,9 +244,24 @@ export const GREENFIELD_STACKS: Array<{
|
|||
}> = [
|
||||
{
|
||||
id: "ios",
|
||||
label: "iOS / Swift",
|
||||
description: "SwiftUI, Swift, iOS frameworks",
|
||||
packs: ["Swift / iOS"],
|
||||
label: "iOS App",
|
||||
description: "Full iOS development — SwiftUI, Swift, and all iOS frameworks",
|
||||
packs: [
|
||||
"SwiftUI",
|
||||
"Swift Core",
|
||||
"iOS App Frameworks",
|
||||
"iOS Data Frameworks",
|
||||
"iOS AI & ML",
|
||||
"iOS Engineering",
|
||||
"iOS Hardware",
|
||||
"iOS Platform",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "swift",
|
||||
label: "Swift (non-iOS)",
|
||||
description: "Swift packages, server-side Swift, CLI tools, SwiftUI without iOS",
|
||||
packs: ["SwiftUI", "Swift Core"],
|
||||
},
|
||||
{
|
||||
id: "react-web",
|
||||
|
|
@ -202,6 +334,12 @@ export function matchPacksForProject(signals: ProjectSignals): SkillPack[] {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Xcode platform match (e.g. iOS packs only when SDKROOT = iphoneos)
|
||||
if (pack.matchXcodePlatforms && signals.xcodePlatforms.length > 0) {
|
||||
const hasMatch = pack.matchXcodePlatforms.some((p) => signals.xcodePlatforms.includes(p));
|
||||
if (hasMatch) matched.add(pack);
|
||||
}
|
||||
}
|
||||
|
||||
return [...matched];
|
||||
|
|
@ -219,14 +357,10 @@ export function installSkillPack(pack: SkillPack): Promise<boolean> {
|
|||
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");
|
||||
for (const skill of pack.skills) {
|
||||
args.push("--skill", skill);
|
||||
}
|
||||
args.push("-y");
|
||||
|
||||
execFile("npx", args, { timeout: 120_000 }, (error) => {
|
||||
resolve(!error);
|
||||
|
|
@ -234,6 +368,41 @@ export function installSkillPack(pack: SkillPack): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install multiple packs, batching by repo to minimize npx invocations.
|
||||
* Returns the labels of successfully installed packs.
|
||||
*/
|
||||
export async function installPacksBatched(
|
||||
packs: SkillPack[],
|
||||
onProgress?: (label: string) => void,
|
||||
): Promise<string[]> {
|
||||
// Group packs by repo
|
||||
const byRepo = new Map<string, { skills: string[]; labels: string[] }>();
|
||||
for (const pack of packs) {
|
||||
const entry = byRepo.get(pack.repo) ?? { skills: [], labels: [] };
|
||||
entry.skills.push(...pack.skills);
|
||||
entry.labels.push(pack.label);
|
||||
byRepo.set(pack.repo, entry);
|
||||
}
|
||||
|
||||
const installed: string[] = [];
|
||||
for (const [repo, { skills, labels }] of byRepo) {
|
||||
onProgress?.(labels.join(", "));
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
const args = ["--yes", "skills", "add", repo];
|
||||
for (const skill of skills) {
|
||||
args.push("--skill", skill);
|
||||
}
|
||||
args.push("-y");
|
||||
execFile("npx", args, { timeout: 120_000 }, (error) => {
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
if (ok) installed.push(...labels);
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any skills from a pack are already installed.
|
||||
*/
|
||||
|
|
@ -241,12 +410,6 @@ 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")),
|
||||
);
|
||||
|
|
@ -281,20 +444,41 @@ export async function runSkillInstallStep(
|
|||
const toInstall = matched.filter((p) => !isPackInstalled(p));
|
||||
if (toInstall.length === 0) return installed;
|
||||
|
||||
const packNames = toInstall.map((p) => `${p.label}: ${p.description}`);
|
||||
// Group for display: Swift packs vs iOS packs vs other
|
||||
const swiftPacks = toInstall.filter((p) => p.matchLanguages?.includes("swift"));
|
||||
const iosPacks = toInstall.filter((p) => p.matchXcodePlatforms?.includes("iphoneos"));
|
||||
const otherPacks = toInstall.filter((p) => !swiftPacks.includes(p) && !iosPacks.includes(p));
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
const hasIOS = signals.xcodePlatforms.includes("iphoneos");
|
||||
if (hasIOS) {
|
||||
summaryLines.push(`Detected: iOS project (${signals.primaryLanguage ?? "swift"})`);
|
||||
} else if (signals.xcodePlatforms.length > 0) {
|
||||
summaryLines.push(`Detected: ${signals.xcodePlatforms.join(", ")} Xcode project (${signals.primaryLanguage ?? "swift"})`);
|
||||
} else {
|
||||
summaryLines.push(`Detected: ${signals.primaryLanguage ?? "unknown"} project`);
|
||||
}
|
||||
summaryLines.push("");
|
||||
summaryLines.push("Recommended skill packs:");
|
||||
if (swiftPacks.length > 0) {
|
||||
summaryLines.push(` Swift: ${swiftPacks.map((p) => p.label).join(", ")}`);
|
||||
}
|
||||
if (iosPacks.length > 0) {
|
||||
summaryLines.push(` iOS: ${iosPacks.map((p) => p.label).join(", ")}`);
|
||||
}
|
||||
for (const p of otherPacks) {
|
||||
summaryLines.push(` • ${p.label}: ${p.description}`);
|
||||
}
|
||||
|
||||
const totalSkills = toInstall.reduce((n, p) => n + p.skills.length, 0);
|
||||
const choice = await showNextAction(ctx, {
|
||||
title: "GSD — Install Skills",
|
||||
summary: [
|
||||
`Detected: ${signals.primaryLanguage ?? "unknown"} project`,
|
||||
"",
|
||||
"Recommended skill packs:",
|
||||
...packNames.map((n) => ` • ${n}`),
|
||||
],
|
||||
summary: summaryLines,
|
||||
actions: [
|
||||
{
|
||||
id: "install",
|
||||
label: "Install recommended skills",
|
||||
description: `Install ${toInstall.length} skill pack${toInstall.length > 1 ? "s" : ""} via skills.sh`,
|
||||
description: `Install ${totalSkills} skills from ${toInstall.length} pack${toInstall.length > 1 ? "s" : ""} via skills.sh`,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -307,14 +491,13 @@ export async function runSkillInstallStep(
|
|||
});
|
||||
|
||||
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");
|
||||
}
|
||||
const labels = await installPacksBatched(toInstall, (label) => {
|
||||
ctx.ui.notify(`Installing ${label} skills...`, "info");
|
||||
});
|
||||
installed.push(...labels);
|
||||
const failed = toInstall.filter((p) => !installed.includes(p.label));
|
||||
for (const pack of failed) {
|
||||
ctx.ui.notify(`Failed to install ${pack.label} — try manually: npx skills add ${pack.repo}`, "info");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -342,14 +525,13 @@ export async function runSkillInstallStep(
|
|||
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");
|
||||
}
|
||||
const labels = await installPacksBatched(packsToInstall, (label) => {
|
||||
ctx.ui.notify(`Installing ${label} skills...`, "info");
|
||||
});
|
||||
installed.push(...labels);
|
||||
const failed = packsToInstall.filter((p) => !installed.includes(p.label));
|
||||
for (const pack of failed) {
|
||||
ctx.ui.notify(`Failed to install ${pack.label} — try manually: npx skills add ${pack.repo}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue