From c55fa9f30231e5407a71fa2a9157cecff987160d Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Thu, 19 Mar 2026 01:16:39 +0900 Subject: [PATCH 1/3] docs(roadmap): add typography components and sidebar restoration to backlog Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ROADMAP.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 928373d..786a86d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,6 +110,10 @@ Claude Code가 자체 설정 관리 GUI를 추가하면, 그걸 쓰면 된다. a - [ ] 스크린샷 — `docs/screenshots/`에 대시보드 캡처 2-3장, README에 삽입 - [ ] .github/ issue template — `bug_report.md`, `feature_request.md` +### UI / UX +- [ ] Typography 컴포넌트 추가 — shadcn typography 참고하여 `H1`, `H2`, `H3`, `Muted` 등 자주 쓰는 타이포 컴포넌트를 `components/ui/`에 정의 +- [ ] 사이드바 복원 — 추가한 프로젝트 리스트를 사이드바에 표시하여 빠른 전환 지원. `⌘+B`로 토글. 현재 스위치 방식보다 직관적 + ### 코드 정리 - [ ] PascalCase → kebab-case 일괄 리네이밍 — `src/components/` 내 기존 PascalCase 파일을 kebab-case로 통일 - [ ] 대시보드 ActionDropdown → 디테일 패널 액션 이동 — 리스트 아이템 → 디테일 패널 헤더로 통합 From f1abbdb808d3913aafc1de74001599a6f1aef9b3 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Thu, 19 Mar 2026 09:04:38 +0900 Subject: [PATCH 2/3] refactor(skill): migrate skill domain to features/skill directory - Move types, utils, config, service, server, queries, components to src/features/skill/ - Merge 4 util files (skill-utils, skill-install-status, slash-command, skill-constants) into single utils.ts - Merge skillConfig + commandConfig into single config.ts - Merge use-skills + use-skill-detail into single queries.ts - Co-locate unit tests (utils.test.ts, config.test.ts) and integration tests (service.test.ts) - Update all import paths across codebase - Update docs (ARCHITECTURE, CONVENTIONS, DESIGN-SYSTEM, TESTING, ROADMAP) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ARCHITECTURE.md | 2 +- docs/CONVENTIONS.md | 4 +- docs/DESIGN-SYSTEM.md | 8 +- docs/ROADMAP.md | 1 + docs/TESTING.md | 2 +- src/components/board/AddSkillDialog.tsx | 4 +- src/components/board/PluginsPanel.tsx | 2 +- src/components/board/entity-inspector.tsx | 2 +- src/components/marketplace/skills-tab.tsx | 10 +- src/config/entities/index.ts | 7 +- src/config/entities/skill-config.tsx | 12 -- .../skill/components}/skill-inspector.tsx | 12 +- .../skill/components}/skill-list-item.tsx | 2 +- .../skill/config.test.ts} | 8 +- .../skill/config.ts} | 9 + .../skill/queries.ts} | 24 ++- .../skills.ts => features/skill/server.ts} | 4 +- src/features/skill/service.test.ts | 193 ++++++++++++++++++ .../skill/service.ts} | 55 +---- src/features/skill/types.ts | 57 ++++++ src/features/skill/utils.test.ts | 179 ++++++++++++++++ src/features/skill/utils.ts | 49 +++++ src/hooks/use-skill-detail.ts | 25 --- src/lib/skill-constants.ts | 16 -- src/lib/skill-install-status.test.ts | 90 -------- src/lib/skill-install-status.ts | 28 --- src/lib/skill-utils.test.ts | 58 ------ src/lib/skill-utils.ts | 14 -- src/lib/slash-command.ts | 11 - src/server/items.ts | 2 +- src/server/marketplace.ts | 2 +- 31 files changed, 548 insertions(+), 344 deletions(-) delete mode 100644 src/config/entities/skill-config.tsx rename src/{components/skill => features/skill/components}/skill-inspector.tsx (97%) rename src/{components/skill => features/skill/components}/skill-list-item.tsx (93%) rename src/{config/entities/skill-config.test.ts => features/skill/config.test.ts} (78%) rename src/{config/entities/command-config.tsx => features/skill/config.ts} (62%) rename src/{hooks/use-skills.ts => features/skill/queries.ts} (89%) rename src/{server/skills.ts => features/skill/server.ts} (99%) create mode 100644 src/features/skill/service.test.ts rename src/{services/skills-service.ts => features/skill/service.ts} (90%) create mode 100644 src/features/skill/types.ts create mode 100644 src/features/skill/utils.test.ts create mode 100644 src/features/skill/utils.ts delete mode 100644 src/hooks/use-skill-detail.ts delete mode 100644 src/lib/skill-constants.ts delete mode 100644 src/lib/skill-install-status.test.ts delete mode 100644 src/lib/skill-install-status.ts delete mode 100644 src/lib/skill-utils.test.ts delete mode 100644 src/lib/skill-utils.ts delete mode 100644 src/lib/slash-command.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a19c354..8a0eac5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,9 +67,9 @@ EntityConfig (설정 객체) 공통 UI Primitives 도메 src/ features/ ← 도메인별 feature 디렉토리 (타입, 서비스, 서버, 쿼리, 컴포넌트 co-locate) hook/ ← Hook 도메인 (types, constants, utils, service, server, queries, config, components/) + skill/ ← Skill/Command 도메인 (types, utils, config, service, server, queries, components/) components/ ui/ ← shadcn primitives (Button, Sheet, ListItem, Inspector 등) - skill/ ← SkillInspector, SkillListItem memory/ ← MemoryInspector agent/ ← AgentInspector mcp/ ← McpInspector diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index a163282..e2cfde3 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -22,9 +22,9 @@ ## 서비스 파일 규칙 -- 인프라(CLI spawn, 파일 I/O)와 비즈니스 로직을 같은 서비스 파일에 배치 (`claude-cli.ts`, `skills-service.ts` 참조) +- 인프라(CLI spawn, 파일 I/O)와 비즈니스 로직을 같은 서비스 파일에 배치 (`claude-cli.ts`, `features/skill/service.ts` 참조) - 별도 repository 파일로 분리하지 말 것 — 프로젝트 규모에서 과도한 추상화 -- 서버 함수(`createServerFn`)는 도메인별 파일에 배치 (예: 스킬 CRUD → `server/skills.ts`, 마켓플레이스 검색 → `server/marketplace.ts`) +- Feature directory 패턴: 도메인별 타입, 서비스, 서버 함수, 쿼리, 컴포넌트를 `src/features/{domain}/`에 co-locate (예: `features/hook/`, `features/skill/`) ## shared/ 디렉토리 규칙 diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 98c04b7..be01957 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -197,7 +197,7 @@ export function XxxPanel({ scopeFilter, onSelectItem, onAction }: XxxPanelProps) 2. `src/components/board/entity-inspector.tsx` — Inspector 라우팅 3. `src/components/icons/entity-icons.tsx` — 아이콘 추가 4. `messages/en/common.json` + `messages/ko/common.json` — `board_no_*` 메시지 -5. `src/config/entities/` — 엔티티 config 파일 +5. `src/config/entities/` 또는 `src/features/{domain}/config.ts` — 엔티티 config 파일 ## Inspector 패턴 @@ -235,10 +235,10 @@ export function XxxInspector({ itemKey }: { itemKey: string }) { ## 참조 구현 새 패널이나 디테일 뷰를 추가할 때는 기존 구현을 참조: -- 엔티티 config: `src/config/entities/command-config.tsx` (groupBy 포함 최신 패턴) +- 엔티티 config: `src/features/skill/config.ts` (groupBy 포함 최신 패턴) - 범용 패널: `src/components/board/EntityListPanel.tsx` (flat + grouped 지원) -- Inspector: `src/components/skill/skill-inspector.tsx` -- Feature directory 패턴: `src/features/hook/` (타입, 서비스, 서버, 쿼리, 컴포넌트 co-locate) +- Inspector: `src/features/skill/components/skill-inspector.tsx` +- Feature directory 패턴: `src/features/hook/`, `src/features/skill/` (타입, 서비스, 서버, 쿼리, 컴포넌트 co-locate) - 레이아웃: `src/components/board/BoardLayout.tsx` ## 금지 사항 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 786a86d..b452324 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -129,6 +129,7 @@ Claude Code가 자체 설정 관리 GUI를 추가하면, 그걸 쓰면 된다. a ## Shipped +- **Skill feature directory 이관** (2026-03-19) — Skill/Command 도메인을 `src/features/skill/`로 통합. utils 4개 파일 병합, config 병합, 유닛 테스트 co-locate. - **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/ 해소, 도메인 디렉토리 이동. diff --git a/docs/TESTING.md b/docs/TESTING.md index e1f510f..de92b88 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,7 +9,7 @@ Vitest, `pnpm test` (전체), `pnpm test -- --run ` (특정 파일) | 유형 | 위치 | 규칙 | |------|------|------| | Unit | `src/**/*.test.ts` | 소스 옆 co-locate, mock 허용 | -| Integration | `tests/integration/*.test.ts` | 실제 파일시스템/API/CLI, mock 최소화 | +| Integration | `src/features/**/service.test.ts` 또는 `tests/integration/*.test.ts` | 실제 파일시스템/API/CLI, mock 최소화. Feature directory는 co-locate 우선 | | E2E | `tests/e2e/*.test.ts` | 빌드 아티팩트 + 서버 프로세스 실행 | | 공통 헬퍼 | `tests/helpers/test-utils.ts` | `createTmpDir`, `removeTmpDir`, `writeFile`, `exists` | diff --git a/src/components/board/AddSkillDialog.tsx b/src/components/board/AddSkillDialog.tsx index 3b4a32a..ac037d7 100644 --- a/src/components/board/AddSkillDialog.tsx +++ b/src/components/board/AddSkillDialog.tsx @@ -17,8 +17,8 @@ import { FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { useSkillMutations } from "@/hooks/use-skills" -import { addSkillSchema } from "@/lib/skill-constants" +import { useSkillMutations } from "@/features/skill/queries" +import { addSkillSchema } from "@/features/skill/types" import { m } from "@/paraglide/messages" import type { Scope } from "@/shared/types" diff --git a/src/components/board/PluginsPanel.tsx b/src/components/board/PluginsPanel.tsx index 4284dc7..c29415e 100644 --- a/src/components/board/PluginsPanel.tsx +++ b/src/components/board/PluginsPanel.tsx @@ -3,7 +3,6 @@ import { ChevronDown } from "lucide-react" import { type ElementType, useEffect, useMemo, useRef, useState } from "react" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import { useProjectContext } from "@/components/ProjectContext" -import { SkillListItem } from "@/components/skill/skill-list-item" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible" @@ -13,6 +12,7 @@ import { EntityActionDropdown, } from "@/components/ui/entity-action-menu" import { ListItem, ListSubItem } from "@/components/ui/list-item" +import { SkillListItem } from "@/features/skill/components/skill-list-item" import { useMcpStatusQuery } from "@/hooks/use-mcp" import { usePluginsQuery } from "@/hooks/use-plugins" import type { EntityActionId } from "@/lib/entity-actions" diff --git a/src/components/board/entity-inspector.tsx b/src/components/board/entity-inspector.tsx index ed7ec7b..587063a 100644 --- a/src/components/board/entity-inspector.tsx +++ b/src/components/board/entity-inspector.tsx @@ -5,8 +5,8 @@ import { FileInspector } from "@/components/file/file-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" +import { SkillInspector } from "@/features/skill/components/skill-inspector" interface EntityInspectorProps { type: string diff --git a/src/components/marketplace/skills-tab.tsx b/src/components/marketplace/skills-tab.tsx index 3d97214..2447837 100644 --- a/src/components/marketplace/skills-tab.tsx +++ b/src/components/marketplace/skills-tab.tsx @@ -1,16 +1,16 @@ import { PackageSearchIcon } from "lucide-react" import { useCallback, useState } from "react" -import { SkillInspector } from "@/components/skill/skill-inspector" -import { SkillListItem } from "@/components/skill/skill-list-item" import { Badge } from "@/components/ui/badge" import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" import { ListItemSkeleton } from "@/components/ui/list-item" +import { SkillInspector } from "@/features/skill/components/skill-inspector" +import { SkillListItem } from "@/features/skill/components/skill-list-item" +import { useSkillActions, useSkillsQuery } from "@/features/skill/queries" +import type { MarketplaceSkill } from "@/features/skill/types" +import { isSameSkill } from "@/features/skill/utils" import { useMarketplaceSearch } from "@/hooks/use-marketplace" -import { useSkillActions, useSkillsQuery } from "@/hooks/use-skills" import { formatInstalls } from "@/lib/format" -import { isSameSkill } from "@/lib/skill-utils" import { m } from "@/paraglide/messages" -import type { MarketplaceSkill } from "@/services/skills-service" import type { AgentFile } from "@/shared/types" import { MarketplaceSearchBar } from "./marketplace-search-bar" diff --git a/src/config/entities/index.ts b/src/config/entities/index.ts index b0e2bb1..c1ca995 100644 --- a/src/config/entities/index.ts +++ b/src/config/entities/index.ts @@ -1,14 +1,12 @@ import { registerEntity } from "@/config/entity-registry" import { hookConfig } from "@/features/hook/config" +import { commandConfig, skillConfig } from "@/features/skill/config" import { agentConfig } from "./agent-config" -import { commandConfig } from "./command-config" import { fileConfig } from "./file-config" import { mcpConfig } from "./mcp-config" import { memoryConfig } from "./memory-config" import { pluginConfig } from "./plugin-config" -import { skillConfig } from "./skill-config" -// 모듈 import 시 자동 등록 registerEntity(skillConfig) registerEntity(commandConfig) registerEntity(agentConfig) @@ -20,11 +18,10 @@ registerEntity(fileConfig) export type { HookItem } from "@/features/hook/config" export { hookConfig } from "@/features/hook/config" +export { commandConfig, skillConfig } from "@/features/skill/config" export { agentConfig } from "./agent-config" -export { commandConfig } from "./command-config" export type { FileItem } from "./file-config" export { fileConfig } from "./file-config" export { mcpConfig } from "./mcp-config" export { memoryConfig } from "./memory-config" export { pluginConfig } from "./plugin-config" -export { skillConfig } from "./skill-config" diff --git a/src/config/entities/skill-config.tsx b/src/config/entities/skill-config.tsx deleted file mode 100644 index 2b531ba..0000000 --- a/src/config/entities/skill-config.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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", - actions: ENTITY_ACTIONS.skill.map((a) => a.id), - getKey: (item) => item.path, - getLabel: (item) => item.frontmatter?.name ?? item.name, - getDescription: (item) => item.frontmatter?.description, - getScope: (item) => item.scope, -} diff --git a/src/components/skill/skill-inspector.tsx b/src/features/skill/components/skill-inspector.tsx similarity index 97% rename from src/components/skill/skill-inspector.tsx rename to src/features/skill/components/skill-inspector.tsx index 2405f51..e87daee 100644 --- a/src/components/skill/skill-inspector.tsx +++ b/src/features/skill/components/skill-inspector.tsx @@ -35,22 +35,16 @@ import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { useAgentFiles } from "@/hooks/use-config" import { usePluginsQuery } from "@/hooks/use-plugins" -import { useSkillDetailQuery } from "@/hooks/use-skill-detail" -import { useSkillActions } from "@/hooks/use-skills" import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" import { extractBody, formatDate, formatInstalls } from "@/lib/format" import { queryKeys } from "@/lib/query-keys" -import { resolveInstallStatus } from "@/lib/skill-install-status" -import { getSlashCommand } from "@/lib/slash-command" import { m } from "@/paraglide/messages" import { getLocale } from "@/paraglide/runtime" -import type { MarketplaceSkill } from "@/services/skills-service" import type { AgentFile, AgentType, InstallStatus } from "@/shared/types" import { agentTypeSchema } from "@/shared/types" - -function isAgentFile(item: AgentFile | MarketplaceSkill): item is AgentFile { - return "path" in item -} +import { useSkillActions, useSkillDetailQuery } from "../queries" +import type { MarketplaceSkill } from "../types" +import { getSlashCommand, isAgentFile, resolveInstallStatus } from "../utils" interface SkillInspectorProps { itemKey: string diff --git a/src/components/skill/skill-list-item.tsx b/src/features/skill/components/skill-list-item.tsx similarity index 93% rename from src/components/skill/skill-list-item.tsx rename to src/features/skill/components/skill-list-item.tsx index 0e14943..c014db5 100644 --- a/src/components/skill/skill-list-item.tsx +++ b/src/features/skill/components/skill-list-item.tsx @@ -1,7 +1,7 @@ import { ENTITY_ICONS } from "@/components/icons/entity-icons" import { ListItem } from "@/components/ui/list-item" -import type { MarketplaceSkill } from "@/services/skills-service" import type { AgentFile } from "@/shared/types" +import type { MarketplaceSkill } from "../types" interface SkillListItemProps { skill: AgentFile | MarketplaceSkill diff --git a/src/config/entities/skill-config.test.ts b/src/features/skill/config.test.ts similarity index 78% rename from src/config/entities/skill-config.test.ts rename to src/features/skill/config.test.ts index 5bf3b17..e3f27bd 100644 --- a/src/config/entities/skill-config.test.ts +++ b/src/features/skill/config.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "vitest" import type { AgentFile } from "@/shared/types" -import { commandConfig } from "./command-config" -import { skillConfig } from "./skill-config" +import { commandConfig, skillConfig } from "./config" -function makeAgentFile(overrides: Partial): AgentFile { +function makeAgentFile(overrides: Partial = {}): AgentFile { return { name: "test", scope: "user", @@ -20,7 +19,7 @@ describe("skillConfig", () => { expect(skillConfig.type).toBe("skill") }) - it("groupBy가 없어야 한다 (skills는 namespace 없음)", () => { + it("groupBy가 없어야 한다", () => { expect(skillConfig.groupBy).toBeUndefined() }) }) @@ -36,7 +35,6 @@ describe("commandConfig", () => { name: "greet", namespace: "ys", }) - expect(commandConfig.groupBy).toBeDefined() expect(commandConfig.groupBy?.(cmd)).toBe("ys") }) diff --git a/src/config/entities/command-config.tsx b/src/features/skill/config.ts similarity index 62% rename from src/config/entities/command-config.tsx rename to src/features/skill/config.ts index 0fd94c4..bdef71a 100644 --- a/src/config/entities/command-config.tsx +++ b/src/features/skill/config.ts @@ -2,6 +2,15 @@ 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", + actions: ENTITY_ACTIONS.skill.map((a) => a.id), + getKey: (item) => item.path, + getLabel: (item) => item.frontmatter?.name ?? item.name, + getDescription: (item) => item.frontmatter?.description, + getScope: (item) => item.scope, +} + export const commandConfig: EntityConfig = { type: "command", actions: ENTITY_ACTIONS.skill.map((a) => a.id), diff --git a/src/hooks/use-skills.ts b/src/features/skill/queries.ts similarity index 89% rename from src/hooks/use-skills.ts rename to src/features/skill/queries.ts index c214587..c2a90e3 100644 --- a/src/hooks/use-skills.ts +++ b/src/features/skill/queries.ts @@ -2,16 +2,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 type { AgentFile, AgentType, InstallStatus, Scope } from "@/shared/types" import { createSkillFn, + getSkillDetailFn, installSkillFn, readSkillsLockFn, readSupportingFileFn, removeSkillFn, saveFrontmatterFn, -} from "@/server/skills" -import type { MarketplaceSkill } from "@/services/skills-service" -import type { AgentType, InstallStatus, Scope } from "@/shared/types" +} from "./server" +import type { MarketplaceSkill } from "./types" +import { isAgentFile } from "./utils" // ── Queries ────────────────────────────────────────────────────────────────── @@ -58,6 +60,22 @@ export function useSkillsLock() { }) } +export function useSkillDetailQuery(item: AgentFile | MarketplaceSkill) { + const isLocal = isAgentFile(item) + + return useQuery({ + queryKey: isLocal + ? ["skillDetail", item.path] + : ["skillDetail", item.source, item.skillId], + queryFn: () => + getSkillDetailFn({ + data: isLocal + ? { path: item.path } + : { source: item.source, skillId: item.skillId }, + }), + }) +} + // ── Mutations ──────────────────────────────────────────────────────────────── export function useSkillMutations() { diff --git a/src/server/skills.ts b/src/features/skill/server.ts similarity index 99% rename from src/server/skills.ts rename to src/features/skill/server.ts index 84098dd..d2b543c 100644 --- a/src/server/skills.ts +++ b/src/features/skill/server.ts @@ -1,12 +1,12 @@ import { createServerFn } from "@tanstack/react-start" import { z } from "zod" +import { agentTypeSchema, scopeSchema } from "@/shared/types" import { getSkillDetail, installSkill, readSkillsLock, removeSkill, -} from "@/services/skills-service" -import { agentTypeSchema, scopeSchema } from "@/shared/types" +} from "./service" export const readSupportingFileFn = createServerFn({ method: "GET" }) .inputValidator( diff --git a/src/features/skill/service.test.ts b/src/features/skill/service.test.ts new file mode 100644 index 0000000..83b8e6a --- /dev/null +++ b/src/features/skill/service.test.ts @@ -0,0 +1,193 @@ +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + createTmpDir, + exists, + removeTmpDir, + writeFile, +} from "../../../tests/helpers/test-utils" +import { + getSkillDetail, + installSkill, + readSkillsLock, + removeSkill, + searchMarketplace, +} from "./service" + +describe("searchMarketplace", () => { + it("returns skills sorted by installs", async () => { + const results = await searchMarketplace("react", 5) + expect(results.length).toBeGreaterThan(0) + expect(results[0]).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + source: expect.any(String), + installs: expect.any(Number), + }) + for (let i = 1; i < results.length; i++) { + expect(results[i - 1].installs).toBeGreaterThanOrEqual( + results[i].installs, + ) + } + }) + + it("returns empty for nonsense query", async () => { + expect(await searchMarketplace("xyzzy_nonexistent_12345", 5)).toEqual([]) + }) +}) + +describe("readSkillsLock", () => { + let tmpDir: string + beforeEach(async () => { + tmpDir = await createTmpDir("skills-lock") + }) + afterEach(async () => { + await removeTmpDir(tmpDir) + }) + + it("reads project lock with scope 'project'", async () => { + await writeFile( + path.join(tmpDir, "skills-lock.json"), + JSON.stringify({ + version: 1, + skills: { + "test-skill": { + source: "owner/repo", + sourceType: "github", + computedHash: "abc", + }, + }, + }), + ) + const result = await readSkillsLock(tmpDir) + expect(result.skills["test-skill"]).toMatchObject({ + source: "owner/repo", + sourceType: "github", + scope: "project", + }) + }) + + it("reads global lock with scope 'user'", async () => { + // Global lock is always read from ~/.agents/.skill-lock.json + // We can only verify it returns non-null and has expected shape + const result = await readSkillsLock(tmpDir) + expect(result).toHaveProperty("skills") + // Global lock entries (if any) should have scope "user" + for (const entry of Object.values(result.skills)) { + expect(["user", "project"]).toContain(entry.scope) + } + }) + + it("returns empty skills for missing files", async () => { + const result = await readSkillsLock(tmpDir) + // No project lock in tmpDir; global lock entries may exist + expect(result).toHaveProperty("skills") + }) +}) + +describe("installSkill / removeSkill", () => { + let tmpDir: string + beforeEach(async () => { + tmpDir = await createTmpDir("skills-cli") + const { execSync } = await import("node:child_process") + execSync("git init", { cwd: tmpDir, stdio: "ignore" }) + }) + afterEach(async () => { + await removeTmpDir(tmpDir) + }) + + it("installs and removes a skill", { timeout: 90_000 }, async () => { + const installed = await installSkill({ + source: "vercel-labs/skills", + skill: "find-skills", + scope: "project", + agent: "claude-code", + projectPath: tmpDir, + }) + expect(installed.success).toBe(true) + expect( + await exists(path.join(tmpDir, ".claude/skills/find-skills/SKILL.md")), + ).toBe(true) + + const lock = await readSkillsLock(tmpDir) + expect(lock.skills["find-skills"]).toMatchObject({ + source: "vercel-labs/skills", + scope: "project", + }) + + const removed = await removeSkill({ + name: "find-skills", + scope: "project", + agent: "claude-code", + projectPath: tmpDir, + }) + expect(removed.success).toBe(true) + expect(await exists(path.join(tmpDir, ".claude/skills/find-skills"))).toBe( + false, + ) + }) +}) + +describe("getSkillDetail", () => { + let tmpDir: string + beforeEach(async () => { + tmpDir = await createTmpDir("skill-detail") + }) + afterEach(async () => { + await removeTmpDir(tmpDir) + }) + + it("returns displayName from frontmatter", async () => { + const skillPath = path.join(tmpDir, "test-skill/SKILL.md") + await writeFile( + skillPath, + "---\nname: My Custom Display Name\ndescription: test\n---\n\n# Test Skill", + ) + const skill = await getSkillDetail({ path: skillPath }) + expect(skill).toMatchObject({ + name: "test-skill", + displayName: "My Custom Display Name", + installStatus: "installed", + }) + expect(skill.content).toContain("# Test Skill") + }) + + it("has no displayName when frontmatter lacks name", async () => { + const skillPath = path.join(tmpDir, "no-name-skill/SKILL.md") + await writeFile( + skillPath, + "---\ndescription: no name field\n---\n\n# No Name", + ) + const skill = await getSkillDetail({ path: skillPath }) + expect(skill.name).toBe("no-name-skill") + expect(skill.displayName).toBeUndefined() + }) + + it("fetches from GitHub", { timeout: 30_000 }, async () => { + const skill = await getSkillDetail({ + source: "vercel-labs/skills", + skillId: "find-skills", + }) + expect(skill).toMatchObject({ + name: "find-skills", + source: "vercel-labs/skills", + installStatus: "not_installed", + }) + expect(skill.content).toContain("# Find Skills") + }) + + it( + "returns undefined content for nonexistent paths", + { timeout: 30_000 }, + async () => { + const local = await getSkillDetail({ path: "/nonexistent/SKILL.md" }) + expect(local.content).toBeUndefined() + + const remote = await getSkillDetail({ + source: "vercel-labs/skills", + skillId: "nonexistent-xyz", + }) + expect(remote.content).toBeUndefined() + }, + ) +}) diff --git a/src/services/skills-service.ts b/src/features/skill/service.ts similarity index 90% rename from src/services/skills-service.ts rename to src/features/skill/service.ts index 4b679dc..e5cf03b 100644 --- a/src/services/skills-service.ts +++ b/src/features/skill/service.ts @@ -1,55 +1,18 @@ -// src/services/skills-service.ts +// src/features/skill/service.ts // Skills domain logic: marketplace search, CLI spawn, lock file reading, skill detail import { spawn } from "node:child_process" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import type { Skill } from "@/shared/types" - -// ── Types ──────────────────────────────────────────────────────────────────── - -export interface SkillsCliResult { - success: boolean - output: string -} - -export interface SkillsInstallOptions { - source: string - skill: string - scope: "user" | "project" - agent?: string - projectPath?: string -} - -export interface SkillsRemoveOptions { - name: string - scope: "user" | "project" - agent?: string - projectPath?: string -} - -export interface MarketplaceSkill { - id: string - skillId: string - name: string - source: string - installs: number - description?: string -} - -export interface SkillsLockEntry { - source: string - sourceType: string - scope: "user" | "project" -} - -export interface SkillsLockData { - skills: Record -} - -export type SkillDetailParams = - | { path: string } - | { source: string; skillId: string } +import type { + MarketplaceSkill, + SkillDetailParams, + SkillsCliResult, + SkillsInstallOptions, + SkillsLockData, + SkillsRemoveOptions, +} from "./types" // ── Private: CLI infrastructure ────────────────────────────────────────────── diff --git a/src/features/skill/types.ts b/src/features/skill/types.ts new file mode 100644 index 0000000..f9837a4 --- /dev/null +++ b/src/features/skill/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod" + +// ── Zod Schemas ── + +export const addSkillSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(64, "Name must be 64 characters or less") + .regex(/^[a-z0-9-]+$/, "Only lowercase letters, numbers, and hyphens"), + description: z.string().min(1, "Description is required"), +}) + +// ── Types ── + +export interface SkillsCliResult { + success: boolean + output: string +} + +export interface SkillsInstallOptions { + source: string + skill: string + scope: "user" | "project" + agent?: string + projectPath?: string +} + +export interface SkillsRemoveOptions { + name: string + scope: "user" | "project" + agent?: string + projectPath?: string +} + +export interface MarketplaceSkill { + id: string + skillId: string + name: string + source: string + installs: number + description?: string +} + +export interface SkillsLockEntry { + source: string + sourceType: string + scope: "user" | "project" +} + +export interface SkillsLockData { + skills: Record +} + +export type SkillDetailParams = + | { path: string } + | { source: string; skillId: string } diff --git a/src/features/skill/utils.test.ts b/src/features/skill/utils.test.ts new file mode 100644 index 0000000..b19e2da --- /dev/null +++ b/src/features/skill/utils.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest" +import type { AgentFile } from "@/shared/types" +import type { MarketplaceSkill } from "./types" +import { + getSlashCommand, + isAgentFile, + isSameSkill, + resolveInstallStatus, +} from "./utils" + +function makeAgentFile(overrides: Partial = {}): AgentFile { + return { + name: "test", + scope: "user", + path: "/test.md", + size: 100, + lastModified: new Date().toISOString(), + type: "skill", + ...overrides, + } +} + +const marketplaceA: MarketplaceSkill = { + id: "vercel-labs/skills/find-skills", + skillId: "find-skills", + name: "find-skills", + source: "vercel-labs/skills", + installs: 100, +} + +const marketplaceB: MarketplaceSkill = { + ...marketplaceA, + id: "skills.volces.com/find-skills", + source: "skills.volces.com", + installs: 50, +} + +describe("isAgentFile", () => { + it("AgentFile이면 true", () => { + expect(isAgentFile(makeAgentFile())).toBe(true) + }) + + it("MarketplaceSkill이면 false", () => { + expect(isAgentFile(marketplaceA)).toBe(false) + }) +}) + +describe("isSameSkill", () => { + it("같은 AgentFile (같은 path)", () => { + const a = makeAgentFile() + expect(isSameSkill(a, { ...a })).toBe(true) + }) + + it("다른 AgentFile (다른 path)", () => { + const a = makeAgentFile() + const b = makeAgentFile({ path: "/other.md", name: "other" }) + expect(isSameSkill(a, b)).toBe(false) + }) + + it("같은 MarketplaceSkill (같은 id)", () => { + expect(isSameSkill(marketplaceA, { ...marketplaceA })).toBe(true) + }) + + it("다른 MarketplaceSkill (다른 id)", () => { + expect(isSameSkill(marketplaceA, marketplaceB)).toBe(false) + }) + + it("AgentFile vs MarketplaceSkill → false", () => { + expect(isSameSkill(makeAgentFile(), marketplaceA)).toBe(false) + expect(isSameSkill(marketplaceA, makeAgentFile())).toBe(false) + }) +}) + +describe("getSlashCommand", () => { + it("namespace 없으면 /name", () => { + const file = makeAgentFile({ name: "greet", type: "command" }) + expect(getSlashCommand(file)).toBe("/greet") + }) + + it("namespace 있으면 /namespace:name", () => { + const file = makeAgentFile({ + name: "greet", + namespace: "ys", + type: "command", + }) + expect(getSlashCommand(file)).toBe("/ys:greet") + }) + + it("frontmatter.name이 있으면 frontmatter.name 사용", () => { + const file = makeAgentFile({ + name: "greet", + namespace: "ys", + type: "command", + frontmatter: { name: "hello" }, + }) + expect(getSlashCommand(file)).toBe("/ys:hello") + }) +}) + +describe("resolveInstallStatus", () => { + it("독립 스킬 파일(user scope)이면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "user", + isInStandaloneSkills: true, + }), + ).toBe("installed") + }) + + it("managed scope이면 항상 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "managed", + isInStandaloneSkills: false, + }), + ).toBe("installed") + }) + + it("독립 스킬에 없는 AgentFile이면 not_installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "user", + isInStandaloneSkills: false, + }), + ).toBe("not_installed") + }) + + it("project scope이고 독립 스킬에 있으면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "project", + isInStandaloneSkills: true, + }), + ).toBe("installed") + }) + + it("마켓플레이스 — 로컬에 없으면 not_installed", () => { + expect( + resolveInstallStatus({ isAgentFile: false, isInStandaloneSkills: false }), + ).toBe("not_installed") + }) + + it("마켓플레이스 — source 일치하면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: "skills.sh/my-skill", + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("installed") + }) + + it("마켓플레이스 — source 불일치하면 conflict", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: "skills.sh/other", + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("conflict") + }) + + it("마켓플레이스 — lock 없으면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: undefined, + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("installed") + }) +}) diff --git a/src/features/skill/utils.ts b/src/features/skill/utils.ts new file mode 100644 index 0000000..8049347 --- /dev/null +++ b/src/features/skill/utils.ts @@ -0,0 +1,49 @@ +import type { AgentFile, InstallStatus } from "@/shared/types" +import type { MarketplaceSkill } from "./types" + +export { extractBody } from "@/lib/format" + +// ── Type guards ── + +type SkillItem = AgentFile | MarketplaceSkill + +export function isAgentFile(item: SkillItem): item is AgentFile { + return "path" in item +} + +// ── Comparison ── + +export function isSameSkill(a: SkillItem, b: SkillItem): boolean { + if (isAgentFile(a) && isAgentFile(b)) return a.path === b.path + if (!isAgentFile(a) && !isAgentFile(b)) return a.id === b.id + return false +} + +// ── Slash command ── + +export function getSlashCommand(file: AgentFile): string { + const name = file.frontmatter?.name ?? file.name + return file.namespace ? `/${file.namespace}:${name}` : `/${name}` +} + +// ── Install status ── + +interface ResolveParams { + isAgentFile: boolean + scope?: string + isInStandaloneSkills: boolean + lockSource?: string + marketplaceSource?: string +} + +export function resolveInstallStatus(params: ResolveParams): InstallStatus { + if (params.isAgentFile) { + if (params.scope === "managed") return "installed" + return params.isInStandaloneSkills ? "installed" : "not_installed" + } + if (!params.isInStandaloneSkills) return "not_installed" + if (!params.lockSource) return "installed" + return params.lockSource === params.marketplaceSource + ? "installed" + : "conflict" +} diff --git a/src/hooks/use-skill-detail.ts b/src/hooks/use-skill-detail.ts deleted file mode 100644 index 254959d..0000000 --- a/src/hooks/use-skill-detail.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useQuery } from "@tanstack/react-query" -import type { MarketplaceSkill } from "@/services/skills-service" -import type { AgentFile } from "@/shared/types" - -function isAgentFile(item: AgentFile | MarketplaceSkill): item is AgentFile { - return "path" in item -} - -export function useSkillDetailQuery(item: AgentFile | MarketplaceSkill) { - const isLocal = isAgentFile(item) - - return useQuery({ - queryKey: isLocal - ? ["skillDetail", item.path] - : ["skillDetail", item.source, item.skillId], - queryFn: async () => { - const { getSkillDetailFn } = await import("@/server/skills") - return getSkillDetailFn({ - data: isLocal - ? { path: item.path } - : { source: item.source, skillId: item.skillId }, - }) - }, - }) -} diff --git a/src/lib/skill-constants.ts b/src/lib/skill-constants.ts deleted file mode 100644 index a9e0377..0000000 --- a/src/lib/skill-constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod" - -// ── Utilities ───────────────────────────────────────────────────────────────── - -export { extractBody } from "@/lib/format" - -// ── Schemas ────────────────────────────────────────────────────────────────── - -export const addSkillSchema = z.object({ - name: z - .string() - .min(1, "Name is required") - .max(64, "Name must be 64 characters or less") - .regex(/^[a-z0-9-]+$/, "Only lowercase letters, numbers, and hyphens"), - description: z.string().min(1, "Description is required"), -}) diff --git a/src/lib/skill-install-status.test.ts b/src/lib/skill-install-status.test.ts deleted file mode 100644 index 0852377..0000000 --- a/src/lib/skill-install-status.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vitest" -import { resolveInstallStatus } from "./skill-install-status" - -describe("resolveInstallStatus", () => { - // ── AgentFile (로컬 파일) ── - - it("독립 스킬 파일(user scope)이면 installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: true, - scope: "user", - isInStandaloneSkills: true, - }), - ).toBe("installed") - }) - - it("managed scope(플러그인 제공)이면 항상 installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: true, - scope: "managed", - isInStandaloneSkills: false, - }), - ).toBe("installed") - }) - - it("독립 스킬에 없는 user scope AgentFile이면 not_installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: true, - scope: "user", - isInStandaloneSkills: false, - }), - ).toBe("not_installed") - }) - - it("project scope이고 독립 스킬에 있으면 installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: true, - scope: "project", - isInStandaloneSkills: true, - }), - ).toBe("installed") - }) - - // ── MarketplaceSkill ── - - it("마켓플레이스 스킬 — 로컬에 없으면 not_installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: false, - isInStandaloneSkills: false, - }), - ).toBe("not_installed") - }) - - it("마켓플레이스 스킬 — 로컬에 있고 source 일치하면 installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: false, - isInStandaloneSkills: true, - lockSource: "skills.sh/my-skill", - marketplaceSource: "skills.sh/my-skill", - }), - ).toBe("installed") - }) - - it("마켓플레이스 스킬 — 로컬에 있고 source 불일치하면 conflict", () => { - expect( - resolveInstallStatus({ - isAgentFile: false, - isInStandaloneSkills: true, - lockSource: "skills.sh/other-skill", - marketplaceSource: "skills.sh/my-skill", - }), - ).toBe("conflict") - }) - - it("마켓플레이스 스킬 — 로컬에 있고 lock 없으면 installed", () => { - expect( - resolveInstallStatus({ - isAgentFile: false, - isInStandaloneSkills: true, - lockSource: undefined, - marketplaceSource: "skills.sh/my-skill", - }), - ).toBe("installed") - }) -}) diff --git a/src/lib/skill-install-status.ts b/src/lib/skill-install-status.ts deleted file mode 100644 index 12c1ff1..0000000 --- a/src/lib/skill-install-status.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { InstallStatus } from "@/shared/types" - -interface ResolveParams { - /** AgentFile인지 MarketplaceSkill인지 */ - isAgentFile: boolean - /** 아이템의 scope (AgentFile 전용) */ - scope?: string - /** useAgentFiles("skill") 결과에 존재하는지 */ - isInStandaloneSkills: boolean - /** lock 파일의 source (MarketplaceSkill 전용) */ - lockSource?: string - /** 마켓플레이스 skill의 source (MarketplaceSkill 전용) */ - marketplaceSource?: string -} - -export function resolveInstallStatus(params: ResolveParams): InstallStatus { - if (params.isAgentFile) { - if (params.scope === "managed") return "installed" - return params.isInStandaloneSkills ? "installed" : "not_installed" - } - - // MarketplaceSkill - if (!params.isInStandaloneSkills) return "not_installed" - if (!params.lockSource) return "installed" - return params.lockSource === params.marketplaceSource - ? "installed" - : "conflict" -} diff --git a/src/lib/skill-utils.test.ts b/src/lib/skill-utils.test.ts deleted file mode 100644 index c286dba..0000000 --- a/src/lib/skill-utils.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { MarketplaceSkill } from "@/services/skills-service" -import type { AgentFile } from "@/shared/types" -import { isSameSkill } from "./skill-utils" - -const agentFileA: AgentFile = { - name: "find-skills", - scope: "user", - path: "/home/.claude/skills/find-skills/SKILL.md", - size: 100, - lastModified: "2026-01-01T00:00:00.000Z", - type: "skill", -} - -const agentFileB: AgentFile = { - ...agentFileA, - path: "/home/.claude/skills/other-skill/SKILL.md", - name: "other-skill", -} - -const marketplaceA: MarketplaceSkill = { - id: "vercel-labs/skills/find-skills", - skillId: "find-skills", - name: "find-skills", - source: "vercel-labs/skills", - installs: 100, -} - -const marketplaceB: MarketplaceSkill = { - id: "skills.volces.com/find-skills", - skillId: "find-skills", - name: "find-skills", - source: "skills.volces.com", - installs: 50, -} - -describe("isSameSkill", () => { - it("same AgentFile (same path)", () => { - expect(isSameSkill(agentFileA, { ...agentFileA })).toBe(true) - }) - - it("different AgentFiles (different path)", () => { - expect(isSameSkill(agentFileA, agentFileB)).toBe(false) - }) - - it("same MarketplaceSkill (same id)", () => { - expect(isSameSkill(marketplaceA, { ...marketplaceA })).toBe(true) - }) - - it("different MarketplaceSkills (different id)", () => { - expect(isSameSkill(marketplaceA, marketplaceB)).toBe(false) - }) - - it("AgentFile vs MarketplaceSkill → false", () => { - expect(isSameSkill(agentFileA, marketplaceA)).toBe(false) - expect(isSameSkill(marketplaceA, agentFileA)).toBe(false) - }) -}) diff --git a/src/lib/skill-utils.ts b/src/lib/skill-utils.ts deleted file mode 100644 index 7a06adc..0000000 --- a/src/lib/skill-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MarketplaceSkill } from "@/services/skills-service" -import type { AgentFile } from "@/shared/types" - -type SkillItem = AgentFile | MarketplaceSkill - -function isAgentFile(item: SkillItem): item is AgentFile { - return "path" in item -} - -export function isSameSkill(a: SkillItem, b: SkillItem): boolean { - if (isAgentFile(a) && isAgentFile(b)) return a.path === b.path - if (!isAgentFile(a) && !isAgentFile(b)) return a.id === b.id - return false -} diff --git a/src/lib/slash-command.ts b/src/lib/slash-command.ts deleted file mode 100644 index 2509cf2..0000000 --- a/src/lib/slash-command.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AgentFile } from "@/shared/types" - -/** - * AgentFile에서 slash command 호출 경로를 생성한다. - * - namespace 있음: `/namespace:name` - * - namespace 없음: `/name` - */ -export function getSlashCommand(file: AgentFile): string { - const name = file.frontmatter?.name ?? file.name - return file.namespace ? `/${file.namespace}:${name}` : `/${name}` -} diff --git a/src/server/items.ts b/src/server/items.ts index 5b627a7..3c6cb24 100644 --- a/src/server/items.ts +++ b/src/server/items.ts @@ -120,7 +120,7 @@ export const deleteItemFn = createServerFn({ method: "POST" }) validateItemName(data.name) if (data.type === "skill") { - const { removeSkill } = await import("@/services/skills-service") + const { removeSkill } = await import("@/features/skill/service") const result = await removeSkill({ name: data.name, scope: data.scope as "user" | "project", diff --git a/src/server/marketplace.ts b/src/server/marketplace.ts index 0fd6199..f7a721d 100644 --- a/src/server/marketplace.ts +++ b/src/server/marketplace.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start" import { z } from "zod" -import { searchMarketplace } from "@/services/skills-service" +import { searchMarketplace } from "@/features/skill/service" export const searchMarketplaceFn = createServerFn({ method: "GET" }) .inputValidator( From 9d27150b32245b8f5e58191ae32739d5583c899b Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Thu, 19 Mar 2026 09:14:09 +0900 Subject: [PATCH 3/3] fix: restore integration test to tests/integration/ per testing guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/TESTING.md | 2 +- src/features/skill/service.test.ts | 193 ----------------------- tests/integration/skills-service.test.ts | 2 +- 3 files changed, 2 insertions(+), 195 deletions(-) delete mode 100644 src/features/skill/service.test.ts diff --git a/docs/TESTING.md b/docs/TESTING.md index de92b88..e1f510f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,7 +9,7 @@ Vitest, `pnpm test` (전체), `pnpm test -- --run ` (특정 파일) | 유형 | 위치 | 규칙 | |------|------|------| | Unit | `src/**/*.test.ts` | 소스 옆 co-locate, mock 허용 | -| Integration | `src/features/**/service.test.ts` 또는 `tests/integration/*.test.ts` | 실제 파일시스템/API/CLI, mock 최소화. Feature directory는 co-locate 우선 | +| Integration | `tests/integration/*.test.ts` | 실제 파일시스템/API/CLI, mock 최소화 | | E2E | `tests/e2e/*.test.ts` | 빌드 아티팩트 + 서버 프로세스 실행 | | 공통 헬퍼 | `tests/helpers/test-utils.ts` | `createTmpDir`, `removeTmpDir`, `writeFile`, `exists` | diff --git a/src/features/skill/service.test.ts b/src/features/skill/service.test.ts deleted file mode 100644 index 83b8e6a..0000000 --- a/src/features/skill/service.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import path from "node:path" -import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { - createTmpDir, - exists, - removeTmpDir, - writeFile, -} from "../../../tests/helpers/test-utils" -import { - getSkillDetail, - installSkill, - readSkillsLock, - removeSkill, - searchMarketplace, -} from "./service" - -describe("searchMarketplace", () => { - it("returns skills sorted by installs", async () => { - const results = await searchMarketplace("react", 5) - expect(results.length).toBeGreaterThan(0) - expect(results[0]).toMatchObject({ - id: expect.any(String), - name: expect.any(String), - source: expect.any(String), - installs: expect.any(Number), - }) - for (let i = 1; i < results.length; i++) { - expect(results[i - 1].installs).toBeGreaterThanOrEqual( - results[i].installs, - ) - } - }) - - it("returns empty for nonsense query", async () => { - expect(await searchMarketplace("xyzzy_nonexistent_12345", 5)).toEqual([]) - }) -}) - -describe("readSkillsLock", () => { - let tmpDir: string - beforeEach(async () => { - tmpDir = await createTmpDir("skills-lock") - }) - afterEach(async () => { - await removeTmpDir(tmpDir) - }) - - it("reads project lock with scope 'project'", async () => { - await writeFile( - path.join(tmpDir, "skills-lock.json"), - JSON.stringify({ - version: 1, - skills: { - "test-skill": { - source: "owner/repo", - sourceType: "github", - computedHash: "abc", - }, - }, - }), - ) - const result = await readSkillsLock(tmpDir) - expect(result.skills["test-skill"]).toMatchObject({ - source: "owner/repo", - sourceType: "github", - scope: "project", - }) - }) - - it("reads global lock with scope 'user'", async () => { - // Global lock is always read from ~/.agents/.skill-lock.json - // We can only verify it returns non-null and has expected shape - const result = await readSkillsLock(tmpDir) - expect(result).toHaveProperty("skills") - // Global lock entries (if any) should have scope "user" - for (const entry of Object.values(result.skills)) { - expect(["user", "project"]).toContain(entry.scope) - } - }) - - it("returns empty skills for missing files", async () => { - const result = await readSkillsLock(tmpDir) - // No project lock in tmpDir; global lock entries may exist - expect(result).toHaveProperty("skills") - }) -}) - -describe("installSkill / removeSkill", () => { - let tmpDir: string - beforeEach(async () => { - tmpDir = await createTmpDir("skills-cli") - const { execSync } = await import("node:child_process") - execSync("git init", { cwd: tmpDir, stdio: "ignore" }) - }) - afterEach(async () => { - await removeTmpDir(tmpDir) - }) - - it("installs and removes a skill", { timeout: 90_000 }, async () => { - const installed = await installSkill({ - source: "vercel-labs/skills", - skill: "find-skills", - scope: "project", - agent: "claude-code", - projectPath: tmpDir, - }) - expect(installed.success).toBe(true) - expect( - await exists(path.join(tmpDir, ".claude/skills/find-skills/SKILL.md")), - ).toBe(true) - - const lock = await readSkillsLock(tmpDir) - expect(lock.skills["find-skills"]).toMatchObject({ - source: "vercel-labs/skills", - scope: "project", - }) - - const removed = await removeSkill({ - name: "find-skills", - scope: "project", - agent: "claude-code", - projectPath: tmpDir, - }) - expect(removed.success).toBe(true) - expect(await exists(path.join(tmpDir, ".claude/skills/find-skills"))).toBe( - false, - ) - }) -}) - -describe("getSkillDetail", () => { - let tmpDir: string - beforeEach(async () => { - tmpDir = await createTmpDir("skill-detail") - }) - afterEach(async () => { - await removeTmpDir(tmpDir) - }) - - it("returns displayName from frontmatter", async () => { - const skillPath = path.join(tmpDir, "test-skill/SKILL.md") - await writeFile( - skillPath, - "---\nname: My Custom Display Name\ndescription: test\n---\n\n# Test Skill", - ) - const skill = await getSkillDetail({ path: skillPath }) - expect(skill).toMatchObject({ - name: "test-skill", - displayName: "My Custom Display Name", - installStatus: "installed", - }) - expect(skill.content).toContain("# Test Skill") - }) - - it("has no displayName when frontmatter lacks name", async () => { - const skillPath = path.join(tmpDir, "no-name-skill/SKILL.md") - await writeFile( - skillPath, - "---\ndescription: no name field\n---\n\n# No Name", - ) - const skill = await getSkillDetail({ path: skillPath }) - expect(skill.name).toBe("no-name-skill") - expect(skill.displayName).toBeUndefined() - }) - - it("fetches from GitHub", { timeout: 30_000 }, async () => { - const skill = await getSkillDetail({ - source: "vercel-labs/skills", - skillId: "find-skills", - }) - expect(skill).toMatchObject({ - name: "find-skills", - source: "vercel-labs/skills", - installStatus: "not_installed", - }) - expect(skill.content).toContain("# Find Skills") - }) - - it( - "returns undefined content for nonexistent paths", - { timeout: 30_000 }, - async () => { - const local = await getSkillDetail({ path: "/nonexistent/SKILL.md" }) - expect(local.content).toBeUndefined() - - const remote = await getSkillDetail({ - source: "vercel-labs/skills", - skillId: "nonexistent-xyz", - }) - expect(remote.content).toBeUndefined() - }, - ) -}) diff --git a/tests/integration/skills-service.test.ts b/tests/integration/skills-service.test.ts index 6053ec5..b23c372 100644 --- a/tests/integration/skills-service.test.ts +++ b/tests/integration/skills-service.test.ts @@ -6,7 +6,7 @@ import { readSkillsLock, removeSkill, searchMarketplace, -} from "@/services/skills-service" +} from "@/features/skill/service" import { createTmpDir, exists,