Skip to content

Comments

feat: skill capability visibility, UI badges, and detail page refactor#358

Open
orlyjamie wants to merge 1 commit intomainfrom
feat/capability-visibility
Open

feat: skill capability visibility, UI badges, and detail page refactor#358
orlyjamie wants to merge 1 commit intomainfrom
feat/capability-visibility

Conversation

@orlyjamie
Copy link
Contributor

@orlyjamie orlyjamie commented Feb 16, 2026

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 detail
page. 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 to ClawdisSkillMetadataSchema
  • validateCapabilities() validates array contents against the allowed set
  • Wired into parseClawdisMetadata() — invalid values produce a validation warning, not a hard error (transition period)

API

  • GET /api/v1/skills/{slug} response now includes capabilities (string array)
  • Added to ApiV1SkillResponseSchema

LLM security evaluator

  • Capabilities section added to the context passed to the evaluator (informational — the evaluator can reference it but there's no
    new hard evaluation dimension)

UI

  • Capability badges rendered in SkillInstallCard when capabilities are declared
  • "No capabilities declared" flag shown when a skill has none — this is the key visual nudge
  • SkillDetailPage refactored into focused components (SkillHeader, SkillInstallCard, SkillStats, SkillDetailTabs, etc.)

Docs

  • skill-format.md updated to describe the capabilities field and best practices

Why 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:

  • For users: No way to see what a skill can do before installing it
  • For authors: No clear best practice for declaring what their skill needs
  • For the ecosystem: No foundation for runtime enforcement

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

  • Visibility-only during transition. No publish-time blocking. Existing skills don't have capabilities and we're not going to
    break everyone's workflow overnight.
  • "No capabilities declared" flag over silent absence. Every skill without capabilities gets a visible indicator. This is
    intentional — it's the nudge that drives adoption.
  • Validation warns, doesn't reject. A typo in capabilities (shellz instead of shell) produces a warning but doesn't block
    publishing. Keeps the migration smooth.
  • No author verification tier. May be introduced later as a governance feature.

Not included

  • Publish-time capability enforcement (after adoption ramps)
  • Capability-based search/filtering
  • Capability diff warnings on skill updates

Test plan

Setup

  1. Start the Convex dev backend:
    npx convex dev
  2. Start the frontend:
    npm run dev
  3. Seed test data or ensure you have skills in your local Convex instance — at least one with capabilities and one without

Test capability badges

  1. Browse to a skill detail page for a skill with capabilities declared
    - Expected: Capability badges render in the SkillInstallCard (one per capability)
  2. Browse to a skill with all five capabilities
    - Expected: All five badges render without layout breakage
  3. Browse to a skill without capabilities
    - Expected: "No capabilities declared" flag shown

Test API response

  1. curl the skill detail endpoint for a skill with capabilities:
    curl /api/v1/skills/
    - Expected: latestVersion.capabilities is a string array like ["shell", "filesystem"]
  2. Same for a skill without capabilities:
    - Expected: capabilities is [] or absent

Test validation

  1. Create a skill with an invalid capability value (e.g., "shellz") via Convex dashboard
    - Expected: Warning in logs, skill still publishes
  2. Create a skill with valid capabilities
    - Expected: No warnings, capabilities stored and displayed correctly

Test LLM evaluator

  1. Trigger the security evaluator for a skill with capabilities
    - Expected: Evaluator input context includes a "Declared capabilities" section
  2. Trigger for a skill without capabilities
    - Expected: Context shows "None declared"

Test refactored components

  1. Browse multiple skill detail pages — verify no layout regressions in header, stats, tabs, install card, comments, versions,
    files panels

Files (27 changed, +2762/-1595)

  • Schema + validation — packages/schema/src/schemas.ts, convex/lib/skillCapabilities.ts, convex/lib/skills.ts
  • API — convex/httpApiV1.ts
  • LLM evaluator — convex/lib/securityPrompt.ts
  • UI components — SkillInstallCard.tsx, SkillHeader.tsx, SkillDetailPage.tsx, SkillDetailTabs.tsx, SkillFilesPanel.tsx,
    SkillSecurityScanResults.tsx, SkillStats.tsx, SkillCommentsPanel.tsx, SkillReportDialog.tsx, SkillVersionsPanel.tsx,
    UserBadge.tsx, skillDetailUtils.ts
  • Skills browse — skills/index.tsx, -SkillsResults.tsx, -SkillsToolbar.tsx, -types.ts, -params.ts, -useSkillsBrowseModel.ts
  • Shared — numberFormat.ts, skillPageEntries.ts, styles.css
  • Docs — skill-format.md
