@cocorograph/hub-agent 0.8.57 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,8 +17,10 @@ curl -fsSL https://api.hub.cocorograph.com/api/cockpit/agents/install-script | b
17
17
 
18
18
  このスクリプトは以下を **全自動** で実行します:
19
19
  - Homebrew (macOS で無ければ install)
20
- - tmux + node 20+ (無ければ install)
20
+ - tmux + node (Active LTS) + python3 (無ければ install)
21
+ - GNU coreutils の `timeout` (補助コマンド。macOS では brew で導入して `gtimeout` への symlink を `~/.local/bin` に作成、Linux は存在確認のみで不在なら手動導入を案内。無くてもセットアップは続行)
21
22
  - `npm i -g @cocorograph/hub-agent`
23
+ - `npm i -g @anthropic-ai/claude-code` / `npm i -g @openai/codex` (Claude Code / Codex CLI。既存なら更新)
22
24
  - `hub-agent enroll <token>` (token は Hub session 経由で埋め込み済、5 分有効)
23
25
  - `hub-agent install-service` で OS サービス登録
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.57",
3
+ "version": "0.8.59",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -12,11 +12,14 @@
12
12
  # 1. macOS なら Homebrew (なければ install)
13
13
  # - 管理者権限が無い環境では自動で $HOME/homebrew にユーザーローカル
14
14
  # インストールに切り替わる (HUB_AGENT_USER_BREW=1 で強制も可)
15
- # 2. tmux + node (なければ install via brew / apt)
16
- # 3. npm i -g @cocorograph/hub-agent
17
- # 4. npm i -g @anthropic-ai/claude-code (既存があれば upgrade のみ)
18
- # 5. HUB_AGENT_TOKEN が設定されていれば hub-agent enroll を自動実行
19
- # 6. hub-agent install-service で OS サービス化
15
+ # 2. tmux + node + python3 (なければ install via brew / apt 等)
16
+ # 3. GNU coreutils の timeout (補助コマンド。macOS では brew で入れて gtimeout への
17
+ # symlink を ~/.local/bin に作成。Linux は存在確認のみで、不在なら手動導入を案内)
18
+ # 4. npm i -g @cocorograph/hub-agent
19
+ # 5. npm i -g @anthropic-ai/claude-code (既存があれば upgrade のみ)
20
+ # 6. npm i -g @openai/codex (常に install / upgrade)
21
+ # 7. HUB_AGENT_TOKEN が設定されていれば hub-agent enroll を自動実行
22
+ # 8. hub-agent install-service で OS サービス化
20
23
  # =============================================================================
21
24
 
22
25
  set -euo pipefail
@@ -41,6 +44,7 @@ NODE_DEFAULT_BREW_FORMULA="node@24"
41
44
 
42
45
  PACKAGE_NAME="@cocorograph/hub-agent"
43
46
  CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
47
+ CODEX_PACKAGE="@openai/codex"
44
48
 
45
49
  color_step() { printf "\033[1;34m==> %s\033[0m\n" "$1"; }
46
50
  color_ok() { printf "\033[1;32m✓ %s\033[0m\n" "$1"; }
@@ -223,6 +227,49 @@ persist_user_brew_shellenv() {
223
227
  done
224
228
  }
225
229
 
