Lesson 09

Claude Code 플러그인 시스템

마켓플레이스, 버전 캐시, 의존성 해결, 라이프사이클 훅까지. Claude Code가 Git 저장소를 어떻게 일급 기능으로 바꾸는지 살펴봅니다.

01 개요

플러그인 시스템은 Claude Code 확장의 핵심 기반입니다. 서드파티 작성자와 Anthropic 자체가 슬래시 명령, MCP 서버, AI 에이전트, 라이프사이클 훅, LSP 통합, 스킬, 출력 스타일을 Git 저장소나 npm 패키지에서 바로 배포할 수 있게 해주며, 그 과정에서 코어 바이너리는 건드릴 필요가 없습니다.

다루는 소스 파일
utils/plugins/ — schemas.ts · pluginLoader.ts · marketplaceManager.ts · dependencyResolver.ts · pluginVersioning.ts · zipCache.ts · pluginAutoupdate.ts · pluginBlocklist.ts · pluginPolicy.ts · reconciler.ts

services/plugins/ — PluginInstallationManager.ts · pluginOperations.ts · pluginCliCommands.ts

이 시스템을 이해하려면 다섯 가지 개념 계층을 알아야 합니다.

계층 1

마켓플레이스 소스

GitHub 저장소, git URL, npm 패키지, 로컬 디렉터리, URL로 제공되는 JSON까지. 플러그인이 어디서 오는지 설명합니다.

계층 2

매니페스트 스키마

plugin.json은 플러그인이 무엇을 내보내는지 선언합니다. 명령, 훅, MCP 서버, LSP, 스킬, 에이전트가 여기에 들어갑니다.

계층 3

버전 캐시

~/.claude/plugins/cache/{mkt}/{plugin}/{version}/ 경로에 버전별 불변 스냅샷이 저장되며, seed fallback도 지원합니다.

계층 4

의존성 해결

설치 시점에는 DFS로 클로저를 순회하고, 로드 시점에는 고정점 demote 패스를 수행합니다. 교차 마켓플레이스 의존성은 차단됩니다.

계층 5

라이프사이클

백그라운드 reconcile, autoupdate, load, hook registration, command registration, MCP connect 순서로 이어집니다.

02 플러그인 라이프사이클 다이어그램

다음 다이어그램은 사용자 의도에서 실제 활성 기능에 이르기까지 플러그인의 전체 흐름을 보여줍니다.

