mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
vi.mock('../api/agents', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
@ -79,6 +80,18 @@ const MOCK_AGENTS = [
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
overriddenBy: 'userSettings' as const,
|
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 = {
|
const MOCK_SKILL_DETAIL = {
|
||||||
@ -132,6 +145,7 @@ describe('Settings > Agents tab', () => {
|
|||||||
activeTabId: 'session-1',
|
activeTabId: 'session-1',
|
||||||
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }],
|
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }],
|
||||||
})
|
})
|
||||||
|
useUIStore.setState({ pendingSettingsTab: null })
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
@ -164,6 +178,7 @@ describe('Settings > Agents tab', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
selectedAgent: null,
|
selectedAgent: null,
|
||||||
|
selectedAgentReturnTab: 'agents',
|
||||||
fetchAgents: noopFetch,
|
fetchAgents: noopFetch,
|
||||||
selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }),
|
selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }),
|
||||||
})
|
})
|
||||||
@ -236,8 +251,10 @@ describe('Settings > Agents tab', () => {
|
|||||||
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('Project').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('code-reviewer')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
|
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('telegram:pairing')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Overridden by User')).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('doc-writer')).toBeInTheDocument()
|
||||||
expect(screen.getByText('plain-agent')).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', () => {
|
describe('Settings > Skills tab', () => {
|
||||||
|
|||||||
@ -55,7 +55,7 @@ describe('McpSettings', () => {
|
|||||||
|
|
||||||
render(<McpSettings />)
|
render(<McpSettings />)
|
||||||
|
|
||||||
expect(fetchServers).toHaveBeenCalledWith(undefined, undefined)
|
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the empty state and add button', () => {
|
it('renders the empty state and add button', () => {
|
||||||
@ -66,24 +66,23 @@ describe('McpSettings', () => {
|
|||||||
expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument()
|
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({
|
useMcpStore.setState({
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
name: 'project-private',
|
name: 'plugin:telegram:telegram',
|
||||||
scope: 'local',
|
scope: 'dynamic',
|
||||||
projectPath: '/workspace/project',
|
|
||||||
transport: 'stdio',
|
transport: 'stdio',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
statusLabel: 'Connected',
|
statusLabel: 'Connected',
|
||||||
configLocation: '/tmp/config',
|
configLocation: '/tmp/config',
|
||||||
summary: 'npx demo',
|
summary: 'npx @telegram/mcp',
|
||||||
canEdit: true,
|
canEdit: false,
|
||||||
canRemove: true,
|
canRemove: false,
|
||||||
canReconnect: true,
|
canReconnect: true,
|
||||||
canToggle: true,
|
canToggle: true,
|
||||||
config: { type: 'stdio', command: 'npx', args: ['demo'], env: {} },
|
config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'global-user',
|
name: 'global-user',
|
||||||
@ -105,8 +104,9 @@ describe('McpSettings', () => {
|
|||||||
|
|
||||||
render(<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('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(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()
|
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 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
slashCommands: [{ name: 'mcp', description: 'List available MCP tools' }],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
},
|
},
|
||||||
@ -288,6 +292,134 @@ describe('Content-only pages render without errors', () => {
|
|||||||
useChatStore.setState({ sessions: {} })
|
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', () => {
|
it('AgentTeams renders team strip and members', () => {
|
||||||
const { container } = render(<AgentTeams />)
|
const { container } = render(<AgentTeams />)
|
||||||
expect(container.innerHTML).toContain('Architect')
|
expect(container.innerHTML).toContain('Architect')
|
||||||
|
|||||||
@ -6,6 +6,12 @@ import { Settings } from '../pages/Settings'
|
|||||||
import { usePluginStore } from '../stores/pluginStore'
|
import { usePluginStore } from '../stores/pluginStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
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', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
@ -34,27 +40,87 @@ vi.mock('../pages/AdapterSettings', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../stores/agentStore', () => ({
|
vi.mock('../stores/agentStore', () => ({
|
||||||
useAgentStore: () => ({
|
useAgentStore: Object.assign((selector?: (state: any) => unknown) => {
|
||||||
activeAgents: [],
|
const state = {
|
||||||
allAgents: [],
|
activeAgents: [],
|
||||||
isLoading: false,
|
allAgents: [],
|
||||||
error: null,
|
isLoading: false,
|
||||||
selectedAgent: null,
|
error: null,
|
||||||
fetchAgents: vi.fn(),
|
selectedAgent: null,
|
||||||
selectAgent: vi.fn(),
|
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', () => ({
|
vi.mock('../stores/skillStore', () => ({
|
||||||
useSkillStore: () => ({
|
useSkillStore: Object.assign((selector?: (state: any) => unknown) => {
|
||||||
skills: [],
|
const state = {
|
||||||
selectedSkill: null,
|
skills: [],
|
||||||
isLoading: false,
|
selectedSkill: null,
|
||||||
isDetailLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
isDetailLoading: false,
|
||||||
fetchSkills: vi.fn(),
|
error: null,
|
||||||
fetchSkillDetail: vi.fn(),
|
fetchSkills: MOCK_FETCH_SKILLS,
|
||||||
clearSelection: vi.fn(),
|
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', () => {
|
describe('Settings > Plugins tab', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
|
useUIStore.setState({ pendingSettingsTab: null })
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
@ -210,6 +278,44 @@ describe('Settings > Plugins tab', () => {
|
|||||||
mcpServers: ['github-api'],
|
mcpServers: ['github-api'],
|
||||||
lspServers: [],
|
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: [],
|
errors: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -220,8 +326,119 @@ describe('Settings > Plugins tab', () => {
|
|||||||
expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
|
expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
|
||||||
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
|
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Bundled capabilities')).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('Apply changes')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Uninstall')).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 { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
vi.mock('../api/agents', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
@ -82,9 +83,11 @@ describe('Settings > Skills tab', () => {
|
|||||||
availableProjects: ['/workspace/project'],
|
availableProjects: ['/workspace/project'],
|
||||||
})
|
})
|
||||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||||
|
useUIStore.setState({ pendingSettingsTab: null })
|
||||||
useSkillStore.setState({
|
useSkillStore.setState({
|
||||||
skills: [],
|
skills: [],
|
||||||
selectedSkill: null,
|
selectedSkill: null,
|
||||||
|
selectedSkillReturnTab: 'skills',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isDetailLoading: false,
|
isDetailLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -115,6 +118,16 @@ describe('Settings > Skills tab', () => {
|
|||||||
contentLength: 200,
|
contentLength: 200,
|
||||||
hasDirectory: true,
|
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('Total skills')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Second skill description')).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', () => {
|
it('uses the active session workDir even when settings tab is focused', () => {
|
||||||
@ -190,6 +205,7 @@ describe('Settings > Skills tab', () => {
|
|||||||
],
|
],
|
||||||
skillRoot: '/tmp/alpha',
|
skillRoot: '/tmp/alpha',
|
||||||
},
|
},
|
||||||
|
selectedSkillReturnTab: 'skills',
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<Settings />)
|
render(<Settings />)
|
||||||
@ -202,4 +218,39 @@ describe('Settings > Skills tab', () => {
|
|||||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/^---$/)).not.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 { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
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 { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useTeamStore } from '../../stores/teamStore'
|
import { useTeamStore } from '../../stores/teamStore'
|
||||||
import { sessionsApi } from '../../api/sessions'
|
import { sessionsApi } from '../../api/sessions'
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
findSlashTrigger,
|
findSlashTrigger,
|
||||||
mergeSlashCommands,
|
mergeSlashCommands,
|
||||||
replaceSlashToken,
|
replaceSlashToken,
|
||||||
|
resolveSlashUiAction,
|
||||||
} from './composerUtils'
|
} from './composerUtils'
|
||||||
|
|
||||||
type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number }
|
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 isWorkspaceMissing = activeSession?.workDirExists === false
|
||||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
||||||
const isHeroComposer = variant === 'hero' && !isMemberSession
|
const isHeroComposer = variant === 'hero' && !isMemberSession
|
||||||
|
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
@ -295,8 +298,19 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
|||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
|
if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
|
||||||
|
|
||||||
if (!isMemberSession && (text === '/mcp' || text === '/skills' || text === '/plugins')) {
|
const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||||
setLocalSlashPanel(text.slice(1) as LocalSlashCommandName)
|
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('')
|
setInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
@ -501,7 +515,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
|||||||
{!isMemberSession && fileSearchOpen && (
|
{!isMemberSession && fileSearchOpen && (
|
||||||
<FileSearchMenu
|
<FileSearchMenu
|
||||||
ref={fileSearchRef}
|
ref={fileSearchRef}
|
||||||
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
|
cwd={resolvedWorkDir || ''}
|
||||||
filter={atFilter}
|
filter={atFilter}
|
||||||
onSelect={(_path, name) => {
|
onSelect={(_path, name) => {
|
||||||
if (atCursorPos >= 0) {
|
if (atCursorPos >= 0) {
|
||||||
@ -525,7 +539,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
|||||||
<div ref={slashMenuRef}>
|
<div ref={slashMenuRef}>
|
||||||
<LocalSlashCommandPanel
|
<LocalSlashCommandPanel
|
||||||
command={localSlashPanel}
|
command={localSlashPanel}
|
||||||
cwd={gitInfo?.workDir || activeSession?.workDir || undefined}
|
cwd={resolvedWorkDir}
|
||||||
onClose={() => setLocalSlashPanel(null)}
|
onClose={() => setLocalSlashPanel(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -681,13 +695,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
|||||||
<div className="mt-3 px-1">
|
<div className="mt-3 px-1">
|
||||||
{hasMessages ? (
|
{hasMessages ? (
|
||||||
<ProjectContextChip
|
<ProjectContextChip
|
||||||
workDir={gitInfo?.workDir || activeSession?.workDir}
|
workDir={resolvedWorkDir}
|
||||||
repoName={gitInfo?.repoName || null}
|
repoName={gitInfo?.repoName || null}
|
||||||
branch={gitInfo?.branch || null}
|
branch={gitInfo?.branch || null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DirectoryPicker
|
<DirectoryPicker
|
||||||
value={gitInfo?.workDir || activeSession?.workDir || ''}
|
value={resolvedWorkDir || ''}
|
||||||
onChange={async (newWorkDir) => {
|
onChange={async (newWorkDir) => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
const oldId = activeTabId
|
const oldId = activeTabId
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useSkillStore } from '../../stores/skillStore'
|
|||||||
import type { McpServerRecord } from '../../types/mcp'
|
import type { McpServerRecord } from '../../types/mcp'
|
||||||
import type { SkillMeta } from '../../types/skill'
|
import type { SkillMeta } from '../../types/skill'
|
||||||
|
|
||||||
export type LocalSlashCommandName = 'mcp' | 'skills' | 'plugins'
|
export type LocalSlashCommandName = 'mcp' | 'skills'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
command: LocalSlashCommandName
|
command: LocalSlashCommandName
|
||||||
@ -243,7 +243,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
key={`${skill.source}:${skill.name}`}
|
key={`${skill.source}:${skill.name}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await fetchSkillDetail(skill.source, skill.name, cwd)
|
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
|
||||||
setPendingSettingsTab('skills')
|
setPendingSettingsTab('skills')
|
||||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||||
onClose()
|
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) {
|
export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) {
|
||||||
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
||||||
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
|
return <SkillsPanel cwd={cwd} onClose={onClose} />
|
||||||
return <PluginsPanel 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 = [
|
export const FALLBACK_SLASH_COMMANDS = [
|
||||||
|
...PANEL_SLASH_COMMANDS,
|
||||||
|
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
|
||||||
{ name: 'compact', description: 'Compact conversation context' },
|
{ name: 'compact', description: 'Compact conversation context' },
|
||||||
{ name: 'clear', description: 'Clear conversation history' },
|
{ name: 'clear', description: 'Clear conversation history' },
|
||||||
{ name: 'help', description: 'Show available commands' },
|
{ name: 'help', description: 'Show available commands' },
|
||||||
@ -25,6 +39,30 @@ export type SlashCommandOption = {
|
|||||||
description: string
|
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(
|
export function mergeSlashCommands(
|
||||||
preferred: ReadonlyArray<SlashCommandOption>,
|
preferred: ReadonlyArray<SlashCommandOption>,
|
||||||
fallback: ReadonlyArray<SlashCommandOption> = FALLBACK_SLASH_COMMANDS,
|
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 { usePluginStore } from '../../stores/pluginStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
import { Button } from '../shared/Button'
|
import { Button } from '../shared/Button'
|
||||||
import type { PluginCapabilityKey } from '../../types/plugin'
|
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[] = [
|
const CAPABILITY_ORDER: PluginCapabilityKey[] = [
|
||||||
'skills',
|
|
||||||
'commands',
|
|
||||||
'agents',
|
|
||||||
'hooks',
|
|
||||||
'mcpServers',
|
|
||||||
'lspServers',
|
'lspServers',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -30,6 +29,11 @@ export function PluginDetail() {
|
|||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const addToast = useUIStore((s) => s.addToast)
|
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 t = useTranslation()
|
||||||
const [actionKey, setActionKey] = useState<string | null>(null)
|
const [actionKey, setActionKey] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -47,6 +51,15 @@ export function PluginDetail() {
|
|||||||
if (!selectedPlugin) return null
|
if (!selectedPlugin) return null
|
||||||
|
|
||||||
const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin'
|
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>) => {
|
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||||
setActionKey(key)
|
setActionKey(key)
|
||||||
@ -90,6 +103,76 @@ export function PluginDetail() {
|
|||||||
return window.confirm(label)
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 min-w-0">
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
@ -261,10 +344,112 @@ export function PluginDetail() {
|
|||||||
{t('settings.plugins.capabilitiesHint')}
|
{t('settings.plugins.capabilitiesHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{CAPABILITY_ORDER.map((key) => {
|
<CapabilityPreviewSection
|
||||||
const items = selectedPlugin.capabilities[key]
|
title={t('settings.plugins.capabilityLabel.skills')}
|
||||||
return (
|
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
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
|
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>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 }) {
|
function MetaPill({ children }: { children: ReactNode }) {
|
||||||
return (
|
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)]">
|
<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 { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||||
import { CodeViewer } from '../chat/CodeViewer'
|
import { CodeViewer } from '../chat/CodeViewer'
|
||||||
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
|
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
|
||||||
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
|
|
||||||
const META_PRIORITY = [
|
const META_PRIORITY = [
|
||||||
'description',
|
'description',
|
||||||
@ -20,7 +21,7 @@ const META_PRIORITY = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function SkillDetail() {
|
export function SkillDetail() {
|
||||||
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
|
const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection } = useSkillStore()
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
|
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
|
||||||
|
|
||||||
@ -31,6 +32,14 @@ export function SkillDetail() {
|
|||||||
: selectedSkill.files[0]?.path || 'SKILL.md'
|
: selectedSkill.files[0]?.path || 'SKILL.md'
|
||||||
}, [selectedFile, selectedSkill])
|
}, [selectedFile, selectedSkill])
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
const returnTab = selectedSkillReturnTab
|
||||||
|
clearSelection()
|
||||||
|
if (returnTab === 'plugins') {
|
||||||
|
useUIStore.getState().setPendingSettingsTab('plugins')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isDetailLoading) {
|
if (isDetailLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<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 className="flex h-full min-h-0 flex-col gap-4 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<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)]"
|
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>
|
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||||
|
|||||||
@ -180,7 +180,7 @@ export function SkillList() {
|
|||||||
key={`${skill.source}-${skill.name}`}
|
key={`${skill.source}-${skill.name}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
skill.hasDirectory &&
|
skill.hasDirectory &&
|
||||||
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
|
fetchSkillDetail(skill.source, skill.name, currentWorkDir, 'skills')
|
||||||
}
|
}
|
||||||
disabled={!skill.hasDirectory}
|
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"
|
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.project': 'Project',
|
||||||
'settings.mcp.scope.local': 'Local',
|
'settings.mcp.scope.local': 'Local',
|
||||||
'settings.mcp.scope.user': 'User',
|
'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.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.project': 'Shared with the team through the selected project’s `.mcp.json`.',
|
||||||
'settings.mcp.scopeDesc.user': 'Available in all projects for your user account.',
|
'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.errorsTitle': 'Plugin errors',
|
||||||
'settings.plugins.capabilitiesTitle': 'Bundled capabilities',
|
'settings.plugins.capabilitiesTitle': 'Bundled capabilities',
|
||||||
'settings.plugins.capabilitiesHint': 'Capabilities exposed by this plugin after loading its manifest and runtime integrations.',
|
'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.capabilityEmpty': 'None exposed',
|
||||||
'settings.plugins.capability.skills': '{count} skills',
|
'settings.plugins.capability.skills': '{count} skills',
|
||||||
'settings.plugins.capability.agents': '{count} agents',
|
'settings.plugins.capability.agents': '{count} agents',
|
||||||
|
|||||||
@ -180,6 +180,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.mcp.scope.project': '项目共享',
|
'settings.mcp.scope.project': '项目共享',
|
||||||
'settings.mcp.scope.local': '项目私有',
|
'settings.mcp.scope.local': '项目私有',
|
||||||
'settings.mcp.scope.user': '全局用户',
|
'settings.mcp.scope.user': '全局用户',
|
||||||
|
'settings.mcp.scope.plugin': '插件',
|
||||||
'settings.mcp.scopeDesc.local': '只对你自己生效,但绑定到某一个项目。配置写在用户配置里,同时带上选中的项目上下文。',
|
'settings.mcp.scopeDesc.local': '只对你自己生效,但绑定到某一个项目。配置写在用户配置里,同时带上选中的项目上下文。',
|
||||||
'settings.mcp.scopeDesc.project': '写入选中项目的 `.mcp.json`,项目成员共享。',
|
'settings.mcp.scopeDesc.project': '写入选中项目的 `.mcp.json`,项目成员共享。',
|
||||||
'settings.mcp.scopeDesc.user': '写入你的全局 Claude 配置,对所有项目生效。',
|
'settings.mcp.scopeDesc.user': '写入你的全局 Claude 配置,对所有项目生效。',
|
||||||
@ -412,6 +413,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.plugins.errorsTitle': '插件错误',
|
'settings.plugins.errorsTitle': '插件错误',
|
||||||
'settings.plugins.capabilitiesTitle': '打包能力',
|
'settings.plugins.capabilitiesTitle': '打包能力',
|
||||||
'settings.plugins.capabilitiesHint': '根据插件 manifest 和运行时集成解析出的能力列表。',
|
'settings.plugins.capabilitiesHint': '根据插件 manifest 和运行时集成解析出的能力列表。',
|
||||||
|
'settings.plugins.sharedNavigationDisabled': '先启用插件并应用变更,才能在共享管理页中打开它的技能、Agent 或 MCP 条目。',
|
||||||
'settings.plugins.capabilityEmpty': '没有暴露',
|
'settings.plugins.capabilityEmpty': '没有暴露',
|
||||||
'settings.plugins.capability.skills': '{count} 个技能',
|
'settings.plugins.capability.skills': '{count} 个技能',
|
||||||
'settings.plugins.capability.agents': '{count} 个 Agent',
|
'settings.plugins.capability.agents': '{count} 个 Agent',
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useTranslation } from '../i18n'
|
|||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
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 { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||||
@ -17,6 +17,7 @@ import {
|
|||||||
insertSlashTrigger,
|
insertSlashTrigger,
|
||||||
mergeSlashCommands,
|
mergeSlashCommands,
|
||||||
replaceSlashCommand,
|
replaceSlashCommand,
|
||||||
|
resolveSlashUiAction,
|
||||||
} from '../components/chat/composerUtils'
|
} from '../components/chat/composerUtils'
|
||||||
import type { AttachmentRef } from '../types/chat'
|
import type { AttachmentRef } from '../types/chat'
|
||||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
@ -178,8 +179,19 @@ export function EmptySession() {
|
|||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if ((!text && attachments.length === 0) || isSubmitting) return
|
if ((!text && attachments.length === 0) || isSubmitting) return
|
||||||
|
|
||||||
if (text === '/mcp' || text === '/skills' || text === '/plugins') {
|
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||||
setLocalSlashPanel(text.slice(1) as LocalSlashCommandName)
|
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('')
|
setInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Modal } from '../components/shared/Modal'
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import { useMcpStore } from '../stores/mcpStore'
|
import { useMcpStore } from '../stores/mcpStore'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
||||||
|
|
||||||
type EditorMode =
|
type EditorMode =
|
||||||
@ -39,6 +40,27 @@ type McpDraft = {
|
|||||||
oauthCallbackPort: string
|
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> = {
|
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||||
connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
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',
|
'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({
|
function ToggleSwitch({
|
||||||
checked,
|
checked,
|
||||||
disabled,
|
disabled,
|
||||||
@ -314,7 +358,7 @@ function ServerRow({
|
|||||||
{transportLabel(server.transport, t)}
|
{transportLabel(server.transport, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
|
<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>
|
||||||
<span className="truncate">{server.summary}</span>
|
<span className="truncate">{server.summary}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -340,6 +384,8 @@ function ServerRow({
|
|||||||
export function McpSettings() {
|
export function McpSettings() {
|
||||||
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
|
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
|
||||||
const addToast = useUIStore((s) => s.addToast)
|
const addToast = useUIStore((s) => s.addToast)
|
||||||
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [view, setView] = useState<EditorMode>({ type: 'list' })
|
const [view, setView] = useState<EditorMode>({ type: 'list' })
|
||||||
const [draft, setDraft] = useState<McpDraft>(createEmptyDraft)
|
const [draft, setDraft] = useState<McpDraft>(createEmptyDraft)
|
||||||
@ -348,20 +394,27 @@ export function McpSettings() {
|
|||||||
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
||||||
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||||
void fetchServers(undefined, undefined)
|
const currentWorkDir = activeSession?.workDir || undefined
|
||||||
}, [fetchServers])
|
|
||||||
|
|
||||||
const globalServers = useMemo(
|
useEffect(() => {
|
||||||
() => servers.filter((server) => server.scope === 'user'),
|
void fetchServers(undefined, currentWorkDir)
|
||||||
[servers],
|
}, [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(() => ({
|
const stats = useMemo(() => ({
|
||||||
total: globalServers.length,
|
total: servers.length,
|
||||||
connected: globalServers.filter((server) => server.status === 'connected').length,
|
connected: servers.filter((server) => server.status === 'connected').length,
|
||||||
attention: globalServers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length,
|
attention: servers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length,
|
||||||
}), [globalServers])
|
}), [servers])
|
||||||
|
|
||||||
const beginCreate = () => {
|
const beginCreate = () => {
|
||||||
setDraft(createEmptyDraft())
|
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">
|
<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">
|
<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.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.status')} value={server.statusLabel} />
|
||||||
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
|
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
|
||||||
</div>
|
</div>
|
||||||
@ -763,9 +816,6 @@ export function McpSettings() {
|
|||||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
||||||
{t('settings.mcp.description')}
|
{t('settings.mcp.description')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm text-[var(--color-text-tertiary)]">
|
|
||||||
{t('settings.mcp.globalOnlyHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="lg" onClick={beginCreate}>
|
<Button variant="secondary" size="lg" onClick={beginCreate}>
|
||||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
<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" />
|
<StatCard label={t('settings.mcp.stats.attention')} value={stats.attention} icon="error" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && globalServers.length === 0 ? (
|
{isLoading && servers.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<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 className="animate-spin h-6 w-6 rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
@ -789,39 +839,48 @@ export function McpSettings() {
|
|||||||
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
|
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void fetchServers(undefined, undefined)}
|
onClick={() => void fetchServers(undefined, currentWorkDir)}
|
||||||
className="text-sm text-[var(--color-text-accent)] hover:underline"
|
className="text-sm text-[var(--color-text-accent)] hover:underline"
|
||||||
>
|
>
|
||||||
{t('common.retry')}
|
{t('common.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)]">
|
<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>
|
<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-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>
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.mcp.emptyHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section>
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{MCP_GROUP_ORDER.map((group) => {
|
||||||
<div className="text-[1.35rem] font-semibold text-[var(--color-text-primary)]">
|
const groupServers = groupedServers[group]
|
||||||
{t('settings.mcp.scope.user')}
|
if (!groupServers?.length) return null
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--color-text-tertiary)]">{globalServers.length}</div>
|
return (
|
||||||
</div>
|
<section key={group}>
|
||||||
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
<div className="flex items-center justify-between mb-3">
|
||||||
{globalServers.map((server) => (
|
<div className="text-[1.35rem] font-semibold text-[var(--color-text-primary)]">
|
||||||
<ServerRow
|
{group === 'plugin' ? t('settings.mcp.scope.plugin') : t(`settings.mcp.scope.${group}`)}
|
||||||
key={`${server.scope}:${server.name}`}
|
</div>
|
||||||
server={server}
|
<div className="text-sm text-[var(--color-text-tertiary)]">{groupServers.length}</div>
|
||||||
isBusy={busyServerName === server.name}
|
</div>
|
||||||
onOpen={() => beginEdit(server)}
|
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||||
onToggle={() => void handleToggle(server)}
|
{groupServers.map((server) => (
|
||||||
t={t}
|
<ServerRow
|
||||||
/>
|
key={`${server.scope}:${server.name}`}
|
||||||
))}
|
server={server}
|
||||||
</div>
|
isBusy={busyServerName === server.name}
|
||||||
</section>
|
onOpen={() => beginEdit(server)}
|
||||||
|
onToggle={() => void handleToggle(server)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -802,6 +802,7 @@ function AgentsSettings() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
|
selectedAgentReturnTab,
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
selectAgent,
|
selectAgent,
|
||||||
} = useAgentStore()
|
} = useAgentStore()
|
||||||
@ -826,10 +827,18 @@ function AgentsSettings() {
|
|||||||
|
|
||||||
const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
|
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) {
|
if (selectedAgent) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
|
<AgentDetailView agent={selectedAgent} onBack={handleAgentBack} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -938,7 +947,7 @@ function AgentsSettings() {
|
|||||||
{group.map((agent) => (
|
{group.map((agent) => (
|
||||||
<button
|
<button
|
||||||
key={`${agent.source}-${agent.agentType}`}
|
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)]"
|
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">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
@ -1,15 +1,21 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { agentsApi, type AgentDefinition } from '../api/agents'
|
import { agentsApi, type AgentDefinition } from '../api/agents'
|
||||||
|
|
||||||
|
export type AgentDetailReturnTab = 'agents' | 'plugins'
|
||||||
|
|
||||||
type AgentStore = {
|
type AgentStore = {
|
||||||
activeAgents: AgentDefinition[]
|
activeAgents: AgentDefinition[]
|
||||||
allAgents: AgentDefinition[]
|
allAgents: AgentDefinition[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
selectedAgent: AgentDefinition | null
|
selectedAgent: AgentDefinition | null
|
||||||
|
selectedAgentReturnTab: AgentDetailReturnTab
|
||||||
|
|
||||||
fetchAgents: (cwd?: string) => Promise<void>
|
fetchAgents: (cwd?: string) => Promise<void>
|
||||||
selectAgent: (agent: AgentDefinition | null) => void
|
selectAgent: (
|
||||||
|
agent: AgentDefinition | null,
|
||||||
|
returnTab?: AgentDetailReturnTab,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAgentStore = create<AgentStore>((set) => ({
|
export const useAgentStore = create<AgentStore>((set) => ({
|
||||||
@ -18,6 +24,7 @@ export const useAgentStore = create<AgentStore>((set) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
selectedAgent: null,
|
selectedAgent: null,
|
||||||
|
selectedAgentReturnTab: 'agents',
|
||||||
|
|
||||||
fetchAgents: async (cwd) => {
|
fetchAgents: async (cwd) => {
|
||||||
set({ isLoading: true, error: null })
|
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 { skillsApi } from '../api/skills'
|
||||||
import type { SkillMeta, SkillDetail } from '../types/skill'
|
import type { SkillMeta, SkillDetail } from '../types/skill'
|
||||||
|
|
||||||
|
export type SkillDetailReturnTab = 'skills' | 'plugins'
|
||||||
|
|
||||||
type SkillStore = {
|
type SkillStore = {
|
||||||
skills: SkillMeta[]
|
skills: SkillMeta[]
|
||||||
selectedSkill: SkillDetail | null
|
selectedSkill: SkillDetail | null
|
||||||
|
selectedSkillReturnTab: SkillDetailReturnTab
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isDetailLoading: boolean
|
isDetailLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
fetchSkills: (cwd?: string) => Promise<void>
|
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
|
clearSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSkillStore = create<SkillStore>((set) => ({
|
export const useSkillStore = create<SkillStore>((set) => ({
|
||||||
skills: [],
|
skills: [],
|
||||||
selectedSkill: null,
|
selectedSkill: null,
|
||||||
|
selectedSkillReturnTab: 'skills',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isDetailLoading: false,
|
isDetailLoading: false,
|
||||||
error: null,
|
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 })
|
set({ isDetailLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { detail } = await skillsApi.detail(source, name, cwd)
|
const { detail } = await skillsApi.detail(source, name, cwd)
|
||||||
set({ selectedSkill: detail, isDetailLoading: false })
|
set({
|
||||||
|
selectedSkill: detail,
|
||||||
|
selectedSkillReturnTab: returnTab,
|
||||||
|
isDetailLoading: false,
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : String(err),
|
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 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 = {
|
export type PluginSummary = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -31,6 +63,11 @@ export type PluginSummary = {
|
|||||||
|
|
||||||
export type PluginDetail = PluginSummary & {
|
export type PluginDetail = PluginSummary & {
|
||||||
capabilities: PluginCapabilities
|
capabilities: PluginCapabilities
|
||||||
|
commandEntries: PluginCommandEntry[]
|
||||||
|
agentEntries: PluginAgentEntry[]
|
||||||
|
hookEntries: PluginHookEntry[]
|
||||||
|
skillEntries: PluginSkillEntry[]
|
||||||
|
mcpServerEntries: PluginMcpServerEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginMarketplaceSummary = {
|
export type PluginMarketplaceSummary = {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
|||||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||||
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
||||||
import { getCwd } from '../../utils/cwd.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'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@ -20,11 +22,12 @@ type SkillMeta = {
|
|||||||
name: string
|
name: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
description: string
|
description: string
|
||||||
source: 'user' | 'project'
|
source: 'user' | 'project' | 'plugin'
|
||||||
userInvocable: boolean
|
userInvocable: boolean
|
||||||
version?: string
|
version?: string
|
||||||
contentLength: number
|
contentLength: number
|
||||||
hasDirectory: boolean
|
hasDirectory: boolean
|
||||||
|
pluginName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillSource = SkillMeta['source']
|
type SkillSource = SkillMeta['source']
|
||||||
@ -92,7 +95,8 @@ function getProjectSkillsDirs(cwd: string): string[] {
|
|||||||
async function loadSkillMeta(
|
async function loadSkillMeta(
|
||||||
skillDir: string,
|
skillDir: string,
|
||||||
skillName: string,
|
skillName: string,
|
||||||
source: 'user' | 'project',
|
source: SkillSource,
|
||||||
|
pluginName?: string,
|
||||||
): Promise<SkillMeta | null> {
|
): Promise<SkillMeta | null> {
|
||||||
const skillFile = path.join(skillDir, 'SKILL.md')
|
const skillFile = path.join(skillDir, 'SKILL.md')
|
||||||
try {
|
try {
|
||||||
@ -116,6 +120,7 @@ async function loadSkillMeta(
|
|||||||
version: frontmatter.version != null ? String(frontmatter.version) : undefined,
|
version: frontmatter.version != null ? String(frontmatter.version) : undefined,
|
||||||
contentLength: raw.length,
|
contentLength: raw.length,
|
||||||
hasDirectory: true,
|
hasDirectory: true,
|
||||||
|
pluginName,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@ -240,7 +245,11 @@ async function resolveSkillDir(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const skillRoots =
|
const skillRoots =
|
||||||
source === 'user' ? [getUserSkillsDir()] : getProjectSkillsDirs(cwd)
|
source === 'user'
|
||||||
|
? [getUserSkillsDir()]
|
||||||
|
: source === 'project'
|
||||||
|
? getProjectSkillsDirs(cwd)
|
||||||
|
: []
|
||||||
|
|
||||||
for (const root of skillRoots) {
|
for (const root of skillRoots) {
|
||||||
const skillDir = path.join(root, name)
|
const skillDir = path.join(root, name)
|
||||||
@ -257,6 +266,95 @@ async function resolveSkillDir(
|
|||||||
return null
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function handleSkillsApi(
|
export async function handleSkillsApi(
|
||||||
@ -288,12 +386,13 @@ export async function handleSkillsApi(
|
|||||||
|
|
||||||
async function listSkills(url: URL): Promise<Response> {
|
async function listSkills(url: URL): Promise<Response> {
|
||||||
const cwd = getRequestedCwd(url)
|
const cwd = getRequestedCwd(url)
|
||||||
const [userSkills, projectSkills] = await Promise.all([
|
const [userSkills, projectSkills, pluginSkills] = await Promise.all([
|
||||||
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
||||||
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
||||||
|
collectPluginSkills(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const skills = [...userSkills, ...projectSkills]
|
const skills = [...userSkills, ...projectSkills, ...pluginSkills]
|
||||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Response.json({ skills })
|
return Response.json({ skills })
|
||||||
}
|
}
|
||||||
@ -311,17 +410,30 @@ async function getSkillDetail(url: URL): Promise<Response> {
|
|||||||
throw ApiError.badRequest('Invalid skill name')
|
throw ApiError.badRequest('Invalid skill name')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source !== 'user' && source !== 'project') {
|
if (source !== 'user' && source !== 'project' && source !== 'plugin') {
|
||||||
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = getRequestedCwd(url)
|
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) {
|
if (!skillDir) {
|
||||||
throw ApiError.notFound(`Skill not found: ${name}`)
|
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) {
|
if (!meta) {
|
||||||
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
|
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 { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'
|
||||||
|
import type { McpServerConfig } from '../../services/mcp/types.js'
|
||||||
import {
|
import {
|
||||||
disablePluginOp,
|
disablePluginOp,
|
||||||
enablePluginOp,
|
enablePluginOp,
|
||||||
@ -25,11 +26,15 @@ import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
|||||||
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
|
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
|
||||||
import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js'
|
import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js'
|
||||||
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
|
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
|
||||||
|
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||||
|
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
|
||||||
import type {
|
import type {
|
||||||
PluginInstallationEntry,
|
PluginInstallationEntry,
|
||||||
PluginScope,
|
PluginScope,
|
||||||
} from '../../utils/plugins/schemas.js'
|
} from '../../utils/plugins/schemas.js'
|
||||||
import { ApiError } from '../middleware/errorHandler.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 = {
|
export type ApiPluginCapabilitySet = {
|
||||||
commands: string[]
|
commands: string[]
|
||||||
@ -57,8 +62,45 @@ export type ApiPluginSummary = {
|
|||||||
errors: string[]
|
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 & {
|
export type ApiPluginDetail = ApiPluginSummary & {
|
||||||
capabilities: ApiPluginCapabilitySet
|
capabilities: ApiPluginCapabilitySet
|
||||||
|
commandEntries: ApiPluginCommandEntry[]
|
||||||
|
agentEntries: ApiPluginAgentEntry[]
|
||||||
|
hookEntries: ApiPluginHookEntry[]
|
||||||
|
skillEntries: ApiPluginSkillEntry[]
|
||||||
|
mcpServerEntries: ApiPluginMcpServerEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiPluginMarketplaceSummary = {
|
export type ApiPluginMarketplaceSummary = {
|
||||||
@ -321,10 +363,22 @@ export class PluginService {
|
|||||||
errors: pluginErrors,
|
errors: pluginErrors,
|
||||||
componentCounts: this.countCapabilities(this.emptyCapabilities()),
|
componentCounts: this.countCapabilities(this.emptyCapabilities()),
|
||||||
capabilities: 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 {
|
return {
|
||||||
id: pluginId,
|
id: pluginId,
|
||||||
name: loaded.name,
|
name: loaded.name,
|
||||||
@ -341,43 +395,100 @@ export class PluginService {
|
|||||||
errors: pluginErrors,
|
errors: pluginErrors,
|
||||||
componentCounts: this.countCapabilities(capabilities),
|
componentCounts: this.countCapabilities(capabilities),
|
||||||
capabilities,
|
capabilities,
|
||||||
|
commandEntries,
|
||||||
|
agentEntries,
|
||||||
|
hookEntries,
|
||||||
|
skillEntries,
|
||||||
|
mcpServerEntries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectCapabilities(
|
private async collectCapabilities(
|
||||||
plugin: LoadedPlugin,
|
plugin: LoadedPlugin,
|
||||||
): Promise<ApiPluginCapabilitySet> {
|
): Promise<{
|
||||||
|
capabilities: ApiPluginCapabilitySet
|
||||||
|
commandEntries: ApiPluginCommandEntry[]
|
||||||
|
agentEntries: ApiPluginAgentEntry[]
|
||||||
|
hookEntries: ApiPluginHookEntry[]
|
||||||
|
skillEntries: ApiPluginSkillEntry[]
|
||||||
|
mcpServerEntries: ApiPluginMcpServerEntry[]
|
||||||
|
}> {
|
||||||
if (plugin.isBuiltin) {
|
if (plugin.isBuiltin) {
|
||||||
const definition = getBuiltinPluginDefinition(plugin.name)
|
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 {
|
return {
|
||||||
commands: [],
|
capabilities: {
|
||||||
agents: [],
|
commands: [],
|
||||||
skills: definition?.skills?.map((skill) => skill.name) ?? [],
|
agents: [],
|
||||||
hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
|
skills: skillEntries.map((skill) => skill.name),
|
||||||
mcpServers: definition?.mcpServers ? Object.keys(definition.mcpServers) : [],
|
hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
|
||||||
lspServers: [],
|
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 {
|
return {
|
||||||
commands: await this.collectMarkdownEntries([
|
capabilities: {
|
||||||
plugin.commandsPath,
|
commands: commandEntries.map((command) => command.name),
|
||||||
...(plugin.commandsPaths ?? []),
|
agents: agentEntries.map((agent) => agent.name),
|
||||||
]),
|
skills: skillEntries.map((skill) => skill.name),
|
||||||
agents: await this.collectMarkdownEntries([
|
hooks: [...new Set(hookEntries.map((hook) => hook.event))],
|
||||||
plugin.agentsPath,
|
mcpServers: mcpServerEntries.map((server) => server.name),
|
||||||
...(plugin.agentsPaths ?? []),
|
lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
|
||||||
]),
|
},
|
||||||
skills: await this.collectSkillDirs([
|
commandEntries,
|
||||||
plugin.skillsPath,
|
agentEntries,
|
||||||
...(plugin.skillsPaths ?? []),
|
hookEntries,
|
||||||
]),
|
skillEntries,
|
||||||
hooks: plugin.hooksConfig ? Object.keys(plugin.hooksConfig) : [],
|
mcpServerEntries,
|
||||||
mcpServers: plugin.mcpServers ? Object.keys(plugin.mcpServers) : [],
|
|
||||||
lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[]> {
|
private async collectMarkdownEntries(paths: Array<string | undefined>): Promise<string[]> {
|
||||||
const fs = await import('node:fs/promises')
|
const fs = await import('node:fs/promises')
|
||||||
const names = new Set<string>()
|
const names = new Set<string>()
|
||||||
@ -386,8 +497,8 @@ export class PluginService {
|
|||||||
if (!dirPath) continue
|
if (!dirPath) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
const dirEntries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||||
for (const entry of entries) {
|
for (const entry of dirEntries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
||||||
names.add(entry.name.replace(/\.md$/i, ''))
|
names.add(entry.name.replace(/\.md$/i, ''))
|
||||||
}
|
}
|
||||||
@ -399,22 +510,93 @@ export class PluginService {
|
|||||||
return [...names].sort()
|
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 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) {
|
for (const dirPath of paths) {
|
||||||
if (!dirPath) continue
|
if (!dirPath) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
const directSkill = await this.readPluginSkillEntry(dirPath, pluginName)
|
||||||
for (const entry of entries) {
|
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
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(`${dirPath}/${entry.name}/SKILL.md`)
|
const skillEntry = await this.readPluginSkillEntry(join(dirPath, entry.name), pluginName)
|
||||||
if (stat.isFile()) {
|
if (skillEntry && !skillEntriesByName.has(skillEntry.name)) {
|
||||||
names.add(entry.name)
|
skillEntriesByName.set(skillEntry.name, skillEntry)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore non-skill directories.
|
// 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(
|
private getErrorsForPlugin(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user