poke-gate 0.2.2 → 0.3.1

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/README.md CHANGED
@@ -28,6 +28,25 @@ Run Poke Gate on your Mac, then message Poke from iMessage, Telegram, or SMS to
28
28
  brew install f/tap/poke-gate
29
29
  ```
30
30
 
31
+ **Install via npx**
32
+
33
+ If you have Node.js installed, you can download and install the macOS app with a single command:
34
+
35
+ ```bash
36
+ npx poke-gate download-macos
37
+ ```
38
+
39
+ This downloads the latest DMG from GitHub Releases, installs the app to `/Applications`, and clears the quarantine flag automatically.
40
+
41
+ **Don't have Node.js?** Install it first:
42
+
43
+ ```bash
44
+ # Option 1: Homebrew
45
+ brew install node
46
+
47
+ # Option 2: Download from https://nodejs.org
48
+ ```
49
+
31
50
  **Manual download**
32
51
 
33
52
  Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f/poke-gate/releases), open it, and drag to Applications. Since the app is not notarized, you may need to run:
@@ -36,7 +55,9 @@ Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f
36
55
  xattr -cr /Applications/Poke\ macOS\ Gate.app
37
56
  ```
38
57
 
39
- **CLI** (no macOS app needed)
58
+ **CLI only** (no macOS app needed)
59
+
60
+ If you just want to run poke-gate from the terminal without the menu bar app:
40
61
 
41
62
  ```bash
42
63
  npx poke-gate
@@ -123,7 +144,9 @@ Hit **Run** in Xcode, or build from the command line:
123
144
 
124
145
  ## CLI usage
125
146
 
126
- If you prefer the command line over the macOS app:
147
+ The CLI requires [Node.js](https://nodejs.org) 18 or later. If you don't have it, install via `brew install node` or download from [nodejs.org](https://nodejs.org).
148
+
149
+ Start the gate:
127
150
 
128
151
  ```bash
129
152
  npx poke-gate
@@ -142,6 +165,12 @@ npx poke-gate --mode limited
142
165
  npx poke-gate --mode sandbox
