Unify plugin capabilities with the desktop management views

Desktop plugin details now route into the shared Skills, Agents, and MCP management surfaces instead of maintaining separate read-only drilldowns. This also extends the desktop/server skill aggregation so plugin-provided skills appear in the shared list, groups MCP entries by source, and preserves detail-view back navigation based on where the user entered the page.

The implementation keeps plugin detail as the high-level capability hub while pushing real inspection into the existing management pages. Disabled plugins no longer expose false navigation paths into shared views, and the agent-browser regression script was expanded to exercise the new end-to-end flows.

Constraint: Shared Agents data only includes enabled plugin agents, so disabled plugins cannot deep-link into agent detail
Rejected: Keep duplicating full Skills/Agents/MCP detail inside Plugin detail | creates divergent UI flows and stale data paths
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If a detail view can be opened from multiple entry points, keep the return target in store state rather than hardcoding a single back destination
Tested: desktop vitest for plugins/skills/agents/mcp; desktop tsc --noEmit; desktop vite build; server skills API test; agent-browser web regression on plugin->skill/mcp and plugin->agent back navigation
Not-tested: packaged desktop app regression after rebuilding the Tauri bundle
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 12:31:57 +08:00
parent 28f36da0fd
commit de736c49f7
22 changed files with 1825 additions and 161 deletions

View File

@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
API_URL="${API_URL:-http://127.0.0.1:3457}"
WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}"
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")"
AB=(agent-browser --session "${SESSION_NAME}")
cleanup() {
local exit_code=$?
"${AB[@]}" close >/dev/null 2>&1 || true
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
}
trap cleanup EXIT
wait_for_text() {
local needle="$1"
if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then
echo "Timed out waiting for page text: ${needle}" >&2
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true
return 1
fi
}
press_escape() {
"${AB[@]}" press Escape >/dev/null 2>&1 || true
}
focus_composer() {
"${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true
}
submit_slash_command() {
local command="$1"
focus_composer
"${AB[@]}" fill textarea "${command}"
"${AB[@]}" press Enter
}
healthcheck() {
curl -fsS "${API_URL}/health" >/dev/null
}
select_plugin_targets() {
DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0))
| sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills))
| .[0].id // empty
')"
ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select(.enabled == true and .componentCounts.skills > 0))
| sort_by(-.componentCounts.skills)
| .[0].id // empty
')"
MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select(.enabled == true and .componentCounts.mcpServers > 0))
| sort_by(-.componentCounts.mcpServers)
| .[0].id // empty
')"
if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then
echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2
exit 1
fi
if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then
echo "No enabled plugin with skills was found in current API data." >&2
exit 1
fi
if [[ -z "${MCP_PLUGIN_ID}" ]]; then
echo "No plugin with MCP servers was found in current API data." >&2
exit 1
fi
DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')"
DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')"
DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')"
DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')"
DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')"
DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')"
DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')"
DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')"
DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')"
DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')"
MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')"
MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')"
MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')"
MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
}
open_plugin_detail() {
local plugin_name="$1"
local escaped_name="${plugin_name//\"/\\\"}"
if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then
wait_for_text "Bundled capabilities"
return 0
fi
echo "Failed to open plugin detail for: ${plugin_name}" >&2
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true
return 1
}
go_back_to_plugin_list() {
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null
wait_for_text "Browse installed plugins"
}
open_settings_sidebar_tab() {
local tab_name="$1"
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null
}
click_visible_card_text() {
local text="$1"
local escaped_text="${text//\"/\\\"}"
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null
}
healthcheck
select_plugin_targets
echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})"
echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})"
echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})"
"${AB[@]}" open "${WEB_URL}"
"${AB[@]}" wait --load networkidle
wait_for_text "Claude Code Haha"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
# Always work from a fresh chat surface so slash-command behavior is deterministic.
"${AB[@]}" find role button click --name "New session"
"${AB[@]}" wait textarea >/dev/null
submit_slash_command "/mcp"
wait_for_text "Available MCP tools"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null
press_escape
"${AB[@]}" wait 300 >/dev/null
submit_slash_command "/skills"
wait_for_text "Available skills"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null
press_escape
"${AB[@]}" wait 300 >/dev/null
submit_slash_command "/plugin"
wait_for_text "Browse installed plugins"
wait_for_text "Plugin Manager"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null
open_plugin_detail "${DETAIL_PLUGIN_NAME}"
wait_for_text "Commands"
wait_for_text "Agents"
wait_for_text "Hooks"
wait_for_text "Skills"
if [[ -n "${DETAIL_COMMAND}" ]]; then
wait_for_text "/${DETAIL_COMMAND}"
fi
if [[ -n "${DETAIL_AGENT}" ]]; then
wait_for_text "${DETAIL_AGENT}"
fi
if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then
wait_for_text "${DETAIL_HOOK_EVENT}"
fi
if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then
wait_for_text "${DETAIL_HOOK_ACTION}"
fi
if [[ -n "${DETAIL_SKILL_NAME}" ]]; then
wait_for_text "/${DETAIL_SKILL_NAME}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null
open_settings_sidebar_tab "Plugins"
wait_for_text "Plugin Detail"
go_back_to_plugin_list
open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}"
if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then
click_visible_card_text "${DETAIL_SKILL_LABEL}"
wait_for_text "Skill metadata"
wait_for_text "${DETAIL_SKILL_LABEL}"
if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then
wait_for_text "${DETAIL_SKILL_DESCRIPTION}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "Skills"
wait_for_text "Skill Browser"
wait_for_text "Plugin"
wait_for_text "${DETAIL_SKILL_LABEL}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null
fi
open_settings_sidebar_tab "Plugins"
wait_for_text "Plugin Detail"
if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then
click_visible_card_text "${DETAIL_AGENT_LABEL}"
wait_for_text "Agent Profile"
wait_for_text "${DETAIL_AGENT}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "Agents"
wait_for_text "Agent Browser"
wait_for_text "Plugin"
wait_for_text "${DETAIL_AGENT}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null
fi
go_back_to_plugin_list
open_plugin_detail "${MCP_PLUGIN_NAME}"
wait_for_text "MCP servers"
if [[ -n "${MCP_SERVER_NAME}" ]]; then
wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}"
fi
if [[ -n "${MCP_SKILL_NAME}" ]]; then
wait_for_text "/${MCP_SKILL_NAME}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null
if [[ -n "${MCP_SERVER_LABEL}" ]]; then
click_visible_card_text "${MCP_SERVER_LABEL}"
wait_for_text "${MCP_SERVER_NAME}"
wait_for_text "Plugin"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "MCP"
wait_for_text "MCP servers"
wait_for_text "Plugin"
wait_for_text "${MCP_SERVER_NAME}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null
fi
echo "agent-browser web UI regression passed"
echo "Artifacts: ${ARTIFACT_DIR}"

View File

