diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a0b38e2..a19c354 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,13 +65,14 @@ EntityConfig (설정 객체) 공통 UI Primitives 도메 ```text src/ + features/ ← 도메인별 feature 디렉토리 (타입, 서비스, 서버, 쿼리, 컴포넌트 co-locate) + hook/ ← Hook 도메인 (types, constants, utils, service, server, queries, config, components/) components/ ui/ ← shadcn primitives (Button, Sheet, ListItem, Inspector 등) skill/ ← SkillInspector, SkillListItem memory/ ← MemoryInspector agent/ ← AgentInspector mcp/ ← McpInspector - hook/ ← HookInspector plugin/ ← PluginInspector file/ ← FileInspector board/ ← BoardLayout, EntityListPanel, EntityInspector, Add*Dialog @@ -82,10 +83,10 @@ src/ icons/ ← entity-icons, agent-logos, editor-icons config/ entity-registry.ts ← EntityConfig 타입 + 레지스트리 - entities/ ← 엔티티별 config (skill, command, agent, hook, mcp, plugin, memory, file) - hooks/ ← React Query 커스텀 훅 (use-hooks, use-mcp, use-plugins 등) + entities/ ← 엔티티별 config (skill, command, agent, mcp, plugin, memory, file) + hooks/ ← React Query 커스텀 훅 (use-mcp, use-plugins 등) server/ ← Server Functions (createServerFn 기반) - skills.ts, marketplace.ts, items.ts, hooks.ts, agents.ts + skills.ts, marketplace.ts, items.ts, agents.ts mcp-fns.ts, plugins-fns.ts, files.ts, overview.ts claude-md.ts, config-settings.ts, memory.ts, editor.ts config.ts, validation.ts, projects.ts, middleware/auth.ts @@ -93,7 +94,6 @@ src/ config-service.ts ← 설정 파일 파싱 (스킬, 커맨드, 에이전트) agent-file-service.ts ← AgentFile CRUD skills-service.ts ← 스킬 설치/삭제 (skills.sh CLI) - hooks-service.ts ← Hook CRUD mcp-service.ts ← MCP 서버 관리 (Claude CLI 위임) plugin-service.ts ← 플러그인 관리 memory-service.ts ← 메모리 파일 조회 diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 3607743..98c04b7 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -228,12 +228,17 @@ export function XxxInspector({ itemKey }: { itemKey: string }) { - `InspectorSkeleton` — 아이템 로딩 중 표시. 아이템을 찾을 수 없는 경우에도 사용 - `entity-inspector.tsx` — `type`에 따라 올바른 Inspector 컴포넌트로 라우팅 +**리스트 vs Inspector 역할 분리:** +- 리스트(`EntityListPanel`)는 **선택만** 담당 — 더보기 버튼, 컨텍스트 메뉴 없음 +- 모든 조작(편집, 삭제, 에디터에서 열기)은 **Inspector에서** 처리 + ## 참조 구현 새 패널이나 디테일 뷰를 추가할 때는 기존 구현을 참조: - 엔티티 config: `src/config/entities/command-config.tsx` (groupBy 포함 최신 패턴) - 범용 패널: `src/components/board/EntityListPanel.tsx` (flat + grouped 지원) - Inspector: `src/components/skill/skill-inspector.tsx` +- Feature directory 패턴: `src/features/hook/` (타입, 서비스, 서버, 쿼리, 컴포넌트 co-locate) - 레이아웃: `src/components/board/BoardLayout.tsx` ## 금지 사항 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 85131c5..928373d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -119,11 +119,13 @@ Claude Code가 자체 설정 관리 GUI를 추가하면, 그걸 쓰면 된다. a - [ ] 플러그인 `scope: "managed"` 개선 — `scanPluginComponents`에서 `scope: "managed"` 주입 - [ ] Add 다이얼로그 재설계 — `AddSkillDialog`, `AddAgentDialog`는 파일 직접 생성 방식이라 뷰어/모니터링 철학과 불일치. CLI 위임 원칙에 맞게 재설계 필요 (CLI 명령 안내 or 마켓플레이스 연결). `AddHookDialog`, `AddMcpDialog`도 에이전트별 분기 필요 (현재 Claude Code 전용 하드코딩) - [ ] `edit` 액션 → 다이얼로그 연결 — MCP/Hook의 edit 액션이 현재 no-op. Add 다이얼로그를 edit 모드로 재활용하거나 별도 edit 다이얼로그 필요 +- [ ] Inspector 헤더 드롭다운 공통화 — 각 Inspector(Hook, Skill, MCP 등)에서 Open in Editor / Edit / Delete 드롭다운이 중복 구현. `InspectorActionMenu` 공통 컴포넌트로 추출하고 entity config 기반으로 액션 자동 생성 --- ## Shipped +- **Hook feature directory 이관 + 리스트 액션 제거** (2026-03-19) — Hook 도메인 파일을 `src/features/hook/`로 통합. 서버 Zod 스키마 중복 제거, Inspector에 EntityActionDropdown 적용. EntityListPanel에서 더보기 버튼/컨텍스트 메뉴 제거 (조작은 Inspector에서만). - **Inspector 아키텍처 단순화** (2026-03-18) — `DashboardDetailTarget` union / `toDetailTarget` 제거. Inspector가 `itemKey`만 받아 자체 fetch 및 액션 처리. `use-entity-action-handler.ts` 제거. - **Inspector 리디자인** (2026-03-18) — DetailPanel/EntityDetailPanel/DetailContent 시스템을 Inspector primitives + 도메인별 full Inspector로 전환. entity/ 해소, 도메인 디렉토리 이동. - **Commands in Skills 패널** (2026-03-18) — `.claude/commands/` 파일을 Skills 열에 통합 표시. `commandConfig` + `ENTITY_ICONS.command`(TerminalSquareIcon) 추가, namespace 트리 그룹핑(`GroupedList`), 디테일 패널 slash command 필드 + 복사 버튼, `getSlashCommand` 유틸. diff --git a/src/components/board/AgentsPanel.tsx b/src/components/board/AgentsPanel.tsx deleted file mode 100644 index 4626cd9..0000000 --- a/src/components/board/AgentsPanel.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" -import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" -import { - EntityActionContextMenu, - EntityActionDropdown, -} from "@/components/ui/entity-action-menu" -import { ListItem } from "@/components/ui/list-item" -import { useAgentFiles } from "@/hooks/use-config" -import type { EntityActionId } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" -import { m } from "@/paraglide/messages" - -interface AgentsPanelProps { - scopeFilter?: string - onSelectItem?: (selected: { type: string; key: string }) => void - onAction?: ( - id: EntityActionId, - selected: { type: string; key: string }, - ) => void -} - -export function AgentsPanel({ - scopeFilter, - onSelectItem, - onAction, -}: AgentsPanelProps) { - const { - query: { data: files = [] }, - } = useAgentFiles("agent") - const filtered = scopeFilter - ? files.filter((f) => f.scope === scopeFilter) - : files - - if (filtered.length === 0) - return ( - - - - - {m.board_no_agents()} - - ) - - return ( -
- {filtered.map((file) => { - const selected = { type: "agent", key: file.path } - return ( - onAction?.(id, selected)} - itemName={file.frontmatter?.name ?? file.name} - > - onAction?.(id, selected)} - itemName={file.frontmatter?.name ?? file.name} - /> - } - onClick={() => onSelectItem?.(selected)} - /> - - ) - })} -
- ) -} diff --git a/src/components/board/BoardLayout.tsx b/src/components/board/BoardLayout.tsx index 2ab48de..99f4acc 100644 --- a/src/components/board/BoardLayout.tsx +++ b/src/components/board/BoardLayout.tsx @@ -24,7 +24,7 @@ import { PlusIcon, } from "lucide-react" import type { ElementType } from "react" -import { useMemo, useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import { useProjectContext } from "@/components/ProjectContext" @@ -50,19 +50,17 @@ import { memoryConfig, skillConfig, } from "@/config/entities" +import { getEntityConfig } from "@/config/entity-registry" +import { AddHookDialog } from "@/features/hook/components/add-hook-dialog" +import { useHooksQuery } from "@/features/hook/queries" +import type { HookScope, HooksSettings } from "@/features/hook/types" +import { getHookIcon } from "@/features/hook/utils" import { useAgentFiles, useMemoryFiles } from "@/hooks/use-config" -import { useHooksQuery } from "@/hooks/use-hooks" import { useMcpMutations, useMcpQuery } from "@/hooks/use-mcp" - +import { usePluginsQuery } from "@/hooks/use-plugins" import { m } from "@/paraglide/messages" -import type { - BoardColumnId, - HookScope, - HooksSettings, - Scope, -} from "@/shared/types" +import type { BoardColumnId, Scope } from "@/shared/types" import { AddAgentDialog } from "./AddAgentDialog" -import { AddHookDialog } from "./AddHookDialog" import { AddMcpDialog } from "./AddMcpDialog" import { AddSkillDialog } from "./AddSkillDialog" import { BoardColumnSettings } from "./BoardColumnSettings" @@ -256,6 +254,9 @@ export function BoardLayout() { const { data: mcpServers = [], isLoading: mcpLoading } = useMcpQuery() const { toggleMutation } = useMcpMutations() const { data: memoryFiles = [], isLoading: memoryLoading } = useMemoryFiles() + const { data: plugins = [], isLoading: pluginsLoading } = usePluginsQuery( + activeProjectPath ?? undefined, + ) const hookItems = useMemo(() => { return [ @@ -270,6 +271,56 @@ export function BoardLayout() { [mcpServers], ) + // ── Auto-close inspector when selected item no longer exists ── + const entityDataMap: Record = + useMemo( + () => ({ + skill: { + items: skills.filter((s) => s.type === "skill"), + loading: skillsLoading, + }, + command: { + items: skills.filter((s) => s.type === "command"), + loading: skillsLoading, + }, + agent: { items: agents, loading: agentsLoading }, + hook: { + items: hookItems, + loading: globalHooksLoading || projectHooksLoading, + }, + mcp: { items: directMcpServers, loading: mcpLoading }, + plugin: { items: plugins, loading: pluginsLoading }, + memory: { items: memoryFiles, loading: memoryLoading }, + }), + [ + skills, + agents, + hookItems, + directMcpServers, + plugins, + memoryFiles, + skillsLoading, + agentsLoading, + globalHooksLoading, + projectHooksLoading, + mcpLoading, + pluginsLoading, + memoryLoading, + ], + ) + + useEffect(() => { + if (!selected) return + const data = entityDataMap[selected.type] + if (!data || data.loading) return + const config = getEntityConfig(selected.type) + if (!config) return + const exists = data.items.some( + (item) => config.getKey(item) === selected.key, + ) + if (!exists) setSelected(null) + }, [selected, entityDataMap]) + function toggleScope(scope: string) { setCollapsedScopes((prev) => { const next = new Set(prev) @@ -462,7 +513,8 @@ export function BoardLayout() { items={hookItems} {...common} emptyDescription={m.board_no_hooks()} - isLoading={globalHooksLoading && projectHooksLoading} + isLoading={globalHooksLoading || projectHooksLoading} + getItemIcon={(item) => getHookIcon(item.entry)} /> ) case "memory": diff --git a/src/components/board/EntityListPanel.tsx b/src/components/board/EntityListPanel.tsx index 9007f42..b47ac41 100644 --- a/src/components/board/EntityListPanel.tsx +++ b/src/components/board/EntityListPanel.tsx @@ -1,14 +1,9 @@ -import { FolderIcon } from "lucide-react" import { useEffect, useRef, useState } from "react" +import { getEntityIcon } from "@/components/icons/entity-icons" import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" -import { - EntityActionContextMenu, - EntityActionDropdown, -} from "@/components/ui/entity-action-menu" import { ListItem, ListSubItem } from "@/components/ui/list-item" import { Skeleton } from "@/components/ui/skeleton" import type { EntityConfig } from "@/config/entity-registry" -import type { EntityActionId } from "@/lib/entity-actions" interface EntityListPanelProps { config: EntityConfig @@ -16,12 +11,10 @@ interface EntityListPanelProps { scopeFilter?: string selectedKey?: string onSelectItem?: (selected: { type: string; key: string }) => void - onAction?: ( - id: EntityActionId, - selected: { type: string; key: string }, - ) => void /** 아이템별 trailing 위젯 (예: MCP Switch) */ renderTrailing?: (item: T) => React.ReactNode + /** 아이템별 아이콘 재정의 (예: 훅 타입별 아이콘) */ + getItemIcon?: (item: T) => React.ElementType emptyDescription?: string /** 로딩 중 Skeleton 표시 */ isLoading?: boolean @@ -50,12 +43,12 @@ export function EntityListPanel({ scopeFilter, selectedKey, onSelectItem, - onAction, renderTrailing, + getItemIcon, emptyDescription, isLoading, }: EntityListPanelProps) { - const Icon = config.icon + const Icon = getEntityIcon(config.type) // --- Loading state --- if (isLoading) { @@ -75,8 +68,8 @@ export function EntityListPanel({ items={filtered} selectedKey={selectedKey} onSelectItem={onSelectItem} - onAction={onAction} renderTrailing={renderTrailing} + getItemIcon={getItemIcon} emptyDescription={emptyDescription} /> ) @@ -84,14 +77,13 @@ export function EntityListPanel({ // --- Empty state --- if (filtered.length === 0) { + if (!emptyDescription) return null return ( - {emptyDescription && ( - {emptyDescription} - )} + {emptyDescription} ) } @@ -104,32 +96,18 @@ export function EntityListPanel({ const label = config.getLabel(item) const description = config.getDescription?.(item) const selected = { type: config.type, key: config.getKey(item) } - const resolvedActions = resolveActions(config.actions) + const ItemIcon = getItemIcon?.(item) ?? Icon return ( - onAction?.(id, selected)} - itemName={label} - > - onAction?.(id, selected)} - itemName={label} - /> - ) - } - onClick={() => onSelectItem?.(selected)} - /> - + icon={ItemIcon} + label={label} + description={description} + selected={selectedKey === key} + trailing={renderTrailing?.(item)} + onClick={() => onSelectItem?.(selected)} + /> ) })} @@ -143,11 +121,8 @@ interface GroupedListProps { items: T[] selectedKey?: string onSelectItem?: (selected: { type: string; key: string }) => void - onAction?: ( - id: EntityActionId, - selected: { type: string; key: string }, - ) => void renderTrailing?: (item: T) => React.ReactNode + getItemIcon?: (item: T) => React.ElementType emptyDescription?: string } @@ -156,11 +131,11 @@ function GroupedList({ items, selectedKey, onSelectItem, - onAction, renderTrailing, + getItemIcon, emptyDescription, }: GroupedListProps) { - const Icon = config.icon + const Icon = getEntityIcon(config.type) // groupBy 보장 (상위에서 확인했지만 타입 안전성을 위해) // biome-ignore lint/style/noNonNullAssertion: guaranteed by caller (GroupedListProps requires groupBy) @@ -203,20 +178,17 @@ function GroupedList({ // --- Empty state --- if (flat.length === 0 && groupKeys.length === 0) { + if (!emptyDescription) return null return ( - {emptyDescription && ( - {emptyDescription} - )} + {emptyDescription} ) } - const resolvedActions = resolveActions(config.actions) - return (
{/* Flat items (groupBy가 빈 문자열) */} @@ -226,30 +198,17 @@ function GroupedList({ const description = config.getDescription?.(item) const selected = { type: config.type, key: config.getKey(item) } + const ItemIcon = getItemIcon?.(item) ?? Icon return ( - onAction?.(id, selected)} - itemName={label} - > - onAction?.(id, selected)} - itemName={label} - /> - ) - } - onClick={() => onSelectItem?.(selected)} - /> - + icon={ItemIcon} + label={label} + description={description} + selected={selectedKey === key} + trailing={renderTrailing?.(item)} + onClick={() => onSelectItem?.(selected)} + /> ) })} {/* Grouped items */} @@ -262,7 +221,7 @@ function GroupedList({ return ( toggleGroup(groupKey)} @@ -273,30 +232,17 @@ function GroupedList({ const description = config.getDescription?.(item) const selected = { type: config.type, key: config.getKey(item) } + const ItemIcon = getItemIcon?.(item) ?? Icon return ( - onAction?.(id, selected)} - itemName={label} - > - onAction?.(id, selected)} - itemName={label} - /> - ) - } - onClick={() => onSelectItem?.(selected)} - /> - + icon={ItemIcon} + label={label} + description={description} + selected={selectedKey === key} + trailing={renderTrailing?.(item)} + onClick={() => onSelectItem?.(selected)} + /> ) })} @@ -305,23 +251,3 @@ function GroupedList({
) } - -// --- Action resolver --- -// EntityConfig.actions는 EntityActionId[] 이고, -// entity-action-menu는 EntityAction[] 을 받으므로 ENTITY_ACTIONS에서 조회 - -import type { EntityAction } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" - -function resolveActions(actionIds: EntityActionId[]): EntityAction[] { - // ENTITY_ACTIONS에서 모든 액션을 flat하게 수집해 ID로 조회 - const allActions = new Map() - for (const actions of Object.values(ENTITY_ACTIONS)) { - for (const action of actions) { - allActions.set(action.id, action) - } - } - return actionIds - .map((id) => allActions.get(id)) - .filter((a): a is EntityAction => a !== undefined) -} diff --git a/src/components/board/HooksPanel.tsx b/src/components/board/HooksPanel.tsx deleted file mode 100644 index b86d218..0000000 --- a/src/components/board/HooksPanel.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react" -import { ENTITY_ICONS } from "@/components/icons/entity-icons" -import { Badge } from "@/components/ui/badge" -import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" -import { - EntityActionContextMenu, - EntityActionDropdown, -} from "@/components/ui/entity-action-menu" -import { ListItem, ListSubItem } from "@/components/ui/list-item" -import { useHooksQuery } from "@/hooks/use-hooks" -import type { EntityActionId } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" -import { getHookIcon } from "@/lib/hook-constants" -import { m } from "@/paraglide/messages" -import type { - HookEntry, - HookMatcherGroup, - HookScope, - HooksSettings, -} from "@/shared/types" - -/** Shorten a hook command for display: show basename for paths, full for short commands */ -function shortenCommand(hook: HookEntry): string { - const raw = hook.command ?? hook.prompt ?? hook.type - // If it looks like a path (contains /), show just the last segment - if (raw.includes("/")) { - const basename = raw.split("/").pop() ?? raw - return basename - } - return raw -} - -interface HooksPanelProps { - scopeFilter?: string - onSelectItem?: (selected: { type: string; key: string }) => void - onAction?: ( - id: EntityActionId, - selected: { type: string; key: string }, - ) => void -} - -interface EventItem { - event: string - groups: HookMatcherGroup[] - allHooks: HookEntry[] - scope: HookScope -} - -function buildEventItems(hooks: HooksSettings, scope: HookScope): EventItem[] { - return Object.entries(hooks) - .filter(([, groups]) => groups && groups.length > 0) - .map(([event, groups]) => { - const allHooks = (groups ?? []).flatMap((g) => g.hooks) - return { event, groups: groups ?? [], allHooks, scope } - }) -} - -export function HooksPanel({ - scopeFilter, - onSelectItem, - onAction, -}: HooksPanelProps) { - const { data: globalHooks = {} } = useHooksQuery("user") - const { data: projectHooks = {} } = useHooksQuery("project") - - const eventItems = useMemo(() => { - const items: EventItem[] = [] - if (!scopeFilter || scopeFilter === "user") - items.push(...buildEventItems(globalHooks, "user")) - if (!scopeFilter || scopeFilter === "project") - items.push(...buildEventItems(projectHooks, "project")) - return items - }, [globalHooks, projectHooks, scopeFilter]) - - const [openEvents, setOpenEvents] = useState>(new Set()) - const initialized = useRef(false) - useEffect(() => { - if (!initialized.current && eventItems.length > 0) { - initialized.current = true - setOpenEvents( - new Set(eventItems.map(({ event, scope }) => `${scope}-${event}`)), - ) - } - }, [eventItems]) - - function toggleEvent(key: string) { - setOpenEvents((prev) => { - const next = new Set(prev) - next.has(key) ? next.delete(key) : next.add(key) - return next - }) - } - - if (eventItems.length === 0) - return ( - - - - - {m.board_no_hooks()} - - ) - - return ( -
- {eventItems.map(({ event, groups, allHooks, scope }) => { - const key = `${scope}-${event}` - if (allHooks.length === 0) return null - - const isOpen = openEvents.has(key) - - return ( - - {allHooks.length} - - } - open={isOpen} - onClick={() => toggleEvent(key)} - > - {groups.flatMap((group) => - group.hooks.map((hook, hi) => { - const selected = { - type: "hook", - key: `${scope}:${event}:${group.matcher ?? ""}:${hook.command ?? hook.prompt ?? ""}`, - } - const HookIcon = getHookIcon(hook) - return ( - onAction?.(id, selected)} - itemName={hook.command ?? hook.type} - > - onAction?.(id, selected)} - itemName={hook.command ?? hook.type} - /> - } - onClick={() => onSelectItem?.(selected)} - /> - - ) - }), - )} - - ) - })} -
- ) -} diff --git a/src/components/board/McpDirectPanel.tsx b/src/components/board/McpDirectPanel.tsx deleted file mode 100644 index 0d5cf01..0000000 --- a/src/components/board/McpDirectPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { toast } from "sonner" -import { ENTITY_ICONS } from "@/components/icons/entity-icons" -import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" -import { - EntityActionContextMenu, - EntityActionDropdown, -} from "@/components/ui/entity-action-menu" -import { ListItem } from "@/components/ui/list-item" -import { Switch } from "@/components/ui/switch" -import { - useMcpMutations, - useMcpQuery, - useMcpStatusQuery, -} from "@/hooks/use-mcp" -import type { EntityActionId } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" -import { getMcpIconClass } from "@/lib/mcp-status" -import { m } from "@/paraglide/messages" - -interface McpDirectPanelProps { - scopeFilter?: string - onSelectItem?: (selected: { type: string; key: string }) => void - onAction?: ( - id: EntityActionId, - selected: { type: string; key: string }, - ) => void -} - -export function McpDirectPanel({ - scopeFilter, - onSelectItem, - onAction, -}: McpDirectPanelProps) { - const { data: servers = [] } = useMcpQuery() - const { data: statusMap } = useMcpStatusQuery() - const { toggleMutation } = useMcpMutations() - // Plugin-provided servers are visible in the Plugins panel — exclude them here - const directServers = servers.filter((s) => !s.fromPlugin) - const filtered = scopeFilter - ? directServers.filter((s) => s.scope === scopeFilter) - : directServers - - if (filtered.length === 0) - return ( - - - - - {m.board_no_mcp()} - - ) - - return ( -
- {filtered.map((server) => { - const selected = { type: "mcp", key: `${server.scope}:${server.name}` } - return ( - onAction?.(id, selected)} - itemName={server.name} - > - - e.stopPropagation()} - onCheckedChange={(checked) => { - toggleMutation.mutate( - { name: server.name, enable: !!checked }, - { - onError: () => toast.error(m.mcp_toggle_error()), - }, - ) - }} - /> - onAction?.(id, selected)} - itemName={server.name} - /> - - } - onClick={() => onSelectItem?.(selected)} - /> - - ) - })} -
- ) -} diff --git a/src/components/board/entity-inspector.tsx b/src/components/board/entity-inspector.tsx index 28861d6..ed7ec7b 100644 --- a/src/components/board/entity-inspector.tsx +++ b/src/components/board/entity-inspector.tsx @@ -2,11 +2,11 @@ import { AgentInspector } from "@/components/agent/agent-inspector" import { FileInspector } from "@/components/file/file-inspector" -import { HookInspector } from "@/components/hook/hook-inspector" import { McpInspector } from "@/components/mcp/mcp-inspector" import { MemoryInspector } from "@/components/memory/memory-inspector" import { PluginInspector } from "@/components/plugin/plugin-inspector" import { SkillInspector } from "@/components/skill/skill-inspector" +import { HookInspector } from "@/features/hook/components/hook-inspector" interface EntityInspectorProps { type: string diff --git a/src/components/icons/entity-icons.tsx b/src/components/icons/entity-icons.tsx index e6f09b0..ffd2c71 100644 --- a/src/components/icons/entity-icons.tsx +++ b/src/components/icons/entity-icons.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + FolderIcon, Plug2Icon, ScrollTextIcon, ServerIcon, @@ -7,6 +8,7 @@ import { WorkflowIcon, ZapIcon, } from "lucide-react" +import type React from "react" /** * 각 엔티티 타입의 표준 아이콘. @@ -21,3 +23,11 @@ export const ENTITY_ICONS = { command: TerminalSquareIcon, memory: BrainIcon, } as const + +/** 엔티티 타입으로 아이콘을 조회. 없으면 fallback 반환 */ +export function getEntityIcon( + type: string, + fallback: React.ElementType = FolderIcon, +): React.ElementType { + return ENTITY_ICONS[type as keyof typeof ENTITY_ICONS] ?? fallback +} diff --git a/src/components/ui/entity-action-menu.tsx b/src/components/ui/entity-action-menu.tsx index 4d2a7b4..d6cd7e2 100644 --- a/src/components/ui/entity-action-menu.tsx +++ b/src/components/ui/entity-action-menu.tsx @@ -1,4 +1,4 @@ -import { MoreHorizontal } from "lucide-react" +import { EllipsisVertical } from "lucide-react" import { useState } from "react" import { AlertDialog, @@ -221,11 +221,10 @@ export function EntityActionDropdown({ diff --git a/src/components/ui/inspector.tsx b/src/components/ui/inspector.tsx index 1976f9a..a5ff92d 100644 --- a/src/components/ui/inspector.tsx +++ b/src/components/ui/inspector.tsx @@ -56,7 +56,7 @@ function InspectorTitle({ return (
{Icon && } -

{title}

+

{title}

{copyable && ( ) {
- {label} + {label} {description && ( {description} @@ -194,7 +194,7 @@ export function ListSubItem({ - {label} + {label} {description && ( {description} diff --git a/src/config/entities/agent-config.tsx b/src/config/entities/agent-config.tsx index 1492d61..a06796e 100644 --- a/src/config/entities/agent-config.tsx +++ b/src/config/entities/agent-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import type { AgentFile } from "@/shared/types" export const agentConfig: EntityConfig = { type: "agent", - icon: ENTITY_ICONS.agent, actions: ENTITY_ACTIONS.agent.map((a) => a.id), getKey: (item) => item.path, getLabel: (item) => item.name, diff --git a/src/config/entities/command-config.tsx b/src/config/entities/command-config.tsx index 71c0415..0fd94c4 100644 --- a/src/config/entities/command-config.tsx +++ b/src/config/entities/command-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import type { AgentFile } from "@/shared/types" export const commandConfig: EntityConfig = { type: "command", - icon: ENTITY_ICONS.command, actions: ENTITY_ACTIONS.skill.map((a) => a.id), getKey: (item) => item.path, getLabel: (item) => item.frontmatter?.name ?? item.name, diff --git a/src/config/entities/file-config.tsx b/src/config/entities/file-config.tsx index 712125d..cd00b06 100644 --- a/src/config/entities/file-config.tsx +++ b/src/config/entities/file-config.tsx @@ -1,4 +1,3 @@ -import { FileIcon } from "lucide-react" import type { EntityConfig } from "@/config/entity-registry" export interface FileItem { @@ -8,7 +7,6 @@ export interface FileItem { export const fileConfig: EntityConfig = { type: "file", - icon: FileIcon, actions: ["open-vscode", "open-cursor"], getKey: (item) => item.path, getLabel: (item) => item.name, diff --git a/src/config/entities/index.ts b/src/config/entities/index.ts index d9a187e..b0e2bb1 100644 --- a/src/config/entities/index.ts +++ b/src/config/entities/index.ts @@ -1,8 +1,8 @@ import { registerEntity } from "@/config/entity-registry" +import { hookConfig } from "@/features/hook/config" import { agentConfig } from "./agent-config" import { commandConfig } from "./command-config" import { fileConfig } from "./file-config" -import { hookConfig } from "./hook-config" import { mcpConfig } from "./mcp-config" import { memoryConfig } from "./memory-config" import { pluginConfig } from "./plugin-config" @@ -18,12 +18,12 @@ registerEntity(pluginConfig) registerEntity(memoryConfig) registerEntity(fileConfig) +export type { HookItem } from "@/features/hook/config" +export { hookConfig } from "@/features/hook/config" export { agentConfig } from "./agent-config" export { commandConfig } from "./command-config" export type { FileItem } from "./file-config" export { fileConfig } from "./file-config" -export type { HookItem } from "./hook-config" -export { hookConfig } from "./hook-config" export { mcpConfig } from "./mcp-config" export { memoryConfig } from "./memory-config" export { pluginConfig } from "./plugin-config" diff --git a/src/config/entities/mcp-config.tsx b/src/config/entities/mcp-config.tsx index 21abf29..3409f44 100644 --- a/src/config/entities/mcp-config.tsx +++ b/src/config/entities/mcp-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import type { McpServer } from "@/shared/types" export const mcpConfig: EntityConfig = { type: "mcp", - icon: ENTITY_ICONS.mcp, actions: ENTITY_ACTIONS.mcp.map((a) => a.id), getKey: (item) => `${item.scope}:${item.name}`, getLabel: (item) => item.name, diff --git a/src/config/entities/memory-config.tsx b/src/config/entities/memory-config.tsx index ea52f9b..d41eede 100644 --- a/src/config/entities/memory-config.tsx +++ b/src/config/entities/memory-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { formatBytes } from "@/lib/format" import type { MemoryFile } from "@/shared/types" export const memoryConfig: EntityConfig = { type: "memory", - icon: ENTITY_ICONS.memory, actions: [], getKey: (item) => item.path, getLabel: (item) => item.name, diff --git a/src/config/entities/plugin-config.tsx b/src/config/entities/plugin-config.tsx index 4ba55de..204dce9 100644 --- a/src/config/entities/plugin-config.tsx +++ b/src/config/entities/plugin-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import type { Plugin } from "@/shared/types" export const pluginConfig: EntityConfig = { type: "plugin", - icon: ENTITY_ICONS.plugin, actions: ENTITY_ACTIONS.plugin.map((a) => a.id), getKey: (item) => item.id, getLabel: (item) => item.name, diff --git a/src/config/entities/skill-config.test.ts b/src/config/entities/skill-config.test.ts index 0c71d36..5bf3b17 100644 --- a/src/config/entities/skill-config.test.ts +++ b/src/config/entities/skill-config.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest" -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { AgentFile } from "@/shared/types" import { commandConfig } from "./command-config" import { skillConfig } from "./skill-config" @@ -17,8 +16,8 @@ function makeAgentFile(overrides: Partial): AgentFile { } describe("skillConfig", () => { - it("icon은 ENTITY_ICONS.skill이어야 한다", () => { - expect(skillConfig.icon).toBe(ENTITY_ICONS.skill) + it("type이 'skill'이어야 한다", () => { + expect(skillConfig.type).toBe("skill") }) it("groupBy가 없어야 한다 (skills는 namespace 없음)", () => { @@ -27,8 +26,8 @@ describe("skillConfig", () => { }) describe("commandConfig", () => { - it("icon은 ENTITY_ICONS.command여야 한다", () => { - expect(commandConfig.icon).toBe(ENTITY_ICONS.command) + it("type이 'command'여야 한다", () => { + expect(commandConfig.type).toBe("command") }) it("groupBy — namespace가 있으면 namespace를 반환", () => { diff --git a/src/config/entities/skill-config.tsx b/src/config/entities/skill-config.tsx index 54e889b..2b531ba 100644 --- a/src/config/entities/skill-config.tsx +++ b/src/config/entities/skill-config.tsx @@ -1,11 +1,9 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import type { AgentFile } from "@/shared/types" export const skillConfig: EntityConfig = { type: "skill", - icon: ENTITY_ICONS.skill, actions: ENTITY_ACTIONS.skill.map((a) => a.id), getKey: (item) => item.path, getLabel: (item) => item.frontmatter?.name ?? item.name, diff --git a/src/config/entity-registry.ts b/src/config/entity-registry.ts index 634bbcf..66b158c 100644 --- a/src/config/entity-registry.ts +++ b/src/config/entity-registry.ts @@ -1,12 +1,9 @@ -import type { LucideIcon } from "lucide-react" import type React from "react" import type { EntityActionId } from "@/lib/entity-actions" export interface EntityConfig { /** 엔티티 타입 식별자 */ type: string - /** 표시 아이콘 */ - icon: LucideIcon /** 사용 가능한 액션 ID 목록 */ actions: EntityActionId[] /** 고유 키 추출 */ diff --git a/src/components/board/AddHookDialog.tsx b/src/features/hook/components/add-hook-dialog.tsx similarity index 99% rename from src/components/board/AddHookDialog.tsx rename to src/features/hook/components/add-hook-dialog.tsx index 2edc7d2..c0ef3c7 100644 --- a/src/components/board/AddHookDialog.tsx +++ b/src/features/hook/components/add-hook-dialog.tsx @@ -36,7 +36,6 @@ import { import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" -import { useHooksMutations } from "@/hooks/use-hooks" import { EVENT_GROUPS, HOOK_EVENT_META, @@ -45,15 +44,16 @@ import { HOOK_TEMPLATES, hookFormSchema, type SelectedHook, -} from "@/lib/hook-constants" -import { m } from "@/paraglide/messages" +} from "@/features/hook/constants" +import { useHooksMutations } from "@/features/hook/queries" import type { HookEntry, HookEventName, HookMatcherGroup, HookScope, HookType, -} from "@/shared/types" +} from "@/features/hook/types" +import { m } from "@/paraglide/messages" // ── AddHookDialog ──────────────────────────────────────────────────────────── diff --git a/src/components/hook/hook-inspector.tsx b/src/features/hook/components/hook-inspector.tsx similarity index 76% rename from src/components/hook/hook-inspector.tsx rename to src/features/hook/components/hook-inspector.tsx index 797cd76..c9a9942 100644 --- a/src/components/hook/hook-inspector.tsx +++ b/src/features/hook/components/hook-inspector.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query" -import { useMemo } from "react" +import { useMemo, useState } from "react" import { toast } from "sonner" import { DetailField } from "@/components/DetailField" import { FileViewer } from "@/components/FileViewer" @@ -11,15 +11,18 @@ import { InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" -import { useHookScriptQuery, useHooksQuery } from "@/hooks/use-hooks" +import { AddHookDialog } from "@/features/hook/components/add-hook-dialog" +import type { SelectedHook } from "@/features/hook/constants" +import { useHookScriptQuery, useHooksQuery } from "@/features/hook/queries" +import type { HookEntry, HookScope } from "@/features/hook/types" +import { isHookFilePath, resolveHookFilePath } from "@/features/hook/utils" import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" -import { queryKeys } from "@/lib/query-keys" import { m } from "@/paraglide/messages" -import type { HookEntry, HookScope } from "@/shared/types" interface HookItemResolved { entry: HookEntry @@ -76,7 +79,11 @@ export function HookInspector({ itemKey }: HookInspectorProps) { }, [globalHooks, projectHooks, itemKey]) const resolvedFilePath = - hookItem?.entry.type === "command" ? hookItem.entry.command : undefined + hookItem && isHookFilePath(hookItem.entry) + ? resolveHookFilePath(hookItem.entry.command ?? "", { + projectPath: activeProjectPath, + }) + : undefined const scriptQuery = useHookScriptQuery( resolvedFilePath, @@ -84,15 +91,44 @@ export function HookInspector({ itemKey }: HookInspectorProps) { !!resolvedFilePath, ) - if (!hookItem) return null + const [editDialogOpen, setEditDialogOpen] = useState(false) + + if (!hookItem) return const { entry: hook, event, matcher } = hookItem const title = hook.command ?? hook.prompt ?? event + const editHook: SelectedHook = { + scope: hookItem.scope, + event: event as SelectedHook["event"], + groupIndex: hookItem.groupIndex, + hookIndex: hookItem.hookIndex, + hook, + matcher, + } + const handleAction = async (actionId: EntityActionId) => { switch (actionId) { + case "open-vscode": + case "open-cursor": { + const { openInEditorFn } = await import("@/server/editor") + const { getSettingsPathFn } = await import("@/features/hook/server") + const filePath = await getSettingsPathFn({ + data: { scope: hookItem.scope, projectPath: activeProjectPath }, + }) + await openInEditorFn({ + data: { + filePath, + editor: actionId === "open-vscode" ? "code" : "cursor", + }, + }) + break + } + case "edit": + setEditDialogOpen(true) + break case "delete": { - const { removeHookFn } = await import("@/server/hooks") + const { removeHookFn } = await import("@/features/hook/server") await toast.promise( removeHookFn({ data: { @@ -105,12 +141,7 @@ export function HookInspector({ itemKey }: HookInspectorProps) { projectPath: activeProjectPath, }, }).then(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.agentFiles.all, - }) - queryClient.invalidateQueries({ - queryKey: queryKeys.overview.all, - }) + queryClient.invalidateQueries({ queryKey: ["hooks"] }) }), { loading: m.toast_deleting({ name: event }), @@ -175,6 +206,12 @@ export function HookInspector({ itemKey }: HookInspectorProps) { )} + + {hook.model && ( + + {hook.model} + + )} {/* Status Message */} @@ -226,23 +263,23 @@ export function HookInspector({ itemKey }: HookInspectorProps) { {/* prompt / agent type */} {(hook.type === "prompt" || hook.type === "agent") && hook.prompt && ( - <> - - - {hook.model && ( - - {hook.model} - - )} - + )}
+ + {editDialogOpen && ( + setEditDialogOpen(false)} + /> + )} ) } diff --git a/src/config/entities/hook-config.tsx b/src/features/hook/config.ts similarity index 68% rename from src/config/entities/hook-config.tsx rename to src/features/hook/config.ts index 67d1e6e..7b88c9f 100644 --- a/src/config/entities/hook-config.tsx +++ b/src/features/hook/config.ts @@ -1,7 +1,6 @@ -import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" +import type { HookEntry, HookScope } from "@/features/hook/types" import { ENTITY_ACTIONS } from "@/lib/entity-actions" -import type { HookEntry, HookScope } from "@/shared/types" export interface HookItem { entry: HookEntry @@ -12,11 +11,14 @@ export interface HookItem { export const hookConfig: EntityConfig = { type: "hook", - icon: ENTITY_ICONS.hook, actions: ENTITY_ACTIONS.hook.map((a) => a.id), getKey: (item) => `${item.scope}:${item.event}:${item.matcher ?? ""}:${item.entry.command ?? item.entry.prompt ?? ""}`, - getLabel: (item) => item.entry.command ?? item.entry.prompt ?? item.event, + getLabel: (item) => { + const raw = item.entry.command ?? item.entry.prompt ?? item.event + if (raw.includes("/")) return raw.split("/").pop() ?? raw + return raw + }, getScope: (item) => item.scope, groupBy: (item) => item.event, } diff --git a/src/lib/hook-constants.ts b/src/features/hook/constants.ts similarity index 84% rename from src/lib/hook-constants.ts rename to src/features/hook/constants.ts index f86ed68..4509cad 100644 --- a/src/lib/hook-constants.ts +++ b/src/features/hook/constants.ts @@ -1,12 +1,11 @@ -import { FileCode, MessageSquare, Zap } from "lucide-react" import { z } from "zod" -import { m } from "@/paraglide/messages" import type { HookEntry, HookEventName, HookScope, HookType, -} from "@/shared/types" +} from "@/features/hook/types" +import { m } from "@/paraglide/messages" // ── 타입 ───────────────────────────────────────────────────────────────────── @@ -19,41 +18,6 @@ export interface SelectedHook { matcher?: string } -// ── 유틸리티 ────────────────────────────────────────────────────────────────── - -export function getHookDisplayName(hook: HookEntry): string { - if (hook.type === "command" && hook.command) { - const cmd = hook.command - .replace(/"\$CLAUDE_PROJECT_DIR"\//g, "") - .replace(/\$CLAUDE_PROJECT_DIR\//g, "") - const parts = cmd.split("/") - const last = parts[parts.length - 1] - if (parts.length === 1 && cmd.length > 30) { - return `${cmd.slice(0, 27)}...` - } - return last - } - if ((hook.type === "prompt" || hook.type === "agent") && hook.prompt) { - return hook.prompt.length > 30 - ? `${hook.prompt.slice(0, 27)}...` - : hook.prompt - } - return hook.type -} - -export function getHookIcon(hook: HookEntry): React.ElementType { - switch (hook.type) { - case "command": - return FileCode - case "prompt": - return MessageSquare - case "agent": - return Zap - default: - return FileCode - } -} - // ── HOOK_EVENT_META ─────────────────────────────────────────────────────────── export const HOOK_EVENT_META: Record< diff --git a/src/hooks/use-hooks.ts b/src/features/hook/queries.ts similarity index 95% rename from src/hooks/use-hooks.ts rename to src/features/hook/queries.ts index b0a1cdb..e05585c 100644 --- a/src/hooks/use-hooks.ts +++ b/src/features/hook/queries.ts @@ -1,14 +1,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useProjectContext } from "@/components/ProjectContext" -import { FREQUENT_REFETCH } from "@/hooks/use-config" -import { queryKeys } from "@/lib/query-keys" import { addHookFn, getHooksFn, readScriptFn, removeHookFn, -} from "@/server/hooks" -import type { HookEventName, HookMatcherGroup, HookScope } from "@/shared/types" +} from "@/features/hook/server" +import type { + HookEventName, + HookMatcherGroup, + HookScope, +} from "@/features/hook/types" +import { FREQUENT_REFETCH } from "@/hooks/use-config" +import { queryKeys } from "@/lib/query-keys" // ── Feature-local query keys ── diff --git a/src/server/hooks.ts b/src/features/hook/server.ts similarity index 59% rename from src/server/hooks.ts rename to src/features/hook/server.ts index 0bcbb58..5cb0452 100644 --- a/src/server/hooks.ts +++ b/src/features/hook/server.ts @@ -1,76 +1,25 @@ import { createServerFn } from "@tanstack/react-start" import { z } from "zod" - -// ── Zod 스키마 ── - -const hookScopeSchema = z.enum(["user", "project", "local"]) - -const hookTypeSchema = z.enum(["command", "prompt", "agent"]) - -const hookEntrySchema = z.object({ - type: hookTypeSchema, - command: z.string().optional(), - prompt: z.string().optional(), - model: z.string().optional(), - timeout: z.number().optional(), - async: z.boolean().optional(), - statusMessage: z.string().optional(), - once: z.boolean().optional(), -}) - -const matcherGroupSchema = z.object({ - matcher: z.string().optional(), - hooks: z.array(hookEntrySchema).min(1), -}) - -const hookEventNameSchema = z.enum([ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "PostToolUse", - "PostToolUseFailure", - "Notification", - "SubagentStart", - "SubagentStop", - "Stop", - "TeammateIdle", - "TaskCompleted", - "ConfigChange", - "WorktreeCreate", - "WorktreeRemove", - "Setup", - "PreCompact", - "SessionEnd", -]) - -// ── 경로 resolve 헬퍼 ── - -type HookScope = z.infer - -async function resolveSettingsFilePath( - scope: HookScope, - projectPath?: string, -): Promise { - const path = await import("node:path") - const os = await import("node:os") - - switch (scope) { - case "user": - return path.join(os.homedir(), ".claude", "settings.json") - case "project": - return path.join(projectPath ?? process.cwd(), ".claude", "settings.json") - case "local": - return path.join( - projectPath ?? process.cwd(), - ".claude", - "settings.local.json", - ) - } -} +import { resolveSettingsFilePath } from "@/features/hook/service" +import { + hookEventNameSchema, + hookScopeSchema, + matcherGroupSchema, +} from "@/features/hook/types" // ── Server Functions ── +export const getSettingsPathFn = createServerFn({ method: "GET" }) + .inputValidator( + z.object({ + scope: hookScopeSchema, + projectPath: z.string().optional(), + }), + ) + .handler(async ({ data }) => + resolveSettingsFilePath(data.scope, data.projectPath), + ) + export const getHooksFn = createServerFn({ method: "GET" }) .inputValidator( z.object({ @@ -79,7 +28,7 @@ export const getHooksFn = createServerFn({ method: "GET" }) }), ) .handler(async ({ data }) => { - const { getHooksFromSettings } = await import("@/services/hooks-service") + const { getHooksFromSettings } = await import("@/features/hook/service") const filePath = await resolveSettingsFilePath(data.scope, data.projectPath) return getHooksFromSettings(filePath) }) @@ -94,7 +43,7 @@ export const addHookFn = createServerFn({ method: "POST" }) }), ) .handler(async ({ data }) => { - const { addHookToSettings } = await import("@/services/hooks-service") + const { addHookToSettings } = await import("@/features/hook/service") const filePath = await resolveSettingsFilePath(data.scope, data.projectPath) await addHookToSettings(filePath, data.event, data.matcherGroup) return { success: true } @@ -111,7 +60,7 @@ export const removeHookFn = createServerFn({ method: "POST" }) }), ) .handler(async ({ data }) => { - const { removeHookFromSettings } = await import("@/services/hooks-service") + const { removeHookFromSettings } = await import("@/features/hook/service") const filePath = await resolveSettingsFilePath(data.scope, data.projectPath) await removeHookFromSettings( filePath, @@ -132,7 +81,7 @@ export const readScriptFn = createServerFn({ method: "GET" }) .handler(async ({ data }) => { const path = await import("node:path") const os = await import("node:os") - const { readScriptFile } = await import("@/services/hooks-service") + const { readScriptFile } = await import("@/features/hook/service") let resolvedPath = data.filePath diff --git a/src/services/hooks-service.test.ts b/src/features/hook/service.test.ts similarity index 98% rename from src/services/hooks-service.test.ts rename to src/features/hook/service.test.ts index 0f729b0..9ef3087 100644 --- a/src/services/hooks-service.test.ts +++ b/src/features/hook/service.test.ts @@ -8,8 +8,8 @@ import { readScriptFile, removeHookFromSettings, saveHooksToSettings, -} from "@/services/hooks-service" -import type { HookMatcherGroup, HooksSettings } from "@/shared/types" +} from "@/features/hook/service" +import type { HookMatcherGroup, HooksSettings } from "@/features/hook/types" let tmpDir: string let settingsFile: string diff --git a/src/services/hooks-service.ts b/src/features/hook/service.ts similarity index 84% rename from src/services/hooks-service.ts rename to src/features/hook/service.ts index 29b54cf..81fddec 100644 --- a/src/services/hooks-service.ts +++ b/src/features/hook/service.ts @@ -3,8 +3,32 @@ import path from "node:path" import type { HookEventName, HookMatcherGroup, + HookScope, HooksSettings, -} from "@/shared/types" +} from "@/features/hook/types" + +// ── 경로 resolve 헬퍼 ── + +export async function resolveSettingsFilePath( + scope: HookScope, + projectPath?: string, +): Promise { + const path = await import("node:path") + const os = await import("node:os") + + switch (scope) { + case "user": + return path.join(os.homedir(), ".claude", "settings.json") + case "project": + return path.join(projectPath ?? process.cwd(), ".claude", "settings.json") + case "local": + return path.join( + projectPath ?? process.cwd(), + ".claude", + "settings.local.json", + ) + } +} // ── settings.json 읽기 헬퍼 ── diff --git a/src/features/hook/types.ts b/src/features/hook/types.ts new file mode 100644 index 0000000..5b8e141 --- /dev/null +++ b/src/features/hook/types.ts @@ -0,0 +1,58 @@ +import { z } from "zod" + +// ── Zod Schemas (single source of truth) ──────────────────────────────────── + +export const hookScopeSchema = z.enum(["user", "project", "local"]) + +export const hookTypeSchema = z.enum(["command", "prompt", "agent"]) + +export const hookEntrySchema = z.object({ + type: hookTypeSchema, + command: z.string().optional(), + prompt: z.string().optional(), + model: z.string().optional(), + timeout: z.number().optional(), + async: z.boolean().optional(), + statusMessage: z.string().optional(), + once: z.boolean().optional(), +}) + +export const matcherGroupSchema = z.object({ + matcher: z.string().optional(), + hooks: z.array(hookEntrySchema).min(1), +}) + +export const hookEventNameSchema = z.enum([ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "SubagentStart", + "SubagentStop", + "Stop", + "TeammateIdle", + "TaskCompleted", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "Setup", + "PreCompact", + "SessionEnd", +]) + +// ── TypeScript Types (derived from schemas) ───────────────────────────────── + +export type HookScope = z.infer + +export type HookType = z.infer + +export type HookEntry = z.infer + +export type HookMatcherGroup = z.infer + +export type HookEventName = z.infer + +export type HooksSettings = Partial> diff --git a/src/features/hook/utils.ts b/src/features/hook/utils.ts new file mode 100644 index 0000000..5bb683b --- /dev/null +++ b/src/features/hook/utils.ts @@ -0,0 +1,74 @@ +import { FileCode, MessageSquare, Zap } from "lucide-react" +import type { HookEntry } from "@/features/hook/types" + +// ── Display helpers ───────────────────────────────────────────────────────── + +export function getHookDisplayName(hook: HookEntry): string { + if (hook.type === "command" && hook.command) { + const cmd = hook.command + .replace(/"\$CLAUDE_PROJECT_DIR"\//g, "") + .replace(/\$CLAUDE_PROJECT_DIR\//g, "") + const parts = cmd.split("/") + const last = parts[parts.length - 1] + if (parts.length === 1 && cmd.length > 30) { + return `${cmd.slice(0, 27)}...` + } + return last + } + if ((hook.type === "prompt" || hook.type === "agent") && hook.prompt) { + return hook.prompt.length > 30 + ? `${hook.prompt.slice(0, 27)}...` + : hook.prompt + } + return hook.type +} + +export function getHookIcon(hook: HookEntry): React.ElementType { + switch (hook.type) { + case "command": + return FileCode + case "prompt": + return MessageSquare + case "agent": + return Zap + default: + return FileCode + } +} + +// ── File path helpers ─────────────────────────────────────────────────────── + +const FILE_EXT_RE = /\.(sh|py|js|ts|cmd|bat|bash|zsh|rb|pl)(\s|$|['"])/ + +/** Hook command가 파일 경로인지 감지 */ +export function isHookFilePath(hook: HookEntry): boolean { + if (hook.type !== "command" || !hook.command) return false + return ( + FILE_EXT_RE.test(hook.command) || + hook.command.includes("$CLAUDE_") || + hook.command.startsWith(".claude/") + ) +} + +/** Hook command에서 파일 경로를 추출하고 변수를 치환 */ +export function resolveHookFilePath( + command: string, + context?: { + projectPath?: string | null + pluginInstallPath?: string + }, +): string { + let filePath = command.split(/\s/)[0] ?? "" + filePath = filePath.replace(/^['"]|['"]$/g, "") + if (context?.pluginInstallPath) { + filePath = filePath + .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, context.pluginInstallPath) + .replace(/\$CLAUDE_PLUGIN_ROOT/g, context.pluginInstallPath) + } + if (context?.projectPath) { + filePath = filePath + .replace(/"\$CLAUDE_PROJECT_DIR"/g, context.projectPath) + .replace(/\$CLAUDE_PROJECT_DIR/g, context.projectPath) + } + return filePath +} diff --git a/src/lib/hook-utils.ts b/src/lib/hook-utils.ts deleted file mode 100644 index 827d481..0000000 --- a/src/lib/hook-utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { HookEntry } from "@/shared/types" - -const FILE_EXT_RE = /\.(sh|py|js|ts|cmd|bat|bash|zsh|rb|pl)(\s|$|['"])/ - -/** Hook command가 파일 경로인지 감지 */ -export function isHookFilePath(hook: HookEntry): boolean { - if (hook.type !== "command" || !hook.command) return false - return ( - FILE_EXT_RE.test(hook.command) || - hook.command.includes("$CLAUDE_") || - hook.command.startsWith(".claude/") - ) -} - -/** Hook command에서 파일 경로를 추출하고 변수를 치환 */ -export function resolveHookFilePath( - command: string, - context?: { - projectPath?: string | null - pluginInstallPath?: string - }, -): string { - let filePath = command.split(/\s/)[0] ?? "" - filePath = filePath.replace(/^['"]|['"]$/g, "") - if (context?.pluginInstallPath) { - filePath = filePath - .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, context.pluginInstallPath) - .replace(/\$CLAUDE_PLUGIN_ROOT/g, context.pluginInstallPath) - } - if (context?.projectPath) { - filePath = filePath - .replace(/"\$CLAUDE_PROJECT_DIR"/g, context.projectPath) - .replace(/\$CLAUDE_PROJECT_DIR/g, context.projectPath) - } - return filePath -} diff --git a/src/services/plugin-service.ts b/src/services/plugin-service.ts index a5dae30..bd47c99 100644 --- a/src/services/plugin-service.ts +++ b/src/services/plugin-service.ts @@ -1,6 +1,7 @@ import type { Dirent } from "node:fs" import fs from "node:fs/promises" import path from "node:path" +import type { HooksSettings } from "@/features/hook/types" import { scanMdDir, scanSkillsDir } from "@/services/agent-file-service" import { getGlobalConfigPath, @@ -8,7 +9,6 @@ import { readSettingsJson, } from "@/services/config-service" import type { - HooksSettings, LspServer, McpServer, Plugin, diff --git a/src/shared/types.ts b/src/shared/types.ts index c9131df..2f1695a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import type { HooksSettings } from "@/features/hook/types" // ── Zod 스키마 ── export const scopeSchema = z.enum(["user", "project", "local", "managed"]) @@ -290,49 +291,6 @@ export interface ClaudeAppJson { [key: string]: unknown } -// ── Hooks ── -export type HookScope = "user" | "project" | "local" - -export type HookType = "command" | "prompt" | "agent" - -export interface HookEntry { - type: HookType - command?: string - prompt?: string - model?: string - timeout?: number - async?: boolean - statusMessage?: string - once?: boolean -} - -export interface HookMatcherGroup { - matcher?: string - hooks: HookEntry[] -} - -export type HookEventName = - | "SessionStart" - | "UserPromptSubmit" - | "PreToolUse" - | "PermissionRequest" - | "PostToolUse" - | "PostToolUseFailure" - | "Notification" - | "SubagentStart" - | "SubagentStop" - | "Stop" - | "TeammateIdle" - | "TaskCompleted" - | "ConfigChange" - | "WorktreeCreate" - | "WorktreeRemove" - | "Setup" - | "PreCompact" - | "SessionEnd" - -export type HooksSettings = Partial> - // ── MCP Connection Status ── export type McpConnectionStatus = | "connected" diff --git a/tests/integration/hooks-crud.test.ts b/tests/integration/hooks-crud.test.ts new file mode 100644 index 0000000..7220fb0 --- /dev/null +++ b/tests/integration/hooks-crud.test.ts @@ -0,0 +1,189 @@ +/** + * Hook 서비스 통합 테스트 - CRUD 흐름 + * + * 실제 파일시스템 기반으로 settings.json에 대한 + * Hook CRUD (add/get/remove) + resolveSettingsFilePath를 검증한다. + */ + +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + addHookToSettings, + getHooksFromSettings, + removeHookFromSettings, + saveHooksToSettings, +} from "@/features/hook/service" +import type { HookMatcherGroup, HooksSettings } from "@/features/hook/types" +import { createTmpDir, removeTmpDir, writeFile } from "../helpers/test-utils" + +let tmpGlobal: string +let tmpProject: string + +beforeEach(async () => { + tmpGlobal = await createTmpDir("hooks-global") + tmpProject = await createTmpDir("hooks-project") + + vi.spyOn(os, "homedir").mockReturnValue(tmpGlobal) + vi.spyOn(process, "cwd").mockReturnValue(tmpProject) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all([removeTmpDir(tmpGlobal), removeTmpDir(tmpProject)]) +}) + +// ── 1. Hook CRUD 흐름 ── + +describe("Hook CRUD 흐름", () => { + it("빈 settings.json → getHooksFromSettings → 빈 객체 반환", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({})) + + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks).toEqual({}) + }) + + it("settings.json 없음 → getHooksFromSettings → 빈 객체 반환", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks).toEqual({}) + }) + + it("addHookToSettings → getHooksFromSettings → 추가된 hook 확인", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({})) + + const group: HookMatcherGroup = { + hooks: [{ type: "command", command: "echo hello" }], + } + await addHookToSettings(settingsPath, "PreToolUse", group) + + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks.PreToolUse).toHaveLength(1) + expect(hooks.PreToolUse?.[0].hooks[0].command).toBe("echo hello") + }) + + it("addHookToSettings 두 번 → 같은 이벤트에 2개 그룹 추가", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({})) + + const group1: HookMatcherGroup = { + matcher: "Bash", + hooks: [{ type: "command", command: "lint.sh" }], + } + const group2: HookMatcherGroup = { + matcher: "Edit", + hooks: [{ type: "command", command: "format.sh" }], + } + + await addHookToSettings(settingsPath, "PreToolUse", group1) + await addHookToSettings(settingsPath, "PreToolUse", group2) + + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks.PreToolUse).toHaveLength(2) + expect(hooks.PreToolUse?.[0].matcher).toBe("Bash") + expect(hooks.PreToolUse?.[1].matcher).toBe("Edit") + }) + + it("removeHookFromSettings → hook 삭제 후 조회 검증", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({})) + + const group: HookMatcherGroup = { + hooks: [ + { type: "command", command: "first.sh" }, + { type: "command", command: "second.sh" }, + ], + } + await addHookToSettings(settingsPath, "PostToolUse", group) + + // 첫 번째 hook 삭제 (groupIndex: 0, hookIndex: 0) + await removeHookFromSettings(settingsPath, "PostToolUse", 0, 0) + + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks.PostToolUse).toHaveLength(1) + expect(hooks.PostToolUse?.[0].hooks).toHaveLength(1) + expect(hooks.PostToolUse?.[0].hooks[0].command).toBe("second.sh") + }) + + it("마지막 hook 삭제 → 이벤트 키 자체가 제거됨", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({})) + + const group: HookMatcherGroup = { + hooks: [{ type: "command", command: "only.sh" }], + } + await addHookToSettings(settingsPath, "Stop", group) + await removeHookFromSettings(settingsPath, "Stop", 0, 0) + + const hooks = await getHooksFromSettings(settingsPath) + expect(hooks.Stop).toBeUndefined() + }) + + it("모든 hook 삭제 → settings.json에서 hooks 키 제거됨", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile(settingsPath, JSON.stringify({ model: "opus", hooks: {} })) + + const group: HookMatcherGroup = { + hooks: [{ type: "command", command: "test.sh" }], + } + await addHookToSettings(settingsPath, "SessionStart", group) + await removeHookFromSettings(settingsPath, "SessionStart", 0, 0) + + // hooks 키가 제거되고 기존 설정은 유지 + const raw = JSON.parse(await fs.readFile(settingsPath, "utf-8")) + expect(raw.hooks).toBeUndefined() + expect(raw.model).toBe("opus") + }) + + it("기존 설정 보존 — hook 추가/삭제 시 다른 키 유지", async () => { + const settingsPath = path.join(tmpGlobal, ".claude", "settings.json") + await writeFile( + settingsPath, + JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }), + ) + + const group: HookMatcherGroup = { + hooks: [{ type: "command", command: "echo test" }], + } + await addHookToSettings(settingsPath, "PreToolUse", group) + + const raw = JSON.parse(await fs.readFile(settingsPath, "utf-8")) + expect(raw.model).toBe("sonnet") + expect(raw.permissions).toEqual({ allow: ["Bash"] }) + expect(raw.hooks.PreToolUse).toHaveLength(1) + }) +}) + +// ── 3. saveHooksToSettings ── + +describe("saveHooksToSettings", () => { + it("빈 HooksSettings 저장 → hooks 키 제거", async () => { + const settingsPath = path.join(tmpProject, ".claude", "settings.json") + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command", command: "x" }] }] }, + }), + ) + + await saveHooksToSettings(settingsPath, {}) + + const raw = JSON.parse(await fs.readFile(settingsPath, "utf-8")) + expect(raw.hooks).toBeUndefined() + }) + + it("settings.json 없는 경로에 저장 → 디렉토리+파일 자동 생성", async () => { + const settingsPath = path.join(tmpProject, ".claude", "settings.json") + + const hooks: HooksSettings = { + SessionStart: [{ hooks: [{ type: "command", command: "echo start" }] }], + } + await saveHooksToSettings(settingsPath, hooks) + + const raw = JSON.parse(await fs.readFile(settingsPath, "utf-8")) + expect(raw.hooks.SessionStart).toHaveLength(1) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index dbef6ab..35f477b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,11 @@ function mergeMessagesPlugin() { } const config = defineConfig({ + server: { + watch: { + ignored: ["**/.claude/**"], + }, + }, plugins: [ mergeMessagesPlugin(), paraglideVitePlugin({