poke-gate 0.1.1 → 0.1.5
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.
- package/.github/workflows/docs.yml +56 -0
- package/README.md +8 -4
- package/assets/screenshots/agents-editor.png +0 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/AgentsView.swift +540 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +83 -17
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +10 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +8 -2
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.xcworkspace/xcuserdata/fka.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/docs/.vitepress/config.mts +75 -0
- package/docs/agents/beeper.md +107 -0
- package/docs/agents/community.md +77 -0
- package/docs/agents/creating.md +132 -0
- package/docs/agents/index.md +85 -0
- package/docs/agents/installing.md +66 -0
- package/docs/agents/sharing.md +97 -0
- package/docs/cli.md +73 -0
- package/docs/getting-started.md +62 -0
- package/docs/how-it-works.md +56 -0
- package/docs/index.md +63 -0
- package/docs/macos-app.md +74 -0
- package/docs/package-lock.json +3629 -0
- package/docs/package.json +15 -0
- package/docs/public/CNAME +1 -0
- package/docs/public/agents-editor.png +0 -0
- package/docs/public/logo.png +0 -0
- package/docs/security.md +35 -0
- package/docs/tools.md +101 -0
- package/examples/agents/battery.30m.js +78 -0
- package/examples/agents/screentime.24h.js +86 -0
- package/examples/agents/wifi.30m.js +85 -0
- package/package.json +1 -1
- package/src/agents.js +20 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Deploy Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- 'docs/**'
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
pages: write
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
concurrency:
|
|
15
|
+
group: pages
|
|
16
|
+
cancel-in-progress: false
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
|
|
27
|
+
- name: Setup Node
|
|
28
|
+
uses: actions/setup-node@v4
|
|
29
|
+
with:
|
|
30
|
+
node-version: 22
|
|
31
|
+
cache: npm
|
|
32
|
+
cache-dependency-path: docs/package-lock.json
|
|
33
|
+
|
|
34
|
+
- name: Install dependencies
|
|
35
|
+
run: npm ci
|
|
36
|
+
working-directory: docs
|
|
37
|
+
|
|
38
|
+
- name: Build
|
|
39
|
+
run: npm run build
|
|
40
|
+
working-directory: docs
|
|
41
|
+
|
|
42
|
+
- name: Upload artifact
|
|
43
|
+
uses: actions/upload-pages-artifact@v3
|
|
44
|
+
with:
|
|
45
|
+
path: docs/.vitepress/dist
|
|
46
|
+
|
|
47
|
+
deploy:
|
|
48
|
+
environment:
|
|
49
|
+
name: github-pages
|
|
50
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
51
|
+
needs: build
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- name: Deploy to GitHub Pages
|
|
55
|
+
id: deployment
|
|
56
|
+
uses: actions/deploy-pages@v4
|
package/README.md
CHANGED
|
@@ -137,6 +137,10 @@ Config is stored at `~/.config/poke-gate/config.json`.
|
|
|
137
137
|
|
|
138
138
|
## Agents
|
|
139
139
|
|
|
140
|
+
<p align="center">
|
|
141
|
+
<img src="assets/screenshots/agents-editor.png" width="600" alt="Agents Editor">
|
|
142
|
+
</p>
|
|
143
|
+
|
|
140
144
|
Agents are scheduled scripts that run automatically in the background. They live in `~/.config/poke-gate/agents/` and follow a simple naming convention:
|
|
141
145
|
|
|
142
146
|
```
|
|
@@ -238,16 +242,16 @@ Only run Poke Gate on machines and networks you trust.
|
|
|
238
242
|
clients/
|
|
239
243
|
Poke macOS Gate/ macOS menu bar app (SwiftUI)
|
|
240
244
|
bin/
|
|
241
|
-
poke-gate.js CLI entry point, run-agent
|
|
245
|
+
poke-gate.js CLI entry point, run-agent + agent get subcommands
|
|
242
246
|
src/
|
|
243
247
|
app.js Startup: MCP server + tunnel + agent scheduler
|
|
244
|
-
agents.js Agent discovery, scheduling,
|
|
248
|
+
agents.js Agent discovery, scheduling, env loading, download
|
|
245
249
|
mcp-server.js JSON-RPC MCP handler with OS tools
|
|
246
250
|
tunnel.js PokeTunnel wrapper
|
|
247
251
|
examples/
|
|
248
252
|
agents/
|
|
249
|
-
beeper.1h.js Example: Beeper message digest
|
|
250
|
-
.env.beeper Example env file
|
|
253
|
+
beeper.1h.js Example: Beeper message digest agent
|
|
254
|
+
.env.beeper Example env file for beeper agent
|
|
251
255
|
```
|
|
252
256
|
|
|
253
257
|
## Credits
|
|
Binary file
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import Combine
|
|
3
|
+
|
|
4
|
+
struct AgentFile: Identifiable, Hashable {
|
|
5
|
+
let id: String
|
|
6
|
+
var fileName: String
|
|
7
|
+
var name: String
|
|
8
|
+
var agentId: String
|
|
9
|
+
var description: String
|
|
10
|
+
var interval: String
|
|
11
|
+
var path: URL
|
|
12
|
+
var envPath: URL
|
|
13
|
+
|
|
14
|
+
var hasEnv: Bool {
|
|
15
|
+
FileManager.default.fileExists(atPath: envPath.path)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
|
19
|
+
static func == (lhs: AgentFile, rhs: AgentFile) -> Bool { lhs.id == rhs.id }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@MainActor
|
|
23
|
+
class AgentsViewModel: ObservableObject {
|
|
24
|
+
@Published var agents: [AgentFile] = []
|
|
25
|
+
@Published var selectedAgent: AgentFile?
|
|
26
|
+
@Published var editorContent: String = ""
|
|
27
|
+
@Published var showingEnv: Bool = false
|
|
28
|
+
@Published var isRunning: Bool = false
|
|
29
|
+
@Published var lastRunOutput: String = ""
|
|
30
|
+
|
|
31
|
+
private var fileWatcher: DispatchSourceFileSystemObject?
|
|
32
|
+
private var dirFD: Int32 = -1
|
|
33
|
+
|
|
34
|
+
private var agentsDir: URL {
|
|
35
|
+
let configDir: URL
|
|
36
|
+
if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
|
|
37
|
+
configDir = URL(fileURLWithPath: xdg)
|
|
38
|
+
} else {
|
|
39
|
+
configDir = FileManager.default.homeDirectoryForCurrentUser
|
|
40
|
+
.appendingPathComponent(".config")
|
|
41
|
+
}
|
|
42
|
+
return configDir
|
|
43
|
+
.appendingPathComponent("poke-gate")
|
|
44
|
+
.appendingPathComponent("agents")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func startWatching() {
|
|
48
|
+
stopWatching()
|
|
49
|
+
let dir = agentsDir
|
|
50
|
+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
51
|
+
|
|
52
|
+
dirFD = open(dir.path, O_EVTONLY)
|
|
53
|
+
guard dirFD >= 0 else { return }
|
|
54
|
+
|
|
55
|
+
let source = DispatchSource.makeFileSystemObjectSource(
|
|
56
|
+
fileDescriptor: dirFD,
|
|
57
|
+
eventMask: [.write, .rename, .delete, .attrib],
|
|
58
|
+
queue: .main
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
source.setEventHandler { [weak self] in
|
|
62
|
+
self?.load()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
source.setCancelHandler { [weak self] in
|
|
66
|
+
if let fd = self?.dirFD, fd >= 0 { close(fd) }
|
|
67
|
+
self?.dirFD = -1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
source.resume()
|
|
71
|
+
fileWatcher = source
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func stopWatching() {
|
|
75
|
+
fileWatcher?.cancel()
|
|
76
|
+
fileWatcher = nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func load() {
|
|
80
|
+
let dir = agentsDir
|
|
81
|
+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
82
|
+
|
|
83
|
+
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
|
|
84
|
+
.filter({ $0.pathExtension == "js" }) else { return }
|
|
85
|
+
|
|
86
|
+
agents = files.compactMap { url in
|
|
87
|
+
let fileName = url.lastPathComponent
|
|
88
|
+
let base = fileName.replacingOccurrences(of: ".js", with: "")
|
|
89
|
+
let parts = base.split(separator: ".")
|
|
90
|
+
guard parts.count >= 2 else { return nil }
|
|
91
|
+
|
|
92
|
+
let interval = String(parts.last!)
|
|
93
|
+
let agentId = parts.dropLast().joined(separator: ".")
|
|
94
|
+
let envPath = dir.appendingPathComponent(".env.\(agentId)")
|
|
95
|
+
|
|
96
|
+
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
|
97
|
+
let meta = parseFrontmatter(content)
|
|
98
|
+
|
|
99
|
+
return AgentFile(
|
|
100
|
+
id: fileName,
|
|
101
|
+
fileName: fileName,
|
|
102
|
+
name: meta["name"] ?? agentId,
|
|
103
|
+
agentId: agentId,
|
|
104
|
+
description: meta["description"] ?? "",
|
|
105
|
+
interval: interval,
|
|
106
|
+
path: url,
|
|
107
|
+
envPath: envPath
|
|
108
|
+
)
|
|
109
|
+
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func select(_ agent: AgentFile) {
|
|
113
|
+
DispatchQueue.main.async {
|
|
114
|
+
self.selectedAgent = agent
|
|
115
|
+
self.showingEnv = false
|
|
116
|
+
self.loadContent()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func loadContent() {
|
|
121
|
+
guard let agent = selectedAgent else { return }
|
|
122
|
+
let url = showingEnv ? agent.envPath : agent.path
|
|
123
|
+
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? (showingEnv ? "# No .env file yet\n" : "")
|
|
124
|
+
DispatchQueue.main.async {
|
|
125
|
+
self.editorContent = content
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func save() {
|
|
130
|
+
guard let agent = selectedAgent else { return }
|
|
131
|
+
let url = showingEnv ? agent.envPath : agent.path
|
|
132
|
+
try? editorContent.write(to: url, atomically: true, encoding: .utf8)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func changeInterval(_ agent: AgentFile, to newInterval: String) {
|
|
136
|
+
let newFileName = "\(agent.agentId).\(newInterval).js"
|
|
137
|
+
let newPath = agentsDir.appendingPathComponent(newFileName)
|
|
138
|
+
guard newPath != agent.path else { return }
|
|
139
|
+
|
|
140
|
+
try? FileManager.default.moveItem(at: agent.path, to: newPath)
|
|
141
|
+
load()
|
|
142
|
+
|
|
143
|
+
if let updated = agents.first(where: { $0.agentId == agent.agentId }) {
|
|
144
|
+
select(updated)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func addAgent() {
|
|
149
|
+
let template = """
|
|
150
|
+
/**
|
|
151
|
+
* @agent my-agent
|
|
152
|
+
* @name My Agent
|
|
153
|
+
* @description Describe what this agent does.
|
|
154
|
+
* @interval 1h
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
import { Poke, getToken } from "poke";
|
|
158
|
+
|
|
159
|
+
const poke = new Poke({ apiKey: getToken() });
|
|
160
|
+
await poke.sendMessage("Hello from my agent!");
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
var name = "my-agent"
|
|
164
|
+
var counter = 1
|
|
165
|
+
while agents.contains(where: { $0.agentId == name }) {
|
|
166
|
+
name = "my-agent-\(counter)"
|
|
167
|
+
counter += 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let filePath = agentsDir.appendingPathComponent("\(name).1h.js")
|
|
171
|
+
try? template.write(to: filePath, atomically: true, encoding: .utf8)
|
|
172
|
+
load()
|
|
173
|
+
|
|
174
|
+
if let newAgent = agents.first(where: { $0.agentId == name }) {
|
|
175
|
+
select(newAgent)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func runAgent(_ agent: AgentFile) {
|
|
180
|
+
guard !isRunning else { return }
|
|
181
|
+
isRunning = true
|
|
182
|
+
lastRunOutput = ""
|
|
183
|
+
|
|
184
|
+
let fullPath = GateService().shellPath()
|
|
185
|
+
let proc = Process()
|
|
186
|
+
let pipe = Pipe()
|
|
187
|
+
|
|
188
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
189
|
+
proc.arguments = ["-c", "npx -y poke-gate run-agent \(agent.agentId)"]
|
|
190
|
+
proc.environment = ["HOME": NSHomeDirectory(), "PATH": fullPath]
|
|
191
|
+
proc.standardOutput = pipe
|
|
192
|
+
proc.standardError = pipe
|
|
193
|
+
proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
|
|
194
|
+
|
|
195
|
+
let handle = pipe.fileHandleForReading
|
|
196
|
+
handle.readabilityHandler = { [weak self] fh in
|
|
197
|
+
let data = fh.availableData
|
|
198
|
+
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
|
199
|
+
DispatchQueue.main.async {
|
|
200
|
+
self?.lastRunOutput += text
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
proc.terminationHandler = { [weak self] _ in
|
|
205
|
+
DispatchQueue.main.async {
|
|
206
|
+
self?.isRunning = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
do {
|
|
211
|
+
try proc.run()
|
|
212
|
+
} catch {
|
|
213
|
+
isRunning = false
|
|
214
|
+
lastRunOutput = "Failed to run: \(error.localizedDescription)"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func deleteAgent(_ agent: AgentFile) {
|
|
219
|
+
try? FileManager.default.removeItem(at: agent.path)
|
|
220
|
+
if agent.hasEnv {
|
|
221
|
+
try? FileManager.default.removeItem(at: agent.envPath)
|
|
222
|
+
}
|
|
223
|
+
if selectedAgent?.id == agent.id {
|
|
224
|
+
selectedAgent = nil
|
|
225
|
+
editorContent = ""
|
|
226
|
+
}
|
|
227
|
+
load()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private func parseFrontmatter(_ content: String) -> [String: String] {
|
|
231
|
+
guard let match = content.range(of: #"/\*\*[\s\S]*?\*/"#, options: .regularExpression) else { return [:] }
|
|
232
|
+
let block = String(content[match])
|
|
233
|
+
var meta: [String: String] = [:]
|
|
234
|
+
for line in block.split(separator: "\n") {
|
|
235
|
+
let s = String(line)
|
|
236
|
+
if let tagMatch = s.range(of: #"@(\w+)\s+(.*)"#, options: .regularExpression) {
|
|
237
|
+
let tagContent = String(s[tagMatch])
|
|
238
|
+
let parts = tagContent.dropFirst(1).split(separator: " ", maxSplits: 1)
|
|
239
|
+
if parts.count == 2 {
|
|
240
|
+
meta[String(parts[0])] = parts[1].trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "*/", with: "").trimmingCharacters(in: .whitespaces)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return meta
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
struct AgentsView: View {
|
|
249
|
+
@StateObject private var viewModel = AgentsViewModel()
|
|
250
|
+
|
|
251
|
+
var body: some View {
|
|
252
|
+
NavigationSplitView {
|
|
253
|
+
List(viewModel.agents, selection: Binding(
|
|
254
|
+
get: { viewModel.selectedAgent },
|
|
255
|
+
set: { if let a = $0 { viewModel.select(a) } }
|
|
256
|
+
)) { agent in
|
|
257
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
258
|
+
HStack {
|
|
259
|
+
Text(agent.name)
|
|
260
|
+
.font(.subheadline)
|
|
261
|
+
.fontWeight(.medium)
|
|
262
|
+
|
|
263
|
+
Spacer()
|
|
264
|
+
|
|
265
|
+
Text(agent.interval)
|
|
266
|
+
.font(.caption2)
|
|
267
|
+
.fontWeight(.medium)
|
|
268
|
+
.padding(.horizontal, 6)
|
|
269
|
+
.padding(.vertical, 2)
|
|
270
|
+
.background(.quaternary)
|
|
271
|
+
.cornerRadius(4)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if !agent.description.isEmpty {
|
|
275
|
+
Text(agent.description)
|
|
276
|
+
.font(.caption)
|
|
277
|
+
.foregroundStyle(.secondary)
|
|
278
|
+
.lineLimit(2)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
.padding(.vertical, 2)
|
|
282
|
+
.tag(agent)
|
|
283
|
+
.contextMenu {
|
|
284
|
+
Button("Delete", role: .destructive) {
|
|
285
|
+
viewModel.deleteAgent(agent)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
.listStyle(.sidebar)
|
|
290
|
+
.navigationSplitViewColumnWidth(min: 180, ideal: 220)
|
|
291
|
+
.safeAreaInset(edge: .bottom) {
|
|
292
|
+
Button {
|
|
293
|
+
viewModel.addAgent()
|
|
294
|
+
} label: {
|
|
295
|
+
Label("New Agent", systemImage: "plus")
|
|
296
|
+
.font(.caption)
|
|
297
|
+
}
|
|
298
|
+
.buttonStyle(.plain)
|
|
299
|
+
.padding(8)
|
|
300
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
301
|
+
}
|
|
302
|
+
} detail: {
|
|
303
|
+
if let agent = viewModel.selectedAgent {
|
|
304
|
+
AgentDetailView(viewModel: viewModel, agent: agent)
|
|
305
|
+
} else {
|
|
306
|
+
Text("Select an agent")
|
|
307
|
+
.foregroundStyle(.secondary)
|
|
308
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
.onAppear {
|
|
312
|
+
viewModel.load()
|
|
313
|
+
viewModel.startWatching()
|
|
314
|
+
}
|
|
315
|
+
.onDisappear {
|
|
316
|
+
viewModel.stopWatching()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
struct AgentDetailView: View {
|
|
322
|
+
@ObservedObject var viewModel: AgentsViewModel
|
|
323
|
+
let agent: AgentFile
|
|
324
|
+
@State private var intervalInput: String = ""
|
|
325
|
+
|
|
326
|
+
var body: some View {
|
|
327
|
+
VStack(spacing: 0) {
|
|
328
|
+
HStack {
|
|
329
|
+
Text(agent.fileName)
|
|
330
|
+
.font(.system(.caption, design: .monospaced))
|
|
331
|
+
.foregroundStyle(.secondary)
|
|
332
|
+
|
|
333
|
+
Spacer()
|
|
334
|
+
|
|
335
|
+
HStack(spacing: 4) {
|
|
336
|
+
Text("every")
|
|
337
|
+
.font(.caption)
|
|
338
|
+
.foregroundStyle(.secondary)
|
|
339
|
+
|
|
340
|
+
TextField("1h", text: $intervalInput)
|
|
341
|
+
.font(.system(.caption, design: .monospaced))
|
|
342
|
+
.textFieldStyle(.roundedBorder)
|
|
343
|
+
.frame(width: 50)
|
|
344
|
+
.onSubmit {
|
|
345
|
+
if !intervalInput.isEmpty && intervalInput != agent.interval {
|
|
346
|
+
viewModel.changeInterval(agent, to: intervalInput)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
Button {
|
|
352
|
+
viewModel.runAgent(agent)
|
|
353
|
+
} label: {
|
|
354
|
+
Label(viewModel.isRunning ? "Running…" : "Run", systemImage: "play.fill")
|
|
355
|
+
.font(.caption)
|
|
356
|
+
}
|
|
357
|
+
.disabled(viewModel.isRunning)
|
|
358
|
+
.padding(.leading, 4)
|
|
359
|
+
}
|
|
360
|
+
.padding(.horizontal, 12)
|
|
361
|
+
.padding(.vertical, 8)
|
|
362
|
+
|
|
363
|
+
Divider()
|
|
364
|
+
|
|
365
|
+
HStack(spacing: 0) {
|
|
366
|
+
Button {
|
|
367
|
+
viewModel.showingEnv = false
|
|
368
|
+
viewModel.loadContent()
|
|
369
|
+
} label: {
|
|
370
|
+
Text(agent.fileName)
|
|
371
|
+
.font(.caption)
|
|
372
|
+
.padding(.horizontal, 12)
|
|
373
|
+
.padding(.vertical, 6)
|
|
374
|
+
.background(viewModel.showingEnv ? Color.clear : Color.accentColor.opacity(0.15))
|
|
375
|
+
}
|
|
376
|
+
.buttonStyle(.plain)
|
|
377
|
+
|
|
378
|
+
Button {
|
|
379
|
+
viewModel.showingEnv = true
|
|
380
|
+
viewModel.loadContent()
|
|
381
|
+
} label: {
|
|
382
|
+
Text(".env.\(agent.agentId)")
|
|
383
|
+
.font(.caption)
|
|
384
|
+
.padding(.horizontal, 12)
|
|
385
|
+
.padding(.vertical, 6)
|
|
386
|
+
.background(viewModel.showingEnv ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
387
|
+
}
|
|
388
|
+
.buttonStyle(.plain)
|
|
389
|
+
|
|
390
|
+
Spacer()
|
|
391
|
+
|
|
392
|
+
Button {
|
|
393
|
+
viewModel.save()
|
|
394
|
+
} label: {
|
|
395
|
+
Label("Save", systemImage: "square.and.arrow.down")
|
|
396
|
+
.font(.caption)
|
|
397
|
+
}
|
|
398
|
+
.buttonStyle(.plain)
|
|
399
|
+
.padding(.horizontal, 12)
|
|
400
|
+
.keyboardShortcut("s")
|
|
401
|
+
}
|
|
402
|
+
.padding(.vertical, 2)
|
|
403
|
+
.background(.quaternary.opacity(0.3))
|
|
404
|
+
|
|
405
|
+
Divider()
|
|
406
|
+
|
|
407
|
+
HighlightedCodeEditor(
|
|
408
|
+
text: $viewModel.editorContent,
|
|
409
|
+
language: viewModel.showingEnv ? "env" : "javascript"
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
.onAppear {
|
|
413
|
+
intervalInput = agent.interval
|
|
414
|
+
}
|
|
415
|
+
.onChange(of: agent.id) { _, _ in
|
|
416
|
+
intervalInput = agent.interval
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// MARK: - Syntax Highlighting Editor
|
|
422
|
+
|
|
423
|
+
struct HighlightedCodeEditor: NSViewRepresentable {
|
|
424
|
+
@Binding var text: String
|
|
425
|
+
var language: String
|
|
426
|
+
|
|
427
|
+
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
428
|
+
|
|
429
|
+
func makeNSView(context: Context) -> NSScrollView {
|
|
430
|
+
let scrollView = NSTextView.scrollableTextView()
|
|
431
|
+
let textView = scrollView.documentView as! NSTextView
|
|
432
|
+
|
|
433
|
+
textView.isEditable = true
|
|
434
|
+
textView.isSelectable = true
|
|
435
|
+
textView.allowsUndo = true
|
|
436
|
+
textView.isRichText = false
|
|
437
|
+
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
438
|
+
textView.backgroundColor = .textBackgroundColor
|
|
439
|
+
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
440
|
+
textView.isAutomaticDashSubstitutionEnabled = false
|
|
441
|
+
textView.isAutomaticTextReplacementEnabled = false
|
|
442
|
+
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
443
|
+
textView.delegate = context.coordinator
|
|
444
|
+
textView.textContainerInset = NSSize(width: 8, height: 8)
|
|
445
|
+
|
|
446
|
+
return scrollView
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
|
450
|
+
let textView = nsView.documentView as! NSTextView
|
|
451
|
+
if textView.string != text {
|
|
452
|
+
let selectedRanges = textView.selectedRanges
|
|
453
|
+
textView.string = text
|
|
454
|
+
SyntaxHighlighter.highlight(textView: textView, language: language)
|
|
455
|
+
textView.selectedRanges = selectedRanges
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
class Coordinator: NSObject, NSTextViewDelegate {
|
|
460
|
+
var parent: HighlightedCodeEditor
|
|
461
|
+
init(_ parent: HighlightedCodeEditor) { self.parent = parent }
|
|
462
|
+
|
|
463
|
+
func textDidChange(_ notification: Notification) {
|
|
464
|
+
guard let textView = notification.object as? NSTextView else { return }
|
|
465
|
+
parent.text = textView.string
|
|
466
|
+
SyntaxHighlighter.highlight(textView: textView, language: parent.language)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
enum SyntaxHighlighter {
|
|
472
|
+
static func highlight(textView: NSTextView, language: String) {
|
|
473
|
+
let storage = textView.textStorage!
|
|
474
|
+
let source = storage.string
|
|
475
|
+
let fullRange = NSRange(location: 0, length: (source as NSString).length)
|
|
476
|
+
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
477
|
+
|
|
478
|
+
storage.beginEditing()
|
|
479
|
+
storage.addAttribute(.font, value: font, range: fullRange)
|
|
480
|
+
storage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange)
|
|
481
|
+
|
|
482
|
+
if language == "env" {
|
|
483
|
+
highlightEnv(storage: storage, source: source)
|
|
484
|
+
} else {
|
|
485
|
+
highlightJS(storage: storage, source: source)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
storage.endEditing()
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private static func apply(_ storage: NSTextStorage, pattern: String, color: NSColor, source: String, options: NSRegularExpression.Options = []) {
|
|
492
|
+
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return }
|
|
493
|
+
let fullRange = NSRange(location: 0, length: (source as NSString).length)
|
|
494
|
+
for match in regex.matches(in: source, range: fullRange) {
|
|
495
|
+
storage.addAttribute(.foregroundColor, value: color, range: match.range)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private static func highlightJS(storage: NSTextStorage, source: String) {
|
|
500
|
+
let keyword = NSColor.systemPink
|
|
501
|
+
let string = NSColor.systemGreen
|
|
502
|
+
let comment = NSColor.systemGray
|
|
503
|
+
let number = NSColor.systemOrange
|
|
504
|
+
let tag = NSColor.systemCyan
|
|
505
|
+
let builtIn = NSColor.systemPurple
|
|
506
|
+
|
|
507
|
+
// Comments (block and line)
|
|
508
|
+
apply(storage, pattern: #"/\*[\s\S]*?\*/"#, color: comment, source: source, options: .dotMatchesLineSeparators)
|
|
509
|
+
apply(storage, pattern: #"//.*$"#, color: comment, source: source, options: .anchorsMatchLines)
|
|
510
|
+
|
|
511
|
+
// Strings
|
|
512
|
+
apply(storage, pattern: #"\"(?:[^\"\\]|\\.)*\""#, color: string, source: source)
|
|
513
|
+
apply(storage, pattern: #"'(?:[^'\\]|\\.)*'"#, color: string, source: source)
|
|
514
|
+
apply(storage, pattern: #"`(?:[^`\\]|\\.)*`"#, color: string, source: source)
|
|
515
|
+
|
|
516
|
+
// Numbers
|
|
517
|
+
apply(storage, pattern: #"\b\d+\.?\d*\b"#, color: number, source: source)
|
|
518
|
+
|
|
519
|
+
// Keywords
|
|
520
|
+
apply(storage, pattern: #"\b(import|export|from|const|let|var|function|async|await|return|if|else|for|while|do|switch|case|break|continue|new|class|try|catch|throw|finally|default|typeof|instanceof|in|of|void|null|undefined|true|false|this|super)\b"#, color: keyword, source: source)
|
|
521
|
+
|
|
522
|
+
// Built-ins
|
|
523
|
+
apply(storage, pattern: #"\b(console|process|require|module|exports|Promise|Array|Object|String|Number|JSON|Math|Date|Error|Map|Set|Buffer|URL|fetch|setTimeout|setInterval)\b"#, color: builtIn, source: source)
|
|
524
|
+
|
|
525
|
+
// JSDoc tags
|
|
526
|
+
apply(storage, pattern: #"@\w+"#, color: tag, source: source)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private static func highlightEnv(storage: NSTextStorage, source: String) {
|
|
530
|
+
// Comments
|
|
531
|
+
apply(storage, pattern: #"^\s*#.*$"#, color: .systemGray, source: source, options: .anchorsMatchLines)
|
|
532
|
+
|
|
533
|
+
// Keys
|
|
534
|
+
apply(storage, pattern: #"^[A-Z_][A-Z0-9_]*(?==)"#, color: .systemCyan, source: source, options: .anchorsMatchLines)
|
|
535
|
+
|
|
536
|
+
// Values (after =)
|
|
537
|
+
apply(storage, pattern: #"(?<==).+$"#, color: .systemGreen, source: source, options: .anchorsMatchLines)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|