@ -8,6 +8,7 @@ import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
vi.mock('../api/agents', () => ({
agentsApi: {
@ -79,6 +80,18 @@ const MOCK_AGENTS = [
isActive: false,
overriddenBy: 'userSettings' as const,
},
{
agentType: 'telegram:pairing',
description: 'Plugin agent for Telegram pairing flows',
model: 'inherit',
modelDisplay: 'inherit',
tools: ['Read'],
systemPrompt: 'Pair Telegram access for the current workspace.',
color: 'cyan',
source: 'plugin' as const,
baseDir: '/Users/test/.claude/plugins/cache/telegram',
isActive: true,
},
]
const MOCK_SKILL_DETAIL = {
@ -132,6 +145,7 @@ describe('Settings > Agents tab', () => {
activeTabId: 'session-1',
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }],
})
useUIStore.setState({ pendingSettingsTab: null })
useSessionStore.setState({
sessions: [
{
@ -164,6 +178,7 @@ describe('Settings > Agents tab', () => {
isLoading: false,
error: null,
selectedAgent: null,
selectedAgentReturnTab: 'agents',
fetchAgents: noopFetch,
selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }),
})
@ -236,8 +251,10 @@ describe('Settings > Agents tab', () => {
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0)
expect(screen.getAllByText('Project').length).toBeGreaterThan(0)
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getByText('code-reviewer')).toBeInTheDocument()
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
expect(screen.getByText('telegram:pairing')).toBeInTheDocument()
expect(screen.getByText('Overridden by User')).toBeInTheDocument()
})
@ -296,6 +313,29 @@ describe('Settings > Agents tab', () => {
expect(screen.getByText('doc-writer')).toBeInTheDocument()
expect(screen.getByText('plain-agent')).toBeInTheDocument()
})
it('returns to plugins tab when agent detail was opened from plugins', () => {
useAgentStore.setState({
allAgents: MOCK_AGENTS,
activeAgents: MOCK_AGENTS.filter((agent) => agent.isActive),
isLoading: false,
selectedAgent: MOCK_AGENTS[0],
selectedAgentReturnTab: 'plugins',
fetchAgents: noopFetch,
selectAgent: (agent) =>
useAgentStore.setState({
selectedAgent: agent,
selectedAgentReturnTab: agent ? 'plugins' : 'agents',
}),
})
render(<Settings />)
switchToAgentsTab()
fireEvent.click(screen.getByText('Back to list'))
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
})
})
describe('Settings > Skills tab', () => {

View File

@ -55,7 +55,7 @@ describe('McpSettings', () => {
render(<McpSettings />)
expect(fetchServers).toHaveBeenCalledWith(undefined, undefined)
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
})
it('renders the empty state and add button', () => {
@ -66,24 +66,23 @@ describe('McpSettings', () => {
expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument()
})
it('shows only global MCP servers on the homepage', () => {
it('shows plugin and user MCP servers in grouped sections', () => {
useMcpStore.setState({
servers: [
{
name: 'project-private',
scope: 'local',
projectPath: '/workspace/project',
name: 'plugin:telegram:telegram',
scope: 'dynamic',
transport: 'stdio',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'npx demo',
canEdit: true,
canRemove: true,
summary: 'npx @telegram/mcp',
canEdit: false,
canRemove: false,
canReconnect: true,
canToggle: true,
config: { type: 'stdio', command: 'npx', args: ['demo'], env: {} },
config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} },
},
{
name: 'global-user',
@ -105,8 +104,9 @@ describe('McpSettings', () => {
render(<McpSettings />)
expect(screen.queryByText('project-private')).not.toBeInTheDocument()
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
expect(screen.getByText('global-user')).toBeInTheDocument()
expect(screen.getByText('This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.')).toBeInTheDocument()
})
})

View File

@ -66,6 +66,10 @@ describe('Content-only pages render without errors', () => {
})
expect(await screen.findByText('/lark-mail')).toBeInTheDocument()
expect(screen.getByText('/mcp')).toBeInTheDocument()
expect(screen.getByText('/skills')).toBeInTheDocument()
expect(screen.getByText('/plugin')).toBeInTheDocument()
expect(screen.getByText('/plugins')).toBeInTheDocument()
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
})
@ -263,7 +267,7 @@ describe('Content-only pages render without errors', () => {
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [{ name: 'mcp', description: 'List available MCP tools' }],
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
@ -288,6 +292,134 @@ describe('Content-only pages render without errors', () => {
useChatStore.setState({ sessions: {} })
})
it('ActiveSession opens a local /skills panel from the fallback slash commands', async () => {
const SESSION_ID = 'skills-panel-session'
const sendMessage = vi.fn()
vi.mocked(skillsApi.list).mockResolvedValueOnce({
skills: [
{
name: 'lark-mail',
description: 'Draft, send, and search emails',
source: 'user',
userInvocable: true,
contentLength: 120,
hasDirectory: true,
},
],
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
sendMessage,
})
render(<ActiveSession />)
const textarea = screen.getByPlaceholderText('Ask anything...')
fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(await screen.findByText('Available skills')).toBeInTheDocument()
expect(screen.getByText('/lark-mail')).toBeInTheDocument()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession routes /plugin to Settings > Plugins instead of sending a chat message', () => {
const SESSION_ID = 'plugin-panel-session'
const sendMessage = vi.fn()
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
sendMessage,
})
render(<ActiveSession />)
const textarea = screen.getByPlaceholderText('Ask anything...')
fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(useTabStore.getState().activeTabId).toBe('__settings__')
expect(useUIStore.getState().pendingSettingsTab).toBe('plugins')
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('AgentTeams renders team strip and members', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')

View File

@ -6,6 +6,12 @@ import { Settings } from '../pages/Settings'
import { usePluginStore } from '../stores/pluginStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useUIStore } from '../stores/uiStore'
const MOCK_FETCH_SKILLS = vi.fn()
const MOCK_FETCH_SKILL_DETAIL = vi.fn()
const MOCK_FETCH_AGENTS = vi.fn()
const MOCK_FETCH_SERVERS = vi.fn()
vi.mock('../api/agents', () => ({
agentsApi: {
@ -34,27 +40,87 @@ vi.mock('../pages/AdapterSettings', () => ({
}))
vi.mock('../stores/agentStore', () => ({
useAgentStore: () => ({
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: vi.fn(),
selectAgent: vi.fn(),
useAgentStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: MOCK_FETCH_AGENTS,
selectAgent: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: MOCK_FETCH_AGENTS,
selectAgent: vi.fn(),
}),
}),
}))
vi.mock('../stores/skillStore', () => ({
useSkillStore: () => ({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: vi.fn(),
fetchSkillDetail: vi.fn(),
clearSelection: vi.fn(),
useSkillStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: vi.fn(),
}),
}),
}))
vi.mock('../stores/mcpStore', () => ({
useMcpStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: MOCK_FETCH_SERVERS,
createServer: vi.fn(),
updateServer: vi.fn(),
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
selectServer: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: MOCK_FETCH_SERVERS,
createServer: vi.fn(),
updateServer: vi.fn(),
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
selectServer: vi.fn(),
}),
}),
}))
@ -66,7 +132,9 @@ function switchToPluginsTab() {
describe('Settings > Plugins tab', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null })
useSessionStore.setState({
sessions: [
{
@ -210,6 +278,44 @@ describe('Settings > Plugins tab', () => {
mcpServers: ['github-api'],
lspServers: [],
},
commandEntries: [
{
name: 'review-pr',
description: 'Review the current pull request.',
},
],
agentEntries: [
{
name: 'pr-reviewer',
description: 'Review pull request quality and risk.',
},
],
hookEntries: [
{
event: 'SessionStart',
matcher: 'Write',
actions: ['echo preparing plugin runtime'],
},
],
skillEntries: [
{
name: 'create-pr',
description: 'Create a pull request from the current branch.',
},
{
name: 'commit',
description: 'Commit the current staged changes.',
version: '1.0.0',
},
],
mcpServerEntries: [
{
name: 'plugin:github:github-api',
displayName: 'github-api',
transport: 'http',
summary: 'https://api.github.com/mcp',
},
],
errors: [],
},
})
@ -220,8 +326,119 @@ describe('Settings > Plugins tab', () => {
expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
expect(screen.getByText('Bundled capabilities')).toBeInTheDocument()
expect(screen.getByText('review-pr')).toBeInTheDocument()
expect(screen.getByText('/review-pr')).toBeInTheDocument()
expect(screen.getByText('Review pull request quality and risk.')).toBeInTheDocument()
expect(screen.getByText('echo preparing plugin runtime')).toBeInTheDocument()
expect(screen.getByText('Create a pull request from the current branch.')).toBeInTheDocument()
expect(screen.getByText('https://api.github.com/mcp')).toBeInTheDocument()
expect(screen.getByText('Apply changes')).toBeInTheDocument()
expect(screen.getByText('Uninstall')).toBeInTheDocument()
})
it('navigates plugin skills into the shared Skills page flow', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'telegram@claude-plugins-official',
name: 'telegram',
marketplace: 'claude-plugins-official',
scope: 'user',
enabled: true,
hasErrors: false,
isBuiltin: false,
description: 'Telegram integration',
componentCounts: {
commands: 0,
agents: 0,
skills: 1,
hooks: 0,
mcpServers: 0,
lspServers: 0,
},
capabilities: {
commands: [],
agents: [],
skills: ['telegram:access'],
hooks: [],
mcpServers: [],
lspServers: [],
},
commandEntries: [],
agentEntries: [],
hookEntries: [],
skillEntries: [
{
name: 'telegram:access',
displayName: 'access',
description: 'Manage Telegram access.',
pluginName: 'telegram',
},
],
mcpServerEntries: [],
errors: [],
},
})
render(<Settings />)
switchToPluginsTab()
fireEvent.click(screen.getByText('access'))
expect(MOCK_FETCH_SKILL_DETAIL).toHaveBeenCalledWith('plugin', 'telegram:access', '/workspace/project', 'plugins')
})
it('disables shared navigation cards for disabled plugins', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'codex@openai-codex',
name: 'codex',
marketplace: 'openai-codex',
scope: 'user',
enabled: false,
hasErrors: false,
isBuiltin: false,
description: 'Use Codex from Claude Code',
componentCounts: {
commands: 0,
agents: 1,
skills: 1,
hooks: 0,
mcpServers: 0,
lspServers: 0,
},
capabilities: {
commands: [],
agents: ['codex:codex-rescue'],
skills: ['codex:gpt-5-4-prompting'],
hooks: [],
mcpServers: [],
lspServers: [],
},
commandEntries: [],
agentEntries: [
{
name: 'codex:codex-rescue',
displayName: 'codex-rescue',
description: 'Delegate to Codex.',
},
],
hookEntries: [],
skillEntries: [
{
name: 'codex:gpt-5-4-prompting',
displayName: 'gpt-5-4-prompting',
description: 'Prompting guide.',
},
],
mcpServerEntries: [],
errors: [],
},
})
render(<Settings />)
switchToPluginsTab()
expect(screen.getAllByText('Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /codex-rescue/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /gpt-5-4-prompting/i })).toBeDisabled()
})
})

