@cocorograph/hub-agent 0.7.45 → 0.7.47
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/package.json +1 -1
- package/src/claude-credentials.mjs +62 -166
- package/src/codex-profiles.mjs +318 -0
- package/src/main.mjs +223 -33
- package/src/profiles.mjs +20 -0
package/package.json
CHANGED
|
@@ -1,202 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code 認証情報 (macOS Keychain)
|
|
2
|
+
* Claude Code の OAuth 認証情報 (macOS Keychain) の在否判定。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 2026-07 更新 (B案 / swap 撤去): Claude Code は OAuth トークンを Keychain の
|
|
5
|
+
* `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)先頭8桁>` に保存し、config
|
|
6
|
+
* ディレクトリごとにネイティブに分離するようになった (Claude Code 2.1.x で実測確認)。
|
|
7
|
+
* そのため hub-agent が旧来行っていた「素の `Claude Code-credentials` を profile 切替
|
|
8
|
+
* のたびに手動退避/復元 (swap) する」処理は不要かつ有害 (Claude Code が認証に使わない
|
|
9
|
+
* 別エントリ = acct "hub-agent" を触るだけで空振りし、既定プロファイル等で再ログインを
|
|
10
|
+
* 誘発していた) になったため撤去した。ここは UI の「要ログイン」表示用に「その config
|
|
11
|
+
* ディレクトリで Claude Code がログイン済みか」を在否チェックするだけに縮小する。
|
|
12
|
+
* シークレットは復号しない (`-w` を付けない) ため Keychain の ACL プロンプトを誘発しない。
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (削除しないと旧アカウントのトークンが残り「切り替わらない」が再発する)
|
|
14
|
+
* 旧 swap の経緯 (2026-06-07): 当時の Claude Code は全 config ディレクトリで単一の素の
|
|
15
|
+
* `Claude Code-credentials` を共有していたため、ディレクトリ切替だけではアカウントが
|
|
16
|
+
* 変わらず、Keychain を手動退避/復元する必要があった。実装は git 履歴を参照
|
|
17
|
+
* (CANONICAL_SERVICE / slotService / swapCredentials 等)。
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Linux 等 Keychain の無いプラットフォームでは Claude Code が
|
|
19
|
-
* `<configDir>/.credentials.json` を使うため、config ディレクトリの切替だけで
|
|
20
|
-
* 認証も分離される。スワップは darwin 限定で no-op を返す。
|
|
21
|
-
*
|
|
22
|
-
* 書き込みは `security -i` (stdin) 経由で行い、トークンが argv (ps で他
|
|
23
|
-
* プロセスから観測可能) に露出しないようにする。`security -w` の読み出しは
|
|
24
|
-
* データが非 printable のとき hex を返すため decode を挟む。
|
|
19
|
+
* Linux 等 Keychain の無い環境は Claude Code が `<configDir>/.credentials.json` を使い
|
|
20
|
+
* もともと config ディレクトリ単位で分離済み (profiles.mjs の hasFileCredentials を使う)。
|
|
25
21
|
*/
|
|
26
|
-
import {
|
|
22
|
+
import { promises as fs } from "node:fs"
|
|
23
|
+
import { createHash } from "node:crypto"
|
|
24
|
+
import { execFile } from "node:child_process"
|
|
27
25
|
import { promisify } from "node:util"
|
|
28
26
|
|
|
29
27
|
const execFileP = promisify(execFile)
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
export const CANONICAL_SERVICE = "Claude Code-credentials"
|
|
33
|
-
|
|
34
|
-
/** プロファイル別退避スロットのサービス名。 */
|
|
35
|
-
export function slotService(profileId) {
|
|
36
|
-
return `Claude Code-credentials (hub-agent profile ${profileId})`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Keychain スワップが必要なプラットフォームか。 */
|
|
29
|
+
/** Keychain を使うプラットフォームか (認証分離が Keychain 依存なのは darwin のみ)。 */
|
|
40
30
|
export function isKeychainPlatform(platform = process.platform) {
|
|
41
31
|
return platform === "darwin"
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
35
|
+
* ある config ディレクトリの認証情報を Claude Code が保存する Keychain サービス名。
|
|
36
|
+
* `Claude Code-credentials-<sha256(configDir)先頭8桁>` (Claude Code 2.1.x の実装)。
|
|
37
|
+
* ⚠️ このハッシュ式が本体とズレると在否チェックが全部空振りするため、
|
|
38
|
+
* test/claude-credentials.test.mjs が実機既知値で固定している。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} configDir CLAUDE_CONFIG_DIR (既定プロファイルは `~/.claude`)
|
|
41
|
+
* @returns {string}
|
|
49
42
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (decoded.startsWith("{")) return decoded
|
|
57
|
-
} catch {
|
|
58
|
-
/* hex ではなかった — raw を返す */
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return s
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** err が「項目が見つからない」(security exit 44) かを判定する。 */
|
|
65
|
-
function isNotFound(err) {
|
|
66
|
-
const msg = `${err?.message || ""}${err?.stderr || ""}`
|
|
67
|
-
return msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")
|
|
43
|
+
export function credentialService(configDir) {
|
|
44
|
+
const hash = createHash("sha256")
|
|
45
|
+
.update(String(configDir))
|
|
46
|
+
.digest("hex")
|
|
47
|
+
.slice(0, 8)
|
|
48
|
+
return `Claude Code-credentials-${hash}`
|
|
68
49
|
}
|
|
69
50
|
|
|
70
51
|
/**
|
|
71
|
-
* Keychain
|
|
72
|
-
* @returns {Promise<
|
|
52
|
+
* Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプト無し)。
|
|
53
|
+
* @returns {Promise<boolean>}
|
|
73
54
|
*/
|
|
74
|
-
export async function readGenericPassword(service, opts = {}) {
|
|
75
|
-
const exec = opts.execFileP || execFileP
|
|
76
|
-
try {
|
|
77
|
-
const { stdout } = await exec("security", [
|
|
78
|
-
"find-generic-password",
|
|
79
|
-
"-s",
|
|
80
|
-
service,
|
|
81
|
-
"-w",
|
|
82
|
-
])
|
|
83
|
-
return decodeSecurityOutput(stdout)
|
|
84
|
-
} catch (err) {
|
|
85
|
-
if (isNotFound(err)) return null
|
|
86
|
-
throw err
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプトを誘発しない)。 */
|
|
91
55
|
export async function existsGenericPassword(service, opts = {}) {
|
|
92
56
|
const exec = opts.execFileP || execFileP
|
|
93
57
|
try {
|
|
94
58
|
await exec("security", ["find-generic-password", "-s", service])
|
|
95
59
|
return true
|
|
96
60
|
} catch (err) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const exec = opts.execFileP || execFileP
|
|
105
|
-
try {
|
|
106
|
-
await exec("security", ["delete-generic-password", "-s", service])
|
|
107
|
-
return true
|
|
108
|
-
} catch (err) {
|
|
109
|
-
if (isNotFound(err)) return false
|
|
61
|
+
const msg = `${err?.message || ""}${err?.stderr || ""}`
|
|
62
|
+
if (
|
|
63
|
+
msg.includes("could not be found") ||
|
|
64
|
+
msg.includes("SecKeychainSearchCopyNext")
|
|
65
|
+
) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
110
68
|
throw err
|
|
111
69
|
}
|
|
112
70
|
}
|
|
113
71
|
|
|
114
|
-
/** `security -i` の対話コマンド用にダブルクォート文字列をエスケープする。 */
|
|
115
|
-
export function escapeSecurityArg(value) {
|
|
116
|
-
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Keychain へシークレットを書き込む (既存エントリは作り直し)。
|
|
121
|
-
* `-U` での更新は ACL 制約で失敗し得るため delete → add の順で行う。
|
|
122
|
-
* シークレットは stdin (`security -i`) 経由で渡し argv へ露出させない。
|
|
123
|
-
*/
|
|
124
|
-
export async function writeGenericPassword(service, secret, opts = {}) {
|
|
125
|
-
await deleteGenericPassword(service, opts).catch(() => {})
|
|
126
|
-
const spawnImpl = opts.spawn || spawn
|
|
127
|
-
const account = opts.account || "hub-agent"
|
|
128
|
-
const line =
|
|
129
|
-
`add-generic-password -a "${escapeSecurityArg(account)}"` +
|
|
130
|
-
` -s "${escapeSecurityArg(service)}"` +
|
|
131
|
-
` -w "${escapeSecurityArg(secret)}"\n`
|
|
132
|
-
await new Promise((resolve, reject) => {
|
|
133
|
-
const child = spawnImpl("security", ["-i"], {
|
|
134
|
-
stdio: ["pipe", "ignore", "pipe"],
|
|
135
|
-
})
|
|
136
|
-
let stderr = ""
|
|
137
|
-
child.stderr?.on("data", (d) => {
|
|
138
|
-
stderr += String(d)
|
|
139
|
-
})
|
|
140
|
-
child.on("error", reject)
|
|
141
|
-
child.on("close", (code) => {
|
|
142
|
-
if (code === 0) resolve()
|
|
143
|
-
else reject(new Error(`security add-generic-password failed (exit ${code}): ${stderr.trim()}`))
|
|
144
|
-
})
|
|
145
|
-
child.stdin.write(line)
|
|
146
|
-
child.stdin.end()
|
|
147
|
-
})
|
|
148
|
-
}
|
|
149
|
-
|
|
150
72
|
/**
|
|
151
|
-
*
|
|
73
|
+
* その config ディレクトリで Claude Code がログイン済みか (UI の「要ログイン」表示用)。
|
|
152
74
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// Keychain の無い環境は <configDir>/.credentials.json でもともと分離済み。
|
|
161
|
-
return { saved: false, restored: false, needsLogin: false, skipped: "not-darwin" }
|
|
162
|
-
}
|
|
163
|
-
const io = { execFileP: opts.execFileP, spawn: opts.spawn }
|
|
164
|
-
const result = { saved: false, restored: false, needsLogin: false }
|
|
165
|
-
|
|
166
|
-
// 1. 現在の canonical を切替元スロットへ退避 (リフレッシュ済み最新トークンを保全)。
|
|
167
|
-
const current = await readGenericPassword(CANONICAL_SERVICE, io)
|
|
168
|
-
if (current) {
|
|
169
|
-
await writeGenericPassword(slotService(fromId), current, io)
|
|
170
|
-
result.saved = true
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 2. 切替先スロットから canonical を復元。無ければ canonical を削除して要ログイン。
|
|
174
|
-
const target = await readGenericPassword(slotService(toId), io)
|
|
175
|
-
if (target) {
|
|
176
|
-
await writeGenericPassword(CANONICAL_SERVICE, target, io)
|
|
177
|
-
result.restored = true
|
|
178
|
-
} else {
|
|
179
|
-
await deleteGenericPassword(CANONICAL_SERVICE, io).catch(() => {})
|
|
180
|
-
result.needsLogin = true
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
logger?.info?.(
|
|
184
|
-
{ from: fromId, to: toId, ...result },
|
|
185
|
-
"claude credentials swapped for profile switch",
|
|
186
|
-
)
|
|
187
|
-
return result
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* プロファイルがログイン可能な認証情報を持っているか (UI 表示用)。
|
|
192
|
-
* darwin: active なら canonical、非 active ならスロットの存在を見る。
|
|
193
|
-
* その他: `<configDir>/.credentials.json` の存在を見る (呼び出し側で判定)。
|
|
75
|
+
* `~/.claude` が symlink (例: iCloud Drive への集約) のとき、Claude Code が
|
|
76
|
+
* symlink パスと realpath のどちらでハッシュを計算するかに依存しないよう、両方の
|
|
77
|
+
* ハッシュ候補を在否チェックし、いずれか存在すればログイン済みとみなす。
|
|
78
|
+
*
|
|
79
|
+
* @param {string} configDir
|
|
80
|
+
* @param {{execFileP?: Function}} [opts]
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
194
82
|
*/
|
|
195
|
-
export async function profileHasKeychainCredentials(
|
|
196
|
-
const
|
|
83
|
+
export async function profileHasKeychainCredentials(configDir, opts = {}) {
|
|
84
|
+
const candidates = new Set([configDir])
|
|
197
85
|
try {
|
|
198
|
-
|
|
86
|
+
candidates.add(await fs.realpath(configDir))
|
|
199
87
|
} catch {
|
|
200
|
-
|
|
88
|
+
/* realpath 不能 (ディレクトリ未作成等) は無視して literal パスのみ評価 */
|
|
89
|
+
}
|
|
90
|
+
for (const dir of candidates) {
|
|
91
|
+
try {
|
|
92
|
+
if (await existsGenericPassword(credentialService(dir), opts)) return true
|
|
93
|
+
} catch {
|
|
94
|
+
/* security 実行エラーは「不明」→ 存在しない寄せ */
|
|
95
|
+
}
|
|
201
96
|
}
|
|
97
|
+
return false
|
|
202
98
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex アカウント・プロファイル管理 (Claude の profiles.mjs と対の Codex 版)。
|
|
3
|
+
*
|
|
4
|
+
* 1 台のマシンで複数の Codex (OpenAI) アカウントを `CODEX_HOME` で切り替えて使うための
|
|
5
|
+
* 仕組み。各プロファイルは独立した Codex ホームディレクトリを持つ。
|
|
6
|
+
*
|
|
7
|
+
* 認証: Codex は OAuth トークン / API キーを `<CODEX_HOME>/auth.json` に **ファイルで**
|
|
8
|
+
* 保存する (Claude の macOS のような Keychain 単一エントリ問題は無い)。したがって
|
|
9
|
+
* `CODEX_HOME` を切り替えるだけでアカウントも分離され、スワップ処理は一切不要。
|
|
10
|
+
*
|
|
11
|
+
* 共有方針 (2026-07, 中村さん決定「アカウントが変わっても振る舞いを変えない」):
|
|
12
|
+
* - **分離するのは認証と純粋なランタイム/ログ/キャッシュだけ**。
|
|
13
|
+
* - 設定 (config.toml) / hooks / skills / scripts / plugins / 記憶 (memories) /
|
|
14
|
+
* 会話履歴 (sessions, history.jsonl) など **振る舞いに影響する資産はすべて共有**する。
|
|
15
|
+
* - 実装は「共有をデフォルトにし、例外 (ISOLATED) だけ分離」する。これにより
|
|
16
|
+
* バージョン番号付き sqlite (memories_1 / goals_1 等) も列挙不要で自動共有される。
|
|
17
|
+
* - 共有は既定 (プライマリ) `~/.codex` への **symlink**。SQLite はシンボリックリンクを
|
|
18
|
+
* たどって実体パスに -wal/-shm を作るため、単一アクティブ運用 (切替時に全 Codex
|
|
19
|
+
* セッションを停止してから起動) では破損しない。
|
|
20
|
+
*
|
|
21
|
+
* 設計方針 (Claude 版と同じ):
|
|
22
|
+
* - 既定プロファイルは常に `~/.codex` (CODEX_DIR override 可)。リネーム・移動しない。
|
|
23
|
+
* - 2 つ目以降だけ `~/.codex-1` `~/.codex-2` … が生える (中立な連番)。
|
|
24
|
+
* - ディレクトリ名にプラン種別を埋め込まない。UI 表示は label で行う。
|
|
25
|
+
* - レジストリは `~/.hub/codex-profiles.json`。
|
|
26
|
+
*/
|
|
27
|
+
import { promises as fs } from "node:fs"
|
|
28
|
+
import path from "node:path"
|
|
29
|
+
import os from "node:os"
|
|
30
|
+
|
|
31
|
+
import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
|
|
32
|
+
|
|
33
|
+
/** 既定 (プライマリ) プロファイルの ID。 */
|
|
34
|
+
export const DEFAULT_CODEX_PROFILE_ID = "default"
|
|
35
|
+
|
|
36
|
+
/** `~/.codex` の絶対パス (CODEX_DIR override は hub-bundle.mjs / skills.mjs と揃える)。 */
|
|
37
|
+
export function defaultCodexDir() {
|
|
38
|
+
return process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 新規 Codex プロファイル用ディレクトリへ共有しない (= アカウントごとに独立させる) 資産。
|
|
43
|
+
* これ以外はすべて既定 `~/.codex` への symlink として共有する。
|
|
44
|
+
*
|
|
45
|
+
* - 認証: auth.json (これがアカウントの実体。分離の主目的)
|
|
46
|
+
* - 端末/インストール固有: installation_id
|
|
47
|
+
* - 純粋なランタイム/キャッシュ/ログ (振る舞いに影響しない・共有すると破損/肥大の恐れ):
|
|
48
|
+
* cache, .tmp, tmp, shell_snapshots, app-server-control, models_cache.json,
|
|
49
|
+
* version.json, .personality_migration
|
|
50
|
+
*/
|
|
51
|
+
const CODEX_ISOLATED_NAMES = new Set([
|
|
52
|
+
"auth.json",
|
|
53
|
+
"installation_id",
|
|
54
|
+
"cache",
|
|
55
|
+
".tmp",
|
|
56
|
+
"tmp",
|
|
57
|
+
"shell_snapshots",
|
|
58
|
+
"app-server-control",
|
|
59
|
+
"models_cache.json",
|
|
60
|
+
"version.json",
|
|
61
|
+
".personality_migration",
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 分離するファイル名パターン (ランタイムログ/セッション状態の sqlite)。
|
|
66
|
+
* 記憶 (memories*.sqlite) / 目標 (goals*.sqlite) は **共有**するため除外する。
|
|
67
|
+
* - logs*.sqlite(-wal/-shm): 実行ログ。アカウント混在を避け分離。
|
|
68
|
+
* - state*.sqlite(-wal/-shm): ランタイム状態。分離。
|
|
69
|
+
*/
|
|
70
|
+
function isIsolatedByPattern(name) {
|
|
71
|
+
return /^logs.*\.sqlite(-wal|-shm)?$/.test(name) || /^state.*\.sqlite(-wal|-shm)?$/.test(name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** その名前を新規プロファイルへ共有する (symlink する) か。 */
|
|
75
|
+
export function isSharedCodexEntry(name) {
|
|
76
|
+
if (CODEX_ISOLATED_NAMES.has(name)) return false
|
|
77
|
+
if (isIsolatedByPattern(name)) return false
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 既定プロファイルを合成して返す (永続化はしない)。 */
|
|
82
|
+
export function makeDefaultCodexProfile() {
|
|
83
|
+
return {
|
|
84
|
+
id: DEFAULT_CODEX_PROFILE_ID,
|
|
85
|
+
label: "メイン",
|
|
86
|
+
configDir: defaultCodexDir(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `~/.hub/codex-profiles.json` の絶対パス。 */
|
|
91
|
+
export function registryPath() {
|
|
92
|
+
return path.join(hubPaths.configDir, "codex-profiles.json")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let _cache = null
|
|
96
|
+
|
|
97
|
+
/** チルダ展開。 */
|
|
98
|
+
function resolveDir(dir) {
|
|
99
|
+
if (!dir) return defaultCodexDir()
|
|
100
|
+
if (dir === "~") return os.homedir()
|
|
101
|
+
if (dir.startsWith("~/")) return path.join(os.homedir(), dir.slice(2))
|
|
102
|
+
return path.resolve(dir)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
|
|
107
|
+
* @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function loadRegistry() {
|
|
110
|
+
if (_cache) return _cache
|
|
111
|
+
let parsed = null
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fs.readFile(registryPath(), "utf-8")
|
|
114
|
+
parsed = JSON.parse(raw)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err.code !== "ENOENT") throw err
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const def = makeDefaultCodexProfile()
|
|
120
|
+
const profiles = [def]
|
|
121
|
+
let activeId = DEFAULT_CODEX_PROFILE_ID
|
|
122
|
+
|
|
123
|
+
if (parsed && Array.isArray(parsed.profiles)) {
|
|
124
|
+
const seen = new Set([DEFAULT_CODEX_PROFILE_ID])
|
|
125
|
+
for (const p of parsed.profiles) {
|
|
126
|
+
if (!p || typeof p.id !== "string" || p.id === DEFAULT_CODEX_PROFILE_ID) continue
|
|
127
|
+
if (seen.has(p.id)) continue
|
|
128
|
+
seen.add(p.id)
|
|
129
|
+
profiles.push({
|
|
130
|
+
id: p.id,
|
|
131
|
+
label: typeof p.label === "string" && p.label ? p.label : p.id,
|
|
132
|
+
configDir: resolveDir(p.configDir),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (typeof parsed.defaultLabel === "string" && parsed.defaultLabel) {
|
|
136
|
+
def.label = parsed.defaultLabel
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
typeof parsed.activeId === "string" &&
|
|
140
|
+
profiles.some((p) => p.id === parsed.activeId)
|
|
141
|
+
) {
|
|
142
|
+
activeId = parsed.activeId
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_cache = { activeId, profiles }
|
|
147
|
+
return _cache
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
|
|
151
|
+
async function saveRegistry(reg) {
|
|
152
|
+
await ensureConfigDir()
|
|
153
|
+
const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
|
|
154
|
+
const payload = {
|
|
155
|
+
activeId: reg.activeId,
|
|
156
|
+
defaultLabel: def ? def.label : undefined,
|
|
157
|
+
profiles: reg.profiles
|
|
158
|
+
.filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
|
|
159
|
+
.map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
|
|
160
|
+
}
|
|
161
|
+
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
162
|
+
mode: 0o600,
|
|
163
|
+
})
|
|
164
|
+
_cache = null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** プロファイル一覧と active を返す (UI 表示用)。 */
|
|
168
|
+
export async function listProfiles() {
|
|
169
|
+
const reg = await loadRegistry()
|
|
170
|
+
return { activeId: reg.activeId, profiles: reg.profiles }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** active プロファイルオブジェクトを返す。 */
|
|
174
|
+
export async function getActiveProfile() {
|
|
175
|
+
const reg = await loadRegistry()
|
|
176
|
+
return reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** active の Codex ホーム絶対パス。 */
|
|
180
|
+
export async function getActiveConfigDir() {
|
|
181
|
+
return (await getActiveProfile()).configDir
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* active プロファイルを切り替える。存在しない id はエラー。
|
|
186
|
+
* env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
|
|
187
|
+
*/
|
|
188
|
+
export async function setActiveProfile(id) {
|
|
189
|
+
const reg = await loadRegistry()
|
|
190
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
191
|
+
if (!target) throw new Error(`unknown codex profile: ${id}`)
|
|
192
|
+
reg.activeId = id
|
|
193
|
+
await saveRegistry(reg)
|
|
194
|
+
return target
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* プロファイルの表示ラベルを変更する。id・configDir・認証には一切触れない。
|
|
199
|
+
* 既定プロファイルのラベルも変更可 (defaultLabel として永続化)。
|
|
200
|
+
*/
|
|
201
|
+
export async function renameProfile(id, label) {
|
|
202
|
+
const trimmed = typeof label === "string" ? label.trim() : ""
|
|
203
|
+
if (!trimmed) throw new Error("label must be a non-empty string")
|
|
204
|
+
const reg = await loadRegistry()
|
|
205
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
206
|
+
if (!target) throw new Error(`unknown codex profile: ${id}`)
|
|
207
|
+
target.label = trimmed
|
|
208
|
+
await saveRegistry(reg)
|
|
209
|
+
return { id: target.id, label: target.label, configDir: target.configDir }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
213
|
+
function nextNumericId(profiles) {
|
|
214
|
+
let max = 0
|
|
215
|
+
for (const p of profiles) {
|
|
216
|
+
const n = Number.parseInt(p.id, 10)
|
|
217
|
+
if (Number.isInteger(n) && n > max) max = n
|
|
218
|
+
}
|
|
219
|
+
return String(max + 1)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* プロファイルを新規追加する (連番 id・中立パス `~/.codex-<n>`)。
|
|
224
|
+
* 共有資産の provisioning は provisionProfile が行う。追加直後はログイン未了
|
|
225
|
+
* (ユーザーが `CODEX_HOME=<dir> codex login` する必要がある)。
|
|
226
|
+
*
|
|
227
|
+
* @param {{label?: string}} [opts]
|
|
228
|
+
* @returns {Promise<{id:string,label:string,configDir:string, needsLogin: boolean}>}
|
|
229
|
+
*/
|
|
230
|
+
export async function addProfile(opts = {}) {
|
|
231
|
+
const reg = await loadRegistry()
|
|
232
|
+
const id = nextNumericId(reg.profiles)
|
|
233
|
+
const configDir = path.join(os.homedir(), `.codex-${id}`)
|
|
234
|
+
const profile = {
|
|
235
|
+
id,
|
|
236
|
+
label:
|
|
237
|
+
opts.label && String(opts.label).trim()
|
|
238
|
+
? String(opts.label).trim()
|
|
239
|
+
: `アカウント${id}`,
|
|
240
|
+
configDir,
|
|
241
|
+
}
|
|
242
|
+
reg.profiles.push(profile)
|
|
243
|
+
await saveRegistry(reg)
|
|
244
|
+
await provisionProfile(configDir)
|
|
245
|
+
return { ...profile, needsLogin: true }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Codex は auth.json (ファイル) の有無でログイン済みかを判定できる。
|
|
250
|
+
* @param {string} configDir
|
|
251
|
+
* @returns {Promise<boolean>}
|
|
252
|
+
*/
|
|
253
|
+
export async function hasCodexCredentials(configDir) {
|
|
254
|
+
try {
|
|
255
|
+
await fs.access(path.join(configDir, "auth.json"))
|
|
256
|
+
return true
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 新規 Codex プロファイルディレクトリを整備する。
|
|
264
|
+
*
|
|
265
|
+
* 「共有デフォルト・例外分離」: 既定 `~/.codex` の各エントリのうち、ISOLATED でない
|
|
266
|
+
* ものすべてを新ディレクトリへ symlink する (設定・hooks・skills・scripts・plugins・
|
|
267
|
+
* memories・sessions・history 等 = 振る舞いを共有)。ISOLATED (auth.json / ランタイム
|
|
268
|
+
* ログ・キャッシュ) は張らず、Codex 側で各アカウント固有に作らせる。
|
|
269
|
+
*
|
|
270
|
+
* 既存エントリ (symlink/実体) には触らない (冪等)。
|
|
271
|
+
*
|
|
272
|
+
* @param {string} configDir - 対象プロファイルの Codex ホーム絶対パス
|
|
273
|
+
* @returns {Promise<{linked: string[], skipped: string[]}>}
|
|
274
|
+
*/
|
|
275
|
+
export async function provisionProfile(configDir) {
|
|
276
|
+
const base = defaultCodexDir()
|
|
277
|
+
await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
|
|
278
|
+
const linked = []
|
|
279
|
+
const skipped = []
|
|
280
|
+
|
|
281
|
+
let entries = []
|
|
282
|
+
try {
|
|
283
|
+
entries = await fs.readdir(base)
|
|
284
|
+
} catch {
|
|
285
|
+
// 既定 ~/.codex がまだ無い (Codex 未初期化)。共有元が無いので何もしない。
|
|
286
|
+
return { linked, skipped: ["<base-missing>"] }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const name of entries) {
|
|
290
|
+
if (!isSharedCodexEntry(name)) {
|
|
291
|
+
skipped.push(`${name}(分離)`)
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
const src = path.join(base, name)
|
|
295
|
+
const dst = path.join(configDir, name)
|
|
296
|
+
// dst が既に存在 (symlink/実体) なら触らない。
|
|
297
|
+
let exists = false
|
|
298
|
+
try {
|
|
299
|
+
await fs.lstat(dst)
|
|
300
|
+
exists = true
|
|
301
|
+
} catch {
|
|
302
|
+
/* not exists */
|
|
303
|
+
}
|
|
304
|
+
if (exists) {
|
|
305
|
+
skipped.push(`${name}(既存)`)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
await fs.symlink(src, dst)
|
|
309
|
+
linked.push(name)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { linked, skipped }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** テスト用: モジュールキャッシュを破棄する。 */
|
|
316
|
+
export function _clearCache() {
|
|
317
|
+
_cache = null
|
|
318
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -56,11 +56,14 @@ import {
|
|
|
56
56
|
readProfileAccount,
|
|
57
57
|
setActiveProfile,
|
|
58
58
|
addProfile,
|
|
59
|
+
renameProfile,
|
|
59
60
|
} from "./profiles.mjs"
|
|
61
|
+
// Codex アカウント・プロファイル (独立切替)。profiles.mjs と同名 export が多いため
|
|
62
|
+
// 名前衝突を避けて namespace import する。
|
|
63
|
+
import * as codexProfiles from "./codex-profiles.mjs"
|
|
60
64
|
import {
|
|
61
65
|
isKeychainPlatform,
|
|
62
66
|
profileHasKeychainCredentials,
|
|
63
|
-
swapCredentials,
|
|
64
67
|
} from "./claude-credentials.mjs"
|
|
65
68
|
import {
|
|
66
69
|
answerAskUserQuestion,
|
|
@@ -360,6 +363,45 @@ export async function syncTmuxProfileEnv(profile, log) {
|
|
|
360
363
|
)
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
/**
|
|
367
|
+
* active Codex プロファイルの configDir を process.env.CODEX_HOME へ反映する。
|
|
368
|
+
* 既定 (`~/.codex`) のときは変数を消して Codex 既定パスに戻す。codex app-server は
|
|
369
|
+
* この env を継承して起動するため、以後の Codex セッションはこのアカウントで動く。
|
|
370
|
+
* Claude 版 applyActiveProfileEnv の Codex 対応 (独立切替)。
|
|
371
|
+
*/
|
|
372
|
+
export function applyActiveCodexEnv(profile, log) {
|
|
373
|
+
const isDefault =
|
|
374
|
+
!profile ||
|
|
375
|
+
profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
|
|
376
|
+
profile.configDir === codexProfiles.defaultCodexDir()
|
|
377
|
+
if (isDefault) {
|
|
378
|
+
delete process.env.CODEX_HOME
|
|
379
|
+
} else {
|
|
380
|
+
process.env.CODEX_HOME = profile.configDir
|
|
381
|
+
}
|
|
382
|
+
log?.info?.(
|
|
383
|
+
{
|
|
384
|
+
profile_id: profile?.id || codexProfiles.DEFAULT_CODEX_PROFILE_ID,
|
|
385
|
+
codex_home: process.env.CODEX_HOME || codexProfiles.defaultCodexDir(),
|
|
386
|
+
},
|
|
387
|
+
"active codex profile applied",
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* active Codex プロファイルの CODEX_HOME を tmux サーバーのグローバル環境へ同期する。
|
|
393
|
+
* tmux pane で直接 `codex` を打つケースや respawn の保険。tmux 未起動でも失敗しない。
|
|
394
|
+
*/
|
|
395
|
+
export async function syncTmuxCodexEnv(profile, log) {
|
|
396
|
+
const isDefault =
|
|
397
|
+
!profile ||
|
|
398
|
+
profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
|
|
399
|
+
profile.configDir === codexProfiles.defaultCodexDir()
|
|
400
|
+
await setTmuxGlobalEnv("CODEX_HOME", isDefault ? null : profile.configDir, {
|
|
401
|
+
logger: log,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
363
405
|
/**
|
|
364
406
|
* `setup_hub_ai.py --silent` を best-effort でキックし、全プロファイルの settings.json へ
|
|
365
407
|
* bundle hooks (cockpit_permission_bridge 等) を反映させる。
|
|
@@ -440,6 +482,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
440
482
|
)
|
|
441
483
|
}
|
|
442
484
|
|
|
485
|
+
// 同様に active Codex プロファイルの CODEX_HOME を反映する (独立切替)。
|
|
486
|
+
try {
|
|
487
|
+
const activeCodex = await codexProfiles.getActiveProfile()
|
|
488
|
+
applyActiveCodexEnv(activeCodex, logger)
|
|
489
|
+
await syncTmuxCodexEnv(activeCodex, logger)
|
|
490
|
+
} catch (err) {
|
|
491
|
+
logger.warn(
|
|
492
|
+
{ err: err.message },
|
|
493
|
+
"failed to apply active codex profile; falling back to default ~/.codex",
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
443
497
|
// launchd / systemd 起動では .zshrc 等の対話シェル設定が読まれず、~/.claude.json の
|
|
444
498
|
// MCP 定義が参照する環境変数 (例: Authorization: Bearer ${WRI_MCP_TOKEN}) が解決
|
|
445
499
|
// できない。ユーザーのログインシェル env + dotenv 層を 1 回解決して process.env を
|
|
@@ -3871,7 +3925,7 @@ async function dispatch(msg, ctx) {
|
|
|
3871
3925
|
reg.profiles.map(async (p) => {
|
|
3872
3926
|
const account = await readProfileAccount(p.configDir)
|
|
3873
3927
|
const hasCreds = isKeychainPlatform()
|
|
3874
|
-
? await profileHasKeychainCredentials(p.
|
|
3928
|
+
? await profileHasKeychainCredentials(p.configDir)
|
|
3875
3929
|
: await hasFileCredentials(p.configDir)
|
|
3876
3930
|
return {
|
|
3877
3931
|
id: p.id,
|
|
@@ -3892,35 +3946,18 @@ async function dispatch(msg, ctx) {
|
|
|
3892
3946
|
return
|
|
3893
3947
|
}
|
|
3894
3948
|
case "profile.switch": {
|
|
3895
|
-
//
|
|
3896
|
-
//
|
|
3949
|
+
// アカウント切替。active 永続化 → env 反映 (process/tmux) → 全 Claude セッション
|
|
3950
|
+
// 強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
|
|
3897
3951
|
//
|
|
3898
|
-
// WHY
|
|
3899
|
-
// CLAUDE_CONFIG_DIR
|
|
3900
|
-
//
|
|
3952
|
+
// WHY 認証スワップ廃止 (B案, 2026-07): Claude Code 2.1.x は OAuth トークンを
|
|
3953
|
+
// `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)>` に config ディレクトリ別
|
|
3954
|
+
// で保存するようになり、CLAUDE_CONFIG_DIR を切り替えるだけでアカウントも切り替わる。
|
|
3955
|
+
// 旧来の Keychain 手動スワップは Claude Code が読まない素エントリを触るだけの空振り
|
|
3956
|
+
// となり、既定プロファイル等で不要な再ログインを誘発していたため撤去した。
|
|
3957
|
+
// claude-credentials.mjs の docstring 参照。
|
|
3901
3958
|
const id = msg.profile_id || msg.id || ""
|
|
3902
3959
|
try {
|
|
3903
|
-
const regBefore = await listProfiles()
|
|
3904
|
-
const fromId = regBefore.activeId
|
|
3905
3960
|
const target = await setActiveProfile(id)
|
|
3906
|
-
let cred = { saved: false, restored: false, needsLogin: false }
|
|
3907
|
-
if (fromId !== target.id) {
|
|
3908
|
-
try {
|
|
3909
|
-
cred = await swapCredentials({
|
|
3910
|
-
fromId,
|
|
3911
|
-
toId: target.id,
|
|
3912
|
-
logger: ctx.logger,
|
|
3913
|
-
})
|
|
3914
|
-
} catch (err) {
|
|
3915
|
-
// スワップ失敗 (Keychain ACL 拒否等) でも切替自体は続行する。
|
|
3916
|
-
// この場合 Keychain には切替前のトークンが残るため、その旨を返す。
|
|
3917
|
-
ctx.logger.warn(
|
|
3918
|
-
{ err: err?.message || String(err), from: fromId, to: target.id },
|
|
3919
|
-
"credential swap failed; keychain may still hold previous account",
|
|
3920
|
-
)
|
|
3921
|
-
cred = { saved: false, restored: false, needsLogin: false, error: err?.message }
|
|
3922
|
-
}
|
|
3923
|
-
}
|
|
3924
3961
|
applyActiveProfileEnv(target, ctx.logger)
|
|
3925
3962
|
await syncTmuxProfileEnv(target, ctx.logger)
|
|
3926
3963
|
// 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
|
|
@@ -3928,6 +3965,11 @@ async function dispatch(msg, ctx) {
|
|
|
3928
3965
|
ctx.claudeBridge?.shutdown()
|
|
3929
3966
|
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
3930
3967
|
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
3968
|
+
// 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
|
|
3969
|
+
const hasCreds = isKeychainPlatform()
|
|
3970
|
+
? await profileHasKeychainCredentials(target.configDir)
|
|
3971
|
+
: await hasFileCredentials(target.configDir)
|
|
3972
|
+
const needsLogin = !hasCreds
|
|
3931
3973
|
ctx.logger.info(
|
|
3932
3974
|
{
|
|
3933
3975
|
profile_id: target.id,
|
|
@@ -3935,9 +3977,7 @@ async function dispatch(msg, ctx) {
|
|
|
3935
3977
|
tmux_killed: tmuxResult.killed.length,
|
|
3936
3978
|
tmux_recreated: tmuxResult.recreated.length,
|
|
3937
3979
|
tmux_failed: tmuxResult.failed.length,
|
|
3938
|
-
|
|
3939
|
-
cred_restored: cred.restored,
|
|
3940
|
-
needs_login: cred.needsLogin,
|
|
3980
|
+
needs_login: needsLogin,
|
|
3941
3981
|
},
|
|
3942
3982
|
"claude profile switched; all sessions reaped",
|
|
3943
3983
|
)
|
|
@@ -3954,13 +3994,12 @@ async function dispatch(msg, ctx) {
|
|
|
3954
3994
|
},
|
|
3955
3995
|
reaped: before,
|
|
3956
3996
|
tmux_restarted: tmuxResult.recreated.length,
|
|
3957
|
-
needs_login:
|
|
3958
|
-
login_command:
|
|
3997
|
+
needs_login: needsLogin,
|
|
3998
|
+
login_command: needsLogin
|
|
3959
3999
|
? isDefaultTarget
|
|
3960
4000
|
? "claude"
|
|
3961
4001
|
: `CLAUDE_CONFIG_DIR=${target.configDir} claude`
|
|
3962
4002
|
: undefined,
|
|
3963
|
-
credential_error: cred.error || undefined,
|
|
3964
4003
|
})
|
|
3965
4004
|
} catch (err) {
|
|
3966
4005
|
ctx.client.send({
|
|
@@ -4005,6 +4044,157 @@ async function dispatch(msg, ctx) {
|
|
|
4005
4044
|
}
|
|
4006
4045
|
return
|
|
4007
4046
|
}
|
|
4047
|
+
case "profile.rename": {
|
|
4048
|
+
// プロファイルの表示ラベルのみ変更する。id・configDir・認証情報 (Keychain
|
|
4049
|
+
// スロット / .credentials.json) には一切触れないため、切替や再ログインは不要。
|
|
4050
|
+
// 既定プロファイルのラベルも変更可 (configDir は常に ~/.claude のまま)。
|
|
4051
|
+
const id = msg.profile_id || msg.id || ""
|
|
4052
|
+
try {
|
|
4053
|
+
const updated = await renameProfile(id, msg.label)
|
|
4054
|
+
ctx.client.send({
|
|
4055
|
+
type: "profile.rename.result",
|
|
4056
|
+
request_id: msg.request_id,
|
|
4057
|
+
ok: true,
|
|
4058
|
+
profile: {
|
|
4059
|
+
id: updated.id,
|
|
4060
|
+
label: updated.label,
|
|
4061
|
+
config_dir: updated.configDir,
|
|
4062
|
+
},
|
|
4063
|
+
})
|
|
4064
|
+
} catch (err) {
|
|
4065
|
+
ctx.client.send({
|
|
4066
|
+
type: "profile.rename.result",
|
|
4067
|
+
request_id: msg.request_id,
|
|
4068
|
+
ok: false,
|
|
4069
|
+
profile_id: id,
|
|
4070
|
+
error: err?.message || String(err),
|
|
4071
|
+
})
|
|
4072
|
+
}
|
|
4073
|
+
return
|
|
4074
|
+
}
|
|
4075
|
+
case "codex.profile.list": {
|
|
4076
|
+
// Codex アカウント・プロファイル一覧と active を返す (Cockpit の Codex 切替UI用)。
|
|
4077
|
+
// 認証はファイル (<CODEX_HOME>/auth.json) の有無で判定 (Keychain 不要)。
|
|
4078
|
+
const reg = await codexProfiles.listProfiles()
|
|
4079
|
+
const profiles = await Promise.all(
|
|
4080
|
+
reg.profiles.map(async (p) => ({
|
|
4081
|
+
id: p.id,
|
|
4082
|
+
label: p.label,
|
|
4083
|
+
config_dir: p.configDir,
|
|
4084
|
+
has_credentials: await codexProfiles.hasCodexCredentials(p.configDir),
|
|
4085
|
+
})),
|
|
4086
|
+
)
|
|
4087
|
+
ctx.client.send({
|
|
4088
|
+
type: "codex.profile.list.response",
|
|
4089
|
+
request_id: msg.request_id,
|
|
4090
|
+
active_id: reg.activeId,
|
|
4091
|
+
profiles,
|
|
4092
|
+
})
|
|
4093
|
+
return
|
|
4094
|
+
}
|
|
4095
|
+
case "codex.profile.switch": {
|
|
4096
|
+
// Codex アカウント切替。active 永続化 → CODEX_HOME を env/tmux に反映 → Codex
|
|
4097
|
+
// app-server セッションのみ再起動 (Claude 側は独立なので触らない)。認証はファイル
|
|
4098
|
+
// 分離のため CODEX_HOME 切替だけでアカウントが変わる (スワップ不要)。
|
|
4099
|
+
const id = msg.profile_id || msg.id || ""
|
|
4100
|
+
try {
|
|
4101
|
+
const target = await codexProfiles.setActiveProfile(id)
|
|
4102
|
+
applyActiveCodexEnv(target, ctx.logger)
|
|
4103
|
+
await syncTmuxCodexEnv(target, ctx.logger)
|
|
4104
|
+
// Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
|
|
4105
|
+
const before = ctx.codexBridge?.sessions?.size ?? 0
|
|
4106
|
+
ctx.codexBridge?.shutdown()
|
|
4107
|
+
const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
|
|
4108
|
+
const needsLogin = !hasCreds
|
|
4109
|
+
const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
|
|
4110
|
+
ctx.logger.info(
|
|
4111
|
+
{ profile_id: target.id, reaped: before, needs_login: needsLogin },
|
|
4112
|
+
"codex profile switched; codex sessions reaped",
|
|
4113
|
+
)
|
|
4114
|
+
ctx.client.send({
|
|
4115
|
+
type: "codex.profile.switch.result",
|
|
4116
|
+
request_id: msg.request_id,
|
|
4117
|
+
ok: true,
|
|
4118
|
+
active_id: target.id,
|
|
4119
|
+
profile: {
|
|
4120
|
+
id: target.id,
|
|
4121
|
+
label: target.label,
|
|
4122
|
+
config_dir: target.configDir,
|
|
4123
|
+
},
|
|
4124
|
+
reaped: before,
|
|
4125
|
+
needs_login: needsLogin,
|
|
4126
|
+
login_command: needsLogin
|
|
4127
|
+
? isDefaultTarget
|
|
4128
|
+
? "codex login"
|
|
4129
|
+
: `CODEX_HOME=${target.configDir} codex login`
|
|
4130
|
+
: undefined,
|
|
4131
|
+
})
|
|
4132
|
+
} catch (err) {
|
|
4133
|
+
ctx.client.send({
|
|
4134
|
+
type: "codex.profile.switch.result",
|
|
4135
|
+
request_id: msg.request_id,
|
|
4136
|
+
ok: false,
|
|
4137
|
+
profile_id: id,
|
|
4138
|
+
error: err?.message || String(err),
|
|
4139
|
+
})
|
|
4140
|
+
}
|
|
4141
|
+
return
|
|
4142
|
+
}
|
|
4143
|
+
case "codex.profile.add": {
|
|
4144
|
+
// 新規 Codex プロファイル追加 (連番 id・中立パス `~/.codex-<n>`)。共有資産を
|
|
4145
|
+
// provisioning 済み。追加直後はログイン未了 (CODEX_HOME=<dir> codex login)。
|
|
4146
|
+
try {
|
|
4147
|
+
const created = await codexProfiles.addProfile({ label: msg.label })
|
|
4148
|
+
ctx.client.send({
|
|
4149
|
+
type: "codex.profile.add.result",
|
|
4150
|
+
request_id: msg.request_id,
|
|
4151
|
+
ok: true,
|
|
4152
|
+
profile: {
|
|
4153
|
+
id: created.id,
|
|
4154
|
+
label: created.label,
|
|
4155
|
+
config_dir: created.configDir,
|
|
4156
|
+
},
|
|
4157
|
+
needs_login: created.needsLogin,
|
|
4158
|
+
login_command: `CODEX_HOME=${created.configDir} codex login`,
|
|
4159
|
+
})
|
|
4160
|
+
// 新プロファイルへ bundle hooks / AGENTS.md を反映 (codex 向け setup)。
|
|
4161
|
+
kickSetupHubAi({ logger: ctx.logger, cliKind: "codex" })
|
|
4162
|
+
} catch (err) {
|
|
4163
|
+
ctx.client.send({
|
|
4164
|
+
type: "codex.profile.add.result",
|
|
4165
|
+
request_id: msg.request_id,
|
|
4166
|
+
ok: false,
|
|
4167
|
+
error: err?.message || String(err),
|
|
4168
|
+
})
|
|
4169
|
+
}
|
|
4170
|
+
return
|
|
4171
|
+
}
|
|
4172
|
+
case "codex.profile.rename": {
|
|
4173
|
+
// Codex プロファイルの表示ラベルのみ変更 (切替・認証に触れない)。
|
|
4174
|
+
const id = msg.profile_id || msg.id || ""
|
|
4175
|
+
try {
|
|
4176
|
+
const updated = await codexProfiles.renameProfile(id, msg.label)
|
|
4177
|
+
ctx.client.send({
|
|
4178
|
+
type: "codex.profile.rename.result",
|
|
4179
|
+
request_id: msg.request_id,
|
|
4180
|
+
ok: true,
|
|
4181
|
+
profile: {
|
|
4182
|
+
id: updated.id,
|
|
4183
|
+
label: updated.label,
|
|
4184
|
+
config_dir: updated.configDir,
|
|
4185
|
+
},
|
|
4186
|
+
})
|
|
4187
|
+
} catch (err) {
|
|
4188
|
+
ctx.client.send({
|
|
4189
|
+
type: "codex.profile.rename.result",
|
|
4190
|
+
request_id: msg.request_id,
|
|
4191
|
+
ok: false,
|
|
4192
|
+
profile_id: id,
|
|
4193
|
+
error: err?.message || String(err),
|
|
4194
|
+
})
|
|
4195
|
+
}
|
|
4196
|
+
return
|
|
4197
|
+
}
|
|
4008
4198
|
case "tmux.exec": {
|
|
4009
4199
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
4010
4200
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
package/src/profiles.mjs
CHANGED
|
@@ -169,6 +169,26 @@ export async function setActiveProfile(id) {
|
|
|
169
169
|
return target
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* プロファイルの表示ラベルを変更する。id・configDir・認証情報には一切触れない。
|
|
174
|
+
* 既定 (プライマリ) プロファイルのラベルも変更可能で、saveRegistry が defaultLabel
|
|
175
|
+
* として永続化する (configDir は常に `~/.claude` のまま = ディレクトリは動かさない)。
|
|
176
|
+
*
|
|
177
|
+
* @param {string} id 対象プロファイルの id (既定は "default")
|
|
178
|
+
* @param {string} label 新しいラベル (前後空白を除去。空文字列は不可)
|
|
179
|
+
* @returns {Promise<{id:string,label:string,configDir:string}>} 更新後のプロファイル
|
|
180
|
+
*/
|
|
181
|
+
export async function renameProfile(id, label) {
|
|
182
|
+
const trimmed = typeof label === "string" ? label.trim() : ""
|
|
183
|
+
if (!trimmed) throw new Error("label must be a non-empty string")
|
|
184
|
+
const reg = await loadRegistry()
|
|
185
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
186
|
+
if (!target) throw new Error(`unknown profile: ${id}`)
|
|
187
|
+
target.label = trimmed
|
|
188
|
+
await saveRegistry(reg)
|
|
189
|
+
return { id: target.id, label: target.label, configDir: target.configDir }
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
173
193
|
function nextNumericId(profiles) {
|
|
174
194
|
let max = 0
|