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 +21 -0
- ghostman-0.1.0/PKG-INFO +153 -0
- ghostman-0.1.0/README.md +129 -0
- ghostman-0.1.0/app.py +279 -0
- ghostman-0.1.0/ghostman.egg-info/PKG-INFO +153 -0
- ghostman-0.1.0/ghostman.egg-info/SOURCES.txt +15 -0
- ghostman-0.1.0/ghostman.egg-info/dependency_links.txt +1 -0
- ghostman-0.1.0/ghostman.egg-info/entry_points.txt +2 -0
- ghostman-0.1.0/ghostman.egg-info/requires.txt +3 -0
- ghostman-0.1.0/ghostman.egg-info/top_level.txt +5 -0
- ghostman-0.1.0/main.py +18 -0
- ghostman-0.1.0/network.py +59 -0
- ghostman-0.1.0/pyproject.toml +40 -0
- ghostman-0.1.0/setup.cfg +4 -0
- ghostman-0.1.0/storage.py +86 -0
- ghostman-0.1.0/tests/test_all.py +90 -0
- ghostman-0.1.0/webhook_server.py +96 -0
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.
|
ghostman-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
|
40
|
+
[](https://www.python.org/)
|
|
41
|
+
[](https://github.com/Textualize/textual)
|
|
42
|
+
[](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).
|
ghostman-0.1.0/README.md
ADDED
|
@@ -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)
|
|
16
|
+
[](https://www.python.org/)
|
|
17
|
+
[](https://github.com/Textualize/textual)
|
|
18
|
+
[](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)
|
|
40
|
+
[](https://www.python.org/)
|
|
41
|
+
[](https://github.com/Textualize/textual)
|
|
42
|
+
[](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 @@
|
|
|
1
|
+
|
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"]
|
ghostman-0.1.0/setup.cfg
ADDED
|
@@ -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
|