View File

@ -7,6 +7,7 @@ import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
vi.mock('../api/agents', () => ({
agentsApi: {
@ -82,9 +83,11 @@ describe('Settings > Skills tab', () => {
availableProjects: ['/workspace/project'],
})
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
@ -115,6 +118,16 @@ describe('Settings > Skills tab', () => {
contentLength: 200,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
})
@ -126,6 +139,8 @@ describe('Settings > Skills tab', () => {
expect(screen.getByText('Total skills')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
expect(screen.getByText('Second skill description')).toBeInTheDocument()
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
})
it('uses the active session workDir even when settings tab is focused', () => {
@ -190,6 +205,7 @@ describe('Settings > Skills tab', () => {
],
skillRoot: '/tmp/alpha',
},
selectedSkillReturnTab: 'skills',
})
render(<Settings />)
@ -202,4 +218,39 @@ describe('Settings > Skills tab', () => {
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.queryByText(/^---$/)).not.toBeInTheDocument()
})
it('returns to plugins tab when skill detail was opened from plugins', () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'telegram:access',
displayName: 'Access',
description: 'Plugin skill',
source: 'plugin',
userInvocable: true,
contentLength: 200,
hasDirectory: true,
},
tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }],
files: [
{
path: 'SKILL.md',
content: '# Access',
body: '# Access',
language: 'markdown',
isEntry: true,
},
],
skillRoot: '/tmp/telegram-access',
},
selectedSkillReturnTab: 'plugins',
})
render(<Settings />)
switchToSkillsTab()
fireEvent.click(screen.getByText('Back to list'))
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
})
})

View File

@ -1,7 +1,8 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTeamStore } from '../../stores/teamStore'
import { sessionsApi } from '../../api/sessions'
@ -18,6 +19,7 @@ import {
findSlashTrigger,
mergeSlashCommands,
replaceSlashToken,
resolveSlashUiAction,
} from './composerUtils'
type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number }
@ -70,6 +72,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const isWorkspaceMissing = activeSession?.workDirExists === false
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
const isHeroComposer = variant === 'hero' && !isMemberSession
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
useEffect(() => {
textareaRef.current?.focus()
@ -295,8 +298,19 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const text = input.trim()
if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
if (!isMemberSession && (text === '/mcp' || text === '/skills' || text === '/plugins')) {
setLocalSlashPanel(text.slice(1) as LocalSlashCommandName)
const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
if (slashUiAction?.type === 'panel') {
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
if (slashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
@ -501,7 +515,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
{!isMemberSession && fileSearchOpen && (
<FileSearchMenu
ref={fileSearchRef}
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
cwd={resolvedWorkDir || ''}
filter={atFilter}
onSelect={(_path, name) => {
if (atCursorPos >= 0) {
@ -525,7 +539,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
<div ref={slashMenuRef}>
<LocalSlashCommandPanel
command={localSlashPanel}
cwd={gitInfo?.workDir || activeSession?.workDir || undefined}
cwd={resolvedWorkDir}
onClose={() => setLocalSlashPanel(null)}
/>
</div>
@ -681,13 +695,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
<div className="mt-3 px-1">
{hasMessages ? (
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
workDir={resolvedWorkDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
) : (
<DirectoryPicker
value={gitInfo?.workDir || activeSession?.workDir || ''}
value={resolvedWorkDir || ''}
onChange={async (newWorkDir) => {
if (!activeTabId) return
const oldId = activeTabId

View File

@ -9,7 +9,7 @@ import { useSkillStore } from '../../stores/skillStore'
import type { McpServerRecord } from '../../types/mcp'
import type { SkillMeta } from '../../types/skill'
export type LocalSlashCommandName = 'mcp' | 'skills' | 'plugins'
export type LocalSlashCommandName = 'mcp' | 'skills'
type Props = {
command: LocalSlashCommandName
@ -243,7 +243,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
type="button"
key={`${skill.source}:${skill.name}`}
onClick={async () => {
await fetchSkillDetail(skill.source, skill.name, cwd)
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
setPendingSettingsTab('skills')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
@ -265,21 +265,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
)
}
function PluginsPanel({ onClose }: { onClose: () => void }) {
const t = useTranslation()
return (
<PanelShell
title={t('slash.plugins.title')}
subtitle={t('slash.plugins.subtitle')}
onClose={onClose}
>
<EmptyState title={t('slash.plugins.emptyTitle')} body={t('slash.plugins.emptyBody')} />
</PanelShell>
)
}
export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) {
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
return <PluginsPanel onClose={onClose} />
return <SkillsPanel cwd={cwd} onClose={onClose} />
}

View File

@ -1,4 +1,18 @@
import type { SettingsTab } from '../../stores/uiStore'
export const PANEL_SLASH_COMMANDS = [
{ name: 'mcp', description: 'Open available MCP tools for the current chat context' },
{ name: 'skills', description: 'Browse user-invocable skills for the current chat context' },
] as const
export const SETTINGS_SLASH_COMMANDS = [
{ name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
{ name: 'plugins', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
] as const
export const FALLBACK_SLASH_COMMANDS = [
...PANEL_SLASH_COMMANDS,
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'help', description: 'Show available commands' },
@ -25,6 +39,30 @@ export type SlashCommandOption = {
description: string
}
export type SlashUiAction =
| {
type: 'panel'
command: typeof PANEL_SLASH_COMMANDS[number]['name']
}
| {
type: 'settings'
tab: SettingsTab
}
export function resolveSlashUiAction(value: string): SlashUiAction | null {
const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === value)
if (panelCommand) {
return { type: 'panel', command: panelCommand.name }
}
const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === value)
if (settingsCommand) {
return { type: 'settings', tab: settingsCommand.tab }
}
return null
}
export function mergeSlashCommands(
preferred: ReadonlyArray<SlashCommandOption>,
fallback: ReadonlyArray<SlashCommandOption> = FALLBACK_SLASH_COMMANDS,

View File

@ -1,17 +1,16 @@
import { useState, type ReactNode } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { usePluginStore } from '../../stores/pluginStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { Button } from '../shared/Button'
import type { PluginCapabilityKey } from '../../types/plugin'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useSkillStore } from '../../stores/skillStore'
import { useAgentStore } from '../../stores/agentStore'
import { useMcpStore } from '../../stores/mcpStore'
const CAPABILITY_ORDER: PluginCapabilityKey[] = [
'skills',
'commands',
'agents',
'hooks',
'mcpServers',
'lspServers',
]
@ -30,6 +29,11 @@ export function PluginDetail() {
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const addToast = useUIStore((s) => s.addToast)
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
const fetchAgents = useAgentStore((s) => s.fetchAgents)
const selectAgent = useAgentStore((s) => s.selectAgent)
const fetchServers = useMcpStore((s) => s.fetchServers)
const selectServer = useMcpStore((s) => s.selectServer)
const t = useTranslation()
const [actionKey, setActionKey] = useState<string | null>(null)
@ -47,6 +51,15 @@ export function PluginDetail() {
if (!selectedPlugin) return null
const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin'
const canNavigateSharedCapabilities = selectedPlugin.enabled
const otherCapabilityItems = useMemo(
() =>
CAPABILITY_ORDER.map((key) => ({
key,
items: selectedPlugin.capabilities[key],
})),
[selectedPlugin],
)
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionKey(key)
@ -90,6 +103,76 @@ export function PluginDetail() {
return window.confirm(label)
}
const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => {
useUIStore.getState().setPendingSettingsTab(tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
const handleOpenSkill = async (skillName: string) => {
if (!canNavigateSharedCapabilities) {
addToast({
type: 'warning',
message: t('settings.plugins.sharedNavigationDisabled'),
})
return
}
openSettingsTab('skills')
await fetchSkillDetail('plugin', skillName, currentWorkDir, 'plugins')
const { selectedSkill, error } = useSkillStore.getState()
if (!selectedSkill && error) {
addToast({ type: 'error', message: error })
}
}
const handleOpenAgent = async (agentType: string) => {
if (!canNavigateSharedCapabilities) {
addToast({
type: 'warning',
message: t('settings.plugins.sharedNavigationDisabled'),
})
return
}
openSettingsTab('agents')
await fetchAgents(currentWorkDir)
const state = useAgentStore.getState()
const agent = state.allAgents.find((entry) => entry.agentType === agentType)
if (!agent) {
addToast({
type: 'error',
message: `Unable to locate agent: ${agentType}`,
})
return
}
selectAgent(agent, 'plugins')
}
const handleOpenMcpServer = async (serverName: string) => {
if (!canNavigateSharedCapabilities) {
addToast({
type: 'warning',
message: t('settings.plugins.sharedNavigationDisabled'),
})
return
}
openSettingsTab('mcp')
await fetchServers(undefined, currentWorkDir)
const state = useMcpStore.getState()
const server = state.servers.find((entry) => entry.name === serverName)
if (!server) {
addToast({
type: 'error',
message: `Unable to locate MCP server: ${serverName}`,
})
return
}
selectServer(server)
}
return (
<div className="flex flex-col gap-4 min-w-0">
<div>
@ -261,10 +344,112 @@ export function PluginDetail() {
{t('settings.plugins.capabilitiesHint')}
</p>
</div>
<div className="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-3">
{CAPABILITY_ORDER.map((key) => {
const items = selectedPlugin.capabilities[key]
return (
<div className="flex flex-col gap-4 p-4">
<CapabilityPreviewSection
title={t('settings.plugins.capabilityLabel.skills')}
count={selectedPlugin.skillEntries.length}
emptyLabel={t('settings.plugins.capabilityEmpty')}
hint={!canNavigateSharedCapabilities ? t('settings.plugins.sharedNavigationDisabled') : undefined}
>
{selectedPlugin.skillEntries.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{selectedPlugin.skillEntries.map((skill) => (
<SkillPreviewCard
key={skill.name}
name={skill.displayName || skill.name}
rawName={skill.displayName ? skill.name : undefined}
description={skill.description}
version={skill.version}
onClick={() => void handleOpenSkill(skill.name)}
disabled={!canNavigateSharedCapabilities}
/>
))}
</div>
) : null}
</CapabilityPreviewSection>
<CapabilityPreviewSection
title={t('settings.plugins.capabilityLabel.mcpServers')}
count={selectedPlugin.mcpServerEntries.length}
emptyLabel={t('settings.plugins.capabilityEmpty')}
hint={!canNavigateSharedCapabilities ? t('settings.plugins.sharedNavigationDisabled') : undefined}
>
{selectedPlugin.mcpServerEntries.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{selectedPlugin.mcpServerEntries.map((server) => (
<McpPreviewCard
key={server.name}
name={server.displayName || server.name}
transport={server.transport}
summary={server.summary}
onClick={() => void handleOpenMcpServer(server.name)}
disabled={!canNavigateSharedCapabilities}
/>
))}
</div>
) : null}
</CapabilityPreviewSection>
<CapabilityPreviewSection
title={t('settings.plugins.capabilityLabel.commands')}
count={selectedPlugin.commandEntries.length}
emptyLabel={t('settings.plugins.capabilityEmpty')}
>
{selectedPlugin.commandEntries.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{selectedPlugin.commandEntries.map((command) => (
<CommandPreviewCard
key={command.name}
name={command.name}
description={command.description}
/>
))}
</div>
) : null}
</CapabilityPreviewSection>
<CapabilityPreviewSection
title={t('settings.plugins.capabilityLabel.agents')}
count={selectedPlugin.agentEntries.length}
emptyLabel={t('settings.plugins.capabilityEmpty')}
hint={!canNavigateSharedCapabilities ? t('settings.plugins.sharedNavigationDisabled') : undefined}
>
{selectedPlugin.agentEntries.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{selectedPlugin.agentEntries.map((agent) => (
<AgentPreviewCard
key={agent.name}
name={agent.displayName || agent.name}
description={agent.description}
onClick={() => void handleOpenAgent(agent.name)}
disabled={!canNavigateSharedCapabilities}
/>
))}
</div>
) : null}
</CapabilityPreviewSection>
<CapabilityPreviewSection
title={t('settings.plugins.capabilityLabel.hooks')}
count={selectedPlugin.hookEntries.length}
emptyLabel={t('settings.plugins.capabilityEmpty')}
>
{selectedPlugin.hookEntries.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{selectedPlugin.hookEntries.map((hook, index) => (
<HookPreviewCard
key={`${hook.event}:${hook.matcher || 'all'}:${index}`}
event={hook.event}
matcher={hook.matcher}
actions={hook.actions}
/>
))}
</div>
) : null}
</CapabilityPreviewSection>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{otherCapabilityItems.map(({ key, items }) => (
<div
key={key}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
@ -294,14 +479,209 @@ export function PluginDetail() {
</div>
)}
</div>
)
})}
))}
</div>
</div>
</section>
</div>
)
}
function CapabilityPreviewSection({
title,
count,
children,
emptyLabel,
hint,
}: {
title: string
count: number
children: ReactNode
emptyLabel: string
hint?: string
}) {
return (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-4 py-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)]">{count}</div>
</div>
<div className="p-4">
{hint && count > 0 && (
<div className="mb-3 text-xs text-[var(--color-text-tertiary)]">{hint}</div>
)}
{count > 0 ? children : (
<div className="text-xs text-[var(--color-text-tertiary)]">{emptyLabel}</div>
)}
</div>
</section>
)
}
function SkillPreviewCard({
name,
rawName,
description,
version,
onClick,
disabled,
}: {
name: string
rawName?: string
description: string
version?: string
onClick: () => void
disabled?: boolean
}) {
const t = useTranslation()
const slashName = rawName || name
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">{name}</span>
{version && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
v{version}
</span>
)}
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.skills.slashCommand')}
</span>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] transition-transform group-hover:translate-x-0.5">
chevron_right
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)] break-all">/{slashName}</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">{description}</div>
</button>
)
}
function CommandPreviewCard({
name,
description,
}: {
name: string
description: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)] break-all">/{name}</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">{description}</div>
</div>
)
}
function AgentPreviewCard({
name,
description,
onClick,
disabled,
}: {
name: string
description: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)] break-all">{name}</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">{description}</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] transition-transform group-hover:translate-x-0.5">
chevron_right
</span>
</div>
</button>
)
}
function McpPreviewCard({
name,
transport,
summary,
onClick,
disabled,
}: {
name: string
transport: string
summary: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">{name}</span>
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{transport}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-all">{summary}</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] transition-transform group-hover:translate-x-0.5">
chevron_right
</span>
</div>
</button>
)
}
function HookPreviewCard({
event,
matcher,
actions,
}: {
event: string
matcher?: string
actions: string[]
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">{event}</span>
{matcher && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)] break-all">
{matcher}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{actions.map((action) => (
<span
key={action}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] break-all"
>
{action}
</span>
))}
</div>
</div>
)
}
function MetaPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">

View File

