claude-code-best/src/components/skills/SkillsMenu.tsx
claude-code-best 34bbc1d403 fix(types): replace all as any with proper type assertions
Eliminate unsafe `as any` casts across 21 non-test source files,
replacing them with specific type annotations:

- Bridge transport: use StdoutMessage type for write/writeBatch calls
- print.ts: type msg.request as Record<string, unknown> for unknown
  SDK control subtypes; use StdoutMessage for output.enqueue()
- API providers (openai/grok/gemini): import ChatCompletion types,
  type streams as AsyncIterable<ChatCompletionChunk>, type request
  bodies as ChatCompletionCreateParamsStreaming
- Computer use executor: use Partial<ResolvePrepareCaptureResult>
  for cross-platform screenshot result
- Components: replace Ink color string casts with proper typing
- Win32 bridge: type stdin as Writable after null check

All 2453 tests pass with 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 23:51:33 +08:00

228 lines
6.2 KiB
TypeScript

import capitalize from 'lodash-es/capitalize.js'
import * as React from 'react'
import { useMemo } from 'react'
import {
type Command,
type CommandBase,
type CommandResultDisplay,
getCommandName,
type PromptCommand,
} from '../../commands.js'
import { Box, Text } from '@anthropic/ink'
import {
estimateSkillFrontmatterTokens,
getSkillsPath,
} from '../../skills/loadSkillsDir.js'
import { getDisplayPath } from '../../utils/file.js'
import { formatTokens } from '../../utils/format.js'
import {
getSettingSourceName,
type SettingSource,
} from '../../utils/settings/constants.js'
import { plural } from '../../utils/stringUtils.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { Dialog } from '@anthropic/ink'
// Skills are always PromptCommands with CommandBase properties
type SkillCommand = CommandBase & PromptCommand
type SkillSource = SettingSource | 'plugin' | 'mcp'
type Props = {
onExit: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
commands: Command[]
}
function getSourceTitle(source: SkillSource): string {
if (source === 'plugin') {
return 'Plugin skills'
}
if (source === 'mcp') {
return 'MCP skills'
}
return `${capitalize(getSettingSourceName(source))} skills`
}
function getSourceSubtitle(
source: SkillSource,
skills: SkillCommand[],
): string | undefined {
// MCP skills show server names; file-based skills show filesystem paths.
// Skill names are `<server>:<skill>`, not `mcp__<server>__…`.
if (source === 'mcp') {
const servers = [
...new Set(
skills
.map(s => {
const idx = s.name.indexOf(':')
return idx > 0 ? s.name.slice(0, idx) : null
})
.filter((n): n is string => n != null),
),
]
return servers.length > 0 ? servers.join(', ') : undefined
}
const skillsPath = getDisplayPath(getSkillsPath(source, 'skills'))
const hasCommandsSkills = skills.some(
s => s.loadedFrom === 'commands_DEPRECATED',
)
return hasCommandsSkills
? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}`
: skillsPath
}
export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
// Filter commands for skills and cast to SkillCommand
const skills = useMemo(() => {
return commands.filter(
(cmd): cmd is SkillCommand =>
cmd.type === 'prompt' &&
(cmd.loadedFrom === 'skills' ||
cmd.loadedFrom === 'commands_DEPRECATED' ||
cmd.loadedFrom === 'plugin' ||
cmd.loadedFrom === 'mcp'),
)
}, [commands])
const skillsBySource = useMemo((): Record<SkillSource, SkillCommand[]> => {
const groups: Record<SkillSource, SkillCommand[]> = {
policySettings: [],
userSettings: [],
projectSettings: [],
localSettings: [],
flagSettings: [],
plugin: [],
mcp: [],
}
for (const skill of skills) {
const source = skill.source as SkillSource
if (source in groups) {
groups[source].push(skill)
}
}
for (const group of Object.values(groups)) {
group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b)))
}
return groups
}, [skills])
const handleCancel = (): void => {
onExit('Skills dialog dismissed', { display: 'system' })
}
if (skills.length === 0) {
return (
<Dialog
title="Skills"
subtitle="No skills found"
onCancel={handleCancel}
hideInputGuide
>
<Text dimColor>
Create skills in .claude/skills/ or ~/.claude/skills/
</Text>
<Text dimColor italic>
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="close"
/>
</Text>
</Dialog>
)
}
const getScopeTag = (
source: SkillSource,
): { label: string; color: string } | undefined => {
switch (source) {
case 'projectSettings':
case 'localSettings':
return { label: 'local', color: 'yellow' }
case 'userSettings':
return { label: 'global', color: 'cyan' }
case 'policySettings':
return { label: 'managed', color: 'magenta' }
default:
return undefined
}
}
const renderSkill = (skill: SkillCommand) => {
const estimatedTokens = estimateSkillFrontmatterTokens(skill)
const tokenDisplay = `~${formatTokens(estimatedTokens)}`
const pluginName =
skill.source === 'plugin'
? skill.pluginInfo?.pluginManifest.name
: undefined
const scopeTag = getScopeTag(skill.source)
return (
<Box key={`${skill.name}-${skill.source}`}>
<Text>{getCommandName(skill)}</Text>
{scopeTag && (
<Text color={scopeTag.color as keyof Theme}> [{scopeTag.label}]</Text>
)}
<Text dimColor>
{pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description
tokens
</Text>
</Box>
)
}
const renderSkillGroup = (source: SkillSource) => {
const groupSkills = skillsBySource[source]
if (groupSkills.length === 0) return null
const title = getSourceTitle(source)
const subtitle = getSourceSubtitle(source, groupSkills)
return (
<Box flexDirection="column" key={source}>
<Box>
<Text bold dimColor>
{title}
</Text>
{subtitle && <Text dimColor> ({subtitle})</Text>}
</Box>
{groupSkills.map(skill => renderSkill(skill))}
</Box>
)
}
return (
<Dialog
title="Skills"
subtitle={`${skills.length} ${plural(skills.length, 'skill')}`}
onCancel={handleCancel}
hideInputGuide
>
<Box flexDirection="column" gap={1}>
{renderSkillGroup('projectSettings')}
{renderSkillGroup('localSettings')}
{renderSkillGroup('userSettings')}
{renderSkillGroup('flagSettings')}
{renderSkillGroup('policySettings')}
{renderSkillGroup('plugin')}
{renderSkillGroup('mcp')}
</Box>
<Text dimColor italic>
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="close"
/>
</Text>
</Dialog>
)
}