Files
wuguanganandClaude Opus 5 3af92f27cf chore: 仓库改名 vibe-foreman,代码认新旧两个目录名
仓库 gulugulu-cn/claude-hub → gulugulu-cn/vibe-foreman。
GitHub 自动重定向旧地址,6 个 Release 全部保留。

代码里按 ~/Documents/code/claude-hub 找脚本和 projects.yaml 的地方,
改成新旧两个目录名都试 —— 老用户的工作区还叫旧名,新 clone 的是新名,
少认一个的表现是"从界面开项目时项目配置没被补齐",而那是静默的。

**Application Support 下的 claude-hub 目录不改名。** 那是用户数据落盘的
地方(验收清单、盯梢配置、已结束会话名册、置顶、审批日志),
改了等于把现有配置全孤立掉。

530 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:05:03 +08:00

372 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import HubProbe
/// 在终端里打开项目的方式。
public enum LaunchMode: String, CaseIterable, Sendable {
case claude
case claudeSkipPermissions
case terminal
case resumeLast
case resumePick
case resumeSession
case finder
case vscode
public var label: String {
switch self {
case .claude: return "Claude Code"
case .claudeSkipPermissions: return "Claude Code(跳过权限)"
case .terminal: return "终端"
case .resumeLast: return "继续上次会话"
case .resumePick: return "选择历史会话"
case .resumeSession: return "恢复此会话"
case .finder: return "在 Finder 打开"
case .vscode: return "用 VS Code 打开"
}
}
/// 要在 tmux 窗口里跑的命令。nil 表示只开一个 shell。
func command(sessionId: String?) -> String? {
switch self {
case .claude: return "claude"
case .claudeSkipPermissions: return "claude --dangerously-skip-permissions"
case .terminal: return nil
case .resumeLast: return "claude --continue"
case .resumePick: return "claude --resume"
case .resumeSession:
guard let sessionId else { return "claude --resume" }
return "claude --resume \(shellQuote(sessionId))"
case .finder, .vscode: return nil
}
}
}
func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// 终端派发。
///
/// ## 必须保留的隐性约束:Keychain ACL
///
/// **首次 `tmux new-session` 必须由终端进程执行**,不能由本 app 直接跑。
///
/// 原因:这样 tmux server 的父进程是 iTerm,claude 作为它的子进程才能复用
/// iTerm 在 macOS Keychain 上的 ACL。如果由 app 创建 tmux server,claude
/// 拿不到登录态,用户会莫名其妙地掉线。这是上一版踩过的坑,改写时原样保留。
///
/// 后续的 `new-window` / `send-keys` 是 client 操作,谁跑都行。
public struct TerminalDispatch: Sendable {
public enum Terminal: String, Sendable {
case iTerm = "iTerm2"
case terminal = "Terminal"
var bundleId: String {
switch self {
case .iTerm: return "com.googlecode.iterm2"
case .terminal: return "com.apple.Terminal"
}
}
/// 磁盘上 `.app` 可能叫什么。
///
/// **`rawValue` 是 AppleScript 名,不是包名。** iTerm2 的 AppleScript
/// 名叫 `iTerm2`,但包实际是 `/Applications/iTerm.app`(可执行文件才叫
/// iTerm2)。旧实现拿 rawValue 拼路径去找 `iTerm2.app`,永远找不到,
/// 于是 `detectTerminal()` 一路降级到 macOS 终端 ——
/// 实机现象是「重启后第一次开项目弹的是系统终端」:
/// 平时 hub session 已存在且有客户端连着,走的是 addWindow,
/// 根本不经过这里;只有开机后第一次建 session 才会暴露。
var appBundleNames: [String] {
switch self {
case .iTerm: return ["iTerm", "iTerm2"]
case .terminal: return ["Terminal"]
}
}
/// iTerm 用 `-CC` 控制模式,tmux 窗口会呈现为原生 tab。
var attachCommand: String {
switch self {
case .iTerm: return "tmux -CC attach -t hub"
case .terminal: return "tmux attach -t hub"
}
}
}
public let sessionName: String
private let tmux: TmuxProbe
public init(sessionName: String = "hub", tmux: TmuxProbe = TmuxProbe()) {
self.sessionName = sessionName
self.tmux = tmux
}
public func detectTerminal() -> Terminal {
for candidate in [Terminal.iTerm, .terminal] where isInstalled(candidate) {
return candidate
}
return .terminal
}
/// 装没装。
///
/// 首选 bundle id 走 LaunchServices —— 用户可能把 app 放在 `~/Applications`、
/// Setapp 目录、甚至改过名,靠猜路径迟早猜错(上面 `appBundleNames` 注释里
/// 那个 bug 就是猜错路径的结果)。LaunchServices 查不到时才退回路径探测,
/// 而且要把两个候选包名都试一遍。
func isInstalled(_ terminal: Terminal) -> Bool {
if NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleId) != nil {
return true
}
return Self.installedPathCandidates(for: terminal)
.contains { FileManager.default.fileExists(atPath: $0) }
}
/// 路径兜底的候选清单。纯函数,测试盯着它别再退化成只认 `rawValue`。
static func installedPathCandidates(for terminal: Terminal) -> [String] {
terminal.appBundleNames.flatMap { name in
[
"/Applications/\(name).app",
NSString(string: "~/Applications/\(name).app").expandingTildeInPath,
"/System/Applications/Utilities/\(name).app",
]
}
}
// MARK: - 打开项目
@discardableResult
public func open(
project name: String,
path: String,
mode: LaunchMode,
sessionId: String? = nil
) -> Bool {
// **每一次派发都记一笔。**
//
// 「点了没反应」和「开到了别的目录」这两类故障,在外面看起来都只是
// "结果不对",而中间传了什么完全不可见 —— `log show` 对这个 app 是瞎的
//(ad-hoc 签名,NOTES.md 记过),os.Logger 打了也看不到。
// 没有这份记录时我只能靠读代码猜 path 怎么会变空,猜了三轮没猜中。
Self.trace("open mode=\(mode.rawValue) name=\(name) path=\(path.isEmpty ? "<空>" : path)")
// **目录不存在必须当场拦下,不能让它往下走。**
//
// tmux 对不存在的 `-c` 目录**返回 0 并静默回落到家目录** ——
// 实测 `tmux new-session -c /不存在的路径` exit=0,
// `pane_current_path` 变成 `/Users/dev`。`open /不存在` 则什么都不做。
// 两者都不报错。
//
// 于是 projects.yaml 里 6 条指向已删除目录的记录,表现成
// 「Finder 那些全没用」+「Claude 开到家目录」,看起来像右键菜单坏了,
// 而真正坏的是数据。我为此猜了三轮代码都没猜中 ——
// **一个静默回落的 API 会把数据问题伪装成功能问题。**
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
guard !path.trimmingCharacters(in: .whitespaces).isEmpty else {
Self.trace("拒绝:\(name) 的路径是空的(检查 projects.yaml)")
return false
}
guard exists, isDirectory.boolValue else {
Self.trace("拒绝:\(name) 的目录不存在 —— \(path)")
return false
}
switch mode {
case .finder:
return Shell.run("/usr/bin/open", [path], timeout: 5).succeeded
case .vscode:
// code 不一定在 GUI app 的 PATH 里,用 open -a 更稳。
let direct = Shell.run("/usr/bin/open", ["-a", "Visual Studio Code", path], timeout: 5)
return direct.succeeded
default:
break
}
// 起 claude 之前先把项目配置补齐(防污染 deny + 全套 hook)。
//
// **这条路必须单独补一次。** app 从岛/主窗口启动项目时**完全不经过**
// project-menu.sh —— 那个脚本只有 tmux 手动开的路径会走。
// 漏了这里的话,凡是从 Hub 界面点开的项目一个都没被检查过,
// 而那正是最常用的入口。
ensureProjectConfig(path)
let inner = mode.command(sessionId: sessionId)
if hubSessionExists() {
return addWindow(name: name, path: path, command: inner)
}
return createSession(name: name, path: path, command: inner)
}
/// 派发日志。
///
/// 写文件而不是 `os.Logger`:本机实测 `log show` 完全看不到这个 app 的输出
/// (多半和 ad-hoc 签名有关),同 `SessionWatchdog` 的心跳。
/// **一个看不见的仪表比没有仪表更糟** —— 我拿它做过诊断,得出了错误结论。
static func trace(_ line: String) {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/launch.log")
let stamp = ISO8601DateFormatter().string(from: Date())
guard let data = "\(stamp) \(line)\n".data(using: .utf8) else { return }
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
if let handle = try? FileHandle(forWritingTo: url) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: url)
}
}
/// 补齐项目的 `.claude/settings.json`(防污染 deny + 全套 hook)。
///
/// 脚本自己是幂等的、也自己兜底不阻断,所以这里失败了什么都不用做 ——
/// 让项目起不来的代价远大于少一次配置检查。
private func ensureProjectConfig(_ path: String) {
guard let script = Self.locateScript("ensure-project-config.sh") else { return }
_ = Shell.run("/bin/bash", [script, path], timeout: 10)
}
/// 找 hub 的脚本目录。
///
/// **不能靠 PATH 或相对路径**:Hub 是 GUI app,从 launchd 起,
/// 工作目录和 PATH 都不是终端里那一套(`StallJudge.locateClaude` 同理)。
static func locateScript(_ name: String) -> String? {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let candidates = [
// 仓库改过名(claude-hub → vibe-foreman),两个都要认:
// 老用户的工作区还叫旧名,新 clone 的是新名。少认一个的表现是
// "从界面开项目时项目配置没被补齐",而那是**静默的**。
"\(home)/Documents/code/vibe-foreman/scripts/\(name)",
"\(home)/Documents/code/claude-hub/scripts/\(name)",
"\(home)/.local/share/claude-hub/scripts/\(name)",
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
}
private func hubSessionExists() -> Bool {
Shell.run(tmux.tmuxPath, ["has-session", "-t", sessionName], timeout: 3).succeeded
}
/// 第一次创建:**必须**让终端来跑,见类型注释里的 Keychain 说明。
private func createSession(name: String, path: String, command: String?) -> Bool {
let terminal = detectTerminal()
ensureRunning(terminal)
var create = "tmux new-session -d -s \(shellQuote(sessionName))"
+ " -n \(shellQuote(name)) -c \(shellQuote(path))"
if let command {
create += " \(shellQuote(command))"
}
return runInTerminal("\(create) && \(terminal.attachCommand)", terminal: terminal)
}
/// session 已存在:直接加窗口,这是 client 操作,app 自己跑没问题。
private func addWindow(name: String, path: String, command: String?) -> Bool {
// 注意 flag 必须全部排在 shell-command 之前,否则 tmux 会把 flag
// 当成命令的一部分。
var args = [
"new-window", "-t", sessionName, "-n", name, "-c", path,
"-P", "-F", "#{window_id}",
]
if let command { args.append(command) }
let result = Shell.run(tmux.tmuxPath, args, timeout: 5)
guard result.succeeded else { return false }
let windowId = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
// 没有客户端连着的话,新窗口只是后台多了一个,用户什么都看不到。
let clients = Shell.run(tmux.tmuxPath, ["list-clients", "-t", sessionName], timeout: 3)
if clients.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let terminal = detectTerminal()
ensureRunning(terminal)
return runInTerminal(terminal.attachCommand, terminal: terminal)
}
// 有客户端:iTerm 不会自动前置新 tab,得主动切过去。
if !windowId.isEmpty {
Shell.run(tmux.tmuxPath, ["select-window", "-t", windowId], timeout: 3)
}
Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4)
return true
}
// MARK: - 终端控制
/// 确保终端在跑且 AppleEvent 接收器就绪。
///
/// 冷启动的 app 会有一段时间接受不了 AppleEvent,这时候发脚本会静默失败。
/// 所以要轮询探测 —— 用 `tell app to return id`,它不创建窗口、无副作用。
private func ensureRunning(_ terminal: Terminal) {
// 用 bundle id 判活,不用 `pgrep -x <名字>`:
// ① 进程名和 AppleScript 名不一定一致;
// ② macOS 的 pgrep **默认把自己的祖先排除在匹配之外**(要 `-a` 才算上),
// 所以从 iTerm 里跑起来的 hubctl/hubprobe 问「iTerm 在跑吗」永远答否。
if !NSRunningApplication
.runningApplications(withBundleIdentifier: terminal.bundleId).isEmpty {
return
}
// -g:不抢焦点,稍后让 AppleScript 的 activate 来抢,
// 避免冷启动期间焦点反复跳。
Shell.run("/usr/bin/open", ["-g", "-b", terminal.bundleId], timeout: 5)
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
let probe = Shell.osascript(
"tell application \"\(terminal.rawValue)\" to return id", timeout: 3
)
if probe.succeeded,
!probe.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return
}
usleep(150_000)
}
}
private func runInTerminal(_ command: String, terminal: Terminal) -> Bool {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script: String
switch terminal {
case .iTerm:
script = """
tell application "iTerm2"
activate
if (count of windows) = 0 then
set w to (create window with default profile)
tell current session of current tab of w
write text "\(escaped)"
end tell
else
tell current window
create tab with default profile
delay 0.3
tell current session of current tab
write text "\(escaped)"
end tell
end tell
end if
end tell
"""
case .terminal:
script = """
tell application "Terminal"
activate
do script "\(escaped)"
end tell
"""
}
return Shell.osascript(script, timeout: 15).succeeded
}
}