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

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies - let Docker rebuild these
node_modules
**/node_modules
# Build outputs
.next
**/.next
dist
**/dist
# Git
.git
.gitignore
# IDE
.vscode
.idea
# Misc
*.log
.env.local
.env.*.local
.DS_Store
Thumbs.db
# Turbo
.turbo
**/.turbo

85
.env.example Normal file
View File

@@ -0,0 +1,85 @@
# SkillHub Environment Configuration
# Copy this file to .env and update the values
# =====================
# Database Configuration
# =====================
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/skillhub
POSTGRES_PASSWORD=postgres
# =====================
# Redis Configuration
# =====================
REDIS_URL=redis://localhost:6379
# =====================
# GitHub API
# =====================
# Create a GitHub Personal Access Token with 'public_repo' scope
# https://github.com/settings/tokens
GITHUB_TOKEN=
# GitHub OAuth (for user authentication)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# =====================
# Meilisearch
# =====================
MEILI_URL=http://localhost:7700
MEILI_MASTER_KEY=skillhub-dev-key
MEILI_ENV=development
# =====================
# Application
# =====================
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NODE_ENV=development
# =====================
# Indexer Configuration
# =====================
INDEXER_CONCURRENCY=5
INDEXER_MIN_STARS=2
# =====================
# Sentry Error Monitoring
# =====================
# Get your DSN from https://sentry.io
NEXT_PUBLIC_SENTRY_DSN=
# Optional: Auth token for source map uploads
SENTRY_AUTH_TOKEN=
SENTRY_ORG=
SENTRY_PROJECT=
# Optional: Release version tracking
NEXT_PUBLIC_SENTRY_RELEASE=
# =====================
# Authentication
# =====================
# Generate a secure secret: openssl rand -base64 32
AUTH_SECRET=
AUTH_URL=http://localhost:3000
# =====================
# Multiple GitHub Tokens (for higher rate limits)
# =====================
# Comma-separated tokens for distributed API access
GITHUB_TOKENS=
GITHUB_TOKEN_NAMES=
# =====================
# Admin Users
# =====================
# Comma-separated GitHub usernames that get admin privileges on login
ADMIN_GITHUB_USERS=
# =====================
# Email (Resend)
# =====================
# Get your API key from https://resend.com
RESEND_API_KEY=
# From address (must be verified domain in Resend)
RESEND_FROM_EMAIL=

47
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,47 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
ignorePatterns: ['dist', 'node_modules', '.turbo', 'coverage'],
overrides: [
{
files: ['*.tsx'],
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
plugins: ['react', 'react-hooks'],
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
},
},
{
files: ['*.test.ts', '*.test.tsx', '*.spec.ts', '*.spec.tsx'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
],
};

150
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,150 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint and Type check
run: pnpm turbo lint typecheck
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run core tests
run: pnpm --filter @skillhub/core test:run
- name: Run db tests
run: pnpm --filter @skillhub/db test:run
- name: Run web API tests
run: pnpm --filter @skillhub/web test:run
- name: Run CLI tests
run: pnpm --filter @skillhub/cli test:run
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: [build]
# TODO: Enable when CI has database/redis services
if: false
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm --filter @skillhub/web exec playwright install --with-deps chromium
- name: Build web app and dependencies
run: pnpm turbo build --filter=@skillhub/web
- name: Run E2E tests
run: pnpm --filter @skillhub/web test:e2e --project=chromium
env:
PLAYWRIGHT_BASE_URL: http://localhost:3000
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
docker:
name: Build Docker Images
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Web image
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/web/Dockerfile
push: false
tags: ghcr.io/${{ github.repository_owner }}/skillhub-web:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build Indexer image
uses: docker/build-push-action@v5
with:
context: .
file: ./services/indexer/Dockerfile
push: false
tags: ghcr.io/${{ github.repository_owner }}/skillhub-indexer:latest
cache-from: type=gha
cache-to: type=gha,mode=max

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# Dependencies
node_modules
.pnpm-store
# scripts/ uses npm separately, we use pnpm
scripts/package-lock.json
# Build outputs
dist
.next
.turbo
*.tsbuildinfo
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Testing
coverage
# Tauri
apps/desktop/src-tauri/target
# Database
*.sqlite
*.db
drizzle/*.sql
*.dump
data/backups/
# Claude Code
.claude/skills/
.claude/settings.local.json
# Keep agents and council_reasoning.md in version control:
# !.claude/agents/
# !.claude/council_reasoning.md
.claude/settings.json

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
dist
node_modules
.turbo
.next
coverage
pnpm-lock.yaml

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# CLAUDE.md
## Project Overview
SkillHub is an open-source marketplace for **Agent Skills** — folders containing a `SKILL.md` file with instructions that AI agents can load dynamically.
**Live Site:** https://skills.palebluedot.live | **Status:** Production (`GET /api/stats`)
**Components:**
- **Web App** (`apps/web`): Next.js 15 marketplace
- **CLI** (`apps/cli`): Skill installer (`npm install -g skillhub`)
- **Indexer** (`services/indexer`): GitHub crawler
- **Packages** (`packages/*`): Core logic, DB, UI components
## Common Commands
```bash
pnpm install # Install dependencies
pnpm dev # Start all apps
pnpm --filter @skillhub/web dev # Start only web app
pnpm build # Build all packages
pnpm test # Run all tests (248 total)
pnpm --filter @skillhub/web test:e2e # E2E tests (requires running app)
pnpm db:push # Push schema changes
docker compose up -d # Start all services
```
## Architecture
```
skillhub/
├── apps/
│ ├── web/ # Next.js 15 + App Router + next-intl (i18n)
│ └── cli/ # Commander.js CLI, builds with tsup
├── packages/
│ ├── core/ # SKILL.md parser, validator, security scanner
│ ├── db/ # Drizzle ORM schema and queries
│ └── ui/ # Shared shadcn/ui components
├── services/
│ └── indexer/ # BullMQ worker for GitHub crawling
└── scripts/ # Database initialization scripts
```
## Database
**Schema files** (keep in sync):
- `scripts/init-db.sql` — SQL schema
- `packages/db/src/schema.ts` — Drizzle ORM schema
- `scripts/categories.sql` — Production categories
- `scripts/seed-data.sql` — Dev sample data
**Caching:** Skill files cached in `cached_files` JSONB column, invalidated when `commitSha` changes.
## Important Patterns
### Next.js 15 Async Params
`params` and `searchParams` are Promises:
```typescript
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
}
```
### i18n (next-intl)
Routes use `[locale]` segment. Use `setRequestLocale(locale)` for static rendering.
### Dynamic Rendering
Add `export const dynamic = 'force-dynamic'` to pages that fetch from database.
### Package Imports
- `skillhub-core` — Parser/validator
- `@skillhub/db` — Database
- `@skillhub/ui` — UI components
### API Route Pattern
Skill IDs = `owner/repo/skill-name`. Use catch-all: `/api/skills/[...id]/route.ts`
### CLI Skill ID Encoding
```typescript
const encodedPath = id.split('/').map(encodeURIComponent).join('/');
```
### Meilisearch IDs
Sanitized: `anthropics/skills/pdf``anthropics__skills__pdf`
## Environment Variables
`DATABASE_URL`, `REDIS_URL`, `GITHUB_TOKEN`, `MEILI_URL` (optional), `MEILI_MASTER_KEY`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `AUTH_SECRET`
## Indexer
```bash
docker compose exec indexer node dist/crawl.js full # Full crawl
docker compose exec indexer node dist/crawl.js incremental # Last 24h
docker compose exec indexer node dist/crawl.js sync-meili # Sync to Meilisearch
docker compose exec indexer node dist/crawl.js deep-scan # Scan discovered repos
```
## Security
Status stored in `security_status` column: **PASS** (green) / **WARNING** (yellow) / **FAIL** (red). Indexer scans for dangerous commands, prompt injection, and data exfiltration.
## Critical Files
| Category | Files |
|----------|-------|
| Database | `packages/db/src/schema.ts`, `packages/db/src/queries.ts` |
| API Routes | `apps/web/app/api/skills/route.ts`, `apps/web/app/api/skill-files/route.ts` |
| Auth | `apps/web/lib/auth.ts`, `apps/web/components/AuthButton.tsx` |
| Caching | `apps/web/lib/cache.ts` (Redis with TTL) |
| Rate Limiting | `apps/web/lib/rate-limit.ts` |
| Indexer | `services/indexer/src/crawler.ts`, `services/indexer/src/strategies/` |
| CLI | `apps/cli/src/commands/install.ts`, `apps/cli/src/utils/api.ts` |

281
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,281 @@
# Contributing to SkillHub
Thank you for your interest in contributing to SkillHub! This document provides guidelines and instructions for contributing.
---
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Development Setup](#development-setup)
- [Pull Request Process](#pull-request-process)
- [Coding Standards](#coding-standards)
- [Commit Messages](#commit-messages)
---
## Code of Conduct
This project follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). Please be respectful and constructive in all interactions.
---
## How Can I Contribute?
### Report Bugs
Found a bug? Please open an issue with:
1. **Clear title** describing the problem
2. **Steps to reproduce** the issue
3. **Expected behavior** vs actual behavior
4. **Environment details** (OS, Node version, browser)
5. **Screenshots** if applicable
### Suggest Features
Have an idea? Open an issue with:
1. **Use case** - What problem does it solve?
2. **Proposed solution** - How should it work?
3. **Alternatives considered** - What else did you think about?
### Submit Skills
Want to add your skill to SkillHub?
1. Ensure your repository has a valid `SKILL.md` file
2. Go to [skills.palebluedot.live/claim](https://skills.palebluedot.live/claim)
3. Sign in with GitHub
4. Submit your repository URL
### Code Contributions
We welcome code contributions for:
- Bug fixes
- New features
- Performance improvements
- Documentation updates
- Test coverage
- Translations (i18n)
---
## Development Setup
### Prerequisites
- Node.js 18+
- pnpm 9+
- Docker (for database and services)
- Git
### Setup Steps
```bash
# Clone the repository
git clone https://github.com/airano-ir/skillhub.git
cd skillhub
# Install dependencies
pnpm install
# Copy environment file
cp .env.example .env
# Start infrastructure (database, redis, meilisearch)
docker compose up -d db redis meilisearch
# Run database migrations
pnpm db:push
# Seed sample data (optional)
docker exec -i skillhub-db psql -U postgres -d skillhub < scripts/seed-data.sql
# Start development servers
pnpm dev
```
### Project Scripts
```bash
pnpm dev # Start all apps in dev mode
pnpm build # Build all packages
pnpm test # Run all tests
pnpm lint # Lint all packages
pnpm typecheck # TypeScript type checking
```
### Package-Specific Commands
```bash
# Web app
pnpm --filter @skillhub/web dev
pnpm --filter @skillhub/web build
# CLI
pnpm --filter skillhub dev
pnpm --filter skillhub build
# Core library
pnpm --filter skillhub-core test
# Database
pnpm --filter @skillhub/db studio # Open Drizzle Studio
```
---
## Pull Request Process
### Before Submitting
1. **Create an issue first** - Discuss your change before implementing
2. **Fork the repository** - Work on your own fork
3. **Create a feature branch** - `git checkout -b feature/your-feature`
4. **Write tests** - Add tests for new functionality
5. **Run tests locally** - `pnpm test`
6. **Lint your code** - `pnpm lint`
### PR Guidelines
1. **Title format:** `type(scope): description`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Example: `feat(cli): add update --all command`
2. **Description should include:**
- What changes were made
- Why the changes were needed
- How to test the changes
- Screenshots (for UI changes)
3. **Keep PRs focused** - One feature/fix per PR
4. **Update documentation** - If your change affects user-facing features
### Review Process
1. Maintainers will review within 3-5 business days
2. Address feedback promptly
3. Once approved, a maintainer will merge
---
## Coding Standards
### TypeScript
- Use TypeScript for all new code
- Enable strict mode
- Avoid `any` type - use `unknown` or proper types
- Use interfaces over types when possible
```typescript
// Good
interface SkillMetadata {
name: string;
description: string;
version?: string;
}
// Avoid
const skill: any = fetchSkill();
```
### React/Next.js
- Use functional components with hooks
- Prefer server components where possible
- Use `next-intl` for translations
- Follow Next.js 15 patterns (async params, etc.)
```typescript
// Good - Next.js 15 pattern
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// ...
}
```
### Styling
- Use Tailwind CSS
- Follow design token system (`bg-surface`, `text-text-primary`)
- Avoid hardcoded colors
- Support dark mode
```tsx
// Good
<div className="bg-surface text-text-primary">
// Avoid
<div className="bg-white text-gray-900">
```
### Testing
- Write unit tests for utilities and functions
- Write integration tests for API routes
- Use Vitest for unit tests
- Use Playwright for E2E tests
```typescript
// Example test
import { describe, it, expect } from 'vitest';
describe('parseSkillMd', () => {
it('should parse valid SKILL.md', () => {
const result = parseSkillMd(validInput);
expect(result.name).toBe('expected-name');
});
});
```
---
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): description
[optional body]
[optional footer]
```
### Types
| Type | Description |
|------|-------------|
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation |
| `style` | Formatting (no code change) |
| `refactor` | Code restructuring |
| `test` | Adding tests |
| `chore` | Maintenance |
### Examples
```bash
feat(cli): add search command with category filter
fix(web): resolve RTL layout issues on skill page
docs: update README with new installation steps
test(core): add tests for SKILL.md parser
```
---
## Questions?
- **GitHub Discussions:** For general questions
- **GitHub Issues:** For bugs and feature requests
- **Discord:** Coming soon
---
Thank you for contributing to SkillHub!

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SkillHub Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

219
README.md Normal file
View File

@@ -0,0 +1,219 @@
# SkillHub
<div align="center">
![SkillHub](https://img.shields.io/badge/SkillHub-AI%20Agent%20Skills-blue?style=for-the-badge)
**The open-source marketplace for AI Agent skills**
[Documentation](./docs) | [CLI](./apps/cli) | [Self-Host](./docs/self-hosting.md)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)](https://www.typescriptlang.org/)
[![Skills](https://img.shields.io/badge/Skills-170K%2B-green)](https://skills.palebluedot.live/api/stats)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md)
</div>
---
## What is SkillHub?
SkillHub indexes **hundreds of thousands of AI agent skills** from GitHub and makes them discoverable and installable. Skills are SKILL.md files that teach AI agents (Claude, Codex, Copilot) specialized capabilities - from PDF manipulation to database queries.
```bash
# Install a skill in seconds
npx skillhub install anthropics/skills/pdf
```
---
## Features
- **Massive Skill Catalog** - Search by category, platform, or keyword
- **One-Line Install** - `npx skillhub install <skill-id>`
- **Multi-Platform** - Works with Claude, OpenAI Codex, GitHub Copilot
- **Security Scanning** - Every skill scanned for malicious patterns
- **Self-Hostable** - Run your own instance with Docker
- **Fully Open Source** - MIT licensed, free forever
---
## Quick Start
### Install the CLI
```bash
npm install -g skillhub
```
### Search for Skills
```bash
skillhub search "pdf processing"
skillhub search "database" --category data-database
```
### Install a Skill
```bash
# For Claude Code (global)
skillhub install anthropics/skills/pdf
# For a specific project
skillhub install anthropics/skills/pdf --project
# For OpenAI Codex
skillhub install anthropics/skills/pdf --platform codex
```
### List Installed Skills
```bash
skillhub list
skillhub list --all # Show both global and project skills
```
---
## Self-Hosting
```bash
# Clone the repository
git clone https://github.com/airano-ir/skillhub.git
cd skillhub
# Copy environment file
cp .env.example .env
# Start with Docker Compose
docker compose up -d
# Open http://localhost:3000
```
For production deployment, see [Self-Hosting Guide](./docs/self-hosting.md).
---
## Project Structure
```
skillhub/
├── apps/
│ ├── web/ # Next.js 15 web application
│ └── cli/ # CLI tool (npm: skillhub)
├── packages/
│ ├── core/ # SKILL.md parser & validator
│ ├── db/ # PostgreSQL + Drizzle ORM
│ └── ui/ # Shared shadcn/ui components
├── services/
│ └── indexer/ # GitHub skill crawler
└── docs/ # Documentation
```
---
## Technology Stack
| Layer | Technology |
|-------|------------|
| Frontend | Next.js 15, React 18, Tailwind CSS, shadcn/ui |
| Backend | Next.js API Routes, PostgreSQL |
| Search | Meilisearch |
| CLI | Commander.js |
| DevOps | Docker, GitHub Actions |
---
## API
SkillHub provides a public REST API:
```bash
# Get skill info
curl https://skills.palebluedot.live/api/skills/anthropics/skills/pdf
# Search skills
curl "https://skills.palebluedot.live/api/skills?q=pdf&limit=10"
# Get stats
curl https://skills.palebluedot.live/api/stats
```
See [API Documentation](./docs/API.md) for full reference.
---
## Contributing
We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
### Development Setup
```bash
# Install dependencies
pnpm install
# Start development servers
pnpm dev
# Run tests
pnpm test
# Build all packages
pnpm build
```
---
## Security
SkillHub scans all indexed skills for:
- Dangerous shell commands (rm -rf, curl | sh)
- Prompt injection patterns
- Data exfiltration attempts
**Security Status:**
- **PASS** - No issues detected
- **WARNING** - Potential issues flagged
- **FAIL** - Dangerous patterns detected
Always review skill source code before installing.
---
## Roadmap
- [x] Web marketplace with growing skill catalog
- [x] CLI tool
- [x] Multi-platform support
- [x] Security scanning
- [ ] Claude Code MCP plugin
- [ ] AI-powered recommendations
- [ ] Enterprise features
---
## License
MIT - See [LICENSE](./LICENSE) for details.
---
## Links
- **Live Site:** https://skills.palebluedot.live
- **GitHub:** https://github.com/airano-ir/skillhub
- **npm:** https://www.npmjs.com/package/skillhub
---
<div align="center">
**[Browse Skills](https://skills.palebluedot.live/browse)** | **[Star on GitHub](https://github.com/airano-ir/skillhub)**
*Built with love for the AI agent community*
</div>

65
SECURITY.md Normal file
View File

@@ -0,0 +1,65 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| Latest | Yes |
## Reporting a Vulnerability
If you discover a security vulnerability in SkillHub, please report it responsibly.
**Do NOT open a public GitHub issue for security vulnerabilities.**
### How to Report
1. **Email:** Send details to [dev@airano.ir](mailto:dev@airano.ir)
2. **Subject:** `[SECURITY] Brief description`
3. **Include:**
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### What to Expect
- **Acknowledgment:** Within 48 hours
- **Assessment:** Within 7 days
- **Fix timeline:** Depends on severity (critical: 24-72h, high: 1-2 weeks)
### Scope
The following are in scope:
- SkillHub web application
- SkillHub CLI (`skillhub` npm package)
- SkillHub API endpoints
- Indexer/crawler service
The following are out of scope:
- Third-party services (GitHub, Meilisearch, Redis)
- Issues in dependencies (report to upstream)
- Social engineering attacks
## Security Measures
SkillHub implements the following security measures:
- **CSRF Protection:** All state-changing operations
- **Input Sanitization:** Query parameters and user inputs
- **Rate Limiting:** Per-endpoint rate limits
- **Security Headers:** CSP, HSTS, X-Frame-Options
- **Authentication:** GitHub OAuth via NextAuth.js
- **Skill Scanning:** Automated security analysis (PASS/WARNING/FAIL) for all indexed skills
- **SQL Injection Prevention:** Parameterized queries via Drizzle ORM
## Responsible Disclosure
We follow responsible disclosure practices. We ask that you:
- Allow reasonable time for a fix before public disclosure
- Do not access or modify other users' data
- Do not perform denial-of-service attacks
- Act in good faith
We will credit reporters in our changelog (unless you prefer to remain anonymous).

121
apps/cli/README.md Normal file
View File

@@ -0,0 +1,121 @@
# @skillhub/cli
Command-line tool for installing and managing AI Agent skills from [SkillHub](https://skills.palebluedot.live).
## Installation
```bash
# Install globally
npm install -g @skillhub/cli
# Or use directly with npx
npx @skillhub/cli
```
## Usage
### Search for skills
```bash
skillhub search pdf
skillhub search "code review" --platform claude --limit 20
```
### Install a skill
```bash
skillhub install anthropics/skills/pdf
skillhub install obra/superpowers/brainstorming --platform codex
skillhub install anthropics/skills/docx --project # Install in current project
```
### List installed skills
```bash
skillhub list
skillhub list --platform claude
```
### Update skills
```bash
skillhub update anthropics/skills/pdf # Update specific skill
skillhub update --all # Update all installed skills
```
### Uninstall a skill
```bash
skillhub uninstall pdf
skillhub uninstall brainstorming --platform codex
```
### Configuration
```bash
skillhub config --list # Show all config
skillhub config --get defaultPlatform # Get specific value
skillhub config --set defaultPlatform=claude # Set value
```
## Platform Support
SkillHub CLI supports multiple AI agent platforms:
| Platform | Flag | Install Path |
|----------|------|--------------|
| Claude | `--platform claude` | `~/.claude/skills/` |
| OpenAI Codex | `--platform codex` | `~/.codex/skills/` |
| GitHub Copilot | `--platform copilot` | `~/.github/skills/` |
| Cursor | `--platform cursor` | `~/.cursor/skills/` |
| Windsurf | `--platform windsurf` | `~/.windsurf/skills/` |
Default platform: `claude`
## Options
### Global Options
- `--platform <name>` - Target platform (claude, codex, copilot, cursor, windsurf)
- `--project` - Install in current project instead of user directory
- `--force` - Overwrite existing installation
- `--help` - Show help information
- `--version` - Show version number
### Environment Variables
- `SKILLHUB_API_URL` - Override API endpoint (default: https://skills.palebluedot.live/api)
- `GITHUB_TOKEN` - GitHub token for API rate limits (optional)
## Configuration File
Configuration is stored in `~/.skillhub/config.json`:
```json
{
"defaultPlatform": "claude",
"apiUrl": "https://skills.palebluedot.live/api",
"githubToken": "ghp_..."
}
```
## Examples
```bash
# Search and install a skill
skillhub search "document processing"
skillhub install anthropics/skills/pdf
# Install skill for Codex
skillhub install obra/superpowers/brainstorming --platform codex
# Update all installed skills
skillhub update --all
# Check what's installed
skillhub list
```
## License
MIT

61
apps/cli/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "skillhub",
"version": "0.2.4",
"description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more",
"author": "SkillHub Contributors",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/airano-ir/skillhub.git",
"directory": "apps/cli"
},
"homepage": "https://skills.palebluedot.live",
"type": "module",
"bin": {
"skillhub": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@octokit/rest": "^20.0.2",
"chalk": "^5.3.0",
"commander": "^11.1.0",
"fs-extra": "^11.2.0",
"ora": "^8.0.1",
"prompts": "^2.4.2"
},
"devDependencies": {
"skillhub-core": "workspace:*",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.10.0",
"@types/prompts": "^2.4.9",
"tsup": "^8.0.1",
"tsx": "^4.7.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"ai",
"agent",
"skills",
"cli",
"claude",
"codex",
"copilot",
"cursor",
"windsurf"
]
}

View File

@@ -0,0 +1,79 @@
import chalk from 'chalk';
import { loadConfig, saveConfig, getConfigPath } from '../utils/paths.js';
interface ConfigOptions {
set?: string;
get?: string;
list?: boolean;
}
/**
* Manage CLI configuration
*/
export async function config(options: ConfigOptions): Promise<void> {
const currentConfig = await loadConfig();
// List all config
if (options.list || (!options.set && !options.get)) {
console.log(chalk.bold('SkillHub CLI Configuration:\n'));
console.log(chalk.dim(`Config file: ${getConfigPath()}\n`));
if (Object.keys(currentConfig).length === 0) {
console.log(chalk.yellow('No configuration set.'));
console.log(chalk.dim('\nAvailable settings:'));
console.log(chalk.dim(' defaultPlatform - Default platform for installations (claude, codex, copilot)'));
console.log(chalk.dim(' apiUrl - SkillHub API URL'));
console.log(chalk.dim(' githubToken - GitHub personal access token for private repos'));
return;
}
for (const [key, value] of Object.entries(currentConfig)) {
const displayValue = key.toLowerCase().includes('token')
? maskSecret(String(value))
: String(value);
console.log(` ${chalk.cyan(key)}: ${displayValue}`);
}
return;
}
// Get a config value
if (options.get) {
const value = currentConfig[options.get];
if (value === undefined) {
console.log(chalk.yellow(`Config '${options.get}' is not set.`));
return;
}
const displayValue = options.get.toLowerCase().includes('token')
? maskSecret(String(value))
: String(value);
console.log(displayValue);
return;
}
// Set a config value
if (options.set) {
const [key, ...valueParts] = options.set.split('=');
const value = valueParts.join('=');
if (!key || value === undefined) {
console.error(chalk.red('Invalid format. Use: --set key=value'));
process.exit(1);
}
currentConfig[key] = value;
await saveConfig(currentConfig);
const displayValue = key.toLowerCase().includes('token')
? maskSecret(value)
: value;
console.log(chalk.green(`Set ${chalk.cyan(key)} = ${displayValue}`));
}
}
function maskSecret(value: string): string {
if (value.length <= 8) {
return '*'.repeat(value.length);
}
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
}

View File

@@ -0,0 +1,391 @@
import fs from 'fs-extra';
import * as path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { parseSkillMd, parseGenericInstructionFile, INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
import { getSkillPath, ensureSkillsDir, isSkillInstalled, isFlatFilePlatform, getPlatformFilePath, type Platform } from '../utils/paths.js';
import { getSkill, trackInstall, getSkillFiles, type SkillFilesResponse } from '../utils/api.js';
import { fetchSkillContent, getDefaultBranch, type SkillContent } from '../utils/github.js';
import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from '../utils/transform.js';
interface InstallOptions {
platform: Platform;
project?: boolean;
force?: boolean;
noApi?: boolean;
}
/**
* Install a skill from the registry
*/
export async function install(skillId: string, options: InstallOptions): Promise<void> {
const spinner = ora('Parsing skill ID...').start();
try {
// Parse skill ID
const parts = skillId.split('/');
if (parts.length < 2) {
spinner.fail('Invalid skill ID format. Use: owner/repo or owner/repo/skill-name');
process.exit(1);
}
const [owner, repo, ...rest] = parts;
let skillPath = rest.join('/');
// Try to get skill info from API (unless --no-api)
let skillInfo;
if (!options.noApi) {
spinner.text = `Connecting to ${process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live'}...`;
try {
skillInfo = await getSkill(skillId);
if (skillInfo) {
spinner.succeed(`Found in registry: ${skillInfo.name}`);
spinner.start('Preparing installation...');
}
} catch (error) {
spinner.warn(`API unavailable: ${(error as Error).message}`);
spinner.start('Falling back to direct GitHub fetch...');
}
} else {
spinner.text = 'Skipping API lookup (--no-api flag)';
}
let skillName: string;
let branch = 'main';
let sourceFormat: SourceFormat = 'skill.md';
if (skillInfo) {
skillName = skillInfo.name;
// Use the actual skillPath from database (e.g., 'skills/nuxt-ui' not 'nuxt-ui')
skillPath = skillInfo.skillPath;
branch = skillInfo.branch || 'main';
sourceFormat = (skillInfo.sourceFormat as SourceFormat) || 'skill.md';
spinner.text = `Found skill: ${chalk.cyan(skillName)}`;
} else {
// Fall back to fetching directly from GitHub
spinner.text = 'Skill not in registry, fetching from GitHub...';
try {
branch = await getDefaultBranch(owner, repo);
skillName = skillPath || repo;
} catch (error) {
spinner.fail('Failed to connect to GitHub');
throw error;
}
}
// Check if already installed
const installed = await isSkillInstalled(options.platform, skillName, options.project);
if (installed && !options.force) {
spinner.fail(
`Skill ${chalk.cyan(skillName)} is already installed. Use ${chalk.yellow('--force')} to overwrite.`
);
process.exit(1);
}
// Ensure skills directory exists
await ensureSkillsDir(options.platform, options.project);
// Fetch skill content - try API first, fall back to GitHub
spinner.text = `Downloading ${skillInfo?.name || skillId}...`;
let content: SkillContent | undefined;
let apiWasReachable = false;
// Try API first (unless --no-api)
if (!options.noApi && skillInfo) {
apiWasReachable = true; // API responded to getSkill, so it's reachable
try {
spinner.text = 'Downloading skill files...';
const cachedFiles = await getSkillFiles(skillInfo.id);
if (cachedFiles && cachedFiles.files.length > 0) {
// Use sourceFormat from API response if available
if (cachedFiles.sourceFormat) {
sourceFormat = cachedFiles.sourceFormat as SourceFormat;
}
// Convert API response to SkillContent format
content = convertCachedFilesToSkillContent(cachedFiles, sourceFormat);
spinner.text = cachedFiles.fromCache
? `Using cached files (${cachedFiles.files.length} files)`
: `Downloaded ${cachedFiles.files.length} files via API`;
}
} catch {
// API was reachable but file fetch failed (timeout, server error, etc.)
spinner.text = 'API file fetch failed, falling back to GitHub...';
}
}
// Fall back to direct GitHub fetch only if:
// - API was not used (--no-api or skill not in registry)
// - OR API file fetch returned empty/null (not a timeout - timeout means server is working on it)
if (!content) {
if (apiWasReachable) {
// API was reachable but couldn't provide files - still try GitHub as last resort
spinner.text = `Falling back to GitHub: ${owner}/${repo}/${skillPath || ''}...`;
} else {
spinner.text = `Downloading from GitHub: ${owner}/${repo}/${skillPath || ''}...`;
}
try {
content = await fetchSkillContent(owner, repo, skillPath, branch, sourceFormat);
spinner.text = `Downloaded ${content.scripts.length} scripts, ${content.references.length} references`;
} catch (error) {
spinner.fail('Failed to download skill files');
console.error(chalk.red((error as Error).message));
console.log();
console.log(chalk.yellow('Troubleshooting tips:'));
console.log(chalk.dim(' 1. Check your internet connection'));
if (apiWasReachable) {
console.log(chalk.dim(' 2. The API server could not fetch files either - try again in a minute'));
console.log(chalk.dim(' 3. The server may be caching the files now - retry shortly'));
} else {
console.log(chalk.dim(' 2. If behind a proxy, configure HTTP_PROXY/HTTPS_PROXY environment variables'));
}
console.log(chalk.dim(` ${apiWasReachable ? '4' : '3'}. Set GITHUB_TOKEN environment variable for higher rate limits`));
process.exit(1);
}
}
// Ensure content is available (TypeScript narrowing)
if (!content) {
spinner.fail('Failed to download skill content');
process.exit(1);
}
// Parse and validate (format-aware)
const parsed = sourceFormat === 'skill.md'
? parseSkillMd(content.skillMd)
: parseGenericInstructionFile(content.skillMd, sourceFormat, {
name: skillName,
description: skillInfo?.description || null,
owner,
});
if (!parsed.validation.isValid) {
spinner.warn('Skill has validation issues:');
for (const error of parsed.validation.errors) {
console.log(chalk.yellow(` - ${error.message}`));
}
}
// Get the actual skill name from metadata
const actualName = parsed.metadata.name || skillName;
const installPath = getSkillPath(options.platform, actualName, options.project);
// Check for name collision with different skill
const metadataPath = path.join(installPath, '.skillhub.json');
if (await fs.pathExists(metadataPath)) {
try {
const existingMetadata = await fs.readJson(metadataPath);
if (existingMetadata.skillId && existingMetadata.skillId !== skillId) {
spinner.warn(`Name collision detected!`);
console.log(chalk.yellow(`\nA different skill is already installed with the name "${actualName}":`));
console.log(chalk.dim(` Existing: ${existingMetadata.skillId}`));
console.log(chalk.dim(` New: ${skillId}`));
console.log();
if (!options.force) {
console.log(chalk.red('Installation cancelled to prevent overwriting.'));
console.log(chalk.dim('Use --force to overwrite the existing skill.'));
process.exit(1);
} else {
console.log(chalk.yellow('Overwriting existing skill (--force flag used).\n'));
}
}
} catch {
// Ignore metadata read errors
}
}
// Remove existing if force
if (installed && options.force) {
await fs.remove(installPath);
}
// Create skill directory and write files
spinner.text = 'Installing skill...';
await fs.ensureDir(installPath);
// Transform content for target platform
const platformFileName = getPlatformFileName(options.platform, actualName);
const { content: transformedContent, warnings: transformWarnings } =
transformForPlatform(options.platform, content.skillMd, parsed);
for (const warning of transformWarnings) {
console.log(chalk.yellow(` Warning: ${warning}`));
}
// Write the platform-specific file
if (isFlatFilePlatform(options.platform)) {
const platformFilePath = getPlatformFilePath(
options.platform, actualName, platformFileName, options.project
);
await fs.writeFile(platformFilePath, transformedContent);
} else {
await fs.writeFile(path.join(installPath, platformFileName), transformedContent);
}
// Keep original SKILL.md in tracking directory for re-transformation
if (shouldKeepOriginal(options.platform)) {
await fs.writeFile(path.join(installPath, 'SKILL.md'), content.skillMd);
}
// Write metadata file for update tracking
// Use canonical ID from registry if available for proper tracking
const canonicalId = skillInfo?.id || skillId;
const platformFilePath = isFlatFilePlatform(options.platform)
? getPlatformFilePath(options.platform, actualName, platformFileName, options.project)
: null;
await fs.writeJson(path.join(installPath, '.skillhub.json'), {
skillId: canonicalId,
installedAt: new Date().toISOString(),
platform: options.platform,
version: parsed.metadata.version || null,
platformFileName,
platformFilePath,
});
// Write scripts
if (content.scripts.length > 0) {
const scriptsDir = path.join(installPath, 'scripts');
await fs.ensureDir(scriptsDir);
for (const script of content.scripts) {
const scriptPath = path.join(scriptsDir, script.name);
await fs.writeFile(scriptPath, script.content);
await fs.chmod(scriptPath, '755');
}
}
// Write references
if (content.references.length > 0) {
const refsDir = path.join(installPath, 'references');
await fs.ensureDir(refsDir);
for (const ref of content.references) {
await fs.writeFile(path.join(refsDir, ref.name), ref.content);
}
}
// Track installation using canonical skill ID from registry (if available)
// This ensures the tracking matches the database record
const trackingId = skillInfo?.id || skillId;
await trackInstall(trackingId, options.platform, 'cli');
spinner.succeed(`Skill ${chalk.green(actualName)} installed successfully!`);
// Print info
console.log();
console.log(chalk.dim(`Path: ${installPath}`));
console.log();
if (parsed.metadata.description) {
console.log(chalk.dim(parsed.metadata.description));
console.log();
}
console.log(chalk.yellow('Usage:'));
console.log(
` This skill will be automatically activated when your ${getPlatformName(options.platform)} agent recognizes it's relevant.`
);
const setupInstructions = getPlatformSetupInstructions(options.platform, installPath);
if (setupInstructions) {
console.log();
console.log(chalk.cyan('Next Steps:'));
console.log(setupInstructions);
}
if (content.scripts.length > 0) {
console.log();
console.log(chalk.dim(`Scripts: ${content.scripts.map((s) => s.name).join(', ')}`));
}
} catch (error) {
spinner.fail('Installation failed');
console.error(chalk.red((error as Error).message));
process.exit(1);
}
}
function getPlatformName(platform: Platform): string {
const names: Record<Platform, string> = {
claude: 'Claude',
codex: 'OpenAI Codex',
copilot: 'GitHub Copilot',
cursor: 'Cursor',
windsurf: 'Windsurf',
};
return names[platform];
}
function getPlatformSetupInstructions(platform: Platform, installPath: string): string | null {
switch (platform) {
case 'claude':
return chalk.dim(' Skills in .claude/skills/ are automatically discovered by Claude Code.');
case 'codex':
return chalk.dim(` Reference this skill in your AGENTS.md:\n @import ${installPath}/SKILL.md`);
case 'copilot':
return chalk.dim(' Instructions in .github/instructions/ are automatically loaded by GitHub Copilot.');
case 'cursor':
return chalk.dim(' Rules in .cursor/rules/ are automatically loaded by Cursor.');
case 'windsurf':
return chalk.dim(' Rules in .windsurf/rules/ are automatically loaded by Windsurf.');
default:
return null;
}
}
/**
* All known main instruction file names across platforms
*/
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
/**
* Convert cached files API response to SkillContent format.
* Detects the main instruction file by name (SKILL.md, AGENTS.md, .cursorrules, etc.)
*/
function convertCachedFilesToSkillContent(
response: SkillFilesResponse,
sourceFormat: SourceFormat = 'skill.md'
): SkillContent {
let skillMd = '';
const scripts: SkillContent['scripts'] = [];
const references: SkillContent['references'] = [];
// Find the expected main filename for this format
const expectedPattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
const expectedFilename = expectedPattern?.filename || 'SKILL.md';
for (const file of response.files) {
// Skip files without content (binary files)
if (!file.content) continue;
// Main instruction file: match expected filename or any known instruction file
if (!skillMd && (file.name === expectedFilename || MAIN_FILE_NAMES.includes(file.name)) &&
file.path === file.name) {
skillMd = file.content;
continue;
}
// Scripts folder
if (file.path.startsWith('scripts/')) {
scripts.push({
name: file.name,
content: file.content,
});
continue;
}
// References folder
if (file.path.startsWith('references/')) {
references.push({
name: file.name,
content: file.content,
});
}
}
return {
skillMd,
scripts,
references,
assets: [],
};
}

View File

@@ -0,0 +1,177 @@
import fs from 'fs-extra';
import * as path from 'path';
import chalk from 'chalk';
import { parseSkillMd } from 'skillhub-core';
import { getSkillsPath, type Platform } from '../utils/paths.js';
interface ListOptions {
platform?: Platform;
project?: boolean;
all?: boolean;
}
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
/**
* List installed skills
*/
export async function list(options: ListOptions): Promise<void> {
const platforms = options.platform ? [options.platform] : ALL_PLATFORMS;
let totalSkills = 0;
// Determine which locations to check
const checkGlobal = options.all || !options.project;
const checkProject = options.all || options.project;
// List global skills
if (checkGlobal) {
for (const platform of platforms) {
const skills = await getInstalledSkills(platform, false);
if (skills.length === 0) {
continue;
}
totalSkills += skills.length;
const header = options.all
? `${getPlatformName(platform)} - Global (${skills.length} skills)`
: `${getPlatformName(platform)} (${skills.length} skills)`;
console.log(chalk.bold(`\n${header}:`));
console.log(chalk.dim('─'.repeat(60)));
for (const skill of skills) {
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
if (skill.description) {
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
}
}
}
}
// List project skills
if (checkProject) {
for (const platform of platforms) {
const skills = await getInstalledSkills(platform, true);
if (skills.length === 0) {
continue;
}
totalSkills += skills.length;
const header = options.all
? `${getPlatformName(platform)} - Project (${skills.length} skills)`
: `${getPlatformName(platform)} (${skills.length} skills)`;
console.log(chalk.bold(`\n${header}:`));
console.log(chalk.dim('─'.repeat(60)));
console.log(chalk.dim(` Path: ${getSkillsPath(platform, true)}`));
for (const skill of skills) {
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
if (skill.description) {
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
}
}
}
}
if (totalSkills === 0) {
if (options.project) {
console.log(chalk.yellow('\nNo skills installed in project.'));
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id> --project'));
} else if (options.all) {
console.log(chalk.yellow('\nNo skills installed (global or project).'));
} else {
console.log(chalk.yellow('\nNo skills installed globally.'));
console.log(chalk.dim('Use --project to list project-level skills'));
console.log(chalk.dim('Use --all to list both global and project skills'));
}
console.log(chalk.dim('\nSearch for skills with: npx skillhub search <query>'));
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id>'));
} else {
console.log(chalk.dim(`\n${totalSkills} total skill(s) installed.`));
}
}
interface InstalledSkill {
name: string;
description?: string;
version?: string;
path: string;
}
async function getInstalledSkills(platform: Platform, project: boolean = false): Promise<InstalledSkill[]> {
const skillsPath = getSkillsPath(platform, project);
const skills: InstalledSkill[] = [];
if (!(await fs.pathExists(skillsPath))) {
return skills;
}
const entries = await fs.readdir(skillsPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const skillPath = path.join(skillsPath, entry.name);
const skillMdPath = path.join(skillPath, 'SKILL.md');
const metadataPath = path.join(skillPath, '.skillhub.json');
const hasSkillMd = await fs.pathExists(skillMdPath);
const hasMetadata = await fs.pathExists(metadataPath);
if (!hasSkillMd && !hasMetadata) {
continue;
}
try {
if (hasSkillMd) {
const content = await fs.readFile(skillMdPath, 'utf-8');
const parsed = parseSkillMd(content);
skills.push({
name: parsed.metadata.name || entry.name,
description: parsed.metadata.description,
version: parsed.metadata.version,
path: skillPath,
});
} else if (hasMetadata) {
const metadata = await fs.readJson(metadataPath);
skills.push({
name: entry.name,
description: undefined,
version: metadata.version || undefined,
path: skillPath,
});
}
} catch {
// Skip invalid skills
skills.push({
name: entry.name,
path: skillPath,
});
}
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function getPlatformName(platform: Platform): string {
const names: Record<Platform, string> = {
claude: 'Claude',
codex: 'OpenAI Codex',
copilot: 'GitHub Copilot',
cursor: 'Cursor',
windsurf: 'Windsurf',
};
return names[platform];
}

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
// Mock dependencies
vi.mock('chalk', () => ({
default: {
bold: vi.fn((s) => s),
dim: vi.fn((s) => s),
cyan: vi.fn((s) => s),
yellow: vi.fn((s) => s),
red: vi.fn((s) => s),
green: vi.fn((s) => s),
white: vi.fn((s) => s),
},
}));
vi.mock('ora', () => ({
default: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn(),
fail: vi.fn(),
})),
}));
vi.mock('../utils/api.js', () => ({
searchSkills: vi.fn(),
}));
import { search } from './search.js';
import { searchSkills } from '../utils/api.js';
describe('search command', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let processExitSpy: any;
beforeEach(() => {
vi.clearAllMocks();
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
processExitSpy.mockRestore();
});
it('should search skills via API', async () => {
vi.mocked(searchSkills).mockResolvedValue({
skills: [
{
id: 'test/skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test',
githubRepo: 'skill',
skillPath: 'skills/test',
branch: 'main',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: true,
compatibility: { platforms: ['claude', 'codex'] },
},
],
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
});
await search('test', {});
expect(searchSkills).toHaveBeenCalledWith('test', { platform: undefined, limit: 10, page: 1, sort: 'downloads' });
expect(consoleLogSpy).toHaveBeenCalled();
});
it('should show "no skills found" message', async () => {
vi.mocked(searchSkills).mockResolvedValue({
skills: [],
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
});
await search('nonexistent', {});
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No skills found'));
});
it('should filter by platform', async () => {
vi.mocked(searchSkills).mockResolvedValue({
skills: [],
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
});
await search('test', { platform: 'claude' });
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ platform: 'claude' }));
});
it('should respect limit option', async () => {
vi.mocked(searchSkills).mockResolvedValue({
skills: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
await search('test', { limit: '20' });
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 20 }));
});
it('should handle API errors gracefully', async () => {
vi.mocked(searchSkills).mockRejectedValue(new Error('API error'));
await search('test', {});
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('API error'));
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should display pagination info when more results available', async () => {
vi.mocked(searchSkills).mockResolvedValue({
skills: [
{
id: 'test/skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test',
githubRepo: 'skill',
skillPath: 'skills/test',
branch: 'main',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
compatibility: { platforms: ['claude'] },
},
],
pagination: { page: 1, limit: 10, total: 50, totalPages: 5 },
});
await search('test', {});
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Page 1 of 5'));
});
});

View File

@@ -0,0 +1,132 @@
import chalk from 'chalk';
import ora from 'ora';
import { searchSkills } from '../utils/api.js';
interface SearchOptions {
platform?: string;
sort?: string;
limit?: string;
page?: string;
}
/**
* Search for skills in the registry
*/
export async function search(query: string, options: SearchOptions): Promise<void> {
const spinner = ora('Searching skills...').start();
try {
const limit = parseInt(options.limit || '10');
const page = parseInt(options.page || '1');
const sort = options.sort || 'downloads';
const result = await searchSkills(query, {
platform: options.platform,
limit,
page,
sort,
});
spinner.stop();
if (result.skills.length === 0) {
console.log(chalk.yellow('No skills found.'));
console.log(chalk.dim('Try a different search term or check the spelling.'));
return;
}
const sortLabel = { downloads: 'downloads', stars: 'GitHub stars', rating: 'rating', recent: 'recently updated' }[sort] || sort;
console.log(chalk.bold(`Found ${result.pagination.total} skills (sorted by ${sortLabel}):\n`));
// Print results as a table
console.log(
chalk.dim('─'.repeat(80))
);
const startIndex = (page - 1) * limit;
for (let i = 0; i < result.skills.length; i++) {
const skill = result.skills[i];
const num = chalk.dim(`[${startIndex + i + 1}]`);
const verified = skill.isVerified ? chalk.green('✓') : ' ';
// Use securityStatus if available, otherwise fall back to score-based badge
const security = skill.securityStatus
? getSecurityStatusBadge(skill.securityStatus)
: getSecurityBadge(skill.securityScore);
// First line: number, ID, security
console.log(
`${num} ${verified} ${chalk.cyan(skill.id.padEnd(38))} ${security}`
);
// Second line: downloads, stars, description
console.log(
`${formatNumber(skill.downloadCount).padStart(6)}${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`
);
// Third line: Rating (only if ratingCount >= 3)
const showRating = (skill.ratingCount ?? 0) >= 3;
if (showRating && skill.rating) {
console.log(
` ${chalk.yellow('★')} ${skill.rating.toFixed(1)} ${chalk.dim(`(${skill.ratingCount} ratings)`)}`
);
}
console.log(chalk.dim('─'.repeat(80)));
}
console.log();
console.log(chalk.dim(`Install with: ${chalk.white('npx skillhub install <skill-id>')}`));
const totalPages = result.pagination.totalPages;
if (totalPages > 1) {
console.log(
chalk.dim(`Page ${page} of ${totalPages}. Use ${chalk.white(`--page ${page + 1}`)} for next page.`)
);
}
if (sort === 'downloads') {
console.log(chalk.dim(`Sort options: ${chalk.white('--sort stars|rating|recent')}`));
}
} catch (error) {
spinner.fail('Search failed');
const err = error as Error;
console.error(chalk.red(err.message || 'Unknown error'));
if (process.env.DEBUG) {
console.error(chalk.dim('Stack:'), err.stack);
}
process.exit(1);
}
}
function formatNumber(num: number): string {
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
}
/**
* Get security badge from securityStatus (new system)
*/
function getSecurityStatusBadge(status: string): string {
switch (status) {
case 'pass':
return chalk.green('🛡️ Pass');
case 'warning':
return chalk.yellow('⚠️ Warn');
case 'fail':
return chalk.red('❌ Fail');
default:
return chalk.dim('- N/A');
}
}
/**
* Get security badge from securityScore (legacy system)
*/
function getSecurityBadge(score: number): string {
if (score >= 90) return chalk.green('●●●●●');
if (score >= 70) return chalk.yellow('●●●●○');
if (score >= 50) return chalk.yellow('●●●○○');
if (score >= 30) return chalk.red('●●○○○');
return chalk.red('●○○○○');
}

239
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { createRequire } from 'module';
import { install } from './commands/install.js';
import { search } from './commands/search.js';
import { list } from './commands/list.js';
import { config } from './commands/config.js';
import { loadConfig, type Platform } from './utils/paths.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
const VERSION = pkg.version;
const program = new Command();
program
.name('skillhub')
.description('CLI for managing AI Agent skills')
.version(VERSION);
// Install command
program
.command('install <skill-id>')
.description('Install a skill from the registry')
.option('-p, --platform <platform>', 'Target platform (claude, codex, copilot, cursor, windsurf)')
.option('--project', 'Install in the current project instead of globally')
.option('-f, --force', 'Overwrite existing skill')
.option('--no-api', 'Skip API lookup and fetch directly from GitHub')
.action(async (skillId: string, options) => {
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
await install(skillId, {
platform,
project: options.project,
force: options.force,
noApi: !options.api, // Commander converts --no-api to api: false
});
});
// Search command
program
.command('search <query>')
.description('Search for skills in the registry')
.option('-p, --platform <platform>', 'Filter by platform')
.option('-s, --sort <sort>', 'Sort by: downloads, stars, rating, recent', 'downloads')
.option('-l, --limit <number>', 'Number of results', '10')
.option('--page <number>', 'Page number', '1')
.action(async (query: string, options) => {
await search(query, options);
});
// List command
program
.command('list')
.description('List installed skills')
.option('-p, --platform <platform>', 'Filter by platform')
.option('--project', 'List skills in the current project')
.option('--all', 'List both global and project skills')
.action(async (options) => {
await list(options);
});
// Config command
program
.command('config')
.description('Manage CLI configuration')
.option('--set <key=value>', 'Set a configuration value')
.option('--get <key>', 'Get a configuration value')
.option('--list', 'List all configuration values')
.action(async (options) => {
await config(options);
});
// Uninstall command
program
.command('uninstall <skill-name>')
.description('Uninstall a skill')
.option('-p, --platform <platform>', 'Target platform')
.option('--project', 'Uninstall from project instead of globally')
.action(async (skillName: string, options) => {
const fs = await import('fs-extra');
const pathModule = await import('path');
const { getSkillPath, isSkillInstalled } = await import('./utils/paths.js');
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
const installed = await isSkillInstalled(platform, skillName, options.project);
if (!installed) {
console.log(chalk.yellow(`Skill ${skillName} is not installed.`));
process.exit(1);
}
const skillPath = getSkillPath(platform, skillName, options.project);
// Clean up flat platform file if present
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
if (await fs.default.pathExists(metadataPath)) {
try {
const metadata = await fs.default.readJson(metadataPath);
if (metadata.platformFilePath) {
await fs.default.remove(metadata.platformFilePath);
}
} catch {
// Ignore metadata read errors
}
}
await fs.default.remove(skillPath);
console.log(chalk.green(`Skill ${skillName} uninstalled successfully.`));
});
// Update command
program
.command('update [skill-name]')
.description('Update installed skills')
.option('-p, --platform <platform>', 'Target platform')
.option('--all', 'Update all installed skills')
.action(async (skillName: string | undefined, options) => {
const fsExtra = await import('fs-extra');
const pathModule = await import('path');
const { getSkillsPath, getSkillPath } = await import('./utils/paths.js');
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
if (options.all) {
console.log(chalk.cyan('\nUpdating all installed skills...\n'));
const platforms = options.platform ? [platform] : ALL_PLATFORMS;
let updated = 0;
let failed = 0;
let skipped = 0;
for (const p of platforms) {
const skillsPath = getSkillsPath(p);
if (!(await fsExtra.default.pathExists(skillsPath))) {
continue;
}
const entries = await fsExtra.default.readdir(skillsPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = pathModule.join(skillsPath, entry.name);
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
// Check if skill has metadata with skillId
if (!(await fsExtra.default.pathExists(metadataPath))) {
console.log(chalk.yellow(` Skipping ${entry.name}: No metadata (reinstall with CLI to enable updates)`));
skipped++;
continue;
}
try {
const metadata = await fsExtra.default.readJson(metadataPath);
const skillId = metadata.skillId;
if (!skillId) {
console.log(chalk.yellow(` Skipping ${entry.name}: No skill ID in metadata`));
skipped++;
continue;
}
console.log(chalk.dim(` Updating ${entry.name}...`));
// Re-install with force
await install(skillId, {
platform: p,
force: true,
});
updated++;
} catch (error) {
console.log(chalk.red(` Failed to update ${entry.name}: ${(error as Error).message}`));
failed++;
}
}
}
console.log();
console.log(chalk.green(`Updated: ${updated}`));
if (failed > 0) console.log(chalk.red(`Failed: ${failed}`));
if (skipped > 0) console.log(chalk.yellow(`Skipped: ${skipped}`));
return;
}
if (!skillName) {
console.log(chalk.red('Please specify a skill name or use --all.'));
process.exit(1);
}
// Update single skill by name
const skillPath = getSkillPath(platform, skillName);
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
if (!(await fsExtra.default.pathExists(metadataPath))) {
console.log(chalk.yellow(`Skill ${skillName} was not installed via CLI.`));
console.log(chalk.dim('To update, reinstall with: npx skillhub install <skill-id> --force'));
process.exit(1);
}
try {
const metadata = await fsExtra.default.readJson(metadataPath);
const skillId = metadata.skillId;
if (!skillId) {
console.log(chalk.red('No skill ID found in metadata.'));
process.exit(1);
}
console.log(chalk.dim(`Updating ${skillName}...`));
await install(skillId, {
platform,
force: true,
});
} catch (error) {
console.log(chalk.red(`Failed to update: ${(error as Error).message}`));
process.exit(1);
}
});
// Parse arguments
program.parse();
// Show help if no command
if (!process.argv.slice(2).length) {
program.help();
}

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PassThrough } from 'stream';
// Use vi.hoisted to make mock state available to hoisted vi.mock calls
const mockState = vi.hoisted(() => ({
responseData: {
skills: [{ id: 'test-skill', name: 'Test Skill' }],
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
} as unknown,
statusCode: 200,
shouldError: false,
}));
// Mock https module
vi.mock('https', () => {
return {
default: {
request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => {
const req = new PassThrough();
if (mockState.shouldError) {
Object.assign(req, {
on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => {
if (event === 'error') {
setImmediate(() => handler(new Error('Network error')));
}
return req;
}),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}
const res = new PassThrough();
Object.assign(res, {
statusCode: mockState.statusCode,
setTimeout: vi.fn(),
});
setImmediate(() => {
callback(res);
res.push(JSON.stringify(mockState.responseData));
res.push(null);
});
Object.assign(req, {
on: vi.fn().mockReturnThis(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}),
},
request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => {
const req = new PassThrough();
if (mockState.shouldError) {
Object.assign(req, {
on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => {
if (event === 'error') {
setImmediate(() => handler(new Error('Network error')));
}
return req;
}),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}
const res = new PassThrough();
Object.assign(res, {
statusCode: mockState.statusCode,
setTimeout: vi.fn(),
});
setImmediate(() => {
callback(res);
res.push(JSON.stringify(mockState.responseData));
res.push(null);
});
Object.assign(req, {
on: vi.fn().mockReturnThis(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}),
};
});
// Mock http module
vi.mock('http', () => ({
default: { request: vi.fn() },
request: vi.fn(),
}));
// Import after mocking
import { searchSkills, getSkill, trackInstall } from './api.js';
describe('API Utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset to default
mockState.responseData = {
skills: [{ id: 'test-skill', name: 'Test Skill' }],
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
};
mockState.statusCode = 200;
mockState.shouldError = false;
});
describe('searchSkills', () => {
it('should search skills with query', async () => {
const result = await searchSkills('test');
expect(result.skills).toBeDefined();
expect(result.pagination).toBeDefined();
});
it('should return skills array', async () => {
const result = await searchSkills('test');
expect(Array.isArray(result.skills)).toBe(true);
});
it('should return pagination info', async () => {
const result = await searchSkills('test');
expect(result.pagination.page).toBeDefined();
expect(result.pagination.limit).toBeDefined();
});
});
describe('getSkill', () => {
it('should return skill data when found', async () => {
mockState.responseData = { id: 'test/repo/skill', name: 'Test Skill' };
const result = await getSkill('test/repo/skill');
expect(result).toBeDefined();
expect(result?.id).toBe('test/repo/skill');
});
it('should return null for 404', async () => {
mockState.statusCode = 404;
mockState.responseData = { error: 'Not found' };
const result = await getSkill('nonexistent');
expect(result).toBeNull();
});
});
describe('trackInstall', () => {
it('should not throw on success', async () => {
mockState.responseData = { success: true };
// Should not throw
await expect(trackInstall('test/skill', 'claude', 'cli')).resolves.not.toThrow();
});
it('should not throw on failure', async () => {
mockState.shouldError = true;
// Should not throw - tracking failures are silently ignored
await expect(trackInstall('test/skill', 'claude')).resolves.not.toThrow();
});
});
});

260
apps/cli/src/utils/api.ts Normal file
View File

@@ -0,0 +1,260 @@
import https from 'https';
import http from 'http';
const API_BASE_URL = process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live/api';
const API_TIMEOUT = parseInt(process.env.SKILLHUB_API_TIMEOUT || '20000'); // 20 seconds default
const API_FILES_TIMEOUT = parseInt(process.env.SKILLHUB_API_FILES_TIMEOUT || '45000'); // 45 seconds for file fetching (server may need to fetch from GitHub on cache miss)
interface HttpResponse {
statusCode: number;
data: string;
}
/**
* Make an HTTPS request using native Node.js module
* Handles Cloudflare chunked encoding issues by detecting complete JSON responses
*/
function httpsRequest(
url: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeout?: number;
} = {}
): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const isHttps = parsedUrl.protocol === 'https:';
const lib = isHttps ? https : http;
const requestTimeout = options.timeout || API_TIMEOUT;
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (isHttps ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: options.method || 'GET',
headers: {
'User-Agent': 'skillhub-cli',
'Accept': 'application/json',
...options.headers,
},
timeout: requestTimeout,
};
const req = lib.request(reqOptions, (res) => {
const chunks: Buffer[] = [];
let resolved = false;
const tryResolve = () => {
if (resolved) return;
const data = Buffer.concat(chunks).toString();
// For JSON responses, detect complete objects
// This works around Cloudflare chunked encoding issues
if (data.startsWith('{') || data.startsWith('[')) {
try {
JSON.parse(data); // Validate complete JSON
resolved = true;
res.destroy(); // Force close connection
resolve({
statusCode: res.statusCode || 0,
data,
});
} catch {
// JSON not complete yet, continue waiting
}
}
};
res.on('data', (chunk) => {
chunks.push(chunk);
tryResolve();
});
res.on('end', () => {
if (!resolved) {
resolve({
statusCode: res.statusCode || 0,
data: Buffer.concat(chunks).toString(),
});
}
});
// Set a per-request timeout for receiving data
res.setTimeout(requestTimeout, () => {
if (!resolved) {
res.destroy();
reject(new Error(`Response timeout after ${requestTimeout / 1000}s`));
}
});
});
req.on('error', (err) => {
// Ignore stream destroyed errors (we caused them intentionally)
if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED') return;
reject(new Error(`Network error: ${err.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timeout after ${requestTimeout / 1000}s`));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
export interface SkillInfo {
id: string;
name: string;
description: string;
githubOwner: string;
githubRepo: string;
skillPath: string;
branch: string;
version?: string;
license?: string;
githubStars: number;
downloadCount: number;
securityScore: number;
securityStatus?: 'pass' | 'warning' | 'fail' | null;
sourceFormat?: string;
rating?: number | null;
ratingCount?: number | null;
isVerified: boolean;
compatibility: {
platforms: string[];
};
}
export interface SearchResult {
skills: SkillInfo[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
/**
* Search for skills
*/
export async function searchSkills(
query: string,
options: {
platform?: string;
limit?: number;
page?: number;
sort?: string;
} = {}
): Promise<SearchResult> {
const params = new URLSearchParams({
q: query,
limit: String(options.limit || 10),
page: String(options.page || 1),
sort: options.sort || 'downloads',
});
if (options.platform) {
params.set('platform', options.platform);
}
const response = await httpsRequest(`${API_BASE_URL}/skills?${params}`);
if (response.statusCode !== 200) {
throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`);
}
return JSON.parse(response.data);
}
/**
* Get skill details
*/
export async function getSkill(id: string): Promise<SkillInfo | null> {
// Encode each segment separately to preserve slashes in URL path
const encodedPath = id.split('/').map(encodeURIComponent).join('/');
const response = await httpsRequest(`${API_BASE_URL}/skills/${encodedPath}`);
if (response.statusCode === 404) {
return null;
}
if (response.statusCode !== 200) {
throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`);
}
return JSON.parse(response.data);
}
/**
* Track an installation
*/
export async function trackInstall(
skillId: string,
platform: string,
method = 'cli'
): Promise<void> {
try {
await httpsRequest(`${API_BASE_URL}/skills/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillId, platform, method }),
});
} catch {
// Silently fail - tracking is not critical
}
}
export interface SkillFile {
name: string;
path: string;
type: 'file';
size: number;
content?: string; // Text file content
downloadUrl?: string; // For binary files
}
export interface SkillFilesResponse {
skillId: string;
githubOwner: string;
githubRepo: string;
skillPath: string;
branch: string;
sourceFormat?: string;
files: SkillFile[];
fromCache: boolean;
cachedAt?: string;
}
/**
* Get skill files from API (uses server-side cache)
* Returns null if API unavailable or skill not found
*/
export async function getSkillFiles(id: string): Promise<SkillFilesResponse | null> {
try {
const response = await httpsRequest(
`${API_BASE_URL}/skill-files?id=${encodeURIComponent(id)}`,
{ timeout: API_FILES_TIMEOUT }
);
if (response.statusCode === 404) {
return null;
}
if (response.statusCode !== 200) {
return null;
}
return JSON.parse(response.data);
} catch {
// API unavailable, return null to fall back to GitHub
return null;
}
}

View File

@@ -0,0 +1,251 @@
import { Octokit } from '@octokit/rest';
import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
import https from 'https';
let octokit: Octokit | null = null;
function getOctokit(): Octokit {
if (!octokit) {
octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
userAgent: 'SkillHub-CLI/1.0',
request: {
timeout: 30000, // 30 second timeout
},
});
}
return octokit;
}
export interface SkillContent {
skillMd: string;
scripts: Array<{ name: string; content: string }>;
references: Array<{ name: string; content: string }>;
assets: Array<{ name: string; content: string }>; // base64 encoded
}
/**
* Fetch file from raw.githubusercontent.com (fallback for network issues)
*/
async function fetchRawFile(
owner: string,
repo: string,
path: string,
branch: string
): Promise<string> {
return new Promise((resolve, reject) => {
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
https.get(url, { timeout: 10000 }, (res) => {
if (res.statusCode === 404) {
reject(new Error('File not found'));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', (err) => {
reject(err);
}).on('timeout', () => {
reject(new Error('Request timeout'));
});
});
}
/**
* Get the filename to look for based on sourceFormat
*/
function getInstructionFilename(sourceFormat: SourceFormat): string {
const pattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
return pattern?.filename || 'SKILL.md';
}
/**
* Fetch skill content from GitHub
*/
export async function fetchSkillContent(
owner: string,
repo: string,
skillPath: string,
branch = 'main',
sourceFormat: SourceFormat = 'skill.md'
): Promise<SkillContent> {
const client = getOctokit();
const filename = getInstructionFilename(sourceFormat);
const isStandalone = sourceFormat !== 'skill.md';
// Build paths to try for the instruction file
const basePath = skillPath ? `${skillPath}/${filename}` : filename;
let skillMdResponse;
// For standalone formats (.cursorrules, .windsurfrules), they're at specific locations
let pathsToTry: string[];
if (sourceFormat === 'cursorrules' || sourceFormat === 'windsurfrules') {
// Root-only files
pathsToTry = [filename];
} else if (sourceFormat === 'copilot-instructions') {
// Must be in .github/
pathsToTry = [`.github/${filename}`];
} else if (sourceFormat === 'agents.md') {
// Can be in root or subdirectories
pathsToTry = [basePath];
if (skillPath) {
pathsToTry.push(filename); // Also try root
}
} else {
// SKILL.md - try multiple common paths
pathsToTry = [
basePath,
...(skillPath && !skillPath.startsWith('skills/') ? [`skills/${skillPath}/SKILL.md`] : []),
...(skillPath && !skillPath.startsWith('.claude/') ? [`.claude/skills/${skillPath}/SKILL.md`] : []),
...(skillPath && !skillPath.startsWith('.github/') ? [`.github/skills/${skillPath}/SKILL.md`] : []),
];
}
for (const pathToTry of pathsToTry) {
try {
skillMdResponse = await client.repos.getContent({
owner,
repo,
path: pathToTry,
ref: branch,
});
// Success! Break out of loop
break;
} catch (error: any) {
// If it's a timeout or network error, try raw.githubusercontent.com fallback
if (error.message?.includes('timeout') || error.message?.includes('network')) {
try {
const rawContent = await fetchRawFile(owner, repo, pathToTry, branch);
// Create a mock response compatible with Octokit
skillMdResponse = {
data: {
content: Buffer.from(rawContent).toString('base64'),
encoding: 'base64' as const,
}
} as any;
break;
} catch (rawError) {
// If raw fetch also fails, throw original error
throw new Error(`GitHub API timeout. Try using --no-api flag or check your network connection.`);
}
}
// If 404, try next path
if (error.status === 404) {
continue;
}
// Other errors, throw immediately
throw new Error(`Failed to fetch from GitHub: ${error.message}`);
}
}
if (!skillMdResponse) {
throw new Error(`${filename} not found at ${owner}/${repo} (tried ${pathsToTry.length} paths)`);
}
if (!('content' in skillMdResponse.data)) {
throw new Error(`${filename} not found`);
}
const skillMd = Buffer.from(skillMdResponse.data.content, 'base64').toString('utf-8');
// For standalone formats, skip scripts/references (they don't have subdirectories)
if (isStandalone) {
return {
skillMd,
scripts: [],
references: [],
assets: [],
};
}
// Fetch scripts
const scripts: SkillContent['scripts'] = [];
try {
const scriptsPath = skillPath ? `${skillPath}/scripts` : 'scripts';
const scriptsResponse = await client.repos.getContent({
owner,
repo,
path: scriptsPath,
ref: branch,
});
if (Array.isArray(scriptsResponse.data)) {
for (const file of scriptsResponse.data) {
if (file.type === 'file') {
const fileResponse = await client.repos.getContent({
owner,
repo,
path: file.path,
ref: branch,
});
if ('content' in fileResponse.data) {
scripts.push({
name: file.name,
content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'),
});
}
}
}
}
} catch {
// No scripts directory
}
// Fetch references
const references: SkillContent['references'] = [];
try {
const refsPath = skillPath ? `${skillPath}/references` : 'references';
const refsResponse = await client.repos.getContent({
owner,
repo,
path: refsPath,
ref: branch,
});
if (Array.isArray(refsResponse.data)) {
for (const file of refsResponse.data) {
if (file.type === 'file' && file.size && file.size < 100000) {
const fileResponse = await client.repos.getContent({
owner,
repo,
path: file.path,
ref: branch,
});
if ('content' in fileResponse.data) {
references.push({
name: file.name,
content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'),
});
}
}
}
}
} catch {
// No references directory
}
return {
skillMd,
scripts,
references,
assets: [], // TODO: handle binary assets
};
}
/**
* Get default branch for a repository
*/
export async function getDefaultBranch(owner: string, repo: string): Promise<string> {
const client = getOctokit();
const response = await client.repos.get({ owner, repo });
return response.data.default_branch;
}

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock fs-extra before importing paths
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
ensureDir: vi.fn(),
readJson: vi.fn(),
writeJson: vi.fn(),
},
}));
import fs from 'fs-extra';
import {
getSkillsPath,
getSkillPath,
ensureSkillsDir,
isSkillInstalled,
getConfigPath,
loadConfig,
saveConfig,
isFlatFilePlatform,
getPlatformFilePath,
} from './paths.js';
describe('Path Utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSkillsPath', () => {
it('should return path ending with .claude/skills for claude', () => {
const result = getSkillsPath('claude');
expect(result).toContain('.claude');
expect(result).toContain('skills');
});
it('should return path ending with .codex/skills for codex', () => {
const result = getSkillsPath('codex');
expect(result).toContain('.codex');
expect(result).toContain('skills');
});
it('should return path ending with .github/instructions for copilot', () => {
const result = getSkillsPath('copilot');
expect(result).toContain('.github');
expect(result).toContain('instructions');
});
it('should return path ending with .cursor/rules for cursor', () => {
const result = getSkillsPath('cursor');
expect(result).toContain('.cursor');
expect(result).toContain('rules');
});
it('should return path ending with .windsurf/rules for windsurf', () => {
const result = getSkillsPath('windsurf');
expect(result).toContain('.windsurf');
expect(result).toContain('rules');
});
it('should return different path for user vs project', () => {
const userPath = getSkillsPath('claude', false);
const projectPath = getSkillsPath('claude', true);
expect(userPath).not.toBe(projectPath);
});
});
describe('getSkillPath', () => {
it('should append skill name to base path', () => {
const result = getSkillPath('claude', 'my-skill');
expect(result).toContain('my-skill');
expect(result).toContain('.claude');
});
it('should handle project path', () => {
const result = getSkillPath('codex', 'my-skill', true);
expect(result).toContain('my-skill');
expect(result).toContain('.codex');
});
});
describe('ensureSkillsDir', () => {
it('should call fs.ensureDir', async () => {
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
const result = await ensureSkillsDir('claude');
expect(fs.ensureDir).toHaveBeenCalled();
expect(result).toContain('.claude');
});
});
describe('isSkillInstalled', () => {
it('should return true when skill exists', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never);
const result = await isSkillInstalled('claude', 'my-skill');
expect(result).toBe(true);
expect(fs.pathExists).toHaveBeenCalled();
});
it('should return false when skill does not exist', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(false as never);
const result = await isSkillInstalled('claude', 'my-skill');
expect(result).toBe(false);
});
});
describe('getConfigPath', () => {
it('should return path ending with .skillhub/config.json', () => {
const result = getConfigPath();
expect(result).toContain('.skillhub');
expect(result).toContain('config.json');
});
});
describe('loadConfig', () => {
it('should load config from file when exists', async () => {
const mockConfig = { defaultPlatform: 'claude' };
vi.mocked(fs.pathExists).mockResolvedValue(true as never);
vi.mocked(fs.readJson).mockResolvedValue(mockConfig as never);
const result = await loadConfig();
expect(result).toEqual(mockConfig);
expect(fs.readJson).toHaveBeenCalled();
});
it('should return empty object when config does not exist', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(false as never);
const result = await loadConfig();
expect(result).toEqual({});
expect(fs.readJson).not.toHaveBeenCalled();
});
});
describe('saveConfig', () => {
it('should save config to file', async () => {
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
vi.mocked(fs.writeJson).mockResolvedValue(undefined);
await saveConfig({ defaultPlatform: 'claude' });
expect(fs.ensureDir).toHaveBeenCalled();
expect(fs.writeJson).toHaveBeenCalledWith(
expect.stringContaining('config.json'),
{ defaultPlatform: 'claude' },
{ spaces: 2 }
);
});
});
describe('isFlatFilePlatform', () => {
it('returns false for claude', () => {
expect(isFlatFilePlatform('claude')).toBe(false);
});
it('returns false for codex', () => {
expect(isFlatFilePlatform('codex')).toBe(false);
});
it('returns true for cursor', () => {
expect(isFlatFilePlatform('cursor')).toBe(true);
});
it('returns true for windsurf', () => {
expect(isFlatFilePlatform('windsurf')).toBe(true);
});
it('returns true for copilot', () => {
expect(isFlatFilePlatform('copilot')).toBe(true);
});
});
describe('getPlatformFilePath', () => {
it('returns file inside skill subdirectory for claude', () => {
const result = getPlatformFilePath('claude', 'my-skill', 'SKILL.md');
expect(result).toContain('my-skill');
expect(result).toContain('SKILL.md');
});
it('returns file in flat directory for cursor', () => {
const result = getPlatformFilePath('cursor', 'my-skill', 'my-skill.mdc');
expect(result).toContain('rules');
expect(result).toContain('my-skill.mdc');
// Should not have skill name as a directory segment
expect(result).not.toMatch(/rules[/\\]my-skill[/\\]my-skill\.mdc/);
});
it('returns file in flat directory for windsurf', () => {
const result = getPlatformFilePath('windsurf', 'my-skill', 'my-skill.md');
expect(result).toContain('rules');
expect(result).toContain('my-skill.md');
});
it('returns file in flat directory for copilot', () => {
const result = getPlatformFilePath('copilot', 'my-skill', 'my-skill.instructions.md');
expect(result).toContain('instructions');
expect(result).toContain('my-skill.instructions.md');
});
});
});

152
apps/cli/src/utils/paths.ts Normal file
View File

@@ -0,0 +1,152 @@
import * as os from 'os';
import * as path from 'path';
import fs from 'fs-extra';
export type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
const PLATFORM_PATHS: Record<Platform, { user: string; project: string }> = {
claude: {
user: '.claude/skills',
project: '.claude/skills',
},
codex: {
user: '.codex/skills',
project: '.codex/skills',
},
copilot: {
user: '.github/instructions',
project: '.github/instructions',
},
cursor: {
user: '.cursor/rules',
project: '.cursor/rules',
},
windsurf: {
user: '.windsurf/rules',
project: '.windsurf/rules',
},
};
const FLAT_FILE_PLATFORMS: Platform[] = ['cursor', 'windsurf', 'copilot'];
/**
* Whether this platform uses a flat file structure
* (skill file in base dir, tracking data in subdirectory)
*/
export function isFlatFilePlatform(platform: Platform): boolean {
return FLAT_FILE_PLATFORMS.includes(platform);
}
/**
* Get the path where the platform-specific skill file should be written.
* For flat-file platforms, this is the base directory + fileName.
* For subdirectory platforms, this is the skill's subdirectory + fileName.
*/
export function getPlatformFilePath(
platform: Platform,
skillName: string,
fileName: string,
project = false
): string {
const basePath = getSkillsPath(platform, project);
if (isFlatFilePlatform(platform)) {
return path.join(basePath, fileName);
}
return path.join(basePath, skillName, fileName);
}
/**
* Get the skills directory path for a platform
*/
export function getSkillsPath(platform: Platform, project = false): string {
const home = os.homedir();
const cwd = process.cwd();
const paths = PLATFORM_PATHS[platform];
if (project) {
return path.join(cwd, paths.project);
}
return path.join(home, paths.user);
}
/**
* Get the path for a specific skill
*/
export function getSkillPath(platform: Platform, skillName: string, project = false): string {
const basePath = getSkillsPath(platform, project);
return path.join(basePath, skillName);
}
/**
* Ensure the skills directory exists
*/
export async function ensureSkillsDir(platform: Platform, project = false): Promise<string> {
const skillsPath = getSkillsPath(platform, project);
await fs.ensureDir(skillsPath);
return skillsPath;
}
/**
* Check if a skill is already installed
*/
export async function isSkillInstalled(
platform: Platform,
skillName: string,
project = false
): Promise<boolean> {
const skillPath = getSkillPath(platform, skillName, project);
return fs.pathExists(skillPath);
}
/**
* Detect which platform is being used in the current directory
*/
export async function detectPlatform(): Promise<Platform | null> {
const cwd = process.cwd();
for (const platform of Object.keys(PLATFORM_PATHS) as Platform[]) {
const configPath = path.join(cwd, `.${platform}`);
if (await fs.pathExists(configPath)) {
return platform;
}
}
// Check for common config files
if (await fs.pathExists(path.join(cwd, '.github'))) {
return 'copilot';
}
return null;
}
/**
* Get config file path
*/
export function getConfigPath(): string {
const home = os.homedir();
return path.join(home, '.skillhub', 'config.json');
}
/**
* Load CLI config
*/
export async function loadConfig(): Promise<Record<string, unknown>> {
const configPath = getConfigPath();
if (await fs.pathExists(configPath)) {
return fs.readJson(configPath);
}
return {};
}
/**
* Save CLI config
*/
export async function saveConfig(config: Record<string, unknown>): Promise<void> {
const configPath = getConfigPath();
await fs.ensureDir(path.dirname(configPath));
await fs.writeJson(configPath, config, { spaces: 2 });
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
ensureDir: vi.fn(),
readJson: vi.fn(),
writeJson: vi.fn(),
},
}));
import { getSkillsPath, type Platform } from './paths.js';
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
describe('Platform Configuration Completeness', () => {
describe('All platforms have path configuration', () => {
ALL_PLATFORMS.forEach((platform) => {
it(`should have user path for ${platform}`, () => {
const userPath = getSkillsPath(platform, false);
expect(userPath).toBeTruthy();
});
it(`should have project path for ${platform}`, () => {
const projectPath = getSkillsPath(platform, true);
expect(projectPath).toBeTruthy();
});
});
});
describe('Platform paths are unique', () => {
it('should have different user paths for each platform', () => {
const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, false));
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(ALL_PLATFORMS.length);
});
it('should have different project paths for each platform', () => {
const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, true));
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(ALL_PLATFORMS.length);
});
});
describe('Platform paths match expected patterns', () => {
it('claude should use .claude/skills', () => {
expect(getSkillsPath('claude', false)).toContain('.claude');
});
it('codex should use .codex/skills', () => {
expect(getSkillsPath('codex', false)).toContain('.codex');
});
it('copilot should use .github/instructions', () => {
const p = getSkillsPath('copilot', false);
expect(p).toContain('.github');
expect(p).toContain('instructions');
});
it('cursor should use .cursor/rules', () => {
const p = getSkillsPath('cursor', false);
expect(p).toContain('.cursor');
expect(p).toContain('rules');
});
it('windsurf should use .windsurf/rules', () => {
const p = getSkillsPath('windsurf', false);
expect(p).toContain('.windsurf');
expect(p).toContain('rules');
});
});
});

View File

@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from './transform.js';
import type { ParsedSkill } from 'skillhub-core';
function makeParsed(overrides: Partial<ParsedSkill['metadata']> = {}, body = '# Test Skill\n\nThis is a test skill.\n\n## Usage\n\nUse this skill for testing.'): ParsedSkill {
return {
metadata: {
name: 'test-skill',
description: 'A comprehensive test skill',
version: '1.0.0',
...overrides,
},
content: body,
resources: { scripts: [], references: [], assets: [] },
validation: { isValid: true, errors: [], warnings: [] },
rawFrontmatter: { name: 'test-skill', description: 'A comprehensive test skill', version: '1.0.0', ...overrides },
} as ParsedSkill;
}
const RAW_SKILL_MD = `---
name: test-skill
description: A comprehensive test skill
version: 1.0.0
---
# Test Skill
This is a test skill.
## Usage
Use this skill for testing.
`;
describe('getPlatformFileName', () => {
it('returns SKILL.md for claude', () => {
expect(getPlatformFileName('claude', 'my-skill')).toBe('SKILL.md');
});
it('returns SKILL.md for codex', () => {
expect(getPlatformFileName('codex', 'my-skill')).toBe('SKILL.md');
});
it('returns .mdc for cursor', () => {
expect(getPlatformFileName('cursor', 'my-skill')).toBe('my-skill.mdc');
});
it('returns .md for windsurf', () => {
expect(getPlatformFileName('windsurf', 'my-skill')).toBe('my-skill.md');
});
it('returns .instructions.md for copilot', () => {
expect(getPlatformFileName('copilot', 'my-skill')).toBe('my-skill.instructions.md');
});
});
describe('shouldKeepOriginal', () => {
it('returns false for claude', () => {
expect(shouldKeepOriginal('claude')).toBe(false);
});
it('returns false for codex', () => {
expect(shouldKeepOriginal('codex')).toBe(false);
});
it('returns true for cursor', () => {
expect(shouldKeepOriginal('cursor')).toBe(true);
});
it('returns true for windsurf', () => {
expect(shouldKeepOriginal('windsurf')).toBe(true);
});
it('returns true for copilot', () => {
expect(shouldKeepOriginal('copilot')).toBe(true);
});
});
describe('transformForPlatform', () => {
describe('claude', () => {
it('returns content unchanged', () => {
const parsed = makeParsed();
const result = transformForPlatform('claude', RAW_SKILL_MD, parsed);
expect(result.content).toBe(RAW_SKILL_MD);
expect(result.warnings).toHaveLength(0);
});
});
describe('codex', () => {
it('returns content unchanged', () => {
const parsed = makeParsed();
const result = transformForPlatform('codex', RAW_SKILL_MD, parsed);
expect(result.content).toBe(RAW_SKILL_MD);
expect(result.warnings).toHaveLength(0);
});
});
describe('cursor (MDC format)', () => {
it('produces MDC with description and alwaysApply', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('---');
expect(result.content).toContain('description: A comprehensive test skill');
expect(result.content).toContain('alwaysApply: true');
expect(result.warnings).toHaveLength(0);
});
it('maps triggers.filePatterns to globs', () => {
const parsed = makeParsed({
triggers: { filePatterns: ['*.tsx', '*.jsx'] },
});
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('globs: *.tsx, *.jsx');
expect(result.content).toContain('alwaysApply: false');
});
it('sets alwaysApply true when no triggers', () => {
const parsed = makeParsed({ triggers: undefined });
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('alwaysApply: true');
expect(result.content).not.toContain('globs:');
});
it('strips original YAML frontmatter fields', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('version: 1.0.0');
expect(result.content).not.toContain('name: test-skill');
});
it('preserves markdown body', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('# Test Skill');
expect(result.content).toContain('## Usage');
});
});
describe('windsurf', () => {
it('strips YAML frontmatter', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('---');
expect(result.content).not.toContain('version:');
expect(result.content).toContain('# Test Skill');
});
it('adds heading when body does not start with one', () => {
const parsed = makeParsed({}, 'Some content without heading.');
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).toMatch(/^# test-skill\n/);
});
it('does not add heading when body starts with one', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).not.toMatch(/^# test-skill\n/);
expect(result.content).toMatch(/^# Test Skill\n/);
});
it('truncates and warns when content exceeds 6K limit', () => {
const longBody = '# Long Skill\n\n' + 'Lorem ipsum dolor sit amet. '.repeat(300);
const parsed = makeParsed({}, longBody);
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('6000');
expect(result.content.length).toBeLessThanOrEqual(6000);
});
it('no warning when content is under limit', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.warnings).toHaveLength(0);
});
});
describe('copilot', () => {
it('strips YAML frontmatter', () => {
const parsed = makeParsed();
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('---');
expect(result.content).toContain('# Test Skill');
});
it('adds heading when body does not start with one', () => {
const parsed = makeParsed({}, 'Some content without heading.');
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.content).toMatch(/^# test-skill\n/);
});
it('returns no warnings', () => {
const parsed = makeParsed();
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.warnings).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,150 @@
import type { Platform } from './paths.js';
import type { ParsedSkill } from 'skillhub-core';
interface TransformResult {
content: string;
warnings: string[];
}
interface PlatformFileConfig {
getFileName: (skillName: string) => string;
keepOriginal: boolean;
transform: (rawSkillMd: string, parsed: ParsedSkill) => TransformResult;
}
const WINDSURF_CHAR_LIMIT = 6000;
const PLATFORM_FILE_CONFIGS: Record<Platform, PlatformFileConfig> = {
claude: {
getFileName: () => 'SKILL.md',
keepOriginal: false,
transform: (raw) => ({ content: raw, warnings: [] }),
},
codex: {
getFileName: () => 'SKILL.md',
keepOriginal: false,
transform: (raw) => ({ content: raw, warnings: [] }),
},
cursor: {
getFileName: (skillName) => `${skillName}.mdc`,
keepOriginal: true,
transform: transformForCursor,
},
windsurf: {
getFileName: (skillName) => `${skillName}.md`,
keepOriginal: true,
transform: transformForWindsurf,
},
copilot: {
getFileName: (skillName) => `${skillName}.instructions.md`,
keepOriginal: true,
transform: transformForCopilot,
},
};
/**
* Get the platform-specific output filename
*/
export function getPlatformFileName(platform: Platform, skillName: string): string {
return PLATFORM_FILE_CONFIGS[platform].getFileName(skillName);
}
/**
* Transform SKILL.md content for a target platform
*/
export function transformForPlatform(
platform: Platform,
rawSkillMd: string,
parsed: ParsedSkill
): TransformResult {
return PLATFORM_FILE_CONFIGS[platform].transform(rawSkillMd, parsed);
}
/**
* Whether the platform needs the original SKILL.md kept alongside
*/
export function shouldKeepOriginal(platform: Platform): boolean {
return PLATFORM_FILE_CONFIGS[platform].keepOriginal;
}
/**
* Convert SKILL.md to Cursor's MDC format
* MDC uses its own frontmatter: description, globs, alwaysApply
*/
function transformForCursor(_raw: string, parsed: ParsedSkill): TransformResult {
const warnings: string[] = [];
const mdcFields: string[] = [];
if (parsed.metadata.description) {
mdcFields.push(`description: ${parsed.metadata.description}`);
}
const filePatterns = parsed.metadata.triggers?.filePatterns;
if (filePatterns && filePatterns.length > 0) {
mdcFields.push(`globs: ${filePatterns.join(', ')}`);
mdcFields.push('alwaysApply: false');
} else {
mdcFields.push('alwaysApply: true');
}
const body = parsed.content.trim();
const mdcContent = `---\n${mdcFields.join('\n')}\n---\n${body}\n`;
return { content: mdcContent, warnings };
}
/**
* Convert SKILL.md to Windsurf format: plain markdown, 6K char limit
*/
function transformForWindsurf(_raw: string, parsed: ParsedSkill): TransformResult {
const warnings: string[] = [];
let body = parsed.content.trim();
if (!body.startsWith('# ')) {
body = `# ${parsed.metadata.name}\n\n${body}`;
}
if (body.length > WINDSURF_CHAR_LIMIT) {
warnings.push(
`Content exceeds Windsurf's ${WINDSURF_CHAR_LIMIT} character limit (${body.length} chars). Truncating.`
);
body = truncateAtSectionBoundary(body, WINDSURF_CHAR_LIMIT);
}
return { content: body + '\n', warnings };
}
/**
* Convert SKILL.md to Copilot format: plain markdown, no frontmatter
*/
function transformForCopilot(_raw: string, parsed: ParsedSkill): TransformResult {
let body = parsed.content.trim();
if (!body.startsWith('# ')) {
body = `# ${parsed.metadata.name}\n\n${body}`;
}
return { content: body + '\n', warnings: [] };
}
function truncateAtSectionBoundary(content: string, limit: number): string {
const notice = '\n\n<!-- Truncated by SkillHub: see SKILL.md for full content -->\n';
const maxLen = limit - notice.length;
if (content.length <= maxLen) return content;
const truncated = content.slice(0, maxLen);
const lastHeading = truncated.lastIndexOf('\n## ');
const lastH1 = truncated.lastIndexOf('\n# ');
const cutPoint = Math.max(lastHeading, lastH1);
if (cutPoint > 0) {
return truncated.slice(0, cutPoint) + notice;
}
const lastParagraph = truncated.lastIndexOf('\n\n');
if (lastParagraph > 0) {
return truncated.slice(0, lastParagraph) + notice;
}
return truncated + notice;
}

9
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

16
apps/cli/tsup.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'node20',
clean: true,
// Bundle skillhub-core into the CLI so the published package
// doesn't depend on a separate npm package that may be outdated
noExternal: ['skillhub-core'],
// gray-matter (dep of skillhub-core) uses CJS require('fs') which
// breaks in ESM bundles - provide a require shim for Node builtins
banner: {
js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
},
});

15
apps/cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
},
},
});

