import React, { useState, useEffect, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { ArrowLeft, Copy, Check, Download, ExternalLink, FileText, Shield } from 'lucide-react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Footer } from '../components/Footer'; import type { SkillJson, SkillChecksums } from '../types'; import { getPlatformDescriptor } from '../utils/advisoryPlatforms'; import { defaultMarkdownComponents } from '../utils/markdownComponents'; import { stripFrontmatter } from '../utils/markdownHelpers.mjs'; import { getRecommendedSkillPlatforms, resolveSkillPlatformMetadata } from '../utils/skillPlatforms'; const RELEASE_REPO_URL = 'https://github.com/prompt-security/clawsec'; const isProbablyHtmlDocument = (text: string): boolean => { const start = text.trimStart().slice(0, 200).toLowerCase(); return start.startsWith(' { const { skillId } = useParams<{ skillId: string }>(); const [skillData, setSkillData] = useState(null); const [checksums, setChecksums] = useState(null); const [doc, setDoc] = useState<{ filename: string; content: string } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(null); useEffect(() => { const fetchSkillData = async () => { if (!skillId) return; try { setDoc(null); // Fetch skill.json const skillResponse = await fetch(`/skills/${skillId}/skill.json`, { headers: { Accept: 'application/json' } }); if (!skillResponse.ok) { throw new Error('Skill not found'); } const skillContentType = skillResponse.headers.get('content-type') ?? ''; const skillRaw = await skillResponse.text(); if (skillContentType.includes('text/html') || isProbablyHtmlDocument(skillRaw)) { throw new Error('Skill not found'); } let skill: SkillJson; try { skill = JSON.parse(skillRaw) as SkillJson; } catch { throw new Error('Invalid skill metadata'); } setSkillData(skill); // Fetch checksums.json try { const checksumsResponse = await fetch(`/skills/${skillId}/checksums.json`, { headers: { Accept: 'application/json' } }); if (checksumsResponse.ok) { const checksumsContentType = checksumsResponse.headers.get('content-type') ?? ''; const checksumsRaw = await checksumsResponse.text(); if (!checksumsContentType.includes('text/html') && !isProbablyHtmlDocument(checksumsRaw)) { try { const checksumsData = JSON.parse(checksumsRaw) as SkillChecksums; setChecksums(checksumsData); } catch { // Checksums malformed, ignore. } } } } catch { // Checksums not available } // Fetch documentation (README.md preferred, fallback to SKILL.md). // Note: Dev servers may fall back to serving index.html with 200 for missing files; // guard against accidentally rendering HTML as docs. try { const fetchDocFile = async (filename: string) => { const response = await fetch(`/skills/${skillId}/${filename}`, { headers: { Accept: 'text/plain' } }); if (!response.ok) return null; const contentType = response.headers.get('content-type') ?? ''; const rawText = await response.text(); if (contentType.includes('text/html') || isProbablyHtmlDocument(rawText)) return null; const text = filename === 'SKILL.md' ? stripFrontmatter(rawText).trim() : rawText.trim(); return text.length > 0 ? text : null; }; const candidates = ['README.md', 'SKILL.md']; for (const filename of candidates) { const content = await fetchDocFile(filename); if (content) { setDoc({ filename, content }); break; } } } catch { // Documentation not available } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load skill'); } finally { setLoading(false); } }; fetchSkillData(); }, [skillId]); const handleCopy = (text: string, id: string) => { navigator.clipboard.writeText(text); setCopied(id); setTimeout(() => setCopied(null), 2000); }; const releaseTag = skillData ? `${skillData.name}-v${skillData.version}` : ''; const skillInstructionsUrl = releaseTag ? `${RELEASE_REPO_URL}/releases/download/${releaseTag}/SKILL.md` : ''; const recommendedPlatforms = useMemo( () => (skillData ? getRecommendedSkillPlatforms(skillData) : []), [skillData] ); const isOpenClawSkill = recommendedPlatforms.includes('openclaw'); const installCommand = skillData ? isOpenClawSkill ? `npx clawhub@latest install ${skillData.name}` : `curl -sLO ${skillInstructionsUrl}` : ''; const installLabel = isOpenClawSkill ? 'Via ClawHub' : 'Via SKILL.md instructions'; const installHelp = isOpenClawSkill ? 'Recommended for OpenClaw-compatible skills.' : 'Pull the published instruction file and follow the platform-specific setup steps.'; const releasePageUrl = useMemo(() => { if (!skillData) return ''; try { const url = new URL(skillData.homepage); if (url.hostname === 'github.com') { const [owner, repo] = url.pathname.split('/').filter(Boolean); if (owner && repo) { const repoBase = `${url.origin}/${owner}/${repo.replace(/\\.git$/, '')}`; return `${repoBase}/releases/tag/${releaseTag}`; } } } catch { // ignore invalid URLs } return skillData.homepage; }, [releaseTag, skillData]); const platformMetadata = useMemo( () => (skillData ? resolveSkillPlatformMetadata(skillData) : null), [skillData] ); const triggers = useMemo(() => { if (!platformMetadata || !Array.isArray(platformMetadata.triggers)) return []; return platformMetadata.triggers; }, [platformMetadata]); if (loading) { return (

Loading skill...

); } if (error || !skillData) { return (

Skill Not Found

{error || 'This skill does not exist'}

Back to Skills Catalog
); } return (
{/* Back Link */} Back to Skills {/* Header */}
{platformMetadata?.emoji || '📦'}

{skillData.name}

v{skillData.version} {recommendedPlatforms.slice(0, 4).map((platform) => { const descriptor = getPlatformDescriptor(platform); return ( {descriptor.label} ); })} {/* Category badge - hidden for now, uncomment when we have multiple categories {platformMetadata?.category || 'utility'} */}
{/* Description */}

{skillData.description}

{/* Install Command */}

Quick Install

{installLabel}

{installHelp}

{installCommand}
{/* Checksums */} {checksums && Object.keys(checksums.files).length > 0 && (

File Checksums

{(Object.entries(checksums.files) as Array< [string, SkillChecksums['files'][string]] >).map(([filename, info]) => { const displayPath = info.path ?? filename; return ( ); })}
File SHA256 Size
{info.url ? ( {displayPath} ) : ( {displayPath} )} {info.sha256} {(info.size / 1024).toFixed(1)} KB
)} {/* Documentation */} {doc && (

Documentation ({doc.filename})

{stripFrontmatter(doc.content)}
)} {/* Metadata */}

Metadata

Author
{skillData.author}
License
{skillData.license}
Category
{platformMetadata?.category || 'utility'}
{triggers.length > 0 && (

Trigger Phrases

{triggers.slice(0, 8).map((trigger) => ( "{trigger}" ))}
)}
); };