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.
@@ -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 subcommand
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, and runner
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
@@ -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
+