diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb21721 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + + steps: + - uses: actions/checkout@v6 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run lints + run: ./scripts/lint + + build: + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v6 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run goreleaser + uses: goreleaser/goreleaser-action@v6.1.0 + with: + version: latest + args: release --snapshot --clean --skip=publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/agentmail-cli' + id: github-oidc + uses: actions/github-script@v8 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: github.repository == 'stainless-sdks/agentmail-cli' + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v6 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..263859c --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,31 @@ +--- +name: Publish Release +permissions: + contents: write + +concurrency: + group: publish + +on: + push: + tags: + - "v*" +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6.1.0 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..92e05a8 --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,19 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'agentmail-to/agentmail-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v6 + + - name: Check release environment + run: | + bash ./bin/check-release-environment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65c3528 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.prism.log +dist/ +/agentmail +*.exe diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..c1e1cb1 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,80 @@ +project_name: agentmail +version: 2 + +before: + hooks: + - mkdir -p completions + - sh -c "go run ./cmd/agentmail/main.go @completion bash > completions/agentmail.bash" + - sh -c "go run ./cmd/agentmail/main.go @completion zsh > completions/agentmail.zsh" + - sh -c "go run ./cmd/agentmail/main.go @completion fish > completions/agentmail.fish" + - sh -c "go run ./cmd/agentmail/main.go @manpages -o man" + +builds: + - id: macos + goos: [darwin] + goarch: [amd64, arm64] + binary: '{{ .ProjectName }}' + main: ./cmd/agentmail/main.go + mod_timestamp: '{{ .CommitTimestamp }}' + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + + - id: linux + goos: [linux] + goarch: ['386', arm, amd64, arm64] + env: + - CGO_ENABLED=0 + binary: '{{ .ProjectName }}' + main: ./cmd/agentmail/main.go + mod_timestamp: '{{ .CommitTimestamp }}' + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + + - id: windows + goos: [windows] + goarch: ['386', amd64, arm64] + binary: '{{ .ProjectName }}' + main: ./cmd/agentmail/main.go + mod_timestamp: '{{ .CommitTimestamp }}' + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + +archives: + - id: linux-archive + ids: [linux] + name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' + formats: [tar.gz] + files: + - completions/* + - man/*/* + - id: macos-archive + ids: [macos] + name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' + formats: [zip] + files: + - completions/* + - man/*/* + - id: windows-archive + ids: [windows] + name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' + formats: [zip] + files: + - completions/* + - man/*/* + +snapshot: + version_template: '{{ .Tag }}-next' + +nfpms: + - license: Apache-2.0 + maintainer: + bindir: /usr + formats: + - apk + - deb + - rpm + - termux.deb + - archlinux + contents: + - src: man/man1/*.1.gz + dst: /usr/share/man/man1/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..c7159c1 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.2" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..dbbbf2c --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 62 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/agentmail%2Fagentmail-2481bfc63c4c9fa16c58f07d8a23b37ca8ce6360ae1de8f7cb864b762b6cb4dd.yml +openapi_spec_hash: caa7650be0b2ab945b63b1361f457873 +config_hash: cea1e69c35953ae493e829cc87a332df diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..feeb9fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.0.2 (2026-03-02) + +Full Changelog: [v0.0.1...v0.0.2](https://github.com/agentmail-to/agentmail-cli/compare/v0.0.1...v0.0.2) + +### Chores + +* sync repo ([3fcf10d](https://github.com/agentmail-to/agentmail-cli/commit/3fcf10d0b9c49193616513db312122c3bc98ef8f)) +* update SDK settings ([55fe742](https://github.com/agentmail-to/agentmail-cli/commit/55fe74252155d07e0a1c36233626b3919ce14139)) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ada9bb6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Agentmail + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index f19f260..e5eb08b 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# agentmail-cli +# Agentmail CLI + +The official CLI for the [Agentmail REST API](https://docs.agentmail.to). + +It is generated with [Stainless](https://www.stainless.com/). + + + +## Installation + +### Installing with Go + +To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed. + +```sh +go install 'github.com/agentmail-to/agentmail-cli/cmd/agentmail@latest' +``` + +Once you have run `go install`, the binary is placed in your Go bin directory: + +- **Default location**: `$HOME/go/bin` (or `$GOPATH/bin` if GOPATH is set) +- **Check your path**: Run `go env GOPATH` to see the base directory + +If commands aren't found after installation, add the Go bin directory to your PATH: + +```sh +# Add to your shell profile (.zshrc, .bashrc, etc.) +export PATH="$PATH:$(go env GOPATH)/bin" +``` + + + +### Running Locally + +After cloning the git repository for this project, you can use the +`scripts/run` script to run the tool locally: + +```sh +./scripts/run args... +``` + +## Usage + +The CLI follows a resource-based command structure: + +```sh +agentmail [resource] [flags...] +``` + +```sh +agentmail inboxes list \ + --api-key 'My API Key' +``` + +For details about specific commands, use the `--help` flag. + +### Environment variables + +| Environment variable | Required | +| -------------------- | -------- | +| `AGENTMAIL_API_KEY` | yes | + +### Global flags + +- `--api-key` (can also be set with `AGENTMAIL_API_KEY` env var) +- `--help` - Show command line usage +- `--debug` - Enable debug logging (includes HTTP request/response details) +- `--version`, `-v` - Show the CLI version +- `--base-url` - Use a custom API backend URL +- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) +- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) +- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) +- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) + +### Passing files as arguments + +To pass files to your API, you can use the `@myfile.ext` syntax: + +```bash +agentmail --arg @abe.jpg +``` + +Files can also be passed inside JSON or YAML blobs: + +```bash +agentmail --arg '{image: "@abe.jpg"}' +# Equivalent: +agentmail < --username '\@abe' +``` + +#### Explicit encoding + +For JSON endpoints, the CLI tool does filetype sniffing to determine whether the +file contents should be sent as a string literal (for plain text files) or as a +base64-encoded string literal (for binary files). If you need to explicitly send +the file as either plain text or base64-encoded data, you can use +`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for +base64-encoding). Note that absolute paths will begin with `@file://` or +`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`). + +```bash +agentmail --arg @data://file.txt +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..92e3ccb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Agentmail, please follow the respective company's security reporting guidelines. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..1e951e9 --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +errors=() + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/cmd/agentmail/main.go b/cmd/agentmail/main.go new file mode 100644 index 0000000..550f177 --- /dev/null +++ b/cmd/agentmail/main.go @@ -0,0 +1,58 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "slices" + + "github.com/agentmail-to/agentmail-cli/pkg/cmd" + "github.com/agentmail-to/agentmail-go" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +func main() { + app := cmd.Command + + if slices.Contains(os.Args, "__complete") { + prepareForAutocomplete(app) + } + + if err := app.Run(context.Background(), os.Args); err != nil { + exitCode := 1 + + // Check if error has a custom exit code + if exitErr, ok := err.(cli.ExitCoder); ok { + exitCode = exitErr.ExitCode() + } + + var apierr *agentmail.Error + if errors.As(err, &apierr) { + fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) + format := app.String("format-error") + json := gjson.Parse(apierr.RawJSON()) + show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error")) + if show_err != nil { + // Just print the original error: + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + } + } else { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + } + os.Exit(exitCode) + } +} + +func prepareForAutocomplete(cmd *cli.Command) { + // urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags. + // This skips that sort of validation + cmd.SkipFlagParsing = true + for _, child := range cmd.Commands { + prepareForAutocomplete(child) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..847f395 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/agentmail-to/agentmail-cli + +go 1.25 + +require ( + github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.1 + github.com/goccy/go-yaml v1.18.0 + github.com/itchyny/json2yaml v0.1.4 + github.com/muesli/reflow v0.3.0 + github.com/stretchr/testify v1.10.0 + github.com/tidwall/gjson v1.18.0 + github.com/tidwall/pretty v1.2.1 + github.com/urfave/cli-docs/v3 v3.0.0-alpha6 + github.com/urfave/cli/v3 v3.3.2 + golang.org/x/sys v0.38.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8526e2c --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf h1:45RcHNvf2rNcNAhSz48Wy8OsQgxqKvPu1yUAExZs/5o= +github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf/go.mod h1:3NrKbeXLQKRgb9gj2bmCoN9WXDTy9y9yacV070xpvDU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= +github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= +github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= +github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= +github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go new file mode 100644 index 0000000..16ca44b --- /dev/null +++ b/internal/apiform/encoder.go @@ -0,0 +1,227 @@ +package apiform + +import ( + "fmt" + "io" + "mime/multipart" + "net/textproto" + "path" + "reflect" + "sort" + "strconv" + "strings" +) + +// Marshal encodes a value as multipart form data using default settings +func Marshal(value any, writer *multipart.Writer) error { + e := &encoder{ + format: FormatRepeat, + } + return e.marshal(value, writer) +} + +// MarshalWithSettings encodes a value with custom array format +func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error { + e := &encoder{ + format: arrayFormat, + } + return e.marshal(value, writer) +} + +type encoder struct { + format FormFormat +} + +func (e *encoder) marshal(value any, writer *multipart.Writer) error { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil + } + return e.encodeValue("", val, writer) +} + +func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error { + if !val.IsValid() { + return writer.WriteField(key, "") + } + + t := val.Type() + + if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { + return e.encodeReader(key, val, writer) + } + + switch t.Kind() { + case reflect.Pointer: + if val.IsNil() || !val.IsValid() { + return writer.WriteField(key, "") + } + return e.encodeValue(key, val.Elem(), writer) + + case reflect.Slice, reflect.Array: + return e.encodeArray(key, val, writer) + + case reflect.Map: + return e.encodeMap(key, val, writer) + + case reflect.Interface: + if val.IsNil() { + return writer.WriteField(key, "") + } + return e.encodeValue(key, val.Elem(), writer) + + case reflect.String: + return writer.WriteField(key, val.String()) + + case reflect.Bool: + if val.Bool() { + return writer.WriteField(key, "true") + } + return writer.WriteField(key, "false") + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return writer.WriteField(key, strconv.FormatInt(val.Int(), 10)) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) + + case reflect.Float32: + return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) + + case reflect.Float64: + return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) + + default: + return fmt.Errorf("unknown type: %s", t.String()) + } +} + +func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error { + if e.format == FormatComma { + var values []string + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + var strValue string + switch item.Kind() { + case reflect.String: + strValue = item.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + strValue = strconv.FormatInt(item.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + strValue = strconv.FormatUint(item.Uint(), 10) + case reflect.Float32, reflect.Float64: + strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) + case reflect.Bool: + strValue = strconv.FormatBool(item.Bool()) + default: + return fmt.Errorf("comma format not supported for complex array elements") + } + values = append(values, strValue) + } + return writer.WriteField(key, strings.Join(values, ",")) + } + + for i := 0; i < val.Len(); i++ { + var formattedKey string + switch e.format { + case FormatRepeat: + formattedKey = key + case FormatBrackets: + formattedKey = key + "[]" + case FormatIndicesDots: + if key == "" { + formattedKey = strconv.Itoa(i) + } else { + formattedKey = key + "." + strconv.Itoa(i) + } + case FormatIndicesBrackets: + if key == "" { + formattedKey = strconv.Itoa(i) + } else { + formattedKey = key + "[" + strconv.Itoa(i) + "]" + } + default: + return fmt.Errorf("apiform: unsupported array format") + } + + if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil { + return err + } + } + return nil +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error { + reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) + if !ok { + return nil + } + + // Set defaults + filename := "anonymous_file" + contentType := "application/octet-stream" + + // Get filename if available + if named, ok := reader.(interface{ Filename() string }); ok { + filename = named.Filename() + } else if named, ok := reader.(interface{ Name() string }); ok { + filename = path.Base(named.Name()) + } + + // Get content type if available + if typed, ok := reader.(interface{ ContentType() string }); ok { + contentType = typed.ContentType() + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes(key), escapeQuotes(filename))) + h.Set("Content-Type", contentType) + + filewriter, err := writer.CreatePart(h) + if err != nil { + return err + } + _, err = io.Copy(filewriter, reader) + return err +} + +func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error { + type mapPair struct { + key string + value reflect.Value + } + + if key != "" { + key = key + "." + } + + // Collect and sort map entries for deterministic output + pairs := []mapPair{} + iter := val.MapRange() + for iter.Next() { + if iter.Key().Type().Kind() != reflect.String { + return fmt.Errorf("cannot encode a map with a non string key") + } + pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) + } + + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].key < pairs[j].key + }) + + // Process sorted pairs + for _, p := range pairs { + if err := e.encodeValue(key+p.key, p.value, writer); err != nil { + return err + } + } + + return nil +} diff --git a/internal/apiform/form.go b/internal/apiform/form.go new file mode 100644 index 0000000..024de27 --- /dev/null +++ b/internal/apiform/form.go @@ -0,0 +1,20 @@ +package apiform + +type Marshaler interface { + MarshalMultipart() ([]byte, string, error) +} + +type FormFormat int + +const ( + // FormatRepeat represents arrays as repeated keys with the same value + FormatRepeat FormFormat = iota + // Comma-separated values 1,2,3 + FormatComma + // FormatBrackets uses the key[] notation for arrays + FormatBrackets + // FormatIndicesDots uses key.0, key.1, etc. notation + FormatIndicesDots + // FormatIndicesBrackets uses key[0], key[1], etc. notation + FormatIndicesBrackets +) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go new file mode 100644 index 0000000..2cf5bdd --- /dev/null +++ b/internal/apiform/form_test.go @@ -0,0 +1,109 @@ +package apiform + +import ( + "bytes" + "mime/multipart" + "testing" +) + +// Define test cases +var tests = map[string]struct { + value any + format FormFormat + expected string +}{ + "nil": { + value: nil, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n", + }, + "string": { + value: "hello", + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n", + }, + "int": { + value: 42, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n", + }, + "float": { + value: 3.14, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n", + }, + "bool": { + value: true, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", + }, + "empty slice": { + value: []string{}, + expected: "\r\n--xxx--\r\n", + }, + "nil slice": { + value: []string(nil), + expected: "\r\n--xxx--\r\n", + }, + "slice with dot indices": { + value: []string{"a", "b", "c"}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n", + }, + "slice with bracket indices": { + value: []int{10, 20, 30}, + format: FormatIndicesBrackets, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n", + }, + "slice with repeat": { + value: []int{10, 20, 30}, + format: FormatRepeat, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n", + }, + "slice with commas": { + value: []int{10, 20, 30}, + format: FormatComma, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n", + }, + "empty map": { + value: map[string]any{}, + expected: "\r\n--xxx--\r\n", + }, + "nil map": { + value: map[string]any(nil), + expected: "\r\n--xxx--\r\n", + }, + "map": { + value: map[string]any{"key1": "value1", "key2": "value2"}, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n", + }, + "nested_map": { + value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n", + }, + "mixed_map": { + value: map[string]any{"name": "John", "ages": []int{25, 30, 35}}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n", + }, +} + +func TestEncode(t *testing.T) { + for name, test := range tests { + t.Run(name, func(t *testing.T) { + buf := bytes.NewBuffer(nil) + writer := multipart.NewWriter(buf) + writer.SetBoundary("xxx") + + form := map[string]any{"foo": test.value} + err := MarshalWithSettings(form, writer, test.format) + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.value, err) + } + err = writer.Close() + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.value, err) + } + result := buf.String() + if result != test.expected { + t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result) + } + }) + } +} diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go new file mode 100644 index 0000000..0d09dee --- /dev/null +++ b/internal/apiquery/encoder.go @@ -0,0 +1,166 @@ +package apiquery + +import ( + "fmt" + "reflect" + "strconv" + "strings" +) + +type encoder struct { + settings QuerySettings +} + +type Pair struct { + key string + value string +} + +func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) { + t := value.Type() + switch t.Kind() { + case reflect.Pointer: + if value.IsNil() || !value.IsValid() { + return []Pair{{key, ""}}, nil + } + return e.Encode(key, value.Elem()) + + case reflect.Array, reflect.Slice: + return e.encodeArray(key, value) + + case reflect.Map: + return e.encodeMap(key, value) + + case reflect.Interface: + if !value.Elem().IsValid() { + return []Pair{{key, ""}}, nil + } + return e.Encode(key, value.Elem()) + + default: + return e.encodePrimitive(key, value) + } +} + +func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) { + var pairs []Pair + iter := value.MapRange() + for iter.Next() { + subkey := iter.Key().String() + keyPath := subkey + if len(key) > 0 { + if e.settings.NestedFormat == NestedQueryFormatDots { + keyPath = fmt.Sprintf("%s.%s", key, subkey) + } else { + keyPath = fmt.Sprintf("%s[%s]", key, subkey) + } + } + + subpairs, err := e.Encode(keyPath, iter.Value()) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil +} + +func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) { + switch e.settings.ArrayFormat { + case ArrayQueryFormatComma: + elements := []string{} + for i := 0; i < value.Len(); i++ { + innerPairs, err := e.Encode("", value.Index(i)) + if err != nil { + return nil, err + } + for _, pair := range innerPairs { + elements = append(elements, pair.value) + } + } + return []Pair{{key, strings.Join(elements, ",")}}, nil + + case ArrayQueryFormatRepeat: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(key, value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + case ArrayQueryFormatIndices: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + case ArrayQueryFormatBrackets: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(key+"[]", value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + default: + panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) + } +} + +func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) { + switch value.Kind() { + case reflect.Pointer: + if !value.IsValid() || value.IsNil() { + return nil, nil + } + return e.encodePrimitive(key, value.Elem()) + + case reflect.String: + return []Pair{{key, value.String()}}, nil + + case reflect.Bool: + if value.Bool() { + return []Pair{{key, "true"}}, nil + } + return []Pair{{key, "false"}}, nil + + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil + + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil + + case reflect.Float32, reflect.Float64: + return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil + + default: + return nil, nil + } +} + +func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) { + present := value.FieldByName("Present") + if !present.Bool() { + return nil, nil + } + null := value.FieldByName("Null") + if null.Bool() { + return nil, fmt.Errorf("apiquery: field cannot be null") + } + raw := value.FieldByName("Raw") + if !raw.IsNil() { + return e.Encode(key, raw) + } + return e.Encode(key, value.FieldByName("Value")) +} diff --git a/internal/apiquery/query.go b/internal/apiquery/query.go new file mode 100644 index 0000000..fd07a2f --- /dev/null +++ b/internal/apiquery/query.go @@ -0,0 +1,53 @@ +package apiquery + +import ( + "net/url" + "reflect" +) + +func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil, nil + } + + e := encoder{settings} + pairs, err := e.Encode("", val) + if err != nil { + return nil, err + } + + kv := url.Values{} + for _, pair := range pairs { + kv.Add(pair.key, pair.value) + } + return kv, nil +} +func Marshal(value any) (url.Values, error) { + return MarshalWithSettings(value, QuerySettings{}) +} + +type Queryer interface { + URLQuery() (url.Values, error) +} + +type NestedQueryFormat int + +const ( + NestedQueryFormatBrackets NestedQueryFormat = iota + NestedQueryFormatDots +) + +type ArrayQueryFormat int + +const ( + ArrayQueryFormatComma ArrayQueryFormat = iota + ArrayQueryFormatRepeat + ArrayQueryFormatIndices + ArrayQueryFormatBrackets +) + +type QuerySettings struct { + NestedFormat NestedQueryFormat + ArrayFormat ArrayQueryFormat +} diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go new file mode 100644 index 0000000..8bee784 --- /dev/null +++ b/internal/apiquery/query_test.go @@ -0,0 +1,128 @@ +package apiquery + +import ( + "net/url" + "testing" +) + +func TestEncode(t *testing.T) { + tests := map[string]struct { + val any + settings QuerySettings + enc string + }{ + "null": { + val: nil, + enc: "query=", + }, + "string": { + val: "hello world", + enc: "query=hello world", + }, + "int": { + val: 42, + enc: "query=42", + }, + "float": { + val: 3.14, + enc: "query=3.14", + }, + "bool": { + val: true, + enc: "query=true", + }, + "empty_slice": { + val: []any{}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=", + }, + "nil_slice": { + val: []any(nil), + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=", + }, + "slice_of_ints": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=10,20,30", + }, + "slice_of_ints_repeat": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, + enc: "query=10&query=20&query=30", + }, + "slice_of_ints_indices": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices}, + enc: "query[0]=10&query[1]=20&query[2]=30", + }, + "slice_of_ints_brackets": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, + enc: "query[]=10&query[]=20&query[]=30", + }, + "slice_of_strings": { + val: []any{"a", "b", "c"}, + settings: QuerySettings{}, + enc: "query=a,b,c", + }, + "empty_map": { + val: map[string]any{}, + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "", + }, + "nil_map": { + val: map[string]any(nil), + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "", + }, + "map_string_to_int_brackets": { + val: map[string]any{"one": 1, "two": 2}, + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "query[one]=1&query[two]=2", + }, + "map_string_to_int_dots": { + val: map[string]any{"one": 1, "two": 2}, + settings: QuerySettings{NestedFormat: NestedQueryFormatDots}, + enc: "query.one=1&query.two=2", + }, + "map_string_to_slice": { + val: map[string][]any{"nums": {10, 20, 30}}, + settings: QuerySettings{}, + enc: "query[nums]=10,20,30", + }, + "map_string_to_slice_repeat_dots": { + val: map[string][]any{"nums": {10, 20, 30}}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots}, + enc: "query.nums=10&query.nums=20&query.nums=30", + }, + "map_with_empties": { + val: map[string]any{ + "empty-array": []any{}, + "nil-array": []any(nil), + "null": nil, + }, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots}, + enc: "query.empty-array=&query.nil-array=&query.null=", + }, + "nested_map": { + val: map[string]map[string]any{"outer": {"inner": 42}}, + settings: QuerySettings{}, + enc: "query[outer][inner]=42", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + query := map[string]any{"query": test.val} + values, err := MarshalWithSettings(query, test.settings) + if err != nil { + t.Fatalf("failed to marshal url %s", err) + } + str, _ := url.QueryUnescape(values.Encode()) + if str != test.enc { + t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str) + } + }) + } +} diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go new file mode 100644 index 0000000..97fe1a8 --- /dev/null +++ b/internal/autocomplete/autocomplete.go @@ -0,0 +1,361 @@ +package autocomplete + +import ( + "context" + "embed" + "fmt" + "os" + "slices" + "strings" + + "github.com/urfave/cli/v3" +) + +type CompletionStyle string + +const ( + CompletionStyleZsh CompletionStyle = "zsh" + CompletionStyleBash CompletionStyle = "bash" + CompletionStylePowershell CompletionStyle = "pwsh" + CompletionStyleFish CompletionStyle = "fish" +) + +type renderCompletion func(cmd *cli.Command, appName string) (string, error) + +var ( + //go:embed shellscripts + autoCompleteFS embed.FS + + shellCompletions = map[CompletionStyle]renderCompletion{ + "bash": func(c *cli.Command, appName string) (string, error) { + b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash") + return strings.ReplaceAll(string(b), "__APPNAME__", appName), err + }, + "fish": func(c *cli.Command, appName string) (string, error) { + b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish") + return strings.ReplaceAll(string(b), "__APPNAME__", appName), err + }, + "pwsh": func(c *cli.Command, appName string) (string, error) { + b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1") + return strings.ReplaceAll(string(b), "__APPNAME__", appName), err + }, + "zsh": func(c *cli.Command, appName string) (string, error) { + b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh") + return strings.ReplaceAll(string(b), "__APPNAME__", appName), err + }, + } +) + +func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { + shells := make([]CompletionStyle, 0, len(shellCompletions)) + for k := range shellCompletions { + shells = append(shells, k) + } + + if cmd.Args().Len() == 0 { + return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1) + } + s := CompletionStyle(cmd.Args().First()) + + renderCompletion, ok := shellCompletions[s] + if !ok { + return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) + } + + completionScript, err := renderCompletion(cmd, cmd.Root().Name) + if err != nil { + return cli.Exit(err, 1) + } + + _, err = cmd.Writer.Write([]byte(completionScript)) + if err != nil { + return cli.Exit(err, 1) + } + + return nil +} + +type ShellCompletion struct { + Name string + Usage string +} + +func NewShellCompletion(name string, usage string) ShellCompletion { + return ShellCompletion{Name: name, Usage: usage} +} + +type ShellCompletionBehavior int + +const ( + ShellCompletionBehaviorDefault ShellCompletionBehavior = iota + ShellCompletionBehaviorFile = 10 + ShellCompletionBehaviorNoComplete +) + +type CompletionResult struct { + Completions []ShellCompletion + Behavior ShellCompletionBehavior +} + +func isFlag(arg string) bool { + return strings.HasPrefix(arg, "-") +} + +func findFlag(cmd *cli.Command, arg string) *cli.Flag { + name := strings.TrimLeft(arg, "-") + for _, flag := range cmd.Flags { + if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() { + continue + } + + if slices.Contains(flag.Names(), name) { + return &flag + } + } + return nil +} + +func findChild(cmd *cli.Command, name string) *cli.Command { + for _, c := range cmd.Commands { + if !c.Hidden && c.Name == name { + return c + } + } + return nil +} + +type shellCompletionBuilder struct { + completionStyle CompletionStyle +} + +func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion { + matchingNames := make([]string, 0, len(command.Names())) + + for _, name := range command.Names() { + if strings.HasPrefix(name, input) { + matchingNames = append(matchingNames, name) + } + } + + if scb.completionStyle == CompletionStyleBash { + index := strings.LastIndex(input, ":") + 1 + if index > 0 { + for _, name := range matchingNames { + result = append(result, NewShellCompletion(name[index:], command.Usage)) + } + return result + } + } + + for _, name := range matchingNames { + result = append(result, NewShellCompletion(name, command.Usage)) + } + return result +} + +func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion { + matchingNames := make([]string, 0, len((*flag).Names())) + + for _, name := range (*flag).Names() { + withPrefix := "" + if len(name) == 1 { + withPrefix = "-" + name + } else { + withPrefix = "--" + name + } + + if strings.HasPrefix(withPrefix, input) { + matchingNames = append(matchingNames, withPrefix) + } + } + + usage := "" + if dgf, ok := (*flag).(cli.DocGenerationFlag); ok { + usage = dgf.GetUsage() + } + + for _, name := range matchingNames { + result = append(result, NewShellCompletion(name, usage)) + } + + return result +} + +func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { + result := getAllPossibleCompletions(completionStyle, root, args) + + // If the user has not put in a colon, filter out colon commands + if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") { + // Nothing with anything after a colon. Create a single entry for groups with the same colon subset + foundNames := make([]string, 0, len(result.Completions)) + filteredCompletions := make([]ShellCompletion, 0, len(result.Completions)) + + for _, completion := range result.Completions { + name := completion.Name + firstColonIndex := strings.Index(name, ":") + if firstColonIndex > -1 { + name = name[0:firstColonIndex] + completion.Name = name + completion.Usage = "" + } + + if !slices.Contains(foundNames, name) { + foundNames = append(foundNames, name) + filteredCompletions = append(filteredCompletions, completion) + } + } + + result.Completions = filteredCompletions + } + + return result +} + +func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { + builder := shellCompletionBuilder{completionStyle: completionStyle} + completions := make([]ShellCompletion, 0) + if len(args) == 0 { + for _, child := range root.Commands { + completions = builder.createFromCommand("", child, completions) + } + return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault} + } + + current := args[len(args)-1] + preceding := args[0 : len(args)-1] + cmd := root + i := 0 + for i < len(preceding) { + arg := preceding[i] + + if isFlag(arg) { + flag := findFlag(cmd, arg) + if flag == nil { + i++ + } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { + // All flags except for bool flags take values + i += 2 + } else { + i++ + } + } else { + child := findChild(cmd, arg) + if child != nil { + cmd = child + } + i++ + } + } + + // Check if the previous arg was a flag expecting a value + if len(preceding) > 0 { + prev := preceding[len(preceding)-1] + if isFlag(prev) { + flag := findFlag(cmd, prev) + if flag != nil { + if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile { + return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile} + } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { + return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete} + } + } + } + } + + // Completing a flag name + if isFlag(current) { + for _, flag := range cmd.Flags { + completions = builder.createFromFlag(current, &flag, completions) + } + } + + for _, child := range cmd.Commands { + if !child.Hidden { + completions = builder.createFromCommand(current, child, completions) + } + } + + return CompletionResult{ + Completions: completions, + Behavior: ShellCompletionBehaviorDefault, + } +} + +func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { + root := cmd.Root() + args := rebuildColonSeparatedArgs(root.Args().Slice()[1:]) + + var completionStyle CompletionStyle + if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok { + switch style { + case "bash": + completionStyle = CompletionStyleBash + case "zsh": + completionStyle = CompletionStyleZsh + case "pwsh": + completionStyle = CompletionStylePowershell + case "fish": + completionStyle = CompletionStyleFish + default: + return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1) + } + } else { + return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1) + } + + result := GetCompletions(completionStyle, root, args) + + for _, completion := range result.Completions { + name := completion.Name + if completionStyle == CompletionStyleZsh { + name = strings.ReplaceAll(name, ":", "\\:") + } + if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 { + _, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage) + } else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 { + _, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage) + } else { + _, _ = fmt.Fprintf(cmd.Writer, "%s\n", name) + } + } + return cli.Exit("", int(result.Behavior)) +} + +// When CLI arguments are passed in, they are separated on word barriers. +// Most commonly this is whitespace but in some cases that may also be colons. +// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring +// arguments. +// +// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]` +func rebuildColonSeparatedArgs(args []string) []string { + if len(args) == 0 { + return args + } + + result := []string{} + i := 0 + + for i < len(args) { + current := args[i] + + // Keep joining while the next element is ":" or the current element ends with ":" + for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { + if args[i+1] == ":" { + current += ":" + i++ + // Check if there's a following element after the ":" + if i+1 < len(args) && args[i+1] != ":" { + current += args[i+1] + i++ + } + } else { + break + } + } + + result = append(result, current) + i++ + } + + return result +} diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go new file mode 100644 index 0000000..3e8aa33 --- /dev/null +++ b/internal/autocomplete/autocomplete_test.go @@ -0,0 +1,393 @@ +package autocomplete + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestGetCompletions_EmptyArgs(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "generate", Usage: "Generate SDK"}, + {Name: "test", Usage: "Run tests"}, + {Name: "build", Usage: "Build project"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 3) + assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"}) + assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"}) + assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"}) +} + +func TestGetCompletions_SubcommandPrefix(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "generate", Usage: "Generate SDK"}, + {Name: "test", Usage: "Run tests"}, + {Name: "build", Usage: "Build project"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"ge"}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 1) + assert.Equal(t, "generate", result.Completions[0].Name) + assert.Equal(t, "Generate SDK", result.Completions[0].Usage) +} + +func TestGetCompletions_HiddenCommand(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "visible", Usage: "Visible command"}, + {Name: "hidden", Usage: "Hidden command", Hidden: true}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{""}) + + assert.Len(t, result.Completions, 1) + assert.Equal(t, "visible", result.Completions[0].Name) +} + +func TestGetCompletions_NestedSubcommand(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "config", + Usage: "Configuration commands", + Commands: []*cli.Command{ + {Name: "get", Usage: "Get config value"}, + {Name: "set", Usage: "Set config value"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 1) + assert.Equal(t, "set", result.Completions[0].Name) + assert.Equal(t, "Set config value", result.Completions[0].Usage) +} + +func TestGetCompletions_FlagCompletion(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, + &cli.StringFlag{Name: "format", Usage: "Output format"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 1) + assert.Equal(t, "--output", result.Completions[0].Name) + assert.Equal(t, "Output directory", result.Completions[0].Usage) +} + +func TestGetCompletions_ShortFlagCompletion(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 1) + assert.Equal(t, "-v", result.Completions[0].Name) +} + +func TestGetCompletions_FileFlagBehavior(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""}) + + assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior) + assert.Empty(t, result.Completions) +} + +func TestGetCompletions_NonBoolFlagValue(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "format", Usage: "Output format"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""}) + + assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior) + assert.Empty(t, result.Completions) +} + +func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, + }, + Commands: []*cli.Command{ + {Name: "typescript", Usage: "Generate TypeScript SDK"}, + {Name: "python", Usage: "Generate Python SDK"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"}) + + assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) + assert.Len(t, result.Completions, 1) + assert.Equal(t, "typescript", result.Completions[0].Name) +} + +func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + {Name: "config:list", Usage: "List config values"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"co"}) + + // Should collapse to single "config" entry without usage + assert.Len(t, result.Completions, 1) + assert.Equal(t, "config", result.Completions[0].Name) + assert.Equal(t, "", result.Completions[0].Usage) +} + +func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + {Name: "config:list", Usage: "List config values"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"config:"}) + + // For bash, should show suffixes only + assert.Len(t, result.Completions, 3) + names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} + assert.Contains(t, names, "get") + assert.Contains(t, names, "set") + assert.Contains(t, names, "list") +} + +func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + {Name: "config:list", Usage: "List config values"}, + }, + } + + result := GetCompletions(CompletionStyleZsh, root, []string{"config:"}) + + // For zsh, should show full names + assert.Len(t, result.Completions, 3) + names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} + assert.Contains(t, names, "config:get") + assert.Contains(t, names, "config:set") + assert.Contains(t, names, "config:list") +} + +func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"config:g"}) + + // For bash, should return suffix from after the colon in the input + // Input "config:g" has colon at index 6, so we take name[7:] from matched commands + assert.Len(t, result.Completions, 1) + assert.Equal(t, "get", result.Completions[0].Name) + assert.Equal(t, "Get config value", result.Completions[0].Usage) +} + +func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"other:g"}) + + // No matches + assert.Len(t, result.Completions, 0) +} + +func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + }, + } + + result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"}) + + // For zsh, should return full name + assert.Len(t, result.Completions, 1) + assert.Equal(t, "config:get", result.Completions[0].Name) + assert.Equal(t, "Get config value", result.Completions[0].Usage) +} + +func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "generate", Usage: "Generate SDK"}, + {Name: "config:get", Usage: "Get config value"}, + {Name: "config:set", Usage: "Set config value"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{""}) + + // Should show "generate" and "config" (collapsed) + assert.Len(t, result.Completions, 2) + names := []string{result.Completions[0].Name, result.Completions[1].Name} + assert.Contains(t, names, "generate") + assert.Contains(t, names, "config") +} + +func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, + &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, + }, + Commands: []*cli.Command{ + {Name: "typescript", Usage: "TypeScript SDK"}, + }, + }, + }, + } + + // Bool flag should not consume the next arg as a value + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"}) + + assert.Len(t, result.Completions, 1) + assert.Equal(t, "typescript", result.Completions[0].Name) +} + +func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}}, + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, + }, + Commands: []*cli.Command{ + {Name: "typescript", Usage: "TypeScript SDK"}, + {Name: "python", Usage: "Python SDK"}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"}) + + assert.Len(t, result.Completions, 1) + assert.Equal(t, "python", result.Completions[0].Name) +} + +func TestGetCompletions_CommandAliases(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"g"}) + + // Should match all aliases that start with "g" + assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too + names := []string{} + for _, c := range result.Completions { + names = append(names, c.Name) + } + assert.Contains(t, names, "generate") + assert.Contains(t, names, "gen") +} + +func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { + root := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate SDK", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, + &cli.StringFlag{Name: "format", Aliases: []string{"f"}}, + }, + }, + }, + } + + result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"}) + + // Should show all flag variations + assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format +} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash new file mode 100755 index 0000000..8fb7b0b --- /dev/null +++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash @@ -0,0 +1,59 @@ +#!/bin/bash + +____APPNAME___bash_autocomplete() { + if [[ "${COMP_WORDS[0]}" != "source" ]]; then + local cur completions exit_code + local IFS=$'\n' + cur="${COMP_WORDS[COMP_CWORD]}" + + completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null) + exit_code=$? + + local last_token="$cur" + + # If the last token has been split apart by a ':', join it back together. + # Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b' + if [[ $COMP_CWORD -ge 2 ]]; then + local prev2="${COMP_WORDS[COMP_CWORD - 2]}" + local prev1="${COMP_WORDS[COMP_CWORD - 1]}" + if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then + last_token="$prev2:$cur" + fi + fi + + # Check for custom file completion patterns + local prefix="" + local file_part="$cur" + local force_file_completion=false + if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then + local before_at="${BASH_REMATCH[1]}" + local protocol="${BASH_REMATCH[2]}" + file_part="${BASH_REMATCH[3]}" + + if [[ "$protocol" == "" ]]; then + prefix="$before_at@" + else + if [[ "$before_at" == "" ]]; then + prefix="//" + else + prefix="$before_at@$protocol" + fi + fi + + force_file_completion=true + fi + + if [[ "$force_file_completion" == true ]]; then + mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|") + else + case $exit_code in + 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion + 11) COMPREPLY=() ;; # no completion + 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions + esac + fi + return 0 + fi +} + +complete -F ____APPNAME___bash_autocomplete __APPNAME__ diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish new file mode 100644 index 0000000..b853057 --- /dev/null +++ b/internal/autocomplete/shellscripts/fish_autocomplete.fish @@ -0,0 +1,51 @@ +#!/usr/bin/env fish + +function ____APPNAME___fish_autocomplete + set -l tokens (commandline -xpc) + set -l current (commandline -ct) + + set -l cmd $tokens[1] + set -l args $tokens[2..-1] + + set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log) + set -l exit_code $status + + # Check for custom file completion patterns + # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') + set -l prefix "" + set -l file_part "$current" + set -l force_file_completion 0 + + if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current + if string match -qr '^[\'"]' -- $before + # Ensures we don't insert an extra quote when the user is building an argument in quotes + set before (string sub -s 2 -- $before) + end + + set prefix "$before@$protocol" + set force_file_completion 1 + end + + if test $force_file_completion -eq 1 + for path in (__fish_complete_path "$file_part") + echo $prefix$path + end + else + switch $exit_code + case 10 + # File completion + __fish_complete_path "$current" + case 11 + # No completion + return 0 + case 0 + # Use returned completions + for completion in $completions + echo $completion + end + end + end +end + +complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)' + diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 new file mode 100644 index 0000000..7cd6e62 --- /dev/null +++ b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 @@ -0,0 +1,97 @@ +Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $elements = $commandAst.CommandElements + $completionArgs = @() + + # Extract each of the arguments + for ($i = 0; $i -lt $elements.Count; $i++) { + $completionArgs += $elements[$i].Extent.Text + } + + # Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space) + # Necessary for differentiating between getting completions for namespaced commands vs. subcommands + if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) { + $completionArgs += "" + } + + $output = & { + $env:COMPLETION_STYLE = 'pwsh' + __APPNAME__ __complete @completionArgs 2>&1 + } + $exitCode = $LASTEXITCODE + + # Check for custom file completion patterns + # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') + $prefix = "" + $filePart = $wordToComplete + $forceFileCompletion = $false + + # PowerShell includes quotes in $wordToComplete - strip them for pattern matching + # but preserve them in the prefix for the completion result + $wordContent = $wordToComplete + $leadingQuote = "" + if ($wordToComplete -match '^([''"])(.*)(\1)$') { + # Fully quoted: "content" or 'content' + $leadingQuote = $Matches[1] + $wordContent = $Matches[2] + } elseif ($wordToComplete -match '^([''"])(.*)$') { + # Opening quote only: "content or 'content + $leadingQuote = $Matches[1] + $wordContent = $Matches[2] + } + + if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') { + $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2] + $filePart = $Matches[3] + $forceFileCompletion = $true + } + + if ($forceFileCompletion) { + # Handle empty filePart (e.g., "@" or "@file://") by listing current directory + $items = if ([string]::IsNullOrEmpty($filePart)) { + Get-ChildItem -ErrorAction SilentlyContinue + } else { + Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue + } + $items | ForEach-Object { + $completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name } + [System.Management.Automation.CompletionResult]::new( + $completionText, + $completionText, + 'ProviderItem', + $completionText + ) + } + } else { + switch ($exitCode) { + 10 { + # File completion behavior + $items = if ([string]::IsNullOrEmpty($wordToComplete)) { + Get-ChildItem -ErrorAction SilentlyContinue + } else { + Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue + } + $items | ForEach-Object { + $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name } + [System.Management.Automation.CompletionResult]::new( + $completionText, + $completionText, + 'ProviderItem', + $completionText + ) + } + } + 11 { + # No reasonable suggestions + [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ') + } + default { + # Default behavior - show command completions + $output | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + } + } +} diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh new file mode 100644 index 0000000..4d4bdcd --- /dev/null +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -0,0 +1,46 @@ +#!/bin/zsh +compdef ____APPNAME___zsh_autocomplete __APPNAME__ + +____APPNAME___zsh_autocomplete() { + + local -a opts + local temp + local exit_code + + temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}") + exit_code=$? + + # Check for custom file completion patterns + # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') + local cur="${words[CURRENT]}" + + if [[ "$cur" = *'@'* ]]; then + # Extract everything after the last @ + local after_last_at="${cur##*@}" + + if [[ $after_last_at =~ ^(file://|data://) ]]; then + compset -P "*$MATCH" + _files + else + compset -P '*@' + _files + fi + return + fi + + case $exit_code in + 10) + # File completion behavior + _files + ;; + 11) + # No completion behavior - return nothing + return 1 + ;; + 0) + # Default behavior - show command completions + opts=("${(@f)temp}") + _describe 'values' opts + ;; + esac +} diff --git a/internal/binaryparam/binary_param.go b/internal/binaryparam/binary_param.go new file mode 100644 index 0000000..79357a3 --- /dev/null +++ b/internal/binaryparam/binary_param.go @@ -0,0 +1,30 @@ +package binaryparam + +import ( + "io" + "os" +) + +const stdinGlyph = "-" + +// FileOrStdin opens the file at the given path for reading. If the path is "-", stdin is returned instead. +// +// It's the caller's responsibility to close the returned ReadCloser (usually with `defer`). +// +// Returns a boolean indicating whether stdin is being used. If true, no other components of the calling +// program should attempt to read from stdin for anything else. +func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) { + // When the special glyph "-" is used, read from stdin. Although probably less necessary, also support + // special Unix files that refer to stdin. + switch path { + case stdinGlyph, "/dev/fd/0", "/dev/stdin": + return stdin, true, nil + } + + readCloser, err := os.Open(path) + if err != nil { + return nil, false, err + } + + return readCloser, false, err +} diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go new file mode 100644 index 0000000..bdac3e9 --- /dev/null +++ b/internal/binaryparam/binary_param_test.go @@ -0,0 +1,84 @@ +package binaryparam + +import ( + "io" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFileOrStdin(t *testing.T) { + t.Parallel() + + const expectedContents = "test file contents" + + t.Run("WithFile", func(t *testing.T) { + tempFile := t.TempDir() + "/test_file.txt" + require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) + + readCloser, stdinInUse, err := FileOrStdin(os.Stdin, tempFile) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, readCloser.Close()) }) + + actualContents, err := io.ReadAll(readCloser) + require.NoError(t, err) + require.Equal(t, expectedContents, string(actualContents)) + + require.False(t, stdinInUse) + }) + + t.Run("WithStdinGlyph", func(t *testing.T) { + tempFile := t.TempDir() + "/test_file.txt" + require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) + + stubStdin, err := os.Open(tempFile) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) + + readCloser, stdinInUse, err := FileOrStdin(stubStdin, "-") + require.NoError(t, err) + + actualContents, err := io.ReadAll(readCloser) + require.NoError(t, err) + require.Equal(t, expectedContents, string(actualContents)) + + require.True(t, stdinInUse) + }) + + t.Run("WithDevFD0File", func(t *testing.T) { + tempFile := t.TempDir() + "/dev_fd_0" + require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) + + stubStdin, err := os.Open(tempFile) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) + + readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/fd/0") + require.NoError(t, err) + + actualContents, err := io.ReadAll(readCloser) + require.NoError(t, err) + require.Equal(t, expectedContents, string(actualContents)) + + require.True(t, stdinInUse) + }) + + t.Run("WithDevStdinFile", func(t *testing.T) { + tempFile := t.TempDir() + "/dev_stdin" + require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) + + stubStdin, err := os.Open(tempFile) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) + + readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/stdin") + require.NoError(t, err) + + actualContents, err := io.ReadAll(readCloser) + require.NoError(t, err) + require.Equal(t, expectedContents, string(actualContents)) + + require.True(t, stdinInUse) + }) +} diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go new file mode 100644 index 0000000..f07b93b --- /dev/null +++ b/internal/debugmiddleware/debug_middleware.go @@ -0,0 +1,127 @@ +package debugmiddleware + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httputil" + "reflect" + "strings" +) + +// For the time being these type definitions are duplicated here so that we can +// test this file in a non-generated context. +type ( + Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) + MiddlewareNext = func(*http.Request) (*http.Response, error) +) + +const redactedPlaceholder = "" + +// Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`, +// which is handled specially in `redactRequest` below. +var sensitiveHeaders = []string{} + +// RequestLogger is a middleware that logs HTTP requests and responses. +type RequestLogger struct { + logger interface{ Printf(string, ...any) } // field for testability; usually log.Default() + sensitiveHeaders []string // field for testability; usually sensitiveHeaders +} + +// NewRequestLogger returns a new RequestLogger instance with default options. +func NewRequestLogger() *RequestLogger { + return &RequestLogger{ + logger: log.Default(), + sensitiveHeaders: sensitiveHeaders, + } +} + +func (m *RequestLogger) Middleware() Middleware { + return func(req *http.Request, mn MiddlewareNext) (*http.Response, error) { + redacted, err := m.redactRequest(req) + if err != nil { + return nil, err + } + if reqBytes, err := httputil.DumpRequest(redacted, true); err == nil { + m.logger.Printf("Request Content:\n%s\n", reqBytes) + } + + resp, err := mn(req) + if err != nil { + return resp, err + } + + if respBytes, err := httputil.DumpResponse(resp, true); err == nil { + m.logger.Printf("Response Content:\n%s\n", respBytes) + } + + return resp, err + } +} + +// redactRequest redacts sensitive information from the request for logging +// purposes. If redaction is necessary, the request is cloned before mutating +// the original and that clone is returned. As a small optimization, the +// original is request is returned unchanged if no redaction is necessary. +func (m *RequestLogger) redactRequest(req *http.Request) (*http.Request, error) { + redactedHeaders := req.Header.Clone() + + // Notably, the clauses below are written so they can redact multiple + // headers of the same name if necessary. + if values := redactedHeaders.Values("Authorization"); len(values) > 0 { + redactedHeaders.Del("Authorization") + + for _, value := range values { + // In case we're using something like a bearer token (e.g. `Bearer + // `), keep the `Bearer` part for more debugging + // information. + if authKind, _, ok := strings.Cut(value, " "); ok { + redactedHeaders.Add("Authorization", authKind+" "+redactedPlaceholder) + } else { + redactedHeaders.Add("Authorization", redactedPlaceholder) + } + } + } + + for _, header := range m.sensitiveHeaders { + values := redactedHeaders.Values(header) + if len(values) == 0 { + continue + } + + redactedHeaders.Del(header) + + for range values { + redactedHeaders.Add(header, redactedPlaceholder) + } + } + + if reflect.DeepEqual(req.Header, redactedHeaders) { + return req, nil + } + + redacted := req.Clone(req.Context()) + redacted.Header = redactedHeaders + var err error + redacted.Body, req.Body, err = cloneBody(req.Body) + return redacted, err +} + +// This function returns two copies of an HTTP request body that can each be +// read independently without affecting the other. +// This logic is taken from `drainBody` in net/http/httputil. +func cloneBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { + if b == nil || b == http.NoBody { + // No copying needed. Preserve the magic sentinel meaning of NoBody. + return http.NoBody, http.NoBody, nil + } + var buf bytes.Buffer + if _, err = buf.ReadFrom(b); err != nil { + return nil, b, err + } + if err = b.Close(); err != nil { + return nil, b, err + } + return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil +} diff --git a/internal/debugmiddleware/debug_middleware_test.go b/internal/debugmiddleware/debug_middleware_test.go new file mode 100644 index 0000000..4e46fbc --- /dev/null +++ b/internal/debugmiddleware/debug_middleware_test.go @@ -0,0 +1,201 @@ +package debugmiddleware + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDebugMiddleware(t *testing.T) { + t.Parallel() + + setup := func() (*RequestLogger, *bytes.Buffer) { + var ( + logBuf bytes.Buffer + middleware = NewRequestLogger() + ) + middleware.logger = log.New(&logBuf, "", 0) + return middleware, &logBuf + } + + t.Run("DoesNotRedactMostHeaders", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + const stainlessUserAgent = "Stainless" + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Set("User-Agent", stainlessUserAgent) + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, stainlessUserAgent, req.Header.Get("User-Agent")) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Contains(t, logBuf.String(), "User-Agent: "+stainlessUserAgent) + }) + + const secretToken = "secret-token" + + t.Run("RedactsAuthorizationHeader", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Set("Authorization", secretToken) + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, secretToken, req.Header.Get("Authorization")) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) + }) + + t.Run("RedactsOnlySecretInAuthorizationHeader", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Set("Authorization", "Bearer "+secretToken) + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Contains(t, logBuf.String(), "Authorization: Bearer "+redactedPlaceholder) + }) + + t.Run("RedactsMultipleAuthorizationHeaders", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Add("Authorization", secretToken+"1") + req.Header.Add("Authorization", secretToken+"2") + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values("Authorization")) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + + if strings.Count(logBuf.String(), "Authorization: "+redactedPlaceholder) != 2 { + t.Error("expected exactly two redacted placeholders in authorization headers") + } + }) + + const customAPIKeyHeader = "X-My-Api-Key" + + t.Run("RedactsSensitiveHeaders", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + middleware.sensitiveHeaders = []string{customAPIKeyHeader} + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Set(customAPIKeyHeader, secretToken) + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, secretToken, req.Header.Get(customAPIKeyHeader)) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Contains(t, logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder) + }) + + t.Run("RedactsMultipleSensitiveHeaders", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + + middleware.sensitiveHeaders = []string{customAPIKeyHeader} + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Header.Add(customAPIKeyHeader, secretToken+"1") + req.Header.Add(customAPIKeyHeader, secretToken+"2") + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values(customAPIKeyHeader)) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Equal(t, 2, strings.Count(logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder)) + }) + + t.Run("DoesNotConsumeRequestBodyWhenIoReader", func(t *testing.T) { + t.Parallel() + + middleware, logBuf := setup() + middleware.sensitiveHeaders = []string{customAPIKeyHeader} + + const bodyContent = "test request body content" + bodyReader := strings.NewReader(bodyContent) + + req := httptest.NewRequest("POST", "https://example.com", bodyReader) + req.Header.Set("Authorization", secretToken) + + var nextMiddlewareRan bool + middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { + nextMiddlewareRan = true + + // The request body should still be fully readable after the middleware runs + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, bodyContent, string(body)) + + // The request sent down through middleware shouldn't be mutated. + require.Equal(t, secretToken, req.Header.Get("Authorization")) + + return &http.Response{}, nil + }) + + require.True(t, nextMiddlewareRan) + require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) + }) +} diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go new file mode 100644 index 0000000..055541e --- /dev/null +++ b/internal/jsonview/explorer.go @@ -0,0 +1,775 @@ +package jsonview + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "os" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" + "github.com/muesli/reflow/truncate" + "github.com/muesli/reflow/wordwrap" + "github.com/tidwall/gjson" +) + +const ( + // UI layout constants + borderPadding = 2 + heightOffset = 5 + tableMinHeight = 2 + titlePaddingLeft = 2 + titlePaddingTop = 0 + footerPaddingLeft = 1 + + // Column width constants + defaultColumnWidth = 10 + keyColumnWidth = 3 + valueColumnWidth = 5 + + // String formatting constants + maxStringLength = 100 + maxPreviewLength = 24 + + arrayColor = lipgloss.Color("1") + stringColor = lipgloss.Color("5") + objectColor = lipgloss.Color("4") +) + +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + PrintValue key.Binding + Raw key.Binding + Quit key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Back: key.NewBinding( + key.WithKeys("left", "h", "backspace"), + key.WithHelp("←/h", "go back"), + ), + Enter: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "expand"), + ), + PrintValue: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "print and exit"), + ), + Raw: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "toggle raw JSON"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c", "enter"), + key.WithHelp("q/enter", "quit"), + ), +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) + arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) + stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) + objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) + stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) +) + +type JSONView interface { + GetPath() string + GetData() gjson.Result + Update(tea.Msg, bool) tea.Cmd + View() string + Resize(width, height int) +} + +type TableView struct { + width int + height int + path string + data gjson.Result + table table.Model + rowData []gjson.Result + iterator AnyIterator + isLoading bool + columns []table.Column +} + +func (tv *TableView) GetPath() string { return tv.path } +func (tv *TableView) GetData() gjson.Result { return tv.data } +func (tv *TableView) View() string { return tv.table.View() } + +func (tv *TableView) Update(msg tea.Msg, raw bool) tea.Cmd { + var cmd tea.Cmd + tv.table, cmd = tv.table.Update(msg) + + // Check if we need to load more data + if tv.iterator != nil && !tv.isLoading && tv.data.IsArray() { + cursor := tv.table.Cursor() + totalRows := len(tv.table.Rows()) + + // Load more when we're at the last row + if cursor == totalRows-1 { + tv.isLoading = true + return tv.loadMoreData(raw) + } + } + + return cmd +} + +func (tv *TableView) loadMoreData(raw bool) tea.Cmd { + return func() tea.Msg { + if tv.iterator == nil { + return nil + } + + if !tv.iterator.Next() { + tv.isLoading = false + return tv.iterator.Err() + } + + obj := tv.iterator.Current() + var result gjson.Result + if jsonBytes, err := json.Marshal(obj); err != nil { + return err + } else { + result = gjson.ParseBytes(jsonBytes) + } + + if !result.Exists() { + tv.isLoading = false + return nil + } + + // Add the new item to our data + tv.rowData = append(tv.rowData, result) + + // Add new row to the table + newRow := table.Row{formatValue(result, raw)} + + // For array of objects, we need to format according to columns + if len(tv.columns) > 1 && result.IsObject() { + newRow = make(table.Row, len(tv.columns)) + for i, col := range tv.columns { + newRow[i] = formatValue(result.Get(col.Title), raw) + } + } + + rows := tv.table.Rows() + rows = append(rows, newRow) + tv.table.SetRows(rows) + + // Resize columns to accommodate the new data + tv.Resize(tv.width, tv.height) + + tv.isLoading = false + return nil + } +} + +func (tv *TableView) Resize(width, height int) { + tv.width = width + tv.height = height + tv.updateColumnWidths(width) + tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) +} + +func (tv *TableView) updateColumnWidths(width int) { + columns := tv.table.Columns() + widths := make([]int, len(columns)) + + // Calculate required widths from headers and content + for i, col := range columns { + widths[i] = lipgloss.Width(col.Title) + } + + for _, row := range tv.table.Rows() { + for i, cell := range row { + if i < len(widths) { + widths[i] = max(widths[i], lipgloss.Width(cell)) + } + } + } + + totalWidth := sum(widths) + available := width - borderPadding*len(columns) + + if totalWidth <= available { + for i, w := range widths { + columns[i].Width = w + } + return + } + + fairShare := float64(available) / float64(len(columns)) + shrinkable := 0.0 + + for _, w := range widths { + if float64(w) > fairShare { + shrinkable += float64(w) - fairShare + } + } + + if shrinkable > 0 { + excess := float64(totalWidth - available) + for i, w := range widths { + if float64(w) > fairShare { + reduction := (float64(w) - fairShare) * (excess / shrinkable) + widths[i] = int(math.Round(float64(w) - reduction)) + } + } + } + + for i, w := range widths { + columns[i].Width = w + } + + tv.table.SetColumns(columns) +} + +type TextView struct { + path string + data gjson.Result + viewport viewport.Model + ready bool +} + +func (tv *TextView) GetPath() string { return tv.path } +func (tv *TextView) GetData() gjson.Result { return tv.data } +func (tv *TextView) View() string { return tv.viewport.View() } + +func (tv *TextView) Update(msg tea.Msg, raw bool) tea.Cmd { + var cmd tea.Cmd + tv.viewport, cmd = tv.viewport.Update(msg) + return cmd +} + +func (tv *TextView) Resize(width, height int) { + h := height - heightOffset + if !tv.ready { + tv.viewport = viewport.New(width, h) + tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) + tv.ready = true + return + } + tv.viewport.Width = width + tv.viewport.Height = h +} + +type JSONViewer struct { + stack []JSONView + root string + width int + height int + rawMode bool + message string + help help.Model +} + +// ExploreJSON explores a single JSON value known ahead of time +func ExploreJSON(title string, json gjson.Result) error { + view, err := newView("", json, false) + if err != nil { + return err + } + + viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} + + _, err = tea.NewProgram(viewer).Run() + if viewer.message != "" { + _, msgErr := fmt.Println("\n" + viewer.message) + err = errors.Join(err, msgErr) + } + return err +} + +// ExploreJSONStream explores JSON data loaded incrementally via an iterator +func ExploreJSONStream[T any](title string, it Iterator[T]) error { + anyIt := genericToAnyIterator(it) + + preloadCount := 20 + if termHeight, _, err := term.GetSize(os.Stdout.Fd()); err == nil { + preloadCount = termHeight + } + + items := make([]any, 0, preloadCount) + for i := 0; i < preloadCount && anyIt.Next(); i++ { + items = append(items, anyIt.Current()) + } + + if err := anyIt.Err(); err != nil { + return err + } + + // Convert items to JSON array + jsonBytes, err := json.Marshal(items) + if err != nil { + return err + } + arrayJSON := gjson.ParseBytes(jsonBytes) + view, err := newTableView("", arrayJSON, false) + if err != nil { + return err + } + + // Set iterator if there might be more data + if len(items) == preloadCount { + view.iterator = anyIt + } + + viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} + _, err = tea.NewProgram(viewer).Run() + if viewer.message != "" { + _, msgErr := fmt.Println("\n" + viewer.message) + err = errors.Join(err, msgErr) + } + return err +} + +func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } +func (v *JSONViewer) Init() tea.Cmd { return nil } + +func (v *JSONViewer) resize(width, height int) { + v.width, v.height = width, height + v.help.Width = width + for i := range v.stack { + v.stack[i].Resize(width, height) + } +} + +func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.resize(msg.Width-borderPadding, msg.Height) + return v, nil + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + return v, tea.Quit + case key.Matches(msg, keys.Enter): + return v.navigateForward() + case key.Matches(msg, keys.Back): + return v.navigateBack() + case key.Matches(msg, keys.Raw): + return v.toggleRaw() + case key.Matches(msg, keys.PrintValue): + v.message = v.getSelectedContent() + return v, tea.Quit + } + } + + return v, v.current().Update(msg, v.rawMode) +} + +func (v *JSONViewer) getSelectedContent() string { + tableView, ok := v.current().(*TableView) + if !ok { + return v.current().GetData().Raw + } + + selected := tableView.rowData[tableView.table.Cursor()] + if selected.Type == gjson.String { + return selected.String() + } + return selected.Raw +} + +func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { + tableView, ok := v.current().(*TableView) + if !ok { + return v, nil + } + + cursor := tableView.table.Cursor() + selected := tableView.rowData[cursor] + if !v.canNavigateInto(selected) { + return v, nil + } + + path := v.buildNavigationPath(tableView, cursor) + forwardView, err := newView(path, selected, v.rawMode) + if err != nil { + return v, nil + } + + v.stack = append(v.stack, forwardView) + v.resize(v.width, v.height) + return v, nil +} + +func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { + if tableView.data.IsArray() { + return fmt.Sprintf("%s[%d]", tableView.path, cursor) + } + key := tableView.data.Get("@keys").Array()[cursor].Str + return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) +} + +func quoteString(s string) string { + // Replace backslashes and quotes with escaped versions + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return stringLiteralStyle.Render("\"" + s + "\"") +} + +func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { + switch { + case data.IsArray(): + return len(data.Array()) > 0 + case data.IsObject(): + return len(data.Map()) > 0 + case data.Type == gjson.String: + str := data.String() + return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength + } + return false +} + +func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { + if len(v.stack) > 1 { + v.stack = v.stack[:len(v.stack)-1] + } + return v, nil +} + +func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { + v.rawMode = !v.rawMode + + for i, view := range v.stack { + viewWithRaw, err := newView(view.GetPath(), view.GetData(), v.rawMode) + if err != nil { + return v, tea.Printf("Error: %s", err) + } + if newTV, ok := viewWithRaw.(*TableView); ok { + if tv, ok := view.(*TableView); ok && tv.iterator != nil { + newTV.iterator = tv.iterator + } + } + v.stack[i] = viewWithRaw + } + + v.resize(v.width, v.height) + return v, nil +} + +func (v *JSONViewer) View() string { + view := v.current() + title := v.buildTitle(view) + content := titleStyle.Render(title) + style := v.getStyleForData(view.GetData()) + content += "\n" + style.Render(view.View()) + content += "\n" + v.help.View(keys) + return content +} + +func (v *JSONViewer) buildTitle(view JSONView) string { + title := v.root + if len(view.GetPath()) > 0 { + title += " → " + view.GetPath() + } + if v.rawMode { + title += " (JSON)" + } + return title +} + +func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { + switch { + case data.Type == gjson.String: + return stringStyle + case data.IsArray(): + return arrayStyle + default: + return objectStyle + } +} + +func newView(path string, data gjson.Result, raw bool) (JSONView, error) { + if data.Type == gjson.String { + return newTextView(path, data) + } + return newTableView(path, data, raw) +} + +func newTextView(path string, data gjson.Result) (*TextView, error) { + if !data.Exists() || data.Type != gjson.String { + return nil, fmt.Errorf("invalid text JSON") + } + return &TextView{path: path, data: data}, nil +} + +func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { + if !data.Exists() || data.Type != gjson.JSON { + return nil, fmt.Errorf("invalid table JSON") + } + + switch { + case data.IsArray(): + array := data.Array() + if isArrayOfObjects(array) { + return newArrayOfObjectsTableView(path, data, array, raw), nil + } else { + return newArrayTableView(path, data, array, raw), nil + } + case data.IsObject(): + return newObjectTableView(path, data, raw), nil + default: + return nil, fmt.Errorf("unsupported JSON type") + } +} + +func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { + columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} + rows := make([]table.Row, 0, len(array)) + rowData := make([]gjson.Result, 0, len(array)) + + for _, item := range array { + rows = append(rows, table.Row{formatValue(item, raw)}) + rowData = append(rowData, item) + } + + t := createTable(columns, rows, arrayColor) + return &TableView{ + path: path, + data: data, + table: t, + rowData: rowData, + columns: columns, + } +} + +func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { + // Collect unique keys + keySet := make(map[string]struct{}) + var columns []table.Column + + for _, item := range array { + for _, key := range item.Get("@keys").Array() { + if _, exists := keySet[key.Str]; !exists { + keySet[key.Str] = struct{}{} + title := key.Str + columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) + } + } + } + + rows := make([]table.Row, 0, len(array)) + rowData := make([]gjson.Result, 0, len(array)) + + for _, item := range array { + row := make(table.Row, len(columns)) + for i, col := range columns { + row[i] = formatValue(item.Get(col.Title), raw) + } + rows = append(rows, row) + rowData = append(rowData, item) + } + + t := createTable(columns, rows, arrayColor) + return &TableView{ + path: path, + data: data, + table: t, + rowData: rowData, + columns: columns, + } +} + +func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { + columns := []table.Column{{Title: "Object"}, {}} + + keys := data.Get("@keys").Array() + rows := make([]table.Row, 0, len(keys)) + rowData := make([]gjson.Result, 0, len(keys)) + + for _, key := range keys { + value := data.Get(key.Str) + title := key.Str + rows = append(rows, table.Row{title, formatValue(value, raw)}) + rowData = append(rowData, value) + } + + // Adjust column widths based on content + for _, row := range rows { + for i, cell := range row { + if i < len(columns) { + columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) + } + } + } + + t := createTable(columns, rows, objectColor) + return &TableView{ + path: path, + data: data, + table: t, + rowData: rowData, + columns: columns, + } +} + +func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + // Set common table styles + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(bgColor). + Bold(false) + t.SetStyles(s) + + return t +} + +func formatValue(value gjson.Result, raw bool) string { + if raw { + return value.Get("@ugly").Raw + } + + switch { + case value.IsObject(): + return formatObject(value) + case value.IsArray(): + return formatArray(value) + case value.Type == gjson.String: + return value.Str + default: + return value.Raw + } +} + +func formatObject(value gjson.Result) string { + keys := value.Get("@keys").Array() + keyStrs := make([]string, len(keys)) + + for i, key := range keys { + val := value.Get(key.Str) + keyStrs[i] = formatObjectKey(key.Str, val) + } + + return "{" + strings.Join(keyStrs, ", ") + "}" +} + +func formatObjectKey(key string, val gjson.Result) string { + switch { + case val.IsObject(): + return key + ":{…}" + case val.IsArray(): + return key + ":[…]" + case val.Type == gjson.String: + str := val.Str + if lipgloss.Width(str) <= maxPreviewLength { + return fmt.Sprintf(`%s:"%s"`, key, str) + } + return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) + default: + return key + ":" + val.Raw + } +} + +func formatArray(value gjson.Result) string { + switch count := len(value.Array()); count { + case 0: + return "[]" + case 1: + return "[...1 item...]" + default: + return fmt.Sprintf("[...%d items...]", count) + } +} + +func isArrayOfObjects(array []gjson.Result) bool { + for _, item := range array { + if !item.IsObject() { + return false + } + } + return len(array) > 0 +} + +func sum(ints []int) int { + total := 0 + for _, n := range ints { + total += n + } + return total +} + +// An iterator over `any` values +type AnyIterator interface { + Next() bool + Err() error + Current() any +} + +// A generic iterator interface that is used by the `genericIterator` struct +// below to convert iterators over specific types to an AnyIterator +type Iterator[T any] interface { + Next() bool + Err() error + Current() T +} + +// genericIterator adapts a generic Iterator[T] to an AnyIterator. +type genericIterator[T any] struct { + iterator Iterator[T] + current any +} + +func (g *genericIterator[T]) Next() bool { + if !g.iterator.Next() { + return false + } + g.current = g.iterator.Current() + return true +} + +func (g *genericIterator[T]) Err() error { + return g.iterator.Err() +} + +func (g *genericIterator[T]) Current() any { + return g.current +} + +func genericToAnyIterator[T any](it Iterator[T]) AnyIterator { + return &genericIterator[T]{ + iterator: it, + } +} diff --git a/internal/jsonview/staticdisplay.go b/internal/jsonview/staticdisplay.go new file mode 100644 index 0000000..768ea34 --- /dev/null +++ b/internal/jsonview/staticdisplay.go @@ -0,0 +1,135 @@ +package jsonview + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" + "github.com/muesli/reflow/truncate" + "github.com/tidwall/gjson" +) + +const ( + tabWidth = 2 +) + +var ( + keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) + stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) + numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) + boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) + nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) + bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) + containerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(0, 1) +) + +func formatJSON(json gjson.Result, width int) string { + if !json.Exists() { + return nullValueStyle.Render("Invalid JSON") + } + return formatResult(json, 0, width) +} + +func formatResult(result gjson.Result, indent, width int) string { + switch result.Type { + case gjson.String: + str := result.Str + if str == "" { + return nullValueStyle.Render("(empty)") + } + if lipgloss.Width(str) > width { + str = truncate.String(str, uint(width-1)) + "…" + } + return stringValueStyle.Render(str) + case gjson.Number: + return numberValueStyle.Render(result.Raw) + case gjson.True: + return boolValueStyle.Render("yes") + case gjson.False: + return boolValueStyle.Render("no") + case gjson.Null: + return nullValueStyle.Render("null") + case gjson.JSON: + if result.IsArray() { + return formatJSONArray(result, indent, width) + } + return formatJSONObject(result, indent, width) + default: + return stringValueStyle.Render(result.String()) + } +} + +func isSingleLine(result gjson.Result, indent int) bool { + return !(result.IsObject() || result.IsArray()) +} + +func formatJSONArray(result gjson.Result, indent, width int) string { + items := result.Array() + if len(items) == 0 { + return nullValueStyle.Render(" (none)") + } + + numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) + + var formattedItems []string + for i, item := range items { + number := fmt.Sprintf("%d.", i+1) + numbering := getIndent(indent) + bulletStyle.Render(number) + + // If the item will be a one-liner, put it inline after the numbering, + // otherwise it starts with a newline and goes below the numbering. + itemWidth := width + if isSingleLine(item, indent+1) { + // Add right-padding: + numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) + itemWidth = width - lipgloss.Width(numbering) + } + value := formatResult(item, indent+1, itemWidth) + formattedItems = append(formattedItems, numbering+value) + } + return "\n" + strings.Join(formattedItems, "\n") +} + +func formatJSONObject(result gjson.Result, indent, width int) string { + keys := result.Get("@keys").Array() + if len(keys) == 0 { + return nullValueStyle.Render("(empty)") + } + + var items []string + for _, key := range keys { + value := result.Get(key.String()) + keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") + // If item will be a one-liner, put it inline after the key, otherwise + // it starts with a newline and goes below the key. + itemWidth := width + if isSingleLine(value, indent+1) { + keyStr += " " + itemWidth = width - lipgloss.Width(keyStr) + } + formattedValue := formatResult(value, indent+1, itemWidth) + items = append(items, keyStr+formattedValue) + } + + return "\n" + strings.Join(items, "\n") +} + +func getIndent(indent int) string { + return strings.Repeat(" ", indent*tabWidth) +} + +func RenderJSON(title string, json gjson.Result) string { + width, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + width = 80 + } + width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + + containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() + content := strings.TrimLeft(formatJSON(json, width), "\n") + return titleStyle.Render(title) + "\n" + containerStyle.Render(content) +} diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go new file mode 100644 index 0000000..870ca73 --- /dev/null +++ b/internal/mocktest/mocktest.go @@ -0,0 +1,127 @@ +package mocktest + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var mockServerURL *url.URL + +func init() { + mockServerURL, _ = url.Parse("http://localhost:4010") + if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" { + if parsed, err := url.Parse(testURL); err == nil { + mockServerURL = parsed + } + } +} + +// OnlyMockServerDialer only allows network connections to the mock server +type OnlyMockServerDialer struct{} + +func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if address == mockServerURL.Host { + return (&net.Dialer{}).DialContext(ctx, network, address) + } + + return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host) +} + +func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) { + restricted := &http.Transport{ + DialContext: (&OnlyMockServerDialer{}).DialContext, + } + + origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport + http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted + return origClient, origDefault +} + +func restoreNetwork(origClient, origDefault http.RoundTripper) { + http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault +} + +// TestRunMockTestWithFlags runs a test against a mock server with the provided +// CLI flags and ensures it succeeds +func TestRunMockTestWithFlags(t *testing.T, flags ...string) { + origClient, origDefault := blockNetworkExceptMockServer() + defer restoreNetwork(origClient, origDefault) + + // Check if mock server is running + conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second) + if err != nil { + require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.") + } else { + conn.Close() + } + + // Get the path to the main command + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "Could not get current file path") + dirPath := filepath.Dir(filename) + project := filepath.Join(dirPath, "..", "..", "cmd", "...") + + args := []string{"run", project, "--base-url", mockServerURL.String()} + args = append(args, flags...) + + t.Logf("Testing command: agentmail %s", strings.Join(args[4:], " ")) + + cliCmd := exec.Command("go", args...) + + // Pipe the CLI tool's output into `head` so it doesn't hang when simulating + // paginated or streamed endpoints. 100 lines of output should be enough to + // test that the API endpoint worked, or report back a meaningful amount of + // data if something went wrong. + headCmd := exec.Command("head", "-n", "100") + pipe, err := cliCmd.StdoutPipe() + require.NoError(t, err, "Failed to create pipe for CLI command") + headCmd.Stdin = pipe + + // Capture `head` output and CLI command stderr outputs: + var output strings.Builder + headCmd.Stdout = &output + headCmd.Stderr = &output + cliCmd.Stderr = &output + + // First start `head`, so it's ready for data to come in: + err = headCmd.Start() + require.NoError(t, err, "Failed to start `head` command") + + // Next start the CLI command so it can pipe data to `head` without + // buffering any data in advance: + err = cliCmd.Start() + require.NoError(t, err, "Failed to start CLI command") + + // Ensure that the stdout pipe is closed as soon as `head` exits, to let the + // CLI tool know that no more output is needed and it can stop streaming + // test data for streaming/paginated endpoints. This needs to happen before + // calling `cliCmd.Wait()`, otherwise there will be a deadlock. + err = headCmd.Wait() + pipe.Close() + require.NoError(t, err, "`head` command finished with an error") + + // Finally, wait for the CLI tool to finish up: + err = cliCmd.Wait() + require.NoError(t, err, "CLI command failed\n%s", output.String()) + + t.Logf("Test passed successfully\nOutput:\n%s", output.String()) +} + +func TestFile(t *testing.T, contents string) string { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(filename, []byte(contents), 0644)) + return filename +} diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go new file mode 100644 index 0000000..102624f --- /dev/null +++ b/internal/requestflag/innerflag.go @@ -0,0 +1,260 @@ +package requestflag + +import ( + "fmt" + "reflect" + "strings" + + "github.com/urfave/cli/v3" +) + +// InnerFlag[T] represents a CLI flag for the urfave/cli package that allows setting +// nested fields within other flags. For example, using `--foo.baz` will set the "baz" +// field on a parent flag named `--foo`. +type InnerFlag[ + T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | + []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | + string | float64 | int64 | bool, +] struct { + Name string // name of the flag + DefaultText string // default text of the flag for usage purposes + Usage string // usage string for help output + Aliases []string // aliases that are allowed for this flag + Validator func(T) error // custom function to validate this flag value + + OuterFlag cli.Flag // The flag on which this inner flag will set values + InnerField string // The inner field which this flag will set +} + +type HasOuterFlag interface { + cli.Flag + SetOuterFlag(cli.Flag) + GetOuterFlag() cli.Flag +} + +func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { + f.OuterFlag = flag +} + +func (f *InnerFlag[T]) GetOuterFlag() cli.Flag { + return f.OuterFlag +} + +// Implementation of the cli.Flag interface +var _ cli.Flag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance + +func (f *InnerFlag[T]) PreParse() error { + return nil +} + +func (f *InnerFlag[T]) PostParse() error { + return nil +} + +func (f *InnerFlag[T]) Set(name string, rawVal string) error { + if parsedValue, err := parseCLIArg[T](rawVal); err != nil { + return err + } else { + if f.Validator != nil { + if err := f.Validator(parsedValue); err != nil { + return err + } + } + + if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { + settableInnerField.SetInnerField(f.InnerField, parsedValue) + } else { + return fmt.Errorf("Cannot set inner field on %v", f.OuterFlag) + } + return nil + } +} + +func (f *InnerFlag[T]) Get() any { + var zeroValue T + return zeroValue +} + +func (f *InnerFlag[T]) String() string { + return cli.FlagStringer(f) +} + +func (f *InnerFlag[T]) IsSet() bool { + return false +} + +func (f *InnerFlag[T]) Names() []string { + return cli.FlagNames(f.Name, f.Aliases) +} + +// Implementation for the cli.DocGenerationFlag interface +var _ cli.DocGenerationFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance + +func (f *InnerFlag[T]) TakesValue() bool { + var t T + return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool +} + +func (f *InnerFlag[T]) GetUsage() string { + return f.Usage +} + +func (f *InnerFlag[T]) GetValue() string { + return "" +} + +func (f *InnerFlag[T]) GetDefaultText() string { + return f.DefaultText +} + +func (f *InnerFlag[T]) GetEnvVars() []string { + return nil +} + +func (f *InnerFlag[T]) IsDefaultVisible() bool { + return false +} + +func (f *InnerFlag[T]) TypeName() string { + var zeroValue T + ty := reflect.TypeOf(zeroValue) + if ty == nil { + return "" + } + + // Get base type name with special handling for built-in types + getTypeName := func(t reflect.Type) string { + switch t.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "int" + case reflect.Float32, reflect.Float64: + return "float" + case reflect.Bool: + return "boolean" + case reflect.String: + switch t.Name() { + case "DateTimeValue": + return "datetime" + case "DateValue": + return "date" + case "TimeValue": + return "time" + default: + return "string" + } + default: + if t.Name() == "" { + return "any" + } + return strings.ToLower(t.Name()) + } + } + + switch ty.Kind() { + case reflect.Slice: + elemType := ty.Elem() + return getTypeName(elemType) + case reflect.Map: + keyType := ty.Key() + valueType := ty.Elem() + return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) + default: + return getTypeName(ty) + } +} + +// Implementation for the cli.DocGenerationMultiValueFlag interface +var _ cli.DocGenerationMultiValueFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance + +func (f *InnerFlag[T]) IsMultiValueFlag() bool { + return false +} + +func (f *InnerFlag[T]) IsBoolFlag() bool { + var zeroValue T + _, isBool := any(zeroValue).(bool) + return isBool +} + +// WithInnerFlags takes a command and a map of flag names to inner flags, +// and returns a modified command with the appropriate inner flags set. +func WithInnerFlags(cmd cli.Command, innerFlagMap map[string][]HasOuterFlag) cli.Command { + if len(innerFlagMap) == 0 { + return cmd + } + + // If any keys are unused by the end, we know that they were not valid + unusedInnerFlagKeys := make(map[string]struct{}) + for name := range innerFlagMap { + unusedInnerFlagKeys[name] = struct{}{} + } + + updatedFlags := make([]cli.Flag, 0, len(cmd.Flags)) + for _, flag := range cmd.Flags { + updatedFlags = append(updatedFlags, flag) + for _, name := range flag.Names() { + // Check if this flag has inner flags in our map + innerFlags, hasInnerFlags := innerFlagMap[name] + if !hasInnerFlags { + continue + } + + // Mark this inner flag key as used + delete(unusedInnerFlagKeys, name) + + for _, innerFlag := range innerFlags { + innerFlag.SetOuterFlag(flag) + updatedFlags = append(updatedFlags, innerFlag) + } + } + } + + // If there are inner flags that don't correspond to any valid outer flag + // names, then panic because the user probably made a typo or forgot to + // delete inner flags that correspond to missing outer flags. + if len(unusedInnerFlagKeys) > 0 { + unusedKeys := make([]string, 0, len(unusedInnerFlagKeys)) + for key := range unusedInnerFlagKeys { + unusedKeys = append(unusedKeys, key) + } + panic(fmt.Sprintf("Missing outer flags to use with inner flags: %v", unusedKeys)) + } + + result := cmd + result.Flags = updatedFlags + return result +} + +// Helper function to verify that all inner flags have an outer flag set and +// follow the --foo.baz prefix format +func CheckInnerFlags(cmd cli.Command) error { + var errors []string + for _, flag := range cmd.Flags { + if innerFlag, ok := flag.(HasOuterFlag); ok { + outerFlag := innerFlag.GetOuterFlag() + if outerFlag == nil { + errors = append(errors, fmt.Sprintf("inner flag %s is missing an outer flag", flag.Names())) + continue + } + + innerFlagName := flag.Names()[0] + valid := false + for _, outerName := range outerFlag.Names() { + if strings.HasPrefix(innerFlagName, outerName+".") { + valid = true + break + } + } + + if !valid { + errors = append(errors, fmt.Sprintf("inner flag %s must start with one of its outer flag's names followed by a dot", innerFlagName)) + } + } + } + + if len(errors) > 0 { + return fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return nil +} diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go new file mode 100644 index 0000000..3f204c9 --- /dev/null +++ b/internal/requestflag/innerflag_test.go @@ -0,0 +1,319 @@ +package requestflag + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestInnerFlagSet(t *testing.T) { + tests := []struct { + name string + flagType string + inputVal string + expected any + expectErr bool + }{ + {"string", "string", "hello", "hello", false}, + {"int64", "int64", "42", int64(42), false}, + {"float64", "float64", "3.14", float64(3.14), false}, + {"bool", "bool", "true", true, false}, + {"invalid int", "int64", "not-a-number", nil, true}, + {"invalid float", "float64", "not-a-float", nil, true}, + {"invalid bool", "bool", "not-a-bool", nil, true}, + {"yaml map", "map", "key: value", map[string]any{"key": "value"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outerFlag := &Flag[map[string]any]{ + Name: "test-flag", + } + + var innerFlag cli.Flag + switch tt.flagType { + case "string": + innerFlag = &InnerFlag[string]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + } + case "int64": + innerFlag = &InnerFlag[int64]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + } + case "float64": + innerFlag = &InnerFlag[float64]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + } + case "bool": + innerFlag = &InnerFlag[bool]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + } + case "map": + innerFlag = &InnerFlag[map[string]any]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + } + } + + err := innerFlag.Set(innerFlag.Names()[0], tt.inputVal) + + if tt.expectErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + actual, ok := outerFlag.Get().(map[string]any)["test_field"] + assert.True(t, ok, "Field 'test_field' should exist in the map") + assert.Equal(t, tt.expected, actual, "Expected %v (%T), got %v (%T)", tt.expected, tt.expected, actual, actual) + }) + } +} + +func TestInnerFlagValidator(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "test-flag"} + + innerFlag := &InnerFlag[int64]{ + Name: "test-flag.test-field", + OuterFlag: outerFlag, + InnerField: "test_field", + Validator: func(val int64) error { + if val < 0 { + return cli.Exit("Value must be non-negative", 1) + } + return nil + }, + } + + // Valid case + err := innerFlag.Set(innerFlag.Name, "42") + assert.NoError(t, err, "Expected no error for valid value, got: %v", err) + + // Should trigger validator error + err = innerFlag.Set(innerFlag.Name, "-5") + assert.Error(t, err, "Expected error for invalid value, got none") +} + +func TestWithInnerFlags(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "outer"} + innerFlag := &InnerFlag[string]{ + Name: "outer.baz", + InnerField: "baz", + } + + cmd := WithInnerFlags(cli.Command{ + Name: "test-command", + Flags: []cli.Flag{outerFlag}, + }, map[string][]HasOuterFlag{ + "outer": {innerFlag}, + }) + + // Verify that the command now has both the original flag and inner flag + assert.Len(t, cmd.Flags, 2, "Expected 2 flags, got %d", len(cmd.Flags)) + assert.Equal(t, outerFlag, cmd.Flags[0], "First flag should be outerFlag") + assert.Equal(t, innerFlag, cmd.Flags[1], "Second flag should be innerFlag") + assert.Same(t, outerFlag, innerFlag.OuterFlag, "innerFlag.OuterFlag should point to outerFlag") +} + +func TestInnerFlagTypeNames(t *testing.T) { + tests := []struct { + name string + flag cli.DocGenerationFlag + expected string + }{ + {"string", &InnerFlag[string]{}, "string"}, + {"int64", &InnerFlag[int64]{}, "int"}, + {"float64", &InnerFlag[float64]{}, "float"}, + {"bool", &InnerFlag[bool]{}, "boolean"}, + {"string slice", &InnerFlag[[]string]{}, "string"}, + {"date", &InnerFlag[DateValue]{}, "date"}, + {"datetime", &InnerFlag[DateTimeValue]{}, "datetime"}, + {"time", &InnerFlag[TimeValue]{}, "time"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typeName := tt.flag.TypeName() + assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) + }) + } +} + +func TestInnerYamlHandling(t *testing.T) { + // Test with map value + t.Run("Parse YAML to map", func(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "outer"} + innerFlag := &InnerFlag[map[string]any]{ + Name: "outer.baz", + OuterFlag: outerFlag, + InnerField: "baz", + } + + err := innerFlag.Set(innerFlag.Name, "{name: test, value: 42}") + assert.NoError(t, err) + + // Retrieve and check the parsed YAML map + result, ok := outerFlag.Get().(map[string]any) + assert.True(t, ok, "Expected map[string]any from outerFlag.Get()") + yamlField, ok := result["baz"].(map[string]any) + assert.True(t, ok, "Expected map[string]any, got %T", result["baz"]) + val := yamlField + + if ok { + assert.Equal(t, map[string]any{"name": "test", "value": uint64(42)}, val) + } + }) + + // Test with invalid YAML + t.Run("Parse invalid YAML", func(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "outer"} + innerFlag := &InnerFlag[map[string]any]{ + Name: "outer.baz", + OuterFlag: outerFlag, + InnerField: "baz", + } + + invalidYaml := `[not closed` + err := innerFlag.Set(innerFlag.Name, invalidYaml) + assert.Error(t, err) + }) + + // Test setting inner flags on a map multiple times + t.Run("Set inner flags on map multiple times", func(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "outer"} + + // Set first inner flag + firstInnerFlag := &InnerFlag[string]{ + Name: "outer.first-flag", + OuterFlag: outerFlag, + InnerField: "first_field", + } + + err := firstInnerFlag.Set(firstInnerFlag.Name, "first-value") + assert.NoError(t, err) + + // Set second inner flag + secondInnerFlag := &InnerFlag[int64]{ + Name: "outer.second-flag", + OuterFlag: outerFlag, + InnerField: "second_field", + } + + err = secondInnerFlag.Set(secondInnerFlag.Name, "42") + assert.NoError(t, err) + + // Verify both fields are set correctly + result := outerFlag.Get().(map[string]any) + assert.Equal(t, map[string]any{"first_field": "first-value", "second_field": int64(42)}, result) + }) + + // Test setting YAML and then an inner flag + t.Run("Set YAML and then inner flag", func(t *testing.T) { + outerFlag := &Flag[map[string]any]{Name: "outer"} + + // First set the outer flag with YAML + err := outerFlag.Set(outerFlag.Name, `{existing: value, another: field}`) + assert.NoError(t, err) + + // Then set an inner flag + innerFlag := &InnerFlag[string]{ + Name: "outer.inner-flag", + OuterFlag: outerFlag, + InnerField: "new_field", + } + + err = innerFlag.Set(innerFlag.Name, "inner-value") + assert.NoError(t, err) + + // Verify both the YAML content and inner flag value + result := outerFlag.Get().(map[string]any) + assert.Equal(t, map[string]any{ + "existing": "value", + "another": "field", + "new_field": "inner-value", + }, result) + }) +} + +func TestInnerFlagWithSliceType(t *testing.T) { + t.Run("Setting inner flags on slice of maps", func(t *testing.T) { + outerFlag := &Flag[[]map[string]any]{Name: "outer"} + + // Set first inner flag (should create first item) + firstInnerFlag := &InnerFlag[string]{ + Name: "outer.name-flag", + OuterFlag: outerFlag, + InnerField: "name", + } + + err := firstInnerFlag.Set(firstInnerFlag.Name, "item1") + assert.NoError(t, err) + + // Set second inner flag (should modify first item) + secondInnerFlag := &InnerFlag[int64]{ + Name: "outer.count-flag", + OuterFlag: outerFlag, + InnerField: "count", + } + + err = secondInnerFlag.Set(secondInnerFlag.Name, "42") + assert.NoError(t, err) + + // Set name flag again (should create second item) + err = firstInnerFlag.Set(firstInnerFlag.Name, "item2") + assert.NoError(t, err) + + // Verify the slice has two items with correct values + result := outerFlag.Get().([]map[string]any) + + assert.Equal(t, []map[string]any{ + {"name": "item1", "count": int64(42)}, + {"name": "item2"}, + }, result) + assert.Nil(t, result[1]["count"], "Second item should not have count field") + }) + + t.Run("Appending to existing slice", func(t *testing.T) { + // Initialize with existing items + outerFlag := &Flag[[]map[string]any]{Name: "outer"} + err := outerFlag.Set(outerFlag.Name, `{name: initial}`) + assert.NoError(t, err) + + // Set inner flag to modify existing item + modifyFlag := &InnerFlag[string]{ + Name: "outer.value-flag", + OuterFlag: outerFlag, + InnerField: "value", + } + + err = modifyFlag.Set(modifyFlag.Name, "updated") + assert.NoError(t, err) + + // Set inner flag to create new item + newItemFlag := &InnerFlag[string]{ + Name: "outer.name-flag", + OuterFlag: outerFlag, + InnerField: "name", + } + + err = newItemFlag.Set(newItemFlag.Name, "second") + assert.NoError(t, err) + + // Verify both items + result := outerFlag.Get().([]map[string]any) + assert.Equal(t, []map[string]any{ + {"name": "initial", "value": "updated"}, + {"name": "second"}, + }, result) + }) +} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go new file mode 100644 index 0000000..cb11412 --- /dev/null +++ b/internal/requestflag/requestflag.go @@ -0,0 +1,671 @@ +package requestflag + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "time" + "unicode" + + "github.com/goccy/go-yaml" + "github.com/urfave/cli/v3" +) + +// Flag [T] is a generic flag base which can be used to implement the most +// common interfaces used by urfave/cli. Additionally, it allows specifying +// where in an HTTP request the flag values should be placed (e.g. query, body, etc.). +type Flag[ + T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | + []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | + string | float64 | int64 | bool, +] struct { + Name string // name of the flag + Category string // category of the flag, if any + DefaultText string // default text of the flag for usage purposes + HideDefault bool // whether to hide the default value in output + Usage string // usage string for help output + Sources cli.ValueSourceChain // sources to load flag value from + Required bool // whether the flag is required or not + Hidden bool // whether to hide the flag in help output + Default T // default value for this flag if not set by from any source + Aliases []string // aliases that are allowed for this flag + Validator func(T) error // custom function to validate this flag value + + QueryPath string // location in the request query string to put this flag's value + HeaderPath string // location in the request header to put this flag's value + BodyPath string // location in the request body to put this flag's value + BodyRoot bool // if true, then use this value as the entire request body + + // unexported fields for internal use + count int // number of times the flag has been set + hasBeenSet bool // whether the flag has been set from env or file + applied bool // whether the flag has been applied to a flag set already + value cli.Value // value representing this flag's value +} + +// Type assertions to verify we implement the relevant urfave/cli interfaces +var _ cli.CategorizableFlag = (*Flag[any])(nil) + +// InRequest interface for flags that should be included in HTTP requests +type InRequest interface { + GetQueryPath() string + GetHeaderPath() string + GetBodyPath() string + IsBodyRoot() bool +} + +func (f Flag[T]) GetQueryPath() string { + return f.QueryPath +} + +func (f Flag[T]) GetHeaderPath() string { + return f.HeaderPath +} + +func (f Flag[T]) GetBodyPath() string { + return f.BodyPath +} + +func (f Flag[T]) IsBodyRoot() bool { + return f.BodyRoot +} + +// The values that will be sent in different parts of a request. +type RequestContents struct { + Queries map[string]any + Headers map[string]any + Body any +} + +// Extract query parameters, headers, and body values from command flags. +func ExtractRequestContents(cmd *cli.Command) RequestContents { + bodyMap := make(map[string]any) + res := RequestContents{ + Queries: make(map[string]any), + Headers: make(map[string]any), + Body: bodyMap, + } + + for _, flag := range cmd.Flags { + if !flag.IsSet() { + continue + } + + value := flag.Get() + if toSend, ok := flag.(InRequest); ok { + if queryPath := toSend.GetQueryPath(); queryPath != "" { + res.Queries[queryPath] = value + } + if headerPath := toSend.GetHeaderPath(); headerPath != "" { + res.Headers[headerPath] = value + } + if toSend.IsBodyRoot() { + res.Body = value + } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { + bodyMap[bodyPath] = value + } + } + } + return res +} + +// Implementation of the cli.Flag interface +var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) PreParse() error { + newVal := f.Default + f.value = &cliValue[T]{newVal} + + // Validate the given default or values set from external sources as well + if f.Validator != nil { + if err := f.Validator(f.value.Get().(T)); err != nil { + return err + } + } + f.applied = true + return nil +} + +func (f *Flag[T]) PostParse() error { + if !f.hasBeenSet { + if val, source, found := f.Sources.LookupWithSource(); found { + if val != "" || reflect.TypeOf(f.value).Kind() == reflect.String { + if err := f.Set(f.Name, val); err != nil { + return fmt.Errorf( + "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", + val, f.value, source, f.Name, err, + ) + } + } else if val == "" && reflect.TypeOf(f.value).Kind() == reflect.Bool { + _ = f.Set(f.Name, "false") + } + + f.hasBeenSet = true + } + } + return nil +} + +func (f *Flag[T]) Set(name string, val string) error { + // Initialize flag if needed + if !f.applied { + if err := f.PreParse(); err != nil { + return err + } + f.applied = true + } + + f.count++ + + // If this is the first time setting a slice type, reset it to empty + // to avoid appending to the default value + if f.count == 1 && f.value != nil { + typ := reflect.TypeOf(f.Default) + if typ != nil && typ.Kind() == reflect.Slice { + // Create a new empty slice of the same type and set it + emptySlice := reflect.MakeSlice(typ, 0, 0).Interface() + f.value = &cliValue[T]{emptySlice.(T)} + } + } + + if err := f.value.Set(val); err != nil { + return err + } + + f.hasBeenSet = true + + if f.Validator != nil { + if err := f.Validator(f.value.Get().(T)); err != nil { + return err + } + } + return nil +} + +func (f *Flag[T]) Get() any { + if f.value != nil { + return f.value.Get() + } + return f.Default +} + +func (f *Flag[T]) String() string { + return cli.FlagStringer(f) +} + +func (f *Flag[T]) IsSet() bool { + return f.hasBeenSet +} + +func (f *Flag[T]) Names() []string { + return cli.FlagNames(f.Name, f.Aliases) +} + +// Implementation for the cli.VisibleFlag interface +var _ cli.VisibleFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) IsVisible() bool { + return !f.Hidden +} + +func (f *Flag[T]) GetCategory() string { + return f.Category +} + +func (f *Flag[T]) SetCategory(c string) { + f.Category = c +} + +// Implementation for the cli.RequiredFlag interface +var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) IsRequired() bool { + return f.Required +} + +// Implementation for the cli.DocGenerationFlag interface +var _ cli.DocGenerationFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) TakesValue() bool { + var t T + return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool +} + +func (f *Flag[T]) GetUsage() string { + return f.Usage +} + +func (f *Flag[T]) GetValue() string { + if f.value == nil { + return "" + } + return f.value.String() +} + +func (f *Flag[T]) GetDefaultText() string { + return f.DefaultText +} + +// GetEnvVars returns the env vars for this flag +func (f *Flag[T]) GetEnvVars() []string { + return f.Sources.EnvKeys() +} + +func (f *Flag[T]) IsDefaultVisible() bool { + return !f.HideDefault +} + +func (f *Flag[T]) TypeName() string { + ty := reflect.TypeOf(f.Default) + if ty == nil { + return "" + } + + // Get base type name with special handling for built-in types + getTypeName := func(t reflect.Type) string { + switch t.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "int" + case reflect.Float32, reflect.Float64: + return "float" + case reflect.Bool: + return "boolean" + case reflect.String: + switch t.Name() { + case "DateTimeValue": + return "datetime" + case "DateValue": + return "date" + case "TimeValue": + return "time" + default: + return "string" + } + default: + if t.Name() == "" { + return "any" + } + return strings.ToLower(t.Name()) + } + } + + switch ty.Kind() { + case reflect.Slice: + elemType := ty.Elem() + return getTypeName(elemType) + case reflect.Map: + keyType := ty.Key() + valueType := ty.Elem() + return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) + default: + return getTypeName(ty) + } +} + +// Implementation for the cli.DocGenerationMultiValueFlag interface +var _ cli.DocGenerationMultiValueFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) IsMultiValueFlag() bool { + if reflect.TypeOf(f.Default) == nil { + return false + } + kind := reflect.TypeOf(f.Default).Kind() + return kind == reflect.Slice || kind == reflect.Map +} + +func (f *Flag[T]) IsBoolFlag() bool { + _, isBool := any(f.Default).(bool) + return isBool +} + +// Implementation for the cli.Countable interface +var _ cli.Countable = (*Flag[any])(nil) // Type assertion to ensure interface compliance + +func (f *Flag[T]) Count() int { + return f.count +} + +// cliValue is a generic implementation of cli.Value for common types +type cliValue[ + T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | + []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | + float64 | int64 | bool, +] struct { + value T +} + +// Take an argument string for a single argument and convert it into a typed +// value for one of the supported CLI argument types +func parseCLIArg[ + T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | + []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | + float64 | int64 | bool, +](value string) (T, error) { + var parsedValue any + var err error + + var empty T + switch any(empty).(type) { + case string: + parsedValue = value + case int64: + parsedValue, err = strconv.ParseInt(value, 0, 64) + case float64: + parsedValue, err = strconv.ParseFloat(value, 64) + case bool: + parsedValue, err = strconv.ParseBool(value) + case DateTimeValue: + var dt DateTimeValue + err = (&dt).Parse(value) + if err == nil { + parsedValue = dt + } + + case DateValue: + var d DateValue + err = (&d).Parse(value) + if err == nil { + parsedValue = d + } + + case TimeValue: + var t TimeValue + err = (&t).Parse(value) + if err == nil { + parsedValue = t + } + + default: + if strings.HasPrefix(value, "@") { + // File literals like @file.txt should work here + parsedValue = value + } else { + var yamlValue T + err = yaml.Unmarshal([]byte(value), &yamlValue) + if err == nil { + parsedValue = yamlValue + } else if allowAsLiteralString(value) { + parsedValue = value + } else { + parsedValue = nil + err = fmt.Errorf("failed to parse as YAML: %w", err) + } + } + } + + // Nil needs to be handled specially because unmarshalling a YAML `null` + // causes problems when doing type assertions. + if parsedValue == nil { + parsedValue = (*struct{})(nil) + } + + if err == nil { + if typedValue, ok := parsedValue.(T); ok { + return typedValue, nil + } else { + expectedType := reflect.TypeFor[T]() + err = fmt.Errorf("Couldn't convert %q (%v) to expected type %v", value, parsedValue, expectedType) + } + } + return empty, err + +} + +// Assuming this string failed to parse as valid YAML, this function will +// return true for strings that can reasonably be interpreted as a string literal, +// like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`), +// base64 (`aGVsbG8=`), and qualified identifiers (`color.Red`). This should +// not include strings that look like mistyped YAML (e.g. `{key:`) +func allowAsLiteralString(s string) bool { + for _, c := range s { + if !unicode.IsLetter(c) && !unicode.IsDigit(c) && + c != '_' && c != '-' && c != '.' && c != '=' { + return false + } + } + return true +} + +// Parse the input string and set result as the cliValue's value +func (c *cliValue[T]) Set(value string) error { + valueType := reflect.TypeOf(c.value) + // When setting slice values, we append to the existing values + // e.g. --foo 10 --foo 20 --foo 30 => [10, 20, 30] + if valueType != nil && valueType.Kind() == reflect.Slice { + elemType := valueType.Elem() + + var singleElem any + var err error + switch elemType.Kind() { + case reflect.String: + singleElem, err = parseCLIArg[string](value) + case reflect.Int64: + singleElem, err = parseCLIArg[int64](value) + case reflect.Float64: + singleElem, err = parseCLIArg[float64](value) + case reflect.Bool: + singleElem, err = parseCLIArg[bool](value) + default: + // Check for special types by name + switch elemType.Name() { + case "DateTimeValue": + singleElem, err = parseCLIArg[DateTimeValue](value) + case "DateValue": + singleElem, err = parseCLIArg[DateValue](value) + case "TimeValue": + singleElem, err = parseCLIArg[TimeValue](value) + default: + // This handles []map[string]any + if elemType.Kind() == reflect.Map && elemType.Key().Kind() == reflect.String { + singleElem, err = parseCLIArg[map[string]any](value) + } else { + singleElem, err = parseCLIArg[any](value) + } + } + } + + if err != nil { + return err + } + + // Append the new element to the slice + sliceValue := reflect.ValueOf(c.value) + if !sliceValue.IsValid() || sliceValue.IsNil() { + // Create a new slice if the current one is nil + sliceValue = reflect.MakeSlice(valueType, 0, 1) + } + + // Append the new element + newElem := reflect.ValueOf(singleElem) + sliceValue = reflect.Append(sliceValue, newElem) + + // Set the updated slice back to c.value + c.value = sliceValue.Interface().(T) + } else { + // For non-slice types, simply parse and set the value + if parsedValue, err := parseCLIArg[T](value); err != nil { + return err + } else { + c.value = parsedValue + } + } + + return nil +} + +func (c *cliValue[T]) Get() any { + return c.value +} + +func (c *cliValue[T]) String() string { + switch v := any(c.value).(type) { + case string, int, int64, float64, bool, DateTimeValue, DateValue, TimeValue, + []string, []int, []int64, []float64, []bool, []DateTimeValue, []DateValue, []TimeValue: + // For basic types, use standard string representation + return fmt.Sprintf("%v", v) + + default: + // For complex types, convert to YAML + yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) + if err != nil { + // Fall back to standard format if YAML conversion fails + return fmt.Sprintf("%v", c.value) + } + return string(yamlBytes) + } +} + +func (c *cliValue[T]) IsBoolFlag() bool { + _, ok := any(c.value).(bool) + return ok +} + +// Time-related value types +type DateValue string +type DateTimeValue string +type TimeValue string + +// String methods for time-related types +func (d DateValue) String() string { + return string(d) +} + +func (d DateTimeValue) String() string { + return string(d) +} + +func (t TimeValue) String() string { + return string(t) +} + +// parseTimeWithFormats attempts to parse a string using multiple formats +func parseTimeWithFormats(s string, formats []string) (time.Time, error) { + var lastErr error + for _, format := range formats { + t, err := time.Parse(format, s) + if err == nil { + return t, nil + } + lastErr = err + } + return time.Time{}, lastErr +} + +// Parse methods for time-related types +func (d *DateValue) Parse(s string) error { + formats := []string{ + "2006-01-02", + "01/02/2006", + "Jan 2, 2006", + "January 2, 2006", + "2-Jan-2006", + } + + t, err := parseTimeWithFormats(s, formats) + if err != nil { + return fmt.Errorf("unable to parse date: %v", err) + } + + *d = DateValue(t.Format("2006-01-02")) + return nil +} + +func (d *DateTimeValue) Parse(s string) error { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + time.RFC1123, + time.RFC822, + time.ANSIC, + } + + t, err := parseTimeWithFormats(s, formats) + if err != nil { + return fmt.Errorf("unable to parse datetime: %v", err) + } + + *d = DateTimeValue(t.Format(time.RFC3339)) + return nil +} + +func (t *TimeValue) Parse(s string) error { + formats := []string{ + "15:04:05", + "15:04:05.999999999Z07:00", + "3:04:05PM", + "3:04 PM", + "15:04", + time.Kitchen, + } + + parsedTime, err := parseTimeWithFormats(s, formats) + if err != nil { + return fmt.Errorf("unable to parse time: %v", err) + } + + *t = TimeValue(parsedTime.Format("15:04:05")) + return nil +} + +// Allow setting inner fields on other flags (e.g. --foo.baz can set the "baz" +// field on the --foo flag) +type SettableInnerField interface { + SetInnerField(string, any) +} + +func (f *Flag[T]) SetInnerField(field string, val any) { + if f.value == nil { + f.value = &cliValue[T]{} + } + + if settableInnerField, ok := f.value.(SettableInnerField); ok { + settableInnerField.SetInnerField(field, val) + f.hasBeenSet = true + } else { + panic(fmt.Sprintf("Cannot set inner field: %v", f.value)) + } +} + +func (c *cliValue[T]) SetInnerField(field string, val any) { + flagVal := c.value + flagValReflect := reflect.ValueOf(flagVal) + switch flagValReflect.Kind() { + case reflect.Slice: + if flagValReflect.Type().Elem().Kind() != reflect.Map { + return + } + + sliceLen := flagValReflect.Len() + if sliceLen > 0 { + // Check if the last element already has the InnerField + lastElement := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any) + if _, hasInnerField := lastElement[field]; !hasInnerField { + // Last element doesn't have the field, set it + lastElement[field] = val + return + } + } + + // Create a new map and append it to the slice + newMap := map[string]any{field: val} + switch sliceVal := any(c.value).(type) { + case []map[string]any: + c.value = any(append(sliceVal, newMap)).(T) + case []any: + c.value = any(append(sliceVal, newMap)).(T) + } + + case reflect.Map: + mapVal, ok := any(flagVal).(map[string]any) + if !ok || mapVal == nil { + mapVal = map[string]any{field: val} + c.value = any(mapVal).(T) + } else { + mapVal[field] = val + } + } +} diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go new file mode 100644 index 0000000..9751904 --- /dev/null +++ b/internal/requestflag/requestflag_test.go @@ -0,0 +1,590 @@ +package requestflag + +import ( + "fmt" + "testing" + "time" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestDateValueParse(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "ISO format", + input: "2023-05-15", + want: "2023-05-15", + wantErr: false, + }, + { + name: "US format", + input: "05/15/2023", + want: "2023-05-15", + wantErr: false, + }, + { + name: "Short month format", + input: "May 15, 2023", + want: "2023-05-15", + wantErr: false, + }, + { + name: "Long month format", + input: "January 15, 2023", + want: "2023-01-15", + wantErr: false, + }, + { + name: "British format", + input: "15-Jan-2023", + want: "2023-01-15", + wantErr: false, + }, + { + name: "Invalid format", + input: "not a date", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d DateValue + err := d.Parse(tt.input) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, d.String()) + } + }) + } +} + +func TestDateTimeValueParse(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "RFC3339", + input: "2023-05-15T14:30:45Z", + wantErr: false, + }, + { + name: "ISO with timezone", + input: "2023-05-15T14:30:45+02:00", + wantErr: false, + }, + { + name: "ISO without timezone", + input: "2023-05-15T14:30:45", + wantErr: false, + }, + { + name: "Space separated", + input: "2023-05-15 14:30:45", + wantErr: false, + }, + { + name: "RFC1123", + input: "Mon, 15 May 2023 14:30:45 GMT", + wantErr: false, + }, + { + name: "RFC822", + input: "15 May 23 14:30 GMT", + wantErr: false, + }, + { + name: "ANSIC", + input: "Mon Jan 2 15:04:05 2006", + wantErr: false, + }, + { + name: "Invalid format", + input: "not a datetime", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d DateTimeValue + err := d.Parse(tt.input) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + // Parse the string back to ensure it's valid RFC3339 + _, parseErr := time.Parse(time.RFC3339, d.String()) + assert.NoError(t, parseErr) + } + }) + } +} + +func TestTimeValueParse(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "24-hour format", + input: "14:30:45", + want: "14:30:45", + wantErr: false, + }, + { + name: "12-hour format with seconds", + input: "2:30:45PM", + want: "14:30:45", + wantErr: false, + }, + { + name: "12-hour format without seconds", + input: "2:30 PM", + want: "14:30:00", + wantErr: false, + }, + { + name: "24-hour without seconds", + input: "14:30", + want: "14:30:00", + wantErr: false, + }, + { + name: "Kitchen format", + input: "2:30PM", + want: "14:30:00", + wantErr: false, + }, + { + name: "Invalid format", + input: "not a time", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tv TimeValue + err := tv.Parse(tt.input) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, tv.String()) + } + }) + } +} + +func TestRequestParams(t *testing.T) { + t.Run("map body type", func(t *testing.T) { + // Create a mock command with flags + cmd := &cli.Command{ + Name: "test", + } + + // Create string flag with body path + stringFlag := &Flag[string]{ + Name: "string-flag", + Default: "default-string", + BodyPath: "string_field", + value: &cliValue[string]{value: "test-value"}, + hasBeenSet: true, + } + + // Create int flag with header path + intFlag := &Flag[int64]{ + Name: "int-flag", + Default: 42, + HeaderPath: "X-Int-Value", + value: &cliValue[int64]{value: 99}, + hasBeenSet: true, + } + + // Create bool flag with query path + boolFlag := &Flag[bool]{ + Name: "bool-flag", + Default: false, + QueryPath: "include_details", + value: &cliValue[bool]{value: true}, + hasBeenSet: true, + } + + // Create date flag with multiple paths + dateFlag := &Flag[DateValue]{ + Name: "date-flag", + Default: DateValue("2023-01-01"), + BodyPath: "effective_date", + HeaderPath: "X-Effective-Date", + QueryPath: "as_of_date", + value: &cliValue[DateValue]{value: DateValue("2023-05-15")}, + hasBeenSet: true, + } + + // Create flag with no path + noPathFlag := &Flag[string]{ + Name: "no-path-flag", + Default: "no-path", + value: &cliValue[string]{value: "no-path-value"}, + hasBeenSet: true, + } + + // Create unset flag + unsetFlag := &Flag[string]{ + Name: "unset-flag", + Default: "unset", + BodyPath: "should_not_appear", + value: &cliValue[string]{value: "unset-value"}, + hasBeenSet: false, + } + + cmd.Flags = []cli.Flag{stringFlag, intFlag, boolFlag, dateFlag, noPathFlag, unsetFlag} + + // Test the RequestParams function + contents := ExtractRequestContents(cmd) + + // Verify query parameters + assert.Equal(t, true, contents.Queries["include_details"]) + assert.Equal(t, DateValue("2023-05-15"), contents.Queries["as_of_date"]) + assert.Len(t, contents.Queries, 2) + + // Verify headers + assert.Equal(t, int64(99), contents.Headers["X-Int-Value"]) + assert.Equal(t, DateValue("2023-05-15"), contents.Headers["X-Effective-Date"]) + assert.Len(t, contents.Headers, 2) + + // Verify body + bodyMap, ok := contents.Body.(map[string]any) + assert.True(t, ok, "Expected body to be map[string]any, got %T", contents.Body) + assert.Equal(t, "test-value", bodyMap["string_field"]) + assert.Equal(t, DateValue("2023-05-15"), bodyMap["effective_date"]) + assert.Len(t, bodyMap, 2) + + // Verify the unset flag didn't make it into the maps + assert.NotContains(t, contents.Body, "should_not_appear") + }) + + t.Run("non-map body type", func(t *testing.T) { + // Create a mock command with flags + cmd := &cli.Command{ + Name: "test", + Flags: []cli.Flag{ + &Flag[int64]{ + Name: "int-body-flag", + Default: 0, + BodyRoot: true, + }, + }, + } + cmd.Set("int-body-flag", "42") + + contents := ExtractRequestContents(cmd) + intBody, ok := contents.Body.(int64) + assert.True(t, ok, "Expected body to be int64, got %T", contents.Body) + assert.Equal(t, int64(42), intBody) + }) +} + +func TestFlagSet(t *testing.T) { + strFlag := &Flag[string]{ + Name: "string-flag", + Default: "default-string", + } + + superstitiousIntFlag := &Flag[int64]{ + Name: "int-flag", + Default: 42, + Validator: func(val int64) error { + if val == 13 { + return fmt.Errorf("Unlucky number!") + } + return nil + }, + } + + boolFlag := &Flag[bool]{ + Name: "bool-flag", + Default: false, + } + + // Test initialization and setting + t.Run("PreParse initialization", func(t *testing.T) { + assert.NoError(t, strFlag.PreParse()) + assert.True(t, strFlag.applied) + assert.Equal(t, "default-string", strFlag.Get()) + }) + + t.Run("Set string flag", func(t *testing.T) { + assert.NoError(t, strFlag.Set("string-flag", "new-value")) + assert.Equal(t, "new-value", strFlag.Get()) + assert.True(t, strFlag.IsSet()) + }) + + t.Run("Set int flag with valid value", func(t *testing.T) { + assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) + assert.Equal(t, int64(100), superstitiousIntFlag.Get()) + assert.True(t, superstitiousIntFlag.IsSet()) + }) + + t.Run("Set int flag with invalid value", func(t *testing.T) { + assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) + }) + + t.Run("Set int flag with validator failing", func(t *testing.T) { + assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) + }) + + t.Run("Set bool flag", func(t *testing.T) { + assert.NoError(t, boolFlag.Set("bool-flag", "true")) + assert.Equal(t, true, boolFlag.Get()) + assert.True(t, boolFlag.IsSet()) + }) + + t.Run("Set slice flag with multiple values", func(t *testing.T) { + sliceFlag := &Flag[[]int64]{ + Name: "slice-flag", + Default: []int64{}, + } + + // Initialize the flag + assert.NoError(t, sliceFlag.PreParse()) + + // First set + assert.NoError(t, sliceFlag.Set("slice-flag", "10")) + + // Subsequent setting should append, not replace + assert.NoError(t, sliceFlag.Set("slice-flag", "20")) + assert.NoError(t, sliceFlag.Set("slice-flag", "30")) + + // Verify that we have both values in the slice + result := sliceFlag.Get() + assert.Equal(t, []int64{10, 20, 30}, result) + assert.True(t, sliceFlag.IsSet()) + }) + + t.Run("Set slice flag with a nonempty default", func(t *testing.T) { + sliceFlag := &Flag[[]int64]{ + Name: "slice-flag", + Default: []int64{99, 100}, + } + + assert.NoError(t, sliceFlag.PreParse()) + assert.NoError(t, sliceFlag.Set("slice-flag", "10")) + assert.NoError(t, sliceFlag.Set("slice-flag", "20")) + assert.NoError(t, sliceFlag.Set("slice-flag", "30")) + + // Verify that we have clobbered the default value instead of appending + // to it. + result := sliceFlag.Get() + assert.Equal(t, []int64{10, 20, 30}, result) + assert.True(t, sliceFlag.IsSet()) + }) +} + +func TestParseTimeWithFormats(t *testing.T) { + tests := []struct { + name string + input string + formats []string + wantTime time.Time + wantErr bool + }{ + { + name: "RFC3339 format", + input: "2023-05-15T14:30:45Z", + formats: []string{time.RFC3339}, + wantTime: time.Date(2023, 5, 15, 14, 30, 45, 0, time.UTC), + wantErr: false, + }, + { + name: "Multiple formats - first matches", + input: "2023-05-15", + formats: []string{"2006-01-02", time.RFC3339}, + wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "Multiple formats - second matches", + input: "15/05/2023", + formats: []string{"2006-01-02", "02/01/2006"}, + wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "No matching format", + input: "not a date", + formats: []string{"2006-01-02", time.RFC3339}, + wantTime: time.Time{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTimeWithFormats(tt.input, tt.formats) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.True(t, tt.wantTime.Equal(got), "Expected %v, got %v", tt.wantTime, got) + } + }) + } +} + +func TestYamlHandling(t *testing.T) { + // Test with any value + t.Run("Parse YAML to any", func(t *testing.T) { + cv := &cliValue[any]{} + err := cv.Set("name: test\nvalue: 42\n") + assert.NoError(t, err) + + // The value should be a map + val, ok := cv.Get().(map[string]any) + assert.True(t, ok, "Expected map[string]any, got %T", cv.Get()) + + if ok { + assert.Equal(t, "test", val["name"]) + assert.Equal(t, uint64(42), val["value"]) + } + + // The string representation should be valid YAML + strVal := cv.String() + var parsed map[string]any + err = yaml.Unmarshal([]byte(strVal), &parsed) + assert.NoError(t, err) + assert.Equal(t, "test", parsed["name"]) + assert.Equal(t, uint64(42), parsed["value"]) + }) + + // Test with array + t.Run("Parse YAML array", func(t *testing.T) { + cv := &cliValue[any]{} + err := cv.Set("- item1\n- item2\n- item3\n") + assert.NoError(t, err) + + // The value should be a slice + val, ok := cv.Get().([]any) + assert.True(t, ok, "Expected []any, got %T", cv.Get()) + + if ok { + assert.Len(t, val, 3) + assert.Equal(t, "item1", val[0]) + assert.Equal(t, "item2", val[1]) + assert.Equal(t, "item3", val[2]) + } + }) + + t.Run("Parse @file.txt as YAML", func(t *testing.T) { + flag := &Flag[any]{ + Name: "file-flag", + Default: nil, + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("file-flag", "@file.txt")) + + val := flag.Get() + assert.Equal(t, "@file.txt", val) + }) + + t.Run("Parse @file.txt list as YAML", func(t *testing.T) { + flag := &Flag[[]any]{ + Name: "file-flag", + Default: nil, + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("file-flag", "@file1.txt")) + assert.NoError(t, flag.Set("file-flag", "@file2.txt")) + + val := flag.Get() + assert.Equal(t, []any{"@file1.txt", "@file2.txt"}, val) + }) + + t.Run("Parse identifiers as YAML", func(t *testing.T) { + tests := []string{ + "hello", + "e4e355fa-b03b-4c57-a73d-25c9733eec79", + "foo_bar", + "Color.Red", + "aGVsbG8=", + } + for _, test := range tests { + flag := &Flag[any]{ + Name: "flag", + Default: nil, + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("flag", test)) + + val := flag.Get() + assert.Equal(t, test, val) + } + + for _, test := range tests { + flag := &Flag[[]any]{ + Name: "identifier", + Default: nil, + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("identifier", test)) + assert.NoError(t, flag.Set("identifier", test)) + + val := flag.Get() + assert.Equal(t, []any{test, test}, val) + } + }) + + // Test with invalid YAML + t.Run("Parse invalid YAML", func(t *testing.T) { + invalidYaml := `[not closed` + cv := &cliValue[any]{} + err := cv.Set(invalidYaml) + assert.Error(t, err) + }) +} + +func TestFlagTypeNames(t *testing.T) { + tests := []struct { + name string + flag cli.DocGenerationFlag + expected string + }{ + {"string", &Flag[string]{}, "string"}, + {"int64", &Flag[int64]{}, "int"}, + {"float64", &Flag[float64]{}, "float"}, + {"bool", &Flag[bool]{}, "boolean"}, + {"string slice", &Flag[[]string]{}, "string"}, + {"date", &Flag[DateValue]{}, "date"}, + {"datetime", &Flag[DateTimeValue]{}, "datetime"}, + {"time", &Flag[TimeValue]{}, "time"}, + {"date slice", &Flag[[]DateValue]{}, "date"}, + {"datetime slice", &Flag[[]DateTimeValue]{}, "datetime"}, + {"time slice", &Flag[[]TimeValue]{}, "time"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typeName := tt.flag.TypeName() + assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) + }) + } +} diff --git a/pkg/cmd/apikey.go b/pkg/cmd/apikey.go new file mode 100644 index 0000000..2539eb1 --- /dev/null +++ b/pkg/cmd/apikey.go @@ -0,0 +1,160 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var apiKeysCreate = cli.Command{ + Name: "create", + Usage: "Create API Key", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "name", + Usage: "Name of api key.", + Required: true, + BodyPath: "name", + }, + }, + Action: handleAPIKeysCreate, + HideHelpCommand: true, +} + +var apiKeysList = cli.Command{ + Name: "list", + Usage: "List API Keys", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleAPIKeysList, + HideHelpCommand: true, +} + +var apiKeysDelete = cli.Command{ + Name: "delete", + Usage: "Delete API Key", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "api-key", + Usage: "ID of api key.", + Required: true, + }, + }, + Action: handleAPIKeysDelete, + HideHelpCommand: true, +} + +func handleAPIKeysCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.APIKeyNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.APIKeys.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "api-keys create", obj, format, transform) +} + +func handleAPIKeysList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.APIKeyListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.APIKeys.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "api-keys list", obj, format, transform) +} + +func handleAPIKeysDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("api-key") && len(unusedArgs) > 0 { + cmd.Set("api-key", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.APIKeys.Delete(ctx, cmd.Value("api-key").(string), options...) +} diff --git a/pkg/cmd/apikey_test.go b/pkg/cmd/apikey_test.go new file mode 100644 index 0000000..e249333 --- /dev/null +++ b/pkg/cmd/apikey_test.go @@ -0,0 +1,40 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestAPIKeysCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "api-keys", "create", + "--api-key", "string", + "--name", "name", + ) +} + +func TestAPIKeysList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "api-keys", "list", + "--api-key", "string", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestAPIKeysDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "api-keys", "delete", + "--api-key", "string", + "--api-key", "api_key", + ) +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go new file mode 100644 index 0000000..0cbbe99 --- /dev/null +++ b/pkg/cmd/cmd.go @@ -0,0 +1,334 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "compress/gzip" + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/agentmail-to/agentmail-cli/internal/autocomplete" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + docs "github.com/urfave/cli-docs/v3" + "github.com/urfave/cli/v3" +) + +var ( + Command *cli.Command +) + +func init() { + Command = &cli.Command{ + Name: "agentmail", + Usage: "CLI for the agentmail API", + Suggest: true, + Version: Version, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "Enable debug logging", + }, + &cli.StringFlag{ + Name: "base-url", + DefaultText: "url", + Usage: "Override the base URL for API requests", + }, + &cli.StringFlag{ + Name: "format", + Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", + Value: "auto", + Validator: func(format string) error { + if !slices.Contains(OutputFormats, strings.ToLower(format)) { + return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) + } + return nil + }, + }, + &cli.StringFlag{ + Name: "format-error", + Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", + Value: "auto", + Validator: func(format string) error { + if !slices.Contains(OutputFormats, strings.ToLower(format)) { + return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) + } + return nil + }, + }, + &cli.StringFlag{ + Name: "transform", + Usage: "The GJSON transformation for data output.", + }, + &cli.StringFlag{ + Name: "transform-error", + Usage: "The GJSON transformation for errors.", + }, + &requestflag.Flag[string]{ + Name: "api-key", + Sources: cli.EnvVars("AGENTMAIL_API_KEY"), + }, + &cli.StringFlag{ + Name: "environment", + Usage: "Set the environment for API requests", + }, + }, + Commands: []*cli.Command{ + { + Name: "inboxes", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &inboxesCreate, + &inboxesRetrieve, + &inboxesUpdate, + &inboxesList, + &inboxesDelete, + &inboxesListMetrics, + }, + }, + { + Name: "inboxes:drafts", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &inboxesDraftsCreate, + &inboxesDraftsRetrieve, + &inboxesDraftsUpdate, + &inboxesDraftsList, + &inboxesDraftsDelete, + &inboxesDraftsSend, + }, + }, + { + Name: "inboxes:messages", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &inboxesMessagesRetrieve, + &inboxesMessagesUpdate, + &inboxesMessagesList, + &inboxesMessagesForward, + &inboxesMessagesGetAttachment, + &inboxesMessagesGetRaw, + &inboxesMessagesReply, + &inboxesMessagesReplyAll, + &inboxesMessagesSend, + }, + }, + { + Name: "inboxes:threads", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &inboxesThreadsRetrieve, + &inboxesThreadsList, + &inboxesThreadsDelete, + &inboxesThreadsGetAttachment, + }, + }, + { + Name: "pods", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &podsCreate, + &podsRetrieve, + &podsList, + &podsDelete, + }, + }, + { + Name: "pods:domains", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &podsDomainsCreate, + &podsDomainsList, + &podsDomainsDelete, + }, + }, + { + Name: "pods:drafts", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &podsDraftsRetrieve, + &podsDraftsList, + }, + }, + { + Name: "pods:inboxes", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &podsInboxesCreate, + &podsInboxesRetrieve, + &podsInboxesList, + &podsInboxesDelete, + }, + }, + { + Name: "pods:threads", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &podsThreadsRetrieve, + &podsThreadsList, + &podsThreadsGetAttachment, + }, + }, + { + Name: "webhooks", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &webhooksCreate, + &webhooksRetrieve, + &webhooksUpdate, + &webhooksList, + &webhooksDelete, + }, + }, + { + Name: "api-keys", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &apiKeysCreate, + &apiKeysList, + &apiKeysDelete, + }, + }, + { + Name: "domains", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &domainsCreate, + &domainsRetrieve, + &domainsList, + &domainsDelete, + &domainsGetZoneFile, + &domainsVerify, + }, + }, + { + Name: "drafts", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &draftsRetrieve, + &draftsList, + }, + }, + { + Name: "metrics", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &metricsList, + }, + }, + { + Name: "organizations", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &organizationsRetrieve, + }, + }, + { + Name: "threads", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &threadsRetrieve, + &threadsList, + &threadsRetrieveAttachment, + }, + }, + { + Name: "@manpages", + Usage: "Generate documentation for 'man'", + UsageText: "agentmail @manpages [-o agentmail.1] [--gzip]", + Hidden: true, + Action: generateManpages, + HideHelpCommand: true, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "write manpages to the given folder", + Value: "man", + }, + &cli.BoolFlag{ + Name: "gzip", + Aliases: []string{"z"}, + Usage: "output gzipped manpage files to .gz", + Value: true, + }, + &cli.BoolFlag{ + Name: "text", + Aliases: []string{"z"}, + Usage: "output uncompressed text files", + Value: false, + }, + }, + }, + { + Name: "__complete", + Hidden: true, + HideHelpCommand: true, + Action: autocomplete.ExecuteShellCompletion, + }, + { + Name: "@completion", + Hidden: true, + HideHelpCommand: true, + Action: autocomplete.OutputCompletionScript, + }, + }, + HideHelpCommand: true, + } +} + +func generateManpages(ctx context.Context, c *cli.Command) error { + manpage, err := docs.ToManWithSection(Command, 1) + if err != nil { + return err + } + dir := c.String("output") + err = os.MkdirAll(filepath.Join(dir, "man1"), 0755) + if err != nil { + // handle error + } + if c.Bool("text") { + file, err := os.Create(filepath.Join(dir, "man1", "agentmail.1")) + if err != nil { + return err + } + defer file.Close() + if _, err := file.WriteString(manpage); err != nil { + return err + } + } + if c.Bool("gzip") { + file, err := os.Create(filepath.Join(dir, "man1", "agentmail.1.gz")) + if err != nil { + return err + } + defer file.Close() + gzWriter := gzip.NewWriter(file) + defer gzWriter.Close() + _, err = gzWriter.Write([]byte(manpage)) + if err != nil { + return err + } + } + fmt.Printf("Wrote manpages to %s\n", dir) + return nil +} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go new file mode 100644 index 0000000..8b6cc8d --- /dev/null +++ b/pkg/cmd/cmdutil.go @@ -0,0 +1,441 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "mime" + "net/http" + "net/http/httputil" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/agentmail-to/agentmail-cli/internal/jsonview" + "github.com/agentmail-to/agentmail-go/option" + + "github.com/charmbracelet/x/term" + "github.com/itchyny/json2yaml" + "github.com/muesli/reflow/wrap" + "github.com/tidwall/gjson" + "github.com/tidwall/pretty" + "github.com/urfave/cli/v3" +) + +var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} + +func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { + opts := []option.RequestOption{ + option.WithHeader("User-Agent", fmt.Sprintf("Agentmail/CLI %s", Version)), + option.WithHeader("X-Stainless-Lang", "cli"), + option.WithHeader("X-Stainless-Package-Version", Version), + option.WithHeader("X-Stainless-Runtime", "cli"), + option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), + option.WithAPIKey(cmd.String("api-key")), + } + + // Override base URL if the --base-url flag is provided + if baseURL := cmd.String("base-url"); baseURL != "" { + opts = append(opts, option.WithBaseURL(baseURL)) + } + + // Set environment if the --environment flag is provided + if environment := cmd.String("environment"); environment != "" { + switch environment { + case "production": + opts = append(opts, option.WithEnvironmentProduction()) + case "development": + opts = append(opts, option.WithEnvironmentDevelopment()) + default: + log.Fatalf("Unknown environment: %s. Valid environments are %s", environment, "production, development") + } + } + + return opts +} + +var debugMiddlewareOption = option.WithMiddleware( + func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { + logger := log.Default() + + if reqBytes, err := httputil.DumpRequest(r, true); err == nil { + logger.Printf("Request Content:\n%s\n", reqBytes) + } + + resp, err := mn(r) + if err != nil { + return resp, err + } + + if respBytes, err := httputil.DumpResponse(resp, true); err == nil { + logger.Printf("Response Content:\n%s\n", respBytes) + } + + return resp, err + }, +) + +func isInputPiped() bool { + stat, _ := os.Stdin.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +func isTerminal(w io.Writer) bool { + switch v := w.(type) { + case *os.File: + return term.IsTerminal(v.Fd()) + default: + return false + } +} + +func streamOutput(label string, generateOutput func(w *os.File) error) error { + // For non-tty output (probably a pipe), write directly to stdout + if !isTerminal(os.Stdout) { + return streamToStdout(generateOutput) + } + + // When streaming output on Unix-like systems, there's a special trick involving creating two socket pairs + // that we prefer because it supports small buffer sizes which results in less pagination per buffer. The + // constructs needed to run it don't exist on Windows builds, so we have this function broken up into + // OS-specific files with conditional build comments. Under Windows (and in case our fancy constructs fail + // on Unix), we fall back to using pipes (`streamToPagerWithPipe`), which are OS agnostic. + // + // Defined in either cmdutil_unix.go or cmdutil_windows.go. + return streamOutputOSSpecific(label, generateOutput) +} + +func streamToPagerWithPipe(label string, generateOutput func(w *os.File) error) error { + r, w, err := os.Pipe() + if err != nil { + return err + } + defer r.Close() + defer w.Close() + + pagerProgram := os.Getenv("PAGER") + if pagerProgram == "" { + pagerProgram = "less" + } + + if _, err := exec.LookPath(pagerProgram); err != nil { + return err + } + + cmd := exec.Command(pagerProgram) + cmd.Stdin = r + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), + "LESS=-X -r -P "+label, + "MORE=-r -P "+label, + ) + + if err := cmd.Start(); err != nil { + return err + } + + if err := r.Close(); err != nil { + return err + } + + // If we would be streaming to a terminal and aren't forcing color one way + // or the other, we should configure things to use color so the pager gets + // colorized input. + if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { + os.Setenv("FORCE_COLOR", "1") + } + + if err := generateOutput(w); err != nil && !strings.Contains(err.Error(), "broken pipe") { + return err + } + + w.Close() + return cmd.Wait() +} + +func streamToStdout(generateOutput func(w *os.File) error) error { + signal.Ignore(syscall.SIGPIPE) + err := generateOutput(os.Stdout) + if err != nil && strings.Contains(err.Error(), "broken pipe") { + return nil + } + return err +} + +func writeBinaryResponse(response *http.Response, outfile string) (string, error) { + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + switch outfile { + case "-", "/dev/stdout": + _, err := os.Stdout.Write(body) + return "", err + case "": + // If output file is unspecified, then print to stdout for plain text or + // if stdout is not a terminal: + if !isTerminal(os.Stdout) || isUTF8TextFile(body) { + _, err := os.Stdout.Write(body) + return "", err + } + + // If response has a suggested filename in the content-disposition + // header, then use that (with an optional suffix to ensure uniqueness): + file, err := createDownloadFile(response, body) + if err != nil { + return "", err + } + defer file.Close() + if _, err := file.Write(body); err != nil { + return "", err + } + return fmt.Sprintf("Wrote output to: %s", file.Name()), nil + default: + if err := os.WriteFile(outfile, body, 0644); err != nil { + return "", err + } + return fmt.Sprintf("Wrote output to: %s", outfile), nil + } +} + +// Return a writable file handle to a new file, which attempts to choose a good filename +// based on the Content-Disposition header or sniffing the MIME filetype of the response. +func createDownloadFile(response *http.Response, data []byte) (*os.File, error) { + filename := "file" + // If the header provided an output filename, use that + disp := response.Header.Get("Content-Disposition") + _, params, err := mime.ParseMediaType(disp) + if err == nil { + if dispFilename, ok := params["filename"]; ok { + // Only use the last path component to prevent directory traversal + filename = filepath.Base(dispFilename) + // Try to create the file with exclusive flag to avoid race conditions + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err == nil { + return file, nil + } + } + } + + // If file already exists, create a unique filename using CreateTemp + ext := filepath.Ext(filename) + if ext == "" { + ext = guessExtension(data) + } + base := strings.TrimSuffix(filename, ext) + return os.CreateTemp(".", base+"-*"+ext) +} + +func guessExtension(data []byte) string { + ct := http.DetectContentType(data) + + // Prefer common extensions over obscure ones + switch ct { + case "application/gzip": + return ".gz" + case "application/pdf": + return ".pdf" + case "application/zip": + return ".zip" + case "audio/mpeg": + return ".mp3" + case "image/bmp": + return ".bmp" + case "image/gif": + return ".gif" + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "video/mp4": + return ".mp4" + } + + exts, err := mime.ExtensionsByType(ct) + if err == nil && len(exts) > 0 { + return exts[0] + } else if isUTF8TextFile(data) { + return ".txt" + } else { + return ".bin" + } +} + +func shouldUseColors(w io.Writer) bool { + force, ok := os.LookupEnv("FORCE_COLOR") + if ok { + if force == "1" { + return true + } + if force == "0" { + return false + } + } + return isTerminal(w) +} + +func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { + if format != "raw" && transform != "" { + transformed := res.Get(transform) + if transformed.Exists() { + res = transformed + } + } + switch strings.ToLower(format) { + case "auto": + return formatJSON(expectedOutput, title, res, "json", "") + case "pretty": + return []byte(jsonview.RenderJSON(title, res) + "\n"), nil + case "json": + prettyJSON := pretty.Pretty([]byte(res.Raw)) + if shouldUseColors(expectedOutput) { + return pretty.Color(prettyJSON, pretty.TerminalStyle), nil + } else { + return prettyJSON, nil + } + case "jsonl": + // @ugly is gjson syntax for "no whitespace", so it fits on one line + oneLineJSON := res.Get("@ugly").Raw + if shouldUseColors(expectedOutput) { + bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') + return bytes, nil + } else { + return []byte(oneLineJSON + "\n"), nil + } + case "raw": + return []byte(res.Raw + "\n"), nil + case "yaml": + input := strings.NewReader(res.Raw) + var yaml strings.Builder + if err := json2yaml.Convert(&yaml, input); err != nil { + return nil, err + } + _, err := expectedOutput.Write([]byte(yaml.String())) + return nil, err + default: + return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) + } +} + +// Display JSON to the user in various different formats +func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { + if format != "raw" && transform != "" { + transformed := res.Get(transform) + if transformed.Exists() { + res = transformed + } + } + + switch strings.ToLower(format) { + case "auto": + return ShowJSON(out, title, res, "json", "") + case "explore": + return jsonview.ExploreJSON(title, res) + default: + bytes, err := formatJSON(out, title, res, format, transform) + if err != nil { + return err + } + + _, err = out.Write(bytes) + return err + } +} + +// Get the number of lines that would be output by writing the data to the terminal +func countTerminalLines(data []byte, terminalWidth int) int { + return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) +} + +type HasRawJSON interface { + RawJSON() string +} + +// For an iterator over different value types, display its values to the user in +// different formats. +func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string) error { + if format == "explore" { + return jsonview.ExploreJSONStream(title, iter) + } + + terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + terminalWidth = 100 + terminalHeight = 40 + } + + // Decide whether or not to use a pager based on whether it's a short output or a long output + usePager := false + output := []byte{} + numberOfNewlines := 0 + for iter.Next() { + item := iter.Current() + var obj gjson.Result + if hasRaw, ok := any(item).(HasRawJSON); ok { + obj = gjson.Parse(hasRaw.RawJSON()) + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return err + } + obj = gjson.ParseBytes(jsonData) + } + json, err := formatJSON(stdout, title, obj, format, transform) + if err != nil { + return err + } + + output = append(output, json...) + numberOfNewlines += countTerminalLines(json, terminalWidth) + + // If the output won't fit in the terminal window, stream it to a pager + if numberOfNewlines >= terminalHeight-3 { + usePager = true + break + } + } + + if !usePager { + _, err := stdout.Write(output) + if err != nil { + return err + } + + return iter.Err() + } + + return streamOutput(title, func(pager *os.File) error { + // Write the output we used during the initial terminal size computation + _, err := pager.Write(output) + if err != nil { + return err + } + + for iter.Next() { + item := iter.Current() + var obj gjson.Result + if hasRaw, ok := any(item).(HasRawJSON); ok { + obj = gjson.Parse(hasRaw.RawJSON()) + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return err + } + obj = gjson.ParseBytes(jsonData) + } + if err := ShowJSON(pager, title, obj, format, transform); err != nil { + return err + } + } + return iter.Err() + }) +} diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go new file mode 100644 index 0000000..0a46fd1 --- /dev/null +++ b/pkg/cmd/cmdutil_test.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStreamOutput(t *testing.T) { + t.Setenv("PAGER", "cat") + err := streamOutput("stream test", func(w *os.File) error { + _, writeErr := w.WriteString("Hello world\n") + return writeErr + }) + if err != nil { + t.Errorf("streamOutput failed: %v", err) + } +} + +func TestWriteBinaryResponse(t *testing.T) { + t.Run("write to explicit file", func(t *testing.T) { + tmpDir := t.TempDir() + outfile := tmpDir + "/output.txt" + body := []byte("test content") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(body)), + } + + msg, err := writeBinaryResponse(resp, outfile) + + require.NoError(t, err) + assert.Contains(t, msg, outfile) + + content, err := os.ReadFile(outfile) + require.NoError(t, err) + assert.Equal(t, body, content) + }) + + t.Run("write to stdout", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + body := []byte("stdout content") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(body)), + } + msg, err := writeBinaryResponse(resp, "-") + + w.Close() + os.Stdout = oldStdout + + require.NoError(t, err) + assert.Empty(t, msg) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + assert.Equal(t, body, buf.Bytes()) + }) +} + +func TestCreateDownloadFile(t *testing.T) { + t.Run("creates file with filename from header", func(t *testing.T) { + tmpDir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="test.txt"`}, + }, + } + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Equal(t, "test.txt", filepath.Base(file.Name())) + + // Create a second file with the same name to ensure it doesn't clobber the first + resp2 := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="test.txt"`}, + }, + } + file2, err := createDownloadFile(resp2, []byte("second content")) + require.NoError(t, err) + defer file2.Close() + assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name") + assert.Contains(t, filepath.Base(file2.Name()), "test") + }) + + t.Run("creates temp file when no header", func(t *testing.T) { + tmpDir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + resp := &http.Response{Header: http.Header{}} + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Contains(t, filepath.Base(file.Name()), "file-") + }) + + t.Run("prevents directory traversal", func(t *testing.T) { + tmpDir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`}, + }, + } + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Equal(t, "passwd", filepath.Base(file.Name())) + }) +} diff --git a/pkg/cmd/cmdutil_unix.go b/pkg/cmd/cmdutil_unix.go new file mode 100644 index 0000000..f4a0e5c --- /dev/null +++ b/pkg/cmd/cmdutil_unix.go @@ -0,0 +1,116 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { + // Try to use socket pair for better buffer control + pagerInput, pid, err := openSocketPairPager(label) + if err != nil || pagerInput == nil { + // Fall back to pipe if socket setup fails + return streamToPagerWithPipe(label, generateOutput) + } + defer pagerInput.Close() + + // If we would be streaming to a terminal and aren't forcing color one way + // or the other, we should configure things to use color so the pager gets + // colorized input. + if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { + os.Setenv("FORCE_COLOR", "1") + } + + // If the pager exits before reading all input, then generateOutput() will + // produce a broken pipe error, which is fine and we don't want to propagate it. + if err := generateOutput(pagerInput); err != nil && + !strings.Contains(err.Error(), "broken pipe") { + return err + } + + // Close the file NOW before we wait for the child process to terminate. + // This way, the child will receive the end-of-file signal and know that + // there is no more input. Otherwise the child process may block + // indefinitely waiting for another line (this can happen when streaming + // less than a screenful of data to a pager). + pagerInput.Close() + + // Wait for child process to exit + var wstatus syscall.WaitStatus + _, err = syscall.Wait4(pid, &wstatus, 0, nil) + if wstatus.ExitStatus() != 0 { + return fmt.Errorf("Pager exited with non-zero exit status: %d", wstatus.ExitStatus()) + } + return err +} + +func openSocketPairPager(label string) (*os.File, int, error) { + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return nil, 0, err + } + + // The child file descriptor will be sent to the child process through + // ProcAttr and ForkExec(), while the parent process will always close the + // child file descriptor. + // The parent file descriptor will be wrapped in an os.File wrapper and + // returned from this function, or closed if something goes wrong. + parentFd, childFd := fds[0], fds[1] + defer unix.Close(childFd) + + // Use small buffer sizes so we don't ask the server for more paginated + // values than we actually need. + if err := unix.SetsockoptInt(parentFd, unix.SOL_SOCKET, unix.SO_SNDBUF, 128); err != nil { + unix.Close(parentFd) + return nil, 0, err + } + if err := unix.SetsockoptInt(childFd, unix.SOL_SOCKET, unix.SO_RCVBUF, 128); err != nil { + unix.Close(parentFd) + return nil, 0, err + } + + // Set CLOEXEC on the parent file descriptor so it doesn't leak to child + syscall.CloseOnExec(parentFd) + + parentConn := os.NewFile(uintptr(parentFd), "parent-socket") + + pagerProgram := os.Getenv("PAGER") + if pagerProgram == "" { + pagerProgram = "less" + } + + pagerPath, err := exec.LookPath(pagerProgram) + if err != nil { + unix.Close(parentFd) + return nil, 0, err + } + + env := os.Environ() + env = append(env, "LESS=-r -P "+label) + env = append(env, "MORE=-r -P "+label) + + procAttr := &syscall.ProcAttr{ + Dir: "", + Env: env, + Files: []uintptr{ + uintptr(childFd), // stdin (fd 0) + uintptr(syscall.Stdout), // stdout (fd 1) + uintptr(syscall.Stderr), // stderr (fd 2) + }, + } + + pid, err := syscall.ForkExec(pagerPath, []string{pagerProgram}, procAttr) + if err != nil { + unix.Close(parentFd) + return nil, 0, err + } + + return parentConn, pid, nil +} diff --git a/pkg/cmd/cmdutil_windows.go b/pkg/cmd/cmdutil_windows.go new file mode 100644 index 0000000..608adb7 --- /dev/null +++ b/pkg/cmd/cmdutil_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package cmd + +import "os" + +func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { + // We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't + // available on Windows, so we fall back to using pipes. + return streamToPagerWithPipe(label, generateOutput) +} diff --git a/pkg/cmd/domain.go b/pkg/cmd/domain.go new file mode 100644 index 0000000..51278ae --- /dev/null +++ b/pkg/cmd/domain.go @@ -0,0 +1,296 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var domainsCreate = cli.Command{ + Name: "create", + Usage: "Create Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "domain", + Usage: `The name of the domain. (e.g., "example.com")`, + Required: true, + BodyPath: "domain", + }, + &requestflag.Flag[bool]{ + Name: "feedback-enabled", + Usage: "Bounce and complaint notifications are sent to your inboxes.", + Required: true, + BodyPath: "feedback_enabled", + }, + }, + Action: handleDomainsCreate, + HideHelpCommand: true, +} + +var domainsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "domain-id", + Usage: `The name of the domain. (e.g., " your-domain.com")`, + Required: true, + }, + }, + Action: handleDomainsRetrieve, + HideHelpCommand: true, +} + +var domainsList = cli.Command{ + Name: "list", + Usage: "List Domains", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleDomainsList, + HideHelpCommand: true, +} + +var domainsDelete = cli.Command{ + Name: "delete", + Usage: "Delete Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "domain-id", + Usage: `The name of the domain. (e.g., " your-domain.com")`, + Required: true, + }, + }, + Action: handleDomainsDelete, + HideHelpCommand: true, +} + +var domainsGetZoneFile = cli.Command{ + Name: "get-zone-file", + Usage: "Get Zone File", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "domain-id", + Usage: `The name of the domain. (e.g., " your-domain.com")`, + Required: true, + }, + }, + Action: handleDomainsGetZoneFile, + HideHelpCommand: true, +} + +var domainsVerify = cli.Command{ + Name: "verify", + Usage: "Verify Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "domain-id", + Usage: `The name of the domain. (e.g., " your-domain.com")`, + Required: true, + }, + }, + Action: handleDomainsVerify, + HideHelpCommand: true, +} + +func handleDomainsCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.DomainNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Domains.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "domains create", obj, format, transform) +} + +func handleDomainsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { + cmd.Set("domain-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Domains.Get(ctx, cmd.Value("domain-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "domains retrieve", obj, format, transform) +} + +func handleDomainsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.DomainListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Domains.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "domains list", obj, format, transform) +} + +func handleDomainsDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { + cmd.Set("domain-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Domains.Delete(ctx, cmd.Value("domain-id").(string), options...) +} + +func handleDomainsGetZoneFile(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { + cmd.Set("domain-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Domains.GetZoneFile(ctx, cmd.Value("domain-id").(string), options...) +} + +func handleDomainsVerify(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { + cmd.Set("domain-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Domains.Verify(ctx, cmd.Value("domain-id").(string), options...) +} diff --git a/pkg/cmd/domain_test.go b/pkg/cmd/domain_test.go new file mode 100644 index 0000000..2f7130c --- /dev/null +++ b/pkg/cmd/domain_test.go @@ -0,0 +1,71 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestDomainsCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "create", + "--api-key", "string", + "--domain", "domain", + "--feedback-enabled=true", + ) +} + +func TestDomainsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "retrieve", + "--api-key", "string", + "--domain-id", "domain_id", + ) +} + +func TestDomainsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "list", + "--api-key", "string", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestDomainsDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "delete", + "--api-key", "string", + "--domain-id", "domain_id", + ) +} + +func TestDomainsGetZoneFile(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "get-zone-file", + "--api-key", "string", + "--domain-id", "domain_id", + ) +} + +func TestDomainsVerify(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "domains", "verify", + "--api-key", "string", + "--domain-id", "domain_id", + ) +} diff --git a/pkg/cmd/draft.go b/pkg/cmd/draft.go new file mode 100644 index 0000000..9fe5f03 --- /dev/null +++ b/pkg/cmd/draft.go @@ -0,0 +1,140 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var draftsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + }, + Action: handleDraftsRetrieve, + HideHelpCommand: true, +} + +var draftsList = cli.Command{ + Name: "list", + Usage: "List Drafts", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleDraftsList, + HideHelpCommand: true, +} + +func handleDraftsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Drafts.Get(ctx, cmd.Value("draft-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "drafts retrieve", obj, format, transform) +} + +func handleDraftsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.DraftListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Drafts.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "drafts list", obj, format, transform) +} diff --git a/pkg/cmd/draft_test.go b/pkg/cmd/draft_test.go new file mode 100644 index 0000000..67b327f --- /dev/null +++ b/pkg/cmd/draft_test.go @@ -0,0 +1,34 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestDraftsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "drafts", "retrieve", + "--api-key", "string", + "--draft-id", "draft_id", + ) +} + +func TestDraftsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "drafts", "list", + "--api-key", "string", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go new file mode 100644 index 0000000..1dce3d4 --- /dev/null +++ b/pkg/cmd/flagoptions.go @@ -0,0 +1,349 @@ +package cmd + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "maps" + "mime/multipart" + "net/http" + "os" + "reflect" + "strings" + "unicode/utf8" + + "github.com/agentmail-to/agentmail-cli/internal/apiform" + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/debugmiddleware" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go/option" + + "github.com/goccy/go-yaml" + "github.com/urfave/cli/v3" +) + +type BodyContentType int + +const ( + EmptyBody BodyContentType = iota + MultipartFormEncoded + ApplicationJSON + ApplicationOctetStream +) + +type FileEmbedStyle int + +const ( + EmbedText FileEmbedStyle = iota + EmbedIOReader +) + +func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { + v := reflect.ValueOf(obj) + result, err := embedFilesValue(v, embedStyle) + if err != nil { + return nil, err + } + return result.Interface(), nil +} + +// Replace "@file.txt" with the file's contents inside a value +func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { + // Unwrap interface values to get the concrete type + if v.Kind() == reflect.Interface { + if v.IsNil() { + return v, nil + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Map: + if v.Len() == 0 { + return v, nil + } + // Always create map[string]any to handle potential type changes when embedding files + result := reflect.MakeMap(reflect.TypeOf(map[string]any{})) + + iter := v.MapRange() + for iter.Next() { + key := iter.Key() + val := iter.Value() + newVal, err := embedFilesValue(val, embedStyle) + if err != nil { + return reflect.Value{}, err + } + result.SetMapIndex(key, newVal) + } + return result, nil + + case reflect.Slice, reflect.Array: + if v.Len() == 0 { + return v, nil + } + // Use `[]any` to allow for types to change when embedding files + result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) + for i := 0; i < v.Len(); i++ { + newVal, err := embedFilesValue(v.Index(i), embedStyle) + if err != nil { + return reflect.Value{}, err + } + result.Index(i).Set(newVal) + } + return result, nil + + case reflect.String: + s := v.String() + if literal, ok := strings.CutPrefix(s, "\\@"); ok { + // Allow for escaped @ signs if you don't want them to be treated as files + return reflect.ValueOf("@" + literal), nil + } + + if embedStyle == EmbedText { + if filename, ok := strings.CutPrefix(s, "@data://"); ok { + // The "@data://" prefix is for files you explicitly want to upload + // as base64-encoded (even if the file itself is plain text) + content, err := os.ReadFile(filename) + if err != nil { + return v, err + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } else if filename, ok := strings.CutPrefix(s, "@file://"); ok { + // The "@file://" prefix is for files that you explicitly want to + // upload as a string literal with backslash escapes (not base64 + // encoded) + content, err := os.ReadFile(filename) + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } else if filename, ok := strings.CutPrefix(s, "@"); ok { + content, err := os.ReadFile(filename) + if err != nil { + // If the string is "@username", it's probably supposed to be a + // string literal and not a file reference. However, if the + // string looks like "@file.txt" or "@/tmp/file", then it's + // probably supposed to be a file. + probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/") + if probablyFile { + // Give a useful error message if the user tried to upload a + // file, but the file couldn't be read (e.g. mistyped + // filename or permission error) + return v, err + } + // Fall back to the raw value if the user provided something + // like "@username" that's not intended to be a file. + return v, nil + } + // If the file looks like a plain text UTF8 file format, then use the contents directly. + if isUTF8TextFile(content) { + return reflect.ValueOf(string(content)), nil + } + // Otherwise it's a binary file, so encode it with base64 + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } + } else { + if filename, ok := strings.CutPrefix(s, "@"); ok { + // Behavior is the same for @file, @data://file, and @file://file, except that + // @username will be treated as a literal string if no "username" file exists + expectsFile := true + if withoutPrefix, ok := strings.CutPrefix(filename, "data://"); ok { + filename = withoutPrefix + } else if withoutPrefix, ok := strings.CutPrefix(filename, "file://"); ok { + filename = withoutPrefix + } else { + expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") + } + + file, err := os.Open(filename) + if err != nil { + if !expectsFile { + // For strings that start with "@" and don't look like a filename, return the string + return v, nil + } + return v, err + } + return reflect.ValueOf(file), nil + } + } + return v, nil + + default: + return v, nil + } +} + +// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed +// to plain text (e.g. .txt or .md). +func isUTF8TextFile(content []byte) bool { + // Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and + // these are the sniffable content types that are plain text: + textTypes := []string{ + "text/", + "application/json", + "application/xml", + "application/javascript", + "application/x-javascript", + "application/ecmascript", + "application/x-ecmascript", + } + + contentType := http.DetectContentType(content) + for _, prefix := range textTypes { + if strings.HasPrefix(contentType, prefix) { + return utf8.Valid(content) + } + } + return false +} + +func flagOptions( + cmd *cli.Command, + nestedFormat apiquery.NestedQueryFormat, + arrayFormat apiquery.ArrayQueryFormat, + bodyType BodyContentType, + + // This parameter is true if stdin is already in use to pass a binary parameter by using the special value + // "-". In this case, we won't attempt to read it as a JSON/YAML blob for options setting. + stdinInUse bool, +) ([]option.RequestOption, error) { + var options []option.RequestOption + if cmd.Bool("debug") { + options = append(options, option.WithMiddleware(debugmiddleware.NewRequestLogger().Middleware())) + } + + flagContents := requestflag.ExtractRequestContents(cmd) + + var bodyData any + var pipeData []byte + if isInputPiped() && !stdinInUse { + var err error + pipeData, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, err + } + } + + if len(pipeData) > 0 { + if err := yaml.Unmarshal(pipeData, &bodyData); err == nil { + if bodyMap, ok := bodyData.(map[string]any); ok { + if flagMap, ok := flagContents.Body.(map[string]any); ok { + maps.Copy(bodyMap, flagMap) + } else { + bodyData = flagContents.Body + } + } else if flagMap, ok := flagContents.Body.(map[string]any); ok && len(flagMap) > 0 { + return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) + } + } + } else { + // No piped input, just use body flag values as a map + bodyData = flagContents.Body + } + + // Embed files passed as "@file.jpg" in the request body, headers, and query: + embedStyle := EmbedText + if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { + embedStyle = EmbedIOReader + } + bodyData, err := embedFiles(bodyData, embedStyle) + if err != nil { + return nil, err + } + if headersWithFiles, err := embedFiles(flagContents.Headers, EmbedText); err != nil { + return nil, err + } else { + flagContents.Headers = headersWithFiles.(map[string]any) + } + if queriesWithFiles, err := embedFiles(flagContents.Queries, EmbedText); err != nil { + return nil, err + } else { + flagContents.Queries = queriesWithFiles.(map[string]any) + } + + querySettings := apiquery.QuerySettings{ + NestedFormat: nestedFormat, + ArrayFormat: arrayFormat, + } + + // Add query parameters: + if values, err := apiquery.MarshalWithSettings(flagContents.Queries, querySettings); err != nil { + return nil, err + } else { + for k, vs := range values { + if len(vs) == 0 { + options = append(options, option.WithQueryDel(k)) + } else { + options = append(options, option.WithQuery(k, vs[0])) + for _, v := range vs[1:] { + options = append(options, option.WithQueryAdd(k, v)) + } + } + } + } + + // Add header parameters + headerSettings := apiquery.QuerySettings{ + NestedFormat: apiquery.NestedQueryFormatDots, + ArrayFormat: apiquery.ArrayQueryFormatRepeat, + } + if values, err := apiquery.MarshalWithSettings(flagContents.Headers, headerSettings); err != nil { + return nil, err + } else { + for k, vs := range values { + if len(vs) == 0 { + options = append(options, option.WithHeaderDel(k)) + } else { + options = append(options, option.WithHeader(k, vs[0])) + for _, v := range vs[1:] { + options = append(options, option.WithHeaderAdd(k, v)) + } + } + } + } + + switch bodyType { + case EmptyBody: + break + case MultipartFormEncoded: + buf := new(bytes.Buffer) + writer := multipart.NewWriter(buf) + + // For multipart/form-encoded, we need a map structure + bodyMap, ok := bodyData.(map[string]any) + if !ok { + return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", bodyData) + } + encodingFormat := apiform.FormatComma + if err := apiform.MarshalWithSettings(bodyMap, writer, encodingFormat); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) + + case ApplicationJSON: + bodyBytes, err := json.Marshal(bodyData) + if err != nil { + return nil, err + } + options = append(options, option.WithRequestBody("application/json", bodyBytes)) + + case ApplicationOctetStream: + if bodyBytes, ok := bodyData.([]byte); ok { + options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) + } else if bodyStr, ok := bodyData.(string); ok { + options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) + } else { + return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", bodyData) + } + + default: + panic("Invalid body content type!") + } + + return options, nil +} diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go new file mode 100644 index 0000000..e5dad4b --- /dev/null +++ b/pkg/cmd/flagoptions_test.go @@ -0,0 +1,244 @@ +package cmd + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsUTF8TextFile(t *testing.T) { + tests := []struct { + content []byte + expected bool + }{ + {[]byte("Hello, world!"), true}, + {[]byte(`{"key": "value"}`), true}, + {[]byte(``), true}, + {[]byte(`function test() {}`), true}, + {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, false}, // JPEG header + {[]byte{0x00, 0x01, 0xFF, 0xFE}, false}, // binary + {[]byte("Hello \xFF\xFE"), false}, // invalid UTF-8 + {[]byte("Hello ☺️"), true}, // emoji + {[]byte{}, true}, // empty + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, isUTF8TextFile(tt.content)) + } +} + +func TestEmbedFiles(t *testing.T) { + // Create temporary directory for test files + tmpDir := t.TempDir() + + // Create test files + configContent := "host=localhost\nport=8080" + templateContent := "Hello" + dataContent := `{"key": "value"}` + + writeTestFile(t, tmpDir, "config.txt", configContent) + writeTestFile(t, tmpDir, "template.html", templateContent) + writeTestFile(t, tmpDir, "data.json", dataContent) + jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46} + writeTestFile(t, tmpDir, "image.jpg", string(jpegHeader)) + + tests := []struct { + name string + input any + want any + wantErr bool + }{ + { + name: "map[string]any with file references", + input: map[string]any{ + "config": "@" + filepath.Join(tmpDir, "config.txt"), + "template": "@file://" + filepath.Join(tmpDir, "template.html"), + "count": 42, + }, + want: map[string]any{ + "config": configContent, + "template": templateContent, + "count": 42, + }, + wantErr: false, + }, + { + name: "map[string]string with file references", + input: map[string]any{ + "config": "@" + filepath.Join(tmpDir, "config.txt"), + "name": "test", + }, + want: map[string]any{ + "config": configContent, + "name": "test", + }, + wantErr: false, + }, + { + name: "[]any with file references", + input: []any{ + "@" + filepath.Join(tmpDir, "config.txt"), + 42, + true, + "@file://" + filepath.Join(tmpDir, "data.json"), + }, + want: []any{ + configContent, + 42, + true, + dataContent, + }, + wantErr: false, + }, + { + name: "[]string with file references", + input: []any{ + "@" + filepath.Join(tmpDir, "config.txt"), + "normal string", + }, + want: []any{ + configContent, + "normal string", + }, + wantErr: false, + }, + { + name: "nested structures", + input: map[string]any{ + "outer": map[string]any{ + "inner": []any{ + "@" + filepath.Join(tmpDir, "config.txt"), + map[string]any{ + "data": "@" + filepath.Join(tmpDir, "data.json"), + }, + }, + }, + }, + want: map[string]any{ + "outer": map[string]any{ + "inner": []any{ + configContent, + map[string]any{ + "data": dataContent, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "base64 encoding", + input: map[string]any{ + "encoded": "@data://" + filepath.Join(tmpDir, "config.txt"), + "image": "@" + filepath.Join(tmpDir, "image.jpg"), + }, + want: map[string]any{ + "encoded": base64.StdEncoding.EncodeToString([]byte(configContent)), + "image": base64.StdEncoding.EncodeToString(jpegHeader), + }, + wantErr: false, + }, + { + name: "non-existent file with @ prefix", + input: map[string]any{ + "missing": "@file.txt", + }, + want: nil, + wantErr: true, + }, + { + name: "non-file-like thing with @ prefix", + input: map[string]any{ + "username": "@user", + "favorite_symbol": "@", + }, + want: map[string]any{ + "username": "@user", + "favorite_symbol": "@", + }, + wantErr: false, + }, + { + name: "non-existent file with @file:// prefix (error)", + input: map[string]any{ + "missing": "@file:///nonexistent/file.txt", + }, + want: nil, + wantErr: true, + }, + { + name: "escaping", + input: map[string]any{ + "simple": "\\@file.txt", + "file": "\\@file://file.txt", + "data": "\\@data://file.txt", + "keep_escape": "user\\@example.com", + }, + want: map[string]any{ + "simple": "@file.txt", + "file": "@file://file.txt", + "data": "@data://file.txt", + "keep_escape": "user\\@example.com", + }, + wantErr: false, + }, + { + name: "primitive types", + input: map[string]any{ + "int": 123, + "float": 45.67, + "bool": true, + "null": nil, + "string": "no prefix", + "email": "user@example.com", + }, + want: map[string]any{ + "int": 123, + "float": 45.67, + "bool": true, + "null": nil, + "string": "no prefix", + "email": "user@example.com", + }, + wantErr: false, + }, + { + name: "[]int values unchanged", + input: []int{1, 2, 3, 4, 5}, + want: []any{1, 2, 3, 4, 5}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" text", func(t *testing.T) { + got, err := embedFiles(tt.input, EmbedText) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + + t.Run(tt.name+" io.Reader", func(t *testing.T) { + _, err := embedFiles(tt.input, EmbedIOReader) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func writeTestFile(t *testing.T, dir, filename, content string) { + t.Helper() + path := filepath.Join(dir, filename) + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err, "failed to write test file %s", path) +} diff --git a/pkg/cmd/inbox.go b/pkg/cmd/inbox.go new file mode 100644 index 0000000..d6400c2 --- /dev/null +++ b/pkg/cmd/inbox.go @@ -0,0 +1,361 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var inboxesCreate = cli.Command{ + Name: "create", + Usage: "Create Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "client-id", + Usage: "Client ID of inbox.", + BodyPath: "client_id", + }, + &requestflag.Flag[any]{ + Name: "display-name", + Usage: "Display name: `Display Name `.", + BodyPath: "display_name", + }, + &requestflag.Flag[any]{ + Name: "domain", + Usage: "Domain of address. Must be verified domain. Defaults to `agentmail.to`.", + BodyPath: "domain", + }, + &requestflag.Flag[any]{ + Name: "username", + Usage: "Username of address. Randomly generated if not specified.", + BodyPath: "username", + }, + }, + Action: handleInboxesCreate, + HideHelpCommand: true, +} + +var inboxesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + }, + Action: handleInboxesRetrieve, + HideHelpCommand: true, +} + +var inboxesUpdate = cli.Command{ + Name: "update", + Usage: "Update Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "display-name", + Usage: "Display name: `Display Name `.", + Required: true, + BodyPath: "display_name", + }, + }, + Action: handleInboxesUpdate, + HideHelpCommand: true, +} + +var inboxesList = cli.Command{ + Name: "list", + Usage: "List Inboxes", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleInboxesList, + HideHelpCommand: true, +} + +var inboxesDelete = cli.Command{ + Name: "delete", + Usage: "Delete Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + }, + Action: handleInboxesDelete, + HideHelpCommand: true, +} + +var inboxesListMetrics = cli.Command{ + Name: "list-metrics", + Usage: "List Metrics", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "end-timestamp", + Usage: "End timestamp for the metrics query range.", + Required: true, + QueryPath: "end_timestamp", + }, + &requestflag.Flag[any]{ + Name: "start-timestamp", + Usage: "Start timestamp for the metrics query range.", + Required: true, + QueryPath: "start_timestamp", + }, + &requestflag.Flag[any]{ + Name: "event-type", + Usage: "List of metric event types to filter by.", + QueryPath: "event_types", + }, + }, + Action: handleInboxesListMetrics, + HideHelpCommand: true, +} + +func handleInboxesCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes create", obj, format, transform) +} + +func handleInboxesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Get(ctx, cmd.Value("inbox-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes retrieve", obj, format, transform) +} + +func handleInboxesUpdate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxUpdateParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Update( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes update", obj, format, transform) +} + +func handleInboxesList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes list", obj, format, transform) +} + +func handleInboxesDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Inboxes.Delete(ctx, cmd.Value("inbox-id").(string), options...) +} + +func handleInboxesListMetrics(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxListMetricsParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.ListMetrics( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes list-metrics", obj, format, transform) +} diff --git a/pkg/cmd/inbox_test.go b/pkg/cmd/inbox_test.go new file mode 100644 index 0000000..992b696 --- /dev/null +++ b/pkg/cmd/inbox_test.go @@ -0,0 +1,77 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestInboxesCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "create", + "--api-key", "string", + "--client-id", "client_id", + "--display-name", "display_name", + "--domain", "domain", + "--username", "username", + ) +} + +func TestInboxesRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "retrieve", + "--api-key", "string", + "--inbox-id", "inbox_id", + ) +} + +func TestInboxesUpdate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "update", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--display-name", "display_name", + ) +} + +func TestInboxesList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "list", + "--api-key", "string", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestInboxesDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "delete", + "--api-key", "string", + "--inbox-id", "inbox_id", + ) +} + +func TestInboxesListMetrics(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes", "list-metrics", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--end-timestamp", "'2019-12-27T18:11:19.117Z'", + "--start-timestamp", "'2019-12-27T18:11:19.117Z'", + "--event-type", "[message.sent]", + ) +} diff --git a/pkg/cmd/inboxdraft.go b/pkg/cmd/inboxdraft.go new file mode 100644 index 0000000..5c1b051 --- /dev/null +++ b/pkg/cmd/inboxdraft.go @@ -0,0 +1,511 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var inboxesDraftsCreate = cli.Command{ + Name: "create", + Usage: "Create Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "bcc", + Usage: "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "bcc", + }, + &requestflag.Flag[any]{ + Name: "cc", + Usage: "Addresses of CC recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "cc", + }, + &requestflag.Flag[any]{ + Name: "client-id", + Usage: "Client ID of draft.", + BodyPath: "client_id", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of draft.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "in-reply-to", + Usage: "ID of message being replied to.", + BodyPath: "in_reply_to", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels of draft.", + BodyPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + Usage: "Reply-to addresses. In format `username@domain.com` or `Display Name `.", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "send-at", + Usage: "Time at which to schedule send draft.", + BodyPath: "send_at", + }, + &requestflag.Flag[any]{ + Name: "subject", + Usage: "Subject of draft.", + BodyPath: "subject", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of draft.", + BodyPath: "text", + }, + &requestflag.Flag[any]{ + Name: "to", + Usage: "Addresses of recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "to", + }, + }, + Action: handleInboxesDraftsCreate, + HideHelpCommand: true, +} + +var inboxesDraftsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + }, + Action: handleInboxesDraftsRetrieve, + HideHelpCommand: true, +} + +var inboxesDraftsUpdate = cli.Command{ + Name: "update", + Usage: "Update Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "bcc", + Usage: "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "bcc", + }, + &requestflag.Flag[any]{ + Name: "cc", + Usage: "Addresses of CC recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "cc", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of draft.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + Usage: "Reply-to addresses. In format `username@domain.com` or `Display Name `.", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "send-at", + Usage: "Time at which to schedule send draft.", + BodyPath: "send_at", + }, + &requestflag.Flag[any]{ + Name: "subject", + Usage: "Subject of draft.", + BodyPath: "subject", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of draft.", + BodyPath: "text", + }, + &requestflag.Flag[any]{ + Name: "to", + Usage: "Addresses of recipients. In format `username@domain.com` or `Display Name `.", + BodyPath: "to", + }, + }, + Action: handleInboxesDraftsUpdate, + HideHelpCommand: true, +} + +var inboxesDraftsList = cli.Command{ + Name: "list", + Usage: "List Drafts", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleInboxesDraftsList, + HideHelpCommand: true, +} + +var inboxesDraftsDelete = cli.Command{ + Name: "delete", + Usage: "Delete Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + }, + Action: handleInboxesDraftsDelete, + HideHelpCommand: true, +} + +var inboxesDraftsSend = cli.Command{ + Name: "send", + Usage: "Send Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "add-label", + Usage: "Labels to add to message.", + BodyPath: "add_labels", + }, + &requestflag.Flag[any]{ + Name: "remove-label", + Usage: "Labels to remove from message.", + BodyPath: "remove_labels", + }, + }, + Action: handleInboxesDraftsSend, + HideHelpCommand: true, +} + +func handleInboxesDraftsCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Drafts.New( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:drafts create", obj, format, transform) +} + +func handleInboxesDraftsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftGetParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Drafts.Get( + ctx, + cmd.Value("draft-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:drafts retrieve", obj, format, transform) +} + +func handleInboxesDraftsUpdate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftUpdateParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Drafts.Update( + ctx, + cmd.Value("draft-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:drafts update", obj, format, transform) +} + +func handleInboxesDraftsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Drafts.List( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:drafts list", obj, format, transform) +} + +func handleInboxesDraftsDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftDeleteParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Inboxes.Drafts.Delete( + ctx, + cmd.Value("draft-id").(string), + params, + options..., + ) +} + +func handleInboxesDraftsSend(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxDraftSendParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Drafts.Send( + ctx, + cmd.Value("draft-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:drafts send", obj, format, transform) +} diff --git a/pkg/cmd/inboxdraft_test.go b/pkg/cmd/inboxdraft_test.go new file mode 100644 index 0000000..d27fc52 --- /dev/null +++ b/pkg/cmd/inboxdraft_test.go @@ -0,0 +1,100 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestInboxesDraftsCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "create", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--bcc", "[string]", + "--cc", "[string]", + "--client-id", "client_id", + "--html", "html", + "--in-reply-to", "in_reply_to", + "--label", "[string]", + "--reply-to", "[string]", + "--send-at", "'2019-12-27T18:11:19.117Z'", + "--subject", "subject", + "--text", "text", + "--to", "[string]", + ) +} + +func TestInboxesDraftsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "retrieve", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--draft-id", "draft_id", + ) +} + +func TestInboxesDraftsUpdate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "update", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--draft-id", "draft_id", + "--bcc", "[string]", + "--cc", "[string]", + "--html", "html", + "--reply-to", "[string]", + "--send-at", "'2019-12-27T18:11:19.117Z'", + "--subject", "subject", + "--text", "text", + "--to", "[string]", + ) +} + +func TestInboxesDraftsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "list", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestInboxesDraftsDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "delete", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--draft-id", "draft_id", + ) +} + +func TestInboxesDraftsSend(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:drafts", "send", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--draft-id", "draft_id", + "--add-label", "[string]", + "--remove-label", "[string]", + ) +} diff --git a/pkg/cmd/inboxmessage.go b/pkg/cmd/inboxmessage.go new file mode 100644 index 0000000..354b6d4 --- /dev/null +++ b/pkg/cmd/inboxmessage.go @@ -0,0 +1,918 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var inboxesMessagesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + }, + Action: handleInboxesMessagesRetrieve, + HideHelpCommand: true, +} + +var inboxesMessagesUpdate = cli.Command{ + Name: "update", + Usage: "Update Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "add-label", + Usage: "Labels to add to message.", + BodyPath: "add_labels", + }, + &requestflag.Flag[any]{ + Name: "remove-label", + Usage: "Labels to remove from message.", + BodyPath: "remove_labels", + }, + }, + Action: handleInboxesMessagesUpdate, + HideHelpCommand: true, +} + +var inboxesMessagesList = cli.Command{ + Name: "list", + Usage: "List Messages", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "include-spam", + Usage: "Include spam in results.", + QueryPath: "include_spam", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleInboxesMessagesList, + HideHelpCommand: true, +} + +var inboxesMessagesForward = requestflag.WithInnerFlags(cli.Command{ + Name: "forward", + Usage: "Forward Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "attachment", + Usage: "Attachments to include in message.", + BodyPath: "attachments", + }, + &requestflag.Flag[any]{ + Name: "bcc", + BodyPath: "bcc", + }, + &requestflag.Flag[any]{ + Name: "cc", + BodyPath: "cc", + }, + &requestflag.Flag[any]{ + Name: "headers", + Usage: "Headers to include in message.", + BodyPath: "headers", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of message.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels of message.", + BodyPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "subject", + Usage: "Subject of message.", + BodyPath: "subject", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of message.", + BodyPath: "text", + }, + &requestflag.Flag[any]{ + Name: "to", + BodyPath: "to", + }, + }, + Action: handleInboxesMessagesForward, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "attachment": { + &requestflag.InnerFlag[any]{ + Name: "attachment.content", + Usage: "Base64 encoded content of attachment.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "attachment.content-disposition", + Usage: "Content disposition of attachment.", + InnerField: "content_disposition", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-id", + Usage: "Content ID of attachment.", + InnerField: "content_id", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-type", + Usage: "Content type of attachment.", + InnerField: "content_type", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.filename", + Usage: "Filename of attachment.", + InnerField: "filename", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.url", + Usage: "URL to the attachment.", + InnerField: "url", + }, + }, +}) + +var inboxesMessagesGetAttachment = cli.Command{ + Name: "get-attachment", + Usage: "Get Attachment", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "attachment-id", + Usage: "ID of attachment.", + Required: true, + }, + }, + Action: handleInboxesMessagesGetAttachment, + HideHelpCommand: true, +} + +var inboxesMessagesGetRaw = cli.Command{ + Name: "get-raw", + Usage: "Get Raw Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + }, + Action: handleInboxesMessagesGetRaw, + HideHelpCommand: true, +} + +var inboxesMessagesReply = requestflag.WithInnerFlags(cli.Command{ + Name: "reply", + Usage: "Reply To Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "attachment", + Usage: "Attachments to include in message.", + BodyPath: "attachments", + }, + &requestflag.Flag[any]{ + Name: "bcc", + BodyPath: "bcc", + }, + &requestflag.Flag[any]{ + Name: "cc", + BodyPath: "cc", + }, + &requestflag.Flag[any]{ + Name: "headers", + Usage: "Headers to include in message.", + BodyPath: "headers", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of message.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels of message.", + BodyPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "reply-all", + Usage: "Reply to all recipients of the original message.", + BodyPath: "reply_all", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of message.", + BodyPath: "text", + }, + &requestflag.Flag[any]{ + Name: "to", + BodyPath: "to", + }, + }, + Action: handleInboxesMessagesReply, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "attachment": { + &requestflag.InnerFlag[any]{ + Name: "attachment.content", + Usage: "Base64 encoded content of attachment.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "attachment.content-disposition", + Usage: "Content disposition of attachment.", + InnerField: "content_disposition", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-id", + Usage: "Content ID of attachment.", + InnerField: "content_id", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-type", + Usage: "Content type of attachment.", + InnerField: "content_type", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.filename", + Usage: "Filename of attachment.", + InnerField: "filename", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.url", + Usage: "URL to the attachment.", + InnerField: "url", + }, + }, +}) + +var inboxesMessagesReplyAll = requestflag.WithInnerFlags(cli.Command{ + Name: "reply-all", + Usage: "Reply All Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "ID of message.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "attachment", + Usage: "Attachments to include in message.", + BodyPath: "attachments", + }, + &requestflag.Flag[any]{ + Name: "headers", + Usage: "Headers to include in message.", + BodyPath: "headers", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of message.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels of message.", + BodyPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of message.", + BodyPath: "text", + }, + }, + Action: handleInboxesMessagesReplyAll, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "attachment": { + &requestflag.InnerFlag[any]{ + Name: "attachment.content", + Usage: "Base64 encoded content of attachment.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "attachment.content-disposition", + Usage: "Content disposition of attachment.", + InnerField: "content_disposition", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-id", + Usage: "Content ID of attachment.", + InnerField: "content_id", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-type", + Usage: "Content type of attachment.", + InnerField: "content_type", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.filename", + Usage: "Filename of attachment.", + InnerField: "filename", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.url", + Usage: "URL to the attachment.", + InnerField: "url", + }, + }, +}) + +var inboxesMessagesSend = requestflag.WithInnerFlags(cli.Command{ + Name: "send", + Usage: "Send Message", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "attachment", + Usage: "Attachments to include in message.", + BodyPath: "attachments", + }, + &requestflag.Flag[any]{ + Name: "bcc", + BodyPath: "bcc", + }, + &requestflag.Flag[any]{ + Name: "cc", + BodyPath: "cc", + }, + &requestflag.Flag[any]{ + Name: "headers", + Usage: "Headers to include in message.", + BodyPath: "headers", + }, + &requestflag.Flag[any]{ + Name: "html", + Usage: "HTML body of message.", + BodyPath: "html", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels of message.", + BodyPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "reply-to", + BodyPath: "reply_to", + }, + &requestflag.Flag[any]{ + Name: "subject", + Usage: "Subject of message.", + BodyPath: "subject", + }, + &requestflag.Flag[any]{ + Name: "text", + Usage: "Plain text body of message.", + BodyPath: "text", + }, + &requestflag.Flag[any]{ + Name: "to", + BodyPath: "to", + }, + }, + Action: handleInboxesMessagesSend, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "attachment": { + &requestflag.InnerFlag[any]{ + Name: "attachment.content", + Usage: "Base64 encoded content of attachment.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "attachment.content-disposition", + Usage: "Content disposition of attachment.", + InnerField: "content_disposition", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-id", + Usage: "Content ID of attachment.", + InnerField: "content_id", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.content-type", + Usage: "Content type of attachment.", + InnerField: "content_type", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.filename", + Usage: "Filename of attachment.", + InnerField: "filename", + }, + &requestflag.InnerFlag[any]{ + Name: "attachment.url", + Usage: "URL to the attachment.", + InnerField: "url", + }, + }, +}) + +func handleInboxesMessagesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageGetParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.Get( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages retrieve", obj, format, transform) +} + +func handleInboxesMessagesUpdate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageUpdateParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.Update( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages update", obj, format, transform) +} + +func handleInboxesMessagesList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.List( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages list", obj, format, transform) +} + +func handleInboxesMessagesForward(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageForwardParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.Forward( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages forward", obj, format, transform) +} + +func handleInboxesMessagesGetAttachment(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { + cmd.Set("attachment-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageGetAttachmentParams{ + InboxID: cmd.Value("inbox-id").(string), + MessageID: cmd.Value("message-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.GetAttachment( + ctx, + cmd.Value("attachment-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages get-attachment", obj, format, transform) +} + +func handleInboxesMessagesGetRaw(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageGetRawParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Inboxes.Messages.GetRaw( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) +} + +func handleInboxesMessagesReply(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageReplyParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.Reply( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages reply", obj, format, transform) +} + +func handleInboxesMessagesReplyAll(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageReplyAllParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.ReplyAll( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages reply-all", obj, format, transform) +} + +func handleInboxesMessagesSend(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxMessageSendParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Messages.Send( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:messages send", obj, format, transform) +} diff --git a/pkg/cmd/inboxmessage_test.go b/pkg/cmd/inboxmessage_test.go new file mode 100644 index 0000000..8fc37ee --- /dev/null +++ b/pkg/cmd/inboxmessage_test.go @@ -0,0 +1,252 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" +) + +func TestInboxesMessagesRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "retrieve", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + ) +} + +func TestInboxesMessagesUpdate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "update", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--add-label", "[string]", + "--remove-label", "[string]", + ) +} + +func TestInboxesMessagesList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "list", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--include-spam=true", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestInboxesMessagesForward(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "forward", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--subject", "subject", + "--text", "text", + "--to", "string", + ) + + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(inboxesMessagesForward) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "forward", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment.content", "content", + "--attachment.content-disposition", "inline", + "--attachment.content-id", "content_id", + "--attachment.content-type", "content_type", + "--attachment.filename", "filename", + "--attachment.url", "url", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--subject", "subject", + "--text", "text", + "--to", "string", + ) +} + +func TestInboxesMessagesGetAttachment(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "get-attachment", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment-id", "attachment_id", + ) +} + +func TestInboxesMessagesGetRaw(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "get-raw", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + ) +} + +func TestInboxesMessagesReply(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "reply", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-all=true", + "--reply-to", "string", + "--text", "text", + "--to", "string", + ) + + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(inboxesMessagesReply) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "reply", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment.content", "content", + "--attachment.content-disposition", "inline", + "--attachment.content-id", "content_id", + "--attachment.content-type", "content_type", + "--attachment.filename", "filename", + "--attachment.url", "url", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-all=true", + "--reply-to", "string", + "--text", "text", + "--to", "string", + ) +} + +func TestInboxesMessagesReplyAll(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "reply-all", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--text", "text", + ) + + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(inboxesMessagesReplyAll) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "reply-all", + "--inbox-id", "inbox_id", + "--message-id", "message_id", + "--attachment.content", "content", + "--attachment.content-disposition", "inline", + "--attachment.content-id", "content_id", + "--attachment.content-type", "content_type", + "--attachment.filename", "filename", + "--attachment.url", "url", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--text", "text", + ) +} + +func TestInboxesMessagesSend(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "send", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--subject", "subject", + "--text", "text", + "--to", "string", + ) + + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(inboxesMessagesSend) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:messages", "send", + "--inbox-id", "inbox_id", + "--attachment.content", "content", + "--attachment.content-disposition", "inline", + "--attachment.content-id", "content_id", + "--attachment.content-type", "content_type", + "--attachment.filename", "filename", + "--attachment.url", "url", + "--bcc", "string", + "--cc", "string", + "--headers", "{foo: string}", + "--html", "html", + "--label", "[string]", + "--reply-to", "string", + "--subject", "subject", + "--text", "text", + "--to", "string", + ) +} diff --git a/pkg/cmd/inboxthread.go b/pkg/cmd/inboxthread.go new file mode 100644 index 0000000..e528d7a --- /dev/null +++ b/pkg/cmd/inboxthread.go @@ -0,0 +1,296 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var inboxesThreadsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Thread", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + }, + Action: handleInboxesThreadsRetrieve, + HideHelpCommand: true, +} + +var inboxesThreadsList = cli.Command{ + Name: "list", + Usage: "List Threads", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "include-spam", + Usage: "Include spam in results.", + QueryPath: "include_spam", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleInboxesThreadsList, + HideHelpCommand: true, +} + +var inboxesThreadsDelete = cli.Command{ + Name: "delete", + Usage: "Delete Thread", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + }, + Action: handleInboxesThreadsDelete, + HideHelpCommand: true, +} + +var inboxesThreadsGetAttachment = cli.Command{ + Name: "get-attachment", + Usage: "Get Attachment", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "attachment-id", + Usage: "ID of attachment.", + Required: true, + }, + }, + Action: handleInboxesThreadsGetAttachment, + HideHelpCommand: true, +} + +func handleInboxesThreadsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { + cmd.Set("thread-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxThreadGetParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Threads.Get( + ctx, + cmd.Value("thread-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:threads retrieve", obj, format, transform) +} + +func handleInboxesThreadsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxThreadListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Threads.List( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:threads list", obj, format, transform) +} + +func handleInboxesThreadsDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { + cmd.Set("thread-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxThreadDeleteParams{ + InboxID: cmd.Value("inbox-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Inboxes.Threads.Delete( + ctx, + cmd.Value("thread-id").(string), + params, + options..., + ) +} + +func handleInboxesThreadsGetAttachment(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { + cmd.Set("attachment-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.InboxThreadGetAttachmentParams{ + InboxID: cmd.Value("inbox-id").(string), + ThreadID: cmd.Value("thread-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Inboxes.Threads.GetAttachment( + ctx, + cmd.Value("attachment-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "inboxes:threads get-attachment", obj, format, transform) +} diff --git a/pkg/cmd/inboxthread_test.go b/pkg/cmd/inboxthread_test.go new file mode 100644 index 0000000..fd8c0a8 --- /dev/null +++ b/pkg/cmd/inboxthread_test.go @@ -0,0 +1,60 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestInboxesThreadsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:threads", "retrieve", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--thread-id", "thread_id", + ) +} + +func TestInboxesThreadsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:threads", "list", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--include-spam=true", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestInboxesThreadsDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:threads", "delete", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--thread-id", "thread_id", + ) +} + +func TestInboxesThreadsGetAttachment(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "inboxes:threads", "get-attachment", + "--api-key", "string", + "--inbox-id", "inbox_id", + "--thread-id", "thread_id", + "--attachment-id", "attachment_id", + ) +} diff --git a/pkg/cmd/metric.go b/pkg/cmd/metric.go new file mode 100644 index 0000000..908c16f --- /dev/null +++ b/pkg/cmd/metric.go @@ -0,0 +1,77 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var metricsList = cli.Command{ + Name: "list", + Usage: "List Metrics", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "end-timestamp", + Usage: "End timestamp for the metrics query range.", + Required: true, + QueryPath: "end_timestamp", + }, + &requestflag.Flag[any]{ + Name: "start-timestamp", + Usage: "Start timestamp for the metrics query range.", + Required: true, + QueryPath: "start_timestamp", + }, + &requestflag.Flag[any]{ + Name: "event-type", + Usage: "List of metric event types to filter by.", + QueryPath: "event_types", + }, + }, + Action: handleMetricsList, + HideHelpCommand: true, +} + +func handleMetricsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.MetricListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Metrics.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "metrics list", obj, format, transform) +} diff --git a/pkg/cmd/metric_test.go b/pkg/cmd/metric_test.go new file mode 100644 index 0000000..a7f25f4 --- /dev/null +++ b/pkg/cmd/metric_test.go @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestMetricsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "metrics", "list", + "--api-key", "string", + "--end-timestamp", "'2019-12-27T18:11:19.117Z'", + "--start-timestamp", "'2019-12-27T18:11:19.117Z'", + "--event-type", "[message.sent]", + ) +} diff --git a/pkg/cmd/organization.go b/pkg/cmd/organization.go new file mode 100644 index 0000000..3d04be7 --- /dev/null +++ b/pkg/cmd/organization.go @@ -0,0 +1,56 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var organizationsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get the current organization.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleOrganizationsRetrieve, + HideHelpCommand: true, +} + +func handleOrganizationsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Organizations.Get(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "organizations retrieve", obj, format, transform) +} diff --git a/pkg/cmd/organization_test.go b/pkg/cmd/organization_test.go new file mode 100644 index 0000000..4b65138 --- /dev/null +++ b/pkg/cmd/organization_test.go @@ -0,0 +1,18 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestOrganizationsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "organizations", "retrieve", + "--api-key", "string", + ) +} diff --git a/pkg/cmd/pod.go b/pkg/cmd/pod.go new file mode 100644 index 0000000..0746bcc --- /dev/null +++ b/pkg/cmd/pod.go @@ -0,0 +1,214 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var podsCreate = cli.Command{ + Name: "create", + Usage: "Create Pod", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "client-id", + Usage: "Client ID of pod.", + BodyPath: "client_id", + }, + &requestflag.Flag[any]{ + Name: "name", + Usage: "Name of pod.", + BodyPath: "name", + }, + }, + Action: handlePodsCreate, + HideHelpCommand: true, +} + +var podsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Pod", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + }, + Action: handlePodsRetrieve, + HideHelpCommand: true, +} + +var podsList = cli.Command{ + Name: "list", + Usage: "List Pods", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handlePodsList, + HideHelpCommand: true, +} + +var podsDelete = cli.Command{ + Name: "delete", + Usage: "Delete Pod", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + }, + Action: handlePodsDelete, + HideHelpCommand: true, +} + +func handlePodsCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods create", obj, format, transform) +} + +func handlePodsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Get(ctx, cmd.Value("pod-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods retrieve", obj, format, transform) +} + +func handlePodsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods list", obj, format, transform) +} + +func handlePodsDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Pods.Delete(ctx, cmd.Value("pod-id").(string), options...) +} diff --git a/pkg/cmd/pod_test.go b/pkg/cmd/pod_test.go new file mode 100644 index 0000000..2c9bd5d --- /dev/null +++ b/pkg/cmd/pod_test.go @@ -0,0 +1,51 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestPodsCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods", "create", + "--api-key", "string", + "--client-id", "client_id", + "--name", "name", + ) +} + +func TestPodsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods", "retrieve", + "--api-key", "string", + "--pod-id", "pod_id", + ) +} + +func TestPodsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods", "list", + "--api-key", "string", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestPodsDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods", "delete", + "--api-key", "string", + "--pod-id", "pod_id", + ) +} diff --git a/pkg/cmd/poddomain.go b/pkg/cmd/poddomain.go new file mode 100644 index 0000000..f790015 --- /dev/null +++ b/pkg/cmd/poddomain.go @@ -0,0 +1,206 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var podsDomainsCreate = cli.Command{ + Name: "create", + Usage: "Create Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "domain", + Usage: `The name of the domain. (e.g., "example.com")`, + Required: true, + BodyPath: "domain", + }, + &requestflag.Flag[bool]{ + Name: "feedback-enabled", + Usage: "Bounce and complaint notifications are sent to your inboxes.", + Required: true, + BodyPath: "feedback_enabled", + }, + }, + Action: handlePodsDomainsCreate, + HideHelpCommand: true, +} + +var podsDomainsList = cli.Command{ + Name: "list", + Usage: "List Domains", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handlePodsDomainsList, + HideHelpCommand: true, +} + +var podsDomainsDelete = cli.Command{ + Name: "delete", + Usage: "Delete Domain", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "domain-id", + Usage: `The name of the domain. (e.g., " your-domain.com")`, + Required: true, + }, + }, + Action: handlePodsDomainsDelete, + HideHelpCommand: true, +} + +func handlePodsDomainsCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodDomainNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Domains.New( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:domains create", obj, format, transform) +} + +func handlePodsDomainsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodDomainListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Domains.List( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:domains list", obj, format, transform) +} + +func handlePodsDomainsDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { + cmd.Set("domain-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodDomainDeleteParams{ + PodID: cmd.Value("pod-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Pods.Domains.Delete( + ctx, + cmd.Value("domain-id").(string), + params, + options..., + ) +} diff --git a/pkg/cmd/poddomain_test.go b/pkg/cmd/poddomain_test.go new file mode 100644 index 0000000..6989849 --- /dev/null +++ b/pkg/cmd/poddomain_test.go @@ -0,0 +1,44 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestPodsDomainsCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:domains", "create", + "--api-key", "string", + "--pod-id", "pod_id", + "--domain", "domain", + "--feedback-enabled=true", + ) +} + +func TestPodsDomainsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:domains", "list", + "--api-key", "string", + "--pod-id", "pod_id", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestPodsDomainsDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:domains", "delete", + "--api-key", "string", + "--pod-id", "pod_id", + "--domain-id", "domain_id", + ) +} diff --git a/pkg/cmd/poddraft.go b/pkg/cmd/poddraft.go new file mode 100644 index 0000000..5a73990 --- /dev/null +++ b/pkg/cmd/poddraft.go @@ -0,0 +1,167 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var podsDraftsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Draft", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "draft-id", + Usage: "ID of draft.", + Required: true, + }, + }, + Action: handlePodsDraftsRetrieve, + HideHelpCommand: true, +} + +var podsDraftsList = cli.Command{ + Name: "list", + Usage: "List Drafts", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handlePodsDraftsList, + HideHelpCommand: true, +} + +func handlePodsDraftsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { + cmd.Set("draft-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodDraftGetParams{ + PodID: cmd.Value("pod-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Drafts.Get( + ctx, + cmd.Value("draft-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:drafts retrieve", obj, format, transform) +} + +func handlePodsDraftsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodDraftListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Drafts.List( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:drafts list", obj, format, transform) +} diff --git a/pkg/cmd/poddraft_test.go b/pkg/cmd/poddraft_test.go new file mode 100644 index 0000000..460706c --- /dev/null +++ b/pkg/cmd/poddraft_test.go @@ -0,0 +1,36 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestPodsDraftsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:drafts", "retrieve", + "--api-key", "string", + "--pod-id", "pod_id", + "--draft-id", "draft_id", + ) +} + +func TestPodsDraftsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:drafts", "list", + "--api-key", "string", + "--pod-id", "pod_id", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} diff --git a/pkg/cmd/podinbox.go b/pkg/cmd/podinbox.go new file mode 100644 index 0000000..2234483 --- /dev/null +++ b/pkg/cmd/podinbox.go @@ -0,0 +1,278 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var podsInboxesCreate = cli.Command{ + Name: "create", + Usage: "Create Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "client-id", + Usage: "Client ID of inbox.", + BodyPath: "client_id", + }, + &requestflag.Flag[any]{ + Name: "display-name", + Usage: "Display name: `Display Name `.", + BodyPath: "display_name", + }, + &requestflag.Flag[any]{ + Name: "domain", + Usage: "Domain of address. Must be verified domain. Defaults to `agentmail.to`.", + BodyPath: "domain", + }, + &requestflag.Flag[any]{ + Name: "username", + Usage: "Username of address. Randomly generated if not specified.", + BodyPath: "username", + }, + }, + Action: handlePodsInboxesCreate, + HideHelpCommand: true, +} + +var podsInboxesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + }, + Action: handlePodsInboxesRetrieve, + HideHelpCommand: true, +} + +var podsInboxesList = cli.Command{ + Name: "list", + Usage: "List Inboxes", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handlePodsInboxesList, + HideHelpCommand: true, +} + +var podsInboxesDelete = cli.Command{ + Name: "delete", + Usage: "Delete Inbox", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "inbox-id", + Usage: "ID of inbox.", + Required: true, + }, + }, + Action: handlePodsInboxesDelete, + HideHelpCommand: true, +} + +func handlePodsInboxesCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodInboxNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Inboxes.New( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:inboxes create", obj, format, transform) +} + +func handlePodsInboxesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodInboxGetParams{ + PodID: cmd.Value("pod-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Inboxes.Get( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:inboxes retrieve", obj, format, transform) +} + +func handlePodsInboxesList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodInboxListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Inboxes.List( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:inboxes list", obj, format, transform) +} + +func handlePodsInboxesDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { + cmd.Set("inbox-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodInboxDeleteParams{ + PodID: cmd.Value("pod-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Pods.Inboxes.Delete( + ctx, + cmd.Value("inbox-id").(string), + params, + options..., + ) +} diff --git a/pkg/cmd/podinbox_test.go b/pkg/cmd/podinbox_test.go new file mode 100644 index 0000000..cded247 --- /dev/null +++ b/pkg/cmd/podinbox_test.go @@ -0,0 +1,57 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestPodsInboxesCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:inboxes", "create", + "--api-key", "string", + "--pod-id", "pod_id", + "--client-id", "client_id", + "--display-name", "display_name", + "--domain", "domain", + "--username", "username", + ) +} + +func TestPodsInboxesRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:inboxes", "retrieve", + "--api-key", "string", + "--pod-id", "pod_id", + "--inbox-id", "inbox_id", + ) +} + +func TestPodsInboxesList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:inboxes", "list", + "--api-key", "string", + "--pod-id", "pod_id", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestPodsInboxesDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:inboxes", "delete", + "--api-key", "string", + "--pod-id", "pod_id", + "--inbox-id", "inbox_id", + ) +} diff --git a/pkg/cmd/podthread.go b/pkg/cmd/podthread.go new file mode 100644 index 0000000..2a87d3b --- /dev/null +++ b/pkg/cmd/podthread.go @@ -0,0 +1,242 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var podsThreadsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Thread", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + }, + Action: handlePodsThreadsRetrieve, + HideHelpCommand: true, +} + +var podsThreadsList = cli.Command{ + Name: "list", + Usage: "List Threads", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "include-spam", + Usage: "Include spam in results.", + QueryPath: "include_spam", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handlePodsThreadsList, + HideHelpCommand: true, +} + +var podsThreadsGetAttachment = cli.Command{ + Name: "get-attachment", + Usage: "Get Attachment", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "pod-id", + Usage: "ID of pod.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "attachment-id", + Usage: "ID of attachment.", + Required: true, + }, + }, + Action: handlePodsThreadsGetAttachment, + HideHelpCommand: true, +} + +func handlePodsThreadsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { + cmd.Set("thread-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodThreadGetParams{ + PodID: cmd.Value("pod-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Threads.Get( + ctx, + cmd.Value("thread-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:threads retrieve", obj, format, transform) +} + +func handlePodsThreadsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { + cmd.Set("pod-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodThreadListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Threads.List( + ctx, + cmd.Value("pod-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:threads list", obj, format, transform) +} + +func handlePodsThreadsGetAttachment(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { + cmd.Set("attachment-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.PodThreadGetAttachmentParams{ + PodID: cmd.Value("pod-id").(string), + ThreadID: cmd.Value("thread-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Pods.Threads.GetAttachment( + ctx, + cmd.Value("attachment-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "pods:threads get-attachment", obj, format, transform) +} diff --git a/pkg/cmd/podthread_test.go b/pkg/cmd/podthread_test.go new file mode 100644 index 0000000..cd715fc --- /dev/null +++ b/pkg/cmd/podthread_test.go @@ -0,0 +1,49 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestPodsThreadsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:threads", "retrieve", + "--api-key", "string", + "--pod-id", "pod_id", + "--thread-id", "thread_id", + ) +} + +func TestPodsThreadsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:threads", "list", + "--api-key", "string", + "--pod-id", "pod_id", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--include-spam=true", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestPodsThreadsGetAttachment(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "pods:threads", "get-attachment", + "--api-key", "string", + "--pod-id", "pod_id", + "--thread-id", "thread_id", + "--attachment-id", "attachment_id", + ) +} diff --git a/pkg/cmd/suggest.go b/pkg/cmd/suggest.go new file mode 100644 index 0000000..b4b637c --- /dev/null +++ b/pkg/cmd/suggest.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "math" + "slices" + "strings" + + "github.com/urfave/cli/v3" +) + +// This entire file is mostly taken from urfave/cli/v3's source, with the exception of suggestCommand which is +// modified for a nicer error message. + +// jaroDistance is the measure of similarity between two strings. It returns a +// value between 0 and 1, where 1 indicates identical strings and 0 indicates +// completely different strings. +// +// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go. +func jaroDistance(a, b string) float64 { + if len(a) == 0 && len(b) == 0 { + return 1 + } + if len(a) == 0 || len(b) == 0 { + return 0 + } + + lenA := float64(len(a)) + lenB := float64(len(b)) + hashA := make([]bool, len(a)) + hashB := make([]bool, len(b)) + maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1)) + + var matches float64 + for i := 0; i < len(a); i++ { + start := int(math.Max(0, float64(i-maxDistance))) + end := int(math.Min(lenB-1, float64(i+maxDistance))) + + for j := start; j <= end; j++ { + if hashB[j] { + continue + } + if a[i] == b[j] { + hashA[i] = true + hashB[j] = true + matches++ + break + } + } + } + if matches == 0 { + return 0 + } + + var transpositions float64 + var j int + for i := 0; i < len(a); i++ { + if !hashA[i] { + continue + } + for !hashB[j] { + j++ + } + if a[i] != b[j] { + transpositions++ + } + j++ + } + + transpositions /= 2 + return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0 +} + +// jaroWinkler is more accurate when strings have a common prefix up to a +// defined maximum length. +// +// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go. +func jaroWinkler(a, b string) float64 { + const ( + boostThreshold = 0.7 + prefixSize = 4 + ) + jaroDist := jaroDistance(a, b) + if jaroDist <= boostThreshold { + return jaroDist + } + + prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b))))) + + var prefixMatch float64 + for i := 0; i < prefix; i++ { + if a[i] == b[i] { + prefixMatch++ + } else { + break + } + } + return jaroDist + 0.1*prefixMatch*(1.0-jaroDist) +} + +// suggestCommand takes a list of commands and a provided string to suggest a +// command name +func suggestCommand(commands []*cli.Command, provided string) string { + distance := 0.0 + var lineage []*cli.Command + for _, command := range commands { + for _, name := range command.Names() { + newDistance := jaroWinkler(name, provided) + if newDistance > distance { + distance = newDistance + lineage = command.Lineage() + } + } + } + + var parts []string + for _, command := range lineage { + parts = append(parts, command.Name) + } + slices.Reverse(parts) + return fmt.Sprintf("Did you mean '%s'?", strings.Join(parts, " ")) +} + +func init() { + cli.SuggestCommand = suggestCommand +} diff --git a/pkg/cmd/thread.go b/pkg/cmd/thread.go new file mode 100644 index 0000000..eb410d0 --- /dev/null +++ b/pkg/cmd/thread.go @@ -0,0 +1,209 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var threadsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Thread", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + }, + Action: handleThreadsRetrieve, + HideHelpCommand: true, +} + +var threadsList = cli.Command{ + Name: "list", + Usage: "List Threads", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "after", + Usage: "Timestamp after which to filter by.", + QueryPath: "after", + }, + &requestflag.Flag[any]{ + Name: "ascending", + Usage: "Sort in ascending temporal order.", + QueryPath: "ascending", + }, + &requestflag.Flag[any]{ + Name: "before", + Usage: "Timestamp before which to filter by.", + QueryPath: "before", + }, + &requestflag.Flag[any]{ + Name: "include-spam", + Usage: "Include spam in results.", + QueryPath: "include_spam", + }, + &requestflag.Flag[any]{ + Name: "label", + Usage: "Labels to filter by.", + QueryPath: "labels", + }, + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleThreadsList, + HideHelpCommand: true, +} + +var threadsRetrieveAttachment = cli.Command{ + Name: "retrieve-attachment", + Usage: "Get Attachment", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "thread-id", + Usage: "ID of thread.", + Required: true, + }, + &requestflag.Flag[string]{ + Name: "attachment-id", + Usage: "ID of attachment.", + Required: true, + }, + }, + Action: handleThreadsRetrieveAttachment, + HideHelpCommand: true, +} + +func handleThreadsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { + cmd.Set("thread-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Threads.Get(ctx, cmd.Value("thread-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "threads retrieve", obj, format, transform) +} + +func handleThreadsList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.ThreadListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Threads.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "threads list", obj, format, transform) +} + +func handleThreadsRetrieveAttachment(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { + cmd.Set("attachment-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.ThreadGetAttachmentParams{ + ThreadID: cmd.Value("thread-id").(string), + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Threads.GetAttachment( + ctx, + cmd.Value("attachment-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "threads retrieve-attachment", obj, format, transform) +} diff --git a/pkg/cmd/thread_test.go b/pkg/cmd/thread_test.go new file mode 100644 index 0000000..1f7ae4d --- /dev/null +++ b/pkg/cmd/thread_test.go @@ -0,0 +1,46 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestThreadsRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "threads", "retrieve", + "--api-key", "string", + "--thread-id", "thread_id", + ) +} + +func TestThreadsList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "threads", "list", + "--api-key", "string", + "--after", "'2019-12-27T18:11:19.117Z'", + "--ascending=true", + "--before", "'2019-12-27T18:11:19.117Z'", + "--include-spam=true", + "--label", "[string]", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestThreadsRetrieveAttachment(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "threads", "retrieve-attachment", + "--api-key", "string", + "--thread-id", "thread_id", + "--attachment-id", "attachment_id", + ) +} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go new file mode 100644 index 0000000..20f136d --- /dev/null +++ b/pkg/cmd/version.go @@ -0,0 +1,5 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +const Version = "0.0.2" // x-release-please-version diff --git a/pkg/cmd/webhook.go b/pkg/cmd/webhook.go new file mode 100644 index 0000000..4143e6e --- /dev/null +++ b/pkg/cmd/webhook.go @@ -0,0 +1,308 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/agentmail-to/agentmail-cli/internal/apiquery" + "github.com/agentmail-to/agentmail-cli/internal/requestflag" + "github.com/agentmail-to/agentmail-go" + "github.com/agentmail-to/agentmail-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var webhooksCreate = cli.Command{ + Name: "create", + Usage: "Create Webhook", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[[]string]{ + Name: "event-type", + Usage: "Event types for which to send events.", + Required: true, + BodyPath: "event_types", + }, + &requestflag.Flag[string]{ + Name: "url", + Usage: "URL of webhook endpoint.", + Required: true, + BodyPath: "url", + }, + &requestflag.Flag[any]{ + Name: "client-id", + Usage: "Client ID of webhook.", + BodyPath: "client_id", + }, + &requestflag.Flag[any]{ + Name: "inbox-id", + Usage: "Inboxes for which to send events. Maximum 10 per webhook.", + BodyPath: "inbox_ids", + }, + &requestflag.Flag[any]{ + Name: "pod-id", + Usage: "Pods for which to send events. Maximum 10 per webhook.", + BodyPath: "pod_ids", + }, + }, + Action: handleWebhooksCreate, + HideHelpCommand: true, +} + +var webhooksRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get Webhook", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "webhook-id", + Usage: "ID of webhook.", + Required: true, + }, + }, + Action: handleWebhooksRetrieve, + HideHelpCommand: true, +} + +var webhooksUpdate = cli.Command{ + Name: "update", + Usage: "Update Webhook", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "webhook-id", + Usage: "ID of webhook.", + Required: true, + }, + &requestflag.Flag[any]{ + Name: "add-inbox-id", + Usage: "Inbox IDs to subscribe to the webhook.", + BodyPath: "add_inbox_ids", + }, + &requestflag.Flag[any]{ + Name: "add-pod-id", + Usage: "Pod IDs to subscribe to the webhook.", + BodyPath: "add_pod_ids", + }, + &requestflag.Flag[any]{ + Name: "remove-inbox-id", + Usage: "Inbox IDs to unsubscribe from the webhook.", + BodyPath: "remove_inbox_ids", + }, + &requestflag.Flag[any]{ + Name: "remove-pod-id", + Usage: "Pod IDs to unsubscribe from the webhook.", + BodyPath: "remove_pod_ids", + }, + }, + Action: handleWebhooksUpdate, + HideHelpCommand: true, +} + +var webhooksList = cli.Command{ + Name: "list", + Usage: "List Webhooks", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "limit", + Usage: "Limit of number of items returned.", + QueryPath: "limit", + }, + &requestflag.Flag[any]{ + Name: "page-token", + Usage: "Page token for pagination.", + QueryPath: "page_token", + }, + }, + Action: handleWebhooksList, + HideHelpCommand: true, +} + +var webhooksDelete = cli.Command{ + Name: "delete", + Usage: "Delete Webhook", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "webhook-id", + Usage: "ID of webhook.", + Required: true, + }, + }, + Action: handleWebhooksDelete, + HideHelpCommand: true, +} + +func handleWebhooksCreate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.WebhookNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Webhooks.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "webhooks create", obj, format, transform) +} + +func handleWebhooksRetrieve(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { + cmd.Set("webhook-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Webhooks.Get(ctx, cmd.Value("webhook-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "webhooks retrieve", obj, format, transform) +} + +func handleWebhooksUpdate(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { + cmd.Set("webhook-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.WebhookUpdateParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Webhooks.Update( + ctx, + cmd.Value("webhook-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "webhooks update", obj, format, transform) +} + +func handleWebhooksList(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + params := agentmail.WebhookListParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Webhooks.List(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON(os.Stdout, "webhooks list", obj, format, transform) +} + +func handleWebhooksDelete(ctx context.Context, cmd *cli.Command) error { + client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { + cmd.Set("webhook-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + EmptyBody, + false, + ) + if err != nil { + return err + } + + return client.Webhooks.Delete(ctx, cmd.Value("webhook-id").(string), options...) +} diff --git a/pkg/cmd/webhook_test.go b/pkg/cmd/webhook_test.go new file mode 100644 index 0000000..9dffcbf --- /dev/null +++ b/pkg/cmd/webhook_test.go @@ -0,0 +1,68 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/agentmail-to/agentmail-cli/internal/mocktest" +) + +func TestWebhooksCreate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "webhooks", "create", + "--api-key", "string", + "--event-type", "message.received", + "--url", "url", + "--client-id", "client_id", + "--inbox-id", "[string]", + "--pod-id", "[string]", + ) +} + +func TestWebhooksRetrieve(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "webhooks", "retrieve", + "--api-key", "string", + "--webhook-id", "webhook_id", + ) +} + +func TestWebhooksUpdate(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "webhooks", "update", + "--api-key", "string", + "--webhook-id", "webhook_id", + "--add-inbox-id", "[string]", + "--add-pod-id", "[string]", + "--remove-inbox-id", "[string]", + "--remove-pod-id", "[string]", + ) +} + +func TestWebhooksList(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "webhooks", "list", + "--api-key", "string", + "--limit", "0", + "--page-token", "page_token", + ) +} + +func TestWebhooksDelete(t *testing.T) { + t.Skip("Mock server tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "webhooks", "delete", + "--api-key", "string", + "--webhook-id", "webhook_id", + ) +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..53619de --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,67 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "simple", + "extra-files": [ + "pkg/cmd/version.go", + "README.md" + ] +} \ No newline at end of file diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..d94c7d1 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then + brew bundle check >/dev/null 2>&1 || { + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +fi + +echo "==> Installing Go dependencies…" +go mod tidy diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..82d9650 --- /dev/null +++ b/scripts/build @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Building agentmail" +go build ./cmd/agentmail diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000..db2a3fa --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running gofmt -s -w" +gofmt -s -w . diff --git a/scripts/link b/scripts/link new file mode 100755 index 0000000..ef3a4e1 --- /dev/null +++ b/scripts/link @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" ]]; then + LOCAL_GO="$1" + shift +else + LOCAL_GO=../agentmail-go +fi + +echo "==> Linking with local directory" +go mod tidy -e +go mod edit -replace github.com/agentmail-to/agentmail-go="$LOCAL_GO" diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..fa7ba1f --- /dev/null +++ b/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running Go build" +go build ./... diff --git a/scripts/run b/scripts/run new file mode 100755 index 0000000..6fa4471 --- /dev/null +++ b/scripts/run @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +go run ./cmd/agentmail "$@" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..df2bd61 --- /dev/null +++ b/scripts/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + + + +echo "==> Running tests" +go test ./... "$@" + +echo "==> Checking tests on Windows" +GOARCH=amd64 GOOS=windows go test -c ./... "$@" +# `go test -c` produces a bunch of .exe files; make sure to clean those up +find . -name "*.test.exe" -exec rm {} \; diff --git a/scripts/unlink b/scripts/unlink new file mode 100755 index 0000000..279b7f7 --- /dev/null +++ b/scripts/unlink @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Unlinking with local directory" +go mod edit -dropreplace github.com/agentmail-to/agentmail-go diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..e3bc57b --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -exuo pipefail + +BINARY_NAME="agentmail" +DIST_DIR="dist" +FILENAME="dist.zip" + +files=() +while IFS= read -r -d '' file; do + files+=("$file") +done < <(find "$DIST_DIR" -type f \( \ + -path "*amd64*/$BINARY_NAME" -o \ + -path "*arm64*/$BINARY_NAME" -o \ + -path "*amd64*/${BINARY_NAME}.exe" -o \ + -path "*arm64*/${BINARY_NAME}.exe" \ + \) -print0) + +if [[ ${#files[@]} -eq 0 ]]; then + echo -e "\033[31mNo binaries found for packaging.\033[0m" + exit 1 +fi + +rm -f "${DIST_DIR}/${FILENAME}" + +while IFS= read -r -d '' dir; do + printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \ + "$BINARY_NAME" >"${dir}/README.txt" + files+=("${dir}/README.txt") +done < <(find "$DIST_DIR" -type d -path '*macos*' -print0) + +relative_files=() +for file in "${files[@]}"; do + relative_files+=("${file#"${DIST_DIR}"/}") +done + +(cd "$DIST_DIR" && zip -r "$FILENAME" "${relative_files[@]}") + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: application/zip" \ + --data-binary "@${DIST_DIR}/${FILENAME}" "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/agentmail-cli/$SHA'. On macOS, run 'xattr -d com.apple.quarantine {executable name}'.\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi