【Claude Code 連載 第99回】SDK でプラグインを読み込む(agent-sdk/plugins)

スポンサーリンク
【Claude Code 連載 第99回】SDK でプラグインを読み込む(agent-sdk/plugins) 用語解説
【Claude Code 連載 第99回】SDK でプラグインを読み込む(agent-sdk/plugins)
この記事は約24分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

スポンサーリンク

背景

この連載では、Claude Codeの公式ドキュメントを1ページずつ読み解いていきます。公式ドキュメントは情報が網羅されている分、「結局どの機能を、どんな場面で使えばいいのか」は自分で考える必要があり、読むのに意外と時間がかかります。そこで、私が実務で使うために読み込んだ内容を「使う場面→実例コード」の順に整理して残していくことにしました。専門家の解説というより、一次情報を読んだ記録です。推測や動作を確認していない部分には、その都度そう書きます。

公式ドキュメント:https://code.claude.com/docs/ja/agent-sdk/plugins

1. 一言でいうと

Claude Code のプラグイン(スキル・エージェント・フック・MCP サーバーをひとまとめにしたもの)を、Agent SDK のアプリにローカルのディレクトリから読み込ませる方法です。plugins オプションに、type を "local"、path をプラグインのディレクトリにした指定を並べるだけで、プラグインの機能がそのセッションで使えるようになります。読み込みに失敗してもセッションは止まらないので、初期化メッセージの plugins 一覧で、本当に読み込まれたかを確かめるのが要点です。

プラグインそのものの作り方は、第54〜61回のプラグイン系の連載で見ました。今回はそれを SDK 側から使う回です。第90回のスキルの回で「スキルは plugins オプションからも読み込める」と触れ、第97回のトラブルシューティングで「プラグインが読み込まれない」は専用のページに回されていました。その本体です。


2. どういう場面で役立つか

シーン1:チームで作ったスキルやエージェントを、SDK のアプリでも使う

開発者が Claude Code で使っているプラグイン(社内のレビュー手順のスキル、専門のサブエージェントなど)を、同じものとして SDK で作った自動化にも載せたい場合です。原文は、プラグインは「プロジェクト全体で共有できる」拡張だと説明しています。CLI 用と SDK 用で別々に定義を持つ必要がありません。

シーン2:アプリごとに機能のセットを切り替える

plugins には複数のパスを並べられるので、アプリや用途ごとに読み込むプラグインの組み合わせを変えられます。たとえば、共通のプラグインはホームの下に置き、アプリ固有のものはアプリのディレクトリに置いて、両方を読み込む、といった構成です(原文の「複数のプラグインソース」の例)。

シーン3:CLI で入れたプラグインをそのまま SDK でも使う

CLI で /plugin install my-plugin@marketplace のように入れたプラグインは、そのインストール先のパスを SDK に渡せば使えます。原文は、CLI で入れたプラグインは ~/.claude/plugins/ を見るよう書いています。

不要・向かないケース

  • マーケットプレイスやリモートのリポジトリから直接読み込みたい:SDK が受け付ける type は "local" だけです。マーケットプレイスやリポジトリで配布されているプラグインは、先にダウンロードしてローカルのパスを渡す必要があります。
  • スキル1つだけを使いたい:スキルは .claude/skills/ に置いて設定ソースから読ませる方法もあります(第90回)。プラグインの形にまとめるのは、複数の部品を一緒に配りたいときです。
  • 中身を確かめていないプラグイン:プラグインにはフックや MCP サーバーが入りうるので、読み込むことはそれらのコードを動かすことにつながります(3-6)。

3. コードと仕組みの解説

原文の fenced コードブロックは10本です(読み込み・確認・スキルの使用・完全な例の、TypeScript と Python の対訳4組8本と、プラグインのディレクトリ構成、複数のソースを組み合わせる TypeScript の断片)。どれも内容が違うので全数を引用します。コードは原文から機械的に抜き出したもので、入れ子に由来する先頭インデントを外した以外は変えていません。

原文にはバージョン要件の記載がありません。

3-1. プラグインに入れられるもの

プラグインには次の4種類を入れられます。

