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
35 changes: 32 additions & 3 deletions Sources/CodeIsland/RemoteInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ private struct RemoteCommandResult: Sendable {
}

enum RemoteInstaller {
private static let remoteHookVersion = "0.1.2"
private static let remoteHookVersion = "0.1.3"
private static let remoteOpencodePluginVersion = "v2"

static func installAll(host: RemoteHost, remoteSocketPath: String) async -> RemoteInstallResult {
Expand All @@ -40,7 +40,7 @@ enum RemoteInstaller {
return RemoteInstallResult(ok: false, message: "Install failed: \(configure.stderrSummary)")
}

let summary = configure.stdoutSummary.isEmpty ? "Claude/Codex/CodeBuddy/Traecli/OpenCode remote hooks installed" : configure.stdoutSummary
let summary = configure.stdoutSummary.isEmpty ? "Claude/Qoder/Codex/CodeBuddy/Traecli/OpenCode remote hooks installed" : configure.stdoutSummary
return RemoteInstallResult(ok: true, message: summary)
}

Expand Down Expand Up @@ -559,6 +559,35 @@ def install_claude():
write_json(settings_path, data)
return "Claude ok"

def install_qoder():
qoder_root = home / ".qoder"
if not qoder_root.exists() and shutil.which("qodercli") is None and shutil.which("qoder") is None:
return "Qoder skipped"

settings_path = qoder_root / "settings.json"
data = ensure_json(settings_path)
hooks = data.get("hooks") or {}
remove_our_hooks(hooks)

cmd = command_for("qoder")
without_matcher = [{"hooks": [{"type": "command", "command": cmd, "timeout": 60}]}]
with_matcher = [{"matcher": "*", "hooks": [{"type": "command", "command": cmd, "timeout": 60}]}]
with_long_timeout = [{"matcher": "*", "hooks": [{"type": "command", "command": cmd, "timeout": 86400}]}]
precompact = [
{"matcher": "auto", "hooks": [{"type": "command", "command": cmd, "timeout": 60}]},
{"matcher": "manual", "hooks": [{"type": "command", "command": cmd, "timeout": 60}]},
]
append_our_hooks(hooks, "UserPromptSubmit", without_matcher)
append_our_hooks(hooks, "PermissionRequest", with_long_timeout)
append_our_hooks(hooks, "Notification", with_matcher)
append_our_hooks(hooks, "Stop", without_matcher)
append_our_hooks(hooks, "SessionStart", without_matcher)
append_our_hooks(hooks, "SessionEnd", without_matcher)
append_our_hooks(hooks, "PreCompact", precompact)
data["hooks"] = hooks
write_json(settings_path, data)
return "Qoder ok"

# Hermes (Nous Research) is NOT a Claude Code fork. It reads shell hooks from
# ~/.hermes/config.yaml under a `hooks:` MAP keyed by snake_case event names whose
# values are lists of { command, timeout }. settings.json is never parsed (#226).
Expand Down Expand Up @@ -905,7 +934,7 @@ def install_custom():
results.append(cli["name"] + " ok")
return results

parts = [install_claude(), install_hermes(), install_codex(), install_codebuddy(), install_traecli(), install_opencode()] + install_custom()
parts = [install_claude(), install_qoder(), install_hermes(), install_codex(), install_codebuddy(), install_traecli(), install_opencode()] + install_custom()
print(" · ".join(parts))
"""
}
Expand Down
29 changes: 28 additions & 1 deletion Sources/CodeIsland/Resources/codeisland-remote-hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import subprocess
import sys

VERSION = "0.1.2"
VERSION = "0.1.3"
# Per-user socket path (#193): CodeIsland injects CODEISLAND_SOCKET_PATH via the hook
# command, but fall back to a uid-scoped path so multiple users on a shared host never
# collide on a single /tmp/codeisland.sock.
Expand Down Expand Up @@ -114,6 +114,19 @@ def _claude_jsonl_path(session_id, cwd):
return path if os.path.exists(path) else None


def _codeisland_project_dir_encoded(cwd):
return "".join("-" if ch == "/" or ch == " " or ord(ch) > 127 else ch for ch in cwd)


def _qoder_jsonl_path(session_id, cwd):
if not session_id or not cwd:
return None
home = os.path.expanduser("~")
project_dir = _codeisland_project_dir_encoded(cwd)
path = os.path.join(home, ".qoder", "projects", project_dir, f"{session_id}.jsonl")
return path if os.path.exists(path) else None


def _codebuddy_jsonl_path(session_id, cwd):
if not session_id or not cwd:
return None
Expand Down Expand Up @@ -171,6 +184,10 @@ def _scan_claude_jsonl(session_id, cwd):
return _scan_session_jsonl(_claude_jsonl_path(session_id, cwd))


def _scan_qoder_jsonl(session_id, cwd):
return _scan_session_jsonl(_qoder_jsonl_path(session_id, cwd))


def _scan_codebuddy_jsonl(session_id, cwd):
return _scan_session_jsonl(_codebuddy_jsonl_path(session_id, cwd))

Expand Down Expand Up @@ -266,6 +283,16 @@ def main():
if prompt:
payload["prompt"] = prompt

if SOURCE == "qoder":
extras = _scan_qoder_jsonl(session_id, cwd)
for key, value in extras.items():
if value and not payload.get(key):
payload[key] = value
if normalized_event == "UserPromptSubmit" and not payload.get("prompt"):
prompt = extras.get("last_user_message")
if prompt:
payload["prompt"] = prompt

if SOURCE == "codebuddy":
extras = _scan_codebuddy_jsonl(session_id, cwd)
for key, value in extras.items():
Expand Down
35 changes: 35 additions & 0 deletions Tests/CodeIslandTests/RemoteInstallerHookMergeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,39 @@ final class RemoteInstallerHookMergeTests: XCTestCase {
XCTAssertTrue(cmds.contains { $0.contains("buddy-user-hook") }, "user hook was wiped: \(cmds)")
XCTAssertTrue(cmds.contains { $0.contains("codeisland-remote-hook.py") }, "our hook missing: \(cmds)")
}

func testQoderInstallPreservesUserHooks() throws {
let userEntry: [String: Any] = [
"hooks": [["type": "command", "command": "echo qoder-user-hook", "timeout": 5]],
]
try writeJSON(["hooks": ["SessionStart": [userEntry]]], to: ".qoder/settings.json")

try runConfigureScript()

let settings = try readJSON(".qoder/settings.json")
let hooks = try XCTUnwrap(settings["hooks"] as? [String: Any])
let sessionStart = try XCTUnwrap(hooks["SessionStart"] as? [[String: Any]])
let cmds = commands(in: sessionStart)
XCTAssertTrue(cmds.contains { $0.contains("qoder-user-hook") }, "user hook was wiped: \(cmds)")
XCTAssertTrue(cmds.contains { $0.contains("CODEISLAND_SOURCE=qoder") }, "Qoder source missing: \(cmds)")
XCTAssertTrue(cmds.contains { $0.contains("codeisland-remote-hook.py") }, "our hook missing: \(cmds)")
}

func testQoderInstallIsIdempotentAcrossReconnects() throws {
let userEntry: [String: Any] = [
"hooks": [["type": "command", "command": "echo keep-qoder", "timeout": 5]],
]
try writeJSON(["hooks": ["Stop": [userEntry]]], to: ".qoder/settings.json")

try runConfigureScript()
try runConfigureScript()
try runConfigureScript()

let settings = try readJSON(".qoder/settings.json")
let hooks = try XCTUnwrap(settings["hooks"] as? [String: Any])
let stop = try XCTUnwrap(hooks["Stop"] as? [[String: Any]])
let cmds = commands(in: stop)
XCTAssertEqual(cmds.filter { $0.contains("keep-qoder") }.count, 1, "user hook duplicated or lost: \(cmds)")
XCTAssertEqual(cmds.filter { $0.contains("CODEISLAND_SOURCE=qoder") }.count, 1, "our hook not deduped: \(cmds)")
}
}