@cocorograph/hub-agent 0.6.32 → 0.6.34
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/main.mjs +175 -1
- package/src/profiles.mjs +281 -0
- package/src/tmux.mjs +90 -0
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -28,6 +28,15 @@ import { fetchSessionHistory, listSessions } from "./claude-history.mjs"
|
|
|
28
28
|
import { listAgents } from "./agents.mjs"
|
|
29
29
|
import { listSkills } from "./skills.mjs"
|
|
30
30
|
import { listSessionStates } from "./state.mjs"
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_PROFILE_ID,
|
|
33
|
+
defaultConfigDir,
|
|
34
|
+
listProfiles,
|
|
35
|
+
getActiveProfile,
|
|
36
|
+
getActiveProjectsRoot,
|
|
37
|
+
setActiveProfile,
|
|
38
|
+
addProfile,
|
|
39
|
+
} from "./profiles.mjs"
|
|
31
40
|
import {
|
|
32
41
|
buildClaudeCmd,
|
|
33
42
|
createSession as createTmuxSession,
|
|
@@ -36,6 +45,7 @@ import {
|
|
|
36
45
|
killManySessions,
|
|
37
46
|
killSession as killTmuxSession,
|
|
38
47
|
listSessions as listTmuxSessions,
|
|
48
|
+
listWorktreeNameHistory,
|
|
39
49
|
listWorktreeStubs,
|
|
40
50
|
removeWorktree as removeWorktreeDir,
|
|
41
51
|
} from "./tmux.mjs"
|
|
@@ -143,6 +153,34 @@ export function isFastPathMessage(type) {
|
|
|
143
153
|
return type === "pty.data" || type === "pty.resize"
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
/**
|
|
157
|
+
* active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
|
|
158
|
+
* 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
|
|
159
|
+
* SDK が起動する Claude Code サブプロセスはこの env を継承するため、以後の query() は
|
|
160
|
+
* このプロファイルの認証・履歴・設定ディレクトリで動く。全セッションの再起動とセットで使う。
|
|
161
|
+
*
|
|
162
|
+
* @param {{id:string,configDir:string}} profile
|
|
163
|
+
* @param {import('pino').Logger} [log]
|
|
164
|
+
*/
|
|
165
|
+
export function applyActiveProfileEnv(profile, log) {
|
|
166
|
+
const isDefault =
|
|
167
|
+
!profile ||
|
|
168
|
+
profile.id === DEFAULT_PROFILE_ID ||
|
|
169
|
+
profile.configDir === defaultConfigDir()
|
|
170
|
+
if (isDefault) {
|
|
171
|
+
delete process.env.CLAUDE_CONFIG_DIR
|
|
172
|
+
} else {
|
|
173
|
+
process.env.CLAUDE_CONFIG_DIR = profile.configDir
|
|
174
|
+
}
|
|
175
|
+
log?.info?.(
|
|
176
|
+
{
|
|
177
|
+
profile_id: profile?.id || DEFAULT_PROFILE_ID,
|
|
178
|
+
config_dir: process.env.CLAUDE_CONFIG_DIR || defaultConfigDir(),
|
|
179
|
+
},
|
|
180
|
+
"active claude profile applied",
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
147
185
|
const config = await readConfig()
|
|
148
186
|
if (!config) {
|
|
@@ -154,6 +192,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
154
192
|
const plugins = await loadPlugins(logger)
|
|
155
193
|
const ctx = { logger, config, plugins }
|
|
156
194
|
|
|
195
|
+
// 起動時: 保存済み active プロファイルの CLAUDE_CONFIG_DIR を反映してから
|
|
196
|
+
// Claude SDK を初期化する。profiles.json が無ければ既定 (~/.claude) のまま。
|
|
197
|
+
try {
|
|
198
|
+
const activeProfile = await getActiveProfile()
|
|
199
|
+
applyActiveProfileEnv(activeProfile, logger)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.warn(
|
|
202
|
+
{ err: err.message },
|
|
203
|
+
"failed to apply active claude profile; falling back to default ~/.claude",
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
157
207
|
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
158
208
|
|
|
159
209
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
@@ -909,10 +959,13 @@ async function dispatch(msg, ctx) {
|
|
|
909
959
|
const cwd = msg.cwd || ""
|
|
910
960
|
const session_id = msg.session_id || ""
|
|
911
961
|
const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
|
|
962
|
+
// active プロファイルの projects/ から読む (アカウント別に履歴がサイロ化されているため)。
|
|
963
|
+
const projectsRoot = await getActiveProjectsRoot()
|
|
912
964
|
const result = await fetchSessionHistory({
|
|
913
965
|
cwd,
|
|
914
966
|
session_id,
|
|
915
967
|
maxLines,
|
|
968
|
+
projectsRoot,
|
|
916
969
|
logger: ctx.logger,
|
|
917
970
|
})
|
|
918
971
|
ctx.client.send({
|
|
@@ -933,7 +986,14 @@ async function dispatch(msg, ctx) {
|
|
|
933
986
|
const stream_id = msg.stream_id
|
|
934
987
|
const cwd = msg.cwd || ""
|
|
935
988
|
const limit = typeof msg.limit === "number" ? msg.limit : undefined
|
|
936
|
-
|
|
989
|
+
// active プロファイルの projects/ からセッション一覧を読む。
|
|
990
|
+
const projectsRoot = await getActiveProjectsRoot()
|
|
991
|
+
const result = await listSessions({
|
|
992
|
+
cwd,
|
|
993
|
+
limit,
|
|
994
|
+
projectsRoot,
|
|
995
|
+
logger: ctx.logger,
|
|
996
|
+
})
|
|
937
997
|
ctx.client.send({
|
|
938
998
|
type: "claude.sessions.response",
|
|
939
999
|
stream_id,
|
|
@@ -943,6 +1003,86 @@ async function dispatch(msg, ctx) {
|
|
|
943
1003
|
})
|
|
944
1004
|
return
|
|
945
1005
|
}
|
|
1006
|
+
case "profile.list": {
|
|
1007
|
+
// Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
|
|
1008
|
+
const reg = await listProfiles()
|
|
1009
|
+
ctx.client.send({
|
|
1010
|
+
type: "profile.list.response",
|
|
1011
|
+
request_id: msg.request_id,
|
|
1012
|
+
active_id: reg.activeId,
|
|
1013
|
+
profiles: reg.profiles.map((p) => ({
|
|
1014
|
+
id: p.id,
|
|
1015
|
+
label: p.label,
|
|
1016
|
+
config_dir: p.configDir,
|
|
1017
|
+
})),
|
|
1018
|
+
})
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
case "profile.switch": {
|
|
1022
|
+
// アカウント切替。active を永続化 → env 反映 → 全 Claude セッション強制再起動。
|
|
1023
|
+
// 走行中ターンも中断する (アカウントが変わる以上、継続は不可)。
|
|
1024
|
+
const id = msg.profile_id || msg.id || ""
|
|
1025
|
+
try {
|
|
1026
|
+
const target = await setActiveProfile(id)
|
|
1027
|
+
applyActiveProfileEnv(target, ctx.logger)
|
|
1028
|
+
// 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
|
|
1029
|
+
const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
|
|
1030
|
+
ctx.claudeBridge?.shutdown()
|
|
1031
|
+
ctx.logger.info(
|
|
1032
|
+
{ profile_id: target.id, restarted: before },
|
|
1033
|
+
"claude profile switched; all sessions reaped",
|
|
1034
|
+
)
|
|
1035
|
+
ctx.client.send({
|
|
1036
|
+
type: "profile.switch.result",
|
|
1037
|
+
request_id: msg.request_id,
|
|
1038
|
+
ok: true,
|
|
1039
|
+
active_id: target.id,
|
|
1040
|
+
profile: {
|
|
1041
|
+
id: target.id,
|
|
1042
|
+
label: target.label,
|
|
1043
|
+
config_dir: target.configDir,
|
|
1044
|
+
},
|
|
1045
|
+
reaped: before,
|
|
1046
|
+
})
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
ctx.client.send({
|
|
1049
|
+
type: "profile.switch.result",
|
|
1050
|
+
request_id: msg.request_id,
|
|
1051
|
+
ok: false,
|
|
1052
|
+
profile_id: id,
|
|
1053
|
+
error: err?.message || String(err),
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
case "profile.add": {
|
|
1059
|
+
// 新規プロファイルを追加 (連番 id・中立パス `~/.claude-<n>`) し、共有資産を整備する。
|
|
1060
|
+
// 追加直後はログイン未了 (needs_login)。ユーザーが端末で
|
|
1061
|
+
// `CLAUDE_CONFIG_DIR=<config_dir> claude` → /login する必要がある。
|
|
1062
|
+
try {
|
|
1063
|
+
const created = await addProfile({ label: msg.label })
|
|
1064
|
+
ctx.client.send({
|
|
1065
|
+
type: "profile.add.result",
|
|
1066
|
+
request_id: msg.request_id,
|
|
1067
|
+
ok: true,
|
|
1068
|
+
profile: {
|
|
1069
|
+
id: created.id,
|
|
1070
|
+
label: created.label,
|
|
1071
|
+
config_dir: created.configDir,
|
|
1072
|
+
},
|
|
1073
|
+
needs_login: created.needsLogin,
|
|
1074
|
+
login_command: `CLAUDE_CONFIG_DIR=${created.configDir} claude`,
|
|
1075
|
+
})
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
ctx.client.send({
|
|
1078
|
+
type: "profile.add.result",
|
|
1079
|
+
request_id: msg.request_id,
|
|
1080
|
+
ok: false,
|
|
1081
|
+
error: err?.message || String(err),
|
|
1082
|
+
})
|
|
1083
|
+
}
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
946
1086
|
case "tmux.exec": {
|
|
947
1087
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
948
1088
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
|
@@ -1113,6 +1253,40 @@ async function dispatch(msg, ctx) {
|
|
|
1113
1253
|
}
|
|
1114
1254
|
return
|
|
1115
1255
|
}
|
|
1256
|
+
case "worktree.list_history": {
|
|
1257
|
+
// body: { request_id, dir }
|
|
1258
|
+
// cockpit (Feature A) の worktree 作成ダイアログから呼ばれる。指定 workspace で
|
|
1259
|
+
// 過去に使った worktree 名 (~/.claude/projects のログ dir 由来) を返す。
|
|
1260
|
+
const dir = (msg.dir || "").trim()
|
|
1261
|
+
if (!dir) {
|
|
1262
|
+
ctx.client.send({
|
|
1263
|
+
type: "worktree.list_history.result",
|
|
1264
|
+
request_id: msg.request_id,
|
|
1265
|
+
ok: false,
|
|
1266
|
+
error: "dir required",
|
|
1267
|
+
names: [],
|
|
1268
|
+
})
|
|
1269
|
+
return
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
const names = await listWorktreeNameHistory(dir)
|
|
1273
|
+
ctx.client.send({
|
|
1274
|
+
type: "worktree.list_history.result",
|
|
1275
|
+
request_id: msg.request_id,
|
|
1276
|
+
ok: true,
|
|
1277
|
+
names,
|
|
1278
|
+
})
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
ctx.client.send({
|
|
1281
|
+
type: "worktree.list_history.result",
|
|
1282
|
+
request_id: msg.request_id,
|
|
1283
|
+
ok: false,
|
|
1284
|
+
error: err.message,
|
|
1285
|
+
names: [],
|
|
1286
|
+
})
|
|
1287
|
+
}
|
|
1288
|
+
return
|
|
1289
|
+
}
|
|
1116
1290
|
case "worktree.remove": {
|
|
1117
1291
|
// body: { request_id, name }
|
|
1118
1292
|
// cockpit (PR 1719) のサイドバー削除ボタンから呼ばれる。
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude アカウント・プロファイル管理。
|
|
3
|
+
*
|
|
4
|
+
* 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
|
|
5
|
+
* で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
|
|
6
|
+
* 認証・会話履歴 (`projects/`)・`.claude.json` をアカウント別に保持する。一方で
|
|
7
|
+
* skills / agents / commands / scripts / hooks / CLAUDE.md は symlink で共有し、
|
|
8
|
+
* settings.json はコピーで共有する (provisionProfile が面倒を見る)。
|
|
9
|
+
*
|
|
10
|
+
* 設計方針 (重要):
|
|
11
|
+
* - 既定 (プライマリ) プロファイルは **常に `~/.claude`**。リネーム・移動しない。
|
|
12
|
+
* profiles.json が存在しない単一アカウント利用者は、この仕組みに一切触れずに
|
|
13
|
+
* 従来どおり `~/.claude` で動く (完全に追加機能・ゼロ設定)。
|
|
14
|
+
* - 2 つ目以降を足したときだけ `~/.claude-1` `~/.claude-2` … が生える (中立な連番)。
|
|
15
|
+
* `~/.claude-0` は作らない。
|
|
16
|
+
* - ディレクトリ名にプラン種別を埋め込まない。UI 表示は label で行う。
|
|
17
|
+
*
|
|
18
|
+
* レジストリは `~/.hub/profiles.json` に保存する (config.mjs と同じ ~/.hub 配下)。
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from "node:fs"
|
|
21
|
+
import path from "node:path"
|
|
22
|
+
import os from "node:os"
|
|
23
|
+
|
|
24
|
+
import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
|
|
25
|
+
|
|
26
|
+
/** 既定 (プライマリ) プロファイルの ID。 */
|
|
27
|
+
export const DEFAULT_PROFILE_ID = "default"
|
|
28
|
+
|
|
29
|
+
/** `~/.claude` の絶対パス。 */
|
|
30
|
+
export function defaultConfigDir() {
|
|
31
|
+
return path.join(os.homedir(), ".claude")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 既定プロファイルを合成して返す (永続化はしない)。 */
|
|
35
|
+
export function makeDefaultProfile() {
|
|
36
|
+
return {
|
|
37
|
+
id: DEFAULT_PROFILE_ID,
|
|
38
|
+
label: "メイン",
|
|
39
|
+
configDir: defaultConfigDir(),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `~/.hub/profiles.json` の絶対パス。 */
|
|
44
|
+
export function registryPath() {
|
|
45
|
+
return path.join(hubPaths.configDir, "profiles.json")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** モジュール内キャッシュ (頻繁に呼ばれる getActiveConfigDir のため)。
|
|
49
|
+
* setActiveProfile / 書き込み系で必ず無効化する。 */
|
|
50
|
+
let _cache = null
|
|
51
|
+
|
|
52
|
+
/** チルダ展開 (`~` / `~/...` を home 起点に解決)。それ以外は path.resolve。 */
|
|
53
|
+
function resolveDir(dir) {
|
|
54
|
+
if (!dir) return defaultConfigDir()
|
|
55
|
+
if (dir === "~") return os.homedir()
|
|
56
|
+
if (dir.startsWith("~/")) return path.join(os.homedir(), dir.slice(2))
|
|
57
|
+
return path.resolve(dir)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
|
|
62
|
+
* 返り値の profiles は必ず先頭に既定プロファイルを含み、configDir は絶対パスに正規化済み。
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function loadRegistry() {
|
|
67
|
+
if (_cache) return _cache
|
|
68
|
+
let parsed = null
|
|
69
|
+
try {
|
|
70
|
+
const raw = await fs.readFile(registryPath(), "utf-8")
|
|
71
|
+
parsed = JSON.parse(raw)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.code !== "ENOENT") throw err
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const def = makeDefaultProfile()
|
|
77
|
+
let profiles = [def]
|
|
78
|
+
let activeId = DEFAULT_PROFILE_ID
|
|
79
|
+
|
|
80
|
+
if (parsed && Array.isArray(parsed.profiles)) {
|
|
81
|
+
// 既定以外を取り込み (configDir 正規化)。id 重複・default の二重定義は弾く。
|
|
82
|
+
const seen = new Set([DEFAULT_PROFILE_ID])
|
|
83
|
+
for (const p of parsed.profiles) {
|
|
84
|
+
if (!p || typeof p.id !== "string" || p.id === DEFAULT_PROFILE_ID) continue
|
|
85
|
+
if (seen.has(p.id)) continue
|
|
86
|
+
seen.add(p.id)
|
|
87
|
+
profiles.push({
|
|
88
|
+
id: p.id,
|
|
89
|
+
label: typeof p.label === "string" && p.label ? p.label : p.id,
|
|
90
|
+
configDir: resolveDir(p.configDir),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
// 既定プロファイルのラベルだけはレジストリで上書きできる。
|
|
94
|
+
if (typeof parsed.defaultLabel === "string" && parsed.defaultLabel) {
|
|
95
|
+
def.label = parsed.defaultLabel
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
typeof parsed.activeId === "string" &&
|
|
99
|
+
profiles.some((p) => p.id === parsed.activeId)
|
|
100
|
+
) {
|
|
101
|
+
activeId = parsed.activeId
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_cache = { activeId, profiles }
|
|
106
|
+
return _cache
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** レジストリを永続化する。default は profiles[] に含めず、追加分と activeId / defaultLabel だけ保存。 */
|
|
110
|
+
async function saveRegistry(reg) {
|
|
111
|
+
await ensureConfigDir()
|
|
112
|
+
const def = reg.profiles.find((p) => p.id === DEFAULT_PROFILE_ID)
|
|
113
|
+
const payload = {
|
|
114
|
+
activeId: reg.activeId,
|
|
115
|
+
defaultLabel: def ? def.label : undefined,
|
|
116
|
+
profiles: reg.profiles
|
|
117
|
+
.filter((p) => p.id !== DEFAULT_PROFILE_ID)
|
|
118
|
+
.map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
|
|
119
|
+
}
|
|
120
|
+
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
})
|
|
123
|
+
_cache = null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** プロファイル一覧と active を返す (UI 表示用)。 */
|
|
127
|
+
export async function listProfiles() {
|
|
128
|
+
const reg = await loadRegistry()
|
|
129
|
+
return { activeId: reg.activeId, profiles: reg.profiles }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** active プロファイルオブジェクトを返す。 */
|
|
133
|
+
export async function getActiveProfile() {
|
|
134
|
+
const reg = await loadRegistry()
|
|
135
|
+
return (
|
|
136
|
+
reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultProfile()
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** active の config ディレクトリ絶対パス。 */
|
|
141
|
+
export async function getActiveConfigDir() {
|
|
142
|
+
return (await getActiveProfile()).configDir
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** active の会話履歴ルート (`<configDir>/projects`)。 */
|
|
146
|
+
export async function getActiveProjectsRoot() {
|
|
147
|
+
return path.join(await getActiveConfigDir(), "projects")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* active プロファイルを切り替える。存在しない id はエラー。
|
|
152
|
+
* env (CLAUDE_CONFIG_DIR) の反映と全セッション再起動は呼び出し側 (main.mjs) の責務。
|
|
153
|
+
*
|
|
154
|
+
* @param {string} id
|
|
155
|
+
* @returns {Promise<{id:string,label:string,configDir:string}>} 切替後の active プロファイル
|
|
156
|
+
*/
|
|
157
|
+
export async function setActiveProfile(id) {
|
|
158
|
+
const reg = await loadRegistry()
|
|
159
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
160
|
+
if (!target) throw new Error(`unknown profile: ${id}`)
|
|
161
|
+
reg.activeId = id
|
|
162
|
+
await saveRegistry(reg)
|
|
163
|
+
return target
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
167
|
+
function nextNumericId(profiles) {
|
|
168
|
+
let max = 0
|
|
169
|
+
for (const p of profiles) {
|
|
170
|
+
const n = Number.parseInt(p.id, 10)
|
|
171
|
+
if (Number.isInteger(n) && n > max) max = n
|
|
172
|
+
}
|
|
173
|
+
return String(max + 1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* プロファイルを新規追加する (連番 id・中立パス `~/.claude-<n>`)。
|
|
178
|
+
* ディレクトリ整備 (symlink / settings コピー) は provisionProfile が行う。
|
|
179
|
+
* 追加後もログインは未了 (ユーザーが端末で `/login` する必要がある)。
|
|
180
|
+
*
|
|
181
|
+
* @param {{label?: string}} [opts]
|
|
182
|
+
* @returns {Promise<{id:string,label:string,configDir:string, needsLogin: boolean}>}
|
|
183
|
+
*/
|
|
184
|
+
export async function addProfile(opts = {}) {
|
|
185
|
+
const reg = await loadRegistry()
|
|
186
|
+
const id = nextNumericId(reg.profiles)
|
|
187
|
+
const configDir = path.join(os.homedir(), `.claude-${id}`)
|
|
188
|
+
const profile = {
|
|
189
|
+
id,
|
|
190
|
+
label: opts.label && String(opts.label).trim() ? String(opts.label).trim() : `アカウント${id}`,
|
|
191
|
+
configDir,
|
|
192
|
+
}
|
|
193
|
+
reg.profiles.push(profile)
|
|
194
|
+
await saveRegistry(reg)
|
|
195
|
+
await provisionProfile(configDir)
|
|
196
|
+
return { ...profile, needsLogin: true }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
|
|
200
|
+
export const SHARED_SYMLINK_ITEMS = [
|
|
201
|
+
"agents",
|
|
202
|
+
"skills",
|
|
203
|
+
"commands",
|
|
204
|
+
"scripts",
|
|
205
|
+
"hooks",
|
|
206
|
+
"CLAUDE.md",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
/** コピーで共有する資産 (アプリが書き換えるため symlink 不可)。 */
|
|
210
|
+
export const SHARED_COPY_ITEMS = ["settings.json"]
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* プロファイルディレクトリを整備する。
|
|
214
|
+
* - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る (既存・実体があれば触らない)。
|
|
215
|
+
* - 共有コピー資産: 既定からコピー (プロファイル側に未存在のときだけ)。
|
|
216
|
+
* 認証・履歴・`.claude.json` には一切触れない (アカウント固有のため)。
|
|
217
|
+
*
|
|
218
|
+
* @param {string} configDir - 対象プロファイルの config ディレクトリ絶対パス
|
|
219
|
+
* @returns {Promise<{linked: string[], copied: string[], skipped: string[]}>}
|
|
220
|
+
*/
|
|
221
|
+
export async function provisionProfile(configDir) {
|
|
222
|
+
const base = defaultConfigDir()
|
|
223
|
+
await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
|
|
224
|
+
const linked = []
|
|
225
|
+
const copied = []
|
|
226
|
+
const skipped = []
|
|
227
|
+
|
|
228
|
+
for (const item of SHARED_SYMLINK_ITEMS) {
|
|
229
|
+
const src = path.join(base, item)
|
|
230
|
+
const dst = path.join(configDir, item)
|
|
231
|
+
try {
|
|
232
|
+
await fs.access(src)
|
|
233
|
+
} catch {
|
|
234
|
+
skipped.push(`${item}(元無し)`)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
// dst が既に存在 (symlink/実体) なら触らない。
|
|
238
|
+
let exists = false
|
|
239
|
+
try {
|
|
240
|
+
await fs.lstat(dst)
|
|
241
|
+
exists = true
|
|
242
|
+
} catch {
|
|
243
|
+
/* not exists */
|
|
244
|
+
}
|
|
245
|
+
if (exists) {
|
|
246
|
+
skipped.push(`${item}(既存)`)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
await fs.symlink(src, dst)
|
|
250
|
+
linked.push(item)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const item of SHARED_COPY_ITEMS) {
|
|
254
|
+
const src = path.join(base, item)
|
|
255
|
+
const dst = path.join(configDir, item)
|
|
256
|
+
let dstExists = false
|
|
257
|
+
try {
|
|
258
|
+
await fs.lstat(dst)
|
|
259
|
+
dstExists = true
|
|
260
|
+
} catch {
|
|
261
|
+
/* not exists */
|
|
262
|
+
}
|
|
263
|
+
if (dstExists) {
|
|
264
|
+
skipped.push(`${item}(既存)`)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await fs.copyFile(src, dst)
|
|
269
|
+
copied.push(item)
|
|
270
|
+
} catch {
|
|
271
|
+
skipped.push(`${item}(元無し)`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { linked, copied, skipped }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** テスト用: モジュールキャッシュを破棄する。 */
|
|
279
|
+
export function _clearCache() {
|
|
280
|
+
_cache = null
|
|
281
|
+
}
|
package/src/tmux.mjs
CHANGED
|
@@ -267,6 +267,96 @@ export async function removeWorktree(name, opts = {}) {
|
|
|
267
267
|
await execFileP("git", ["-C", parentRepo, "worktree", "remove", "--force", resolved])
|
|
268
268
|
return { name: sanitized, wt_path: resolved }
|
|
269
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Claude Code がセッションログを格納する `~/.claude/projects/<encoded>` の
|
|
273
|
+
* ディレクトリ名エンコードを再現する。Claude Code は cwd の絶対パスに含まれる
|
|
274
|
+
* `/` `.` `_` をすべて `-` に置換したものを dir 名に使う。
|
|
275
|
+
*
|
|
276
|
+
* 例: `/Users/kaz/hub/projects/D00000_hub.cocorograph.com/.claude/worktrees`
|
|
277
|
+
* → `-Users-kaz-hub-projects-D00000-hub-cocorograph-com--claude-worktrees`
|
|
278
|
+
*
|
|
279
|
+
* 実データ (cockpit / cocomiru / orbit 等の worktree ログ dir) で prefix 一致を
|
|
280
|
+
* 検証済み (2026-06-01)。worktree 名はブランチ sanitize 済で `_` `.` を含まないため、
|
|
281
|
+
* encode 後も名前が無損失で復元できる。
|
|
282
|
+
*/
|
|
283
|
+
function encodeClaudeProjectPath(p) {
|
|
284
|
+
return p.replace(/[/._]/g, "-")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 指定 workspace で過去に使った worktree 名の履歴を返す。
|
|
289
|
+
*
|
|
290
|
+
* cockpit の worktree 作成ダイアログで「過去に使った名前」をサジェストし、物理削除
|
|
291
|
+
* した worktree を同じ名前で簡単に復元できるようにするための情報源 (cockpit Feature A)。
|
|
292
|
+
*
|
|
293
|
+
* データ源は `buildWorktreeIndex` (現存 worktree の fs 走査) ではなく
|
|
294
|
+
* `~/.claude/projects/` の **セッションログ dir**。worktree dir を git worktree remove
|
|
295
|
+
* しても Claude Code のログ dir は残るため、「かつて使った名前」= 復元候補が得られる。
|
|
296
|
+
*
|
|
297
|
+
* - worktree の cwd は `<projectsBase>/<workspaceDir>/.claude/worktrees/<name>` なので
|
|
298
|
+
* encode 後は `<encodedWtBase>-<name>` という prefix を持つ。これで当該 workspace の
|
|
299
|
+
* 履歴だけを抽出する (他 repo の同名 worktree と混ざらない)。
|
|
300
|
+
* - いま現存する worktree (= buildWorktreeIndex に居る) は復元する必要がないため除外。
|
|
301
|
+
* - last_used (ログ dir の mtime) 降順でソートして返す。
|
|
302
|
+
*
|
|
303
|
+
* @param {string} workspaceDir 例: "D00000_hub.cocorograph.com" / "~/hub/projects/..."
|
|
304
|
+
* @returns {Promise<Array<{ name: string, last_used: number }>>}
|
|
305
|
+
*/
|
|
306
|
+
export async function listWorktreeNameHistory(workspaceDir) {
|
|
307
|
+
const out = []
|
|
308
|
+
if (!workspaceDir || typeof workspaceDir !== "string") return out
|
|
309
|
+
const projectsBase = hubProjectsBase()
|
|
310
|
+
const repoDir = path.resolve(projectsBase, expandTilde(workspaceDir))
|
|
311
|
+
// path traversal 防止: projectsBase 配下のみ許可
|
|
312
|
+
if (repoDir !== projectsBase && !repoDir.startsWith(projectsBase + path.sep)) {
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
315
|
+
const wtBase = path.join(repoDir, ".claude", "worktrees")
|
|
316
|
+
// dir 名は encode(`${wtBase}/${name}`) = `${encode(wtBase)}-${name}` (区切り `/` も `-`)
|
|
317
|
+
const prefix = encodeClaudeProjectPath(wtBase) + "-"
|
|
318
|
+
|
|
319
|
+
const claudeProjects = path.join(os.homedir(), ".claude", "projects")
|
|
320
|
+
let entries
|
|
321
|
+
try {
|
|
322
|
+
entries = await fs.readdir(claudeProjects, { withFileTypes: true })
|
|
323
|
+
} catch {
|
|
324
|
+
// ~/.claude/projects が無い環境では履歴なし
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 現存 worktree (この workspace 直下のもの) は復元候補から除外する。
|
|
329
|
+
const existing = new Set()
|
|
330
|
+
try {
|
|
331
|
+
const idx = await buildWorktreeIndex()
|
|
332
|
+
for (const info of idx.values()) {
|
|
333
|
+
if (path.dirname(info.cwd) === wtBase) existing.add(path.basename(info.cwd))
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// index 構築失敗時は除外なしで続行 (best-effort)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 同名が複数ログ dir に跨る場合は最新 mtime を採用する。
|
|
340
|
+
const seen = new Map()
|
|
341
|
+
for (const e of entries) {
|
|
342
|
+
if (!e.isDirectory() || !e.name.startsWith(prefix)) continue
|
|
343
|
+
const name = e.name.slice(prefix.length)
|
|
344
|
+
if (!name || existing.has(name)) continue
|
|
345
|
+
let mtime = 0
|
|
346
|
+
try {
|
|
347
|
+
const st = await fs.stat(path.join(claudeProjects, e.name))
|
|
348
|
+
mtime = st.mtimeMs
|
|
349
|
+
} catch {
|
|
350
|
+
// stat 失敗時は 0 のまま (末尾に並ぶ)
|
|
351
|
+
}
|
|
352
|
+
const prev = seen.get(name)
|
|
353
|
+
if (prev == null || mtime > prev) seen.set(name, mtime)
|
|
354
|
+
}
|
|
355
|
+
for (const [name, last_used] of seen) out.push({ name, last_used })
|
|
356
|
+
out.sort((a, b) => b.last_used - a.last_used)
|
|
357
|
+
return out
|
|
358
|
+
}
|
|
359
|
+
|
|
270
360
|
/**
|
|
271
361
|
* Claude CLI コマンドを組み立てる。
|
|
272
362
|
*
|