feat: Add configuration management UI for /agents page
- Add 6-tab horizontal bar: Files, Tools, MCPs, Plugins, Hooks, Skills - Backend: permission parser, config/known-tools/skills/plugins/mcp-json endpoints - Backend: POST endpoints for permissions, hooks, and MCP config - Store: tool entries with 3-state toggle, MCP servers, hooks CRUD, skills/plugins fetch - ToolsManager: search, grouped cards (base/MCP), ask/allow/deny cycle, parameterized rules - McpManager: server cards with enable/disable, add/edit/delete modal - PluginsManager: read-only global plugin cards from ~/.claude/plugins/ - HooksManager: accordion by event type, inline edit with matcher/command/timeout - SkillsManager: two-column layout with SKILL.md preview and references
This commit is contained in:
505
frontend/src/components/agents/HooksManager.vue
Normal file
505
frontend/src/components/agents/HooksManager.vue
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAgentsStore, HOOK_EVENT_TYPES } from '../../stores/agents'
|
||||||
|
import type { HookEntry, HookCommand } from '../../stores/agents'
|
||||||
|
|
||||||
|
const store = useAgentsStore()
|
||||||
|
const editingHook = ref<{ eventType: string; index: number } | null>(null)
|
||||||
|
const editMatcher = ref('')
|
||||||
|
const editCommand = ref('')
|
||||||
|
const editTimeout = ref(5000)
|
||||||
|
|
||||||
|
function hookCount(eventType: string): number {
|
||||||
|
return store.hooksConfig[eventType]?.length || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalHookCount(): number {
|
||||||
|
return Object.values(store.hooksConfig).reduce((sum, arr) => sum + (arr?.length || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(eventType: string, index: number) {
|
||||||
|
const entry = store.hooksConfig[eventType]?.[index]
|
||||||
|
if (!entry) return
|
||||||
|
editingHook.value = { eventType, index }
|
||||||
|
editMatcher.value = entry.matcher || ''
|
||||||
|
editCommand.value = entry.hooks?.[0]?.command || ''
|
||||||
|
editTimeout.value = entry.hooks?.[0]?.timeout || 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
if (!editingHook.value) return
|
||||||
|
const { eventType, index } = editingHook.value
|
||||||
|
|
||||||
|
const entry: HookEntry = {
|
||||||
|
matcher: editMatcher.value.trim() || null,
|
||||||
|
hooks: [{
|
||||||
|
type: 'command',
|
||||||
|
command: editCommand.value.trim(),
|
||||||
|
timeout: editTimeout.value
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateHook(eventType, index, entry)
|
||||||
|
editingHook.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingHook.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateCommand(cmd: string, max = 60): string {
|
||||||
|
return cmd.length > max ? cmd.slice(0, max) + '...' : cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypeLabels: Record<string, string> = {
|
||||||
|
'UserPromptSubmit': 'User Prompt Submit',
|
||||||
|
'PreToolUse': 'Pre Tool Use',
|
||||||
|
'PostToolUse': 'Post Tool Use',
|
||||||
|
'SessionStart': 'Session Start',
|
||||||
|
'Stop': 'Stop',
|
||||||
|
'Notification': 'Notification',
|
||||||
|
'PermissionRequest': 'Permission Request'
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypeColors: Record<string, string> = {
|
||||||
|
'UserPromptSubmit': '#3b82f6',
|
||||||
|
'PreToolUse': '#f59e0b',
|
||||||
|
'PostToolUse': '#22c55e',
|
||||||
|
'SessionStart': '#6366f1',
|
||||||
|
'Stop': '#ef4444',
|
||||||
|
'Notification': '#8b5cf6',
|
||||||
|
'PermissionRequest': '#06b6d4'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="hooks-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="hooks-header">
|
||||||
|
<div class="hooks-header-left">
|
||||||
|
<h3>Hooks</h3>
|
||||||
|
<span class="hooks-count">{{ totalHookCount() }} hooks</span>
|
||||||
|
<span v-if="store.configFile" class="hooks-path">{{ store.configFile }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hooks-header-right">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="store.saving"
|
||||||
|
@click="store.saveHooks()"
|
||||||
|
>{{ store.saving ? 'Saving...' : 'Save Hooks' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.hooksLoading" class="hooks-loading">Loading hooks...</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="hooks-error">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Hook sections by event type -->
|
||||||
|
<div v-if="!store.hooksLoading" class="hooks-scroll">
|
||||||
|
<div v-for="eventType in HOOK_EVENT_TYPES" :key="eventType" class="hook-section">
|
||||||
|
<button
|
||||||
|
class="hook-section-header"
|
||||||
|
@click="store.toggleHookType(eventType)"
|
||||||
|
>
|
||||||
|
<span class="hook-dot" :style="{ background: eventTypeColors[eventType] }"></span>
|
||||||
|
<span class="hook-type-label">{{ eventTypeLabels[eventType] || eventType }}</span>
|
||||||
|
<span
|
||||||
|
v-if="hookCount(eventType)"
|
||||||
|
class="hook-type-count"
|
||||||
|
:style="{ background: eventTypeColors[eventType] + '18', color: eventTypeColors[eventType] }"
|
||||||
|
>{{ hookCount(eventType) }}</span>
|
||||||
|
<svg
|
||||||
|
class="hook-chevron"
|
||||||
|
:class="{ expanded: store.expandedHookTypes.has(eventType) }"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="store.expandedHookTypes.has(eventType)" class="hook-section-body">
|
||||||
|
<!-- Hook entries -->
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in (store.hooksConfig[eventType] || [])"
|
||||||
|
:key="idx"
|
||||||
|
class="hook-entry"
|
||||||
|
>
|
||||||
|
<!-- View mode -->
|
||||||
|
<template v-if="!editingHook || editingHook.eventType !== eventType || editingHook.index !== idx">
|
||||||
|
<div class="hook-entry-info">
|
||||||
|
<div v-if="entry.matcher" class="hook-matcher">
|
||||||
|
<span class="entry-label">Matcher</span>
|
||||||
|
<code>{{ entry.matcher }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-for="(hook, hIdx) in entry.hooks" :key="hIdx" class="hook-command-row">
|
||||||
|
<span class="entry-label">Command</span>
|
||||||
|
<code :title="hook.command">{{ truncateCommand(hook.command) }}</code>
|
||||||
|
<span v-if="hook.timeout" class="hook-timeout">{{ hook.timeout }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hook-entry-actions">
|
||||||
|
<button class="btn-link" @click="startEdit(eventType, idx)">Edit</button>
|
||||||
|
<button class="btn-link danger" @click="store.removeHook(eventType, idx)">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="hook-edit-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Matcher</label>
|
||||||
|
<input type="text" v-model="editMatcher" placeholder=".* (regex or empty)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Command</label>
|
||||||
|
<textarea v-model="editCommand" rows="2" placeholder="bash script.sh"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Timeout (ms)</label>
|
||||||
|
<input type="number" v-model.number="editTimeout" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row-actions">
|
||||||
|
<button class="btn btn-secondary btn-xs" @click="cancelEdit">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-xs" @click="saveEdit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state for this type -->
|
||||||
|
<div v-if="!hookCount(eventType)" class="hook-empty">No hooks configured</div>
|
||||||
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<button class="hook-add-btn" @click="store.addHook(eventType)">
|
||||||
|
+ Add hook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hooks-manager {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-header-left h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-path {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-error {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hooks-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hook sections */
|
||||||
|
.hook-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-section-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-section-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-type-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-type-count {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.4375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-chevron.expanded {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-section-body {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hook entries */
|
||||||
|
.hook-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-entry + .hook-entry {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-entry-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-matcher,
|
||||||
|
.hook-command-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-label {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 55px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-entry code {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-timeout {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-entry-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit form */
|
||||||
|
.hook-edit-form {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.1875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input,
|
||||||
|
.form-row textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.3125rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input:focus,
|
||||||
|
.form-row textarea:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-empty {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-add-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-add-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.3125rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
560
frontend/src/components/agents/McpManager.vue
Normal file
560
frontend/src/components/agents/McpManager.vue
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAgentsStore } from '../../stores/agents'
|
||||||
|
import type { McpServerEntry } from '../../stores/agents'
|
||||||
|
|
||||||
|
const store = useAgentsStore()
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const editingServer = ref<string | null>(null)
|
||||||
|
|
||||||
|
const newServer = ref<McpServerEntry>({
|
||||||
|
name: '',
|
||||||
|
type: 'stdio',
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
url: '',
|
||||||
|
env: {},
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const argsText = ref('')
|
||||||
|
const envText = ref('')
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
newServer.value = { name: '', type: 'stdio', command: '', args: [], url: '', env: {}, enabled: true }
|
||||||
|
argsText.value = ''
|
||||||
|
envText.value = ''
|
||||||
|
editingServer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
resetForm()
|
||||||
|
showAddModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(server: McpServerEntry) {
|
||||||
|
newServer.value = { ...server, args: [...(server.args || [])], env: { ...(server.env || {}) } }
|
||||||
|
argsText.value = (server.args || []).join('\n')
|
||||||
|
envText.value = Object.entries(server.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||||
|
editingServer.value = server.name
|
||||||
|
showAddModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveServer() {
|
||||||
|
if (!newServer.value.name.trim()) return
|
||||||
|
|
||||||
|
// Parse args from text
|
||||||
|
newServer.value.args = argsText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
// Parse env from text
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
for (const line of envText.value.split('\n')) {
|
||||||
|
const eq = line.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newServer.value.env = env
|
||||||
|
|
||||||
|
if (editingServer.value) {
|
||||||
|
// Update existing
|
||||||
|
const idx = store.mcpServers.findIndex(s => s.name === editingServer.value)
|
||||||
|
if (idx >= 0) {
|
||||||
|
store.mcpServers[idx] = { ...newServer.value }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.addMcpServer({ ...newServer.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddModal.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteServer(name: string) {
|
||||||
|
store.removeMcpServer(name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mcp-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mcp-header">
|
||||||
|
<div class="mcp-header-left">
|
||||||
|
<h3>MCP Servers</h3>
|
||||||
|
<span class="mcp-count">{{ store.mcpServers.length }} servers</span>
|
||||||
|
<span class="mcp-path">.mcp.json</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-header-right">
|
||||||
|
<button class="btn btn-secondary" @click="openAddModal">+ Add Server</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="store.saving"
|
||||||
|
@click="store.saveMcpServers()"
|
||||||
|
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.mcpsLoading" class="mcp-loading">Loading MCP servers...</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="mcp-error">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Server cards -->
|
||||||
|
<div v-if="!store.mcpsLoading" class="mcp-scroll">
|
||||||
|
<div class="server-grid">
|
||||||
|
<div v-for="server in store.mcpServers" :key="server.name" class="server-card">
|
||||||
|
<div class="server-card-header">
|
||||||
|
<div class="server-info">
|
||||||
|
<span class="server-name">{{ server.name }}</span>
|
||||||
|
<span class="server-type-badge" :class="server.type">{{ server.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="server-actions">
|
||||||
|
<button
|
||||||
|
class="toggle-btn"
|
||||||
|
:class="{ enabled: server.enabled }"
|
||||||
|
@click="store.toggleMcpServer(server.name)"
|
||||||
|
:title="server.enabled ? 'Disable' : 'Enable'"
|
||||||
|
>
|
||||||
|
<span class="toggle-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-card-body">
|
||||||
|
<div v-if="server.command" class="server-detail">
|
||||||
|
<span class="detail-label">Command</span>
|
||||||
|
<code>{{ server.command }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="server.args?.length" class="server-detail">
|
||||||
|
<span class="detail-label">Args</span>
|
||||||
|
<code>{{ server.args.join(' ') }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="server.url" class="server-detail">
|
||||||
|
<span class="detail-label">URL</span>
|
||||||
|
<code>{{ server.url }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="server.env && Object.keys(server.env).length" class="server-detail">
|
||||||
|
<span class="detail-label">Env</span>
|
||||||
|
<span class="env-count">{{ Object.keys(server.env).length }} vars</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-card-footer">
|
||||||
|
<button class="btn-link" @click="openEditModal(server)">Edit</button>
|
||||||
|
<button class="btn-link danger" @click="deleteServer(server.name)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!store.mcpServers.length" class="mcp-empty">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
<p>No MCP servers configured</p>
|
||||||
|
<span>Add a server to get started</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>{{ editingServer ? 'Edit Server' : 'Add MCP Server' }}</h4>
|
||||||
|
<button class="modal-close" @click="showAddModal = false">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" v-model="newServer.name" placeholder="my-server" :disabled="!!editingServer" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<select v-model="newServer.type">
|
||||||
|
<option value="stdio">stdio</option>
|
||||||
|
<option value="sse">sse</option>
|
||||||
|
<option value="streamable-http">streamable-http</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||||
|
<label>Command</label>
|
||||||
|
<input type="text" v-model="newServer.command" placeholder="npx" />
|
||||||
|
</div>
|
||||||
|
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||||
|
<label>Args (one per line)</label>
|
||||||
|
<textarea v-model="argsText" placeholder="arg1 arg2" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="newServer.type !== 'stdio'" class="form-group">
|
||||||
|
<label>URL</label>
|
||||||
|
<input type="text" v-model="newServer.url" placeholder="http://localhost:3000" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Environment (KEY=value, one per line)</label>
|
||||||
|
<textarea v-model="envText" placeholder="API_KEY=xxx" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="saveServer">{{ editingServer ? 'Update' : 'Add' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mcp-manager {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-header-left h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-path {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-loading,
|
||||||
|
.mcp-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-empty span {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-error {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server grid */
|
||||||
|
.server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-type-badge {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-type-badge.stdio { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||||
|
.server-type-badge.sse { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
|
||||||
|
.server-type-badge.streamable-http { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.enabled {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.enabled .toggle-dot {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-body {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.1875rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-detail code {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 480px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.3125rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
212
frontend/src/components/agents/PluginsManager.vue
Normal file
212
frontend/src/components/agents/PluginsManager.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAgentsStore } from '../../stores/agents'
|
||||||
|
|
||||||
|
const store = useAgentsStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="plugins-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="plugins-header">
|
||||||
|
<div class="plugins-header-left">
|
||||||
|
<h3>Global Plugins</h3>
|
||||||
|
<span class="plugins-count">{{ store.plugins.length }} plugins</span>
|
||||||
|
<span class="plugins-path">~/.claude/plugins/marketplaces/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.pluginsLoading" class="plugins-loading">Loading plugins...</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="plugins-error">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Plugin cards -->
|
||||||
|
<div v-if="!store.pluginsLoading" class="plugins-scroll">
|
||||||
|
<div class="plugin-grid">
|
||||||
|
<div v-for="plugin in store.plugins" :key="plugin.name" class="plugin-card">
|
||||||
|
<div class="plugin-card-header">
|
||||||
|
<div class="plugin-info">
|
||||||
|
<span class="plugin-name">{{ plugin.name }}</span>
|
||||||
|
<span class="plugin-installed" v-if="plugin.installed">Installed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-card-body">
|
||||||
|
<p v-if="plugin.description" class="plugin-desc">{{ plugin.description }}</p>
|
||||||
|
<div v-if="plugin.author" class="plugin-meta">
|
||||||
|
<span class="meta-label">Author</span>
|
||||||
|
<span class="meta-value">{{ plugin.author }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="plugin.mcpConfig" class="plugin-meta">
|
||||||
|
<span class="meta-label">MCP</span>
|
||||||
|
<span class="meta-value">{{ typeof plugin.mcpConfig === 'object' ? JSON.stringify(plugin.mcpConfig).slice(0, 80) : plugin.mcpConfig }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!store.plugins.length" class="plugins-empty">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<p>No plugins installed</p>
|
||||||
|
<span>Plugins from ~/.claude/plugins/marketplaces/ will appear here</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugins-manager {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header-left h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-path {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-loading,
|
||||||
|
.plugins-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-empty span {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-error {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-installed {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #22c55e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-body {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-desc {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
316
frontend/src/components/agents/SkillsManager.vue
Normal file
316
frontend/src/components/agents/SkillsManager.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAgentsStore } from '../../stores/agents'
|
||||||
|
import type { SkillEntry } from '../../stores/agents'
|
||||||
|
|
||||||
|
const store = useAgentsStore()
|
||||||
|
|
||||||
|
function selectSkill(skill: SkillEntry) {
|
||||||
|
store.selectedSkill = skill
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="skills-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="skills-header">
|
||||||
|
<h3>Skills</h3>
|
||||||
|
<span class="skills-count">{{ store.skills.length }} skills</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.skillsLoading" class="skills-loading">Loading skills...</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="skills-error">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Two-column layout -->
|
||||||
|
<div v-if="!store.skillsLoading" class="skills-layout">
|
||||||
|
<!-- Left: skills list -->
|
||||||
|
<aside class="skills-list">
|
||||||
|
<button
|
||||||
|
v-for="skill in store.skills"
|
||||||
|
:key="skill.name"
|
||||||
|
class="skill-item"
|
||||||
|
:class="{ active: store.selectedSkill?.name === skill.name }"
|
||||||
|
@click="selectSkill(skill)"
|
||||||
|
>
|
||||||
|
<div class="skill-item-main">
|
||||||
|
<span class="skill-name">{{ skill.name }}</span>
|
||||||
|
<span v-if="skill.references.length" class="skill-refs">{{ skill.references.length }} files</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="skill.description" class="skill-desc">{{ skill.description }}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!store.skills.length" class="skills-empty-list">
|
||||||
|
<p>No skills found</p>
|
||||||
|
<span>Add a SKILL.md to .claude/skills/*/</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right: skill detail -->
|
||||||
|
<main class="skill-detail">
|
||||||
|
<template v-if="store.selectedSkill">
|
||||||
|
<div class="skill-detail-header">
|
||||||
|
<h4>{{ store.selectedSkill.name }}</h4>
|
||||||
|
<span class="skill-detail-path" :title="store.selectedSkill.path">{{ store.selectedSkill.path }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SKILL.md content -->
|
||||||
|
<div class="skill-md-content">
|
||||||
|
<pre>{{ store.selectedSkill.skillMdContent }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<div v-if="store.selectedSkill.references.length" class="skill-references">
|
||||||
|
<h5>Referenced Files</h5>
|
||||||
|
<div class="ref-list">
|
||||||
|
<div v-for="ref in store.selectedSkill.references" :key="ref.name" class="ref-item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ ref.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="skill-detail-empty">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
<p>Select a skill to view details</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.skills-manager {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-error {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-column layout */
|
||||||
|
.skills-layout {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-list {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-item {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-refs {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-desc {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-empty-list {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-empty-list p {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-empty-list span {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skill detail */
|
||||||
|
.skill-detail {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-detail-header {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-detail-header h4 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-detail-path {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-md-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-md-content pre {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-references {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-references h5 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-detail-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-detail-empty p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
484
frontend/src/components/agents/ToolsManager.vue
Normal file
484
frontend/src/components/agents/ToolsManager.vue
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAgentsStore } from '../../stores/agents'
|
||||||
|
import type { ToolEntry } from '../../stores/agents'
|
||||||
|
|
||||||
|
const store = useAgentsStore()
|
||||||
|
const newRuleFor = ref<string | null>(null)
|
||||||
|
const newRuleValue = ref('')
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
if (status === 'allow') return '#22c55e'
|
||||||
|
if (status === 'deny') return '#ef4444'
|
||||||
|
return 'var(--text-muted)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === 'allow') return 'Allow'
|
||||||
|
if (status === 'deny') return 'Deny'
|
||||||
|
return 'Ask'
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRule(toolKey: string) {
|
||||||
|
if (newRuleValue.value.trim()) {
|
||||||
|
store.addToolRule(toolKey, newRuleValue.value.trim())
|
||||||
|
newRuleValue.value = ''
|
||||||
|
newRuleFor.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParameterizable(name: string): boolean {
|
||||||
|
return ['Bash', 'WebFetch', 'Skill', 'WebSearch'].includes(name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tools-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="tools-header">
|
||||||
|
<div class="tools-header-left">
|
||||||
|
<h3>Tool Permissions</h3>
|
||||||
|
<span class="tools-count">{{ store.filteredTools.length }} tools</span>
|
||||||
|
<span v-if="store.configFile" class="config-path">{{ store.configFile }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tools-header-right">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="tools-search"
|
||||||
|
placeholder="Filter tools..."
|
||||||
|
v-model="store.toolsFilter"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!store.configDirty || store.saving"
|
||||||
|
@click="store.savePermissions()"
|
||||||
|
>{{ store.saving ? 'Saving...' : 'Save Permissions' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.toolsLoading" class="tools-loading">Loading tools...</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="tools-error">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Tools list -->
|
||||||
|
<div v-if="!store.toolsLoading" class="tools-scroll">
|
||||||
|
<!-- Base tools -->
|
||||||
|
<div v-if="store.toolsByCategory.base.length" class="tool-group">
|
||||||
|
<div class="tool-group-header">
|
||||||
|
<span class="tool-group-label">Base Tools</span>
|
||||||
|
<span class="tool-group-count">{{ store.toolsByCategory.base.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-cards">
|
||||||
|
<div v-for="tool in store.toolsByCategory.base" :key="tool.fullKey" class="tool-card">
|
||||||
|
<div class="tool-card-main">
|
||||||
|
<div class="tool-info">
|
||||||
|
<span class="tool-name">{{ tool.name }}</span>
|
||||||
|
<span class="tool-badge base">base</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="status-toggle"
|
||||||
|
:style="{ '--status-color': statusColor(tool.status) }"
|
||||||
|
@click="store.cycleToolStatus(tool.fullKey)"
|
||||||
|
:title="`Click to cycle: ${tool.status}`"
|
||||||
|
>
|
||||||
|
<span class="status-dot" :class="tool.status"></span>
|
||||||
|
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules for parameterizable tools -->
|
||||||
|
<div v-if="isParameterizable(tool.name) && tool.status !== 'ask'" class="tool-rules">
|
||||||
|
<div v-for="(rule, idx) in tool.rules" :key="idx" class="rule-item">
|
||||||
|
<code>{{ tool.name }}({{ rule }})</code>
|
||||||
|
<button class="rule-remove" @click="store.removeToolRule(tool.fullKey, rule)">×</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="newRuleFor === tool.fullKey" class="rule-add-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="rule-input"
|
||||||
|
v-model="newRuleValue"
|
||||||
|
placeholder="e.g. git:* or domain:example.com"
|
||||||
|
@keydown.enter="submitRule(tool.fullKey)"
|
||||||
|
@keydown.escape="newRuleFor = null"
|
||||||
|
/>
|
||||||
|
<button class="btn-sm" @click="submitRule(tool.fullKey)">Add</button>
|
||||||
|
</div>
|
||||||
|
<button v-else class="rule-add-btn" @click="newRuleFor = tool.fullKey; newRuleValue = ''">
|
||||||
|
+ Add rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MCP tools grouped by server -->
|
||||||
|
<div
|
||||||
|
v-for="(tools, server) in store.toolsByCategory.mcpGroups"
|
||||||
|
:key="server"
|
||||||
|
class="tool-group"
|
||||||
|
>
|
||||||
|
<div class="tool-group-header">
|
||||||
|
<span class="tool-group-label">MCP: {{ server }}</span>
|
||||||
|
<span class="tool-group-count">{{ tools.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-cards">
|
||||||
|
<div v-for="tool in tools" :key="tool.fullKey" class="tool-card">
|
||||||
|
<div class="tool-card-main">
|
||||||
|
<div class="tool-info">
|
||||||
|
<span class="tool-name">{{ tool.name }}</span>
|
||||||
|
<span class="tool-badge mcp">mcp</span>
|
||||||
|
<span v-if="tool.host" class="tool-host">{{ tool.host }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="status-toggle"
|
||||||
|
:style="{ '--status-color': statusColor(tool.status) }"
|
||||||
|
@click="store.cycleToolStatus(tool.fullKey)"
|
||||||
|
:title="`Click to cycle: ${tool.status}`"
|
||||||
|
>
|
||||||
|
<span class="status-dot" :class="tool.status"></span>
|
||||||
|
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!store.toolsByCategory.base.length && !Object.keys(store.toolsByCategory.mcpGroups).length" class="tools-empty">
|
||||||
|
<p>No tools found</p>
|
||||||
|
<span>Tool permissions will appear here when configured</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tools-manager {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-header-left h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-path {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-search {
|
||||||
|
padding: 0.3125rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-search:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-loading,
|
||||||
|
.tools-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-empty span {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-error {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool groups */
|
||||||
|
.tool-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group-count {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool cards */
|
||||||
|
.tool-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-badge {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-badge.base {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-badge.mcp {
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-host {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status toggle */
|
||||||
|
.status-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.allow { background: #22c55e; }
|
||||||
|
.status-dot.deny { background: #ef4444; }
|
||||||
|
.status-dot.ask { background: var(--text-muted); }
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules */
|
||||||
|
.tool-rules {
|
||||||
|
padding: 0.375rem 0.75rem 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.1875rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item code {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-remove:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-add-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-add-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-add-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.3125rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useAgentsStore, CATEGORY_META, formatSize } from '../stores/agents'
|
import { useAgentsStore, CATEGORY_META, formatSize } from '../stores/agents'
|
||||||
|
import type { AgentTab } from '../stores/agents'
|
||||||
|
import ToolsManager from '../components/agents/ToolsManager.vue'
|
||||||
|
import McpManager from '../components/agents/McpManager.vue'
|
||||||
|
import PluginsManager from '../components/agents/PluginsManager.vue'
|
||||||
|
import HooksManager from '../components/agents/HooksManager.vue'
|
||||||
|
import SkillsManager from '../components/agents/SkillsManager.vue'
|
||||||
|
|
||||||
const store = useAgentsStore()
|
const store = useAgentsStore()
|
||||||
|
|
||||||
|
const tabs: { key: AgentTab; label: string; icon: string }[] = [
|
||||||
|
{ key: 'files', label: 'Files', icon: 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z|M14 2v6h6' },
|
||||||
|
{ key: 'tools', label: 'Tools', icon: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z' },
|
||||||
|
{ key: 'mcps', label: 'MCPs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
|
||||||
|
{ key: 'plugins', label: 'Plugins', icon: 'M12 2L2 7l10 5 10-5-10-5z|M2 17l10 5 10-5|M2 12l10 5 10-5' },
|
||||||
|
{ key: 'hooks', label: 'Hooks', icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71|M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
|
||||||
|
{ key: 'skills', label: 'Skills', icon: 'M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z|M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z' }
|
||||||
|
]
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (store.isDirty) store.saveFile()
|
if (store.activeTab === 'files' && store.isDirty) store.saveFile()
|
||||||
|
else if (store.activeTab === 'tools' && store.configDirty) store.savePermissions()
|
||||||
|
else if (store.activeTab === 'hooks') store.saveHooks()
|
||||||
|
else if (store.activeTab === 'mcps') store.saveMcpServers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab: AgentTab) {
|
||||||
|
store.activeTab = tab
|
||||||
|
if (tab !== 'files') {
|
||||||
|
store.loadTabData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortPath(path: string): string {
|
function shortPath(path: string): string {
|
||||||
// Remove the agent directory prefix to show just the meaningful part
|
|
||||||
const parts = path.split('/')
|
const parts = path.split('/')
|
||||||
if (parts.length <= 2) return parts[parts.length - 1]
|
if (parts.length <= 2) return parts[parts.length - 1]
|
||||||
return parts.slice(1).join('/')
|
return parts.slice(1).join('/')
|
||||||
@@ -30,9 +54,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="agents-page">
|
<div class="agents-page">
|
||||||
<!-- Sidebar -->
|
<!-- Top bar: agent selector + main tabs -->
|
||||||
<aside class="agents-sidebar">
|
<div class="agents-topbar">
|
||||||
<!-- Agent tabs -->
|
|
||||||
<div class="agent-tabs">
|
<div class="agent-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="agent in store.agents"
|
v-for="agent in store.agents"
|
||||||
@@ -46,124 +69,154 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<nav class="main-tabs">
|
||||||
<div v-if="store.loading" class="sidebar-loading">Loading agents...</div>
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="main-tab"
|
||||||
|
:class="{ active: store.activeTab === tab.key }"
|
||||||
|
@click="switchTab(tab.key)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path v-for="(d, i) in tab.icon.split('|')" :key="i" :d="d"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ tab.label }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Category groups -->
|
<!-- Files tab: sidebar + editor -->
|
||||||
<div v-else-if="store.selectedAgent" class="category-list">
|
<div v-if="store.activeTab === 'files'" class="files-layout">
|
||||||
<div v-for="cat in store.groupedFiles" :key="cat.key" class="category-group">
|
<aside class="agents-sidebar">
|
||||||
<button class="category-header" @click="store.toggleCategory(cat.key)">
|
<!-- Loading -->
|
||||||
<svg
|
<div v-if="store.loading" class="sidebar-loading">Loading agents...</div>
|
||||||
class="category-icon"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
:style="{ color: cat.color }"
|
|
||||||
>
|
|
||||||
<path v-for="(d, i) in cat.icon.split('|')" :key="i" :d="d"/>
|
|
||||||
</svg>
|
|
||||||
<span class="category-label">{{ cat.label }}</span>
|
|
||||||
<span class="category-count" :style="{ background: cat.color + '18', color: cat.color }">{{ cat.files.length }}</span>
|
|
||||||
<svg
|
|
||||||
class="category-chevron"
|
|
||||||
:class="{ collapsed: store.collapsedCategories.has(cat.key) }"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="!store.collapsedCategories.has(cat.key)" class="category-files">
|
<!-- Category groups -->
|
||||||
<button
|
<div v-else-if="store.selectedAgent" class="category-list">
|
||||||
v-for="file in cat.files"
|
<div v-for="cat in store.groupedFiles" :key="cat.key" class="category-group">
|
||||||
:key="file.path"
|
<button class="category-header" @click="store.toggleCategory(cat.key)">
|
||||||
class="file-row"
|
<svg
|
||||||
:class="{ active: store.openFile?.path === file.path }"
|
class="category-icon"
|
||||||
@click="store.loadFile(store.selectedAgentId!, file)"
|
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
:title="file.path"
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
>
|
:style="{ color: cat.color }"
|
||||||
<span class="file-type-dot" :style="{ background: cat.color }"></span>
|
>
|
||||||
<span class="file-label">{{ shortPath(file.path) }}</span>
|
<path v-for="(d, i) in cat.icon.split('|')" :key="i" :d="d"/>
|
||||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
</svg>
|
||||||
<span v-if="store.openFile?.path === file.path && store.isDirty" class="unsaved-dot"></span>
|
<span class="category-label">{{ cat.label }}</span>
|
||||||
|
<span class="category-count" :style="{ background: cat.color + '18', color: cat.color }">{{ cat.files.length }}</span>
|
||||||
|
<svg
|
||||||
|
class="category-chevron"
|
||||||
|
:class="{ collapsed: store.collapsedCategories.has(cat.key) }"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!store.collapsedCategories.has(cat.key)" class="category-files">
|
||||||
|
<button
|
||||||
|
v-for="file in cat.files"
|
||||||
|
:key="file.path"
|
||||||
|
class="file-row"
|
||||||
|
:class="{ active: store.openFile?.path === file.path }"
|
||||||
|
@click="store.loadFile(store.selectedAgentId!, file)"
|
||||||
|
:title="file.path"
|
||||||
|
>
|
||||||
|
<span class="file-type-dot" :style="{ background: cat.color }"></span>
|
||||||
|
<span class="file-label">{{ shortPath(file.path) }}</span>
|
||||||
|
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||||
|
<span v-if="store.openFile?.path === file.path && store.isDirty" class="unsaved-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!store.groupedFiles.length" class="sidebar-empty">No files found</div>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div v-if="!store.groupedFiles.length" class="sidebar-empty">No files found</div>
|
<main class="editor-panel">
|
||||||
</div>
|
<template v-if="store.openFile">
|
||||||
</aside>
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">
|
||||||
|
<span
|
||||||
|
class="editor-cat-dot"
|
||||||
|
:style="{ background: CATEGORY_META[store.openFile.category]?.color }"
|
||||||
|
></span>
|
||||||
|
<span class="editor-path">{{ store.openFile.path }}</span>
|
||||||
|
<span v-if="store.isDirty" class="unsaved-badge">Unsaved</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="!store.isDirty"
|
||||||
|
@click="store.revertFile()"
|
||||||
|
>Revert</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!store.isDirty || store.saving"
|
||||||
|
@click="store.saveFile()"
|
||||||
|
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Editor -->
|
<div v-if="store.error" class="editor-error">{{ store.error }}</div>
|
||||||
<main class="editor-panel">
|
|
||||||
<template v-if="store.openFile">
|
<textarea
|
||||||
<div class="editor-header">
|
class="editor-textarea"
|
||||||
<div class="editor-title">
|
v-model="store.openFile.content"
|
||||||
<span
|
spellcheck="false"
|
||||||
class="editor-cat-dot"
|
></textarea>
|
||||||
:style="{ background: CATEGORY_META[store.openFile.category]?.color }"
|
</template>
|
||||||
></span>
|
|
||||||
<span class="editor-path">{{ store.openFile.path }}</span>
|
<div v-else class="editor-empty">
|
||||||
<span v-if="store.isDirty" class="unsaved-badge">Unsaved</span>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</div>
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||||
<div class="editor-actions">
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
<button
|
</svg>
|
||||||
class="btn btn-secondary"
|
<p>Select a file to view or edit</p>
|
||||||
:disabled="!store.isDirty"
|
<span class="editor-hint">Ctrl+S to save</span>
|
||||||
@click="store.revertFile()"
|
|
||||||
>Revert</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!store.isDirty || store.saving"
|
|
||||||
@click="store.saveFile()"
|
|
||||||
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="store.error" class="editor-error">{{ store.error }}</div>
|
<!-- Other tabs: full-width components -->
|
||||||
|
<div v-if="store.activeTab !== 'files'" class="tab-content">
|
||||||
<textarea
|
<ToolsManager v-if="store.activeTab === 'tools'" />
|
||||||
class="editor-textarea"
|
<McpManager v-if="store.activeTab === 'mcps'" />
|
||||||
v-model="store.openFile.content"
|
<PluginsManager v-if="store.activeTab === 'plugins'" />
|
||||||
spellcheck="false"
|
<HooksManager v-if="store.activeTab === 'hooks'" />
|
||||||
></textarea>
|
<SkillsManager v-if="store.activeTab === 'skills'" />
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<div v-else class="editor-empty">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
</svg>
|
|
||||||
<p>Select a file to view or edit</p>
|
|
||||||
<span class="editor-hint">Ctrl+S to save</span>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.agents-page {
|
.agents-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ── */
|
/* ── Top bar ── */
|
||||||
.agents-sidebar {
|
.agents-topbar {
|
||||||
width: 320px;
|
|
||||||
min-width: 320px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
overflow: hidden;
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Agent tabs */
|
|
||||||
.agent-tabs {
|
.agent-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 0.75rem 0.75rem 0;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -172,11 +225,10 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.375rem 0.625rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-bottom: none;
|
border-radius: 6px;
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -216,12 +268,69 @@ onUnmounted(() => {
|
|||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Main tabs */
|
||||||
|
.main-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tab.active {
|
||||||
|
color: #6366f1;
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
border-color: rgba(99, 102, 241, 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tab svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Files layout ── */
|
||||||
|
.files-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Category list */
|
/* Category list */
|
||||||
.category-list {
|
.category-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-group {
|
.category-group {
|
||||||
@@ -485,4 +594,11 @@ onUnmounted(() => {
|
|||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tab content ── */
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
// ── Existing types ──
|
||||||
|
|
||||||
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
||||||
|
|
||||||
interface AgentFile {
|
interface AgentFile {
|
||||||
@@ -31,11 +33,80 @@ interface OpenFile {
|
|||||||
export interface CategoryInfo {
|
export interface CategoryInfo {
|
||||||
key: FileCategory
|
key: FileCategory
|
||||||
label: string
|
label: string
|
||||||
icon: string // SVG path
|
icon: string
|
||||||
color: string
|
color: string
|
||||||
files: AgentFile[]
|
files: AgentFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New types for config management ──
|
||||||
|
|
||||||
|
type PermissionStatus = 'allow' | 'deny' | 'ask'
|
||||||
|
type AgentTab = 'files' | 'tools' | 'mcps' | 'plugins' | 'hooks' | 'skills'
|
||||||
|
type HookEventType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SessionStart' | 'Stop' | 'Notification' | 'PermissionRequest'
|
||||||
|
|
||||||
|
interface ParsedPermission {
|
||||||
|
raw: string
|
||||||
|
tool: string
|
||||||
|
params: string | null
|
||||||
|
category: 'base' | 'mcp'
|
||||||
|
server?: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolEntry {
|
||||||
|
name: string
|
||||||
|
fullKey: string
|
||||||
|
category: 'base' | 'mcp'
|
||||||
|
server?: string
|
||||||
|
host?: string
|
||||||
|
status: PermissionStatus
|
||||||
|
rules: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpServerEntry {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
url?: string
|
||||||
|
env?: Record<string, string>
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookCommand {
|
||||||
|
type: string
|
||||||
|
command: string
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookEntry {
|
||||||
|
matcher: string | null
|
||||||
|
hooks: HookCommand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillEntry {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
path: string
|
||||||
|
skillMdContent: string
|
||||||
|
references: { name: string; path: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginEntry {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
mcpConfig: any
|
||||||
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const HOOK_EVENT_TYPES: HookEventType[] = [
|
||||||
|
'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
||||||
|
'SessionStart', 'Stop', 'Notification', 'PermissionRequest'
|
||||||
|
]
|
||||||
|
|
||||||
const CATEGORY_META: Record<FileCategory, { label: string; icon: string; color: string; order: number }> = {
|
const CATEGORY_META: Record<FileCategory, { label: string; icon: string; color: string; order: number }> = {
|
||||||
config: { label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0-6 0', color: '#6366f1', order: 0 },
|
config: { label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0-6 0', color: '#6366f1', order: 0 },
|
||||||
instructions: { label: 'Instructions', icon: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20|M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z', color: '#3b82f6', order: 1 },
|
instructions: { label: 'Instructions', icon: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20|M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z', color: '#3b82f6', order: 1 },
|
||||||
@@ -48,8 +119,8 @@ const CATEGORY_META: Record<FileCategory, { label: string; icon: string; color:
|
|||||||
other: { label: 'Other files', icon: 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z|M14 2v6h6', color: '#78716c', order: 8 }
|
other: { label: 'Other files', icon: 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z|M14 2v6h6', color: '#78716c', order: 8 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CATEGORY_META }
|
export { CATEGORY_META, HOOK_EVENT_TYPES }
|
||||||
export type { FileCategory, AgentFile, Agent }
|
export type { FileCategory, AgentFile, Agent, AgentTab, HookEventType, ToolEntry, McpServerEntry, HookEntry, HookCommand, SkillEntry, PluginEntry, ParsedPermission, PermissionStatus }
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -60,6 +131,7 @@ function formatSize(bytes: number): string {
|
|||||||
export { formatSize }
|
export { formatSize }
|
||||||
|
|
||||||
export const useAgentsStore = defineStore('agents', () => {
|
export const useAgentsStore = defineStore('agents', () => {
|
||||||
|
// ── Existing state ──
|
||||||
const agents = ref<Agent[]>([])
|
const agents = ref<Agent[]>([])
|
||||||
const selectedAgentId = ref<string | null>(null)
|
const selectedAgentId = ref<string | null>(null)
|
||||||
const openFile = ref<OpenFile | null>(null)
|
const openFile = ref<OpenFile | null>(null)
|
||||||
@@ -68,6 +140,39 @@ export const useAgentsStore = defineStore('agents', () => {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const collapsedCategories = ref<Set<string>>(new Set())
|
const collapsedCategories = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// ── New state: tab ──
|
||||||
|
const activeTab = ref<AgentTab>('files')
|
||||||
|
|
||||||
|
// ── New state: tools ──
|
||||||
|
const toolEntries = ref<ToolEntry[]>([])
|
||||||
|
const toolsFilter = ref('')
|
||||||
|
const toolsLoading = ref(false)
|
||||||
|
|
||||||
|
// ── New state: MCP servers ──
|
||||||
|
const mcpServers = ref<McpServerEntry[]>([])
|
||||||
|
const mcpsLoading = ref(false)
|
||||||
|
const enableAllProjectMcpServers = ref(false)
|
||||||
|
const enabledMcpjsonServers = ref<string[]>([])
|
||||||
|
|
||||||
|
// ── New state: hooks ──
|
||||||
|
const hooksConfig = ref<Record<string, HookEntry[]>>({})
|
||||||
|
const hooksLoading = ref(false)
|
||||||
|
const expandedHookTypes = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// ── New state: skills ──
|
||||||
|
const skills = ref<SkillEntry[]>([])
|
||||||
|
const skillsLoading = ref(false)
|
||||||
|
const selectedSkill = ref<SkillEntry | null>(null)
|
||||||
|
|
||||||
|
// ── New state: plugins ──
|
||||||
|
const plugins = ref<PluginEntry[]>([])
|
||||||
|
const pluginsLoading = ref(false)
|
||||||
|
|
||||||
|
// ── New state: config metadata ──
|
||||||
|
const configFile = ref('')
|
||||||
|
const configDirty = ref(false)
|
||||||
|
|
||||||
|
// ── Existing computed ──
|
||||||
const isDirty = computed(() => {
|
const isDirty = computed(() => {
|
||||||
if (!openFile.value) return false
|
if (!openFile.value) return false
|
||||||
return openFile.value.content !== openFile.value.originalContent
|
return openFile.value.content !== openFile.value.originalContent
|
||||||
@@ -100,6 +205,35 @@ export const useAgentsStore = defineStore('agents', () => {
|
|||||||
.sort((a, b) => CATEGORY_META[a.key].order - CATEGORY_META[b.key].order)
|
.sort((a, b) => CATEGORY_META[a.key].order - CATEGORY_META[b.key].order)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── New computed ──
|
||||||
|
const filteredTools = computed(() => {
|
||||||
|
if (!toolsFilter.value) return toolEntries.value
|
||||||
|
const q = toolsFilter.value.toLowerCase()
|
||||||
|
return toolEntries.value.filter(t =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.fullKey.toLowerCase().includes(q) ||
|
||||||
|
(t.server && t.server.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolsByCategory = computed(() => {
|
||||||
|
const base: ToolEntry[] = []
|
||||||
|
const mcpGroups: Record<string, ToolEntry[]> = {}
|
||||||
|
|
||||||
|
for (const tool of filteredTools.value) {
|
||||||
|
if (tool.category === 'base') {
|
||||||
|
base.push(tool)
|
||||||
|
} else {
|
||||||
|
const key = tool.server || 'unknown'
|
||||||
|
if (!mcpGroups[key]) mcpGroups[key] = []
|
||||||
|
mcpGroups[key].push(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { base, mcpGroups }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Existing actions ──
|
||||||
async function fetchAgents() {
|
async function fetchAgents() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -120,6 +254,10 @@ export const useAgentsStore = defineStore('agents', () => {
|
|||||||
function selectAgent(id: string) {
|
function selectAgent(id: string) {
|
||||||
selectedAgentId.value = id
|
selectedAgentId.value = id
|
||||||
openFile.value = null
|
openFile.value = null
|
||||||
|
// Reload config for new agent when on config tabs
|
||||||
|
if (activeTab.value !== 'files') {
|
||||||
|
loadTabData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(agentId: string, file: AgentFile) {
|
async function loadFile(agentId: string, file: AgentFile) {
|
||||||
@@ -183,7 +321,408 @@ export const useAgentsStore = defineStore('agents', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New actions: tab data loading ──
|
||||||
|
|
||||||
|
async function loadTabData() {
|
||||||
|
const tab = activeTab.value
|
||||||
|
if (tab === 'tools') await fetchConfig()
|
||||||
|
else if (tab === 'mcps') await fetchMcpJson()
|
||||||
|
else if (tab === 'hooks') await fetchConfig()
|
||||||
|
else if (tab === 'skills') await fetchSkills()
|
||||||
|
else if (tab === 'plugins') await fetchPlugins()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
toolsLoading.value = true
|
||||||
|
hooksLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const agentId = selectedAgentId.value || 'main'
|
||||||
|
const [configRes, knownRes] = await Promise.all([
|
||||||
|
fetch(`/api/agents/config?agentId=${encodeURIComponent(agentId)}`),
|
||||||
|
fetch('/api/agents/known-tools')
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!configRes.ok) throw new Error('Failed to fetch config')
|
||||||
|
if (!knownRes.ok) throw new Error('Failed to fetch known tools')
|
||||||
|
|
||||||
|
const config = await configRes.json()
|
||||||
|
const known = await knownRes.json()
|
||||||
|
|
||||||
|
configFile.value = config.configFile
|
||||||
|
enableAllProjectMcpServers.value = config.enableAllProjectMcpServers
|
||||||
|
enabledMcpjsonServers.value = config.enabledMcpjsonServers
|
||||||
|
|
||||||
|
// Build tool entries from known tools + permissions
|
||||||
|
buildToolEntries(config, known)
|
||||||
|
|
||||||
|
// Build hooks config
|
||||||
|
hooksConfig.value = config.hooks || {}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
toolsLoading.value = false
|
||||||
|
hooksLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolEntries(config: any, known: any) {
|
||||||
|
const entries: ToolEntry[] = []
|
||||||
|
const allowMap = new Map<string, ParsedPermission>()
|
||||||
|
const denyMap = new Map<string, ParsedPermission>()
|
||||||
|
|
||||||
|
// Index permissions by tool name
|
||||||
|
for (const p of config.permissions.allow) {
|
||||||
|
allowMap.set(p.raw, p)
|
||||||
|
}
|
||||||
|
for (const p of config.permissions.deny) {
|
||||||
|
denyMap.set(p.raw, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base tools
|
||||||
|
for (const toolName of known.baseTools) {
|
||||||
|
// Find all allow/deny entries for this base tool
|
||||||
|
const allowRules: string[] = []
|
||||||
|
const denyRules: string[] = []
|
||||||
|
let hasSimpleAllow = false
|
||||||
|
let hasSimpleDeny = false
|
||||||
|
|
||||||
|
for (const [raw, p] of allowMap) {
|
||||||
|
if (p.tool === toolName) {
|
||||||
|
if (p.params) allowRules.push(p.params)
|
||||||
|
else hasSimpleAllow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [raw, p] of denyMap) {
|
||||||
|
if (p.tool === toolName) {
|
||||||
|
if (p.params) denyRules.push(p.params)
|
||||||
|
else hasSimpleDeny = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: PermissionStatus = 'ask'
|
||||||
|
if (hasSimpleAllow || allowRules.length > 0) status = 'allow'
|
||||||
|
if (hasSimpleDeny || denyRules.length > 0) status = 'deny'
|
||||||
|
// If both exist, allow takes precedence (list first)
|
||||||
|
if (hasSimpleAllow) status = 'allow'
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: toolName,
|
||||||
|
fullKey: toolName,
|
||||||
|
category: 'base',
|
||||||
|
status,
|
||||||
|
rules: allowRules.length ? allowRules : denyRules
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP tools from known discovery
|
||||||
|
for (const [server, hosts] of Object.entries(known.mcpTools as Record<string, Record<string, string[]>>)) {
|
||||||
|
for (const [host, tools] of Object.entries(hosts)) {
|
||||||
|
for (const toolName of tools) {
|
||||||
|
const fullKey = `mcp__${server.replace(/-/g, '_')}__${host}-${toolName}`
|
||||||
|
const isAllowed = allowMap.has(fullKey)
|
||||||
|
const isDenied = denyMap.has(fullKey)
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: toolName,
|
||||||
|
fullKey,
|
||||||
|
category: 'mcp',
|
||||||
|
server: server.replace(/_/g, '-'),
|
||||||
|
host,
|
||||||
|
status: isAllowed ? 'allow' : isDenied ? 'deny' : 'ask',
|
||||||
|
rules: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add any permission entries that weren't in known tools
|
||||||
|
const allKnownKeys = new Set(entries.map(e => e.fullKey))
|
||||||
|
for (const [raw, p] of allowMap) {
|
||||||
|
if (!allKnownKeys.has(raw) && !p.params) {
|
||||||
|
entries.push({
|
||||||
|
name: p.tool,
|
||||||
|
fullKey: raw,
|
||||||
|
category: p.category,
|
||||||
|
server: p.server,
|
||||||
|
host: p.host,
|
||||||
|
status: 'allow',
|
||||||
|
rules: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [raw, p] of denyMap) {
|
||||||
|
if (!allKnownKeys.has(raw) && !p.params) {
|
||||||
|
entries.push({
|
||||||
|
name: p.tool,
|
||||||
|
fullKey: raw,
|
||||||
|
category: p.category,
|
||||||
|
server: p.server,
|
||||||
|
host: p.host,
|
||||||
|
status: 'deny',
|
||||||
|
rules: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolEntries.value = entries
|
||||||
|
configDirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool actions ──
|
||||||
|
|
||||||
|
function setToolStatus(toolName: string, status: PermissionStatus) {
|
||||||
|
const tool = toolEntries.value.find(t => t.fullKey === toolName || t.name === toolName)
|
||||||
|
if (tool) {
|
||||||
|
tool.status = status
|
||||||
|
if (status === 'ask') tool.rules = []
|
||||||
|
configDirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleToolStatus(toolName: string) {
|
||||||
|
const tool = toolEntries.value.find(t => t.fullKey === toolName || t.name === toolName)
|
||||||
|
if (!tool) return
|
||||||
|
const cycle: PermissionStatus[] = ['ask', 'allow', 'deny']
|
||||||
|
const idx = cycle.indexOf(tool.status)
|
||||||
|
tool.status = cycle[(idx + 1) % 3]
|
||||||
|
if (tool.status === 'ask') tool.rules = []
|
||||||
|
configDirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToolRule(toolName: string, rule: string) {
|
||||||
|
const tool = toolEntries.value.find(t => t.fullKey === toolName || t.name === toolName)
|
||||||
|
if (tool && !tool.rules.includes(rule)) {
|
||||||
|
tool.rules.push(rule)
|
||||||
|
configDirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToolRule(toolName: string, rule: string) {
|
||||||
|
const tool = toolEntries.value.find(t => t.fullKey === toolName || t.name === toolName)
|
||||||
|
if (tool) {
|
||||||
|
tool.rules = tool.rules.filter(r => r !== rule)
|
||||||
|
configDirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const allow: string[] = []
|
||||||
|
const deny: string[] = []
|
||||||
|
|
||||||
|
for (const tool of toolEntries.value) {
|
||||||
|
if (tool.status === 'allow') {
|
||||||
|
if (tool.rules.length > 0) {
|
||||||
|
for (const rule of tool.rules) {
|
||||||
|
allow.push(`${tool.name}(${rule})`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allow.push(tool.category === 'mcp' ? tool.fullKey : tool.name)
|
||||||
|
}
|
||||||
|
} else if (tool.status === 'deny') {
|
||||||
|
if (tool.rules.length > 0) {
|
||||||
|
for (const rule of tool.rules) {
|
||||||
|
deny.push(`${tool.name}(${rule})`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deny.push(tool.category === 'mcp' ? tool.fullKey : tool.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/agents/config/permissions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: selectedAgentId.value || 'main',
|
||||||
|
permissions: { allow, deny }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to save permissions')
|
||||||
|
configDirty.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MCP actions ──
|
||||||
|
|
||||||
|
async function fetchMcpJson() {
|
||||||
|
mcpsLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [mcpRes, configRes] = await Promise.all([
|
||||||
|
fetch('/api/agents/mcp-json'),
|
||||||
|
fetch(`/api/agents/config?agentId=${encodeURIComponent(selectedAgentId.value || 'main')}`)
|
||||||
|
])
|
||||||
|
if (!mcpRes.ok) throw new Error('Failed to fetch MCP config')
|
||||||
|
|
||||||
|
const mcpData = await mcpRes.json()
|
||||||
|
const configData = configRes.ok ? await configRes.json() : {}
|
||||||
|
|
||||||
|
enableAllProjectMcpServers.value = configData.enableAllProjectMcpServers ?? false
|
||||||
|
enabledMcpjsonServers.value = configData.enabledMcpjsonServers || []
|
||||||
|
|
||||||
|
const servers: McpServerEntry[] = []
|
||||||
|
const mcpServersObj = mcpData.mcpServers || {}
|
||||||
|
for (const [name, config] of Object.entries(mcpServersObj as Record<string, any>)) {
|
||||||
|
servers.push({
|
||||||
|
name,
|
||||||
|
type: config.type || 'stdio',
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
url: config.url,
|
||||||
|
env: config.env,
|
||||||
|
enabled: enableAllProjectMcpServers.value || enabledMcpjsonServers.value.includes(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mcpServers.value = servers
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
mcpsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMcpServers() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const mcpServersObj: Record<string, any> = {}
|
||||||
|
for (const s of mcpServers.value) {
|
||||||
|
const entry: any = { type: s.type }
|
||||||
|
if (s.command) entry.command = s.command
|
||||||
|
if (s.args?.length) entry.args = s.args
|
||||||
|
if (s.url) entry.url = s.url
|
||||||
|
if (s.env && Object.keys(s.env).length) entry.env = s.env
|
||||||
|
mcpServersObj[s.name] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/agents/config/mcp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mcpServers: mcpServersObj })
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to save MCP config')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMcpServer(server: McpServerEntry) {
|
||||||
|
mcpServers.value.push(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMcpServer(name: string) {
|
||||||
|
mcpServers.value = mcpServers.value.filter(s => s.name !== name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMcpServer(name: string) {
|
||||||
|
const server = mcpServers.value.find(s => s.name === name)
|
||||||
|
if (server) server.enabled = !server.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks actions ──
|
||||||
|
|
||||||
|
async function saveHooks() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agents/config/hooks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: selectedAgentId.value || 'main',
|
||||||
|
hooks: hooksConfig.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to save hooks')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHook(eventType: string) {
|
||||||
|
if (!hooksConfig.value[eventType]) {
|
||||||
|
hooksConfig.value[eventType] = []
|
||||||
|
}
|
||||||
|
hooksConfig.value[eventType].push({
|
||||||
|
matcher: null,
|
||||||
|
hooks: [{ type: 'command', command: '', timeout: 5000 }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHook(eventType: string, index: number) {
|
||||||
|
if (hooksConfig.value[eventType]) {
|
||||||
|
hooksConfig.value[eventType].splice(index, 1)
|
||||||
|
if (hooksConfig.value[eventType].length === 0) {
|
||||||
|
delete hooksConfig.value[eventType]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHook(eventType: string, index: number, entry: HookEntry) {
|
||||||
|
if (hooksConfig.value[eventType]?.[index]) {
|
||||||
|
hooksConfig.value[eventType][index] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHookType(eventType: string) {
|
||||||
|
if (expandedHookTypes.value.has(eventType)) {
|
||||||
|
expandedHookTypes.value.delete(eventType)
|
||||||
|
} else {
|
||||||
|
expandedHookTypes.value.add(eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skills actions ──
|
||||||
|
|
||||||
|
async function fetchSkills() {
|
||||||
|
skillsLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const agentId = selectedAgentId.value || 'main'
|
||||||
|
const res = await fetch(`/api/agents/skills?agentId=${encodeURIComponent(agentId)}`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch skills')
|
||||||
|
skills.value = await res.json()
|
||||||
|
if (skills.value.length && !selectedSkill.value) {
|
||||||
|
selectedSkill.value = skills.value[0]
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
skillsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugins actions ──
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
pluginsLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agents/plugins')
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch plugins')
|
||||||
|
plugins.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
pluginsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Existing
|
||||||
agents,
|
agents,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
@@ -199,6 +738,59 @@ export const useAgentsStore = defineStore('agents', () => {
|
|||||||
loadFile,
|
loadFile,
|
||||||
saveFile,
|
saveFile,
|
||||||
revertFile,
|
revertFile,
|
||||||
toggleCategory
|
toggleCategory,
|
||||||
|
|
||||||
|
// New: tab
|
||||||
|
activeTab,
|
||||||
|
|
||||||
|
// New: tools
|
||||||
|
toolEntries,
|
||||||
|
toolsFilter,
|
||||||
|
toolsLoading,
|
||||||
|
filteredTools,
|
||||||
|
toolsByCategory,
|
||||||
|
setToolStatus,
|
||||||
|
cycleToolStatus,
|
||||||
|
addToolRule,
|
||||||
|
removeToolRule,
|
||||||
|
savePermissions,
|
||||||
|
|
||||||
|
// New: MCP
|
||||||
|
mcpServers,
|
||||||
|
mcpsLoading,
|
||||||
|
enableAllProjectMcpServers,
|
||||||
|
enabledMcpjsonServers,
|
||||||
|
fetchMcpJson,
|
||||||
|
saveMcpServers,
|
||||||
|
addMcpServer,
|
||||||
|
removeMcpServer,
|
||||||
|
toggleMcpServer,
|
||||||
|
|
||||||
|
// New: hooks
|
||||||
|
hooksConfig,
|
||||||
|
hooksLoading,
|
||||||
|
expandedHookTypes,
|
||||||
|
saveHooks,
|
||||||
|
addHook,
|
||||||
|
removeHook,
|
||||||
|
updateHook,
|
||||||
|
toggleHookType,
|
||||||
|
|
||||||
|
// New: skills
|
||||||
|
skills,
|
||||||
|
skillsLoading,
|
||||||
|
selectedSkill,
|
||||||
|
fetchSkills,
|
||||||
|
|
||||||
|
// New: plugins
|
||||||
|
plugins,
|
||||||
|
pluginsLoading,
|
||||||
|
fetchPlugins,
|
||||||
|
|
||||||
|
// New: config metadata
|
||||||
|
configFile,
|
||||||
|
configDirty,
|
||||||
|
fetchConfig,
|
||||||
|
loadTabData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { join, resolve, basename } from 'path'
|
import { join, resolve, basename } from 'path'
|
||||||
|
import { homedir } from 'os'
|
||||||
|
|
||||||
const PROJECT_ROOT = resolve(import.meta.dir, '../..')
|
const PROJECT_ROOT = resolve(import.meta.dir, '../..')
|
||||||
|
|
||||||
@@ -18,6 +19,22 @@ const SKIP_DIRS = ['.git']
|
|||||||
// Sensitive files blocked from read/write via API
|
// Sensitive files blocked from read/write via API
|
||||||
const BLOCKED_FILES = ['.credentials.json']
|
const BLOCKED_FILES = ['.credentials.json']
|
||||||
|
|
||||||
|
// Base tool names known to Claude Code
|
||||||
|
const BASE_TOOLS = [
|
||||||
|
'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob',
|
||||||
|
'WebFetch', 'WebSearch', 'Task', 'NotebookEdit',
|
||||||
|
'Skill', 'EnterPlanMode', 'ExitPlanMode', 'AskUserQuestion',
|
||||||
|
'TodoRead', 'TodoWrite'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Hook event types
|
||||||
|
type HookEventType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SessionStart' | 'Stop' | 'Notification' | 'PermissionRequest'
|
||||||
|
|
||||||
|
const HOOK_EVENT_TYPES: HookEventType[] = [
|
||||||
|
'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
||||||
|
'SessionStart', 'Stop', 'Notification', 'PermissionRequest'
|
||||||
|
]
|
||||||
|
|
||||||
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
||||||
|
|
||||||
interface AgentFile {
|
interface AgentFile {
|
||||||
@@ -46,6 +63,53 @@ interface Agent {
|
|||||||
uiConfig: UiConfig | null
|
uiConfig: UiConfig | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedPermission {
|
||||||
|
raw: string
|
||||||
|
tool: string
|
||||||
|
params: string | null
|
||||||
|
category: 'base' | 'mcp'
|
||||||
|
server?: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission string parser ──
|
||||||
|
|
||||||
|
function parsePermission(raw: string): ParsedPermission {
|
||||||
|
// MCP tools: mcp__<server>__<host>-<toolName>
|
||||||
|
const mcpMatch = raw.match(/^mcp__([^_]+(?:_[^_]+)?)__([^-]+)-(.+)$/)
|
||||||
|
if (mcpMatch) {
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
tool: mcpMatch[3],
|
||||||
|
params: null,
|
||||||
|
category: 'mcp',
|
||||||
|
server: mcpMatch[1].replace(/_/g, '-'),
|
||||||
|
host: mcpMatch[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized: Tool(params)
|
||||||
|
const paramMatch = raw.match(/^(\w+)\((.+)\)$/)
|
||||||
|
if (paramMatch) {
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
tool: paramMatch[1],
|
||||||
|
params: paramMatch[2],
|
||||||
|
category: BASE_TOOLS.includes(paramMatch[1]) ? 'base' : 'mcp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple: ToolName
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
tool: raw,
|
||||||
|
params: null,
|
||||||
|
category: BASE_TOOLS.includes(raw) ? 'base' : 'mcp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File utilities ──
|
||||||
|
|
||||||
function getFileType(filename: string): AgentFile['type'] {
|
function getFileType(filename: string): AgentFile['type'] {
|
||||||
if (filename.endsWith('.jsonl')) return 'jsonl'
|
if (filename.endsWith('.jsonl')) return 'jsonl'
|
||||||
if (filename.endsWith('.json')) return 'json'
|
if (filename.endsWith('.json')) return 'json'
|
||||||
@@ -54,21 +118,15 @@ function getFileType(filename: string): AgentFile['type'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function categorizeFile(relPath: string, filename: string): FileCategory {
|
function categorizeFile(relPath: string, filename: string): FileCategory {
|
||||||
// Backups first (most specific)
|
|
||||||
if (filename.includes('.backup')) return 'backups'
|
if (filename.includes('.backup')) return 'backups'
|
||||||
|
|
||||||
// By directory
|
|
||||||
if (relPath.includes('/plugins/')) return 'plugins'
|
if (relPath.includes('/plugins/')) return 'plugins'
|
||||||
if (relPath.includes('/debug/')) return 'debug'
|
if (relPath.includes('/debug/')) return 'debug'
|
||||||
if (relPath.includes('/cache/')) return 'cache'
|
if (relPath.includes('/cache/')) return 'cache'
|
||||||
if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions'
|
if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions'
|
||||||
|
|
||||||
// By filename
|
|
||||||
if (filename === 'history.jsonl') return 'history'
|
if (filename === 'history.jsonl') return 'history'
|
||||||
if (filename.endsWith('.md')) return 'instructions'
|
if (filename.endsWith('.md')) return 'instructions'
|
||||||
if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config'
|
if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config'
|
||||||
if (filename.endsWith('.json')) return 'config'
|
if (filename.endsWith('.json')) return 'config'
|
||||||
|
|
||||||
return 'other'
|
return 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +192,46 @@ function readUiConfig(agentDir: string): UiConfig | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readJsonFile(path: string): any {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return null
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonFile(path: string, data: any): void {
|
||||||
|
const dir = resolve(path, '..')
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config file targeting ──
|
||||||
|
|
||||||
|
function getSettingsPath(agentId: string): string {
|
||||||
|
if (agentId === 'main') {
|
||||||
|
return join(PROJECT_ROOT, '.claude', 'settings.local.json')
|
||||||
|
}
|
||||||
|
return join(PROJECT_ROOT, `.claude-${agentId}`, 'settings.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillsDir(agentId: string): string {
|
||||||
|
if (agentId === 'main') {
|
||||||
|
return join(PROJECT_ROOT, '.claude', 'skills')
|
||||||
|
}
|
||||||
|
return join(PROJECT_ROOT, `.claude-${agentId}`, 'skills')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpJsonPath(): string {
|
||||||
|
return join(PROJECT_ROOT, '.mcp.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent discovery ──
|
||||||
|
|
||||||
function discoverAgents(): Agent[] {
|
function discoverAgents(): Agent[] {
|
||||||
const agents: Agent[] = []
|
const agents: Agent[] = []
|
||||||
|
|
||||||
// Main agent (.claude/ + root files)
|
|
||||||
const claudeDir = join(PROJECT_ROOT, '.claude')
|
const claudeDir = join(PROJECT_ROOT, '.claude')
|
||||||
if (existsSync(claudeDir)) {
|
if (existsSync(claudeDir)) {
|
||||||
const mainAgent: Agent = {
|
const mainAgent: Agent = {
|
||||||
@@ -148,7 +242,6 @@ function discoverAgents(): Agent[] {
|
|||||||
uiConfig: readUiConfig(claudeDir)
|
uiConfig: readUiConfig(claudeDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root CLAUDE.md
|
|
||||||
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
|
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
|
||||||
if (existsSync(claudeMd)) {
|
if (existsSync(claudeMd)) {
|
||||||
const stat = statSync(claudeMd)
|
const stat = statSync(claudeMd)
|
||||||
@@ -158,7 +251,6 @@ function discoverAgents(): Agent[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root .mcp.json
|
|
||||||
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
|
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
|
||||||
if (existsSync(mcpJson)) {
|
if (existsSync(mcpJson)) {
|
||||||
const stat = statSync(mcpJson)
|
const stat = statSync(mcpJson)
|
||||||
@@ -171,7 +263,6 @@ function discoverAgents(): Agent[] {
|
|||||||
agents.push(mainAgent)
|
agents.push(mainAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other agents (.claude-*/)
|
|
||||||
try {
|
try {
|
||||||
const rootEntries = readdirSync(PROJECT_ROOT)
|
const rootEntries = readdirSync(PROJECT_ROOT)
|
||||||
for (const entry of rootEntries) {
|
for (const entry of rootEntries) {
|
||||||
@@ -193,6 +284,147 @@ function discoverAgents(): Agent[] {
|
|||||||
return agents
|
return agents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Skill discovery ──
|
||||||
|
|
||||||
|
function discoverSkills(agentId: string): any[] {
|
||||||
|
const skills: any[] = []
|
||||||
|
const skillsDir = getSkillsDir(agentId)
|
||||||
|
|
||||||
|
if (!existsSync(skillsDir)) {
|
||||||
|
// Fallback for sub-agents: try main agent's skills dir
|
||||||
|
if (agentId !== 'main') {
|
||||||
|
const mainSkillsDir = getSkillsDir('main')
|
||||||
|
if (existsSync(mainSkillsDir)) {
|
||||||
|
return discoverSkillsFromDir(mainSkillsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
return discoverSkillsFromDir(skillsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverSkillsFromDir(dir: string): any[] {
|
||||||
|
const skills: any[] = []
|
||||||
|
if (!existsSync(dir)) return skills
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(dir, entry)
|
||||||
|
let stat
|
||||||
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const skillMdPath = join(entryPath, 'SKILL.md')
|
||||||
|
if (existsSync(skillMdPath)) {
|
||||||
|
const content = readFileSync(skillMdPath, 'utf-8')
|
||||||
|
const nameMatch = content.match(/^#\s+(.+)/m)
|
||||||
|
const descMatch = content.match(/^(?:#+\s+.+\n+)(.+)/m)
|
||||||
|
|
||||||
|
// Find referenced files
|
||||||
|
const references: { name: string; path: string }[] = []
|
||||||
|
try {
|
||||||
|
const skillEntries = readdirSync(entryPath)
|
||||||
|
for (const se of skillEntries) {
|
||||||
|
if (se !== 'SKILL.md') {
|
||||||
|
references.push({ name: se, path: join(entryPath, se) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
skills.push({
|
||||||
|
name: nameMatch ? nameMatch[1].trim() : entry,
|
||||||
|
description: descMatch ? descMatch[1].trim() : '',
|
||||||
|
path: entryPath,
|
||||||
|
skillMdContent: content,
|
||||||
|
references
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin discovery ──
|
||||||
|
|
||||||
|
function discoverPlugins(): any[] {
|
||||||
|
const plugins: any[] = []
|
||||||
|
const pluginsDir = join(homedir(), '.claude', 'plugins', 'marketplaces')
|
||||||
|
|
||||||
|
if (!existsSync(pluginsDir)) return plugins
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(pluginsDir)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(pluginsDir, entry)
|
||||||
|
let stat
|
||||||
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Look for plugin manifest (package.json or plugin.json)
|
||||||
|
const pkgPath = join(entryPath, 'package.json')
|
||||||
|
const pluginJsonPath = join(entryPath, 'plugin.json')
|
||||||
|
|
||||||
|
let manifest: any = readJsonFile(pkgPath) || readJsonFile(pluginJsonPath)
|
||||||
|
if (manifest) {
|
||||||
|
plugins.push({
|
||||||
|
name: manifest.name || entry,
|
||||||
|
description: manifest.description || '',
|
||||||
|
author: manifest.author || '',
|
||||||
|
mcpConfig: manifest.mcpConfig || manifest.mcp || null,
|
||||||
|
installed: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
plugins.push({
|
||||||
|
name: entry,
|
||||||
|
description: '',
|
||||||
|
author: '',
|
||||||
|
mcpConfig: null,
|
||||||
|
installed: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Known tools discovery ──
|
||||||
|
|
||||||
|
function discoverKnownTools(): { baseTools: string[]; mcpTools: Record<string, Record<string, string[]>> } {
|
||||||
|
const mcpTools: Record<string, Record<string, string[]>> = {}
|
||||||
|
const agents = discoverAgents()
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const settings = readJsonFile(getSettingsPath(agent.id))
|
||||||
|
if (!settings?.permissions) continue
|
||||||
|
|
||||||
|
const allPerms = [
|
||||||
|
...(settings.permissions.allow || []),
|
||||||
|
...(settings.permissions.deny || [])
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const raw of allPerms) {
|
||||||
|
const parsed = parsePermission(raw)
|
||||||
|
if (parsed.category === 'mcp' && parsed.server && parsed.host) {
|
||||||
|
if (!mcpTools[parsed.server]) mcpTools[parsed.server] = {}
|
||||||
|
if (!mcpTools[parsed.server][parsed.host]) mcpTools[parsed.server][parsed.host] = []
|
||||||
|
if (!mcpTools[parsed.server][parsed.host].includes(parsed.tool)) {
|
||||||
|
mcpTools[parsed.server][parsed.host].push(parsed.tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseTools: BASE_TOOLS, mcpTools }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
|
|
||||||
export async function handleAgents(req: Request): Promise<Response | null> {
|
export async function handleAgents(req: Request): Promise<Response | null> {
|
||||||
if (req.method !== 'GET') return null
|
if (req.method !== 'GET') return null
|
||||||
return jsonResponse(discoverAgents())
|
return jsonResponse(discoverAgents())
|
||||||
@@ -237,3 +469,103 @@ export async function handleAgentsFile(req: Request, url: URL): Promise<Response
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsConfig(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
|
||||||
|
const agentId = url.searchParams.get('agentId') || 'main'
|
||||||
|
const settingsPath = getSettingsPath(agentId)
|
||||||
|
const settings = readJsonFile(settingsPath) || {}
|
||||||
|
|
||||||
|
const allowRaw: string[] = settings.permissions?.allow || []
|
||||||
|
const denyRaw: string[] = settings.permissions?.deny || []
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
agentId,
|
||||||
|
configFile: settingsPath.replace(PROJECT_ROOT + '/', '').replace(PROJECT_ROOT + '\\', ''),
|
||||||
|
permissions: {
|
||||||
|
allow: allowRaw.map(parsePermission),
|
||||||
|
deny: denyRaw.map(parsePermission)
|
||||||
|
},
|
||||||
|
hooks: settings.hooks || {},
|
||||||
|
env: settings.env || {},
|
||||||
|
enableAllProjectMcpServers: settings.enableAllProjectMcpServers ?? false,
|
||||||
|
enabledMcpjsonServers: settings.enabledMcpjsonServers || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsKnownTools(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
return jsonResponse(discoverKnownTools())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsSkills(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
const agentId = url.searchParams.get('agentId') || 'main'
|
||||||
|
return jsonResponse(discoverSkills(agentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsPlugins(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
return jsonResponse(discoverPlugins())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsMcpJson(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
const mcpPath = getMcpJsonPath()
|
||||||
|
const data = readJsonFile(mcpPath)
|
||||||
|
return jsonResponse(data || { mcpServers: {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsConfigPermissions(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'POST') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const agentId = body.agentId || 'main'
|
||||||
|
const settingsPath = getSettingsPath(agentId)
|
||||||
|
const settings = readJsonFile(settingsPath) || {}
|
||||||
|
|
||||||
|
settings.permissions = {
|
||||||
|
allow: body.permissions?.allow || [],
|
||||||
|
deny: body.permissions?.deny || []
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJsonFile(settingsPath, settings)
|
||||||
|
return jsonResponse({ success: true })
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResponse(`Failed to save permissions: ${e.message}`, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsConfigHooks(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'POST') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const agentId = body.agentId || 'main'
|
||||||
|
const settingsPath = getSettingsPath(agentId)
|
||||||
|
const settings = readJsonFile(settingsPath) || {}
|
||||||
|
|
||||||
|
settings.hooks = body.hooks || {}
|
||||||
|
|
||||||
|
writeJsonFile(settingsPath, settings)
|
||||||
|
return jsonResponse({ success: true })
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResponse(`Failed to save hooks: ${e.message}`, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsConfigMcp(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'POST') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const mcpPath = getMcpJsonPath()
|
||||||
|
|
||||||
|
writeJsonFile(mcpPath, { mcpServers: body.mcpServers || {} })
|
||||||
|
return jsonResponse({ success: true })
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResponse(`Failed to save MCP config: ${e.message}`, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user