143
166
  ```
144
167
 
168
+ Install or update the macOS app:
169
+
170
+ ```bash
171
+ npx poke-gate download-macos
172
+ ```
173
+
145
174
  Config is stored at `~/.config/poke-gate/config.json`.
146
175
 
147
176
  ## Agents
@@ -264,30 +293,6 @@ POKE_GATE_PERMISSION_MODE=limited npx poke-gate
264
293
 
265
294
  Only run Poke Gate on machines and networks you trust. Use `limited` or `sandbox` mode if you want tighter restrictions.
266
295
 
267
- ## Project structure
268
-
269
- ```
270
- clients/
271
- Poke macOS Gate/ macOS menu bar app (SwiftUI)
272
- bin/
273
- poke-gate.js CLI entry point with --mode flag
274
- src/
275
- app.js Startup: MCP server + tunnel + agent scheduler
276
- agents.js Agent discovery, scheduling, env loading, download
277
- mcp-server.js JSON-RPC MCP handler, tools, access policy, sandbox
278
- permission-service.js HMAC approval tokens, session whitelisting
279
- tunnel.js PokeTunnel wrapper
280
- test/
281
- mcp-server-access-policy.test.js
282
- mcp-server-loop-guard.test.js
283
- mcp-server-sandbox-command.test.js
284
- permission-service.test.js
285
- examples/
286
- agents/
287
- beeper.1h.js Example: Beeper message digest agent
288
- .env.beeper Example env file for beeper agent
289
- ```
290
-
291
296
  ## Credits
292
297
 
293
298
  - [Poke](https://poke.com) by [The Interaction Company of California](https://interaction.co)
package/bin/poke-gate.js CHANGED
@@ -39,6 +39,12 @@ async function main() {
39
39
  const prompt = promptIdx !== -1 ? args.slice(promptIdx + 1).join(" ") : args.slice(2).join(" ") || null;
40
40
  const { createAgent } = await import("../src/agent-create.js");
41
41
  await createAgent(prompt);
42
+ } else if (args[0] === "download-macos") {
43
+ const { downloadMacOSApp } = await import("../src/download-macos.js");
44
+ await downloadMacOSApp();
45
+ } else if (args[0] === "take-screenshot") {
46
+ const { takeScreenshot } = await import("../src/take-screenshot.js");
47
+ await takeScreenshot();
42
48
  } else {
43
49
  const mode = parseMode();
44
50
  if (mode) {
@@ -4,7 +4,7 @@ struct AccessibilityPermissionView: View {
4
4
  @ObservedObject var service: GateService
5
5
 
6
6
  var body: some View {
7
- let granted = service.hasSystemPermissionsGranted
7
+ let granted = service.isPermissionGranted(.accessibility)
8
8
 
9
9
  VStack(alignment: .leading, spacing: 10) {
10
10
  HStack(spacing: 8) {
@@ -1,6 +1,5 @@
1
1
  import Foundation
2
2
  import Combine
3
- import ScreenCaptureKit
4
3
  import AppKit
5
4
  import CoreGraphics
6
5
  import ApplicationServices
@@ -41,30 +40,35 @@ class GateService: ObservableObject {
41
40
 
42
41
  enum SystemPermission: String, CaseIterable, Identifiable {
43
42
  case accessibility
43
+ case screenRecording
44
44
 
45
45
  var id: String { rawValue }
46
46
 
47
47
  var title: String {
48
48
  switch self {
49
49
  case .accessibility: return "Accessibility"
50
+ case .screenRecording: return "Screen Recording"
50
51
  }
51
52
  }
52
53
 
53
54
  var subtitle: String {
54
55
  switch self {
55
56
  case .accessibility: return "Needed for keyboard, mouse, and automation-style control."
57
+ case .screenRecording: return "Needed for the take_screenshot tool."
56
58
  }
57
59
  }
58
60
 
59
61
  var settingsURL: String {
60
62
  switch self {
61
63
  case .accessibility: return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
64
+ case .screenRecording: return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
62
65
  }
63
66
  }
64
67
 
65
68
  var systemImageName: String {
66
69
  switch self {
67
70
  case .accessibility: return "figure.wave"
71
+ case .screenRecording: return "camera.viewfinder"
68
72
  }
69
73
  }
70
74
  }
@@ -97,6 +101,10 @@ class GateService: ObservableObject {
97
101
  @Published var permissionMode: PermissionMode
98
102
  @Published var hasCompletedSetup: Bool
99
103
  @Published var systemPermissionStatuses: [SystemPermissionStatus] = []
104
+ @Published var availableUpdate: String? = nil
105
+ @Published var isUpdating = false
106
+ @Published var isCheckingForUpdate = false
107
+ @Published var updateCheckResult: String? = nil
100
108
 
101
109
  private var hasAutoStarted = false
102
110
  private var process: Process?
@@ -121,7 +129,9 @@ class GateService: ObservableObject {
121
129
  object: nil,
122
130
  queue: .main
123
131
  ) { [weak self] _ in
124
- self?.refreshSystemPermissions()
132
+ Task { @MainActor in
133
+ self?.refreshSystemPermissions()
134
+ }
125
135
  }
126
136
  }
127
137
 
@@ -233,74 +243,124 @@ class GateService: ObservableObject {
233
243
  case .accessibility:
234
244
  let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
235
245
  _ = AXIsProcessTrustedWithOptions(options)
246
+ case .screenRecording:
247
+ guard let url = URL(string: permission.settingsURL) else { return }
248
+ NSWorkspace.shared.open(url)
236
249
  }
237
250
  }
238
251
 
239
- func captureAndSend() {
240
- appendLog("Screenshot requested via deeplink.")
241
252
 
242
- Task {
253
+ func autoStartIfNeeded() {
254
+ guard !hasAutoStarted else { return }
255
+ hasAutoStarted = true
256
+ if hasAPIKey {
257
+ start()
258
+ }
259
+ checkForUpdate(silent: true)
260
+ }
261
+
262
+ func checkForUpdate(silent: Bool = false) {
263
+ let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
264
+ isCheckingForUpdate = true
265
+ updateCheckResult = nil
266
+
267
+ Task.detached {
268
+ let start = ContinuousClock.now
269
+ var found = false
270
+
271
+ defer {
272
+ Task { @MainActor in
273
+ self.isCheckingForUpdate = false
274
+ if !found && !silent {
275
+ self.updateCheckResult = "You're on the latest version."
276
+ }
277
+ }
278
+ }
279
+
280
+ guard let url = URL(string: "https://registry.npmjs.org/poke-gate/latest") else { return }
243
281
  do {
244
- let content = try await SCShareableContent.current
245
- guard let display = content.displays.first else {
246
- appendLog("No display found for screenshot.")
247
- return
282
+ let (data, _) = try await URLSession.shared.data(from: url)
283
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
284
+ let latestVersion = json["version"] as? String else { return }
285
+
286
+ let elapsed = ContinuousClock.now - start
287
+ if elapsed < .seconds(1) {
288
+ try await Task.sleep(for: .seconds(1) - elapsed)
248
289
  }
249
290
 
250
- let filter = SCContentFilter(display: display, excludingWindows: [])
251
- let config = SCStreamConfiguration()
252
- config.width = display.width * 2
253
- config.height = display.height * 2
254
- config.capturesAudio = false
255
-
256
- let image = try await SCScreenshotManager.captureImage(
257
- contentFilter: filter,
258
- configuration: config
259
- )
260
-
261
- let rep = NSBitmapImageRep(cgImage: image)
262
- guard let pngData = rep.representation(using: .png, properties: [:]) else {
263
- appendLog("Failed to encode screenshot as PNG.")
264
- return
291
+ if Self.isNewer(latestVersion, than: currentVersion) {
292
+ found = true
293
+ await MainActor.run {
294
+ self.availableUpdate = latestVersion
295
+ self.appendLog("Update available: v\(latestVersion) (current: v\(currentVersion))")
296
+ }
265
297
  }
298
+ } catch {}
299
+ }
300
+ }
266
301
 
267
- let tempPath = NSTemporaryDirectory() + "poke-gate-screenshot.png"
268
- let tempURL = URL(fileURLWithPath: tempPath)
269
- try pngData.write(to: tempURL)
270
- appendLog("Screenshot saved to \(tempPath) (\(pngData.count) bytes)")
302
+ private nonisolated static func isNewer(_ remote: String, than local: String) -> Bool {
303
+ let r = remote.split(separator: ".").compactMap { Int($0) }
304
+ let l = local.split(separator: ".").compactMap { Int($0) }
305
+ for i in 0..<max(r.count, l.count) {
306
+ let rv = i < r.count ? r[i] : 0
307
+ let lv = i < l.count ? l[i] : 0
308
+ if rv > lv { return true }
309
+ if rv < lv { return false }
310
+ }
311
+ return false
312
+ }
271
313
 
272
- guard let token = loadPokeLoginToken() else {
273
- appendLog("Cannot send screenshot: not signed in to Poke.")
274
- return
275
- }
314
+ func performUpdate() {
315
+ guard let version = availableUpdate else { return }
316
+ isUpdating = true
317
+ appendLog("Updating to v\(version)...")
318
+
319
+ stop()
320
+
321
+ let fullPath = shellPath()
322
+ let npxBin = findNpx()
276
323
 
277
- let url = URL(string: "https://poke.com/api/v1/inbound/api-message")!
278
- var request = URLRequest(url: url)
279
- request.httpMethod = "POST"
280
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
281
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
324
+ let proc = Process()
325
+ let pipe = Pipe()
326
+ proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
327
+ proc.arguments = ["-c", "\(npxBin) -y poke-gate@\(version) download-macos"]
328
+ proc.environment = ProcessInfo.processInfo.environment.merging(
329
+ ["PATH": fullPath],
330
+ uniquingKeysWith: { _, new in new }
331
+ )
332
+ proc.standardOutput = pipe
333
+ proc.standardError = pipe
334
+ proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
282
335
 
283
- let message = "Here's a screenshot of my screen right now. [Image attached as base64 PNG, \(pngData.count) bytes, \(display.width)x\(display.height)]"
284
- let body: [String: Any] = ["message": message]
285
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
336
+ let handle = pipe.fileHandleForReading
337
+ handle.readabilityHandler = { [weak self] fh in
338
+ let data = fh.availableData
339
+ guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
340
+ DispatchQueue.main.async {
341
+ for l in line.components(separatedBy: .newlines) where !l.isEmpty {
342
+ self?.appendLog(l)
343
+ }
344
+ }
345
+ }
286
346
 
287
- let (_, response) = try await URLSession.shared.data(for: request)
288
- if let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 {
289
- appendLog("Screenshot sent to Poke.")
347
+ proc.terminationHandler = { [weak self] proc in
348
+ DispatchQueue.main.async {
349
+ self?.isUpdating = false
350
+ if proc.terminationStatus == 0 {
351
+ self?.appendLog("Update complete. The app will relaunch.")
352
+ self?.availableUpdate = nil
290
353
  } else {
291
- appendLog("Failed to send screenshot to Poke.")
354
+ self?.appendLog("Update failed (exit \(proc.terminationStatus)).")
292
355
  }
293
- } catch {
294
- appendLog("Screenshot error: \(error.localizedDescription)")
295
356
  }
296
357
  }
297
- }
298
358
 
299
- func autoStartIfNeeded() {
300
- guard !hasAutoStarted else { return }
301
- hasAutoStarted = true
302
- if hasAPIKey {
303
- start()
359
+ do {
360
+ try proc.run()
361
+ } catch {
362
+ isUpdating = false
363
+ appendLog("Failed to start update: \(error.localizedDescription)")
304
364
  }
305
365
  }
306
366
 
@@ -496,7 +556,7 @@ class GateService: ObservableObject {
496
556
  private func killOrphanedProcesses() {
497
557
  let task = Process()
498
558
  task.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
499
- task.arguments = ["-f", "poke-gate"]
559
+ task.arguments = ["-f", "node.*poke-gate.*app\\.js"]
500
560
  try? task.run()
501
561
  task.waitUntilExit()
502
562
  }
@@ -677,14 +737,10 @@ class GateService: ObservableObject {
677
737
  writeConfig(json)
678
738
  }
679
739
 
680
- private func isPermissionGranted(_ permission: SystemPermission) -> Bool {
740
+ func isPermissionGranted(_ permission: SystemPermission) -> Bool {
681
741
  switch permission {
682
742
  case .accessibility:
683
743
  if AXIsProcessTrusted() { return true }
684
- // AXIsProcessTrusted() can return false despite the toggle being ON
685
- // in System Settings when the TCC entry's code signature doesn't match
686
- // the running binary (ad-hoc signing, rebuild from Xcode, etc.).
687
- // Probe the API directly as a fallback.
688
744
  let systemWide = AXUIElementCreateSystemWide()
689
745
  var value: AnyObject?
690
746
  let result = AXUIElementCopyAttributeValue(
@@ -693,6 +749,8 @@ class GateService: ObservableObject {
693
749
  &value
694
750
  )
695
751
  return result == .success || result == .noValue || result == .attributeUnsupported
752
+ case .screenRecording:
753
+ return CGPreflightScreenCaptureAccess()
696
754
  }
697
755
  }
698
756
 
@@ -6,16 +6,5 @@
6
6
  <string>dev.fka.Poke-macOS-Gate</string>
7
7
  <key>LSUIElement</key>
8
8
  <true/>
9
- <key>CFBundleURLTypes</key>
10
- <array>
11
- <dict>
12
- <key>CFBundleURLName</key>
13
- <string>dev.fka.Poke-macOS-Gate</string>
14
- <key>CFBundleURLSchemes</key>
15
- <array>
16
- <string>poke-gate</string>
17
- </array>
18
- </dict>
19
- </array>
20
9
  </dict>
21
10
  </plist>
@@ -3,15 +3,6 @@ import ServiceManagement
3
3
 
4
4
  class AppDelegate: NSObject, NSApplicationDelegate {
5
5
  var service: GateService?
6
-
7
- func application(_ application: NSApplication, open urls: [URL]) {
8
- for url in urls {
9
- guard url.scheme == "poke-gate" else { continue }
10
- if url.host == "screenshot" {
11
- service?.captureAndSend()
12
- }
13
- }
14
- }
15
6
  }
16
7
 
17
8
  @main
@@ -111,6 +102,9 @@ struct PopoverContent: View {
111
102
  SetupView(service: service)
112
103
  } else {
113
104
  VStack(spacing: 10) {
105
+ if service.availableUpdate != nil {
106
+ updateBanner
107
+ }
114
108
  statusSection
115
109
  recentActivitySection
116
110
  accessModeSection
@@ -119,8 +113,8 @@ struct PopoverContent: View {
119
113
  }
120
114
  .frame(width: 320)
121
115
  .padding(10)
122
- .onChange(of: service.hasSystemPermissionsGranted) { _, granted in
123
- if granted && pendingFullMode {
116
+ .onChange(of: service.systemPermissionStatuses) { _, _ in
117
+ if service.isPermissionGranted(.accessibility) && pendingFullMode {
124
118
  pendingFullMode = false
125
119
  service.setPermissionMode(.full)
126
120
  }
@@ -128,6 +122,37 @@ struct PopoverContent: View {
128
122
  }
129
123
  }
130
124
 
125
+ private var updateBanner: some View {
126
+ HStack(spacing: 8) {
127
+ Image(systemName: "arrow.down.circle.fill")
128
+ .foregroundStyle(.blue)
129
+
130
+ VStack(alignment: .leading, spacing: 1) {
131
+ Text("Update available: v\(service.availableUpdate ?? "")")
132
+ .font(.caption)
133
+ .fontWeight(.semibold)
134
+ Text("A new version of Poke Gate is ready.")
135
+ .font(.caption2)
136
+ .foregroundStyle(.secondary)
137
+ }
138
+
139
+ Spacer()
140
+
141
+ if service.isUpdating {
142
+ ProgressView()
143
+ .controlSize(.small)
144
+ } else {
145
+ Button("Update") {
146
+ service.performUpdate()
147
+ }
148
+ .controlSize(.small)
149
+ .buttonStyle(.borderedProminent)
150
+ }
151
+ }
152
+ .padding(10)
153
+ .macPanelStyle(.neutral, cornerRadius: 10)
154
+ }
155
+
131
156
  private var statusSection: some View {
132
157
  VStack(alignment: .leading, spacing: 6) {
133
158
  HStack(spacing: 8) {
@@ -303,8 +328,10 @@ struct PopoverContent: View {
303
328
  .macPanelStyle(.neutral, cornerRadius: 12)
304
329
  }
305
330
 
331
+ @State private var refreshRotation: Double = 0
332
+
306
333
  private var footerSection: some View {
307
- HStack {
334
+ HStack(spacing: 6) {
308
335
  Button {
309
336
  NSApp.activate(ignoringOtherApps: true)
310
337
  openWindow(id: "about")
@@ -315,6 +342,28 @@ struct PopoverContent: View {
315
342
  }
316
343
  .buttonStyle(.plain)
317
344
 
345
+ Button {
346
+ service.checkForUpdate()
347
+ } label: {
348
+ Image(systemName: "arrow.triangle.2.circlepath")
349
+ .font(.system(size: 9))
350
+ .foregroundStyle(MacVisualStyle.sectionTitleColor)
351
+ .rotationEffect(.degrees(refreshRotation))
352
+ }
353
+ .buttonStyle(.plain)
354
+ .disabled(service.isCheckingForUpdate)
355
+ .onChange(of: service.isCheckingForUpdate) { _, checking in
356
+ if checking {
357
+ withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
358
+ refreshRotation = 360
359
+ }
360
+ } else {
361
+ withAnimation(.default) {
362
+ refreshRotation = 0
363
+ }
364
+ }
365
+ }
366
+
318
367
  Spacer()
319
368
 
320
369
  Text("Not affiliated with Poke")
@@ -322,6 +371,18 @@ struct PopoverContent: View {
322
371
  .foregroundStyle(MacVisualStyle.sectionTitleColor.opacity(0.7))
323
372
  }
324
373
  .padding(.horizontal, 6)
374
+ .onChange(of: service.updateCheckResult) { _, result in
375
+ guard let message = result else { return }
376
+ service.updateCheckResult = nil
377
+ DispatchQueue.main.async {
378
+ let alert = NSAlert()
379
+ alert.messageText = "Version Check"
380
+ alert.informativeText = message
381
+ alert.alertStyle = .informational
382
+ alert.addButton(withTitle: "OK")
383
+ alert.runModal()
384
+ }
385
+ }
325
386
  }
326
387
 
327
388
  private func sectionTitle(_ text: String) -> some View {
@@ -366,7 +427,7 @@ struct PopoverContent: View {
366
427
  private func handleModeSelection(_ mode: GateService.PermissionMode) {
367
428
  guard mode != service.permissionMode else { return }
368
429
 
369
- if mode == .full && !service.hasSystemPermissionsGranted {
430
+ if mode == .full && !service.isPermissionGranted(.accessibility) {
370
431
  pendingFullMode = true
371
432
  service.openSystemPermission(.accessibility)
372
433
  } else {
@@ -286,7 +286,7 @@
286
286
  "@executable_path/../Frameworks",
287
287
  );
288
288
  MACOSX_DEPLOYMENT_TARGET = 15.0;
289
- MARKETING_VERSION = 0.2.1;
289
+ MARKETING_VERSION = 0.3.0;
290
290
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
291
291
  PRODUCT_NAME = "$(TARGET_NAME)";
292
292
  REGISTER_APP_GROUPS = YES;
@@ -322,7 +322,7 @@
322
322
  "@executable_path/../Frameworks",
323
323
  );
324
324
  MACOSX_DEPLOYMENT_TARGET = 15.0;
325
- MARKETING_VERSION = 0.2.1;
325
+ MARKETING_VERSION = 0.3.0;
326
326
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
327
327
  PRODUCT_NAME = "$(TARGET_NAME)";
328
328
  REGISTER_APP_GROUPS = YES;
package/docs/cli.md CHANGED
@@ -82,6 +82,22 @@ npx poke-gate agent create --prompt "send me a daily git commit summary across a
82
82
  npx poke-gate agent create --prompt "track Spotify listening and log my music taste"
83
83
  ```