92
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,92 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN npm install -g pnpm@9
WORKDIR /app
# Copy workspace configuration and all package.json files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/core/package.json ./packages/core/
COPY packages/db/package.json ./packages/db/
COPY packages/ui/package.json ./packages/ui/
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder
RUN npm install -g pnpm@9
WORKDIR /app
# Copy all source code first
COPY . .
# Copy node_modules from deps stage (this overwrites the empty directories)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/core/node_modules ./packages/core/node_modules
COPY --from=deps /app/packages/db/node_modules ./packages/db/node_modules
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
# Set build-time environment variables
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build-time arguments for NEXT_PUBLIC_* variables
# These are embedded into the JavaScript bundle at build time
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_SENTRY_RELEASE
ARG NEXT_PUBLIC_OPENPANEL_CLIENT_ID
ARG NEXT_PUBLIC_OPENPANEL_API_URL
ARG NEXT_PUBLIC_OPENPANEL_SCRIPT_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_URL
# Convert ARGs to ENVs so they're available during build
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_RELEASE=$NEXT_PUBLIC_SENTRY_RELEASE
ENV NEXT_PUBLIC_OPENPANEL_CLIENT_ID=$NEXT_PUBLIC_OPENPANEL_CLIENT_ID
ENV NEXT_PUBLIC_OPENPANEL_API_URL=$NEXT_PUBLIC_OPENPANEL_API_URL
ENV NEXT_PUBLIC_OPENPANEL_SCRIPT_URL=$NEXT_PUBLIC_OPENPANEL_SCRIPT_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_URL=$NEXT_PUBLIC_UMAMI_URL
# Build the application (turbo will build dependencies first)
RUN pnpm turbo run build --filter=@skillhub/web
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application (monorepo standalone preserves directory structure)
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
# Copy messages folder for internationalization (next-intl requires this at runtime)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/messages ./apps/web/messages
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "apps/web/server.js"]

View File

@@ -0,0 +1,69 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Code2, Globe, Shield } from 'lucide-react';
export default async function AboutPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('about');
const features = [
{
icon: Code2,
title: t('features.openSource'),
description: t('features.openSourceDesc'),
},
{
icon: Globe,
title: t('features.multiPlatform'),
description: t('features.multiPlatformDesc'),
},
{
icon: Shield,
title: t('features.secure'),
description: t('features.secureDesc'),
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto text-center mb-12">
<h2 className="section-title mb-4">{t('mission.title')}</h2>
<p className="text-lg text-text-secondary">{t('mission.description')}</p>
</div>
<h2 className="section-title mb-8">{t('features.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.map((feature, index) => (
<div key={index} className="card p-6 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary-50 text-primary-600 mb-4">
<feature.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold mb-2">{feature.title}</h3>
<p className="text-text-secondary">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Github, Heart, Users, Code, GitFork, ExternalLink, Database, Clock } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface AttributionStats {
totalSkills: number;
totalContributors: number;
totalRepos: number;
awesomeLists: {
count: number;
totalRepos: number;
};
forkNetworks: number;
licenseDistribution: Array<{
license: string;
count: number;
percentage: number;
}>;
discoveryBySource: Array<{
source: string;
count: number;
withSkills: number;
}>;
lastUpdated: string;
}
async function getAttributionStats(): Promise<AttributionStats | null> {
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/attribution`, {
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
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.toLocaleString();
}
function formatLicenseName(license: string, locale: string): string {
const licenseNames: Record<string, { en: string; fa: string }> = {
'Unspecified': { en: 'Not Specified', fa: 'مشخص نشده' },
'NOASSERTION': { en: 'Not Declared', fa: 'اعلام نشده' },
'Complete terms in LICENSE.txt': { en: 'Custom License', fa: 'لایسنس سفارشی' },
'Proprietary. LICENSE.txt has complete terms': { en: 'Proprietary', fa: 'اختصاصی' },
'MIT license': { en: 'MIT', fa: 'MIT' },
'BSD-3-Clause license': { en: 'BSD-3-Clause', fa: 'BSD-3-Clause' },
'Unknown': { en: 'Unknown', fa: 'نامشخص' },
};
const names = licenseNames[license];
return names ? names[locale as 'en' | 'fa'] || names.en : license;
}
export default async function AttributionPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('attribution');
const stats = await getAttributionStats();
// Fallback data if API fails
const sources = [
{
name: locale === 'fa' ? 'مهارت‌های ایندکس شده' : 'Indexed Skills',
icon: Database,
description: locale === 'fa'
? 'مهارت‌های ایندکس شده در تمام پلتفرم‌ها'
: 'Skills indexed across all supported platforms',
count: stats ? formatNumber(stats.totalSkills) : '170K+',
},
{
name: locale === 'fa' ? 'مخازن کشف شده' : 'Repositories Discovered',
icon: Github,
description: locale === 'fa'
? 'مخازن عمومی GitHub اسکن شده برای مهارت‌ها'
: 'Public GitHub repositories scanned for skills',
count: stats ? formatNumber(stats.totalRepos) : '50K+',
},
{
name: locale === 'fa' ? 'شبکه Forkها' : 'Fork Networks',
icon: GitFork,
description: locale === 'fa'
? 'شبکه Forkهای مخازن معروف مهارت'
: 'Fork networks of popular skill repositories',
count: stats ? formatNumber(stats.forkNetworks) : '500+',
},
{
name: locale === 'fa' ? 'مشارکت‌کنندگان' : 'Contributors',
icon: Users,
description: locale === 'fa'
? 'توسعه‌دهندگانی که مهارت‌ها را ایجاد و نگهداری می‌کنند'
: 'Developers who create and maintain skills',
count: stats ? formatNumber(stats.totalContributors) : '6K+',
},
];
// Use real license data if available, otherwise fallback
const licenses = stats?.licenseDistribution.slice(0, 5).map((l) => ({
name: formatLicenseName(l.license, locale),
percentage: l.percentage,
count: l.count,
})) || [
{ name: 'MIT', percentage: 65, count: 0 },
{ name: 'Apache 2.0', percentage: 20, count: 0 },
{ name: 'BSD', percentage: 8, count: 0 },
{ name: 'GPL', percentage: 5, count: 0 },
{ name: locale === 'fa' ? 'سایر' : 'Other', percentage: 2, count: 0 },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
{stats && (
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-text-muted">
<Clock className="w-4 h-4" />
<span>
{locale === 'fa' ? 'آخرین به‌روزرسانی: ' : 'Last updated: '}
{new Date(stats.lastUpdated).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
)}
</div>
</section>
{/* Main Content */}
<section className="section bg-surface">
<div className="container-main max-w-4xl">
{/* Sources Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('sources.title')}
</h2>
<div className="grid gap-4 md:grid-cols-2">
{sources.map((source) => {
const Icon = source.icon;
return (
<div key={source.name} className="card p-5 hover:border-primary-300 transition-colors">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<h3 className="font-semibold text-text-primary">{source.name}</h3>
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{source.count}
</span>
</div>
<p className="text-sm text-text-secondary">{source.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* License Compliance Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('licenses.title')}
</h2>
<div className="card p-6">
<p className="text-text-secondary mb-6">{t('licenses.description')}</p>
<div className="space-y-3">
{licenses.map((license) => (
<div key={license.name} className="flex items-center gap-4">
<span className="w-24 text-sm font-medium text-text-primary">{license.name}</span>
<div className="flex-1 h-2 bg-surface-subtle rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${license.percentage}%` }}
/>
</div>
<span className="w-16 text-sm text-text-muted text-right">
{license.percentage}%
{stats && license.count > 0 && (
<span className="block text-xs">({formatNumber(license.count)})</span>
)}
</span>
</div>
))}
</div>
</div>
</div>
{/* How It Works Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('howItWorks.title')}
</h2>
<div className="card p-6">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">1</div>
<p className="text-text-secondary">{t('howItWorks.step1')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">2</div>
<p className="text-text-secondary">{t('howItWorks.step2')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">3</div>
<p className="text-text-secondary">{t('howItWorks.step3')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">4</div>
<p className="text-text-secondary">{t('howItWorks.step4')}</p>
</div>
</div>
</div>
</div>
{/* Special Thanks Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('thanks.title')}
</h2>
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<Heart className="w-5 h-5 text-red-500" />
<span className="font-medium text-text-primary">{t('thanks.subtitle')}</span>
</div>
<ul className="space-y-2 text-text-secondary">
<li className="flex items-center gap-2">
<Code className="w-4 h-4" />
<Link href="https://anthropic.com" target="_blank" className="hover:text-primary-600 transition-colors">
Anthropic
</Link>
<span className="text-text-muted">- {locale === 'fa' ? 'استاندارد SKILL.md و Agent Skills' : 'SKILL.md and Agent Skills standard'}</span>
</li>
<li className="flex items-center gap-2">
<Github className="w-4 h-4" />
<Link href="https://github.com/anthropics/skills" target="_blank" className="hover:text-primary-600 transition-colors">
anthropics/skills
</Link>
<span className="text-text-muted">- {locale === 'fa' ? 'مخزن رسمی مهارت‌ها' : 'Official skills repository'}</span>
</li>
<li className="flex items-center gap-2">
<Code className="w-4 h-4" />
<span>
{locale === 'fa'
? 'OpenAI، GitHub، Cursor و Windsurf'
: 'OpenAI, GitHub, Cursor & Windsurf'}
</span>
<span className="text-text-muted">- {locale === 'fa' ? 'پلتفرم‌های پشتیبانی شده' : 'Supported platforms'}</span>
</li>
<li className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>
{locale === 'fa'
? `همه ${stats ? formatNumber(stats.totalContributors) : ''} مشارکت‌کنندگان متن‌باز`
: `All ${stats ? formatNumber(stats.totalContributors) : ''} open-source contributors`}
</span>
</li>
</ul>
</div>
</div>
{/* Your Rights Section */}
<div>
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('rights.title')}
</h2>
<div className="card p-6 bg-amber-50 dark:bg-amber-900/10 border-amber-200 dark:border-amber-800">
<p className="text-text-secondary mb-4">{t('rights.description')}</p>
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium transition-colors"
>
{t('rights.claimLink')}
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillQueries, categoryQueries } from '@skillhub/db';
import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface BrowsePageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{
q?: string;
category?: string;
platform?: string;
format?: string;
sort?: string;
page?: string;
}>;
}
// Get skills directly from database with filters - all filtering at database level
async function getSkills(params: {
q?: string;
platform?: string;
format?: string;
sort?: string;
page?: string;
category?: string;
}) {
try {
const db = createDb();
const limit = 20;
const page = parseInt(params.page || '1');
const offset = (page - 1) * limit;
const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
'stars': 'stars',
'downloads': 'downloads',
'recent': 'updated',
'rating': 'rating',
'lastDownloaded': 'lastDownloaded',
};
// Build filter options - push ALL filters to database level
const filterOptions = {
query: params.q,
category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
sourceFormat: params.format || 'skill.md',
sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded',
sortOrder: 'desc' as const,
limit,
offset,
};
// Fetch paginated results directly from database
const skills = await skillQueries.search(db, filterOptions);
// Get accurate total count for pagination
const total = await skillQueries.count(db, {
query: params.q,
category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
sourceFormat: params.format || 'skill.md',
});
const totalPages = Math.ceil(total / limit);
return {
skills,
pagination: { total, page, totalPages },
};
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } };
}
}
export default async function BrowsePage({ params, searchParams }: BrowsePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const searchParamsResolved = await searchParams;
const t = await getTranslations('browse');
const tCommon = await getTranslations('common');
const sortOptions = [
{ id: 'lastDownloaded', name: t('filters.sortOptions.lastDownloaded') },
{ id: 'downloads', name: t('filters.sortOptions.downloads') },
{ id: 'stars', name: t('filters.sortOptions.stars') },
{ id: 'recent', name: t('filters.sortOptions.recent') },
{ id: 'rating', name: t('filters.sortOptions.rating') },
];
// Fetch categories hierarchically for filter dropdown with translations
const tCategories = await getTranslations('categories');
type HierarchicalCategory = {
id: string;
name: string;
slug: string;
skillCount: number;
children?: { id: string; name: string; slug: string; skillCount: number }[];
};
let categories: HierarchicalCategory[] = [];
try {
const db = createDb();
const rawCategories = await categoryQueries.getHierarchical(db);
categories = rawCategories.map(parent => ({
id: parent.id,
name: tCategories(`parents.${parent.slug}`) || parent.name,
slug: parent.slug,
skillCount: parent.skillCount ?? 0,
children: parent.children?.map(cat => ({
id: cat.id,
name: tCategories(`names.${cat.slug}`) || cat.name,
slug: cat.slug,
skillCount: cat.skillCount ?? 0,
})),
}));
} catch (error) {
console.error('Error fetching categories:', error);
}
const filterTranslations = {
category: t('filters.category') || 'Category',
allCategories: t('filters.allCategories') || 'All Categories',
sort: t('filters.sort'),
format: t('filters.format') || 'Format',
allFormats: t('filters.allFormats') || 'All Formats',
agentSkills: t('filters.agentSkills') || 'Agent Skills (SKILL.md)',
searching: t('searching') || 'Searching...',
viewFeatured: t('filters.viewFeatured') || 'View Featured Skills',
};
const paginationTranslations = {
previous: t('pagination.previous') || 'Previous',
next: t('pagination.next') || 'Next',
page: t('pagination.page') || 'Page',
of: t('pagination.of') || 'of',
};
const activeFiltersTranslations = {
search: t('activeFilters.search') || 'Search',
category: t('activeFilters.category') || 'Category',
sortBy: t('activeFilters.sortBy') || 'Sorted by',
clearAll: t('activeFilters.clearAll') || 'Clear all',
};
const emptyStateTranslations = {
noResults: t('noResults') || 'No skills found',
noResultsWithQuery: t('noResultsWithQuery') || 'No results for "{query}"',
tryDifferent: t('emptyState.tryDifferent') || 'Try different search terms or adjust your filters',
clearFilters: t('emptyState.clearFilters') || 'Clear filters',
browseAll: t('emptyState.browseAll') || 'Browse Featured Skills',
};
const searchPlaceholder = tCommon('search');
// Fetch skills from API with all filters
const { skills, pagination } = await getSkills(searchParamsResolved);
const limit = 20;
const startItem = (pagination.page - 1) * limit + 1;
const endItem = Math.min(pagination.page * limit, pagination.total);
// Get category name for active filters display
const currentCategory = searchParamsResolved.category;
const currentSort = searchParamsResolved.sort || 'lastDownloaded';
const currentFormat = searchParamsResolved.format || '';
const hasActiveFilters = !!(searchParamsResolved.q || currentCategory || (currentSort && currentSort !== 'lastDownloaded') || currentFormat);
// Find category name from hierarchical categories
let categoryName = '';
if (currentCategory) {
for (const parent of categories) {
if (parent.id === currentCategory) {
categoryName = parent.name;
break;
}
const child = parent.children?.find(c => c.id === currentCategory);
if (child) {
categoryName = child.name;
break;
}
}
}
// Find sort option name
const sortName = sortOptions.find(o => o.id === currentSort)?.name || '';
return (
<div className="min-h-screen flex flex-col bg-surface-muted">
<Header />
<main className="flex-1">
{/* Page Header */}
<div className="bg-surface-elevated border-b border-border">
<div className="container-main py-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">
{t('title')}
</h1>
<p className="text-text-secondary">
{t('subtitle')}
</p>
</div>
</div>
<div className="container-main py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Filters Sidebar - Client Component */}
<BrowseFilters
sortOptions={sortOptions}
categories={categories}
locale={locale}
translations={filterTranslations}
/>
{/* Skills Grid */}
<div className="flex-1">
{/* Search Bar - Client Component */}
<SearchBar
placeholder={searchPlaceholder}
defaultValue={searchParamsResolved.q}
/>
{/* Active Filters - Shows applied filters as removable chips */}
<ActiveFilters
query={searchParamsResolved.q}
categoryId={currentCategory}
categoryName={categoryName}
sortBy={currentSort}
sortName={sortName}
translations={activeFiltersTranslations}
/>
{/* Results count with range */}
{pagination.total > 0 && (
<p className="text-text-secondary mb-6">
{t('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(pagination.total) : pagination.total
}) || `Showing ${startItem}-${endItem} of ${pagination.total} skills`}
</p>
)}
{/* Skills Grid or Empty State */}
{skills.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
) : (
<EmptyState
query={searchParamsResolved.q}
hasFilters={hasActiveFilters}
locale={locale}
translations={emptyStateTranslations}
/>
)}
{/* Pagination */}
{skills.length > 0 && (
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
locale={locale}
translations={paginationTranslations}
/>
)}
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import Link from 'next/link';
import {
Brain,
Bot,
Sparkles,
Monitor,
Server,
Cloud,
Database,
GitBranch,
CheckCircle,
Shield,
FileText,
PenTool,
Smartphone,
Layers,
Code,
Code2,
Package,
StickyNote,
Home,
Music,
MessageCircle,
Briefcase,
Calculator,
Coins,
type LucideIcon,
} from 'lucide-react';
import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Map category slugs to Lucide icons (23 categories + 7 parents)
const iconMap: Record<string, LucideIcon> = {
// Original 16 categories
'ai-llm': Brain,
'git-version-control': GitBranch,
'data-database': Database,
'backend-apis': Server,
'frontend-ui': Monitor,
'agents-orchestration': Bot,
'testing-qa': CheckCircle,
'devops-cloud': Cloud,
'programming-languages': Code,
'documents-files': FileText,
'security-auth': Shield,
'mcp-skills': Layers,
'prompts-instructions': Sparkles,
'content-writing': PenTool,
'mobile-development': Smartphone,
'other-utilities': Package,
// New categories from Phase 1
'productivity-notes': StickyNote,
'smart-home-iot': Home,
'multimedia-audio-video': Music,
'social-communications': MessageCircle,
'business-finance': Briefcase,
'science-mathematics': Calculator,
'blockchain-web3': Coins,
// Parent categories from Phase 2
'development': Code2,
'ai-automation': Brain,
'data-documents': Database,
'devops-security': Cloud,
'business-productivity': Briefcase,
'media-iot': Music,
'specialized': Sparkles,
};
// Color map for parent categories
const parentColorMap: Record<string, string> = {
'development': 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
'ai-automation': 'bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400',
'data-documents': 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400',
'devops-security': 'bg-orange-50 text-orange-600 dark:bg-orange-950 dark:text-orange-400',
'business-productivity': 'bg-green-50 text-green-600 dark:bg-green-950 dark:text-green-400',
'media-iot': 'bg-rose-50 text-rose-600 dark:bg-rose-950 dark:text-rose-400',
'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
// Get categories hierarchically from database
async function getHierarchicalCategories() {
try {
const db = createDb();
return await categoryQueries.getHierarchical(db);
} catch (error) {
console.error('Error fetching categories:', error);
return [];
}
}
export default async function CategoriesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('categories');
const hierarchicalCategories = await getHierarchicalCategories();
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{hierarchicalCategories.map((parent) => {
const ParentIcon = iconMap[parent.slug] || Code;
const colorClass = parentColorMap[parent.slug] || 'bg-primary-50 text-primary-600';
return (
<div key={parent.id} className="mb-12 last:mb-0">
{/* Parent Section Header */}
<div className="flex items-center gap-4 mb-6">
<div className={`flex-shrink-0 w-12 h-12 rounded-xl ${colorClass} flex items-center justify-center`}>
<ParentIcon className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold text-text-primary">
{t(`parents.${parent.slug}`) || parent.name}
</h2>
<p className="text-text-muted text-sm">{parent.description}</p>
</div>
</div>
{/* Child Categories Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{parent.children?.map((category) => {
const IconComponent = iconMap[category.slug] || Code;
return (
<Link
key={category.id}
href={`/${locale}/browse?category=${category.id}`}
className="card p-5 flex items-center gap-4 border border-transparent hover:border-primary-500 transition-all hover:shadow-md"
>
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 flex items-center justify-center">
<IconComponent className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-text-primary truncate">
{t(`names.${category.slug}`) || category.name}
</h3>
<p className="text-text-muted text-sm ltr-nums">
{formatNumber(category.skillCount || 0, locale)} {t('skillCount')}
</p>
</div>
</Link>
);
})}
</div>
</div>
);
})}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest';
/**
* Claim Form Validation Tests
*
* These tests validate the URL validation logic used in the claim form.
* Full component tests require React Testing Library setup with jsdom environment.
*/
// Validate GitHub URL format (duplicated from ClaimForm for testing)
function isValidGitHubUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2;
} catch {
return false;
}
}
describe('Claim Form - URL Validation', () => {
describe('isValidGitHubUrl', () => {
it('should accept valid GitHub repository URL', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo')).toBe(true);
});
it('should accept valid GitHub repository URL with trailing slash', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo/')).toBe(true);
});
it('should accept valid GitHub repository URL with tree path', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo/tree/main/path')).toBe(true);
});
it('should reject non-GitHub URLs', () => {
expect(isValidGitHubUrl('https://example.com/owner/repo')).toBe(false);
});
it('should reject GitHub URL without owner', () => {
expect(isValidGitHubUrl('https://github.com/owner')).toBe(false);
});
it('should reject GitHub homepage', () => {
expect(isValidGitHubUrl('https://github.com')).toBe(false);
});
it('should reject invalid URLs', () => {
expect(isValidGitHubUrl('not a url')).toBe(false);
});
it('should reject empty string', () => {
expect(isValidGitHubUrl('')).toBe(false);
});
it('should reject relative paths', () => {
expect(isValidGitHubUrl('/owner/repo')).toBe(false);
});
it('should reject GitHub gist URLs', () => {
expect(isValidGitHubUrl('https://gist.github.com/user/abc123')).toBe(false);
});
});
});
describe('Claim Form - Error Code Handling', () => {
it('should map RATE_LIMIT_EXCEEDED error code correctly', () => {
const errorCodes = {
'RATE_LIMIT_EXCEEDED': 'GitHub API rate limit exceeded. Please try again in a few minutes.',
'INVALID_REPO': 'The repository was not found or is not accessible.',
'NETWORK_TIMEOUT': 'Request timed out while checking the repository.',
'INVALID_URL': 'Please enter a valid GitHub repository URL.',
};
expect(errorCodes['RATE_LIMIT_EXCEEDED']).toBe('GitHub API rate limit exceeded. Please try again in a few minutes.');
});
it('should have distinct error messages for each error code', () => {
const errorCodes = [
'RATE_LIMIT_EXCEEDED',
'INVALID_REPO',
'NETWORK_TIMEOUT',
'INVALID_URL',
'ALREADY_PENDING',
];
// All error codes should be unique
expect(new Set(errorCodes).size).toBe(errorCodes.length);
});
});
describe('Claim Form - API Response Scenarios', () => {
describe('Add Request Success Scenarios', () => {
it('should handle single skill found', () => {
const response = {
success: true,
hasSkillMd: true,
skillCount: 1,
skillPaths: ['skills/my-skill'],
};
expect(response.skillCount).toBe(1);
expect(response.hasSkillMd).toBe(true);
});
it('should handle multiple skills found', () => {
const response = {
success: true,
hasSkillMd: true,
skillCount: 3,
skillPaths: ['skills/skill1', 'skills/skill2', 'skills/skill3'],
};
expect(response.skillCount).toBe(3);
expect(response.skillPaths.length).toBe(3);
});
it('should handle no skills found', () => {
const response = {
success: true,
hasSkillMd: false,
skillCount: 0,
skillPaths: [],
};
expect(response.skillCount).toBe(0);
expect(response.hasSkillMd).toBe(false);
expect(response.skillPaths.length).toBe(0);
});
});
describe('Add Request Error Scenarios', () => {
it('should handle rate limit error', () => {
const errorResponse = {
error: 'GitHub API rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
};
expect(errorResponse.code).toBe('RATE_LIMIT_EXCEEDED');
});
it('should handle invalid repository error', () => {
const errorResponse = {
error: 'Repository not found',
code: 'INVALID_REPO',
};
expect(errorResponse.code).toBe('INVALID_REPO');
});
it('should handle network timeout error', () => {
const errorResponse = {
error: 'Request timed out',
code: 'NETWORK_TIMEOUT',
};
expect(errorResponse.code).toBe('NETWORK_TIMEOUT');
});
});
});
describe('Repository Validation - Error Message Clarity', () => {
it('should provide specific error for 404 (not found)', () => {
const error = 'Repository not found. Please check the URL and ensure the repository exists.';
expect(error).toContain('not found');
expect(error).toContain('check the URL');
});
it('should provide specific error for rate limit', () => {
const error = 'GitHub API rate limit exceeded. Please try again later.';
expect(error).toContain('rate limit');
expect(error).toContain('try again');
});
it('should provide specific error for private repository', () => {
const error = 'Repository is private or you do not have access. Please ensure the repository is public.';
expect(error).toContain('private');
expect(error).toContain('public');
});
it('should provide specific error for timeout', () => {
const error = 'Request timed out while checking repository. Please try again.';
expect(error).toContain('timed out');
expect(error).toContain('try again');
});
it('should provide generic error for network issues', () => {
const error = 'Network error while verifying repository. Please check your connection and try again.';
expect(error).toContain('Network error');
expect(error).toContain('connection');
});
});

View File

@@ -0,0 +1,115 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ClaimForm } from '@/components/ClaimForm';
export const dynamic = 'force-dynamic';
export default async function ClaimPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('claim');
const translations = {
title: t('title'),
subtitle: t('subtitle'),
loginRequired: t('loginRequired'),
signIn: t('signIn'),
optional: t('optional'),
mirror: {
title: t('mirror.title'),
description: t('mirror.description'),
button: t('mirror.button'),
},
tabs: {
remove: t('tabs.remove'),
add: t('tabs.add'),
},
form: {
skillId: t('form.skillId'),
skillIdPlaceholder: t('form.skillIdPlaceholder'),
skillIdHelp: t('form.skillIdHelp'),
reason: t('form.reason'),
reasonPlaceholder: t('form.reasonPlaceholder'),
submit: t('form.submit'),
submitting: t('form.submitting'),
},
addForm: {
repositoryUrl: t('addForm.repositoryUrl'),
repositoryUrlPlaceholder: t('addForm.repositoryUrlPlaceholder'),
repositoryUrlHelp: t('addForm.repositoryUrlHelp'),
reason: t('addForm.reason'),
reasonPlaceholder: t('addForm.reasonPlaceholder'),
submit: t('addForm.submit'),
submitting: t('addForm.submitting'),
},
success: {
title: t('success.title'),
description: t('success.description'),
viewRequests: t('success.viewRequests'),
},
addSuccess: {
title: t('addSuccess.title'),
description: t('addSuccess.description'),
descriptionNoSkillMd: t('addSuccess.descriptionNoSkillMd'),
descriptionMultiplePrefix: t('addSuccess.descriptionMultiplePrefix'),
descriptionMultipleSuffix: t('addSuccess.descriptionMultipleSuffix'),
viewRequests: t('addSuccess.viewRequests'),
foundSkillsIn: t('addSuccess.foundSkillsIn'),
root: t('addSuccess.root'),
andMore: t.raw('addSuccess.andMore') as string,
},
error: {
notOwner: t('error.notOwner'),
skillNotFound: t('error.skillNotFound'),
alreadyPending: t('error.alreadyPending'),
githubError: t('error.githubError'),
invalidSkill: t('error.invalidSkill'),
invalidUrl: t('error.invalidUrl'),
invalidRepo: t('error.invalidRepo'),
rateLimitExceeded: t('error.rateLimitExceeded'),
networkTimeout: t('error.networkTimeout'),
generic: t('error.generic'),
},
myRequests: {
title: t('myRequests.title'),
empty: t('myRequests.empty'),
status: {
pending: t('myRequests.status.pending'),
approved: t('myRequests.status.approved'),
rejected: t('myRequests.status.rejected'),
indexed: t('myRequests.status.indexed'),
},
skillsFoundPrefix: t('myRequests.skillsFoundPrefix'),
skillsFoundSuffix: t('myRequests.skillsFoundSuffix'),
showLess: t('myRequests.showLess'),
showAllPrefix: t('myRequests.showAllPrefix'),
showAllSuffix: t('myRequests.showAllSuffix'),
},
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{translations.title}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{translations.subtitle}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-2xl">
<ClaimForm translations={translations} />
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import type { Metadata } from 'next';
import {
Zap,
Search,
Shield,
ArrowRight,
ArrowLeft,
Sparkles,
Terminal,
Clock,
Star,
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { EarlyAccessForm } from '@/components/EarlyAccessForm';
import { createDb, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'claudePlugin' });
return {
title: t('metadata.title'),
description: t('metadata.description'),
openGraph: {
title: t('metadata.title'),
description: t('metadata.description'),
},
};
}
async function getStats() {
try {
const db = createDb();
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return skillsResult[0]?.count ?? 0;
} catch {
return 119000;
}
}
export default async function ClaudePluginPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ variant?: string }>;
}) {
const { locale } = await params;
const { variant } = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('claudePlugin');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const totalSkills = await getStats();
// A/B Test: variant A (default) = "Get early access", variant B = "Help us prioritize"
const isVariantB = variant === 'b';
const benefits = [
{
icon: Search,
title: t('benefits.discovery.title'),
description: t('benefits.discovery.description'),
},
{
icon: Terminal,
title: t('benefits.install.title'),
description: t('benefits.install.description'),
},
{
icon: Zap,
title: t('benefits.instant.title'),
description: t('benefits.instant.description'),
},
{
icon: Shield,
title: t('benefits.secure.title'),
description: t('benefits.secure.description'),
},
];
const stats = [
{
value: formatCompactNumber(totalSkills, locale),
label: t('stats.skills'),
icon: Sparkles,
},
{
value: '5+',
label: t('stats.platforms'),
icon: Terminal,
},
{
value: '4.9',
label: t('stats.rating'),
icon: Star,
},
];
return (
<div className="min-h-screen bg-gradient-to-b from-surface to-surface-elevated">
<Header />
<main>
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 -z-10">
<div className="absolute top-20 left-1/4 w-72 h-72 bg-primary/10 rounded-full blur-3xl" />
<div className="absolute bottom-20 right-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
</div>
<div className="container mx-auto px-4 py-16 md:py-24">
<div className="max-w-4xl mx-auto text-center">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
{t('hero.badge')}
</div>
{/* Title */}
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-text-primary mb-6 leading-tight">
{t('hero.title')}
</h1>
{/* Subtitle */}
<p className="text-lg md:text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
{isVariantB ? t('hero.subtitleB') : t('hero.subtitleA', { count: formatCompactNumber(totalSkills, locale) })}
</p>
{/* Stats */}
<div className="flex flex-wrap justify-center gap-8 mb-12">
{stats.map((stat, i) => (
<div key={i} className="text-center">
<div className="flex items-center justify-center gap-2 text-3xl font-bold text-text-primary">
<stat.icon className="w-6 h-6 text-primary" />
{stat.value}
</div>
<div className="text-sm text-text-secondary">{stat.label}</div>
</div>
))}
</div>
{/* Email Signup Form */}
<div className="max-w-md mx-auto">
<EarlyAccessForm variant={isVariantB ? 'b' : 'a'} locale={locale} />
</div>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="py-16 bg-surface-elevated">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-12">
{t('benefits.title')}
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
{benefits.map((benefit, i) => (
<div
key={i}
className="p-6 rounded-xl bg-surface border border-border hover:border-primary/50 transition-colors"
>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
<benefit.icon className="w-6 h-6 text-primary" />
</div>
<h3 className="text-lg font-semibold text-text-primary mb-2">
{benefit.title}
</h3>
<p className="text-text-secondary text-sm">{benefit.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How It Works Preview */}
<section className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-4">
{t('howItWorks.title')}
</h2>
<p className="text-text-secondary text-center mb-12 max-w-2xl mx-auto">
{t('howItWorks.subtitle')}
</p>
<div className="max-w-4xl mx-auto">
{/* Code example */}
<div className="bg-gray-900 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-2"># {t('howItWorks.example.comment')}</div>
<div className="text-green-400 mb-4">
<span className="text-purple-400">User:</span> {t('howItWorks.example.userMessage')}
</div>
<div className="text-gray-400 mb-2"># {t('howItWorks.example.claudeComment')}</div>
<div className="text-blue-400">
<span className="text-purple-400">Claude:</span>{' '}
<span className="text-yellow-400">search_skills</span>(
<span className="text-orange-400">query</span>=
<span className="text-green-400">&quot;pdf&quot;</span>)
</div>
<div className="text-gray-500 mt-4 border-t border-gray-700 pt-4">
{t('howItWorks.example.result')}
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-16 bg-surface-elevated">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-12">
{t('faq.title')}
</h2>
<div className="max-w-3xl mx-auto space-y-6">
{[1, 2, 3].map((i) => (
<details
key={i}
className="group p-6 rounded-xl bg-surface border border-border"
>
<summary className="flex items-center justify-between cursor-pointer list-none">
<h3 className="text-lg font-medium text-text-primary">
{t(`faq.q${i}.question`)}
</h3>
<ArrowIcon className="w-5 h-5 text-text-secondary group-open:rotate-90 transition-transform" />
</summary>
<p className="mt-4 text-text-secondary">{t(`faq.q${i}.answer`)}</p>
</details>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-16">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto text-center bg-gradient-to-r from-primary/10 to-accent/10 rounded-2xl p-8 md:p-12">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary mb-4">
{t('cta.title')}
</h2>
<p className="text-text-secondary mb-8 max-w-xl mx-auto">
{t('cta.description', { count: formatCompactNumber(totalSkills, locale) })}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/browse"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary/90 transition-colors"
>
{t('cta.browseSkills')}
<ArrowIcon className="w-4 h-4" />
</Link>
<a
href="https://github.com/anthropics/skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg border border-border text-text-primary font-medium hover:bg-surface-subtle transition-colors"
>
{t('cta.learnMore')}
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation';
export default async function ContactPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
redirect(`/${locale}/support`);
}

View File

@@ -0,0 +1,556 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ApiEndpointSection } from '@/components/ApiEndpointSection';
import type { EndpointDef } from '@/components/ApiEndpointSection';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, Search, FileCode, Users, Compass, Mail } from 'lucide-react';
export default async function ApiDocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
const labels = {
parameters: t('api.labels.parameters'),
requestBody: t('api.labels.requestBody'),
required: t('api.labels.required'),
optional: t('api.labels.optional'),
default: t('api.labels.default'),
responseExample: t('api.labels.responseExample'),
rateLimit: t('api.labels.rateLimit'),
cache: t('api.labels.cache'),
authRequired: t('api.labels.authRequired'),
notes: t('api.labels.notes'),
};
// --- Section 1: Skills ---
const skillsEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/skills',
description: t('api.endpoints.searchSkills'),
auth: false,
rateLimit: '60 req/min',
cacheTTL: '5 min',
params: [
{ name: 'q', type: 'string', required: false, description: t('api.params.q') },
{ name: 'category', type: 'string', required: false, description: t('api.params.category') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platform') + ' (claude, codex, copilot, cursor, windsurf)' },
{ name: 'format', type: 'string', required: false, description: t('api.params.format'), default: 'skill.md' },
{ name: 'verified', type: 'boolean', required: false, description: t('api.params.verified') },
{ name: 'minStars', type: 'number', required: false, description: t('api.params.minStars') },
{ name: 'sort', type: 'string', required: false, description: t('api.params.sort') + ' (stars, downloads, rating, recent)', default: 'downloads' },
{ name: 'page', type: 'number', required: false, description: t('api.params.page'), default: '1' },
{ name: 'limit', type: 'number', required: false, description: t('api.params.limit'), default: '20' },
],
responseExample: `{
"skills": [
{
"id": "anthropic/skills/code-review",
"name": "code-review",
"description": "AI-powered code review assistant",
"githubOwner": "anthropic",
"githubRepo": "skills",
"githubStars": 1234,
"downloadCount": 567,
"securityStatus": "PASS",
"rating": 4.5,
"ratingCount": 23,
"isVerified": true,
"compatibility": { "platforms": ["claude", "cursor"] }
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
},
"searchEngine": "meilisearch"
}`,
notes: t('api.notes.searchFallback'),
},
{
method: 'GET',
path: '/api/skills/:id',
description: t('api.endpoints.getSkill'),
auth: false,
rateLimit: '120 req/min',
responseExample: `{
"id": "anthropic/skills/code-review",
"name": "code-review",
"description": "AI-powered code review assistant",
"githubOwner": "anthropic",
"githubRepo": "skills",
"skillPath": "code-review",
"branch": "main",
"version": "1.0.0",
"license": "MIT",
"githubStars": 1234,
"downloadCount": 567,
"securityScore": 95,
"securityStatus": "PASS",
"isVerified": true,
"compatibility": { "platforms": ["claude"] },
"rawContent": "# Code Review\\n...",
"sourceFormat": "skill.md"
}`,
notes: t('api.notes.viewCount'),
},
{
method: 'GET',
path: '/api/skills/featured',
description: t('api.endpoints.featuredSkills'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '2 hours',
params: [
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '6' },
],
},
{
method: 'GET',
path: '/api/skills/recent',
description: t('api.endpoints.recentSkills'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
params: [
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' },
],
},
{
method: 'POST',
path: '/api/skills/install',
description: t('api.endpoints.trackInstall'),
auth: false,
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platform') },
{ name: 'method', type: 'string', required: false, description: t('api.params.method') },
],
responseExample: `{ "success": true, "skillId": "owner/repo/skill", "platform": "claude", "method": "cli" }`,
notes: t('api.notes.installDedup'),
},
{
method: 'POST',
path: '/api/skills/add-request',
description: t('api.endpoints.addRequest'),
auth: true,
bodyParams: [
{ name: 'repositoryUrl', type: 'string', required: true, description: t('api.params.repositoryUrl') },
{ name: 'reason', type: 'string', required: false, description: t('api.params.reason') },
],
responseExample: `{ "success": true, "requestId": "uuid", "hasSkillMd": true, "skillCount": 3 }`,
},
{
method: 'POST',
path: '/api/skills/removal-request',
description: t('api.endpoints.removalRequest'),
auth: true,
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'reason', type: 'string', required: false, description: t('api.params.reason') },
],
responseExample: `{ "success": true, "requestId": "uuid", "blocked": true }`,
},
];
// --- Section 2: Skill Files ---
const skillFilesEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/skill-files',
description: t('api.endpoints.getSkillFiles'),
auth: false,
rateLimit: '60 req/min',
params: [
{ name: 'id', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{
"skillId": "owner/repo/skill",
"githubOwner": "owner",
"githubRepo": "repo",
"skillPath": "skill",
"branch": "main",
"files": [
{
"name": "SKILL.md",
"path": "SKILL.md",
"type": "file",
"size": 2048,
"content": "# Skill Name\\n..."
}
],
"fromCache": true,
"cachedAt": "2026-01-15T10:30:00Z"
}`,
notes: t('api.notes.cacheInDB'),
},
{
method: 'GET',
path: '/api/skill-files/zip',
description: t('api.endpoints.downloadZip'),
auth: false,
rateLimit: '60 req/min',
params: [
{ name: 'id', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platformZip'), default: 'claude' },
],
notes: t('api.notes.zipTransform'),
},
];
// --- Section 3: User Actions ---
const userActionsEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/ratings',
description: t('api.endpoints.getRatings'),
auth: false,
rateLimit: '120 req/min',
params: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' },
{ name: 'offset', type: 'number', required: false, description: t('api.params.offset'), default: '0' },
],
responseExample: `{
"ratings": [
{
"id": "uuid",
"rating": 5,
"review": "Great skill!",
"createdAt": "2026-01-10T00:00:00Z",
"user": { "id": "uid", "username": "user1", "avatarUrl": "https://..." }
}
],
"summary": { "average": 4.5, "count": 23 }
}`,
},
{
method: 'POST',
path: '/api/ratings',
description: t('api.endpoints.createRating'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'rating', type: 'number', required: true, description: t('api.params.rating') },
{ name: 'review', type: 'string', required: false, description: t('api.params.review') },
],
responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "..." }, "summary": { "average": 4.5, "count": 24 } }`,
},
{
method: 'GET',
path: '/api/ratings/me',
description: t('api.endpoints.getMyRating'),
auth: true,
rateLimit: '600 req/min',
params: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "Great!" } }`,
},
{
method: 'GET',
path: '/api/favorites',
description: t('api.endpoints.getFavorites'),
auth: true,
rateLimit: '600 req/min',
responseExample: `{
"favorites": [
{
"id": "owner/repo/skill",
"name": "skill-name",
"description": "...",
"githubStars": 100,
"downloadCount": 50,
"securityStatus": "PASS",
"isVerified": true,
"rating": 4.5,
"ratingCount": 10
}
]
}`,
},
{
method: 'POST',
path: '/api/favorites',
description: t('api.endpoints.addFavorite'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "success": true, "favorited": true }`,
},
{
method: 'DELETE',
path: '/api/favorites',
description: t('api.endpoints.removeFavorite'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "success": true, "favorited": false }`,
},
{
method: 'POST',
path: '/api/favorites/check',
description: t('api.endpoints.checkFavorites'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillIds', type: 'string[]', required: true, description: t('api.params.skillIds') },
],
responseExample: `{ "favorited": { "owner/repo/skill-a": true, "owner/repo/skill-b": false } }`,
},
];
// --- Section 4: Discovery & Metadata ---
const discoveryEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/categories',
description: t('api.endpoints.getCategories'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '12 hours',
responseExample: `{
"categories": [
{
"id": "cat-id",
"name": "Code Quality",
"slug": "code-quality",
"description": "...",
"icon": "🔍",
"skillCount": 15,
"sortOrder": 1
}
]
}`,
},
{
method: 'GET',
path: '/api/stats',
description: t('api.endpoints.getStats'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
responseExample: `{
"totalSkills": 850,
"totalDownloads": 12500,
"totalCategories": 23,
"totalContributors": 320,
"platforms": 5
}`,
},
{
method: 'GET',
path: '/api/health',
description: t('api.endpoints.healthCheck'),
auth: false,
responseExample: `{
"status": "healthy",
"timestamp": "2026-02-11T12:00:00Z",
"version": "0.2.4",
"isPrimary": true,
"services": {
"database": { "status": "healthy", "latency": 5 },
"meilisearch": { "status": "healthy", "latency": 12 },
"redis": { "status": "healthy", "latency": 3 }
}
}`,
},
{
method: 'GET',
path: '/api/attribution',
description: t('api.endpoints.getAttribution'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
responseExample: `{
"totalSkills": 850,
"totalContributors": 320,
"totalRepos": 150,
"licenseDistribution": [
{ "license": "MIT", "count": 50, "percentage": 50 }
],
"lastUpdated": "2026-02-11T00:00:00Z"
}`,
},
];
// --- Section 5: Newsletter ---
const newsletterEndpoints: EndpointDef[] = [
{
method: 'POST',
path: '/api/newsletter/subscribe',
description: t('api.endpoints.subscribe'),
auth: false,
rateLimit: '60 req/min',
bodyParams: [
{ name: 'email', type: 'string', required: true, description: t('api.params.email') },
{ name: 'source', type: 'string', required: false, description: t('api.params.source') },
{ name: 'locale', type: 'string', required: false, description: t('api.params.locale'), default: 'en' },
],
responseExample: `{ "success": true, "subscribed": true }`,
},
{
method: 'POST',
path: '/api/newsletter/unsubscribe',
description: t('api.endpoints.unsubscribe'),
auth: false,
rateLimit: '60 req/min',
bodyParams: [
{ name: 'email', type: 'string', required: true, description: t('api.params.email') },
],
responseExample: `{ "success": true, "unsubscribed": true }`,
notes: t('api.notes.alwaysSuccess'),
},
];
const sections = [
{ title: t('api.sections.skills.title'), description: t('api.sections.skills.description'), icon: Search, endpoints: skillsEndpoints },
{ title: t('api.sections.skillFiles.title'), description: t('api.sections.skillFiles.description'), icon: FileCode, endpoints: skillFilesEndpoints },
{ title: t('api.sections.userActions.title'), description: t('api.sections.userActions.description'), icon: Users, endpoints: userActionsEndpoints },
{ title: t('api.sections.discovery.title'), description: t('api.sections.discovery.description'), icon: Compass, endpoints: discoveryEndpoints },
{ title: t('api.sections.newsletter.title'), description: t('api.sections.newsletter.description'), icon: Mail, endpoints: newsletterEndpoints },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-4xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-4">{t('api.title')}</h1>
<p className="text-lg text-text-secondary mb-10">{t('api.description')}</p>
{/* Base URL */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.baseUrl')}</h2>
<div className="glass-card p-4" dir="ltr">
<code className="text-sm font-mono text-left block">{siteUrl}</code>
</div>
</div>
{/* Authentication */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.authSection.title')}</h2>
<p className="text-text-secondary mb-2">{t('api.authSection.description')}</p>
<p className="text-text-secondary text-sm">{t('api.authSection.howTo')}</p>
</div>
{/* Rate Limiting */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.rateLimitSection.title')}</h2>
<p className="text-text-secondary mb-4">{t('api.rateLimitSection.description')}</p>
<div className="glass-card overflow-hidden mb-4" dir="ltr">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b border-border/50 bg-surface-hover/50">
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.tier')}</th>
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.limit')}</th>
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.use')}</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/30">
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.anonymous')}</td>
<td className="py-2 px-4">120 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.anonymousUse')}</td>
</tr>
<tr className="border-b border-border/30">
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.search')}</td>
<td className="py-2 px-4">60 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.searchUse')}</td>
</tr>
<tr>
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.authenticated')}</td>
<td className="py-2 px-4">600 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.authenticatedUse')}</td>
</tr>
</tbody>
</table>
</div>
<div dir="ltr">
<h3 className="text-sm font-semibold mb-2 text-left">{t('api.rateLimitSection.headersTitle')}</h3>
<div className="space-y-1 text-sm text-left">
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Limit</code> {t('api.rateLimitSection.headerLimit')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Remaining</code> {t('api.rateLimitSection.headerRemaining')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Reset</code> {t('api.rateLimitSection.headerReset')}</p>
</div>
</div>
</div>
{/* Error Responses */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.errorSection.title')}</h2>
<p className="text-text-secondary mb-4">{t('api.errorSection.description')}</p>
<div className="glass-card p-4 mb-4" dir="ltr">
<pre className="text-sm font-mono overflow-x-auto text-left">{`{
"error": "Too Many Requests",
"message": "Rate limit exceeded",
"retryAfter": 45,
"limit": 60
}`}</pre>
</div>
<div dir="ltr">
<h3 className="text-sm font-semibold mb-2 text-left">{t('api.errorSection.commonCodes')}</h3>
<div className="space-y-1 text-sm text-left">
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">400</code> {t('api.errorSection.code400')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">401</code> {t('api.errorSection.code401')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">404</code> {t('api.errorSection.code404')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">429</code> {t('api.errorSection.code429')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">500</code> {t('api.errorSection.code500')}</p>
</div>
</div>
</div>
{/* Quick example */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">Quick Example</h2>
<div className="glass-card p-4" dir="ltr">
<code className="text-sm font-mono whitespace-pre-wrap text-left block">{`curl "${siteUrl}/api/skills?q=code-review&limit=5"`}</code>
</div>
</div>
<hr className="border-border/50 mb-10" />
{/* Endpoint Sections */}
{sections.map((section, index) => (
<ApiEndpointSection
key={index}
title={section.title}
description={section.description}
endpoints={section.endpoints}
labels={labels}
/>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
export default async function CliDocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const tContent = await getTranslations('docs.content');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const commands = [
{ name: 'install <skill-id>', desc: tContent('cmdInstallDesc') },
{ name: 'search <query>', desc: tContent('cmdSearchDesc') },
{ name: 'list', desc: tContent('cmdListDesc') },
{ name: 'update [skill-name]', desc: tContent('cmdUpdateDesc') },
{ name: 'uninstall <skill-name>', desc: tContent('cmdUninstallDesc') },
{ name: 'config', desc: tContent('cmdConfigDesc') },
];
const platforms = [
{ name: 'Claude', path: tContent('platformClaudePath') },
{ name: 'Codex', path: tContent('platformCodexPath') },
{ name: 'Copilot', path: tContent('platformCopilotPath') },
{ name: 'Cursor', path: tContent('platformCursorPath') },
{ name: 'Windsurf', path: tContent('platformWindsurfPath') },
];
const configKeys = [
{ key: 'defaultPlatform', desc: tContent('configDefaultPlatform') },
{ key: 'apiUrl', desc: tContent('configApiUrl') },
{ key: 'githubToken', desc: tContent('configGithubToken') },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-6">{t('cli.title')}</h1>
<p className="text-lg text-text-secondary mb-8">{t('cli.description')}</p>
<div className="prose prose-lg max-w-none">
<h2>{tContent('installation')}</h2>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">npm install -g skillhub</code>
</div>
<h2>{tContent('commands')}</h2>
<div className="space-y-4 my-6" dir="ltr">
{commands.map((cmd, index) => (
<div key={index} className="glass-card p-4 text-left">
<code className="text-sm font-mono text-primary-600">skillhub {cmd.name}</code>
<p className="text-text-secondary mt-2">{cmd.desc}</p>
</div>
))}
</div>
<h2>{tContent('examples')}</h2>
<div className="glass-card p-4 my-4 space-y-4" dir="ltr">
{/* Install globally */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exInstallGlobal')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf
</code>
<code className="block text-sm font-mono text-success">
Skill installed to ~/.claude/skills/pdf/
</code>
</div>
{/* Install in project */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exInstallProject')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf --project
</code>
<code className="block text-sm font-mono text-success">
Skill installed to ./.claude/skills/pdf/
</code>
</div>
{/* Search */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearch')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search pdf
</code>
</div>
{/* Search with sort */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearchSort')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort stars --limit 5
</code>
</div>
{/* Update all */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exUpdateAll')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub update --all
</code>
</div>
</div>
{/* Supported Platforms */}
<h2>{tContent('platformsTitle')}</h2>
<p>{tContent('platformsDesc')}</p>
<div className="space-y-3 my-6" dir="ltr">
{platforms.map((p) => (
<div key={p.name} className="glass-card p-4 text-left flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary">{p.name}</span>
<code className="text-sm font-mono text-text-secondary">{p.path}</code>
</div>
))}
</div>
{/* Configuration */}
<h2>{tContent('configTitle')}</h2>
<p>{tContent('configDesc')}</p>
<div className="space-y-3 my-6" dir="ltr">
{configKeys.map((c) => (
<div key={c.key} className="glass-card p-4 text-left">
<code className="text-sm font-mono text-primary-600">{c.key}</code>
<p className="text-text-secondary mt-1 text-sm">{c.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { DiscoveryModes } from '@/components/DiscoveryModes';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number';
export const dynamic = 'force-dynamic';
async function getSkillCount(): Promise<string> {
try {
const db = createDb();
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return formatPromptSkillCount(result[0]?.count ?? 170000);
} catch {
return '170,000+';
}
}
function getDiscoveryPrompts(count: string) {
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
return {
standard: `## SkillHub - AI Agent Skills
${count} skills available at ${siteUrl}
Search when working with unfamiliar tech, complex workflows, or after a failed attempt:
\`\`\`bash
npx skillhub search "<query>" --limit 5
npx skillhub install <skill-id> --project
npx skillhub uninstall <skill-name> # when done
\`\`\`
Rules:
- Only install skills with security status "Pass"
- After installing, read the SKILL.md and follow its instructions
- Search during planning phase for best results`,
compact: `## SkillHub (${count} skills) -- ${siteUrl}
\`\`\`bash
npx skillhub search "<query>" --limit 5
npx skillhub install <skill-id> --project
npx skillhub uninstall <skill-name>
\`\`\`
Search for unfamiliar tech or complex tasks. Only install "Pass" security status. Read SKILL.md after install.`,
oneshot: `You have access to SkillHub (${count} AI agent skills, ${siteUrl}). For unfamiliar or complex tasks: \`npx skillhub search "<query>" --limit 5\` then \`npx skillhub install <id> --project\`. Only "Pass" security. Read SKILL.md after install. Uninstall when done.`,
};
}
export default async function GettingStartedPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const tContent = await getTranslations('docs.content');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const skillCount = await getSkillCount();
const prompts = getDiscoveryPrompts(skillCount);
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-6">{t('gettingStarted.title')}</h1>
<p className="text-lg text-text-secondary mb-8">{t('gettingStarted.description')}</p>
<div className="prose prose-lg max-w-none">
<h2>{tContent('installation')}</h2>
<p>{tContent('installCli')}</p>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">npm install -g skillhub</code>
</div>
<h2>{tContent('usage')}</h2>
<h3>{tContent('searchSkills')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub search code-review</code>
</div>
<h3>{tContent('installSkill')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub install anthropic/skills/code-review</code>
</div>
<h3>{tContent('listInstalled')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub list</code>
</div>
{/* Web-based Installation */}
<hr className="my-8 border-border" />
<h2>{tContent('webInstallTitle')}</h2>
<p>{tContent('webInstallDesc')}</p>
<div className="not-prose my-4 space-y-3">
<p className="text-text-primary">{tContent('webInstallStep1')}</p>
<p className="text-text-primary">{tContent('webInstallStep2')}</p>
<p className="text-text-primary">{tContent('webInstallStep3')}</p>
<ul className="list-disc list-inside space-y-2 ms-4">
<li className="text-text-secondary">{tContent('webInstallMethodFolder')}</li>
<li className="text-text-secondary">{tContent('webInstallMethodZip')}</li>
</ul>
</div>
{/* Dynamic Skill Discovery */}
<hr className="my-8 border-border" />
<h2>{tContent('discoveryTitle')}</h2>
<p>{tContent('discoveryIntro')}</p>
<h3>{tContent('discoveryModesTitle')}</h3>
<p className="text-text-secondary">{tContent('selectMode')}</p>
<DiscoveryModes
modes={[
{
key: 'standard',
letter: 'A',
title: tContent('modeStandard'),
description: tContent('modeStandardDesc'),
latency: tContent('modeStandardLatency'),
quality: tContent('modeStandardQuality'),
bestFor: tContent('modeStandardBestFor'),
recommended: true,
prompt: prompts.standard,
},
{
key: 'compact',
letter: 'B',
title: tContent('modeCompact'),
description: tContent('modeCompactDesc'),
latency: tContent('modeCompactLatency'),
quality: tContent('modeCompactQuality'),
bestFor: tContent('modeCompactBestFor'),
prompt: prompts.compact,
},
{
key: 'oneshot',
letter: 'C',
title: tContent('modeOneShot'),
description: tContent('modeOneShotDesc'),
latency: tContent('modeOneShotLatency'),
quality: tContent('modeOneShotQuality'),
bestFor: tContent('modeOneShotBestFor'),
prompt: prompts.oneshot,
},
]}
labels={{
latency: tContent('latency'),
quality: tContent('qualityBoost'),
bestFor: tContent('bestFor'),
recommended: tContent('recommended'),
copyPrompt: tContent('copyPrompt'),
copied: tContent('copied'),
selectMode: tContent('selectMode'),
addToFile: tContent('addToFile'),
}}
/>
<h3>{tContent('crossPlatformTitle')}</h3>
<p>{tContent('crossPlatformDesc')}</p>
<div className="not-prose my-4 space-y-2" dir="ltr">
<div className="glass-card p-3 text-sm font-mono">{tContent('platformClaude')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCodex')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCopilot')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCursor')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformWindsurf')}</div>
</div>
<p>
{tContent('fullDocsLink')}{' '}
<Link href={`/${locale}/docs/cli`} className="text-primary-600 hover:text-primary-700 no-underline">
CLI Reference <ArrowIcon className="inline w-4 h-4" />
</Link>
</p>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { BookOpen, Terminal, Code } from 'lucide-react';
import Link from 'next/link';
export default async function DocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const docs = [
{
icon: BookOpen,
title: t('gettingStarted.title'),
description: t('gettingStarted.description'),
href: `/${locale}/docs/getting-started`,
},
{
icon: Terminal,
title: t('cli.title'),
description: t('cli.description'),
href: `/${locale}/docs/cli`,
},
{
icon: Code,
title: t('api.title'),
description: t('api.description'),
href: `/${locale}/docs/api`,
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{docs.map((doc, index) => (
<Link
key={index}
href={doc.href}
className="card p-6 text-center hover:border-primary-500 transition-colors"
>
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary-50 text-primary-600 mb-4">
<doc.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold mb-2">{doc.title}</h3>
<p className="text-text-secondary">{doc.description}</p>
</Link>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Heart } from 'lucide-react';
import { auth } from '@/lib/auth';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FavoritesList } from '@/components/FavoritesList';
import { FavoritesSignIn } from '@/components/FavoritesSignIn';
import { createDb, userQueries } from '@skillhub/db';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
interface FavoritesPageProps {
params: Promise<{ locale: string }>;
}
export default async function FavoritesPage({ params }: FavoritesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('favorites');
const tCommon = await getTranslations('common');
const session = await auth();
const isLoggedIn = !!session?.user?.githubId;
// Get favorites only if logged in
let favorites: Awaited<ReturnType<typeof userQueries.getFavorites>> = [];
if (isLoggedIn) {
const db = createDb();
const dbUser = await userQueries.getByGithubId(db, session.user.githubId!);
favorites = dbUser ? await userQueries.getFavorites(db, dbUser.id) : [];
}
return (
<div className="min-h-screen flex flex-col bg-surface-muted">
<Header />
<main className="flex-1">
<div className="bg-surface border-b border-border">
<div className="container-main py-8">
<div className="flex items-center gap-3 mb-2">
<Heart className="w-8 h-8 text-red-500 fill-red-500" />
<h1 className="text-3xl font-bold text-text-primary">{t('title')}</h1>
</div>
<p className="text-text-secondary">{t('subtitle')}</p>
</div>
</div>
<div className="container-main py-8">
{isLoggedIn ? (
<FavoritesList
initialFavorites={favorites}
locale={locale}
translations={{
verified: tCommon('verified'),
emptyTitle: t('empty.title'),
emptyDescription: t('empty.description'),
emptyCta: t('empty.cta'),
}}
/>
) : (
<FavoritesSignIn
translations={{
loginRequired: t('loginRequired'),
signIn: t('signIn'),
}}
/>
)}
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface FeaturedPageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string }>;
}
// Get featured skills with pagination
async function getFeaturedSkills(page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
// Try featured first, fall back to combined popularity score
let featuredSkills = await skillQueries.getFeatured(db, limit, offset);
let total = await skillQueries.countFeatured(db);
// If no featured skills, use adaptive popularity with owner/repo diversity
if (total === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
total = await skillQueries.countAll(db);
}
return { skills: featuredSkills, total };
} catch (error) {
console.error('Error fetching featured skills:', error);
return { skills: [], total: 0 };
}
}
export default async function FeaturedPage({
params,
searchParams,
}: FeaturedPageProps) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('featured');
const tBrowse = await getTranslations('browse');
const limit = 12;
const page = parseInt(searchParamsResolved.page || '1');
const { skills: featuredSkills, total } = await getFeaturedSkills(page, limit);
const totalPages = Math.ceil(total / limit);
const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, total);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{/* Results count */}
{total > 0 && (
<p className="text-text-secondary mb-6 text-center">
{tBrowse('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(total) : total
})}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
{/* Pagination */}
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,79 @@
import type { Metadata } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, localeDirection, type Locale } from '@/i18n';
import { Providers } from '../providers';
import { Suspense } from 'react';
import { QueryNotification } from '@/components/QueryNotification';
import '../globals.css';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
return {
title: {
default: t('title'),
template: `%s | SkillHub`,
},
description: t('description'),
keywords: ['AI', 'Agent', 'Skills', 'Claude', 'Codex', 'Copilot', 'Marketplace'],
authors: [{ name: 'SkillHub' }],
icons: {
icon: '/logo.svg',
shortcut: '/logo.svg',
apple: '/logo.svg',
},
openGraph: {
title: t('title'),
description: t('description'),
type: 'website',
locale: locale === 'fa' ? 'fa_IR' : 'en_US',
url: `${primaryDomain}/${locale}`,
},
};
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
const dir = localeDirection[locale as Locale];
return (
<html lang={locale} dir={dir} suppressHydrationWarning>
<body className="min-h-screen bg-surface">
<Providers>
<NextIntlClientProvider messages={messages}>
<Suspense fallback={null}>
<QueryNotification />
</Suspense>
{children}
</NextIntlClientProvider>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,193 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { NewSkillsTabs } from '@/components/NewSkillsTabs';
import { SkillCard } from '@/components/SkillCard';
import { Clock, RefreshCw } from 'lucide-react';
import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface NewSkillsPageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
// Format date to "X hours/days ago" with locale support
function formatTimeAgo(date: Date | null, locale: string): string {
if (!date) return locale === 'fa' ? 'اخیراً' : 'Recently';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (locale === 'fa') {
if (diffDays > 0) return `${toPersianNumber(diffDays)} روز پیش`;
if (diffHours > 0) return `${toPersianNumber(diffHours)} ساعت پیش`;
return 'همین الان';
}
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
}
if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
}
return 'Just now';
}
// Get skills based on tab with pagination
async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
if (tab === 'new') {
const skills = await skillQueries.getNewSkills(db, limit, offset);
const total = await skillQueries.countNewSkills(db);
return { skills, total };
} else {
const skills = await skillQueries.getUpdatedSkills(db, limit, offset);
const total = await skillQueries.countUpdatedSkills(db);
return { skills, total };
}
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], total: 0 };
}
}
// Get counts for both tabs
async function getTabCounts() {
try {
const db = createDb();
const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db),
]);
return { newCount, updatedCount };
} catch (error) {
console.error('Error fetching counts:', error);
return { newCount: 0, updatedCount: 0 };
}
}
export default async function NewSkillsPage({
params,
searchParams,
}: NewSkillsPageProps) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('new');
const tBrowse = await getTranslations('browse');
const limit = 12;
const page = parseInt(searchParamsResolved.page || '1');
const tab = (searchParamsResolved.tab as 'new' | 'updated') || 'new';
// Fetch data in parallel
const [{ skills, total }, { newCount, updatedCount }] = await Promise.all([
getSkillsForTab(tab, page, limit),
getTabCounts(),
]);
const totalPages = Math.ceil(total / limit);
const startItem = total > 0 ? (page - 1) * limit + 1 : 0;
const endItem = Math.min(page * limit, total);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
const tabsTranslations = {
new: t('tabs.new'),
updated: t('tabs.updated'),
newDescription: t('newDescription'),
updatedDescription: t('updatedDescription'),
};
const noSkillsMessage = tab === 'new' ? t('noNewSkills') : t('noUpdatedSkills');
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{/* Tabs Component */}
<NewSkillsTabs
activeTab={tab}
newCount={newCount}
updatedCount={updatedCount}
locale={locale}
translations={tabsTranslations}
/>
{/* Results count */}
{total > 0 && (
<p className="text-text-secondary mb-6 text-center">
{tBrowse('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(total) : total
})}
</p>
)}
{/* Skills Grid */}
{skills.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
showTimeBadge={tab === 'new' ? 'created' : 'updated'}
formatTimeAgo={formatTimeAgo}
/>
))}
</div>
) : (
<div className="text-center py-16">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-elevated flex items-center justify-center">
{tab === 'new' ? (
<Clock className="w-8 h-8 text-text-muted" />
) : (
<RefreshCw className="w-8 h-8 text-text-muted" />
)}
</div>
<p className="text-text-secondary text-lg">{noSkillsMessage}</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
)}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,307 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { SkillCard } from '@/components/SkillCard';
import { Pagination } from '@/components/BrowseFilters';
import { createDb, skillQueries } from '@skillhub/db';
import { formatCompactNumber, toPersianNumber } from '@/lib/format-number';
import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 } from 'lucide-react';
import Link from 'next/link';
export const dynamic = 'force-dynamic';
const ITEMS_PER_PAGE = 24;
type SortOption = 'popularity' | 'downloads' | 'stars';
interface OwnerPageProps {
params: Promise<{ locale: string; username: string }>;
searchParams: Promise<{ page?: string; sort?: string; repo?: string }>;
}
export default async function OwnerPage({ params, searchParams }: OwnerPageProps) {
const { locale, username: rawUsername } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('owner');
const tBrowse = await getTranslations('browse');
const username = decodeURIComponent(rawUsername);
const page = Math.max(1, parseInt(searchParamsResolved.page || '1'));
const sort = (['popularity', 'downloads', 'stars'].includes(searchParamsResolved.sort || '')
? searchParamsResolved.sort
: 'popularity') as SortOption;
const activeRepo = searchParamsResolved.repo || '';
const db = createDb();
// Fetch stats, count, and repo list in parallel
const [stats, totalSkills, ownerRepos] = await Promise.all([
skillQueries.getOwnerStats(db, username),
skillQueries.countByOwner(db, username, activeRepo || undefined),
skillQueries.getOwnerRepos(db, username),
]);
if (stats.totalSkills === 0) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">{t('notFound')}</h1>
<p className="text-text-secondary mb-8">
{t('notFoundDescription', { username })}
</p>
<Link href={`/${locale}/browse`} className="text-primary hover:underline">
{t('browseAll')}
</Link>
</main>
<Footer />
</div>
);
}
const offset = (page - 1) * ITEMS_PER_PAGE;
const totalPages = Math.ceil(totalSkills / ITEMS_PER_PAGE);
const skills = await skillQueries.getByOwner(db, username, {
limit: ITEMS_PER_PAGE,
offset,
sortBy: sort,
repo: activeRepo || undefined,
});
// Group fetched skills by repo for display
const repoMap = new Map<string, {
name: string;
stars: number;
skills: typeof skills;
}>();
for (const skill of skills) {
const repo = skill.githubRepo;
if (!repoMap.has(repo)) {
repoMap.set(repo, {
name: repo,
stars: skill.githubStars ?? 0,
skills: [],
});
}
repoMap.get(repo)!.skills.push(skill);
}
const repos = Array.from(repoMap.values());
const formatNum = (n: number) =>
locale === 'fa' ? toPersianNumber(formatCompactNumber(n, locale)) : formatCompactNumber(n, locale);
const startItem = offset + 1;
const endItem = Math.min(offset + ITEMS_PER_PAGE, totalSkills);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'popularity', label: t('sort.popularity') },
{ value: 'downloads', label: t('sort.downloads') },
{ value: 'stars', label: t('sort.stars') },
];
// Build URL helper preserving sort/repo params
const buildUrl = (overrides: { sort?: string; repo?: string; page?: number }) => {
const params = new URLSearchParams();
const s = overrides.sort ?? sort;
const r = overrides.repo ?? activeRepo;
const p = overrides.page ?? 1;
if (s && s !== 'popularity') params.set('sort', s);
if (r) params.set('repo', r);
if (p > 1) params.set('page', String(p));
const qs = params.toString();
return `/${locale}/owner/${rawUsername}${qs ? `?${qs}` : ''}`;
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Owner Header */}
<section className="bg-gradient-subtle border-b border-border">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-5">
<img
src={`https://github.com/${username}.png?size=96`}
alt={username}
width={96}
height={96}
className="rounded-full border-2 border-border shadow-sm"
/>
<div className="text-center sm:text-start flex-1">
<h1 className="text-2xl md:text-3xl font-bold flex items-center justify-center sm:justify-start gap-2 text-text-primary">
{t('title', { username })}
<a
href={`https://github.com/${username}`}
target="_blank"
rel="noopener noreferrer"
className="text-text-muted hover:text-text-primary transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</h1>
{/* Stats */}
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-4 mt-3 text-sm text-text-secondary">
<span className="flex items-center gap-1.5">
<Package className="w-4 h-4 text-primary" />
{t('stats.skills', { count: stats.totalSkills })}
</span>
<span className="flex items-center gap-1.5">
<Download className="w-4 h-4 text-primary" />
{formatNum(stats.totalDownloads)} {t('stats.downloads')}
</span>
<span className="flex items-center gap-1.5">
<Eye className="w-4 h-4 text-primary" />
{formatNum(stats.totalViews)} {t('stats.views')}
</span>
<span className="flex items-center gap-1.5">
<GitFork className="w-4 h-4 text-primary" />
{t('stats.repos', { count: stats.totalRepos })}
</span>
</div>
</div>
</div>
</div>
</section>
<section className="container mx-auto px-4 py-6">
{/* Repo filter chips (only if > 1 repo) */}
{ownerRepos.length > 1 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<FolderGit2 className="w-4 h-4 text-text-muted" />
<span className="text-sm text-text-secondary">{t('repo.filter')}:</span>
<Link
href={buildUrl({ repo: '', page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
!activeRepo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{t('repo.all')} ({locale === 'fa' ? toPersianNumber(stats.totalSkills) : stats.totalSkills})
</Link>
{ownerRepos.map((r) => (
<Link
key={r.repo}
href={buildUrl({ repo: r.repo, page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
activeRepo === r.repo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{r.repo} ({locale === 'fa' ? toPersianNumber(r.skillCount) : r.skillCount})
</Link>
))}
</div>
)}
{/* Controls: Sort + Results count */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
{/* Results count */}
<p className="text-sm text-text-secondary">
{t('pagination.showing', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(totalSkills) : totalSkills,
})}
</p>
{/* Sort selector */}
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-text-muted" />
<span className="text-sm text-text-secondary">{t('sort.label')}:</span>
<div className="flex gap-1">
{sortOptions.map((opt) => (
<Link
key={opt.value}
href={buildUrl({ sort: opt.value, page: 1 })}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
sort === opt.value
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{opt.label}
</Link>
))}
</div>
</div>
</div>
{/* Claim CTA - subtle inline hint */}
<div className="flex items-center gap-2 mb-6 text-xs text-text-muted">
<span>{t('claimCta')}</span>
<Link
href={`/${locale}/claim`}
className="text-primary hover:underline font-medium whitespace-nowrap"
>
{t('claimButton')}
</Link>
</div>
{/* Repos + Skills */}
{repos.map((repo) => (
<section key={repo.name} className="mb-8">
<div className="flex items-center gap-3 mb-4 pb-2 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{repo.name}</h2>
<span className="text-xs text-text-muted flex items-center gap-1 bg-surface-elevated px-2 py-0.5 rounded-full">
{t('repo.skills', { count: repo.skills.length })}
</span>
<a
href={`https://github.com/${username}/${repo.name}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-text-muted hover:text-primary flex items-center gap-1 ms-auto transition-colors"
>
<ExternalLink className="w-3 h-3" /> {t('repo.viewOnGithub')}
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{repo.skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
</section>
))}
{/* Pagination */}
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
{/* Browse CTA - link to owner page and CLI for owners with long enough usernames */}
<div className="bg-surface-elevated border border-border rounded-xl p-6 mt-8 text-center">
<p className="text-sm text-text-secondary mb-3">
{t('installCta')}
</p>
{skills.length > 0 && (
<code className="bg-surface border border-border px-4 py-2 rounded-lg text-sm font-mono text-text-primary" dir="ltr">
npx skillhub install {skills[0].id}
</code>
)}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Search, ArrowLeft, ArrowRight, Download, Users, Layers, Sparkles, Terminal, Zap } from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { HeroSearch } from '@/components/HeroSearch';
import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Get stats directly from database
async function getStats() {
try {
const db = createDb();
// Get total skills count (SKILL.md only - real reusable skills)
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false`);
const totalSkills = skillsResult[0]?.count ?? 0;
// Get total downloads
const downloadsResult = await db
.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills);
const totalDownloads = downloadsResult[0]?.sum ?? 0;
// Get total categories
const categories = await categoryQueries.getAll(db);
const totalCategories = categories.length;
// Get unique contributors (github owners)
const contributorsResult = await db
.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills);
const totalContributors = contributorsResult[0]?.count ?? 0;
return {
totalSkills,
totalDownloads,
totalCategories,
totalContributors,
platforms: 5,
};
} catch (error) {
console.error('Error fetching stats:', error);
return null;
}
}
// Get featured skills directly from database
async function getFeaturedSkills() {
try {
const db = createDb();
// Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6);
if (featuredSkills.length === 0) {
// Fallback to adaptive popularity with owner/repo diversity
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
}
return featuredSkills;
} catch (error) {
console.error('Error fetching featured skills:', error);
return [];
}
}
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('home');
const tCommon = await getTranslations('common');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
// Fetch real data
const [statsData, featuredSkills] = await Promise.all([
getStats(),
getFeaturedSkills(),
]);
const stats = [
{ value: statsData ? formatCompactNumber(statsData.totalSkills, locale) : '۰', label: t('stats.skills'), icon: Layers },
{ value: statsData ? formatCompactNumber(statsData.totalDownloads, locale) : '۰', label: t('stats.downloads'), icon: Download },
{ value: statsData ? formatCompactNumber(statsData.totalContributors, locale) : '۰', label: t('stats.contributors'), icon: Users },
{ value: statsData ? formatCompactNumber(statsData.totalCategories || 8, locale) : '۸', label: t('stats.categories'), icon: Sparkles },
];
const steps = [
{
icon: Search,
title: t('howItWorks.step1.title'),
description: t('howItWorks.step1.description'),
},
{
icon: Terminal,
title: t('howItWorks.step2.title'),
description: t('howItWorks.step2.description'),
},
{
icon: Zap,
title: t('howItWorks.step3.title'),
description: t('howItWorks.step3.description'),
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-subtle">
<div className="container-main py-20 lg:py-32">
<div className="max-w-3xl mx-auto text-center">
{/* Tagline */}
<p className="hero-tagline mb-4 animate-fade-up">
{t('hero.tagline')}
</p>
{/* Title */}
<h1 className="hero-title mb-6 animate-fade-up animation-delay-100 whitespace-pre-line">
{t('hero.title')}
</h1>
{/* Subtitle */}
<p className="hero-subtitle mb-8 animate-fade-up animation-delay-200">
{t('hero.subtitle')}
</p>
{/* Search - Client Component */}
<HeroSearch
placeholder={t('hero.searchPlaceholder')}
locale={locale}
/>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center animate-fade-up animation-delay-400">
<Link href={`/${locale}/browse`} className="btn-primary gap-2">
{t('hero.cta')}
<ArrowIcon className="w-4 h-4" />
</Link>
<Link href={`/${locale}/docs/getting-started`} className="btn-secondary">
{t('hero.ctaSecondary')}
</Link>
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/4 start-1/4 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 end-1/4 w-96 h-96 bg-gold/20 rounded-full blur-3xl" />
</div>
</section>
{/* Stats Section */}
<section className="py-12 bg-surface border-y border-border">
<div className="container-main">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={index} className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary-50 text-primary-600 mb-3">
<stat.icon className="w-6 h-6" />
</div>
<div className="text-3xl font-bold text-text-primary ltr-nums mb-1">
{stat.value}
</div>
<div className="text-text-secondary">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
{/* Featured Skills */}
<section className="section bg-surface">
<div className="container-main">
<div className="text-center mb-12">
<h2 className="section-title">{t('featured.title')}</h2>
<p className="section-subtitle">{t('featured.subtitle')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredSkills.length > 0 ? (
featuredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))
) : (
// Fallback placeholder cards if no skills
[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-6 bg-surface-subtle rounded mb-2 w-1/3"></div>
<div className="h-4 bg-surface-subtle rounded mb-4 w-2/3"></div>
<div className="flex gap-4">
<div className="h-4 bg-surface-subtle rounded w-16"></div>
<div className="h-4 bg-surface-subtle rounded w-16"></div>
</div>
</div>
))
)}
</div>
<div className="text-center mt-8">
<Link href={`/${locale}/featured`} className="btn-secondary gap-2">
{tCommon('viewAll')}
<ArrowIcon className="w-4 h-4" />
</Link>
</div>
</div>
</section>
{/* How It Works */}
<section className="section bg-surface-muted">
<div className="container-main">
<div className="text-center mb-12">
<h2 className="section-title">{t('howItWorks.title')}</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{steps.map((step, index) => (
<div key={index} className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-primary text-white mb-6 shadow-primary">
<step.icon className="w-8 h-8" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-3">
{step.title}
</h3>
<p className="text-text-secondary">
{step.description}
</p>
</div>
))}
</div>
{/* CLI Example */}
<div className="max-w-2xl mx-auto mt-12" dir="ltr">
<div className="glass-card p-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-error" />
<div className="w-3 h-3 rounded-full bg-warning" />
<div className="w-3 h-3 rounded-full bg-success" />
</div>
<code className="block text-sm font-mono text-text-primary text-start">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf
</code>
<code className="block text-sm font-mono text-success mt-2 text-start">
Skill installed to ~/.claude/skills/pdf/
</code>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Shield, Database, Cookie, Users, Lock, Mail, ArrowRight, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
const sectionIcons = {
dataCollected: Database,
cookies: Cookie,
thirdParty: Users,
retention: Lock,
rights: Shield,
contact: Mail,
};
export default async function PrivacyPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('privacy');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const sections = [
'dataCollected',
'cookies',
'thirdParty',
'retention',
'rights',
'contact',
] as const;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
<p className="text-sm text-text-muted mt-4">{t('lastUpdated')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
<div className="space-y-8">
{sections.map((sectionKey) => {
const Icon = sectionIcons[sectionKey];
const showClaimLink = sectionKey === 'rights';
return (
<div key={sectionKey} className="card p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-text-primary mb-2">
{t(`sections.${sectionKey}.title`)}
</h2>
<p className="text-text-secondary" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
{t(`sections.${sectionKey}.description`)}
</p>
{showClaimLink && (
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-1 mt-3 text-primary-600 hover:text-primary-700 text-sm font-medium transition-colors"
>
{locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'}
<ArrowIcon className="w-3 h-3" />
</Link>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,518 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import {
Star, Download, Shield, CheckCircle, Copy,
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FavoriteButton } from '@/components/FavoriteButton';
import { RatingStars } from '@/components/RatingStars';
import { InstallSection } from '@/components/InstallSection';
import { ShareButton } from '@/components/ShareButton';
import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number';
import { shouldCountView } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>;
}
// Get skill directly from database
async function getSkill(skillId: string) {
try {
const db = createDb();
return await skillQueries.getById(db, skillId);
} catch (error) {
console.error('Error fetching skill:', error);
return null;
}
}
export default async function SkillPage({ params }: SkillPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
const t = await getTranslations('skill');
const tCommon = await getTranslations('common');
const skillId = id.join('/');
const isRTL = locale === 'fa';
// Get skill from database
const dbSkill = await getSkill(skillId);
if (!dbSkill) {
notFound();
}
// Check if skill is blocked (removed by owner request)
if (dbSkill.isBlocked) {
notFound();
}
// Track view count with IP-based rate limiting (1 hour cooldown per IP)
// Get client IP from headers (works with Cloudflare, nginx, etc.)
const headersList = await headers();
const clientIp =
headersList.get('cf-connecting-ip') ||
headersList.get('x-real-ip') ||
headersList.get('x-forwarded-for')?.split(',')[0].trim() ||
'unknown';
// Only increment on primary server (mirror DB is read-only)
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (isPrimary) {
const db = createDb();
shouldCountView(dbSkill.id, clientIp).then((shouldCount) => {
if (shouldCount) {
skillQueries.incrementViews(db, dbSkill.id).catch(() => {});
}
}).catch(() => {});
}
// Map database response to expected format
const skill = {
id: dbSkill.id,
name: dbSkill.name,
description: dbSkill.description,
longDescription: dbSkill.rawContent || dbSkill.description,
version: dbSkill.version || null,
license: dbSkill.license || 'MIT',
author: dbSkill.githubOwner,
repo: dbSkill.githubRepo,
repository: `https://github.com/${dbSkill.githubOwner}/${dbSkill.githubRepo}`,
homepage: dbSkill.homepage || null,
stars: dbSkill.githubStars || 0,
downloads: dbSkill.downloadCount || 0,
views: dbSkill.viewCount || 0,
securityStatus: dbSkill.securityStatus || 'pass',
isVerified: dbSkill.isVerified || false,
createdAt: dbSkill.createdAt,
updatedAt: dbSkill.updatedAt ? dbSkill.updatedAt.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') : 'N/A',
rating: dbSkill.rating || 0,
ratingCount: dbSkill.ratingCount || 0,
sourceFormat: dbSkill.sourceFormat || 'skill.md',
};
// Content section title based on source format (uses FORMAT_LABELS from skillhub-core)
const getContentTitle = (format: string) => {
const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || FORMAT_LABELS['skill.md'];
if (format === 'copilot-instructions') {
return isRTL ? 'دستورالعمل Copilot' : label;
}
return isRTL ? `محتوای ${label}` : `${label} Content`;
};
// Source format badge configuration (for non-SKILL.md formats)
const FORMAT_PLATFORMS: Record<string, string> = {
'agents.md': 'Codex',
'cursorrules': 'Cursor',
'windsurfrules': 'Windsurf',
'copilot-instructions': 'Copilot',
};
const getSourceFormatBadge = (format: string) => {
const platform = FORMAT_PLATFORMS[format];
if (!platform) return null;
const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || format;
return { label, platform };
};
const sourceFormatBadge = getSourceFormatBadge(skill.sourceFormat);
const getSecurityConfig = (status: string) => {
switch (status) {
case 'pass': return {
label: t('security.pass'),
icon: '✓',
bg: 'bg-success/10',
text: 'text-success',
border: 'border-success/20'
};
case 'warning': return {
label: t('security.warning'),
icon: '⚠',
bg: 'bg-warning/10',
text: 'text-warning',
border: 'border-warning/20'
};
case 'fail': return {
label: t('security.fail'),
icon: '✕',
bg: 'bg-error/10',
text: 'text-error',
border: 'border-error/20'
};
default: return {
label: t('security.pass'),
icon: '✓',
bg: 'bg-success/10',
text: 'text-success',
border: 'border-success/20'
};
}
};
const securityConfig = getSecurityConfig(skill.securityStatus);
const installCommands = {
claude: {
cli: `npx skillhub install ${skillId}`,
path: `~/.claude/skills/${skill.name}/`,
},
codex: {
cli: `npx skillhub install ${skillId} --platform codex`,
path: `~/.codex/skills/${skill.name}/`,
},
copilot: {
cli: `npx skillhub install ${skillId} --platform copilot`,
path: `.github/instructions/${skill.name}.instructions.md`,
},
cursor: {
cli: `npx skillhub install ${skillId} --platform cursor`,
path: `.cursor/rules/${skill.name}.mdc`,
},
windsurf: {
cli: `npx skillhub install ${skillId} --platform windsurf`,
path: `.windsurf/rules/${skill.name}.md`,
},
};
return (
<div className="min-h-screen flex flex-col bg-surface">
<Header />
<main className="flex-1">
{/* Hero Section */}
<div className="bg-gradient-subtle border-b border-border">
<div className="container-main py-6 lg:py-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-text-muted mb-6">
<Link href={`/${locale}/browse`} className="hover:text-primary-600 transition-colors">
{isRTL ? 'مرور' : 'Browse'}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-text-primary font-medium">{skill.name}</span>
</nav>
{/* Main Header */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
{/* Left: Title & Description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-3 flex-wrap">
<h1 className="text-3xl lg:text-4xl font-bold text-text-primary">
{skill.name}
</h1>
{skill.isVerified && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-success/10 text-success text-sm font-medium rounded-full border border-success/20">
<CheckCircle className="w-4 h-4" />
{tCommon('verified')}
</span>
)}
<span className={`inline-flex items-center gap-1.5 px-3 py-1 ${securityConfig.bg} ${securityConfig.text} text-sm font-medium rounded-full border ${securityConfig.border}`}>
<Shield className="w-4 h-4" />
{securityConfig.label}
</span>
{sourceFormatBadge && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-primary-50 text-primary-700 text-sm font-medium rounded-full border border-primary-200 dark:bg-primary-900/20 dark:text-primary-400 dark:border-primary-800">
{sourceFormatBadge.platform}
</span>
)}
</div>
<p className="text-lg text-text-secondary mb-4 max-w-2xl" dir="auto">
{skill.description}
</p>
{/* Author, Version, License & Last Update */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<a
href={`https://github.com/${skill.author}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-text-secondary hover:text-primary-600 transition-colors"
>
<div className="w-6 h-6 rounded-full bg-surface-subtle flex items-center justify-center">
<User className="w-4 h-4" />
</div>
<span className="font-medium">@{skill.author}</span>
</a>
{skill.version && (
<>
<span className="text-text-muted"></span>
<span className="flex items-center gap-1.5 text-text-muted">
<Tag className="w-4 h-4" />
<span className="ltr-nums">v{skill.version}</span>
</span>
</>
)}
<span className="text-text-muted"></span>
<span className="text-text-muted bg-surface-subtle px-2 py-0.5 rounded">
{skill.license}
</span>
<span className="text-text-muted"></span>
<span className="flex items-center gap-1.5 text-text-muted">
<Calendar className="w-4 h-4" />
<span className="ltr-nums">{skill.updatedAt}</span>
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-3">
<FavoriteButton skillId={skill.id} size="lg" showLabel={true} />
<ShareButton
title={skill.name}
path={`/${locale}/skill/${skill.id}`}
translations={{
share: t('share.button'),
copied: t('share.copied'),
copyLink: t('share.copyLink'),
}}
/>
<a
href={skill.repository}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-xl bg-surface-elevated hover:bg-surface-subtle border border-border text-text-secondary hover:text-text-primary transition-colors"
title="GitHub"
>
<Github className="w-5 h-5" />
</a>
</div>
</div>
</div>
</div>
{/* Project Configuration Warning Banner (non-SKILL.md) */}
{sourceFormatBadge && (
<div className="bg-warning/10 border-b border-warning/20">
<div className="container-main py-3">
<div className="flex items-start gap-3 text-sm">
<span className="text-warning text-lg flex-shrink-0"></span>
<div>
<p className="text-warning-foreground dark:text-warning" dir="auto">
{isRTL
? `این یک فایل پیکربندی اختصاصی پروژه (${sourceFormatBadge.label}) است، نه یک مهارت عامل قابل استفاده مجدد. حاوی دستورالعمل‌هایی است که برای مخزن ${skill.repo} طراحی شده و ممکن است در پروژه‌های دیگر کاربردی نباشد.`
: `This is a project-specific configuration file (${sourceFormatBadge.label}), not a reusable Agent Skill. It contains instructions designed for the ${skill.repo} repository and may not be applicable to other projects.`}
</p>
<Link
href={`/${locale}/browse`}
className="inline-flex items-center gap-1 mt-1 text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
>
{isRTL ? 'مرور مهارت‌های قابل استفاده مجدد' : 'Browse reusable skills'}
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</div>
</div>
</div>
)}
{/* Stats Bar */}
<div className="bg-surface-elevated border-b border-border">
<div className="container-main">
<div className="flex items-center gap-6 lg:gap-10 py-4 overflow-x-auto">
<div className="flex items-center gap-2 min-w-fit">
<RatingStars
skillId={skill.id}
averageRating={skill.rating}
ratingCount={skill.ratingCount}
size="sm"
/>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Star className="w-5 h-5 text-gold" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.stars, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('stars')}</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Download className="w-5 h-5 text-primary-500" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.downloads, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('downloads')}</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Eye className="w-5 h-5 text-text-muted" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.views, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('views')}</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="container-main py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: README Content */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Install (Mobile) */}
<div className="lg:hidden">
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
</div>
{/* README Section */}
<div className="bg-surface-elevated rounded-2xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-surface-subtle/50">
<h2 className="font-semibold text-text-primary flex items-center gap-2">
<Copy className="w-4 h-4" />
{getContentTitle(skill.sourceFormat)}
</h2>
</div>
<div className="p-6">
<div className="prose prose-slate dark:prose-invert max-w-none" dir="auto">
<pre className="whitespace-pre-wrap text-sm leading-relaxed bg-surface-subtle rounded-xl p-4 overflow-x-auto text-start border border-border">
<code>{skill.longDescription}</code>
</pre>
</div>
</div>
</div>
{/* Source Links (Mobile) */}
<div className="lg:hidden bg-surface-elevated rounded-2xl border border-border p-6">
<h3 className="font-semibold text-text-primary mb-4">
{isRTL ? 'لینک‌ها' : 'Links'}
</h3>
<div className="space-y-3">
<a
href={skill.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-xl bg-surface-subtle hover:bg-surface-muted transition-colors group"
>
<div className="flex items-center gap-3">
<Github className="w-5 h-5 text-text-muted" />
<div>
<div className="font-medium text-text-primary">{t('meta.repository')}</div>
<div className="text-sm text-text-muted">{skill.author}/{skill.repo}</div>
</div>
</div>
<ExternalLink className="w-4 h-4 text-text-muted group-hover:text-primary-600 transition-colors" />
</a>
{skill.homepage && (
<a
href={skill.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-xl bg-surface-subtle hover:bg-surface-muted transition-colors group"
>
<div className="flex items-center gap-3">
<ExternalLink className="w-5 h-5 text-text-muted" />
<div>
<div className="font-medium text-text-primary">{t('meta.homepage')}</div>
<div className="text-sm text-text-muted truncate max-w-[200px]">{skill.homepage}</div>
</div>
</div>
<ExternalLink className="w-4 h-4 text-text-muted group-hover:text-primary-600 transition-colors" />
</a>
)}
</div>
</div>
</div>
{/* Right: Sidebar (Desktop) */}
<div className="hidden lg:block space-y-6">
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto space-y-6 scrollbar-thin">
{/* Install Section */}
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
{/* Links Card (Desktop) */}
{skill.homepage && (
<div className="bg-surface-elevated rounded-2xl border border-border p-6">
<h3 className="font-semibold text-text-primary mb-4">
{isRTL ? 'لینک‌ها' : 'Links'}
</h3>
<a
href={skill.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-text-secondary hover:text-primary-600 transition-colors py-2"
>
<ExternalLink className="w-4 h-4" />
<span className="flex-1">{t('meta.homepage')}</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Mail, Bitcoin, ExternalLink } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface SupportPageProps {
params: Promise<{ locale: string }>;
}
export default async function SupportPage({ params }: SupportPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('support');
const isRTL = locale === 'fa';
const supportOptions = [
{
icon: Mail,
title: t('email'),
description: t('emailDesc'),
href: 'mailto:hi.airano@gmail.com',
cta: t('sendEmail'),
color: 'bg-surface-subtle text-text-secondary',
},
{
icon: Bitcoin,
title: t('donate'),
description: t('donateDesc'),
href: 'https://nowpayments.io/donation/airano',
cta: t('donateNow'),
color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
},
];
return (
<div className="min-h-screen flex flex-col bg-surface">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
{/* Support Options */}
<section className="section bg-surface">
<div className="container-main">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl mx-auto">
{supportOptions.map((option, index) => (
<a
key={index}
href={option.href}
target="_blank"
rel="noopener noreferrer"
className="card p-6 text-center hover:border-primary-500 border border-border transition-all group"
>
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-xl ${option.color} mb-4`}>
<option.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">
{option.title}
</h3>
<p className="text-text-secondary mb-4" dir={isRTL ? 'rtl' : 'ltr'}>
{option.description}
</p>
<span className="inline-flex items-center gap-2 btn-primary text-sm py-2 px-4 group-hover:scale-105 transition-transform">
{option.cta}
<ExternalLink className="w-4 h-4" />
</span>
</a>
))}
</div>
{/* Why Crypto Only */}
<div className="mt-16 max-w-2xl mx-auto">
<div className="card p-6 bg-surface-subtle border border-border">
<h3 className="text-lg font-semibold text-text-primary mb-3">
{t('whyCryptoTitle')}
</h3>
<p className="text-text-secondary text-sm leading-relaxed" dir={isRTL ? 'rtl' : 'ltr'}>
{t('whyCryptoDesc')}
</p>
</div>
</div>
{/* Crypto Info */}
<div className="mt-8 text-center">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('popularCoins')}
</h3>
<div className="flex flex-wrap justify-center gap-3">
{['Bitcoin', 'Ethereum', 'TON', 'Tron', 'Solana', 'Litecoin', 'Dogecoin'].map((crypto) => (
<span
key={crypto}
className="px-3 py-1.5 bg-surface-subtle text-text-secondary text-sm rounded-full"
>
{crypto}
</span>
))}
</div>
<p className="text-text-muted text-sm mt-4">
{t('andMoreCrypto')}
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FileText, UserCheck, AlertTriangle, Scale, Trash2, RefreshCw, ArrowRight, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
const sectionIcons = {
service: FileText,
responsibilities: UserCheck,
disclaimer: AlertTriangle,
liability: Scale,
takedown: Trash2,
changes: RefreshCw,
};
export default async function TermsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('terms');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const sections = [
'service',
'responsibilities',
'disclaimer',
'liability',
'takedown',
'changes',
] as const;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
<p className="text-sm text-text-muted mt-4">{t('lastUpdated')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
<div className="space-y-8">
{sections.map((sectionKey) => {
const Icon = sectionIcons[sectionKey];
const showClaimLink = sectionKey === 'takedown';
return (
<div key={sectionKey} className="card p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-text-primary mb-2">
{t(`sections.${sectionKey}.title`)}
</h2>
<p className="text-text-secondary" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
{t(`sections.${sectionKey}.description`)}
</p>
{showClaimLink && (
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-1 mt-3 text-primary-600 hover:text-primary-700 text-sm font-medium transition-colors"
>
{locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'}
<ArrowIcon className="w-3 h-3" />
</Link>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,205 @@
/**
* Test helpers for API route tests
*/
import { vi } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Create a mock NextRequest for testing API routes
*/
export function createMockRequest(
url: string,
options?: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
searchParams?: Record<string, string>;
}
): NextRequest {
const { method = 'GET', body, headers = {}, searchParams = {} } = options || {};
// Build URL with search params
const urlObj = new URL(url, 'http://localhost:3000');
Object.entries(searchParams).forEach(([key, value]) => {
urlObj.searchParams.set(key, value);
});
const requestInit: { method: string; headers: Headers; body?: string } = {
method,
headers: new Headers({
'Content-Type': 'application/json',
...headers,
}),
};
if (body && method !== 'GET') {
requestInit.body = JSON.stringify(body);
}
// Cast to any to avoid Next.js RequestInit vs global RequestInit type mismatch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new NextRequest(urlObj, requestInit as any);
}
/**
* Mock authenticated session
*/
export function mockAuthSession(user?: {
githubId: string;
username: string;
email?: string;
avatarUrl?: string;
}) {
const mockAuth = vi.fn().mockResolvedValue(
user
? {
user: {
...user,
name: user.username,
},
}
: null
);
vi.doMock('@/lib/auth', () => ({
auth: mockAuth,
}));
return mockAuth;
}
/**
* Create a mock user for testing
*/
export function createMockUser(overrides: Partial<{
id: string;
githubId: string;
username: string;
displayName: string;
email: string;
avatarUrl: string;
}> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
displayName: 'Test User',
email: 'test@example.com',
avatarUrl: 'https://example.com/avatar.png',
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
...overrides,
};
}
/**
* Create a mock skill for testing
*/
export function createMockSkill(overrides: Partial<{
id: string;
name: string;
description: string;
githubOwner: string;
githubRepo: string;
githubStars: number;
downloadCount: number;
securityScore: number;
isVerified: boolean;
isFeatured: boolean;
rating: number;
ratingCount: number;
compatibility: { platforms?: string[] };
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
skillPath: 'skills/test-skill',
branch: 'main',
version: '1.0.0',
license: 'MIT',
author: 'Test Author',
githubStars: 100,
githubForks: 10,
downloadCount: 50,
viewCount: 200,
rating: 4,
ratingCount: 5,
ratingSum: 20,
securityScore: 85,
isVerified: false,
isFeatured: false,
compatibility: { platforms: ['claude', 'codex'] },
createdAt: new Date(),
updatedAt: new Date(),
indexedAt: new Date(),
...overrides,
};
}
/**
* Create a mock category for testing
*/
export function createMockCategory(overrides: Partial<{
id: string;
name: string;
slug: string;
description: string;
icon: string;
skillCount: number;
sortOrder: number;
}> = {}) {
return {
id: 'cat-1',
name: 'Test Category',
slug: 'test-category',
description: 'A test category',
icon: 'folder',
color: '#3B82F6',
skillCount: 10,
sortOrder: 0,
createdAt: new Date(),
...overrides,
};
}
/**
* Create a mock rating for testing
*/
export function createMockRating(overrides: Partial<{
id: string;
skillId: string;
userId: string;
rating: number;
review: string;
}> = {}) {
return {
id: 'rating-1',
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-123',
rating: 4,
review: 'Great skill!',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/**
* Parse JSON response from NextResponse
*/
export async function parseResponse<T>(response: Response): Promise<{
status: number;
data: T;
}> {
const data = await response.json();
return {
status: response.status,
data,
};
}

View File

@@ -0,0 +1,62 @@
/**
* Test setup for API route tests
*
* This file is loaded before each test file via vitest setupFiles
*/
import { vi } from 'vitest';
// Mock environment variables
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.GITHUB_CLIENT_ID = 'test-client-id';
process.env.GITHUB_CLIENT_SECRET = 'test-client-secret';
process.env.AUTH_SECRET = 'test-auth-secret';
// Mock @skillhub/db module
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getById: vi.fn(),
search: vi.fn(),
getFeatured: vi.fn(),
getTrending: vi.fn(),
getRecent: vi.fn(),
upsert: vi.fn(),
incrementDownloads: vi.fn(),
incrementViews: vi.fn(),
updateRating: vi.fn(),
},
categoryQueries: {
getAll: vi.fn(),
getBySlug: vi.fn(),
getSkills: vi.fn(),
updateSkillCount: vi.fn(),
},
userQueries: {
getByGithubId: vi.fn(),
upsertFromGithub: vi.fn(),
getFavorites: vi.fn(),
getById: vi.fn(),
},
ratingQueries: {
upsert: vi.fn(),
getForSkill: vi.fn(),
getUserRating: vi.fn(),
},
favoriteQueries: {
add: vi.fn(),
remove: vi.fn(),
isFavorited: vi.fn(),
getFavoritedIds: vi.fn(),
},
installationQueries: {
track: vi.fn(),
getStats: vi.fn(),
},
skills: {},
categories: {},
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
sql: strings.join('?'),
values,
})),
}));

View File

@@ -0,0 +1,139 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skills, discoveredRepos, awesomeLists, sql } from '@skillhub/db';
import { getCached, setCache, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface AttributionStats {
totalSkills: number;
totalContributors: number;
totalRepos: number;
awesomeLists: {
count: number;
totalRepos: number;
};
forkNetworks: number;
licenseDistribution: Array<{
license: string;
count: number;
percentage: number;
}>;
discoveryBySource: Array<{
source: string;
count: number;
withSkills: number;
}>;
lastUpdated: string;
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first (cache for 1 hour)
const cacheKey = 'attribution:stats';
const cached = await getCached<AttributionStats>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: {
'X-Cache': 'HIT',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
}
// Get total skills and contributors
const skillStats = await db
.select({
totalSkills: sql<number>`count(*)::int`,
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
})
.from(skills);
// Get license distribution
const licenseStats = await db
.select({
license: sql<string>`coalesce(${skills.license}, 'Unspecified')`,
count: sql<number>`count(*)::int`,
})
.from(skills)
.groupBy(sql`coalesce(${skills.license}, 'Unspecified')`)
.orderBy(sql`count(*) desc`)
.limit(10);
const totalForPercentage = skillStats[0]?.totalSkills ?? 1;
const licenseDistribution = licenseStats.map((l) => ({
license: l.license || 'Unspecified',
count: l.count,
percentage: Math.round((l.count / totalForPercentage) * 100),
}));
// Get discovered repos stats
const repoStats = await db
.select({ count: sql<number>`count(*)::int` })
.from(discoveredRepos);
// Get discovery by source
const bySource = await db
.select({
source: discoveredRepos.discoveredVia,
count: sql<number>`count(*)::int`,
withSkills: sql<number>`sum(case when has_skill_md then 1 else 0 end)::int`,
})
.from(discoveredRepos)
.groupBy(discoveredRepos.discoveredVia);
// Get fork networks count
const forkCount = bySource.find((s) => s.source === 'fork-network');
// Get awesome lists stats
const awesomeStats = await db
.select({
count: sql<number>`count(*)::int`,
totalRepos: sql<number>`coalesce(sum(${awesomeLists.repoCount}), 0)::int`,
})
.from(awesomeLists)
.where(sql`${awesomeLists.isActive} = true`);
const data: AttributionStats = {
totalSkills: skillStats[0]?.totalSkills ?? 0,
totalContributors: skillStats[0]?.totalContributors ?? 0,
totalRepos: repoStats[0]?.count ?? 0,
awesomeLists: {
count: awesomeStats[0]?.count ?? 0,
totalRepos: awesomeStats[0]?.totalRepos ?? 0,
},
forkNetworks: forkCount?.count ?? 0,
licenseDistribution,
discoveryBySource: bySource.map((s) => ({
source: s.source ?? 'unknown',
count: s.count,
withSkills: s.withSkills ?? 0,
})),
lastUpdated: new Date().toISOString(),
};
// Cache the result for 1 hour
await setCache(cacheKey, data, cacheTTL.stats);
return NextResponse.json(data, {
headers: {
'X-Cache': 'MISS',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('Error fetching attribution stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch attribution stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Mock cache - must be before route import
vi.mock('@/lib/cache', () => ({
getCached: vi.fn().mockResolvedValue(null),
setCache: vi.fn().mockResolvedValue(undefined),
cacheKeys: { categories: () => 'categories' },
cacheTTL: { categories: 43200 },
}));
// Create mock category helper
function createMockCategory(overrides: Partial<{
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
skillCount: number | null;
sortOrder: number | null;
color: string | null;
parentId: string | null;
}> = {}) {
return {
id: 'cat-1',
name: 'Test Category',
slug: 'test-category',
description: 'A test category',
icon: 'folder',
color: '#3B82F6',
skillCount: 10,
sortOrder: 0,
parentId: null,
createdAt: new Date(),
...overrides,
};
}
// Mock the db module
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
categoryQueries: {
getLeafCategories: vi.fn(),
},
}));
import { GET } from './route';
// Import after mocking
import { categoryQueries } from '@skillhub/db';
// Helper to create mock request
function createMockRequest(url = 'http://localhost:3000/api/categories') {
return new NextRequest(url);
}
describe('GET /api/categories', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all categories', async () => {
const mockCategories = [
createMockCategory({ id: 'cat-1', name: 'Development', slug: 'development', skillCount: 20 }),
createMockCategory({ id: 'cat-2', name: 'Testing', slug: 'testing', skillCount: 15 }),
];
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.categories).toBeDefined();
expect(Array.isArray(data.categories)).toBe(true);
expect(data.categories.length).toBe(2);
});
it('should include skillCount for each category', async () => {
const mockCategories = [
createMockCategory({ skillCount: 25 }),
];
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.categories[0].skillCount).toBeDefined();
expect(typeof data.categories[0].skillCount).toBe('number');
});
it('should handle database errors gracefully', async () => {
vi.mocked(categoryQueries.getLeafCategories).mockRejectedValue(new Error('Database error'));
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
it('should return empty array when no categories exist', async () => {
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue([]);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.categories).toBeDefined();
expect(Array.isArray(data.categories)).toBe(true);
expect(data.categories.length).toBe(0);
});
});

View File

@@ -0,0 +1,67 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, categoryQueries } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface CategoryData {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
skillCount: number;
sortOrder: number;
}
interface CategoriesResponse {
categories: CategoryData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first
const cacheKey = cacheKeys.categories();
const cached = await getCached<CategoriesResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
// Get only leaf categories (filter out parent categories)
const categories = await categoryQueries.getLeafCategories(db);
const data: CategoriesResponse = {
categories: categories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
description: cat.description,
icon: cat.icon,
skillCount: cat.skillCount ?? 0,
sortOrder: cat.sortOrder ?? 0,
})),
};
// Cache the result (12 hours - categories rarely change)
await setCache(cacheKey, data, cacheTTL.categories);
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,136 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb, sql } from '@skillhub/db';
// Simple rate limiting
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
const RATE_LIMIT = 5; // 5 requests per minute
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const record = rateLimitMap.get(ip);
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
rateLimitMap.set(ip, { count: 1, timestamp: now });
return true;
}
if (record.count >= RATE_LIMIT) {
return false;
}
record.count++;
return true;
}
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
);
}
const body = await request.json();
const { email, variant, locale, source } = body;
// Validate email
if (!email || typeof email !== 'string' || !email.includes('@')) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Sanitize email
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
// Validate variant
const validVariant = variant === 'a' || variant === 'b' ? variant : 'a';
// Store in database
const db = createDb();
// Create table if not exists (for first-time setup)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS early_access_signups (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
variant VARCHAR(1) NOT NULL DEFAULT 'a',
locale VARCHAR(10),
source VARCHAR(100),
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert or update (upsert)
await db.execute(sql`
INSERT INTO early_access_signups (email, variant, locale, source, ip_address, user_agent)
VALUES (
${sanitizedEmail},
${validVariant},
${locale || null},
${source || 'unknown'},
${ip},
${request.headers.get('user-agent') || null}
)
ON CONFLICT (email) DO UPDATE SET
variant = EXCLUDED.variant,
locale = EXCLUDED.locale,
source = EXCLUDED.source
`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Early access signup error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
// Get signup stats (for internal use)
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const expectedToken = process.env.ADMIN_API_TOKEN;
if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = createDb();
// Get counts by variant
const stats = await db.execute(sql`
SELECT
variant,
COUNT(*) as count,
MAX(created_at) as last_signup
FROM early_access_signups
GROUP BY variant
`) as unknown as { rows: Array<{ variant: string; count: number; last_signup: Date }> };
const totalCount = await db.execute(sql`
SELECT COUNT(*) as total FROM early_access_signups
`) as unknown as { rows: Array<{ total: number }> };
return NextResponse.json({
total: totalCount.rows[0]?.total || 0,
byVariant: stats.rows,
});
} catch (error) {
console.error('Error fetching early access stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, userQueries, favoriteQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function POST(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillIds } = body;
if (!Array.isArray(skillIds)) {
return NextResponse.json({ error: 'skillIds must be an array' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ favorited: {} }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const favoritedIds = await favoriteQueries.getFavoritedIds(db, dbUser.id, skillIds);
const favorited: Record<string, boolean> = {};
skillIds.forEach((id: string) => {
favorited[id] = favoritedIds.includes(id);
});
return NextResponse.json({ favorited }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error checking favorites:', error);
return NextResponse.json({ error: 'Failed to check favorites' }, { status: 500 });
}
}

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
description: string;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
compatibility: { platforms: ['claude'] },
rating: 4,
ratingCount: 5,
...overrides,
};
}
// Helper to create mock user
function createMockUser(overrides: Partial<{ id: string; githubId: string }> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
...overrides,
};
}
// Mock auth
const mockAuth = vi.fn();
vi.mock('@/lib/auth', () => ({
auth: () => mockAuth(),
}));
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
userQueries: {
getByGithubId: vi.fn(),
getFavorites: vi.fn(),
},
skillQueries: {
getById: vi.fn(),
},
favoriteQueries: {
add: vi.fn(),
remove: vi.fn(),
},
}));
import { GET, POST, DELETE } from './route';
import { userQueries, skillQueries, favoriteQueries } from '@skillhub/db';
// Helper to create mock GET request
function createMockGetRequest() {
return new NextRequest('http://localhost:3000/api/favorites');
}
describe('/api/favorites', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return empty array for new user', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(null as any);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.favorites).toEqual([]);
});
it('should return user favorites', async () => {
const mockUser = createMockUser();
const mockFavorites = [
{ skill: createMockSkill({ id: 'skill-1' }) },
{ skill: createMockSkill({ id: 'skill-2' }) },
];
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(userQueries.getFavorites).mockResolvedValue(mockFavorites as any);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.favorites).toHaveLength(2);
});
});
describe('POST', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/favorites', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill' });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return 404 when skill not found', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
const request = createRequest({ skillId: 'nonexistent' });
const response = await POST(request);
await response.json();
expect(response.status).toBe(404);
});
it('should add favorite successfully', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(favoriteQueries.add).mockResolvedValue(undefined);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.favorited).toBe(true);
});
});
describe('DELETE', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/favorites', {
method: 'DELETE',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill' });
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({});
const response = await DELETE(request);
await response.json();
expect(response.status).toBe(400);
});
it('should remove favorite successfully', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(favoriteQueries.remove).mockResolvedValue(undefined);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.favorited).toBe(false);
});
});
});

