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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ kmsg chats --json
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --keep-window
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json --background-safe
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --deep-recovery
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --layout split-right
kmsg watch "본인, 친구, 또는 단톡방 이름"
kmsg watch "본인, 친구, 또는 단톡방 이름" --json
kmsg watch "본인, 친구, 또는 단톡방 이름" --json --poll-interval 0.5
Expand All @@ -87,7 +89,7 @@ kmsg status [--verbose]

- `--verbose`: 상세 상태 출력

`status`, `chats`, `read`, `send`, `send-image`, `watch`, `cache warmup`은 카카오톡 로그인이 풀려 있으면 저장된 자격 증명으로 자동 로그인을 시도합니다. 저장된 정보가 없거나 불완전하면 터미널에서 아이디/비밀번호를 입력받아 `~/.config/kmsg/credentials.json`에 저장하고, 비밀번호 암호키는 `~/.config/kmsg/credentials/`에 별도로 보관합니다.
`status`, `chats`, `read`, `send`, `send-image`, `watch`, `cache warmup`은 카카오톡 로그인이 풀려 있으면 저장된 자격 증명으로 자동 로그인을 시도합니다. 저장된 정보가 없거나 불완전하면 터미널에서 아이디/비밀번호를 입력받아 `~/.config/kmsg/credentials.json`에 저장하고, 비밀번호 암호키는 `~/.config/kmsg/credentials/`에 별도로 보관합니다. 단, `read --background-safe`는 카카오톡 실행/활성화/자동 로그인을 하지 않습니다.

### auth login

Expand Down Expand Up @@ -117,14 +119,16 @@ kmsg chats [--verbose] [--limit <limit>] [--trace-ax] [--json] [--keep-window]
### read

```bash
kmsg read <chat> [--limit <limit>] [--debug] [--trace-ax] [--keep-window] [--deep-recovery] [--json]
kmsg read <chat> [--limit <limit>] [--debug] [--trace-ax] [--keep-window] [--background-safe] [--deep-recovery] [--layout <layout>] [--json]
```

- `-l, --limit <limit>`: 최대 메시지 개수 (기본값: 20)
- `--debug`: raw element 디버그 정보 출력
- `--trace-ax`: AX 탐색/재시도 로그 출력
- `-k, --keep-window`: 자동으로 연 채팅창과 리스트창 유지
- `--background-safe`: 카카오톡 실행/활성화/자동 로그인/검색/채팅방 열기/창 크기 변경/자동 닫기를 하지 않고, 이미 노출된 매칭 채팅창만 읽음
- `--deep-recovery`: 빠른 탐색 실패 시 deep recovery 수행
- `--layout <layout>`: `preserve`, `left`, `right`, `split-left`, `split-right`. 마케팅 작업자가 브라우저/광고툴과 화면을 나눠 쓸 때는 `split-left` 또는 `split-right`를 사용
- `--json`: JSON 형식으로 출력

### watch
Expand Down Expand Up @@ -218,6 +222,7 @@ kmsg help cache

```bash
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json
kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json --background-safe
```

### 출력 형식
Expand All @@ -231,7 +236,12 @@ kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json
{
"author": "홍길동",
"time_raw": "00:27",
"body": "밤이 깊었네"
"body": "밤이 깊었네",
"has_image": false,
"image_count": 0,
"link_count": 0,
"has_attachment": false,
"attachment_count": 0
}
]
}
Expand Down Expand Up @@ -291,17 +301,22 @@ kmsg mcp-server
kmsg watch "채팅방 이름" --json
```

```bash
$HOME/.local/bin/kmsg mcp-server
```

OpenClaw MCP 설정 예시:

```json
{
"mcpServers": {
"kmsg": {
"command": "kmsg",
"command": "$HOME/.local/bin/kmsg",
"args": ["mcp-server"],
"env": {
"KMSG_DEFAULT_DEEP_RECOVERY": "false",
"KMSG_TRACE_DEFAULT": "false"
"KMSG_TRACE_DEFAULT": "false",
"KMSG_DEFAULT_READ_LAYOUT": "split-right"
}
}
}
Expand Down
31 changes: 26 additions & 5 deletions Sources/kmsg/Commands/MCPServerCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ private final class KmsgSubprocessRunner {
)
}

let env = ProcessInfo.processInfo.environment
let shouldRunStatusCheck = (env["KMSG_MCP_STARTUP_STATUS_CHECK"] ?? "false").lowercased() == "true"
if !shouldRunStatusCheck {
return (
true,
[
"kmsg_bin": executablePath,
"version": version.stdout.trimmingCharacters(in: .whitespacesAndNewlines),
"status_check": "skipped",
]
)
}

