Files
clawsec/pages/SkillDetail.tsx
T
davida-ps e4c1e07544 fix(skills-catalog): resolve platform metadata fallbacks (#229)
* fix(skills-catalog): resolve platform metadata fallbacks

* fix(skills-catalog): harden platform metadata guards
2026-05-10 16:07:31 +03:00

404 lines
15 KiB
TypeScript

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, SkillPlatformMetadata } from '../types';
import { defaultMarkdownComponents } from '../utils/markdownComponents';
import { stripFrontmatter } from '../utils/markdownHelpers.mjs';
const PLATFORM_METADATA_KEYS = ['openclaw', 'hermes', 'nanoclaw', 'picoclaw'] as const;
const isProbablyHtmlDocument = (text: string): boolean => {
const start = text.trimStart().slice(0, 200).toLowerCase();
return start.startsWith('<!doctype html') || start.startsWith('<html');
};
const isPlatformMetadataObject = (value: unknown): value is SkillPlatformMetadata => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const maybe = value as Record<string, unknown>;
return 'emoji' in maybe || 'category' in maybe || 'triggers' in maybe;
};
const resolvePlatformMetadata = (skill: SkillJson): SkillPlatformMetadata => {
const platform = skill.platform;
if (
typeof platform === 'string' &&
(PLATFORM_METADATA_KEYS as readonly string[]).includes(platform)
) {
const platformBlock = skill[platform as (typeof PLATFORM_METADATA_KEYS)[number]];
if (isPlatformMetadataObject(platformBlock)) return platformBlock;
}
for (const key of PLATFORM_METADATA_KEYS) {
const fallbackBlock = skill[key];
if (isPlatformMetadataObject(fallbackBlock)) return fallbackBlock;
}
return {};
};
export const SkillDetail: React.FC = () => {
const { skillId } = useParams<{ skillId: string }>();
const [skillData, setSkillData] = useState<SkillJson | null>(null);
const [checksums, setChecksums] = useState<SkillChecksums | null>(null);
const [doc, setDoc] = useState<{ filename: string; content: string } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(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 installCommand = skillData
? `npx clawhub@latest install ${skillData.name}`
: '';
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/${skillData.name}-v${skillData.version}`;
}
}
} catch {
// ignore invalid URLs
}
return skillData.homepage;
}, [skillData]);
const platformMetadata = useMemo(
() => (skillData ? resolvePlatformMetadata(skillData) : null),
[skillData]
);
const triggers = useMemo(() => {
if (!platformMetadata || !Array.isArray(platformMetadata.triggers)) return [];
return platformMetadata.triggers;
}, [platformMetadata]);
if (loading) {
return (
<div className="py-16 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-clawd-accent"></div>
<p className="mt-4 text-gray-400">Loading skill...</p>
</div>
);
}
if (error || !skillData) {
return (
<div className="py-16 text-center">
<Shield className="w-16 h-16 mx-auto text-gray-600 mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Skill Not Found</h2>
<p className="text-gray-400 mb-4">{error || 'This skill does not exist'}</p>
<Link to="/skills" className="text-clawd-accent hover:underline">
Back to Skills Catalog
</Link>
</div>
);
}
return (
<div className="pt-8 space-y-8">
{/* Back Link */}
<Link
to="/skills"
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
Back to Skills
</Link>
{/* Header */}
<section className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div className="flex items-start gap-4">
<span className="text-4xl">{platformMetadata?.emoji || '📦'}</span>
<div>
<h1 className="text-3xl font-bold text-white mb-1">{skillData.name}</h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-500 font-mono">v{skillData.version}</span>
{/* Category badge - hidden for now, uncomment when we have multiple categories
<span className="text-gray-500 bg-clawd-800 px-2 py-0.5 rounded">
{platformMetadata?.category || 'utility'}
</span>
*/}
</div>
</div>
</div>
<div className="flex gap-3">
<a
href={releasePageUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-clawd-800 border border-clawd-700 rounded-lg text-white hover:border-clawd-accent transition-colors"
>
<ExternalLink size={16} />
Release Page
</a>
</div>
</section>
{/* Description */}
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
<p className="text-gray-300 text-lg">{skillData.description}</p>
</section>
{/* Install Command */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Download size={20} />
Quick Install
</h2>
<div className="bg-clawd-800 rounded-lg p-3 sm:p-4 flex items-center justify-between gap-2 sm:gap-4">
<code className="text-gray-200 font-mono text-xs sm:text-sm overflow-x-auto break-all min-w-0 flex-1">
{installCommand}
</code>
<button
onClick={() => handleCopy(installCommand, 'install')}
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
title="Copy to clipboard"
>
{copied === 'install' ? (
<Check size={20} className="text-green-400" />
) : (
<Copy size={20} className="text-gray-400" />
)}
</button>
</div>
</section>
{/* Checksums */}
{checksums && Object.keys(checksums.files).length > 0 && (
<section className="space-y-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Shield size={20} />
File Checksums
</h2>
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b border-clawd-700">
<th className="text-left px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">File</th>
<th className="text-left px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">SHA256</th>
<th className="text-right px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">Size</th>
<th className="px-3 sm:px-4 py-3"></th>
</tr>
</thead>
<tbody>
{(Object.entries(checksums.files) as Array<
[string, SkillChecksums['files'][string]]
>).map(([filename, info]) => {
const displayPath = info.path ?? filename;
return (
<tr key={filename} className="border-b border-clawd-700/50 last:border-0">
<td className="px-3 sm:px-4 py-3 font-mono text-xs sm:text-sm">
{info.url ? (
<a
href={info.url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:text-clawd-accent hover:underline"
title={info.url}
>
{displayPath}
</a>
) : (
<span className="text-white">{displayPath}</span>
)}
</td>
<td className="px-3 sm:px-4 py-3 font-mono text-xs text-gray-400 truncate max-w-[120px] sm:max-w-[200px]">
{info.sha256}
</td>
<td className="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-400 text-right whitespace-nowrap">
{(info.size / 1024).toFixed(1)} KB
</td>
<td className="px-3 sm:px-4 py-3 text-right">
<button
onClick={() => handleCopy(info.sha256, filename)}
className="p-1.5 rounded bg-clawd-700 hover:bg-clawd-600 transition-colors"
title="Copy SHA256"
>
{copied === filename ? (
<Check size={14} className="text-green-400" />
) : (
<Copy size={14} className="text-gray-400" />
)}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
)}
{/* Documentation */}
{doc && (
<section className="space-y-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<FileText size={20} />
Documentation <span className="text-sm font-normal text-gray-500">({doc.filename})</span>
</h2>
<div className="skill-docs bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
<Markdown
remarkPlugins={[remarkGfm]}
components={defaultMarkdownComponents}
>
{stripFrontmatter(doc.content)}
</Markdown>
</div>
</section>
)}
{/* Metadata */}
<section className="grid md:grid-cols-2 gap-6">
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6 space-y-4">
<h3 className="font-bold text-white">Metadata</h3>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Author</dt>
<dd className="text-white">{skillData.author}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">License</dt>
<dd className="text-white">{skillData.license}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Category</dt>
<dd className="text-white">{platformMetadata?.category || 'utility'}</dd>
</div>
</dl>
</div>
{triggers.length > 0 && (
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6 space-y-4">
<h3 className="font-bold text-white">Trigger Phrases</h3>
<div className="flex flex-wrap gap-2">
{triggers.slice(0, 8).map((trigger) => (
<span
key={trigger}
className="text-xs bg-clawd-700 text-gray-300 px-2 py-1 rounded"
>
"{trigger}"
</span>
))}
</div>
</div>
)}
</section>
<Footer />
</div>
);
};