View File

@@ -0,0 +1,127 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, userQueries, favoriteQueries, skillQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ favorites: [] }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const favorites = await userQueries.getFavorites(db, dbUser.id);
return NextResponse.json({
favorites: favorites.map((f) => ({
id: f.skill.id,
name: f.skill.name,
description: f.skill.description,
githubOwner: f.skill.githubOwner,
githubRepo: f.skill.githubRepo,
githubStars: f.skill.githubStars,
downloadCount: f.skill.downloadCount,
securityStatus: f.skill.securityStatus,
isVerified: f.skill.isVerified,
compatibility: f.skill.compatibility,
rating: f.skill.rating,
ratingCount: f.skill.ratingCount,
})),
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching favorites:', error);
return NextResponse.json({ error: 'Failed to fetch favorites' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
}
await favoriteQueries.add(db, dbUser.id, skillId);
return NextResponse.json({ success: true, favorited: true }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error adding favorite:', error);
return NextResponse.json({ error: 'Failed to add favorite' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
await favoriteQueries.remove(db, dbUser.id, skillId);
return NextResponse.json({ success: true, favorited: false }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error removing favorite:', error);
return NextResponse.json({ error: 'Failed to remove favorite' }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { GET } from './route';
// Mock the database module
vi.mock('@skillhub/db', () => ({
createDb: () => ({
execute: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
}),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
isMeilisearchHealthy: vi.fn().mockResolvedValue(true),
}));
describe('GET /api/health', () => {
it('should return status 200', async () => {
const response = await GET();
expect(response.status).toBe(200);
});
it('should return health status', async () => {
const response = await GET();
const data = await response.json();
expect(['healthy', 'degraded', 'unhealthy']).toContain(data.status);
});
it('should return current timestamp', async () => {
const before = new Date().toISOString();
const response = await GET();
const data = await response.json();
const after = new Date().toISOString();
expect(data.timestamp).toBeDefined();
expect(new Date(data.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime());
expect(new Date(data.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime());
});
it('should return version', async () => {
const response = await GET();
const data = await response.json();
expect(data.version).toBe('0.1.0');
});
it('should return services status', async () => {
const response = await GET();
const data = await response.json();
expect(data.services).toBeDefined();
expect(data.services.database).toBeDefined();
expect(data.services.meilisearch).toBeDefined();
});
});

View File

@@ -0,0 +1,190 @@
import { NextResponse } from 'next/server';
import { createDb, sql, isMeilisearchHealthy } from '@skillhub/db';
import { isCacheAvailable } from '@/lib/cache';
interface ServiceStatus {
status: 'healthy' | 'unhealthy' | 'degraded';
latency?: number;
error?: string;
}
interface ReplicationStatus extends ServiceStatus {
isReplica?: boolean;
lagSeconds?: number;
}
interface GitHubStatus extends ServiceStatus {
// GitHub accessibility status (for OAuth on mirror)
}
interface HealthResponse {
status: 'healthy' | 'unhealthy' | 'degraded';
timestamp: string;
version: string;
isPrimary: boolean;
services: {
database: ServiceStatus;
meilisearch: ServiceStatus;
redis: ServiceStatus;
replication?: ReplicationStatus;
github?: GitHubStatus;
};
}
export async function GET() {
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
const services: HealthResponse['services'] = {
database: { status: 'unhealthy' },
meilisearch: { status: 'unhealthy' },
redis: { status: 'unhealthy' },
};
// Check database connectivity
try {
const dbStart = Date.now();
const db = createDb();
await db.execute(sql`SELECT 1`);
services.database = {
status: 'healthy',
latency: Date.now() - dbStart,
};
} catch (error) {
// Don't expose DATABASE_URL or connection details in error messages
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Sanitize error message - remove any potential credentials
const sanitizedError = errorMessage
.replace(/postgresql:\/\/[^@]*@/gi, 'postgresql://***@')
.replace(/password[=:][^\s&]*/gi, 'password=***');
services.database = {
status: 'unhealthy',
error: process.env.DATABASE_URL ? sanitizedError : 'DATABASE_URL not configured',
};
}
// Check Meilisearch connectivity (optional service)
try {
const meiliStart = Date.now();
const meiliHealthy = await isMeilisearchHealthy();
if (meiliHealthy) {
services.meilisearch = {
status: 'healthy',
latency: Date.now() - meiliStart,
};
} else {
services.meilisearch = {
status: 'degraded',
error: 'Meilisearch not responding or not configured',
};
}
} catch (error) {
services.meilisearch = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
// Check Redis connectivity (optional service for caching and rate limiting)
try {
const redisStart = Date.now();
const redisHealthy = await isCacheAvailable();
if (redisHealthy) {
services.redis = {
status: 'healthy',
latency: Date.now() - redisStart,
};
} else {
services.redis = {
status: 'degraded',
error: 'Redis not responding or not configured',
};
}
} catch (error) {
services.redis = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
// Mirror server only: Check PostgreSQL replication status
if (!isPrimary) {
try {
const db = createDb();
// drizzle-orm/postgres-js returns results directly as an array
const lagResult = await db.execute<{ is_replica: boolean; lag_seconds: number | null }>(sql`
SELECT
pg_is_in_recovery() as is_replica,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds
`);
const row = lagResult[0];
const isReplica = row?.is_replica ?? false;
const lagSeconds = row?.lag_seconds ?? 0;
// Healthy if lag is under 5 minutes (300 seconds)
services.replication = {
status: isReplica && lagSeconds < 300 ? 'healthy' : 'degraded',
isReplica,
lagSeconds,
latency: undefined,
};
} catch (error) {
services.replication = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Failed to check replication status',
isReplica: false,
lagSeconds: undefined,
};
}
// Mirror server only: Check GitHub accessibility (for OAuth)
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const githubStart = Date.now();
const githubCheck = await fetch('https://github.com', {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeoutId);
services.github = {
status: githubCheck.ok ? 'healthy' : 'degraded',
latency: Date.now() - githubStart,
};
} catch {
// GitHub likely filtered/blocked in Iran
services.github = {
status: 'degraded',
error: 'GitHub unreachable (may be filtered)',
};
}
}
// Determine overall status
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
// Database is critical - if it's down, the whole system is unhealthy
if (services.database.status === 'unhealthy') {
overallStatus = 'unhealthy';
}
// Meilisearch and Redis are optional - if either is down, system is degraded but still functional
else if (services.meilisearch.status !== 'healthy' || services.redis.status !== 'healthy') {
overallStatus = 'degraded';
}
const response: HealthResponse = {
status: overallStatus,
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
isPrimary,
services,
};
// Return appropriate HTTP status code for load balancers/orchestrators
// 200 = healthy, 503 = unhealthy (critical service down)
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
return NextResponse.json(response, { status: statusCode });
}

View File

@@ -0,0 +1,114 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb } from '@skillhub/db';
import { emailSubscriptionQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
import { sendNewsletterWelcomeEmail } from '@/lib/email';
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
// Email validation regex (RFC 5322 simplified)
const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
/**
* GET handler for one-click newsletter subscription from email links
* Subscribes and redirects to homepage with confirmation
*/
export async function GET(request: NextRequest) {
try {
const email = request.nextUrl.searchParams.get('email');
const locale = request.nextUrl.searchParams.get('locale') || 'en';
if (!email) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
if (!EMAIL_REGEX.test(sanitizedEmail)) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
const db = createDb();
await emailSubscriptionQueries.subscribe(db, {
email: sanitizedEmail,
source: 'newsletter',
marketingConsent: true,
});
const validLocales = ['en', 'fa'];
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale as 'en' | 'fa').catch((err) => {
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
});
const redirectUrl = new URL('/', PRIMARY_URL);
redirectUrl.searchParams.set('subscribed', 'true');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[Newsletter] Subscribe GET error:', error);
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
}
export async function POST(request: NextRequest) {
try {
// Rate limiting (reuse 'search' tier: 60/min)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult, 'search');
}
const body = await request.json();
const { email, source, marketingConsent, locale } = body;
// Validate email
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
if (!EMAIL_REGEX.test(sanitizedEmail)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Validate source
const validSources = ['newsletter', 'oauth', 'claim', 'early-access'];
const sanitizedSource = validSources.includes(source) ? source : 'newsletter';
// Store in database
const db = createDb();
await emailSubscriptionQueries.subscribe(db, {
email: sanitizedEmail,
source: sanitizedSource,
marketingConsent: Boolean(marketingConsent),
});
// Validate locale
const validLocales = ['en', 'fa'];
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
// Send newsletter welcome email (non-blocking, don't fail if email sending fails)
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale).catch((err) => {
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
});
return NextResponse.json({
success: true,
subscribed: true,
});
} catch (error) {
console.error('[Newsletter] Subscribe error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb } from '@skillhub/db';
import { emailSubscriptionQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
export async function POST(request: NextRequest) {
try {
// Rate limiting (reuse 'search' tier: 60/min)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult, 'search');
}
// POST is already blocked by middleware on mirror servers (503)
const body = await request.json();
const { email } = body;
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
const db = createDb();
await emailSubscriptionQueries.unsubscribe(db, email);
// Always return success to prevent email enumeration
return NextResponse.json({
success: true,
unsubscribed: true,
});
} catch (error) {
console.error('[Newsletter] Unsubscribe error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
/**
* GET handler for one-click unsubscribe links in emails
* Redirects to homepage after unsubscribing
* On mirror servers, redirects to primary server for the unsubscribe action
*/
export async function GET(request: NextRequest) {
try {
const email = request.nextUrl.searchParams.get('email');
if (!email) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
// On mirror server, redirect to primary for write operation
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (!isPrimary) {
return NextResponse.redirect(
`${PRIMARY_URL}/api/newsletter/unsubscribe?email=${encodeURIComponent(email)}`
);
}
const db = createDb();
await emailSubscriptionQueries.unsubscribe(db, email);
// Redirect to homepage with unsubscribe confirmation
const redirectUrl = new URL('/', PRIMARY_URL);
redirectUrl.searchParams.set('unsubscribed', 'true');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[Newsletter] Unsubscribe GET error:', error);
return NextResponse.redirect(new URL('/', request.url));
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, ratingQueries, userQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const skillId = searchParams.get('skillId');
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ rating: null }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const rating = await ratingQueries.getUserRating(db, dbUser.id, skillId);
return NextResponse.json({ rating }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching user rating:', error);
return NextResponse.json({ error: 'Failed to fetch rating' }, { status: 500 });
}
}

View File

@@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
rating: number;
ratingCount: number;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
rating: 4,
ratingCount: 10,
...overrides,
};
}
// Helper to create mock user
function createMockUser(overrides: Partial<{ id: string; githubId: string; username: string }> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
avatarUrl: 'https://example.com/avatar.png',
...overrides,
};
}
// Helper to create mock rating
function createMockRating(overrides: Partial<{
id: string;
rating: number;
review: string;
}> = {}) {
return {
id: 'rating-1',
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-123',
rating: 4,
review: 'Great skill!',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock auth
const mockAuth = vi.fn();
vi.mock('@/lib/auth', () => ({
auth: () => mockAuth(),
}));
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
userQueries: {
getByGithubId: vi.fn(),
},
skillQueries: {
getById: vi.fn(),
},
ratingQueries: {
getForSkill: vi.fn(),
upsert: vi.fn(),
},
}));
import { GET, POST } from './route';
import { userQueries, skillQueries, ratingQueries } from '@skillhub/db';
describe('/api/ratings', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET', () => {
const createRequest = (searchParams: Record<string, string> = {}) => {
const url = new URL('http://localhost:3000/api/ratings');
Object.entries(searchParams).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return new NextRequest(url);
};
it('should return 400 when skillId missing', async () => {
const request = createRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return ratings for skill', async () => {
const mockSkill = createMockSkill();
const mockRatings = [
{
rating: createMockRating({ id: 'rating-1' }),
user: createMockUser(),
},
];
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.ratings).toBeDefined();
expect(Array.isArray(data.ratings)).toBe(true);
});
it('should include user info', async () => {
const mockSkill = createMockSkill();
const mockRatings = [
{
rating: createMockRating(),
user: createMockUser({ username: 'testuser' }),
},
];
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(data.ratings[0].user).toBeDefined();
expect(data.ratings[0].user.username).toBe('testuser');
});
it('should include rating summary', async () => {
const mockSkill = createMockSkill({ rating: 4, ratingCount: 10 });
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(data.summary).toBeDefined();
expect(data.summary.average).toBe(4);
expect(data.summary.count).toBe(10);
});
it('should respect pagination', async () => {
const mockSkill = createMockSkill();
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
limit: '5',
offset: '10',
});
const response = await GET(request);
expect(response.status).toBe(200);
expect(ratingQueries.getForSkill).toHaveBeenCalledWith(
expect.anything(),
'test-owner/test-repo/test-skill',
5,
10
);
});
});
describe('POST', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/ratings', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill', rating: 5 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ rating: 5 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return 400 when rating invalid', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ skillId: 'test-skill', rating: 6 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('between 1 and 5');
});
it('should return 400 when rating is zero', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ skillId: 'test-skill', rating: 0 });
const response = await POST(request);
await response.json();
expect(response.status).toBe(400);
});
it('should return 404 when skill not found', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
const request = createRequest({ skillId: 'nonexistent', rating: 5 });
const response = await POST(request);
await response.json();
expect(response.status).toBe(404);
});
it('should create rating successfully', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill();
const mockRating = createMockRating({ rating: 5 });
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
rating: 5,
review: 'Excellent!',
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.rating).toBeDefined();
expect(data.summary).toBeDefined();
});
it('should update existing rating', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill({ rating: 4, ratingCount: 1 });
const mockRating = createMockRating({ rating: 4 });
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
rating: 4,
});
const response = await POST(request);
await response.json();
expect(response.status).toBe(200);
expect(ratingQueries.upsert).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,122 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { sanitizeReview } from '@/lib/sanitize';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const { searchParams } = new URL(request.url);
const skillId = searchParams.get('skillId');
// Parse and validate pagination parameters
const limitRaw = parseInt(searchParams.get('limit') || '10');
const limit = isNaN(limitRaw) || limitRaw < 1 ? 10 : Math.min(limitRaw, 100);
const offsetRaw = parseInt(searchParams.get('offset') || '0');
const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset);
const skill = await skillQueries.getById(db, skillId);
return NextResponse.json({
ratings: ratings.map((r) => ({
id: r.rating.id,
rating: r.rating.rating,
review: r.rating.review,
createdAt: r.rating.createdAt,
updatedAt: r.rating.updatedAt,
user: {
id: r.user.id,
username: r.user.username,
avatarUrl: r.user.avatarUrl,
},
})),
summary: {
average: skill?.rating || 0,
count: skill?.ratingCount || 0,
},
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching ratings:', error);
return NextResponse.json({ error: 'Failed to fetch ratings' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
// Rate limiting (authenticated tier for POST)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId, rating, review } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
// Validate rating
const ratingValue = parseInt(rating);
if (isNaN(ratingValue) || ratingValue < 1 || ratingValue > 5) {
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
}
// Get database user ID
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
}
// Upsert rating with sanitized review
const result = await ratingQueries.upsert(db, {
skillId,
userId: dbUser.id,
rating: ratingValue,
review: sanitizeReview(review) ?? undefined,
});
// Get updated skill aggregates
const updatedSkill = await skillQueries.getById(db, skillId);
return NextResponse.json({
rating: result,
summary: {
average: updatedSkill?.rating || 0,
count: updatedSkill?.ratingCount || 0,
},
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error creating rating:', error);
return NextResponse.json({ error: 'Failed to create rating' }, { status: 500 });
}
}

View File

@@ -0,0 +1,380 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
// Route configuration for longer timeout (skills with many files)
export const maxDuration = 60; // 60 seconds
// Maximum recursion depth to prevent infinite loops
const MAX_DEPTH = 5;
// Fetch timeout in milliseconds
const FETCH_TIMEOUT = 30000; // 30 seconds
// Create database connection
const db = createDb();
interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
type: 'file' | 'dir';
download_url: string | null;
}
interface SkillFile {
name: string;
path: string;
content: string;
size: number;
isBinary: boolean;
}
interface CachedFiles {
fetchedAt: string;
commitSha: string;
totalSize: number;
items: SkillFile[];
}
/**
* GET /api/skill-files?id=anthropics/skills/pdf
* Returns all files in a skill folder, using cache when available
*
* Flow:
* 1. Check if cached files exist in database
* 2. If cache is valid (commitSha matches), return from cache (FAST)
* 3. If cache is stale or missing, fetch from GitHub
* 4. Save to cache for future requests
* 5. Return files
*/
export async function GET(request: NextRequest) {
// Rate limiting (use search tier as this is an expensive operation)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const skillId = request.nextUrl.searchParams.get('id');
if (!skillId) {
return NextResponse.json(
{ error: 'Missing skill ID parameter', code: 'MISSING_ID' },
{ status: 400 }
);
}
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found', code: 'NOT_FOUND' },
{ status: 404 }
);
}
const { githubOwner, githubRepo, skillPath, branch, commitSha, sourceFormat } = skill;
// === CACHE CHECK ===
// Try to get cached files first (this is the fast path)
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
if (cachedFiles) {
// eslint-disable-next-line no-console
console.log(`[skill-files] Cache HIT for ${skillId}`);
// Note: Download count is NOT incremented here.
// It should only be incremented in /api/skills/install after successful download.
// This endpoint is just for fetching files - the download may still fail
// (e.g., if JSZip fails to load from CDN).
return NextResponse.json({
skillId,
githubOwner,
githubRepo,
skillPath,
branch: branch || 'main',
sourceFormat: sourceFormat || 'skill.md',
files: cachedFiles.items.map(item => ({
name: item.name,
path: item.path,
type: 'file' as const,
size: item.size,
content: item.isBinary ? undefined : item.content,
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
})),
fromCache: true,
cachedAt: cachedFiles.fetchedAt,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
// === CACHE MISS - Fetch from GitHub ===
// eslint-disable-next-line no-console
console.log(`[skill-files] Cache MISS for ${skillId}, fetching from GitHub...`);
// Get GitHub headers with token rotation
const { headers: githubHeaders, token } = await getGitHubHeaders();
// Warn if no GitHub token (limited to 60 req/hr)
if (!token) {
console.warn('No GITHUB_TOKEN configured - limited to 60 requests/hour');
}
// Fetch skill folder contents from GitHub
const files = await fetchSkillFiles(
githubOwner,
githubRepo,
skillPath,
branch || 'main',
0, // Start at depth 0
githubHeaders,
token
);
// === SAVE TO CACHE ===
// Prepare cached files structure
const filesToCache: CachedFiles = {
fetchedAt: new Date().toISOString(),
commitSha: commitSha || 'unknown',
totalSize: files.reduce((sum, f) => sum + f.size, 0),
items: files,
};
// Save to database (async, don't block response)
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
console.error(`[skill-files] Failed to cache files for ${skillId}:`, err);
});
// Note: Download count is NOT incremented here.
// It should only be incremented in /api/skills/install after successful download.
// Return response with files
return NextResponse.json({
skillId,
githubOwner,
githubRepo,
skillPath,
branch: branch || 'main',
sourceFormat: sourceFormat || 'skill.md',
files: files.map(item => ({
name: item.name,
path: item.path,
type: 'file' as const,
size: item.size,
content: item.isBinary ? undefined : item.content,
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
})),
fromCache: false,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching skill files:', error);
// Return specific error codes for different failure types
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
return NextResponse.json(
{ error: 'GitHub API rate limit exceeded. Please try again later.', code: 'RATE_LIMIT' },
{ status: 429 }
);
}
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
return NextResponse.json(
{ error: 'Request timed out. The skill may have too many files.', code: 'TIMEOUT' },
{ status: 504 }
);
}
if (errorMessage.includes('404')) {
return NextResponse.json(
{ error: 'Skill files not found on GitHub', code: 'GITHUB_NOT_FOUND' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to fetch skill files', code: 'FETCH_ERROR' },
{ status: 500 }
);
}
}
/**
* Recursively fetch all files in a skill folder from GitHub
* @param depth - Current recursion depth (max MAX_DEPTH levels)
* @param headers - GitHub API headers (with token rotation)
* @param token - Current token for stats tracking
*/
async function fetchSkillFiles(
owner: string,
repo: string,
path: string,
ref: string,
depth: number = 0,
headers: Record<string, string>,
token: string | null
): Promise<SkillFile[]> {
// Prevent infinite recursion
if (depth > MAX_DEPTH) {
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
return [];
}
const files: SkillFile[] = [];
// Fetch directory contents with timeout
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
// Update token stats for rotation
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
// Check for rate limiting
if (response.status === 403) {
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
throw new Error('GitHub API rate limit exceeded');
}
}
throw new Error(`GitHub API error: ${response.status}`);
}
const contents: GitHubFile[] = await response.json();
// Process each item
for (const item of contents) {
if (item.type === 'file') {
// Determine if file is binary
const isBinary = !isTextFile(item.name);
if (!isBinary && item.size < 1024 * 1024) {
// For text files (< 1MB), fetch content
try {
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content,
size: item.size,
isBinary: false,
});
} catch {
// If content fetch fails, mark as binary (will use download URL)
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else {
// For binary or large files, don't store content (use download URL)
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else if (item.type === 'dir') {
// Recursively fetch subdirectory (with depth limit)
const subPath = `${path}/${item.name}`;
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
// Add subdirectory files with proper paths
for (const subFile of subFiles) {
files.push({
...subFile,
path: `${item.name}/${subFile.path}`,
});
}
}
}
return files;
}
/**
* Fetch file content from GitHub with timeout
*/
async function fetchFileContent(
owner: string,
repo: string,
path: string,
ref: string,
headers: Record<string, string>,
token: string | null
): Promise<string> {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
// Update token stats for rotation
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
}
const data = await response.json();
if (data.content && data.encoding === 'base64') {
return Buffer.from(data.content, 'base64').toString('utf-8');
}
throw new Error(`Invalid file content for: ${path}`);
}
/**
* Check if file is a text file based on extension
* Note: .env files are excluded for security (could contain secrets)
*/
/**
* Known instruction file names that are always plain text
*/
const KNOWN_TEXT_FILENAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md'];
function isTextFile(filename: string): boolean {
// Exclude potentially sensitive files
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
const lowerFilename = filename.toLowerCase();
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
return false;
}
// Known instruction dotfiles that are plain text
if (KNOWN_TEXT_FILENAMES.includes(filename)) {
return true;
}
const textExtensions = [
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
return textExtensions.includes(ext) || !filename.includes('.');
}

View File

@@ -0,0 +1,438 @@
import { type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitHeaders } from '@/lib/rate-limit';
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
import { INSTRUCTION_FILE_PATTERNS } from 'skillhub-core';
import archiver from 'archiver';
import { Readable } from 'stream';
// Route configuration for longer timeout
export const maxDuration = 60;
// Create database connection
const db = createDb();
// Fetch timeout in milliseconds
const FETCH_TIMEOUT = 30000;
// Maximum recursion depth
const MAX_DEPTH = 5;
type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
const VALID_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
// All known main instruction file names
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
type: 'file' | 'dir';
download_url: string | null;
}
interface SkillFile {
name: string;
path: string;
content: string;
size: number;
isBinary: boolean;
}
interface CachedFiles {
fetchedAt: string;
commitSha: string;
totalSize: number;
items: SkillFile[];
}
// --- Platform transformation (mirrors InstallSection.tsx logic) ---
function stripFrontmatter(content: string): { body: string; description?: string; filePatterns?: string[] } {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { body: content };
const yaml = match[1];
const body = match[2].trim();
const descMatch = yaml.match(/^description:\s*(.+)$/m);
const description = descMatch ? descMatch[1].trim() : undefined;
const patternsMatch = yaml.match(/filePatterns:\s*\n((?:\s+-\s+.+\n?)+)/);
let filePatterns: string[] | undefined;
if (patternsMatch) {
filePatterns = patternsMatch[1]
.split('\n')
.map(l => l.replace(/^\s+-\s+/, '').replace(/["']/g, '').trim())
.filter(Boolean);
}
return { body, description, filePatterns };
}
function getPlatformFileName(platform: Platform, skillName: string): string {
switch (platform) {
case 'claude':
case 'codex':
return 'SKILL.md';
case 'cursor':
return `${skillName}.mdc`;
case 'windsurf':
return `${skillName}.md`;
case 'copilot':
return `${skillName}.instructions.md`;
}
}
function transformContent(platform: Platform, content: string, skillName: string): string {
if (platform === 'claude' || platform === 'codex') return content;
const { body, description, filePatterns } = stripFrontmatter(content);
if (platform === 'cursor') {
const mdcFields: string[] = [];
if (description) mdcFields.push(`description: ${description}`);
if (filePatterns && filePatterns.length > 0) {
mdcFields.push(`globs: ${filePatterns.join(', ')}`);
mdcFields.push('alwaysApply: false');
} else {
mdcFields.push('alwaysApply: true');
}
return `---\n${mdcFields.join('\n')}\n---\n${body}\n`;
}
// windsurf / copilot: plain markdown
let plainBody = body;
if (!plainBody.startsWith('# ')) {
plainBody = `# ${skillName}\n\n${plainBody}`;
}
return plainBody + '\n';
}
function isMainInstructionFile(fileName: string): boolean {
return MAIN_FILE_NAMES.includes(fileName);
}
/**
* GET /api/skill-files/zip?id=owner/repo/skill-name&platform=cursor
* Returns a ZIP file containing all skill files, transformed for the target platform
*/
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded', code: 'RATE_LIMIT' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...createRateLimitHeaders(rateLimitResult),
},
}
);
}
try {
const skillId = request.nextUrl.searchParams.get('id');
const platformParam = request.nextUrl.searchParams.get('platform');
const platform: Platform = (platformParam && VALID_PLATFORMS.includes(platformParam as Platform))
? platformParam as Platform
: 'claude';
if (!skillId) {
return new Response(
JSON.stringify({ error: 'Missing skill ID parameter', code: 'MISSING_ID' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return new Response(
JSON.stringify({ error: 'Skill not found', code: 'NOT_FOUND' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const { githubOwner, githubRepo, skillPath, branch, name: skillName } = skill;
// Try to get cached files first
let files: SkillFile[];
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
if (cachedFiles) {
// eslint-disable-next-line no-console
console.log(`[skill-files/zip] Cache HIT for ${skillId}`);
files = cachedFiles.items;
} else {
// Fetch from GitHub
// eslint-disable-next-line no-console
console.log(`[skill-files/zip] Cache MISS for ${skillId}, fetching from GitHub...`);
const { headers: githubHeaders, token } = await getGitHubHeaders();
files = await fetchSkillFiles(
githubOwner,
githubRepo,
skillPath,
branch || 'main',
0,
githubHeaders,
token
);
// Save to cache (async, don't block)
const filesToCache: CachedFiles = {
fetchedAt: new Date().toISOString(),
commitSha: skill.commitSha || 'unknown',
totalSize: files.reduce((sum, f) => sum + f.size, 0),
items: files,
};
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
console.error(`[skill-files/zip] Failed to cache files for ${skillId}:`, err);
});
}
if (files.length === 0) {
return new Response(
JSON.stringify({ error: 'No files found in skill', code: 'NO_FILES' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Determine if this is a flat-file platform
const isFlatPlatform = ['cursor', 'windsurf', 'copilot'].includes(platform);
// Create ZIP archive using streaming
const archive = archiver('zip', { zlib: { level: 6 } });
// Add files to archive with platform-specific transformation
for (const file of files) {
if (file.content && !file.isBinary) {
const isMainFile = isMainInstructionFile(file.name) && file.path === file.name;
if (isMainFile) {
const platformFileName = getPlatformFileName(platform, skillName);
const transformed = transformContent(platform, file.content, skillName);
if (isFlatPlatform) {
archive.append(transformed, { name: platformFileName });
} else {
archive.append(transformed, { name: `${skillName}/${platformFileName}` });
}
} else {
// Supporting files always go in subfolder
archive.append(file.content, { name: `${skillName}/${file.path}` });
}
} else if (file.isBinary) {
// For binary files, we need to fetch them
const downloadUrl = `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${file.path}`;
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), { name: `${skillName}/${file.path}` });
}
} catch {
console.warn(`[skill-files/zip] Failed to fetch binary file: ${file.path}`);
}
}
}
// Finalize archive
archive.finalize();
// Convert Node.js stream to Web ReadableStream
const webStream = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
// Sanitize filename for Content-Disposition header
const safeFileName = skillName.replace(/[^a-zA-Z0-9_-]/g, '_');
return new Response(webStream, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${safeFileName}.zip"`,
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('[skill-files/zip] Error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
return new Response(
JSON.stringify({ error: 'GitHub API rate limit exceeded', code: 'RATE_LIMIT' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
);
}
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
return new Response(
JSON.stringify({ error: 'Request timed out', code: 'TIMEOUT' }),
{ status: 504, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ error: 'Failed to generate ZIP', code: 'INTERNAL_ERROR' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
/**
* Recursively fetch all files in a skill folder from GitHub
*/
async function fetchSkillFiles(
owner: string,
repo: string,
path: string,
ref: string,
depth: number = 0,
headers: Record<string, string>,
token: string | null
): Promise<SkillFile[]> {
if (depth > MAX_DEPTH) {
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
return [];
}
const files: SkillFile[] = [];
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
if (response.status === 403) {
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
throw new Error('GitHub API rate limit exceeded');
}
}
throw new Error(`GitHub API error: ${response.status}`);
}
const contents: GitHubFile[] = await response.json();
for (const item of contents) {
if (item.type === 'file') {
const isBinary = !isTextFile(item.name);
if (!isBinary && item.size < 1024 * 1024) {
try {
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content,
size: item.size,
isBinary: false,
});
} catch {
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else {
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else if (item.type === 'dir') {
const subPath = `${path}/${item.name}`;
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
for (const subFile of subFiles) {
files.push({
...subFile,
path: `${item.name}/${subFile.path}`,
});
}
}
}
return files;
}
/**
* Fetch file content from GitHub
*/
async function fetchFileContent(
owner: string,
repo: string,
path: string,
ref: string,
headers: Record<string, string>,
token: string | null
): Promise<string> {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
}
const data = await response.json();
if (data.content && data.encoding === 'base64') {
return Buffer.from(data.content, 'base64').toString('utf-8');
}
throw new Error(`Invalid file content for: ${path}`);
}
/**
* Check if file is a text file based on extension
*/
function isTextFile(filename: string): boolean {
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
const lowerFilename = filename.toLowerCase();
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
return false;
}
// Known instruction dotfiles that are plain text
if (MAIN_FILE_NAMES.includes(filename)) {
return true;
}
const textExtensions = [
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
return textExtensions.includes(ext) || !filename.includes('.');
}

View File

@@ -0,0 +1,93 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { shouldCountView } from '@/lib/cache';
// Create database connection
const db = createDb();
/**
* Get client IP from request headers
* Handles various proxy headers (Cloudflare, nginx, etc.)
*/
function getClientIp(request: NextRequest): string {
// Try various headers in order of preference
const cfConnectingIp = request.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp;
const xRealIp = request.headers.get('x-real-ip');
if (xRealIp) return xRealIp;
const xForwardedFor = request.headers.get('x-forwarded-for');
if (xForwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return xForwardedFor.split(',')[0].trim();
}
// Fallback to a default (shouldn't happen in production)
return 'unknown';
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string[] }> }
) {
try {
const { id } = await params;
const skillId = id.join('/');
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found' },
{ status: 404 }
);
}
// Increment view count only on primary server (mirror DB is read-only)
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (isPrimary) {
const clientIp = getClientIp(request);
const shouldCount = await shouldCountView(skillId, clientIp);
if (shouldCount) {
await skillQueries.incrementViews(db, skillId);
}
}
return NextResponse.json({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
skillPath: skill.skillPath,
branch: skill.branch,
version: skill.version,
license: skill.license,
author: skill.author,
homepage: skill.homepage,
githubStars: skill.githubStars,
githubForks: skill.githubForks,
downloadCount: skill.downloadCount,
viewCount: skill.viewCount,
securityScore: skill.securityScore,
isVerified: skill.isVerified,
isFeatured: skill.isFeatured,
compatibility: skill.compatibility,
triggers: skill.triggers,
rawContent: skill.rawContent,
sourceFormat: skill.sourceFormat || 'skill.md',
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
indexedAt: skill.indexedAt,
});
} catch (error) {
console.error('Error fetching skill:', error);
return NextResponse.json(
{ error: 'Failed to fetch skill' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,429 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createDb, addRequestQueries, userQueries } from '@skillhub/db';
import { sanitizeReason } from '@/lib/sanitize';
import { sendClaimSubmittedEmail } from '@/lib/email';
export const dynamic = 'force-dynamic';
const db = createDb();
// Parse GitHub URL to extract owner and repo
function parseGitHubUrl(url: string): { owner: string; repo: string; path?: string } | null {
try {
const urlObj = new URL(url);
if (!urlObj.hostname.includes('github.com')) {
return null;
}
// Handle various GitHub URL formats:
// https://github.com/owner/repo
// https://github.com/owner/repo/tree/main/path/to/skill
// https://github.com/owner/repo/blob/main/SKILL.md
const pathParts = urlObj.pathname.split('/').filter(Boolean);
if (pathParts.length < 2) {
return null;
}
const owner = pathParts[0];
const repo = pathParts[1];
// Extract path if present (after tree/branch or blob/branch)
let skillPath: string | undefined;
if (pathParts.length > 3 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) {
// Skip 'tree' or 'blob' and branch name
const remainingPath = pathParts.slice(4).join('/');
if (remainingPath && !remainingPath.endsWith('.md')) {
skillPath = remainingPath;
}
}
return { owner, repo, path: skillPath };
} catch {
return null;
}
}
// Find all SKILL.md files in a repository using GitHub Tree API
async function findSkillMdFiles(
owner: string,
repo: string,
defaultBranch: string
): Promise<string[]> {
try {
// Get the full repository tree recursively
const treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(30000),
}
);
if (!treeResponse.ok) {
console.error(`Failed to fetch repository tree (HTTP ${treeResponse.status}):`, treeResponse.statusText);
return [];
}
const treeData = await treeResponse.json() as {
tree: Array<{ path: string; type: string }>;
truncated?: boolean;
};
if (treeData.truncated) {
console.warn(`Repository tree for ${owner}/${repo} is truncated - some SKILL.md files may be missed`);
}
// Find all SKILL.md files (case-sensitive)
const skillMdPaths = treeData.tree
.filter((item) => item.type === 'blob' && item.path.endsWith('/SKILL.md'))
.map((item) => {
// Extract the directory path (remove /SKILL.md)
const parts = item.path.split('/');
parts.pop(); // Remove SKILL.md
return parts.join('/');
});
// Also check for SKILL.md at root level
const hasRootSkillMd = treeData.tree.some(
(item) => item.type === 'blob' && item.path === 'SKILL.md'
);
if (hasRootSkillMd) {
skillMdPaths.unshift(''); // Empty string means root
}
return skillMdPaths;
} catch (error) {
console.error('Error finding SKILL.md files:', error);
return [];
}
}
// Validate GitHub repository exists and check for SKILL.md
async function validateGitHubRepo(
owner: string,
repo: string,
skillPath?: string
): Promise<{
valid: boolean;
hasSkillMd: boolean;
skillPaths: string[];
defaultBranch: string;
error?: string;
}> {
try {
// Check if repository exists and get default branch
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(10000),
}
);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Repository not found. Please check the URL and ensure the repository exists.'
};
}
if (repoResponse.status === 403) {
// Check if it's rate limit or forbidden access
const rateLimitRemaining = repoResponse.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'GitHub API rate limit exceeded. Please try again later.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Repository is private or you do not have access. Please ensure the repository is public.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: `Failed to access repository (HTTP ${repoResponse.status})`
};
}
const repoData = await repoResponse.json() as { default_branch: string; private?: boolean };
const defaultBranch = repoData.default_branch || 'main';
// Additional check for private repos (in case 200 but private)
if (repoData.private) {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch,
error: 'Repository is private. SkillHub only indexes public repositories.'
};
}
// If a specific path is provided, only check that path
if (skillPath) {
const skillMdPath = `${skillPath}/SKILL.md`;
const fileResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${skillMdPath}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(10000),
}
);
const hasSkillMd = fileResponse.ok;
return {
valid: true,
hasSkillMd,
skillPaths: hasSkillMd ? [skillPath] : [],
defaultBranch,
};
}
// No specific path - do a deep scan for all SKILL.md files
const skillPaths = await findSkillMdFiles(owner, repo, defaultBranch);
return {
valid: true,
hasSkillMd: skillPaths.length > 0,
skillPaths,
defaultBranch,
};
} catch (error) {
console.error('GitHub API error:', error);
// Distinguish timeout errors
if (error instanceof Error && error.name === 'TimeoutError') {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Request timed out while checking repository. Please try again.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Network error while verifying repository. Please check your connection and try again.'
};
}
}
/**
* GET /api/skills/add-request - Get user's add requests
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// Get user from database
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ requests: [] });
}
const requests = await addRequestQueries.getByUser(db, dbUser.id);
return NextResponse.json({ requests });
} catch (error) {
console.error('Error fetching add requests:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* POST /api/skills/add-request - Submit an add request
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.githubId || !session.user.username) {
return NextResponse.json(
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const body = await request.json();
const { repositoryUrl, reason } = body;
if (!repositoryUrl) {
return NextResponse.json(
{ error: 'repositoryUrl is required', code: 'INVALID_INPUT' },
{ status: 400 }
);
}
// Parse GitHub URL
const parsed = parseGitHubUrl(repositoryUrl);
if (!parsed) {
return NextResponse.json(
{ error: 'Invalid GitHub URL', code: 'INVALID_URL' },
{ status: 400 }
);
}
// Normalize the repository URL
const normalizedUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
const finalSkillPath = undefined;
// Get or create user in database
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
dbUser = await userQueries.upsertFromGithub(db, {
githubId: session.user.githubId,
username: session.user.username,
displayName: session.user.name || undefined,
email: session.user.email || undefined,
avatarUrl: session.user.image || undefined,
});
}
if (!dbUser) {
return NextResponse.json(
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
{ status: 500 }
);
}
// Check if user already has a pending request for this repository + path combination
const hasPending = await addRequestQueries.hasPendingRequest(
db,
dbUser.id,
normalizedUrl,
finalSkillPath || null
);
if (hasPending) {
return NextResponse.json(
{ error: 'You already have a pending request for this skill path', code: 'ALREADY_PENDING' },
{ status: 409 }
);
}
// Validate the GitHub repository
const validation = await validateGitHubRepo(parsed.owner, parsed.repo, finalSkillPath);
if (!validation.valid) {
let errorCode = 'INVALID_REPO';
// Map specific error messages to error codes
if (validation.error?.includes('rate limit')) {
errorCode = 'RATE_LIMIT_EXCEEDED';
} else if (validation.error?.includes('not found')) {
errorCode = 'INVALID_REPO';
} else if (validation.error?.includes('timeout') || validation.error?.includes('timed out')) {
errorCode = 'NETWORK_TIMEOUT';
}
return NextResponse.json(
{ error: validation.error || 'Invalid repository', code: errorCode },
{ status: 400 }
);
}
// Create the add request with found skill paths
const skillPathsJson = validation.skillPaths.length > 0
? validation.skillPaths.join(',')
: undefined;
// Sanitize user-provided reason
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
const requestId = await addRequestQueries.create(db, {
userId: dbUser.id,
repositoryUrl: normalizedUrl,
skillPath: skillPathsJson,
reason: sanitizedReason,
validRepo: validation.valid,
hasSkillMd: validation.hasSkillMd,
});
// Build appropriate response message
let message: string;
if (validation.skillPaths.length > 1) {
message = `Request submitted. Found ${validation.skillPaths.length} skills in the repository.`;
} else if (validation.skillPaths.length === 1) {
const pathInfo = validation.skillPaths[0] === '' ? 'at root' : `in ${validation.skillPaths[0]}`;
message = `Request submitted. SKILL.md found ${pathInfo}.`;
} else {
message = 'Request submitted. No SKILL.md found - repository will be reviewed.';
}
// Send confirmation email (non-blocking) - ONLY if skills were found
if (dbUser.email && validation.skillPaths.length > 0) {
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
sendClaimSubmittedEmail(dbUser.email, locale, 'add', {
repositoryUrl: normalizedUrl,
skillCount: validation.skillPaths.length,
}).catch((err) => {
console.error('[Claim] Failed to send add confirmation email:', err);
});
}
return NextResponse.json({
success: true,
requestId,
hasSkillMd: validation.hasSkillMd,
skillCount: validation.skillPaths.length,
skillPaths: validation.skillPaths,
message,
});
} catch (error) {
console.error('Error creating add request:', error);
return NextResponse.json(
{ error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
isFeatured: boolean;
githubStars: number;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
isFeatured: true,
compatibility: { platforms: ['claude'] },
...overrides,
};
}
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getFeatured: vi.fn(),
getByPopularity: vi.fn(),
getFeaturedWithDiversity: vi.fn(),
},
skills: {},
}));
import { GET } from './route';
import { skillQueries } from '@skillhub/db';
describe('GET /api/skills/featured', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return featured skills', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', isFeatured: true }),
createMockSkill({ id: 'skill-2', isFeatured: true }),
];
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.skills).toBeDefined();
expect(Array.isArray(data.skills)).toBe(true);
expect(data.skills.length).toBe(2);
});
it('should respect limit parameter', async () => {
const mockSkills = [createMockSkill()];
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured?limit=5');
await GET(request);
expect(skillQueries.getFeatured).toHaveBeenCalledWith(expect.anything(), 5);
});
it('should fallback to diversity-based popularity when no featured', async () => {
vi.mocked(skillQueries.getFeatured).mockResolvedValue([]);
vi.mocked(skillQueries.getFeaturedWithDiversity).mockResolvedValue([createMockSkill()] as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(skillQueries.getFeaturedWithDiversity).toHaveBeenCalled();
expect(data.skills.length).toBeGreaterThan(0);
});
it('should handle database errors gracefully', async () => {
vi.mocked(skillQueries.getFeatured).mockRejectedValue(new Error('Database error'));
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});

View File

@@ -0,0 +1,88 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
type Skill = typeof skills.$inferSelect;
interface SkillData {
id: string;
name: string;
description: string | null;
githubOwner: string;
githubRepo: string;
githubStars: number | null;
downloadCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
compatibility: unknown;
}
interface FeaturedResponse {
skills: SkillData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '6');
// Try to get from cache first (only for default limit)
const cacheKey = cacheKeys.featuredSkills();
if (limit === 6) {
const cached = await getCached<FeaturedResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
}
// Get featured skills, fallback to popularity-based ranking
// Uses adaptive algorithm: quality + freshness + engagement
let featuredSkills = await skillQueries.getFeatured(db, limit);
// If no manually featured skills, use adaptive popularity with owner/repo diversity
if (featuredSkills.length === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
}
const data: FeaturedResponse = {
skills: featuredSkills.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityStatus: skill.securityStatus,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
})),
};
// Cache the result (2 hours)
if (limit === 6) {
await setCache(cacheKey, data, cacheTTL.featured);
}
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return NextResponse.json(
{ error: 'Failed to fetch featured skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache';
// Create database connection
const db = createDb();
/**
* Get client IP from request headers
*/
function getClientIp(request: NextRequest): string {
const cfConnectingIp = request.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp;
const xRealIp = request.headers.get('x-real-ip');
if (xRealIp) return xRealIp;
const xForwardedFor = request.headers.get('x-forwarded-for');
if (xForwardedFor) {
return xForwardedFor.split(',')[0].trim();
}
return 'unknown';
}
/**
* POST /api/skills/install
* Track a skill installation from CLI or other sources
*
* Body: { skillId: string, platform?: string, method?: string }
*/
export async function POST(request: NextRequest) {
try {
// Parse request body
let body: { skillId?: string; platform?: string; method?: string };
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
const { skillId, platform = 'unknown', method = 'unknown' } = body;
if (!skillId) {
return NextResponse.json(
{ error: 'skillId is required' },
{ status: 400 }
);
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found' },
{ status: 404 }
);
}
// Rate limit: same IP can only count 1 download per skill per 5 minutes
const clientIp = getClientIp(request);
const shouldCount = await shouldCountDownload(skillId, clientIp);
// Only increment if this is a new download from this IP
if (shouldCount) {
await skillQueries.incrementDownloads(db, skillId);
}
// Invalidate relevant caches so featured/recent lists reflect the new download
await Promise.all([
invalidateCache(cacheKeys.featuredSkills()),
invalidateCache(cacheKeys.recentSkills()),
invalidateCache(cacheKeys.stats()),
invalidateCache(cacheKeys.skill(skillId)),
]);
return NextResponse.json({
success: true,
skillId,
platform,
method,
});
} catch (error) {
console.error('Error tracking install:', error);
return NextResponse.json(
{ error: 'Failed to track install' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
updatedAt: Date;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
compatibility: { platforms: ['claude'] },
updatedAt: new Date(),
createdAt: new Date(),
...overrides,
};
}
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getRecent: vi.fn(),
},
skills: {},
}));
import { GET } from './route';
import { skillQueries } from '@skillhub/db';
describe('GET /api/skills/recent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return recent skills', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
];
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.skills).toBeDefined();
expect(Array.isArray(data.skills)).toBe(true);
expect(data.skills.length).toBe(2);
});
it('should order by updatedAt descending', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
];
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(data.skills[0].updatedAt).toBeDefined();
});
it('should respect limit parameter', async () => {
vi.mocked(skillQueries.getRecent).mockResolvedValue([]);
const request = new NextRequest('http://localhost:3000/api/skills/recent?limit=5');
await GET(request);
expect(skillQueries.getRecent).toHaveBeenCalledWith(expect.anything(), 5);
});
it('should handle database errors gracefully', async () => {
vi.mocked(skillQueries.getRecent).mockRejectedValue(new Error('Database error'));
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});

