From ebf53de765d0ba5e902f2b82a526743d35fed453 Mon Sep 17 00:00:00 2001 From: riChar Date: Mon, 20 Jul 2026 17:36:45 +0800 Subject: [PATCH] feat(remote): add Qoder to remote hook installer --- Sources/CodeIsland/RemoteInstaller.swift | 35 +++++++++++++++++-- .../Resources/codeisland-remote-hook.py | 29 ++++++++++++++- .../RemoteInstallerHookMergeTests.swift | 35 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Sources/CodeIsland/RemoteInstaller.swift b/Sources/CodeIsland/RemoteInstaller.swift index 0bbfb0f2..ded47a69 100644 --- a/Sources/CodeIsland/RemoteInstaller.swift +++ b/Sources/CodeIsland/RemoteInstaller.swift @@ -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 { @@ -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) } @@ -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). @@ -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)) """ } diff --git a/Sources/CodeIsland/Resources/codeisland-remote-hook.py b/Sources/CodeIsland/Resources/codeisland-remote-hook.py index a638d747..fbd12c31 100644 --- a/Sources/CodeIsland/Resources/codeisland-remote-hook.py +++ b/Sources/CodeIsland/Resources/codeisland-remote-hook.py @@ -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. @@ -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 @@ -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)) @@ -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(): diff --git a/Tests/CodeIslandTests/RemoteInstallerHookMergeTests.swift b/Tests/CodeIslandTests/RemoteInstallerHookMergeTests.swift index 8832af08..39078677 100644 --- a/Tests/CodeIslandTests/RemoteInstallerHookMergeTests.swift +++ b/Tests/CodeIslandTests/RemoteInstallerHookMergeTests.swift @@ -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)") + } }