部品中身連載の対応回
SkillsClaude が関係するときに自分で呼ぶ機能。/plugin-name:skill-name で直接呼ぶこともできる第90回(SDK のスキル)
Agents特定のタスク向けの専門のサブエージェント第89回(SDK のサブエージェント)
Hooksツールの使用などのイベントに反応するハンドラー第82回(SDK のフック)
MCP serversModel Context Protocol による外部ツールの統合第84回(SDK の MCP)

「連載の対応回」の列は筆者が足したものです。プラグインの構造と作り方の全体は、本体側のプラグインのページ(第61回)とリファレンス(第55回)にあります。

3-2. プラグインを読み込む

オプションの plugins に、ローカルのファイルシステムのパスを指定して読み込みます。type は "local" でなければならず、これが SDK の受け付ける唯一の値です。 複数の場所から複数のプラグインを読み込めます。

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Hello",
  options: {
    plugins: [
      { type: "local", path: "./my-plugin" },
      { type: "local", path: "/absolute/path/to/another-plugin" }
    ]
  }
})) {
  // Plugin commands, agents, and other features are now available
}
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions


async def main():
    async for message in query(
        prompt="Hello",
        options=ClaudeAgentOptions(
            plugins=[
                {"type": "local", "path": "./my-plugin"},
                {"type": "local", "path": "/absolute/path/to/another-plugin"},
            ]
        ),
    ):
        # Plugin commands, agents, and other features are now available
        pass


asyncio.run(main())

TypeScript も Python も plugins という同じ名前のオプションで、要素は type と path を持つオブジェクト(Python では辞書)です。この例では、相対パスと絶対パスのプラグインを1つずつ読み込んでいます。

パスの指定

種類解決のされ方例
相対パスcwd オプションを基準に解決される"./plugins/my-plugin"
絶対パスそのままのファイルシステムのパス"/home/user/plugins/my-plugin"

相対パスの基準は、アプリのプロセスの作業ディレクトリではなく、query() に渡す cwd オプションです。第94回のマルチテナント分離のように、セッションごとに cwd を変える構成では、同じ "./my-plugin" でもセッションごとに違う場所を指すことになります。後述の完全な例が、どちらの言語でもプラグインのパスをスクリプトの位置から組み立てた絶対パスにしているのは、この取り違えを避ける書き方だと読めます(筆者の指摘)。

パスはプラグインのルートディレクトリ、つまり skills/・agents/・hooks/・commands/・.claude-plugin/ の親ディレクトリを指す必要があります。.claude-plugin/ そのものや skills/ そのものを指すと読み込まれません。第61回で見た「.claude-plugin/ の中に部品を置いてしまう」配置の間違いと並んで、ありがちな失敗です(筆者の対応づけ)。

3-3. 読み込まれたかを確かめる

プラグインが読み込まれると、システムの初期化メッセージ(init)に現れます。

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Hello",
  options: {
    plugins: [{ type: "local", path: "./my-plugin" }]
  }
})) {
  if (message.type === "system" && message.subtype === "init") {
    // Check loaded plugins
    console.log("Plugins:", message.plugins);
    // Example: [{ name: "my-plugin", path: "/absolute/path/to/my-plugin" }]

    // Plugin skills appear with the plugin name as a prefix
    console.log("Skills:", message.skills);
    // Example: ["my-plugin:greet"]

    // Plugin commands use the same prefix, and skills appear here too
    console.log("Commands:", message.slash_commands);
    // Example: ["compact", "context", "my-plugin:custom-command", "my-plugin:greet"]
  }
}
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, SystemMessage


async def main():
    async for message in query(
        prompt="Hello",
        options=ClaudeAgentOptions(
            plugins=[{"type": "local", "path": "./my-plugin"}]
        ),
    ):
        if isinstance(message, SystemMessage) and message.subtype == "init":
            # Check loaded plugins
            print("Plugins:", message.data.get("plugins"))
            # Example: [{"name": "my-plugin", "path": "/absolute/path/to/my-plugin"}]

            # Plugin skills appear with the plugin name as a prefix
            print("Skills:", message.data.get("skills"))
            # Example: ["my-plugin:greet"]

            # Plugin commands use the same prefix, and skills appear here too
            print("Commands:", message.data.get("slash_commands"))
            # Example: ["compact", "context", "my-plugin:custom-command", "my-plugin:greet"]


asyncio.run(main())

初期化メッセージで見られるものを整理します。

項目中身(原文の例)
plugins読み込まれたプラグインの名前と絶対パス(my-plugin と /absolute/path/to/my-plugin)
skillsプラグインのスキルはプラグイン名の接頭辞付き(my-plugin:greet)
slash_commandsプラグインのコマンドも同じ接頭辞付きで、スキルもここに出る(compact、context、my-plugin:custom-command、my-plugin:greet)

取り出し方は言語で違います。TypeScript は message.plugins のようにメッセージのフィールドを直接読み、Python は message.data.get("plugins") のように SystemMessage の data から取ります。第87回で見た「init のセッション ID は TypeScript が直接のフィールド、Python は SystemMessage.data」と同じ構図です。

plugins の例でパスが絶対パスになっている点も実用的です。相対パスで渡しても、ここには解決後の絶対パスが出るので、cwd の基準で意図した場所が読まれたかを確かめられます(筆者の指摘)。

3-4. プラグインのスキルを使う

プラグインのスキルは、ぶつからないようにプラグイン名で自動的に名前空間が付きます。直接呼ぶには、/plugin-name:skill-name をプロンプトとして送ります。

import { query } from "@anthropic-ai/claude-agent-sdk";

// Load a plugin with a custom /greet skill
for await (const message of query({
  prompt: "/my-plugin:greet", // Use plugin skill with namespace
  options: {
    plugins: [{ type: "local", path: "./my-plugin" }]
  }
})) {
  // Claude executes the custom greeting skill from the plugin
  if (message.type === "assistant") {
    console.log(message.message.content);
  }
}
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock


async def main():
    # Load a plugin with a custom /greet skill
    async for message in query(
        prompt="/my-plugin:greet",  # Use plugin skill with namespace
        options=ClaudeAgentOptions(
            plugins=[{"type": "local", "path": "./my-plugin"}]
        ),
    ):
        # Claude executes the custom greeting skill from the plugin
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, TextBlock):
                    print(f"Claude: {block.text}")


asyncio.run(main())

TypeScript は message.message.content をそのまま表示し(第77回の二重構造)、Python は AssistantMessage の content からテキストのブロックだけを取り出して表示しています。表示の仕方は違いますが、どちらも /my-plugin:greet という名前空間付きの名前で呼んでいる点が要です。名前空間を付けずに /greet と送ると、第90回で見たとおり、一致しない /<name> として扱われます(v2.1.274 以降は通常のメッセージとして送られ、モデルのターンを使う)(筆者の対応づけ)。

3-5. 完全な例

読み込みと使用をまとめた例です。

import { query } from "@anthropic-ai/claude-agent-sdk";
import { fileURLToPath } from "node:url";

async function runWithPlugin() {
  const pluginPath = fileURLToPath(new URL("./plugins/my-plugin", import.meta.url));

  console.log("Loading plugin from:", pluginPath);

  for await (const message of query({
    prompt: "What custom commands do you have available?",
    options: {
      plugins: [{ type: "local", path: pluginPath }],
      maxTurns: 3
    }
  })) {
    if (message.type === "system" && message.subtype === "init") {
      console.log("Loaded plugins:", message.plugins);
      console.log("Available skills:", message.skills);
      console.log("Available commands:", message.slash_commands);
    }

    if (message.type === "assistant") {
      console.log("Assistant:", message.message.content);
    }
  }
}

runWithPlugin().catch(console.error);
#!/usr/bin/env python3
"""Example demonstrating how to use plugins with the Agent SDK."""

import asyncio
from pathlib import Path

from claude_agent_sdk import (
    AssistantMessage,
    ClaudeAgentOptions,
    SystemMessage,
    TextBlock,
    query,
)


async def run_with_plugin():
    """Example using a custom plugin."""
    plugin_path = Path(__file__).parent / "plugins" / "my-plugin"

    print(f"Loading plugin from: {plugin_path}")

    options = ClaudeAgentOptions(
        plugins=[{"type": "local", "path": str(plugin_path)}],
        max_turns=3,
    )

    async for message in query(
        prompt="What custom commands do you have available?", options=options
    ):
        if isinstance(message, SystemMessage) and message.subtype == "init":
            print(f"Loaded plugins: {message.data.get('plugins')}")
            print(f"Available skills: {message.data.get('skills')}")
            print(f"Available commands: {message.data.get('slash_commands')}")

        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, TextBlock):
                    print(f"Assistant: {block.text}")


if __name__ == "__main__":
    asyncio.run(run_with_plugin())

2つの言語で共通しているのは次の点です。

  • プラグインのパスをスクリプトの位置から組み立てる:TypeScript は fileURLToPath(new URL("./plugins/my-plugin", import.meta.url))、Python は Path(__file__).parent / "plugins" / "my-plugin" です。どちらも「このスクリプトと同じ場所にある plugins/my-plugin」を指す絶対パスになり、cwd に左右されません。Python は str(plugin_path) で文字列に変えて渡しています。
  • ターン数を3に制限する:TypeScript は maxTurns: 3、Python は max_turns=3 です。「使えるコマンドは何か」を尋ねるだけの例なので、短く終わらせています。
  • 初期化メッセージで読み込み結果を表示し、その後にアシスタントの応答を表示する。

3-6. プラグインのディレクトリ構成

プラグインのディレクトリには、通常 .claude-plugin/plugin.json というマニフェストが入っています。ただしマニフェストは任意で、省いた場合は Claude Code がディレクトリの構成から部品を自動で見つけます。入れられるものは次のとおりです。

my-plugin/
├── .claude-plugin/
│   └── plugin.json          # Plugin manifest (optional, components auto-discovered without it)
├── skills/                   # Agent Skills (invoked autonomously or via /plugin-name:skill-name)
│   └── my-skill/
│       └── SKILL.md
├── commands/                 # Skills as flat .md files
│   └── custom-cmd.md
├── agents/                   # Custom agents
│   └── specialist.md
├── hooks/                    # Event handlers
│   └── hooks.json
└── .mcp.json                # MCP server definitions

注意点が1つあります。commands/ は、スキルを平らな Markdown ファイルとして置く場所です。新しいプラグインでは skills/ を使ってください、と原文は書いています。Claude Code は両方に対応しています。第90回で見た「.claude/commands/ は旧形式で、スキルが後継」と同じ関係です。

筆者の指摘:この構成図を SDK の利用者の目で見ると、hooks/hooks.json と .mcp.json が入っている点が重要です。プラグインを読み込むことは、そのプラグインのフック(第82回で見たとおり、シェルコマンドのフックもありうる)を動かし、MCP サーバーを起動することにつながります。第57回の「プラグインのインストールは任意のコードの実行になりうる」という警告は、SDK で読み込む場合にもそのまま当てはまります。また第90回で見たとおり、スキルの allowed-tools の frontmatter はツールの事前承認を持ち込めます。出どころの分からないプラグインを読み込むなら、中身を確かめてからにするべきでしょう。一方、MCP サーバーのツールは明示的な許可が無いと呼べない(第84回)ので、.mcp.json のサーバーが起動しても、ツールの実行までは自動で通るわけではありません。

3-7. 複数のソースを組み合わせる

違う場所のプラグインを組み合わせる例です。

import * as os from "node:os";
import * as path from "node:path";

plugins: [
  { type: "local", path: "./local-plugin" },
  {
    type: "local",
    path: path.join(os.homedir(), ".claude", "custom-plugins", "shared-plugin")
  }
];

これはオプションの一部だけを抜き出した断片で、そのままでは実行できるファイルになりません(plugins: から始まっています)。3-2 の例の options の中に入れて使う形です。2つ目のプラグインは、os.homedir() と path.join でホームの下の .claude/custom-plugins/shared-plugin を組み立てています。

ここで原文は重要な注意を2つ置いています。

  • SDK はチルダのパス(~/plugins など)を展開しません。 例がわざわざ os.homedir() を使っているのはこのためです。"~/plugins/x" と書くと、~ という名前のディレクトリとして扱われると考えられます(筆者の解釈)。
  • プラグインのパスが存在しないと、SDK はそのプラグインを飛ばし、セッションは続きます。 だから、初期化メッセージの plugins 一覧で、各プラグインが読み込まれたかを確かめる必要があります。

2点目は、第93回の mirror_error、第96回のテレメトリの送信失敗と同じ「失敗しても止まらない」設計です。プラグインが無いまま動いたエージェントは、プラグインのスキルやフックが無い状態で作業を続けるので、たとえばフックで入れていた安全確認が効かないまま進む、ということが起こりえます。本番では、期待するプラグインが plugins 一覧に無ければ、アプリ側で処理を止めるのが安全です(筆者の指摘)。

3-8. トラブルシューティング

プラグインが読み込まれない

プラグインが初期化メッセージの plugins 一覧に出ない場合は、まず plugin_errors フィールドで理由を確かめ、そのうえで次を確認します。

確認内容
パスプラグインのルート(skills/・agents/・hooks/・commands/・.claude-plugin/ の親)を指しているか
plugin.jsonマニフェストがあるなら、JSON の構文として正しいか
ファイルの権限プラグインのディレクトリが読めるか
ディレクトリの存在存在しないパスは飛ばされ、plugins 一覧に出ない

plugin_errors は、第75回のヘッドレスの回で「CI では stderr の警告が出ないので plugin_errors や mcp_server_errors を見る」とした項目と同じ名前です(筆者の対応づけ)。原文はこのフィールドの説明を TypeScript リファレンスの SDKSystemMessage に回しています。

スキルが出てこない

確認内容
名前空間/plugin-name:skill-name の形で呼んでいるか
初期化メッセージスキルが正しい名前空間付きで skills 一覧に出ているか
スキルのファイル各スキルが skills/ の下の自分のサブディレクトリに SKILL.md を持っているか(例:skills/my-skill/SKILL.md)

3つ目は、skills/ の直下に SKILL.md を置いてしまう、あるいは skills/my-skill.md のような平らなファイルにしてしまう間違いを指していると読めます。平らなファイルは commands/ の形式です(筆者の解釈)。


4. まとめ

  • SDK はプラグイン(スキル・エージェント・フック・MCP サーバー)を plugins オプションで読み込む。type は "local" だけ。マーケットプレイスやリポジトリのものは先にダウンロードする。CLI で入れたものは ~/.claude/plugins/ のパスを渡せば使える。
  • 相対パスは cwd オプション基準。パスはプラグインのルートを指す。チルダは展開されない。完全な例のように、スクリプトの位置から絶対パスを組み立てるのが安全。
  • 読み込み結果は初期化メッセージの plugins・skills・slash_commands で確かめる(TS は直接のフィールド、Python は message.data)。スキルとコマンドはプラグイン名の接頭辞付き。
  • スキルを直接呼ぶには /plugin-name:skill-name。
  • マニフェスト(.claude-plugin/plugin.json)は任意で、無ければ構成から自動検出。commands/ は旧形式、新しくは skills/。
  • 存在しないパスは黙って飛ばされ、セッションは続く。期待するプラグインが一覧に無ければアプリ側で止める。理由は plugin_errors で見る。
  • プラグインの読み込みはフックや MCP サーバーの実行につながる。出どころの分からないものは中身を確かめてから。

次回予告

次回は 「SDK で Claude Code の機能を使う」(agent-sdk/claude-code-features) を取り上げる予定です。

今回、プラグインを plugins オプションで明示的に読み込む方法を見ました。一方で、第90回のスキルや第94回のマルチテナント分離では、CLAUDE.md・スキル・設定などをファイルシステムから読み込ませる settingSources の話や、「settingSources が制御しないもの」があることにも触れてきました。次回は、Claude Code の機能のうち、SDK のエージェントにどれがどこから読み込まれるのか、それをどう絞ったり有効にしたりするのかを整理して見ていきます。


よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました