651 lines
21 KiB
Swift
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"
|
|
"""
|
|
}
|
|
}
|