Files
ggbond268--mactools/Plugins/ActivityBar/Sources/ActivityBarHookInstaller.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

651 lines
21 KiB
Swift

import Foundation
import MacToolsPluginKit
struct ActivityBarHookInstallSummary: Equatable, Sendable {
let scriptDirectory: URL
let installedTools: [String]
}
struct ActivityBarHookUninstallSummary: Equatable, Sendable {
let scriptDirectory: URL
let removedTools: [String]
}
struct ActivityBarHookInstallerPaths: Equatable, Sendable {
let hookScriptsDirectory: URL
let claudeSettingsPath: URL
let cursorHooksPath: URL
let codexConfigPath: URL
let codexHooksPath: URL
init(homeDirectory: URL, hookScriptsDirectory: URL? = nil) {
self.hookScriptsDirectory = hookScriptsDirectory
?? homeDirectory.appendingPathComponent(".mactools/activity-bar/hooks")
self.claudeSettingsPath = homeDirectory.appendingPathComponent(".claude/settings.json")
self.cursorHooksPath = homeDirectory.appendingPathComponent(".cursor/hooks.json")
self.codexConfigPath = homeDirectory.appendingPathComponent(".codex/config.toml")
self.codexHooksPath = homeDirectory.appendingPathComponent(".codex/hooks.json")
}
static func defaults(
supportDirectory: URL?,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
) -> ActivityBarHookInstallerPaths {
ActivityBarHookInstallerPaths(
homeDirectory: homeDirectory,
hookScriptsDirectory: supportDirectory?.appendingPathComponent("hooks")
)
}
}
enum ActivityBarHookInstallerError: LocalizedError, Equatable {
case invalidJSON(URL)
var errorDescription: String? {
localizedDescription(localization: PluginLocalization(bundle: .main))
}
func localizedDescription(localization: PluginLocalization) -> String {
switch self {
case let .invalidJSON(url):
return localization.format("error.hook.invalidJSON", defaultValue: "无法解析配置文件:%@", url.path)
}
}
}
struct ActivityBarHookInstaller {
private enum FileName {
static let claude = "mactools-activity-claude-hook.sh"
static let cursor = "mactools-activity-cursor-hook.sh"
static let codex = "mactools-activity-codex-hook.sh"
}
private let paths: ActivityBarHookInstallerPaths
private let socketPath: String
private let fileManager: FileManager
init(
paths: ActivityBarHookInstallerPaths,
socketPath: String = ActivityBarConstants.defaultSocketPath,
fileManager: FileManager = .default
) {
self.paths = paths
self.socketPath = socketPath
self.fileManager = fileManager
}
func install() throws -> ActivityBarHookInstallSummary {
try installScript(fileName: FileName.claude, content: claudeHookScript)
try installScript(fileName: FileName.cursor, content: cursorHookScript)
try installScript(fileName: FileName.codex, content: codexHookScript)
try registerClaudeHooks()
try registerCursorHooks()
try enableCodexHooksFeature()
try registerCodexHooks()
return ActivityBarHookInstallSummary(
scriptDirectory: paths.hookScriptsDirectory,
installedTools: ["Claude Code", "Cursor", "Codex"]
)
}
func uninstall() throws -> ActivityBarHookUninstallSummary {
try unregisterClaudeHooks()
try unregisterCursorHooks()
try unregisterCodexHooks()
try removeInstalledScripts()
return ActivityBarHookUninstallSummary(
scriptDirectory: paths.hookScriptsDirectory,
removedTools: ["Claude Code", "Cursor", "Codex"]
)
}
private var claudeScriptURL: URL {
paths.hookScriptsDirectory.appendingPathComponent(FileName.claude)
}
private var cursorScriptURL: URL {
paths.hookScriptsDirectory.appendingPathComponent(FileName.cursor)
}
private var codexScriptURL: URL {
paths.hookScriptsDirectory.appendingPathComponent(FileName.codex)
}
private func installScript(fileName: String, content: String) throws {
try fileManager.createDirectory(
at: paths.hookScriptsDirectory,
withIntermediateDirectories: true
)
let scriptURL = paths.hookScriptsDirectory.appendingPathComponent(fileName)
let existingContent = try? String(contentsOf: scriptURL, encoding: .utf8)
if existingContent != content {
try content.write(to: scriptURL, atomically: true, encoding: .utf8)
}
try fileManager.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: scriptURL.path
)
}
private func registerClaudeHooks() throws {
let events = [
"PreToolUse", "PostToolUse", "UserPromptSubmit",
"Stop", "SubagentStop", "PreCompact",
"SessionStart", "SessionEnd", "PermissionRequest",
]
var settings = try readJSONObject(at: paths.claudeSettingsPath)
var hooks = settings["hooks"] as? [String: Any] ?? [:]
let command: [String: Any] = [
"type": "command",
"command": shellQuoted(claudeScriptURL.path),
"timeout": 5000,
]
var needsWrite = false
for event in events {
var groups = hooks[event] as? [[String: Any]] ?? []
let alreadyRegistered = groups.contains { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else {
return false
}
return groupHooks.contains { hook in
(hook["command"] as? String)?.contains(FileName.claude) == true
}
}
if !alreadyRegistered {
groups.append([
"matcher": "",
"hooks": [command],
])
hooks[event] = groups
needsWrite = true
}
}
guard needsWrite else {
return
}
settings["hooks"] = hooks
try writeJSONObject(settings, to: paths.claudeSettingsPath)
}
private func unregisterClaudeHooks() throws {
try unregisterGroupedHooks(at: paths.claudeSettingsPath, scriptFileName: FileName.claude)
}
private func registerCursorHooks() throws {
let events = [
"beforeSubmitPrompt", "stop", "afterFileEdit",
"beforeReadFile", "beforeShellExecution", "beforeMCPExecution",
]
var root = try readJSONObject(at: paths.cursorHooksPath)
if root["version"] == nil {
root["version"] = 1
}
var hooks = root["hooks"] as? [String: Any] ?? [:]
var needsWrite = false
for event in events {
var entries = hooks[event] as? [[String: Any]] ?? []
let alreadyRegistered = entries.contains { entry in
(entry["command"] as? String)?.contains(FileName.cursor) == true
}
if !alreadyRegistered {
entries.append([
"command": "\(shellQuoted(cursorScriptURL.path)) \(event)",
])
hooks[event] = entries
needsWrite = true
}
}
guard needsWrite else {
return
}
root["hooks"] = hooks
try writeJSONObject(root, to: paths.cursorHooksPath)
}
private func unregisterCursorHooks() throws {
guard fileManager.fileExists(atPath: paths.cursorHooksPath.path) else {
return
}
var root = try readJSONObject(at: paths.cursorHooksPath)
guard var hooks = root["hooks"] as? [String: Any] else {
return
}
var needsWrite = false
for event in Array(hooks.keys) {
guard let entries = hooks[event] as? [[String: Any]] else {
continue
}
let filteredEntries = entries.filter { entry in
!containsHookCommand(entry["command"], scriptFileName: FileName.cursor)
}
guard filteredEntries.count != entries.count else {
continue
}
if filteredEntries.isEmpty {
hooks.removeValue(forKey: event)
} else {
hooks[event] = filteredEntries
}
needsWrite = true
}
guard needsWrite else {
return
}
if hooks.isEmpty {
root.removeValue(forKey: "hooks")
} else {
root["hooks"] = hooks
}
try writeJSONObject(root, to: paths.cursorHooksPath)
}
private func enableCodexHooksFeature() throws {
try createParentDirectory(for: paths.codexConfigPath)
var config = ""
if fileManager.fileExists(atPath: paths.codexConfigPath.path) {
config = try String(contentsOf: paths.codexConfigPath, encoding: .utf8)
}
let lines = config.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
var updatedLines = lines
var foundCodexHooks = false
for index in updatedLines.indices {
if updatedLines[index].trimmingCharacters(in: .whitespaces).hasPrefix("codex_hooks") {
updatedLines[index] = "codex_hooks = true"
foundCodexHooks = true
}
}
if !foundCodexHooks {
if let featuresIndex = updatedLines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
updatedLines.insert("codex_hooks = true", at: featuresIndex + 1)
} else {
if !updatedLines.isEmpty, updatedLines.last != "" {
updatedLines.append("")
}
updatedLines.append("[features]")
updatedLines.append("codex_hooks = true")
}
}
var updated = updatedLines.joined(separator: "\n")
if !updated.hasSuffix("\n") {
updated += "\n"
}
if updated != config {
try updated.write(to: paths.codexConfigPath, atomically: true, encoding: .utf8)
}
}
private func registerCodexHooks() throws {
let events = [
"PreToolUse", "PostToolUse", "UserPromptSubmit",
"Stop", "SessionStart", "SessionEnd",
]
var root = try readJSONObject(at: paths.codexHooksPath)
var hooks = root["hooks"] as? [String: Any] ?? [:]
let command: [String: Any] = [
"type": "command",
"command": shellQuoted(codexScriptURL.path),
"timeout": 5000,
]
var needsWrite = false
for event in events {
var groups = hooks[event] as? [[String: Any]] ?? []
let alreadyRegistered = groups.contains { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else {
return false
}
return groupHooks.contains { hook in
(hook["command"] as? String)?.contains(FileName.codex) == true
}
}
if !alreadyRegistered {
groups.append([
"matcher": "",
"hooks": [command],
])
hooks[event] = groups
needsWrite = true
}
}
guard needsWrite else {
return
}
root["hooks"] = hooks
try writeJSONObject(root, to: paths.codexHooksPath)
}
private func unregisterCodexHooks() throws {
try unregisterGroupedHooks(at: paths.codexHooksPath, scriptFileName: FileName.codex)
}
private func unregisterGroupedHooks(at url: URL, scriptFileName: String) throws {
guard fileManager.fileExists(atPath: url.path) else {
return
}
var root = try readJSONObject(at: url)
guard var hooks = root["hooks"] as? [String: Any] else {
return
}
var needsWrite = false
for event in Array(hooks.keys) {
guard let groups = hooks[event] as? [[String: Any]] else {
continue
}
var updatedGroups: [[String: Any]] = []
for group in groups {
guard let groupHooks = group["hooks"] as? [[String: Any]] else {
updatedGroups.append(group)
continue
}
let filteredHooks = groupHooks.filter { hook in
!containsHookCommand(hook["command"], scriptFileName: scriptFileName)
}
if filteredHooks.count != groupHooks.count {
needsWrite = true
}
guard !filteredHooks.isEmpty else {
continue
}
var updatedGroup = group
updatedGroup["hooks"] = filteredHooks
updatedGroups.append(updatedGroup)
}
if updatedGroups.isEmpty {
hooks.removeValue(forKey: event)
needsWrite = true
} else {
hooks[event] = updatedGroups
}
}
guard needsWrite else {
return
}
if hooks.isEmpty {
root.removeValue(forKey: "hooks")
} else {
root["hooks"] = hooks
}
try writeJSONObject(root, to: url)
}
private func removeInstalledScripts() throws {
for scriptURL in [claudeScriptURL, cursorScriptURL, codexScriptURL] {
guard fileManager.fileExists(atPath: scriptURL.path) else {
continue
}
try fileManager.removeItem(at: scriptURL)
}
guard
fileManager.fileExists(atPath: paths.hookScriptsDirectory.path),
(try? fileManager.contentsOfDirectory(atPath: paths.hookScriptsDirectory.path).isEmpty) == true
else {
return
}
try? fileManager.removeItem(at: paths.hookScriptsDirectory)
}
private func readJSONObject(at url: URL) throws -> [String: Any] {
guard fileManager.fileExists(atPath: url.path) else {
return [:]
}
let data = try Data(contentsOf: url)
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ActivityBarHookInstallerError.invalidJSON(url)
}
return object
}
private func writeJSONObject(_ object: [String: Any], to url: URL) throws {
try createParentDirectory(for: url)
let data = try JSONSerialization.data(
withJSONObject: object,
options: [.prettyPrinted, .sortedKeys]
)
try data.write(to: url, options: .atomic)
}
private func createParentDirectory(for url: URL) throws {
let parent = url.deletingLastPathComponent()
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
}
private func shellQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private func containsHookCommand(_ value: Any?, scriptFileName: String) -> Bool {
(value as? String)?.contains(scriptFileName) == true
}
}
private extension ActivityBarHookInstaller {
var claudeHookScript: String {
"""
#!/bin/bash
# MacTools Activity Bar hook for Claude Code.
SOCKET_PATH="\(socketPath)"
[ -S "$SOCKET_PATH" ] || exit 0
IS_INTERACTIVE=true
for CHECK_PID in $PPID $(ps -o ppid= -p $PPID 2>/dev/null | tr -d ' '); do
if ps -o args= -p "$CHECK_PID" 2>/dev/null | grep -qE '(^| )(-p|--print)( |$)'; then
IS_INTERACTIVE=false
break
fi
done
export MACTOOLS_ACTIVITY_INTERACTIVE=$IS_INTERACTIVE
/usr/bin/python3 -c '
import json
import os
import socket
import sys
socket_path = sys.argv[1]
try:
input_data = json.load(sys.stdin)
except Exception:
sys.exit(0)
hook_event = input_data.get("hook_event_name", "")
status_map = {
"UserPromptSubmit": "processing",
"PreCompact": "compacting",
"SessionStart": "waiting_for_input",
"SessionEnd": "ended",
"PreToolUse": "running_tool",
"PostToolUse": "processing",
"PermissionRequest": "waiting_for_input",
"Stop": "waiting_for_input",
"SubagentStop": "waiting_for_input",
}
output = {
"session_id": input_data.get("session_id", ""),
"cwd": input_data.get("cwd", ""),
"event": hook_event,
"status": input_data.get("status", status_map.get(hook_event, "unknown")),
"interactive": os.environ.get("MACTOOLS_ACTIVITY_INTERACTIVE", "true") == "true",
}
if hook_event == "UserPromptSubmit" and input_data.get("prompt"):
output["user_prompt"] = input_data.get("prompt")
if input_data.get("tool_name"):
output["tool"] = input_data.get("tool_name")
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(socket_path)
sock.sendall(json.dumps(output).encode())
sock.close()
except Exception:
pass
' "$SOCKET_PATH"
"""
}
var cursorHookScript: String {
"""
#!/bin/bash
# MacTools Activity Bar hook for Cursor.
SOCKET_PATH="\(socketPath)"
[ -S "$SOCKET_PATH" ] || { cat > /dev/null 2>&1; exit 0; }
EVENT_TYPE="$1"
SESSION_ID="cursor-$(echo "${PWD}-$(date +%Y-%m-%d)" | shasum | cut -c1-12)"
/usr/bin/python3 -c '
import json
import os
import socket
import sys
socket_path = sys.argv[1]
event_type = sys.argv[2] if len(sys.argv) > 2 else ""
session_id = sys.argv[3] if len(sys.argv) > 3 else "cursor"
event_map = {
"beforeSubmitPrompt": "UserPromptSubmit",
"afterFileEdit": "PostToolUse",
"stop": "Stop",
"beforeReadFile": "PreToolUse",
"beforeShellExecution": "PreToolUse",
"beforeMCPExecution": "PreToolUse",
}
mapped_event = event_map.get(event_type)
if not mapped_event:
sys.exit(0)
status_map = {
"UserPromptSubmit": "processing",
"PostToolUse": "processing",
"Stop": "waiting_for_input",
"PreToolUse": "running_tool",
}
output = {
"session_id": session_id,
"cwd": os.getcwd(),
"event": mapped_event,
"status": status_map.get(mapped_event, "unknown"),
"interactive": True,
}
try:
input_data = json.load(sys.stdin)
if isinstance(input_data, dict):
for key in ("prompt", "message", "content", "text", "query"):
if isinstance(input_data.get(key), str):
output["user_prompt"] = input_data[key]
break
if input_data.get("filePath"):
output["tool"] = "Read"
if input_data.get("command"):
output["tool"] = "Bash"
except Exception:
pass
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(socket_path)
sock.sendall(json.dumps(output).encode())
sock.close()
except Exception:
pass
' "$SOCKET_PATH" "$EVENT_TYPE" "$SESSION_ID"
"""
}
var codexHookScript: String {
"""
#!/bin/bash
# MacTools Activity Bar hook for Codex.
SOCKET_PATH="\(socketPath)"
[ -S "$SOCKET_PATH" ] || exit 0
/usr/bin/python3 -c '
import json
import socket
import sys
socket_path = sys.argv[1]
try:
input_data = json.load(sys.stdin)
except Exception:
sys.exit(0)
hook_event = input_data.get("hook_event_name", "")
session_id = input_data.get("session_id", "")
if session_id and not session_id.startswith("codex-"):
session_id = "codex-" + session_id
status_map = {
"UserPromptSubmit": "processing",
"SessionStart": "waiting_for_input",
"SessionEnd": "ended",
"PreToolUse": "running_tool",
"PostToolUse": "processing",
"Stop": "waiting_for_input",
}
output = {
"session_id": session_id,
"cwd": input_data.get("cwd", ""),
"event": hook_event,
"status": status_map.get(hook_event, "unknown"),
"interactive": True,
}
if hook_event == "UserPromptSubmit" and input_data.get("prompt"):
output["user_prompt"] = input_data.get("prompt")
if input_data.get("tool_name"):
output["tool"] = input_data.get("tool_name")
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(socket_path)
sock.sendall(json.dumps(output).encode())
sock.close()
except Exception:
pass
' "$SOCKET_PATH"
"""
}
}