🐳

Dev Contaienr 上の Claude Code とホストの Mac で起動している Neovim を IDE 連携する

に公開

masaki です。
先日は「Dev Container で起動した Claude Code から Hooks でホストの Mac に通知する」という記事を書きました。
https://zenn.dev/socialplus/articles/ea9be95301ae99
今日は先日に引き続き、Claude Code と Dev Container の話題です。
先日の記事の内容と共通する部分があるため、先日の記事に興味を持っていただいた方は今日の記事にも興味を持っていただけるかもしれません。

この記事で得られるもの

  • Claude Code (コンテナ) と Neovim (ホスト) を 双方向に接続 する方法
  • 上記作業を 自動化する Bash スクリプト
  • パスの食い違いによって diff 表示で発生する問題 の解決策

前提条件

この記事の内容を実践するには、以下の環境が必要です。

  • macOS(Docker Desktop がインストール済み)
  • Dev Container 環境が構築済み
  • Neovim と claudecode.nvim プラグインがインストール済み

また、Docker・Neovim・Claude Code についての基本的な知識があることを前提としています。

Claude Code の IDE 機能

Claude Code はターミナルベースの AI コーディングアシスタントですが、主要な IDE とシームレスに連携することができます。

https://docs.anthropic.com/ja/docs/claude-code/ide-integrations

現在サポートされているエディタは Visual Studio Code 系と JetBrains 系のエディタです。
連携することで使用できる機能は以下のとおりです。

  • クイック起動: エディタから直接 Claude Code を起動
  • 差分表示: IDE の差分ビューアーでコードの変更を確認可能
  • 選択コンテキスト共有: IDE 上で選択中のテキストや開いているタブのコンテキストを共有
  • ファイル参照ショートカット: @File#L1-99 のようなファイル位置指定を挿入
  • 診断共有: Lint エラーや構文エラーなど IDE の診断情報を共有

これらの機能のうち、「差分表示」や「選択コンテキスト」は便利な機能なので使いたいと思っていましたが、Neovim がサポートされておらず、Neovim をメインエディタとして使っている自分は残念に思っていました。
しかし、claudecode.nvim という Neovim のプラグインがあることが分かり、現在このプラグインを利用して Claude Code と IDE 連携しています。
ちなみにこのプラグインは Claude を用いて VS Code 拡張機能をリバースエンジニアリングして開発したそうです。
claudecode.nvim の開発元である Coder は、セルフホスト型のクラウド開発環境サービスを提供するベンチャー企業で、顧客企業には Dropbox やゴールドマン・サックスなどがいると自社サイトに記載されていたので、公式プラグインではないですが、ある程度信用できそうです。

話を戻します。
claudecode.nvim はとてもよいプラグインで、同一環境で起動している Claude Code と Neovim でしたら問題はありません。
しかし、先日の記事でお伝えしたとおり、弊社では Dev Container を利用しており、Claude Code も Dev Container 内で利用しています。
そこで、Dev Container 上で起動した Claude Code と Mac で起動している Neovim を IDE 連携する仕組みを作ってみました。

1点注意事項として、claudecode.nvim を使っていますが、claudecode.nvim は Claude Code との接続部分だけを使用しています。
つまり、Claude Code を Neovim 内の別バッファで開かず、別ターミナルで開いた Claude Code と Neovim を接続して使用しています。

Claude Code と claudecode.nvim が接続する仕組み

コンテナで起動した Claude Code と Mac で起動した Neovim を接続する」という説明をする前に、「Mac で起動した Claude Code と Mac で起動した Neovim が claudecode.nvim を通じて接続される」仕組みを簡単な図で説明します。

  1. Neovim 起動
  2. claudecode.nvim プラグインが ~/.claude/ide/PORT.lock ファイルを作成する
  3. Claude Code を起動し、 /ide コマンドを実行
  4. Claude Code が ~/.claude/ide/PORT.lock ファイルを読み取り、127.0.0.1:$PORT にWebSocket 接続を試みる
  5. claudecode.nvim プラグインが承認し、両者の接続が確立される

~/.claude/ide/PORT.lock の内容は以下のような JSON で構成されています。
pid は Neovim のプロセスID、workspaceFolders は Neovim を起動しているディレクトリです。

{
  "ideName": "Neovim",
  "transport": "ws",
  "pid": 5915,
  "authToken": "xxxx-xxxx-xxxx-xxxx-xxxx",
  "workspaceFolders": ["/home/user/projects/my-project"]
}

コンテナで起動した Claude Code と接続する仕組み

