ghostman 0.1.0__tar.gz

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.
ghostman-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4yk5
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostman
3
+ Version: 0.1.0
4
+ Summary: A fast, keyboard-driven terminal API client and webhook inspector
5
+ Author: 4yk5
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/4yk5/Ghostman
8
+ Project-URL: Repository, https://github.com/4yk5/Ghostman
9
+ Project-URL: Issues, https://github.com/4yk5/Ghostman/issues
10
+ Keywords: api,tui,terminal,webhook,postman,rest,http
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Environment :: Console
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: textual>=8.0.0
21
+ Requires-Dist: httpx>=0.28.0
22
+ Requires-Dist: rich>=14.0.0
23
+ Dynamic: license-file
24
+
25
+ ```
26
+ ____ _ _
27
+ / ___| |__ ___ ___| |_ _ __ ___ __ _ _ __
28
+ | | _| '_ \ / _ \/ __| __| '_ ` _ \ / _` | '_ \
29
+ | |_| | | | | (_) \__ \ |_| | | | | | (_| | | | |
30
+ \____|_| |_|\___/|___/\__|_| |_| |_|\__,_|_| |_|
31
+
32
+ ```
33
+
34
+ # Ghostman 👻
35
+
36
+ > **A fast, keyboard-driven terminal API client and webhook inspector.**
37
+ > *No mandatory cloud accounts. No 1GB Electron bloat. 100% local-first.*
38
+
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
40
+ [![Python: 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
41
+ [![UI: Textual](https://img.shields.io/badge/UI-Textual-bd93f9.svg)](https://github.com/Textualize/textual)
42
+ [![Tests](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml/badge.svg)](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml)
43
+
44
+ <p align="center">
45
+ <img src="assets/ghostman-main.svg" alt="Ghostman Terminal Studio" width="100%">
46
+ </p>
47
+
48
+ ---
49
+
50
+ ## 💡 Why Ghostman?
51
+
52
+ Modern API clients (Postman, Insomnia) started as simple developer utilities, but evolved into heavy enterprise platforms:
53
+
54
+ - ❌ **Bloated Memory:** Postman idles at **800MB – 1.5GB of RAM** just sitting in the background.
55
+ 👉 **Ghostman runs on ~30MB of RAM.**
56
+ - ❌ **Mandatory Cloud Lock-in:** Forced account sign-ins, team workspaces, and cloud syncing for private test requests.
57
+ 👉 **Ghostman is 100% local-first.** Your requests and collections stay as plain JSON/YAML on your disk (`~/.ghostman/`).
58
+ - ❌ **Sluggish Startup:** 5 to 10 seconds of splash screens and loading spinners.
59
+ 👉 **Ghostman cold boots in ~150ms** directly inside your terminal.
60
+ - ❌ **External Webhook Tunnels:** Needing ngrok, webhook.site, or third-party accounts just to inspect a local webhook payload.
61
+ 👉 **Ghostman has a built-in webhook listener** on port 9090 (`Ctrl + W`).
62
+ - ❌ **Mouse-Heavy Navigation:** Constant clicking between dropdowns, sidebars, and nested dialogs.
63
+ 👉 **Keyboard-driven workflows.** Send with `Ctrl + S`, save with `Ctrl + K`, new request with `Ctrl + N`.
64
+
65
+ ---
66
+
67
+ ## 📸 Interface Tour
68
+
69
+ ### 1. High-Speed Telemetry & Formatted Responses
70
+ Instant latency tracking in milliseconds, HTTP status badges, payload size metrics, and syntax-highlighted JSON rendering:
71
+
72
+ <p align="center">
73
+ <img src="assets/ghostman-response.svg" alt="Response Telemetry" width="100%">
74
+ </p>
75
+
76
+ ### 2. Live Webhook Stream
77
+ Catch incoming HTTP callbacks from Stripe, GitHub, Shopify, or local microservices in real-time without leaving your terminal:
78
+
79
+ <p align="center">
80
+ <img src="assets/ghostman-webhook.svg" alt="Live Webhooks" width="100%">
81
+ </p>
82
+
83
+ ---
84
+
85
+ ## ⚡ Core Features
86
+
87
+ - **Full HTTP Method Suite:** Seamlessly compose `GET`, `POST`, `PUT`, `DELETE`, and `PATCH` requests.
88
+ - **Performance Telemetry:** DNS + TTFB latency in real-time to monitor endpoint bottlenecks.
89
+ - **Embedded Webhook Server:** Spin up a local server on port 9090 with one shortcut to catch incoming hooks.
90
+ - **Zero Cloud Dependency:** Save collections as plain files in `.ghostman/` and commit them to Git.
91
+ - **Auto-Formatting:** Automatically indents and formats JSON payloads with Rich syntax coloring.
92
+ - **Cyberpunk Dark Theme:** Designed for readability in terminal environments with solid borders.
93
+
94
+ ---
95
+
96
+ ## 🚀 Quick Start
97
+
98
+ ### Prerequisites
99
+ - Python 3.10 or newer
100
+
101
+ ### Installation
102
+
103
+ ```bash
104
+ # 1. Clone the repository
105
+ git clone https://github.com/4yk5/ghostman.git
106
+ cd ghostman
107
+
108
+ # 2. Install dependencies
109
+ pip install -r requirements.txt
110
+
111
+ # 3. Launch Ghostman
112
+ python main.py
113
+ ```
114
+
115
+ > **Windows tip:** You can also run Ghostman by double-clicking `run.bat` in the project root!
116
+
117
+ ---
118
+
119
+ ## ⌨️ Keyboard Shortcuts
120
+
121
+ | Keybinding | Action |
122
+ | :--- | :--- |
123
+ | `Ctrl + S` | **Send HTTP Request** (Executes call and switches to Response view) |
124
+ | `Ctrl + K` | **Save Request** (Saves current endpoint to collections) |
125
+ | `Ctrl + N` | **New Request** (Resets input fields) |
126
+ | `Ctrl + W` | **Toggle Webhook Server** (Starts / Stops listener on port 9090) |
127
+ | `Tab` / `Shift + Tab` | Navigate between inputs, method selector, and tabs |
128
+ | `Enter` | Select endpoint from sidebar / trigger button |
129
+ | `Ctrl + Q` | **Quit Ghostman** |
130
+
131
+ ---
132
+
133
+ ## 🛠️ Testing Webhooks Locally
134
+
135
+ 1. Press `Ctrl + W` to start the listener on port `9090`.
136
+ 2. Send a test webhook from another terminal:
137
+ ```bash
138
+ curl -X POST http://127.0.0.1:9090/webhook \
139
+ -H "Content-Type: application/json" \
140
+ -d '{"event": "payment_succeeded", "amount": 4200}'
141
+ ```
142
+ 3. Switch to the **Live Webhooks** tab to inspect headers, timestamp, and payload.
143
+ 4. Click any captured webhook to load its payload directly into the editor.
144
+
145
+ ---
146
+
147
+ ## 🤝 Contributing
148
+
149
+ Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
150
+
151
+ ## 📄 License
152
+
153
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,129 @@
1
+ ```
2
+ ____ _ _
3
+ / ___| |__ ___ ___| |_ _ __ ___ __ _ _ __
4
+ | | _| '_ \ / _ \/ __| __| '_ ` _ \ / _` | '_ \
5
+ | |_| | | | | (_) \__ \ |_| | | | | | (_| | | | |
6
+ \____|_| |_|\___/|___/\__|_| |_| |_|\__,_|_| |_|
7
+
8
+ ```
9
+
10
+ # Ghostman 👻
11
+
12
+ > **A fast, keyboard-driven terminal API client and webhook inspector.**
13
+ > *No mandatory cloud accounts. No 1GB Electron bloat. 100% local-first.*
14
+
15
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
16
+ [![Python: 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
17
+ [![UI: Textual](https://img.shields.io/badge/UI-Textual-bd93f9.svg)](https://github.com/Textualize/textual)
18
+ [![Tests](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml/badge.svg)](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml)
19
+
20
+ <p align="center">
21
+ <img src="assets/ghostman-main.svg" alt="Ghostman Terminal Studio" width="100%">
22
+ </p>
23
+
24
+ ---
25
+
26
+ ## 💡 Why Ghostman?
27
+
28
+ Modern API clients (Postman, Insomnia) started as simple developer utilities, but evolved into heavy enterprise platforms:
29
+
30
+ - ❌ **Bloated Memory:** Postman idles at **800MB – 1.5GB of RAM** just sitting in the background.
31
+ 👉 **Ghostman runs on ~30MB of RAM.**
32
+ - ❌ **Mandatory Cloud Lock-in:** Forced account sign-ins, team workspaces, and cloud syncing for private test requests.
33
+ 👉 **Ghostman is 100% local-first.** Your requests and collections stay as plain JSON/YAML on your disk (`~/.ghostman/`).
34
+ - ❌ **Sluggish Startup:** 5 to 10 seconds of splash screens and loading spinners.
35
+ 👉 **Ghostman cold boots in ~150ms** directly inside your terminal.
36
+ - ❌ **External Webhook Tunnels:** Needing ngrok, webhook.site, or third-party accounts just to inspect a local webhook payload.
37
+ 👉 **Ghostman has a built-in webhook listener** on port 9090 (`Ctrl + W`).
38
+ - ❌ **Mouse-Heavy Navigation:** Constant clicking between dropdowns, sidebars, and nested dialogs.
39
+ 👉 **Keyboard-driven workflows.** Send with `Ctrl + S`, save with `Ctrl + K`, new request with `Ctrl + N`.
40
+
41
+ ---
42
+
43
+ ## 📸 Interface Tour
44
+
45
+ ### 1. High-Speed Telemetry & Formatted Responses
46
+ Instant latency tracking in milliseconds, HTTP status badges, payload size metrics, and syntax-highlighted JSON rendering:
47
+
48
+ <p align="center">
49
+ <img src="assets/ghostman-response.svg" alt="Response Telemetry" width="100%">
50
+ </p>
51
+
52
+ ### 2. Live Webhook Stream
53
+ Catch incoming HTTP callbacks from Stripe, GitHub, Shopify, or local microservices in real-time without leaving your terminal:
54
+
55
+ <p align="center">
56
+ <img src="assets/ghostman-webhook.svg" alt="Live Webhooks" width="100%">
57
+ </p>
58
+
59
+ ---
60
+
61
+ ## ⚡ Core Features
62
+
63
+ - **Full HTTP Method Suite:** Seamlessly compose `GET`, `POST`, `PUT`, `DELETE`, and `PATCH` requests.
64
+ - **Performance Telemetry:** DNS + TTFB latency in real-time to monitor endpoint bottlenecks.
65
+ - **Embedded Webhook Server:** Spin up a local server on port 9090 with one shortcut to catch incoming hooks.
66
+ - **Zero Cloud Dependency:** Save collections as plain files in `.ghostman/` and commit them to Git.
67
+ - **Auto-Formatting:** Automatically indents and formats JSON payloads with Rich syntax coloring.
68
+ - **Cyberpunk Dark Theme:** Designed for readability in terminal environments with solid borders.
69
+
70
+ ---
71
+
72
+ ## 🚀 Quick Start
73
+
74
+ ### Prerequisites
75
+ - Python 3.10 or newer
76
+
77
+ ### Installation
78
+
79
+ ```bash
80
+ # 1. Clone the repository
81
+ git clone https://github.com/4yk5/ghostman.git
82
+ cd ghostman
83
+
84
+ # 2. Install dependencies
85
+ pip install -r requirements.txt
86
+
87
+ # 3. Launch Ghostman
88
+ python main.py
89
+ ```
90
+
91
+ > **Windows tip:** You can also run Ghostman by double-clicking `run.bat` in the project root!
92
+
93
+ ---
94
+
95
+ ## ⌨️ Keyboard Shortcuts
96
+
97
+ | Keybinding | Action |
98
+ | :--- | :--- |
99
+ | `Ctrl + S` | **Send HTTP Request** (Executes call and switches to Response view) |
100
+ | `Ctrl + K` | **Save Request** (Saves current endpoint to collections) |
101
+ | `Ctrl + N` | **New Request** (Resets input fields) |
102
+ | `Ctrl + W` | **Toggle Webhook Server** (Starts / Stops listener on port 9090) |
103
+ | `Tab` / `Shift + Tab` | Navigate between inputs, method selector, and tabs |
104
+ | `Enter` | Select endpoint from sidebar / trigger button |
105
+ | `Ctrl + Q` | **Quit Ghostman** |
106
+
107
+ ---
108
+
109
+ ## 🛠️ Testing Webhooks Locally
110
+
111
+ 1. Press `Ctrl + W` to start the listener on port `9090`.
112
+ 2. Send a test webhook from another terminal:
113
+ ```bash
114
+ curl -X POST http://127.0.0.1:9090/webhook \
115
+ -H "Content-Type: application/json" \
116
+ -d '{"event": "payment_succeeded", "amount": 4200}'
117
+ ```
118
+ 3. Switch to the **Live Webhooks** tab to inspect headers, timestamp, and payload.
119
+ 4. Click any captured webhook to load its payload directly into the editor.
120
+
121
+ ---
122
+
123
+ ## 🤝 Contributing
124
+
125
+ Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
126
+
127
+ ## 📄 License
128
+
129
+ Released under the [MIT License](LICENSE).
ghostman-0.1.0/app.py ADDED
@@ -0,0 +1,279 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.containers import Horizontal, Vertical
3
+ from textual.widgets import (
4
+ Footer, Input, Button, Select, Tabs, Tab, TextArea, ListView, ListItem, Label
5
+ )
6
+ from textual.binding import Binding
7
+
8
+ from network import NetworkEngine
9
+ from webhook_server import WebhookServer
10
+ from storage import StorageManager
11
+
12
+ class GhostmanApp(App):
13
+ CSS_PATH = "styles.tcss"
14
+ ENABLE_COMMAND_PALETTE = False
15
+
16
+ BINDINGS = [
17
+ Binding("ctrl+s", "send_request", "Send Request", priority=True),
18
+ Binding("ctrl+w", "toggle_webhook", "Toggle Webhook Server", priority=True),
19
+ Binding("ctrl+n", "new_request", "New Request", priority=True),
20
+ Binding("ctrl+k", "save_request", "Save Request", priority=True),
21
+ Binding("ctrl+q", "quit", "Quit", priority=True)
22
+ ]
23
+
24
+ METHODS = [
25
+ ("GET", "GET"),
26
+ ("POST", "POST"),
27
+ ("PUT", "PUT"),
28
+ ("DELETE", "DELETE"),
29
+ ("PATCH", "PATCH")
30
+ ]
31
+
32
+ def __init__(self):
33
+ super().__init__()
34
+ self.network = NetworkEngine()
35
+ self.storage = StorageManager()
36
+ self.collections = self.storage.load_collections()
37
+ self.webhook_server = WebhookServer(
38
+ host="127.0.0.1",
39
+ port=9090,
40
+ on_hook_received=self.handle_incoming_webhook
41
+ )
42
+
43
+ def compose(self) -> ComposeResult:
44
+ with Horizontal(id="header-bar"):
45
+ yield Label("[GHOSTMAN]", id="title-label")
46
+ yield Label("Terminal API & Webhook Studio (Local-First)", id="subtitle-label")
47
+
48
+ with Horizontal(id="main-container"):
49
+ with Vertical(id="sidebar"):
50
+ with Horizontal(id="sidebar-header"):
51
+ yield Label("COLLECTIONS", id="sidebar-title")
52
+ yield Button("+ NEW", id="new-req-btn")
53
+ yield ListView(id="requests-list")
54
+
55
+ with Vertical(id="content-area"):
56
+ with Horizontal(id="request-bar"):
57
+ yield Select(self.METHODS, value="GET", allow_blank=False, id="method-select")
58
+ yield Input(placeholder="Request URL (e.g. https://api.github.com)...", id="url-input")
59
+ yield Button("SEND", id="send-btn")
60
+ yield Button("SAVE", id="save-btn")
61
+
62
+ yield Tabs(
63
+ Tab("Body & Payload", id="tab-body"),
64
+ Tab("Response", id="tab-response"),
65
+ Tab("Live Webhooks", id="tab-webhook"),
66
+ id="tabs"
67
+ )
68
+
69
+ with Vertical(id="tab-body-pane"):
70
+ yield TextArea(id="body-input", language="json", text='{\n "hello": "world"\n}')
71
+
72
+ with Vertical(id="tab-response-pane"):
73
+ yield Label("Status: Idle", id="metrics-bar")
74
+ yield TextArea(id="response-area", read_only=True, language="json", text="")
75
+
76
+ with Vertical(id="tab-webhook-pane"):
77
+ with Horizontal(id="webhook-status"):
78
+ yield Label("Webhook Receiver: [OFFLINE] (Port 9090)", id="webhook-status-label")
79
+ yield Button("START RECEIVER", id="webhook-toggle-btn")
80
+ yield Label("Send requests to http://127.0.0.1:9090 to inspect payloads live:")
81
+ yield ListView(id="webhook-list")
82
+
83
+ yield Footer()
84
+
85
+ async def on_mount(self):
86
+ self.populate_sidebar()
87
+ self.query_one("#tab-response-pane").display = False
88
+ self.query_one("#tab-webhook-pane").display = False
89
+ self.query_one("#url-input").value = "https://jsonplaceholder.typicode.com/posts/1"
90
+
91
+ async def on_unmount(self):
92
+ try:
93
+ if self.webhook_server and self.webhook_server.is_running:
94
+ await self.webhook_server.stop()
95
+ except Exception:
96
+ pass
97
+
98
+ def populate_sidebar(self):
99
+ try:
100
+ list_view = self.query_one("#requests-list", ListView)
101
+ list_view.clear()
102
+ if not isinstance(self.collections, list):
103
+ return
104
+ for col in self.collections:
105
+ if not isinstance(col, dict):
106
+ continue
107
+ for req in col.get("requests", []):
108
+ if not isinstance(req, dict):
109
+ continue
110
+ method = req.get("method", "GET").upper()
111
+ name = req.get("name", "Request")
112
+ item = ListItem(Label(f"[{method}] {name}"))
113
+ item.req_data = req
114
+ list_view.append(item)
115
+ except Exception as e:
116
+ self.notify(f"Error updating sidebar: {e}", severity="error")
117
+
118
+ def on_list_view_selected(self, event: ListView.Selected):
119
+ item = event.item
120
+ if not item:
121
+ return
122
+ if hasattr(item, "req_data"):
123
+ req = item.req_data
124
+ method = req.get("method", "GET").upper()
125
+ if method in [m[0] for m in self.METHODS]:
126
+ self.query_one("#method-select", Select).value = method
127
+ self.query_one("#url-input", Input).value = req.get("url", "")
128
+ self.query_one("#body-input", TextArea).text = req.get("body", "") or ""
129
+ self.notify(f"Selected: {req.get('name')}", timeout=2)
130
+ elif hasattr(item, "hook_data"):
131
+ hook = item.hook_data
132
+ h_method = hook.get("method", "GET").upper()
133
+ if h_method in [m[0] for m in self.METHODS]:
134
+ self.query_one("#method-select", Select).value = h_method
135
+ self.query_one("#body-input", TextArea).text = hook.get("raw_body", "") or ""
136
+ self.query_one("#tabs", Tabs).active = "tab-body"
137
+ self.notify(f"Loaded hook payload ({hook.get('path', '/')})", timeout=2)
138
+
139
+ def on_tabs_tab_activated(self, event: Tabs.TabActivated):
140
+ tab_id = event.tab.id
141
+ self.query_one("#tab-body-pane").display = (tab_id == "tab-body")
142
+ self.query_one("#tab-response-pane").display = (tab_id == "tab-response")
143
+ self.query_one("#tab-webhook-pane").display = (tab_id == "tab-webhook")
144
+
145
+ async def action_send_request(self):
146
+ await self.send_http_request()
147
+
148
+ def action_save_request(self):
149
+ self.save_current_request()
150
+
151
+ def action_new_request(self):
152
+ self.query_one("#method-select", Select).value = "GET"
153
+ self.query_one("#url-input", Input).value = ""
154
+ self.query_one("#body-input", TextArea).text = ""
155
+ self.query_one("#tabs", Tabs).active = "tab-body"
156
+ self.query_one("#url-input", Input).focus()
157
+ self.notify("New request ready")
158
+
159
+ async def on_button_pressed(self, event: Button.Pressed):
160
+ btn_id = event.button.id
161
+ if btn_id == "send-btn":
162
+ await self.send_http_request()
163
+ elif btn_id == "save-btn":
164
+ self.save_current_request()
165
+ elif btn_id == "new-req-btn":
166
+ self.action_new_request()
167
+ elif btn_id == "webhook-toggle-btn":
168
+ await self.action_toggle_webhook()
169
+
170
+ def save_current_request(self):
171
+ try:
172
+ url = self.query_one("#url-input", Input).value.strip()
173
+ if not url:
174
+ self.notify("Cannot save request without URL", severity="warning")
175
+ return
176
+ method = self.query_one("#method-select", Select).value
177
+ body = self.query_one("#body-input", TextArea).text
178
+
179
+ path_part = url.split("://")[-1].split("?")[0].strip("/")
180
+ name = path_part.split("/")[-1] if "/" in path_part else path_part
181
+ name = name or "Endpoint"
182
+
183
+ new_req = {
184
+ "name": f"{method} {name[:18]}",
185
+ "method": method,
186
+ "url": url,
187
+ "headers": {"Content-Type": "application/json"} if body else {},
188
+ "body": body
189
+ }
190
+
191
+ if not self.collections or not isinstance(self.collections, list):
192
+ self.collections = [{"name": "My Collection", "requests": []}]
193
+ if "requests" not in self.collections[0] or not isinstance(self.collections[0]["requests"], list):
194
+ self.collections[0]["requests"] = []
195
+ self.collections[0]["requests"].append(new_req)
196
+ self.storage.save_collections(self.collections)
197
+ self.populate_sidebar()
198
+ self.notify(f"Saved: {new_req['name']}", timeout=2)
199
+ except Exception as e:
200
+ self.notify(f"Error saving request: {e}", severity="error")
201
+
202
+ async def send_http_request(self):
203
+ try:
204
+ method = self.query_one("#method-select", Select).value
205
+ url = self.query_one("#url-input", Input).value.strip()
206
+ body = self.query_one("#body-input", TextArea).text
207
+
208
+ if not url:
209
+ self.notify("URL cannot be empty", severity="error")
210
+ return
211
+
212
+ if not url.startswith("http://") and not url.startswith("https://"):
213
+ url = "https://" + url
214
+ self.query_one("#url-input", Input).value = url
215
+
216
+ self.query_one("#tabs", Tabs).active = "tab-response"
217
+ metrics = self.query_one("#metrics-bar", Label)
218
+ response_box = self.query_one("#response-area", TextArea)
219
+
220
+ metrics.update(f"Sending {method} to {url}...")
221
+ response_box.text = "Waiting for response..."
222
+
223
+ req_body = body if method in ["POST", "PUT", "PATCH", "DELETE"] else None
224
+
225
+ res = await self.network.execute_request(method, url, body=req_body)
226
+
227
+ if res["success"]:
228
+ code = res["status_code"]
229
+ color = "green" if code < 400 else "red"
230
+ reason = res.get('reason') or ('OK' if code == 200 else '')
231
+ metrics.update(
232
+ f"[{color}]{code} {reason}[/] | {res['elapsed_ms']}ms | {res['size_bytes']} bytes"
233
+ )
234
+ response_box.text = res["body"]
235
+ else:
236
+ metrics.update(f"[red]Failed[/] | {res['elapsed_ms']}ms")
237
+ response_box.text = res["body"]
238
+ except Exception as e:
239
+ self.notify(f"Request error: {e}", severity="error")
240
+
241
+ async def action_toggle_webhook(self):
242
+ btn = self.query_one("#webhook-toggle-btn", Button)
243
+ lbl = self.query_one("#webhook-status-label", Label)
244
+
245
+ if not self.webhook_server.is_running:
246
+ try:
247
+ await self.webhook_server.start()
248
+ lbl.update("[green][ONLINE][/] (Listening on http://127.0.0.1:9090)")
249
+ btn.label = "STOP RECEIVER"
250
+ btn.variant = "error"
251
+ self.notify("Webhook listener started on :9090")
252
+ except Exception as e:
253
+ self.notify(f"Could not bind port 9090: {e}", severity="error")
254
+ else:
255
+ try:
256
+ await self.webhook_server.stop()
257
+ except Exception:
258
+ pass
259
+ lbl.update("Webhook Receiver: [OFFLINE] (Port 9090)")
260
+ btn.label = "START RECEIVER"
261
+ btn.variant = "default"
262
+ self.notify("Webhook listener stopped")
263
+
264
+ def handle_incoming_webhook(self, hook: dict):
265
+ try:
266
+ self.call_from_thread(self._add_webhook_to_ui, hook)
267
+ except RuntimeError:
268
+ self._add_webhook_to_ui(hook)
269
+
270
+ def _add_webhook_to_ui(self, hook: dict):
271
+ try:
272
+ list_view = self.query_one("#webhook-list", ListView)
273
+ label_text = f"[{hook['timestamp']}] {hook['method']} {hook['path']} ({len(hook.get('raw_body', ''))}B)"
274
+ item = ListItem(Label(f"[cyan]{label_text}[/] -> {hook.get('body', '')[:40]}..."))
275
+ item.hook_data = hook
276
+ list_view.append(item)
277
+ self.notify(f"Hook received: {hook['path']}", timeout=2)
278
+ except Exception:
279
+ pass
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostman
3
+ Version: 0.1.0
4
+ Summary: A fast, keyboard-driven terminal API client and webhook inspector
5
+ Author: 4yk5
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/4yk5/Ghostman
8
+ Project-URL: Repository, https://github.com/4yk5/Ghostman
9
+ Project-URL: Issues, https://github.com/4yk5/Ghostman/issues
10
+ Keywords: api,tui,terminal,webhook,postman,rest,http
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Environment :: Console
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: textual>=8.0.0
21
+ Requires-Dist: httpx>=0.28.0
22
+ Requires-Dist: rich>=14.0.0
23
+ Dynamic: license-file
24
+
25
+ ```
26
+ ____ _ _
27
+ / ___| |__ ___ ___| |_ _ __ ___ __ _ _ __
28
+ | | _| '_ \ / _ \/ __| __| '_ ` _ \ / _` | '_ \
29
+ | |_| | | | | (_) \__ \ |_| | | | | | (_| | | | |
30
+ \____|_| |_|\___/|___/\__|_| |_| |_|\__,_|_| |_|
31
+
32
+ ```
33
+
34
+ # Ghostman 👻
35
+
36
+ > **A fast, keyboard-driven terminal API client and webhook inspector.**
37
+ > *No mandatory cloud accounts. No 1GB Electron bloat. 100% local-first.*
38
+
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
40
+ [![Python: 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
41
+ [![UI: Textual](https://img.shields.io/badge/UI-Textual-bd93f9.svg)](https://github.com/Textualize/textual)
42
+ [![Tests](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml/badge.svg)](https://github.com/4yk5/Ghostman/actions/workflows/tests.yml)
43
+
44
+ <p align="center">
45
+ <img src="assets/ghostman-main.svg" alt="Ghostman Terminal Studio" width="100%">
46
+ </p>
47
+
48
+ ---
49
+
50
+ ## 💡 Why Ghostman?
51
+
52
+ Modern API clients (Postman, Insomnia) started as simple developer utilities, but evolved into heavy enterprise platforms:
53
+
54
+ - ❌ **Bloated Memory:** Postman idles at **800MB – 1.5GB of RAM** just sitting in the background.
55
+ 👉 **Ghostman runs on ~30MB of RAM.**
56
+ - ❌ **Mandatory Cloud Lock-in:** Forced account sign-ins, team workspaces, and cloud syncing for private test requests.
57
+ 👉 **Ghostman is 100% local-first.** Your requests and collections stay as plain JSON/YAML on your disk (`~/.ghostman/`).
58
+ - ❌ **Sluggish Startup:** 5 to 10 seconds of splash screens and loading spinners.
59
+ 👉 **Ghostman cold boots in ~150ms** directly inside your terminal.
60
+ - ❌ **External Webhook Tunnels:** Needing ngrok, webhook.site, or third-party accounts just to inspect a local webhook payload.
61
+ 👉 **Ghostman has a built-in webhook listener** on port 9090 (`Ctrl + W`).
62
+ - ❌ **Mouse-Heavy Navigation:** Constant clicking between dropdowns, sidebars, and nested dialogs.
63
+ 👉 **Keyboard-driven workflows.** Send with `Ctrl + S`, save with `Ctrl + K`, new request with `Ctrl + N`.
64
+
65
+ ---
66
+
67
+ ## 📸 Interface Tour
68
+
69
+ ### 1. High-Speed Telemetry & Formatted Responses
70
+ Instant latency tracking in milliseconds, HTTP status badges, payload size metrics, and syntax-highlighted JSON rendering:
71
+
72
+ <p align="center">
73
+ <img src="assets/ghostman-response.svg" alt="Response Telemetry" width="100%">
74
+ </p>
75
+
76
+ ### 2. Live Webhook Stream
77
+ Catch incoming HTTP callbacks from Stripe, GitHub, Shopify, or local microservices in real-time without leaving your terminal:
78
+
79
+ <p align="center">
80
+ <img src="assets/ghostman-webhook.svg" alt="Live Webhooks" width="100%">
81
+ </p>
82
+
83
+ ---
84
+
85
+ ## ⚡ Core Features
86
+
87
+ - **Full HTTP Method Suite:** Seamlessly compose `GET`, `POST`, `PUT`, `DELETE`, and `PATCH` requests.
88
+ - **Performance Telemetry:** DNS + TTFB latency in real-time to monitor endpoint bottlenecks.
89
+ - **Embedded Webhook Server:** Spin up a local server on port 9090 with one shortcut to catch incoming hooks.
90
+ - **Zero Cloud Dependency:** Save collections as plain files in `.ghostman/` and commit them to Git.
91
+ - **Auto-Formatting:** Automatically indents and formats JSON payloads with Rich syntax coloring.
92
+ - **Cyberpunk Dark Theme:** Designed for readability in terminal environments with solid borders.
93
+
94
+ ---
95
+
96
+ ## 🚀 Quick Start
97
+
98
+ ### Prerequisites
99
+ - Python 3.10 or newer
100
+
101
+ ### Installation
102
+
103
+ ```bash
104
+ # 1. Clone the repository
105
+ git clone https://github.com/4yk5/ghostman.git
106
+ cd ghostman
107
+
108
+ # 2. Install dependencies
109
+ pip install -r requirements.txt
110
+
111
+ # 3. Launch Ghostman
112
+ python main.py
113
+ ```
114
+
115
+ > **Windows tip:** You can also run Ghostman by double-clicking `run.bat` in the project root!
116
+
117
+ ---
118
+
119
+ ## ⌨️ Keyboard Shortcuts
120
+
121
+ | Keybinding | Action |
122
+ | :--- | :--- |
123
+ | `Ctrl + S` | **Send HTTP Request** (Executes call and switches to Response view) |
124
+ | `Ctrl + K` | **Save Request** (Saves current endpoint to collections) |
125
+ | `Ctrl + N` | **New Request** (Resets input fields) |
126
+ | `Ctrl + W` | **Toggle Webhook Server** (Starts / Stops listener on port 9090) |
127
+ | `Tab` / `Shift + Tab` | Navigate between inputs, method selector, and tabs |
128
+ | `Enter` | Select endpoint from sidebar / trigger button |
129
+ | `Ctrl + Q` | **Quit Ghostman** |
130
+
131
+ ---
132
+
133
+ ## 🛠️ Testing Webhooks Locally
134
+
135
+ 1. Press `Ctrl + W` to start the listener on port `9090`.
136
+ 2. Send a test webhook from another terminal:
137
+ ```bash
138
+ curl -X POST http://127.0.0.1:9090/webhook \
139
+ -H "Content-Type: application/json" \
140
+ -d '{"event": "payment_succeeded", "amount": 4200}'
141
+ ```
142
+ 3. Switch to the **Live Webhooks** tab to inspect headers, timestamp, and payload.
143
+ 4. Click any captured webhook to load its payload directly into the editor.
144
+
145
+ ---
146
+
147
+ ## 🤝 Contributing
148
+
149
+ Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
150
+
151
+ ## 📄 License
152
+
153
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ app.py
4
+ main.py
5
+ network.py
6
+ pyproject.toml
7
+ storage.py
8
+ webhook_server.py
9
+ ghostman.egg-info/PKG-INFO
10
+ ghostman.egg-info/SOURCES.txt
11
+ ghostman.egg-info/dependency_links.txt
12
+ ghostman.egg-info/entry_points.txt
13
+ ghostman.egg-info/requires.txt
14
+ ghostman.egg-info/top_level.txt
15
+ tests/test_all.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghostman = main:main
@@ -0,0 +1,3 @@
1
+ textual>=8.0.0
2
+ httpx>=0.28.0
3
+ rich>=14.0.0
@@ -0,0 +1,5 @@
1
+ app
2
+ main
3
+ network
4
+ storage
5
+ webhook_server
ghostman-0.1.0/main.py ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.platform == "win32":
5
+ try:
6
+ sys.stdout.reconfigure(encoding="utf-8")
7
+ sys.stderr.reconfigure(encoding="utf-8")
8
+ except Exception:
9
+ pass
10
+
11
+ from app import GhostmanApp
12
+
13
+ def main():
14
+ app = GhostmanApp()
15
+ app.run()
16
+
17
+ if __name__ == "__main__":
18
+ main()
@@ -0,0 +1,59 @@
1
+ import time
2
+ import json
3
+ import httpx
4
+
5
+ class NetworkEngine:
6
+ def __init__(self, timeout: float = 15.0):
7
+ self.timeout = timeout
8
+
9
+ async def execute_request(self, method: str, url: str, headers: dict = None, body: str = None):
10
+ start_time = time.perf_counter()
11
+ headers = dict(headers) if headers else {}
12
+
13
+ if not any(k.lower() == "user-agent" for k in headers):
14
+ headers["User-Agent"] = "Ghostman/0.1.0"
15
+
16
+ if body and not any(k.lower() == "content-type" for k in headers):
17
+ try:
18
+ json.loads(body)
19
+ headers["Content-Type"] = "application/json"
20
+ except Exception:
21
+ pass
22
+
23
+ try:
24
+ async with httpx.AsyncClient(verify=False, timeout=self.timeout, follow_redirects=True) as client:
25
+ res = await client.request(
26
+ method=method.upper(),
27
+ url=url,
28
+ headers=headers,
29
+ content=body.encode("utf-8") if body else None
30
+ )
31
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
32
+
33
+ raw_text = res.text
34
+ try:
35
+ formatted_body = json.dumps(res.json(), indent=2, ensure_ascii=False)
36
+ is_json = True
37
+ except Exception:
38
+ formatted_body = raw_text
39
+ is_json = False
40
+
41
+ return {
42
+ "success": True,
43
+ "status_code": res.status_code,
44
+ "reason": res.reason_phrase,
45
+ "elapsed_ms": round(elapsed_ms, 2),
46
+ "headers": dict(res.headers),
47
+ "body": formatted_body,
48
+ "is_json": is_json,
49
+ "size_bytes": len(res.content)
50
+ }
51
+ except Exception as e:
52
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
53
+ return {
54
+ "success": False,
55
+ "error": str(e),
56
+ "elapsed_ms": round(elapsed_ms, 2),
57
+ "status_code": 0,
58
+ "body": f"Request Failed: {e}"
59
+ }
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ghostman"
7
+ version = "0.1.0"
8
+ description = "A fast, keyboard-driven terminal API client and webhook inspector"
9
+ readme = "README.md"
10
+ authors = [{ name = "4yk5" }]
11
+ license = { text = "MIT" }
12
+ requires-python = ">=3.10"
13
+ keywords = ["api", "tui", "terminal", "webhook", "postman", "rest", "http"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Topic :: Software Development :: Testing",
21
+ ]
22
+ dependencies = [
23
+ "textual>=8.0.0",
24
+ "httpx>=0.28.0",
25
+ "rich>=14.0.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/4yk5/Ghostman"
30
+ Repository = "https://github.com/4yk5/Ghostman"
31
+ Issues = "https://github.com/4yk5/Ghostman/issues"
32
+
33
+ [project.scripts]
34
+ ghostman = "main:main"
35
+
36
+ [tool.setuptools.package-data]
37
+ "*" = ["styles.tcss"]
38
+
39
+ [tool.setuptools]
40
+ py-modules = ["app", "main", "network", "storage", "webhook_server"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,86 @@
1
+ import os
2
+ from pathlib import Path
3
+ import json
4
+
5
+ try:
6
+ import yaml
7
+ HAS_YAML = True
8
+ except ImportError:
9
+ HAS_YAML = False
10
+
11
+ DEFAULT_COLLECTIONS = [
12
+ {
13
+ "name": "General API Tests",
14
+ "requests": [
15
+ {
16
+ "name": "Fetch Sample Post",
17
+ "method": "GET",
18
+ "url": "https://jsonplaceholder.typicode.com/posts/1",
19
+ "headers": {"Accept": "application/json"},
20
+ "body": ""
21
+ },
22
+ {
23
+ "name": "Create Post (JSON)",
24
+ "method": "POST",
25
+ "url": "https://jsonplaceholder.typicode.com/posts",
26
+ "headers": {"Content-Type": "application/json"},
27
+ "body": '{\n "title": "Ghostman Test",\n "body": "Fast TUI API client",\n "userId": 42\n}'
28
+ },
29
+ {
30
+ "name": "GitHub Public User",
31
+ "method": "GET",
32
+ "url": "https://api.github.com/users/octocat",
33
+ "headers": {"User-Agent": "Ghostman"},
34
+ "body": ""
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+
40
+ class StorageManager:
41
+ def __init__(self, workspace_path: str = None):
42
+ self.workspace_dir = Path(workspace_path or os.getcwd()) / ".ghostman"
43
+ self.yaml_file = self.workspace_dir / "collections.yaml"
44
+ self.json_file = self.workspace_dir / "collections.json"
45
+ self._init_storage()
46
+
47
+ def _init_storage(self):
48
+ if not self.workspace_dir.exists():
49
+ self.workspace_dir.mkdir(parents=True, exist_ok=True)
50
+ if not self.yaml_file.exists() and not self.json_file.exists():
51
+ self.save_collections(DEFAULT_COLLECTIONS)
52
+
53
+ def load_collections(self):
54
+ if HAS_YAML and self.yaml_file.exists():
55
+ try:
56
+ with open(self.yaml_file, "r", encoding="utf-8") as f:
57
+ data = yaml.safe_load(f)
58
+ if isinstance(data, list) and data:
59
+ return data
60
+ except Exception:
61
+ pass
62
+
63
+ if self.json_file.exists():
64
+ try:
65
+ with open(self.json_file, "r", encoding="utf-8") as f:
66
+ data = json.load(f)
67
+ if isinstance(data, list) and data:
68
+ return data
69
+ except Exception:
70
+ pass
71
+
72
+ return DEFAULT_COLLECTIONS
73
+
74
+ def save_collections(self, collections: list):
75
+ try:
76
+ with open(self.json_file, "w", encoding="utf-8") as f:
77
+ json.dump(collections, f, indent=2, ensure_ascii=False)
78
+ except Exception as e:
79
+ pass
80
+
81
+ if HAS_YAML:
82
+ try:
83
+ with open(self.yaml_file, "w", encoding="utf-8") as f:
84
+ yaml.dump(collections, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
85
+ except Exception:
86
+ pass
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import httpx
5
+
6
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ if sys.platform == "win32":
9
+ try:
10
+ sys.stdout.reconfigure(encoding="utf-8")
11
+ sys.stderr.reconfigure(encoding="utf-8")
12
+ except Exception:
13
+ pass
14
+
15
+ from app import GhostmanApp
16
+ from network import NetworkEngine
17
+ from webhook_server import WebhookServer
18
+ from storage import StorageManager
19
+ from textual.widgets import Input, TextArea, Select, ListView, Tabs
20
+
21
+ async def run_tests():
22
+ print("Running Ghostman test suite...")
23
+
24
+ # Storage
25
+ sm = StorageManager()
26
+ cols = sm.load_collections()
27
+ assert len(cols) > 0, "Collections failed to initialize"
28
+ print(" [+] Storage: OK")
29
+
30
+ # Network engine
31
+ net = NetworkEngine()
32
+ r = await net.execute_request("GET", "https://jsonplaceholder.typicode.com/posts/1")
33
+ assert r["success"] and r["status_code"] == 200, "Network GET failed"
34
+
35
+ r_post = await net.execute_request("POST", "https://jsonplaceholder.typicode.com/posts", body='{"test": 1}')
36
+ assert r_post["success"], "Network POST failed"
37
+
38
+ r_fail = await net.execute_request("GET", "invalid-target")
39
+ assert not r_fail["success"], "Error handling check failed"
40
+ print(" [+] Network Engine: OK")
41
+
42
+ # Webhook server
43
+ received = []
44
+ ws = WebhookServer(port=9098, on_hook_received=lambda h: received.append(h))
45
+ await ws.start()
46
+
47
+ async with httpx.AsyncClient() as c:
48
+ resp = await c.post("http://127.0.0.1:9098/test", json={"ping": "pong"})
49
+ assert resp.status_code == 200
50
+
51
+ await asyncio.sleep(0.1)
52
+ assert len(received) == 1 and received[0]["path"] == "/test"
53
+ await ws.stop()
54
+ print(" [+] Webhook Server: OK")
55
+
56
+ # TUI Interactions
57
+ app = GhostmanApp()
58
+ async with app.run_test() as pilot:
59
+ # Save request integrity
60
+ save_btn = app.query_one("#save-btn")
61
+ await pilot.click(save_btn)
62
+ await pilot.pause(0.2)
63
+ await pilot.click(save_btn)
64
+ await pilot.pause(0.2)
65
+
66
+ # New request action
67
+ new_btn = app.query_one("#new-req-btn")
68
+ await pilot.click(new_btn)
69
+ await pilot.pause(0.1)
70
+ assert app.query_one("#url-input", Input).value == ""
71
+
72
+ # Execute request
73
+ app.query_one("#url-input", Input).value = "https://jsonplaceholder.typicode.com/posts/1"
74
+ await pilot.press("ctrl+s")
75
+ await pilot.pause(1.0)
76
+ assert app.query_one("#tab-response-pane").display
77
+
78
+ # Webhook toggle via hotkey
79
+ await pilot.press("ctrl+w")
80
+ await pilot.pause(0.2)
81
+ assert app.webhook_server.is_running
82
+ await pilot.press("ctrl+w")
83
+ await pilot.pause(0.2)
84
+ assert not app.webhook_server.is_running
85
+ print(" [+] TUI Interactions: OK")
86
+
87
+ print("\nAll tests passed successfully.")
88
+
89
+ if __name__ == "__main__":
90
+ asyncio.run(run_tests())
@@ -0,0 +1,96 @@
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime
4
+ from typing import Callable, Optional
5
+
6
+ class WebhookServer:
7
+ def __init__(self, host: str = "127.0.0.1", port: int = 9090, on_hook_received: Optional[Callable] = None):
8
+ self.host = host
9
+ self.port = port
10
+ self.on_hook_received = on_hook_received
11
+ self.server: Optional[asyncio.AbstractServer] = None
12
+ self.is_running = False
13
+ self.captured_hooks = []
14
+
15
+ async def start(self):
16
+ if self.is_running:
17
+ return
18
+ self.server = await asyncio.start_server(self._handle_client, self.host, self.port)
19
+ self.is_running = True
20
+
21
+ async def stop(self):
22
+ if self.server:
23
+ self.server.close()
24
+ await self.server.wait_closed()
25
+ self.server = None
26
+ self.is_running = False
27
+
28
+ async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
29
+ try:
30
+ line = await reader.readline()
31
+ if not line:
32
+ writer.close()
33
+ await writer.wait_closed()
34
+ return
35
+
36
+ req_line = line.decode("utf-8", errors="ignore").strip()
37
+ parts = req_line.split(" ")
38
+ method = parts[0] if parts else "GET"
39
+ path = parts[1] if len(parts) > 1 else "/"
40
+
41
+ headers = {}
42
+ content_length = 0
43
+ while True:
44
+ header_line = await reader.readline()
45
+ if not header_line or header_line in (b"\r\n", b"\n"):
46
+ break
47
+ decoded_header = header_line.decode("utf-8", errors="ignore").strip()
48
+ if ":" in decoded_header:
49
+ k, v = decoded_header.split(":", 1)
50
+ k_clean = k.strip().title()
51
+ v_clean = v.strip()
52
+ headers[k_clean] = v_clean
53
+ if k_clean.lower() == "content-length":
54
+ try:
55
+ content_length = int(v_clean)
56
+ except ValueError:
57
+ content_length = 0
58
+
59
+ body_bytes = b""
60
+ if content_length > 0:
61
+ body_bytes = await reader.readexactly(content_length)
62
+
63
+ body_str = body_bytes.decode("utf-8", errors="ignore")
64
+ try:
65
+ formatted_body = json.dumps(json.loads(body_str), indent=2, ensure_ascii=False)
66
+ except Exception:
67
+ formatted_body = body_str
68
+
69
+ hook_entry = {
70
+ "id": len(self.captured_hooks) + 1,
71
+ "timestamp": datetime.now().strftime("%H:%M:%S"),
72
+ "method": method,
73
+ "path": path,
74
+ "headers": headers,
75
+ "body": formatted_body,
76
+ "raw_body": body_str
77
+ }
78
+ self.captured_hooks.append(hook_entry)
79
+
80
+ response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"ok\",\"source\":\"ghostman\"}"
81
+ writer.write(response)
82
+ await writer.drain()
83
+
84
+ if self.on_hook_received:
85
+ if asyncio.iscoroutinefunction(self.on_hook_received):
86
+ await self.on_hook_received(hook_entry)
87
+ else:
88
+ self.on_hook_received(hook_entry)
89
+ except Exception:
90
+ pass
91
+ finally:
92
+ try:
93
+ writer.close()
94
+ await writer.wait_closed()
95
+ except Exception:
96
+ pass