image image image image image

Greptile Summary

This PR adds capability declarations to skills (schema, API, LLM evaluator, UI badges) and refactors the SkillDetailPage monolith 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 browse page (/skills) is non-functional. useSkillsBrowseModel is a stub returning hardcoded empty state. All data fetching, search, sorting, filtering, and pagination logic from the original SkillsIndex was deleted but not re-implemented in the new hook. Users will see an empty page with "No skills match that filter."
  • formatSkillStatsTriplet return type mismatch. Returns an array of { label, value } objects, but SkillHeader.tsx and SkillStats.tsx access it as an object with .stars, .downloads, .installsAllTime properties — all will render undefined.
  • UserBadge prop interface doesn't match callsites. Accepts { handle, displayName, image } but is called with { user, fallbackHandle, prefix, ... } — will always display "Unknown" for every skill author.
  • Stub components (SkillReportDialog, SkillCommentsPanel, SkillVersionsPanel) have mismatched prop signatures and remove existing functionality (comments, reporting, version listing) from the skill detail page.
  • The new capability badges in SkillInstallCard and the backend/schema work are clean and correctly implemented.

Confidence Score: 1/5

  • This PR will break the skills browse page, stat rendering, and user badge display at runtime due to incomplete stub implementations and type mismatches.
  • The backend changes (capabilities schema, validation, API, LLM evaluator) are well-implemented and safe. However, the UI refactor introduces multiple runtime-breaking issues: the skills browse page returns no data, stat formatting produces undefined values, UserBadge always shows "Unknown", and three stub components remove existing functionality (comments, reporting, versions) without re-implementing it. These are not edge cases — they affect the primary user-facing pages.
  • src/routes/skills/-useSkillsBrowseModel.ts (completely breaks /skills page), src/components/UserBadge.tsx (prop mismatch), src/components/SkillStats.tsx and src/lib/numberFormat.ts (return type mismatch), src/components/SkillReportDialog.tsx (stub with wrong props)

Last reviewed commit: 03cdb91

Context used:

  • Context from dashboard - AGENTS.md (source)

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
@vercel
Copy link
Contributor

vercel bot commented Feb 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clawhub Ready Ready Preview, Comment Feb 16, 2026 4:03pm

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

27 files reviewed, 6 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +191 to +193
⭐ {formattedStats.stars} · ⤓ {formattedStats.downloads} · ⤒{' '}
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current ·{' '}
{formattedStats.installsAllTime} all-time
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +8
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>
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +7 to +13
export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) {
const formatted = formatSkillStatsTriplet(stats)
return (
<>
⭐ {formatted.stars} · ⤓ {formatted.downloads} · ⤒ {formatted.installsAllTime}
</>
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

Comment on lines +36 to +59
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' }),
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +3
export function SkillReportDialog(_props: { slug: string; open: boolean; onClose: () => void }) {
return null
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?? [],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useSkillsBrowseModel hook returns hardcoded stub values instead of fetching actual skill data, causing the /skills page to always display empty results.

Fix on Vercel

return String(n)
}

export function formatSkillStatsTriplet(stats: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatSkillStatsTriplet returns an array but callers access it as an object with .stars, .downloads, .installsAllTime properties

Fix on Vercel

<span className="analysis-detail-toggle">
Details <span className="chevron">{'\u25BE'}</span>
</span>
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildSkillHref called with wrong argument order (ownerHandle, ownerId, slug instead of slug, ownerHandle), generating incorrect URLs for fork and canonical skill links.

Fix on Vercel

Comment on lines +1 to +5
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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Fix on Vercel

@@ -0,0 +1,3 @@
export function SkillVersionsPanel(_props: { skillId?: string }) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants