Skip to content
Merged
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
21 changes: 21 additions & 0 deletions Sources/kmsg/Accessibility/UIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ public final class UIElement: @unchecked Sendable {
return CGRect(origin: pos, size: size)
}

public func setPosition(_ point: CGPoint) throws {
var point = point
guard let value = AXValueCreate(.cgPoint, &point) else {
throw AccessibilityError.typeMismatch
}
try setAttribute(kAXPositionAttribute, value: value)
}

public func setSize(_ size: CGSize) throws {
var size = size
guard let value = AXValueCreate(.cgSize, &size) else {
throw AccessibilityError.typeMismatch
}
try setAttribute(kAXSizeAttribute, value: value)
}

public func setFrame(_ frame: CGRect) throws {
try setPosition(frame.origin)
try setSize(frame.size)
}

// MARK: - Hierarchy

public var parent: UIElement? {
Expand Down
59 changes: 53 additions & 6 deletions Sources/kmsg/Commands/MCPServerCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ private final class KmsgMCPServer {
private let runner = KmsgSubprocessRunner()
private let deepRecoveryDefault: Bool
private let traceDefault: Bool
private let readLayoutDefault: String
private let serverVersion: String
private var initialized = false
private var shutdown = false
Expand All @@ -144,6 +145,7 @@ private final class KmsgMCPServer {
let env = ProcessInfo.processInfo.environment
deepRecoveryDefault = (env["KMSG_DEFAULT_DEEP_RECOVERY"] ?? "false").lowercased() == "true"
traceDefault = (env["KMSG_TRACE_DEFAULT"] ?? "false").lowercased() == "true"
readLayoutDefault = Self.validReadLayout(env["KMSG_DEFAULT_READ_LAYOUT"]) ?? "preserve"
serverVersion = env["KMSG_MCP_VERSION"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? env["KMSG_MCP_VERSION"]!.trimmingCharacters(in: .whitespacesAndNewlines)
: BuildVersion.current
Expand Down Expand Up @@ -251,6 +253,7 @@ private final class KmsgMCPServer {
"type": "object",
"properties": [
"chat": ["type": "string", "description": "Chat room or user name"],
"chat_id": ["type": "string", "description": "Synthetic chat_id from kmsg chats"],
"limit": ["type": "integer", "minimum": 1, "maximum": 100, "default": 20],
"deep_recovery": [
"type": "boolean",
Expand All @@ -267,8 +270,13 @@ private final class KmsgMCPServer {
"default": traceDefault,
"description": "Include AX tracing logs",
],
"layout": [
"type": "string",
"enum": ["preserve", "left", "right"],
"default": readLayoutDefault,
"description": "Window layout before reading",
],
],
"required": ["chat"],
"additionalProperties": false,
],
],
Expand Down Expand Up @@ -366,11 +374,22 @@ private final class KmsgMCPServer {

private func callKmsgRead(_ arguments: JSONDict) -> JSONDict {
let chat = String(describing: arguments["chat"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if chat.isEmpty {
let chatID = String(describing: arguments["chat_id"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if chat.isEmpty && chatID.isEmpty {
return errorPayload(
code: "INVALID_ARGUMENT",
message: "chat is required",
hint: "Provide a non-empty chat name.",
message: "chat or chat_id is required",
hint: "Provide either a non-empty chat name or a chat_id from kmsg chats.",
rawStdout: "",
rawStderr: "",
latencyMs: 0
)
}
if !chat.isEmpty && !chatID.isEmpty {
return errorPayload(
code: "INVALID_ARGUMENT",
message: "chat and chat_id cannot be used together",
hint: "Use chat_id for stable identity, or chat for name-based lookup.",
rawStdout: "",
rawStderr: "",
latencyMs: 0
Expand Down Expand Up @@ -400,8 +419,24 @@ private final class KmsgMCPServer {
let deepRecovery = boolValue(arguments["deep_recovery"], defaultValue: deepRecoveryDefault)
let keepWindow = boolValue(arguments["keep_window"], defaultValue: false)
let traceAX = boolValue(arguments["trace_ax"], defaultValue: traceDefault)
guard let layout = Self.validReadLayout(arguments["layout"] as? String ?? readLayoutDefault) else {
return errorPayload(
code: "INVALID_ARGUMENT",
message: "layout must be preserve, left, or right",
hint: "Use layout=preserve, layout=left, or layout=right.",
rawStdout: "",
rawStderr: "",
latencyMs: 0
)
}

var command = ["read", chat, "--json", "--limit", String(boundedLimit)]
var command = ["read"]
if !chatID.isEmpty {
command.append(contentsOf: ["--chat-id", chatID])
} else {
command.append(chat)
}
command.append(contentsOf: ["--json", "--limit", String(boundedLimit), "--layout", layout])
if deepRecovery { command.append("--deep-recovery") }
if keepWindow { command.append("--keep-window") }
if traceAX { command.append("--trace-ax") }
Expand Down Expand Up @@ -465,12 +500,13 @@ private final class KmsgMCPServer {

var response: JSONDict = [
"ok": true,
"chat": payload["chat"] ?? chat,
"chat": payload["chat"] ?? (chat.isEmpty ? chatID : chat),
"fetched_at": payload["fetched_at"] as Any,
"count": payload["count"] ?? 0,
"messages": payload["messages"] ?? [],
"meta": [
"latency_ms": first.latencyMs,
"layout": layout,
],
]
if traceAX, !first.stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Expand Down Expand Up @@ -716,6 +752,17 @@ private final class KmsgMCPServer {
return defaultValue
}

private static func validReadLayout(_ raw: String?) -> String? {
guard let raw else { return nil }
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "preserve", "left", "right":
return normalized
default:
return nil
}
}

private func jsonObject(from string: String) -> JSONDict? {
guard let data = string.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
Expand Down
59 changes: 50 additions & 9 deletions Sources/kmsg/Commands/ReadCommand.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ArgumentParser
import Foundation

extension ChatWindowLayoutMode: ExpressibleByArgument {}

struct ReadCommand: ParsableCommand {
private struct ReadJSONResponse: Encodable {
let chat: String
Expand All @@ -19,11 +21,20 @@ struct ReadCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "read",
abstract: "Read messages from a chat",
discussion: "When author is \"(me)\", the message was sent by you."
discussion: """
Use either:
kmsg read <chat>
kmsg read --chat-id <chat-id>

When author is "(me)", the message was sent by you.
"""
)

@Option(name: .long, help: "Read using a chat_id from 'kmsg chats'")
var chatID: String?

@Argument(help: "Name of the chat to read from (partial match supported)")
var chat: String
var chat: String?

@Option(name: .shortAndLong, help: "Maximum number of messages to show")
var limit: Int = 20
Expand All @@ -46,9 +57,25 @@ struct ReadCommand: ParsableCommand {
)
var deepRecovery: Bool = false

@Option(name: .long, help: "Window layout before reading: preserve, left, or right")
var layout: ChatWindowLayoutMode = .preserve

@Flag(name: .long, help: "Output in JSON format")
var json: Bool = false

func validate() throws {
if let chatID, !chatID.isEmpty {
guard chat == nil else {
throw ValidationError("Chat name cannot be provided together with --chat-id.")
}
return
}

guard let chat, !chat.isEmpty else {
throw ValidationError("Chat name is required unless --chat-id is provided.")
}
}

func run() throws {
guard AccessibilityPermission.ensureGranted() else {
AccessibilityPermission.printInstructions()
Expand All @@ -60,15 +87,24 @@ struct ReadCommand: ParsableCommand {
let chatWindowResolver = ChatWindowResolver(
kakao: kakao,
runner: runner,
deepRecoveryEnabled: deepRecovery
deepRecoveryEnabled: deepRecovery,
layoutMode: layout
)
let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner)

let resolution: ChatWindowResolution
let requestedChat: String
do {
resolution = try chatWindowResolver.resolve(query: chat)
if let chatID {
requestedChat = chatID
resolution = try chatWindowResolver.resolve(chatID: chatID)
} else {
let chat = chat ?? ""
requestedChat = chat
resolution = try chatWindowResolver.resolve(query: chat)
}
} catch {
print("No chat window found for '\(chat)'")
print("No chat window found for '\(requestedChat)'")
print("Reason: \(error)")
print("\nAvailable windows:")
for (index, window) in kakao.windows.enumerated() {
Expand All @@ -80,6 +116,11 @@ struct ReadCommand: ParsableCommand {
let window = resolution.window
if resolution.openedViaSearch {
runner.log("read: opening chat via search")
} else if resolution.method == .openedViaChatList {
runner.log("read: opening chat via chat list")
}

if resolution.openedTransiently {
if keepWindow {
runner.log("read: keep-window enabled; auto-opened window will be kept")
} else {
Expand All @@ -90,16 +131,16 @@ struct ReadCommand: ParsableCommand {
}

defer {
if resolution.openedViaSearch && !keepWindow {
if resolution.openedTransiently && !keepWindow {
let resolvedTitle = window.title ?? ""
if !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(chat) {
if chatID == nil && !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(requestedChat) {
runner.log("read: skipped auto-close because resolved title '\(resolvedTitle)' did not match query")
} else if chatWindowResolver.closeWindow(window) {
runner.log("read: auto-opened chat window closed")
} else {
runner.log("read: failed to close auto-opened chat window")
}
} else if resolution.openedViaSearch && keepWindow {
} else if resolution.openedTransiently && keepWindow {
runner.log("read: auto-opened chat window kept by --keep-window")
}
}
Expand All @@ -108,7 +149,7 @@ struct ReadCommand: ParsableCommand {
do {
snapshot = try transcriptReader.readSnapshot(
from: window,
fallbackChatTitle: chat,
fallbackChatTitle: window.title ?? requestedChat,
limit: limit
)
} catch TranscriptReadError.transcriptContextUnavailable {
Expand Down
12 changes: 4 additions & 8 deletions Sources/kmsg/Commands/SendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,18 @@ struct SendCommand: ParsableCommand {
runner.log("window strategy: focusedWindow -> mainWindow -> windows.first")
let resolution: ChatWindowResolution
if let chatID {
guard let record = ChatIdentityRegistryStore.shared.record(for: chatID) else {
throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.")
}
print("Looking for chat with \(targetDescription)...")
print("Resolved \(targetDescription) to '\(record.displayName)'.")
resolution = try chatWindowResolver.resolve(query: record.displayName)
if resolution.openedViaSearch {
print("No existing chat window. Opening via search...")
resolution = try chatWindowResolver.resolve(chatID: chatID)
if resolution.openedTransiently {
print("No existing chat window. Opening via chat list or search...")
} else {
print("Found existing chat window.")
}
} else {
let recipient = recipient ?? ""
print("Looking for chat with \(targetDescription)...")
resolution = try chatWindowResolver.resolve(query: recipient)
if resolution.openedViaSearch {
if resolution.openedTransiently {
print("No existing chat window. Opening via search...")
} else {
print("Found existing chat window.")
Expand Down
4 changes: 2 additions & 2 deletions Sources/kmsg/Commands/WatchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct WatchCommand: ParsableCommand {

var currentWindow = resolution.window
var currentChatTitle = currentWindow.title ?? chat
var autoOpenedWindow: UIElement? = resolution.openedViaSearch ? currentWindow : nil
var autoOpenedWindow: UIElement? = resolution.openedTransiently ? currentWindow : nil
var cachedContext: MessageTranscriptContext?

defer {
Expand Down Expand Up @@ -224,7 +224,7 @@ struct WatchCommand: ParsableCommand {
currentWindow = resolution.window
currentChatTitle = currentWindow.title ?? chat
cachedContext = nil
if resolution.openedViaSearch {
if resolution.openedTransiently {
autoOpenedWindow = currentWindow
}
return try stabilizeBaseline(
Expand Down
Loading
Loading