230
+ # snippet をマーカーで囲んで各 profile に冪等追記する。
231
+ # 使い方: persist_profile_snippet '<snippet>' '<label>' '<marker>' '<end_marker>' <profile>...
232
+ # 既に「snippet と完全一致する行」があれば skip する(部分一致だとコメントアウトされた
233
+ # `#export PATH=...` を既存扱いにして追記を飛ばす。マーカーの有無は見ない——マーカーだけ
234
+ # 残って snippet が欠けた部分書込みを再実行で補完できるようにするため)。
235
+ # 追記できない profile(root 所有・読み取り専用)は警告して次へ進み、最後に 1 を返す。
236
+ # 呼び出し側が失敗を致命にするか警告に留めるかを決める(set -e 下で裸で呼べば致命)。
237
+ persist_profile_snippet() {
238
+ local snippet="$1" label="$2" marker="$3" end_marker="$4"
239
+ shift 4
240
+ local target rc=0
241
+ for target in "$@"; do
242
+ if [[ -f "$target" ]] && grep -Fxq "$snippet" "$target"; then
243
+ continue
244
+ fi
245
+ if {
246
+ printf '\n%s\n' "$marker"
247
+ printf '%s\n' "$snippet"
248
+ printf '%s\n' "$end_marker"
249
+ } >> "$target" 2>/dev/null; then
250
+ color_ok "$target に $label を追記 (新規シェルから有効)"
251
+ else
252
+ color_warn "$target に $label を追記できません (書き込み権限を確認してください)"
253
+ rc=1
254
+ fi
255
+ done
256
+ return "$rc"
257
+ }
258
+
259
+ # bash の login profile を 1 つ選ぶ。bash は ~/.bash_profile → ~/.bash_login → ~/.profile の
260
+ # うち最初に存在するものだけを読むため、~/.profile しか無い環境で ~/.bash_profile を
261
+ # 新規作成すると ~/.profile が読まれなくなる。既存のものを優先し、無ければ ~/.bash_profile。
262
+ bash_login_profile() {
263
+ local f
264
+ for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
265
+ if [[ -f "$f" ]]; then
266
+ printf '%s\n' "$f"
267
+ return 0
268
+ fi
269
+ done
270
+ printf '%s\n' "$HOME/.bash_profile"
271
+ }
272
+
226
273
  # user-mode の場合、npm の global prefix を $HOME/.npm-global に切り替える。
227
274
  # brew が $HOME/homebrew にいるとき、その prefix への書き込み権限はあるものの、
228
275
  # 後で system 版 node / npm に切り替わる可能性も考えて user 領域に逃がしておく。
