@cocorograph/hub-agent 0.6.53 → 0.6.55
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 +202 -0
- package/src/main.mjs +142 -8
- package/src/profiles.mjs +50 -0
- package/src/tmux.mjs +52 -1
package/package.json
CHANGED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code 認証情報 (macOS Keychain) のプロファイル別スワップ。
|
|
3
|
+
*
|
|
4
|
+
* WHY: macOS の Claude Code は OAuth トークンを Keychain の単一エントリ
|
|
5
|
+
* 「Claude Code-credentials」に保存し、サービス名は `CLAUDE_CONFIG_DIR` に
|
|
6
|
+
* 依存しない。そのため config ディレクトリを切り替えても全プロファイルが
|
|
7
|
+
* 同じトークンを読んでしまい、アカウントが切り替わらない (2026-06-07 実測)。
|
|
8
|
+
*
|
|
9
|
+
* 対策: profile.switch のタイミングで
|
|
10
|
+
* 1. 正規エントリ (canonical) を切替元プロファイルのスロットへ退避
|
|
11
|
+
* 2. 切替先プロファイルのスロットから canonical へ復元
|
|
12
|
+
* 3. 切替先スロットが無ければ canonical を削除して needs_login を返す
|
|
13
|
+
* (削除しないと旧アカウントのトークンが残り「切り替わらない」が再発する)
|
|
14
|
+
*
|
|
15
|
+
* スロットは「Claude Code-credentials (hub-agent profile <id>)」という
|
|
16
|
+
* 別サービス名の Keychain エントリ。Claude Code 本体はスロットを読まない。
|
|
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 を挟む。
|
|
25
|
+
*/
|
|
26
|
+
import { execFile, spawn } from "node:child_process"
|
|
27
|
+
import { promisify } from "node:util"
|
|
28
|
+
|
|
29
|
+
const execFileP = promisify(execFile)
|
|
30
|
+
|
|
31
|
+
/** Claude Code 本体が読む正規エントリのサービス名 (固定)。 */
|
|
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 スワップが必要なプラットフォームか。 */
|
|
40
|
+
export function isKeychainPlatform(platform = process.platform) {
|
|
41
|
+
return platform === "darwin"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `security find-generic-password -w` の出力を decode する。
|
|
46
|
+
* security はデータに非 printable バイトが含まれると hex で出力する。
|
|
47
|
+
* Claude の credentials は ASCII JSON なので通常 raw のまま返るが、
|
|
48
|
+
* hex らしき文字列で decode 結果が JSON (`{` 始まり) なら decode を採用する。
|
|
49
|
+
*/
|
|
50
|
+
export function decodeSecurityOutput(raw) {
|
|
51
|
+
const s = (raw || "").replace(/\r?\n$/, "")
|
|
52
|
+
if (s.startsWith("{")) return s
|
|
53
|
+
if (/^(?:[0-9a-f]{2})+$/.test(s)) {
|
|
54
|
+
try {
|
|
55
|
+
const decoded = Buffer.from(s, "hex").toString("utf-8")
|
|
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")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Keychain からシークレットを読む。存在しなければ null。
|
|
72
|
+
* @returns {Promise<string|null>}
|
|
73
|
+
*/
|
|
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
|
+
export async function existsGenericPassword(service, opts = {}) {
|
|
92
|
+
const exec = opts.execFileP || execFileP
|
|
93
|
+
try {
|
|
94
|
+
await exec("security", ["find-generic-password", "-s", service])
|
|
95
|
+
return true
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (isNotFound(err)) return false
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Keychain からエントリを削除する。存在しなければ何もしない。 */
|
|
103
|
+
export async function deleteGenericPassword(service, opts = {}) {
|
|
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
|
|
110
|
+
throw err
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
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
|
+
/**
|
|
151
|
+
* プロファイル切替に伴う認証情報のスワップ本体。
|
|
152
|
+
*
|
|
153
|
+
* @param {{fromId: string, toId: string, logger?: object,
|
|
154
|
+
* execFileP?: Function, spawn?: Function, platform?: string}} opts
|
|
155
|
+
* @returns {Promise<{saved: boolean, restored: boolean, needsLogin: boolean, skipped?: string}>}
|
|
156
|
+
*/
|
|
157
|
+
export async function swapCredentials(opts) {
|
|
158
|
+
const { fromId, toId, logger } = opts
|
|
159
|
+
if (!isKeychainPlatform(opts.platform)) {
|
|
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` の存在を見る (呼び出し側で判定)。
|
|
194
|
+
*/
|
|
195
|
+
export async function profileHasKeychainCredentials(profileId, isActive, opts = {}) {
|
|
196
|
+
const service = isActive ? CANONICAL_SERVICE : slotService(profileId)
|
|
197
|
+
try {
|
|
198
|
+
return await existsGenericPassword(service, opts)
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -39,12 +39,19 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
DEFAULT_PROFILE_ID,
|
|
41
41
|
defaultConfigDir,
|
|
42
|
+
hasFileCredentials,
|
|
42
43
|
listProfiles,
|
|
43
44
|
getActiveProfile,
|
|
44
45
|
getActiveProjectsRoot,
|
|
46
|
+
readProfileAccount,
|
|
45
47
|
setActiveProfile,
|
|
46
48
|
addProfile,
|
|
47
49
|
} from "./profiles.mjs"
|
|
50
|
+
import {
|
|
51
|
+
isKeychainPlatform,
|
|
52
|
+
profileHasKeychainCredentials,
|
|
53
|
+
swapCredentials,
|
|
54
|
+
} from "./claude-credentials.mjs"
|
|
48
55
|
import {
|
|
49
56
|
buildClaudeCmd,
|
|
50
57
|
createSession as createTmuxSession,
|
|
@@ -59,6 +66,7 @@ import {
|
|
|
59
66
|
rebindClaudeSession,
|
|
60
67
|
removeWorktree as removeWorktreeDir,
|
|
61
68
|
resumeWithMessage,
|
|
69
|
+
setTmuxGlobalEnv,
|
|
62
70
|
} from "./tmux.mjs"
|
|
63
71
|
import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
64
72
|
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
@@ -126,6 +134,53 @@ function claudeCmdFromAgentConfig(config) {
|
|
|
126
134
|
return cmd
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
/**
|
|
138
|
+
* プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
|
|
139
|
+
*
|
|
140
|
+
* 再作成された pane の claude は createSession 内の injectConfigDirEnv 経由で
|
|
141
|
+
* 新しい CLAUDE_CONFIG_DIR を拾うため、TUI セッションもアカウント切替に追従する。
|
|
142
|
+
* initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
|
|
143
|
+
* 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
|
|
144
|
+
* cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
|
|
145
|
+
* tmux サーバー未起動なら何もしない。
|
|
146
|
+
*/
|
|
147
|
+
async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
148
|
+
const result = { killed: [], recreated: [], failed: [] }
|
|
149
|
+
let sessions = []
|
|
150
|
+
try {
|
|
151
|
+
sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
|
|
152
|
+
} catch (err) {
|
|
153
|
+
ctx.logger?.debug?.(
|
|
154
|
+
{ err: err?.message || String(err) },
|
|
155
|
+
"tmux list failed during profile switch (server not running?)",
|
|
156
|
+
)
|
|
157
|
+
return result
|
|
158
|
+
}
|
|
159
|
+
if (!sessions.length) return result
|
|
160
|
+
const { killed } = await killManySessions(
|
|
161
|
+
sessions.map((s) => s.name),
|
|
162
|
+
{ logger: ctx.logger },
|
|
163
|
+
)
|
|
164
|
+
result.killed = killed
|
|
165
|
+
for (const s of sessions) {
|
|
166
|
+
if (!killed.includes(s.name)) continue
|
|
167
|
+
if (!s.cwd) {
|
|
168
|
+
result.failed.push({ name: s.name, reason: "cwd unknown" })
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await createTmuxSession(s.name, s.cwd, {
|
|
173
|
+
claudeCmd: claudeCmdFromAgentConfig(ctx.config),
|
|
174
|
+
logger: ctx.logger,
|
|
175
|
+
})
|
|
176
|
+
result.recreated.push(s.name)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
result.failed.push({ name: s.name, reason: err?.message || String(err) })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/**
|
|
130
185
|
* `~/.claude/scripts/manifest.json` から Hub AI bundle の version を読む。
|
|
131
186
|
* 未セットアップなら null を返す。
|
|
@@ -218,6 +273,25 @@ export function applyActiveProfileEnv(profile, log) {
|
|
|
218
273
|
)
|
|
219
274
|
}
|
|
220
275
|
|
|
276
|
+
/**
|
|
277
|
+
* active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
|
|
278
|
+
* tmux pane のシェルは hub-agent の process.env を見ないため、手動 respawn や
|
|
279
|
+
* ユーザーが pane 内で直接 `claude` を打つケースの保険として set-environment -g を併用する
|
|
280
|
+
* (新規セッションの主経路は createSession 内の injectConfigDirEnv コマンド焼き込み)。
|
|
281
|
+
* tmux サーバー未起動でも失敗しない。
|
|
282
|
+
*/
|
|
283
|
+
export async function syncTmuxProfileEnv(profile, log) {
|
|
284
|
+
const isDefault =
|
|
285
|
+
!profile ||
|
|
286
|
+
profile.id === DEFAULT_PROFILE_ID ||
|
|
287
|
+
profile.configDir === defaultConfigDir()
|
|
288
|
+
await setTmuxGlobalEnv(
|
|
289
|
+
"CLAUDE_CONFIG_DIR",
|
|
290
|
+
isDefault ? null : profile.configDir,
|
|
291
|
+
{ logger: log },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
221
295
|
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
222
296
|
const config = await readConfig()
|
|
223
297
|
if (!config) {
|
|
@@ -234,6 +308,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
234
308
|
try {
|
|
235
309
|
const activeProfile = await getActiveProfile()
|
|
236
310
|
applyActiveProfileEnv(activeProfile, logger)
|
|
311
|
+
await syncTmuxProfileEnv(activeProfile, logger)
|
|
237
312
|
} catch (err) {
|
|
238
313
|
logger.warn(
|
|
239
314
|
{ err: err.message },
|
|
@@ -1523,33 +1598,84 @@ async function dispatch(msg, ctx) {
|
|
|
1523
1598
|
}
|
|
1524
1599
|
case "profile.list": {
|
|
1525
1600
|
// Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
|
|
1601
|
+
// 各プロファイルの「最後にログインしていたアカウント」(<configDir>/.claude.json の
|
|
1602
|
+
// oauthAccount) と認証情報の有無も添えて、切替が実際に効いたかを UI で検証可能にする。
|
|
1526
1603
|
const reg = await listProfiles()
|
|
1604
|
+
const profiles = await Promise.all(
|
|
1605
|
+
reg.profiles.map(async (p) => {
|
|
1606
|
+
const account = await readProfileAccount(p.configDir)
|
|
1607
|
+
const hasCreds = isKeychainPlatform()
|
|
1608
|
+
? await profileHasKeychainCredentials(p.id, p.id === reg.activeId)
|
|
1609
|
+
: await hasFileCredentials(p.configDir)
|
|
1610
|
+
return {
|
|
1611
|
+
id: p.id,
|
|
1612
|
+
label: p.label,
|
|
1613
|
+
config_dir: p.configDir,
|
|
1614
|
+
account_email: account?.email || null,
|
|
1615
|
+
account_organization: account?.organization || null,
|
|
1616
|
+
has_credentials: hasCreds,
|
|
1617
|
+
}
|
|
1618
|
+
}),
|
|
1619
|
+
)
|
|
1527
1620
|
ctx.client.send({
|
|
1528
1621
|
type: "profile.list.response",
|
|
1529
1622
|
request_id: msg.request_id,
|
|
1530
1623
|
active_id: reg.activeId,
|
|
1531
|
-
profiles
|
|
1532
|
-
id: p.id,
|
|
1533
|
-
label: p.label,
|
|
1534
|
-
config_dir: p.configDir,
|
|
1535
|
-
})),
|
|
1624
|
+
profiles,
|
|
1536
1625
|
})
|
|
1537
1626
|
return
|
|
1538
1627
|
}
|
|
1539
1628
|
case "profile.switch": {
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1629
|
+
// アカウント切替。認証情報スワップ → active 永続化 → env 反映 (process/tmux) →
|
|
1630
|
+
// 全 Claude セッション強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
|
|
1631
|
+
//
|
|
1632
|
+
// WHY 認証スワップ: macOS では OAuth トークンが Keychain の単一エントリに保存され
|
|
1633
|
+
// CLAUDE_CONFIG_DIR と無関係なため、ディレクトリ切替だけではアカウントが変わらない
|
|
1634
|
+
// (2026-06-07 実測)。claude-credentials.mjs の docstring 参照。
|
|
1542
1635
|
const id = msg.profile_id || msg.id || ""
|
|
1543
1636
|
try {
|
|
1637
|
+
const regBefore = await listProfiles()
|
|
1638
|
+
const fromId = regBefore.activeId
|
|
1544
1639
|
const target = await setActiveProfile(id)
|
|
1640
|
+
let cred = { saved: false, restored: false, needsLogin: false }
|
|
1641
|
+
if (fromId !== target.id) {
|
|
1642
|
+
try {
|
|
1643
|
+
cred = await swapCredentials({
|
|
1644
|
+
fromId,
|
|
1645
|
+
toId: target.id,
|
|
1646
|
+
logger: ctx.logger,
|
|
1647
|
+
})
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
// スワップ失敗 (Keychain ACL 拒否等) でも切替自体は続行する。
|
|
1650
|
+
// この場合 Keychain には切替前のトークンが残るため、その旨を返す。
|
|
1651
|
+
ctx.logger.warn(
|
|
1652
|
+
{ err: err?.message || String(err), from: fromId, to: target.id },
|
|
1653
|
+
"credential swap failed; keychain may still hold previous account",
|
|
1654
|
+
)
|
|
1655
|
+
cred = { saved: false, restored: false, needsLogin: false, error: err?.message }
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1545
1658
|
applyActiveProfileEnv(target, ctx.logger)
|
|
1659
|
+
await syncTmuxProfileEnv(target, ctx.logger)
|
|
1546
1660
|
// 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
|
|
1547
1661
|
const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
|
|
1548
1662
|
ctx.claudeBridge?.shutdown()
|
|
1663
|
+
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
1664
|
+
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
1549
1665
|
ctx.logger.info(
|
|
1550
|
-
{
|
|
1666
|
+
{
|
|
1667
|
+
profile_id: target.id,
|
|
1668
|
+
reaped: before,
|
|
1669
|
+
tmux_killed: tmuxResult.killed.length,
|
|
1670
|
+
tmux_recreated: tmuxResult.recreated.length,
|
|
1671
|
+
tmux_failed: tmuxResult.failed.length,
|
|
1672
|
+
cred_saved: cred.saved,
|
|
1673
|
+
cred_restored: cred.restored,
|
|
1674
|
+
needs_login: cred.needsLogin,
|
|
1675
|
+
},
|
|
1551
1676
|
"claude profile switched; all sessions reaped",
|
|
1552
1677
|
)
|
|
1678
|
+
const isDefaultTarget = target.id === DEFAULT_PROFILE_ID
|
|
1553
1679
|
ctx.client.send({
|
|
1554
1680
|
type: "profile.switch.result",
|
|
1555
1681
|
request_id: msg.request_id,
|
|
@@ -1561,6 +1687,14 @@ async function dispatch(msg, ctx) {
|
|
|
1561
1687
|
config_dir: target.configDir,
|
|
1562
1688
|
},
|
|
1563
1689
|
reaped: before,
|
|
1690
|
+
tmux_restarted: tmuxResult.recreated.length,
|
|
1691
|
+
needs_login: !!cred.needsLogin,
|
|
1692
|
+
login_command: cred.needsLogin
|
|
1693
|
+
? isDefaultTarget
|
|
1694
|
+
? "claude"
|
|
1695
|
+
: `CLAUDE_CONFIG_DIR=${target.configDir} claude`
|
|
1696
|
+
: undefined,
|
|
1697
|
+
credential_error: cred.error || undefined,
|
|
1564
1698
|
})
|
|
1565
1699
|
} catch (err) {
|
|
1566
1700
|
ctx.client.send({
|
package/src/profiles.mjs
CHANGED
|
@@ -196,6 +196,56 @@ export async function addProfile(opts = {}) {
|
|
|
196
196
|
return { ...profile, needsLogin: true }
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* プロファイルの `.claude.json` から最後にログインしていたアカウント情報を読む。
|
|
201
|
+
* Claude Code がログイン時に oauthAccount を書き込むため、「このディレクトリで最後に
|
|
202
|
+
* 使われたアカウント」のメタ情報として UI 表示に使える (認証トークンそのものではない)。
|
|
203
|
+
* ファイルが無い・壊れている・未ログインなら null。
|
|
204
|
+
*
|
|
205
|
+
* ⚠️ パスの罠: Claude Code は `.claude.json` を CLAUDE_CONFIG_DIR 指定時のみ
|
|
206
|
+
* `<configDir>/.claude.json` に置き、既定プロファイルでは `~/.claude.json`
|
|
207
|
+
* (ホーム直下、`~/.claude/` の外) に置く。一律 `<configDir>/.claude.json` を読むと
|
|
208
|
+
* 既定プロファイルだけ常に null になる (2026-06-07 実バグ: Cockpit のアカウント
|
|
209
|
+
* 切替 UI で既定プロファイルのみメール・組織が表示されなかった)。
|
|
210
|
+
*
|
|
211
|
+
* @param {string} configDir
|
|
212
|
+
* @returns {Promise<{email: string|null, organization: string|null}|null>}
|
|
213
|
+
*/
|
|
214
|
+
export async function readProfileAccount(configDir) {
|
|
215
|
+
const candidates = [path.join(configDir, ".claude.json")]
|
|
216
|
+
if (path.resolve(configDir) === defaultConfigDir()) {
|
|
217
|
+
candidates.push(path.join(os.homedir(), ".claude.json"))
|
|
218
|
+
}
|
|
219
|
+
for (const file of candidates) {
|
|
220
|
+
try {
|
|
221
|
+
const raw = await fs.readFile(file, "utf-8")
|
|
222
|
+
const oa = JSON.parse(raw)?.oauthAccount
|
|
223
|
+
if (!oa || typeof oa !== "object") continue
|
|
224
|
+
return {
|
|
225
|
+
email: typeof oa.emailAddress === "string" ? oa.emailAddress : null,
|
|
226
|
+
organization:
|
|
227
|
+
typeof oa.organizationName === "string" ? oa.organizationName : null,
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
/* 次の候補へ */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Keychain の無いプラットフォーム向け: `<configDir>/.credentials.json` の有無で
|
|
238
|
+
* ログイン済みかを判定する (darwin では claude-credentials.mjs 側の Keychain 判定を使う)。
|
|
239
|
+
*/
|
|
240
|
+
export async function hasFileCredentials(configDir) {
|
|
241
|
+
try {
|
|
242
|
+
await fs.access(path.join(configDir, ".credentials.json"))
|
|
243
|
+
return true
|
|
244
|
+
} catch {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
199
249
|
/** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
|
|
200
250
|
export const SHARED_SYMLINK_ITEMS = [
|
|
201
251
|
"agents",
|
package/src/tmux.mjs
CHANGED
|
@@ -424,6 +424,51 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
|
424
424
|
.join(" || ")
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
/**
|
|
428
|
+
* claudeCmd の各セグメント先頭に `CLAUDE_CONFIG_DIR=<dir>` の環境変数代入を注入する。
|
|
429
|
+
*
|
|
430
|
+
* WHY: tmux pane のシェルは tmux サーバーの環境を引き継ぐため、hub-agent が
|
|
431
|
+
* process.env.CLAUDE_CONFIG_DIR を切り替えても send-keys で起動する claude には
|
|
432
|
+
* 伝わらない (SDK 経由のチャットセッションにしか効かない)。アカウント・
|
|
433
|
+
* プロファイル切替を TUI セッションにも効かせるため、コマンド文字列自体に
|
|
434
|
+
* 代入プレフィックスを焼き込む。`a || b` の OR 構造では代入が最初のコマンドに
|
|
435
|
+
* しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
|
|
436
|
+
*
|
|
437
|
+
* configDir が空 (= 既定プロファイル) のときは何もしない。
|
|
438
|
+
*/
|
|
439
|
+
export function injectConfigDirEnv(claudeCmd, configDir) {
|
|
440
|
+
if (!configDir || typeof configDir !== "string") return claudeCmd
|
|
441
|
+
if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
|
|
442
|
+
const assignment = `CLAUDE_CONFIG_DIR=${JSON.stringify(configDir)}`
|
|
443
|
+
return claudeCmd
|
|
444
|
+
.split("||")
|
|
445
|
+
.map((part) => `${assignment} ${part.trim()}`)
|
|
446
|
+
.join(" || ")
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* tmux サーバーのグローバル環境変数を設定/解除する。
|
|
451
|
+
* プロファイル切替後に手動で開く pane や respawn にも CLAUDE_CONFIG_DIR が
|
|
452
|
+
* 行き渡るようにするための補助線 (主経路は injectConfigDirEnv のコマンド焼き込み)。
|
|
453
|
+
* value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
|
|
454
|
+
*/
|
|
455
|
+
export async function setTmuxGlobalEnv(name, value, opts = {}) {
|
|
456
|
+
const args =
|
|
457
|
+
value == null
|
|
458
|
+
? ["set-environment", "-gu", name]
|
|
459
|
+
: ["set-environment", "-g", name, value]
|
|
460
|
+
try {
|
|
461
|
+
await execFileP(tmuxBin(opts), args)
|
|
462
|
+
return true
|
|
463
|
+
} catch (err) {
|
|
464
|
+
opts.logger?.debug?.(
|
|
465
|
+
{ name, err: err?.message || String(err) },
|
|
466
|
+
"tmux set-environment failed (server not running?)",
|
|
467
|
+
)
|
|
468
|
+
return false
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
427
472
|
/**
|
|
428
473
|
* 汎用 tmux 実行。`tmux.exec` メッセージから呼び出される。
|
|
429
474
|
*
|
|
@@ -674,7 +719,13 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
674
719
|
}
|
|
675
720
|
}
|
|
676
721
|
const claudeCmdBase = opts.claudeCmd ?? DEFAULT_CLAUDE_CMD
|
|
677
|
-
|
|
722
|
+
// アカウント・プロファイル切替 (CLAUDE_CONFIG_DIR) を TUI セッションにも効かせる。
|
|
723
|
+
// applyActiveProfileEnv が process.env を最新に保つため、既定では env から拾う。
|
|
724
|
+
const configDir = opts.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? null
|
|
725
|
+
const claudeCmd = injectInitialPrompt(
|
|
726
|
+
injectConfigDirEnv(claudeCmdBase, configDir),
|
|
727
|
+
opts.initialPrompt,
|
|
728
|
+
)
|
|
678
729
|
if (claudeCmd) {
|
|
679
730
|
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
|
|
680
731
|
}
|