samagotchi 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +11 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Agent CLIs (claude, codex, …) in kitty windows as panel targets, through
|
|
2
|
+
// the kitty binary's remote control (`kitty @ --to unix:<socket> …`). The
|
|
3
|
+
// settings come from launch.json's "kitty" section (chi desktop bakes it
|
|
4
|
+
// from config.yml's kitty:); without it there are no kitty targets.
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
struct KittySettings: Decodable, Equatable {
|
|
8
|
+
let listenOn: String
|
|
9
|
+
let binary: String
|
|
10
|
+
let agents: [String]
|
|
11
|
+
|
|
12
|
+
enum CodingKeys: String, CodingKey {
|
|
13
|
+
case listenOn = "listen_on"
|
|
14
|
+
case binary, agents
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// One kitty window running an agent: `<socket>#<id>` keys it.
|
|
19
|
+
struct KittyWindow: Identifiable, Equatable {
|
|
20
|
+
let socket: String
|
|
21
|
+
let windowId: Int
|
|
22
|
+
let agent: String
|
|
23
|
+
let title: String
|
|
24
|
+
let cwd: String
|
|
25
|
+
|
|
26
|
+
var id: String { key }
|
|
27
|
+
var key: String { "\(socket)#\(windowId)" }
|
|
28
|
+
var shortCwd: String { LiveSession.shorten(cwd) }
|
|
29
|
+
/// "claude · samagotchi": the agent and its folder.
|
|
30
|
+
var label: String {
|
|
31
|
+
let folder = (cwd as NSString).lastPathComponent
|
|
32
|
+
return folder.isEmpty ? agent : "\(agent) · \(folder)"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Parses a key back into its socket and window id.
|
|
36
|
+
static func parse(key: String) -> (socket: String, windowId: Int)? {
|
|
37
|
+
guard let hash = key.lastIndex(of: "#"), let id = Int(key[key.index(after: hash)...]) else { return nil }
|
|
38
|
+
let socket = String(key[..<hash])
|
|
39
|
+
return socket.isEmpty ? nil : (socket, id)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
enum KittyError: Error, CustomStringConvertible {
|
|
44
|
+
case notUnix(String)
|
|
45
|
+
case windowClosed
|
|
46
|
+
case failed(String)
|
|
47
|
+
|
|
48
|
+
var description: String {
|
|
49
|
+
switch self {
|
|
50
|
+
case .notUnix(let value): return "kitty.listen_on \(value): only unix: sockets"
|
|
51
|
+
case .windowClosed: return "window closed"
|
|
52
|
+
case .failed(let message): return message
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// The pure parts: which files are sockets of this listen_on, which windows
|
|
58
|
+
/// are agents, what text a paste carries. The runner below does the I/O.
|
|
59
|
+
enum Kitty {
|
|
60
|
+
/// listen_on's path with `~`, `$VAR` and `${VAR}` expanded from +env+
|
|
61
|
+
/// (unknown variables stay as written: kitty.conf's `${KITTY_PID}` is not
|
|
62
|
+
/// expanded by kitty either). Nil for anything but `unix:`.
|
|
63
|
+
static func socketPath(listenOn: String, env: [String: String]) -> String? {
|
|
64
|
+
guard listenOn.hasPrefix("unix:") else { return nil }
|
|
65
|
+
var path = String(listenOn.dropFirst("unix:".count))
|
|
66
|
+
let home = env["HOME"] ?? NSHomeDirectory()
|
|
67
|
+
if path == "~" || path.hasPrefix("~/") { path = home + path.dropFirst() }
|
|
68
|
+
let pattern = try! NSRegularExpression(pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"#)
|
|
69
|
+
var result = ""
|
|
70
|
+
var last = path.startIndex
|
|
71
|
+
for match in pattern.matches(in: path, range: NSRange(path.startIndex..., in: path)) {
|
|
72
|
+
let whole = Range(match.range, in: path)!
|
|
73
|
+
let nameRange = Range(match.range(at: 1), in: path) ?? Range(match.range(at: 2), in: path)!
|
|
74
|
+
result += path[last..<whole.lowerBound]
|
|
75
|
+
result += env[String(path[nameRange])] ?? String(path[whole])
|
|
76
|
+
last = whole.upperBound
|
|
77
|
+
}
|
|
78
|
+
return result + path[last...]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Whether +name+ is a socket name kitty makes from +base+: base itself,
|
|
82
|
+
/// base with `-<pid>` appended (kitty.conf's listen_on without
|
|
83
|
+
/// {kitty_pid}), or base with {kitty_pid} filled in.
|
|
84
|
+
static func socketName(_ name: String, matches base: String) -> Bool {
|
|
85
|
+
let parts = base.components(separatedBy: "{kitty_pid}")
|
|
86
|
+
let pattern = parts.map(NSRegularExpression.escapedPattern(for:)).joined(separator: #"\d+"#)
|
|
87
|
+
let full = parts.count > 1 ? "^\(pattern)$" : "^\(pattern)(-\\d+)?$"
|
|
88
|
+
return name.range(of: full, options: .regularExpression) != nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The windows of one `kitty @ ls` whose foreground programs include an
|
|
92
|
+
/// agent: the basename of a process's cmdline[0] or cmdline[1] (codex and
|
|
93
|
+
/// gemini run as `node …/codex`); `*` takes every window.
|
|
94
|
+
static func windows(ls data: Data, socket: String, agents: [String]) -> [KittyWindow] {
|
|
95
|
+
guard let osWindows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
|
96
|
+
let all = agents.contains("*")
|
|
97
|
+
var found: [KittyWindow] = []
|
|
98
|
+
for osWindow in osWindows {
|
|
99
|
+
for tab in osWindow["tabs"] as? [[String: Any]] ?? [] {
|
|
100
|
+
for window in tab["windows"] as? [[String: Any]] ?? [] {
|
|
101
|
+
guard let id = window["id"] as? Int else { continue }
|
|
102
|
+
let names = (window["foreground_processes"] as? [[String: Any]] ?? []).flatMap { process -> [String] in
|
|
103
|
+
let cmdline = process["cmdline"] as? [String] ?? []
|
|
104
|
+
return cmdline.prefix(2).map(programName)
|
|
105
|
+
}.filter { !$0.isEmpty }
|
|
106
|
+
guard let agent = names.first(where: agents.contains) ?? (all ? names.last ?? "" : nil) else { continue }
|
|
107
|
+
found.append(KittyWindow(socket: socket, windowId: id, agent: agent,
|
|
108
|
+
title: cleanTitle(window["title"] as? String ?? ""),
|
|
109
|
+
cwd: window["cwd"] as? String ?? ""))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// "/usr/bin/claude" → "claude", a login shell's "-fish" → "fish".
|
|
117
|
+
static func programName(_ arg: String) -> String {
|
|
118
|
+
var name = (arg as NSString).lastPathComponent
|
|
119
|
+
if name.hasPrefix("-") { name.removeFirst() }
|
|
120
|
+
return name
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// A title without the status glyph agents put in front ("✳ Claude Code").
|
|
124
|
+
static func cleanTitle(_ title: String) -> String {
|
|
125
|
+
guard let first = title.unicodeScalars.first, first.value > 127, !first.properties.isAlphabetic,
|
|
126
|
+
title.dropFirst().first == " " else { return title }
|
|
127
|
+
return String(title.dropFirst(2))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Context as a quote above the message, then one image path per line:
|
|
131
|
+
/// ContextQuote.block's shape (`> line`, `>` for an empty one, a blank line
|
|
132
|
+
/// after). A paste-only text ends in a newline, so the next paste starts
|
|
133
|
+
/// on its own line. Bracketed-paste markers are taken out of the text:
|
|
134
|
+
/// it is wrapped in them to arrive as one paste.
|
|
135
|
+
static func text(context: String, message: String, imagePaths: [String], pasteOnly: Bool) -> String {
|
|
136
|
+
var text = quote(context) ?? ""
|
|
137
|
+
text += message
|
|
138
|
+
let paths = imagePaths.map(shellQuote)
|
|
139
|
+
if !paths.isEmpty {
|
|
140
|
+
if !text.isEmpty && !text.hasSuffix("\n") { text += "\n" }
|
|
141
|
+
text += paths.joined(separator: "\n")
|
|
142
|
+
}
|
|
143
|
+
while text.hasSuffix("\n") { text.removeLast() }
|
|
144
|
+
if pasteOnly && !text.isEmpty { text += "\n" }
|
|
145
|
+
return text.replacingOccurrences(of: "\u{1b}[200~", with: "").replacingOccurrences(of: "\u{1b}[201~", with: "")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
static func quote(_ context: String) -> String? {
|
|
149
|
+
var lines = context.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
|
150
|
+
.components(separatedBy: "\n")
|
|
151
|
+
.map { line -> String in
|
|
152
|
+
var line = line
|
|
153
|
+
while let last = line.unicodeScalars.last, CharacterSet.whitespacesAndNewlines.contains(last) {
|
|
154
|
+
line.unicodeScalars.removeLast()
|
|
155
|
+
}
|
|
156
|
+
return line
|
|
157
|
+
}
|
|
158
|
+
while lines.first?.isEmpty == true { lines.removeFirst() }
|
|
159
|
+
while lines.last?.isEmpty == true { lines.removeLast() }
|
|
160
|
+
guard !lines.isEmpty else { return nil }
|
|
161
|
+
return lines.map { $0.isEmpty ? ">" : "> \($0)" }.joined(separator: "\n") + "\n\n"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// A path as is when it holds only safe characters, else single-quoted
|
|
165
|
+
/// (claude reads a quoted path with spaces as an image).
|
|
166
|
+
static func shellQuote(_ path: String) -> String {
|
|
167
|
+
if path.range(of: #"^[A-Za-z0-9_./~+@%,:=-]+$"#, options: .regularExpression) != nil { return path }
|
|
168
|
+
return "'" + path.replacingOccurrences(of: "'", with: #"'\''"#) + "'"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
final class KittyRunner {
|
|
173
|
+
let settings: KittySettings?
|
|
174
|
+
let queue: DispatchQueue
|
|
175
|
+
let env: [String: String]
|
|
176
|
+
let lsTimeout: TimeInterval = 2
|
|
177
|
+
let sendTimeout: TimeInterval = 5
|
|
178
|
+
|
|
179
|
+
/// @param queue where completions run (the main queue in the app)
|
|
180
|
+
init(settings: KittySettings?, queue: DispatchQueue = .main,
|
|
181
|
+
env: [String: String] = ProcessInfo.processInfo.environment) {
|
|
182
|
+
self.settings = settings
|
|
183
|
+
self.queue = queue
|
|
184
|
+
self.env = env
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// From a launch file; no settings when it has no kitty section.
|
|
188
|
+
convenience init(launchPath: String, queue: DispatchQueue = .main) {
|
|
189
|
+
let launch = FileManager.default.contents(atPath: launchPath)
|
|
190
|
+
.flatMap { try? JSONDecoder().decode(LaunchConfig.self, from: $0) }
|
|
191
|
+
self.init(settings: launch?.kitty, queue: queue)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// kitty @ runs, and listen_on expands, with the helper's env minus
|
|
195
|
+
/// kitty's own variables (a helper started from a kitty window must not
|
|
196
|
+
/// talk to that one, nor fill in the ${KITTY_PID} kitty keeps literal).
|
|
197
|
+
private var kittyEnv: [String: String] { env.filter { !$0.key.hasPrefix("KITTY_") } }
|
|
198
|
+
|
|
199
|
+
/// The sockets listen_on names: the files in its folder whose names match
|
|
200
|
+
/// (a directory listing, not glob(): the path may hold `${ }` literally).
|
|
201
|
+
func sockets() -> Result<[String], KittyError> {
|
|
202
|
+
guard let settings else { return .success([]) }
|
|
203
|
+
guard let path = Kitty.socketPath(listenOn: settings.listenOn, env: kittyEnv) else {
|
|
204
|
+
return .failure(.notUnix(settings.listenOn))
|
|
205
|
+
}
|
|
206
|
+
let dir = (path as NSString).deletingLastPathComponent
|
|
207
|
+
let base = (path as NSString).lastPathComponent
|
|
208
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []
|
|
209
|
+
return .success(names.filter { Kitty.socketName($0, matches: base) }.sorted()
|
|
210
|
+
.map { (dir as NSString).appendingPathComponent($0) }
|
|
211
|
+
.filter { (try? FileManager.default.attributesOfItem(atPath: $0)[.type] as? FileAttributeType) == .typeSocket })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Agent windows from every live socket; a dead one drops out silently.
|
|
215
|
+
func windows(completion: @escaping (Result<[KittyWindow], KittyError>) -> Void) {
|
|
216
|
+
let sockets: [String]
|
|
217
|
+
switch self.sockets() {
|
|
218
|
+
case .failure(let error): queue.async { completion(.failure(error)) }; return
|
|
219
|
+
case .success(let found): sockets = found
|
|
220
|
+
}
|
|
221
|
+
guard let settings, !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
|
|
222
|
+
let group = DispatchGroup()
|
|
223
|
+
let lock = NSLock()
|
|
224
|
+
var bySocket: [String: [KittyWindow]] = [:]
|
|
225
|
+
for socket in sockets {
|
|
226
|
+
group.enter()
|
|
227
|
+
kitty(socket, ["ls"], timeout: lsTimeout, on: .global()) { result in
|
|
228
|
+
if case .success(let r) = result, r.status == 0, !r.timedOut {
|
|
229
|
+
let found = Kitty.windows(ls: Data(r.stdout.utf8), socket: socket, agents: settings.agents)
|
|
230
|
+
lock.lock(); bySocket[socket] = found; lock.unlock()
|
|
231
|
+
}
|
|
232
|
+
group.leave()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
group.notify(queue: queue) { completion(.success(sockets.flatMap { bySocket[$0] ?? [] })) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Pastes +text+ into the window as one bracketed paste, then presses
|
|
239
|
+
/// Enter unless +pasteOnly+. send-text reports nothing, even for a gone
|
|
240
|
+
/// window, so `ls --match` checks the window first.
|
|
241
|
+
func send(key: String, text: String, pasteOnly: Bool, completion: @escaping (Result<Void, KittyError>) -> Void) {
|
|
242
|
+
guard let (socket, id) = KittyWindow.parse(key: key) else {
|
|
243
|
+
queue.async { completion(.failure(.failed("not a kitty window: \(key)"))) }; return
|
|
244
|
+
}
|
|
245
|
+
let match = ["--match", "id:\(id)"]
|
|
246
|
+
kitty(socket, ["ls"] + match, timeout: lsTimeout) { result in
|
|
247
|
+
guard case .success(let r) = result, !r.timedOut else {
|
|
248
|
+
completion(.failure(self.failure(result, "kitty ls"))); return
|
|
249
|
+
}
|
|
250
|
+
guard r.status == 0 else { completion(.failure(.windowClosed)); return }
|
|
251
|
+
let paste = Data(("\u{1b}[200~" + text + "\u{1b}[201~").utf8)
|
|
252
|
+
self.kitty(socket, ["send-text"] + match + ["--bracketed-paste=disable", "--stdin"], stdin: paste,
|
|
253
|
+
timeout: self.sendTimeout) { result in
|
|
254
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else {
|
|
255
|
+
completion(.failure(self.failure(result, "kitty send-text"))); return
|
|
256
|
+
}
|
|
257
|
+
if pasteOnly { completion(.success(())); return }
|
|
258
|
+
self.kitty(socket, ["send-key"] + match + ["enter"], timeout: self.sendTimeout) { result in
|
|
259
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else {
|
|
260
|
+
completion(.failure(self.failure(result, "kitty send-key"))); return
|
|
261
|
+
}
|
|
262
|
+
completion(.success(()))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private func failure(_ result: Result<ChiResult, ChiError>, _ what: String) -> KittyError {
|
|
269
|
+
switch result {
|
|
270
|
+
case .failure(let error): return .failed(error.description)
|
|
271
|
+
case .success(let r):
|
|
272
|
+
if r.timedOut { return .failed("\(what) timed out") }
|
|
273
|
+
let detail = (r.stderr.isEmpty ? r.stdout : r.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
274
|
+
return .failed("\(what) failed (exit \(r.status)): \(detail.prefix(200))")
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private func kitty(_ socket: String, _ args: [String], stdin: Data? = nil, timeout: TimeInterval,
|
|
279
|
+
on queue: DispatchQueue? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
280
|
+
guard let settings else { return }
|
|
281
|
+
ProcessRunner.run(executable: settings.binary, args: ["@", "--to", "unix:\(socket)"] + args, env: kittyEnv,
|
|
282
|
+
stdin: stdin, timeout: timeout, queue: queue ?? self.queue, completion: completion)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// `ChiHelper --kitty list|send …`, run directly (specs, a terminal):
|
|
287
|
+
/// --kitty list [--launch PATH] JSON {"windows": […], "error": …}
|
|
288
|
+
/// --kitty send KEY [--paste-only] [--message M] [--image PATH]… [--temp-image PATH]… [--launch PATH]
|
|
289
|
+
/// send reads the context from stdin and prints "sent", "pasted" or the
|
|
290
|
+
/// error (exit 1). --temp-image is an image the helper wrote (copied first).
|
|
291
|
+
func kittyCommand(_ args: [String]) -> Int32 {
|
|
292
|
+
var args = args
|
|
293
|
+
let sub = args.isEmpty ? "" : args.removeFirst()
|
|
294
|
+
var launchPath = ChiRunner.launchPath, key: String?, message = "", pasteOnly = false
|
|
295
|
+
var images: [PanelImage] = []
|
|
296
|
+
while !args.isEmpty {
|
|
297
|
+
let arg = args.removeFirst()
|
|
298
|
+
switch arg {
|
|
299
|
+
case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
|
|
300
|
+
case "--message" where !args.isEmpty: message = args.removeFirst()
|
|
301
|
+
case "--image" where !args.isEmpty: images.append(PanelImage(url: URL(fileURLWithPath: args.removeFirst()), temp: false))
|
|
302
|
+
case "--temp-image" where !args.isEmpty: images.append(PanelImage(url: URL(fileURLWithPath: args.removeFirst()), temp: true))
|
|
303
|
+
case "--paste-only": pasteOnly = true
|
|
304
|
+
default:
|
|
305
|
+
if key == nil && !arg.hasPrefix("--") { key = arg } else { return kittyUsage() }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let queue = DispatchQueue(label: "kitty-command")
|
|
309
|
+
let runner = KittyRunner(launchPath: launchPath, queue: queue)
|
|
310
|
+
let done = DispatchSemaphore(value: 0)
|
|
311
|
+
var status: Int32 = 0
|
|
312
|
+
switch sub {
|
|
313
|
+
case "list":
|
|
314
|
+
runner.windows { result in
|
|
315
|
+
var out: [String: Any] = ["windows": [], "error": NSNull()]
|
|
316
|
+
switch result {
|
|
317
|
+
case .success(let windows):
|
|
318
|
+
out["windows"] = windows.map { ["key": $0.key, "agent": $0.agent, "title": $0.title, "cwd": $0.cwd, "label": $0.label] }
|
|
319
|
+
case .failure(let error): out["error"] = error.description; status = 1
|
|
320
|
+
}
|
|
321
|
+
let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
|
|
322
|
+
print(String(decoding: data, as: UTF8.self))
|
|
323
|
+
done.signal()
|
|
324
|
+
}
|
|
325
|
+
case "send":
|
|
326
|
+
guard let key else { return kittyUsage() }
|
|
327
|
+
let context = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
|
|
328
|
+
let paths = ImageIntake.keepForPaste(images)
|
|
329
|
+
let text = Kitty.text(context: context, message: message, imagePaths: paths, pasteOnly: pasteOnly)
|
|
330
|
+
runner.send(key: key, text: text, pasteOnly: pasteOnly) { result in
|
|
331
|
+
switch result {
|
|
332
|
+
case .success: print(pasteOnly ? "pasted" : "sent")
|
|
333
|
+
case .failure(let error): print(error.description); status = 1
|
|
334
|
+
}
|
|
335
|
+
done.signal()
|
|
336
|
+
}
|
|
337
|
+
default: return kittyUsage()
|
|
338
|
+
}
|
|
339
|
+
done.wait()
|
|
340
|
+
return status
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private func kittyUsage() -> Int32 {
|
|
344
|
+
FileHandle.standardError.write("usage: ChiHelper --kitty list|send KEY [--paste-only] [--message M] [--image PATH] [--launch PATH]\n".data(using: .utf8)!)
|
|
345
|
+
return 2
|
|
346
|
+
}
|