Repository: cli/cli
Pull request #8466 (2023-12-15 14:43:53)

build(deps): bump actions/download-artifact from 3 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/download-artifact/releases">actions/download-artifact's releases</a>.</em></p>
<blockquote>
<h2>v4.0.0</h2>
<h2>What's Changed</h2>
<p>The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.</p>
<p>For more information, see the <a href="https://github.com/actions/toolkit/tree/main/packages/artifact"><code>@​actions/artifact</code></a> documentation.</p>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/bflad"><code>@​bflad</code></a> made their first contribution in <a href="https://redirect.github.com/actions/download-artifact/pull/194">actions/download-artifact#194</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/download-artifact/compare/v3...v4.0.0">https://github.com/actions/download-artifact/compare/v3...v4.0.0</a></p>
<h2>v3.0.2</h2>
<ul>
<li>Bump <code>@actions/artifact</code> to v1.1.1 - <a href="https://redirect.github.com/actions/download-artifact/pull/195">actions/download-artifact#195</a></li>
<li>Fixed a bug in Node16 where if an HTTP download finished too quickly (&lt;1ms, e.g. when it's mocked) we attempt to delete a temp file that has not been created yet <a href="hhttps://redirect.github.com/actions/toolkit/pull/1278">actions/toolkit#1278</a></li>
</ul>
<h2>v3.0.1</h2>
<ul>
<li><a href="https://redirect.github.com/actions/download-artifact/pull/178">Bump <code>@​actions/core</code> to 1.10.0</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/download-artifact/commit/7a1cd3216ca9260cd8022db641d960b1db4d1be4"><code>7a1cd32</code></a> Merge pull request <a href="https://redirect.github.com/actions/download-artifact/issues/246">#246</a> from actions/v4-beta</li>
<li><a href="https://github.com/actions/download-artifact/commit/8f32874a49903ea488c5e7d476a9173e8706f409"><code>8f32874</code></a> licensed cache</li>
<li><a href="https://github.com/actions/download-artifact/commit/b5ff8444b1c4fcec8131f3cb1ddade813ddfacb1"><code>b5ff844</code></a> Merge pull request <a href="https://redirect.github.com/actions/download-artifact/issues/245">#245</a> from actions/robherley/v4-documentation</li>
<li><a href="https://github.com/actions/download-artifact/commit/f07a0f73f51b3f1d41667c782c821b9667da9d19"><code>f07a0f7</code></a> Update README.md</li>
<li><a href="https://github.com/actions/download-artifact/commit/7226129829bb686fdff47bd63bbd0d1373993a84"><code>7226129</code></a> update test workflow to use different artifact names for matrix</li>
<li><a href="https://github.com/actions/download-artifact/commit/ada9446619b84dd8a557aaaec3b79b58c4986cdf"><code>ada9446</code></a> update docs and bump <code>@​actions/artifact</code></li>
<li><a href="https://github.com/actions/download-artifact/commit/7eafc8b729ba790ce8f2cee54be8ad6257af4c7c"><code>7eafc8b</code></a> Merge pull request <a href="https://redirect.github.com/actions/download-artifact/issues/244">#244</a> from actions/robherley/bump-toolkit</li>
<li><a href="https://github.com/actions/download-artifact/commit/3132d12662b5915f20cdbf449465896962101abf"><code>3132d12</code></a> consume latest toolkit</li>
<li><a href="https://github.com/actions/download-artifact/commit/5be1d3867182a382bc59f2775e002595f487aa88"><code>5be1d38</code></a> Merge pull request <a href="https://redirect.github.com/actions/download-artifact/issues/243">#243</a> from actions/robherley/v4-beta-updates</li>
<li><a href="https://github.com/actions/download-artifact/commit/465b526e63559575a64716cdbb755bc78dfb263b"><code>465b526</code></a> consume latest <code>@​actions/toolkit</code></li>
<li>Additional commits viewable in <a href="https://github.com/actions/download-artifact/compare/v3...v4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.



Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c9de19da2..e69a804ee 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,5 +1,5 @@
 {
-	"image": "mcr.microsoft.com/devcontainers/go:1.19",
+	"image": "mcr.microsoft.com/devcontainers/go:1.21",
 	"features": {
 		"ghcr.io/devcontainers/features/sshd:1": {}
 	},
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index 39943ce6e..933b219c6 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -1,4 +1,5 @@
 name: Deployment
+run-name: ${{ inputs.tag_name }} / go ${{ inputs.go_version }} / ${{ inputs.environment }}
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref_name }}
@@ -52,7 +53,7 @@ jobs:
         run: |
           go run ./cmd/gen-docs --website --doc-path dist/manual
           tar -czvf dist/manual.tar.gz -C dist -- manual
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: linux
           if-no-files-found: error
@@ -110,7 +111,7 @@ jobs:
         run: |
           shopt -s failglob
           script/sign dist/gh_*_macOS_*.zip
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: macos
           if-no-files-found: error
@@ -130,26 +131,43 @@ jobs:
         uses: actions/setup-go@v5
         with:
           go-version: ${{ inputs.go_version }}
-      - name: Obtain signing certificate
-        id: obtain_cert
-        if: inputs.environment == 'production'
-        shell: bash
-        run: |
-          base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
-          printf "cert-file=%s\n" ".\\cert.pfx" >> $GITHUB_OUTPUT
-        env:
-          CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
       - name: Install GoReleaser
         uses: goreleaser/goreleaser-action@v5
         with:
           version: "~1.17.1"
           install-only: true
+      - name: Install Azure Code Signing Client
+        shell: pwsh
+        env:
+          ACS_DIR: ${{ runner.temp }}\acs
+          ACS_ZIP: ${{ runner.temp }}\acs.zip
+          CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
+        run: |
+          # Download Azure Code Signing client containing the DLL needed for signtool in script/sign
+          Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.43 -OutFile $Env:ACS_ZIP -Verbose
+          Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
+
+          # Generate metadata file for signtool, used in signing box .exe and .msi
+          @{
+            CertificateProfileName = "GitHubInc"
+            CodeSigningAccountName = "GitHubInc"
+            CorrelationId = $Env:CORRELATION_ID
+            Endpoint =  "https://wus.codesigning.azure.net/"
+          } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
+
+      # Azure Code Signing leverages the environment variables for secrets that complement the metadata.json
+      # file generated above (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
+      # For more information, see https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet
       - name: Build release binaries
         shell: bash
         env:
+          AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
+          AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
+          AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
+          DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
+          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
           TAG_NAME: ${{ inputs.tag_name }}
-          CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
-          CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
         run: script/release --local "$TAG_NAME" --platform windows
       - name: Set up MSBuild
         id: setupmsbuild
@@ -184,17 +202,20 @@ jobs:
             esac
             "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
           done
-      - name: Sign MSI
+      - name: Sign .msi release binaries
         if: inputs.environment == 'production'
         shell: pwsh
+        env:
+          AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
+          AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
+          AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
+          DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
+          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
         run: |
           Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
-              .\script\sign $_.FullName
+            .\script\sign.ps1 $_.FullName
           }
-        env:
-          CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
-          CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: windows
           if-no-files-found: error
@@ -212,7 +233,7 @@ jobs:
       - name: Checkout cli/cli
         uses: actions/checkout@v4
       - name: Merge built artifacts
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
       - name: Checkout documentation site
         uses: actions/checkout@v4
         with:
diff --git a/.goreleaser-hsm.yml b/.goreleaser-hsm.yml
deleted file mode 100644
index e469af091..000000000
--- a/.goreleaser-hsm.yml
+++ /dev/null
@@ -1,98 +0,0 @@
-project_name: gh
-
-release:
-  prerelease: auto
-  draft: true # we only publish after the Windows MSI gets uploaded
-  name_template: "GitHub CLI {{.Version}}"
-
-before:
-  hooks:
-    - >-
-      {{ if eq .Runtime.Goos "windows" }}echo{{ end }} make manpages GH_VERSION={{.Version}}
-    - >-
-      {{ if ne .Runtime.Goos "linux" }}echo{{ end }} make completions
-
-builds:
-  - id: macos #build:macos
-    goos: [darwin]
-    goarch: [amd64, arm64]
-    hooks:
-      post:
-        - cmd: ./script/sign '{{ .Path }}'
-          output: true
-    binary: bin/gh
-    main: ./cmd/gh
-    ldflags:
-      - -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
-
-  - id: linux #build:linux
-    goos: [linux]
-    goarch: [386, arm, amd64, arm64]
-    env:
-      - CGO_ENABLED=0
-    binary: bin/gh
-    main: ./cmd/gh
-    ldflags:
-      - -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
-
-  - id: windows #build:windows
-    goos: [windows]
-    goarch: [386, amd64, arm64]
-    hooks:
-      post:
-        - cmd: >-
-            {{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ else }}./script/sign{{ end }} '{{ .Path }}'
-          output: true
-    binary: bin/gh
-    main: ./cmd/gh
-    ldflags:
-      - -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
-
-archives:
-  - id: linux-archive
-    builds: [linux]
-    name_template: "gh_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
-    wrap_in_directory: true
-    format: tar.gz
-    rlcp: true
-    files:
-      - LICENSE
-      - ./share/man/man1/gh*.1
-  - id: macos-archive
-    builds: [macos]
-    name_template: "gh_{{ .Version }}_macOS_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
-    wrap_in_directory: true
-    format: zip
-    rlcp: true
-    files:
-      - LICENSE
-      - ./share/man/man1/gh*.1
-  - id: windows-archive
-    builds: [windows]
-    name_template: "gh_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
-    wrap_in_directory: false
-    format: zip
-    rlcp: true
-    files:
-      - LICENSE
-
-nfpms: #build:linux
-  - license: MIT
-    maintainer: GitHub
-    homepage: https://github.com/cli/cli
-    bindir: /usr
-    dependencies:
-      - git
-    description: GitHub’s official command line tool.
-    formats:
-      - deb
-      - rpm
-    contents:
-      - src: "./share/man/man1/gh*.1"
-        dst: "/usr/share/man/man1"
-      - src: "./share/bash-completion/completions/gh"
-        dst: "/usr/share/bash-completion/completions/gh"
-      - src: "./share/fish/vendor_completions.d/gh.fish"
-        dst: "/usr/share/fish/vendor_completions.d/gh.fish"
-      - src: "./share/zsh/site-functions/_gh"
-        dst: "/usr/share/zsh/site-functions/_gh"
diff --git a/.goreleaser.yml b/.goreleaser.yml
index f441156e3..e469af091 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -41,7 +41,7 @@ builds:
     hooks:
       post:
         - cmd: >-
-            {{ if eq .Runtime.Goos "windows" }}.\script\sign{{ else }}./script/sign{{ end }} '{{ .Path }}'
+            {{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ else }}./script/sign{{ end }} '{{ .Path }}'
           output: true
     binary: bin/gh
     main: ./cmd/gh
diff --git a/README.md b/README.md
index 7433e6e64..6e0c7de6a 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ If you are a hubber and are interested in shipping new commands for the CLI, che
 
 ### macOS
 
-`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][].
+`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], [Webi][], and as a downloadable binary from the [releases page][].
 
 #### Homebrew
 
@@ -49,19 +49,27 @@ Additional Conda installation options available on the [gh-feedstock page](https
 | ------------------ | ---------------------------------------- |
 | `spack install gh` | `spack uninstall gh && spack install gh` |
 
+#### Webi
+
+| Install:                            | Upgrade:         |
+| ----------------------------------- | ---------------- |
+| `curl -sS https://webi.sh/gh \| sh` | `webi gh@stable` |
+
+For more information about the Webi installer see [its homepage](https://webinstall.dev/).
+
 ### Linux & BSD
 
 `gh` is available via:
 - [our Debian and RPM repositories](./docs/install_linux.md);
 - community-maintained repositories in various Linux distros;
-- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), and [Spack](#spack); and
+- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), [Webi](#webi); and
 - our [releases page][] as precompiled binaries.
 
 For more information, see [Linux & BSD installation](./docs/install_linux.md).
 
 ### Windows
 
-`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI.
+`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), [Webi](#webi), and as downloadable MSI.
 
 #### WinGet
 
@@ -125,6 +133,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
 [Chocolatey]: https://chocolatey.org
 [Conda]: https://docs.conda.io/en/latest/
 [Spack]: https://spack.io
+[Webi]: https://webinstall.dev
 [releases page]: https://github.com/cli/cli/releases/latest
 [hub]: https://github.com/github/hub
 [contributing]: ./.github/CONTRIBUTING.md
diff --git a/go.mod b/go.mod
index b1220652d..6e8ae3238 100644
--- a/go.mod
+++ b/go.mod
@@ -36,10 +36,10 @@ require (
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.8.4
 	github.com/zalando/go-keyring v0.2.3
-	golang.org/x/crypto v0.14.0
+	golang.org/x/crypto v0.17.0
 	golang.org/x/sync v0.1.0
-	golang.org/x/term v0.13.0
-	golang.org/x/text v0.13.0
+	golang.org/x/term v0.15.0
+	golang.org/x/text v0.14.0
 	google.golang.org/grpc v1.56.3
 	google.golang.org/protobuf v1.30.0
 	gopkg.in/h2non/gock.v1 v1.1.2
@@ -83,7 +83,7 @@ require (
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 	golang.org/x/net v0.17.0 // indirect
-	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/sys v0.15.0 // indirect
 	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )
diff --git a/go.sum b/go.sum
index fce90ec3e..d7ae9a30a 100644
--- a/go.sum
+++ b/go.sum
@@ -174,8 +174,8 @@ github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97
 github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
-golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -200,19 +200,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
-golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go
index 11f75a753..87ac5826b 100644
--- a/pkg/cmd/browse/browse.go
+++ b/pkg/cmd/browse/browse.go
@@ -67,6 +67,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
 			$ gh browse
 			#=> Open the home page of the current repository
 
+			$ gh browse script/
+			#=> Open the script directory of the current repository
+
 			$ gh browse 217
 			#=> Open issue or pull request 217
 
diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go
index 99d1f339c..fc70febee 100644
--- a/pkg/cmd/codespace/common.go
+++ b/pkg/cmd/codespace/common.go
@@ -114,10 +114,11 @@ func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace, i
 		},
 	}
 
+	prompter := &Prompter{}
 	var answers struct {
 		Codespace int
 	}
-	if err := ask(csSurvey, &answers); err != nil {
+	if err := prompter.Ask(csSurvey, &answers); err != nil {
 		return nil, fmt.Errorf("error getting answers: %w", err)
 	}
 
@@ -145,9 +146,15 @@ func safeClose(closer io.Closer, err *error) {
 // It is not portable to assume stdin/stdout are fds 0 and 1.
 var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
 
+type SurveyPrompter interface {
+	Ask(qs []*survey.Question, response interface{}) error
+}
+
+type Prompter struct{}
+
 // ask asks survey questions on the terminal, using standard options.
 // It fails unless hasTTY, but ideally callers should avoid calling it in that case.
-func ask(qs []*survey.Question, response interface{}) error {
+func (p *Prompter) Ask(qs []*survey.Question, response interface{}) error {
 	if !hasTTY {
 		return fmt.Errorf("no terminal")
 	}
diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go
index 821faa10b..67eb5461e 100644
--- a/pkg/cmd/codespace/create.go
+++ b/pkg/cmd/codespace/create.go
@@ -140,6 +140,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 		return a.browser.Browse(fmt.Sprintf("%s/codespaces/new", a.apiClient.ServerURL()))
 	}
 
+	prompter := &Prompter{}
 	promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb
 	if promptForRepoAndBranch {
 		var defaultRepo string
@@ -167,7 +168,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 				Validate: survey.Required,
 			},
 		}
-		if err := ask(repoQuestions, &userInputs); err != nil {
+		if err := prompter.Ask(repoQuestions, &userInputs); err != nil {
 			return fmt.Errorf("failed to prompt: %w", err)
 		}
 	}
@@ -212,7 +213,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 			},
 		}
 
-		if err := ask(branchQuestions, &userInputs); err != nil {
+		if err := prompter.Ask(branchQuestions, &userInputs); err != nil {
 			return fmt.Errorf("failed to prompt: %w", err)
 		}
 	}
@@ -259,7 +260,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 					},
 				}
 
-				if err := ask([]*survey.Question{devContainerPathQuestion}, &devContainerPath); err != nil {
+				if err := prompter.Ask([]*survey.Question{devContainerPathQuestion}, &devContainerPath); err != nil {
 					return fmt.Errorf("failed to prompt: %w", err)
 				}
 			}