flowchart TD subgraph STARTUP["시작 단계, 비차단"] direction TB S1["getDeclaredMarketplaces()\nsettings와 --add-dir 소스를 병합"] S2["diffMarketplaces()\n선언값과 known_marketplaces.json 비교"] S3{"누락됐거나\n소스가 바뀌었나?"} S4["reconcileMarketplaces()\ngit clone / http fetch / npm install"] S5["플러그인 자동 새로고침\n또는 needsRefresh 플래그 설정"] S1 --> S2 --> S3 S3 -->|"예"| S4 --> S5 S3 -->|"아니오"| S5 end subgraph AUTOUPDATE["백그라운드 자동 업데이트"] direction TB U1["getAutoUpdateEnabledMarketplaces()\n공식 마켓플레이스는 기본값이 true"] U2["refreshMarketplace()\ngit pull / 다시 fetch"] U3["updatePluginsForMarketplaces()\n설치별로 updatePluginOp() 실행"] U4["onPluginsAutoUpdated()로 REPL에 알림"] U1 --> U2 --> U3 --> U4 end subgraph INSTALL["플러그인 설치, 사용자 트리거"] direction TB I1["parseMarketplaceInput()\n'name@marketplace' 해석"] I2["isPluginBlockedByPolicy()"] I3["resolveDependencyClosure()\nDFS 순회, 사이클 감지, cross-mkt 차단"] I4["클로저의 각 dep마다\n마켓플레이스에서 getPluginById() 호출"] I5["installPlugin()\nclone / fetch / npm"] I6["calculatePluginVersion()\n1. plugin.json 2. 제공된 값 3. git SHA 4. 'unknown'"] I7["copyPluginToVersionedCache()\n~/.claude/plugins/cache/{mkt}/{plugin}/{ver}/"] I8["updateSettingsForSource()\nenabledPlugins[id] = true"] I1 --> I2 --> I3 --> I4 --> I5 --> I6 --> I7 --> I8 end subgraph LOAD["로드 단계, 캐시 전용, 세션별"] direction TB L1["loadAllPluginsCacheOnly()\nsettings.enabledPlugins 읽기"] L2["verifyAndDemote()\n고정점 의존성 검사, 깨진 항목 demote"] L3["detectAndUninstallDelistedPlugins()\n삭제된 마켓플레이스 항목 자동 제거"] L4["loadPluginManifest()\nPluginManifestSchema로 plugin.json 파싱"] L5["경로 해석\ncommands/, agents/, skills/, hooks/, mcpServers, lspServers"] L1 --> L2 --> L3 --> L4 --> L5 end subgraph REGISTER["등록"] direction TB R1["getPluginCommands()\n.md 파일을 파싱해 /plugin:cmd 네임스페이스 생성"] R2["getPluginSkills()\nskills/ 아래 SKILL.md 하위 디렉터리 스캔"] R3["loadPluginHooks()\nPluginHookMatcher[]로 변환"] R4["loadPluginAgents()\n에이전트 .md 파일 등록"] R5["mcpPluginIntegration\nMCP 서버 연결, userConfig 주입"] R6["lspPluginIntegration\nLSP 서버 등록"] R1 & R2 & R3 & R4 & R5 & R6 end STARTUP --> AUTOUPDATE AUTOUPDATE --> LOAD INSTALL --> LOAD LOAD --> REGISTER style STARTUP fill:#0f1a2e,stroke:#7d9ab8,color:#b8b0a4 style AUTOUPDATE fill:#0f2a1a,stroke:#6e9468,color:#b8b0a4 style INSTALL fill:#1a0f2e,stroke:#8e82ad,color:#b8b0a4 style LOAD fill:#1a1a0f,stroke:#b8965e,color:#b8b0a4 style REGISTER fill:#2a0f1a,stroke:#c47a50,color:#b8b0a4
03 마켓플레이스 소스

marketplace는 플러그인 목록이 들어 있는 카탈로그입니다. 즉 marketplace.json 파일이며, Claude Code는 이 카탈로그를 가져오기 위해 여섯 가지 소스 타입을 지원합니다.

github

GitHub 저장소

SSH로 owner/repo를 clone합니다. 원격 모드에서는 HTTPS를 씁니다. 공식 마켓플레이스는 anthropics/claude-plugins-official를 사용합니다.

git

임의의 Git URL

HTTPS, SSH (git@), 또는 file://를 지원합니다. injection 방지를 위해 clone 전에 검증합니다.

git-subdir

모노레포 하위 디렉터리

partial clone(--filter=tree:0)과 sparse-checkout을 조합합니다. 충돌을 막기 위해 버전에 path hash가 포함됩니다.

url

HTTP/HTTPS URL

마켓플레이스 JSON을 직접 fetch합니다. 공식 마켓플레이스용 GCS fallback은 officialMarketplaceGcs.ts를 통해 처리합니다.

npm

npm 패키지

공유 npm-cache/node_modules/에 설치한 뒤 복사합니다. zip-cache 모드에서는 지원되지 않습니다.

directory / file

로컬 경로

로컬 디렉터리나 JSON 파일을 직접 가리킵니다. 경로가 cache dir 밖에 있으므로 zip-cache 대상에서는 제외됩니다.

공식 마켓플레이스

공식 마켓플레이스(claude-plugins-official)는 암묵적으로 선언됩니다. 활성화된 플러그인 중 하나라도 이를 참조하면 Claude Code가 첫 실행 시 anthropics/claude-plugins-official를 자동 clone합니다. 사용자 설정은 필요 없습니다.

예약된 이름 보호

이 스키마는 사칭 방어를 두 겹으로 적용합니다. BLOCKED_OFFICIAL_NAME_PATTERN 정규식은 official-claude-pluginsanthropic-marketplace-v2 같은 이름을 차단합니다. 그리고 allowlist에 들어 있는 예약 이름, 예를 들어 claude-plugins-official 같은 경우에는, 소스 org가 GitHub의 anthropics/여야 합니다.

