diff --git a/.github/workflows/update-vendored-skills.yml b/.github/workflows/update-vendored-skills.yml index d76ec82..d245c16 100644 --- a/.github/workflows/update-vendored-skills.yml +++ b/.github/workflows/update-vendored-skills.yml @@ -3,7 +3,7 @@ name: Update vendored skills on: workflow_dispatch: schedule: - - cron: "17 2 * * *" + - cron: '17 2 * * *' permissions: contents: write @@ -28,15 +28,18 @@ jobs: - name: Update vendored skills env: - SKILLS_UPDATE_REHOME: "1" + SKILLS_UPDATE_REHOME: '1' run: ./plugins/vendored/scripts/update-skills.sh + - name: Sync rn-skills lookup table + run: node ./packages/rn-skills/scripts/sync-lookup.mjs + - name: Create pull request uses: peter-evans/create-pull-request@v7 with: branch: automation/update-vendored-skills - commit-message: "chore: update vendored skills" - title: "chore: update vendored skills" + commit-message: 'chore: update vendored skills' + title: 'chore: update vendored skills' labels: enhancement body: | Automated nightly refresh of vendored skills. diff --git a/packages/rn-skills/CONTRIBUTING.md b/packages/rn-skills/CONTRIBUTING.md new file mode 100644 index 0000000..c13faed --- /dev/null +++ b/packages/rn-skills/CONTRIBUTING.md @@ -0,0 +1,7 @@ +To refresh the skill catalog metadata used by the lookup table: + +```bash +npm --prefix packages/rn-skills run sync:lookup +``` + +Existing entries in the [`lookup-table.json`](packages/rn-skills/src/lookup-table.json) will be kept, new ones will be added with default descriptions as in source repos - they need to be adjusted. diff --git a/packages/rn-skills/README.md b/packages/rn-skills/README.md new file mode 100644 index 0000000..8080792 --- /dev/null +++ b/packages/rn-skills/README.md @@ -0,0 +1,234 @@ +# rn-skills + +CLI for recommending and managing React Native agent skills from detected project dependencies, with curated mappings for common React Native libraries. + +It scans every `package.json` under the target directory, compares discovered libraries against a curated lookup table, and uses the [Vercel `skills` CLI](https://vercel.com/docs/agent-resources/skills) underneath to report, install, or remove relevant skills. + +## Installation + +Run it without installing permanently: + +```bash +npx rn-skills +``` + +Or install it globally: + +```bash +npm i -g rn-skills +``` + +## Commands + +```bash +rn-skills +rn-skills auto +rn-skills report +rn-skills interactive +rn-skills list-supported +``` + +What each command does: + +- `rn-skills`: defaults to `auto` +- `auto`: install all missing skills and remove extra managed RN skills without prompts +- `report`: print detected libraries, recommended skills, missing skills, and extra managed RN skills without changing anything +- `interactive`: print the same report and ask which missing skills to install and which extra skills to remove +- `list-supported`: print the curated library-to-skill mappings bundled in the lookup table + +`auto` and `interactive` only remove skills managed by this CLI's lookup table. They do not remove unrelated installed skills. + +## Flags + +These flags are supported for all commands: + +```bash +--cwd Scan and operate on a different project root +--global Compare against and modify global skills instead of project skills +--no-remove Keep extra managed skills installed; only add missing skills +--no-mapping-update Use the bundled local lookup table instead of fetching the latest one +--help, -h Print usage +``` + +`--no-remove` is useful with `auto` and `interactive` when you want recommendations and installs, but do not want the CLI to prune managed skills that are currently not needed by the detected dependencies. + +`--no-mapping-update` forces the CLI to use the packaged `lookup-table.json` instead of trying to fetch the latest version from GitHub. This is useful for offline or firewalled environments, deterministic local testing, and debugging. + +Examples: + +```bash +rn-skills --help +rn-skills report --cwd /path/to/repo +rn-skills auto --global +rn-skills auto --no-remove +rn-skills report --no-mapping-update +rn-skills list-supported +``` + +By default, the CLI attempts to fetch the newest lookup table from GitHub. If that fails, times out, or the downloaded JSON is invalid, it automatically falls back to the bundled local file. + +## Typical Usage + +Inspect recommendations without making changes: + +```bash +rn-skills report --cwd /path/to/repo +``` + +Apply everything automatically: + +```bash +rn-skills +``` + +Apply missing skills without removing currently installed managed ones: + +```bash +rn-skills auto --no-remove +``` + +Use the packaged lookup table only: + +```bash +rn-skills report --no-mapping-update +``` + +Review and choose interactively: + +```bash +rn-skills interactive +``` + +See which libraries and skills are included in the curated mappings: + +```bash +rn-skills list-supported +``` + +## Prior Art + +This tool uses the [Vercel `skills` CLI](https://vercel.com/docs/agent-resources/skills) under the hood. + +--- + +## Made with ❤️ at Callstack + +This CLI is made by Callstack. Excluding ones maintained by Callstack, all other tools, libraries and skills, especially the Vercel `skills` CLI, are not related to Callstack in any way; their maintainers are not related to nor endorse this project. + +[Callstack](https://www.callstack.com/) is a group of React and React Native experts. Contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need help with performance optimization or just want to say hi! + +Like what we do? [Join the Callstack team](https://www.callstack.com/careers) and work on amazing React Native projects! + +# rn-skills + +CLI for recommending and managing React Native agent skills from detected project dependencies, with curated mappings for common React Native libraries, wrapping the [Vercel `skills` CLI](https://vercel.com/docs/agent-resources/skills) - which is used underneath this package. + +It scans every `package.json` under the target directory, compares discovered libraries against a curated lookup table, and uses the Vercel `skills` CLI to report, install, or remove relevant skills. + +## Installation + +Run it without installing permanently: + +```bash +npx rn-skills +``` + +Or install it globally: + +```bash +npm i -g rn-skills +``` + +## Commands + +```bash +rn-skills +rn-skills auto +rn-skills report +rn-skills interactive +rn-skills list-supported +``` + +What each command does: + +- `rn-skills`: defaults to `auto` +- `auto`: install all missing skills and remove extra managed RN skills without prompts +- `report`: print detected libraries, recommended skills, missing skills, and extra managed RN skills without changing anything +- `interactive`: print the same report and ask which missing skills to install and which extra skills to remove +- `list-supported`: print the curated library-to-skill mappings bundled in the lookup table + +`auto` and `interactive` only remove skills managed by this CLI's lookup table. They do not remove unrelated installed skills. + +## Flags + +These flags are supported for all commands: + +```bash +--cwd Scan and operate on a different project root +--global Compare against and modify global skills instead of project skills +--no-remove Keep extra managed skills installed; only add missing skills +--no-mapping-update Use the bundled local lookup table instead of fetching the latest one +--help, -h Print usage +``` + +`--no-remove` is useful with `auto` and `interactive` when you want recommendations and installs, but do not want the CLI to prune managed skills that are currently not needed by the detected dependencies. + +`--no-mapping-update` forces the CLI to use the packaged libraries-to-skills mapping instead of trying to fetch the latest version from GitHub. This is useful for offline or firewalled environments, deterministic local testing, and debugging. + +Examples: + +```bash +rn-skills --help +rn-skills report --cwd /path/to/repo +rn-skills auto --global +rn-skills auto --no-remove +rn-skills list-supported +``` + +The CLI by default attempts to fetch the newest library-to-skills mapping when it is run from our repository. In case this fails, or the structure of the fetched file is unexpected (may happen if you installed the package and a new version with breaking changes is published in the meantime), the packaged . + +## Typical Usage + +Inspect recommendations without making changes: + +```bash +rn-skills report --cwd /path/to/repo +``` + +Apply everything automatically: + +```bash +rn-skills +``` + +Apply missing skills without removing currently installed managed ones: + +```bash +rn-skills auto --no-remove +``` + +Review and choose interactively: + +```bash +rn-skills interactive +``` + +See which libraries and skills are included in our curated mappings: + +```bash +rn-skills list-supported +``` + +## Prior Art + +This tool uses the [Vercel `skills` CLI](https://vercel.com/docs/agent-resources/skills) under the hood. + +--- + +## Made with ❤️ at Callstack + +This CLI is made by Callstack. Excluding ones maintained by Callstack, all other tools, libraries and skills - especially the Vercel `skills` CLI - are not related to Callstack in any way; their maintainers are not related nor endorse this project. + +[Callstack](https://www.callstack.com/) is a group of React and React Native experts. Contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need help with performance optimization or just want to say hi! + +Like what we do? ⚛️ [Join the Callstack team](https://www.callstack.com/careers) and work on amazing React Native projects! diff --git a/packages/rn-skills/bun.lock b/packages/rn-skills/bun.lock new file mode 100644 index 0000000..157a7d1 --- /dev/null +++ b/packages/rn-skills/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "rn-skills", + "dependencies": { + "@clack/prompts": "^1.1.0", + "colorette": "^2.0.20", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "^1.3.11", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/packages/rn-skills/package.json b/packages/rn-skills/package.json new file mode 100644 index 0000000..ae7aefe --- /dev/null +++ b/packages/rn-skills/package.json @@ -0,0 +1,42 @@ +{ + "name": "rn-skills", + "version": "0.1.0", + "description": "Suggests and manages React Native agent skills based on project dependencies.", + "type": "module", + "author": { + "name": "Callstack", + "email": "hello@callstack.com" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/agent-skills.git", + "directory": "packages/rn-skills" + }, + "homepage": "https://github.com/callstackincubator/agent-skills/tree/main/packages/rn-skills", + "bugs": { + "url": "https://github.com/callstackincubator/agent-skills/issues" + }, + "bin": { + "rn-skills": "dist/index.js" + }, + "files": [ + "dist", + "README.md" + ], + "dependencies": { + "@clack/prompts": "^1.1.0", + "colorette": "^2.0.20", + "zod": "^4.3.6" + }, + "scripts": { + "build": "mkdir -p dist && bun build src/index.ts --target=node --outfile dist/index.js", + "prepare": "bun run build", + "start": "bun run src/index.ts", + "sync:lookup": "node ./scripts/sync-lookup.mjs", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "^1.3.11" + } +} diff --git a/packages/rn-skills/scripts/sync-lookup.mjs b/packages/rn-skills/scripts/sync-lookup.mjs new file mode 100644 index 0000000..94f9dff --- /dev/null +++ b/packages/rn-skills/scripts/sync-lookup.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(packageRoot, "..", ".."); +const lookupPath = path.join(packageRoot, "src", "lookup-table.json"); +const vendoredRoot = path.join(repoRoot, "plugins", "vendored"); + +const remoteSources = [ + { + repo: "callstackincubator/agent-skills", + displayName: "Callstack Agent Skills" + }, + { + repo: "software-mansion-labs/skills", + displayName: "Software Mansion Skills" + } +]; + +const vendoredDisplayNameOverrides = { + "callstack/react-native-testing-library": "React Native Testing Library Skills", + "vercel-labs/agent-skills": "Vercel Agent Skills" +}; + +async function main() { + const lookup = JSON.parse(await readFile(lookupPath, "utf8")); + const nextSources = {}; + + for (const source of remoteSources) { + nextSources[source.repo] = { + repo: source.repo, + displayName: source.displayName, + skills: preserveExistingDescriptions(lookup, source.repo, await fetchRemoteSkills(source.repo)) + }; + } + + for (const source of await discoverVendoredSources()) { + nextSources[source.repo] = { + repo: source.repo, + displayName: source.displayName, + skills: preserveExistingDescriptions(lookup, source.repo, await readVendoredSkills(source.skills)) + }; + } + + lookup.lastSyncedAt = new Date().toISOString(); + lookup.sources = nextSources; + await writeFile(lookupPath, `${JSON.stringify(lookup, null, 2)}\n`, "utf8"); + + process.stdout.write(`Synced rn-skills lookup table: ${lookupPath}\n`); +} + +async function discoverVendoredSources() { + const lockfilePath = path.join(vendoredRoot, "skills-lock.json"); + const vendoredSkillsRoot = path.join(vendoredRoot, ".agents", "skills"); + const lockfile = JSON.parse(await readFile(lockfilePath, "utf8")); + const availableSkillNames = new Set(await readdir(vendoredSkillsRoot)); + const skillsBySource = new Map(); + + for (const [skillName, skillInfo] of Object.entries(lockfile.skills)) { + if (skillInfo.sourceType !== "github" || !availableSkillNames.has(skillName)) { + continue; + } + + if (!skillsBySource.has(skillInfo.source)) { + skillsBySource.set(skillInfo.source, []); + } + + skillsBySource.get(skillInfo.source).push(skillName); + } + + return Array.from(skillsBySource.entries()) + .map(([repo, skills]) => ({ + repo, + displayName: vendoredDisplayNameOverrides[repo] ?? formatRepoDisplayName(repo), + skills: skills.sort() + })) + .sort((left, right) => left.repo.localeCompare(right.repo)); +} + +async function fetchRemoteSkills(repo) { + const directoryResponse = await fetch(`https://api.github.com/repos/${repo}/contents/skills`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "rn-skills-sync" + } + }); + + if (!directoryResponse.ok) { + throw new Error(`Failed to list skills for ${repo}: ${directoryResponse.status} ${directoryResponse.statusText}`); + } + + const entries = await directoryResponse.json(); + const directories = entries.filter((entry) => entry.type === "dir").map((entry) => entry.name).sort(); + const skills = []; + + for (const directory of directories) { + const rawSkillUrl = `https://raw.githubusercontent.com/${repo}/main/skills/${directory}/SKILL.md`; + const response = await fetch(rawSkillUrl, { + headers: { + "User-Agent": "rn-skills-sync" + } + }); + + if (!response.ok) { + continue; + } + + const skillMarkdown = await response.text(); + skills.push({ + name: directory, + description: extractDescription(skillMarkdown) + }); + } + + return skills; +} + +async function readVendoredSkills(skillNames) { + const skills = []; + + for (const skillName of skillNames) { + const skillPath = path.join(vendoredRoot, ".agents", "skills", skillName, "SKILL.md"); + const skillMarkdown = await readFile(skillPath, "utf8"); + skills.push({ + name: skillName, + description: extractDescription(skillMarkdown) + }); + } + + return skills; +} + +function formatRepoDisplayName(repo) { + const repoName = repo.split("/")[1] ?? repo; + return repoName + .split(/[-_]/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" "); +} + +function preserveExistingDescriptions(lookup, repo, skills) { + const existingDescriptionsBySkillName = new Map( + (lookup.sources?.[repo]?.skills ?? []).map((skill) => [skill.name, skill.description]) + ); + + return skills.map((skill) => ({ + ...skill, + description: existingDescriptionsBySkillName.get(skill.name) ?? skill.description + })); +} + +function extractDescription(skillMarkdown) { + const frontmatterMatch = skillMarkdown.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return ""; + } + + const frontmatterLines = frontmatterMatch[1].split("\n"); + + for (let index = 0; index < frontmatterLines.length; index += 1) { + const line = frontmatterLines[index]; + const descriptionMatch = line.match(/^description:\s*(.*)$/); + if (!descriptionMatch) { + continue; + } + + const inlineValue = descriptionMatch[1].trim(); + if (inlineValue && inlineValue !== ">" && inlineValue !== "|" && inlineValue !== ">-" && inlineValue !== "|-") { + return inlineValue; + } + + const descriptionLines = []; + for (let nextIndex = index + 1; nextIndex < frontmatterLines.length; nextIndex += 1) { + const nextLine = frontmatterLines[nextIndex]; + if (/^\S/.test(nextLine)) { + break; + } + + descriptionLines.push(nextLine.replace(/^\s+/, "")); + } + + return descriptionLines.join(" ").replace(/\s+/g, " ").trim(); + } + + return ""; +} + +await main(); diff --git a/packages/rn-skills/src/core.ts b/packages/rn-skills/src/core.ts new file mode 100644 index 0000000..69b9901 --- /dev/null +++ b/packages/rn-skills/src/core.ts @@ -0,0 +1,353 @@ +import {Dirent} from 'node:fs'; +import { + mkdtemp, + mkdir, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {z} from 'zod'; +import lookupTableJson from './lookup-table.json'; + +export type Scope = 'project' | 'global'; + +export type LookupSkill = { + name: string; + description: string; +}; + +export type LookupSource = { + repo: string; + displayName: string; + skills: LookupSkill[]; +}; + +export type LookupLibrary = { + skillRefs: string[]; +}; + +export type LookupTable = { + catalogVersion: number; + lastSyncedAt: string; + sources: Record; + libraries: Record; +}; + +export type InstalledSkill = { + name: string; + path: string; + scope: string; + agents: string[]; +}; + +export type RecommendedSkill = { + ref: string; + sourceRepo: string; + sourceDisplayName: string; + name: string; + description: string; + matchedLibraries: string[]; +}; + +export type ProjectScan = { + packageJsonPaths: string[]; + libraries: string[]; +}; + +export type SkillPlan = { + packageJsonPaths: string[]; + libraries: string[]; + recommendedSkills: RecommendedSkill[]; + missingSkills: RecommendedSkill[]; + extraInstalledSkills: InstalledSkill[]; + ignoredInstalledSkills: InstalledSkill[]; +}; + +type PackageManifest = { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +}; + +const lookupTable = lookupTableJson as LookupTable; +const REMOTE_LOOKUP_TABLE_URL = + 'https://raw.githubusercontent.com/callstackincubator/agent-skills/refs/heads/main/packages/rn-skills/src/lookup-table.json'; +const LOOKUP_TABLE_FETCH_TIMEOUT_MS = 1500; + +let remoteLookupTablePromise: Promise | undefined; + +const lookupTableSchema = z.object({ + catalogVersion: z.number(), + lastSyncedAt: z.string(), + sources: z.record( + z.string(), + z.object({ + repo: z.string(), + displayName: z.string(), + skills: z.array( + z.object({ + name: z.string(), + description: z.string(), + }), + ), + }), + ), + libraries: z.record( + z.string(), + z.object({ + skillRefs: z.array(z.string()), + }), + ), +}); + +const IGNORE_DIRECTORY_NAMES = new Set([ + '.git', + '.hg', + '.next', + '.turbo', + '.yarn', + 'android', + 'build', + 'coverage', + 'dist', + 'ios', + 'node_modules', + 'Pods', +]); + +export async function getLookupTableWithOptions(options?: { + disableRemoteLookup?: boolean; +}): Promise { + if (options?.disableRemoteLookup) { + return lookupTable; + } + + if (!remoteLookupTablePromise) { + remoteLookupTablePromise = fetchRemoteLookupTable(); + } + + return remoteLookupTablePromise; +} + +export function getBundledLookupTable(): LookupTable { + return lookupTable; +} + +export async function discoverPackageJsonPaths( + rootDirectory: string, +): Promise { + const results: string[] = []; + + async function walk(directory: string): Promise { + const entries = await readdir(directory, {withFileTypes: true}); + + for (const entry of entries) { + if (shouldSkipEntry(entry)) { + continue; + } + + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (entry.isFile() && entry.name === 'package.json') { + results.push(fullPath); + } + } + } + + await walk(rootDirectory); + return results.sort(); +} + +function shouldSkipEntry(entry: Dirent): boolean { + if (!entry.isDirectory()) { + return false; + } + + return IGNORE_DIRECTORY_NAMES.has(entry.name); +} + +export async function scanProjectLibraries( + rootDirectory: string, +): Promise { + const packageJsonPaths = await discoverPackageJsonPaths(rootDirectory); + const libraries = new Set(); + + for (const packageJsonPath of packageJsonPaths) { + const manifest = await readJsonFile(packageJsonPath); + for (const dependencyGroup of [ + manifest.dependencies, + manifest.devDependencies, + manifest.peerDependencies, + manifest.optionalDependencies, + ]) { + if (!dependencyGroup) { + continue; + } + + for (const libraryName of Object.keys(dependencyGroup)) { + libraries.add(libraryName); + } + } + } + + return { + packageJsonPaths, + libraries: Array.from(libraries).sort(), + }; +} + +export function buildSkillPlan( + scan: ProjectScan, + installedSkills: InstalledSkill[], + catalog: LookupTable = lookupTable, +): SkillPlan { + const matchedByRef = new Map>(); + + for (const libraryName of scan.libraries) { + const lookupEntry = catalog.libraries[libraryName]; + if (!lookupEntry) { + continue; + } + + for (const skillRef of lookupEntry.skillRefs) { + if (!matchedByRef.has(skillRef)) { + matchedByRef.set(skillRef, new Set()); + } + + matchedByRef.get(skillRef)!.add(libraryName); + } + } + + const recommendedSkills = Array.from(matchedByRef.entries()) + .map(([ref, matchedLibraries]) => + getRecommendedSkill(ref, matchedLibraries, catalog), + ) + .sort( + (left, right) => + left.name.localeCompare(right.name) || + left.sourceRepo.localeCompare(right.sourceRepo), + ); + + const recommendedNames = new Set( + recommendedSkills.map((skill) => skill.name), + ); + const managedSkillNames = new Set( + Object.values(catalog.libraries) + .flatMap((library) => library.skillRefs) + .map((ref) => ref.split(':')[1]) + .filter((name): name is string => Boolean(name)), + ); + + const missingSkills = recommendedSkills.filter( + (skill) => + !installedSkills.some((installed) => installed.name === skill.name), + ); + const extraInstalledSkills = installedSkills + .filter( + (installed) => + managedSkillNames.has(installed.name) && + !recommendedNames.has(installed.name), + ) + .sort((left, right) => left.name.localeCompare(right.name)); + const ignoredInstalledSkills = installedSkills + .filter((installed) => !managedSkillNames.has(installed.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + + return { + packageJsonPaths: scan.packageJsonPaths, + libraries: scan.libraries, + recommendedSkills, + missingSkills, + extraInstalledSkills, + ignoredInstalledSkills, + }; +} + +export async function createTempProject( + packageJsonFiles: Record, +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'rn-skills-')); + + for (const [relativePath, contents] of Object.entries(packageJsonFiles)) { + const absolutePath = join(directory, relativePath); + await mkdir(dirname(absolutePath), {recursive: true}); + await writeFile( + absolutePath, + `${JSON.stringify(contents, null, 2)}\n`, + 'utf8', + ); + } + + return directory; +} + +export async function removeTempProject(directory: string): Promise { + await rm(directory, {recursive: true, force: true}); +} + +function getRecommendedSkill( + ref: string, + matchedLibraries: Set, + catalog: LookupTable, +): RecommendedSkill { + const [sourceRepo, skillName] = ref.split(':'); + if (!sourceRepo || !skillName) { + throw new Error(`Invalid skill reference: ${ref}`); + } + + const source = catalog.sources[sourceRepo]; + if (!source) { + throw new Error(`Unknown source repository in lookup table: ${sourceRepo}`); + } + + const skill = source.skills.find((candidate) => candidate.name === skillName); + if (!skill) { + throw new Error(`Unknown skill "${skillName}" for source "${sourceRepo}"`); + } + + return { + ref, + sourceRepo, + sourceDisplayName: source.displayName, + name: skill.name, + description: skill.description, + matchedLibraries: Array.from(matchedLibraries).sort(), + }; +} + +async function readJsonFile(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T; +} + +export function getSkillsCliArgs(scope: Scope): string[] { + return scope === 'global' ? ['-g'] : []; +} + +async function fetchRemoteLookupTable(): Promise { + try { + const response = await fetch(REMOTE_LOOKUP_TABLE_URL, { + signal: AbortSignal.timeout(LOOKUP_TABLE_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return lookupTable; + } + + const payload = lookupTableSchema.safeParse(await response.json()); + if (!payload.success) { + return lookupTable; + } + + return payload.data; + } catch { + return lookupTable; + } +} diff --git a/packages/rn-skills/src/index.ts b/packages/rn-skills/src/index.ts new file mode 100644 index 0000000..b08fcf9 --- /dev/null +++ b/packages/rn-skills/src/index.ts @@ -0,0 +1,395 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process'; +import {cwd} from 'node:process'; +import {cancel, intro, isCancel, multiselect, outro} from '@clack/prompts'; +import {dim, italic} from 'colorette'; + +import { + buildSkillPlan, + getBundledLookupTable, + getLookupTableWithOptions, + getSkillsCliArgs, + scanProjectLibraries, +} from './core'; +import type {InstalledSkill, Scope} from './core'; +import {error, info, printBanner, section, success, warn} from './logger'; + +type Command = 'auto' | 'interactive' | 'report' | 'list-supported'; + +type CliOptions = { + command: Command; + scope: Scope; + rootDirectory: string; + help: boolean; + remove: boolean; + disableRemoteLookup: boolean; +}; + +function getUsage(): string { + return 'Usage: rn-skills [report|interactive|auto|list-supported] [--global] [--cwd ] [--no-remove] [--no-mapping-update] [--help]'; +} + +function parseArgs(argv: string[]): CliOptions { + if (argv.includes('--help') || argv.includes('-h')) { + return { + command: 'auto', + scope: 'project', + rootDirectory: cwd(), + help: true, + remove: true, + disableRemoteLookup: false, + }; + } + + const [firstArg, ...rest] = argv; + let command: Command = 'auto'; + const input = + firstArg === undefined + ? [] + : firstArg === 'auto' || + firstArg === 'interactive' || + firstArg === 'report' || + firstArg === 'list-supported' + ? [...rest] + : firstArg.startsWith('--') + ? [...argv] + : (() => { + throw new Error(getUsage()); + })(); + let scope: Scope = 'project'; + let rootDirectory = cwd(); + let remove = true; + let disableRemoteLookup = false; + + if ( + firstArg === 'auto' || + firstArg === 'interactive' || + firstArg === 'report' || + firstArg === 'list-supported' + ) { + command = firstArg; + } + + while (input.length > 0) { + const arg = input.shift()!; + if (arg === '--global') { + scope = 'global'; + continue; + } + if (arg === '--cwd') { + const value = input.shift(); + if (!value) { + throw new Error('Pass a directory after --cwd.'); + } + + rootDirectory = value; + continue; + } + if (arg === '--no-remove') { + remove = false; + continue; + } + if (arg === '--no-mapping-update') { + disableRemoteLookup = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return { + command, + scope, + rootDirectory, + help: false, + remove, + disableRemoteLookup, + }; +} + +async function getInstalledSkills( + scope: Scope, + rootDirectory: string, +): Promise { + const output = execFileSync( + 'npx', + ['-y', 'skills', 'list', '--json', ...getSkillsCliArgs(scope)], + { + cwd: rootDirectory, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + return JSON.parse(output) as InstalledSkill[]; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + process.stdout.write(`${getUsage()}\n`); + return; + } + + printBanner(); + const lookup = await getLookupTableWithOptions({ + disableRemoteLookup: options.disableRemoteLookup, + }); + if (options.command === 'list-supported') { + intro('Listing curated React Native library mappings'); + printSupportedMappings(lookup); + outro('Listed supported libraries and skills.'); + return; + } + + intro(`Inspecting ${options.rootDirectory}`); + info( + 'Using the Vercel Skills CLI documented at https://vercel.com/docs/agent-resources/skills', + ); + + const scan = await scanProjectLibraries(options.rootDirectory); + const installedSkills = await getInstalledSkills( + options.scope, + options.rootDirectory, + ); + const plan = buildSkillPlan(scan, installedSkills, lookup); + + printPlan(plan, options.scope); + + if (options.command === 'report') { + outro('Report complete. No changes applied.'); + return; + } + + if (options.command === 'auto') { + await applyChanges({ + rootDirectory: options.rootDirectory, + scope: options.scope, + installs: plan.missingSkills.map((skill) => skill.ref), + removals: options.remove + ? plan.extraInstalledSkills.map((skill) => skill.name) + : [], + }); + outro('Finished applying recommended skill changes.'); + return; + } + + const installRefs = await askForInstalls( + plan.missingSkills.map((skill) => skill.ref), + lookup, + ); + const removalNames = await askForRemovals( + options.remove ? plan.extraInstalledSkills.map((skill) => skill.name) : [], + ); + + await applyChanges({ + rootDirectory: options.rootDirectory, + scope: options.scope, + installs: installRefs, + removals: removalNames, + }); + + outro('Finished applying selected skill changes.'); +} + +function printSupportedMappings( + lookup: Awaited>, +): void { + const sortedLibraries = Object.entries(lookup.libraries).sort( + ([left], [right]) => left.localeCompare(right), + ); + + info( + `The CLI provides the following ${sortedLibraries.length} curated library mappings.`, + ); + + for (const [libraryName, library] of sortedLibraries) { + process.stdout.write(`- ${libraryName}\n`); + + for (const skillRef of library.skillRefs) { + const [sourceRepo, skillName] = skillRef.split(':'); + const source = lookup.sources[sourceRepo]; + const skill = source?.skills.find( + (candidate) => candidate.name === skillName, + ); + const hint = source + ? `\t· ${skillName} from ${source.displayName}` + : skillRef; + + process.stdout.write(` ${hint}\n`); + } + } +} + +function printPlan( + plan: ReturnType, + scope: Scope, +): void { + section('Project Scan'); + info( + `Found ${plan.packageJsonPaths.length} package.json file(s) and ${plan.libraries.length} dependency name(s).`, + ); + info(`Comparing against ${scope} skills installed via \`npx skills\`.`); + + section('Recommended Skills'); + if (plan.recommendedSkills.length === 0) { + warn('No recommended skills matched the detected libraries.'); + } else { + for (const skill of plan.recommendedSkills) { + process.stdout.write( + `- ${skill.name} from ${skill.sourceRepo}\n` + + ` matches: ${skill.matchedLibraries.join(', ')}\n` + + ` reason: ${skill.description}\n`, + ); + } + } + + section('Missing Skills'); + if (plan.missingSkills.length === 0) { + success('No missing skills.'); + } else { + for (const skill of plan.missingSkills) { + process.stdout.write(`- ${skill.name} from ${skill.sourceRepo}\n`); + } + } + + section('Installed But Not Needed'); + if (plan.extraInstalledSkills.length === 0) { + success('No extra managed RN skills detected.'); + } else { + for (const skill of plan.extraInstalledSkills) { + process.stdout.write(`- ${skill.name}\n`); + } + } + + if (plan.ignoredInstalledSkills.length > 0) { + section('Ignored Installed Skills'); + info( + `Leaving ${ + plan.ignoredInstalledSkills.length + } installed skill(s) alone because they are outside the RN lookup table: ${plan.ignoredInstalledSkills + .map((skill) => skill.name) + .join(', ')}`, + ); + } +} + +async function askForInstalls( + skillRefs: string[], + lookup: Awaited< + ReturnType + > = getBundledLookupTable(), +): Promise { + if (skillRefs.length === 0) { + return []; + } + const selection = await multiselect({ + message: 'Which missing skills should rn-skills install?', + options: skillRefs.map((ref) => { + const [sourceRepo, skillName] = ref.split(':'); + const source = lookup.sources[sourceRepo]; + + return { + value: ref, + label: skillName, + hint: source?.displayName ?? sourceRepo, + selected: true, + }; + }), + }); + + if (isCancel(selection)) { + cancel('Interactive install selection cancelled.'); + process.exit(1); + } + + return selection; +} + +async function askForRemovals(skillNames: string[]): Promise { + if (skillNames.length === 0) { + return []; + } + + const selection = await multiselect({ + message: 'Which extra skills should rn-skills remove?', + options: skillNames.map((skillName) => ({ + value: skillName, + label: skillName, + selected: true, + })), + }); + + if (isCancel(selection)) { + cancel('Interactive removal selection cancelled.'); + process.exit(1); + } + + return selection; +} + +async function applyChanges(options: { + rootDirectory: string; + scope: Scope; + installs: string[]; + removals: string[]; +}): Promise { + if (options.installs.length === 0 && options.removals.length === 0) { + info('Nothing to change.'); + return; + } + + for (const ref of options.installs) { + const [sourceRepo, skillName] = ref.split(':'); + info( + `Installing ${skillName} from ${sourceRepo} using the Vercel Skills CLI (${dim( + italic('npx skills add'), + )})`, + ); + execFileSync( + 'npx', + [ + '-y', + 'skills', + 'add', + sourceRepo, + '--skill', + skillName, + '--yes', + ...getSkillsCliArgs(options.scope), + ], + { + cwd: options.rootDirectory, + stdio: 'inherit', + }, + ); + } + + for (const skillName of options.removals) { + info(`Removing ${skillName}`); + execFileSync( + 'npx', + [ + '-y', + 'skills', + 'remove', + skillName, + '--yes', + ...getSkillsCliArgs(options.scope), + ], + { + cwd: options.rootDirectory, + stdio: 'inherit', + }, + ); + } + + success('Skill changes applied.'); +} + +main().catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + error(message); + process.exit(1); +}); diff --git a/packages/rn-skills/src/logger.ts b/packages/rn-skills/src/logger.ts new file mode 100644 index 0000000..ee81ba7 --- /dev/null +++ b/packages/rn-skills/src/logger.ts @@ -0,0 +1,36 @@ +import {bold, cyan, dim, green, magenta, red, yellow} from 'colorette'; + +export function printBanner(): void { + process.stdout.write( + bold( + magenta( + '██████╗ ███╗ ██╗ ███████╗██╗ ██╗██╗██╗ ██╗ ███████╗\n' + + '██╔══██╗████╗ ██║ ██╔════╝██║ ██╔╝██║██║ ██║ ██╔════╝\n' + + '██████╔╝██╔██╗ ██║ ███████╗█████╔╝ ██║██║ ██║ ███████╗\n' + + '██╔══██╗██║╚██╗██║ ╚════██║██╔═██╗ ██║██║ ██║ ╚════██║\n' + + '██║ ██║██║ ╚████║ ███████║██║ ██╗██║███████╗███████╗███████║\n' + + '╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚══════╝\n', + ), + ) + `${dim('React Native Skills by Callstack')}\n\n`, + ); +} + +export function info(message: string): void { + process.stdout.write(`${cyan('info')} ${message}\n`); +} + +export function success(message: string): void { + process.stdout.write(`${green('success')} ${message}\n`); +} + +export function warn(message: string): void { + process.stdout.write(`${yellow('warn')} ${message}\n`); +} + +export function error(message: string): void { + process.stderr.write(`${red('error')} ${message}\n`); +} + +export function section(title: string): void { + process.stdout.write(`\n${bold(title)}\n`); +} diff --git a/packages/rn-skills/src/lookup-table.json b/packages/rn-skills/src/lookup-table.json new file mode 100644 index 0000000..242a88d --- /dev/null +++ b/packages/rn-skills/src/lookup-table.json @@ -0,0 +1,158 @@ +{ + "catalogVersion": 1, + "lastSyncedAt": "2026-03-31T14:55:12.244Z", + "sources": { + "callstackincubator/agent-skills": { + "repo": "callstackincubator/agent-skills", + "displayName": "Callstack Agent Skills", + "skills": [ + { + "name": "github", + "description": "GitHub workflow patterns for PRs, code review, branching." + }, + { + "name": "github-actions", + "description": "GitHub Actions workflow patterns for React Native simulator/emulator build artifacts." + }, + { + "name": "upgrading-react-native", + "description": "React Native upgrade workflow: templates, dependencies, and common pitfalls." + }, + { + "name": "react-native-best-practices", + "description": "React Native optimization best practices from Callstack." + }, + { + "name": "react-native-brownfield-migration", + "description": "Incremental migration strategy to adopt React Native or Expo in native apps using @callstack/react-native-brownfield, with setup, packaging, and phased integration steps." + } + ] + }, + "software-mansion-labs/skills": { + "repo": "software-mansion-labs/skills", + "displayName": "Software Mansion Skills", + "skills": [ + { + "name": "radon-mcp", + "description": "Best practices for using Radon IDE's MCP tools when developing, debugging, and inspecting React Native and Expo apps." + }, + { + "name": "react-native-best-practices", + "description": "Software Mansion's best practices for production React Native and Expo apps on the New Architecture." + } + ] + }, + "callstack/react-native-testing-library": { + "repo": "callstack/react-native-testing-library", + "displayName": "React Native Testing Library Skills", + "skills": [ + { + "name": "react-native-testing", + "description": "Write tests using React Native Testing Library (RNTL) for React Native and Expo apps." + } + ] + }, + "callstackincubator/agent-device": { + "repo": "callstackincubator/agent-device", + "displayName": "Agent Device", + "skills": [ + { + "name": "agent-device", + "description": "Automates interactions for Apple-platform apps (iOS, tvOS, macOS) and Android devices." + }, + { + "name": "dogfood", + "description": "Systematically explore and test a mobile app on iOS/Android with agent-device to find bugs, UX issues, and other problems." + } + ] + }, + "vercel-labs/agent-skills": { + "repo": "vercel-labs/agent-skills", + "displayName": "Vercel Agent Skills", + "skills": [ + { + "name": "vercel-react-native-skills", + "description": "Vercel's collection of React Native and Expo best practices for building performant mobile apps." + } + ] + } + }, + "libraries": { + "@callstack/react-native-brownfield": { + "skillRefs": [ + "callstackincubator/agent-skills:react-native-brownfield-migration" + ] + }, + "@react-navigation/native": { + "skillRefs": ["vercel-labs/agent-skills:vercel-react-native-skills"] + }, + "@react-navigation/native-stack": { + "skillRefs": ["vercel-labs/agent-skills:vercel-react-native-skills"] + }, + "@shopify/flash-list": { + "skillRefs": [ + "callstackincubator/agent-skills:react-native-best-practices", + "vercel-labs/agent-skills:vercel-react-native-skills" + ] + }, + "@testing-library/react-native": { + "skillRefs": [ + "callstack/react-native-testing-library:react-native-testing" + ] + }, + "expo": { + "skillRefs": [ + "callstackincubator/agent-skills:react-native-best-practices", + "vercel-labs/agent-skills:vercel-react-native-skills" + ] + }, + "expo-font": { + "skillRefs": ["vercel-labs/agent-skills:vercel-react-native-skills"] + }, + "expo-image": { + "skillRefs": ["vercel-labs/agent-skills:vercel-react-native-skills"] + }, + "react-native": { + "skillRefs": [ + "callstackincubator/agent-skills:react-native-best-practices", + "callstackincubator/agent-skills:upgrading-react-native" + ] + }, + "react-native-audio-api": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-enriched": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-enriched-markdown": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-executorch": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-gesture-handler": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-reanimated": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-safe-area-context": { + "skillRefs": ["vercel-labs/agent-skills:vercel-react-native-skills"] + }, + "react-native-screens": { + "skillRefs": [ + "callstackincubator/agent-skills:react-native-best-practices", + "vercel-labs/agent-skills:vercel-react-native-skills" + ] + }, + "react-native-svg": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-video": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + }, + "react-native-worklets": { + "skillRefs": ["software-mansion-labs/skills:react-native-best-practices"] + } + } +} diff --git a/packages/rn-skills/test/core.test.ts b/packages/rn-skills/test/core.test.ts new file mode 100644 index 0000000..cab3848 --- /dev/null +++ b/packages/rn-skills/test/core.test.ts @@ -0,0 +1,173 @@ +import {afterEach, describe, expect, it} from 'bun:test'; +import { + buildSkillPlan, + createTempProject, + discoverPackageJsonPaths, + getBundledLookupTable, + getLookupTableWithOptions, + removeTempProject, + scanProjectLibraries, +} from '../src/core'; + +const tempDirectories: string[] = []; + +afterEach(async () => { + while (tempDirectories.length > 0) { + await removeTempProject(tempDirectories.pop()!); + } +}); + +describe('discoverPackageJsonPaths', () => { + it('finds package.json files across a monorepo', async () => { + const root = await createTempProject({ + 'package.json': { + private: true, + }, + 'apps/mobile/package.json': { + dependencies: { + expo: '^54.0.0', + }, + }, + 'packages/ui/package.json': { + peerDependencies: { + 'react-native-svg': '^15.0.0', + }, + }, + 'node_modules/ignored/package.json': { + dependencies: { + react: '^19.0.0', + }, + }, + }); + + tempDirectories.push(root); + + const packageJsonPaths = await discoverPackageJsonPaths(root); + expect(packageJsonPaths).toHaveLength(3); + expect( + packageJsonPaths.some((entry) => entry.includes('node_modules')), + ).toBeFalse(); + }); +}); + +describe('scanProjectLibraries', () => { + it('merges dependency names across all dependency sections', async () => { + const root = await createTempProject({ + 'apps/mobile/package.json': { + dependencies: { + 'react-native': '0.82.0', + 'react-native-reanimated': '^4.0.0', + }, + devDependencies: { + '@testing-library/react-native': '^13.0.0', + }, + peerDependencies: { + expo: '^54.0.0', + }, + }, + }); + + tempDirectories.push(root); + + const scan = await scanProjectLibraries(root); + expect(scan.libraries).toEqual([ + '@testing-library/react-native', + 'expo', + 'react-native', + 'react-native-reanimated', + ]); + }); +}); + +describe('buildSkillPlan', () => { + it('detects missing and extra RN skills', () => { + const plan = buildSkillPlan( + { + packageJsonPaths: ['/repo/package.json'], + libraries: [ + '@testing-library/react-native', + 'react-native', + 'react-native-reanimated', + ], + }, + [ + { + name: 'github', + path: '/repo/.agents/skills/github', + scope: 'project', + agents: ['Cursor'], + }, + { + name: 'react-native-best-practices', + path: '/repo/.agents/skills/react-native-best-practices', + scope: 'project', + agents: ['Cursor'], + }, + ], + ); + + expect(plan.missingSkills.map((skill) => skill.name)).toEqual([ + 'react-native-testing', + 'upgrading-react-native', + ]); + expect(plan.extraInstalledSkills.map((skill) => skill.name)).toEqual([]); + expect(plan.ignoredInstalledSkills.map((skill) => skill.name)).toEqual([ + 'github', + ]); + }); + + it('marks managed but unmatched installed skills as extra', () => { + const plan = buildSkillPlan( + { + packageJsonPaths: ['/repo/package.json'], + libraries: ['@testing-library/react-native'], + }, + [ + { + name: 'react-native-brownfield-migration', + path: '/repo/.agents/skills/react-native-brownfield-migration', + scope: 'project', + agents: ['Cursor'], + }, + { + name: 'github', + path: '/repo/.agents/skills/github', + scope: 'project', + agents: ['Cursor'], + }, + ], + ); + + expect(plan.missingSkills.map((skill) => skill.name)).toEqual([ + 'react-native-testing', + ]); + expect(plan.extraInstalledSkills.map((skill) => skill.name)).toEqual([ + 'react-native-brownfield-migration', + ]); + expect(plan.ignoredInstalledSkills.map((skill) => skill.name)).toEqual([ + 'github', + ]); + }); +}); + +describe('getLookupTableWithOptions', () => { + it('does not attempt a remote fetch when disableRemoteLookup is true', async () => { + const originalFetch = globalThis.fetch; + const fetchCalls: unknown[][] = []; + + globalThis.fetch = (async (...args: unknown[]) => { + fetchCalls.push(args); + throw new Error('fetch should not be called'); + }) as unknown as typeof fetch; + + try { + const lookup = await getLookupTableWithOptions({ + disableRemoteLookup: true, + }); + expect(lookup).toEqual(getBundledLookupTable()); + expect(fetchCalls).toHaveLength(0); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/rn-skills/test/fixtures/brownfield-app/package.json b/packages/rn-skills/test/fixtures/brownfield-app/package.json new file mode 100644 index 0000000..ffa5dff --- /dev/null +++ b/packages/rn-skills/test/fixtures/brownfield-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "brownfield-app", + "private": true, + "dependencies": { + "@callstack/react-native-brownfield": "^0.1.0" + } +} diff --git a/packages/rn-skills/test/fixtures/expo-app/package.json b/packages/rn-skills/test/fixtures/expo-app/package.json new file mode 100644 index 0000000..10808d7 --- /dev/null +++ b/packages/rn-skills/test/fixtures/expo-app/package.json @@ -0,0 +1,12 @@ +{ + "name": "expo-app", + "private": true, + "dependencies": { + "expo": "^54.0.0", + "react-native": "0.82.0", + "react-native-screens": "^4.11.1" + }, + "devDependencies": { + "@testing-library/react-native": "^13.3.3" + } +} diff --git a/packages/rn-skills/test/fixtures/reanimated-app/package.json b/packages/rn-skills/test/fixtures/reanimated-app/package.json new file mode 100644 index 0000000..2fef8ca --- /dev/null +++ b/packages/rn-skills/test/fixtures/reanimated-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "reanimated-app", + "private": true, + "dependencies": { + "react-native-reanimated": "^4.0.0" + } +} diff --git a/packages/rn-skills/test/index.e2e.test.ts b/packages/rn-skills/test/index.e2e.test.ts new file mode 100644 index 0000000..8282a30 --- /dev/null +++ b/packages/rn-skills/test/index.e2e.test.ts @@ -0,0 +1,260 @@ +import {afterEach, describe, expect, it} from 'bun:test'; +import {chmod, mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; + +const tempDirectories: string[] = []; +const testRoot = import.meta.dir; +const fixtureRoot = join(testRoot, 'fixtures'); +const templatePath = join(testRoot, 'template', 'fake-npx.ts'); + +afterEach(async () => { + while (tempDirectories.length > 0) { + await rm(tempDirectories.pop()!, {recursive: true, force: true}); + } +}); + +describe('rn-skills e2e', () => { + it('prints usage for --help', () => { + const cliPath = resolve(testRoot, '..', 'src', 'index.ts'); + const processResult = Bun.spawnSync({ + cmd: ['bun', cliPath, '--help', '--no-mapping-update'], + cwd: dirname(cliPath), + stdout: 'pipe', + stderr: 'pipe', + }); + + expect(processResult.exitCode).toBe(0); + expect(new TextDecoder().decode(processResult.stdout)).toContain( + 'Usage: rn-skills', + ); + }); + + it('lists curated supported libraries and skills', () => { + const cliPath = resolve(testRoot, '..', 'src', 'index.ts'); + const processResult = Bun.spawnSync({ + cmd: ['bun', cliPath, 'list-supported', '--no-mapping-update'], + cwd: dirname(cliPath), + stdout: 'pipe', + stderr: 'pipe', + }); + + const stdout = new TextDecoder().decode(processResult.stdout); + + expect(processResult.exitCode).toBe(0); + expect(stdout).toContain('@testing-library/react-native'); + expect(stdout).toContain( + 'react-native-testing from React Native Testing Library Skills', + ); + expect(stdout).toContain('react-native-reanimated'); + }); + + it('defaults to auto when no command is passed', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'brownfield-app', + installedSkills: [], + expectedAdds: [ + [ + 'callstackincubator/agent-skills', + 'react-native-brownfield-migration', + ], + ], + expectedRemovals: [], + command: [], + }); + + expect(result.exitCode).toBe(0); + }); + + it('adds the expected Callstack, Vercel, and testing skills for expo-app', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'expo-app', + installedSkills: [], + expectedAdds: [ + ['callstackincubator/agent-skills', 'react-native-best-practices'], + ['callstack/react-native-testing-library', 'react-native-testing'], + ['callstackincubator/agent-skills', 'upgrading-react-native'], + ['vercel-labs/agent-skills', 'vercel-react-native-skills'], + ], + expectedRemovals: [], + command: ['auto'], + }); + + expect(result.exitCode).toBe(0); + }); + + it('adds the brownfield migration skill for brownfield-app', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'brownfield-app', + installedSkills: [], + expectedAdds: [ + [ + 'callstackincubator/agent-skills', + 'react-native-brownfield-migration', + ], + ], + expectedRemovals: [], + command: ['auto'], + }); + + expect(result.exitCode).toBe(0); + }); + + it('adds the Software Mansion skill for reanimated-app', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'reanimated-app', + installedSkills: [], + expectedAdds: [ + ['software-mansion-labs/skills', 'react-native-best-practices'], + ], + expectedRemovals: [], + command: ['auto'], + }); + + expect(result.exitCode).toBe(0); + }); + + it('does not remove installed skills that are outside the RN lookup', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'brownfield-app', + installedSkills: [ + { + name: 'github', + path: '/tmp/.agents/skills/github', + scope: 'project', + agents: ['Cursor'], + }, + { + name: 'validate-skills', + path: '/tmp/.agents/skills/validate-skills', + scope: 'project', + agents: ['Claude Code'], + }, + ], + expectedAdds: [ + [ + 'callstackincubator/agent-skills', + 'react-native-brownfield-migration', + ], + ], + expectedRemovals: [], + command: ['auto'], + }); + + expect(result.exitCode).toBe(0); + }); + + it('does not remove extra managed skills when --no-remove is passed', async () => { + const result = await runAutoWithFixture({ + fixtureName: 'expo-app', + installedSkills: [ + { + name: 'react-native-brownfield-migration', + path: '/tmp/.agents/skills/react-native-brownfield-migration', + scope: 'project', + agents: ['Cursor'], + }, + ], + expectedAdds: [ + ['callstackincubator/agent-skills', 'react-native-best-practices'], + ['callstack/react-native-testing-library', 'react-native-testing'], + ['callstackincubator/agent-skills', 'upgrading-react-native'], + ['vercel-labs/agent-skills', 'vercel-react-native-skills'], + ], + expectedRemovals: [], + command: ['auto', '--no-remove'], + }); + + expect(result.exitCode).toBe(0); + }); +}); + +async function runAutoWithFixture(options: { + fixtureName: string; + installedSkills: Array<{ + name: string; + path: string; + scope: string; + agents: string[]; + }>; + expectedAdds: Array<[string, string]>; + expectedRemovals: string[]; + command: string[]; +}) { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'rn-skills-e2e-')); + tempDirectories.push(workspaceRoot); + + const fixturePath = join(fixtureRoot, options.fixtureName, 'package.json'); + const projectDirectory = join(workspaceRoot, 'project'); + const binDirectory = join(workspaceRoot, 'bin'); + const logPath = join(workspaceRoot, 'skills-log.json'); + + await mkdir(projectDirectory, {recursive: true}); + await mkdir(binDirectory, {recursive: true}); + await writeFile( + join(projectDirectory, 'package.json'), + await readFile(fixturePath, 'utf8'), + 'utf8', + ); + await writeFile(logPath, '[]\n', 'utf8'); + + const fakeNpxPath = join(binDirectory, 'npx'); + const fakeNpxTemplate = await readFile(templatePath, 'utf8'); + await writeFile( + fakeNpxPath, + fakeNpxTemplate.replace( + "'__INSTALLED_SKILLS_JSON__'", + JSON.stringify(JSON.stringify(options.installedSkills)), + ), + 'utf8', + ); + await chmod(fakeNpxPath, 0o755); + + const cliPath = resolve(testRoot, '..', 'src', 'index.ts'); + const processResult = Bun.spawnSync({ + cmd: [ + 'bun', + cliPath, + ...options.command, + '--no-mapping-update', + '--cwd', + projectDirectory, + ], + cwd: dirname(cliPath), + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ''}`, + RN_SKILLS_E2E_LOG_PATH: logPath, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + const invocations = JSON.parse(await readFile(logPath, 'utf8')) as string[][]; + const addInvocations = invocations + .filter( + (args) => args[0] === '-y' && args[1] === 'skills' && args[2] === 'add', + ) + .map((args) => [args[3], args[5]] as [string, string]) + .sort((left, right) => left.join(' ').localeCompare(right.join(' '))); + const removeInvocations = invocations + .filter( + (args) => + args[0] === '-y' && args[1] === 'skills' && args[2] === 'remove', + ) + .map((args) => args[3]) + .sort((left, right) => left.localeCompare(right)); + + expect(addInvocations).toEqual( + [...options.expectedAdds].sort((left, right) => + left.join(' ').localeCompare(right.join(' ')), + ), + ); + expect(removeInvocations).toEqual( + [...options.expectedRemovals].sort((left, right) => + left.localeCompare(right), + ), + ); + + return processResult; +} diff --git a/packages/rn-skills/test/template/fake-npx.ts b/packages/rn-skills/test/template/fake-npx.ts new file mode 100644 index 0000000..c4b9fa8 --- /dev/null +++ b/packages/rn-skills/test/template/fake-npx.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env bun +import {readFileSync, writeFileSync} from 'node:fs'; + +const logPath = process.env.RN_SKILLS_E2E_LOG_PATH; +if (!logPath) { + throw new Error('RN_SKILLS_E2E_LOG_PATH is required'); +} + +const args = process.argv.slice(2); +const existing = JSON.parse(readFileSync(logPath, 'utf8')); +existing.push(args); +writeFileSync(logPath, JSON.stringify(existing, null, 2) + '\n', 'utf8'); + +if ( + args[0] === '-y' && + args[1] === 'skills' && + args[2] === 'list' && + args[3] === '--json' +) { + const installedSkillsJson = '__INSTALLED_SKILLS_JSON__'; + process.stdout.write(installedSkillsJson + '\n'); + process.exit(0); +} + +if ( + args[0] === '-y' && + args[1] === 'skills' && + (args[2] === 'add' || args[2] === 'remove') +) { + process.exit(0); +} + +throw new Error('Unexpected npx invocation: ' + args.join(' ')); diff --git a/packages/rn-skills/tsconfig.json b/packages/rn-skills/tsconfig.json new file mode 100644 index 0000000..9aef727 --- /dev/null +++ b/packages/rn-skills/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "types": [ + "bun" + ] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +}