0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

セルフホスト環境でClaude Codeを動かす — 自社Macで17ジョブを無人運用している構成と注意点

0
Posted at

はじめに

当社(合同会社ジョインクラス)は、社員が筆者ひとりの会社です。出版・受託開発・SaaSの3事業を、自宅のMac 1台で動くClaude Codeの無人ジョブ17本で回しています。クラウドの実行基盤は使っていません。理由は単純で、財務データや戦略メモを含むリポジトリを外に出したくなかったからです。

この記事では「自分のマシンでClaude Codeを無人実行する」ための構成を、実際に動いているスクリプトをそのまま載せて説明します。経営の話は最小限にして、本体は設定ファイルとコマンドで構成します。読み終わったらその日のうちに1本目のジョブが組めるはずです。

全体構成

構成要素は3つだけです。

  1. launchd — macOSのジョブスケジューラ。cronの代わり
  2. ラッパー関数 claude_run — claude -p を包み、実コストを記録する
  3. 部門スクリプト — ラッパーを呼んで結果をSlackに流すBash
launchd (plist)
  └─ auto-dept-publishing.sh
       └─ claude_run  ← lib/claude-run.sh
            ├─ claude --output-format json -p "..."
            └─ record-cost.py  → cost-tracker.json / Slack通知

落とし穴1: macOSではcronが動かない

最初はcronで組みました。全滅しました。原因はmacOSのフルディスクアクセスで、cronから起動したプロセスがホームディレクトリ配下のファイルを読めなかったのです。launchdに全面移行して解決しました。

launchdのジョブ定義は ~/Library/LaunchAgents/ にplistを置くだけです。以下は実際に毎週水曜7時に動いている出版部門ジョブの定義です。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.joinclass.auto-dept-publishing</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/kyoagun/workspace/one-ceo/.company/scripts/auto-dept-publishing.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>HOME</key>
    <string>/Users/kyoagun</string>
  </dict>
  <key>WorkingDirectory</key>
  <string>/Users/kyoagun/workspace/one-ceo</string>
  <key>StandardOutPath</key>
  <string>/Users/kyoagun/workspace/one-ceo/.company/scripts/logs/auto-dept-publishing.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/kyoagun/workspace/one-ceo/.company/scripts/logs/auto-dept-publishing-error.log</string>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Weekday</key>
    <integer>3</integer>
    <key>Hour</key>
    <integer>7</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
</dict>
</plist>

ポイントは2つあります。

  • PATHとHOMEを明示する。 launchdはログインシェルの環境を引き継ぎません。nvmで入れた claude はPATHに通っていないので、plist側で絶対パスを書きます。これを忘れると「command not found」すら出ず、ただ黙って何も起きません。
  • StandardErrorPathを必ず指定する。 無人実行はエラーが見えないのが一番怖い。最初から標準エラーをファイルに落としておきます。

登録と動作確認は次のコマンドです。

# 登録
launchctl load ~/Library/LaunchAgents/com.joinclass.auto-dept-publishing.plist

# スケジュールを待たずに今すぐ1回動かす(検証用)
launchctl start com.joinclass.auto-dept-publishing

# 登録されているか・最終終了コードを確認
launchctl list | grep joinclass

# ログを見る
tail -f ~/workspace/one-ceo/.company/scripts/logs/auto-dept-publishing.log

launchctl list の第2列が終了コードです。0以外なら error.log を見ます。

落とし穴2: --bare を付けると「Not logged in」で死ぬ

無人実行なので軽量そうな --bare を試しましたが、これは認証情報を読まないモードで、Not logged in で即終了します。セルフホストではログイン済みの通常モードで claude -p を呼びます。Maxプランでログインした状態をそのまま使うので、APIキーの管理も不要です。

落とし穴3: コストを「概算」で記録していた

これが一番痛かった失敗です。当初のコスト追跡は、スクリプトが自己申告する固定値(0.02ドルなど)を積み上げる作りでした。月160ドルで警告、200ドルで自動停止というガードを組んでいたのに、架空の数字で判定していたので一度も作動しない状態だったのです。

実測すると「2+2」を聞くだけの1回でも0.27〜0.47ドルかかっていました。内訳はキャッシュ作成の約41,000トークンで、そのうち自社ファイル(CLAUDE.md、エージェント定義、スキル)は5,800トークン程度。残りはClaude Code側の固定オーバーヘッドです。つまり削れるのは呼び出し回数だけで、プロンプトを短くしても大して効きません。

対策として、全ジョブを共通ラッパー経由に差し替えました。

