diff --git a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift index 6be3dd0e8..3593f0490 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift @@ -17,6 +17,7 @@ nonisolated enum AgentIntegrationFactory { case .copilot: copilot(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .grok: grok(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .hermes: hermes(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + case .jcode: jcode(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .kimi: kimi(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .kiro: kiro(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .omp: omp(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) @@ -101,6 +102,22 @@ nonisolated enum AgentIntegrationFactory { ] } + private static func jcode(homeDirectoryURL: URL, fileManager: FileManager) + -> [AgentIntegration.Component] + { + let installer = JcodeSettingsInstaller( + homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + return [ + AgentIntegration.Component( + kind: .hooks, + state: { try installer.installState() }, + install: { try installer.installAllHooks() }, + uninstall: { try installer.uninstallAllHooks() } + ), + skillsComponent(agent: .jcode, homeDirectoryURL: homeDirectoryURL), + ] + } + private static func kimi(homeDirectoryURL: URL, fileManager: FileManager) -> [AgentIntegration.Component] { diff --git a/SupacodeSettingsShared/BusinessLogic/JcodeHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/JcodeHookSettings.swift new file mode 100644 index 000000000..1cf3e3b80 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/JcodeHookSettings.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Canonical jcode hook definition: the Supacode-managed entries in jcode's +/// `[hooks]` table (`~/.jcode/config.toml`), plus the body of the presence-hook +/// wrapper they point at (`~/.jcode/hooks/supacode-presence.sh`). +/// +/// jcode differs from Kimi in one way that shapes this whole module: jcode +/// executes a hook command directly — the line is only split shell-style, never +/// run through a shell — so Supacode's presence one-liner, which needs `printf` / +/// `ps` / `case` / `&&`, cannot be inlined into the TOML the way `KimiHookSettings` +/// does. Instead each `[hooks]` entry points at an executable wrapper the +/// installer writes, and the wrapper carries the presence logic. The wrapper is +/// composed from the shared `AgentPresenceOSC` snippets, so the `OSC 3008` +/// presence signal it emits is byte-identical to every other harness and the +/// parse side cannot drift. +nonisolated enum JcodeHookSettings { + /// The lifecycle events Supacode hooks: session_start / turn_start / turn_end / + /// session_end. `pre_tool` / `post_tool` are left to the user (a `pre_tool` + /// hook is a blocking gate, not a presence signal). Each maps to an + /// `AgentPresenceOSC` event inside the wrapper's dispatch: + /// `session_start`→sessionStart, `turn_start`→busy, `turn_end`→idle/error, + /// `session_end`→sessionEnd+idle. + static let hookedEvents = ["session_start", "turn_start", "turn_end", "session_end"] + + /// Path components of the wrapper under the jcode config directory (`~/.jcode`). + static let wrapperDirectoryName = "hooks" + static let wrapperFileName = "supacode-presence.sh" + + /// Absolute URL of the wrapper for a given home directory: + /// `~/.jcode/hooks/supacode-presence.sh`. + static func wrapperURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appending(path: SkillAgent.jcode.configDirectoryName, directoryHint: .isDirectory) + .appending(path: wrapperDirectoryName, directoryHint: .isDirectory) + .appending(path: wrapperFileName, directoryHint: .notDirectory) + } + + /// The presence-hook wrapper's body. It begins with `#!/bin/sh` — jcode runs + /// the hook with no shell, so the wrapper must supply its own — carries the + /// ownership marker, and is inert outside a Supacode surface (the + /// `SUPACODE_SURFACE_ID` guard). It resolves the pane tty and dispatches on + /// `$JCODE_HOOK_EVENT`, reusing `AgentPresenceOSC` verbatim so the wire format + /// matches every other harness. + /// + /// Note: the per-pane session binding (`session=$JCODE_HOOK_SESSION_ID`) is + /// intentionally omitted until `AgentPresenceOSC` carries a matching `session` + /// field; add it to this wrapper in the same change so the field only appears + /// once a consumer can read it. + static func wrapperScript() -> String { + let tty = AgentPresenceOSC.ttyResolveSnippet + let sessionStart = AgentPresenceOSC.emitShell(event: .sessionStart, agent: .jcode) + let busy = AgentPresenceOSC.emitShell(event: .busy, agent: .jcode) + let idle = AgentPresenceOSC.emitShell(event: .idle, agent: .jcode) + let error = AgentPresenceOSC.emitShell(event: .error, agent: .jcode) + let sessionEnd = AgentPresenceOSC.emitShell(event: .sessionEnd, agent: .jcode) + let errorNotify = AgentPresenceOSC.emitFixedNotifyShell( + agent: .jcode, + title: AgentHookSettingsCommand.errorNotifyTitle, + body: AgentHookSettingsCommand.errorNotifyBody, + ) + return """ + #!/bin/sh + \(AgentHookSettingsCommand.ownershipMarker) + # Managed by Supacode: emits OSC 3008 agent-presence for jcode panes. jcode + # exec's hooks directly (no shell), so this wrapper carries the presence + # snippet the other harnesses inline. Safe to delete — Supacode reinstalls it. + # Do not edit by hand; changes are overwritten on the next install. + [ -n "${SUPACODE_SURFACE_ID:-}" ] || exit 0 + \(tty) + case "${JCODE_HOOK_EVENT:-}" in + session_start) \(sessionStart) ;; + turn_start) \(busy) ;; + turn_end) + if [ "${JCODE_HOOK_STATUS:-}" = error ]; then + \(error); \(errorNotify) + else + \(idle) + fi + ;; + session_end) \(sessionEnd); \(idle) ;; + esac + exit 0 + """ + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/JcodeSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/JcodeSettingsInstaller.swift new file mode 100644 index 000000000..1e6c2f87f --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/JcodeSettingsInstaller.swift @@ -0,0 +1,397 @@ +import Foundation + +private nonisolated let jcodeInstallerLogger = SupaLogger("Settings") + +/// Installs and removes jcode's presence hooks. Owns two on-disk artifacts: +/// +/// 1. The Supacode-managed entries in jcode's `[hooks]` table (in +/// `~/.jcode/config.toml`) — one per lifecycle event, each invoking the +/// presence-hook wrapper below. +/// 2. The wrapper itself, `~/.jcode/hooks/supacode-presence.sh`, written as a +/// whole-file replacement and made executable via `chmod 0755` (as in +/// `CopilotHooksInstaller`). +/// +/// Like the Kimi installers, this is a structured read-modify-write of a +/// `config.toml` keyed on ownership, but jcode uses a `[hooks]` *table* +/// (`event = command`) rather than Kimi's `[[hooks]]` array-of-tables, and each +/// entry points at the wrapper because jcode exec's hooks directly (see +/// `JcodeHookSettings`). jcode activates hooks from its config alone, so there is +/// no version probe and no feature flag to gate on. +/// +/// Ownership of `[hooks]` entries is keyed on the wrapper path: an entry — or one +/// element of an array value — equal to the wrapper path is Supacode's. Install is +/// an idempotent prune-and-replace of only those entries, so a user's own hook on +/// the same event is preserved by merging it into an array; uninstall removes only +/// Supacode's entries and wrapper, leaving any user value intact. +/// +/// Note: value scanning is single-line (a TOML string or inline array). A +/// hooked-event value split across multiple lines (a rare multi-line array) is +/// left untouched rather than rewritten; this can be revisited as a follow-up if +/// a user hits it. +nonisolated struct JcodeSettingsInstaller { + let homeDirectoryURL: URL + let fileManager: FileManager + let logWarning: @Sendable (String) -> Void + + init( + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default, + logWarning: @escaping @Sendable (String) -> Void = { jcodeInstallerLogger.warning($0) }, + ) { + self.homeDirectoryURL = homeDirectoryURL + self.fileManager = fileManager + self.logWarning = logWarning + } + + // MARK: - Install state. + + /// Reports `.installed` only when both artifacts are current: every lifecycle + /// event references the wrapper AND the wrapper file matches its canonical body. + /// A partial or stale install is `.outdated` (so auto-update can repair it); + /// nothing present at all is `.notInstalled`. + func installState() throws -> ComponentInstallState { + let wrapperPath = wrapperURL.path(percentEncoded: false) + let managed: Set + do { + managed = Self.managedEvents(in: try readText(at: settingsURL), wrapperPath: wrapperPath) + } catch { + logWarning("Failed to inspect jcode hook settings at \(settingsURL.path): \(error.localizedDescription)") + throw error + } + let wrapperText = try AgentFileProbe.text(at: wrapperURL) + if managed.isEmpty, wrapperText == nil { return .notInstalled } + let allEventsManaged = managed == Set(JcodeHookSettings.hookedEvents) + let wrapperCurrent = wrapperText == JcodeHookSettings.wrapperScript() + return allEventsManaged && wrapperCurrent ? .installed : .outdated + } + + // MARK: - Install / uninstall. + + func installAllHooks() throws { + let wrapperPath = wrapperURL.path(percentEncoded: false) + let text = try readText(at: settingsURL) + let updated = Self.installed( + into: text, wrapperPath: wrapperPath, events: JcodeHookSettings.hookedEvents) + try installWrapper() + try writeText(updated, to: settingsURL) + } + + func uninstallAllHooks() throws { + let wrapperPath = wrapperURL.path(percentEncoded: false) + let text = try readText(at: settingsURL) + let updated = Self.uninstalled( + from: text, wrapperPath: wrapperPath, events: JcodeHookSettings.hookedEvents) + try writeText(updated, to: settingsURL) + try removeWrapper() + } + + // MARK: - Paths. + + var settingsURL: URL { Self.settingsURL(homeDirectoryURL: homeDirectoryURL) } + + static func settingsURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appending(path: SkillAgent.jcode.configDirectoryName, directoryHint: .isDirectory) + .appending(path: "config.toml", directoryHint: .notDirectory) + } + + var wrapperURL: URL { JcodeHookSettings.wrapperURL(homeDirectoryURL: homeDirectoryURL) } + + // MARK: - Wrapper file (whole-file write, executable). + + private func installWrapper() throws { + try fileManager.createDirectory( + at: wrapperURL.deletingLastPathComponent(), withIntermediateDirectories: true) + guard let data = JcodeHookSettings.wrapperScript().data(using: .utf8) else { + throw JcodeSettingsInstallerError.invalidUTF8 + } + try data.write(to: wrapperURL, options: .atomic) + // An atomic write lands a fresh inode with umask perms; restore the executable bits. + try fileManager.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: wrapperURL.path(percentEncoded: false)) + } + + /// Removes the wrapper only when it is Supacode's (carries the ownership marker), + /// so a user file that happens to share the name is never deleted. + private func removeWrapper() throws { + guard let text = try AgentFileProbe.text(at: wrapperURL) else { return } + guard text.contains(AgentHookSettingsCommand.ownershipMarker) else { return } + try fileManager.removeItem(at: wrapperURL) + } + + // MARK: - Text I/O. + + private func readText(at url: URL) throws -> String { + guard let data = try AgentFileProbe.data(at: url) else { return "" } + guard let text = String(data: data, encoding: .utf8) else { + throw JcodeSettingsInstallerError.invalidUTF8 + } + return text.replacing("\r\n", with: "\n").replacing("\r", with: "\n") + } + + private func writeText(_ text: String, to url: URL) throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + guard let data = text.data(using: .utf8) else { + throw JcodeSettingsInstallerError.invalidUTF8 + } + try data.write(to: url, options: .atomic) + } + + // MARK: - `[hooks]` table editing (internal for unit tests). + + /// The lifecycle events whose `[hooks]` value carries `wrapperPath` — i.e. those + /// Supacode currently manages. Ownership here is deliberately path-based, not the + /// shared `AgentHookCommandOwnership` marker other harnesses key on; the + /// `# supacode-managed-hook` comment that `render` appends to managed lines is + /// human-readable only and is never consulted for detection. + static func managedEvents(in text: String, wrapperPath: String) -> Set { + let lines = text.components(separatedBy: "\n") + guard let range = hooksBodyRange(in: lines) else { return [] } + var found = Set() + for line in lines[range] { + guard + let (key, rhs) = assignment(in: line), + JcodeHookSettings.hookedEvents.contains(key) + else { continue } + if quotedStrings(in: rhs).contains(wrapperPath) { found.insert(key) } + } + return found + } + + /// Returns `text` with the wrapper present on every event in `events`. Existing + /// user values on those events are preserved by merging them into an array; any + /// prior Supacode entry is replaced. All other content is left untouched. + static func installed(into text: String, wrapperPath: String, events: [String]) -> String { + let eventSet = Set(events) + var lines = text.isEmpty ? [] : text.components(separatedBy: "\n") + + // Collect user values (everything but the wrapper) from existing lines for + // our events, and drop those lines; canonical lines are re-added below. + var userValues: [String: [String]] = [:] + if let range = hooksBodyRange(in: lines) { + var otherLines: [String] = [] + for line in lines[range] { + if let (key, rhs) = assignment(in: line), eventSet.contains(key) { + let users = quotedStrings(in: rhs).filter { $0 != wrapperPath } + userValues[key, default: []].append(contentsOf: users) + continue + } + otherLines.append(line) + } + // Canonical entries first, then any other `[hooks]` content (user keys or + // comments), blank-trimmed so re-install is byte-idempotent. + let canonical = events.map { + render(event: $0, values: (userValues[$0] ?? []) + [wrapperPath], managed: true) + } + let preservedUser = trimmedBlankEdges(otherLines) + let newBody = canonical + (preservedUser.isEmpty ? [] : [""] + preservedUser) + lines.replaceSubrange(range, with: newBody) + return normalizedTrailingNewline(lines.joined(separator: "\n")) + } + + // No `[hooks]` section yet — append one. + let canonical = events.map { render(event: $0, values: [wrapperPath], managed: true) } + var result = text + if !result.isEmpty { + if !result.hasSuffix("\n") { result.append("\n") } + if !result.hasSuffix("\n\n") { result.append("\n") } + } + result += (["[hooks]"] + canonical).joined(separator: "\n") + return normalizedTrailingNewline(result) + } + + /// Returns `text` with the wrapper removed from every event in `events`. An event + /// left with only user values is rewritten to keep them; an event left with + /// nothing (it was Supacode's alone) is dropped entirely. + static func uninstalled(from text: String, wrapperPath: String, events: [String]) -> String { + let eventSet = Set(events) + var lines = text.components(separatedBy: "\n") + guard let range = hooksBodyRange(in: lines) else { return text } + var body: [String] = [] + for line in lines[range] { + guard let (key, rhs) = assignment(in: line), eventSet.contains(key) else { + body.append(line) + continue + } + let users = quotedStrings(in: rhs).filter { $0 != wrapperPath } + if users.isEmpty { continue } // was Supacode's alone — drop the line. + body.append(render(event: key, values: users, managed: false)) + } + lines.replaceSubrange(range, with: body) + return normalizedTrailingNewline(lines.joined(separator: "\n")) + } + + // MARK: - Line/section parsing. + + /// Body-line range of the first `[hooks]` table (excluding its header), running + /// to the next section header or EOF. `nil` when there is no `[hooks]` table. + static func hooksBodyRange(in lines: [String]) -> Range? { + guard let header = lines.firstIndex(where: isHooksHeader) else { return nil } + var end = header + 1 + while end < lines.count, !isSectionHeader(lines[end]) { end += 1 } + return (header + 1).. (key: String, rhs: String)? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix("[") else { return nil } + guard let equals = trimmed.firstIndex(of: "=") else { return nil } + let key = trimmed[.. [String] { + var values: [String] = [] + let chars = Array(rhs) + var index = 0 + while index < chars.count { + switch chars[index] { + case "#": + return values // Trailing comment (we only reach here outside a string). + case "\"": + let (value, next) = scanBasicString(chars, from: index + 1) + index = next + if let value { values.append(value) } + case "'": + let (value, next) = scanLiteralString(chars, from: index + 1) + index = next + if let value { values.append(value) } + default: + index += 1 + } + } + return values + } + + /// Scans a TOML basic string starting just after the opening `"`. Returns the + /// decoded value (nil if unterminated) and the index just past the closing `"`. + private static func scanBasicString( + _ chars: [Character], from start: Int + ) -> (value: String?, next: Int) { + var index = start + var value = "" + var escaped = false + while index < chars.count { + let char = chars[index] + index += 1 + if escaped { + value.append(Self.unescapeBasic(char)) + escaped = false + } else if char == "\\" { + escaped = true + } else if char == "\"" { + return (value, index) + } else { + value.append(char) + } + } + return (nil, index) + } + + private static func unescapeBasic(_ char: Character) -> Character { + switch char { + case "n": "\n" + case "r": "\r" + case "t": "\t" + default: char + } + } + + /// Scans a TOML literal string (verbatim, no escapes) starting just after the + /// opening `'`. Returns the value (nil if unterminated) and the next index. + private static func scanLiteralString( + _ chars: [Character], from start: Int + ) -> (value: String?, next: Int) { + var index = start + var value = "" + while index < chars.count { + let char = chars[index] + index += 1 + if char == "'" { return (value, index) } + value.append(char) + } + return (nil, index) + } + + /// Renders one `[hooks]` entry: a bare string for a single value, an inline + /// array for several. Managed entries carry the ownership marker as a trailing + /// comment so a human reading the file sees who owns the line. + static func render(event: String, values: [String], managed: Bool) -> String { + let rhs = + values.count == 1 + ? tomlQuote(values[0]) + : "[\(values.map(tomlQuote).joined(separator: ", "))]" + let suffix = managed ? " \(AgentHookSettingsCommand.ownershipMarker)" : "" + return "\(event) = \(rhs)\(suffix)" + } + + private static func isHooksHeader(_ line: String) -> Bool { + line.trimmingCharacters(in: .whitespaces) + .range(of: #"^\[\s*hooks\s*\]\s*(#.*)?$"#, options: .regularExpression) != nil + } + + /// Any TOML table (`[section]`) or array-of-tables (`[[section]]`) header, which + /// ends the current section's scope. A `key = value` line is rejected by the + /// leading-`[` guard inside the regex. + private static func isSectionHeader(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("[") else { return false } + let keySegment = #"(?:[A-Za-z0-9_\-]+|"(?:[^"\\]|\\.)*"|'[^']*')"# + let pattern = #"^\[\[?\s*"# + keySegment + #"(?:\s*\.\s*"# + keySegment + #")*\s*\]\]?\s*(#.*)?$"# + return trimmed.range(of: pattern, options: .regularExpression) != nil + } + + /// Quotes a string as a TOML basic string, escaping `\`, `"`, and common + /// control characters. + private static func tomlQuote(_ value: String) -> String { + var escaped = "" + for char in value { + switch char { + case "\\": escaped.append("\\\\") + case "\"": escaped.append("\\\"") + case "\n": escaped.append("\\n") + case "\r": escaped.append("\\r") + case "\t": escaped.append("\\t") + default: escaped.append(char) + } + } + return "\"\(escaped)\"" + } + + private static func normalizedTrailingNewline(_ text: String) -> String { + var result = text + while result.hasSuffix("\n") { result.removeLast() } + return result.isEmpty ? "" : result + "\n" + } + + /// Drops leading and trailing blank lines, so preserved content re-inserts at a + /// stable position and a re-install is byte-identical. + private static func trimmedBlankEdges(_ lines: [String]) -> [String] { + var result = lines + while let first = result.first, first.trimmingCharacters(in: .whitespaces).isEmpty { + result.removeFirst() + } + while let last = result.last, last.trimmingCharacters(in: .whitespaces).isEmpty { + result.removeLast() + } + return result + } +} + +nonisolated enum JcodeSettingsInstallerError: Error, Equatable, LocalizedError { + case invalidUTF8 + + var errorDescription: String? { + switch self { + case .invalidUTF8: + "jcode's config.toml is not valid UTF-8. Fix or remove ~/.jcode/config.toml and try again." + } + } +} diff --git a/SupacodeSettingsShared/Models/SkillAgent.swift b/SupacodeSettingsShared/Models/SkillAgent.swift index 008f4d20c..3b2da8c3e 100644 --- a/SupacodeSettingsShared/Models/SkillAgent.swift +++ b/SupacodeSettingsShared/Models/SkillAgent.swift @@ -7,6 +7,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case copilot case grok case hermes + case jcode case kimi case kiro case omp @@ -25,6 +26,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .copilot: ".copilot" case .grok: ".grok" case .hermes: ".hermes" + case .jcode: ".jcode" case .kimi: ".kimi-code" case .kiro: ".kiro" case .omp: ".omp/agent" @@ -42,6 +44,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .copilot: "Copilot CLI" case .grok: "Grok Code" case .hermes: "Hermes" + case .jcode: "jcode" case .kimi: "Kimi Code" case .kiro: "Kiro CLI" case .omp: "Oh My Pi" @@ -59,6 +62,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .copilot: "copilot-mark" case .grok: "grok-mark" case .hermes: "hermes-mark" + case .jcode: "jcode-mark" case .kimi: "kimi-mark" case .kiro: "kiro-mark" case .omp: "omp-mark" diff --git a/supacode/Assets.xcassets/jcode-mark.imageset/Contents.json b/supacode/Assets.xcassets/jcode-mark.imageset/Contents.json new file mode 100644 index 000000000..c4074d0f9 --- /dev/null +++ b/supacode/Assets.xcassets/jcode-mark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "jcode-mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/supacode/Assets.xcassets/jcode-mark.imageset/jcode-mark.svg b/supacode/Assets.xcassets/jcode-mark.imageset/jcode-mark.svg new file mode 100644 index 000000000..fec18193f --- /dev/null +++ b/supacode/Assets.xcassets/jcode-mark.imageset/jcode-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/supacode/Features/Settings/Views/DeveloperSettingsView.swift b/supacode/Features/Settings/Views/DeveloperSettingsView.swift index 0554308ff..6fa45d523 100644 --- a/supacode/Features/Settings/Views/DeveloperSettingsView.swift +++ b/supacode/Features/Settings/Views/DeveloperSettingsView.swift @@ -321,6 +321,7 @@ extension SkillAgent { case .copilot: "Hooks in `~/.copilot/hooks/supacode.json` and skill in `~/.copilot/skills/`." case .grok: "Hooks in `~/.grok/hooks/supacode.json` and skill in `~/.grok/skills/`." case .hermes: "Plugin in `~/.hermes/plugins/` and skill in `~/.hermes/skills/`." + case .jcode: "Hooks in `~/.jcode/config.toml` and skill in `~/.jcode/skills/`." case .kimi: "Hooks in `~/.kimi-code/config.toml` and skill in `~/.kimi-code/skills/`. Hooks system is in Beta." case .kiro: "Hooks in `~/.kiro/agents/` and skill in `~/.kiro/skills/`." case .omp: "Extension in `~/.omp/agent/extensions/` and skill in `~/.omp/agent/skills/`." diff --git a/supacodeTests/CodingAgentsSidebarCardModeTests.swift b/supacodeTests/CodingAgentsSidebarCardModeTests.swift index 9c19f1512..92594b71f 100644 --- a/supacodeTests/CodingAgentsSidebarCardModeTests.swift +++ b/supacodeTests/CodingAgentsSidebarCardModeTests.swift @@ -13,6 +13,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -30,6 +31,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -47,6 +49,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -64,6 +67,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -83,6 +87,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -102,6 +107,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -122,6 +128,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.notInstalled), .grok: .ready(.notInstalled), .hermes: .ready(.notInstalled), + .jcode: .ready(.notInstalled), .kimi: .ready(.notInstalled), .kiro: .ready(.notInstalled), .omp: .ready(.notInstalled), @@ -141,6 +148,7 @@ struct CodingAgentsSidebarCardModeTests { .copilot: .ready(.installed), .grok: .ready(.installed), .hermes: .ready(.installed), + .jcode: .ready(.installed), .kimi: .ready(.installed), .kiro: .ready(.installed), .omp: .ready(.installed), diff --git a/supacodeTests/JcodeSettingsInstallerTests.swift b/supacodeTests/JcodeSettingsInstallerTests.swift new file mode 100644 index 000000000..f9efc0c1c --- /dev/null +++ b/supacodeTests/JcodeSettingsInstallerTests.swift @@ -0,0 +1,294 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +struct JcodeSettingsInstallerTests { + private let fileManager = FileManager.default + private let marker = AgentHookSettingsCommand.ownershipMarker + + private func makeTempHome() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("supacode-jcode-test-\(UUID().uuidString)", isDirectory: true) + } + + private func makeInstaller(home: URL) -> JcodeSettingsInstaller { + JcodeSettingsInstaller(homeDirectoryURL: home, fileManager: fileManager) + } + + private func seed(_ text: String, at url: URL) throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Fresh install. + + @Test func freshInstallWritesHooksTableAndExecutableWrapper() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + + try installer.installAllHooks() + + let config = try String(contentsOf: installer.settingsURL, encoding: .utf8) + #expect(config.contains("[hooks]")) + for event in JcodeHookSettings.hookedEvents { + #expect(config.contains("\(event) = ")) + } + let wrapperPath = installer.wrapperURL.path(percentEncoded: false) + #expect(config.contains(wrapperPath)) + #expect(config.contains(marker)) + + // Wrapper exists, is our script, and is user-executable. + let wrapper = try String(contentsOf: installer.wrapperURL, encoding: .utf8) + #expect(wrapper.hasPrefix("#!/bin/sh")) + #expect(wrapper.contains(marker)) + #expect(wrapper.contains(AgentPresenceOSC.surfaceEnvVar)) // the no-op-outside-Supacode guard + #expect(wrapper.contains("JCODE_HOOK_EVENT")) + let perms = + try fileManager.attributesOfItem( + atPath: wrapperPath)[.posixPermissions] as? NSNumber + #expect((perms?.int16Value ?? 0) & 0o111 != 0) + } + + @Test func freshStateIsNotInstalledWhenNothingPresent() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + #expect(try makeInstaller(home: home).installState() == .notInstalled) + } + + @Test func stateIsInstalledAfterInstall() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try installer.installAllHooks() + #expect(try installer.installState() == .installed) + } + + // MARK: - Canonical paths. + + @Test func settingsAndWrapperURLsResolveUnderDotJcode() { + let home = URL(fileURLWithPath: "/Users/test", isDirectory: true) + #expect( + JcodeSettingsInstaller.settingsURL(homeDirectoryURL: home).path(percentEncoded: false) + == "/Users/test/.jcode/config.toml") + #expect( + JcodeHookSettings.wrapperURL(homeDirectoryURL: home).path(percentEncoded: false) + == "/Users/test/.jcode/hooks/supacode-presence.sh") + } + + // MARK: - Preserve existing content. + + @Test func installPreservesNonHooksSections() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try seed( + """ + model = "kimi-k2" + + [providers.openai] + base_url = "https://example.com" + """, at: installer.settingsURL) + + try installer.installAllHooks() + + let config = try String(contentsOf: installer.settingsURL, encoding: .utf8) + #expect(config.contains("model = \"kimi-k2\"")) + #expect(config.contains("[providers.openai]")) + #expect(config.contains("base_url = \"https://example.com\"")) + #expect(config.contains("[hooks]")) + #expect(try installer.installState() == .installed) + } + + @Test func installMergesWithAUserHookOnTheSameEventAsAnArray() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try seed( + """ + [hooks] + turn_start = "my-own-hook" + """, at: installer.settingsURL) + + try installer.installAllHooks() + + let config = try String(contentsOf: installer.settingsURL, encoding: .utf8) + // The user's hook survives, now alongside ours in an array value. + let wrapperPath = installer.wrapperURL.path(percentEncoded: false) + let turnStartLine = try #require( + config.components(separatedBy: "\n").first { $0.hasPrefix("turn_start = ") }) + let values = JcodeSettingsInstaller.quotedStrings(in: turnStartLine) + #expect(values.contains("my-own-hook")) + #expect(values.contains(wrapperPath)) + #expect(try installer.installState() == .installed) + } + + // MARK: - Idempotency. + + @Test func reinstallIsIdempotent() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + + try installer.installAllHooks() + let first = try String(contentsOf: installer.settingsURL, encoding: .utf8) + try installer.installAllHooks() + let second = try String(contentsOf: installer.settingsURL, encoding: .utf8) + + #expect(first == second) + // Exactly one entry per hooked event (no duplicates). + for event in JcodeHookSettings.hookedEvents { + #expect(second.components(separatedBy: "\(event) = ").count - 1 == 1) + } + } + + // MARK: - Uninstall. + + @Test func uninstallRemovesManagedEntriesAndWrapperButKeepsUserHooks() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try seed( + """ + [hooks] + turn_start = "my-own-hook" + + [providers.openai] + base_url = "https://example.com" + """, at: installer.settingsURL) + + try installer.installAllHooks() + #expect(fileManager.fileExists(atPath: installer.wrapperURL.path(percentEncoded: false))) + + try installer.uninstallAllHooks() + + let config = try String(contentsOf: installer.settingsURL, encoding: .utf8) + #expect(!config.contains(marker)) + #expect(!config.contains(installer.wrapperURL.path(percentEncoded: false))) + // The user's own hook and unrelated sections survive. + #expect(config.contains("my-own-hook")) + #expect(config.contains("[providers.openai]")) + // The wrapper file is gone. + #expect(!fileManager.fileExists(atPath: installer.wrapperURL.path(percentEncoded: false))) + #expect(try installer.installState() == .notInstalled) + } + + @Test func uninstallOnMissingFilesIsNoOp() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + #expect(throws: Never.self) { try makeInstaller(home: home).uninstallAllHooks() } + } + + @Test func uninstallKeepsAnUnmarkedUserFileSharingTheWrapperName() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + // A user file living at the wrapper path but lacking the ownership marker + // must never be deleted by uninstall — only Supacode's own wrapper is removed. + let userScript = "#!/bin/sh\necho hi\n" + try seed(userScript, at: installer.wrapperURL) + + try installer.uninstallAllHooks() + + #expect(fileManager.fileExists(atPath: installer.wrapperURL.path(percentEncoded: false))) + #expect(try String(contentsOf: installer.wrapperURL, encoding: .utf8) == userScript) + } + + // MARK: - Outdated detection. + + @Test func stateIsOutdatedWhenWrapperBodyDrifts() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try installer.installAllHooks() + + // A stale wrapper (e.g. from an older Supacode) must read as outdated so + // auto-update repairs it — but only because the marker is still present. + try "#!/bin/sh\n\(marker)\n# old body\n".write( + to: installer.wrapperURL, atomically: true, encoding: .utf8) + #expect(try installer.installState() == .outdated) + } + + @Test func stateIsOutdatedWhenOnlySomeEventsPresent() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + let wrapperPath = installer.wrapperURL.path(percentEncoded: false) + // Seed just one managed event out of the full set, plus a current wrapper. + try seed( + "[hooks]\n\(JcodeSettingsInstaller.render(event: "turn_start", values: [wrapperPath], managed: true))\n", + at: installer.settingsURL) + try seed(JcodeHookSettings.wrapperScript(), at: installer.wrapperURL) + + #expect(try installer.installState() == .outdated) + } + + // MARK: - `[hooks]` value parsing. + + @Test func quotedStringsParsesStringArrayLiteralAndComment() { + #expect(JcodeSettingsInstaller.quotedStrings(in: #""a""#) == ["a"]) + #expect(JcodeSettingsInstaller.quotedStrings(in: #"["a", "b"]"#) == ["a", "b"]) + #expect(JcodeSettingsInstaller.quotedStrings(in: #"'literal'"#) == ["literal"]) + #expect( + JcodeSettingsInstaller.quotedStrings(in: #""cmd" # supacode-managed-hook"#) == ["cmd"]) + } + + @Test func renderEmitsStringForOneValueAndArrayForMany() { + #expect( + JcodeSettingsInstaller.render(event: "turn_start", values: ["w"], managed: true) + == "turn_start = \"w\" \(marker)") + #expect( + JcodeSettingsInstaller.render(event: "turn_start", values: ["a", "w"], managed: false) + == "turn_start = [\"a\", \"w\"]") + } + + // MARK: - Line-ending tolerance & corrupt files. + + @Test func crlfConfigIsRecognizedAndNotDuplicatedOnReinstall() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try installer.installAllHooks() + + let crlf = try String(contentsOf: installer.settingsURL, encoding: .utf8) + .replacing("\n", with: "\r\n") + try crlf.write(to: installer.settingsURL, atomically: true, encoding: .utf8) + + #expect(try installer.installState() == .installed) + try installer.installAllHooks() + let config = try String(contentsOf: installer.settingsURL, encoding: .utf8) + for event in JcodeHookSettings.hookedEvents { + #expect(config.components(separatedBy: "\(event) = ").count - 1 == 1) + } + } + + @Test func installStateThrowsOnInvalidUTF8() throws { + let home = makeTempHome() + defer { try? fileManager.removeItem(at: home) } + let installer = makeInstaller(home: home) + try fileManager.createDirectory( + at: installer.settingsURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data([0xFF, 0xFE, 0xFF]).write(to: installer.settingsURL) + + #expect(throws: JcodeSettingsInstallerError.invalidUTF8) { + try installer.installState() + } + } + + // MARK: - Wrapper body. + + @Test func wrapperDispatchesEveryLifecycleEventToPresence() { + let wrapper = JcodeHookSettings.wrapperScript() + #expect(wrapper.contains("session_start)")) + #expect(wrapper.contains("turn_start)")) + #expect(wrapper.contains("turn_end)")) + #expect(wrapper.contains("session_end)")) + // The error branch keys off jcode's own status var. + #expect(wrapper.contains("JCODE_HOOK_STATUS")) + // Emits the shared OSC 3008 context signal (byte-identical to other harnesses). + #expect(wrapper.contains("3008")) + #expect(wrapper.contains("=jcode;")) + } +} diff --git a/supacodeTests/SettingsFeatureAgentIntegrationTests.swift b/supacodeTests/SettingsFeatureAgentIntegrationTests.swift index ecac771c3..4d400c6b4 100644 --- a/supacodeTests/SettingsFeatureAgentIntegrationTests.swift +++ b/supacodeTests/SettingsFeatureAgentIntegrationTests.swift @@ -678,7 +678,7 @@ struct SettingsFeatureAgentIntegrationTests { // agents but keeps the persistent error (`pi`). #expect( state.mainListAgentRows == [ - .claude, .codex, .copilot, .antigravity, .hermes, .kimi, .kiro, .opencode, .pi, + .claude, .codex, .copilot, .antigravity, .hermes, .jcode, .kimi, .kiro, .opencode, .pi, ] ) // A transient error is modal-only; a persistent error is main-list-only; a diff --git a/supacodeTests/SkillAgentTests.swift b/supacodeTests/SkillAgentTests.swift index 7cd37b613..208fe6492 100644 --- a/supacodeTests/SkillAgentTests.swift +++ b/supacodeTests/SkillAgentTests.swift @@ -13,7 +13,7 @@ struct SkillAgentTests { #expect( SkillAgent.allCasesByDisplayName.map(\.displayName) == [ "Claude Code", "Codex", "Copilot CLI", "Google Antigravity", "Grok Code", "Hermes", - "Kimi Code", "Kiro CLI", "Oh My Pi", "OpenCode", "Pi", + "jcode", "Kimi Code", "Kiro CLI", "Oh My Pi", "OpenCode", "Pi", ] ) } @@ -32,6 +32,13 @@ struct SkillAgentTests { #expect(SkillAgent.hermes.configDirectoryName == ".hermes") } + @Test func jcodeIdentityUsesDotJcodePathsAndLowercaseDisplayName() { + #expect(SkillAgent.jcode.rawValue == "jcode") + #expect(SkillAgent.jcode.displayName == "jcode") + #expect(SkillAgent.jcode.configDirectoryName == ".jcode") + #expect(SkillAgent.jcode.assetName == "jcode-mark") + } + @Test func kimiIdentityUsesKimiCodePathsAndDisplayName() { #expect(SkillAgent.kimi.rawValue == "kimi") #expect(SkillAgent.kimi.displayName == "Kimi Code")