mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
28f36da0fd
commit
de736c49f7
254
desktop/scripts/e2e-slash-plugin-agent-browser.sh
Normal file
254
desktop/scripts/e2e-slash-plugin-agent-browser.sh
Normal 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}"
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 project’s `.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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -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' }),
|
||||
}))
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user