diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..bdf880a --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,173 @@ +name: Develop Build + +on: + push: + branches: + - develop + +permissions: + contents: read + +jobs: + build: + name: Build QC artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser (all platforms, unsigned) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release --snapshot --config .goreleaser/config-qc.yaml --clean --parallelism 2 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: qc-artifacts-${{ github.run_number }} + if-no-files-found: error + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + + sign-macos: + name: Sign macOS artifacts + runs-on: macos-latest + needs: build + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Check for Apple signing secrets + id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + run: | + if [ -n "$APPLE_CERT_P12" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." + fi + + - name: Import Apple certificate + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: qc-artifacts-${{ github.run_number }} + path: dist + + - name: Sign macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + run: | + set -e + if [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::error::APPLE_SIGNING_IDENTITY is required for signing." + exit 1 + fi + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found." + exit 1 + fi + + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF + done + + - name: Upload signed macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: qc-artifacts-macos-signed-${{ github.run_number }} + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + - name: Clean up keychain and cert + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..1a560e2 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,32 @@ +name: Quality Checks + +on: pull_request + +permissions: + contents: read + issues: read + checks: write + pull-requests: write + +jobs: + test: + name: Verify build and run unit tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Download modules + run: go mod download + + - name: Verify build + run: go build + + - name: Run tests + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5c0473c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,526 @@ +name: Release + +on: + push: + tags: + # v0.* is intentional: prevents accidental production releases during + # initial development. Update to v* when ready to ship v1.0. + - v0.* + workflow_dispatch: + inputs: + snapshot: + description: 'Run goreleaser in --snapshot mode (developer build, no publish)' + required: false + type: boolean + default: false + + +permissions: + contents: write # required to create releases and upload assets + +jobs: + release: + name: Build and publish release artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Verify release branch + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + # Tag pushes don't populate origin/* remote-tracking refs, so fetch + # the main branches explicitly before checking containment. + git fetch --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/heads/main-*:refs/remotes/origin/main-*' || true + # Allow main or a main- branch (e.g. main-hotfix) for emergency releases. + if ! git branch -r --contains "$GITHUB_SHA" | grep -qE '^\s*origin/main($|-)'; then + echo "::error::Tag $GITHUB_REF_NAME must be on the main branch (or a main- branch)." + exit 1 + fi + elif [[ "$GITHUB_REF" != "refs/heads/main" && "$GITHUB_REF" != refs/heads/main-* && "${{ inputs.snapshot }}" != "true" ]]; then + echo "::error::Release workflow must be dispatched from the main branch (or a main- branch, or use snapshot=true for ad-hoc builds)." + exit 1 + fi + + - name: Validate dispatch inputs + if: github.event_name == 'workflow_dispatch' + env: + SNAPSHOT: ${{ inputs.snapshot }} + run: | + if [[ "$GITHUB_REF" != refs/tags/* ]] && [ "$SNAPSHOT" != "true" ]; then + echo "::error::Manual dispatch from a non-tag ref requires snapshot=true to avoid creating a broken release." + exit 1 + fi + + - name: Ensure mac signing secrets for tag releases + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + missing=0 + for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::warning::$var_name secret not configured; macOS signing/finalization steps will be skipped." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + echo "Proceeding with Linux/Windows build only." + fi + + - name: Run GoReleaser (Linux) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SNAPSHOT: ${{ inputs.snapshot }} + run: | + set -e + EXTRA="" + CONFIG=".goreleaser/config.yaml" + if [ "$SNAPSHOT" = "true" ]; then + # Snapshot builds are QC builds: QC endpoints, apimetrics-qc binary. + EXTRA="--snapshot --skip homebrew" + CONFIG=".goreleaser/config-qc.yaml" + echo "Running goreleaser in snapshot mode (QC build, no publish)" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + EXTRA="--skip publish,homebrew" + echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" + fi + + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release $EXTRA --config "$CONFIG" --clean --parallelism 2 + + - name: Create GitHub draft release + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + if gh release view "$GITHUB_REF_NAME" &>/dev/null; then + echo "Draft release $GITHUB_REF_NAME already exists (rerun); re-uploading artifacts." + gh release upload "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --clobber + elif [ -f dist/CHANGELOG.md ]; then + gh release create "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --draft \ + --notes-file dist/CHANGELOG.md + else + gh release create "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --draft \ + --generate-notes + fi + + - name: Upload release artifacts + if: ${{ inputs.snapshot != true }} + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + if-no-files-found: error + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + + # Snapshot (QC) builds: upload each asset as its own artifact so the + # Actions run lists them individually rather than as one combined zip. + - name: Upload QC snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-amd64 + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload QC snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-arm64 + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload QC snapshot — Linux (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-linux-amd64 + if-no-files-found: error + path: dist/*-linux-amd64.tar.gz + + - name: Upload QC snapshot — Linux (arm64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-linux-arm64 + if-no-files-found: error + path: dist/*-linux-arm64.tar.gz + + - name: Upload QC snapshot — Windows (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-windows-amd64 + if-no-files-found: error + path: dist/*-windows-amd64.zip + + - name: Upload QC snapshot — checksums + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + if-no-files-found: error + path: dist/checksums.txt + + release-macos: + name: Sign and notarize macOS artifacts + runs-on: macos-latest + needs: release + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Check for Apple signing secrets + id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + run: | + if [ -n "$APPLE_CERT_P12" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." + fi + + - name: Import Apple certificate + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) + + - name: Download release artifacts + if: ${{ inputs.snapshot != true }} + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: dist + + # Snapshot builds upload each asset as its own artifact; pull them all + # back (flattened) so the signing step finds the darwin archives. + - name: Download QC snapshot artifacts + if: ${{ inputs.snapshot == true }} + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: dist + + - name: Sign and notarize macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + run: | + set -e + for var_name in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required for signing and notarization." + exit 1 + fi + done + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found; expected macOS artifacts from Linux job." + exit 1 + fi + + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" + + # Notarize each binary via a zip (notarytool requires zip/dmg/pkg, not a raw binary) + zip_dir="$(mktemp -d)" + zip_path="$zip_dir/binary.zip" + zip -j "$zip_path" "$bin" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + rm -rf "$zip_dir" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + + # Keep artifacts.json in sync with the signed checksums for archival + # accuracy. Only present for full releases, not snapshot builds. + if [ -f dist/artifacts.json ]; then + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF + fi + done + + - name: Upload signed macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' && inputs.snapshot != true }} + uses: actions/upload-artifact@v4 + with: + name: macos-signed-artifacts + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + # Snapshot builds: replace the unsigned darwin placeholders uploaded by + # the build job with the signed archives, listed as individual assets. + - name: Upload signed QC snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-amd64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload signed QC snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-arm64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload signed QC snapshot — checksums + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + overwrite: true + if-no-files-found: error + path: dist/checksums.txt + + - name: Replace macOS assets on GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + gh release upload "$GITHUB_REF_NAME" \ + dist/*-darwin-amd64.tar.gz \ + dist/*-darwin-arm64.tar.gz \ + dist/checksums.txt \ + --clobber + + - name: Publish finalized GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + gh release edit "$GITHUB_REF_NAME" --draft=false + + - name: Generate Homebrew tap token + id: tap-token + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + repositories: homebrew-tap + + - name: Publish Homebrew formula + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + BASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" + AMD64_FILE="apimetrics-${VERSION}-darwin-amd64.tar.gz" + ARM64_FILE="apimetrics-${VERSION}-darwin-arm64.tar.gz" + + AMD64_SHA=$(awk -v f="${AMD64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + ARM64_SHA=$(awk -v f="${ARM64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + + if [ -z "$AMD64_SHA" ] || [ -z "$ARM64_SHA" ]; then + echo "::error::Could not find darwin checksums in dist/checksums.txt" + exit 1 + fi + + git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/APImetrics/homebrew-tap.git" tap-repo + + cat > tap-repo/Formula/apimetrics.rb << FORMULA_END + # typed: false + # frozen_string_literal: true + + class Apimetrics < Formula + desc "APImetrics CLI" + homepage "https://apimetrics.io" + version "${VERSION}" + license "MIT" + + on_macos do + on_intel do + url "${BASE_URL}/${AMD64_FILE}" + sha256 "${AMD64_SHA}" + end + on_arm do + url "${BASE_URL}/${ARM64_FILE}" + sha256 "${ARM64_SHA}" + end + end + + def install + bin.install "apimetrics" + end + + test do + system "#{bin}/apimetrics", "--version" + end + end + FORMULA_END + + cd tap-repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/apimetrics.rb + if git diff --cached --quiet; then + echo "Homebrew formula is already up to date." + else + git commit -m "apimetrics ${VERSION}" + git push + fi + + - name: Clean up keychain and cert + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 + + release-winget: + name: Submit WinGet package update + runs-on: windows-latest + needs: release-macos + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + timeout-minutes: 15 + + steps: + - name: Check WinGet token + id: winget_check + shell: bash + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + if [ -n "$WINGET_GITHUB_TOKEN" ]; then + echo "has_token=true" >> "$GITHUB_OUTPUT" + else + echo "has_token=false" >> "$GITHUB_OUTPUT" + echo "::warning::WINGET_GITHUB_TOKEN not configured — skipping WinGet submission." + fi + + - name: Check release is published + id: release_check + if: ${{ steps.winget_check.outputs.has_token == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + is_draft=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft') + if [ "$is_draft" = "true" ]; then + echo "is_published=false" >> "$GITHUB_OUTPUT" + echo "::warning::GitHub release $GITHUB_REF_NAME is still a draft (Apple signing secrets may not be configured) — skipping WinGet submission." + else + echo "is_published=true" >> "$GITHUB_OUTPUT" + fi + + - name: Submit WinGet package update + if: ${{ steps.winget_check.outputs.has_token == 'true' && steps.release_check.outputs.is_published == 'true' }} + shell: pwsh + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + # Requires APIContext.APImetricsCLI to already exist in microsoft/winget-pkgs. + # See RELEASING.md § 4 ("WinGet package") for the one-time manual submission required before this runs. + $version = $env:GITHUB_REF_NAME -replace '^v', '' + $installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/apimetrics-$version-windows-amd64.zip" + + Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + .\wingetcreate.exe update APIContext.APImetricsCLI ` + --version $version ` + --urls $installerUrl ` + --submit ` + --token $env:WINGET_GITHUB_TOKEN diff --git a/.gitignore b/.gitignore index 4c9fca0..6677c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ dist TODO* launch.json +apimetrics +gcp-secret.json +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 21b5759..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,80 +0,0 @@ -version: 2 - -project_name: apimetrics - -before: - hooks: - - go mod download - # TODO: Figure out how to test with CGO on all platforms. - - go test -v ./... - -builds: - - id: apimetrics-darwin-amd64 - goos: - - darwin - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=o64-clang - - CXX=o64-clang++ - - - id: apimetrics-darwin-arm64 - goos: - - darwin - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=oa64-clang - - CXX=oa64-clang++ - - - id: apimetrics-linux-amd64 - goos: - - linux - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-linux-gnu-gcc - - CXX=x86_64-linux-gnu-g++ - - - id: apimetrics-linux-arm64 - goos: - - linux - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=aarch64-linux-gnu-gcc - - CXX=aarch64-linux-gnu-g++ - - - id: apimetrics-windows-amd64 - goos: - - windows - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-w64-mingw32-gcc - - CXX=x86_64-w64-mingw32-g++ - -archives: - - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - format_overrides: - - goos: windows - formats: - - zip - -checksum: - name_template: "checksums.txt" - -snapshot: - version_template: "{{ .Tag }}-next" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" diff --git a/.goreleaser/config-qc.yaml b/.goreleaser/config-qc.yaml new file mode 100644 index 0000000..85a699f --- /dev/null +++ b/.goreleaser/config-qc.yaml @@ -0,0 +1,128 @@ +version: 2 +project_name: apimetrics-qc +builds: + - id: apimetrics-qc-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-qc-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-qc-linux-amd64 + goos: + - linux + goarch: + - amd64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-qc-linux-arm64 + goos: + - linux + goarch: + - arm64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-qc-windows-amd64 + goos: + - windows + goarch: + - amd64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* +snapshot: + version_template: '{{ .ShortCommit }}' +checksum: + name_template: checksums.txt + algorithm: sha256 +dist: dist +gomod: + gobinary: go +before: + hooks: + - go mod download + - go test -v ./... +git: + tag_sort: -version:refname diff --git a/.goreleaser/config.yaml b/.goreleaser/config.yaml new file mode 100644 index 0000000..a418153 --- /dev/null +++ b/.goreleaser/config.yaml @@ -0,0 +1,282 @@ +version: 2 +project_name: apimetrics +release: + github: + owner: APImetrics + name: APImetrics-cli + name_template: '{{.Tag}}' +builds: + - id: apimetrics-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-linux-amd64 + goos: + - linux + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-linux-arm64 + goos: + - linux + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-windows-amd64 + goos: + - windows + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* + - src: changelog* + - src: CHANGELOG* +snapshot: + version_template: '{{ .Tag }}-next' +checksum: + name_template: checksums.txt + algorithm: sha256 +docker_digest: + name_template: digests.txt +changelog: + filters: + exclude: + - '^docs:' + - '^test:' + sort: asc + format: '{{ .SHA }} {{ .Message }}' +dist: dist +env_files: + github_token: ~/.config/goreleaser/github_token + gitlab_token: ~/.config/goreleaser/gitlab_token + gitea_token: ~/.config/goreleaser/gitea_token +before: + hooks: + - go mod download + - go test -v ./... +source: + name_template: '{{ .ProjectName }}-{{ .Version }}' + format: tar.gz +gomod: + gobinary: go +announce: + twitter: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + mastodon: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + server: "" + reddit: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + url_template: '{{ .ReleaseURL }}' + slack: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + username: GoReleaser + discord: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + author: GoReleaser + color: "3888754" + icon_url: https://goreleaser.com/static/avatar.png + teams: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + color: '#2D313E' + icon_url: https://goreleaser.com/static/avatar.png + smtp: + subject_template: '{{ .ProjectName }} {{ .Tag }} is out!' + body_template: 'You can view details from: {{ .ReleaseURL }}' + mattermost: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + username: GoReleaser + linkedin: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + telegram: + message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}' + parse_mode: MarkdownV2 + webhook: + message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}' + content_type: application/json; charset=utf-8 + expected_status_codes: + - 200 + - 201 + - 202 + - 204 + opencollective: + title_template: '{{ .Tag }}' + message_template: '{{ .ProjectName }} {{ .Tag }} is out!
Check it out at {{ .ReleaseURL }}' + bluesky: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' +git: + tag_sort: -version:refname +github_urls: + download: https://github.com +gitlab_urls: + download: https://gitlab.com diff --git a/README.md b/README.md index d8995b9..c8c1e8e 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,232 @@ -![Restish Logo](https://user-images.githubusercontent.com/106826/82109918-ec5b2300-96ee-11ea-9af0-8515329d5965.png) - -[![Works With Restish](https://img.shields.io/badge/Works%20With-Restish-ff5f87)](https://rest.sh/) [![User Guide](https://img.shields.io/badge/Docs-Guide-5fafd7)](https://rest.sh/#/guide) [![CI](https://github.com/rest-sh/restish/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/rest-sh/restish/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/rest-sh/restish/branch/main/graph/badge.svg)](https://codecov.io/gh/rest-sh/restish) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](https://pkg.go.dev/github.com/rest-sh/restish?tab=subdirectories) [![Go Report Card](https://goreportcard.com/badge/github.com/rest-sh/restish)](https://goreportcard.com/report/github.com/rest-sh/restish) - -[Restish](https://rest.sh/) is a CLI for interacting with [REST](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019)-ish HTTP APIs with some nice features built-in — like always having the latest API resources, fields, and operations available when they go live on the API without needing to install or update anything. -Check out [how Restish compares to cURL & HTTPie](https://rest.sh/#/comparison). - -See the [user guide](https://rest.sh/#/guide) for how to install Restish and get started. - -Features include: - -- HTTP/2 ([RFC 7540](https://tools.ietf.org/html/rfc7540)) with TLS by _default_ with fallback to HTTP/1.1 -- Generic head/get/post/put/patch/delete verbs like `curl` or [HTTPie](https://httpie.org/) -- Generated commands for CLI operations, e.g. `restish my-api list-users` - - Automatically discovers API descriptions - - [RFC 8631](https://tools.ietf.org/html/rfc8631) `service-desc` link relation - - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation - - Supported formats - - OpenAPI [3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) / [3.1](https://spec.openapis.org/oas/v3.1.0.html) and [JSON Schema](https://json-schema.org/) - - Automatic configuration of API auth if advertised by the API - - Shell command completion for Bash, Fish, Zsh, Powershell -- Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links -- API endpoint-based auth built-in with support for profiles: - - HTTP Basic - - API key via header or query param - - OAuth2 client credentials flow (machine-to-machine, [RFC 6749](https://tools.ietf.org/html/rfc6749)) - - OAuth2 authorization code (with PKCE [RFC 7636](https://tools.ietf.org/html/rfc7636)) flow - - On the fly authorization through external tools for custom API signature mechanisms -- Content negotiation, decoding & unmarshalling built-in: - - JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259), ) - - YAML () - - CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049), ) - - MessagePack () - - Amazon Ion () - - Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding -- Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited. -- Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links: - - HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2)) - - [HAL](http://stateless.co/hal_specification.html) - - [Siren](https://github.com/kevinswiber/siren) - - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON) - - [JSON:API](https://jsonapi.org/) -- Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers -- CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON) -- [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection -- Colorized prettified readable output -- Fast native zero-dependency binary - -Articles: - -- [A CLI for REST APIs](https://dev.to/danielgtaylor/a-cli-for-rest-apis-part-1-104b) -- [Mapping OpenAPI to the CLI](https://dev.to/danielgtaylor/mapping-openapi-to-the-cli-37pb) - -This project started life as a fork of [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator). +# APImetrics CLI + +A command-line interface for managing API monitors, schedules, SLOs, and more on the [APImetrics](https://apimetrics.io) platform, by [APIContext Inc](https://apicontext.com). + +## Documentation + +For more information, please visit our main documentation site, available at [https://docs.apicontext.com/cli](https://docs.apicontext.com/cli). + + +## Installation + +## Manual installation + +The binaries for the APImetrics CLI can be downloaded from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). Choose the appropriate version for your operating system and put the executable in your path for easy access from the command line. + +## MacOS + +You can install the APImetrics CLI using Homebrew: + +```bash +brew install apicontext/tap/apimetrics-cli +``` + +## Windows - Coming soon! + +> Note: Coming soon, not available yet + +You can install the APImetrics CLI using the Windows Package Manager (winget): + +```powershell +winget install APImetrics.APImetricsCLI +``` + +## Linux + +You can install the APImetrics CLI by downloading the binary from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). + +```bash +curl -LO https://github.com/APImetrics/APImetrics-cli/releases/latest/download/apimetrics-[version]-linux-amd64.tar.gz +tar -xzf apimetrics-[version]-linux-amd64.tar.gz +sudo mv apimetrics /usr/local/bin/apimetrics +``` +Note: [version] should be replaced with the specific version you want to download, e.g., `0.0.1`. + +## Getting Started + +### 1. Log in + +```bash +apimetrics login +``` + +This opens your browser for OAuth2 authentication. Your credentials are cached locally and refreshed automatically. + +### 2. Select a project + +All commands require an active project. Run the following to choose one: + +```bash +apimetrics project select +``` + +To confirm which project is currently active: + +```bash +apimetrics project show +``` + +### 3. Run your first command + +```bash +# List all API calls in the current project +apimetrics list-calls + +# List all schedules +apimetrics list-schedules +``` + +To see all supported commands, run +```bash +apimetrics --help +``` + +## Passing Input + +All create and update commands read a JSON body from **stdin** using a heredoc. There is no `--body`, `--data`, or `-d` flag. + +```bash +apimetrics create-call <<'EOF' +{ + "meta": { + "name": "My API Check" + }, + "request": { + "method": "GET", + "url": "https://api.example.com/health" + } +} +EOF +``` + +You can also use **CLI Shorthand** as a concise alternative to JSON: + +```bash +apimetrics create-call meta.name: "My API Check", request.method: GET, request.url: https://api.example.com/health +``` + +Or pipe from a file: + +```bash +apimetrics create-call < my-call.json +``` + +## Output Formats + +Use `-o` / `--rsh-output-format` to control output: + +| Format | Description | +|---|---| +| `auto` (default) | Pretty in terminal, JSON when piped | +| `json` | Standard JSON | +| `yaml` | YAML | +| `table` | Tabular layout (best for lists) | +| `readable` | Colorized human-readable format | +| `gron` | Grep-friendly flattened format | + +```bash +# Table output +apimetrics list-calls -o table + +# Raw JSON for scripting +apimetrics list-calls -o json + +# Filter results with a query expression +apimetrics list-calls -f body[0].meta.name +``` + +When output is redirected to a pipe or file, color is disabled and only the body is printed as JSON automatically. + +## Global Flags + +These flags work with every command: + +| Flag | Short | Description | +|---|---|---| +| `--rsh-output-format` | `-o` | Output format (auto/json/yaml/table/readable/gron) | +| `--rsh-filter` | `-f` | Filter/project response using a query expression | +| `--rsh-raw` | `-r` | Raw output (strips quotes from strings) | +| `--rsh-verbose` | `-v` | Verbose logging | +| `--rsh-header` | `-H` | Add a request header (repeatable) | +| `--rsh-query` | `-q` | Add a query parameter (repeatable) | +| `--rsh-profile` | `-p` | Use a named auth profile | +| `--rsh-server` | `-s` | Override the API server base URL | +| `--rsh-no-cache` | | Disable HTTP caching | +| `--rsh-no-paginate` | | Disable automatic pagination | +| `--rsh-retry` | | Retry count (default 2) | +| `--rsh-timeout` | `-t` | HTTP request timeout | +| `--rsh-insecure` | | Disable TLS verification | + +## Shell Completion + +Enable tab completion for your shell: + +```bash +# Bash +apimetrics completion bash >> ~/.bash_profile + +# Zsh +apimetrics completion zsh >> ~/.zshrc + +# Fish +apimetrics completion fish > ~/.config/fish/completions/apimetrics.fish + +# PowerShell +apimetrics completion powershell >> $PROFILE +``` + +Once enabled, press `tab` to explore available commands and their arguments. + +## AI Agent Integration + +The CLI includes built-in skills for AI coding agents (e.g. Claude Code). These skills teach agents how to create and configure monitors using the CLI. + +**Install skills into Claude Code:** + +```bash +apimetrics skills install --claude-code +``` + +**Print all skills to stdout** (for any agent or model that can read context): + +```bash +apimetrics onboard +``` + +Available skills: +- `setup-api-monitor` — Create an API (HTTP) monitor, attach a schedule, and verify it +- `setup-browser-monitor` — Create a browser monitor, attach a schedule, and verify it +- `setup-mcp-monitor` — Create an MCP protocol monitor with session steps + +## Configuration + +Configuration and cached tokens are stored in platform-specific locations: + +| OS | Config | Cache | +|---|---|---| +| macOS | `~/Library/Application Support/apimetrics/` | `~/Library/Caches/apimetrics/` | +| Linux | `~/.config/apimetrics/` | `~/.cache/apimetrics/` | +| Windows | `%AppData%\apimetrics\` | `%LocalAppData%\apimetrics\` | + +Override these locations with environment variables: + +```bash +export APIMETRICS_CONFIG_DIR=/path/to/config +export APIMETRICS_CACHE_DIR=/path/to/cache +``` + +## Logout + +```bash +apimetrics logout +``` + +This removes cached tokens. Run `apimetrics login` again to re-authenticate. + +# Issues and Contributing + +For support questions, visit [APImetrics Support](https://apicontext.com/support) and open a request. + +For reporting bugs or requesting features, please open an issue on the [GitHub repository](https://github.com/APImetrics/APImetrics-cli/issues). + +We welcome contributions! Feel free to fork the repository, make changes, and submit pull requests. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..72ba4c8 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,165 @@ +# Release Process + +This document covers the one-time infrastructure setup required to run the release workflow, and how to cut a release once everything is configured. + +## How releases work + +Pushing a `v0.x.y` tag triggers the release workflow (`.github/workflows/release.yml`), which: + +1. **Linux job** — cross-compiles all platform binaries inside `goreleaser-cross` (Docker), builds archives, and creates a GitHub draft release with the Linux and Windows assets. +2. **macOS job** — signs and notarizes the Darwin binaries using an Apple Developer certificate, re-archives them, replaces the macOS assets on the draft release, publishes the release, then updates the Homebrew tap formula. + +All binaries are distributed as **GitHub release assets** — Homebrew and WinGet download directly from `https://github.com/APImetrics/APImetrics-cli/releases/download//`. + +Snapshot builds (no publish) can be triggered via `workflow_dispatch` with `snapshot: true`. + +--- + +## One-time infrastructure setup + +> Binaries are distributed as GitHub release assets, so **the repository must be +> public** (or the download URLs used by Homebrew/WinGet must otherwise be +> reachable by end users). No cloud storage or service-account setup is required. + +### 1. GitHub App — Homebrew tap access + +The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-tap`. This avoids a long-lived personal access token. + +1. Go to **GitHub → APImetrics org settings → Developer settings → GitHub Apps → New GitHub App**. +2. Name it something like `apimetrics-release-bot`. +3. Under **Permissions → Repository permissions**, set **Contents** to `Read & write`. No other permissions needed. +4. Uncheck **Webhooks** (not needed). +5. Set **Where can this GitHub App be installed?** to `Only on this account`. +6. Create the app and note the **App ID**. +7. Under **Private keys**, generate and download a private key (`.pem` file). +8. Go to **Install App** and install it on `APImetrics/homebrew-tap` only. + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `GH_APP_ID` | Numeric App ID shown on the app settings page | +| `GH_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file, including the `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----` header/footer lines | + +--- + +### 2. Apple code signing and notarization + +macOS binaries must be signed and notarized with an Apple Developer ID certificate so they run without Gatekeeper warnings. + +**Prerequisites:** +- An [Apple Developer Program](https://developer.apple.com/programs/) membership. +- A **Developer ID Application** certificate. Export it as a `.p12` file from Keychain Access (include the private key, set a strong export password). +- An [app-specific password](https://support.apple.com/en-us/102654) for the Apple ID used to notarize. + +**Encode the certificate:** + +```bash +base64 -i certificate.p12 | pbcopy # copies base64 to clipboard +``` + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `APPLE_CERT_P12` | Base64-encoded `.p12` certificate (see above) | +| `APPLE_CERT_PASSWORD` | Password set when exporting the `.p12` | +| `KEYCHAIN_PASSWORD` | Any strong random string (used for the temporary CI keychain) | +| `APPLE_SIGNING_IDENTITY` | Certificate common name, e.g. `Developer ID Application: APImetrics Inc (XXXXXXXXXX)` | +| `APPLE_ID` | Apple ID email used for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for that Apple ID | +| `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | + +--- + +### 3. Homebrew tap repository + +The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: + +- The repository exists. +- A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — the release workflow will create `Formula/apimetrics.rb` on first release). +- The GitHub App from step 1 is installed on this repository. + +--- + +### 4. WinGet package + +The workflow uses `wingetcreate` to submit a PR to [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) after each release, keeping the Windows Package Manager entry up to date. + +#### Initial package submission (first release only) + +The package must exist in `microsoft/winget-pkgs` before automated updates can run. Create the initial manifest with `wingetcreate`: + +```powershell +# Download wingetcreate.exe (Windows only — run this on a Windows machine) +Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + +$version = "0.1.0" # set to the first published version +$installerUrl = "https://github.com/APImetrics/APImetrics-cli/releases/download/v$version/apimetrics-$version-windows-amd64.zip" + +.\wingetcreate.exe new $installerUrl ` + --id APIContext.APImetricsCLI ` + --version $version ` + --name "APImetrics CLI" ` + --publisher "APIContext" ` + --token +``` + +Review the generated manifest files, then submit the PR manually. Once merged, subsequent releases are automated. + +#### GitHub PAT for automated updates + +The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. + +- **Classic PAT (recommended):** `public_repo` scope is sufficient. +- **Fine-grained PAT:** grant access to your fork of `winget-pkgs` and enable **Contents: read/write** and **Pull requests: read/write**. Note that `wingetcreate` creates the fork on first run — the fork must exist before you can pre-select it when generating the token. + +**Secret to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `WINGET_GITHUB_TOKEN` | Token used by `wingetcreate` to push to your fork and open PRs on `microsoft/winget-pkgs` | + +--- + +## Complete secrets reference + +All secrets required on `APImetrics/APImetrics-cli`: + +| Secret | Purpose | +|--------|---------| +| `GH_APP_ID` | GitHub App ID for Homebrew tap access | +| `GH_APP_PRIVATE_KEY` | GitHub App private key for Homebrew tap access | +| `APPLE_CERT_P12` | Base64 Apple Developer ID certificate | +| `APPLE_CERT_PASSWORD` | Password for the p12 certificate | +| `KEYCHAIN_PASSWORD` | Temporary CI keychain password | +| `APPLE_SIGNING_IDENTITY` | Apple signing identity string | +| `APPLE_ID` | Apple ID for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for notarization | +| `APPLE_TEAM_ID` | Apple Developer Team ID | +| `WINGET_GITHUB_TOKEN` | PAT for submitting WinGet PRs to `microsoft/winget-pkgs` | + +--- + +## Cutting a release + +Once all secrets are configured: + +```bash +git tag v0.x.y +git push origin v0.x.y +``` + +Monitor progress in the [Actions tab](https://github.com/APImetrics/APImetrics-cli/actions). The full pipeline (Linux build + macOS sign/notarize) takes approximately 20–30 minutes. + +### Snapshot builds (no publish) + +To build all artifacts without publishing anywhere: + +1. Go to **Actions → Release → Run workflow**. +2. Check **Run goreleaser in --snapshot mode**. +3. Run from any branch. + +### Tag pattern note + +The workflow only triggers on `v0.*` tags. This is intentional during initial development to prevent accidental production releases. Update the tag pattern in `.github/workflows/release.yml` to `v*` when ready to ship v1.0. diff --git a/bench_test.go b/bench_test.go index a4ed1a0..2bf729a 100644 --- a/bench_test.go +++ b/bench_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/amzn/ion-go/ion" - "github.com/fxamacker/cbor/v2" "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/openapi" + "github.com/amzn/ion-go/ion" + "github.com/fxamacker/cbor/v2" "github.com/shamaton/msgpack/v2" "github.com/spf13/cobra" ) diff --git a/bulk/commands_test.go b/bulk/commands_test.go index 1d96f0f..3690921 100644 --- a/bulk/commands_test.go +++ b/bulk/commands_test.go @@ -127,6 +127,7 @@ func TestWorkflow(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -377,6 +378,7 @@ func TestPullFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -423,6 +425,7 @@ func TestPushFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== diff --git a/cli/api.go b/cli/api.go index 2c574d9..435cc23 100644 --- a/cli/api.go +++ b/cli/api.go @@ -279,6 +279,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { } api, err := load(root, *opsBase, *resolved, resp, name, l) if err == nil { + api.CLIVersion = root.Version cacheAPI(name, &api) } return api, err diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 789a6af..dbc13b5 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -129,11 +129,11 @@ func findAPI(uri string) (string, *APIConfig) { if strings.HasPrefix(uri, config.Profiles[profile].Base) { return name, config } - } else if strings.HasPrefix(uri, config.Base) { + } else if config.Base != "" && strings.HasPrefix(uri, config.Base) { return name, config } } else { - if strings.HasPrefix(uri, config.Base) { + if config.Base != "" && strings.HasPrefix(uri, config.Base) { // TODO: find the longest matching base? return name, config } diff --git a/cli/apiconfig_test.go b/cli/apiconfig_test.go index e29ed03..ae4114c 100644 --- a/cli/apiconfig_test.go +++ b/cli/apiconfig_test.go @@ -7,70 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIContentTypes(t *testing.T) { - captured := run("api content-types") - assert.Contains(t, captured, "application/json") - assert.Contains(t, captured, "table") - assert.Contains(t, captured, "readable") -} - -func TestAPIShow(t *testing.T) { - for tn, tc := range map[string]struct { - color bool - want string - }{ - "no color": {false, "{\n \"base\": \"https://api.example.com\"\n}\n"}, - "color": {true, "\x1b[38;5;247m{\x1b[0m\n \x1b[38;5;74m\"base\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"https://api.example.com\"\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n"}, - } { - t.Run(tn, func(t *testing.T) { - reset(tc.color) - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - captured := runNoReset("api show test") - assert.Equal(t, captured, tc.want) - }) - } -} - -func TestAPIClearCache(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - - runNoReset("api clear-auth-cache test") - - assert.Equal(t, "", Cache.GetString("test:default.token")) -} - -func TestAPIClearCacheProfile(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - Cache.Set("test:other.token", "def456") - - runNoReset("api clear-auth-cache test -p other") - - assert.Equal(t, "abc123", Cache.GetString("test:default.token")) - assert.Equal(t, "", Cache.GetString("test:other.token")) -} - -func TestAPIClearCacheMissing(t *testing.T) { - reset(false) - - captured := runNoReset("api clear-auth-cache missing-api") - assert.Contains(t, captured, "API missing-api not found") -} - func TestEditAPIsMissingEditor(t *testing.T) { os.Setenv("EDITOR", "") os.Setenv("VISUAL", "") diff --git a/cli/cli.go b/cli/cli.go index 5c4860d..190890b 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -366,6 +366,9 @@ func Run() (returnErr error) { if p := cfg.Profiles[profile]; p != nil && p.Base != "" { currentBase = p.Base } + if currentBase == "" { + continue + } api, err := Load(currentBase, Root) if err != nil { panic(err) diff --git a/cli/cli_test.go b/cli/cli_test.go index 923ee0c..5a76015 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -1,16 +1,13 @@ package cli import ( - "net/http" "os" - "path/filepath" "strings" "testing" "time" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) @@ -30,16 +27,6 @@ func reset(color bool) { Defaults() } -func run(cmd string, color ...bool) string { - if len(color) == 0 || !color[0] { - reset(false) - } else { - reset(true) - } - - return runNoReset(cmd) -} - func runNoReset(cmd string) string { capture := &strings.Builder{} Stdout = capture @@ -51,166 +38,6 @@ func runNoReset(cmd string) string { return capture.String() } -func expectJSON(t *testing.T, cmd string, expected string) { - captured := run("-o json -f body " + cmd) - assert.JSONEq(t, expected, captured) -} - -func expectExitCode(t *testing.T, expected int) { - assert.Equal(t, expected, GetExitCode()) -} - -func TestGetURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "Hello": "World", - }) - - expectJSON(t, "http://example.com/foo", `{ - "Hello": "World" - }`) - expectExitCode(t, 0) -} - -func TestPostURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Post("/foo").Reply(200).JSON(map[string]any{ - "id": 1, - "value": 123, - }) - - expectJSON(t, "post http://example.com/foo value: 123", `{ - "id": 1, - "value": 123 - }`) -} - -func TestPutURI400(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(422).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 4) -} - -func TestIgnoreStatusCodeExit(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(400).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123 --rsh-ignore-status-code", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 0) -} - -func TestHeaderWithComma(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/").MatchHeader("Foo", "a,b,c").Reply(204) - - out := run("http://example.com/ -H Foo:a,b,c") - assert.Contains(t, out, "204 No Content") -} - -type TestAuth struct{} - -// Parameters returns a list of OAuth2 Authorization Code inputs. -func (h *TestAuth) Parameters() []AuthParam { - return []AuthParam{} -} - -// OnRequest gets run before the request goes out on the wire. -func (h *TestAuth) OnRequest(request *http.Request, key string, params map[string]string) error { - request.Header.Set("Authorization", "abc123") - return nil -} - -func TestAuthHeader(t *testing.T) { - reset(false) - - AddAuth("test-auth", &TestAuth{}) - - configs["test-auth-header"] = &APIConfig{ - name: "test-auth-header", - Base: "https://auth-header-test.example.com", - Profiles: map[string]*APIProfile{ - "default": { - Auth: &APIAuth{ - Name: "test-auth", - }, - }, - "no-auth": {}, - }, - } - - captured := runNoReset("auth-header bad-api") - assert.Contains(t, captured, "no matched API") - - captured = runNoReset("auth-header test-auth-header") - assert.Equal(t, "abc123\n", captured) - - captured = runNoReset("auth-header test-auth-header -p bad") - assert.Contains(t, captured, "invalid profile bad") - - captured = runNoReset("auth-header test-auth-header -p no-auth") - assert.Contains(t, captured, "no auth set up") -} - -func TestLinks(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(204).SetHeader("Link", "; rel=\"item\"") - - captured := run("links http://example.com/foo") - assert.JSONEq(t, `{ - "item": [ - { - "rel": "item", - "uri": "http://example.com/bar" - } - ] - }`, captured) -} - -func TestDefaultOutput(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "hello": "world", - }) - - captured := run("http://example.com/foo", true) - assert.Equal(t, "\x1b[38;5;204mHTTP\x1b[0m/\x1b[38;5;172m1.1\x1b[0m \x1b[38;5;172m200\x1b[0m \x1b[38;5;74mOK\x1b[0m\n\x1b[38;5;74mContent-Type\x1b[0m: application/json\n\n\x1b[38;5;172m{\x1b[0m\n \x1b[38;5;74mhello\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"world\"\x1b[0m\x1b[38;5;247m\n\x1b[0m\x1b[38;5;172m}\x1b[0m\n", captured) -} - -func TestHelp(t *testing.T) { - captured := run("--help", false) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - -func TestHelpHighlight(t *testing.T) { - captured := run("--help", true) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - func TestLoadCache(t *testing.T) { // Invalidate any existin cache. Cache.Set("cache-test.expires", time.Now().Add(-24*time.Hour)) @@ -252,129 +79,3 @@ func TestLoadCache(t *testing.T) { runNoReset("cache-test --help") runNoReset("cache-test --help") } - -func TestAPISync(t *testing.T) { - defer gock.Off() - - gock.New("https://sync-test.example.com/").Reply(404) - gock.New("https://sync-test.example.com/openapi.json").Reply(404) - - reset(false) - - configs["sync-test"] = &APIConfig{ - name: "sync-test", - Base: "https://sync-test.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - runNoReset("api sync sync-test") -} - -func TestDuplicateAPIBase(t *testing.T) { - defer func() { - os.Remove(filepath.Join(getConfigDir("test"), "apis.json")) - reset(false) - }() - reset(false) - - configs["dupe1"] = &APIConfig{ - name: "dupe1", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - configs["dupe2"] = &APIConfig{ - name: "dupe2", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - configs["dupe1"].Save() - configs["dupe2"].Save() - - assert.Panics(t, func() { - run("--help") - }) -} - -func TestCompletion(t *testing.T) { - defer gock.Off() - - gock.New("https://api.example.com/").Reply(http.StatusNotFound) - gock.New("https://api.example.com/openapi.json").Reply(http.StatusOK) - - Init("Completion test", "1.0.0") - Defaults() - - configs["comptest"] = &APIConfig{ - name: "comptest", - Base: "https://api.example.com", - } - - Root.AddCommand(&cobra.Command{ - Use: "comptest", - }) - - AddLoader(&testLoader{ - API: API{ - Operations: []Operation{ - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users", - }, - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users/{user-id}", - }, - { - Short: "List item tags", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags", - }, - { - Short: "Get tag details", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - { - Method: http.MethodDelete, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - }, - }, - }) - - // Force a cache-reload if needed. - viper.Set("rsh-no-cache", true) - Load("https://api.example.com/", &cobra.Command{}) - viper.Set("rsh-no-cache", false) - - currentConfig = nil - - // Show APIs - possible, _ := completeGenericCmd(http.MethodGet, true)(nil, []string{}, "") - assert.Equal(t, []string{ - "comptest", - }, possible) - - currentConfig = configs["comptest"] - - // Short-name URL completion with variables filled in. - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "comptest/items/my-item") - assert.Equal(t, []string{ - "comptest/items/my-item/tags\tList item tags", - "comptest/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) - - // URL without scheme - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "api.example.com/items/my-item") - assert.Equal(t, []string{ - "api.example.com/items/my-item/tags\tList item tags", - "api.example.com/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) -} diff --git a/cli/interactive_test.go b/cli/interactive_test.go index fcf378d..dd683cc 100644 --- a/cli/interactive_test.go +++ b/cli/interactive_test.go @@ -40,6 +40,7 @@ func TestInteractive(t *testing.T) { os.Remove(filepath.Join(getConfigDir("test"), "cache.json")) reset(false) + AddAuth("http-basic", &BasicAuth{}) defer gock.Off() diff --git a/cli/request_test.go b/cli/request_test.go index 7aed307..ba9ca61 100644 --- a/cli/request_test.go +++ b/cli/request_test.go @@ -72,6 +72,7 @@ func (a *authHookFailure) OnRequest(req *http.Request, key string, params map[st func TestAuthHookFailure(t *testing.T) { configs["auth-hook-fail"] = &APIConfig{ + Base: "http://auth-hook-fail.example.com", Profiles: map[string]*APIProfile{ "default": { Auth: &APIAuth{ @@ -83,7 +84,7 @@ func TestAuthHookFailure(t *testing.T) { authHandlers["hook-fail"] = &authHookFailure{} - r, _ := http.NewRequest(http.MethodGet, "/test", nil) + r, _ := http.NewRequest(http.MethodGet, "http://auth-hook-fail.example.com/test", nil) assert.PanicsWithError(t, "some-error", func() { MakeRequest(r) }) @@ -170,7 +171,7 @@ func TestRequestRetryAfter(t *testing.T) { Put("/"). Times(1). Reply(http.StatusTooManyRequests). - SetHeader("Retry-After", time.Now().Format(http.TimeFormat)) + SetHeader("Retry-After", time.Now().UTC().Format(http.TimeFormat)) gock.New("http://example.com"). Put("/"). diff --git a/config_dev.go b/config_dev.go new file mode 100644 index 0000000..a6f856d --- /dev/null +++ b/config_dev.go @@ -0,0 +1,9 @@ +//go:build dev && !prod + +package main + +var baseURL = "http://localhost:8080" +var authURL = "https://local-apimetrics.auth0.com/authorize" +var tokenURL = "https://local-apimetrics.auth0.com/oauth/token" +var authAudience = "https://apimetrics-qc.appspot.com" +var clientID = "bpplXMDCn187JipRLl6Y9KrsTVZCJTbS" diff --git a/config_prod.go b/config_prod.go new file mode 100644 index 0000000..ef370a8 --- /dev/null +++ b/config_prod.go @@ -0,0 +1,9 @@ +//go:build prod + +package main + +var baseURL = "https://client.apimetrics.io" +var authURL = "https://auth.apimetrics.io/authorize" +var tokenURL = "https://auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "dPbV4VPvioF4nZ3oGQMn7n1vE2pFNAAI" diff --git a/config_qc.go b/config_qc.go new file mode 100644 index 0000000..2691890 --- /dev/null +++ b/config_qc.go @@ -0,0 +1,9 @@ +//go:build !prod && !dev + +package main + +var baseURL = "https://qc-client.apimetrics.io" +var authURL = "https://qc-auth.apimetrics.io/authorize" +var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "4fhqu4lEH5ExaRh00X1B9WJSkjTnUmuK" diff --git a/main.go b/main.go index fe38132..caed2aa 100644 --- a/main.go +++ b/main.go @@ -7,20 +7,13 @@ import ( "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/oauth" "apicontext.com/apimetrics/openapi" + "apicontext.com/apimetrics/skills" ) var version string = "dev" -var commit string -var date string - -// Build-time API configuration — override with -ldflags at build time: -// -// go build -ldflags "-X main.baseURL=https://client.apimetrics.io ..." -var baseURL = "https://qc-client.apimetrics.io" -var authURL = "https://qc-auth.apimetrics.io/authorize" -var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" -var authAudience = "https://client.apimetrics.io" -var clientID = "bj0yh0AjBMzfeOpffmCj5UP8FbmYDwcM" + +// baseURL, authURL, tokenURL, authAudience, clientID are defined in +// config_qc.go (default) or config_prod.go (build tag: prod). func main() { if version == "dev" { @@ -38,6 +31,7 @@ func main() { cli.Defaults() bulk.Init(cli.Root) + skills.Init(cli.Root) // Register format loaders to auto-discover API descriptions cli.AddLoader(openapi.New()) diff --git a/oauth/authcode.go b/oauth/authcode.go index bde8881..927a6c4 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "html" "net/http" "net/url" "os" @@ -16,111 +17,157 @@ import ( "context" - "github.com/mattn/go-isatty" "apicontext.com/apimetrics/cli" + "github.com/mattn/go-isatty" "golang.org/x/oauth2" ) var htmlSuccess = ` - + + + + + + Login Successful — APImetrics + + + + -
-
-

Login Successful!

- Please return to the terminal. You may now close this window. -

+
+ +
+

Login Successful!

+

Please return to the terminal. You may now close this window.

` var htmlError = ` - + + + + + + Login Failed — APImetrics + + + + -
-
-

Error: $ERROR

- $DETAILS -

+
+ +
+

Login Failed

+

$ERROR
$DETAILS

@@ -187,7 +234,9 @@ func (h authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := r.URL.Query().Get("error"); err != "" { details := r.URL.Query().Get("error_description") - rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", err, 1), "$DETAILS", details, 1) + // Escape the query params before interpolating into HTML to avoid + // reflected XSS via the localhost redirect URL. + rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", html.EscapeString(err), 1), "$DETAILS", html.EscapeString(details), 1) w.Write([]byte(rendered)) h.c <- "" return @@ -285,10 +334,33 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) { } }() - // Open auth URL in browser, print for manual use in case open fails. - fmt.Fprintln(os.Stderr, "Open your browser to log in using the URL:") - fmt.Fprintln(os.Stderr, authorizeURL.String()) - open(authorizeURL.String()) + // Print welcome banner, show login URL, and ask before opening browser. + fmt.Fprint(os.Stderr, ` + _ ___ ___ _ _ + /_\ | _ \_ _|_ __ ___| |_ _ _(_)__ ___ + / _ \| _/| || ' \/ -_| _| '_| / _(_-< + /_/ \_\_| |___|_|_|_\___|\__|_| |_\__/__/ +`) + fmt.Fprintln(os.Stderr, "Welcome to APImetrics CLI!") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To log in, open the following URL in your browser:") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " "+authorizeURL.String()) + fmt.Fprintln(os.Stderr, "") + // Only prompt interactively if stdin is a live terminal. If a file or + // command has been piped in it is likely the request body to use after + // auth, so we must not consume it here (see the manual-code path below). + if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) { + fmt.Fprint(os.Stderr, "Open your browser now? [Y/n]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer == "" || answer == "y" || answer == "yes" { + open(authorizeURL.String()) + } + } else { + open(authorizeURL.String()) + } // Provide a way to manually enter the code, e.g. for remote SSH sessions. // Only read from stdin if it is a live terminal, if a file or command has diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md new file mode 100644 index 0000000..4fb4baf --- /dev/null +++ b/skills/embed/setup-api-monitor.md @@ -0,0 +1,131 @@ +--- +name: setup-api-monitor +description: > + Set up an API monitor (HTTP call) in APImetrics: create the monitor, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an API monitor or API call. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-call <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the API monitor + +```bash +apimetrics create-call <<'EOF' +{ + "meta": { + "name": "" + }, + "request": { + "method": "GET", + "url": "" + } +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +To add request headers or auth: +```bash +apimetrics create-call <<'EOF' +{ + "meta": { "name": "" }, + "request": { + "method": "GET", + "url": "", + "headers": [{"key": "Accept", "value": "application/json"}], + "auth_id": "" + } +} +EOF +``` + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, check that `meta.name`, `request.url`, and `request.method` are all present. + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-call` takes the call ID as a positional argument: +```bash +apimetrics run-call +``` + +The response contains a `result_id`. Save it for the next step. + +**Validation gate:** Response must contain `result_id`. A 422 response means the project is out of quota. + +### 4. Poll results to verify + +```bash +apimetrics list-call-results +``` + +A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `-f` to narrow output: + +```bash +apimetrics list-call-results -f body.results[0] +``` + +**Validation gate:** Confirm `result.success` is `true`. If `false`, inspect `result.failure_reason` and the response body for details. + +## Hard rules + +- Always verify the call ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds. +- `frequency` on schedules is in seconds, not minutes. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. + +## Error recovery + +- **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 60s:** The run may be queued behind other runs. Increase wait time or check monitor status. diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md new file mode 100644 index 0000000..93e36d1 --- /dev/null +++ b/skills/embed/setup-browser-monitor.md @@ -0,0 +1,123 @@ +--- +name: setup-browser-monitor +description: > + Set up a Browser monitor in APImetrics: create the monitor, attach or + create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test a browser monitor. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-browser-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the browser monitor + +```bash +apimetrics create-browser-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +Optional fields: +- `"description"` — human-readable description +- `"browser_types"` — list of browsers the monitor is permitted to run on +- `"tags"` — list of tags for grouping +- `"user_agent"` — override the User-Agent header +- `"workspace"` — workspace ID if using workspaces + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that `url` includes the scheme (`https://`). + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: +```bash +apimetrics run-monitor <<'EOF' +{} +EOF +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued. + +### 4. Poll result to verify + +```bash +apimetrics get-result +``` + +Poll until `result` is no longer `QUEUED`. Wait 10–15 seconds between checks (browser monitors typically complete within 60 seconds). + +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem — inspect the response for details. + +To get a screenshot of the page at the time of the run: +```bash +apimetrics get-result-screenshot +``` + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. +- Browser monitors take longer to complete than API monitors — wait at least 60 seconds before polling results. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `frequency` on schedules is in seconds, not minutes. + +## Error recovery + +- **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`). +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor ` and retry. +- **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md new file mode 100644 index 0000000..fd6df47 --- /dev/null +++ b/skills/embed/setup-mcp-monitor.md @@ -0,0 +1,133 @@ +--- +name: setup-mcp-monitor +description: > + Set up an MCP monitor in APImetrics: create the monitor with session steps, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an MCP monitor. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-mcp-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the MCP monitor + +```bash +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +Optional fields: +- `"steps"` — steps to execute during the MCP session +- `"auth_id"` — Auth Settings ID if the MCP server requires authentication +- `"token_id"` — Auth Token ID for token-based auth +- `"overall_timeout_ms"` — session timeout in milliseconds (default: 30000) +- `"description"` — human-readable description +- `"tags"` — list of tags for grouping +- `"workspace"` — workspace ID if using workspaces + +Example with steps: +```bash +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "Check tools endpoint", + "url": "https://mcp.example.com/sse", + "steps": [{"step_type": "list_tools", "timeout_ms": 10000}] +} +EOF +``` + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that the URL points to a reachable MCP SSE endpoint. + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: +```bash +apimetrics run-monitor <<'EOF' +{} +EOF +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A 422 means the project is out of quota. + +### 4. Poll result to verify + +```bash +apimetrics get-result +``` + +Poll until `result` is no longer `QUEUED`. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks. + +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. +- MCP monitor runs are bounded by `overall_timeout_ms`. Sessions exceeding this limit are terminated and reported as failures. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `frequency` on schedules is in seconds, not minutes. +- The `url` must be an SSE endpoint, not a plain HTTP endpoint. + +## Error recovery + +- **400 on create:** Confirm `name` and `url` are both provided and that the URL is a valid SSE endpoint. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **Session timeout failures:** Increase `overall_timeout_ms` and re-run. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 120s:** Verify the MCP server URL is reachable before retrying. diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..d7163ff --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,246 @@ +package skills + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "apicontext.com/apimetrics/cli" + "github.com/spf13/cobra" +) + +//go:embed embed/*.md +var skillFiles embed.FS + +type agent int + +const ( + agentCustom agent = iota + agentClaudeCode + agentCodex +) + +// Init registers the skills command group and the top-level onboard command. +func Init(cmd *cobra.Command) { + skills := cobra.Command{ + Use: "skills", + Short: "Manage agent skills for APImetrics", + } + + install := cobra.Command{ + Use: "install", + Short: "Install APImetrics skills into an agent skills directory", + Example: " " + os.Args[0] + " skills install --claude-code\n" + + " " + os.Args[0] + " skills install --codex\n" + + " " + os.Args[0] + " skills install --dir ./custom", + RunE: func(cmd *cobra.Command, args []string) error { + claudeCode, _ := cmd.Flags().GetBool("claude-code") + codex, _ := cmd.Flags().GetBool("codex") + dir, _ := cmd.Flags().GetString("dir") + + target, kind, err := resolveTarget(claudeCode, codex, dir) + if err != nil { + return err + } + + return installSkills(target, kind) + }, + } + + install.Flags().Bool("claude-code", false, "Install into .claude/commands/ as slash commands and write .claude/agents.md") + install.Flags().Bool("codex", false, "Install into .codex/skills/") + install.Flags().StringP("dir", "d", "", "Install into a custom directory path") + + onboard := cobra.Command{ + Use: "onboard", + Short: "Print all APImetrics skills to stdout for agent onboarding", + Long: "Prints all embedded skill workflows to stdout.\n" + + "Run this command and include the output in your agent's context\n" + + "to onboard it on the correct APImetrics CLI workflows.", + RunE: func(cmd *cobra.Command, args []string) error { + return printSkills() + }, + } + + skills.AddCommand(&install) + cmd.AddCommand(&skills) + cmd.AddCommand(&onboard) +} + +func resolveTarget(claudeCode, codex bool, dir string) (string, agent, error) { + count := 0 + if claudeCode { + count++ + } + if codex { + count++ + } + if dir != "" { + count++ + } + + if count == 0 { + return "", agentCustom, fmt.Errorf("specify a target: --claude-code, --codex, or --dir ") + } + if count > 1 { + return "", agentCustom, fmt.Errorf("specify only one of --claude-code, --codex, or --dir") + } + + switch { + case claudeCode: + return ".claude/commands", agentClaudeCode, nil + case codex: + return ".codex/skills", agentCodex, nil + default: + return dir, agentCustom, nil + } +} + +func installSkills(target string, kind agent) error { + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", target, err) + } + + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + var names []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + dest := filepath.Join(target, entry.Name()) + if err := os.WriteFile(dest, data, 0644); err != nil { + return fmt.Errorf("writing skill %s: %w", dest, err) + } + + fmt.Fprintf(cli.Stdout, "Installed %s\n", dest) + names = append(names, strings.TrimSuffix(entry.Name(), ".md")) + } + + fmt.Fprintf(cli.Stdout, "\nSkills installed to %s\n", target) + + switch kind { + case agentClaudeCode: + fmt.Fprintf(cli.Stdout, "\nAvailable as slash commands in Claude Code:\n") + for _, name := range names { + fmt.Fprintf(cli.Stdout, " /%s\n", name) + } + if err := writeAgentsMD(names, os.Args[0]); err != nil { + return err + } + if err := updateClaudeMD(); err != nil { + return err + } + case agentCodex: + fmt.Fprintf(cli.Stdout, "\nReference these files from your agent's context configuration.\n") + } + + fmt.Fprintf(cli.Stdout, "\nTo onboard any agent directly, run: %s onboard\n", os.Args[0]) + return nil +} + +func writeAgentsMD(skillNames []string, bin string) error { + const path = ".claude/agents.md" + + content := buildAgentsMD(skillNames, bin) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + + fmt.Fprintf(cli.Stdout, "\nWritten %s\n", path) + return nil +} + +// updateClaudeMD appends an @.claude/agents.md reference to CLAUDE.md if not already present. +func updateClaudeMD() error { + const path = "CLAUDE.md" + const ref = "@.claude/agents.md" + + existing, _ := os.ReadFile(path) + + if bytes.Contains(existing, []byte(ref)) { + return nil + } + + updated := existing + if len(updated) > 0 && updated[len(updated)-1] != '\n' { + updated = append(updated, '\n') + } + if len(updated) > 0 { + updated = append(updated, '\n') + } + updated = append(updated, []byte(ref+"\n")...) + + if err := os.WriteFile(path, updated, 0644); err != nil { + return fmt.Errorf("updating CLAUDE.md: %w", err) + } + + fmt.Fprintf(cli.Stdout, "Added @.claude/agents.md reference to CLAUDE.md\n") + return nil +} + +func buildAgentsMD(skillNames []string, bin string) string { + var b strings.Builder + + b.WriteString("# APImetrics CLI Agent Guide\n\n") + b.WriteString(fmt.Sprintf("Use `%s` for all apimetrics commands in this project.\n\n", bin)) + + b.WriteString("## Prerequisites\n\n") + b.WriteString("Before running any command:\n\n") + b.WriteString(fmt.Sprintf("1. Run `%s project show` to confirm a project is active — all commands fail without one. If none is set, run `%s project select`.\n", bin, bin)) + b.WriteString("2. Invoke the relevant skill for the task.\n\n") + + b.WriteString("## Critical facts\n\n") + b.WriteString("- Commands are flat, not grouped: use `create-call`, not `calls create`\n") + b.WriteString("- All create commands read JSON from stdin using heredoc syntax — there is no `--body`, `--data`, or `-d` flag\n") + b.WriteString(fmt.Sprintf("- Correct pattern: `%s create-call <<'EOF' ... EOF`\n\n", bin)) + + b.WriteString("## Skills\n\n") + for _, name := range skillNames { + b.WriteString(fmt.Sprintf("- `/%s`\n", name)) + } + b.WriteString(fmt.Sprintf("\nIf slash commands are unavailable or you need to refresh context, run:\n\n```bash\n%s onboard\n```\n\nThis works for any agent and always reflects the current skill set.\n", bin)) + + return b.String() +} + +func printSkills() error { + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + first := true + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + if !first { + fmt.Fprintln(cli.Stdout) + } + first = false + + fmt.Fprint(cli.Stdout, string(data)) + } + + return nil +}