@beast01/tcurl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # tcurl
2
+
3
+ An interactive terminal UI (TUI) for building, sending, and saving HTTP
4
+ requests — **curl, but comfortable.** Create requests of any method
5
+ (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`), tweak headers,
6
+ query params, body and auth, fire them off, and browse the response — all
7
+ without leaving your terminal. Requests are saved to disk so you can reuse
8
+ them later.
9
+
10
+ Styled with the **Gruvbox** and **Ember** color themes. Works on macOS,
11
+ Linux, and Windows (any terminal with a TTY). Zero native dependencies.
12
+
13
+ ```
14
+ ┌ tcurl — interactive HTTP client ──────────────── gruvbox ┐
15
+ │ ┌ Saved requests (2) ──┐ ┌ GET Get users ──────────────┐ │
16
+ │ │ ❯ GET Get users │ │ GET https://api.site/users │ │
17
+ │ │ POST Create user │ │ Auth bearer │ │
18
+ │ └──────────────────────┘ │ Headers 2 │ │
19
+ │ └──────────────────────────────┘ │
20
+ │ n new ↵ edit s send d delete y curl ? help │
21
+ └──────────────────────────────────────────────────────────┘
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install -g tcurl
28
+ ```
29
+
30
+ Requires **Node.js ≥ 18**.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ tcurl # launch the interactive TUI
36
+ tcurl https://api.example.com # launch with a new request prefilled
37
+ tcurl -X POST https://api.example.com/items
38
+ tcurl --theme ember # start with the Ember theme
39
+ tcurl --help
40
+ ```
41
+
42
+ ### Keyboard
43
+
44
+ **List view**
45
+
46
+ | Key | Action |
47
+ | ------------ | ------------------------------- |
48
+ | `↑` / `↓` | Move selection |
49
+ | `n` | New request |
50
+ | `Enter` / `e`| Edit request |
51
+ | `s` | Send request |
52
+ | `d` | Delete request |
53
+ | `y` | Copy as `curl` (prints on exit) |
54
+ | `t` | Cycle theme (gruvbox / ember) |
55
+ | `?` | Help |
56
+ | `q` / `Ctrl+C` | Quit |
57
+
58
+ **Editor**
59
+
60
+ | Key | Action |
61
+ | ------------------ | -------------------------------------------- |
62
+ | `↑` / `↓` / `Tab` | Move between fields |
63
+ | `←` / `→` / `Space`| Change method, body type, toggles |
64
+ | `Enter` | Edit the focused field |
65
+ | `Ctrl+S` | Save a multi-line field (body, headers…) |
66
+ | `Esc` | Back to list (auto-saves) |
67
+
68
+ **Response**
69
+
70
+ | Key | Action |
71
+ | --------------- | ------------------- |
72
+ | `↑`/`↓`/`j`/`k` | Scroll |
73
+ | `PgUp`/`PgDn` | Page scroll |
74
+ | `g` / `G` | Jump to top/bottom |
75
+ | `Tab` / `h` | Body ↔ Headers |
76
+ | `Esc` | Back |
77
+
78
+ ## Features
79
+
80
+ - **All HTTP methods** — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
81
+ - **Body modes** — none, raw text, JSON (auto content-type), and
82
+ URL-encoded form (`key=value` per line).
83
+ - **Headers & query params** — edited as simple `Key: Value` / `key=value`
84
+ lines.
85
+ - **Auth** — Bearer token or HTTP Basic (username / password).
86
+ - **Redirects** — follow (with method downgrade on 301/302/303) or manual.
87
+ - **TLS control** — toggle certificate verification for self-signed hosts.
88
+ - **Timeouts** — per-request, in milliseconds.
89
+ - **Pretty responses** — JSON is auto-formatted; scrollable body & headers
90
+ with status, timing, and size.
91
+ - **Persistence** — requests are stored as JSON and reused across sessions.
92
+ - **Export** — copy any saved request as an equivalent `curl` command.
93
+
94
+ ## Where is my data?
95
+
96
+ Saved requests and your theme choice live in a single JSON file under your
97
+ platform's config directory (via [`env-paths`](https://www.npmjs.com/package/env-paths)):
98
+
99
+ - Linux: `~/.config/tcurl/store.json`
100
+ - macOS: `~/Library/Preferences/tcurl/store.json`
101
+ - Windows: `%APPDATA%\tcurl\Config\store.json`
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ npm install
107
+ npm run build # compile TypeScript to dist/
108
+ npm run dev # watch-compile
109
+ node dist/cli.js # run locally
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/app.js ADDED
@@ -0,0 +1,170 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ import { getTheme, themes } from './theme.js';
4
+ import { defaultRequest, saveStore } from './storage.js';
5
+ import { executeRequest } from './http/client.js';
6
+ import { toCurl } from './lib/curlgen.js';
7
+ import { Header } from './components/Header.js';
8
+ import { StatusBar } from './components/StatusBar.js';
9
+ import { RequestList } from './components/RequestList.js';
10
+ import { RequestEditor } from './components/RequestEditor.js';
11
+ import { ResponseViewer } from './components/ResponseViewer.js';
12
+ import { Help } from './components/Help.js';
13
+ import { Spinner } from './components/Spinner.js';
14
+ function useDimensions() {
15
+ const { stdout } = useStdout();
16
+ const [dims, setDims] = useState({
17
+ cols: stdout?.columns ?? 80,
18
+ rows: stdout?.rows ?? 24,
19
+ });
20
+ useEffect(() => {
21
+ if (!stdout)
22
+ return;
23
+ const onResize = () => setDims({ cols: stdout.columns, rows: stdout.rows });
24
+ stdout.on('resize', onResize);
25
+ return () => {
26
+ stdout.off('resize', onResize);
27
+ };
28
+ }, [stdout]);
29
+ return dims;
30
+ }
31
+ export function App({ initialStore, exitState, }) {
32
+ const { exit } = useApp();
33
+ const { cols, rows } = useDimensions();
34
+ const [store, setStore] = useState(initialStore);
35
+ const [selected, setSelected] = useState(0);
36
+ const [mode, setMode] = useState('list');
37
+ const [working, setWorking] = useState(null);
38
+ const [response, setResponse] = useState(null);
39
+ const [loading, setLoading] = useState(false);
40
+ const [flash, setFlash] = useState(null);
41
+ const theme = getTheme(store.theme);
42
+ const requests = store.requests;
43
+ function persist(next) {
44
+ setStore(next);
45
+ saveStore(next).catch(() => {
46
+ /* non-fatal: keep running even if disk write fails */
47
+ });
48
+ }
49
+ function flashMessage(msg) {
50
+ setFlash(msg);
51
+ setTimeout(() => setFlash(null), 2500);
52
+ }
53
+ function upsertWorking(r) {
54
+ const idx = requests.findIndex((x) => x.id === r.id);
55
+ const nextRequests = idx === -1 ? [...requests, r] : requests.map((x) => (x.id === r.id ? r : x));
56
+ persist({ ...store, requests: nextRequests });
57
+ }
58
+ async function send(config) {
59
+ if (!config.url.trim()) {
60
+ flashMessage('Set a URL first');
61
+ return;
62
+ }
63
+ setLoading(true);
64
+ setResponse(null);
65
+ const result = await executeRequest(config);
66
+ setResponse(result);
67
+ setLoading(false);
68
+ setMode('response');
69
+ }
70
+ // Global keys — only in list mode and when idle.
71
+ useInput((input, key) => {
72
+ if (key.ctrl && input === 'c') {
73
+ exit();
74
+ return;
75
+ }
76
+ if (loading)
77
+ return;
78
+ if (input === '?') {
79
+ setMode('help');
80
+ return;
81
+ }
82
+ if (input === 'q') {
83
+ exit();
84
+ return;
85
+ }
86
+ if (input === 't') {
87
+ const names = Object.keys(themes);
88
+ const next = names[(names.indexOf(store.theme) + 1) % names.length];
89
+ persist({ ...store, theme: next });
90
+ return;
91
+ }
92
+ if (key.upArrow || input === 'k') {
93
+ setSelected((s) => Math.max(0, s - 1));
94
+ return;
95
+ }
96
+ if (key.downArrow || input === 'j') {
97
+ setSelected((s) => Math.min(requests.length - 1, s + 1));
98
+ return;
99
+ }
100
+ if (input === 'n') {
101
+ const r = defaultRequest();
102
+ setWorking(r);
103
+ setMode('editor');
104
+ return;
105
+ }
106
+ if ((key.return || input === 'e') && requests[selected]) {
107
+ setWorking({ ...requests[selected] });
108
+ setMode('editor');
109
+ return;
110
+ }
111
+ if (input === 's' && requests[selected]) {
112
+ send(requests[selected]);
113
+ return;
114
+ }
115
+ if (input === 'd' && requests[selected]) {
116
+ const next = requests.filter((_, i) => i !== selected);
117
+ persist({ ...store, requests: next });
118
+ setSelected((s) => Math.max(0, Math.min(s, next.length - 1)));
119
+ flashMessage('Deleted');
120
+ return;
121
+ }
122
+ if (input === 'y' && requests[selected]) {
123
+ exitState.curl = toCurl(requests[selected]);
124
+ flashMessage('curl command will print on exit');
125
+ return;
126
+ }
127
+ }, { isActive: mode === 'list' && !loading });
128
+ // Editor callbacks
129
+ function onEditorChange(r) {
130
+ setWorking(r);
131
+ }
132
+ function onEditorBack() {
133
+ if (working) {
134
+ upsertWorking(working);
135
+ const idx = requests.findIndex((x) => x.id === working.id);
136
+ if (idx !== -1)
137
+ setSelected(idx);
138
+ else
139
+ setSelected(requests.length); // newly added goes last
140
+ }
141
+ setWorking(null);
142
+ setMode('list');
143
+ }
144
+ function onEditorSend() {
145
+ if (working) {
146
+ upsertWorking(working);
147
+ send(working);
148
+ }
149
+ }
150
+ const statusHints = mode === 'list'
151
+ ? [
152
+ { key: 'n', label: 'new' },
153
+ { key: '↵', label: 'edit' },
154
+ { key: 's', label: 'send' },
155
+ { key: 'd', label: 'delete' },
156
+ { key: 'y', label: 'curl' },
157
+ { key: 't', label: 'theme' },
158
+ { key: '?', label: 'help' },
159
+ { key: 'q', label: 'quit' },
160
+ ]
161
+ : [];
162
+ return (React.createElement(Box, { flexDirection: "column", width: cols, minHeight: rows - 1 },
163
+ React.createElement(Header, { theme: theme, subtitle: mode === 'list' ? `${requests.length} saved · ` : '' }),
164
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center" },
165
+ React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
166
+ React.createElement(Box, { justifyContent: "space-between" },
167
+ React.createElement(StatusBar, { theme: theme, hints: statusHints }),
168
+ flash ? (React.createElement(Box, { paddingX: 1 },
169
+ React.createElement(Text, { color: theme.info }, flash))) : null)));
170
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { App } from './app.js';
5
+ import { loadStore, saveStore, storeLocation } from './storage.js';
6
+ const VERSION = '1.0.0';
7
+ const HELP = `
8
+ tcurl — an interactive terminal UI for HTTP requests.
9
+
10
+ Usage:
11
+ tcurl Launch the interactive TUI
12
+ tcurl [url] Launch and prefill a new request with [url]
13
+
14
+ Options:
15
+ -X, --method <m> Prefill method (used with a url)
16
+ --theme <name> Start with a theme (gruvbox | ember)
17
+ -h, --help Show this help
18
+ -v, --version Show version
19
+
20
+ Inside the TUI, press ? for the full keyboard reference.
21
+ Saved requests live at:
22
+ ${storeLocation()}
23
+ `;
24
+ function parseArgs(argv) {
25
+ const out = {};
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === '-X' || a === '--method') {
29
+ out.method = (argv[++i] ?? '').toUpperCase();
30
+ }
31
+ else if (a === '--theme') {
32
+ out.theme = argv[++i];
33
+ }
34
+ else if (!a.startsWith('-')) {
35
+ out.url = a;
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ async function main() {
41
+ const argv = process.argv.slice(2);
42
+ if (argv.includes('-h') || argv.includes('--help')) {
43
+ process.stdout.write(HELP + '\n');
44
+ return;
45
+ }
46
+ if (argv.includes('-v') || argv.includes('--version')) {
47
+ process.stdout.write(`tcurl ${VERSION}\n`);
48
+ return;
49
+ }
50
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
51
+ process.stderr.write('tcurl requires an interactive terminal (TTY). Run it directly in your terminal.\n');
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ const args = parseArgs(argv);
56
+ const store = await loadStore();
57
+ if (args.theme && (args.theme === 'gruvbox' || args.theme === 'ember')) {
58
+ store.theme = args.theme;
59
+ }
60
+ // Prefill a new request from CLI args and persist it so it appears in the list.
61
+ if (args.url) {
62
+ const { defaultRequest } = await import('./storage.js');
63
+ const r = defaultRequest({
64
+ url: args.url,
65
+ name: args.url,
66
+ method: args.method || 'GET',
67
+ });
68
+ store.requests.push(r);
69
+ await saveStore(store);
70
+ }
71
+ const exitState = { curl: null };
72
+ const { waitUntilExit } = render(React.createElement(App, { initialStore: store, exitState: exitState }), { exitOnCtrlC: false });
73
+ await waitUntilExit();
74
+ if (exitState.curl) {
75
+ process.stdout.write('\n' + exitState.curl + '\n');
76
+ }
77
+ }
78
+ main().catch((err) => {
79
+ process.stderr.write(`tcurl: ${err?.stack ?? err}\n`);
80
+ process.exitCode = 1;
81
+ });
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ export function Header({ theme, subtitle }) {
4
+ return (React.createElement(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, justifyContent: "space-between" },
5
+ React.createElement(Text, null,
6
+ React.createElement(Text, { color: theme.accent, bold: true }, ' tcurl '),
7
+ React.createElement(Text, { color: theme.fgDim }, "\u2014 interactive HTTP client")),
8
+ React.createElement(Text, { color: theme.muted },
9
+ subtitle ?? '',
10
+ React.createElement(Text, { color: theme.info },
11
+ " ",
12
+ theme.name))));
13
+ }
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ const SECTIONS = [
4
+ {
5
+ title: 'List view',
6
+ rows: [
7
+ ['↑ / ↓', 'Move selection'],
8
+ ['n', 'New request'],
9
+ ['Enter / e', 'Edit request'],
10
+ ['s', 'Send request'],
11
+ ['d', 'Delete request'],
12
+ ['y', 'Copy as curl (prints on exit)'],
13
+ ['t', 'Cycle theme (gruvbox / ember)'],
14
+ ['? ', 'Toggle this help'],
15
+ ['q / Ctrl+C', 'Quit'],
16
+ ],
17
+ },
18
+ {
19
+ title: 'Editor',
20
+ rows: [
21
+ ['↑ / ↓ / Tab', 'Move between fields'],
22
+ ['← / → / Space', 'Change method, body type, toggles'],
23
+ ['Enter', 'Edit the focused field'],
24
+ ['Ctrl+S', 'Save multi-line field'],
25
+ ['Esc', 'Back to list (auto-saves)'],
26
+ ],
27
+ },
28
+ {
29
+ title: 'Response',
30
+ rows: [
31
+ ['↑ / ↓ / j / k', 'Scroll'],
32
+ ['PgUp / PgDn', 'Page scroll'],
33
+ ['g / G', 'Top / bottom'],
34
+ ['Tab / h', 'Switch body / headers'],
35
+ ['Esc', 'Back'],
36
+ ],
37
+ },
38
+ ];
39
+ export function Help({ theme, onClose }) {
40
+ useInput((input, key) => {
41
+ if (key.escape || input === '?' || input === 'q')
42
+ onClose();
43
+ });
44
+ return (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borderActive, paddingX: 2, paddingY: 1, flexGrow: 1 },
45
+ React.createElement(Text, { color: theme.accent, bold: true }, "tcurl \u2014 keyboard reference"),
46
+ React.createElement(Box, { height: 1 }),
47
+ SECTIONS.map((section) => (React.createElement(Box, { key: section.title, flexDirection: "column", marginBottom: 1 },
48
+ React.createElement(Text, { color: theme.accentAlt, bold: true }, section.title),
49
+ section.rows.map(([k, desc]) => (React.createElement(Text, { key: k },
50
+ React.createElement(Text, { color: theme.accent }, k.padEnd(16)),
51
+ React.createElement(Text, { color: theme.fgDim }, desc))))))),
52
+ React.createElement(Text, { color: theme.muted }, "Press ? or Esc to close.")));
53
+ }