깊이 보기, 이름 검증 코드
// schemas.ts, 사칭 시도를 막는 차단 패턴
export const BLOCKED_OFFICIAL_NAME_PATTERN =
  /(?:official[^a-z0-9]*(anthropic|claude)|(?:anthropic|claude)[^a-z0-9]*official|^(?:anthropic|claude)[^a-z0-9]*(marketplace|plugins|official))/i

// Non-ASCII는 동형이의 공격을 막는다. 키릴 문자 'а'는 라틴 문자 'a'와 다르다.
const NON_ASCII_PATTERN = /[^\u0020-\u007E]/

export function validateOfficialNameSource(name, source): string | null {
  // 예약된 이름인가? 반드시 github.com/anthropics/ 에서 와야 한다.
  if (source.source === 'github') {
    const repo = source.repo || ''
    if (!repo.toLowerCase().startsWith(`anthropics/`)) {
      return `이 이름 '${name}'은 공식 Anthropic 마켓플레이스용으로 예약되어 있습니다.`
    }
  }
  return null
}
04 매니페스트 스키마 (plugin.json)

모든 플러그인은 루트에 선택적으로 plugin.json을 둘 수 있습니다. Claude Code는 이를 엄격한 Zod 스키마로 파싱합니다. 최상위의 알 수 없는 키는 조용히 제거되어 미래 필드가 추가돼도 견딜 수 있게 하고, userConfigchannels 같은 중첩 객체 안의 알 수 없는 키는 여전히 검증 실패를 일으킵니다.

필드 타입 설명
name string kebab-case, 공백 없음. 명령을 /plugin:cmd 형태로 네임스페이스하는 데 사용합니다.
version string? semver. 가장 우선순위가 높은 버전 소스이며 git SHA를 덮어씁니다.
description string? /plugin list에 표시되는 사용자용 설명입니다.
dependencies string[]? bare name은 선언한 플러그인의 마켓플레이스를 상속합니다. 교차 마켓플레이스는 기본적으로 차단됩니다.
commands path | path[] | Record<name, metadata> commands/ 디렉터리를 보완합니다. 객체 형태에서는 inline content도 지원합니다.
hooks path | HooksConfig | array hooks/hooks.json을 보완합니다. 20개가 넘는 모든 라이프사이클 이벤트를 지원합니다.
mcpServers path | McpbPath | Record | array .mcpb/.dxt 번들이나 inline 서버 설정 객체를 지원합니다.
lspServers path | Record | array 각 서버는 command, extensionToLanguage 맵, transport, env, timeouts를 가집니다.
agents path | path[] agents/ 디렉터리 외에 추가 에이전트 .md 파일을 지정합니다.
skills path | path[] 추가 스킬 디렉터리입니다. 각각 SKILL.md를 포함해야 합니다.
outputStyles path | path[] 사용자 정의 렌더링을 위한 output style 정의입니다.
channels ChannelDecl[] 메시징 채널입니다. Telegram, Slack 등이 여기에 해당합니다. MCP 서버를 묶고 userConfig용 프롬프트를 연결합니다.
userConfig Record<key, Option> 활성화 시점에 물어보는 사용자 설정 값입니다. 민감한 값은 keychain으로 갑니다.
settings Record? 플러그인 활성화 시 병합할 설정입니다. allowlist에 있는 키만 유지합니다. 현재는 agent뿐입니다.
author / homepage / repository / license / keywords metadata 검색성과 출처 표기를 위한 메타데이터 필드입니다.
주석이 달린 전체 plugin.json 예시
{
  "name": "my-plugin",
  "version": "1.2.0",
  "description": "모든 기능 유형을 보여주는 예제 플러그인",
  "author": { "name": "Acme Corp", "url": "https://acme.example" },
  "license": "MIT",

  // 의존성 선언, bare name은 이 플러그인의 마켓플레이스를 상속한다
  "dependencies": ["shared-utils"],

  // 명령, 디렉터리 자동 스캔 + 추가 파일 + inline content
  "commands": {
    "hello": { "content": "${CLAUDE_PLUGIN_ROOT}에 인사하기" },
    "deploy": { "source": "./docs/deploy.md", "argumentHint": "[env]" }
  },

  // 훅, inline 또는 path
  "hooks": "./hooks/extra.json",

  // .mcpb 번들을 통한 MCP 서버, 미리 패키징된 바이너리
  "mcpServers": "./server.mcpb",

  // TypeScript용 LSP 서버
  "lspServers": {
    "typescript": {
      "command": "typescript-language-server",
      "args": ["--stdio"],
      "extensionToLanguage": { ".ts": "typescript", ".tsx": "typescriptreact" }
    }
  },

  // 사용자 설정 값, 활성화 시점에 입력받는다
  "userConfig": {
    "API_KEY": {
      "type": "string",
      "title": "API Key",
      "description": "서비스 API 키",
      "sensitive": true,   // → settings.json이 아니라 keychain에 저장된다
      "required": true
    }
  }
}
플러그인 디렉터리 관례