それでは本題の「コンテナで起動した Claude Code と Mac で起動した Neovim を接続する」仕組みを解説します。
以下が全体図になります。

Mac(ホスト)

  • Neovim を起動
    • claudecode.nvim プラグインによってランダムに選択されたポートで待ち受け
    • 同時に ~/.claude/ide/PORT.lock が作成される

コンテナ

  • socat を起動
    • host.docker.internal で受け付けたリクエストを Mac の 同じポート番号 に転送する
  • Claude Code を起動し /ide を実行
    • ~/.claude/ide/PORT.lock を参照し、host.docker.internal:PORT にアクセス
    • socat を通じて Mac の Neovim と接続できる

前回の記事で紹介した host.docker.internal というコンテナ内からホストマシンの IP アドレスを参照できる特別な DNS を使います。
また、socat(TCP/UDPソケットなどの2つのアドレス間で双方向にデータを転送するツール)でコンテナからホストへの通信をフォワーディングします。
これは、Claude Code が行うコンテナ内の localhost へのアクセスを、host.docker.internal にフォワーディングさせるために必要です。
また、Claude Code は IDE 接続時に WebSocket の プロセスID を参照しますが、コンテナでは Mac で起動している WebSocket のプロセスID が見つかりません。
そのため、コンテナ内にコピーした ~/.claude/ide/PORT.lockpid の値を、コンテナ内で起動した socat のプロセスID で上書きし、コンテナ内で WebSocket のプロセスが起動しているというように見せかける、ということを行っています。

手順

それではステップバイステップで実際の手順を解説します。

環境

Mac(ホスト)

ツール バージョン
macOS Sequoia
Docker Desktop 4.43.1
Dev Container 0.422.0
Neovim v0.11.1
claudecode.nvim v0.2.0

コンテナ

ツール バージョン
Claude Code 1.0.51
jq jq-1.6
socat 1.7.4.2

Step 1: Claude Code コンテナを起動

まずはプロジェクト直下で Dev Container を立ち上げます。

$ devcontainer up --workspace-folder .
✔ Starting Dev Container... done
✔ Container ID: d86f960a52280f977f9362129f5f40cbf54b2eedfecbe258472f4b5171a612c6

コンテナ ID は後続のコマンドで使うので変数に入れておきます。

CONTAINER_ID=d86f9...

Step 2: Neovim を起動

claudecode.nvim プラグインをセットアップし、Neovim を起動します。
claudecode.nvim のセットアップ手順は claudecode.nvim のドキュメントを参照してください。
https://github.com/coder/claudecode.nvim

Step 3: WebSocket のポート番号を取得

Neovim(claudecode.nvim)のプロセス ID から WebSocket のポート番号を取得します。

# Neovim プロセス ID
PID=$(ps xe | grep "PWD=$(pwd)" | grep 'nvim --embed' | awk '{print $1}' | head -n 1)

# WebSocket のポート番号
PORT=$(lsof -nP -iTCP -sTCP:LISTEN -a -p "$PID" | tail -n +2 | awk '{print $9}' | sed -E 's/.*:([0-9]+)(->.*)?/\1/')

echo "WebSocket PORT=$PORT"

Step 4: ~/.claude/ide/PORT.lock をコンテナへコピー

docker cp ~/.claude/ide/$PORT.lock "$CONTAINER_ID":/tmp/$PORT.lock

コピー先は一時ディレクトリ /tmp で OK です。
後ほど所定の場所へ移動します。

Step 5: コンテナに入る

docker exec -e PORT=$PORT -it $CONTAINER_ID bash

docker exec でコンテナに入ります。
このとき、WebSocket のポート番号を環境変数としてコンテナに渡します。

Step 6: socat を起動

通信をポートフォワードするために socat が必要なのでコンテナにインストールしてください。

# socat のインストール(Debian/Ubuntu 系の場合)
apt-get update && apt-get install -y socat

# socat の起動
socat TCP-LISTEN:$PORT,fork,reuseaddr TCP:host.docker.internal:$PORT &
SOCAT_PID=$!

socat を起動し、プロセスID を取得しておきます。
このプロセスID は後のステップで使用します。

Step 7: $PORT.lock を移動、編集

mkdir -p ~/.claude/ide
mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock

PORT.lock を Claude Code が参照する正しい場所に移動します。

Step 8: $PORT.lock の内容を変更

$PORT.lock を編集するために jq が必要なのでコンテナにインストールしてください。

# jq のインストール(Debian/Ubuntu 系の場合)
apt-get update && apt-get install -y jq

# PORT.lock の編集
jq ".pid = $PID" ~/.claude/ide/$PORT.lock > /tmp/$PORT.lock && mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock
jq ".workspaceFolders = [\"$PWD\"]" ~/.claude/ide/$PORT.lock > /tmp/$PORT.lock && mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock

~/.claude/ide/$PORT.lock の内容を変更します。
pidsocat のプロセスID で上書き、workspaceFolders を「コンテナ内のワークスペースディレクトリ」で上書きます。
「コンテナ内のワークスペースディレクトリ」は Claude Code を起動するディレクトリです。
適宜書き換えてください。

Step 9: 動作確認

コンテナ内で Claude Code を起動し、/ide コマンドを実行します。

claude
> /ide

「 Connected to Neovim.」と表示されたら成功です 🎉

スクリプト自動化

接続するための一連の手順をスクリプトにしました。
手動でやると面倒ですが、スクリプトにすると簡単にセットアップできます。

#!/usr/bin/env bash
set -euo pipefail

# 引数チェック
if [[ $# -ne 1 ]]; then
  echo "Usage: $0 <CONTAINER_ID>" >&2
  exit 1
fi

CONTAINER_ID="$1"
PID=''
PORT=''

# 1. コンテナIDが存在するかチェック
if ! docker ps -q --filter "id=$CONTAINER_ID" | grep -q .; then
  echo "⚠️  Container ID '$CONTAINER_ID' が見つかりません。docker ps の出力を確認してください。" >&2
  exit 1
fi
echo "✔️  Container ID: $CONTAINER_ID"

# 2. Neovim のプロセス PID を取得
echo "👉 Locating Neovim PID…"
PID=$(ps xe | grep "PWD=$(pwd)" | grep 'nvim --embed' | awk '{print $1}' | head -n 1)

if [[ -z "$PID" ]]; then
  echo "⚠️  Neovim プロセスが見つかりませんでした。" >&2
  exit 1
fi
echo "✔️  Neovim PID: $PID"

# 3. LISTEN ポートを特定
echo "👉 Detecting listen port for PID $PID"
PORT=$(lsof -nP -iTCP -sTCP:LISTEN -a -p $PID | tail -n +2 | awk '{print $9}' | sed -E 's/.*:([0-9]+)(->.*)?/\1/')

if [[ -z "$PORT" ]]; then
  echo "⚠️  ポートが見つかりませんでした。" >&2
  exit 1
fi
echo "✔️  Port: $PORT"

# コンテナ内の ~/.claude/ide/*.lock を削除
docker exec $CONTAINER_ID bash -c 'rm -f ~/.claude/ide/*.lock'

# 4. Lock ファイルをコンテナ内 /tmp にコピー
HOST_LOCK_DIR="$HOME/.claude/ide"
mkdir -p "$HOST_LOCK_DIR"
echo "👉 Copying lock file to container…"
docker cp "$HOST_LOCK_DIR/$PORT.lock" \
  "$CONTAINER_ID":/tmp/"$PORT".lock
echo "✔️  Lock file copied."

# 5. コンテナ内で実行するスクリプト作成
echo "👉 Entering container to start socat and update lock…"
# 一時ファイルを作成してスクリプト内容を書き込み
TEMP_SCRIPT="/tmp/start_port_forward_in_container.$$"
cat > "$TEMP_SCRIPT" << 'EOF'
#!/usr/bin/env bash
set -euo pipefail

# 環境変数チェック
if [[ -z "${PORT:-}" ]]; then
  echo "⚠️  PORT環境変数が設定されていません。" >&2
  exit 1
fi

# コマンドの存在チェック
if ! command -v socat &> /dev/null; then
    echo "⚠️  エラー: socat コマンドが見つかりません。" >&2
    echo "   コンテナ内でインストールしてください。" >&2
    exit 1
fi

if ! command -v jq &> /dev/null; then
    echo "⚠️  エラー: jq コマンドが見つかりません。" >&2
    echo "   コンテナ内でインストールしてください。" >&2
    exit 1
fi

# 6. 既存プロセスを kill して socat 起動
echo "  → Killing any existing process on port $PORT…"
fuser -k "$PORT"/tcp || true
echo "  → Starting socat…"
socat TCP-LISTEN:$PORT,fork,reuseaddr TCP:host.docker.internal:$PORT &

# 7. socat の PID を取得
SOCAT_PID=$!
echo "  → socat PID: $SOCAT_PID"

# 8. lock ファイルをホストと共有されるディレクトリへ移動
mkdir -p ~/.claude/ide
mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock

# 9. lock ファイルの中身を書き換え
jq ".pid = $SOCAT_PID" ~/.claude/ide/$PORT.lock \
  > /tmp/$PORT.lock && mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock

jq ".workspaceFolders = [\"$PWD\"]" ~/.claude/ide/$PORT.lock \
  > /tmp/$PORT.lock && mv /tmp/$PORT.lock ~/.claude/ide/$PORT.lock

echo "  → lock file updated."
EOF

# 実行可能ファイルに変換
chmod +x "$TEMP_SCRIPT"

# コンテナ内にコピー
docker cp "$TEMP_SCRIPT" "$CONTAINER_ID":/tmp/start_port_forward_in_container

# 一時ファイルを削除
rm "$TEMP_SCRIPT"
docker exec -e PORT="$PORT" -e WORKSPACE="$PWD" "$CONTAINER_ID" /tmp/start_port_forward_in_container

echo "✅ All done! socat is running inside the container."

設定方法

上記コードをコピーし、 Mac 上の /usr/local/bin 配下などに setup_claudecode_ide_in_container として配置し、スクリプトとして実行できるよう実行権限を変更してください。

chmod +x setup_claudecode_ide_in_container

Dev Container を立ち上げたら、以下のコマンドを実行します。

setup_claudecode_ide_in_container {コンテナID}

手動でセットアップしたときと同様に、コンテナ内の Claude Code から Mac の Neovim に接続できたら成功です。

diff 表示の問題を解決する

これで Neovim と接続できたと思いきや、1点問題があります。
Claude Code が提案した修正を Neovim の vertical diff などで表示すると、既存のファイルではなく新規ファイルとして表示される、という問題です。
原因は ホストのファイルパスコンテナ内のファイルパス が一致しないためです。

この問題に対して私は claudecode.nvim プラグインのコードを変更し、diff 表示時のパスをコンテナ内の絶対パスから相対パスに変換する、という手段で解決しました。
claudecode.nvimlua/claudecode/tools/open_diff.lua というファイルを以下のように変更することで、期待する結果が出力されるようになりました。

diff --git a/lua/claudecode/tools/open_diff.lua b/lua/claudecode/tools/open_diff.lua
index 18525a9..81ef1c3 100644
--- a/lua/claudecode/tools/open_diff.lua
+++ b/lua/claudecode/tools/open_diff.lua
@@ -66,11 +66,20 @@ local function handler(params)
     error({ code = -32000, message = "Internal server error", data = "Failed to load diff module" })
   end
 
+  local old_file_path = params.old_file_path
+  if old_file_path:sub(1, 5) == "/path/" then -- path はコンテナ内の Claude Code を起動しているディレクトリ
+    old_file_path = old_file_path:sub(6)
+  end
+  local new_file_path = params.old_file_path
+  if new_file_path:sub(1, 5) == "/path/" then -- path はコンテナ内の Claude Code を起動しているディレクトリ
+    new_file_path = new_file_path:sub(6)
+  end
+
   -- Use the new blocking diff operation
   local success, result = pcall(
     diff_module.open_diff_blocking,
-    params.old_file_path,
-    params.new_file_path,
+    old_file_path,
+    new_file_path,
     params.new_file_contents,
     params.tab_name
   )

ただし、絶対パスから相対パスに変換するパスの値が固定値だとプロジェクトごとにパスが異なる場合に対応できないため、claudecode.nvim をフォークし、プロジェクト配下の .nvim.lua で上書きできるようにしました。

https://github.com/masakiq/claudecode.nvim/pull/1/files

コンテナを起動しているプロジェクト配下の .nvim.lua を以下のように定義することで、プロジェクトに応じたパス変換を行うことができます。

require("claudecode").setup({
  diff_strip_path_prefix = "/app/", -- コンテナ内の Claude Code を起動しているディレクトリ
})

パス問題はこれでとりあえずは解決できましたが、もっと他によい方法があるかもしれません。

まとめ

本記事では、Dev Container 環境で動作する Claude Code とホストマシンの Neovim を接続する方法を紹介しました。

実現できたこと:

  • コンテナとホスト間の IDE 接続
  • 自動化スクリプトによる簡単なセットアップ
  • パス変換による diff 表示の問題解決

今後の課題:

  • パス変換のより汎用的な解決策
  • 他のエディタへの応用

この仕組みにより、Dev Container の利便性を保ちながら、普段使い慣れた Neovim 環境で Claude Code の強力な AI アシスタント機能を活用できるようになりました。
開発効率の向上に貢献できれば幸いです。

もし改善案や質問がありましたら、ぜひコメントでお知らせください!

Social PLUS Tech Blog

Discussion