@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { CodeViewer } from '../chat/CodeViewer'
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
import { useUIStore } from '../../stores/uiStore'
const META_PRIORITY = [
'description',
@ -20,7 +21,7 @@ const META_PRIORITY = [
] as const
export function SkillDetail() {
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection } = useSkillStore()
const t = useTranslation()
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
@ -31,6 +32,14 @@ export function SkillDetail() {
: selectedSkill.files[0]?.path || 'SKILL.md'
}, [selectedFile, selectedSkill])
const handleBack = () => {
const returnTab = selectedSkillReturnTab
clearSelection()
if (returnTab === 'plugins') {
useUIStore.getState().setPendingSettingsTab('plugins')
}
}
if (isDetailLoading) {
return (
<div className="flex justify-center py-12">
@ -50,7 +59,7 @@ export function SkillDetail() {
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
<div>
<button
onClick={clearSelection}
onClick={handleBack}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>

View File

@ -180,7 +180,7 @@ export function SkillList() {
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
fetchSkillDetail(skill.source, skill.name, currentWorkDir, 'skills')
}
disabled={!skill.hasDirectory}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"

View File

@ -178,6 +178,7 @@ export const en = {
'settings.mcp.scope.project': 'Project',
'settings.mcp.scope.local': 'Local',
'settings.mcp.scope.user': 'User',
'settings.mcp.scope.plugin': 'Plugin',
'settings.mcp.scopeDesc.local': 'Private to you for one project. Stored in your user config, scoped by the selected project.',
'settings.mcp.scopeDesc.project': 'Shared with the team through the selected projects `.mcp.json`.',
'settings.mcp.scopeDesc.user': 'Available in all projects for your user account.',
@ -410,6 +411,7 @@ export const en = {
'settings.plugins.errorsTitle': 'Plugin errors',
'settings.plugins.capabilitiesTitle': 'Bundled capabilities',
'settings.plugins.capabilitiesHint': 'Capabilities exposed by this plugin after loading its manifest and runtime integrations.',
'settings.plugins.sharedNavigationDisabled': 'Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.',
'settings.plugins.capabilityEmpty': 'None exposed',
'settings.plugins.capability.skills': '{count} skills',
'settings.plugins.capability.agents': '{count} agents',

View File

@ -180,6 +180,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.mcp.scope.project': '项目共享',
'settings.mcp.scope.local': '项目私有',
'settings.mcp.scope.user': '全局用户',
'settings.mcp.scope.plugin': '插件',
'settings.mcp.scopeDesc.local': '只对你自己生效,但绑定到某一个项目。配置写在用户配置里,同时带上选中的项目上下文。',
'settings.mcp.scopeDesc.project': '写入选中项目的 `.mcp.json`,项目成员共享。',
'settings.mcp.scopeDesc.user': '写入你的全局 Claude 配置,对所有项目生效。',
@ -412,6 +413,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.plugins.errorsTitle': '插件错误',
'settings.plugins.capabilitiesTitle': '打包能力',
'settings.plugins.capabilitiesHint': '根据插件 manifest 和运行时集成解析出的能力列表。',
'settings.plugins.sharedNavigationDisabled': '先启用插件并应用变更才能在共享管理页中打开它的技能、Agent 或 MCP 条目。',
'settings.plugins.capabilityEmpty': '没有暴露',
'settings.plugins.capability.skills': '{count} 个技能',
'settings.plugins.capability.agents': '{count} 个 Agent',

View File

@ -4,7 +4,7 @@ import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
import { useTabStore } from '../stores/tabStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
@ -17,6 +17,7 @@ import {
insertSlashTrigger,
mergeSlashCommands,
replaceSlashCommand,
resolveSlashUiAction,
} from '../components/chat/composerUtils'
import type { AttachmentRef } from '../types/chat'
import type { SlashCommandOption } from '../components/chat/composerUtils'
@ -178,8 +179,19 @@ export function EmptySession() {
const text = input.trim()
if ((!text && attachments.length === 0) || isSubmitting) return
if (text === '/mcp' || text === '/skills' || text === '/plugins') {
setLocalSlashPanel(text.slice(1) as LocalSlashCommandName)
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
if (slashUiAction?.type === 'panel') {
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
if (slashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)

View File

@ -5,6 +5,7 @@ import { Modal } from '../components/shared/Modal'
import { useTranslation } from '../i18n'
import { useUIStore } from '../stores/uiStore'
import { useMcpStore } from '../stores/mcpStore'
import { useSessionStore } from '../stores/sessionStore'
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
type EditorMode =
@ -39,6 +40,27 @@ type McpDraft = {
oauthCallbackPort: string
}
type McpGroupKey =
| 'plugin'
| 'user'
| 'project'
| 'local'
| 'managed'
| 'enterprise'
| 'claudeai'
| 'dynamic'
const MCP_GROUP_ORDER: McpGroupKey[] = [
'plugin',
'user',
'project',
'local',
'managed',
'enterprise',
'claudeai',
'dynamic',
]
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
'needs-auth': 'bg-amber-500/10 text-amber-600 border-amber-500/20',
@ -184,6 +206,28 @@ function transportLabel(transport: string, t: ReturnType<typeof useTranslation>)
}
}
function getServerGroupKey(server: McpServerRecord): McpGroupKey {
if (server.name.startsWith('plugin:')) return 'plugin'
switch (server.scope) {
case 'user':
case 'project':
case 'local':
case 'managed':
case 'enterprise':
case 'claudeai':
case 'dynamic':
return server.scope
default:
return 'dynamic'
}
}
function scopeLabel(server: McpServerRecord, t: ReturnType<typeof useTranslation>) {
const group = getServerGroupKey(server)
if (group === 'plugin') return t('settings.mcp.scope.plugin')
return t(`settings.mcp.scope.${group}`)
}
function ToggleSwitch({
checked,
disabled,
@ -314,7 +358,7 @@ function ServerRow({
{transportLabel(server.transport, t)}
</span>
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
{t('settings.mcp.scope.user')}
{scopeLabel(server, t)}
</span>
<span className="truncate">{server.summary}</span>
</div>
@ -340,6 +384,8 @@ function ServerRow({
export function McpSettings() {
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
const addToast = useUIStore((s) => s.addToast)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const t = useTranslation()
const [view, setView] = useState<EditorMode>({ type: 'list' })
const [draft, setDraft] = useState<McpDraft>(createEmptyDraft)
@ -348,20 +394,27 @@ export function McpSettings() {
const [busyServerName, setBusyServerName] = useState<string | null>(null)
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
useEffect(() => {
void fetchServers(undefined, undefined)
}, [fetchServers])
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
const globalServers = useMemo(
() => servers.filter((server) => server.scope === 'user'),
[servers],
)
useEffect(() => {
void fetchServers(undefined, currentWorkDir)
}, [fetchServers, currentWorkDir])
const groupedServers = useMemo(() => {
const groups: Partial<Record<McpGroupKey, McpServerRecord[]>> = {}
for (const server of servers) {
const key = getServerGroupKey(server)
;(groups[key] ??= []).push(server)
}
return groups
}, [servers])
const stats = useMemo(() => ({
total: globalServers.length,
connected: globalServers.filter((server) => server.status === 'connected').length,
attention: globalServers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length,
}), [globalServers])
total: servers.length,
connected: servers.filter((server) => server.status === 'connected').length,
attention: servers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length,
}), [servers])
const beginCreate = () => {
setDraft(createEmptyDraft())
@ -546,7 +599,7 @@ export function McpSettings() {
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
<div className="grid gap-4 md:grid-cols-2">
<InfoPair label={t('settings.mcp.form.transport')} value={transportLabel(server.transport, t)} />
<InfoPair label={t('settings.mcp.form.scope')} value={t('settings.mcp.scope.user')} />
<InfoPair label={t('settings.mcp.form.scope')} value={scopeLabel(server, t)} />
<InfoPair label={t('settings.mcp.form.status')} value={server.statusLabel} />
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
</div>
@ -763,9 +816,6 @@ export function McpSettings() {
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
{t('settings.mcp.description')}
</p>
<p className="mt-3 text-sm text-[var(--color-text-tertiary)]">
{t('settings.mcp.globalOnlyHint')}
</p>
</div>
<Button variant="secondary" size="lg" onClick={beginCreate}>
<span className="material-symbols-outlined text-[18px]">add</span>
@ -779,7 +829,7 @@ export function McpSettings() {
<StatCard label={t('settings.mcp.stats.attention')} value={stats.attention} icon="error" />
</div>
{isLoading && globalServers.length === 0 ? (
{isLoading && servers.length === 0 ? (
<div className="flex justify-center py-16">
<div className="animate-spin h-6 w-6 rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
</div>
@ -789,39 +839,48 @@ export function McpSettings() {
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
<button
type="button"
onClick={() => void fetchServers(undefined, undefined)}
onClick={() => void fetchServers(undefined, currentWorkDir)}
className="text-sm text-[var(--color-text-accent)] hover:underline"
>
{t('common.retry')}
</button>
</div>
) : globalServers.length === 0 ? (
) : servers.length === 0 ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">dns</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.mcp.empty')}</p>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.mcp.emptyHint')}</p>
</div>
) : (
<section>
<div className="flex items-center justify-between mb-3">
<div className="text-[1.35rem] font-semibold text-[var(--color-text-primary)]">
{t('settings.mcp.scope.user')}
</div>
<div className="text-sm text-[var(--color-text-tertiary)]">{globalServers.length}</div>
</div>
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
{globalServers.map((server) => (
<ServerRow
key={`${server.scope}:${server.name}`}
server={server}
isBusy={busyServerName === server.name}
onOpen={() => beginEdit(server)}
onToggle={() => void handleToggle(server)}
t={t}
/>
))}
</div>
</section>
<div className="flex flex-col gap-6">
{MCP_GROUP_ORDER.map((group) => {
const groupServers = groupedServers[group]
if (!groupServers?.length) return null
return (
<section key={group}>
<div className="flex items-center justify-between mb-3">
<div className="text-[1.35rem] font-semibold text-[var(--color-text-primary)]">
{group === 'plugin' ? t('settings.mcp.scope.plugin') : t(`settings.mcp.scope.${group}`)}
</div>
<div className="text-sm text-[var(--color-text-tertiary)]">{groupServers.length}</div>
</div>
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
{groupServers.map((server) => (
<ServerRow
key={`${server.scope}:${server.name}`}
server={server}
isBusy={busyServerName === server.name}
onOpen={() => beginEdit(server)}
onToggle={() => void handleToggle(server)}
t={t}
/>
))}
</div>
</section>
)
})}
</div>
)}
<Modal

View File

@ -802,6 +802,7 @@ function AgentsSettings() {
isLoading,
error,
selectedAgent,
selectedAgentReturnTab,
fetchAgents,
selectAgent,
} = useAgentStore()
@ -826,10 +827,18 @@ function AgentsSettings() {
const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
const handleAgentBack = () => {
const returnTab = selectedAgentReturnTab
selectAgent(null)
if (returnTab === 'plugins') {
useUIStore.getState().setPendingSettingsTab('plugins')
}
}
if (selectedAgent) {
return (
<div className="w-full min-w-0">
<AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
<AgentDetailView agent={selectedAgent} onBack={handleAgentBack} />
</div>
)
}
@ -938,7 +947,7 @@ function AgentsSettings() {
{group.map((agent) => (
<button
key={`${agent.source}-${agent.agentType}`}
onClick={() => selectAgent(agent)}
onClick={() => selectAgent(agent, 'agents')}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<div className="flex items-start gap-3">

View File

@ -1,15 +1,21 @@
import { create } from 'zustand'
import { agentsApi, type AgentDefinition } from '../api/agents'
export type AgentDetailReturnTab = 'agents' | 'plugins'
type AgentStore = {
activeAgents: AgentDefinition[]
allAgents: AgentDefinition[]
isLoading: boolean
error: string | null
selectedAgent: AgentDefinition | null
selectedAgentReturnTab: AgentDetailReturnTab
fetchAgents: (cwd?: string) => Promise<void>
selectAgent: (agent: AgentDefinition | null) => void
selectAgent: (
agent: AgentDefinition | null,
returnTab?: AgentDetailReturnTab,
) => void
}
export const useAgentStore = create<AgentStore>((set) => ({
@ -18,6 +24,7 @@ export const useAgentStore = create<AgentStore>((set) => ({
isLoading: false,
error: null,
selectedAgent: null,
selectedAgentReturnTab: 'agents',
fetchAgents: async (cwd) => {
set({ isLoading: true, error: null })
@ -30,5 +37,9 @@ export const useAgentStore = create<AgentStore>((set) => ({
}
},
selectAgent: (agent) => set({ selectedAgent: agent }),
selectAgent: (agent, returnTab = 'agents') =>
set({
selectedAgent: agent,
selectedAgentReturnTab: agent ? returnTab : 'agents',
}),
}))

View File

@ -2,21 +2,30 @@ import { create } from 'zustand'
import { skillsApi } from '../api/skills'
import type { SkillMeta, SkillDetail } from '../types/skill'
export type SkillDetailReturnTab = 'skills' | 'plugins'
type SkillStore = {
skills: SkillMeta[]
selectedSkill: SkillDetail | null
selectedSkillReturnTab: SkillDetailReturnTab
isLoading: boolean
isDetailLoading: boolean
error: string | null
fetchSkills: (cwd?: string) => Promise<void>
fetchSkillDetail: (source: string, name: string, cwd?: string) => Promise<void>
fetchSkillDetail: (
source: string,
name: string,
cwd?: string,
returnTab?: SkillDetailReturnTab,
) => Promise<void>
clearSelection: () => void
}
export const useSkillStore = create<SkillStore>((set) => ({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
@ -34,11 +43,15 @@ export const useSkillStore = create<SkillStore>((set) => ({
}
},
fetchSkillDetail: async (source, name, cwd) => {
fetchSkillDetail: async (source, name, cwd, returnTab = 'skills') => {
set({ isDetailLoading: true, error: null })
try {
const { detail } = await skillsApi.detail(source, name, cwd)
set({ selectedSkill: detail, isDetailLoading: false })
set({
selectedSkill: detail,
selectedSkillReturnTab: returnTab,
isDetailLoading: false,
})
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
@ -47,5 +60,5 @@ export const useSkillStore = create<SkillStore>((set) => ({
}
},
clearSelection: () => set({ selectedSkill: null }),
clearSelection: () => set({ selectedSkill: null, selectedSkillReturnTab: 'skills' }),
}))

View File

@ -12,6 +12,38 @@ export type PluginCapabilities = Record<PluginCapabilityKey, string[]>
export type PluginComponentCounts = Record<PluginCapabilityKey, number>
export type PluginSkillEntry = {
name: string
displayName?: string
description: string
version?: string
pluginName?: string
}
export type PluginCommandEntry = {
name: string
description: string
}
export type PluginAgentEntry = {
name: string
displayName?: string
description: string
}
export type PluginHookEntry = {
event: string
matcher?: string
actions: string[]
}
export type PluginMcpServerEntry = {
name: string
displayName?: string
transport: string
summary: string
}
export type PluginSummary = {
id: string
name: string
@ -31,6 +63,11 @@ export type PluginSummary = {
export type PluginDetail = PluginSummary & {
capabilities: PluginCapabilities
commandEntries: PluginCommandEntry[]
agentEntries: PluginAgentEntry[]
hookEntries: PluginHookEntry[]
skillEntries: PluginSkillEntry[]
mcpServerEntries: PluginMcpServerEntry[]
}
export type PluginMarketplaceSummary = {

View File

@ -12,6 +12,8 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
import { getCwd } from '../../utils/cwd.js'
import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import type { LoadedPlugin } from '../../types/plugin.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
// ─── Types ───────────────────────────────────────────────────────────────────
@ -20,11 +22,12 @@ type SkillMeta = {
name: string
displayName?: string
description: string
source: 'user' | 'project'
source: 'user' | 'project' | 'plugin'
userInvocable: boolean
version?: string
contentLength: number
hasDirectory: boolean
pluginName?: string
}
type SkillSource = SkillMeta['source']
@ -92,7 +95,8 @@ function getProjectSkillsDirs(cwd: string): string[] {
async function loadSkillMeta(
skillDir: string,
skillName: string,
source: 'user' | 'project',
source: SkillSource,
pluginName?: string,
): Promise<SkillMeta | null> {
const skillFile = path.join(skillDir, 'SKILL.md')
try {
@ -116,6 +120,7 @@ async function loadSkillMeta(
version: frontmatter.version != null ? String(frontmatter.version) : undefined,
contentLength: raw.length,
hasDirectory: true,
pluginName,
}
} catch {
return null
@ -240,7 +245,11 @@ async function resolveSkillDir(
cwd: string,
): Promise<string | null> {
const skillRoots =
source === 'user' ? [getUserSkillsDir()] : getProjectSkillsDirs(cwd)
source === 'user'
? [getUserSkillsDir()]
: source === 'project'
? getProjectSkillsDirs(cwd)
: []
for (const root of skillRoots) {
const skillDir = path.join(root, name)
@ -257,6 +266,95 @@ async function resolveSkillDir(
return null
}
type PluginSkillLocation = {
skillDir: string
pluginName: string
}
function buildPluginSkillName(pluginName: string, skillDir: string): string {
return `${pluginName}:${path.basename(skillDir)}`
}
async function collectPluginSkillDirectories(): Promise<Map<string, PluginSkillLocation>> {
const locations = new Map<string, PluginSkillLocation>()
let enabledPlugins: LoadedPlugin[]
try {
const result = await loadAllPluginsCacheOnly()
enabledPlugins = result.enabled
} catch {
return locations
}
for (const plugin of enabledPlugins) {
const candidateRoots = [plugin.skillsPath, ...(plugin.skillsPaths ?? [])]
for (const root of candidateRoots) {
if (!root) continue
const directSkillFile = path.join(root, 'SKILL.md')
try {
const stat = await fs.stat(directSkillFile)
if (stat.isFile()) {
const name = buildPluginSkillName(plugin.name, root)
if (!locations.has(name)) {
locations.set(name, { skillDir: root, pluginName: plugin.name })
}
continue
}
} catch {
// Fall through and inspect as a skills root.
}
let entries: import('fs').Dirent[]
try {
entries = await fs.readdir(root, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const skillDir = path.join(root, entry.name)
const skillFile = path.join(skillDir, 'SKILL.md')
try {
const stat = await fs.stat(skillFile)
if (!stat.isFile()) continue
} catch {
continue
}
const name = buildPluginSkillName(plugin.name, skillDir)
if (!locations.has(name)) {
locations.set(name, { skillDir, pluginName: plugin.name })
}
}
}
}
return locations
}
async function collectPluginSkills(): Promise<SkillMeta[]> {
const locations = await collectPluginSkillDirectories()
const skills: SkillMeta[] = []
for (const [name, location] of locations) {
const meta = await loadSkillMeta(
location.skillDir,
name,
'plugin',
location.pluginName,
)
if (meta) {
skills.push(meta)
}
}
return skills
}
// ─── Router ──────────────────────────────────────────────────────────────────
export async function handleSkillsApi(
@ -288,12 +386,13 @@ export async function handleSkillsApi(
async function listSkills(url: URL): Promise<Response> {
const cwd = getRequestedCwd(url)
const [userSkills, projectSkills] = await Promise.all([
const [userSkills, projectSkills, pluginSkills] = await Promise.all([
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
collectPluginSkills(),
])
const skills = [...userSkills, ...projectSkills]
const skills = [...userSkills, ...projectSkills, ...pluginSkills]
skills.sort((a, b) => a.name.localeCompare(b.name))
return Response.json({ skills })
}
@ -311,17 +410,30 @@ async function getSkillDetail(url: URL): Promise<Response> {
throw ApiError.badRequest('Invalid skill name')
}
if (source !== 'user' && source !== 'project') {
if (source !== 'user' && source !== 'project' && source !== 'plugin') {
throw ApiError.badRequest(`Unsupported source: ${source}`)
}
const cwd = getRequestedCwd(url)
const skillDir = await resolveSkillDir(source, name, cwd)
const pluginLocations =
source === 'plugin' ? await collectPluginSkillDirectories() : null
const pluginLocation = pluginLocations?.get(name)
const skillDir =
source === 'plugin'
? pluginLocation?.skillDir ?? null
: await resolveSkillDir(source, name, cwd)
if (!skillDir) {
throw ApiError.notFound(`Skill not found: ${name}`)
}
const meta = await loadSkillMeta(skillDir, name, source)
const meta = await loadSkillMeta(
skillDir,
name,
source,
pluginLocation?.pluginName,
)
if (!meta) {
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
}

View File

@ -1,5 +1,6 @@
import { sep } from 'node:path'
import { basename, join, sep } from 'node:path'
import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'
import type { McpServerConfig } from '../../services/mcp/types.js'
import {
disablePluginOp,
enablePluginOp,
@ -25,11 +26,15 @@ import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js'
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
import type {
PluginInstallationEntry,
PluginScope,
} from '../../utils/plugins/schemas.js'
import { ApiError } from '../middleware/errorHandler.js'
import { walkPluginMarkdown } from '../../utils/plugins/walkPluginMarkdown.js'
import type { HookCommand, HooksSettings } from '../../utils/settings/types.js'
export type ApiPluginCapabilitySet = {
commands: string[]
@ -57,8 +62,45 @@ export type ApiPluginSummary = {
errors: string[]
}
export type ApiPluginSkillEntry = {
name: string
displayName?: string
description: string
version?: string
pluginName?: string
}
export type ApiPluginCommandEntry = {
name: string
description: string
}
export type ApiPluginAgentEntry = {
name: string
displayName?: string
description: string
}
export type ApiPluginHookEntry = {
event: string
matcher?: string
actions: string[]
}
export type ApiPluginMcpServerEntry = {
name: string
displayName?: string
transport: string
summary: string
}
export type ApiPluginDetail = ApiPluginSummary & {
capabilities: ApiPluginCapabilitySet
commandEntries: ApiPluginCommandEntry[]
agentEntries: ApiPluginAgentEntry[]
hookEntries: ApiPluginHookEntry[]
skillEntries: ApiPluginSkillEntry[]
mcpServerEntries: ApiPluginMcpServerEntry[]
}
export type ApiPluginMarketplaceSummary = {
@ -321,10 +363,22 @@ export class PluginService {
errors: pluginErrors,
componentCounts: this.countCapabilities(this.emptyCapabilities()),
capabilities: this.emptyCapabilities(),
commandEntries: [],
agentEntries: [],
hookEntries: [],
skillEntries: [],
mcpServerEntries: [],
}
}
const capabilities = await this.collectCapabilities(loaded)
const {
capabilities,
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
} = await this.collectCapabilities(loaded)
return {
id: pluginId,
name: loaded.name,
@ -341,43 +395,100 @@ export class PluginService {
errors: pluginErrors,
componentCounts: this.countCapabilities(capabilities),
capabilities,
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
}
}
private async collectCapabilities(
plugin: LoadedPlugin,
): Promise<ApiPluginCapabilitySet> {
): Promise<{
capabilities: ApiPluginCapabilitySet
commandEntries: ApiPluginCommandEntry[]
agentEntries: ApiPluginAgentEntry[]
hookEntries: ApiPluginHookEntry[]
skillEntries: ApiPluginSkillEntry[]
mcpServerEntries: ApiPluginMcpServerEntry[]
}> {
if (plugin.isBuiltin) {
const definition = getBuiltinPluginDefinition(plugin.name)
const skillEntries = (definition?.skills ?? []).map((skill) => ({
name: skill.name,
description: skill.description,
}))
const mcpServerEntries = Object.entries(definition?.mcpServers ?? {}).map(([serverName, config]) => ({
name: `plugin:${plugin.name}:${serverName}`,
displayName: serverName,
transport: this.getPluginMcpTransport(config),
summary: this.getPluginMcpSummary(config),
}))
return {
commands: [],
agents: [],
skills: definition?.skills?.map((skill) => skill.name) ?? [],
hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
mcpServers: definition?.mcpServers ? Object.keys(definition.mcpServers) : [],
lspServers: [],
capabilities: {
commands: [],
agents: [],
skills: skillEntries.map((skill) => skill.name),
hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
mcpServers: mcpServerEntries.map((server) => server.name),
lspServers: [],
},
commandEntries: [],
agentEntries: [],
hookEntries: this.collectHookEntries(definition?.hooks),
skillEntries,
mcpServerEntries,
}
}
const commandEntries = await this.collectCommandEntries(plugin)
const agentEntries = await this.collectAgentEntries(plugin)
const hookEntries = this.collectHookEntries(plugin.hooksConfig)
const skillEntries = await this.collectSkillEntries([
plugin.skillsPath,
...(plugin.skillsPaths ?? []),
], plugin.name)
const mcpServerEntries = this.collectMcpServerEntries(plugin.name, plugin.mcpServers)
return {
commands: await this.collectMarkdownEntries([
plugin.commandsPath,
...(plugin.commandsPaths ?? []),
]),
agents: await this.collectMarkdownEntries([
plugin.agentsPath,
...(plugin.agentsPaths ?? []),
]),
skills: await this.collectSkillDirs([
plugin.skillsPath,
...(plugin.skillsPaths ?? []),
]),
hooks: plugin.hooksConfig ? Object.keys(plugin.hooksConfig) : [],
mcpServers: plugin.mcpServers ? Object.keys(plugin.mcpServers) : [],
lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
capabilities: {
commands: commandEntries.map((command) => command.name),
agents: agentEntries.map((agent) => agent.name),
skills: skillEntries.map((skill) => skill.name),
hooks: [...new Set(hookEntries.map((hook) => hook.event))],
mcpServers: mcpServerEntries.map((server) => server.name),
lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
},
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
}
}
private async collectCommandEntries(
plugin: LoadedPlugin,
): Promise<ApiPluginCommandEntry[]> {
return this.collectMarkdownEntriesWithDescriptions(
plugin.name,
[plugin.commandsPath, ...(plugin.commandsPaths ?? [])],
{ stopAtSkillDir: true, useNamespace: true },
)
}
private async collectAgentEntries(
plugin: LoadedPlugin,
): Promise<ApiPluginAgentEntry[]> {
return this.collectMarkdownEntriesWithDescriptions(
plugin.name,
[plugin.agentsPath, ...(plugin.agentsPaths ?? [])],
{ stopAtSkillDir: false, useNamespace: true, preferFrontmatterName: true },
)
}
private async collectMarkdownEntries(paths: Array<string | undefined>): Promise<string[]> {
const fs = await import('node:fs/promises')
const names = new Set<string>()
@ -386,8 +497,8 @@ export class PluginService {
if (!dirPath) continue
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const dirEntries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
names.add(entry.name.replace(/\.md$/i, ''))
}
@ -399,22 +510,93 @@ export class PluginService {
return [...names].sort()
}
private async collectSkillDirs(paths: Array<string | undefined>): Promise<string[]> {
private async collectMarkdownEntriesWithDescriptions(
pluginName: string,
paths: Array<string | undefined>,
options: {
stopAtSkillDir: boolean
useNamespace: boolean
preferFrontmatterName?: boolean
},
): Promise<Array<{ name: string; displayName?: string; description: string }>> {
const fs = await import('node:fs/promises')
const names = new Set<string>()
const entries = new Map<string, { name: string; displayName?: string; description: string }>()
for (const rootPath of paths) {
if (!rootPath) continue
await walkPluginMarkdown(
rootPath,
async (fullPath, namespace) => {
const raw = await fs.readFile(fullPath, 'utf-8')
const parsed = parseFrontmatter(raw, fullPath)
const baseName = basename(fullPath).replace(/\.md$/i, '')
const resolvedLeafName =
options.preferFrontmatterName &&
typeof parsed.frontmatter.name === 'string' &&
parsed.frontmatter.name.trim().length > 0
? parsed.frontmatter.name.trim()
: /^skill$/i.test(baseName)
? basename(join(fullPath, '..'))
: baseName
const leafName = resolvedLeafName
const name = options.useNamespace && namespace.length > 0
? `${pluginName}:${namespace.join(':')}:${leafName}`
: options.useNamespace
? `${pluginName}:${leafName}`
: leafName
const description =
(typeof parsed.frontmatter.description === 'string' && parsed.frontmatter.description.trim()) ||
(typeof parsed.frontmatter['when-to-use'] === 'string' && parsed.frontmatter['when-to-use'].trim()) ||
extractDescriptionFromMarkdown(parsed.content, 'No description')
if (!entries.has(name)) {
entries.set(name, {
name,
displayName: leafName !== name ? leafName : undefined,
description,
})
}
},
{
stopAtSkillDir: options.stopAtSkillDir,
logLabel: options.useNamespace ? 'plugin-details' : 'markdown',
},
)
}
return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name))
}
private async collectSkillEntries(
paths: Array<string | undefined>,
pluginName: string,
): Promise<ApiPluginSkillEntry[]> {
const fs = await import('node:fs/promises')
const skillEntriesByName = new Map<string, ApiPluginSkillEntry>()
for (const dirPath of paths) {
if (!dirPath) continue
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const directSkill = await this.readPluginSkillEntry(dirPath, pluginName)
if (directSkill && !skillEntriesByName.has(directSkill.name)) {
skillEntriesByName.set(directSkill.name, directSkill)
continue
}
} catch {
// Fall back to scanning as a skill root.
}
try {
const dirEntries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
try {
const stat = await fs.stat(`${dirPath}/${entry.name}/SKILL.md`)
if (stat.isFile()) {
names.add(entry.name)
const skillEntry = await this.readPluginSkillEntry(join(dirPath, entry.name), pluginName)
if (skillEntry && !skillEntriesByName.has(skillEntry.name)) {
skillEntriesByName.set(skillEntry.name, skillEntry)
}
} catch {
// Ignore non-skill directories.
@ -425,7 +607,111 @@ export class PluginService {
}
}
return [...names].sort()
return [...skillEntriesByName.values()].sort((a, b) => a.name.localeCompare(b.name))
}
private async readPluginSkillEntry(
skillDir: string,
pluginName: string,
): Promise<ApiPluginSkillEntry | null> {
const fs = await import('node:fs/promises')
const skillFile = join(skillDir, 'SKILL.md')
try {
const stat = await fs.stat(skillFile)
if (!stat.isFile()) return null
const raw = await fs.readFile(skillFile, 'utf-8')
const parsed = parseFrontmatter(raw, skillFile)
const body = parsed.content
const name = typeof parsed.frontmatter.name === 'string' && parsed.frontmatter.name.trim().length > 0
? parsed.frontmatter.name.trim()
: basename(skillDir)
const description =
(typeof parsed.frontmatter.description === 'string' && parsed.frontmatter.description.trim()) ||
body
.split('\n')
.find((line) => line.trim().length > 0)
?.trim() ||
'No description'
return {
name: `${pluginName}:${basename(skillDir)}`,
displayName: name !== basename(skillDir) ? name : undefined,
description,
version: parsed.frontmatter.version != null ? String(parsed.frontmatter.version) : undefined,
pluginName,
}
} catch {
return null
}
}
private collectMcpServerEntries(
pluginName: string,
servers?: Record<string, McpServerConfig>,
): ApiPluginMcpServerEntry[] {
return Object.entries(servers ?? {})
.map(([name, config]) => ({
name: `plugin:${pluginName}:${name}`,
displayName: name,
transport: this.getPluginMcpTransport(config),
summary: this.getPluginMcpSummary(config),
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
private collectHookEntries(hooks?: HooksSettings): ApiPluginHookEntry[] {
const entries: ApiPluginHookEntry[] = []
for (const [event, matchers] of Object.entries(hooks ?? {})) {
for (const matcher of matchers ?? []) {
entries.push({
event,
matcher: matcher.matcher,
actions: matcher.hooks.map((hook) => this.describeHookAction(hook)),
})
}
}
return entries.sort((a, b) => {
if (a.event !== b.event) return a.event.localeCompare(b.event)
return (a.matcher ?? '').localeCompare(b.matcher ?? '')
})
}
private describeHookAction(hook: HookCommand): string {
switch (hook.type) {
case 'command':
return hook.command
case 'prompt':
return hook.prompt
case 'agent':
return hook.prompt
case 'http':
return hook.url
default:
return hook.type
}
}
private getPluginMcpTransport(config: McpServerConfig): string {
return config.type ?? 'stdio'
}
private getPluginMcpSummary(config: McpServerConfig): string {
if (!config.type || config.type === 'stdio') {
const stdioConfig = config as McpServerConfig & {
command?: string
args?: string[]
}
return [stdioConfig.command, ...(stdioConfig.args ?? [])].filter(Boolean).join(' ').trim()
}
if ('url' in config && typeof config.url === 'string') {
return config.url
}
return config.type
}
private getErrorsForPlugin(