Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions Sources/CodeIsland/ConfigInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]] ?? []
Expand All @@ -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)"]
Expand Down
97 changes: 97 additions & 0 deletions Tests/CodeIslandTests/ConfigInstallerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down