@@ -256,8 +303,6 @@ ensure_npm_user_prefix() {
256
303
  export PATH="$HOME/.npm-global/bin:$PATH"
257
304
 
258
305
  local snippet='export PATH="$HOME/.npm-global/bin:$PATH"'
259
- local marker="# >>> hub-agent: npm-global PATH (user-mode) >>>"
260
- local end_marker="# <<< hub-agent: npm-global PATH <<<"
261
306
  # 永続先は OS で出し分け。Linux の hub-agent は systemd --user 起動時や
262
307
  # `$SHELL -lic` 経由の env 注入で PATH を解決するため、login(.profile) と
263
308
  # interactive(.bashrc) の双方に通す。macOS は従来どおり zsh/bash の profile。
@@ -267,18 +312,9 @@ ensure_npm_user_prefix() {
267
312
  else
268
313
  targets=("$HOME/.bashrc" "$HOME/.profile")
269
314
  fi
270
- local target
271
- for target in "${targets[@]}"; do
272
- if [[ -f "$target" ]] && grep -Fq "$snippet" "$target"; then
273
- continue
274
- fi
275
- {
276
- printf '\n%s\n' "$marker"
277
- printf '%s\n' "$snippet"
278
- printf '%s\n' "$end_marker"
279
- } >> "$target"
280
- color_ok "$target に npm-global PATH を追記"
281
- done
315
+ persist_profile_snippet "$snippet" "npm-global PATH" \
316
+ "# >>> hub-agent: npm-global PATH (user-mode) >>>" "# <<< hub-agent: npm-global PATH <<<" \
317
+ "${targets[@]}"
282
318
  }
283
319
 
284
320
  # =============================================================================
@@ -544,6 +580,120 @@ ensure_build_tools() {
544
580
  fi
545
581
  }
546
582
 
583
+ # Hub AI bundle のフック・helper と hub-agent bootstrap が使う python3 を保証する。
584
+ # macOS の /usr/bin/python3 は Xcode CLT 未導入時にスタブの場合があるため、
585
+ # command の存在ではなく実際の version コマンドが成功するかで判定する。
586
+ ensure_python3() {
587
+ if python3 --version >/dev/null 2>&1; then
588
+ color_ok "$(python3 --version 2>&1)"
589
+ return 0
590
+ fi
591
+
592
+ color_step "python3 をインストール"
593
+ if [[ "$(uname)" == "Darwin" ]]; then
594
+ # macOS で python3 が動かないのは、ほぼ Xcode CLT 未導入(/usr/bin/python3 がスタブ)。
595
+ # brew 経路が失敗したら、set -e で黙って落ちる前に実際の復旧手順を出す。
596
+ if ! retry 3 brew install python; then
597
+ color_err "python3 を導入できません。\`xcode-select --install\` を実行してから再実行してください"
598
+ exit 1
599
+ fi
600
+ elif present apt-get; then
601
+ retry 3 sudo apt-get update -y
602
+ retry 3 sudo apt-get install -y python3
603
+ elif present dnf; then
604
+ retry 3 sudo dnf install -y python3
605
+ elif present pacman; then
606
+ retry 3 sudo pacman -S --noconfirm python
607
+ else
608
+ color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。python3 を手動で install してください"
609
+ exit 1
610
+ fi
611
+ hash -r 2>/dev/null || true
612
+
613
+ if python3 --version >/dev/null 2>&1; then
614
+ color_ok "$(python3 --version 2>&1)"
615
+ else
616
+ color_err "python3 の導入後も実行できません"
617
+ exit 1
618
+ fi
619
+ }
620
+
621
+ # timeout を保証する(補助コマンド。AI セッションの Bash やスクリプトが使うが、
622
+ # hub-agent の常駐動作には必須ではないため、導入失敗は警告して継続する。
623
+ # 必須ランタイムの python3 とは契約を分けている)。
624
+ # macOS の coreutils は gtimeout 名で入るため、timeout だけを ~/.local/bin に公開する。
625
+ # coreutils の gnubin 全体を PATH に足すと sed / date 等まで GNU 版へ置き換わり、
626
+ # 既存スクリプトの挙動が変わるため採用しない。
627
+ _report_timeout() {
628
+ if present timeout; then
629
+ color_ok "timeout $(timeout --version 2>/dev/null </dev/null | head -1 || echo 'installed')"
630
+ else
631
+ color_warn "timeout コマンドが PATH に見つかりません (補助コマンドのため継続。新規シェルで再確認してください)"
632
+ fi
633
+ }
634
+
635
+ ensure_timeout() {
636
+ if [[ "$(uname)" != "Darwin" ]]; then
637
+ # Linux の timeout は coreutils (Debian の essential / busybox にも同梱) なので
638
+ # 通常は存在する。無い環境はインストーラが推測で入れず、警告に留める。
639
+ if ! present timeout; then
640
+ color_warn "timeout が見つかりません (coreutils を手動で導入してください。補助コマンドのため継続)"
641
+ fi
642
+ _report_timeout
643
+ return 0
644
+ fi
645
+
646
+ local local_bin="$HOME/.local/bin"
647
+ local link="$local_bin/timeout"
648
+ local found
649
+ found="$(command -v timeout 2>/dev/null || true)"
650
+ # このインストーラ管理の symlink 以外の timeout が既に見えていれば、それを尊重する
651
+ if [[ -n "$found" && "$found" != "$link" ]]; then
652
+ color_ok "timeout already installed ($found)"
653
+ return 0
654
+ fi
655
+
656
+ if [[ -e "$link" && ! -L "$link" ]]; then
657
+ # ユーザーが自前で置いた実体は上書きしない(PATH 永続化だけ揃える)
658
+ color_ok "$link は手動管理のため変更しません"
659
+ else
660
+ local gtimeout
661
+ gtimeout="$(command -v gtimeout 2>/dev/null || true)"
662
+ if [[ -z "$gtimeout" ]]; then
663
+ color_step "GNU coreutils (timeout) をインストール"
664
+ if ! retry 3 brew install coreutils; then
665
+ color_warn "coreutils の導入に失敗しました (timeout は補助コマンドのため継続)"
666
+ return 0
667
+ fi
668
+ hash -r 2>/dev/null || true
669
+ gtimeout="$(command -v gtimeout 2>/dev/null || true)"
670
+ fi
671
+ if [[ -z "$gtimeout" ]]; then
672
+ color_warn "gtimeout が PATH に見つかりません (timeout は補助コマンドのため継続)"
673
+ return 0
674
+ fi
675
+ if ! mkdir -p "$local_bin" 2>/dev/null || ! ln -sfn "$gtimeout" "$link" 2>/dev/null; then
676
+ color_warn "$link を作成できません (書き込み権限を確認してください。補助コマンドのため継続)"
677
+ return 0
678
+ fi
679
+ fi
680
+
681
+ # symlink の有無と PATH 永続化は別々に収束させる。再実行時に片方だけ欠けていても
682
+ # ここで補完される(早期 return で profile の修復を飛ばさない)。
683
+ # ~/.local/bin は PATH の「末尾」に足す。先頭に置くと pipx / pyenv 等が置いた
684
+ # ~/.local/bin/python3 や node のシムが brew / system のものを隠してしまう。
685
+ # timeout を見つけるだけなら末尾で足りる(別の timeout があれば上で return 済み)。
686
+ # PATH 代入で bash の command hash は全消去されるので、この後の hash -r は不要。
687
+ export PATH="$PATH:$local_bin"
688
+ # npm prefix と同じく zsh / bash 双方の login profile へ永続化する。
689
+ # 追記失敗(root 所有の profile 等)はこの補助コマンドの契約に合わせて警告に留める。
690
+ persist_profile_snippet 'export PATH="$PATH:$HOME/.local/bin"' "~/.local/bin PATH" \
691
+ "# >>> hub-agent: local-bin PATH (timeout) >>>" "# <<< hub-agent: local-bin PATH <<<" \
692
+ "$HOME/.zprofile" "$(bash_login_profile)" \
693
+ || color_warn "PATH の永続化に失敗した profile があります (timeout は補助コマンドのため継続)"
694
+ _report_timeout
695
+ }
696
+
547
697
  # Node.js のサポート範囲ポリシー判定。
548
698
  # - 未インストール → LTS を install
549
699
  # - [MIN, MAX] 範囲内 → 現状維持(ユーザーの環境を尊重)
@@ -622,6 +772,30 @@ ensure_claude_code() {
622
772
  fi
623
773
  }
624
774
 
775
+ # Codex CLI を同梱でセットアップ。hub-agent 自体の動作には必須ではないため、
776
+ # 新規導入・upgrade とも失敗時は警告してセットアップを継続する。
777
+ ensure_codex() {
778
+ if present codex; then
779
+ local cur
780
+ cur=$(codex --version 2>/dev/null </dev/null || echo "unknown")
781
+ color_step "Codex CLI (現在 $cur) を最新版にアップデート"
782
+ retry 2 npm install -g "${CODEX_PACKAGE}@latest" --force \
783
+ || color_warn "Codex CLI upgrade に失敗 (既存版で継続)"
784
+ else
785
+ color_step "$CODEX_PACKAGE を install"
786
+ if ! retry 3 npm install -g "${CODEX_PACKAGE}@latest" --force; then
787
+ color_warn "Codex CLI の導入に失敗しました。Codex チャット / 委譲 / 画像生成は使えません"
788
+ color_warn " 手動で実行: npm install -g @openai/codex@latest"
789
+ fi
790
+ fi
791
+ hash -r 2>/dev/null || true
792
+ if present codex; then
793
+ color_ok "codex $(codex --version 2>/dev/null </dev/null || echo 'installed')"
794
+ else
795
+ color_warn "codex コマンドが PATH に見つかりません。新規シェルで再確認してください"
796
+ fi
797
+ }
798
+
625
799
  do_enroll() {
626
800
  if [[ -z "${HUB_AGENT_TOKEN:-}" ]]; then
627
801
  color_warn "HUB_AGENT_TOKEN が未設定。enroll は skip します"
@@ -767,6 +941,24 @@ verify_setup() {
767
941
  color_warn "Claude Code (claude) コマンドが見つかりません (cockpit から claude を呼ぶ場合は必要)"
768
942
  fi
769
943
 
944
+ # Codex CLI (任意なので warn のみ)
945
+ if present codex; then
946
+ color_ok "Codex CLI: $(codex --version 2>/dev/null </dev/null || echo 'installed')"
947
+ else
948
+ color_warn "Codex CLI (codex) コマンドが見つかりません (Codex チャット / 委譲 / 画像生成には必要)"
949
+ fi
950
+
951
+ # python3 (Hub AI bundle と hub-agent bootstrap に必須)
952
+ if python3 --version >/dev/null 2>&1; then
953
+ color_ok "Python 3: $(python3 --version 2>&1)"
954
+ else
955
+ color_err "python3 コマンドが実行できません"
956
+ errors=$((errors + 1))
957
+ fi
958
+
959
+ # timeout は補助コマンド (ensure_timeout と同じ契約) なので warn のみ
960
+ _report_timeout
961
+
770
962
  # 3. Hub AI bundle 展開を **CLI 別** に検証(manifest / CONFIG_MD マーカー /
771
963
  # hooks の Hub SessionStart hook / 正規 .hub_token.json)。Codex 未インストールなら
772
964
  # codex は検証しない(2026-07-15)。
@@ -831,7 +1023,7 @@ EOF
831
1023
 
832
1024
  main() {
833
1025
  color_step "hub-agent ワンライナーセットアップを開始"
834
- STEP_TOTAL=11
1026
+ STEP_TOTAL=15
835
1027
  STEP_NUM=0
836
1028
 
837
1029
  step_header "Homebrew"
@@ -842,6 +1034,10 @@ main() {
842
1034
  ensure_node_version
843
1035
  step_header "Build tools (native addons)"
844
1036
  ensure_build_tools
1037
+ step_header "Python 3"
1038
+ ensure_python3
1039
+ step_header "GNU coreutils (timeout)"
1040
+ ensure_timeout
845
1041
  step_header "npm global prefix"
846
1042
  ensure_npm_user_prefix
847
1043
  # ensure_node_version 直後だと brew link でシェルの command hash がズレている
@@ -854,6 +1050,8 @@ main() {
854
1050
  ensure_global_install
855
1051
  step_header "Claude Code install"
856
1052
  ensure_claude_code
1053
+ step_header "Codex CLI install"
1054
+ ensure_codex
857
1055
  step_header "enroll + bundle 同期"
858
1056
  do_enroll
859
1057
  step_header "Hub AI bootstrap (CLAUDE.md / hooks 初期化)"
@@ -869,4 +1067,15 @@ main() {
869
1067
  macos_perm_guidance
870
1068
  }
871
1069
 
872
- main "$@"
1070
+ # このファイルが source されたか(実行されたか)を判定する。
1071
+ # - `bash -c "$(curl ...)"` / `curl ... | bash` : BASH_SOURCE が空 → 実行
1072
+ # - `bash install.sh` / `./install.sh` : BASH_SOURCE[0] == $0 → 実行
1073
+ # - `source install.sh`(test/install-sh.test.mjs): BASH_SOURCE[0] != $0 → source
1074
+ # 環境変数で切り替えると、変数が残ったシェルや CI でワンライナーが無言の no-op になるため使わない。
1075
+ _install_sh_is_sourced() {
1076
+ [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "$0" ]]
1077
+ }
1078
+
1079
+ if ! _install_sh_is_sourced; then
1080
+ main "$@"
1081
+ fi
package/src/main.mjs CHANGED
@@ -520,9 +520,11 @@ export async function syncTmuxCodexEnv(profile, log) {
520
520
  * bundle hooks (cockpit_permission_bridge 等) を反映させる。
521
521
  *
522
522
  * 新規プロファイル追加 (profile.add) 直後に呼ぶ。provisionProfile は
523
- * `~/.claude/settings.json` を新プロファイルへコピーするが、コピー元が古い / setup 未走の
524
- * タイミングだと新プロファイルにフックが欠けうる。setup_hub_ai は全プロファイル対応 (案1)
525
- * なので 1 回叩けば新プロファイルの初回セッションでもフックが揃う。
523
+ * `~/.claude/settings.json` への symlink を新プロファイルへ張る (コピーではない) ので、
524
+ * 正本が最新ならフックは自動的に揃う。ただし正本自体が古い / setup 未走のタイミングだと
525
+ * 新プロファイルの初回セッションでもフックが欠けうるため、1 回叩いて正本を最新化する。
526
+ * 旧版がコピーで置いた実体 settings.json が残るプロファイルにも、setup_hub_ai は
527
+ * 全プロファイル対応 (案1) なので同じ 1 回で反映される。
526
528
  *
527
529
  * 失敗・未 setup 環境 (スクリプト不在 / python3 無し) でも **例外を投げない**。detached +
528
530
  * stdio ignore で daemon をブロックしない。session_start.py の auto-update と同じ
package/src/profiles.mjs CHANGED
@@ -4,8 +4,12 @@
4
4
  * 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
5
5
  * で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
6
6
  * 認証 (`.credentials.json` / Keychain) と `.claude.json` をアカウント別に保持する。一方で
7
- * skills / agents / commands / scripts / hooks / CLAUDE.md は symlink で共有し、
8
- * settings.json はコピーで共有する (provisionProfile が面倒を見る)。
7
+ * skills / agents / commands / scripts / hooks / CLAUDE.md / settings.json は symlink で
8
+ * 共有する (provisionProfile が面倒を見る)。settings.json はプロファイル間で共有扱いとし、
9
+ * アカウント別の上書きはサポートしない — つまり forceLoginOrgUUID / apiKeyHelper /
10
+ * env.ANTHROPIC_* のようなアカウント固有の値を settings.json に置くと全プロファイルへ効く
11
+ * (経緯と却下案は Hub ナレッジ
12
+ * `ナレッジ/Cockpit/自動コンパクションとPreCompactフック` の「プロファイル間の設定ドリフト」節)。
9
13
  *
10
14
  * 会話履歴 (`projects/`) は **既定 `~/.claude/projects` に集約して全プロファイルで共有する**
11
15
  * (ensureSharedProjects が `<configDir>/projects` を既定への symlink にする)。同じ作業
@@ -312,7 +316,7 @@ function nextNumericId(profiles) {
312
316
 
313
317
  /**
314
318
  * プロファイルを新規追加する (連番 id・中立パス `~/.claude-<n>`)。
315
- * ディレクトリ整備 (symlink / settings コピー) は provisionProfile が行う。
319
+ * ディレクトリ整備 (共有資産の symlink) は provisionProfile が行う。
316
320
  * 追加後もログインは未了 (ユーザーが端末で `/login` する必要がある)。
317
321
  *
318
322
  * @param {{label?: string}} [opts]
@@ -383,7 +387,8 @@ export async function hasFileCredentials(configDir) {
383
387
  }
384
388
  }
385
389
 
386
- /** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
390
+ /** 既定プロファイルから共有 symlink する資産。settings.json の書き手 (Claude Code 本体 /
391
+ * setup_hub_ai.py の hooks auto-merge) は symlink 越しに正本 `~/.claude/settings.json` を更新する。 */
387
392
  export const SHARED_SYMLINK_ITEMS = [
388
393
  "agents",
389
394
  "skills",
@@ -391,11 +396,9 @@ export const SHARED_SYMLINK_ITEMS = [
391
396
  "scripts",
392
397
  "hooks",
393
398
  "CLAUDE.md",
399
+ "settings.json",
394
400
  ]
395
401
 
396
- /** コピーで共有する資産 (アプリが書き換えるため symlink 不可)。 */
397
- export const SHARED_COPY_ITEMS = ["settings.json"]
398
-
399
402
  /**
400
403
  * 会話履歴 (`projects/`) を既定 `~/.claude/projects` に集約・共有する。
401
404
  *
@@ -459,18 +462,17 @@ export async function ensureSharedProjects(configDir) {
459
462
 
460
463
  /**
461
464
  * プロファイルディレクトリを整備する。
462
- * - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る (既存・実体があれば触らない)。
463
- * - 共有コピー資産: 既定からコピー (プロファイル側に未存在のときだけ)。
465
+ * - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る。既存 (symlink でも
466
+ * 実体でも) があれば触らない — 旧版がコピーで置いた settings.json も同じ規則で残る。
464
467
  * 認証・履歴・`.claude.json` には一切触れない (アカウント固有のため)。
465
468
  *
466
469
  * @param {string} configDir - 対象プロファイルの config ディレクトリ絶対パス
467
- * @returns {Promise<{linked: string[], copied: string[], skipped: string[]}>}
470
+ * @returns {Promise<{linked: string[], skipped: string[]}>}
468
471
  */
469
472
  export async function provisionProfile(configDir) {
470
473
  const base = defaultConfigDir()
471
474
  await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
472
475
  const linked = []
473
- const copied = []
474
476
  const skipped = []
475
477
 
476
478
  for (const item of SHARED_SYMLINK_ITEMS) {
@@ -479,8 +481,19 @@ export async function provisionProfile(configDir) {
479
481
  try {
480
482
  await fs.access(src)
481
483
  } catch {
482
- skipped.push(`${item}(元無し)`)
483
- continue
484
+ // settings.json だけは正本を空 JSON で作ってから張る。ここで skip すると、直後の
485
+ // setup_hub_ai (hooks auto-merge) がプロファイル側に実体を作り、以後 provisionProfile が
486
+ // 「既存」として触らないため、コピー時代と同じドリフトが復活する。
487
+ // 既定側が壊れた symlink のときは書き込み先が分からないので触らない。
488
+ if (item !== "settings.json" || (await isBrokenSymlink(src))) {
489
+ skipped.push(`${item}(元無し)`)
490
+ continue
491
+ }
492
+ // 既定ディレクトリ自体が無い端末 (Claude Code 未起動) でも正本を作れるようにする。
493
+ await fs.mkdir(base, { recursive: true, mode: 0o700 })
494
+ await fs.writeFile(src, "{}\n", { flag: "wx" }).catch((err) => {
495
+ if (err?.code !== "EEXIST") throw err
496
+ })
484
497
  }
485
498
  // dst が既に存在 (symlink/実体) なら触らない。
486
499
  let exists = false
@@ -498,28 +511,6 @@ export async function provisionProfile(configDir) {
498
511
  linked.push(item)
499
512
  }
500
513
 
501
- for (const item of SHARED_COPY_ITEMS) {
502
- const src = path.join(base, item)
503
- const dst = path.join(configDir, item)
504
- let dstExists = false
505
- try {
506
- await fs.lstat(dst)
507
- dstExists = true
508
- } catch {
509
- /* not exists */
510
- }
511
- if (dstExists) {
512
- skipped.push(`${item}(既存)`)
513
- continue
514
- }
515
- try {
516
- await fs.copyFile(src, dst)
517
- copied.push(item)
518
- } catch {
519
- skipped.push(`${item}(元無し)`)
520
- }
521
- }
522
-
523
514
  // 会話履歴は既定 ~/.claude/projects へ集約して共有する (アカウント間で会話
524
515
  // コンテキストを引き継げるようにするため)。新規は素直に symlink、既存実体は
525
516
  // 無損失マージしてから symlink 化する。
@@ -531,7 +522,16 @@ export async function provisionProfile(configDir) {
531
522
  skipped.push(`projects(失敗:${err?.code || err?.message || "error"})`)
532
523
  }
533
524
 
534
- return { linked, copied, skipped }
525
+ return { linked, skipped }
526
+ }
527
+
528
+ /** `p` が lstat できるのに access できない (= リンク先を失った symlink) なら true。 */
529
+ async function isBrokenSymlink(p) {
530
+ try {
531
+ return (await fs.lstat(p)).isSymbolicLink()
532
+ } catch {
533
+ return false
534
+ }
535
535
  }
536
536
 
537
537
  /** テスト用: モジュールキャッシュを破棄する。 */