feat: add uxpro-cli for easy skill installation (#4)
* feat: add uxpro-cli for easy skill installation - Add CLI tool (uxpro-cli) with commands: init, versions, update - Support multiple AI assistants: claude, cursor, windsurf, antigravity, all - Update README with CLI installation guide and usage examples - Add CC BY-NC 4.0 license - Update feature counts to accurate numbers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename CLI from uxpro to uipro - Package: uxpro-cli -> uipro-cli - Command: uxpro -> uipro 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
125
cli/src/commands/init.ts
Normal file
125
cli/src/commands/init.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
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 { fetchReleases, getLatestRelease, downloadRelease, getAssetUrl } from '../utils/github.js';
|
||||
import { extractZip, copyFolders, cleanup } from '../utils/extract.js';
|
||||
import { detectAIType, getAITypeDescription } from '../utils/detect.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
interface InitOptions {
|
||||
ai?: AIType;
|
||||
version?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}`);
|
||||
|
||||
// Fetch release
|
||||
const spinner = ora('Fetching release info...').start();
|
||||
|
||||
try {
|
||||
let release;
|
||||
if (options.version) {
|
||||
const releases = await fetchReleases();
|
||||
release = releases.find(r => r.tag_name === options.version);
|
||||
if (!release) {
|
||||
spinner.fail(`Version ${options.version} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
release = await getLatestRelease();
|
||||
}
|
||||
|
||||
spinner.text = `Found version: ${release.tag_name}`;
|
||||
|
||||
const assetUrl = getAssetUrl(release);
|
||||
if (!assetUrl) {
|
||||
spinner.fail('No downloadable asset found in release');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Download
|
||||
spinner.text = 'Downloading...';
|
||||
const tempDir = join(tmpdir(), `uipro-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
|
||||
const zipPath = join(tempDir, 'release.zip');
|
||||
await downloadRelease(assetUrl, zipPath);
|
||||
|
||||
// Extract
|
||||
spinner.text = 'Extracting...';
|
||||
const extractDir = join(tempDir, 'extracted');
|
||||
await mkdir(extractDir, { recursive: true });
|
||||
await extractZip(zipPath, extractDir);
|
||||
|
||||
// Copy folders
|
||||
spinner.text = 'Installing files...';
|
||||
const cwd = process.cwd();
|
||||
const copiedFolders = await copyFolders(extractDir, cwd, aiType);
|
||||
|
||||
// Cleanup
|
||||
await cleanup(tempDir);
|
||||
|
||||
spinner.succeed('Installation complete!');
|
||||
|
||||
// Summary
|
||||
console.log();
|
||||
logger.info('Installed folders:');
|
||||
copiedFolders.forEach(folder => {
|
||||
console.log(` ${chalk.green('+')} ${folder}`);
|
||||
});
|
||||
|
||||
console.log();
|
||||
logger.success(`UI/UX Pro Max ${release.tag_name} 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);
|
||||
}
|
||||
}
|
||||
36
cli/src/commands/update.ts
Normal file
36
cli/src/commands/update.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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';
|
||||
|
||||
interface UpdateOptions {
|
||||
ai?: AIType;
|
||||
}
|
||||
|
||||
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();
|
||||
spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`);
|
||||
|
||||
console.log();
|
||||
logger.info('Running update (same as init with latest version)...');
|
||||
console.log();
|
||||
|
||||
await initCommand({
|
||||
ai: options.ai,
|
||||
force: true,
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail('Update check failed');
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
42
cli/src/commands/versions.ts
Normal file
42
cli/src/commands/versions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { fetchReleases } from '../utils/github.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export async function versionsCommand(): Promise<void> {
|
||||
const spinner = ora('Fetching available versions...').start();
|
||||
|
||||
try {
|
||||
const releases = await fetchReleases();
|
||||
|
||||
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('Use: uipro init --version <tag> to install a specific version');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch versions');
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
56
cli/src/index.ts
Normal file
56
cli/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { initCommand } from './commands/init.js';
|
||||
import { versionsCommand } from './commands/versions.js';
|
||||
import { updateCommand } from './commands/update.js';
|
||||
import type { AIType } from './types/index.js';
|
||||
import { AI_TYPES } from './types/index.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('uipro')
|
||||
.description('CLI to install UI/UX Pro Max skill for AI coding assistants')
|
||||
.version('1.0.0');
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Install UI/UX Pro Max skill to current project')
|
||||
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
||||
.option('-v, --version <tag>', 'Specific version to install')
|
||||
.option('-f, --force', 'Overwrite existing files')
|
||||
.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,
|
||||
version: options.version,
|
||||
force: options.force,
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('versions')
|
||||
.description('List available versions')
|
||||
.action(versionsCommand);
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update UI/UX Pro Max to latest version')
|
||||
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
||||
.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,
|
||||
});
|
||||
});
|
||||
|
||||
program.parse();
|
||||
30
cli/src/types/index.ts
Normal file
30
cli/src/types/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type AIType = 'claude' | 'cursor' | 'windsurf' | 'antigravity' | 'all';
|
||||
|
||||
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 const AI_TYPES: AIType[] = ['claude', 'cursor', 'windsurf', 'antigravity', 'all'];
|
||||
|
||||
export const AI_FOLDERS: Record<Exclude<AIType, 'all'>, string[]> = {
|
||||
claude: ['.claude'],
|
||||
cursor: ['.cursor', '.shared'],
|
||||
windsurf: ['.windsurf', '.shared'],
|
||||
antigravity: ['.agent', '.shared'],
|
||||
};
|
||||
50
cli/src/utils/detect.ts
Normal file
50
cli/src/utils/detect.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { AIType } from '../types/index.js';
|
||||
|
||||
interface DetectionResult {
|
||||
detected: AIType[];
|
||||
suggested: AIType | null;
|
||||
}
|
||||
|
||||
export function detectAIType(cwd: string = process.cwd()): DetectionResult {
|
||||
const detected: AIType[] = [];
|
||||
|
||||
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, '.agent'))) {
|
||||
detected.push('antigravity');
|
||||
}
|
||||
|
||||
// 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/commands/ + .shared/)';
|
||||
case 'windsurf':
|
||||
return 'Windsurf (.windsurf/workflows/ + .shared/)';
|
||||
case 'antigravity':
|
||||
return 'Antigravity (.agent/workflows/ + .shared/)';
|
||||
case 'all':
|
||||
return 'All AI assistants';
|
||||
}
|
||||
}
|
||||
72
cli/src/utils/extract.ts
Normal file
72
cli/src/utils/extract.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { AIType } from '../types/index.js';
|
||||
import { AI_FOLDERS } from '../types/index.js';
|
||||
|
||||
export async function extractZip(zipPath: string, destDir: string): Promise<void> {
|
||||
const proc = Bun.spawn(['unzip', '-o', zipPath, '-d', destDir], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Failed to extract zip: exit code ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyFolders(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
aiType: AIType
|
||||
): Promise<string[]> {
|
||||
const copiedFolders: string[] = [];
|
||||
|
||||
const foldersToCopy = aiType === 'all'
|
||||
? ['.claude', '.cursor', '.windsurf', '.agent', '.shared']
|
||||
: 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 Bun.file(sourcePath).exists().catch(() => false);
|
||||
if (!sourceExists) {
|
||||
// Try checking if it's a directory
|
||||
try {
|
||||
const proc = Bun.spawn(['test', '-d', sourcePath]);
|
||||
await proc.exited;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Create target directory if needed
|
||||
await mkdir(targetPath, { recursive: true });
|
||||
|
||||
// Copy using cp -r
|
||||
const proc = Bun.spawn(['cp', '-r', `${sourcePath}/.`, targetPath], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode === 0) {
|
||||
copiedFolders.push(folder);
|
||||
}
|
||||
}
|
||||
|
||||
return copiedFolders;
|
||||
}
|
||||
|
||||
export async function cleanup(tempDir: string): Promise<void> {
|
||||
const proc = Bun.spawn(['rm', '-rf', tempDir], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await proc.exited;
|
||||
}
|
||||
59
cli/src/utils/github.ts
Normal file
59
cli/src/utils/github.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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';
|
||||
|
||||
export async function fetchReleases(): 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': 'uipro-cli',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch releases: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getLatestRelease(): 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': 'uipro-cli',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest release: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function downloadRelease(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'uipro-cli',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
await Bun.write(dest, buffer);
|
||||
}
|
||||
|
||||
export function getAssetUrl(release: Release): string | null {
|
||||
const asset = release.assets.find(a => a.name.endsWith('.zip'));
|
||||
return asset?.browser_download_url ?? null;
|
||||
}
|
||||
11
cli/src/utils/logger.ts
Normal file
11
cli/src/utils/logger.ts
Normal 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)),
|
||||
};
|
||||
Reference in New Issue
Block a user