plugin.json 필드가 하나도 없어도 플러그인은 여전히 유효합니다. Claude Code는 관례에 따라 commands/*.md, agents/*.md, skills/*/SKILL.md, hooks/hooks.json, .mcp.json을 자동으로 찾습니다. 매니페스트는 관례 밖 경로를 쓰거나 메타데이터를 선언하고 싶을 때만 필요합니다.

05 버전 캐시

플러그인은 ~/.claude/plugins/cache/ 아래의 불변 content-addressed cache에 설치됩니다. 경로 구조는 다음과 같습니다.

# 경로 형식
~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/

# 예시
~/.claude/plugins/cache/claude-plugins-official/sql-helper/1.3.2/
~/.claude/plugins/cache/acme-mkt/deploy-tool/a3f8c1d94b12/
~/.claude/plugins/cache/acme-mkt/monorepo-plugin/a3f8c1d94b12-e5a7c3f1/
                                                          ^^ git-subdir용 path hash

버전 우선순위

버전 문자열은 pluginVersioning.ts에서 다음 우선순위로 계산됩니다.

  1. plugin.json의 version 필드, 명시적인 semver이며 가장 권한이 높습니다
  2. 제공된 version, 마켓플레이스 엔트리에서 온 값입니다. 예를 들면 marketplace.json에 pin된 경우입니다
  3. 미리 해석된 git SHA, clone 결과를 버리기 전에 저장합니다. git-subdir 케이스입니다
  4. Git commit SHA, 설치 경로의 .git/HEAD에서 읽습니다. 앞 12자만 사용합니다
  5. 'unknown', 마지막 수단입니다. 그래도 유효한 cache path는 만들어집니다
git-subdir path hashing

모노레포 플러그인(source: "git-subdir")에서는 같은 커밋 안의 서로 다른 하위 디렉터리 플러그인 둘이 같은 SHA 경로에서 충돌할 수 있습니다. 그래서 버전은 {sha12}-{pathHash8}가 됩니다. 여기서 path hash는 하위 디렉터리 경로를 정규화한 뒤 구한 SHA-256입니다. 이 정규화는 squashfs cron과 바이트 단위까지 일치합니다. backslash를 forward slash로 바꾸고, 앞의 ./를 제거하고, 뒤의 /를 제거합니다.

Seed cache fallback

엔터프라이즈 배포에서는 읽기 전용 seed 디렉터리를 미리 채워 둘 수 있습니다. 설정은 CLAUDE_CODE_PLUGIN_SEED_DIR로 합니다. 설치 시 로더는 네트워크 fetch를 하기 전에 먼저 seed를 확인합니다. 덕분에 첫 실행에서 네트워크가 전혀 필요 없는 시나리오가 가능합니다.

Zip cache 모드

CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE=1이 설정되면, 즉 headless나 container 배포에서는, 플러그인이 마운트된 Filestore 위 디렉터리 대신 .zip 파일로 저장됩니다. 세션 시작 시에는 이를 claude-plugin-session-{hex}/ 임시 디렉터리에 풀고, 종료 시 정리합니다. Unix 실행 비트는 ZIP central directory의 external_attr 필드를 통해 유지되며, +x가 필요한 훅과 스크립트에 중요합니다.

