Initial release v1.0.0

Open-source marketplace for AI Agent skills.

Features:
- Next.js 15 web app with i18n (en/fa)
- CLI tool for skill installation (npx skillhub)
- GitHub crawler/indexer with multi-strategy discovery
- Security scanning for all indexed skills
- Self-hostable with Docker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

56
packages/ui/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "@skillhub/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js"
},
"./styles.css": "./dist/styles.css"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format esm --dts && pnpm build:css",
"build:css": "tailwindcss -i ./src/styles/globals.css -o ./dist/styles.css --minify",
"dev": "tsup src/index.ts --format esm --dts --watch",
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.309.0",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.4.1",
"tsup": "^8.0.1",
"typescript": "^5.3.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
}

View File

@@ -0,0 +1,34 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils.js';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white hover:bg-green-600',
warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils.js';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { cn } from '../lib/utils.js';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '../lib/utils.js';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,99 @@
import { Shield, ShieldCheck, ShieldAlert, ShieldX } from 'lucide-react';
import { cn } from '../lib/utils.js';
import { Badge } from './badge.js';
export interface SecurityBadgeProps {
score: number;
showScore?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function SecurityBadge({
score,
showScore = true,
size = 'md',
className,
}: SecurityBadgeProps) {
const { Icon, color, label, variant } = getSecurityInfo(score);
const sizeClasses = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
return (
<Badge variant={variant as 'default' | 'success' | 'warning' | 'destructive'} className={cn('gap-1', className)}>
<Icon className={cn(sizeClasses[size], color)} />
{showScore ? (
<span>{score}/100</span>
) : (
<span>{label}</span>
)}
</Badge>
);
}
function getSecurityInfo(score: number): {
Icon: typeof Shield;
color: string;
label: string;
variant: string;
} {
if (score >= 90) {
return {
Icon: ShieldCheck,
color: 'text-green-600',
label: 'Excellent',
variant: 'success',
};
}
if (score >= 70) {
return {
Icon: Shield,
color: 'text-yellow-600',
label: 'Good',
variant: 'warning',
};
}
if (score >= 50) {
return {
Icon: ShieldAlert,
color: 'text-orange-600',
label: 'Fair',
variant: 'warning',
};
}
return {
Icon: ShieldX,
color: 'text-red-600',
label: 'Poor',
variant: 'destructive',
};
}
export function SecurityScoreBar({ score }: { score: number }) {
const getColor = () => {
if (score >= 90) return 'bg-green-500';
if (score >= 70) return 'bg-yellow-500';
if (score >= 50) return 'bg-orange-500';
return 'bg-red-500';
};
return (
<div className="w-full">
<div className="flex justify-between text-sm mb-1">
<span className="text-muted-foreground">Security Score</span>
<span className="font-medium">{score}/100</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={cn('h-full transition-all', getColor())}
style={{ width: `${score}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { Star, Download, Shield, CheckCircle } from 'lucide-react';
import { cn } from '../lib/utils.js';
import { Card, CardContent, CardFooter, CardHeader } from './card.js';
import { Badge } from './badge.js';
export interface SkillCardProps {
id: string;
name: string;
description: string;
githubStars?: number;
downloadCount?: number;
securityScore?: number;
isVerified?: boolean;
platforms?: string[];
className?: string;
onClick?: () => void;
}
export function SkillCard({
name,
description,
githubStars = 0,
downloadCount = 0,
securityScore,
isVerified = false,
platforms = [],
className,
onClick,
}: SkillCardProps) {
const getSecurityColor = (score: number) => {
if (score >= 90) return 'text-green-500';
if (score >= 70) return 'text-yellow-500';
if (score >= 50) return 'text-orange-500';
return 'text-red-500';
};
return (
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-md hover:border-primary/50',
className
)}
onClick={onClick}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{name}</h3>
{isVerified && <CheckCircle className="h-4 w-4 text-green-500" />}
</div>
{securityScore !== undefined && (
<div className={cn('flex items-center gap-1', getSecurityColor(securityScore))}>
<Shield className="h-4 w-4" />
<span className="text-sm font-medium">{securityScore}</span>
</div>
)}
</div>
</CardHeader>
<CardContent className="pb-2">
<p className="text-sm text-muted-foreground line-clamp-2">{description}</p>
{platforms.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{platforms.map((platform) => (
<Badge key={platform} variant="secondary" className="text-xs">
{platform}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="pt-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Star className="h-4 w-4" />
<span>{formatNumber(githubStars)}</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
<span>{formatNumber(downloadCount)}</span>
</div>
</div>
</CardFooter>
</Card>
);
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}

19
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
// Utility
export { cn } from './lib/utils.js';
// Base components
export { Button, buttonVariants, type ButtonProps } from './components/button.js';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
} from './components/card.js';
export { Badge, badgeVariants, type BadgeProps } from './components/badge.js';
export { Input, type InputProps } from './components/input.js';
// SkillHub-specific components
export { SkillCard, type SkillCardProps } from './components/skill-card.js';
export { SecurityBadge, SecurityScoreBar, type SecurityBadgeProps } from './components/security-badge.js';

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [],
};

11
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}