View File

@@ -0,0 +1,85 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
type Skill = typeof skills.$inferSelect;
interface SkillData {
id: string;
name: string;
description: string | null;
githubOwner: string;
githubRepo: string;
githubStars: number | null;
downloadCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
compatibility: unknown;
updatedAt: Date | null;
createdAt: Date | null;
}
interface RecentResponse {
skills: SkillData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '10');
// Try to get from cache first (only for default limit)
const cacheKey = cacheKeys.recentSkills();
if (limit === 10) {
const cached = await getCached<RecentResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
}
const recentSkills = await skillQueries.getRecent(db, limit);
const data: RecentResponse = {
skills: recentSkills.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityStatus: skill.securityStatus,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
createdAt: skill.createdAt,
})),
};
// Cache the result (1 hour)
if (limit === 10) {
await setCache(cacheKey, data, cacheTTL.recent);
}
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching recent skills:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,210 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createDb, skillQueries, removalRequestQueries, userQueries } from '@skillhub/db';
import { sanitizeReason } from '@/lib/sanitize';
import { sendClaimSubmittedEmail } from '@/lib/email';
export const dynamic = 'force-dynamic';
const db = createDb();
/**
* GET /api/skills/removal-request - Get user's removal requests
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// Get user from database
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ requests: [] });
}
const requests = await removalRequestQueries.getByUser(db, dbUser.id);
return NextResponse.json({ requests });
} catch (error) {
console.error('Error fetching removal requests:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* POST /api/skills/removal-request - Submit a removal request
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.githubId || !session.user.username) {
return NextResponse.json(
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const body = await request.json();
const { skillId, reason } = body;
if (!skillId) {
return NextResponse.json(
{ error: 'skillId is required', code: 'INVALID_INPUT' },
{ status: 400 }
);
}
// Check if skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found', code: 'SKILL_NOT_FOUND' },
{ status: 404 }
);
}
// Get or create user in database
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
// Auto-create user if not in database (first API request after OAuth)
if (!dbUser) {
dbUser = await userQueries.upsertFromGithub(db, {
githubId: session.user.githubId,
username: session.user.username,
displayName: session.user.name || undefined,
email: session.user.email || undefined,
avatarUrl: session.user.image || undefined,
});
}
if (!dbUser) {
return NextResponse.json(
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
{ status: 500 }
);
}
// Check if user already has a pending request for this skill
const hasPending = await removalRequestQueries.hasPendingRequest(
db,
dbUser.id,
skillId
);
if (hasPending) {
return NextResponse.json(
{ error: 'You already have a pending request for this skill', code: 'ALREADY_PENDING' },
{ status: 409 }
);
}
// Verify ownership via GitHub API
const owner = skill.githubOwner;
const repo = skill.githubRepo;
const username = session.user.username;
// Check if skill has required GitHub info
if (!owner || !repo) {
console.error('Skill missing GitHub info:', { skillId, owner, repo });
return NextResponse.json(
{ error: 'Skill does not have valid GitHub repository information', code: 'INVALID_SKILL' },
{ status: 400 }
);
}
// Check repository ownership using public API (no token needed for public repos)
let isOwner = false;
let githubError: string | null = null;
try {
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
},
signal: AbortSignal.timeout(10000), // 10 second timeout
}
);
if (repoResponse.ok) {
const repoData = await repoResponse.json();
if (repoData.owner?.login?.toLowerCase() === username.toLowerCase()) {
isOwner = true;
}
} else if (repoResponse.status === 404) {
githubError = 'Repository not found on GitHub';
} else if (repoResponse.status === 403) {
githubError = 'GitHub API rate limit exceeded';
}
} catch (fetchError) {
console.error('GitHub API fetch error:', fetchError);
githubError = 'Failed to verify repository ownership';
}
if (githubError) {
return NextResponse.json(
{ error: githubError, code: 'GITHUB_ERROR' },
{ status: 502 }
);
}
if (!isOwner) {
return NextResponse.json(
{ error: 'You are not the owner of this repository', code: 'NOT_OWNER' },
{ status: 403 }
);
}
// Create the removal request (auto-approved since owner is verified)
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
const requestId = await removalRequestQueries.create(db, {
userId: dbUser.id,
skillId,
reason: sanitizedReason,
verifiedOwner: true,
});
// Auto-approve: Block the skill from being re-indexed
await skillQueries.block(db, skillId);
// Update the request status to approved
await removalRequestQueries.resolve(db, requestId, {
status: 'approved',
resolvedBy: dbUser.id,
resolutionNote: 'Auto-approved: Owner verified via GitHub API',
});
// Send confirmation email (non-blocking)
if (dbUser.email) {
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
sendClaimSubmittedEmail(dbUser.email, locale, 'remove', { skillId }).catch((err) => {
console.error('[Claim] Failed to send removal confirmation email:', err);
});
}
return NextResponse.json({
success: true,
requestId,
message: 'Skill has been blocked from indexing',
blocked: true,
});
} catch (error) {
console.error('Error creating removal request:', error);
return NextResponse.json(
{ error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,252 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache';
import { captureException, log } from '@/lib/sentry';
// Create database connection
const db = createDb();
type Skill = typeof skills.$inferSelect;
/**
* Restore skill ID from Meilisearch format
* Converts sanitized IDs back to original format:
* "anthropics__skills__pdf" -> "anthropics/skills/pdf"
* "bdmorin___dot_claude__git" -> "bdmorin/.claude/git"
* "user__repo_dot_name__skill" -> "user/repo.name/skill"
*/
function restoreIdFromMeili(meiliId: string): string {
return meiliId
.replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST)
.replace(/__/g, '/'); // double underscore -> slash
}
export async function GET(request: NextRequest) {
// Apply rate limiting (search is more expensive, use lower limit)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || undefined;
const platform = searchParams.get('platform') || undefined;
const category = searchParams.get('category') || undefined;
const format = searchParams.get('format') || 'skill.md';
const verified = searchParams.get('verified') === 'true';
// Parse and validate numeric parameters
const minStarsRaw = parseInt(searchParams.get('minStars') || '0');
const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw;
const sort = searchParams.get('sort') || 'stars';
const pageRaw = parseInt(searchParams.get('page') || '1');
const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw;
const limitRaw = parseInt(searchParams.get('limit') || '20');
const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page
const offset = (page - 1) * limit;
// Create cache key from search parameters
const searchHash = hashSearchParams({
q: query,
category,
platform,
format,
verified: verified ? 'true' : undefined,
sort,
page,
limit,
minStars: minStars > 0 ? minStars : undefined,
});
const cacheKey = cacheKeys.searchSkills(searchHash);
// Check cache first
const cached = await getCached<{
skills: Skill[];
total: number;
searchEngine: string;
}>(cacheKey);
if (cached) {
return NextResponse.json(
{
skills: cached.skills,
pagination: {
page,
limit,
total: cached.total,
totalPages: Math.ceil(cached.total / limit),
},
searchEngine: 'cache',
cachedFrom: cached.searchEngine,
},
{
headers: createRateLimitHeaders(rateLimitResult),
}
);
}
// Try Meilisearch for text search queries
if (query) {
const useMeilisearch = await isMeilisearchHealthy();
if (useMeilisearch) {
// Use Meilisearch for full-text search with relevance ranking
// Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback
const meiliResult = await meilisearchSearch({
query,
filters: {
platforms: platform && platform !== 'all' ? [platform] : undefined,
minStars: minStars > 0 ? minStars : undefined,
verified: verified ? true : undefined,
},
sort: sort as 'stars' | 'downloads' | 'rating' | 'recent',
limit,
offset,
});
if (meiliResult) {
const skills = meiliResult.hits.map((hit) => ({
id: restoreIdFromMeili(hit.id),
name: hit.name,
description: hit.description,
githubOwner: hit.githubOwner,
githubRepo: hit.githubRepo,
githubStars: hit.githubStars,
downloadCount: hit.downloadCount,
securityScore: hit.securityScore,
securityStatus: null, // Not available in Meilisearch yet
rating: hit.rating,
ratingCount: null, // Not available in Meilisearch yet
isVerified: hit.isVerified,
compatibility: { platforms: hit.platforms },
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total: meiliResult.estimatedTotalHits,
searchEngine: 'meilisearch',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total: meiliResult.estimatedTotalHits,
totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit),
},
searchEngine: 'meilisearch',
processingTimeMs: meiliResult.processingTimeMs,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
// If Meilisearch search failed, fall through to PostgreSQL
}
}
// Fall back to PostgreSQL search
// Map sort parameter to database column
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
stars: 'stars',
downloads: 'downloads',
rating: 'rating',
recent: 'updated',
lastDownloaded: 'lastDownloaded',
security: 'stars', // Use stars as fallback
};
const sortBy = sortByMap[sort] || 'downloads';
// Build filter options for database query
const filterOptions = {
query,
category: category || undefined,
platform: platform && platform !== 'all' ? platform : undefined,
sourceFormat: format,
minStars,
verified: verified || undefined,
};
// Get paginated results directly from database (no in-memory filtering)
const paginatedResults = await skillQueries.search(db, {
...filterOptions,
limit,
offset,
sortBy,
sortOrder: 'desc',
});
// Get total count for pagination
const total = await skillQueries.count(db, filterOptions);
const skills = paginatedResults.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
skillPath: skill.skillPath,
version: skill.version,
license: skill.license,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityScore: skill.securityScore,
securityStatus: skill.securityStatus,
rating: skill.rating,
ratingCount: skill.ratingCount,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total,
searchEngine: 'postgresql',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
searchEngine: 'postgresql',
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
// Log and report error to Sentry
log.error('Error fetching skills', {
error: error instanceof Error ? error.message : String(error),
});
captureException(error, {
tags: { route: '/api/skills' },
extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) },
});
return NextResponse.json(
{ error: 'Failed to fetch skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
export const dynamic = 'force-dynamic';
/**
* Verify if the current user is the owner of a GitHub repository
* GET /api/skills/verify-ownership?owner=...&repo=...
*/
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.username) {
return NextResponse.json(
{ error: 'Authentication required', isOwner: false },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const owner = searchParams.get('owner');
const repo = searchParams.get('repo');
if (!owner || !repo) {
return NextResponse.json(
{ error: 'owner and repo parameters are required', isOwner: false },
{ status: 400 }
);
}
// Get the GitHub username from session
const username = session.user.username;
// Check if user is the repo owner using public API
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
},
}
);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return NextResponse.json(
{ error: 'Repository not found', isOwner: false },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to verify repository', isOwner: false },
{ status: 500 }
);
}
const repoData = await repoResponse.json();
// Check if the user is the owner
const isOwner = repoData.owner?.login?.toLowerCase() === username.toLowerCase();
return NextResponse.json({
isOwner,
permission: isOwner ? 'owner' : 'none',
username,
repoOwner: repoData.owner?.login,
});
} catch (error) {
console.error('Error verifying ownership:', error);
return NextResponse.json(
{ error: 'Internal server error', isOwner: false },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Mock cache - must be before route import
vi.mock('@/lib/cache', () => ({
getCached: vi.fn().mockResolvedValue(null),
setCache: vi.fn().mockResolvedValue(undefined),
cacheKeys: { stats: () => 'stats' },
cacheTTL: { stats: 3600 },
}));
// Mock the db module - must be before imports that use it
vi.mock('@skillhub/db', () => {
return {
createDb: vi.fn(() => ({
select: vi.fn().mockReturnValue({
from: vi.fn().mockResolvedValue([
{ totalSkills: 100, totalDownloads: 5000, totalContributors: 50 },
]),
}),
})),
skills: { downloadCount: 'download_count', githubOwner: 'github_owner' },
categories: {},
sql: vi.fn(() => 'mock-sql'),
};
});
import { GET } from './route';
// Helper to create mock request
function createMockRequest(url = 'http://localhost:3000/api/stats') {
return new NextRequest(url);
}
describe('GET /api/stats', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return totalSkills count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.totalSkills).toBeDefined();
expect(typeof data.totalSkills).toBe('number');
});
it('should return totalDownloads sum', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalDownloads).toBeDefined();
expect(typeof data.totalDownloads).toBe('number');
});
it('should return totalCategories count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalCategories).toBeDefined();
expect(typeof data.totalCategories).toBe('number');
});
it('should return totalContributors count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalContributors).toBeDefined();
expect(typeof data.totalContributors).toBe('number');
});
it('should return platforms count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.platforms).toBe(5);
});
});

