From d243905c4dd55921a9b180d3d9310266f1998667 Mon Sep 17 00:00:00 2001 From: anlang Date: Thu, 16 Jul 2026 21:52:45 +0800 Subject: [PATCH] fix(trae): support Trae CLI Next hooks --- Sources/CodeIsland/ConfigInstaller.swift | 40 +++++++- .../ConfigInstallerTests.swift | 97 +++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 1470b67e..dbcde376 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -116,6 +116,8 @@ struct CLIConfig { var rootOverride: (@Sendable () -> String)? = nil /// Optional override for the user-visible config path (e.g. "$CODEX_HOME/hooks.json"). var displayPathOverride: (@Sendable () -> String)? = nil + /// Optional override for the `--source` value passed to the bridge. + var bridgeSourceOverride: String? = nil var fullPath: String { if let override = rootOverride { @@ -294,6 +296,14 @@ struct ConfigInstaller { format: .traecli, events: defaultEvents(for: .traecli) ), + // Trae CLI Next — hooks.json moved under ~/.trae/cli and uses TraeX event names. + CLIConfig( + name: "Trae CLI Next", source: "traecli-next", + configPath: ".trae/cli/hooks.json", configKey: "hooks", + format: .nested, + events: traecliNextEvents(), + bridgeSourceOverride: "traecli" + ), // Qoder — Claude Code fork CLIConfig( name: "Qoder", source: "qoder", @@ -621,6 +631,24 @@ struct ConfigInstaller { } } + private static func traecliNextEvents() -> [(String, Int, Bool)] { + [ + ("SessionStart", 5, false), + ("SessionEnd", 5, true), + ("UserPromptSubmit", 5, true), + ("PreToolUse", 5, false), + ("PostToolUse", 5, true), + ("PostToolUseFailure", 5, true), + ("PermissionRequest", 86400, false), + ("Notification", 86400, false), + ("SubagentStart", 5, true), + ("SubagentStop", 5, true), + ("Stop", 5, true), + ("PreCompact", 5, true), + ("PostCompact", 5, true), + ] + } + static func customCLIConfigs() -> [CustomCLIConfig] { guard let data = UserDefaults.standard.data(forKey: customCLIConfigsKey), let items = try? JSONDecoder().decode([CustomCLIConfig].self, from: data) else { @@ -1309,9 +1337,15 @@ struct ConfigInstaller { let root = parseJSONFile(at: cli.fullPath, fm: fm) ?? [:] var hooks = root[cli.configKey] as? [String: Any] ?? [:] + if cli.source == "traecli-next" { + // Clean up CodeIsland-managed entries written with the old Trae IDE + // event names (for example beforeReadFile) at the new Trae CLI path. + hooks = removeManagedHookEntries(from: hooks) + } // Quote the path in case home directory contains spaces or special characters let quotedBridge = bridgeCommand.contains(" ") ? "\"\(bridgeCommand)\"" : bridgeCommand - let baseCommand = "\(quotedBridge) --source \(cli.source)" + let bridgeSource = cli.bridgeSourceOverride ?? cli.source + let baseCommand = "\(quotedBridge) --source \(bridgeSource)" for (event, timeout, _) in cli.events { var eventEntries = hooks[event] as? [[String: Any]] ?? [] @@ -1325,7 +1359,9 @@ struct ConfigInstaller { // — otherwise long-running PermissionRequest hooks hang the agent (#103). entry = ["matcher": "*", "hooks": [["type": "command", "command": baseCommand, "timeout": timeout] as [String: Any]]] case .nested: - let cmd = cli.source == "gemini" ? "\(baseCommand) --event \(event)" : baseCommand + let cmd = (cli.source == "gemini" || cli.source == "traecli-next") + ? "\(baseCommand) --event \(event)" + : baseCommand entry = ["hooks": [["type": "command", "command": cmd, "timeout": timeout] as [String: Any]]] case .flat: entry = ["command": "\(baseCommand) --event \(event)"] diff --git a/Tests/CodeIslandTests/ConfigInstallerTests.swift b/Tests/CodeIslandTests/ConfigInstallerTests.swift index a4e26cb7..b16274b4 100644 --- a/Tests/CodeIslandTests/ConfigInstallerTests.swift +++ b/Tests/CodeIslandTests/ConfigInstallerTests.swift @@ -159,6 +159,103 @@ final class ConfigInstallerTests: XCTestCase { XCTAssertTrue(command.contains("--event beforeSubmitPrompt")) } + func testBuiltInTraeKeepsLegacyIDEHooksPath() throws { + let cli = try XCTUnwrap(ConfigInstaller.allCLIs.first { $0.source == "trae" }) + + XCTAssertEqual(cli.configPath, ".trae/hooks.json") + XCTAssertEqual(cli.format.storageValue, HookFormat.traeIDE.storageValue) + XCTAssertTrue(cli.events.contains { $0.0 == "beforeReadFile" }) + } + + func testTraeCLINextUsesTraeXHooksSchema() throws { + let cli = try XCTUnwrap(ConfigInstaller.allCLIs.first { $0.source == "traecli-next" }) + + XCTAssertEqual(cli.configPath, ".trae/cli/hooks.json") + XCTAssertEqual(cli.format.storageValue, HookFormat.nested.storageValue) + XCTAssertEqual(cli.bridgeSourceOverride, "traecli") + + let eventNames = cli.events.map { $0.0 } + XCTAssertTrue(eventNames.contains("PreToolUse")) + XCTAssertTrue(eventNames.contains("PermissionRequest")) + XCTAssertTrue(eventNames.contains("PostToolUseFailure")) + XCTAssertFalse(eventNames.contains("beforeReadFile")) + } + + func testTraeCLINextInstallUsesSupportedEventsAndCleansLegacyIDEEvents() throws { + let fm = FileManager.default + let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let configDir = tempDir.appendingPathComponent(".trae/cli") + try fm.createDirectory(at: configDir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: tempDir) } + + let configPath = configDir.appendingPathComponent("hooks.json").path + let legacy = """ + { + "hooks": { + "beforeReadFile": [ + { + "matcher": "*", + "loop_limit": 5, + "hooks": [ + { + "type": "command", + "command": "\(NSHomeDirectory())/.codeisland/codeisland-bridge --source trae --event beforeReadFile", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/user-stop", + "timeout": 5 + } + ] + } + ] + } + } + """ + try legacy.write(toFile: configPath, atomically: true, encoding: .utf8) + + let cli = CLIConfig( + name: "Trae CLI Next", + source: "traecli-next", + configPath: configPath, + configKey: "hooks", + format: .nested, + events: [ + ("PreToolUse", 5, false), + ("PermissionRequest", 86400, false), + ("Stop", 5, false), + ], + bridgeSourceOverride: "traecli" + ) + + XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: fm)) + + let data = try XCTUnwrap(fm.contents(atPath: configPath)) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let hooks = try XCTUnwrap(root["hooks"] as? [String: Any]) + XCTAssertNil(hooks["beforeReadFile"]) + XCTAssertNotNil(hooks["PreToolUse"]) + XCTAssertNotNil(hooks["PermissionRequest"]) + + let preToolUse = try XCTUnwrap(hooks["PreToolUse"] as? [[String: Any]]) + let hookList = try XCTUnwrap(preToolUse.first?["hooks"] as? [[String: Any]]) + let command = try XCTUnwrap(hookList.first?["command"] as? String) + XCTAssertTrue(command.contains("codeisland-bridge --source traecli")) + XCTAssertTrue(command.contains("--event PreToolUse")) + + let stopEntries = try XCTUnwrap(hooks["Stop"] as? [[String: Any]]) + XCTAssertTrue(stopEntries.contains { + (($0["hooks"] as? [[String: Any]])?.first?["command"] as? String) == "/usr/local/bin/user-stop" + }) + } + func testTraeIDEInstallMigratesOldFlatCodeIslandEntry() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)