@@ -277,7 +278,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 	// web UI also provide a way to select machine type
 	// therefore we let the user choose from the web UI instead of prompting from CLI
 	if !(opts.useWeb && opts.machine == "") {
-		machine, err = getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
+		machine, err = getMachineName(ctx, a.apiClient, prompter, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
 		if err != nil {
 			return fmt.Errorf("error getting machine type: %w", err)
 		}
@@ -320,7 +321,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 			return fmt.Errorf("error creating codespace: %w", err)
 		}
 
-		codespace, err = a.handleAdditionalPermissions(ctx, createParams, aerr.AllowPermissionsURL)
+		codespace, err = a.handleAdditionalPermissions(ctx, prompter, createParams, aerr.AllowPermissionsURL)
 		if err != nil {
 			// this error could be a cmdutil.SilentError (in the case that the user opened the browser) so we don't want to wrap it
 			return err
@@ -344,7 +345,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
 	return nil
 }
 
-func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api.CreateCodespaceParams, allowPermissionsURL string) (*api.Codespace, error) {
+func (a *App) handleAdditionalPermissions(ctx context.Context, prompter SurveyPrompter, createParams *api.CreateCodespaceParams, allowPermissionsURL string) (*api.Codespace, error) {
 	var (
 		isInteractive = a.io.CanPrompt()
 		cs            = a.io.ColorScheme()
@@ -379,7 +380,7 @@ func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api
 		Accept string
 	}
 
-	if err := ask(permsSurvey, &answers); err != nil {
+	if err := prompter.Ask(permsSurvey, &answers); err != nil {
 		return nil, fmt.Errorf("error getting answers: %w", err)
 	}
 
@@ -393,12 +394,12 @@ func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api
 		if err := a.pollForPermissions(ctx, createParams); err != nil {
 			return nil, fmt.Errorf("error polling for permissions: %w", err)
 		}
+	} else {
+		// If the user chose to create the codespace without the permissions,
+		// we can continue with the create opting out of the additional permissions
+		createParams.PermissionsOptOut = true
 	}
 
-	// if the user chose to create the codespace without the permissions,
-	// we can continue with the create opting out of the additional permissions
-	createParams.PermissionsOptOut = true
-
 	var codespace *api.Codespace
 	err := a.RunWithProgress("Creating codespace", func() (err error) {
 		codespace, err = a.apiClient.CreateCodespace(ctx, createParams)
@@ -510,7 +511,7 @@ func (a *App) showStatus(ctx context.Context, codespace *api.Codespace) error {
 }
 
 // getMachineName prompts the user to select the machine type, or validates the machine if non-empty.
-func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string, devcontainerPath string) (string, error) {
+func getMachineName(ctx context.Context, apiClient apiClient, prompter SurveyPrompter, repoID int, machine, branch, location string, devcontainerPath string) (string, error) {
 	machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location, devcontainerPath)
 	if err != nil {
 		return "", fmt.Errorf("error requesting machine instance types: %w", err)
@@ -561,7 +562,7 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin
 	}
 
 	var machineAnswers struct{ Machine string }
-	if err := ask(machineSurvey, &machineAnswers); err != nil {
+	if err := prompter.Ask(machineSurvey, &machineAnswers); err != nil {
 		return "", fmt.Errorf("error getting machine: %w", err)
 	}
 
diff --git a/pkg/cmd/codespace/delete.go b/pkg/cmd/codespace/delete.go
index e54bf09fb..5da24a634 100644
--- a/pkg/cmd/codespace/delete.go
+++ b/pkg/cmd/codespace/delete.go
@@ -220,6 +220,7 @@ func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool
 type surveyPrompter struct{}
 
 func (p *surveyPrompter) Confirm(message string) (bool, error) {
+	prompter := &Prompter{}
 	var confirmed struct {
 		Confirmed bool
 	}
@@ -231,7 +232,7 @@ func (p *surveyPrompter) Confirm(message string) (bool, error) {
 			},
 		},
 	}
-	if err := ask(q, &confirmed); err != nil {
+	if err := prompter.Ask(q, &confirmed); err != nil {
 		return false, fmt.Errorf("failed to prompt: %w", err)
 	}
 
diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go
index cf374ec1f..4384baaf0 100644
--- a/pkg/cmd/config/config.go
+++ b/pkg/cmd/config/config.go
@@ -18,7 +18,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
 	longDoc.WriteString("Display or change configuration settings for gh.\n\n")
 	longDoc.WriteString("Current respected settings:\n")
 	for _, co := range config.ConfigOptions() {
-		longDoc.WriteString(fmt.Sprintf("- %s: %s", co.Key, co.Description))
+		longDoc.WriteString(fmt.Sprintf("- `%s`: %s", co.Key, co.Description))
 		if co.DefaultValue != "" {
 			longDoc.WriteString(fmt.Sprintf(" (default: %q)", co.DefaultValue))
 		}
diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go
index f4016fe50..d24278a23 100644
--- a/pkg/cmd/project/close/close.go
+++ b/pkg/cmd/project/close/close.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -19,7 +18,7 @@ type closeOpts struct {
 	owner     string
 	reopen    bool
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type closeConfig struct {
@@ -78,7 +77,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(config closeConfig) error) *cobra
 
 	closeCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	closeCmd.Flags().BoolVar(&opts.reopen, "undo", false, "Reopen a closed project")
-	closeCmd.Flags().StringVar(&opts.format, "format", "", "Output format, must be 'json'")
+	cmdutil.AddFormatFlags(closeCmd, &opts.exporter)
 
 	return closeCmd
 }
@@ -103,8 +102,8 @@ func runClose(config closeConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.UpdateProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.UpdateProjectV2.ProjectV2)
@@ -132,12 +131,3 @@ func printResults(config closeConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
-
-func printJSON(config closeConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go
index d3e07424b..63047f0a3 100644
--- a/pkg/cmd/project/copy/copy.go
+++ b/pkg/cmd/project/copy/copy.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -22,7 +21,7 @@ type copyOpts struct {
 	sourceOwner        string
 	targetOwner        string
 	title              string
-	format             string
+	exporter           cmdutil.Exporter
 }
 
 type copyConfig struct {
@@ -79,7 +78,7 @@ func NewCmdCopy(f *cmdutil.Factory, runF func(config copyConfig) error) *cobra.C
 	copyCmd.Flags().StringVar(&opts.targetOwner, "target-owner", "", "Login of the target owner. Use \"@me\" for the current user.")
 	copyCmd.Flags().StringVar(&opts.title, "title", "", "Title for the new project")
 	copyCmd.Flags().BoolVar(&opts.includeDraftIssues, "drafts", false, "Include draft issues when copying")
-	cmdutil.StringEnumFlag(copyCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(copyCmd, &opts.exporter)
 
 	_ = copyCmd.MarkFlagRequired("title")
 
@@ -113,8 +112,8 @@ func runCopy(config copyConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.CopyProjectV2.ProjectV2)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.CopyProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.CopyProjectV2.ProjectV2)
@@ -142,12 +141,3 @@ func printResults(config copyConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
-
-func printJSON(config copyConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go
index 894a6e9ce..a261cb600 100644
--- a/pkg/cmd/project/create/create.go
+++ b/pkg/cmd/project/create/create.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -14,10 +13,10 @@ import (
 )
 
 type createOpts struct {
-	title   string
-	owner   string
-	ownerID string
-	format  string
+	title    string
+	owner    string
+	ownerID  string
+	exporter cmdutil.Exporter
 }
 
 type createConfig struct {
@@ -63,7 +62,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(config createConfig) error) *cob
 
 	createCmd.Flags().StringVar(&opts.title, "title", "", "Title for the project")
 	createCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
-	cmdutil.StringEnumFlag(createCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(createCmd, &opts.exporter)
 
 	_ = createCmd.MarkFlagRequired("title")
 
@@ -85,8 +84,8 @@ func runCreate(config createConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.CreateProjectV2.ProjectV2)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.CreateProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.CreateProjectV2.ProjectV2)
@@ -113,13 +112,3 @@ func printResults(config createConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
-
-func printJSON(config createConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go
index 674c30a97..4848a52ae 100644
--- a/pkg/cmd/project/delete/delete.go
+++ b/pkg/cmd/project/delete/delete.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -18,7 +17,7 @@ type deleteOpts struct {
 	owner     string
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type deleteConfig struct {
@@ -72,7 +71,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(config deleteConfig) error) *cob
 	}
 
 	deleteCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
-	cmdutil.StringEnumFlag(deleteCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(deleteCmd, &opts.exporter)
 
 	return deleteCmd
 }
@@ -96,8 +95,8 @@ func runDelete(config deleteConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.DeleteProject.Project)
 	}
 
 	return printResults(config, query.DeleteProject.Project)
@@ -124,13 +123,3 @@ func printResults(config deleteConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "Deleted project %d\n", project.Number)
 	return err
 }
-
-func printJSON(config deleteConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go
index 92fee3284..f303ce357 100644
--- a/pkg/cmd/project/edit/edit.go
+++ b/pkg/cmd/project/edit/edit.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -22,7 +21,7 @@ type editOpts struct {
 	visibility       string
 	shortDescription string
 	projectID        string
-	format           string
+	exporter         cmdutil.Exporter
 }
 
 type editConfig struct {
@@ -86,7 +85,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(config editConfig) error) *cobra.C
 	editCmd.Flags().StringVar(&opts.title, "title", "", "New title for the project")
 	editCmd.Flags().StringVar(&opts.readme, "readme", "", "New readme for the project")
 	editCmd.Flags().StringVarP(&opts.shortDescription, "description", "d", "", "New description of the project")
-	cmdutil.StringEnumFlag(editCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(editCmd, &opts.exporter)
 
 	return editCmd
 }
@@ -110,8 +109,8 @@ func runEdit(config editConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.UpdateProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.UpdateProjectV2.ProjectV2)
@@ -153,13 +152,3 @@ func printResults(config editConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
-
-func printJSON(config editConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/field-create/field_create.go b/pkg/cmd/project/field-create/field_create.go
index d369148ba..56a305480 100644
--- a/pkg/cmd/project/field-create/field_create.go
+++ b/pkg/cmd/project/field-create/field_create.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -21,7 +20,7 @@ type createFieldOpts struct {
 	singleSelectOptions []string
 	number              int32
 	projectID           string
-	format              string
+	exporter            cmdutil.Exporter
 }
 
 type createFieldConfig struct {
@@ -85,7 +84,7 @@ func NewCmdCreateField(f *cmdutil.Factory, runF func(config createFieldConfig) e
 	createFieldCmd.Flags().StringVar(&opts.name, "name", "", "Name of the new field")
 	cmdutil.StringEnumFlag(createFieldCmd, &opts.dataType, "data-type", "", "", []string{"TEXT", "SINGLE_SELECT", "DATE", "NUMBER"}, "DataType of the new field.")
 	createFieldCmd.Flags().StringSliceVar(&opts.singleSelectOptions, "single-select-options", []string{}, "Options for SINGLE_SELECT data type")
-	cmdutil.StringEnumFlag(createFieldCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(createFieldCmd, &opts.exporter)
 
 	_ = createFieldCmd.MarkFlagRequired("name")
 	_ = createFieldCmd.MarkFlagRequired("data-type")
@@ -113,8 +112,8 @@ func runCreateField(config createFieldConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.CreateProjectV2Field.Field)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.CreateProjectV2Field.Field)
 	}
 
 	return printResults(config, query.CreateProjectV2Field.Field)
@@ -151,13 +150,3 @@ func printResults(config createFieldConfig, field queries.ProjectField) error {
 	_, err := fmt.Fprintf(config.io.Out, "Created field\n")
 	return err
 }
-
-func printJSON(config createFieldConfig, field queries.ProjectField) error {
-	b, err := format.JSONProjectField(field)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/field-delete/field_delete.go b/pkg/cmd/project/field-delete/field_delete.go
index 3d3bb25ce..f8b97826b 100644
--- a/pkg/cmd/project/field-delete/field_delete.go
+++ b/pkg/cmd/project/field-delete/field_delete.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -13,8 +12,8 @@ import (
 )
 
 type deleteFieldOpts struct {
-	fieldID string
-	format  string
+	fieldID  string
+	exporter cmdutil.Exporter
 }
 
 type deleteFieldConfig struct {
@@ -55,7 +54,7 @@ func NewCmdDeleteField(f *cmdutil.Factory, runF func(config deleteFieldConfig) e
 	}
 
 	deleteFieldCmd.Flags().StringVar(&opts.fieldID, "id", "", "ID of the field to delete")
-	cmdutil.StringEnumFlag(deleteFieldCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(deleteFieldCmd, &opts.exporter)
 
 	_ = deleteFieldCmd.MarkFlagRequired("id")
 
@@ -70,8 +69,8 @@ func runDeleteField(config deleteFieldConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.DeleteProjectV2Field.Field)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.DeleteProjectV2Field.Field)
 	}
 
 	return printResults(config, query.DeleteProjectV2Field.Field)
@@ -93,13 +92,3 @@ func printResults(config deleteFieldConfig, field queries.ProjectField) error {
 	_, err := fmt.Fprintf(config.io.Out, "Deleted field\n")
 	return err
 }
-
-func printJSON(config deleteFieldConfig, field queries.ProjectField) error {
-	b, err := format.JSONProjectField(field)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/field-list/field_list.go b/pkg/cmd/project/field-list/field_list.go
index f15369c94..11964cb52 100644
--- a/pkg/cmd/project/field-list/field_list.go
+++ b/pkg/cmd/project/field-list/field_list.go
@@ -7,7 +7,6 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/tableprinter"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -15,10 +14,10 @@ import (
 )
 
 type listOpts struct {
-	limit  int
-	owner  string
-	number int32
-	format string
+	limit    int
+	owner    string
+	number   int32
+	exporter cmdutil.Exporter
 }
 
 type listConfig struct {
@@ -66,7 +65,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
 	}
 
 	listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
-	cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(listCmd, &opts.exporter)
 	listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of fields to fetch")
 
 	return listCmd
@@ -94,8 +93,8 @@ func runList(config listConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, project.Fields)
 	}
 
 	return printResults(config, project.Fields.Nodes, owner.Login)
@@ -117,13 +116,3 @@ func printResults(config listConfig, fields []queries.ProjectField, login string
 
 	return tp.Render()
 }
-
-func printJSON(config listConfig, project *queries.Project) error {
-	b, err := format.JSONProjectFields(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/item-add/item_add.go b/pkg/cmd/project/item-add/item_add.go
index 51c9ecfce..5e4eeff10 100644
--- a/pkg/cmd/project/item-add/item_add.go
+++ b/pkg/cmd/project/item-add/item_add.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -20,7 +19,7 @@ type addItemOpts struct {
 	itemURL   string
 	projectID string
 	itemID    string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type addItemConfig struct {
@@ -75,7 +74,7 @@ func NewCmdAddItem(f *cmdutil.Factory, runF func(config addItemConfig) error) *c
 
 	addItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	addItemCmd.Flags().StringVar(&opts.itemURL, "url", "", "URL of the issue or pull request to add to the project")
-	cmdutil.StringEnumFlag(addItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(addItemCmd, &opts.exporter)
 
 	_ = addItemCmd.MarkFlagRequired("url")
 
@@ -107,8 +106,8 @@ func runAddItem(config addItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.CreateProjectItem.ProjectV2Item)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.CreateProjectItem.ProjectV2Item)
 	}
 
 	return printResults(config, query.CreateProjectItem.ProjectV2Item)
@@ -132,13 +131,3 @@ func printResults(config addItemConfig, item queries.ProjectItem) error {
 	_, err := fmt.Fprintf(config.io.Out, "Added item\n")
 	return err
 }
-
-func printJSON(config addItemConfig, item queries.ProjectItem) error {
-	b, err := format.JSONProjectItem(item)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/item-archive/item_archive.go b/pkg/cmd/project/item-archive/item_archive.go
index 7ae336242..18a820e86 100644
--- a/pkg/cmd/project/item-archive/item_archive.go
+++ b/pkg/cmd/project/item-archive/item_archive.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -20,7 +19,7 @@ type archiveItemOpts struct {
 	undo      bool
 	itemID    string
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type archiveItemConfig struct {
@@ -82,7 +81,7 @@ func NewCmdArchiveItem(f *cmdutil.Factory, runF func(config archiveItemConfig) e
 	archiveItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	archiveItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to archive")
 	archiveItemCmd.Flags().BoolVar(&opts.undo, "undo", false, "Unarchive an item")
-	cmdutil.StringEnumFlag(archiveItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(archiveItemCmd, &opts.exporter)
 
 	_ = archiveItemCmd.MarkFlagRequired("id")
 
@@ -109,8 +108,8 @@ func runArchiveItem(config archiveItemConfig) error {
 			return err
 		}
 
-		if config.opts.format == "json" {
-			return printJSON(config, query.UnarchiveProjectItem.ProjectV2Item)
+		if config.opts.exporter != nil {
+			return config.opts.exporter.Write(config.io, query.UnarchiveProjectItem.ProjectV2Item)
 		}
 
 		return printResults(config, query.UnarchiveProjectItem.ProjectV2Item)
@@ -121,8 +120,8 @@ func runArchiveItem(config archiveItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.ArchiveProjectItem.ProjectV2Item)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.ArchiveProjectItem.ProjectV2Item)
 	}
 
 	return printResults(config, query.ArchiveProjectItem.ProjectV2Item)
@@ -159,13 +158,3 @@ func printResults(config archiveItemConfig, item queries.ProjectItem) error {
 	_, err := fmt.Fprintf(config.io.Out, "Archived item\n")
 	return err
 }
-
-func printJSON(config archiveItemConfig, item queries.ProjectItem) error {
-	b, err := format.JSONProjectItem(item)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/item-create/item_create.go b/pkg/cmd/project/item-create/item_create.go
index 6ee32e9f6..d1775172d 100644
--- a/pkg/cmd/project/item-create/item_create.go
+++ b/pkg/cmd/project/item-create/item_create.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -20,7 +19,7 @@ type createItemOpts struct {
 	owner     string
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type createItemConfig struct {
@@ -76,7 +75,7 @@ func NewCmdCreateItem(f *cmdutil.Factory, runF func(config createItemConfig) err
 	createItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	createItemCmd.Flags().StringVar(&opts.title, "title", "", "Title for the draft issue")
 	createItemCmd.Flags().StringVar(&opts.body, "body", "", "Body for the draft issue")
-	cmdutil.StringEnumFlag(createItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(createItemCmd, &opts.exporter)
 
 	_ = createItemCmd.MarkFlagRequired("title")
 
@@ -103,8 +102,8 @@ func runCreateItem(config createItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, query.CreateProjectDraftItem.ProjectV2Item)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.CreateProjectDraftItem.ProjectV2Item)
 	}
 
 	return printResults(config, query.CreateProjectDraftItem.ProjectV2Item)
@@ -128,13 +127,3 @@ func printResults(config createItemConfig, item queries.ProjectItem) error {
 	_, err := fmt.Fprintf(config.io.Out, "Created item\n")
 	return err
 }
-
-func printJSON(config createItemConfig, item queries.ProjectItem) error {
-	b, err := format.JSONProjectItem(item)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/item-delete/item_delete.go b/pkg/cmd/project/item-delete/item_delete.go
index 2717afedc..fc0d7c5ea 100644
--- a/pkg/cmd/project/item-delete/item_delete.go
+++ b/pkg/cmd/project/item-delete/item_delete.go
@@ -18,7 +18,7 @@ type deleteItemOpts struct {
 	number    int32
 	itemID    string
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type deleteItemConfig struct {
@@ -73,7 +73,7 @@ func NewCmdDeleteItem(f *cmdutil.Factory, runF func(config deleteItemConfig) err
 
 	deleteItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	deleteItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to delete")
-	cmdutil.StringEnumFlag(deleteItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(deleteItemCmd, &opts.exporter)
 
 	_ = deleteItemCmd.MarkFlagRequired("id")
 
@@ -99,7 +99,7 @@ func runDeleteItem(config deleteItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.DeleteProjectItem.DeletedItemId)
 	}
 
@@ -125,7 +125,9 @@ func printResults(config deleteItemConfig) error {
 	return err
 }
 
-func printJSON(config deleteItemConfig, ID githubv4.ID) error {
-	_, err := config.io.Out.Write([]byte(fmt.Sprintf(`{"id": "%s"}`, ID)))
-	return err
+func printJSON(config deleteItemConfig, id githubv4.ID) error {
+	m := map[string]interface{}{
+		"id": id,
+	}
+	return config.opts.exporter.Write(config.io, m)
 }
diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go
index dcddac8f9..63dcb3b39 100644
--- a/pkg/cmd/project/item-edit/item_edit.go
+++ b/pkg/cmd/project/item-edit/item_edit.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -30,7 +29,7 @@ type editItemOpts struct {
 	iterationID          string
 	clear                bool
 	// format
-	format string
+	exporter cmdutil.Exporter
 }
 
 type editItemConfig struct {
@@ -116,7 +115,7 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error)
 	}
 
 	editItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to edit")
-	cmdutil.StringEnumFlag(editItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(editItemCmd, &opts.exporter)
 
 	editItemCmd.Flags().StringVar(&opts.title, "title", "", "Title of the draft issue item")
 	editItemCmd.Flags().StringVar(&opts.body, "body", "", "Body of the draft issue item")
@@ -219,15 +218,6 @@ func printDraftIssueResults(config editItemConfig, item queries.DraftIssue) erro
 	return err
 }
 
-func printDraftIssueJSON(config editItemConfig, item queries.DraftIssue) error {
-	b, err := format.JSONProjectDraftIssue(item)
-	if err != nil {
-		return err
-	}
-	_, err = config.io.Out.Write(b)
-	return err
-}
-
 func printItemResults(config editItemConfig, item *queries.ProjectItem) error {
 	if !config.io.IsStdoutTTY() {
 		return nil
@@ -236,16 +226,6 @@ func printItemResults(config editItemConfig, item *queries.ProjectItem) error {
 	return err
 }
 
-func printItemJSON(config editItemConfig, item *queries.ProjectItem) error {
-	b, err := format.JSONProjectItem(*item)
-	if err != nil {
-		return err
-	}
-	_, err = config.io.Out.Write(b)
-	return err
-
-}
-
 func clearItemFieldValue(config editItemConfig) error {
 	if err := fieldIdAndProjectIdPresence(config); err != nil {
 		return err
@@ -256,8 +236,8 @@ func clearItemFieldValue(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printItemJSON(config, &query.Clear.Item)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, &query.Clear.Item)
 	}
 
 	return printItemResults(config, &query.Clear.Item)
@@ -275,8 +255,8 @@ func updateDraftIssue(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printDraftIssueJSON(config, query.UpdateProjectV2DraftIssue.DraftIssue)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.UpdateProjectV2DraftIssue.DraftIssue)
 	}
 
 	return printDraftIssueResults(config, query.UpdateProjectV2DraftIssue.DraftIssue)
@@ -302,8 +282,8 @@ func updateItemValues(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printItemJSON(config, &query.Update.Item)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, &query.Update.Item)
 	}
 
 	return printItemResults(config, &query.Update.Item)
diff --git a/pkg/cmd/project/item-list/item_list.go b/pkg/cmd/project/item-list/item_list.go
index 45ca0c77f..d62eb2716 100644
--- a/pkg/cmd/project/item-list/item_list.go
+++ b/pkg/cmd/project/item-list/item_list.go
@@ -7,7 +7,6 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/tableprinter"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -15,10 +14,10 @@ import (
 )
 
 type listOpts struct {
-	limit  int
-	owner  string
-	number int32
-	format string
+	limit    int
+	owner    string
+	number   int32
+	exporter cmdutil.Exporter
 }
 
 type listConfig struct {
@@ -66,7 +65,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
 	}
 
 	listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
-	cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(listCmd, &opts.exporter)
 	listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of items to fetch")
 
 	return listCmd
@@ -93,8 +92,8 @@ func runList(config listConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, project.DetailedItems())
 	}
 
 	return printResults(config, project.Items.Nodes, owner.Login)
@@ -122,12 +121,3 @@ func printResults(config listConfig, items []queries.ProjectItem, login string)
 
 	return tp.Render()
 }
-
-func printJSON(config listConfig, project *queries.Project) error {
-	b, err := format.JSONProjectDetailedItems(project)
-	if err != nil {
-		return err
-	}
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go
index 88f419b90..ba6e1d747 100644
--- a/pkg/cmd/project/list/list.go
+++ b/pkg/cmd/project/list/list.go
@@ -15,11 +15,11 @@ import (
 )
 
 type listOpts struct {
-	limit  int
-	web    bool
-	owner  string
-	closed bool
-	format string
+	limit    int
+	web      bool
+	owner    string
+	closed   bool
+	exporter cmdutil.Exporter
 }
 
 type listConfig struct {
@@ -69,7 +69,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
 	listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner")
 	listCmd.Flags().BoolVarP(&opts.closed, "closed", "", false, "Include closed projects")
 	listCmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open projects list in the browser")
-	cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(listCmd, &opts.exporter)
 	listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of projects to fetch")
 
 	return listCmd
@@ -97,14 +97,14 @@ func runList(config listConfig) error {
 		return err
 	}
 
-	projects, totalCount, err := config.client.Projects(config.opts.owner, owner.Type, config.opts.limit, false)
+	projects, err := config.client.Projects(config.opts.owner, owner.Type, config.opts.limit, false)
 	if err != nil {
 		return err
 	}
 	projects = filterProjects(projects, config)
 
-	if config.opts.format == "json" {
-		return printJSON(config, projects, totalCount)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, projects)
 	}
 
 	return printResults(config, projects, owner.Login)
@@ -138,47 +138,41 @@ func buildURL(config listConfig) (string, error) {
 	return url, nil
 }
 
-func filterProjects(nodes []queries.Project, config listConfig) []queries.Project {
-	projects := make([]queries.Project, 0, len(nodes))
-	for _, p := range nodes {
-		if !config.opts.closed && p.Closed {
+func filterProjects(nodes queries.Projects, config listConfig) queries.Projects {
+	filtered := queries.Projects{
+		Nodes:      make([]queries.Project, 0, len(nodes.Nodes)),
+		TotalCount: nodes.TotalCount,
+	}
+	for _, project := range nodes.Nodes {
+		if !config.opts.closed && project.Closed {
 			continue
 		}
-		projects = append(projects, p)
+		filtered.Nodes = append(filtered.Nodes, project)
 	}
-	return projects
+	return filtered
 }
 
-func printResults(config listConfig, projects []queries.Project, owner string) error {
-	if len(projects) == 0 {
+func printResults(config listConfig, projects queries.Projects, owner string) error {
+	if len(projects.Nodes) == 0 {
 		return cmdutil.NewNoResultsError(fmt.Sprintf("No projects found for %s", owner))
 	}
 
 	tp := tableprinter.New(config.io, tableprinter.WithHeader("Number", "Title", "State", "ID"))
 
-	for _, p := range projects {
-		tp.AddField(strconv.Itoa(int(p.Number)), tableprinter.WithTruncate(nil))
+	cs := config.io.ColorScheme()
+	for _, p := range projects.Nodes {
+		tp.AddField(
+			strconv.Itoa(int(p.Number)),
+			tableprinter.WithTruncate(nil),
+		)
 		tp.AddField(p.Title)
-		var state string
-		if p.Closed {
-			state = "closed"
-		} else {
-			state = "open"
-		}
-		tp.AddField(state)
+		tp.AddField(
+			format.ProjectState(p),
+			tableprinter.WithColor(cs.ColorFromString(format.ColorForProjectState(p))),
+		)
 		tp.AddField(p.ID, tableprinter.WithTruncate(nil))
 		tp.EndRow()
 	}
 
 	return tp.Render()
 }
-
-func printJSON(config listConfig, projects []queries.Project, totalCount int) error {
-	b, err := format.JSONProjects(projects, totalCount)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/mark-template/mark_template.go b/pkg/cmd/project/mark-template/mark_template.go
index 92eb87ca6..025fe2838 100644
--- a/pkg/cmd/project/mark-template/mark_template.go
+++ b/pkg/cmd/project/mark-template/mark_template.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -19,7 +18,7 @@ type markTemplateOpts struct {
 	undo      bool
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type markTemplateConfig struct {
@@ -82,7 +81,7 @@ func NewCmdMarkTemplate(f *cmdutil.Factory, runF func(config markTemplateConfig)
 
 	markTemplateCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the org owner.")
 	markTemplateCmd.Flags().BoolVar(&opts.undo, "undo", false, "Unmark the project as a template.")
-	cmdutil.StringEnumFlag(markTemplateCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(markTemplateCmd, &opts.exporter)
 
 	return markTemplateCmd
 }
@@ -107,8 +106,8 @@ func runMarkTemplate(config markTemplateConfig) error {
 			return err
 		}
 
-		if config.opts.format == "json" {
-			return printJSON(config, *project)
+		if config.opts.exporter != nil {
+			return config.opts.exporter.Write(config.io, *project)
 		}
 
 		return printResults(config, query.TemplateProject.Project)
@@ -120,8 +119,8 @@ func runMarkTemplate(config markTemplateConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, query.TemplateProject.Project)
 	}
 
 	return printResults(config, query.TemplateProject.Project)
@@ -164,13 +163,3 @@ func printResults(config markTemplateConfig, project queries.Project) error {
 	_, err := fmt.Fprintf(config.io.Out, "Marked project %d as a template.\n", project.Number)
 	return err
 }
-
-func printJSON(config markTemplateConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/project/shared/format/display.go b/pkg/cmd/project/shared/format/display.go
new file mode 100644
index 000000000..498fd7f2b
--- /dev/null
+++ b/pkg/cmd/project/shared/format/display.go
@@ -0,0 +1,19 @@
+package format
+
+import (
+	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
+)
+
+func ProjectState(project queries.Project) string {
+	if project.Closed {
+		return "closed"
+	}
+	return "open"
+}
+
+func ColorForProjectState(project queries.Project) string {
+	if project.Closed {
+		return "gray"
+	}
+	return "green"
+}
diff --git a/pkg/cmd/project/shared/format/json.go b/pkg/cmd/project/shared/format/json.go
deleted file mode 100644
index efe9f4800..000000000
--- a/pkg/cmd/project/shared/format/json.go
+++ /dev/null
@@ -1,381 +0,0 @@
-package format
-
-import (
-	"encoding/json"
-	"strings"
-
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
-)
-
-// JSONProject serializes a Project to JSON.
-func JSONProject(project queries.Project) ([]byte, error) {
-	return json.Marshal(projectJSON{
-		Number:           project.Number,
-		URL:              project.URL,
-		ShortDescription: project.ShortDescription,
-		Public:           project.Public,
-		Closed:           project.Closed,
-		// The Template field is commented out due to https://github.com/cli/cli/issues/8103.
-		// The Template field does not exist on GHES 3.8 and older, once GHES 3.8 gets
-		// deprecated on 2024-03-07 we can start populating this field again.
-		// Template: project.Template,
-		Title:  project.Title,
-		ID:     project.ID,
-		Readme: project.Readme,
-		Items: struct {
-			TotalCount int `json:"totalCount"`
-		}{
-			TotalCount: project.Items.TotalCount,
-		},
-		Fields: struct {
-			TotalCount int `json:"totalCount"`
-		}{
-			TotalCount: project.Fields.TotalCount,
-		},
-		Owner: struct {
-			Type  string `json:"type"`
-			Login string `json:"login"`
-		}{
-			Type:  project.OwnerType(),
-			Login: project.OwnerLogin(),
-		},
-	})
-}
-
-// JSONProjects serializes a slice of Projects to JSON.
-// JSON fields are `totalCount` and `projects`.
-func JSONProjects(projects []queries.Project, totalCount int) ([]byte, error) {
-	var result []projectJSON
-	for _, p := range projects {
-		result = append(result, projectJSON{
-			Number:           p.Number,
-			URL:              p.URL,
-			ShortDescription: p.ShortDescription,
-			Public:           p.Public,
-			Closed:           p.Closed,
-			// The Template field is commented out due to https://github.com/cli/cli/issues/8103.
-			// The Template field does not exist on GHES 3.8 and older, once GHES 3.8 gets
-			// deprecated on 2024-03-07 we can start populating this field again.
-			// Template: p.Template,
-			Title:  p.Title,
-			ID:     p.ID,
-			Readme: p.Readme,
-			Items: struct {
-				TotalCount int `json:"totalCount"`
-			}{
-				TotalCount: p.Items.TotalCount,
-			},
-			Fields: struct {
-				TotalCount int `json:"totalCount"`
-			}{
-				TotalCount: p.Fields.TotalCount,
-			},
-			Owner: struct {
-				Type  string `json:"type"`
-				Login string `json:"login"`
-			}{
-				Type:  p.OwnerType(),
-				Login: p.OwnerLogin(),
-			},
-		})
-	}
-
-	return json.Marshal(struct {
-		Projects   []projectJSON `json:"projects"`
-		TotalCount int           `json:"totalCount"`
-	}{
-		Projects:   result,
-		TotalCount: totalCount,
-	})
-}
-
-type projectJSON struct {
-	Number           int32  `json:"number"`
-	URL              string `json:"url"`
-	ShortDescription string `json:"shortDescription"`
-	Public           bool   `json:"public"`
-	Closed           bool   `json:"closed"`
-	// The Template field is commented out due to https://github.com/cli/cli/issues/8103.
-	// The Template field does not exist on GHES 3.8 and older, once GHES 3.8 gets
-	// deprecated on 2024-03-07 we can start populating this field again.
-	// Template         bool   `json:"template"`
-	Title  string `json:"title"`
-	ID     string `json:"id"`
-	Readme string `json:"readme"`
-	Items  struct {
-		TotalCount int `json:"totalCount"`
-	} `graphql:"items(first: 100)" json:"items"`
-	Fields struct {
-		TotalCount int `json:"totalCount"`
-	} `graphql:"fields(first:100)" json:"fields"`
-	Owner struct {
-		Type  string `json:"type"`
-		Login string `json:"login"`
-	} `json:"owner"`
-}
-
-// JSONProjectField serializes a ProjectField to JSON.
-func JSONProjectField(field queries.ProjectField) ([]byte, error) {
-	val := projectFieldJSON{
-		ID:   field.ID(),
-		Name: field.Name(),
-		Type: field.Type(),
-	}
-	for _, o := range field.Options() {
-		val.Options = append(val.Options, singleSelectOptionJSON{
-			Name: o.Name,
-			ID:   o.ID,
-		})
-	}
-
-	return json.Marshal(val)
-}
-
-// JSONProjectFields serializes a slice of ProjectFields to JSON.
-// JSON fields are `totalCount` and `fields`.
-func JSONProjectFields(project *queries.Project) ([]byte, error) {
-	var result []projectFieldJSON
-	for _, f := range project.Fields.Nodes {
-		val := projectFieldJSON{
-			ID:   f.ID(),
-			Name: f.Name(),
-			Type: f.Type(),
-		}
-		for _, o := range f.Options() {
-			val.Options = append(val.Options, singleSelectOptionJSON{
-				Name: o.Name,
-				ID:   o.ID,
-			})
-		}
-
-		result = append(result, val)
-	}
-
-	return json.Marshal(struct {
-		Fields     []projectFieldJSON `json:"fields"`
-		TotalCount int                `json:"totalCount"`
-	}{
-		Fields:     result,
-		TotalCount: project.Fields.TotalCount,
-	})
-}
-
-type projectFieldJSON struct {
-	ID      string                   `json:"id"`
-	Name    string                   `json:"name"`
-	Type    string                   `json:"type"`
-	Options []singleSelectOptionJSON `json:"options,omitempty"`
-}
-
-type singleSelectOptionJSON struct {
-	ID   string `json:"id"`
-	Name string `json:"name"`
-}
-
-// JSONProjectItem serializes a ProjectItem to JSON.
-func JSONProjectItem(item queries.ProjectItem) ([]byte, error) {
-	return json.Marshal(projectItemJSON{
-		ID:    item.ID(),
-		Title: item.Title(),
-		Body:  item.Body(),
-		Type:  item.Type(),
-		URL:   item.URL(),
-	})
-}
-
-type projectItemJSON struct {
-	ID    string `json:"id"`
-	Title string `json:"title"`
-	Body  string `json:"body"`
-	Type  string `json:"type"`
-	URL   string `json:"url,omitempty"`
-}
-
-// JSONProjectDraftIssue serializes a DraftIssue to JSON.
-// This is needed because the field for
-// https://docs.github.com/en/graphql/reference/mutations#updateprojectv2draftissue
-// is a DraftIssue https://docs.github.com/en/graphql/reference/objects#draftissue
-// and not a ProjectV2Item https://docs.github.com/en/graphql/reference/objects#projectv2item
-func JSONProjectDraftIssue(item queries.DraftIssue) ([]byte, error) {
-
-	return json.Marshal(draftIssueJSON{
-		ID:    item.ID,
-		Title: item.Title,
-		Body:  item.Body,
-		Type:  "DraftIssue",
-	})
-}
-
-type draftIssueJSON struct {
-	ID    string `json:"id"`
-	Title string `json:"title"`
-	Body  string `json:"body"`
-	Type  string `json:"type"`
-}
-
-func projectItemContent(p queries.ProjectItem) any {
-	switch p.Content.TypeName {
-	case "DraftIssue":
-		return struct {
-			Type  string `json:"type"`
-			Body  string `json:"body"`
-			Title string `json:"title"`
-		}{
-			Type:  p.Type(),
-			Body:  p.Body(),
-			Title: p.Title(),
-		}
-	case "Issue":
-		return struct {
-			Type       string `json:"type"`
-			Body       string `json:"body"`
-			Title      string `json:"title"`
-			Number     int    `json:"number"`
-			Repository string `json:"repository"`
-			URL        string `json:"url"`
-		}{
-			Type:       p.Type(),
-			Body:       p.Body(),
-			Title:      p.Title(),
-			Number:     p.Number(),
-			Repository: p.Repo(),
-			URL:        p.URL(),
-		}
-	case "PullRequest":
-		return struct {
-			Type       string `json:"type"`
-			Body       string `json:"body"`
-			Title      string `json:"title"`
-			Number     int    `json:"number"`
-			Repository string `json:"repository"`
-			URL        string `json:"url"`
-		}{
-			Type:       p.Type(),
-			Body:       p.Body(),
-			Title:      p.Title(),
-			Number:     p.Number(),
-			Repository: p.Repo(),
-			URL:        p.URL(),
-		}
-	}
-
-	return nil
-}
-
-func projectFieldValueData(v queries.FieldValueNodes) any {
-	switch v.Type {
-	case "ProjectV2ItemFieldDateValue":
-		return v.ProjectV2ItemFieldDateValue.Date
-	case "ProjectV2ItemFieldIterationValue":
-		return struct {
-			Title     string `json:"title"`
-			StartDate string `json:"startDate"`
-			Duration  int    `json:"duration"`
-		}{
-			Title:     v.ProjectV2ItemFieldIterationValue.Title,
-			StartDate: v.ProjectV2ItemFieldIterationValue.StartDate,
-			Duration:  v.ProjectV2ItemFieldIterationValue.Duration,
-		}
-	case "ProjectV2ItemFieldNumberValue":
-		return v.ProjectV2ItemFieldNumberValue.Number
-	case "ProjectV2ItemFieldSingleSelectValue":
-		return v.ProjectV2ItemFieldSingleSelectValue.Name
-	case "ProjectV2ItemFieldTextValue":
-		return v.ProjectV2ItemFieldTextValue.Text
-	case "ProjectV2ItemFieldMilestoneValue":
-		return struct {
-			Title       string `json:"title"`
-			Description string `json:"description"`
-			DueOn       string `json:"dueOn"`
-		}{
-			Title:       v.ProjectV2ItemFieldMilestoneValue.Milestone.Title,
-			Description: v.ProjectV2ItemFieldMilestoneValue.Milestone.Description,
-			DueOn:       v.ProjectV2ItemFieldMilestoneValue.Milestone.DueOn,
-		}
-	case "ProjectV2ItemFieldLabelValue":
-		names := make([]string, 0)
-		for _, p := range v.ProjectV2ItemFieldLabelValue.Labels.Nodes {
-			names = append(names, p.Name)
-		}
-		return names
-	case "ProjectV2ItemFieldPullRequestValue":
-		urls := make([]string, 0)
-		for _, p := range v.ProjectV2ItemFieldPullRequestValue.PullRequests.Nodes {
-			urls = append(urls, p.Url)
-		}
-		return urls
-	case "ProjectV2ItemFieldRepositoryValue":
-		return v.ProjectV2ItemFieldRepositoryValue.Repository.Url
-	case "ProjectV2ItemFieldUserValue":
-		logins := make([]string, 0)
-		for _, p := range v.ProjectV2ItemFieldUserValue.Users.Nodes {
-			logins = append(logins, p.Login)
-		}
-		return logins
-	case "ProjectV2ItemFieldReviewerValue":
-		names := make([]string, 0)
-		for _, p := range v.ProjectV2ItemFieldReviewerValue.Reviewers.Nodes {
-			if p.Type == "Team" {
-				names = append(names, p.Team.Name)
-			} else if p.Type == "User" {
-				names = append(names, p.User.Login)
-			}
-		}
-		return names
-
-	}
-
-	return nil
-}
-
-// serialize creates a map from field to field values
-func serializeProjectWithItems(project *queries.Project) []map[string]any {
-	fields := make(map[string]string)
-
-	// make a map of fields by ID
-	for _, f := range project.Fields.Nodes {
-		fields[f.ID()] = CamelCase(f.Name())
-	}
-	itemsSlice := make([]map[string]any, 0)
-
-	// for each value, look up the name by ID
-	// and set the value to the field value
-	for _, i := range project.Items.Nodes {
-		o := make(map[string]any)
-		o["id"] = i.Id
-		o["content"] = projectItemContent(i)
-		for _, v := range i.FieldValues.Nodes {
-			id := v.ID()
-			value := projectFieldValueData(v)
-
-			o[fields[id]] = value
-		}
-		itemsSlice = append(itemsSlice, o)
-	}
-	return itemsSlice
-}
-
-// JSONProjectWithItems returns a detailed JSON representation of project items.
-// JSON fields are `totalCount` and `items`.
-func JSONProjectDetailedItems(project *queries.Project) ([]byte, error) {
-	items := serializeProjectWithItems(project)
-	return json.Marshal(struct {
-		Items      []map[string]any `json:"items"`
-		TotalCount int              `json:"totalCount"`
-	}{
-		Items:      items,
-		TotalCount: project.Items.TotalCount,
-	})
-}
-
-// CamelCase converts a string to camelCase, which is useful for turning Go field names to JSON keys.
-func CamelCase(s string) string {
-	if len(s) == 0 {
-		return ""
-	}
-
-	if len(s) == 1 {
-		return strings.ToLower(s)
-	}
-	return strings.ToLower(s[0:1]) + s[1:]
-}
diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go
index c1c518848..354cf2cca 100644
--- a/pkg/cmd/project/shared/queries/queries.go
+++ b/pkg/cmd/project/shared/queries/queries.go
@@ -128,12 +128,8 @@ type Project struct {
 		TotalCount int
 		Nodes      []ProjectItem
 	} `graphql:"items(first: $firstItems, after: $afterItems)"`
-	Fields struct {
-		TotalCount int
-		Nodes      []ProjectField
-		PageInfo   PageInfo
-	} `graphql:"fields(first: $firstFields, after: $afterFields)"`
-	Owner struct {
+	Fields ProjectFields `graphql:"fields(first: $firstFields, after: $afterFields)"`
+	Owner  struct {
 		TypeName string `graphql:"__typename"`
 		User     struct {
 			Login string
@@ -144,6 +140,36 @@ type Project struct {
 	}
 }
 
+func (p Project) DetailedItems() map[string]interface{} {
+	return map[string]interface{}{
+		"items":      serializeProjectWithItems(&p),
+		"totalCount": p.Items.TotalCount,
+	}
+}
+
+func (p Project) ExportData(_ []string) map[string]interface{} {
+	return map[string]interface{}{
+		"number":           p.Number,
+		"url":              p.URL,
+		"shortDescription": p.ShortDescription,
+		"public":           p.Public,
+		"closed":           p.Closed,
+		"title":            p.Title,
+		"id":               p.ID,
+		"readme":           p.Readme,
+		"items": map[string]interface{}{
+			"totalCount": p.Items.TotalCount,
+		},
+		"fields": map[string]interface{}{
+			"totalCount": p.Fields.TotalCount,
+		},
+		"owner": map[string]interface{}{
+			"type":  p.OwnerType(),
+			"login": p.OwnerLogin(),
+		},
+	}
+}
+
 func (p Project) OwnerType() string {
 	return p.Owner.TypeName
 }
@@ -155,6 +181,22 @@ func (p Project) OwnerLogin() string {
 	return p.Owner.Organization.Login
 }
 
+type Projects struct {
+	Nodes      []Project
+	TotalCount int
+}
+
+func (p Projects) ExportData(_ []string) map[string]interface{} {
+	v := make([]map[string]interface{}, len(p.Nodes))
+	for i := range p.Nodes {
+		v[i] = p.Nodes[i].ExportData(nil)
+	}
+	return map[string]interface{}{
+		"projects":   v,
+		"totalCount": p.TotalCount,
+	}
+}
+
 // ProjectItem is a ProjectV2Item GraphQL object https://docs.github.com/en/graphql/reference/objects#projectv2item.
 type ProjectItem struct {
 	Content     ProjectItemContent
@@ -284,6 +326,19 @@ type DraftIssue struct {
 	Title string
 }
 
+func (i DraftIssue) ExportData(_ []string) map[string]interface{} {
+	v := map[string]interface{}{
+		"title": i.Title,
+		"body":  i.Body,
+		"type":  "DraftIssue",
+	}
+	// Emulate omitempty.
+	if i.ID != "" {
+		v["id"] = i.ID
+	}
+	return v
+}
+
 type PullRequest struct {
 	Body       string
 	Title      string
@@ -294,6 +349,17 @@ type PullRequest struct {
 	}
 }
 
+func (pr PullRequest) ExportData(_ []string) map[string]interface{} {
+	return map[string]interface{}{
+		"type":       "PullRequest",
+		"body":       pr.Body,
+		"title":      pr.Title,
+		"number":     pr.Number,
+		"repository": pr.Repository.NameWithOwner,
+		"url":        pr.URL,
+	}
+}
+
 type Issue struct {
 	Body       string
 	Title      string
@@ -304,6 +370,50 @@ type Issue struct {
 	}
 }
 
+func (i Issue) ExportData(_ []string) map[string]interface{} {
+	return map[string]interface{}{
+		"type":       "Issue",
+		"body":       i.Body,
+		"title":      i.Title,
+		"number":     i.Number,
+		"repository": i.Repository.NameWithOwner,
+		"url":        i.URL,
+	}
+}
+
+func (p ProjectItem) DetailedItem() exportable {
+	switch p.Type() {
+	case "DraftIssue":
+		return DraftIssue{
+			Body:  p.Body(),
+			Title: p.Title(),
+		}
+
+	case "Issue":
+		return Issue{
+			Body:   p.Body(),
+			Title:  p.Title(),
+			Number: p.Number(),
+			Repository: struct{ NameWithOwner string }{
+				NameWithOwner: p.Repo(),
+			},
+			URL: p.URL(),
+		}
+
+	case "PullRequest":
+		return PullRequest{
+			Body:   p.Body(),
+			Title:  p.Title(),
+			Number: p.Number(),
+			Repository: struct{ NameWithOwner string }{
+				NameWithOwner: p.Repo(),
+			},
+			URL: p.URL(),
+		}
+	}
+	return nil
+}
+
 // Type is the underlying type of the project item.
 func (p ProjectItem) Type() string {
 	return p.Content.TypeName
@@ -374,6 +484,20 @@ func (p ProjectItem) URL() string {
 	return ""
 }
 
+func (p ProjectItem) ExportData(_ []string) map[string]interface{} {
+	v := map[string]interface{}{
+		"id":    p.ID(),
+		"title": p.Title(),
+		"body":  p.Body(),
+		"type":  p.Type(),
+	}
+	// Emulate omitempty.
+	if url := p.URL(); url != "" {
+		v["url"] = url
+	}
+	return v
+}
+
 // ProjectItems returns the items of a project. If the OwnerType is VIEWER, no login is required.
 // If limit is 0, the default limit is used.
 func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, error) {
@@ -631,6 +755,13 @@ type SingleSelectFieldOptions struct {
 	Name string
 }
 
+func (f SingleSelectFieldOptions) ExportData(_ []string) map[string]interface{} {
+	return map[string]interface{}{
+		"id":   f.ID,
+		"name": f.Name,
+	}
+}
+
 func (p ProjectField) Options() []SingleSelectFieldOptions {
 	if p.TypeName == "ProjectV2SingleSelectField" {
 		var options []SingleSelectFieldOptions
@@ -645,6 +776,40 @@ func (p ProjectField) Options() []SingleSelectFieldOptions {
 	return nil
 }
 
+func (p ProjectField) ExportData(_ []string) map[string]interface{} {
+	v := map[string]interface{}{
+		"id":   p.ID(),
+		"name": p.Name(),
+		"type": p.Type(),
+	}
+	// Emulate omitempty
+	if opts := p.Options(); len(opts) != 0 {
+		options := make([]map[string]interface{}, len(opts))
+		for i, opt := range opts {
+			options[i] = opt.ExportData(nil)
+		}
+		v["options"] = options
+	}
+	return v
+}
+
+type ProjectFields struct {
+	TotalCount int
+	Nodes      []ProjectField
+	PageInfo   PageInfo
+}
+
+func (p ProjectFields) ExportData(_ []string) map[string]interface{} {
+	fields := make([]map[string]interface{}, len(p.Nodes))
+	for i := range p.Nodes {
+		fields[i] = p.Nodes[i].ExportData(nil)
+	}
+	return map[string]interface{}{
+		"fields":     fields,
+		"totalCount": p.TotalCount,
+	}
+}
+
 // ProjectFields returns a project with fields. If the OwnerType is VIEWER, no login is required.
 // If limit is 0, the default limit is used.
 func (c *Client) ProjectFields(o *Owner, number int32, limit int) (*Project, error) {
@@ -1084,17 +1249,17 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool)
 		return nil, fmt.Errorf("project number is required when not running interactively")
 	}
 
-	projects, _, err := c.Projects(o.Login, o.Type, 0, fields)
+	projects, err := c.Projects(o.Login, o.Type, 0, fields)
 	if err != nil {
 		return nil, err
 	}
 
-	if len(projects) == 0 {
+	if len(projects.Nodes) == 0 {
 		return nil, fmt.Errorf("no projects found for %s", o.Login)
 	}
 
-	options := make([]string, 0, len(projects))
-	for _, p := range projects {
+	options := make([]string, 0, len(projects.Nodes))
+	for _, p := range projects.Nodes {
 		title := fmt.Sprintf("%s (#%d)", p.Title, p.Number)
 		options = append(options, title)
 	}
@@ -1104,16 +1269,17 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool)
 		return nil, err
 	}
 
-	return &projects[answerIndex], nil
+	return &projects.Nodes[answerIndex], nil
 }
 
 // Projects returns all the projects for an Owner. If the OwnerType is VIEWER, no login is required.
 // If limit is 0, the default limit is used.
-func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) ([]Project, int, error) {
-	projects := make([]Project, 0)
+func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Projects, error) {
+	projects := Projects{
+		Nodes: make([]Project, 0),
+	}
 	cursor := (*githubv4.String)(nil)
 	hasNextPage := false
-	totalCount := 0
 
 	if limit == 0 {
 		limit = LimitDefault
@@ -1149,38 +1315,38 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) ([]
 		if t == UserOwner {
 			var query userProjects
 			if err := c.doQuery("UserProjects", &query, variables); err != nil {
-				return projects, 0, err
+				return projects, err
 			}
-			projects = append(projects, query.Owner.Projects.Nodes...)
+			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
 			hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
 			cursor = &query.Owner.Projects.PageInfo.EndCursor
-			totalCount = query.Owner.Projects.TotalCount
+			projects.TotalCount = query.Owner.Projects.TotalCount
 		} else if t == OrgOwner {
 			var query orgProjects
 			if err := c.doQuery("OrgProjects", &query, variables); err != nil {
-				return projects, 0, err
+				return projects, err
 			}
-			projects = append(projects, query.Owner.Projects.Nodes...)
+			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
 			hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
 			cursor = &query.Owner.Projects.PageInfo.EndCursor
-			totalCount = query.Owner.Projects.TotalCount
+			projects.TotalCount = query.Owner.Projects.TotalCount
 		} else if t == ViewerOwner {
 			var query viewerProjects
 			if err := c.doQuery("ViewerProjects", &query, variables); err != nil {
-				return projects, 0, err
+				return projects, err
 			}
-			projects = append(projects, query.Owner.Projects.Nodes...)
+			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
 			hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
 			cursor = &query.Owner.Projects.PageInfo.EndCursor
-			totalCount = query.Owner.Projects.TotalCount
+			projects.TotalCount = query.Owner.Projects.TotalCount
 		}
 
-		if !hasNextPage || len(projects) >= limit {
-			return projects, totalCount, nil
+		if !hasNextPage || len(projects.Nodes) >= limit {
+			return projects, nil
 		}
 
-		if len(projects)+LimitMax > limit {
-			first := limit - len(projects)
+		if len(projects.Nodes)+LimitMax > limit {
+			first := limit - len(projects.Nodes)
 			variables["first"] = githubv4.Int(first)
 		}
 		variables["after"] = cursor
@@ -1223,3 +1389,107 @@ func requiredScopesFromServerMessage(msg string) []string {
 	}
 	return scopes
 }
+
+func projectFieldValueData(v FieldValueNodes) interface{} {
+	switch v.Type {
+	case "ProjectV2ItemFieldDateValue":
+		return v.ProjectV2ItemFieldDateValue.Date
+	case "ProjectV2ItemFieldIterationValue":
+		return map[string]interface{}{
+			"title":     v.ProjectV2ItemFieldIterationValue.Title,
+			"startDate": v.ProjectV2ItemFieldIterationValue.StartDate,
+			"duration":  v.ProjectV2ItemFieldIterationValue.Duration,
+		}
+	case "ProjectV2ItemFieldNumberValue":
+		return v.ProjectV2ItemFieldNumberValue.Number
+	case "ProjectV2ItemFieldSingleSelectValue":
+		return v.ProjectV2ItemFieldSingleSelectValue.Name
+	case "ProjectV2ItemFieldTextValue":
+		return v.ProjectV2ItemFieldTextValue.Text
+	case "ProjectV2ItemFieldMilestoneValue":
+		return map[string]interface{}{
+			"title":       v.ProjectV2ItemFieldMilestoneValue.Milestone.Title,
+			"description": v.ProjectV2ItemFieldMilestoneValue.Milestone.Description,
+			"dueOn":       v.ProjectV2ItemFieldMilestoneValue.Milestone.DueOn,
+		}
+	case "ProjectV2ItemFieldLabelValue":
+		names := make([]string, 0)
+		for _, p := range v.ProjectV2ItemFieldLabelValue.Labels.Nodes {
+			names = append(names, p.Name)
+		}
+		return names
+	case "ProjectV2ItemFieldPullRequestValue":
+		urls := make([]string, 0)
+		for _, p := range v.ProjectV2ItemFieldPullRequestValue.PullRequests.Nodes {
+			urls = append(urls, p.Url)
+		}
+		return urls
+	case "ProjectV2ItemFieldRepositoryValue":
+		return v.ProjectV2ItemFieldRepositoryValue.Repository.Url
+	case "ProjectV2ItemFieldUserValue":
+		logins := make([]string, 0)
+		for _, p := range v.ProjectV2ItemFieldUserValue.Users.Nodes {
+			logins = append(logins, p.Login)
+		}
+		return logins
+	case "ProjectV2ItemFieldReviewerValue":
+		names := make([]string, 0)
+		for _, p := range v.ProjectV2ItemFieldReviewerValue.Reviewers.Nodes {
+			if p.Type == "Team" {
+				names = append(names, p.Team.Name)
+			} else if p.Type == "User" {
+				names = append(names, p.User.Login)
+			}
+		}
+		return names
+
+	}
+
+	return nil
+}
+
+// serialize creates a map from field to field values
+func serializeProjectWithItems(project *Project) []map[string]interface{} {
+	fields := make(map[string]string)
+
+	// make a map of fields by ID
+	for _, f := range project.Fields.Nodes {
+		fields[f.ID()] = camelCase(f.Name())
+	}
+	itemsSlice := make([]map[string]interface{}, 0)
+
+	// for each value, look up the name by ID
+	// and set the value to the field value
+	for _, i := range project.Items.Nodes {
+		o := make(map[string]interface{})
+		o["id"] = i.Id
+		if projectItem := i.DetailedItem(); projectItem != nil {
+			o["content"] = projectItem.ExportData(nil)
+		} else {
+			o["content"] = nil
+		}
+		for _, v := range i.FieldValues.Nodes {
+			id := v.ID()
+			value := projectFieldValueData(v)
+
+			o[fields[id]] = value
+		}
+		itemsSlice = append(itemsSlice, o)
+	}
+	return itemsSlice
+}
+
+// camelCase converts a string to camelCase, which is useful for turning Go field names to JSON keys.
+func camelCase(s string) string {
+	if len(s) == 0 {
+		return ""
+	}
+	if len(s) == 1 {
+		return strings.ToLower(s)
+	}
+	return strings.ToLower(s[0:1]) + s[1:]
+}
+
+type exportable interface {
+	ExportData([]string) map[string]interface{}
+}
diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go
index 36eb148e3..ce54e1f09 100644
--- a/pkg/cmd/project/view/view.go
+++ b/pkg/cmd/project/view/view.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -16,10 +15,10 @@ import (
 )
 
 type viewOpts struct {
-	web    bool
-	owner  string
-	number int32
-	format string
+	web      bool
+	owner    string
+	number   int32
+	exporter cmdutil.Exporter
 }
 
 type viewConfig struct {
@@ -77,7 +76,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(config viewConfig) error) *cobra.C
 
 	viewCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
 	viewCmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open a project in the browser")
-	cmdutil.StringEnumFlag(viewCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
+	cmdutil.AddFormatFlags(viewCmd, &opts.exporter)
 
 	return viewCmd
 }
@@ -106,8 +105,8 @@ func runView(config viewConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return config.opts.exporter.Write(config.io, *project)
 	}
 
 	return printResults(config, project)
@@ -191,13 +190,3 @@ func printResults(config viewConfig, project *queries.Project) error {
 	_, err = fmt.Fprint(config.io.Out, out)
 	return err
 }
-
-func printJSON(config viewConfig, project queries.Project) error {
-	b, err := format.JSONProject(project)
-	if err != nil {
-		return err
-	}
-
-	_, err = config.io.Out.Write(b)
-	return err
-}
diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go
index 56e6ac72a..bb0df7243 100644
--- a/pkg/cmd/release/download/download.go
+++ b/pkg/cmd/release/download/download.go
@@ -10,6 +10,9 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
+	"runtime"
+	"slices"
+	"strings"
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
@@ -174,6 +177,12 @@ func downloadRun(opts *DownloadOptions) error {
 			if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
 				continue
 			}
+			// Note that if we need to start checking for reserved filenames on
+			// more operating systems we should move to using a build constraints
+			// pattern rather than checking the operating system at runtime.
+			if runtime.GOOS == "windows" && isWindowsReservedFilename(a.Name) {
+				return fmt.Errorf("unable to download release due to asset with reserved filename %q", a.Name)
+			}
 			toDownload = append(toDownload, a)
 		}
 	}
@@ -391,3 +400,23 @@ func (w destinationWriter) Copy(name string, r io.Reader) (copyErr error) {
 	_, copyErr = io.Copy(f, r)
 	return
 }
+
+func isWindowsReservedFilename(filename string) bool {
+	// Windows terminals should prevent the creation of these files
+	// but that behavior is not enforced across terminals. Prevent
+	// the user from downloading files with these reserved names as
+	// they represent an exploit vector for bad actors.
+	// Reserved filenames defined at:
+	// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#win32-file-namespaces
+	reservedFilenames := []string{"CON", "PRN", "AUX", "NUL", "COM0",
+		"COM1", "COM2", "COM3", "COM4", "COM5",
+		"COM6", "COM7", "COM8", "COM9", "COM¹",
+		"COM²", "COM³", "LPT0", "LPT1", "LPT2",
+		"LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
+		"LPT8", "LPT9", "LPT¹", "LPT²", "LPT³"}
+
+	// Normalize type case and remove file type extension from filename.
+	filename = strings.ToUpper(strings.Split(filename, ".")[0])
+
+	return slices.Contains(reservedFilenames, filename)
+}
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index 6c0ff8a00..f04a573fd 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -188,7 +188,7 @@ var HelpTopics = []helpTopic{
 			cli-maintainer
 
 			# --jq can be used to implement more complex filtering and output changes:
-			$ bin/gh issue list --json number,title,labels --jq \
+			$ gh issue list --json number,title,labels --jq \
 			  'map(select((.labels | length) > 0))    # must have labels
 			  | map(.labels = (.labels | map(.name))) # show only the label names
 			  | .[:3]                                 # select the first 3 results'
diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go
index 0aef38db4..aeb888653 100644
--- a/pkg/cmdutil/json_flags.go
+++ b/pkg/cmdutil/json_flags.go
@@ -85,7 +85,7 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
 	})
 }
 
-func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {
+func checkJSONFlags(cmd *cobra.Command) (*jsonExporter, error) {
 	f := cmd.Flags()
 	jsonFlag := f.Lookup("json")
 	jqFlag := f.Lookup("jq")
@@ -97,7 +97,7 @@ func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {
 			return nil, errors.New("cannot use `--web` with `--json`")
 		}
 		jv := jsonFlag.Value.(pflag.SliceValue)
-		return &exportFormat{
+		return &jsonExporter{
 			fields:   jv.GetSlice(),
 			filter:   jqFlag.Value.String(),
 			template: tplFlag.Value.String(),
@@ -110,25 +110,82 @@ func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {
 	return nil, nil
 }
 
+func AddFormatFlags(cmd *cobra.Command, exportTarget *Exporter) {
+	var format string
+	StringEnumFlag(cmd, &format, "format", "", "", []string{"json"}, "Output format")
+	f := cmd.Flags()
+	f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`")
+	f.StringP("template", "t", "", "Format JSON output using a Go template; see \"gh help formatting\"")
+
+	oldPreRun := cmd.PreRunE
+	cmd.PreRunE = func(c *cobra.Command, args []string) error {
+		if oldPreRun != nil {
+			if err := oldPreRun(c, args); err != nil {
+				return err
+			}
+		}
+
+		if export, err := checkFormatFlags(c); err == nil {
+			if export == nil {
+				*exportTarget = nil
+			} else {
+				*exportTarget = export
+			}
+		} else {
+			return err
+		}
+		return nil
+	}
+}
+
+func checkFormatFlags(cmd *cobra.Command) (*jsonExporter, error) {
+	f := cmd.Flags()
+	formatFlag := f.Lookup("format")
+	formatValue := formatFlag.Value.String()
+	jqFlag := f.Lookup("jq")
+	tplFlag := f.Lookup("template")
+	webFlag := f.Lookup("web")
+
+	if formatFlag.Changed {
+		if webFlag != nil && webFlag.Changed {
+			return nil, errors.New("cannot use `--web` with `--format`")
+		}
+		return &jsonExporter{
+			filter:   jqFlag.Value.String(),
+			template: tplFlag.Value.String(),
+		}, nil
+	} else if jqFlag.Changed && formatValue != "json" {
+		return nil, errors.New("cannot use `--jq` without specifying `--format json`")
+	} else if tplFlag.Changed && formatValue != "json" {
+		return nil, errors.New("cannot use `--template` without specifying `--format json`")
+	}
+	return nil, nil
+}
+
 type Exporter interface {
 	Fields() []string
 	Write(io *iostreams.IOStreams, data interface{}) error
 }
 
-type exportFormat struct {
+type jsonExporter struct {
 	fields   []string
 	filter   string
 	template string
 }
 
-func (e *exportFormat) Fields() []string {
+// NewJSONExporter returns an Exporter to emit JSON.
+func NewJSONExporter() Exporter {
+	return &jsonExporter{}
+}
+
+func (e *jsonExporter) Fields() []string {
 	return e.fields
 }
 
 // Write serializes data into JSON output written to w. If the object passed as data implements exportable,
 // or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
 // raw data for serialization.
-func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
+func (e *jsonExporter) Write(ios *iostreams.IOStreams, data interface{}) error {
 	buf := bytes.Buffer{}
 	encoder := json.NewEncoder(&buf)
 	encoder.SetEscapeHTML(false)
@@ -162,7 +219,7 @@ func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
 	return err
 }
 
-func (e *exportFormat) exportData(v reflect.Value) interface{} {
+func (e *jsonExporter) exportData(v reflect.Value) interface{} {
 	switch v.Kind() {
 	case reflect.Ptr, reflect.Interface:
 		if !v.IsNil() {
diff --git a/script/release-hsm b/script/release-hsm
deleted file mode 100755
index dbe51c8ac..000000000
--- a/script/release-hsm
+++ /dev/null
@@ -1,120 +0,0 @@
-#!/bin/bash
-set -e
-
-print_help() {
-    cat <<EOF
-To tag a new release:
-  script/release [--staging] <tag-name> [--platform {linux|macos|windows}] [--branch <branch>]
-
-To build staging binaries from the current branch:
-  script/release --current [--platform {linux|macos|windows}]
-
-To build binaries locally with goreleaser:
-  script/release --local --platform {linux|macos|windows}
-EOF
-}
-
-if [ $# -eq 0 ]; then
-  print_help >&2
-  exit 1
-fi
-
-tag_name=""
-is_local=""
-do_push=""
-platform=""
-branch="trunk"
-deploy_env="production"
-goreleaser_config=".goreleaser.yml"
-
-while [ $# -gt 0 ]; do
-  case "$1" in
-  -h | --help )
-    print_help
-    exit 0
-    ;;
-  -b | --branch )
-    branch="$2"
-    shift 2
-    ;;
-  -c | --config )
-    goreleaser_config="$2"
-    shift 2
-    ;;
-  -p | --platform )
-    platform="$2"
-    shift 2
-    ;;
-  --local )
-    is_local=1
-    shift 1
-    ;;
-  --staging )
-    deploy_env="staging"
-    shift 1
-    ;;
-  --current )
-    deploy_env="staging"
-    tag_name="$(git describe --tags --abbrev=0)"
-    branch="$(git rev-parse --symbolic-full-name '@{upstream}' 2>/dev/null || git branch --show-current)"
-    branch="${branch#refs/remotes/*/}"
-    do_push=1
-    shift 1
-    ;;
-  -* )
-    printf "unrecognized flag: %s\n" "$1" >&2
-    exit 1
-    ;;
-  * )
-    tag_name="$1"
-    shift 1
-    ;;
-  esac
-done
-
-announce() {
-  local tmpdir="${TMPDIR:-/tmp}"
-  echo "$*" | sed "s:${tmpdir%/}:\$TMPDIR:"
-  "$@"
-}
-
-trigger_deployment() {
-  announce gh workflow -R cli/cli run deployment.yml --ref "$branch" -f tag_name="$tag_name" -f environment="$deploy_env"
-}
-
-build_local() {
-  local config="$goreleaser_config"
-  case "$platform" in
-  linux )
-    sed '/#build:windows/,/^$/d; /#build:macos/,/^$/d' "$config" >.goreleaser.generated.yml
-    config=".goreleaser.generated.yml"
-    ;;
-  macos )
-    sed '/#build:windows/,/^$/d; /#build:linux/,/^$/d' "$config" >.goreleaser.generated.yml
-    config=".goreleaser.generated.yml"
-    ;;
-  windows )
-    sed '/#build:linux/,/^$/d; /#build:macos/,/^$/d' "$config" >.goreleaser.generated.yml
-    config=".goreleaser.generated.yml"
-    ;;
-  esac
-  [ -z "$tag_name" ] || export GORELEASER_CURRENT_TAG="$tag_name"
-  announce goreleaser release -f "$config" --clean --skip-validate --skip-publish --release-notes="$(mktemp)"
-}
-
-if [ -n "$is_local" ]; then
-  build_local
-else
-  if [ -n "$do_push" ]; then
-    if ! git diff --quiet || ! git diff --cached --quiet; then
-      echo "refusing to continue due to uncomitted local changes" >&2
-      exit 1
-    fi
-    announce git push
-  fi
-  trigger_deployment
-  if [ "$deploy_env" = "production" ]; then
-    echo
-    echo "Go to Slack to manually approve this production deployment."
-  fi
-fi
diff --git a/script/sign-hsm.bat b/script/sign-hsm.bat
deleted file mode 100644
index 2b70726f7..000000000
--- a/script/sign-hsm.bat
+++ /dev/null
@@ -1,14 +0,0 @@
-@echo off
-
-if "%DLIB_PATH%" == "" (
-    echo skipping Windows code-signing; DLIB_PATH not set
-    exit /b
-)
-
-if "%METADATA_PATH%" == "" (
-    echo skipping Windows code-signing; METADATA_PATH not set
-    exit /b
-)
-
-REM For more information on signtool, see https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
-"C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool" sign /d "GitHub CLI" /fd sha256 /td sha256 /tr http://timestamp.acs.microsoft.com /v /dlib "%DLIB_PATH%" /dmdf "%METADATA_PATH%" "%1"
diff --git a/script/sign.bat b/script/sign.bat
deleted file mode 100644
index 4fbe8a7af..000000000
--- a/script/sign.bat
+++ /dev/null
@@ -1,8 +0,0 @@
-@echo off
-
-if "%CERT_FILE%" == "" (
-    echo skipping Windows code-signing; CERT_FILE not set
-    exit /b
-)
-
-.\script\signtool sign /d "GitHub CLI" /f "%CERT_FILE%" /p "%CERT_PASSWORD%" /fd sha256 /tr http://timestamp.digicert.com /v "%1"
\ No newline at end of file
diff --git a/script/sign.ps1 b/script/sign.ps1
index 1d79e3f88..f64ec1e72 100644
--- a/script/sign.ps1
+++ b/script/sign.ps1
@@ -14,3 +14,4 @@ $signtool = Resolve-Path "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signt
 Write-Host "Using signtool from $signtool"
 
 & $signtool sign /d "GitHub CLI" /fd sha256 /td sha256 /tr http://timestamp.acs.microsoft.com /v /dlib "$Env:DLIB_PATH" /dmdf "$Env:METADATA_PATH" $Args[0]
+exit $LASTEXITCODE


Test patch:
diff --git a/.github/workflows/deployment-hsm-testing.yml b/.github/workflows/deployment-hsm-testing.yml
deleted file mode 100644
index c3e8f24e4..000000000
--- a/.github/workflows/deployment-hsm-testing.yml
+++ /dev/null
@@ -1,122 +0,0 @@
-name: Deployment HSM Testing
-run-name: ${{ inputs.tag_name }} / go ${{ inputs.go_version }}
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref_name }}
-  cancel-in-progress: true
-
-permissions:
-  contents: write
-
-on:
-  workflow_dispatch:
-    inputs:
-      tag_name:
-        required: true
-        type: string
-      go_version:
-        default: "1.21"
-        type: string
-
-jobs:
-  windows:
-    runs-on: windows-latest
-    environment: production
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v4
-      - name: Set up Go
-        uses: actions/setup-go@v4
-        with:
-          go-version: ${{ inputs.go_version }}
-      - name: Install GoReleaser
-        uses: goreleaser/goreleaser-action@v5
-        with:
-          version: "~1.17.1"
-          install-only: true
-      - name: Install Azure Code Signing Client
-        shell: pwsh
-        env:
-          ACS_DIR: ${{ runner.temp }}\acs
-          ACS_ZIP: ${{ runner.temp }}\acs.zip
-          CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
-        run: |
-          # Download Azure Code Signing client containing the DLL needed for signtool in script/sign
-          Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.38 -OutFile $Env:ACS_ZIP -Verbose
-          Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
-
-          # Generate metadata file for signtool, used in signing box .exe and .msi
-          @{
-            CertificateProfileName = "GitHubInc"
-            CodeSigningAccountName = "GitHubInc"
-            CorrelationId = $Env:CORRELATION_ID
-            Endpoint =  "https://wus.codesigning.azure.net/"
-          } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
-
-      # Azure Code Signing leverages the environment variables for secrets that complement the metadata.json
-      # file generated above (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
-      # For more information, see https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet
-      - name: Build release binaries
-        shell: bash
-        env:
-          AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
-          AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
-          AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
-          DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
-          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
-          TAG_NAME: ${{ inputs.tag_name }}
-        run: script/release-hsm --local "$TAG_NAME" --platform windows --config .goreleaser-hsm.yml
-      - name: Set up MSBuild
-        id: setupmsbuild
-        uses: microsoft/setup-msbuild@v1.3.1
-      - name: Build MSI
-        shell: bash
-        env:
-          MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
-        run: |
-          for ZIP_FILE in dist/gh_*_windows_*.zip; do
-            MSI_NAME="$(basename "$ZIP_FILE" ".zip")"
-            MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)"
-            case "$MSI_NAME" in
-            *_386 )
-              source_dir="$PWD/dist/windows_windows_386"
-              platform="x86"
-              ;;
-            *_amd64 )
-              source_dir="$PWD/dist/windows_windows_amd64_v1"
-              platform="x64"
-              ;;
-            *_arm64 )
-              echo "skipping building MSI for arm64 because WiX 3.11 doesn't support it: https://github.com/wixtoolset/issues/issues/6141" >&2
-              continue
-              #source_dir="$PWD/dist/windows_windows_arm64"
-              #platform="arm64"
-              ;;
-            * )
-              printf "unsupported architecture: %s\n" "$MSI_NAME" >&2
-              exit 1
-              ;;
-            esac
-            "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
-          done
-      - name: Sign .msi release binaries
-        shell: pwsh
-        env:
-          AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
-          AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
-          AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
-          DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
-          METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
-        run: |
-          Get-ChildItem "$Env:GITHUB_WORKSPACE/dist" -Filter *.msi | Foreach-Object {
-            .\script\sign.ps1 $_.FullName
-          }
-      - uses: actions/upload-artifact@v3
-        with:
-          name: windows
-          if-no-files-found: error
-          retention-days: 7
-          path: |
-            dist/*.zip
-            dist/*.msi
diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go
index 78039db0d..6466d9be7 100644
--- a/pkg/cmd/codespace/create_test.go
+++ b/pkg/cmd/codespace/create_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/AlecAivazis/survey/v2"
 	"github.com/cli/cli/v2/internal/browser"
 	"github.com/cli/cli/v2/internal/codespaces/api"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -703,6 +704,127 @@ func TestBuildDisplayName(t *testing.T) {
 	}
 }
 
+type MockSurveyPrompter struct {
+	AskFunc func(qs []*survey.Question, response interface{}) error
+}
+
+func (m *MockSurveyPrompter) Ask(qs []*survey.Question, response interface{}) error {
+	return m.AskFunc(qs, response)
+}
+
+type MockBrowser struct {
+	Err error
+}
+
+func (b *MockBrowser) Browse(url string) error {
+	if b.Err != nil {
+		return b.Err
+	}
+
+	return nil
+}
+
+func TestHandleAdditionalPermissions(t *testing.T) {
+	tests := []struct {
+		name                  string
+		isInteractive         bool
+		accept                string
+		permissionsOptOut     bool
+		browserErr            error
+		pollForPermissionsErr error
+		createCodespaceErr    error
+		wantErr               bool
+	}{
+		{
+			name:              "non-interactive",
+			isInteractive:     false,
+			permissionsOptOut: false,
+			wantErr:           true,
+		},
+		{
+			name:              "interactive, continue in browser, browser error",
+			isInteractive:     true,
+			accept:            "Continue in browser to review and authorize additional permissions (Recommended)",
+			permissionsOptOut: false,
+			browserErr:        fmt.Errorf("browser error"),
+			wantErr:           true,
+		},
+		{
+			name:                  "interactive, continue in browser, poll for permissions error",
+			isInteractive:         true,
+			accept:                "Continue in browser to review and authorize additional permissions (Recommended)",
+			permissionsOptOut:     false,
+			pollForPermissionsErr: fmt.Errorf("poll for permissions error"),
+			wantErr:               true,
+		},
+		{
+			name:               "interactive, continue in browser, create codespace error",
+			isInteractive:      true,
+			accept:             "Continue in browser to review and authorize additional permissions (Recommended)",
+			permissionsOptOut:  false,
+			createCodespaceErr: fmt.Errorf("create codespace error"),
+			wantErr:            true,
+		},
+		{
+			name:               "interactive, continue without authorizing",
+			isInteractive:      true,
+			accept:             "Continue without authorizing additional permissions",
+			permissionsOptOut:  true,
+			createCodespaceErr: fmt.Errorf("create codespace error"),
+			wantErr:            true,
+		},
+		{
+			name:              "interactive, continue without authorizing, create codespace success",
+			isInteractive:     true,
+			accept:            "Continue without authorizing additional permissions",
+			permissionsOptOut: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			a := &App{
+				io: ios,
+				browser: &MockBrowser{
+					Err: tt.browserErr,
+				},
+				apiClient: &apiClientMock{
+					CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
+						return nil, tt.createCodespaceErr
+					},
+					GetCodespacesPermissionsCheckFunc: func(ctx context.Context, repoID int, branch string, devcontainerPath string) (bool, error) {
+						if tt.pollForPermissionsErr != nil {
+							return false, tt.pollForPermissionsErr
+						}
+						return true, nil
+					},
+				},
+			}
+
+			if tt.isInteractive {
+				a.io.SetStdinTTY(true)
+				a.io.SetStdoutTTY(true)
+				a.io.SetStderrTTY(true)
+			}
+
+			params := &api.CreateCodespaceParams{}
+			_, err := a.handleAdditionalPermissions(context.Background(), &MockSurveyPrompter{
+				AskFunc: func(qs []*survey.Question, response interface{}) error {
+					*response.(*struct{ Accept string }) = struct{ Accept string }{Accept: tt.accept}
+					return nil
+				},
+			}, params, "http://example.com")
+			if (err != nil) != tt.wantErr {
+				t.Errorf("handleAdditionalPermissions() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.permissionsOptOut != params.PermissionsOptOut {
+				t.Errorf("handleAdditionalPermissions() permissionsOptOut = %v, want %v", params.PermissionsOptOut, tt.permissionsOptOut)
+			}
+		})
+	}
+}
+
 func apiCreateDefaults(c *apiClientMock) *apiClientMock {
 	if c.GetRepositoryFunc == nil {
 		c.GetRepositoryFunc = func(ctx context.Context, nwo string) (*api.Repository, error) {
diff --git a/pkg/cmd/project/close/close_test.go b/pkg/cmd/project/close/close_test.go
index 96ec6ac50..92ca64bd0 100644
--- a/pkg/cmd/project/close/close_test.go
+++ b/pkg/cmd/project/close/close_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdClose(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       closeOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         closeOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -47,11 +48,9 @@ func TestNewCmdClose(t *testing.T) {
 			},
 		},
 		{
-			name: "json",
-			cli:  "--format json",
-			wants: closeOpts{
-				format: "json",
-			},
+			name:          "json",
+			cli:           "--format json",
+			wantsExporter: true,
 		},
 	}
 
@@ -84,7 +83,7 @@ func TestNewCmdClose(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -453,3 +452,100 @@ func TestRunClose_Reopen(t *testing.T) {
 		"http://a-url.com\n",
 		stdout.String())
 }
+
+func TestRunClose_JSON(t *testing.T) {
+	defer gock.Off()
+	// gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get user project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]string{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// close project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation CloseProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","closed":true}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"updateProjectV2": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"number": 1,
+						"title":  "a title",
+						"url":    "http://a-url.com",
+						"owner": map[string]interface{}{
+							"__typename": "User",
+							"login":      "monalisa",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := closeConfig{
+		io: ios,
+		opts: closeOpts{
+			number:   1,
+			owner:    "monalisa",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+	}
+
+	err := runClose(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"http://a-url.com","shortDescription":"","public":false,"closed":false,"title":"a title","id":"","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"User","login":"monalisa"}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/copy/copy_test.go b/pkg/cmd/project/copy/copy_test.go
index 3caec592d..f098818ea 100644
--- a/pkg/cmd/project/copy/copy_test.go
+++ b/pkg/cmd/project/copy/copy_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdCopy(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       copyOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         copyOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -68,9 +69,9 @@ func TestNewCmdCopy(t *testing.T) {
 			name: "json",
 			cli:  "--format json --title t",
 			wants: copyOpts{
-				format: "json",
-				title:  "t",
+				title: "t",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -106,7 +107,7 @@ func TestNewCmdCopy(t *testing.T) {
 			assert.Equal(t, tt.wants.targetOwner, gotOpts.targetOwner)
 			assert.Equal(t, tt.wants.title, gotOpts.title)
 			assert.Equal(t, tt.wants.includeDraftIssues, gotOpts.includeDraftIssues)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -458,3 +459,128 @@ func TestRunCopy_Me(t *testing.T) {
 		"http://a-url.com\n",
 		stdout.String())
 }
+
+func TestRunCopy_JSON(t *testing.T) {
+	defer gock.Off()
+	// gock.Observe(gock.DumpRequest)
+
+	// get user project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]string{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// get source user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]string{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id":    "an ID",
+					"login": "monalisa",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get target user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]string{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id":    "an ID",
+					"login": "monalisa",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// Copy project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation CopyProjectV2.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","ownerId":"an ID","title":"a title","includeDraftIssues":false}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"copyProjectV2": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"number": 1,
+						"title":  "a title",
+						"url":    "http://a-url.com",
+						"owner": map[string]string{
+							"login": "monalisa",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := copyConfig{
+		io: ios,
+		opts: copyOpts{
+			title:       "a title",
+			sourceOwner: "monalisa",
+			targetOwner: "monalisa",
+			number:      1,
+			exporter:    cmdutil.NewJSONExporter(),
+		},
+		client: client,
+	}
+
+	err := runCopy(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"http://a-url.com","shortDescription":"","public":false,"closed":false,"title":"a title","id":"","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"","login":"monalisa"}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/create/create_test.go b/pkg/cmd/project/create/create_test.go
index 74d1747b7..30f1a879f 100644
--- a/pkg/cmd/project/create/create_test.go
+++ b/pkg/cmd/project/create/create_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdCreate(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       createOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         createOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name: "title",
@@ -38,9 +39,9 @@ func TestNewCmdCreate(t *testing.T) {
 			name: "json",
 			cli:  "--title t --format json",
 			wants: createOpts{
-				format: "json",
-				title:  "t",
+				title: "t",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -73,7 +74,7 @@ func TestNewCmdCreate(t *testing.T) {
 
 			assert.Equal(t, tt.wants.title, gotOpts.title)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -274,3 +275,74 @@ func TestRunCreate_Me(t *testing.T) {
 		"http://a-url.com\n",
 		stdout.String())
 }
+
+func TestRunCreate_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]string{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id":    "an ID",
+					"login": "monalisa",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// create project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation CreateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"ownerId":"an ID","title":"a title"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"createProjectV2": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"number": 1,
+						"title":  "a title",
+						"url":    "http://a-url.com",
+						"owner": map[string]string{
+							"login": "monalisa",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := createConfig{
+		opts: createOpts{
+			title:    "a title",
+			owner:    "monalisa",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runCreate(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"http://a-url.com","shortDescription":"","public":false,"closed":false,"title":"a title","id":"","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"","login":"monalisa"}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/delete/delete_test.go b/pkg/cmd/project/delete/delete_test.go
index 589ef72bc..bcd332934 100644
--- a/pkg/cmd/project/delete/delete_test.go
+++ b/pkg/cmd/project/delete/delete_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdDelete(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       deleteOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         deleteOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -40,11 +41,9 @@ func TestNewCmdDelete(t *testing.T) {
 			},
 		},
 		{
-			name: "json",
-			cli:  "--format json",
-			wants: deleteOpts{
-				format: "json",
-			},
+			name:          "json",
+			cli:           "--format json",
+			wantsExporter: true,
 		},
 	}
 
@@ -77,7 +76,7 @@ func TestNewCmdDelete(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -347,3 +346,95 @@ func TestRunDelete_Me(t *testing.T) {
 		"Deleted project 1\n",
 		stdout.String())
 }
+
+func TestRunDelete_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// delete project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation DeleteProject.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"deleteProjectV2": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id":     "project ID",
+						"number": 1,
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := deleteConfig{
+		opts: deleteOpts{
+			owner:    "monalisa",
+			number:   1,
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runDelete(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"","shortDescription":"","public":false,"closed":false,"title":"","id":"project ID","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"","login":""}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/edit/edit_test.go b/pkg/cmd/project/edit/edit_test.go
index da30b0935..3160eacde 100644
--- a/pkg/cmd/project/edit/edit_test.go
+++ b/pkg/cmd/project/edit/edit_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdEdit(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       editOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         editOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -85,9 +86,9 @@ func TestNewCmdEdit(t *testing.T) {
 			name: "json",
 			cli:  "--format json --title t",
 			wants: editOpts{
-				format: "json",
-				title:  "t",
+				title: "t",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -124,7 +125,7 @@ func TestNewCmdEdit(t *testing.T) {
 			assert.Equal(t, tt.wants.title, gotOpts.title)
 			assert.Equal(t, tt.wants.readme, gotOpts.readme)
 			assert.Equal(t, tt.wants.shortDescription, gotOpts.shortDescription)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -508,3 +509,103 @@ func TestRunUpdate_OmitParams(t *testing.T) {
 		"http://a-url.com\n",
 		stdout.String())
 }
+
+func TestRunUpdate_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get user project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]string{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// edit project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation UpdateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","title":"a new title","shortDescription":"a new description","readme":"a new readme","public":true}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"updateProjectV2": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"number": 1,
+						"title":  "a title",
+						"url":    "http://a-url.com",
+						"owner": map[string]string{
+							"login": "monalisa",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := editConfig{
+		opts: editOpts{
+			number:           1,
+			owner:            "monalisa",
+			title:            "a new title",
+			shortDescription: "a new description",
+			visibility:       "PUBLIC",
+			readme:           "a new readme",
+			exporter:         cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runEdit(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"http://a-url.com","shortDescription":"","public":false,"closed":false,"title":"a title","id":"","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"","login":"monalisa"}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/field-create/field_create_test.go b/pkg/cmd/project/field-create/field_create_test.go
index 01904d9e9..e900f2321 100644
--- a/pkg/cmd/project/field-create/field_create_test.go
+++ b/pkg/cmd/project/field-create/field_create_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdCreateField(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       createFieldOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         createFieldOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-name-and-data-type",
@@ -70,11 +71,11 @@ func TestNewCmdCreateField(t *testing.T) {
 			name: "json",
 			cli:  "--format json --name n --data-type TEXT ",
 			wants: createFieldOpts{
-				format:              "json",
 				name:                "n",
 				dataType:            "TEXT",
 				singleSelectOptions: []string{},
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -110,7 +111,7 @@ func TestNewCmdCreateField(t *testing.T) {
 			assert.Equal(t, tt.wants.name, gotOpts.name)
 			assert.Equal(t, tt.wants.dataType, gotOpts.dataType)
 			assert.Equal(t, tt.wants.singleSelectOptions, gotOpts.singleSelectOptions)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -628,3 +629,98 @@ func TestRunCreateField_NUMBER(t *testing.T) {
 		"Created field\n",
 		stdout.String())
 }
+
+func TestRunCreateField_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// create Field
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"TEXT","name":"a name"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"createProjectV2Field": map[string]interface{}{
+					"projectV2Field": map[string]interface{}{
+						"__typename": "ProjectV2Field",
+						"id":         "Field ID",
+						"name":       "a name",
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := createFieldConfig{
+		opts: createFieldOpts{
+			name:     "a name",
+			owner:    "monalisa",
+			number:   1,
+			dataType: "TEXT",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runCreateField(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"Field ID","name":"a name","type":"ProjectV2Field"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/field-delete/field_delete_test.go b/pkg/cmd/project/field-delete/field_delete_test.go
index 6b1e6c763..6d686c348 100644
--- a/pkg/cmd/project/field-delete/field_delete_test.go
+++ b/pkg/cmd/project/field-delete/field_delete_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdDeleteField(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       deleteFieldOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         deleteFieldOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "no id",
@@ -36,9 +37,9 @@ func TestNewCmdDeleteField(t *testing.T) {
 			name: "json",
 			cli:  "--id 123 --format json",
 			wants: deleteFieldOpts{
-				format:  "json",
 				fieldID: "123",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -70,7 +71,7 @@ func TestNewCmdDeleteField(t *testing.T) {
 			assert.NoError(t, err)
 
 			assert.Equal(t, tt.wants.fieldID, gotOpts.fieldID)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -113,3 +114,44 @@ func TestRunDeleteField(t *testing.T) {
 		"Deleted field\n",
 		stdout.String())
 }
+
+func TestRunDeleteField_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// delete Field
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation DeleteField.*","variables":{"input":{"fieldId":"an ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"deleteProjectV2Field": map[string]interface{}{
+					"projectV2Field": map[string]interface{}{
+						"__typename": "ProjectV2Field",
+						"id":         "Field ID",
+						"name":       "a name",
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := deleteFieldConfig{
+		opts: deleteFieldOpts{
+			fieldID:  "an ID",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runDeleteField(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"Field ID","name":"a name","type":"ProjectV2Field"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/field-list/field_list_test.go b/pkg/cmd/project/field-list/field_list_test.go
index ca01599fb..cc2aa49fc 100644
--- a/pkg/cmd/project/field-list/field_list_test.go
+++ b/pkg/cmd/project/field-list/field_list_test.go
@@ -14,11 +14,12 @@ import (
 
 func TestNewCmdList(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       listOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         listOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -46,9 +47,9 @@ func TestNewCmdList(t *testing.T) {
 			name: "json",
 			cli:  "--format json",
 			wants: listOpts{
-				format: "json",
-				limit:  30,
+				limit: 30,
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -82,7 +83,7 @@ func TestNewCmdList(t *testing.T) {
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
 			assert.Equal(t, tt.wants.limit, gotOpts.limit)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -512,3 +513,97 @@ func TestRunList_Empty(t *testing.T) {
 		err,
 		"Project 1 for owner @me has no fields")
 }
+
+func TestRunList_JSON(t *testing.T) {
+	defer gock.Off()
+	// gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// list project fields
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  queries.LimitMax,
+				"afterItems":  nil,
+				"firstFields": queries.LimitDefault,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"fields": map[string]interface{}{
+							"nodes": []map[string]interface{}{
+								{
+									"__typename": "ProjectV2Field",
+									"name":       "FieldTitle",
+									"id":         "field ID",
+								},
+								{
+									"__typename": "ProjectV2SingleSelectField",
+									"name":       "Status",
+									"id":         "status ID",
+								},
+								{
+									"__typename": "ProjectV2IterationField",
+									"name":       "Iterations",
+									"id":         "iteration ID",
+								},
+							},
+							"totalCount": 3,
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := listConfig{
+		opts: listOpts{
+			number:   1,
+			owner:    "monalisa",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runList(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"fields":[{"id":"field ID","name":"FieldTitle","type":"ProjectV2Field"},{"id":"status ID","name":"Status","type":"ProjectV2SingleSelectField"},{"id":"iteration ID","name":"Iterations","type":"ProjectV2IterationField"}],"totalCount":3}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-add/item_add_test.go b/pkg/cmd/project/item-add/item_add_test.go
index 0beb1fed6..8d117a708 100644
--- a/pkg/cmd/project/item-add/item_add_test.go
+++ b/pkg/cmd/project/item-add/item_add_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdaddItem(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       addItemOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         addItemOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-url",
@@ -58,9 +59,9 @@ func TestNewCmdaddItem(t *testing.T) {
 			name: "json",
 			cli:  "--format json --url github.com/cli/cli",
 			wants: addItemOpts{
-				format:  "json",
 				itemURL: "github.com/cli/cli",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -94,7 +95,7 @@ func TestNewCmdaddItem(t *testing.T) {
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
 			assert.Equal(t, tt.wants.itemURL, gotOpts.itemURL)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -422,3 +423,119 @@ func TestRunAddItem_Me(t *testing.T) {
 		"Added item\n",
 		stdout.String())
 }
+
+func TestRunAddItem_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// get item ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query GetIssueOrPullRequest.*",
+			"variables": map[string]interface{}{
+				"url": "https://github.com/cli/go-gh/issues/1",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"resource": map[string]interface{}{
+					"id":         "item ID",
+					"__typename": "Issue",
+				},
+			},
+		})
+
+	// create item
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation AddItem.*","variables":{"input":{"projectId":"an ID","contentId":"item ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"addProjectV2ItemById": map[string]interface{}{
+					"item": map[string]interface{}{
+						"id": "item ID",
+						"content": map[string]interface{}{
+							"__typename": "Issue",
+							"title":      "a title",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := addItemConfig{
+		opts: addItemOpts{
+			owner:    "monalisa",
+			number:   1,
+			itemURL:  "https://github.com/cli/go-gh/issues/1",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runAddItem(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"item ID","title":"a title","body":"","type":"Issue"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-archive/item_archive_test.go b/pkg/cmd/project/item-archive/item_archive_test.go
index 6eb8336f2..30473ff54 100644
--- a/pkg/cmd/project/item-archive/item_archive_test.go
+++ b/pkg/cmd/project/item-archive/item_archive_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdarchiveItem(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       archiveItemOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         archiveItemOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-id",
@@ -66,9 +67,9 @@ func TestNewCmdarchiveItem(t *testing.T) {
 			name: "json",
 			cli:  "--format json --id 123",
 			wants: archiveItemOpts{
-				format: "json",
 				itemID: "123",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -103,7 +104,7 @@ func TestNewCmdarchiveItem(t *testing.T) {
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
 			assert.Equal(t, tt.wants.itemID, gotOpts.itemID)
 			assert.Equal(t, tt.wants.undo, gotOpts.undo)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -635,3 +636,99 @@ func TestRunArchive_Me_Undo(t *testing.T) {
 		"Unarchived item\n",
 		stdout.String())
 }
+
+func TestRunArchive_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// archive item
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation ArchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"archiveProjectV2Item": map[string]interface{}{
+					"item": map[string]interface{}{
+						"id": "item ID",
+						"content": map[string]interface{}{
+							"__typename": "Issue",
+							"title":      "a title",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := archiveItemConfig{
+		opts: archiveItemOpts{
+			owner:    "monalisa",
+			number:   1,
+			itemID:   "item ID",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runArchiveItem(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"item ID","title":"a title","body":"","type":"Issue"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-create/item_create_test.go b/pkg/cmd/project/item-create/item_create_test.go
index 1482fe268..085aa99f4 100644
--- a/pkg/cmd/project/item-create/item_create_test.go
+++ b/pkg/cmd/project/item-create/item_create_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdCreateItem(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       createItemOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         createItemOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-title",
@@ -66,9 +67,9 @@ func TestNewCmdCreateItem(t *testing.T) {
 			name: "json",
 			cli:  "--format json --title t",
 			wants: createItemOpts{
-				format: "json",
-				title:  "t",
+				title: "t",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -102,7 +103,7 @@ func TestNewCmdCreateItem(t *testing.T) {
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
 			assert.Equal(t, tt.wants.title, gotOpts.title)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -370,3 +371,98 @@ func TestRunCreateItem_Draft_Me(t *testing.T) {
 		"Created item\n",
 		stdout.String())
 }
+
+func TestRunCreateItem_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// create item
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation CreateDraftItem.*","variables":{"input":{"projectId":"an ID","title":"a title","body":""}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"addProjectV2DraftIssue": map[string]interface{}{
+					"projectItem": map[string]interface{}{
+						"id": "item ID",
+						"content": map[string]interface{}{
+							"__typename": "Draft",
+							"title":      "a title",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := createItemConfig{
+		opts: createItemOpts{
+			title:    "a title",
+			owner:    "monalisa",
+			number:   1,
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runCreateItem(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"item ID","title":"","body":"","type":"Draft"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-delete/item_delete_test.go b/pkg/cmd/project/item-delete/item_delete_test.go
index 4d3e97059..785f73edd 100644
--- a/pkg/cmd/project/item-delete/item_delete_test.go
+++ b/pkg/cmd/project/item-delete/item_delete_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdDeleteItem(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       deleteItemOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         deleteItemOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-id",
@@ -58,9 +59,9 @@ func TestNewCmdDeleteItem(t *testing.T) {
 			name: "json",
 			cli:  "--format json --id 123",
 			wants: deleteItemOpts{
-				format: "json",
 				itemID: "123",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -94,7 +95,7 @@ func TestNewCmdDeleteItem(t *testing.T) {
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
 			assert.Equal(t, tt.wants.itemID, gotOpts.itemID)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -355,3 +356,92 @@ func TestRunDelete_Me(t *testing.T) {
 		"Deleted item\n",
 		stdout.String())
 }
+
+func TestRunDelete_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserProject.*",
+			"variables": map[string]interface{}{
+				"login":       "monalisa",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// delete item
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation DeleteProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"deleteProjectV2Item": map[string]interface{}{
+					"deletedItemId": "item ID",
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := deleteItemConfig{
+		opts: deleteItemOpts{
+			owner:    "monalisa",
+			number:   1,
+			itemID:   "item ID",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runDeleteItem(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"item ID"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go
index f865d4ade..fb9097d19 100644
--- a/pkg/cmd/project/item-edit/item_edit_test.go
+++ b/pkg/cmd/project/item-edit/item_edit_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdeditItem(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       editItemOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         editItemOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "missing-id",
@@ -108,9 +109,9 @@ func TestNewCmdeditItem(t *testing.T) {
 			name: "json",
 			cli:  "--format json --id 123",
 			wants: editItemOpts{
-				format: "json",
 				itemID: "123",
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -143,7 +144,7 @@ func TestNewCmdeditItem(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.itemID, gotOpts.itemID)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 			assert.Equal(t, tt.wants.title, gotOpts.title)
 			assert.Equal(t, tt.wants.fieldID, gotOpts.fieldID)
 			assert.Equal(t, tt.wants.projectID, gotOpts.projectID)
@@ -531,3 +532,46 @@ func TestRunItemEdit_Clear(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, "Edited item \"title\"\n", stdout.String())
 }
+
+func TestRunItemEdit_JSON(t *testing.T) {
+	defer gock.Off()
+	// gock.Observe(gock.DumpRequest)
+
+	// edit item
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"a title","body":"a new body"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"updateProjectV2DraftIssue": map[string]interface{}{
+					"draftIssue": map[string]interface{}{
+						"id":    "DI_item_id",
+						"title": "a title",
+						"body":  "a new body",
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := editItemConfig{
+		io: ios,
+		opts: editItemOpts{
+			title:    "a title",
+			body:     "a new body",
+			itemID:   "DI_item_id",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+	}
+
+	err := runEditItem(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"id":"DI_item_id","title":"a title","body":"a new body","type":"DraftIssue"}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/item-list/item_list_test.go b/pkg/cmd/project/item-list/item_list_test.go
index f637ea99b..58d4beeeb 100644
--- a/pkg/cmd/project/item-list/item_list_test.go
+++ b/pkg/cmd/project/item-list/item_list_test.go
@@ -15,11 +15,12 @@ import (
 
 func TestNewCmdList(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       listOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         listOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -47,9 +48,9 @@ func TestNewCmdList(t *testing.T) {
 			name: "json",
 			cli:  "--format json",
 			wants: listOpts{
-				format: "json",
-				limit:  30,
+				limit: 30,
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -82,7 +83,7 @@ func TestNewCmdList(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 			assert.Equal(t, tt.wants.limit, gotOpts.limit)
 		})
 	}
@@ -504,3 +505,111 @@ func TestRunList_Me(t *testing.T) {
 		"Issue\tan issue\t1\tcli/go-gh\tissue ID\nPullRequest\ta pull request\t2\tcli/go-gh\tpull request ID\nDraftIssue\tdraft issue\t\t\tdraft issue ID\n",
 		stdout.String())
 }
+
+func TestRunList_JSON(t *testing.T) {
+	defer gock.Off()
+	// gock.Observe(gock.DumpRequest)
+
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	// list project items
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		JSON(map[string]interface{}{
+			"query": "query UserProjectWithItems.*",
+			"variables": map[string]interface{}{
+				"firstItems":  queries.LimitDefault,
+				"afterItems":  nil,
+				"firstFields": queries.LimitMax,
+				"afterFields": nil,
+				"login":       "monalisa",
+				"number":      1,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"items": map[string]interface{}{
+							"nodes": []map[string]interface{}{
+								{
+									"id": "issue ID",
+									"content": map[string]interface{}{
+										"__typename": "Issue",
+										"title":      "an issue",
+										"number":     1,
+										"repository": map[string]string{
+											"nameWithOwner": "cli/go-gh",
+										},
+									},
+								},
+								{
+									"id": "pull request ID",
+									"content": map[string]interface{}{
+										"__typename": "PullRequest",
+										"title":      "a pull request",
+										"number":     2,
+										"repository": map[string]string{
+											"nameWithOwner": "cli/go-gh",
+										},
+									},
+								},
+								{
+									"id": "draft issue ID",
+									"content": map[string]interface{}{
+										"title":      "draft issue",
+										"__typename": "DraftIssue",
+									},
+								},
+							},
+							"totalCount": 3,
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := listConfig{
+		opts: listOpts{
+			number:   1,
+			owner:    "monalisa",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runList(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"items":[{"content":{"type":"Issue","body":"","title":"an issue","number":1,"repository":"cli/go-gh","url":""},"id":"issue ID"},{"content":{"type":"PullRequest","body":"","title":"a pull request","number":2,"repository":"cli/go-gh","url":""},"id":"pull request ID"},{"content":{"type":"DraftIssue","body":"","title":"draft issue"},"id":"draft issue ID"}],"totalCount":3}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go
index bb840caa6..38cce4f22 100644
--- a/pkg/cmd/project/list/list_test.go
+++ b/pkg/cmd/project/list/list_test.go
@@ -15,11 +15,12 @@ import (
 
 func TestNewCmdlist(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       listOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         listOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name: "owner",
@@ -49,9 +50,9 @@ func TestNewCmdlist(t *testing.T) {
 			name: "json",
 			cli:  "--format json",
 			wants: listOpts{
-				format: "json",
-				limit:  30,
+				limit: 30,
 			},
+			wantsExporter: true,
 		},
 	}
 
@@ -86,7 +87,7 @@ func TestNewCmdlist(t *testing.T) {
 			assert.Equal(t, tt.wants.closed, gotOpts.closed)
 			assert.Equal(t, tt.wants.web, gotOpts.web)
 			assert.Equal(t, tt.wants.limit, gotOpts.limit)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
@@ -248,6 +249,86 @@ func TestRunList(t *testing.T) {
 		stdout.String())
 }
 
+func TestRunList_tty(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"login": "monalisa",
+					"projectsV2": map[string]interface{}{
+						"nodes": []interface{}{
+							map[string]interface{}{
+								"title":            "Project 1",
+								"shortDescription": "Short description 1",
+								"url":              "url1",
+								"closed":           false,
+								"ID":               "id-1",
+								"number":           1,
+							},
+							map[string]interface{}{
+								"title":            "Project 2",
+								"shortDescription": "",
+								"url":              "url2",
+								"closed":           true,
+								"ID":               "id-2",
+								"number":           2,
+							},
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	ios.SetStdoutTTY(true)
+	config := listConfig{
+		opts: listOpts{
+			owner: "monalisa",
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runList(config)
+	assert.NoError(t, err)
+	assert.Equal(t, heredoc.Doc(`
+			NUMBER  TITLE      STATE  ID
+			1       Project 1  open   id-1
+		`),
+		stdout.String())
+}
+
 func TestRunList_Me(t *testing.T) {
 	defer gock.Off()
 	gock.Observe(gock.DumpRequest)
@@ -806,3 +887,83 @@ func TestRunListWeb_Closed(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, "https://github.com/users/theviewer/projects?query=is%3Aclosed", buf.String())
 }
+
+func TestRunList_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+	// get user ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "monalisa",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"organization"},
+				},
+			},
+		})
+
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"login": "monalisa",
+					"projectsV2": map[string]interface{}{
+						"nodes": []interface{}{
+							map[string]interface{}{
+								"title":            "Project 1",
+								"shortDescription": "Short description 1",
+								"url":              "url1",
+								"closed":           false,
+								"ID":               "id-1",
+								"number":           1,
+							},
+							map[string]interface{}{
+								"title":            "Project 2",
+								"shortDescription": "",
+								"url":              "url2",
+								"closed":           true,
+								"ID":               "id-2",
+								"number":           2,
+							},
+						},
+						"totalCount": 2,
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := listConfig{
+		opts: listOpts{
+			owner:    "monalisa",
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runList(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"projects":[{"number":1,"url":"url1","shortDescription":"Short description 1","public":false,"closed":false,"title":"Project 1","id":"id-1","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"","login":""}}],"totalCount":2}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/mark-template/mark_template_test.go b/pkg/cmd/project/mark-template/mark_template_test.go
index 581c4d8f6..91d2c8095 100644
--- a/pkg/cmd/project/mark-template/mark_template_test.go
+++ b/pkg/cmd/project/mark-template/mark_template_test.go
@@ -13,11 +13,12 @@ import (
 
 func TestNewCmdMarkTemplate(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       markTemplateOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         markTemplateOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -48,11 +49,9 @@ func TestNewCmdMarkTemplate(t *testing.T) {
 		},
 
 		{
-			name: "json",
-			cli:  "--format json",
-			wants: markTemplateOpts{
-				format: "json",
-			},
+			name:          "json",
+			cli:           "--format json",
+			wantsExporter: true,
 		},
 	}
 
@@ -85,10 +84,11 @@ func TestNewCmdMarkTemplate(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 		})
 	}
 }
+
 func TestRunMarkTemplate_Org(t *testing.T) {
 	defer gock.Off()
 	gock.Observe(gock.DumpRequest)
@@ -273,3 +273,99 @@ func TestRunUnmarkTemplate_Org(t *testing.T) {
 		"Unmarked project 1 as a template.\n",
 		stdout.String())
 }
+
+func TestRunMarkTemplate_JSON(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// get org ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query UserOrgOwner.*",
+			"variables": map[string]interface{}{
+				"login": "github",
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"organization": map[string]interface{}{
+					"id": "an ID",
+				},
+			},
+			"errors": []interface{}{
+				map[string]interface{}{
+					"type": "NOT_FOUND",
+					"path": []string{"user"},
+				},
+			},
+		})
+
+	// get project ID
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		MatchType("json").
+		JSON(map[string]interface{}{
+			"query": "query OrgProject.*",
+			"variables": map[string]interface{}{
+				"login":       "github",
+				"number":      1,
+				"firstItems":  0,
+				"afterItems":  nil,
+				"firstFields": 0,
+				"afterFields": nil,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"organization": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id": "an ID",
+					},
+				},
+			},
+		})
+
+	// template project
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		BodyString(`{"query":"mutation MarkProjectTemplate.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"markProjectV2AsTemplate": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"id":     "project ID",
+						"number": 1,
+						"owner": map[string]interface{}{
+							"__typename": "Organization",
+							"login":      "github",
+						},
+					},
+				},
+			},
+		})
+
+	client := queries.NewTestClient()
+
+	ios, _, stdout, _ := iostreams.Test()
+	config := markTemplateConfig{
+		opts: markTemplateOpts{
+			owner:    "github",
+			number:   1,
+			exporter: cmdutil.NewJSONExporter(),
+		},
+		client: client,
+		io:     ios,
+	}
+
+	err := runMarkTemplate(config)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"number":1,"url":"","shortDescription":"","public":false,"closed":false,"title":"","id":"project ID","readme":"","items":{"totalCount":0},"fields":{"totalCount":0},"owner":{"type":"Organization","login":"github"}}`,
+		stdout.String())
+}
diff --git a/pkg/cmd/project/shared/format/display_test.go b/pkg/cmd/project/shared/format/display_test.go
new file mode 100644
index 000000000..0aa21623b
--- /dev/null
+++ b/pkg/cmd/project/shared/format/display_test.go
@@ -0,0 +1,18 @@
+package format
+
+import (
+	"testing"
+
+	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestProjectState(t *testing.T) {
+	assert.Equal(t, "open", ProjectState(queries.Project{}))
+	assert.Equal(t, "closed", ProjectState(queries.Project{Closed: true}))
+}
+
+func TestColorForProjectState(t *testing.T) {
+	assert.Equal(t, "green", ColorForProjectState(queries.Project{}))
+	assert.Equal(t, "gray", ColorForProjectState(queries.Project{Closed: true}))
+}
diff --git a/pkg/cmd/project/shared/format/json_test.go b/pkg/cmd/project/shared/queries/export_data_test.go
similarity index 63%
rename from pkg/cmd/project/shared/format/json_test.go
rename to pkg/cmd/project/shared/queries/export_data_test.go
index c05a3e5ff..976839d43 100644
--- a/pkg/cmd/project/shared/format/json_test.go
+++ b/pkg/cmd/project/shared/queries/export_data_test.go
@@ -1,15 +1,15 @@
-package format
+package queries
 
 import (
+	"encoding/json"
 	"testing"
 
-	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
-
 	"github.com/stretchr/testify/assert"
 )
 
+// Regression test from before ExportData was implemented.
 func TestJSONProject_User(t *testing.T) {
-	project := queries.Project{
+	project := Project{
 		ID:               "123",
 		Number:           2,
 		URL:              "a url",
@@ -22,14 +22,15 @@ func TestJSONProject_User(t *testing.T) {
 	project.Fields.TotalCount = 2
 	project.Owner.TypeName = "User"
 	project.Owner.User.Login = "monalisa"
-	b, err := JSONProject(project)
+	b, err := json.Marshal(project.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}}`, string(b))
+	assert.JSONEq(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}}`, string(b))
 }
 
+// Regression test from before ExportData was implemented.
 func TestJSONProject_Org(t *testing.T) {
-	project := queries.Project{
+	project := Project{
 		ID:               "123",
 		Number:           2,
 		URL:              "a url",
@@ -42,14 +43,15 @@ func TestJSONProject_Org(t *testing.T) {
 	project.Fields.TotalCount = 2
 	project.Owner.TypeName = "Organization"
 	project.Owner.Organization.Login = "github"
-	b, err := JSONProject(project)
+	b, err := json.Marshal(project.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}`, string(b))
+	assert.JSONEq(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}`, string(b))
 }
 
+// Regression test from before ExportData was implemented.
 func TestJSONProjects(t *testing.T) {
-	userProject := queries.Project{
+	userProject := Project{
 		ID:               "123",
 		Number:           2,
 		URL:              "a url",
@@ -63,7 +65,7 @@ func TestJSONProjects(t *testing.T) {
 	userProject.Owner.TypeName = "User"
 	userProject.Owner.User.Login = "monalisa"
 
-	orgProject := queries.Project{
+	orgProject := Project{
 		ID:               "123",
 		Number:           2,
 		URL:              "a url",
@@ -76,33 +78,38 @@ func TestJSONProjects(t *testing.T) {
 	orgProject.Fields.TotalCount = 2
 	orgProject.Owner.TypeName = "Organization"
 	orgProject.Owner.Organization.Login = "github"
-	b, err := JSONProjects([]queries.Project{userProject, orgProject}, 2)
+
+	projects := Projects{
+		Nodes:      []Project{userProject, orgProject},
+		TotalCount: 2,
+	}
+	b, err := json.Marshal(projects.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(
+	assert.JSONEq(
 		t,
 		`{"projects":[{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}},{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}],"totalCount":2}`,
 		string(b))
 }
 
 func TestJSONProjectField_FieldType(t *testing.T) {
-	field := queries.ProjectField{}
+	field := ProjectField{}
 	field.TypeName = "ProjectV2Field"
 	field.Field.ID = "123"
 	field.Field.Name = "name"
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(field.ExportData(nil))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2Field"}`, string(b))
 }
 
 func TestJSONProjectField_SingleSelectType(t *testing.T) {
-	field := queries.ProjectField{}
+	field := ProjectField{}
 	field.TypeName = "ProjectV2SingleSelectField"
 	field.SingleSelectField.ID = "123"
 	field.SingleSelectField.Name = "name"
-	field.SingleSelectField.Options = []queries.SingleSelectFieldOptions{
+	field.SingleSelectField.Options = []SingleSelectFieldOptions{
 		{
 			ID:   "123",
 			Name: "name",
@@ -113,35 +120,35 @@ func TestJSONProjectField_SingleSelectType(t *testing.T) {
 		},
 	}
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(field.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}`, string(b))
+	assert.JSONEq(t, `{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}`, string(b))
 }
 
 func TestJSONProjectField_ProjectV2IterationField(t *testing.T) {
-	field := queries.ProjectField{}
+	field := ProjectField{}
 	field.TypeName = "ProjectV2IterationField"
 	field.IterationField.ID = "123"
 	field.IterationField.Name = "name"
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(field.ExportData(nil))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2IterationField"}`, string(b))
 }
 
 func TestJSONProjectFields(t *testing.T) {
-	field := queries.ProjectField{}
+	field := ProjectField{}
 	field.TypeName = "ProjectV2Field"
 	field.Field.ID = "123"
 	field.Field.Name = "name"
 
-	field2 := queries.ProjectField{}
+	field2 := ProjectField{}
 	field2.TypeName = "ProjectV2SingleSelectField"
 	field2.SingleSelectField.ID = "123"
 	field2.SingleSelectField.Name = "name"
-	field2.SingleSelectField.Options = []queries.SingleSelectFieldOptions{
+	field2.SingleSelectField.Options = []SingleSelectFieldOptions{
 		{
 			ID:   "123",
 			Name: "name",
@@ -152,72 +159,72 @@ func TestJSONProjectFields(t *testing.T) {
 		},
 	}
 
-	p := &queries.Project{
+	p := &Project{
 		Fields: struct {
 			TotalCount int
-			Nodes      []queries.ProjectField
-			PageInfo   queries.PageInfo
+			Nodes      []ProjectField
+			PageInfo   PageInfo
 		}{
-			Nodes:      []queries.ProjectField{field, field2},
+			Nodes:      []ProjectField{field, field2},
 			TotalCount: 5,
 		},
 	}
-	b, err := JSONProjectFields(p)
+	b, err := json.Marshal(p.Fields.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"fields":[{"id":"123","name":"name","type":"ProjectV2Field"},{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}],"totalCount":5}`, string(b))
+	assert.JSONEq(t, `{"fields":[{"id":"123","name":"name","type":"ProjectV2Field"},{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}],"totalCount":5}`, string(b))
 }
 
 func TestJSONProjectItem_DraftIssue(t *testing.T) {
-	item := queries.ProjectItem{}
+	item := ProjectItem{}
 	item.Content.TypeName = "DraftIssue"
 	item.Id = "123"
 	item.Content.DraftIssue.Title = "title"
 	item.Content.DraftIssue.Body = "a body"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(item.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
+	assert.JSONEq(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
 }
 
 func TestJSONProjectItem_Issue(t *testing.T) {
-	item := queries.ProjectItem{}
+	item := ProjectItem{}
 	item.Content.TypeName = "Issue"
 	item.Id = "123"
 	item.Content.Issue.Title = "title"
 	item.Content.Issue.Body = "a body"
 	item.Content.Issue.URL = "a-url"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(item.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"Issue","url":"a-url"}`, string(b))
+	assert.JSONEq(t, `{"id":"123","title":"title","body":"a body","type":"Issue","url":"a-url"}`, string(b))
 }
 
 func TestJSONProjectItem_PullRequest(t *testing.T) {
-	item := queries.ProjectItem{}
+	item := ProjectItem{}
 	item.Content.TypeName = "PullRequest"
 	item.Id = "123"
 	item.Content.PullRequest.Title = "title"
 	item.Content.PullRequest.Body = "a body"
 	item.Content.PullRequest.URL = "a-url"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(item.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"PullRequest","url":"a-url"}`, string(b))
+	assert.JSONEq(t, `{"id":"123","title":"title","body":"a body","type":"PullRequest","url":"a-url"}`, string(b))
 }
 
 func TestJSONProjectDetailedItems(t *testing.T) {
-	p := &queries.Project{}
+	p := &Project{}
 	p.Items.TotalCount = 5
-	p.Items.Nodes = []queries.ProjectItem{
+	p.Items.Nodes = []ProjectItem{
 		{
 			Id: "issueId",
-			Content: queries.ProjectItemContent{
+			Content: ProjectItemContent{
 				TypeName: "Issue",
-				Issue: queries.Issue{
+				Issue: Issue{
 					Title:  "Issue title",
 					Body:   "a body",
 					Number: 1,
@@ -232,9 +239,9 @@ func TestJSONProjectDetailedItems(t *testing.T) {
 		},
 		{
 			Id: "pullRequestId",
-			Content: queries.ProjectItemContent{
+			Content: ProjectItemContent{
 				TypeName: "PullRequest",
-				PullRequest: queries.PullRequest{
+				PullRequest: PullRequest{
 					Title:  "Pull Request title",
 					Body:   "a body",
 					Number: 2,
@@ -249,9 +256,9 @@ func TestJSONProjectDetailedItems(t *testing.T) {
 		},
 		{
 			Id: "draftIssueId",
-			Content: queries.ProjectItemContent{
+			Content: ProjectItemContent{
 				TypeName: "DraftIssue",
-				DraftIssue: queries.DraftIssue{
+				DraftIssue: DraftIssue{
 					Title: "Pull Request title",
 					Body:  "a body",
 				},
@@ -259,56 +266,56 @@ func TestJSONProjectDetailedItems(t *testing.T) {
 		},
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(p.DetailedItems())
 	assert.NoError(t, err)
-	assert.Equal(
+	assert.JSONEq(
 		t,
 		`{"items":[{"content":{"type":"Issue","body":"a body","title":"Issue title","number":1,"repository":"cli/go-gh","url":"issue-url"},"id":"issueId"},{"content":{"type":"PullRequest","body":"a body","title":"Pull Request title","number":2,"repository":"cli/go-gh","url":"pr-url"},"id":"pullRequestId"},{"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title"},"id":"draftIssueId"}],"totalCount":5}`,
 		string(out))
 }
 
 func TestJSONProjectDraftIssue(t *testing.T) {
-	item := queries.DraftIssue{}
+	item := DraftIssue{}
 	item.ID = "123"
 	item.Title = "title"
 	item.Body = "a body"
 
-	b, err := JSONProjectDraftIssue(item)
+	b, err := json.Marshal(item.ExportData(nil))
 	assert.NoError(t, err)
 
-	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
+	assert.JSONEq(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
 }
 
 func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.T) {
-	iterationField := queries.ProjectField{TypeName: "ProjectV2IterationField"}
+	iterationField := ProjectField{TypeName: "ProjectV2IterationField"}
 	iterationField.IterationField.ID = "sprint"
 	iterationField.IterationField.Name = "Sprint"
 
-	iterationFieldValue := queries.FieldValueNodes{Type: "ProjectV2ItemFieldIterationValue"}
+	iterationFieldValue := FieldValueNodes{Type: "ProjectV2ItemFieldIterationValue"}
 	iterationFieldValue.ProjectV2ItemFieldIterationValue.Title = "Iteration Title"
 	iterationFieldValue.ProjectV2ItemFieldIterationValue.Field = iterationField
 
-	draftIssue := queries.ProjectItem{
+	draftIssue := ProjectItem{
 		Id: "draftIssueId",
-		Content: queries.ProjectItemContent{
+		Content: ProjectItemContent{
 			TypeName: "DraftIssue",
-			DraftIssue: queries.DraftIssue{
+			DraftIssue: DraftIssue{
 				Title: "Pull Request title",
 				Body:  "a body",
 			},
 		},
 	}
-	draftIssue.FieldValues.Nodes = []queries.FieldValueNodes{
+	draftIssue.FieldValues.Nodes = []FieldValueNodes{
 		iterationFieldValue,
 	}
-	p := &queries.Project{}
-	p.Fields.Nodes = []queries.ProjectField{iterationField}
+	p := &Project{}
+	p.Fields.Nodes = []ProjectField{iterationField}
 	p.Items.TotalCount = 5
-	p.Items.Nodes = []queries.ProjectItem{
+	p.Items.Nodes = []ProjectItem{
 		draftIssue,
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(p.DetailedItems())
 	assert.NoError(t, err)
 	assert.JSONEq(
 		t,
@@ -318,35 +325,35 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.
 }
 
 func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldMilestoneValue(t *testing.T) {
-	milestoneField := queries.ProjectField{TypeName: "ProjectV2IterationField"}
+	milestoneField := ProjectField{TypeName: "ProjectV2IterationField"}
 	milestoneField.IterationField.ID = "milestone"
 	milestoneField.IterationField.Name = "Milestone"
 
-	milestoneFieldValue := queries.FieldValueNodes{Type: "ProjectV2ItemFieldMilestoneValue"}
+	milestoneFieldValue := FieldValueNodes{Type: "ProjectV2ItemFieldMilestoneValue"}
 	milestoneFieldValue.ProjectV2ItemFieldMilestoneValue.Milestone.Title = "Milestone Title"
 	milestoneFieldValue.ProjectV2ItemFieldMilestoneValue.Field = milestoneField
 
-	draftIssue := queries.ProjectItem{
+	draftIssue := ProjectItem{
 		Id: "draftIssueId",
-		Content: queries.ProjectItemContent{
+		Content: ProjectItemContent{
 			TypeName: "DraftIssue",
-			DraftIssue: queries.DraftIssue{
+			DraftIssue: DraftIssue{
 				Title: "Pull Request title",
 				Body:  "a body",
 			},
 		},
 	}
-	draftIssue.FieldValues.Nodes = []queries.FieldValueNodes{
+	draftIssue.FieldValues.Nodes = []FieldValueNodes{
 		milestoneFieldValue,
 	}
-	p := &queries.Project{}
-	p.Fields.Nodes = []queries.ProjectField{milestoneField}
+	p := &Project{}
+	p.Fields.Nodes = []ProjectField{milestoneField}
 	p.Items.TotalCount = 5
-	p.Items.Nodes = []queries.ProjectItem{
+	p.Items.Nodes = []ProjectItem{
 		draftIssue,
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(p.DetailedItems())
 	assert.NoError(t, err)
 	assert.JSONEq(
 		t,
@@ -354,10 +361,3 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldMilestoneValue(t *testing.
 		string(out))
 
 }
-
-func TestCamelCase(t *testing.T) {
-	assert.Equal(t, "camelCase", CamelCase("camelCase"))
-	assert.Equal(t, "camelCase", CamelCase("CamelCase"))
-	assert.Equal(t, "c", CamelCase("C"))
-	assert.Equal(t, "", CamelCase(""))
-}
diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go
index 5767929a3..bfdd4d95c 100644
--- a/pkg/cmd/project/shared/queries/queries_test.go
+++ b/pkg/cmd/project/shared/queries/queries_test.go
@@ -428,3 +428,10 @@ func TestProjectItems_FieldTitle(t *testing.T) {
 	assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[0].ProjectV2ItemFieldIterationValue.Title, "Iteration Title 1")
 	assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[1].ProjectV2ItemFieldMilestoneValue.Milestone.Title, "Milestone Title 1")
 }
+
+func TestCamelCase(t *testing.T) {
+	assert.Equal(t, "camelCase", camelCase("camelCase"))
+	assert.Equal(t, "camelCase", camelCase("CamelCase"))
+	assert.Equal(t, "c", camelCase("C"))
+	assert.Equal(t, "", camelCase(""))
+}
diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go
index 35f6dfc9b..5ac599d12 100644
--- a/pkg/cmd/project/view/view_test.go
+++ b/pkg/cmd/project/view/view_test.go
@@ -14,11 +14,12 @@ import (
 
 func TestNewCmdview(t *testing.T) {
 	tests := []struct {
-		name        string
-		cli         string
-		wants       viewOpts
-		wantsErr    bool
-		wantsErrMsg string
+		name          string
+		cli           string
+		wants         viewOpts
+		wantsErr      bool
+		wantsErrMsg   string
+		wantsExporter bool
 	}{
 		{
 			name:        "not-a-number",
@@ -48,11 +49,9 @@ func TestNewCmdview(t *testing.T) {
 			},
 		},
 		{
-			name: "json",
-			cli:  "--format json",
-			wants: viewOpts{
-				format: "json",
-			},
+			name:          "json",
+			cli:           "--format json",
+			wantsExporter: true,
 		},
 	}
 
@@ -85,7 +84,7 @@ func TestNewCmdview(t *testing.T) {
 
 			assert.Equal(t, tt.wants.number, gotOpts.number)
 			assert.Equal(t, tt.wants.owner, gotOpts.owner)
-			assert.Equal(t, tt.wants.format, gotOpts.format)
+			assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
 			assert.Equal(t, tt.wants.web, gotOpts.web)
 		})
 	}
diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go
index 782056314..3c2abf32d 100644
--- a/pkg/cmd/release/download/download_test.go
+++ b/pkg/cmd/release/download/download_test.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"runtime"
 	"testing"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
@@ -553,6 +554,87 @@ func Test_downloadRun_cloberAndSkip(t *testing.T) {
 	}
 }
 
+func Test_downloadRun_windowsReservedFilename(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		t.SkipNow()
+	}
+
+	tagName := "v1.2.3"
+
+	ios, _, _, _ := iostreams.Test()
+
+	reg := &httpmock.Registry{}
+	defer reg.Verify(t)
+
+	shared.StubFetchRelease(t, reg, "OWNER", "REPO", tagName, `{
+		"assets": [
+			{ "name": "valid-asset.zip", "size": 12,
+			  "url": "https://api.github.com/assets/1234" },
+			{ "name": "valid-asset-2.zip", "size": 34,
+			  "url": "https://api.github.com/assets/3456" },
+			{ "name": "CON.tgz", "size": 56,
+			  "url": "https://api.github.com/assets/5678" }
+		],
+		"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+		"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+	}`)
+
+	opts := &DownloadOptions{
+		IO: ios,
+		HttpClient: func() (*http.Client, error) {
+			return &http.Client{Transport: reg}, nil
+		},
+		BaseRepo: func() (ghrepo.Interface, error) {
+			return ghrepo.FromFullName("OWNER/REPO")
+		},
+		TagName: tagName,
+	}
+
+	err := downloadRun(opts)
+
+	assert.EqualError(t, err, `unable to download release due to asset with reserved filename "CON.tgz"`)
+}
+
+func TestIsWindowsReservedFilename(t *testing.T) {
+	tests := []struct {
+		name     string
+		filename string
+		want     bool
+	}{
+		{
+			name:     "non-reserved filename",
+			filename: "test",
+			want:     false,
+		},
+		{
+			name:     "non-reserved filename with file type extension",
+			filename: "test.tar.gz",
+			want:     false,
+		},
+		{
+			name:     "reserved filename",
+			filename: "NUL",
+			want:     true,
+		},
+		{
+			name:     "reserved filename with file type extension",
+			filename: "NUL.tar.gz",
+			want:     true,
+		},
+		{
+			name:     "reserved filename with mixed type case",
+			filename: "NuL",
+			want:     true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, isWindowsReservedFilename(tt.filename))
+		})
+	}
+}
+
 func listFiles(dir string) ([]string, error) {
 	var files []string
 	err := filepath.Walk(dir, func(p string, f os.FileInfo, err error) error {
diff --git a/pkg/cmdutil/json_flags_test.go b/pkg/cmdutil/json_flags_test.go
index 975530df4..9b5f54c54 100644
--- a/pkg/cmdutil/json_flags_test.go
+++ b/pkg/cmdutil/json_flags_test.go
@@ -16,7 +16,7 @@ func TestAddJSONFlags(t *testing.T) {
 		name        string
 		fields      []string
 		args        []string
-		wantsExport *exportFormat
+		wantsExport *jsonExporter
 		wantsError  string
 	}{
 		{
@@ -64,7 +64,7 @@ func TestAddJSONFlags(t *testing.T) {
 			name:   "with JSON fields",
 			fields: []string{"id", "number", "title"},
 			args:   []string{"--json", "number,title"},
-			wantsExport: &exportFormat{
+			wantsExport: &jsonExporter{
 				fields:   []string{"number", "title"},
 				filter:   "",
 				template: "",
@@ -74,7 +74,7 @@ func TestAddJSONFlags(t *testing.T) {
 			name:   "with jq filter",
 			fields: []string{"id", "number", "title"},
 			args:   []string{"--json", "number", "-q.number"},
-			wantsExport: &exportFormat{
+			wantsExport: &jsonExporter{
 				fields:   []string{"number"},
 				filter:   ".number",
 				template: "",
@@ -84,7 +84,7 @@ func TestAddJSONFlags(t *testing.T) {
 			name:   "with Go template",
 			fields: []string{"id", "number", "title"},
 			args:   []string{"--json", "number", "-t", "{{.number}}"},
-			wantsExport: &exportFormat{
+			wantsExport: &jsonExporter{
 				fields:   []string{"number"},
 				filter:   "",
 				template: "{{.number}}",
@@ -116,13 +116,105 @@ func TestAddJSONFlags(t *testing.T) {
 	}
 }
 
+func TestAddFormatFlags(t *testing.T) {
+	tests := []struct {
+		name        string
+		args        []string
+		wantsExport *jsonExporter
+		wantsError  string
+	}{
+		{
+			name:        "no format flag",
+			args:        []string{},
+			wantsExport: nil,
+		},
+		{
+			name:        "empty format flag",
+			args:        []string{"--format"},
+			wantsExport: nil,
+			wantsError:  "flag needs an argument: --format",
+		},
+		{
+			name:        "invalid format field",
+			args:        []string{"--format", "idontexist"},
+			wantsExport: nil,
+			wantsError:  "invalid argument \"idontexist\" for \"--format\" flag: valid values are {json}",
+		},
+		{
+			name:        "cannot combine --format with --web",
+			args:        []string{"--format", "json", "--web"},
+			wantsExport: nil,
+			wantsError:  "cannot use `--web` with `--format`",
+		},
+		{
+			name:        "cannot use --jq without --format",
+			args:        []string{"--jq", ".number"},
+			wantsExport: nil,
+			wantsError:  "cannot use `--jq` without specifying `--format json`",
+		},
+		{
+			name:        "cannot use --template without --format",
+			args:        []string{"--template", "{{.number}}"},
+			wantsExport: nil,
+			wantsError:  "cannot use `--template` without specifying `--format json`",
+		},
+		{
+			name: "with json format",
+			args: []string{"--format", "json"},
+			wantsExport: &jsonExporter{
+				filter:   "",
+				template: "",
+			},
+		},
+		{
+			name: "with jq filter",
+			args: []string{"--format", "json", "-q.number"},
+			wantsExport: &jsonExporter{
+				filter:   ".number",
+				template: "",
+			},
+		},
+		{
+			name: "with Go template",
+			args: []string{"--format", "json", "-t", "{{.number}}"},
+			wantsExport: &jsonExporter{
+				filter:   "",
+				template: "{{.number}}",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cmd := &cobra.Command{Run: func(*cobra.Command, []string) {}}
+			cmd.Flags().Bool("web", false, "")
+			var exporter Exporter
+			AddFormatFlags(cmd, &exporter)
+			cmd.SetArgs(tt.args)
+			cmd.SetOut(io.Discard)
+			cmd.SetErr(io.Discard)
+			_, err := cmd.ExecuteC()
+			if tt.wantsError == "" {
+				require.NoError(t, err)
+			} else {
+				assert.EqualError(t, err, tt.wantsError)
+				return
+			}
+			if tt.wantsExport == nil {
+				assert.Nil(t, exporter)
+			} else {
+				assert.Equal(t, tt.wantsExport, exporter)
+			}
+		})
+	}
+}
+
 func Test_exportFormat_Write(t *testing.T) {
 	type args struct {
 		data interface{}
 	}
 	tests := []struct {
 		name     string
-		exporter exportFormat
+		exporter jsonExporter
 		args     args
 		wantW    string
 		wantErr  bool
@@ -130,7 +222,7 @@ func Test_exportFormat_Write(t *testing.T) {
 	}{
 		{
 			name:     "regular JSON output",
-			exporter: exportFormat{},
+			exporter: jsonExporter{},
 			args: args{
 				data: map[string]string{"name": "hubot"},
 			},
@@ -140,7 +232,7 @@ func Test_exportFormat_Write(t *testing.T) {
 		},
 		{
 			name:     "call ExportData",
-			exporter: exportFormat{fields: []string{"field1", "field2"}},
+			exporter: jsonExporter{fields: []string{"field1", "field2"}},
 			args: args{
 				data: &exportableItem{"item1"},
 			},
@@ -150,7 +242,7 @@ func Test_exportFormat_Write(t *testing.T) {
 		},
 		{
 			name:     "recursively call ExportData",
-			exporter: exportFormat{fields: []string{"f1", "f2"}},
+			exporter: jsonExporter{fields: []string{"f1", "f2"}},
 			args: args{
 				data: map[string]interface{}{
 					"s1": []exportableItem{{"i1"}, {"i2"}},
@@ -163,7 +255,7 @@ func Test_exportFormat_Write(t *testing.T) {
 		},
 		{
 			name:     "with jq filter",
-			exporter: exportFormat{filter: ".name"},
+			exporter: jsonExporter{filter: ".name"},
 			args: args{
 				data: map[string]string{"name": "hubot"},
 			},
@@ -173,7 +265,7 @@ func Test_exportFormat_Write(t *testing.T) {
 		},
 		{
 			name:     "with jq filter pretty printing",
-			exporter: exportFormat{filter: "."},
+			exporter: jsonExporter{filter: "."},
 			args: args{
 				data: map[string]string{"name": "hubot"},
 			},
@@ -183,7 +275,7 @@ func Test_exportFormat_Write(t *testing.T) {
 		},
 		{
 			name:     "with Go template",
-			exporter: exportFormat{template: "{{.name}}"},
+			exporter: jsonExporter{template: "{{.name}}"},
 			args: args{
 				data: map[string]string{"name": "hubot"},
 			},