깊이 보기, 버전 경로 계산
// pluginLoader.ts
export function getVersionedCachePathIn(
  baseDir: string,
  pluginId: string,
  version: string,
): string {
  const { name: pluginName, marketplace } = parsePluginIdentifier(pluginId)
  // path traversal을 막기 위해 각 세그먼트를 정리한다
  const sanitizedMarketplace = (marketplace || 'unknown').replace(/[^a-zA-Z0-9\-_]/g, '-')
  const sanitizedPlugin    = (pluginName    || pluginId).replace(/[^a-zA-Z0-9\-_]/g, '-')
  const sanitizedVersion   = version.replace(/[^a-zA-Z0-9\-_.]/g, '-')
  return join(baseDir, 'cache', sanitizedMarketplace, sanitizedPlugin, sanitizedVersion)
}

// pluginVersioning.ts, git-subdir path hash
const normPath = source.path
  .replace(/\\/g, '/')    // backslash를 forward slash로 바꾼다
  .replace(/^\.\//, '')    // 앞의 ./ 제거
  .replace(/\/+$/, '')     // 뒤의 / 제거
const pathHash = createHash('sha256').update(normPath).digest('hex').substring(0, 8)
const v = `${shortSha}-${pathHash}`
06 의존성 해결

플러그인 시스템에는 의미가 서로 다른 두 개의 의존성 해결 패스가 있으며, 이는 dependencyResolver.ts에 구현되어 있습니다.

설치 시점: resolveDependencyClosure()

설치할 플러그인의 전체 전이 클로저를 계산하는 재귀 DFS 순회입니다. 여기서는 세 가지 규칙을 강제합니다.

  • 사이클 감지, 현재 DFS 스택에 플러그인이 다시 나타나면 { reason: 'cycle' }을 반환합니다
  • 교차 마켓플레이스 차단, 마켓플레이스 X의 플러그인 A는 마켓플레이스 Y의 플러그인 B를 자동 설치할 수 없습니다. 단, Y가 X의 allowCrossMarketplaceDependenciesOn allowlist에 있으면 예외입니다
  • 이미 활성화된 항목 건너뛰기, 설정에 이미 있는 dep는 version pin을 덮어쓰지 않도록 건너뜁니다. 단 root는 건너뛰지 않습니다. 이는 "cache는 지워졌지만 설정에는 남아 있는" 경우를 처리합니다
설치 시점 DFS 순회, 소스 발췌
export async function resolveDependencyClosure(
  rootId: PluginId,
  lookup: (id: PluginId) => Promise<DependencyLookupResult | null>,
  alreadyEnabled: ReadonlySet<PluginId>,
  allowedCrossMarketplaces: ReadonlySet<string> = new Set(),
): Promise<ResolutionResult> {
  const closure: PluginId[] = []
  const visited = new Set<PluginId>()
  const stack: PluginId[] = []  // 사이클 감지를 위한 DFS 스택

  async function walk(id, requiredBy) {
    // 이미 활성화된 DEP는 건너뛴다. root 제외. version pin 덮어쓰기를 막기 위함이다.
    if (id !== rootId && alreadyEnabled.has(id)) return null

    // 교차 마켓플레이스 보안 게이트
    const idMkt = parsePluginIdentifier(id).marketplace
    if (idMkt !== rootMarketplace && !allowedCrossMarketplaces.has(idMkt)) {
      return { ok: false, reason: 'cross-marketplace', dependency: id, requiredBy }
    }

    if (stack.includes(id)) return { ok: false, reason: 'cycle', chain: [...stack, id] }
    if (visited.has(id)) return null
    visited.add(id)

    const entry = await lookup(id)
    if (!entry) return { ok: false, reason: 'not-found', missing: id, requiredBy }

    stack.push(id)
    for (const rawDep of entry.dependencies ?? []) {
      const dep = qualifyDependency(rawDep, id)  // bare name이면 marketplace를 상속한다
      const err = await walk(dep, id)
      if (err) return err
    }
    stack.pop()
    closure.push(id)   // post-order, dep가 dependent보다 먼저 온다
    return null
  }

  const err = await walk(rootId, rootId)
  if (err) return err
  return { ok: true, closure }
}

로드 시점: verifyAndDemote()

모든 세션 시작 시 cache-only로 로드된 집합에서 실행됩니다. 이것은 고정점 루프입니다. 플러그인 A를 demote하면, 예를 들어 dep가 없어서, 플러그인 B가 A에 의존한다는 사실이 드러날 수 있고, 그러면 B도 함께 demote되어야 합니다. 변화가 없어질 때까지 이 루프를 반복합니다.

apt 스타일 의미론

Claude Code의 의존성 모델은 Debian의 apt에서 영감을 받았습니다. 의존성은 존재 보장이지 모듈 import가 아닙니다. 플러그인 B가 플러그인 A에 의존한다는 뜻은 "B가 실행될 때 A의 네임스페이스된 MCP 서버, 명령, 에이전트가 이용 가능해야 한다"는 의미이지, B의 코드가 A의 코드를 import한다는 뜻은 아닙니다.

flowchart LR subgraph "의존성 이름 해석" N1["'shared-utils'\n(bare name)"] -->|"선언한 플러그인이 acme@acme-mkt"| N2["'shared-utils@acme-mkt'\n(qualified)"] N3["'shared-utils@acme-mkt'\n(이미 qualified됨)"] --> N3 N4["'shared-utils'\n(--plugin-dir 플러그인에서 옴)"] -->|"@inline sentinel, 그대로 유지"| N4 end style N2 fill:#1a2a0f,stroke:#6e9468,color:#b8b0a4 style N3 fill:#0f1a2a,stroke:#7d9ab8,color:#b8b0a4 style N4 fill:#2a1a0f,stroke:#b8965e,color:#b8b0a4
07 명령, 스킬, 훅 로딩

명령과 스킬

명령은 commands/*.md에 들어 있습니다. 각 파일은 /plugin-name:command-name 형태의 슬래시 명령이 됩니다. 하위 디렉터리는 네임스페이스를 만듭니다. commands/ci/build.md/my-plugin:ci:build가 됩니다.

스킬은 SKILL.md를 포함한 디렉터리입니다. Claude Code가 skills/의 하위 디렉터리에서 SKILL.md를 찾으면, 그 부모 디렉터리 이름을 스킬 이름으로 등록하고 ${CLAUDE_SKILL_DIR}를 주입합니다. 덕분에 스킬이 자기 보조 파일을 참조할 수 있습니다.

명령과 스킬에서의 변수 치환

런타임에는 명령과 스킬 내용에서 다음 변수가 치환됩니다.

변수해석 결과
${CLAUDE_PLUGIN_ROOT}플러그인이 설치된 디렉터리의 절대 경로
${CLAUDE_PLUGIN_DATA}플러그인의 쓰기 가능한 데이터 디렉터리
${CLAUDE_SKILL_DIR}이 스킬의 특정 하위 디렉터리, 스킬 모드에서만 사용
${CLAUDE_SESSION_ID}현재 세션 식별자
${user_config.KEY}사용자 설정 옵션, 민감한 키는 placeholder로 대체됨

플러그인 훅은 PluginHookMatcher[] 객체로 변환되어 전역 hook config와 함께 등록됩니다. 모든 훅 이벤트를 지원합니다.

// 플러그인이 구독할 수 있는 20개 이상의 모든 훅 이벤트
PreToolUse | PostToolUse | PostToolUseFailure | PermissionDenied
Notification | UserPromptSubmit | SessionStart | SessionEnd | Stop | StopFailure
SubagentStart | SubagentStop | PreCompact | PostCompact
PermissionRequest | Setup | TeammateIdle
TaskCreated | TaskCompleted | Elicitation | ElicitationResult
ConfigChange | WorktreeCreate | WorktreeRemove
InstructionsLoaded | CwdChanged | FileChanged
핫 리로드

훅 로딩은 settingsChangeDetector를 구독합니다. 설정의 enabledPlugins가 바뀌면, 예를 들어 사용자가 재시작 없이 플러그인을 켜거나 끄면, 플러그인 훅이 자동으로 다시 로드됩니다. 스냅샷 비교에는 JSON 직렬화를 써서 변경을 감지합니다.

08 보안과 정책

정책 차단 (managed-settings.json)

엔터프라이즈 관리자는 managed-settings.json에서 enabledPlugins["name@marketplace"] = false를 설정해 어떤 플러그인이든 강제로 비활성화할 수 있습니다. isPluginBlockedByPolicy() 검사는 설치 차단 지점, 활성화 작업, UI에서 모두 실행됩니다. 즉 사용자 설정이나 프로젝트 설정으로 덮어쓸 수 없는 단일 진실 공급원입니다.

목록 제거와 자동 삭제

마켓플레이스가 forceRemoveDeletedPlugins: true를 설정하면, Claude Code는 시작 시점에 installed_plugins.json과 현재 마켓플레이스 매니페스트를 비교합니다. 더 이상 목록에 없는 플러그인은 사용자가 제어하는 모든 스코프에서 자동으로 제거되며, 재설치 루프를 막기 위해 flagged-plugins 파일에도 기록됩니다.

설치 스코프

플러그인은 네 가지 스코프에 설치할 수 있습니다. 이 중 앞의 세 가지만 사용자가 직접 설치할 수 있습니다.

user

사용자 스코프

~/.claude/settings.json, 이 사용자의 모든 프로젝트에서 활성화됩니다.

project

프로젝트 스코프

현재 프로젝트의 .claude/settings.json에 저장되며 저장소에 커밋됩니다.

local

로컬 스코프

.claude/settings.local.json, 프로젝트 전용이며 커밋되지 않습니다.

managed

관리 스코프

조직 관리자가 managed-settings.json으로 설정합니다. 사용자에게는 읽기 전용입니다.

삭제 경고, 역방향 의존 플러그인 감지
// dependencyResolver.ts
export function findReverseDependents(
  pluginId: PluginId,
  plugins: readonly LoadedPlugin[],
): string[] {
  const { name: targetName } = parsePluginIdentifier(pluginId)
  return plugins
    .filter(p =>
      p.enabled &&
      p.source !== pluginId &&
      (p.manifest.dependencies ?? []).some(d => {
        const qualified = qualifyDependency(d, p.source)
        // @inline 플러그인의 bare dep는 이름만으로 매칭한다
        return parsePluginIdentifier(qualified).marketplace
          ? qualified === pluginId
          : qualified === targetName
      }),
    )
    .map(p => p.name)
}
// 결과 예시, "warning: plugin-a, plugin-b가 필요로 함"
09 백그라운드 자동 업데이트

시작 시점에 autoUpdateMarketplacesAndPluginsInBackground()가 조용히 실행되며 REPL을 막지 않습니다. 흐름은 세 단계로 나뉩니다.

  1. autoUpdate: true인 마켓플레이스를 결정합니다. 공식 마켓플레이스의 기본값은 true, 서드파티의 기본값은 false입니다
  2. 자동 업데이트 대상 마켓플레이스마다 refreshMarketplace()를 실행합니다. git pull 또는 다시 fetch합니다
  3. 그 마켓플레이스에서 설치된 각 플러그인에 대해 updatePluginOp()를 호출합니다

업데이트는 in-place가 아닙니다. 새 버전은 새로운 버전 경로에 캐시되고, 현재 실행 중인 세션은 계속 기존 경로를 사용합니다. REPL은 onPluginsAutoUpdated()로 알림을 받고 재시작 프롬프트를 표시합니다.

Race condition 처리

autoupdate가 끝났을 때 REPL이 아직 마운트되지 않았을 수 있습니다. 이 모듈은 업데이트 알림을 pendingNotification에 저장해 두고, 나중에 REPL이 onPluginsAutoUpdated()를 호출하면 즉시 전달합니다. 덕분에 "아무도 듣고 있지 않을 때 업데이트가 끝나 버리는" 조용한 누락을 막을 수 있습니다.

핵심 정리

  • 플러그인은 plugin.json 매니페스트를 가진 디렉터리, 또는 ZIP입니다. 매니페스트는 선택 사항이며, 대부분의 경우 관례 기반 자동 탐색만으로 충분합니다.
  • 마켓플레이스는 카탈로그입니다. github, git, git-subdir, url, npm, directory/file 여섯 가지 소스 타입을 지원합니다. 공식 Anthropic 마켓플레이스는 이를 참조하는 플러그인이 있으면 암묵적으로 선언됩니다.
  • ~/.claude/plugins/cache/{mkt}/{plugin}/{ver}/의 버전 캐시는 버전별로 불변입니다. git-subdir 플러그인은 모노레포 충돌을 막기 위해 버전에 path hash를 넣습니다.
  • 의존성 해결은 두 단계입니다. 설치 시점에는 DFS 클로저 순회를 수행하고, 로드 시점에는 고정점 demote 패스를 수행해 세션 시작 때 깨진 dep를 잡아냅니다. 교차 마켓플레이스는 기본적으로 차단됩니다.
  • 플러그인은 네임스페이스를 가집니다. 명령은 /plugin-name:command가 되고, 훅에는 pluginId 태그가 붙고, MCP 서버 이름에도 접두사가 붙습니다. 덕분에 플러그인끼리 충돌하지 않습니다.
  • 정책 차단(managed-settings.json)은 설치 시점, 활성화 시점, UI에서 모두 강제되며, 사용자 설정이나 프로젝트 설정으로 덮어쓸 수 없습니다.
  • autoupdate는 백그라운드에서만 실행되며 비차단입니다. 현재 세션은 기존 코드를 계속 사용하고, 새 버전은 다음 재시작을 위해 캐시됩니다.
  • 민감한 userConfig 값, 즉 sensitive: true로 표시된 값은 settings.json이 아니라 OS keychain으로 갑니다. MCP 서버 env에서는 쓸 수 있지만, 모델에 보내는 skill이나 agent 내용에는 절대 치환되지 않습니다.

퀴즈 — 6문제

Q1. 어떤 플러그인이 "dependencies": ["shared-utils"]를 선언했고, 그 플러그인 자체는 acme-mkt 마켓플레이스에서 왔다면, 해석된 의존성 ID는 무엇일까요?
정답! qualifyDependency()는 bare dep 이름 뒤에 선언한 플러그인의 marketplace를 붙입니다. 선언한 플러그인이 @inline이면 dep는 bare 상태로 유지됩니다.
Q2. 플러그인 A는 정상 활성화되어 있고, 플러그인 B는 A에 의존합니다. 이후 A가 마켓플레이스에서 제거되어 자동 삭제되었습니다. 다음 세션 시작 시 B는 어떻게 될까요?
정답! verifyAndDemote()는 로드 시점에 고정점 루프를 실행합니다. B의 dep인 A가 enabled 집합에 없으므로 B는 이번 세션에서 demote됩니다. 설정 자체는 바뀌지 않습니다.
Q3. 모노레포 안의 plugins/my-plugin/ 플러그인이 git SHA abc123def456에 있고, 하위 디렉터리 경로가 ./plugins/my-plugin이라면 버전은 어떤 형태일까요?
정답! 형식은 {12자리 SHA}-{8자리 path hash}입니다. 경로를 정규화한 뒤(./를 제거하고, backslash를 /로 바꾸고, 끝의 /를 제거한 뒤) SHA-256 해시를 구해 앞 8개의 hex 문자를 사용합니다. 이 방식은 squashfs cron과 바이트 단위까지 일치합니다.
Q4. 다음 중 첫 실행 시 네트워크 fetch를 막기 위한 엔터프라이즈 사전 채움 플러그인 캐시에 해당하는 올바른 경로는 무엇일까요?
정답! CLAUDE_CODE_PLUGIN_SEED_DIR은 seed 디렉터리를 설정하며, 우선순위 순서대로 확인됩니다. CLAUDE_CODE_PLUGIN_CACHE_DIR은 zip-cache Filestore 경로용으로, 다른 엔터프라이즈 배포 모드에 해당합니다.
Q5. 어떤 플러그인이 API_KEY라는 userConfig 필드에 "sensitive": true를 설정했습니다. 이 값은 어디에 저장되며, skill 내용에 나타날 수 있을까요?
정답! 민감한 값은 안전한 저장소(macOS keychain 또는 .credentials.json)로 갑니다. ${user_config.API_KEY}를 skill 내용에 치환할 때, 그 내용이 모델로 전송된다면 실제 비밀값 대신 설명용 placeholder가 들어갑니다. 비밀값은 프롬프트에 들어가지 않습니다.
Q6. anthropic-marketplace-v2라는 이름의 마켓플레이스를 서드파티가 등록하지 못하게 막는 것은 무엇일까요?
정답! BLOCKED_OFFICIAL_NAME_PATTERN은 anthropic-marketplace-v2 같은 이름을 매칭합니다. "anthropic" 뒤에 "marketplace"가 오기 때문입니다. allowlist와 source 검사는 claude-plugins-official 같은 정확한 예약 이름에 대한 별도 방어선입니다.
0/6