let status = run(["status"], timeoutSec: 15.0)
if status.returncode != 0 {
return (
Expand Down Expand Up @@ -300,6 +313,11 @@ private final class KmsgMCPServer {
"default": deepRecoveryDefault,
"description": "Enable deep recovery mode for window resolution",
],
"background_safe": [
"type": "boolean",
"default": false,
"description": "Only read already exposed matching chat windows; do not launch, activate, search, resize, or close KakaoTalk windows",
],
"keep_window": [
"type": "boolean",
"default": false,
Expand All @@ -312,7 +330,7 @@ private final class KmsgMCPServer {
],
"layout": [
"type": "string",
"enum": ["preserve", "left", "right"],
"enum": ["preserve", "left", "right", "split-left", "split-right"],
"default": readLayoutDefault,
"description": "Window layout before reading",
],
Expand Down Expand Up @@ -457,13 +475,14 @@ private final class KmsgMCPServer {

let boundedLimit = max(1, min(limit, 100))
let deepRecovery = boolValue(arguments["deep_recovery"], defaultValue: deepRecoveryDefault)
let backgroundSafe = boolValue(arguments["background_safe"], defaultValue: false)
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.",
message: "layout must be preserve, left, right, split-left, or split-right",
hint: "Use layout=preserve, layout=left, layout=right, layout=split-left, or layout=split-right.",
rawStdout: "",
rawStderr: "",
latencyMs: 0
Expand All @@ -478,6 +497,7 @@ private final class KmsgMCPServer {
}
command.append(contentsOf: ["--json", "--limit", String(boundedLimit), "--layout", layout])
if deepRecovery { command.append("--deep-recovery") }
if backgroundSafe { command.append("--background-safe") }
if keepWindow { command.append("--keep-window") }
if traceAX { command.append("--trace-ax") }

Expand All @@ -498,7 +518,7 @@ private final class KmsgMCPServer {
if first.returncode != 0 {
let combined = "\(first.stdout)\n\(first.stderr)"
let code = extractErrorCode(combined)
if code == "CHAT_NOT_FOUND" && !deepRecovery {
if code == "CHAT_NOT_FOUND" && !deepRecovery && !backgroundSafe {
var retryCommand = command
retryCommand.append("--deep-recovery")
let retry = runner.run(retryCommand, timeoutSec: 15.0)
Expand Down Expand Up @@ -547,6 +567,7 @@ private final class KmsgMCPServer {
"meta": [
"latency_ms": first.latencyMs,
"layout": layout,
"background_safe": backgroundSafe,
],
]
if traceAX, !first.stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Expand Down Expand Up @@ -801,7 +822,7 @@ private final class KmsgMCPServer {
guard let raw else { return nil }
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "preserve", "left", "right":
case "preserve", "left", "right", "split-left", "split-right":
return normalized
default:
return nil
Expand Down
34 changes: 27 additions & 7 deletions Sources/kmsg/Commands/ReadCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct ReadCommand: ParsableCommand {
kmsg read <chat>
kmsg read --chat-id <chat-id>

Use --background-safe to read only already exposed chat windows without launching,
activating, logging in, searching, opening rows, resizing, or closing windows.

When author is "(me)", the message was sent by you.
"""
)
Expand All @@ -48,6 +51,12 @@ struct ReadCommand: ParsableCommand {
@Flag(name: [.short, .long], help: "Keep auto-opened chat window after read")
var keepWindow: Bool = false

@Flag(
name: .long,
help: "Do not activate, search, open, resize, or close KakaoTalk windows; only read already exposed matching chat windows"
)
var backgroundSafe: Bool = false

@Flag(
name: .long,
help: ArgumentHelp(
Expand All @@ -57,7 +66,7 @@ struct ReadCommand: ParsableCommand {
)
var deepRecovery: Bool = false

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

@Flag(name: .long, help: "Output in JSON format")
Expand All @@ -83,14 +92,21 @@ struct ReadCommand: ParsableCommand {
}

let runner = AXActionRunner(traceEnabled: traceAX)
let kakao = try AuthBootstrap.requireAuthenticated(traceAX: traceAX)
let kakao = backgroundSafe
? try KakaoTalkApp(autoLaunch: false)
: try AuthBootstrap.requireAuthenticated(traceAX: traceAX)
let chatWindowResolver = ChatWindowResolver(
kakao: kakao,
runner: runner,
deepRecoveryEnabled: deepRecovery,
layoutMode: layout
layoutMode: layout,
interactionMode: backgroundSafe ? .backgroundSafe : .allowUIAutomation
)
let transcriptReader = KakaoTalkTranscriptReader(
kakao: kakao,
runner: runner,
interactionMode: backgroundSafe ? .backgroundSafe : .allowUIAutomation
)
let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner)

let resolution: ChatWindowResolution
let requestedChat: String
Expand All @@ -106,9 +122,13 @@ struct ReadCommand: ParsableCommand {
} catch {
print("No chat window found for '\(requestedChat)'")
print("Reason: \(error)")
print("\nAvailable windows:")
for (index, window) in kakao.windows.enumerated() {
print(" [\(index)] \(window.title ?? "(untitled)")")
if backgroundSafe {
print("Available windows: \(kakao.windows.count) title(s) hidden in background-safe mode")
} else {
print("\nAvailable windows:")
for (index, window) in kakao.windows.enumerated() {
print(" [\(index)] \(window.title ?? "(untitled)")")
}
}
throw ExitCode.failure
}
Expand Down
Loading
Loading