#!/bin/bash
# lib/claude-run.sh — claude を無人実行するときの共通ラッパー。実コストを記録する。
#
# 使い方:
#   source /path/to/lib/claude-run.sh
#   RESULT=$(claude_run dept-dev --effort low -p "…")
#
# 注意: --bare は認証を読まないため "Not logged in" で失敗する。使わない。

_CLAUDE_RUN_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

claude_run() {
  local label="$1"; shift
  claude --output-format json \
         --fallback-model claude-sonnet-5 \
         --max-budget-usd 3 \
         "$@" 2>/dev/null \
    | python3 "$_CLAUDE_RUN_LIB/record-cost.py" "$label"
}

3つのフラグが肝です。

フラグ 役割
--output-format json 応答と一緒に total_cost_usd と usage が返る。これを記録する
--max-budget-usd 3 1回の実行上限。暴走しても3ドルで止まる
--fallback-model 上位モデルが混雑時に落ちるのではなく、Sonnetに切り替えて完走させる

JSONを受け取って記録する側がこちらです。

#!/usr/bin/env python3
"""claude --output-format json の出力を受け取り、応答テキストを標準出力に流しつつ実コストを記録する。"""
import datetime, json, os, pathlib, subprocess, sys

DEFAULT_COST_FILE = pathlib.Path(__file__).resolve().parents[1] / "cost-tracker.json"
WARN_USD = 160.0
STOP_USD = 200.0

def notify_slack(text):
    url = os.environ.get("SLACK_WEBHOOK_URL")
    if not url:
        return
    subprocess.run(
        ["curl", "-sf", "-X", "POST", "-H", "Content-Type: application/json",
         "-d", json.dumps({"text": text}), url],
        capture_output=True, timeout=20,
    )

def main():
    label = sys.argv[1]
    raw = sys.stdin.read()
    try:
        d = json.loads(raw)
    except json.JSONDecodeError:
        sys.stdout.write(raw)          # JSONでなければ素通しする
        return 0

    sys.stdout.write(d.get("result") or "")

    path = pathlib.Path(os.environ.get("COST_FILE") or DEFAULT_COST_FILE)
    month = datetime.date.today().strftime("%Y-%m")
    try:
        t = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        t = {}
    if t.get("month") != month:         # 月が替わったら前月分を退避して開き直す
        if t.get("month"):
            path.with_name(f"cost-tracker-{t['month']}.json").write_text(
                json.dumps(t, ensure_ascii=False, indent=1), encoding="utf-8")
        t = {"month": month}

    cost = float(d.get("total_cost_usd") or 0)
    usage = d.get("usage") or {}
    before = float(t.get("total_usd") or 0)
    t["total_usd"] = round(before + cost, 6)
    j = t.setdefault("jobs", {}).setdefault(
        label, {"calls": 0, "usd": 0.0, "errors": 0, "cache_creation_tokens": 0})
    j["calls"] += 1
    j["usd"] = round(j["usd"] + cost, 6)
    j["cache_creation_tokens"] += int(usage.get("cache_creation_input_tokens") or 0)
    if d.get("is_error"):
        j["errors"] += 1
    path.write_text(json.dumps(t, ensure_ascii=False, indent=1), encoding="utf-8")

    # 閾値は跨いだ瞬間だけ通知する(毎回鳴らすと無視されるようになる)
    now = t["total_usd"]
    if before < STOP_USD <= now:
        notify_slack(f":rotating_light: 月間AI支出が ${now:.2f} に到達(上限${STOP_USD:.0f})。直近: {label}")
    elif before < WARN_USD <= now:
        notify_slack(f":warning: 月間AI支出 ${now:.2f} / ${STOP_USD:.0f}。直近: {label}")
    return 0

if __name__ == "__main__":
    sys.exit(main())

標準出力には応答テキストだけを流すので、既存の claude -p をそのまま claude_run <ラベル> に置き換えられます。通知を「閾値を跨いだ瞬間だけ」にしているのは、毎回鳴らすと人間が無視し始めるからです。

部門スクリプト本体

ラッパーを使う側はこれだけです。実際に水曜7時に動いているものをそのまま載せます。

#!/bin/bash
# 出版部門 自律実行スクリプト
# launchd: com.joinclass.auto-dept-publishing(毎週水曜 7:00 JST)
set -o pipefail

source "/Users/kyoagun/workspace/one-ceo/.company/scripts/lib/claude-run.sh"

export PATH="/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/kyoagun"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/.company/scripts/logs/auto-dept-publishing.log"