View File

@@ -0,0 +1,79 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skills, categories, sql } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface StatsData {
totalSkills: number;
totalDownloads: number;
totalCategories: number;
totalContributors: number;
platforms: number;
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first
const cacheKey = cacheKeys.stats();
const cached = await getCached<StatsData>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: {
'X-Cache': 'HIT',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
}
// Consolidate all skill stats into a single query for better performance
const statsResult = await db
.select({
totalSkills: sql<number>`count(*)::int`,
totalDownloads: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int`,
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
})
.from(skills);
// Get category count in separate query (different table)
const categoryResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(categories);
const stats = statsResult[0];
const totalCategories = categoryResult[0]?.count ?? 0;
const data: StatsData = {
totalSkills: stats?.totalSkills ?? 0,
totalDownloads: stats?.totalDownloads ?? 0,
totalCategories,
totalContributors: stats?.totalContributors ?? 0,
platforms: 5, // Claude, Codex, Copilot, Cursor, Windsurf
};
// Cache the result
await setCache(cacheKey, data, cacheTTL.stats);
return NextResponse.json(data, {
headers: {
'X-Cache': 'MISS',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('Error fetching stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}

399
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,399 @@
/* Font import must be at the very top */
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================
CSS Variables - Design Tokens
============================================ */
:root {
/* Primary Colors - Sky Blue */
--color-primary-50: #f0f9ff;
--color-primary-100: #e0f2fe;
--color-primary-200: #bae6fd;
--color-primary-300: #7dd3fc;
--color-primary-400: #38bdf8;
--color-primary-500: #66b3e6;
--color-primary-600: #0284c7;
--color-primary-700: #0369a1;
--color-primary-800: #075985;
--color-primary-900: #0c4a6e;
/* Accent - Gold */
--color-accent-gold: #f7c150;
--color-accent-gold-light: #fbd87a;
--color-accent-gold-dark: #d4a43d;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%);
--gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.08) 0%, transparent 60%);
--gradient-gold: linear-gradient(135deg, #f7c150 0%, #f59e0b 100%);
/* Text Colors */
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
/* Surface Colors */
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
--color-surface-muted: #f8fafc;
--color-surface-subtle: #f1f5f9;
/* Borders */
--color-border: #e2e8f0;
--color-border-light: #f1f5f9;
/* States */
--color-success: #22c55e;
--color-success-bg: #f0fdf4;
--color-warning: #f59e0b;
--color-warning-bg: #fffbeb;
--color-error: #ef4444;
--color-error-bg: #fef2f2;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Animation */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
}
/* ============================================
Dark Mode Colors
============================================ */
.dark {
/* Primary Colors - Adjusted for dark mode */
--color-primary-50: #0c3d5a;
--color-primary-100: #0f4c6e;
--color-primary-200: #125e87;
--color-primary-300: #1c79a8;
--color-primary-400: #3b9ac9;
--color-primary-500: #66b3e6;
--color-primary-600: #8cc7ed;
--color-primary-700: #b3dbf5;
--color-primary-800: #d9effa;
--color-primary-900: #ecf7fd;
/* Accent - Gold */
--color-accent-gold: #f7c150;
--color-accent-gold-light: #fbd87a;
--color-accent-gold-dark: #c48f30;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%);
--gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.15) 0%, transparent 60%);
/* Text Colors */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
/* Surface Colors */
--color-surface: #0f172a;
--color-surface-elevated: #1e293b;
--color-surface-muted: #1e293b;
--color-surface-subtle: #334155;
/* Borders */
--color-border: #334155;
--color-border-light: #1e293b;
/* States */
--color-success: #4ade80;
--color-success-bg: #14532d;
--color-warning: #fbbf24;
--color-warning-bg: #78350f;
--color-error: #f87171;
--color-error-bg: #7f1d1d;
/* Shadows - Softer for dark mode */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
/* ============================================
Font Faces - Vazirmatn (Open Source Persian Font)
https://github.com/rastikerdar/vazirmatn
Note: @import is at the top of this file
============================================ */
/* ============================================
Base Styles
============================================ */
@layer base {
html {
@apply antialiased;
scroll-behavior: smooth;
}
/* RTL Typography - Persian optimized with Vazirmatn */
html[dir="rtl"] {
font-family: 'Vazirmatn', system-ui, sans-serif;
font-size: 16px;
line-height: 1.8;
}
/* LTR Typography - English */
html[dir="ltr"] {
font-family: system-ui, 'Vazirmatn', sans-serif;
font-size: 16px;
line-height: 1.6;
}
body {
@apply bg-surface text-text-primary;
}
/* Persian headings - slightly tighter line-height */
html[dir="rtl"] h1,
html[dir="rtl"] h2,
html[dir="rtl"] h3 {
line-height: 1.4;
}
/* Avoid bold in Persian - use medium weight instead */
html[dir="rtl"] strong,
html[dir="rtl"] b {
font-weight: 500;
}
/* Focus visible for accessibility */
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
}
/* ============================================
Component Styles
============================================ */
@layer components {
/* Container */
.container-main {
@apply container mx-auto px-4;
max-width: 1200px;
}
/* Primary Button */
.btn-primary {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium text-white;
@apply transition-all duration-200;
background: var(--gradient-primary);
box-shadow: 0 4px 15px rgba(102, 179, 230, 0.4);
min-height: 44px;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 179, 230, 0.5);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
@apply opacity-50 cursor-not-allowed;
transform: none;
}
/* Secondary Button */
.btn-secondary {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium;
@apply border-2 border-border;
@apply bg-surface text-text-primary;
@apply transition-all duration-200;
min-height: 44px;
}
.btn-secondary:hover {
@apply border-primary-500 text-primary-600 bg-primary-50;
}
/* Ghost Button */
.btn-ghost {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium text-text-secondary;
@apply transition-all duration-200;
@apply bg-transparent;
min-height: 44px;
}
.btn-ghost:hover {
@apply bg-surface-subtle text-text-primary;
}
/* Card */
.card {
@apply bg-surface-elevated rounded-2xl;
@apply transition-all duration-300;
box-shadow: var(--shadow-md);
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
}
/* Glass Card */
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-2xl);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.dark .glass-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(51, 65, 85, 0.3);
}
/* Input Field */
.input-field {
@apply w-full px-4 py-3 rounded-xl;
@apply border-2 border-border;
@apply bg-surface text-text-primary;
@apply transition-all duration-200;
@apply placeholder-text-muted;
min-height: 44px;
}
.input-field:focus {
@apply border-primary-500;
@apply outline-none;
box-shadow: 0 0 0 3px rgba(102, 179, 230, 0.15);
}
/* Hero Typography */
.hero-title {
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--color-text-primary);
}
.hero-tagline {
font-size: clamp(1rem, 2.5vw, 1.25rem);
color: var(--color-primary-500);
font-weight: 500;
}
.hero-subtitle {
font-size: clamp(1.125rem, 3vw, 1.25rem);
color: var(--color-text-secondary);
line-height: 1.6;
}
/* Section */
.section {
@apply py-16 lg:py-24;
}
/* Section following a header - no top padding to avoid double spacing */
.section-header + .section,
.section.no-top-padding {
@apply pt-0;
}
/* Section header - reduced padding for hero/header sections */
.section-header {
@apply py-12 lg:py-16;
}
.section-title {
@apply text-3xl lg:text-4xl font-bold text-center mb-4;
color: var(--color-text-primary);
}
.section-subtitle {
@apply text-lg text-center max-w-2xl mx-auto;
color: var(--color-text-secondary);
}
}
/* ============================================
Utility Classes
============================================ */
@layer utilities {
/* Keep numbers LTR in RTL context */
.ltr-nums {
direction: ltr;
unicode-bidi: embed;
}
/* Gradient text */
.text-gradient {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Subtle gradient background */
.bg-gradient-subtle {
background: var(--gradient-hero);
}
/* Animation delay utilities */
.animation-delay-100 { animation-delay: 0.1s; }
.animation-delay-200 { animation-delay: 0.2s; }
.animation-delay-300 { animation-delay: 0.3s; }
.animation-delay-400 { animation-delay: 0.4s; }
.animation-delay-500 { animation-delay: 0.5s; }
/* Line clamp */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

View File

@@ -0,0 +1,21 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'next-themes';
import { CsrfProvider } from '@/components/CsrfProvider';
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<CsrfProvider>
{children}
</CsrfProvider>
</ThemeProvider>
</SessionProvider>
);
}

37
apps/web/app/robots.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { MetadataRoute } from 'next';
/**
* Dynamic robots.txt for mirror server support
*
* Primary server (IS_PRIMARY_SERVER=true): Allow all crawlers
* Mirror server (IS_PRIMARY_SERVER=false): Block all crawlers to prevent duplicate content
*/
export default function robots(): MetadataRoute.Robots {
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
if (isPrimary) {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/monitoring/'],
},
],
sitemap: `${primaryDomain}/sitemap.xml`,
};
}
// Mirror server: Block all crawlers to prevent duplicate content indexing
return {
rules: [
{
userAgent: '*',
disallow: '/',
},
],
// Point to primary sitemap even on mirror
sitemap: `${primaryDomain}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { ChevronDown, Lock } from 'lucide-react';
export interface EndpointParam {
name: string;
type: string;
required: boolean;
description: string;
default?: string;
}
export interface EndpointDef {
method: 'GET' | 'POST' | 'DELETE';
path: string;
description: string;
auth: boolean;
rateLimit?: string;
cacheTTL?: string;
params?: EndpointParam[];
bodyParams?: EndpointParam[];
responseExample?: string;
notes?: string;
}
interface ApiEndpointSectionProps {
title: string;
description: string;
endpoints: EndpointDef[];
labels: {
parameters: string;
requestBody: string;
required: string;
optional: string;
default: string;
responseExample: string;
rateLimit: string;
cache: string;
authRequired: string;
notes: string;
};
}
const METHOD_STYLES: Record<string, string> = {
GET: 'bg-success/20 text-success',
POST: 'bg-primary-100 text-primary-600',
DELETE: 'bg-red-100 text-red-600',
};
export function ApiEndpointSection({ title, description, endpoints, labels }: ApiEndpointSectionProps) {
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(new Set());
const toggle = (index: number) => {
setExpandedIndexes(prev => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
return (
<div className="mb-10">
<h3 className="text-2xl font-bold mb-2">{title}</h3>
<p className="text-text-secondary mb-4">{description}</p>
<div className="space-y-3" dir="ltr">
{endpoints.map((ep, index) => {
const expanded = expandedIndexes.has(index);
const hasDetails = ep.params?.length || ep.bodyParams?.length || ep.responseExample || ep.notes;
return (
<div key={index} className="glass-card overflow-hidden text-left">
<button
type="button"
onClick={() => hasDetails && toggle(index)}
className={`w-full p-4 flex items-center gap-3 ${hasDetails ? 'cursor-pointer hover:bg-surface-hover' : 'cursor-default'}`}
>
<span className={`px-2 py-1 rounded text-xs font-bold shrink-0 ${METHOD_STYLES[ep.method] || ''}`}>
{ep.method}
</span>
<code className="text-sm font-mono shrink-0">{ep.path}</code>
{ep.auth && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700 shrink-0">
<Lock className="w-3 h-3" />
{labels.authRequired}
</span>
)}
<span className="text-text-secondary text-sm ml-auto hidden sm:block truncate">{ep.description}</span>
{hasDetails && (
<ChevronDown className={`w-4 h-4 text-text-secondary shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`} />
)}
</button>
{/* Description visible on mobile (below sm) */}
<div className="px-4 pb-2 sm:hidden">
<p className="text-text-secondary text-sm">{ep.description}</p>
</div>
{expanded && hasDetails && (
<div className="px-4 pb-4 border-t border-border/50 pt-4 space-y-4">
{/* Rate limit & cache badges */}
{(ep.rateLimit || ep.cacheTTL) && (
<div className="flex flex-wrap gap-3 text-xs">
{ep.rateLimit && (
<span className="text-text-secondary">
<span className="font-semibold">{labels.rateLimit}:</span> {ep.rateLimit}
</span>
)}
{ep.cacheTTL && (
<span className="text-text-secondary">
<span className="font-semibold">{labels.cache}:</span> {ep.cacheTTL}
</span>
)}
</div>
)}
{/* Query parameters */}
{ep.params && ep.params.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">{labels.parameters}</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-left">
<th className="py-1.5 pr-3 font-medium text-text-secondary">Name</th>
<th className="py-1.5 pr-3 font-medium text-text-secondary">Type</th>
<th className="py-1.5 pr-3 font-medium text-text-secondary"></th>
<th className="py-1.5 font-medium text-text-secondary">Description</th>
</tr>
</thead>
<tbody>
{ep.params.map((p) => (
<tr key={p.name} className="border-b border-border/30">
<td className="py-1.5 pr-3 font-mono text-xs">{p.name}</td>
<td className="py-1.5 pr-3 text-text-secondary">{p.type}</td>
<td className="py-1.5 pr-3">
<span className={`text-xs ${p.required ? 'text-red-500' : 'text-text-tertiary'}`}>
{p.required ? labels.required : labels.optional}
</span>
</td>
<td className="py-1.5 text-text-secondary">
{p.description}
{p.default && <span className="text-text-tertiary ml-1">(default: {p.default})</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Body parameters */}
{ep.bodyParams && ep.bodyParams.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">{labels.requestBody}</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-left">
<th className="py-1.5 pr-3 font-medium text-text-secondary">Name</th>
<th className="py-1.5 pr-3 font-medium text-text-secondary">Type</th>
<th className="py-1.5 pr-3 font-medium text-text-secondary"></th>
<th className="py-1.5 font-medium text-text-secondary">Description</th>
</tr>
</thead>
<tbody>
{ep.bodyParams.map((p) => (
<tr key={p.name} className="border-b border-border/30">
<td className="py-1.5 pr-3 font-mono text-xs">{p.name}</td>
<td className="py-1.5 pr-3 text-text-secondary">{p.type}</td>
<td className="py-1.5 pr-3">
<span className={`text-xs ${p.required ? 'text-red-500' : 'text-text-tertiary'}`}>
{p.required ? labels.required : labels.optional}
</span>
</td>
<td className="py-1.5 text-text-secondary">{p.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Response example */}
{ep.responseExample && (
<div>
<h4 className="text-sm font-semibold mb-2">{labels.responseExample}</h4>
<div className="bg-gray-900 rounded-lg p-3 overflow-x-auto">
<pre className="text-sm font-mono text-gray-100 whitespace-pre">{ep.responseExample}</pre>
</div>
</div>
)}
{/* Notes */}
{ep.notes && (
<div className="text-sm text-text-secondary bg-primary-50/50 rounded-lg p-3">
<span className="font-semibold">{labels.notes}:</span> {ep.notes}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useTranslations, useLocale } from 'next-intl';
import Link from 'next/link';
import { LogIn, LogOut, User, Heart, ChevronDown, Settings } from 'lucide-react';
import { clsx } from 'clsx';
export function AuthButton() {
const { data: session, status } = useSession();
const t = useTranslations('auth');
const locale = useLocale();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (status === 'loading') {
return (
<div className="w-8 h-8 rounded-full bg-surface-subtle animate-pulse" />
);
}
if (!session) {
return (
<button
onClick={() => signIn('github')}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors"
>
<LogIn className="w-4 h-4" />
<span className="text-sm font-medium hidden sm:inline">{t('signIn')}</span>
</button>
);
}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-surface-subtle transition-colors"
>
{session.user?.avatarUrl ? (
<img
src={session.user.avatarUrl}
alt={session.user.username || 'User'}
className="w-7 h-7 rounded-full"
/>
) : (
<div className="w-7 h-7 rounded-full bg-primary-100 flex items-center justify-center">
<User className="w-4 h-4 text-primary-600" />
</div>
)}
<ChevronDown className={clsx(
'w-4 h-4 text-text-muted transition-transform',
dropdownOpen && 'rotate-180'
)} />
</button>
{dropdownOpen && (
<div className="absolute end-0 top-full mt-2 w-48 bg-surface-elevated rounded-lg shadow-lg border border-border py-1 z-50">
{/* User info */}
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-text-primary truncate">
{session.user?.name || session.user?.username}
</p>
<p className="text-xs text-text-muted truncate">
@{session.user?.username}
</p>
</div>
{/* Menu items */}
<Link
href={`/${locale}/favorites`}
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
>
<Heart className="w-4 h-4" />
{t('favorites')}
</Link>
<Link
href={`/${locale}/claim`}
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
>
<Settings className="w-4 h-4" />
{t('manageSkills')}
</Link>
<button
onClick={() => {
setDropdownOpen(false);
signOut();
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
>
<LogOut className="w-4 h-4" />
{t('signOut')}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Zap } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import Link from 'next/link';
import { formatCompactNumber } from '@/lib/format-number';
export function BetaBanner() {
const [isVisible, setIsVisible] = useState(false);
const [skillCount, setSkillCount] = useState<string | null>(null);
const locale = useLocale();
const t = useTranslations('banner');
useEffect(() => {
const dismissed = localStorage.getItem('promo-banner-dismissed');
if (!dismissed) {
setIsVisible(true);
}
fetch('/api/stats')
.then((res) => res.json())
.then((data) => {
if (data.totalSkills) {
setSkillCount(formatCompactNumber(data.totalSkills, locale));
}
})
.catch(() => {});
}, [locale]);
const handleDismiss = () => {
setIsVisible(false);
localStorage.setItem('promo-banner-dismissed', 'true');
};
if (!isVisible) return null;
const count = skillCount || (locale === 'fa' ? '۱۷۲k' : '172k');
return (
<div className="relative bg-gradient-to-r from-primary-600 to-primary-500 text-white">
<div className="container-main py-2 px-4 flex items-center justify-center gap-2 text-sm">
<Zap className="w-4 h-4 flex-shrink-0" />
<span className="font-medium">{t('text', { count })}</span>
<span className="hidden sm:inline text-primary-100">|</span>
<Link
href={`/${locale}/support`}
className="hidden sm:inline text-white underline underline-offset-2 hover:text-primary-100 transition-colors"
>
{t('feedback')}
</Link>
<button
onClick={handleDismiss}
className="absolute end-2 sm:end-4 p-1 hover:bg-white/10 rounded transition-colors"
aria-label={t('dismiss')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,661 @@
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useCallback, useState, useTransition, useEffect, type FormEvent } from 'react';
import { Search, Filter, ChevronLeft, ChevronRight, Loader2, X, Star, SlidersHorizontal, Sparkles } from 'lucide-react';
import Link from 'next/link';
// Persian number conversion
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
function toPersianNumber(num: number | string): string {
return String(num).replace(/\d/g, (d) => persianDigits[parseInt(d, 10)]);
}
interface SortOption {
id: string;
name: string;
}
interface Category {
id: string;
name: string;
slug: string;
skillCount: number;
parentId?: string | null;
}
interface HierarchicalCategory extends Category {
children?: Category[];
}
interface BrowseFiltersProps {
sortOptions: SortOption[];
categories: Category[] | HierarchicalCategory[];
locale?: string;
translations: {
category: string;
allCategories: string;
sort: string;
format?: string;
allFormats?: string;
agentSkills?: string;
searching: string;
viewFeatured?: string;
};
}
export function BrowseFilters({
sortOptions,
categories,
locale = 'en',
translations,
}: BrowseFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
// Get current values from URL
const currentCategory = searchParams.get('category') || '';
const currentSort = searchParams.get('sort') || 'lastDownloaded';
const currentFormat = searchParams.get('format') || '';
// Count active filters for mobile badge
const activeFilterCount = [
currentCategory ? 1 : 0,
currentSort !== 'lastDownloaded' ? 1 : 0,
currentFormat ? 1 : 0,
].reduce((a, b) => a + b, 0);
// Update URL with new params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '' || value === 'all' || value === 'false') {
params.delete(key);
} else {
params.set(key, value);
}
});
// Reset page when filters change
params.delete('page');
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[pathname, router, searchParams]
);
// Handle category change
const handleCategoryChange = (categoryId: string) => {
updateParams({ category: categoryId === '' ? null : categoryId });
};
// Handle sort change
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ sort: e.target.value === 'lastDownloaded' ? null : e.target.value });
};
// Handle format change
const handleFormatChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ format: e.target.value === '' ? null : e.target.value });
};
// Filter content (shared between mobile and desktop)
const filterContent = (
<>
<h2 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Filter className="w-4 h-4" />
{translations.category}
</h2>
{/* Category Filter */}
<div className="relative mb-6">
<select
className="input-field text-sm disabled:opacity-50 w-full pe-10"
value={currentCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
disabled={isPending}
>
<option value="">{translations.allCategories}</option>
{categories.length > 0 && 'children' in categories[0] ? (
(categories as HierarchicalCategory[]).map((parent) => (
<optgroup key={parent.id} label={parent.name}>
{parent.children?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({locale === 'fa' ? toPersianNumber(cat.skillCount) : cat.skillCount})
</option>
))}
</optgroup>
))
) : (
(categories as Category[]).map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({locale === 'fa' ? toPersianNumber(cat.skillCount) : cat.skillCount})
</option>
))
)}
</select>
{currentCategory && (
<button
onClick={() => handleCategoryChange('')}
className="absolute end-8 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary p-1 rounded"
type="button"
title="Clear category"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Sort */}
<h3 className="font-semibold text-text-primary mb-3">
{translations.sort}
</h3>
<select
className="input-field text-sm mb-6 disabled:opacity-50"
value={currentSort}
onChange={handleSortChange}
disabled={isPending}
>
{sortOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
{/* Format Filter */}
{translations.format && (
<>
<h3 className="font-semibold text-text-primary mb-3">
{translations.format}
</h3>
<select
className="input-field text-sm mb-6 disabled:opacity-50"
value={currentFormat}
onChange={handleFormatChange}
disabled={isPending}
>
<option value="">{translations.agentSkills || 'Agent Skills (SKILL.md)'}</option>
<option value="all">{translations.allFormats || 'All Formats'}</option>
<option value="agents.md">AGENTS.md (Codex)</option>
<option value="cursorrules">.cursorrules (Cursor)</option>
<option value="windsurfrules">.windsurfrules (Windsurf)</option>
<option value="copilot-instructions">Copilot Instructions</option>
</select>
</>
)}
{/* View Featured Link */}
{translations.viewFeatured && (
<Link
href={`/${locale}/featured`}
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors mt-4 pt-4 border-t border-border"
>
<Star className="w-4 h-4" />
{translations.viewFeatured}
</Link>
)}
{/* Loading indicator */}
{isPending && (
<div className="mt-4 flex items-center gap-2 text-primary-500 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{translations.searching}
</div>
)}
</>
);
return (
<>
{/* Mobile Filter Toggle */}
<div className="lg:hidden mb-4">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2.5 bg-surface-elevated border border-border rounded-xl text-text-primary hover:bg-surface-subtle transition-colors w-full justify-center"
>
<SlidersHorizontal className="w-4 h-4" />
<span>{translations.category}</span>
{activeFilterCount > 0 && (
<span className="bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full">
{locale === 'fa' ? toPersianNumber(activeFilterCount) : activeFilterCount}
</span>
)}
</button>
{/* Mobile Filter Panel */}
{isOpen && (
<div className="mt-4 bg-surface-elevated rounded-2xl p-6 shadow-sm border border-border">
{filterContent}
</div>
)}
</div>
{/* Desktop Sidebar */}
<aside className="hidden lg:block lg:w-64 flex-shrink-0">
<div className="bg-surface-elevated rounded-2xl p-6 shadow-sm sticky top-24">
{filterContent}
</div>
</aside>
</>
);
}
// Search Bar Component with Debounce
interface SearchBarProps {
placeholder: string;
defaultValue?: string;
}
export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [searchQuery, setSearchQuery] = useState(defaultValue);
// Sync internal state when URL changes externally (e.g., ActiveFilters clearAll)
useEffect(() => {
setSearchQuery(defaultValue);
}, [defaultValue]);
const handleSearch = (e: FormEvent) => {
e.preventDefault();
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set('q', searchQuery);
} else {
params.delete('q');
}
params.delete('page');
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
};
const clearSearch = () => {
setSearchQuery('');
const params = new URLSearchParams(searchParams.toString());
params.delete('q');
params.delete('page');
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
};
return (
<form onSubmit={handleSearch} className="mb-4">
<div className="relative">
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
<input
type="text"
placeholder={placeholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isPending}
className="input-field ps-12 pe-24 disabled:opacity-50"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute end-14 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary rounded"
>
<X className="w-4 h-4" />
</button>
)}
<button
type="submit"
disabled={isPending}
className="absolute end-2 top-1/2 -translate-y-1/2 btn-primary py-1.5 px-4 text-sm disabled:opacity-50"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</button>
</div>
</form>
);
}
// Active Filters Display - Shows applied filters as removable chips
interface ActiveFiltersProps {
query?: string;
categoryId?: string;
categoryName?: string;
sortBy?: string;
sortName?: string;
translations: {
search: string;
category: string;
sortBy: string;
clearAll: string;
};
}
export function ActiveFilters({
query,
categoryId,
categoryName,
sortBy,
sortName,
translations,
}: ActiveFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const hasFilters = query || categoryId || (sortBy && sortBy !== 'lastDownloaded');
if (!hasFilters) return null;
const removeFilter = (key: string) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(key);
params.delete('page');
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
};
const clearAll = () => {
startTransition(() => {
router.push(pathname, { scroll: false });
});
};
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
{query && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-sm rounded-full">
<Search className="w-3 h-3" />
<span className="max-w-[150px] truncate">&quot;{query}&quot;</span>
<button
onClick={() => removeFilter('q')}
disabled={isPending}
className="hover:bg-primary-100 dark:hover:bg-primary-800/50 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{categoryId && categoryName && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-success/10 text-success text-sm rounded-full">
<Filter className="w-3 h-3" />
<span>{categoryName}</span>
<button
onClick={() => removeFilter('category')}
disabled={isPending}
className="hover:bg-success/20 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{sortBy && sortBy !== 'lastDownloaded' && sortName && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-surface-subtle text-text-secondary text-sm rounded-full">
<SlidersHorizontal className="w-3 h-3" />
<span>{sortName}</span>
<button
onClick={() => removeFilter('sort')}
disabled={isPending}
className="hover:bg-surface-muted rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{(query || categoryId) && (
<button
onClick={clearAll}
disabled={isPending}
className="text-sm text-text-muted hover:text-error transition-colors"
>
{translations.clearAll}
</button>
)}
{isPending && <Loader2 className="w-4 h-4 animate-spin text-primary-500" />}
</div>
);
}
// Empty State Component
interface EmptyStateProps {
query?: string;
hasFilters: boolean;
locale?: string;
translations: {
noResults: string;
noResultsWithQuery: string;
tryDifferent: string;
clearFilters: string;
browseAll: string;
};
}
export function EmptyState({ query, hasFilters, locale = 'en', translations }: EmptyStateProps) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const clearFilters = () => {
startTransition(() => {
router.push(pathname, { scroll: false });
});
};
return (
<div className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-surface-elevated flex items-center justify-center">
<Sparkles className="w-10 h-10 text-text-muted" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">
{query ? translations.noResultsWithQuery.replace('{query}', query) : translations.noResults}
</h3>
<p className="text-text-secondary mb-6 max-w-md mx-auto">
{translations.tryDifferent}
</p>
{hasFilters && (
<button
onClick={clearFilters}
disabled={isPending}
className="btn-secondary gap-2"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
{translations.clearFilters}
</button>
)}
{!hasFilters && (
<Link href={`/${locale}/featured`} className="btn-primary gap-2">
<Star className="w-4 h-4" />
{translations.browseAll}
</Link>
)}
</div>
);
}
// Pagination Component
interface PaginationProps {
currentPage: number;
totalPages: number;
locale?: string;
translations: {
previous: string;
next: string;
page: string;
of: string;
};
}
// Helper: Generate page numbers with ellipsis
function generatePageNumbers(current: number, total: number): (number | '...')[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
if (current <= 3) {
return [1, 2, 3, 4, '...', total];
}
if (current >= total - 2) {
return [1, '...', total - 3, total - 2, total - 1, total];
}
return [1, '...', current - 1, current, current + 1, '...', total];
}
export function Pagination({ currentPage, totalPages, locale = 'en', translations }: PaginationProps) {
// Helper to format numbers based on locale
const formatPageNumber = (num: number | string) => locale === 'fa' ? toPersianNumber(num) : String(num);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const goToPage = (page: number) => {
if (page < 1 || page > totalPages || page === currentPage) return;
const params = new URLSearchParams(searchParams.toString());
if (page === 1) {
params.delete('page');
} else {
params.set('page', String(page));
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
};
// Don't show pagination if only one page
if (totalPages <= 1) return null;
const pages = generatePageNumbers(currentPage, totalPages);
return (
<div className="flex flex-col items-center gap-4 mt-8">
{/* Page indicator */}
<p className="text-sm text-text-muted">
{translations.page} {formatPageNumber(currentPage)} {translations.of} {formatPageNumber(totalPages)}
</p>
{/* Pagination controls */}
<div className="flex items-center gap-1 sm:gap-2">
{/* Previous Button */}
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1 || isPending}
className="flex items-center gap-1 px-2 sm:px-3 py-2 text-sm border border-border rounded-lg hover:bg-surface-subtle disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={translations.previous}
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline">{translations.previous}</span>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pages.map((page, index) => (
page === '...' ? (
<span
key={`ellipsis-${index}`}
className="px-2 py-2 text-text-muted"
>
...
</span>
) : (
<button
key={page}
onClick={() => goToPage(page)}
disabled={isPending}
className={`min-w-[40px] px-3 py-2 text-sm border rounded-lg transition-colors disabled:cursor-wait ${
page === currentPage
? 'bg-primary-500 text-white border-primary-500'
: 'border-border hover:bg-surface-subtle'
}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{formatPageNumber(page)}
</button>
)
))}
</div>
{/* Next Button */}
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || isPending}
className="flex items-center gap-1 px-2 sm:px-3 py-2 text-sm border border-border rounded-lg hover:bg-surface-subtle disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={translations.next}
>
<span className="hidden sm:inline">{translations.next}</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Loading indicator */}
{isPending && (
<div className="flex items-center gap-2 text-primary-500 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
</div>
);
}
// Legacy LoadMoreButton for backward compatibility (if needed elsewhere)
interface LoadMoreButtonProps {
hasMore: boolean;
currentPage: number;
label: string;
}
export function LoadMoreButton({ hasMore, currentPage, label }: LoadMoreButtonProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const handleLoadMore = () => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(currentPage + 1));
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
};
if (!hasMore) return null;
return (
<div className="text-center mt-8">
<button
onClick={handleLoadMore}
disabled={isPending}
className="btn-secondary gap-2 disabled:opacity-50"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
label
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,831 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useSession, signIn } from 'next-auth/react';
import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
import { fetchWithCsrf } from '@/lib/csrf-client';
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
// Validate GitHub URL format
function isValidGitHubUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2;
} catch {
return false;
}
}
interface ClaimFormProps {
translations: {
title: string;
subtitle: string;
loginRequired: string;
signIn: string;
optional: string;
mirror: {
title: string;
description: string;
button: string;
};
tabs: {
remove: string;
add: string;
};
form: {
skillId: string;
skillIdPlaceholder: string;
skillIdHelp: string;
reason: string;
reasonPlaceholder: string;
submit: string;
submitting: string;
};
addForm: {
repositoryUrl: string;
repositoryUrlPlaceholder: string;
repositoryUrlHelp: string;
reason: string;
reasonPlaceholder: string;
submit: string;
submitting: string;
};
success: {
title: string;
description: string;
viewRequests: string;
};
addSuccess: {
title: string;
description: string;
descriptionNoSkillMd: string;
descriptionMultiplePrefix: string;
descriptionMultipleSuffix: string;
viewRequests: string;
foundSkillsIn: string;
root: string;
andMore: string;
};
error: {
notOwner: string;
skillNotFound: string;
alreadyPending: string;
githubError: string;
invalidSkill: string;
invalidUrl: string;
invalidRepo: string;
rateLimitExceeded: string;
networkTimeout: string;
generic: string;
};
myRequests: {
title: string;
empty: string;
status: {
pending: string;
approved: string;
rejected: string;
indexed: string;
};
skillsFoundPrefix: string;
skillsFoundSuffix: string;
showLess: string;
showAllPrefix: string;
showAllSuffix: string;
};
};
}
interface RemovalRequest {
id: string;
skillId: string;
reason: string;
status: string;
createdAt: string;
}
interface AddRequest {
id: string;
repositoryUrl: string;
skillPath: string | null;
reason: string;
status: string;
hasSkillMd: boolean;
createdAt: string;
indexedSkillId: string | null;
errorMessage: string | null;
}
export function ClaimForm({ translations }: ClaimFormProps) {
const { data: session, status } = useSession();
const [activeTab, setActiveTab] = useState<'remove' | 'add'>('add'); // Default to 'add' tab
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
// Read tab from URL hash on mount and update on hash change
useEffect(() => {
const readHashTab = () => {
const hash = window.location.hash.slice(1);
if (hash === 'remove' || hash === 'add') {
setActiveTab(hash);
}
};
readHashTab();
window.addEventListener('hashchange', readHashTab);
return () => window.removeEventListener('hashchange', readHashTab);
}, []);
// Update URL hash when tab changes
const handleTabChange = useCallback((tab: 'remove' | 'add') => {
setActiveTab(tab);
window.history.replaceState(null, '', `#${tab}`);
}, []);
// Toggle expanded state for a request
const toggleExpanded = useCallback((requestId: string) => {
setExpandedRequests(prev => {
const next = new Set(prev);
if (next.has(requestId)) {
next.delete(requestId);
} else {
next.add(requestId);
}
return next;
});
}, []);
// Remove form state
const [skillId, setSkillId] = useState('');
const [removeReason, setRemoveReason] = useState('');
const [isSubmittingRemove, setIsSubmittingRemove] = useState(false);
const [removeError, setRemoveError] = useState('');
const [removeSuccess, setRemoveSuccess] = useState(false);
const [removalRequests, setRemovalRequests] = useState<RemovalRequest[]>([]);
const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false);
// Add form state
const [repositoryUrl, setRepositoryUrl] = useState('');
const [repositoryUrlError, setRepositoryUrlError] = useState('');
const [addReason, setAddReason] = useState('');
const [isSubmittingAdd, setIsSubmittingAdd] = useState(false);
const [addError, setAddError] = useState('');
const [addSuccess, setAddSuccess] = useState(false);
const [addHasSkillMd, setAddHasSkillMd] = useState(true);
const [addSkillCount, setAddSkillCount] = useState(0);
const [addSkillPaths, setAddSkillPaths] = useState<string[]>([]);
const [addRequests, setAddRequests] = useState<AddRequest[]>([]);
const [loadingAddRequests, setLoadingAddRequests] = useState(false);
// Fetch user's existing requests
useEffect(() => {
if (session) {
fetchRemovalRequests();
fetchAddRequests();
}
}, [session]);
const fetchRemovalRequests = async () => {
setLoadingRemovalRequests(true);
try {
const res = await fetch('/api/skills/removal-request');
if (res.ok) {
const data = await res.json();
setRemovalRequests(data.requests || []);
}
} catch {
// Ignore errors
} finally {
setLoadingRemovalRequests(false);
}
};
const fetchAddRequests = async () => {
setLoadingAddRequests(true);
try {
const res = await fetch('/api/skills/add-request');
if (res.ok) {
const data = await res.json();
setAddRequests(data.requests || []);
}
} catch {
// Ignore errors
} finally {
setLoadingAddRequests(false);
}
};
const handleRemoveSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!session) {
signIn('github');
return;
}
if (!skillId.trim()) {
return;
}
setIsSubmittingRemove(true);
setRemoveError('');
setRemoveSuccess(false);
try {
const res = await fetchWithCsrf('/api/skills/removal-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillId: skillId.trim(), reason: removeReason.trim() }),
});
const data = await res.json();
if (!res.ok) {
switch (data.code) {
case 'NOT_OWNER':
setRemoveError(translations.error.notOwner);
break;
case 'SKILL_NOT_FOUND':
setRemoveError(translations.error.skillNotFound);
break;
case 'ALREADY_PENDING':
setRemoveError(translations.error.alreadyPending);
break;
case 'GITHUB_ERROR':
setRemoveError(translations.error.githubError);
break;
case 'INVALID_SKILL':
setRemoveError(translations.error.invalidSkill);
break;
case 'AUTH_REQUIRED':
signIn('github');
return;
default:
console.error('Claim form error:', data);
setRemoveError(translations.error.generic);
}
return;
}
setRemoveSuccess(true);
setSkillId('');
setRemoveReason('');
fetchRemovalRequests();
} catch {
setRemoveError(translations.error.generic);
} finally {
setIsSubmittingRemove(false);
}
};
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!session) {
signIn('github');
return;
}
if (!repositoryUrl.trim()) {
return;
}
// Client-side validation
if (!isValidGitHubUrl(repositoryUrl.trim())) {
setAddError(translations.error.invalidUrl);
return;
}
setIsSubmittingAdd(true);
setAddError('');
setAddSuccess(false);
try {
const res = await fetchWithCsrf('/api/skills/add-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repositoryUrl: repositoryUrl.trim(),
reason: addReason.trim() || 'No reason provided',
}),
});
const data = await res.json();
if (!res.ok) {
switch (data.code) {
case 'INVALID_URL':
setAddError(translations.error.invalidUrl);
break;
case 'INVALID_REPO':
setAddError(translations.error.invalidRepo);
break;
case 'RATE_LIMIT_EXCEEDED':
setAddError(translations.error.rateLimitExceeded);
break;
case 'NETWORK_TIMEOUT':
setAddError(translations.error.networkTimeout);
break;
case 'ALREADY_PENDING':
setAddError(translations.error.alreadyPending);
break;
case 'AUTH_REQUIRED':
signIn('github');
return;
default:
console.error('Add form error:', data);
setAddError(translations.error.generic);
}
return;
}
setAddSuccess(true);
setAddHasSkillMd(data.hasSkillMd ?? true);
setAddSkillCount(data.skillCount ?? 0);
setAddSkillPaths(data.skillPaths ?? []);
setRepositoryUrl('');
setAddReason('');
fetchAddRequests();
} catch {
setAddError(translations.error.generic);
} finally {
setIsSubmittingAdd(false);
}
};
const getStatusBadge = (requestStatus: string) => {
switch (requestStatus) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-warning-bg text-warning">
<Clock className="w-3 h-3" />
{translations.myRequests.status.pending}
</span>
);
case 'approved':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-success-bg text-success">
<CheckCircle className="w-3 h-3" />
{translations.myRequests.status.approved}
</span>
);
case 'rejected':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-error-bg text-error">
<AlertCircle className="w-3 h-3" />
{translations.myRequests.status.rejected}
</span>
);
case 'indexed':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-primary-50 text-primary-600">
<CheckCircle className="w-3 h-3" />
{translations.myRequests.status.indexed}
</span>
);
default:
return null;
}
};
// Loading state
if (status === 'loading') {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
);
}
// Mirror server - redirect to primary
if (isMirror) {
return (
<div className="card p-8 text-center">
<ExternalLink className="w-12 h-12 mx-auto mb-4 text-text-muted" />
<h2 className="text-xl font-semibold text-text-primary mb-2">{translations.mirror.title}</h2>
<p className="text-text-secondary mb-6">{translations.mirror.description}</p>
<a
href={`${PRIMARY_URL}/claim`}
target="_blank"
rel="noopener noreferrer"
className="btn-primary inline-flex items-center gap-2"
>
<ExternalLink className="w-5 h-5" />
{translations.mirror.button}
</a>
</div>
);
}
// Not logged in
if (!session) {
return (
<div className="card p-8 text-center">
<Github className="w-12 h-12 mx-auto mb-4 text-text-muted" />
<p className="text-text-secondary mb-6">{translations.loginRequired}</p>
<button
onClick={() => signIn('github')}
className="btn-primary inline-flex items-center gap-2"
>
<Github className="w-5 h-5" />
{translations.signIn}
</button>
</div>
);
}
// Remove success state
if (removeSuccess) {
return (
<div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
<h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.success.title}
</h2>
<p className="text-text-secondary mb-6">{translations.success.description}</p>
<button
onClick={() => setRemoveSuccess(false)}
className="btn-secondary"
>
{translations.success.viewRequests}
</button>
</div>
);
}
// Add success state
if (addSuccess) {
// Determine which message to show
let successDescription: string | React.ReactNode;
if (addSkillCount > 1) {
successDescription = (
<>
{translations.addSuccess.descriptionMultiplePrefix}
{addSkillCount}
{translations.addSuccess.descriptionMultipleSuffix}
</>
);
} else if (addHasSkillMd) {
successDescription = translations.addSuccess.description;
} else {
successDescription = translations.addSuccess.descriptionNoSkillMd;
}
return (
<div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
<h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.addSuccess.title}
</h2>
<p className="text-text-secondary mb-4">
{successDescription}
</p>
{addSkillCount > 1 && addSkillPaths.length > 0 && (
<div className="mb-6 text-start max-w-md mx-auto">
<p className="text-sm text-text-muted mb-2" dir="ltr">{translations.addSuccess.foundSkillsIn}</p>
<ul className="text-sm text-text-secondary space-y-1 bg-surface-subtle rounded-lg p-3 max-h-40 overflow-y-auto" dir="ltr">
{addSkillPaths.slice(0, 10).map((path, index) => (
<li key={index} className="font-mono truncate">
{path === '' ? translations.addSuccess.root : path}
</li>
))}
{addSkillPaths.length > 10 && (
<li className="text-text-muted">{translations.addSuccess.andMore.replace('{count}', String(addSkillPaths.length - 10))}</li>
)}
</ul>
</div>
)}
<button
onClick={() => setAddSuccess(false)}
className="btn-secondary"
>
{translations.addSuccess.viewRequests}
</button>
</div>
);
}
return (
<div className="space-y-8">
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => handleTabChange('remove')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'remove'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-text-muted hover:text-text-primary'
}`}
>
<Minus className="w-4 h-4" />
{translations.tabs.remove}
</button>
<button
onClick={() => handleTabChange('add')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'add'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-text-muted hover:text-text-primary'
}`}
>
<Plus className="w-4 h-4" />
{translations.tabs.add}
</button>
</div>
{/* Remove Form */}
{activeTab === 'remove' && (
<>
<div className="card p-6">
<form onSubmit={handleRemoveSubmit} className="space-y-4">
<div>
<label
htmlFor="skillId"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.form.skillId}
</label>
<input
type="text"
id="skillId"
value={skillId}
onChange={(e) => setSkillId(e.target.value)}
placeholder={translations.form.skillIdPlaceholder}
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500"
dir="ltr"
required
/>
<p className="text-xs text-text-muted mt-1">
{translations.form.skillIdHelp}
</p>
</div>
<div>
<label
htmlFor="removeReason"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.form.reason} <span className="text-text-muted">{translations.optional}</span>
</label>
<textarea
id="removeReason"
value={removeReason}
onChange={(e) => setRemoveReason(e.target.value)}
placeholder={translations.form.reasonPlaceholder}
rows={4}
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
/>
</div>
{removeError && (
<div className="p-3 bg-error-bg border border-error/30 rounded-lg">
<div className="flex items-center gap-2 text-error">
<AlertCircle className="w-4 h-4" />
<p className="text-sm">{removeError}</p>
</div>
</div>
)}
<button
type="submit"
disabled={isSubmittingRemove || !skillId.trim()}
className="btn-primary w-full justify-center disabled:opacity-50"
>
{isSubmittingRemove ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{translations.form.submitting}
</>
) : (
translations.form.submit
)}
</button>
</form>
</div>
{/* Removal Requests */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-text-primary mb-4">
{translations.myRequests.title}
</h2>
{loadingRemovalRequests ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-text-muted" />
</div>
) : removalRequests.length === 0 ? (
<p className="text-text-muted text-center py-8">
{translations.myRequests.empty}
</p>
) : (
<div className="space-y-3">
{removalRequests.map((request) => (
<div
key={request.id}
className="p-4 bg-surface-subtle rounded-lg"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<code className="text-sm font-mono text-text-primary break-all" dir="ltr">
{request.skillId}
</code>
<p className="text-sm text-text-secondary mt-1 line-clamp-2" dir="auto">
{request.reason}
</p>
<p className="text-xs text-text-muted mt-1" dir="ltr">
{new Date(request.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex-shrink-0">
{getStatusBadge(request.status)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
{/* Add Form */}
{activeTab === 'add' && (
<>
<div className="card p-6">
<form onSubmit={handleAddSubmit} className="space-y-4">
<div>
<label
htmlFor="repositoryUrl"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.addForm.repositoryUrl}
</label>
<input
type="url"
id="repositoryUrl"
value={repositoryUrl}
onChange={(e) => {
setRepositoryUrl(e.target.value);
// Clear error when user types
if (repositoryUrlError) {
setRepositoryUrlError('');
}
}}
onBlur={(e) => {
const url = e.target.value.trim();
if (url && !isValidGitHubUrl(url)) {
setRepositoryUrlError(translations.error.invalidUrl);
}
}}
placeholder={translations.addForm.repositoryUrlPlaceholder}
className={`w-full px-4 py-2 border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 ${
repositoryUrlError ? 'border-error focus:ring-error' : 'border-border focus:ring-primary-500'
}`}
dir="ltr"
required
/>
{repositoryUrlError && (
<p className="text-xs text-error mt-1">{repositoryUrlError}</p>
)}
{!repositoryUrlError && (
<p className="text-xs text-text-muted mt-1">
{translations.addForm.repositoryUrlHelp}
</p>
)}
</div>
<div>
<label
htmlFor="addReason"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.addForm.reason} <span className="text-text-muted">{translations.optional}</span>
</label>
<textarea
id="addReason"
value={addReason}
onChange={(e) => setAddReason(e.target.value)}
placeholder={translations.addForm.reasonPlaceholder}
rows={4}
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
/>
</div>
{addError && (
<div className="p-3 bg-error-bg border border-error/30 rounded-lg">
<div className="flex items-center gap-2 text-error">
<AlertCircle className="w-4 h-4" />
<p className="text-sm">{addError}</p>
</div>
</div>
)}
<button
type="submit"
disabled={isSubmittingAdd || !repositoryUrl.trim()}
className="btn-primary w-full justify-center disabled:opacity-50"
>
{isSubmittingAdd ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{translations.addForm.submitting}
</>
) : (
translations.addForm.submit
)}
</button>
</form>
</div>
{/* Add Requests */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-text-primary mb-4">
{translations.myRequests.title}
</h2>
{loadingAddRequests ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-text-muted" />
</div>
) : addRequests.length === 0 ? (
<p className="text-text-muted text-center py-8">
{translations.myRequests.empty}
</p>
) : (
<div className="space-y-3">
{addRequests.map((request) => {
// Parse skillPath - could be comma-separated list of paths
const skillPaths = request.skillPath?.split(',').map(p => p.trim()).filter(Boolean) || [];
const hasMultiplePaths = skillPaths.length > 3;
const isExpanded = expandedRequests.has(request.id);
const displayPaths = isExpanded ? skillPaths : skillPaths.slice(0, 3);
return (
<div
key={request.id}
className="p-4 bg-surface-subtle rounded-lg"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<code className="text-sm font-mono text-text-primary break-all" dir="ltr">
{request.repositoryUrl}
</code>
{skillPaths.length > 0 && (
<div className="mt-2">
<div className="text-xs text-text-muted mb-1">
{translations.myRequests.skillsFoundPrefix}{skillPaths.length}{translations.myRequests.skillsFoundSuffix}
</div>
<div className="bg-surface rounded p-2 max-h-32 overflow-y-auto" dir="ltr">
<ul className="text-xs font-mono text-text-secondary space-y-0.5">
{displayPaths.map((path, idx) => (
<li key={idx} className="truncate">
{path || '(root)'}
</li>
))}
</ul>
{hasMultiplePaths && (
<button
onClick={() => toggleExpanded(request.id)}
className="flex items-center gap-1 text-xs text-primary-500 hover:text-primary-600 mt-1"
>
{isExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
{translations.myRequests.showLess}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{translations.myRequests.showAllPrefix}{skillPaths.length}{translations.myRequests.showAllSuffix}
</>
)}
</button>
)}
</div>
</div>
)}
<p className="text-sm text-text-secondary mt-2 line-clamp-2" dir="auto">
{request.reason}
</p>
{request.errorMessage && (
<p className="text-xs text-error mt-1" dir="ltr">
{request.errorMessage}
</p>
)}
<p className="text-xs text-text-muted mt-1" dir="ltr">
{new Date(request.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex-shrink-0">
{getStatusBadge(request.status)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
import { initializeCsrfToken } from '@/lib/csrf-client';
interface CsrfProviderProps {
children: React.ReactNode;
}
/**
* Provider component that initializes the CSRF token on mount.
* This ensures the token is available for protected API requests.
*/
export function CsrfProvider({ children }: CsrfProviderProps) {
useEffect(() => {
// Initialize CSRF token on mount
initializeCsrfToken();
}, []);
return <>{children}</>;
}

View File

@@ -0,0 +1,127 @@
'use client';
import { useState, useCallback } from 'react';
import { Copy, Check } from 'lucide-react';
interface DiscoveryMode {
key: string;
letter: string;
title: string;
description: string;
latency: string;
quality: string;
bestFor: string;
recommended?: boolean;
prompt: string;
}
interface DiscoveryModesProps {
modes: DiscoveryMode[];
labels: {
latency: string;
quality: string;
bestFor: string;
recommended: string;
copyPrompt: string;
copied: string;
selectMode: string;
addToFile: string;
};
}
export function DiscoveryModes({ modes, labels }: DiscoveryModesProps) {
const [selectedMode, setSelectedMode] = useState<string>('standard');
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, []);
const selected = modes.find((m) => m.key === selectedMode) || modes[0];
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 my-6 not-prose">
{modes.map((mode) => (
<button
key={mode.key}
onClick={() => setSelectedMode(mode.key)}
className={`glass-card p-4 text-start transition-all cursor-pointer ${
selectedMode === mode.key
? 'ring-2 ring-primary-500 shadow-lg'
: 'hover:ring-1 hover:ring-primary-300 opacity-70 hover:opacity-100'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-text-primary">
{mode.letter}. {mode.title}
</span>
{mode.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 font-medium">
{labels.recommended}
</span>
)}
</div>
<p className="text-sm text-text-secondary mb-3">{mode.description}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-tertiary">
<span>
<span className="font-medium">{labels.latency}:</span> {mode.latency}
</span>
<span>
<span className="font-medium">{labels.quality}:</span> {mode.quality}
</span>
</div>
</button>
))}
</div>
{/* Selected mode details */}
<div className="glass-card p-5 my-6 not-prose">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="font-semibold text-text-primary text-lg">
{selected.letter}. {selected.title}
</h4>
<p className="text-sm text-text-secondary">
{labels.bestFor}: {selected.bestFor}
</p>
</div>
<button
onClick={() => handleCopy(selected.prompt)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50"
>
{copied ? (
<>
<Check className="w-4 h-4" />
{labels.copied}
</>
) : (
<>
<Copy className="w-4 h-4" />
{labels.copyPrompt}
</>
)}
</button>
</div>
<p className="text-sm text-text-secondary mb-3">{labels.addToFile}</p>
<div className="bg-surface-secondary rounded-lg p-4 overflow-x-auto" dir="ltr">
<pre className="text-sm font-mono text-left whitespace-pre-wrap">{selected.prompt}</pre>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Mail, CheckCircle, Loader2 } from 'lucide-react';
interface EarlyAccessFormProps {
variant: 'a' | 'b';
locale: string;
}
export function EarlyAccessForm({ variant, locale }: EarlyAccessFormProps) {
const t = useTranslations('claudePlugin');
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !email.includes('@')) {
setErrorMessage(t('form.invalidEmail'));
setStatus('error');
return;
}
setStatus('loading');
try {
const response = await fetch('/api/early-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
variant,
locale,
source: 'claude-plugin-landing',
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit');
}
setStatus('success');
setEmail('');
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : t('form.genericError'));
setStatus('error');
}
};
if (status === 'success') {
return (
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
<p className="text-green-800 dark:text-green-300 font-medium text-center">
{t('form.successMessage')}
</p>
<p className="text-green-600 dark:text-green-400 text-sm text-center">
{t('form.successDescription')}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary" />
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (status === 'error') setStatus('idle');
}}
placeholder={t('form.emailPlaceholder')}
className="w-full pl-10 pr-4 py-3 rounded-lg border border-border bg-surface text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
disabled={status === 'loading'}
/>
</div>
<button
type="submit"
disabled={status === 'loading'}
className="px-6 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{status === 'loading' ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : null}
{variant === 'a' ? t('form.ctaA') : t('form.ctaB')}
</button>
</div>
{status === 'error' && (
<p className="text-red-500 text-sm text-center">{errorMessage}</p>
)}
<p className="text-text-secondary text-xs text-center">
{t('form.privacyNote')}
</p>
</form>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Component, useState, useCallback } from "react";
import type { ReactNode, ErrorInfo } from "react";
import * as Sentry from "@sentry/nextjs";
import { AlertTriangle, RefreshCw } from "lucide-react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
eventId: string | null;
}
/**
* React Error Boundary with Sentry integration
* Catches rendering errors in child components and reports them to Sentry
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, eventId: null };
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Report to Sentry with React component stack
const eventId = Sentry.captureException(error, {
contexts: {
react: {
componentStack: errorInfo.componentStack,
},
},
});
this.setState({ eventId });
// Also log to console in development
if (process.env.NODE_ENV === "development") {
console.error("Error caught by boundary:", error, errorInfo);
}
}
handleRetry = () => {
this.setState({ hasError: false, error: null, eventId: null });
};
handleReportFeedback = () => {
if (this.state.eventId) {
Sentry.showReportDialog({ eventId: this.state.eventId });
}
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-error-bg">
<AlertTriangle className="h-8 w-8 text-error" />
</div>
<h2 className="mb-2 text-xl font-semibold text-text-primary">
Something went wrong
</h2>
<p className="mb-6 text-text-secondary">
We&apos;ve been notified and are working on a fix. Please try
again or contact support if the problem persists.
</p>
{process.env.NODE_ENV === "development" && this.state.error && (
<div className="mb-6 rounded-lg bg-error-bg p-4 text-left">
<p className="font-mono text-sm text-error">
{this.state.error.message}
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
onClick={this.handleRetry}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Try again
</button>
{this.state.eventId && (
<button
onClick={this.handleReportFeedback}
className="inline-flex items-center justify-center rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-text-primary hover:bg-surface-subtle transition-colors"
>
Report feedback
</button>
)}
</div>
</div>
</div>
);
}
return this.props.children;
}
}
/**
* Hook to throw errors from async code so they can be caught by ErrorBoundary
* Usage:
* const throwError = useAsyncError();
* fetchData().catch(throwError);
*/
export function useAsyncError() {
const [, setError] = useState<Error | null>(null);
return useCallback((error: Error) => {
setError(() => {
throw error;
});
}, []);
}
export default ErrorBoundary;

View File

@@ -0,0 +1,164 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSession, signIn } from 'next-auth/react';
import { useTranslations, useLocale } from 'next-intl';
import { Heart, ExternalLink } from 'lucide-react';
import { fetchWithCsrf } from '@/lib/csrf-client';
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
interface FavoriteButtonProps {
skillId: string;
initialFavorited?: boolean;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
onToggle?: (favorited: boolean) => void;
}
export function FavoriteButton({
skillId,
initialFavorited = false,
size = 'md',
showLabel = false,
onToggle,
}: FavoriteButtonProps) {
const { data: session, status } = useSession();
const t = useTranslations('favorites');
const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(initialFavorited);
const [isLoading, setIsLoading] = useState(false);
const [mirrorNotice, setMirrorNotice] = useState(false);
const pendingRef = useRef(false);
// Fetch actual favorite status when user is authenticated
useEffect(() => {
if (initialFavorited || status !== 'authenticated') return;
let cancelled = false;
fetchWithCsrf('/api/favorites/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillIds: [skillId] }),
})
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data?.favorited?.[skillId]) {
setIsFavorited(true);
}
})
.catch(() => {});
return () => { cancelled = true; };
}, [skillId, status, initialFavorited]);
// Auto-dismiss mirror notice
useEffect(() => {
if (!mirrorNotice) return;
const timer = setTimeout(() => setMirrorNotice(false), 4000);
return () => clearTimeout(timer);
}, [mirrorNotice]);
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
};
const buttonSizeClasses = {
sm: 'p-1.5',
md: 'p-2',
lg: 'p-2.5',
};
const handleToggle = useCallback(async () => {
if (pendingRef.current) return;
if (status === 'loading') return;
if (!session) {
signIn('github');
return;
}
if (isMirror) {
setMirrorNotice(true);
return;
}
pendingRef.current = true;
const newState = !isFavorited;
setIsFavorited(newState); // Optimistic update
setIsLoading(true);
try {
const res = await fetchWithCsrf('/api/favorites', {
method: newState ? 'POST' : 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillId }),
});
if (!res.ok) throw new Error('Failed to update favorite');
onToggle?.(newState);
} catch (error) {
setIsFavorited(!newState); // Rollback on error
console.error('Favorite error:', error);
} finally {
pendingRef.current = false;
setIsLoading(false);
}
}, [session, status, isFavorited, skillId, onToggle]);
const mirrorMsg = locale === 'fa'
? 'برای افزودن به علاقه‌مندی‌ها، از سایت اصلی استفاده کنید'
: 'To add favorites, visit the main site';
return (
<span className="relative inline-block">
<button
type="button"
onClick={handleToggle}
disabled={isLoading || status === 'loading'}
aria-label={isFavorited ? t('remove') : t('add')}
aria-pressed={isFavorited}
title={!session && status !== 'loading' ? t('loginRequired') : (isFavorited ? t('remove') : t('add'))}
className={`
${buttonSizeClasses[size]}
rounded-lg transition-all duration-200
hover:bg-red-50 dark:hover:bg-red-950 group
${isLoading ? 'opacity-50 cursor-wait' : ''}
${showLabel ? 'flex items-center gap-2 px-3' : ''}
`}
>
<Heart
className={`
${sizeClasses[size]}
transition-all duration-200
${
isFavorited
? 'fill-red-500 text-red-500'
: 'fill-transparent text-text-muted group-hover:text-red-500'
}
`}
/>
{showLabel && (
<span className="text-sm text-text-secondary">
{isFavorited ? t('remove') : t('add')}
</span>
)}
</button>
{mirrorNotice && (
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap animate-in fade-in slide-in-from-bottom-2 duration-200">
<a
href={PRIMARY_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 text-xs bg-surface-elevated border border-border rounded-lg shadow-lg text-text-primary hover:text-primary-500 transition-colors"
>
{mirrorMsg}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
)}
</span>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import {
Heart,
Star,
Download,
Shield,
CheckCircle,
ArrowRight,
ArrowLeft,
} from 'lucide-react';
import { FavoriteButton } from './FavoriteButton';
interface Skill {
id: string;
name: string;
description: string | null;
githubOwner: string;
githubStars: number | null;
downloadCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
compatibility: { platforms?: string[] } | null;
}
interface FavoriteItem {
skill: Skill;
}
interface FavoritesListProps {
initialFavorites: FavoriteItem[];
locale: string;
translations: {
verified: string;
emptyTitle: string;
emptyDescription: string;
emptyCta: string;
};
}
export function FavoritesList({ initialFavorites, locale, translations }: FavoritesListProps) {
const [favorites, setFavorites] = useState<FavoriteItem[]>(initialFavorites);
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const formatNumber = (num: number | null): string => {
if (num === null) return '0';
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
const handleToggleFavorite = useCallback((skillId: string, isFavorited: boolean) => {
if (!isFavorited) {
// Remove from list when unfavorited
setFavorites(prev => prev.filter(item => item.skill.id !== skillId));
}
}, []);
if (favorites.length === 0) {
return (
<div className="text-center py-16">
<Heart className="w-16 h-16 text-text-muted mx-auto mb-4" />
<h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.emptyTitle}
</h2>
<p className="text-text-secondary mb-6">{translations.emptyDescription}</p>
<Link href={`/${locale}/browse`} className="btn-primary gap-2">
{translations.emptyCta}
<ArrowIcon className="w-4 h-4" />
</Link>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{favorites.map(({ skill }) => (
<div
key={skill.id}
className="card p-6 relative border border-transparent hover:border-primary-200 transition-all"
>
{/* Favorite button in corner */}
<div className="absolute top-4 end-4">
<FavoriteButton
skillId={skill.id}
initialFavorited={true}
size="sm"
onToggle={(isFavorited) => handleToggleFavorite(skill.id, isFavorited)}
/>
</div>
<Link href={`/${locale}/skill/${skill.id}`}>
<div className="flex items-start gap-3 mb-3 pe-8">
<div className="w-12 h-12 bg-primary-50 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-xl font-bold text-primary-600">
{skill.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<h3 className="font-semibold text-text-primary truncate">
{skill.name}
</h3>
<p className="text-sm text-text-muted">@{skill.githubOwner}</p>
</div>
</div>
<p className="text-sm text-text-secondary line-clamp-2 mb-4" dir="auto">
{skill.description ?? ''}
</p>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-text-muted">
<span className="flex items-center gap-1 ltr-nums">
<Star className="w-4 h-4 text-warning" />
{formatNumber(skill.githubStars)}
</span>
<span className="flex items-center gap-1 ltr-nums">
<Download className="w-4 h-4 text-primary-500" />
{formatNumber(skill.downloadCount)}
</span>
<span className={`flex items-center gap-1 ${skill.securityStatus === 'pass' ? 'text-success' : skill.securityStatus === 'warning' ? 'text-warning' : 'text-text-muted'}`}>
<Shield className="w-4 h-4" />
{skill.securityStatus === 'pass' ? '✓' : skill.securityStatus === 'warning' ? '⚠' : '-'}
</span>
{skill.isVerified && (
<span className="flex items-center gap-1 text-success">
<CheckCircle className="w-4 h-4" />
{translations.verified}
</span>
)}
</div>
{/* Platforms */}
{skill.compatibility?.platforms && skill.compatibility.platforms.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{skill.compatibility.platforms.map((platform) => (
<span
key={platform}
className="px-2 py-0.5 bg-primary-50 text-primary-600 text-xs font-medium rounded"
>
{platform}
</span>
))}
</div>
)}
</Link>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { signIn } from 'next-auth/react';
import { Github } from 'lucide-react';
interface FavoritesSignInProps {
translations: {
loginRequired: string;
signIn: string;
};
}
export function FavoritesSignIn({ translations }: FavoritesSignInProps) {
return (
<div className="card p-8 text-center">
<Github className="w-12 h-12 mx-auto mb-4 text-text-muted" />
<p className="text-text-secondary mb-6">{translations.loginRequired}</p>
<button
onClick={() => signIn('github')}
className="btn-primary inline-flex items-center gap-2"
>
<Github className="w-5 h-5" />
{translations.signIn}
</button>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More