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:
Viet Tran
2025-12-02 18:55:29 +07:00
committed by GitHub
parent 6a220478a2
commit 70200ed41a
16 changed files with 796 additions and 95 deletions

50
cli/src/utils/detect.ts Normal file
View 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
View 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
View 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
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)),
};