notify_slack() {
  if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
    curl -sf -X POST "$SLACK_WEBHOOK_URL" -H 'Content-Type: application/json' \
      -d "$(jq -n --arg text "$1" '{text: $text}')" >/dev/null 2>&1 || true
  fi
}

echo "$(date): === 出版部門 自律実行開始 ===" >> "$LOG_FILE"

cd "$PROJECT_ROOT"
RESULT=$(claude_run dept-publishing --effort low -p "
あなたは出版部門のPublisherエージェントです。以下のタスクを実行してください。

1. 全書籍の状態を確認:
   - Zenn: /Users/kyoagun/workspace/zenn-content-company/books/ の各config.yaml
   - Kindle: .company/departments/publishing/STATE.md
2. 各書籍のZenn上の情報を確認(公開状態、章数)
3. 目標に対する進捗を評価
4. 改善提案があれば1-2個提示

結果を以下の形式で報告:
📚 出版部門 週次レポート
- Zenn: X冊公開中
- Kindle: X冊販売中
- 改善提案: (あれば)
" --allowedTools "Read,Bash" 2>/dev/null | tail -20)

echo "$(date): 結果: $RESULT" >> "$LOG_FILE"

if [ -n "$RESULT" ]; then
  notify_slack "📚 [出版部門 週次レポート]\n${RESULT}"
fi

echo "$(date): === 出版部門 自律実行完了 ===" >> "$LOG_FILE"

注意点を挙げます。

  • スクリプト内でもPATHとHOMEを再export しています。plistで指定していても、手動実行やほかのスケジューラから呼ばれたときの保険です。二重に書く価値があります。
  • --allowedTools "Read,Bash" で使えるツールを絞っています。レポート系ジョブにWriteやEditは不要です。無人実行では「できることを減らす」のが安全側の設計です。
  • --effort low を付けています。週次レポート程度なら十分で、コストが目に見えて下がります。
  • set -e は使っていません。 Slack通知が失敗してもジョブ全体を落としたくないからです。代わりに pipefail と || true で要所だけ制御しています。

落とし穴4: 無人のAIに git push --force を実行された

セルフホストで一番怖いのは、AIがローカルのファイルシステムとgitに直接触れることです。当社では一度、自動実行中のAIが git push --force を実行してブランチが消えました。

対策はCLAUDE.mdに「絶対禁止リスト」を作ることと、無人ジョブでは --allowedTools を最小にすることの2本立てです。禁止リストは次の方針で書いています。

  • 対外アクション(メール送信、SNS投稿、デプロイ)は無人ジョブでは直接実行させず、ドラフトを作って承認キューに積む
  • git push --force、rm -rf、本番DBへの書き込みは明示的に禁止
  • ルールは200行以内。それ以上増えるとAIが矛盾した指示に混乱し、逆の動作を始めます(これも実体験です。1,000行超えで事故りました)

検証手順のまとめ

新しいジョブを組んだら、公開前に次の順で確認します。

# 1. スクリプト単体を手で叩く(launchdの環境を模倣するため env -i で素の環境から)
env -i HOME=$HOME PATH=/Users/you/.nvm/versions/node/v22.17.0/bin:/usr/bin:/bin \
  /bin/bash ~/workspace/one-ceo/.company/scripts/auto-dept-publishing.sh

# 2. コストが記録されたか
python3 -c "import json;d=json.load(open('.company/scripts/cost-tracker.json'));print(d['jobs'])"

# 3. launchd経由で起動して同じ結果になるか
launchctl start com.joinclass.auto-dept-publishing
sleep 60 && tail -5 .company/scripts/logs/auto-dept-publishing-error.log

手順1の env -i が重要です。ターミナルから普通に実行すると動くのに、launchdから動かないケースの9割はPATHの違いなので、先に素の環境で確かめておくと時間を無駄にしません。

おわりに

セルフホストのClaude Code運用で学んだことは「AIの能力を制限するためではなく、安心して委任するための仕組みを作る」に尽きます。コストは実測しないとガードが機能しない。ツールは絞る。禁止事項は短く明文化する。この3つを押さえれば、Mac 1台で部門単位の定常業務を無人化できます。

この記事で触れたlaunchd運用、Hooksによる禁止制御、コスト管理の全体像は、当社の書籍『Claude Code 全自動化バイブル』にまとめています。17ジョブ分の設計と失敗事例を体系的に書いているので、本格的に組む方はそちらも参考にしてください。

書籍一覧: https://zenn.dev/joinclass?tab=books

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?