chore: import upstream snapshot with attribution
Check asset sync / cli/assets must match src/ui-ux-pro-max (push) Failing after 4s
Release / Semantic release (push) Has been skipped
Smoke test data / smoke (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 11:58:17 +08:00
commit a6e8bcde90
483 changed files with 67346 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
import ora from 'ora';
import prompts from 'prompts';
import type { AIType } from '../types/index.js';
import { AI_TYPES } from '../types/index.js';
import { copyFolders, installFromZip, createTempDir, cleanup } from '../utils/extract.js';
import { generatePlatformFiles, generateAllPlatformFiles } from '../utils/template.js';
import { detectAIType, getAITypeDescription } from '../utils/detect.js';
import { logger } from '../utils/logger.js';
import {
getLatestRelease,
getAssetUrl,
downloadRelease,
getGitHubTokenGuidance,
GitHubRateLimitError,
GitHubDownloadError,
} from '../utils/github.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ASSETS_CANDIDATES = [
// Bun bundle: dist/index.js
join(__dirname, '..', 'assets'),
// TypeScript fallback: dist/commands/init.js
join(__dirname, '..', '..', 'assets'),
];
const ASSETS_DIR = ASSETS_CANDIDATES.find(path => existsSync(path)) ?? ASSETS_CANDIDATES[0];
interface InitOptions {
ai?: AIType;
force?: boolean;
offline?: boolean;
legacy?: boolean; // Use old ZIP-based install
global?: boolean; // Install to home directory (global mode)
token?: string; // GitHub PAT for higher API rate limits
}
/**
* Try to install from GitHub release (legacy method)
* Returns the copied folders if successful, null if failed
*/
async function tryGitHubInstall(
targetDir: string,
aiType: AIType,
spinner: ReturnType<typeof ora>,
token?: string
): Promise<string[] | null> {
let tempDir: string | null = null;
try {
spinner.text = 'Fetching latest release from GitHub...';
const release = await getLatestRelease(token);
const assetUrl = getAssetUrl(release);
if (!assetUrl) {
throw new GitHubDownloadError('No ZIP asset found in latest release');
}
spinner.text = `Downloading ${release.tag_name}...`;
tempDir = await createTempDir();
const zipPath = join(tempDir, 'release.zip');
await downloadRelease(assetUrl, zipPath, token);
spinner.text = 'Extracting and installing files...';
const { copiedFolders, tempDir: extractedTempDir } = await installFromZip(
zipPath,
targetDir,
aiType
);
// Cleanup temp directory
await cleanup(extractedTempDir);
return copiedFolders;
} catch (error) {
// Cleanup temp directory on error
if (tempDir) {
await cleanup(tempDir);
}
if (error instanceof GitHubRateLimitError) {
spinner.warn(`GitHub rate limit reached, falling back to bundled assets.\n${getGitHubTokenGuidance()}`);
return null;
}
if (error instanceof GitHubDownloadError) {
spinner.warn('GitHub download failed, using template generation...');
return null;
}
// Network errors or other fetch failures
if (error instanceof TypeError && error.message.includes('fetch')) {
spinner.warn('Network error, using template generation...');
return null;
}
// Unknown errors - still fall back
spinner.warn('Download failed, using template generation...');
return null;
}
}
/**
* Install using template generation (new method)
*/
async function templateInstall(
targetDir: string,
aiType: AIType,
spinner: ReturnType<typeof ora>,
isGlobal = false,
force = false
): Promise<string[]> {
spinner.text = isGlobal
? 'Generating skill files globally...'
: 'Generating skill files from templates...';
if (aiType === 'all') {
return generateAllPlatformFiles(targetDir, isGlobal, force);
}
return generatePlatformFiles(targetDir, aiType, isGlobal, force);
}
export async function initCommand(options: InitOptions): Promise<void> {
logger.title('UI/UX Pro Max Installer');
let aiType = options.ai;
// Auto-detect or prompt for AI type
if (!aiType) {
const { detected, suggested } = detectAIType();
if (detected.length > 0) {
logger.info(`Detected: ${detected.map(t => chalk.cyan(t)).join(', ')}`);
}
const response = await prompts({
type: 'select',
name: 'aiType',
message: 'Select AI assistant to install for:',
choices: AI_TYPES.map(type => ({
title: getAITypeDescription(type),
value: type,
})),
initial: suggested ? AI_TYPES.indexOf(suggested) : 0,
});
if (!response.aiType) {
logger.warn('Installation cancelled');
return;
}
aiType = response.aiType as AIType;
}
const isGlobal = !!options.global;
const modeLabel = isGlobal ? ' (global)' : '';
logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}${modeLabel}`);
const spinner = ora('Installing files...').start();
const cwd = process.cwd();
let copiedFolders: string[] = [];
let installMethod = 'template';
try {
// Use legacy ZIP-based install if --legacy flag is set
if (options.legacy) {
if (isGlobal) {
spinner.warn('--global is not supported with --legacy mode, installing locally instead');
}
// Try GitHub download first (unless offline mode)
if (!options.offline) {
const githubResult = await tryGitHubInstall(cwd, aiType, spinner, options.token);
if (githubResult) {
copiedFolders = githubResult;
installMethod = 'github';
}
}
// Fall back to bundled assets if GitHub failed or offline mode
if (installMethod !== 'github') {
spinner.text = 'Installing from bundled assets...';
copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType);
installMethod = 'bundled';
}
} else {
// Use new template-based generation (default)
copiedFolders = await templateInstall(cwd, aiType, spinner, isGlobal, options.force);
installMethod = 'template';
}
const methodMessage = {
github: 'Installed from GitHub release!',
bundled: 'Installed from bundled assets!',
template: 'Generated from templates!',
}[installMethod];
spinner.succeed(methodMessage);
// Summary
console.log();
logger.info('Installed folders:');
copiedFolders.forEach(folder => {
console.log(` ${chalk.green('+')} ${folder}`);
});
console.log();
logger.success('UI/UX Pro Max installed successfully!');
// Next steps
console.log();
console.log(chalk.bold('Next steps:'));
console.log(chalk.dim(' 1. Restart your AI coding assistant'));
console.log(chalk.dim(' 2. Try: "Build a landing page for a SaaS product"'));
console.log();
} catch (error) {
spinner.fail('Installation failed');
if (error instanceof Error) {
logger.error(error.message);
}
process.exit(1);
}
}
+156
View File
@@ -0,0 +1,156 @@
import { rm, stat } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import chalk from 'chalk';
import ora from 'ora';
import prompts from 'prompts';
import type { AIType, ConcreteAIType } from '../types/index.js';
import { AI_TYPES, AI_FOLDERS } from '../types/index.js';
import { detectAIType, getAITypeDescription } from '../utils/detect.js';
import { listBundledSubSkills, loadPlatformConfig } from '../utils/template.js';
import { logger } from '../utils/logger.js';
interface UninstallOptions {
ai?: AIType;
global?: boolean;
}
/**
* Remove skill directory for a given AI type
*/
async function removeSkillDir(baseDir: string, aiType: ConcreteAIType): Promise<string[]> {
const removed: string[] = [];
// The orchestrator plus the bundled sibling sub-skills installed by init.
const skillNames = ['ui-ux-pro-max', ...(await listBundledSubSkills())];
// Parent directories to clean. Derive the real install location from the
// platform config's skillPath (same source the installer uses), so
// non-`skills/` platforms are handled — copilot installs under
// `.github/prompts/`, kiro under `.kiro/steering/`. Also clean the legacy
// `<folder>/skills/` layout (incl. `.shared/`) so older installs are removed.
const parents = new Set<string>();
try {
const { folderStructure } = await loadPlatformConfig(aiType);
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
} catch {
// No platform config — fall back to the legacy folders below.
}
for (const folder of AI_FOLDERS[aiType]) {
parents.add(join(folder, 'skills'));
}
for (const parent of parents) {
for (const name of skillNames) {
const skillDir = join(baseDir, parent, name);
try {
await stat(skillDir);
await rm(skillDir, { recursive: true, force: true });
removed.push(`${parent.replaceAll('\\', '/')}/${name}`);
} catch (err: unknown) {
// Skip non-existent dirs; re-throw permission or other errors
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
}
return removed;
}
export async function uninstallCommand(options: UninstallOptions): Promise<void> {
logger.title('UI/UX Pro Max Uninstaller');
const isGlobal = !!options.global;
const baseDir = isGlobal ? homedir() : process.cwd();
const locationLabel = isGlobal ? '~/ (global)' : process.cwd();
let aiType = options.ai;
const { detected: initialDetected } = detectAIType(baseDir);
// Auto-detect or prompt for AI type
if (!aiType) {
const detected = initialDetected;
if (detected.length === 0) {
logger.warn('No installed AI skill directories detected.');
return;
}
logger.info(`Detected installations: ${detected.map(t => chalk.cyan(t)).join(', ')}`);
const choices = [
...detected.map(type => ({
title: getAITypeDescription(type),
value: type,
})),
{ title: 'All detected', value: 'all' as AIType },
];
const response = await prompts({
type: 'select',
name: 'aiType',
message: 'Select which AI skill to uninstall:',
choices,
});
if (!response.aiType) {
logger.warn('Uninstall cancelled');
return;
}
aiType = response.aiType as AIType;
}
// Confirm before removing
const { confirmed } = await prompts({
type: 'confirm',
name: 'confirmed',
message: `Remove UI/UX Pro Max skill for ${chalk.cyan(getAITypeDescription(aiType))} from ${locationLabel}?`,
initial: false,
});
if (!confirmed) {
logger.warn('Uninstall cancelled');
return;
}
const spinner = ora('Removing skill files...').start();
try {
const allRemoved: string[] = [];
if (aiType === 'all') {
// Remove for all detected platforms
for (const type of initialDetected) {
const removed = await removeSkillDir(baseDir, type);
allRemoved.push(...removed);
}
} else {
const removed = await removeSkillDir(baseDir, aiType);
allRemoved.push(...removed);
}
if (allRemoved.length === 0) {
spinner.warn('No skill files found to remove');
return;
}
spinner.succeed('Skill files removed!');
console.log();
logger.info('Removed:');
allRemoved.forEach(folder => {
console.log(` ${chalk.red('-')} ${folder}`);
});
console.log();
logger.success('UI/UX Pro Max uninstalled successfully!');
console.log();
} catch (error) {
spinner.fail('Uninstall failed');
if (error instanceof Error) {
logger.error(error.message);
}
process.exit(1);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
import ora from 'ora';
import { getLatestRelease } from '../utils/github.js';
import { logger } from '../utils/logger.js';
import { initCommand } from './init.js';
import type { AIType } from '../types/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PACKAGE_NAME = 'ui-ux-pro-max-cli';
interface UpdateOptions {
ai?: AIType;
token?: string;
}
async function getPackageVersion(): Promise<string> {
const packagePath = join(__dirname, '..', 'package.json');
const pkg = JSON.parse(await readFile(packagePath, 'utf-8')) as { version: string };
return pkg.version;
}
function normalizeTagVersion(tagName: string): string {
return tagName.replace(/^v/i, '');
}
export async function updateCommand(options: UpdateOptions): Promise<void> {
logger.title('UI/UX Pro Max Updater');
const spinner = ora('Checking for updates...').start();
try {
const release = await getLatestRelease(options.token);
const currentVersion = await getPackageVersion();
const latestVersion = normalizeTagVersion(release.tag_name);
spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`);
if (currentVersion !== latestVersion) {
console.log();
// Only auto-run with a well-formed semver, so nothing unexpected can
// reach the shell that npm.cmd requires on Windows.
if (!/^\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?$/.test(latestVersion)) {
logger.warn(`Installed CLI is ${chalk.cyan(currentVersion)}; latest release is ${chalk.cyan(release.tag_name)}.`);
logger.info(`Update the CLI package: ${chalk.cyan(`npm install -g ${CLI_PACKAGE_NAME}@${latestVersion}`)}`);
logger.info('Then rerun: uipro init --ai <platform> --force');
return;
}
logger.info(`Updating CLI from ${chalk.cyan(currentVersion)} to ${chalk.cyan(latestVersion)}...`);
console.log();
const isWindows = process.platform === 'win32';
try {
// execFileSync with an explicit args array — no shell string to expand.
// On Windows npm is npm.cmd, which Node only spawns via a shell.
execFileSync(
isWindows ? 'npm.cmd' : 'npm',
['install', '-g', `${CLI_PACKAGE_NAME}@${latestVersion}`],
{ stdio: 'inherit', shell: isWindows }
);
} catch {
console.log();
logger.error('Automatic update failed (you may need elevated/admin permissions).');
logger.info(`Update manually: ${chalk.cyan(`npm install -g ${CLI_PACKAGE_NAME}@${latestVersion}`)}`);
process.exit(1);
}
console.log();
logger.success(`Updated to ${chalk.cyan(latestVersion)}.`);
logger.info(`Now rerun ${chalk.cyan('uipro init --ai <platform> --force')} to refresh your skill files.`);
return;
}
console.log();
logger.info('Refreshing installed skill files from this CLI package...');
console.log();
await initCommand({
ai: options.ai,
force: true,
token: options.token,
});
} catch (error) {
spinner.fail('Update check failed');
if (error instanceof Error) {
logger.error(error.message);
}
process.exit(1);
}
}
+46
View File
@@ -0,0 +1,46 @@
import chalk from 'chalk';
import ora from 'ora';
import { fetchReleases } from '../utils/github.js';
import { logger } from '../utils/logger.js';
interface VersionsOptions {
token?: string;
}
export async function versionsCommand(options: VersionsOptions = {}): Promise<void> {
const spinner = ora('Fetching available versions...').start();
try {
const releases = await fetchReleases(options.token);
if (releases.length === 0) {
spinner.warn('No releases found');
return;
}
spinner.succeed(`Found ${releases.length} version(s)\n`);
console.log(chalk.bold('Available versions:\n'));
releases.forEach((release, index) => {
const isLatest = index === 0;
const tag = release.tag_name;
const date = new Date(release.published_at).toLocaleDateString();
if (isLatest) {
console.log(` ${chalk.green('*')} ${chalk.bold(tag)} ${chalk.dim(`(${date})`)} ${chalk.green('[latest]')}`);
} else {
console.log(` ${tag} ${chalk.dim(`(${date})`)}`);
}
});
console.log();
logger.dim('Update the CLI package first, then run: uipro init --ai <platform>');
} catch (error) {
spinner.fail('Failed to fetch versions');
if (error instanceof Error) {
logger.error(error.message);
}
process.exit(1);
}
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { initCommand } from './commands/init.js';
import { versionsCommand } from './commands/versions.js';
import { updateCommand } from './commands/update.js';
import { uninstallCommand } from './commands/uninstall.js';
import type { AIType } from './types/index.js';
import { AI_TYPES } from './types/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
const program = new Command();
program
.name('uipro')
.description('CLI to install UI/UX Pro Max skill for AI coding assistants')
.version(pkg.version);
program
.command('init')
.description('Install UI/UX Pro Max skill to current project')
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
.option('-f, --force', 'Overwrite existing files')
.option('-o, --offline', 'Compatibility flag; template installs use bundled assets')
.option('-g, --global', 'Install globally to home directory (~/) instead of current project')
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
console.error(`Valid types: ${AI_TYPES.join(', ')}`);
process.exit(1);
}
await initCommand({
ai: options.ai as AIType | undefined,
force: options.force,
offline: options.offline,
global: options.global,
token: options.token,
});
});
program
.command('versions')
.description('List available versions')
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(versionsCommand);
program
.command('update')
.description('Update UI/UX Pro Max to latest version')
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
console.error(`Valid types: ${AI_TYPES.join(', ')}`);
process.exit(1);
}
await updateCommand({
ai: options.ai as AIType | undefined,
token: options.token,
});
});
program
.command('uninstall')
.description('Remove UI/UX Pro Max skill from current project or globally')
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
.option('-g, --global', 'Uninstall from home directory (~/) instead of current project')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
console.error(`Valid types: ${AI_TYPES.join(', ')}`);
process.exit(1);
}
await uninstallCommand({
ai: options.ai as AIType | undefined,
global: options.global,
});
});
program.parse();
+69
View File
@@ -0,0 +1,69 @@
export type AIType = 'claude' | 'cursor' | 'windsurf' | 'antigravity' | 'copilot' | 'kiro' | 'roocode' | 'codex' | 'qoder' | 'gemini' | 'trae' | 'opencode' | 'continue' | 'codebuddy' | 'droid' | 'kilocode' | 'warp' | 'augment' | 'all';
export type ConcreteAIType = Exclude<AIType, 'all'>;
export type InstallType = 'full' | 'reference';
export interface Release {
tag_name: string;
name: string;
published_at: string;
html_url: string;
assets: Asset[];
}
export interface Asset {
name: string;
browser_download_url: string;
size: number;
}
export interface InstallConfig {
aiType: AIType;
version?: string;
force?: boolean;
}
export interface PlatformConfig {
platform: string;
displayName: string;
installType: InstallType;
folderStructure: {
root: string;
skillPath: string;
filename: string;
};
scriptPath: string;
frontmatter: Record<string, string> | null;
sections: {
quickReference: boolean;
};
title: string;
description: string;
skillOrWorkflow: string;
}
export const AI_TYPES: AIType[] = ['claude', 'cursor', 'windsurf', 'antigravity', 'copilot', 'roocode', 'kiro', 'codex', 'qoder', 'gemini', 'trae', 'opencode', 'continue', 'codebuddy', 'droid', 'kilocode', 'warp', 'augment', 'all'];
// Legacy folder mapping for backward compatibility with ZIP-based installs.
// Note: .shared is included for platforms that used ZIP installs. Post-ZIP platforms
// (kilocode, warp, augment) include .shared as a no-op for consistent uninstall behavior.
export const AI_FOLDERS: Record<ConcreteAIType, string[]> = {
claude: ['.claude'],
cursor: ['.cursor', '.shared'],
windsurf: ['.windsurf', '.shared'],
antigravity: ['.agents', '.shared'],
copilot: ['.github', '.shared'],
kiro: ['.kiro', '.shared'],
codex: ['.codex'],
roocode: ['.roo', '.shared'],
qoder: ['.qoder', '.shared'],
gemini: ['.gemini', '.shared'],
trae: ['.trae', '.shared'],
opencode: ['.opencode', '.shared'],
continue: ['.continue'],
codebuddy: ['.codebuddy'],
droid: ['.factory'],
kilocode: ['.kilocode', '.shared'],
warp: ['.warp', '.shared'],
augment: ['.augment', '.shared'],
};
+120
View File
@@ -0,0 +1,120 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { AIType, ConcreteAIType } from '../types/index.js';
interface DetectionResult {
detected: ConcreteAIType[];
suggested: AIType | null;
}
export function detectAIType(cwd: string = process.cwd()): DetectionResult {
const detected: ConcreteAIType[] = [];
if (existsSync(join(cwd, '.claude'))) {
detected.push('claude');
}
if (existsSync(join(cwd, '.cursor'))) {
detected.push('cursor');
}
if (existsSync(join(cwd, '.windsurf'))) {
detected.push('windsurf');
}
if (existsSync(join(cwd, '.agents')) || existsSync(join(cwd, '.agent'))) {
detected.push('antigravity');
}
if (existsSync(join(cwd, '.github'))) {
detected.push('copilot');
}
if (existsSync(join(cwd, '.kiro'))) {
detected.push('kiro');
}
if (existsSync(join(cwd, '.codex'))) {
detected.push('codex');
}
if (existsSync(join(cwd, '.roo'))) {
detected.push('roocode');
}
if (existsSync(join(cwd, '.qoder'))) {
detected.push('qoder');
}
if (existsSync(join(cwd, '.gemini'))) {
detected.push('gemini');
}
if (existsSync(join(cwd, '.trae'))) {
detected.push('trae');
}
if (existsSync(join(cwd, '.opencode'))) {
detected.push('opencode');
}
if (existsSync(join(cwd, '.continue'))) {
detected.push('continue');
}
if (existsSync(join(cwd, '.codebuddy'))) {
detected.push('codebuddy');
}
if (existsSync(join(cwd, '.factory'))) {
detected.push('droid');
}
if (existsSync(join(cwd, '.kilocode'))) {
detected.push('kilocode');
}
if (existsSync(join(cwd, '.warp'))) {
detected.push('warp');
}
if (existsSync(join(cwd, '.augment'))) {
detected.push('augment');
}
// Suggest based on what's detected
let suggested: AIType | null = null;
if (detected.length === 1) {
suggested = detected[0];
} else if (detected.length > 1) {
suggested = 'all';
}
return { detected, suggested };
}
export function getAITypeDescription(aiType: AIType): string {
switch (aiType) {
case 'claude':
return 'Claude Code (.claude/skills/)';
case 'cursor':
return 'Cursor (.cursor/skills/)';
case 'windsurf':
return 'Windsurf (.windsurf/skills/)';
case 'antigravity':
return 'Antigravity (.agents/skills/)';
case 'copilot':
return 'GitHub Copilot (.github/prompts/)';
case 'kiro':
return 'Kiro (.kiro/steering/)';
case 'codex':
return 'Codex (.codex/skills/)';
case 'roocode':
return 'RooCode (.roo/skills/)';
case 'qoder':
return 'Qoder (.qoder/skills/)';
case 'gemini':
return 'Gemini CLI (.gemini/skills/)';
case 'trae':
return 'Trae (.trae/skills/)';
case 'opencode':
return 'OpenCode (.opencode/skills/)';
case 'continue':
return 'Continue (.continue/skills/)';
case 'codebuddy':
return 'CodeBuddy (.codebuddy/skills/)';
case 'droid':
return 'Droid (Factory) (.factory/skills/)';
case 'kilocode':
return 'KiloCode (.kilocode/skills/)';
case 'warp':
return 'Warp (.warp/skills/)';
case 'augment':
return 'Augment (.augment/skills/)';
case 'all':
return 'All AI assistants';
}
}
+149
View File
@@ -0,0 +1,149 @@
import { mkdir, rm, access, cp, mkdtemp, readdir } from 'node:fs/promises';
import { join, basename } from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
import type { AIType } from '../types/index.js';
import { AI_FOLDERS } from '../types/index.js';
const execAsync = promisify(exec);
const EXCLUDED_FILES = ['settings.local.json'];
export async function extractZip(zipPath: string, destDir: string): Promise<void> {
try {
const isWindows = process.platform === 'win32';
if (isWindows) {
await execAsync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`);
} else {
await execAsync(`unzip -o "${zipPath}" -d "${destDir}"`);
}
} catch (error) {
throw new Error(`Failed to extract zip: ${error}`);
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
export async function copyFolders(
sourceDir: string,
targetDir: string,
aiType: AIType
): Promise<string[]> {
const copiedFolders: string[] = [];
const foldersToCopy = aiType === 'all'
? Object.values(AI_FOLDERS).flat()
: AI_FOLDERS[aiType];
// Deduplicate folders (e.g., .shared might be listed multiple times)
const uniqueFolders = [...new Set(foldersToCopy)];
for (const folder of uniqueFolders) {
const sourcePath = join(sourceDir, folder);
const targetPath = join(targetDir, folder);
// Check if source folder exists
const sourceExists = await exists(sourcePath);
if (!sourceExists) {
continue;
}
// Create target directory if needed
await mkdir(targetPath, { recursive: true });
// Filter function to exclude certain files
const filterFn = (src: string): boolean => {
const fileName = basename(src);
return !EXCLUDED_FILES.includes(fileName);
};
// Copy recursively
try {
await cp(sourcePath, targetPath, { recursive: true, filter: filterFn });
copiedFolders.push(folder);
} catch {
// Try shell fallback for older Node versions
try {
if (process.platform === 'win32') {
await execAsync(`xcopy "${sourcePath}" "${targetPath}" /E /I /Y`);
} else {
await execAsync(`cp -r "${sourcePath}/." "${targetPath}"`);
}
copiedFolders.push(folder);
} catch {
// Skip if copy fails
}
}
}
return copiedFolders;
}
export async function cleanup(tempDir: string): Promise<void> {
try {
await rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
/**
* Create a temporary directory for extracting ZIP files
*/
export async function createTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'uipro-'));
}
/**
* Find the extracted folder inside temp directory
* GitHub release ZIPs often contain a single root folder
*/
async function findExtractedRoot(tempDir: string): Promise<string> {
const entries = await readdir(tempDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory());
// If there's exactly one directory, it's likely the extracted root
if (dirs.length === 1) {
return join(tempDir, dirs[0].name);
}
// Otherwise, assume tempDir itself is the root
return tempDir;
}
/**
* Install from a downloaded and extracted ZIP file
*/
export async function installFromZip(
zipPath: string,
targetDir: string,
aiType: AIType
): Promise<{ copiedFolders: string[]; tempDir: string }> {
// Create temp directory
const tempDir = await createTempDir();
try {
// Extract ZIP to temp directory
await extractZip(zipPath, tempDir);
// Find the actual root of the extracted content
const extractedRoot = await findExtractedRoot(tempDir);
// Copy folders from extracted content to target
const copiedFolders = await copyFolders(extractedRoot, targetDir, aiType);
return { copiedFolders, tempDir };
} catch (error) {
// Cleanup on error
await cleanup(tempDir);
throw error;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { writeFile } from 'node:fs/promises';
import type { Release } from '../types/index.js';
const REPO_OWNER = 'nextlevelbuilder';
const REPO_NAME = 'ui-ux-pro-max-skill';
const API_BASE = 'https://api.github.com';
const USER_AGENT = 'ui-ux-pro-max-cli';
export class GitHubRateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = 'GitHubRateLimitError';
}
}
export class GitHubDownloadError extends Error {
constructor(message: string) {
super(message);
this.name = 'GitHubDownloadError';
}
}
export function getGitHubTokenGuidance(): string {
return (
'To increase your GitHub API rate limit, set the UI_PRO_MAX_GITHUB_TOKEN environment variable\n' +
'to a GitHub Personal Access Token (no scopes needed for public repos).\n' +
'Create one at: https://github.com/settings/tokens\n' +
'Example: UI_PRO_MAX_GITHUB_TOKEN=ghp_xxx uipro init\n' +
'Or pass it directly: uipro init --token ghp_xxx'
);
}
function checkRateLimit(response: Response): void {
const remaining = response.headers.get('x-ratelimit-remaining');
if (response.status === 403 && remaining === '0') {
const resetTime = response.headers.get('x-ratelimit-reset');
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'unknown';
throw new GitHubRateLimitError(
`GitHub API rate limit exceeded. Resets at ${resetDate}.\n${getGitHubTokenGuidance()}`
);
}
if (response.status === 429) {
throw new GitHubRateLimitError(
`GitHub API rate limit exceeded (429 Too Many Requests).\n${getGitHubTokenGuidance()}`
);
}
}
function getAuthHeaders(token?: string): Record<string, string> {
const resolved = (token || process.env['UI_PRO_MAX_GITHUB_TOKEN'] || process.env['GITHUB_TOKEN'])?.trim();
return resolved ? { 'Authorization': `Bearer ${resolved}` } : {};
}
export async function fetchReleases(token?: string): Promise<Release[]> {
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': USER_AGENT,
...getAuthHeaders(token),
},
});
checkRateLimit(response);
if (!response.ok) {
throw new GitHubDownloadError(`Failed to fetch releases: ${response.status} ${response.statusText}`);
}
return response.json();
}
export async function getLatestRelease(token?: string): Promise<Release> {
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': USER_AGENT,
...getAuthHeaders(token),
},
});
checkRateLimit(response);
if (!response.ok) {
throw new GitHubDownloadError(`Failed to fetch latest release: ${response.status} ${response.statusText}`);
}
return response.json();
}
export async function downloadRelease(url: string, dest: string, token?: string): Promise<void> {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/octet-stream',
...getAuthHeaders(token),
},
});
checkRateLimit(response);
if (!response.ok) {
throw new GitHubDownloadError(`Failed to download: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
await writeFile(dest, Buffer.from(buffer));
}
export function getAssetUrl(release: Release): string | null {
// First try to find an uploaded ZIP asset
const asset = release.assets.find(a => a.name.endsWith('.zip'));
if (asset?.browser_download_url) {
return asset.browser_download_url;
}
// Fall back to GitHub's auto-generated archive
// Format: https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.zip
if (release.tag_name) {
return `https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/refs/tags/${release.tag_name}.zip`;
}
return null;
}
+11
View File
@@ -0,0 +1,11 @@
import chalk from 'chalk';
export const logger = {
info: (msg: string) => console.log(chalk.blue('info'), msg),
success: (msg: string) => console.log(chalk.green('success'), msg),
warn: (msg: string) => console.log(chalk.yellow('warn'), msg),
error: (msg: string) => console.log(chalk.red('error'), msg),
title: (msg: string) => console.log(chalk.bold.cyan(`\n${msg}\n`)),
dim: (msg: string) => console.log(chalk.dim(msg)),
};
+314
View File
@@ -0,0 +1,314 @@
import { existsSync } from 'node:fs';
import { readFile, mkdir, writeFile, cp, access, readdir, lstat, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ASSETS_CANDIDATES = [
// Bun bundle: dist/index.js
join(__dirname, '..', 'assets'),
// TypeScript fallback: dist/utils/template.js
join(__dirname, '..', '..', 'assets'),
];
const ASSETS_DIR = ASSETS_CANDIDATES.find(path => existsSync(path)) ?? ASSETS_CANDIDATES[0];
export interface PlatformConfig {
platform: string;
displayName: string;
installType: 'full' | 'reference';
folderStructure: {
root: string;
skillPath: string;
filename: string;
};
scriptPath: string;
frontmatter: Record<string, string> | null;
sections: {
quickReference: boolean;
};
title: string;
description: string;
skillOrWorkflow: string;
}
// Map AIType to platform config file name
const AI_TO_PLATFORM: Record<string, string> = {
claude: 'claude',
cursor: 'cursor',
windsurf: 'windsurf',
antigravity: 'agent',
copilot: 'copilot',
kiro: 'kiro',
opencode: 'opencode',
roocode: 'roocode',
codex: 'codex',
qoder: 'qoder',
gemini: 'gemini',
trae: 'trae',
continue: 'continue',
codebuddy: 'codebuddy',
droid: 'droid',
kilocode: 'kilocode',
warp: 'warp',
augment: 'augment',
};
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Load platform configuration from JSON file
*/
export async function loadPlatformConfig(aiType: string): Promise<PlatformConfig> {
const platformName = AI_TO_PLATFORM[aiType];
if (!platformName) {
throw new Error(`Unknown AI type: ${aiType}`);
}
const configPath = join(ASSETS_DIR, 'templates', 'platforms', `${platformName}.json`);
const content = await readFile(configPath, 'utf-8');
return JSON.parse(content) as PlatformConfig;
}
/**
* Load all available platform configs
*/
export async function loadAllPlatformConfigs(): Promise<Map<string, PlatformConfig>> {
const configs = new Map<string, PlatformConfig>();
for (const [aiType, platformName] of Object.entries(AI_TO_PLATFORM)) {
try {
const config = await loadPlatformConfig(aiType);
configs.set(aiType, config);
} catch {
// Skip if config doesn't exist
}
}
return configs;
}
/**
* Load a template file
*/
async function loadTemplate(templateName: string): Promise<string> {
const templatePath = join(ASSETS_DIR, 'templates', templateName);
return readFile(templatePath, 'utf-8');
}
/**
* Render frontmatter section
*/
function renderFrontmatter(frontmatter: Record<string, string> | null): string {
if (!frontmatter) return '';
const lines = ['---'];
for (const [key, value] of Object.entries(frontmatter)) {
// Quote values that contain special characters
if (value.includes(':') || value.includes('"') || value.includes('\n')) {
lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
} else {
lines.push(`${key}: ${value}`);
}
}
lines.push('---', '');
return lines.join('\n');
}
/**
* Render skill file content from template
* When isGlobal=true, rewrites script paths to use ~/{root}/ prefix
*/
export async function renderSkillFile(config: PlatformConfig, isGlobal = false): Promise<string> {
// Load base template
let content = await loadTemplate('base/skill-content.md');
// Load quick reference if needed
let quickReferenceContent = '';
if (config.sections.quickReference) {
quickReferenceContent = await loadTemplate('base/quick-reference.md');
}
// Build the final content
const frontmatter = renderFrontmatter(config.frontmatter);
// Replace placeholders
// Add newline before quick reference content if it exists
const quickRefWithNewline = quickReferenceContent ? '\n' + quickReferenceContent : '';
content = content
.replace(/\{\{TITLE\}\}/g, config.title)
.replace(/\{\{DESCRIPTION\}\}/g, config.description)
.replace(/\{\{SCRIPT_PATH\}\}/g, config.scriptPath)
.replace(/\{\{SKILL_OR_WORKFLOW\}\}/g, config.skillOrWorkflow)
.replace(/\{\{QUICK_REFERENCE\}\}/g, quickRefWithNewline);
// For global install, rewrite relative script paths to absolute ~/root/ paths
if (isGlobal) {
const globalPrefix = `~/${config.folderStructure.root}/`;
content = content.replace(
/python3 skills\//g,
`python3 ${globalPrefix}skills/`
);
}
return frontmatter + content;
}
/**
* Replace a pre-existing non-directory at `path` so a real directory can be
* created there. Older CLI installs (and Windows checkouts of the repo's
* symlinked data/scripts) can leave plain "pointer" files at these paths;
* mkdir then throws EEXIST and the install silently leaves stale files.
*/
async function ensureCleanDir(path: string): Promise<void> {
try {
const stat = await lstat(path);
if (!stat.isDirectory()) {
await rm(path, { recursive: true, force: true });
}
} catch {
// Nothing exists at the path yet — mkdir will create it.
}
}
/**
* Copy data and scripts to target directory
*/
async function copyDataAndScripts(targetSkillDir: string): Promise<void> {
const dataSource = join(ASSETS_DIR, 'data');
const scriptsSource = join(ASSETS_DIR, 'scripts');
const dataTarget = join(targetSkillDir, 'data');
const scriptsTarget = join(targetSkillDir, 'scripts');
// Copy data
if (await exists(dataSource)) {
await ensureCleanDir(dataTarget);
await mkdir(dataTarget, { recursive: true });
await cp(dataSource, dataTarget, { recursive: true });
}
// Copy scripts
if (await exists(scriptsSource)) {
await ensureCleanDir(scriptsTarget);
await mkdir(scriptsTarget, { recursive: true });
await cp(scriptsSource, scriptsTarget, { recursive: true });
}
}
/**
* List the static sub-skills bundled under assets/skills/ (everything except
* the template-rendered orchestrator). Empty if the package predates bundling.
*/
export async function listBundledSubSkills(): Promise<string[]> {
const skillsSource = join(ASSETS_DIR, 'skills');
if (!(await exists(skillsSource))) return [];
const entries = await readdir(skillsSource, { withFileTypes: true });
return entries.filter(e => e.isDirectory()).map(e => e.name).sort();
}
/**
* Install the bundled sub-skills as siblings of the orchestrator skill, so a
* single `uipro init` delivers all 7 skills instead of only ui-ux-pro-max.
*/
async function copySubSkills(skillsParentDir: string, force: boolean): Promise<void> {
const skillsSource = join(ASSETS_DIR, 'skills');
if (!(await exists(skillsSource))) return;
for (const name of await listBundledSubSkills()) {
const target = join(skillsParentDir, name);
if (await exists(target) && !force) continue;
await mkdir(target, { recursive: true });
await cp(join(skillsSource, name), target, { recursive: true });
}
}
/**
* Generate platform files for a specific AI type
* All platforms use self-contained installation with data and scripts
* When isGlobal=true, installs to ~/home directory with absolute script paths
*/
export async function generatePlatformFiles(
targetDir: string,
aiType: string,
isGlobal = false,
force = false
): Promise<string[]> {
const config = await loadPlatformConfig(aiType);
const createdFolders: string[] = [];
// For global install, target the user's home directory
const effectiveDir = isGlobal ? homedir() : targetDir;
// Determine full skill directory path
const skillDir = join(
effectiveDir,
config.folderStructure.root,
config.folderStructure.skillPath
);
// Create directory structure
await mkdir(skillDir, { recursive: true });
// Render and write skill file (pass isGlobal to adjust paths)
const skillContent = await renderSkillFile(config, isGlobal);
const skillFilePath = join(skillDir, config.folderStructure.filename);
const fileAlreadyExists = await exists(skillFilePath);
if (fileAlreadyExists && !force) {
console.log(` Skipped (already exists): ${skillFilePath} — use --force to overwrite`);
return [];
}
await writeFile(skillFilePath, skillContent, 'utf-8');
createdFolders.push(config.folderStructure.root);
// Copy data and scripts into the skill directory (self-contained)
await copyDataAndScripts(skillDir);
// Install the sibling sub-skills (banner-design, brand, design, ...) next to
// the orchestrator so all 7 skills are delivered. The skills parent is the
// orchestrator's parent dir (skills/ for most platforms, prompts/ for
// copilot, steering/ for kiro) — derived, not hardcoded.
const skillsParentDir = join(
effectiveDir,
config.folderStructure.root,
dirname(config.folderStructure.skillPath)
);
await copySubSkills(skillsParentDir, force);
return createdFolders;
}
/**
* Generate files for all AI types
*/
export async function generateAllPlatformFiles(targetDir: string, isGlobal = false, force = false): Promise<string[]> {
const allFolders = new Set<string>();
for (const aiType of Object.keys(AI_TO_PLATFORM)) {
try {
const folders = await generatePlatformFiles(targetDir, aiType, isGlobal, force);
folders.forEach(f => allFolders.add(f));
} catch {
// Skip if generation fails for a platform
}
}
return Array.from(allFolders);
}
/**
* Get list of supported AI types
*/
export function getSupportedAITypes(): string[] {
return Object.keys(AI_TO_PLATFORM);
}