├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── _config.yml ├── args ├── curlopts.go ├── gen.go ├── gen.sh ├── httpie.go ├── parse.go └── parse_test.go ├── console_other.go ├── console_windows.go ├── doc ├── get.png └── put.png ├── formatter ├── binaryfilter.go ├── cleanup.go ├── color.go ├── help.go └── json.go ├── go.mod ├── go.sum └── main.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | tags: 11 | - "*" 12 | 13 | concurrency: 14 | group: ${{ github.ref_name }}-goreleaser 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | goreleaser: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: write 25 | id-token: write 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: stable 36 | 37 | - name: Test 38 | run: go test ./... 39 | 40 | - name: Install Cosign 41 | uses: sigstore/cosign-installer@v3 42 | if: github.ref_type == 'tag' 43 | 44 | - name: Run GoReleaser 45 | uses: goreleaser/goreleaser-action@v6 46 | with: 47 | version: latest 48 | args: ${{ github.ref_type == 'tag' && 'release' || 'build --snapshot' }} --clean 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .snapcraft/snapcraft.cfg 2 | dist/ 3 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: curlie 3 | builds: 4 | - binary: curlie 5 | env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - linux 10 | - freebsd 11 | - windows 12 | goarch: 13 | - 386 14 | - amd64 15 | - arm 16 | - arm64 17 | - ppc64le 18 | - s390x 19 | goarm: 20 | - 6 21 | - 7 22 | 23 | archives: 24 | - format_overrides: 25 | - goos: windows 26 | formats: ['zip'] 27 | 28 | release: 29 | name_template: "{{.ProjectName}}-v{{.Version}}" 30 | 31 | signs: 32 | - cmd: cosign 33 | artifacts: checksum 34 | output: true 35 | certificate: "${artifact}.pem" 36 | args: 37 | - sign-blob 38 | - "--output-signature=${signature}" 39 | - "--output-certificate=${certificate}" 40 | - "${artifact}" 41 | - "--yes" 42 | 43 | brews: 44 | - repository: 45 | owner: rs 46 | name: homebrew-tap 47 | commit_author: 48 | name: Olivier Poitrey 49 | email: rs@rhapsodyk.net 50 | homepage: https://github.com/rs/curlie 51 | description: The power of curl, the ease of use of httpie. 52 | 53 | nfpms: 54 | - maintainer: Olivier Poitrey 55 | description: curle is a frontend to curl that offers the ease of use of httpie without having to compromise curl features and performance. 56 | license: MIT 57 | formats: 58 | - deb 59 | - rpm 60 | dependencies: 61 | - curl 62 | bindir: /usr/bin 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Olivier Poitrey 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Curlie 2 | 3 | If you like the interface of [HTTPie](https://httpie.org) but miss the features of [curl](https://curl.haxx.se), curlie is what you are searching for. Curlie is a frontend to `curl` that adds the ease of use of `httpie`, without compromising on features and performance. All `curl` options are exposed with syntax sugar and output formatting inspired from `httpie`. 4 | 5 | ## Install 6 | 7 | Using [homebrew](https://brew.sh/): 8 | 9 | ```sh 10 | brew install curlie 11 | ``` 12 | 13 | Using [webi](https://webinstall.dev/curlie/): 14 | 15 | ```sh 16 | # macOS / Linux 17 | curl -sS https://webinstall.dev/curlie | bash 18 | ``` 19 | 20 | ```pwsh 21 | # Windows 22 | curl.exe -A "MS" https://webinstall.dev/curlie | powershell 23 | ``` 24 | 25 | Using [eget](https://github.com/zyedidia/eget): 26 | 27 | ```sh 28 | # Ubuntu/Debian 29 | eget rs/curlie -a deb --to=curlie.deb 30 | sudo dpkg -i curlie.deb 31 | ``` 32 | 33 | Using [macports](https://www.macports.org): 34 | 35 | ```sh 36 | sudo port install curlie 37 | ``` 38 | 39 | Using [pkg](https://man.freebsd.org/pkg/8): 40 | 41 | ```sh 42 | pkg install curlie 43 | ``` 44 | 45 | Using [go](https://golang.org/): 46 | 47 | ```sh 48 | go install github.com/rs/curlie@latest 49 | ``` 50 | 51 | Using [scoop](https://scoop.sh/): 52 | 53 | ```sh 54 | scoop install curlie 55 | ``` 56 | 57 | Or download a [binary package](https://github.com/rs/curlie/releases/latest). 58 | 59 | ## Usage 60 | 61 | Synopsis: 62 | 63 | ```sh 64 | curlie [CURL_OPTIONS...] [METHOD] URL [ITEM [ITEM]] 65 | ``` 66 | 67 | Simple GET: 68 | 69 | ![Simple GET request example](doc/get.png) 70 | 71 | Custom method, headers and JSON data: 72 | 73 | ![Custom PUT request with headers and JSON data example](doc/put.png) 74 | 75 | When running interactively, `curlie` provides pretty-printed output for json. To force pretty-printed output, pass `--pretty`. 76 | 77 | ## Build 78 | 79 | Build with [goreleaser](https://goreleaser.com) to test that all platforms compile properly. 80 | 81 | ```sh 82 | goreleaser build --clean --snapshot 83 | ``` 84 | 85 | Or for your current platform only. 86 | 87 | ```sh 88 | goreleaser build --clean --snapshot --single-target 89 | ``` 90 | 91 | ## Differences with httpie 92 | 93 | * Like `curl` but unlike `httpie`, headers are written on `stderr` instead of `stdout`. 94 | * Output is not buffered, all the formatting is done on the fly so you can easily debug streamed data. 95 | * Use the `--curl` option to print executed curl command. 96 | 97 | ## License 98 | 99 | All source code is licensed under the [MIT License](https://raw.github.com/rs/curlie/master/LICENSE). 100 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: rs/gh-readme 2 | -------------------------------------------------------------------------------- /args/curlopts.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import "sort" 4 | 5 | var ( 6 | curlShortValues = "EKCbcdDFPHhmoUQreXYytzTuAw" 7 | curlLongValues = []string{ 8 | "abstract-unix-socket", 9 | "alt-svc", 10 | "aws-sigv4", 11 | "cacert", 12 | "capath", 13 | "cert", 14 | "cert-type", 15 | "ciphers", 16 | "config", 17 | "connect-timeout", 18 | "connect-to", 19 | "continue-at", 20 | "cookie", 21 | "cookie-jar", 22 | "create-file-mode", 23 | "crlfile", 24 | "curves", 25 | "data", 26 | "data-ascii", 27 | "data-binary", 28 | "data-raw", 29 | "data-urlencode", 30 | "delegation", 31 | "dns-interface", 32 | "dns-ipv4-addr", 33 | "dns-ipv6-addr", 34 | "dns-servers", 35 | "doh-url", 36 | "dump-header", 37 | "egd-file", 38 | "engine", 39 | "etag-compare", 40 | "etag-save", 41 | "expect100-timeout", 42 | "form", 43 | "form-string", 44 | "ftp-account", 45 | "ftp-alternative-to-user", 46 | "ftp-method", 47 | "ftp-port", 48 | "ftp-ssl-ccc-mode", 49 | "happy-eyeballs-timeout-ms", 50 | "header", 51 | "help", 52 | "hostpubmd5", 53 | "hostpubsha256", 54 | "hsts", 55 | "interface", 56 | "json", 57 | "keepalive-time", 58 | "key", 59 | "key-type", 60 | "krb", 61 | "libcurl", 62 | "limit-rate", 63 | "local-port", 64 | "login-options", 65 | "mail-auth", 66 | "mail-from", 67 | "mail-rcpt", 68 | "max-filesize", 69 | "max-redirs", 70 | "max-time", 71 | "netrc-file", 72 | "noproxy", 73 | "oauth2-bearer", 74 | "output", 75 | "output-dir", 76 | "parallel-max", 77 | "pass", 78 | "pinnedpubkey", 79 | "proto", 80 | "proto-default", 81 | "proto-redir", 82 | "proxy", 83 | "proxy-cacert", 84 | "proxy-capath", 85 | "proxy-cert", 86 | "proxy-cert-type", 87 | "proxy-ciphers", 88 | "proxy-crlfile", 89 | "proxy-header", 90 | "proxy-key", 91 | "proxy-key-type", 92 | "proxy-pass", 93 | "proxy-pinnedpubkey", 94 | "proxy-service-name", 95 | "proxy-tls13-ciphers", 96 | "proxy-tlsauthtype", 97 | "proxy-tlspassword", 98 | "proxy-tlsuser", 99 | "proxy-user", 100 | "proxy1.0", 101 | "pubkey", 102 | "quote", 103 | "random-file", 104 | "range", 105 | "rate", 106 | "referer", 107 | "request", 108 | "request-target", 109 | "resolve", 110 | "retry", 111 | "retry-delay", 112 | "retry-max-time", 113 | "sasl-authzid", 114 | "service-name", 115 | "socks4", 116 | "socks4a", 117 | "socks5", 118 | "socks5-gssapi-service", 119 | "socks5-hostname", 120 | "speed-limit", 121 | "speed-time", 122 | "stderr", 123 | "telnet-option", 124 | "tftp-blksize", 125 | "time-cond", 126 | "tls-max", 127 | "tls13-ciphers", 128 | "tlsauthtype", 129 | "tlspassword", 130 | "tlsuser", 131 | "trace", 132 | "trace-ascii", 133 | "unix-socket", 134 | "upload-file", 135 | "url", 136 | "url-query", 137 | "user", 138 | "user-agent", 139 | "write-out", 140 | } 141 | ) 142 | 143 | func init() { 144 | sort.Strings(curlLongValues) 145 | } 146 | -------------------------------------------------------------------------------- /args/gen.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | //go:generate ./gen.sh 4 | -------------------------------------------------------------------------------- /args/gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -h all|perl -pe 's/^\s*(?:-([^-]),\s+)?--(.*?)\s+<.*?>.*/push @a, $1; push @b, $2/e; undef $_; END {print "package args\n\nvar (\n\tcurlShortValues = \"", @a, "\"\n\tcurlLongValues = []string{\n", join(",\n", map {"\t\t\"$_\""} @b), "}\n)\n"}' > curlopts.go 4 | -------------------------------------------------------------------------------- /args/httpie.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type argType int 11 | 12 | const ( 13 | unknownArg argType = iota 14 | headerArg 15 | paramArg 16 | fieldArg 17 | jsonArg 18 | ) 19 | 20 | type PostMode int 21 | 22 | const ( 23 | PostModeJSON PostMode = iota + 1 24 | PostModeFORM 25 | ) 26 | 27 | func parseFancyArgs(args []string, postMode PostMode) (opts Opts) { 28 | if len(args) == 0 { 29 | return 30 | } 31 | method := strings.ToUpper(args[0]) 32 | switch method { 33 | case "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE": 34 | opts = append(opts, "-X", method) 35 | args = args[1:] 36 | case "HEAD": 37 | opts = append(opts, "-I") 38 | args = args[1:] 39 | } 40 | if len(args) == 0 { 41 | return 42 | } 43 | url := args[0] 44 | data := map[string]interface{}{} 45 | for _, arg := range args[1:] { 46 | typ, name, value := parseArg(arg) 47 | switch typ { 48 | case headerArg: 49 | opts = append(opts, "-H", name+":"+value) 50 | case paramArg: 51 | url = appendURLParam(url, name, value) 52 | case fieldArg: 53 | if postMode == PostModeFORM { 54 | opts = append(opts, "-F", name+"="+value) 55 | } else { 56 | data[name] = value 57 | } 58 | case jsonArg: 59 | var v interface{} 60 | json.Unmarshal([]byte(value), &v) 61 | data[name] = v 62 | default: 63 | opts = append(opts, arg) 64 | } 65 | } 66 | if len(data) > 0 { 67 | j, _ := json.Marshal(data) 68 | opts = append(opts, "-d", string(j)) 69 | } 70 | opts = append(opts, normalizeURL(url)) 71 | return 72 | } 73 | 74 | func normalizeURL(u string) string { 75 | // If scheme is omitted, use http: 76 | noScheme := false 77 | if !strings.HasPrefix(u, "http") { 78 | if strings.HasPrefix(u, "//") { 79 | u = "http:" + u 80 | } else { 81 | u = "http://" + u 82 | } 83 | noScheme = true 84 | } 85 | println(u) 86 | pu, err := url.Parse(u) 87 | if err != nil { 88 | fmt.Print(err) 89 | return u 90 | } 91 | if pu.Host == ":" { 92 | pu.Host = "localhost" 93 | } else if pu.Host != "" && pu.Host[0] == ':' { 94 | // If :port is given with no hostname, add localhost 95 | pu.Host = "localhost" + pu.Host 96 | } 97 | nu := pu.String() 98 | if noScheme { 99 | // Remove the prefixed scheme added above so we let curl handle deal 100 | // with the default scheme (ie if --proto-default is used) 101 | nu = strings.TrimPrefix(nu, "http://") 102 | } 103 | return nu 104 | } 105 | 106 | func parseArg(arg string) (typ argType, name, value string) { 107 | for i := 0; i < len(arg); i++ { 108 | switch arg[i] { 109 | case ':': 110 | if i+1 < len(arg) && arg[i+1] == '=' { 111 | return jsonArg, arg[:i], arg[i+2:] 112 | } 113 | return headerArg, arg[:i], arg[i+1:] 114 | case '=': 115 | if i+1 < len(arg) && arg[i+1] == '=' { 116 | return paramArg, arg[:i], arg[i+2:] 117 | } 118 | return fieldArg, arg[:i], arg[i+1:] 119 | } 120 | } 121 | return 122 | } 123 | 124 | func appendURLParam(u, name, value string) string { 125 | sep := "?" 126 | if strings.IndexByte(u, '?') != -1 { 127 | sep = "&" 128 | } 129 | return u + sep + url.QueryEscape(name) + "=" + url.QueryEscape(value) 130 | } 131 | -------------------------------------------------------------------------------- /args/parse.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | type Opts []string 9 | 10 | func (opts Opts) index(opt string) int { 11 | off := 1 12 | if len(opt) > 1 { 13 | off = 2 14 | } 15 | for i, o := range opts { 16 | if len(o) >= 2 && o[0] == '-' { 17 | if o[off:] == opt { 18 | return i 19 | } 20 | } 21 | } 22 | return -1 23 | } 24 | 25 | // Has returns true if opt flag is in opts. 26 | func (opts Opts) Has(opt string) bool { 27 | return opts.index(opt) != -1 28 | } 29 | 30 | // Val return the value of the first occurrence of opt. 31 | func (opts Opts) Val(opt string) string { 32 | if idx := opts.index(opt); idx != -1 && idx+1 < len(opts) { 33 | return opts[idx+1] 34 | } 35 | return "" 36 | } 37 | 38 | // Vals return the values of all occurrences of opt. 39 | func (opts Opts) Vals(opt string) []string { 40 | var vals []string 41 | off := 1 42 | if len(opt) > 1 { 43 | off = 2 44 | } 45 | for i, o := range opts { 46 | if len(o) >= 2 && o[0] == '-' { 47 | if o[off:] == opt { 48 | if i+1 < len(opts) { 49 | i++ 50 | vals = append(vals, opts[i]) 51 | } 52 | } 53 | } 54 | } 55 | return vals 56 | } 57 | 58 | // Remove removes all occurrences of opt and return true if found. 59 | func (opts *Opts) Remove(opt string) bool { 60 | found := false 61 | for idx := opts.index(opt); idx != -1 && idx < len(*opts); idx = opts.index(opt) { 62 | *opts = append((*opts)[:idx], (*opts)[idx+1:]...) 63 | found = true 64 | } 65 | return found 66 | } 67 | 68 | // Parse converts an HTTPie like argv into a list of curl options. 69 | func Parse(argv Opts) (opts Opts) { 70 | isForm := argv.Has("F") || argv.Has("form") 71 | if isForm { 72 | argv.Remove("F") 73 | argv.Remove("form") 74 | } 75 | args := []string{} 76 | sort.Strings(curlLongValues) 77 | more := true 78 | for i := 1; i < len(argv); i++ { 79 | arg := argv[i] 80 | if !more || len(arg) < 2 || arg[0] != '-' { 81 | args = append(args, arg) 82 | continue 83 | } 84 | if arg == "--" { 85 | // Enf of opts marker 86 | more = false 87 | continue 88 | } 89 | if arg[1] == '-' { 90 | opts = append(opts, arg) 91 | if longHasValue(arg[2:]) && i+1 < len(argv) { 92 | opts = append(opts, argv[i+1]) 93 | i++ 94 | } 95 | continue 96 | } 97 | // Parse componed short args 98 | for j := 1; j < len(arg); j++ { 99 | opts = append(opts, string([]byte{'-', arg[j]})) 100 | if strings.IndexByte(curlShortValues, arg[j]) != -1 { 101 | // Short arg as value, it must be last in compound. 102 | // The value is either the remaining or the next arg. 103 | if j == len(arg)-1 { 104 | if i+1 < len(argv) { 105 | opts = append(opts, argv[i+1]) 106 | i++ 107 | } 108 | } else { 109 | opts = append(opts, arg[j+1:]) 110 | } 111 | break 112 | } 113 | } 114 | } 115 | if len(args) > 0 { 116 | postMode := PostModeJSON 117 | if isForm { 118 | postMode = PostModeFORM 119 | } 120 | opts = append(opts, parseFancyArgs(args, postMode)...) 121 | } 122 | return 123 | } 124 | 125 | func longHasValue(arg string) bool { 126 | i := sort.SearchStrings(curlLongValues, arg) 127 | return i < len(curlLongValues) && curlLongValues[i] == arg 128 | } 129 | -------------------------------------------------------------------------------- /args/parse_test.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParse(t *testing.T) { 8 | expected := []string{"example.com"} 9 | parseAndCompare(t, []string{"curlie", "example.com"}, expected) 10 | } 11 | 12 | func TestParsePost(t *testing.T) { 13 | expected := []string{"-X", "POST", "example.com"} 14 | parseAndCompare(t, []string{"curlie", "post", "example.com"}, expected) 15 | } 16 | 17 | func TestParseHead(t *testing.T) { 18 | expected := []string{"-I", "example.com"} 19 | parseAndCompare(t, []string{"curlie", "head", "example.com"}, expected) 20 | } 21 | 22 | func parseAndCompare(t *testing.T, args, expected []string) { 23 | opts := Parse(args) 24 | if !compareStrings(opts, expected) { 25 | t.Errorf("Expecting %v, but got %v for %v", expected, opts, args) 26 | } 27 | 28 | } 29 | 30 | func compareStrings(a, b []string) bool { 31 | if len(a) != len(b) { 32 | return false 33 | } 34 | for i, v := range a { 35 | if b[i] != v { 36 | return false 37 | } 38 | } 39 | return true 40 | } 41 | -------------------------------------------------------------------------------- /console_other.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package main 4 | 5 | func setupWindowsConsole(stdoutFd int) error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /console_windows.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "golang.org/x/sys/windows" 4 | 5 | func setupWindowsConsole(stdoutFd int) error { 6 | console := windows.Handle(stdoutFd) 7 | var originalMode uint32 8 | windows.GetConsoleMode(console, &originalMode) 9 | return windows.SetConsoleMode(console, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) 10 | } 11 | -------------------------------------------------------------------------------- /doc/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/curlie/b8f139ff16d4b85b829ea0cad05e9026f2a97e6e/doc/get.png -------------------------------------------------------------------------------- /doc/put.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/curlie/b8f139ff16d4b85b829ea0cad05e9026f2a97e6e/doc/put.png -------------------------------------------------------------------------------- /formatter/binaryfilter.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | type BinaryFilter struct { 10 | Out io.Writer 11 | ignore bool 12 | } 13 | 14 | var binarySuppressNotice = []byte(strings.Join([]string{ 15 | "+-----------------------------------------+", 16 | "| NOTE: binary data not shown in terminal |", 17 | "+-----------------------------------------+\n", 18 | }, "\n")) 19 | 20 | func (bf *BinaryFilter) Write(p []byte) (n int, err error) { 21 | if bf.ignore { 22 | return len(p), nil 23 | } 24 | if bytes.IndexByte(p, 0) != -1 { 25 | bf.ignore = true 26 | bf.Out.Write(binarySuppressNotice) 27 | return len(p), nil 28 | } 29 | return bf.Out.Write(p) 30 | } 31 | -------------------------------------------------------------------------------- /formatter/cleanup.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // HeaderCleaner removes > and < from curl --verbose output. 9 | type HeaderCleaner struct { 10 | Out io.Writer 11 | 12 | // Verbose removes the request headers part of the output as well as the lines 13 | // starting with * if set to false. 14 | Verbose bool 15 | 16 | // Post is inserted after the request headers. 17 | Post *bytes.Buffer 18 | 19 | buf []byte 20 | line []byte 21 | } 22 | 23 | var ( 24 | capath = []byte(" CApath:") 25 | ccapath = []byte("* CApath:") 26 | ) 27 | 28 | func (c *HeaderCleaner) Write(p []byte) (n int, err error) { 29 | n = len(p) 30 | cp := c.buf 31 | p = bytes.Replace(p, capath, ccapath, 1) // Fix curl misformatted line 32 | for len(p) > 0 { 33 | idx := bytes.IndexByte(p, '\n') 34 | if idx == -1 { 35 | c.line = append(c.line, p...) 36 | break 37 | } 38 | c.line = append(c.line, p[:idx+1]...) 39 | p = p[idx+1:] 40 | ignore := false 41 | b, i := firstVisibleChar(c.line) 42 | switch b { 43 | case '>': 44 | if c.Verbose { 45 | c.line = c.line[i+2:] 46 | } else { 47 | ignore = true 48 | } 49 | case '<': 50 | c.line = c.line[i+2:] 51 | case '}', '{': 52 | ignore = true 53 | if c.Verbose && c.Post != nil { 54 | cp = append(append(cp, bytes.TrimSpace(c.Post.Bytes())...), '\n', '\n') 55 | c.Post = nil 56 | } 57 | case '*': 58 | if !c.Verbose { 59 | ignore = true 60 | } 61 | } 62 | if !ignore { 63 | cp = append(cp, c.line...) 64 | } 65 | c.line = c.line[:0] 66 | } 67 | _, err = c.Out.Write(cp) 68 | return 69 | } 70 | 71 | var colorEscape = []byte("\x1b[") 72 | 73 | func firstVisibleChar(b []byte) (byte, int) { 74 | if bytes.HasPrefix(b, colorEscape) { 75 | if idx := bytes.IndexByte(b, 'm'); idx != -1 { 76 | if idx < len(b) { 77 | return b[idx+1], idx + 1 78 | } else { 79 | return 0, -1 80 | } 81 | } 82 | } 83 | return b[0], 0 84 | } 85 | -------------------------------------------------------------------------------- /formatter/color.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "io" 5 | "regexp" 6 | ) 7 | 8 | // ColorScheme contains coloring configuration for the formatters. 9 | type ColorScheme struct { 10 | Default string 11 | Comment string 12 | Status string 13 | Field string 14 | Value string 15 | Literal string 16 | Error string 17 | } 18 | 19 | type ColorName int 20 | 21 | const ( 22 | ResetColor ColorName = iota 23 | DefaultColor 24 | CommentColor 25 | StatusColor 26 | FieldColor 27 | ValueColor 28 | LiteralColor 29 | ErrorColor 30 | ) 31 | 32 | func (cs ColorScheme) Color(name ColorName) string { 33 | switch name { 34 | case ResetColor: 35 | return "\x1b[0m" 36 | case DefaultColor: 37 | return cs.Default 38 | case CommentColor: 39 | return cs.Comment 40 | case StatusColor: 41 | return cs.Status 42 | case FieldColor: 43 | return cs.Field 44 | case ValueColor: 45 | return cs.Value 46 | case LiteralColor: 47 | return cs.Literal 48 | case ErrorColor: 49 | return cs.Error 50 | } 51 | return "" 52 | } 53 | 54 | func (cs ColorScheme) IsZero() bool { 55 | return cs == ColorScheme{} 56 | } 57 | 58 | var DefaultColorScheme = ColorScheme{ 59 | Default: "\x1b[37m", 60 | Comment: "\x1b[90m", 61 | Status: "\x1b[33m", 62 | Field: "\x1b[34m", 63 | Value: "\x1b[36m", 64 | Literal: "\x1b[35m", 65 | Error: "\x1b[31m", 66 | } 67 | 68 | type HeaderColorizer struct { 69 | Out io.Writer 70 | Scheme ColorScheme 71 | buf []byte 72 | line []byte 73 | } 74 | 75 | func (c *HeaderColorizer) Write(p []byte) (n int, err error) { 76 | c.buf = c.buf[:0] 77 | for i := 0; i < len(p); i++ { 78 | b := p[i] 79 | c.line = append(c.line, b) 80 | if b == '\n' { 81 | c.formatLine() 82 | continue 83 | } 84 | } 85 | n, err = c.Out.Write(c.buf) 86 | if err != nil || n != len(c.buf) { 87 | return 88 | } 89 | return len(p), nil 90 | } 91 | 92 | type headerFormatter struct { 93 | re *regexp.Regexp 94 | colors []ColorName 95 | } 96 | 97 | var headerFormatters = []headerFormatter{ 98 | { 99 | // Curl errors 100 | regexp.MustCompile(`^(curl: \(\d+\).*)(\n)$`), 101 | []ColorName{ErrorColor, ResetColor}, 102 | }, 103 | { 104 | // Method + Status line 105 | regexp.MustCompile(`^([A-Z]+)(\s+\S+\s+)(HTTP)(/)([\d\.]+\s*)(\n)$`), 106 | []ColorName{FieldColor, DefaultColor, FieldColor, DefaultColor, ValueColor, ResetColor}, 107 | }, 108 | { 109 | // Status line 110 | regexp.MustCompile(`^(HTTP)(/)([\d.]+\s+\d{3})(\s+.+)(\n)$`), 111 | []ColorName{FieldColor, DefaultColor, ValueColor, StatusColor, ResetColor}, 112 | }, 113 | { 114 | // Header 115 | regexp.MustCompile(`^([a-zA-Z0-9.-]*?:)(.*)(\n)$`), 116 | []ColorName{DefaultColor, ValueColor, ResetColor}, 117 | }, 118 | { 119 | // Comments 120 | regexp.MustCompile(`^(\* .*)([\n\r]*)$`), 121 | []ColorName{CommentColor, ResetColor}, 122 | }, 123 | } 124 | 125 | func (c *HeaderColorizer) formatLine() { 126 | defer func() { 127 | c.line = c.line[:0] 128 | }() 129 | cs := c.Scheme 130 | if cs.IsZero() { 131 | c.buf = append(c.buf, c.line...) 132 | return 133 | } 134 | for _, formatter := range headerFormatters { 135 | m := formatter.re.FindSubmatch(c.line) 136 | if m == nil { 137 | continue 138 | } 139 | for i, s := range m[1:] { 140 | col := cs.Color(formatter.colors[i]) 141 | c.buf = append(append(c.buf, col...), s...) 142 | } 143 | return 144 | } 145 | c.buf = append(c.buf, c.line...) 146 | } 147 | -------------------------------------------------------------------------------- /formatter/help.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | type HelpAdapter struct { 9 | Out io.Writer 10 | CmdName string 11 | } 12 | 13 | func (j HelpAdapter) Write(p []byte) (n int, err error) { 14 | n = len(p) 15 | cmd := "curlie" 16 | if j.CmdName != "" { 17 | cmd = j.CmdName 18 | } 19 | p = bytes.Replace(p, 20 | []byte("curl [options...] "), 21 | []byte(cmd+" [options...] [METHOD] URL [REQUEST_ITEM [REQUEST_ITEM ...]]"), 1) 22 | _, err = j.Out.Write(p) 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /formatter/json.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // JSON is a writer that formats/colorizes JSON without decoding it. 9 | // If the stream of bytes does not start with {, the formatting is disabled. 10 | type JSON struct { 11 | Out io.Writer 12 | Scheme ColorScheme 13 | inited bool 14 | disabled bool 15 | last byte 16 | lastQuote byte 17 | isValue bool 18 | level int 19 | buf []byte 20 | } 21 | 22 | var indent = []byte(` `) 23 | 24 | func (j *JSON) Write(p []byte) (n int, err error) { 25 | if !j.inited && len(p) > 0 { 26 | // Only JSON object are supported. 27 | j.disabled = (p[0] != '{' && p[0] != '[') 28 | j.inited = true 29 | } 30 | if j.disabled { 31 | return j.Out.Write(p) 32 | } 33 | cs := j.Scheme 34 | cp := j.buf 35 | for i := 0; i < len(p); i++ { 36 | b := p[i] 37 | if j.last == '\\' { 38 | cp = append(cp, b) 39 | j.last = b 40 | continue 41 | } 42 | switch b { 43 | case '\'', '"': 44 | switch j.lastQuote { 45 | case 0: 46 | j.lastQuote = b 47 | c := cs.Field 48 | if j.isValue { 49 | c = cs.Value 50 | } 51 | cp = append(append(cp, c...), b) 52 | case b: 53 | j.lastQuote = 0 54 | cp = append(cp, b) 55 | } 56 | continue 57 | default: 58 | if j.lastQuote != 0 { 59 | cp = append(cp, b) 60 | j.last = b 61 | continue 62 | } 63 | } 64 | switch b { 65 | case ' ', '\t', '\r', '\n': 66 | // Skip spaces outside of quoted areas. 67 | continue 68 | case '{', '[': 69 | j.isValue = false 70 | j.level++ 71 | cp = append(append(append(cp, cs.Default...), b, '\n'), bytes.Repeat(indent, j.level)...) 72 | case '}', ']': 73 | j.level-- 74 | if j.level < 0 { 75 | j.level = 0 76 | } 77 | cp = append(append(append(append(cp, '\n'), bytes.Repeat(indent, j.level)...), cs.Default...), b) 78 | if (p[0] != '}' && p[0] != ']') && j.level == 0 { 79 | // Add a return after the outer closing brace. 80 | // If cs is zero that means color is disabled, so only append '\n' 81 | // else append '\n' and ResetColor. 82 | if cs.IsZero() { 83 | cp = append(cp, '\n') 84 | } else { 85 | cp = append(append(cp, '\n'), cs.Color(ResetColor)...) 86 | } 87 | 88 | } 89 | case ':': 90 | j.isValue = true 91 | cp = append(append(cp, cs.Default...), b, ' ') 92 | case ',': 93 | j.isValue = false 94 | cp = append(append(append(cp, cs.Default...), b, '\n'), bytes.Repeat(indent, j.level)...) 95 | default: 96 | if j.last == ':' { 97 | switch b { 98 | case 'n', 't', 'f': 99 | // null, true, false 100 | cp = append(cp, cs.Literal...) 101 | default: 102 | // unquoted values like numbers 103 | cp = append(cp, cs.Value...) 104 | } 105 | } 106 | cp = append(cp, b) 107 | } 108 | j.last = b 109 | } 110 | n, err = j.Out.Write(cp) 111 | if err != nil || n != len(cp) { 112 | return 113 | } 114 | return len(p), nil 115 | } 116 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rs/curlie 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | golang.org/x/sys v0.30.0 7 | golang.org/x/term v0.29.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 2 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 3 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 4 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 5 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/rs/curlie/args" 13 | "github.com/rs/curlie/formatter" 14 | "golang.org/x/term" 15 | ) 16 | 17 | var ( 18 | version = "v0.0.0-LOCAL" 19 | date = "0000-00-00T00:00:00Z" 20 | ) 21 | 22 | func main() { 23 | // handle `curlie version` separately from `curl --version` 24 | if len(os.Args) == 2 && os.Args[1] == "version" { 25 | fmt.Printf("curlie %s (%s)\n", version, date) 26 | os.Exit(0) 27 | return 28 | } 29 | 30 | // *nixes use 0, 1, 2 31 | // Windows uses random numbers 32 | stdinFd := int(os.Stdin.Fd()) 33 | stdoutFd := int(os.Stdout.Fd()) 34 | stderrFd := int(os.Stderr.Fd()) 35 | 36 | // Setting Console mode on windows to allow color output, By default scheme is DefaultColorScheme 37 | // But in case of any error, it is set to ColorScheme{}. 38 | scheme := formatter.DefaultColorScheme 39 | if err := setupWindowsConsole(stdoutFd); err != nil { 40 | scheme = formatter.ColorScheme{} 41 | } 42 | var stdout io.Writer = os.Stdout 43 | var stderr io.Writer = os.Stderr 44 | var stdin io.Reader = os.Stdin 45 | input := &bytes.Buffer{} 46 | var inputWriter io.Writer = input 47 | opts := args.Parse(os.Args) 48 | 49 | verbose := opts.Has("verbose") || opts.Has("v") 50 | quiet := opts.Has("silent") || opts.Has("s") 51 | pretty := opts.Remove("pretty") 52 | opts.Remove("i") 53 | 54 | if len(opts) == 0 { 55 | // Show help if no args 56 | opts = append(opts, "-h", "all") 57 | } else { 58 | // Remove progress bar. 59 | opts = append(opts, "-s", "-S") 60 | } 61 | 62 | // Change default method based on binary name. 63 | switch os.Args[0] { 64 | case "post", "put", "delete": 65 | if !opts.Has("X") && !opts.Has("request") { 66 | opts = append(opts, "-X", os.Args[0]) 67 | } 68 | case "head": 69 | if !opts.Has("I") && !opts.Has("head") { 70 | opts = append(opts, "-I") 71 | } 72 | } 73 | 74 | if opts.Has("h") || opts.Has("help") { 75 | stdout = &formatter.HelpAdapter{Out: stdout, CmdName: os.Args[0]} 76 | } else { 77 | isForm := opts.Has("F") 78 | if pretty || term.IsTerminal(stdoutFd) { 79 | inputWriter = &formatter.JSON{ 80 | Out: inputWriter, 81 | Scheme: scheme, 82 | } 83 | // Format/colorize JSON output if stdout is to the terminal. 84 | stdout = &formatter.JSON{ 85 | Out: stdout, 86 | Scheme: scheme, 87 | } 88 | 89 | // Filter out binary output. 90 | stdout = &formatter.BinaryFilter{Out: stdout} 91 | } 92 | if pretty || term.IsTerminal(stderrFd) { 93 | // If stderr is not redirected, output headers. 94 | if !quiet { 95 | opts = append(opts, "-v") 96 | } 97 | 98 | stderr = &formatter.HeaderColorizer{ 99 | Out: stderr, 100 | Scheme: scheme, 101 | } 102 | } 103 | hasInput := true 104 | if data := opts.Val("d"); data != "" { 105 | // If data is provided via -d, read it from there for the verbose mode. 106 | // XXX handle the @filename case. 107 | inputWriter.Write([]byte(data)) 108 | } else if !term.IsTerminal(stdinFd) { 109 | // If something is piped in to the command, tell curl to use it as input. 110 | opts = append(opts, "-d@-") 111 | // Tee the stdin to the buffer used show the posted data in verbose mode. 112 | stdin = io.TeeReader(stdin, inputWriter) 113 | } else { 114 | hasInput = false 115 | } 116 | if hasInput { 117 | if !headerSupplied(opts, "Content-Type") && !isForm { 118 | opts = append(opts, "-H", "Content-Type: application/json") 119 | } 120 | } 121 | } 122 | if !headerSupplied(opts, "Accept") { 123 | opts = append(opts, "-H", "Accept: application/json, */*") 124 | } 125 | if opts.Has("curl") { 126 | opts.Remove("curl") 127 | fmt.Print("curl") 128 | for _, opt := range opts { 129 | if strings.IndexByte(opt, ' ') != -1 { 130 | fmt.Printf(" %q", opt) 131 | } else { 132 | fmt.Printf(" %s", opt) 133 | } 134 | } 135 | fmt.Println() 136 | return 137 | } 138 | cmd := exec.Command("curl", opts...) 139 | cmd.Stdin = stdin 140 | cmd.Stdout = stdout 141 | cmd.Stderr = &formatter.HeaderCleaner{ 142 | Out: stderr, 143 | Verbose: verbose, 144 | Post: input, 145 | } 146 | if (opts.Has("I") || opts.Has("head")) && term.IsTerminal(stdoutFd) { 147 | cmd.Stdout = io.Discard 148 | } 149 | status := 0 150 | if err := cmd.Run(); err != nil { 151 | switch err := err.(type) { 152 | case *exec.ExitError: 153 | if err.Stderr != nil { 154 | fmt.Fprint(stderr, string(err.Stderr)) 155 | } 156 | if ws, ok := err.ProcessState.Sys().(syscall.WaitStatus); ok { 157 | status = ws.ExitStatus() 158 | } 159 | default: 160 | fmt.Fprint(stderr, err) 161 | } 162 | } 163 | os.Exit(status) 164 | } 165 | 166 | func headerSupplied(opts args.Opts, header string) bool { 167 | header = strings.ToLower(header) 168 | for _, h := range append(opts.Vals("H"), opts.Vals("header")...) { 169 | if strings.HasPrefix(strings.ToLower(h), header+":") { 170 | return true 171 | } 172 | } 173 | return false 174 | } 175 | --------------------------------------------------------------------------------