84
84
 
85
+ ## Download the macOS app
86
+
87
+ ```bash
88
+ npx poke-gate download-macos
89
+ ```
90
+
91
+ Downloads and installs the Poke macOS Gate app from GitHub Releases. Matches the version of the npm package being run.
92
+
93
+ This command:
94
+ 1. Downloads the DMG from the matching GitHub release
95
+ 2. Mounts the DMG, copies the app to `/Applications`
96
+ 3. Clears the quarantine flag (`xattr -cr`)
97
+ 4. Launches the app
98
+
99
+ The macOS app also checks for updates automatically on startup and shows a banner when a new version is available.
100
+
85
101
  ## Install an agent
86
102
 
87
103
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poke-gate",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Expose your machine to your Poke AI assistant via MCP tunnel",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -9,9 +9,10 @@ enableLogging(verbose);
9
9
 
10
10
  function killExistingInstances() {
11
11
  const myPid = process.pid;
12
+ const ppid = process.ppid;
12
13
  try {
13
- const out = execSync("pgrep -f 'poke-gate'", { encoding: "utf-8" }).trim();
14
- const pids = out.split("\n").map(Number).filter((p) => p && p !== myPid);
14
+ const out = execSync("pgrep -f 'node.*poke-gate.*app\\.js'", { encoding: "utf-8" }).trim();
15
+ const pids = out.split("\n").map(Number).filter((p) => p && p !== myPid && p !== ppid);
15
16
  for (const pid of pids) {
16
17
  try { process.kill(pid, "SIGTERM"); } catch {}
17
18
  }
@@ -146,8 +147,8 @@ function buildAccessModeMessage(mode) {
146
147
  default:
147
148
  return (
148
149
  "Access mode: Full. " +
149
- "You can run any shell command, read and write files, list directories, take screenshots, and check system info. " +
150
- "Use the tools directly whenever needed no approval required."
150
+ "You can run any shell command, read files, list directories, take screenshots, and check system info — no approval needed. " +
151
+ "Only destructive actions (deleting files, rm, write_file) require a one-time approval; after that, everything is auto-approved for the session."
151
152
  );
152
153
  }
153
154
  }
@@ -0,0 +1,133 @@
1
+ import https from "node:https";
2
+ import { createWriteStream, unlinkSync, readFileSync } from "node:fs";
3
+ import { execSync } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const APP_NAME = "Poke macOS Gate";
9
+ const DMG_ASSET = "Poke.macOS.Gate.dmg";
10
+ const INSTALL_PATH = `/Applications/${APP_NAME}.app`;
11
+ const REPO = "f/poke-gate";
12
+
13
+ function getPackageVersion() {
14
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
15
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
16
+ return pkg.version;
17
+ }
18
+
19
+ function httpGet(url) {
20
+ return new Promise((resolve, reject) => {
21
+ https.get(url, { headers: { "User-Agent": "poke-gate" } }, (res) => {
22
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
23
+ return httpGet(res.headers.location).then(resolve, reject);
24
+ }
25
+ resolve(res);
26
+ }).on("error", reject);
27
+ });
28
+ }
29
+
30
+ function downloadFile(url, dest, version) {
31
+ return new Promise((resolve, reject) => {
32
+ const follow = (followUrl) => {
33
+ https.get(followUrl, { headers: { "User-Agent": "poke-gate" } }, (res) => {
34
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
35
+ return follow(res.headers.location);
36
+ }
37
+ if (res.statusCode !== 200) {
38
+ reject(new Error(`Download failed: HTTP ${res.statusCode}`));
39
+ return;
40
+ }
41
+
42
+ const total = parseInt(res.headers["content-length"], 10) || 0;
43
+ let downloaded = 0;
44
+ const file = createWriteStream(dest);
45
+
46
+ res.on("data", (chunk) => {
47
+ downloaded += chunk.length;
48
+ if (total > 0) {
49
+ const pct = Math.round((downloaded / total) * 100);
50
+ const dlMB = (downloaded / 1024 / 1024).toFixed(1);
51
+ const totalMB = (total / 1024 / 1024).toFixed(1);
52
+ process.stdout.write(`\rDownloading ${APP_NAME} v${version}... ${pct}% (${dlMB}/${totalMB} MB)`);
53
+ }
54
+ });
55
+
56
+ res.pipe(file);
57
+
58
+ file.on("finish", () => {
59
+ file.close(() => {
60
+ console.log("");
61
+ resolve();
62
+ });
63
+ });
64
+
65
+ file.on("error", (err) => {
66
+ file.close();
67
+ reject(err);
68
+ });
69
+ }).on("error", reject);
70
+ };
71
+ follow(url);
72
+ });
73
+ }
74
+
75
+ function run(cmd) {
76
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
77
+ }
78
+
79
+ export async function downloadMacOSApp() {
80
+ const version = getPackageVersion();
81
+ const tag = `v${version}`;
82
+ const dmgUrl = `https://github.com/${REPO}/releases/download/${tag}/${DMG_ASSET}`;
83
+
84
+ console.log(`Poke Gate macOS installer (${tag})`);
85
+ console.log("");
86
+
87
+ const res = await httpGet(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
88
+ const chunks = [];
89
+ for await (const chunk of res) chunks.push(chunk);
90
+ const release = JSON.parse(Buffer.concat(chunks).toString());
91
+
92
+ if (!release.assets || !release.assets.find((a) => a.name === DMG_ASSET)) {
93
+ console.error(`No DMG found for ${tag}. The release may not have finished building yet.`);
94
+ process.exit(1);
95
+ }
96
+
97
+ const dmgPath = join(tmpdir(), `poke-gate-${version}.dmg`);
98
+
99
+ try {
100
+ await downloadFile(dmgUrl, dmgPath, version);
101
+
102
+ console.log("Mounting DMG...");
103
+ const mountOutput = run(`hdiutil attach "${dmgPath}" -nobrowse`);
104
+ const mountMatch = mountOutput.match(/\/Volumes\/.+/);
105
+ if (!mountMatch) {
106
+ throw new Error("Failed to detect mount point from hdiutil output.");
107
+ }
108
+ const mountPoint = mountMatch[0].trim();
109
+ const appSource = `${mountPoint}/${APP_NAME}.app`;
110
+
111
+ console.log("Stopping running instances...");
112
+ try { run(`pkill -f "${APP_NAME}"`); } catch {}
113
+ await new Promise((r) => setTimeout(r, 1000));
114
+
115
+ console.log(`Installing to ${INSTALL_PATH}...`);
116
+ try { run(`rm -rf "${INSTALL_PATH}"`); } catch {}
117
+ run(`cp -R "${appSource}" "${INSTALL_PATH}"`);
118
+
119
+ console.log("Clearing quarantine...");
120
+ run(`xattr -cr "${INSTALL_PATH}"`);
121
+
122
+ console.log("Unmounting DMG...");
123
+ try { run(`hdiutil detach "${mountPoint}" -quiet`); } catch {}
124
+
125
+ console.log("");
126
+ console.log(`Poke macOS Gate v${version} installed successfully.`);
127
+ console.log("");
128
+ console.log("Launching...");
129
+ try { run(`open "${INSTALL_PATH}"`); } catch {}
130
+ } finally {
131
+ try { unlinkSync(dmgPath); } catch {}
132
+ }
133
+ }
package/src/mcp-server.js CHANGED
@@ -22,6 +22,15 @@ const runCommandLoopState = new Map();
22
22
 
23
23
  const SAFE_TOOL_NAMES = new Set(["read_file", "read_image", "list_directory", "system_info", "network_speed"]);
24
24
 
25
+ const DESTRUCTIVE_COMMAND_PATTERNS = [
26
+ /\brm\b/i,
27
+ /\brmdir\b/i,
28
+ /\bunlink\b/i,
29
+ /\bmkfs\b/i,
30
+ /\bdiskutil\s+erase/i,
31
+ />\s*\//,
32
+ ];
33
+
25
34
  const LIMITED_RUN_COMMANDS = new Set([
26
35
  "curl", "yt-dlp", "youtube-dl",
27
36
  "ls", "pwd", "cat", "grep", "find", "head", "tail", "wc", "sed", "awk",
@@ -270,6 +279,15 @@ function hasDangerousPattern(commandText) {
270
279
  return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(commandText));
271
280
  }
272
281
 
282
+ function isDestructiveInFullMode(name, cleanArgs) {
283
+ if (name === "write_file") return true;
284
+ if (name === "run_command") {
285
+ const cmd = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
286
+ return DESTRUCTIVE_COMMAND_PATTERNS.some((p) => p.test(cmd));
287
+ }
288
+ return false;
289
+ }
290
+
273
291
  function validateRunCommandAgainstAllowlist(commandText, allowlist) {
274
292
  if (typeof commandText !== "string" || commandText.trim().length === 0) {
275
293
  return "Command is empty.";
@@ -565,7 +583,11 @@ function handleToolCall(name, args, context = {}) {
565
583
  return blocked;
566
584
  }
567
585
 
568
- if (PERMISSION_MODE !== "full" && permissionService.isRisky(name)) {
586
+ const needsApproval = PERMISSION_MODE === "full"
587
+ ? isDestructiveInFullMode(name, cleanArgs)
588
+ : permissionService.isRisky(name);
589
+
590
+ if (needsApproval) {
569
591
  const commandText = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
570
592
  const alreadyAllowed = sessionAutoApproveAllRisky.has(sessionId) ||
571
593
  (commandText && permissionService.isAllowedBySessionPattern(sessionId, commandText));
@@ -581,12 +603,15 @@ function handleToolCall(name, args, context = {}) {
581
603
  return buildApprovalResponse(name, cleanArgs, approval);
582
604
  }
583
605
 
584
- if (name === "run_command" && args.remember_in_session === true && commandText) {
585
- permissionService.allowPatternForSession(sessionId, commandText);
586
- }
587
-
588
- if (args.remember_all_risky === true) {
606
+ if (PERMISSION_MODE === "full") {
589
607
  sessionAutoApproveAllRisky.add(sessionId);
608
+ } else {
609
+ if (name === "run_command" && args.remember_in_session === true && commandText) {
610
+ permissionService.allowPatternForSession(sessionId, commandText);
611
+ }
612
+ if (args.remember_all_risky === true) {
613
+ sessionAutoApproveAllRisky.add(sessionId);
614
+ }
590
615
  }
591
616
  }
592
617
  }
@@ -754,23 +779,11 @@ function handleToolCall(name, args, context = {}) {
754
779
  case "take_screenshot": {
755
780
  logTool(name, cleanArgs);
756
781
 
757
- return runCommand('open -Ra "Poke macOS Gate" 2>/dev/null', homedir()).then((appCheck) => {
758
- if (appCheck.exitCode === 0) {
759
- return runCommand('open "poke-gate://screenshot"', homedir()).then(() => {
760
- return { content: [{ type: "text", text: "Screenshot captured and sent to Poke via the macOS app." }] };
761
- });
782
+ return runCommand("npx -y poke-gate@latest take-screenshot", homedir(), { permissionMode: "full" }).then((result) => {
783
+ if (result.exitCode === 0) {
784
+ return { content: [{ type: "text", text: "Screenshot captured and sent to Poke." }] };
762
785
  }
763
-
764
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
765
- const dest = cleanArgs.path
766
- ? resolve(cleanArgs.path.replace(/^~/, homedir()))
767
- : join(homedir(), "Desktop", `screenshot-${ts}.png`);
768
- return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
769
- if (result.exitCode === 0) {
770
- return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
771
- }
772
- return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || "unknown error"}. Grant Screen Recording permission to Terminal or install the Poke macOS Gate app.` }], isError: true };
773
- });
786
+ return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || result.stdout || "unknown error"}` }], isError: true };
774
787
  });
775
788
  }
776
789
 
@@ -0,0 +1,48 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir, platform } from "node:os";
5
+ import { Poke, getToken, isLoggedIn, login } from "poke";
6
+
7
+ export async function takeScreenshot() {
8
+ if (platform() !== "darwin") {
9
+ console.error("Screenshots are only supported on macOS.");
10
+ process.exit(1);
11
+ }
12
+
13
+ if (!isLoggedIn()) {
14
+ console.log("Signing in to Poke...");
15
+ await login();
16
+ }
17
+
18
+ const token = getToken();
19
+ if (!token) {
20
+ console.error("Authentication failed: no token returned by Poke SDK.");
21
+ process.exit(1);
22
+ }
23
+
24
+ const dest = join(tmpdir(), `poke-gate-screenshot-${Date.now()}.png`);
25
+
26
+ console.log("Capturing screenshot...");
27
+ try {
28
+ execSync(`/usr/sbin/screencapture -x "${dest}"`, { stdio: "pipe" });
29
+ } catch {
30
+ console.error("Screenshot failed. Grant Screen Recording permission in System Settings > Privacy & Security > Screen Recording.");
31
+ process.exit(1);
32
+ }
33
+
34
+ const png = readFileSync(dest);
35
+ const base64 = png.toString("base64");
36
+
37
+ console.log(`Screenshot captured (${(png.length / 1024).toFixed(0)} KB). Sending to Poke...`);
38
+
39
+ try {
40
+ const poke = new Poke({ token });
41
+ await poke.sendMessage(
42
+ `Here is a screenshot of my screen right now. Reply me with the image.\n\n\`\`\`\ndata:image/png;base64,${base64}\n\`\`\``
43
+ );
44
+ console.log("Screenshot sent to Poke.");
45
+ } finally {
46
+ try { unlinkSync(dest); } catch {}
47
+ }
48
+ }
package/src/tunnel.js CHANGED
@@ -64,7 +64,7 @@ export async function startTunnel({ mcpUrl, onEvent }) {
64
64
  url: mcpUrl,
65
65
  name: "poke-gate",
66
66
  token,
67
- cleanupOnStop: false,
67
+ cleanupOnStop: true,
68
68
  });
69
69
 
70
70
  tunnel.on("connected", (info) => {
package/Gate.app DELETED
File without changes