feat: skill capability visibility, UI badges, and detail page refactor#358
feat: skill capability visibility, UI badges, and detail page refactor#358
Conversation
Not all ClawHub users are security-savvy, and skill authors don't always know what best practices look like. This change brings capability declarations front and center — making it obvious what a skill needs before you install it, and giving authors clear guidance on what to declare and why. Capabilities (shell, filesystem, network, browser, sessions) are now visible throughout ClawHub: on skill detail pages, in API responses, and in the LLM security evaluator context. Skills that haven't declared any capabilities are flagged with "No capabilities declared" — a gentle nudge toward the ecosystem maturity we need as community skills grow. This is visibility-only during the transition period. No publishing is blocked. The goal is to build the habit first — authors declare what their skill needs, users see it before they install, and OpenClaw enforces it at runtime. Changes: - capabilities field in schema + API response - validateCapabilities() wired into metadata parsing - Capabilities section in LLM evaluator context (informational) - UI: capability badges + "No capabilities declared" flag - SkillDetailPage refactored into focused components - skill-format docs updated Companion: openclaw/openclaw branch client-side-security-initial
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| ⭐ {formattedStats.stars} · ⤓ {formattedStats.downloads} · ⤒{' '} | ||
| {formatCompactStat(skill.stats.installsCurrent ?? 0)} current ·{' '} | ||
| {formattedStats.installsAllTime} all-time |
There was a problem hiding this comment.
formatSkillStatsTriplet return type mismatch
formatSkillStatsTriplet returns SkillStatsTriplet[] (an array of { label, value } objects), but this code accesses formattedStats.stars, formattedStats.downloads, and formattedStats.installsAllTime — named properties that do not exist on the array type. These will all evaluate to undefined at runtime, rendering blank stats in the skill header.
Either refactor formatSkillStatsTriplet to return a named-property object like { stars: string; downloads: string; installsAllTime: string }, or use formatCompactStat directly for each stat here (as was done on the same line for installsCurrent).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/SkillHeader.tsx
Line: 191:193
Comment:
**`formatSkillStatsTriplet` return type mismatch**
`formatSkillStatsTriplet` returns `SkillStatsTriplet[]` (an array of `{ label, value }` objects), but this code accesses `formattedStats.stars`, `formattedStats.downloads`, and `formattedStats.installsAllTime` — named properties that do not exist on the array type. These will all evaluate to `undefined` at runtime, rendering blank stats in the skill header.
Either refactor `formatSkillStatsTriplet` to return a named-property object like `{ stars: string; downloads: string; installsAllTime: string }`, or use `formatCompactStat` directly for each stat here (as was done on the same line for `installsCurrent`).
How can I resolve this? If you propose a fix, please make it concise.| export function UserBadge({ handle, displayName, image }: { handle?: string | null; displayName?: string | null; image?: string | null }) { | ||
| return ( | ||
| <span className="user-badge"> | ||
| {image ? <img src={image} alt="" style={{ width: 20, height: 20, borderRadius: '50%', marginRight: 4 }} /> : null} | ||
| <span>{displayName ?? handle ?? 'Unknown'}</span> | ||
| </span> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Props interface doesn't match callsites
UserBadge accepts { handle, displayName, image }, but every callsite passes completely different props:
SkillHeader.tsx:196:<UserBadge user={owner} fallbackHandle={ownerHandle} prefix="by" size="md" showName />SkillsResults.tsx:63,95:<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />
None of user, fallbackHandle, prefix, size, showName, or link are accepted by this component. The destructured props (handle, displayName, image) will all be undefined, so the component will always display "Unknown" for every user.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/UserBadge.tsx
Line: 1:8
Comment:
**Props interface doesn't match callsites**
`UserBadge` accepts `{ handle, displayName, image }`, but every callsite passes completely different props:
- `SkillHeader.tsx:196`: `<UserBadge user={owner} fallbackHandle={ownerHandle} prefix="by" size="md" showName />`
- `SkillsResults.tsx:63,95`: `<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />`
None of `user`, `fallbackHandle`, `prefix`, `size`, `showName`, or `link` are accepted by this component. The destructured props (`handle`, `displayName`, `image`) will all be `undefined`, so the component will always display "Unknown" for every user.
How can I resolve this? If you propose a fix, please make it concise.| export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) { | ||
| const formatted = formatSkillStatsTriplet(stats) | ||
| return ( | ||
| <> | ||
| ⭐ {formatted.stars} · ⤓ {formatted.downloads} · ⤒ {formatted.installsAllTime} | ||
| </> | ||
| ) |
There was a problem hiding this comment.
Type error: SkillStatsTripletLine accesses nonexistent properties
This component has two compounding type issues:
-
The
statsprop is typed asSkillStatsTriplet({ label: string; value: string }) — a single label/value pair — but the component is called withskill.stats(the full stats object withdownloads,stars, etc.) atSkillsResults.tsx:65. -
formatSkillStatsTriplet(stats)returnsSkillStatsTriplet[](an array), but the template accessesformatted.stars,formatted.downloads, andformatted.installsAllTime— named properties that don't exist on an array.
The same issue applies to SkillMetricsRow below (line 16–25). Both components will render undefined for all stat values.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/SkillStats.tsx
Line: 7:13
Comment:
**Type error: `SkillStatsTripletLine` accesses nonexistent properties**
This component has two compounding type issues:
1. The `stats` prop is typed as `SkillStatsTriplet` (`{ label: string; value: string }`) — a single label/value pair — but the component is called with `skill.stats` (the full stats object with `downloads`, `stars`, etc.) at `SkillsResults.tsx:65`.
2. `formatSkillStatsTriplet(stats)` returns `SkillStatsTriplet[]` (an array), but the template accesses `formatted.stars`, `formatted.downloads`, and `formatted.installsAllTime` — named properties that don't exist on an array.
The same issue applies to `SkillMetricsRow` below (line 16–25). Both components will render `undefined` for all stat values.
How can I resolve this? If you propose a fix, please make it concise.| return { | ||
| query, | ||
| hasQuery, | ||
| sort, | ||
| dir, | ||
| view, | ||
| highlightedOnly, | ||
| nonSuspiciousOnly, | ||
| isLoadingSkills: false, | ||
| sorted: [] as SkillListEntry[], | ||
| paginationStatus: 'Exhausted' as const, | ||
| canLoadMore: false, | ||
| isLoadingMore: false, | ||
| canAutoLoad: false, | ||
| loadMoreRef: useRef<HTMLDivElement>(null), | ||
| activeFilters: [] as string[], | ||
| loadMore: () => {}, | ||
| onQueryChange: (next: string) => updateSearch({ q: next || undefined }), | ||
| onToggleHighlighted: () => updateSearch({ highlighted: highlightedOnly ? undefined : true }), | ||
| onToggleNonSuspicious: () => updateSearch({ nonSuspicious: nonSuspiciousOnly ? undefined : true }), | ||
| onSortChange: (value: string) => updateSearch({ sort: value }), | ||
| onToggleDir: () => updateSearch({ dir: dir === 'asc' ? 'desc' : 'asc' }), | ||
| onToggleView: () => updateSearch({ view: view === 'cards' ? 'list' : 'cards' }), | ||
| } |
There was a problem hiding this comment.
Skills browse page is completely broken — hardcoded empty state
This hook returns hardcoded no-op values: sorted: [], isLoadingSkills: false, paginationStatus: 'Exhausted', loadMore: () => {}. The previous implementation in skills/index.tsx had working usePaginatedQuery, search action calls, sorting, filtering, and IntersectionObserver-based infinite scroll — all of that was removed and replaced with this stub.
As a result, the /skills browse page will always show "No skills match that filter." with no way to load or search for skills. This appears to be an incomplete extraction — the data fetching, search, sorting, and pagination logic from the original SkillsIndex was not migrated into this hook.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/skills/-useSkillsBrowseModel.ts
Line: 36:59
Comment:
**Skills browse page is completely broken — hardcoded empty state**
This hook returns hardcoded no-op values: `sorted: []`, `isLoadingSkills: false`, `paginationStatus: 'Exhausted'`, `loadMore: () => {}`. The previous implementation in `skills/index.tsx` had working `usePaginatedQuery`, search action calls, sorting, filtering, and IntersectionObserver-based infinite scroll — all of that was removed and replaced with this stub.
As a result, the `/skills` browse page will always show "No skills match that filter." with no way to load or search for skills. This appears to be an incomplete extraction — the data fetching, search, sorting, and pagination logic from the original `SkillsIndex` was not migrated into this hook.
How can I resolve this? If you propose a fix, please make it concise.| export function SkillReportDialog(_props: { slug: string; open: boolean; onClose: () => void }) { | ||
| return null | ||
| } |
There was a problem hiding this comment.
Stub props don't match callsite — reporting is broken
SkillReportDialog accepts { slug, open, onClose } but SkillDetailPage.tsx:392-400 passes { isOpen, isSubmitting, reportReason, reportError, onReasonChange, onCancel, onSubmit }. Since the stub always returns null, the report dialog will never render even when the user clicks "Report". The old inline window.prompt-based reporting was removed in this refactor, so skill reporting functionality is fully removed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/SkillReportDialog.tsx
Line: 1:3
Comment:
**Stub props don't match callsite — reporting is broken**
`SkillReportDialog` accepts `{ slug, open, onClose }` but `SkillDetailPage.tsx:392-400` passes `{ isOpen, isSubmitting, reportReason, reportError, onReasonChange, onCancel, onSubmit }`. Since the stub always returns `null`, the report dialog will never render even when the user clicks "Report". The old inline `window.prompt`-based reporting was removed in this refactor, so skill reporting functionality is fully removed.
How can I resolve this? If you propose a fix, please make it concise.| version: result.latestVersion.version, | ||
| createdAt: result.latestVersion.createdAt, | ||
| changelog: result.latestVersion.changelog, | ||
| capabilities: (result.latestVersion.parsed as any)?.clawdis?.capabilities ?? [], |
There was a problem hiding this comment.
as any bypasses type safety
The as any cast here silently suppresses any type error if the shape of result.latestVersion.parsed changes. Consider using a narrower cast or adding a typed accessor for the clawdis capabilities, similar to how it's accessed in SkillDetailPage.tsx with (latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)?.clawdis.
Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/httpApiV1.ts
Line: 273:273
Comment:
**`as any` bypasses type safety**
The `as any` cast here silently suppresses any type error if the shape of `result.latestVersion.parsed` changes. Consider using a narrower cast or adding a typed accessor for the clawdis capabilities, similar to how it's accessed in `SkillDetailPage.tsx` with `(latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)?.clawdis`.
How can I resolve this? If you propose a fix, please make it concise.| view, | ||
| highlightedOnly, | ||
| nonSuspiciousOnly, | ||
| isLoadingSkills: false, |
| return String(n) | ||
| } | ||
|
|
||
| export function formatSkillStatsTriplet(stats: { |
src/components/SkillDetailPage.tsx
Outdated
| <span className="analysis-detail-toggle"> | ||
| Details <span className="chevron">{'\u25BE'}</span> | ||
| </span> | ||
| </button> |
| export function UserBadge({ handle, displayName, image }: { handle?: string | null; displayName?: string | null; image?: string | null }) { | ||
| return ( | ||
| <span className="user-badge"> | ||
| {image ? <img src={image} alt="" style={{ width: 20, height: 20, borderRadius: '50%', marginRight: 4 }} /> : null} | ||
| <span>{displayName ?? handle ?? 'Unknown'}</span> |
There was a problem hiding this comment.
| export function UserBadge({ handle, displayName, image }: { handle?: string | null; displayName?: string | null; image?: string | null }) { | |
| return ( | |
| <span className="user-badge"> | |
| {image ? <img src={image} alt="" style={{ width: 20, height: 20, borderRadius: '50%', marginRight: 4 }} /> : null} | |
| <span>{displayName ?? handle ?? 'Unknown'}</span> | |
| type UserBadgeProps = { | |
| user?: { handle?: string | null; name?: string | null; displayName?: string | null; image?: string | null } | null | |
| fallbackHandle?: string | null | |
| prefix?: string | |
| link?: boolean | |
| size?: 'sm' | 'md' | 'lg' | |
| showName?: boolean | |
| } | |
| export function UserBadge({ user, fallbackHandle, prefix, size = 'sm' }: UserBadgeProps) { | |
| const handle = user?.handle ?? fallbackHandle | |
| const displayName = user?.displayName ?? user?.name ?? handle | |
| const image = user?.image | |
| const imgSize = size === 'lg' ? 28 : size === 'md' ? 24 : 20 | |
| return ( | |
| <span className="user-badge"> | |
| {prefix ? <span>{prefix} </span> : null} | |
| {image ? <img src={image} alt="" style={{ width: imgSize, height: imgSize, borderRadius: '50%', marginRight: 4 }} /> : null} | |
| <span>{displayName ?? 'Unknown'}</span> |
UserBadge component defined with wrong props, causing all callers to pass undefined values and display "Unknown" instead of actual user information.
| @@ -0,0 +1,3 @@ | |||
| export function SkillVersionsPanel(_props: { skillId?: string }) { | |||
There was a problem hiding this comment.
| export function SkillVersionsPanel(_props: { skillId?: string }) { | |
| import type { Doc } from '../../convex/_generated/dataModel' | |
| type SkillVersionsPanelProps = { | |
| versions: Doc<'skillVersions'>[] | undefined | |
| nixPlugin: boolean | |
| skillSlug: string | |
| } | |
| export function SkillVersionsPanel(_props: SkillVersionsPanelProps) { |
SkillVersionsPanel component has incorrect props type definition that doesn't match caller's props
Summary
Not all ClawHub users are security-savvy, and skill authors don't always know what best practices look like. This change brings
capability declarations front and center — making it obvious what a skill needs before you install it, and giving authors clear
guidance on what to declare and why.
Skills now display their declared capabilities (
shell,filesystem,network,browser,sessions) as badges on the detailpage. Skills that haven't declared any show a "No capabilities declared" flag — a clear signal to both users and authors that
something's missing.
This is the registry-side companion to the runtime enforcement work in OpenClaw. During the transition period, ClawHub is
visibility-only — capabilities are displayed but don't block publishing. The goal is to build the habit first: authors declare
what their skill needs, users see it before install, and OpenClaw enforces it at runtime.
Companion PR: https://github.com/openclaw/openclaw/compare/client-side-security-initial?expand=1
What changed
Schema and validation
capabilities: 'string[]?'added toClawdisSkillMetadataSchemavalidateCapabilities()validates array contents against the allowed setparseClawdisMetadata()— invalid values produce a validation warning, not a hard error (transition period)API
GET /api/v1/skills/{slug}response now includescapabilities(string array)ApiV1SkillResponseSchemaLLM security evaluator
new hard evaluation dimension)
UI
SkillInstallCardwhen capabilities are declaredSkillDetailPagerefactored into focused components (SkillHeader,SkillInstallCard,SkillStats,SkillDetailTabs, etc.)Docs
skill-format.mdupdated to describe thecapabilitiesfield and best practicesWhy this matters
Community skills are growing. Users install them without knowing what system access they need. Skill authors publish without
thinking about what permissions their skill actually requires. This creates a gap:
Capability declarations close that gap. They're modeled after how mobile apps and the Apple ecosystem handle permissions — you
declare what you need, users see it upfront, and the platform enforces it. It's not a silver bullet, but it's a massive
improvement over "install and hope for the best."
Design decisions
break everyone's workflow overnight.
intentional — it's the nudge that drives adoption.
shellzinstead ofshell) produces a warning but doesn't blockpublishing. Keeps the migration smooth.
Not included
Test plan
Setup
npm run dev
Test capability badges
- Expected: Capability badges render in the SkillInstallCard (one per capability)
- Expected: All five badges render without layout breakage
- Expected: "No capabilities declared" flag shown
Test API response
curl /api/v1/skills/
- Expected: latestVersion.capabilities is a string array like ["shell", "filesystem"]
- Expected: capabilities is [] or absent
Test validation
- Expected: Warning in logs, skill still publishes
- Expected: No warnings, capabilities stored and displayed correctly
Test LLM evaluator
- Expected: Evaluator input context includes a "Declared capabilities" section
- Expected: Context shows "None declared"
Test refactored components
files panels
Files (27 changed, +2762/-1595)
SkillSecurityScanResults.tsx, SkillStats.tsx, SkillCommentsPanel.tsx, SkillReportDialog.tsx, SkillVersionsPanel.tsx,
UserBadge.tsx, skillDetailUtils.ts
Greptile Summary
This PR adds capability declarations to skills (schema, API, LLM evaluator, UI badges) and refactors the
SkillDetailPagemonolith into focused subcomponents. The backend additions —skillCapabilities.ts, schema changes, API response, and security prompt — are solid and ready. However, the UI refactor introduces several runtime-breaking issues that need to be resolved before merge:/skills) is non-functional.useSkillsBrowseModelis a stub returning hardcoded empty state. All data fetching, search, sorting, filtering, and pagination logic from the originalSkillsIndexwas deleted but not re-implemented in the new hook. Users will see an empty page with "No skills match that filter."formatSkillStatsTripletreturn type mismatch. Returns an array of{ label, value }objects, butSkillHeader.tsxandSkillStats.tsxaccess it as an object with.stars,.downloads,.installsAllTimeproperties — all will renderundefined.UserBadgeprop interface doesn't match callsites. Accepts{ handle, displayName, image }but is called with{ user, fallbackHandle, prefix, ... }— will always display "Unknown" for every skill author.SkillReportDialog,SkillCommentsPanel,SkillVersionsPanel) have mismatched prop signatures and remove existing functionality (comments, reporting, version listing) from the skill detail page.SkillInstallCardand the backend/schema work are clean and correctly implemented.Confidence Score: 1/5
src/routes/skills/-useSkillsBrowseModel.ts(completely breaks /skills page),src/components/UserBadge.tsx(prop mismatch),src/components/SkillStats.tsxandsrc/lib/numberFormat.ts(return type mismatch),src/components/SkillReportDialog.tsx(stub with wrong props)Last reviewed commit: 03cdb91
Context used:
dashboard- AGENTS.md (source)