Repository: cli/cli
Pull request #7832 (2023-08-12 18:36:29)

Support template, jq flags with standard format flag
Defines a new `cmdutil` package function that adds support for `--format` along with `--template` and `--jq`. This can be used instead of the `--json` flags which requires a field list and will filter the GraphQL query when filtering is unnecessary.

heaths: @samcoe this is part of the work to standardize the `project` command. Before I go too far down this path for all the `project` subcommands, any concerns? The main issue is that the fields passed to `--json` are effectively ignored currently because of how queries are constructed. If everything else looks fine (and I'll keep going for all the subcommands that implement `--format json`), I can refactor the queries to support filtering in this PR or another.

There's two ideas, and I'm certainly open to alternatives of feedback:

1. Refactor the queries as string queries like all the other commands. Pretty much, get rid of all the `graphql`-tagged structs used as input. It's a big change, but probably better long term as we don't need separate request and response structs (which, that alone could probably be unified separately).
2. The number of fields requested isn't huge. Instead of modifying the queries, I could simply filter the fields as I'm writing them out with either a backward compatible tweak to the exporter, or something local to the `project` utility functions.
samcoe: @heaths The main concerns here are that the `--format` flag is meant to be semantically different than the `--json` flag. The `--json` requires arguments for which fields to fetch, while `--format` is just meant to set the output. We designed it this way with the hopes of adding a `csv` format option for the `--format` flag as we anticipated that being a desired export format for projects so they can be imported in other tools. 

I think the best approach here is keep the `--format` flag as is but add support for the `--template` and `--jq` flags. Obviously this is still a slight variation from other commands but a good compromise. This will greatly reduce the amount of work needed to rewrite the queries to support filtering and keeps the command open for a `csv` output option in the future. What do you think? 
heaths: @samcoe, what do you think of this? Figured I'd make it general if  is going to be a new thing in other cases. Also added color, though a bit redundant for the project state but it otherwise retains the current table ifno. If good (or minor suggestions), I'll give the same  treatment to all the  subcommands, and add a bit more color as well. That said, if you want to retain the table header for TTY - which is inconsistent, but has long since shipped - what about making it a lighter gray, like dates? Personally, though I'd love to hear other feedback, I find the table headers distracting and harder to separate the table data at a blance. Making them gray - like they are less important but still provide some value - might strike a good UX balance. Thoughts? It could be implimented as a WithOptions-style option so only  subcommands get it for TTY.
samcoe: @heaths Ah I see I misunderstood your previous comment, apologies for that. I would also like to see them added to all our table output to make it consistent 👍 
heaths: @samecoe no, I think you understood right. :smile:. I was pointing out they were inconsistent throughout the CLI, but I do think the headers make for a better UX. No guess work as to what columns are, even if most seem to be self-explanatory to me, anyway.

I did want to ask, though, only because I haven't dug deep into the `ExportData` functionality, are these two comments/suggestions in conflict?

1. https://github.com/cli/cli/pull/7832#discussion_r1338559707
2. https://github.com/cli/cli/pull/7832#discussion_r1338617300

Or not, since the latter would just fully de/serialize all its fields? I'm not entirely clear on how the passed-in `fields` parameter is used...rather, why it's passed down to other models' `ExportData`. When I get some time (hopefully this weekend), I'll dig in more, but thought I might ask here anyway in case a quick summary would help expedite my investigation and refactoring.
samcoe: > Or not, since the latter would just fully de/serialize all its fields?

Exactly this. The structs that are exported from commands using the `format` flag instead of the `json` flag should implement the `exportable` interface but ignore the passed in `fields` argument as they will be serializing all the fields of the struct rather than relying on the passed in ones.

I am also open to keeping the list of fields, my intention of the comment was to simplify the implementation, but perhaps keeping consistency with other commands/implementations is more valuable here. 
heaths: > I am also open to keeping the list of fields, my intention of the comment was to simplify the implementation, but perhaps keeping consistency with other commands/implementations is more valuable here.

If it were a lib and you wanted a stable API, I'd agree. But you make a good point that anything using the `--format` flags (and friends) really doesn't need it. So with no filtering needed, I understand and appreciate your point now. Seems worth removing. Could always add it later through a fairly simple refactoring if needed.
samcoe: @heaths I wanted to circle back around to this and see if there was anything blocking this from getting out of the draft state? I had put off continuing the review process as the PR state was set to work in process. 
heaths: No. I'll work on this and the other this weekend. I ended up not touching my computer much over the holiday, opting for some woodworking and other physical world projects instead.
cliAutomation: Hi! Thanks for the pull request. Please ensure that this change is linked to an issue by mentioning an issue number in the description of the pull request. If this pull request would close the issue, please put the word 'Fixes' before the issue number somewhere in the pull request body. If this is a tiny change like fixing a typo, feel free to ignore this message.
heaths: @samcoe I completed what we discussed sans `ExportData`. As noted in the PR comments, you're right: this is a large refactoring. I'm happy to do it (perhaps after the JSON merge PR), but I suggest it be done in a separate PR to isolate the changes for easier review.
heaths: As I'm adding regression tests to make sure whatever course we take doesn't break existing output, I found a number of project commands aren't actually writing JSON for the return projects, but the minimal input projects (project ID, basically). I'm assuming this would be a desirable regression.

E.g., with `gh project close`:

```go
project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false)
// --- CLIP ---
query, variables := closeArgs(config)

err = config.client.Mutate("CloseProjectV2", query, variables)
// ---- CLIP ---
if config.opts.exporter != nil {
	return printJSON(config, *project) // should be query.UpdateProjectV2.ProjectV2
}

return printResults(config, query.UpdateProjectV2.ProjectV2)
```

heaths: I'm going to pull in the changes from #8541 to fix the JSON output, which seems related more to this PR than refactoring how the data is serialized. Probably the regression tests as well, because I made sure to add more data to the responses to check most - though, not all - of the JSON model data; otherwise, what's the point of improving the JSON output if the `--format json`-formatted responses are useless for some `project` commands (about 30-40%).

Patch:
diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go
index f4016fe50..73c61e888 100644
--- a/pkg/cmd/project/close/close.go
+++ b/pkg/cmd/project/close/close.go
@@ -19,7 +19,7 @@ type closeOpts struct {
 	owner     string
 	reopen    bool
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type closeConfig struct {
@@ -78,7 +78,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 +103,8 @@ func runClose(config closeConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return printJSON(config, query.UpdateProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.UpdateProjectV2.ProjectV2)
@@ -134,10 +134,6 @@ func printResults(config closeConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go
index d3e07424b..19fa3b0c1 100644
--- a/pkg/cmd/project/copy/copy.go
+++ b/pkg/cmd/project/copy/copy.go
@@ -22,7 +22,7 @@ type copyOpts struct {
 	sourceOwner        string
 	targetOwner        string
 	title              string
-	format             string
+	exporter           cmdutil.Exporter
 }
 
 type copyConfig struct {
@@ -79,7 +79,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,7 +113,7 @@ func runCopy(config copyConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.CopyProjectV2.ProjectV2)
 	}
 
@@ -144,10 +144,6 @@ func printResults(config copyConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go
index 894a6e9ce..a914e0229 100644
--- a/pkg/cmd/project/create/create.go
+++ b/pkg/cmd/project/create/create.go
@@ -14,10 +14,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 +63,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,7 +85,7 @@ func runCreate(config createConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.CreateProjectV2.ProjectV2)
 	}
 
@@ -115,11 +115,6 @@ func printResults(config createConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go
index 674c30a97..52c26ed4d 100644
--- a/pkg/cmd/project/delete/delete.go
+++ b/pkg/cmd/project/delete/delete.go
@@ -18,7 +18,7 @@ type deleteOpts struct {
 	owner     string
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type deleteConfig struct {
@@ -72,7 +72,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 +96,8 @@ func runDelete(config deleteConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return printJSON(config, query.DeleteProject.Project)
 	}
 
 	return printResults(config, query.DeleteProject.Project)
@@ -126,11 +126,6 @@ func printResults(config deleteConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go
index 92fee3284..67f01e63d 100644
--- a/pkg/cmd/project/edit/edit.go
+++ b/pkg/cmd/project/edit/edit.go
@@ -22,7 +22,7 @@ type editOpts struct {
 	visibility       string
 	shortDescription string
 	projectID        string
-	format           string
+	exporter         cmdutil.Exporter
 }
 
 type editConfig struct {
@@ -86,7 +86,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 +110,8 @@ func runEdit(config editConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return printJSON(config, query.UpdateProjectV2.ProjectV2)
 	}
 
 	return printResults(config, query.UpdateProjectV2.ProjectV2)
@@ -155,11 +155,6 @@ func printResults(config editConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
diff --git a/pkg/cmd/project/field-create/field_create.go b/pkg/cmd/project/field-create/field_create.go
index d369148ba..c65c164de 100644
--- a/pkg/cmd/project/field-create/field_create.go
+++ b/pkg/cmd/project/field-create/field_create.go
@@ -21,7 +21,7 @@ type createFieldOpts struct {
 	singleSelectOptions []string
 	number              int32
 	projectID           string
-	format              string
+	exporter            cmdutil.Exporter
 }
 
 type createFieldConfig struct {
@@ -85,7 +85,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,7 +113,7 @@ func runCreateField(config createFieldConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.CreateProjectV2Field.Field)
 	}
 
@@ -153,11 +153,6 @@ func printResults(config createFieldConfig, field queries.ProjectField) error {
 }
 
 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
+	projectFieldJSON := format.JSONProjectField(field)
+	return config.opts.exporter.Write(config.io, projectFieldJSON)
 }
diff --git a/pkg/cmd/project/field-delete/field_delete.go b/pkg/cmd/project/field-delete/field_delete.go
index 3d3bb25ce..a98d10b89 100644
--- a/pkg/cmd/project/field-delete/field_delete.go
+++ b/pkg/cmd/project/field-delete/field_delete.go
@@ -13,8 +13,8 @@ import (
 )
 
 type deleteFieldOpts struct {
-	fieldID string
-	format  string
+	fieldID  string
+	exporter cmdutil.Exporter
 }
 
 type deleteFieldConfig struct {
@@ -55,7 +55,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,7 +70,7 @@ func runDeleteField(config deleteFieldConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.DeleteProjectV2Field.Field)
 	}
 
@@ -95,11 +95,6 @@ func printResults(config deleteFieldConfig, field queries.ProjectField) error {
 }
 
 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
+	projectFieldJSON := format.JSONProjectField(field)
+	return config.opts.exporter.Write(config.io, projectFieldJSON)
 }
diff --git a/pkg/cmd/project/field-list/field_list.go b/pkg/cmd/project/field-list/field_list.go
index f15369c94..88f261eeb 100644
--- a/pkg/cmd/project/field-list/field_list.go
+++ b/pkg/cmd/project/field-list/field_list.go
@@ -15,10 +15,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 +66,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,7 +94,7 @@ func runList(config listConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, project)
 	}
 
@@ -119,11 +119,6 @@ func printResults(config listConfig, fields []queries.ProjectField, login string
 }
 
 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
+	projectsJSON := format.JSONProjectFields(project)
+	return config.opts.exporter.Write(config.io, projectsJSON)
 }
diff --git a/pkg/cmd/project/item-add/item_add.go b/pkg/cmd/project/item-add/item_add.go
index 51c9ecfce..e3417952d 100644
--- a/pkg/cmd/project/item-add/item_add.go
+++ b/pkg/cmd/project/item-add/item_add.go
@@ -20,7 +20,7 @@ type addItemOpts struct {
 	itemURL   string
 	projectID string
 	itemID    string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type addItemConfig struct {
@@ -75,7 +75,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,7 +107,7 @@ func runAddItem(config addItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.CreateProjectItem.ProjectV2Item)
 	}
 
@@ -134,11 +134,6 @@ func printResults(config addItemConfig, item queries.ProjectItem) error {
 }
 
 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
+	projectItemJSON := format.JSONProjectItem(item)
+	return config.opts.exporter.Write(config.io, projectItemJSON)
 }
diff --git a/pkg/cmd/project/item-archive/item_archive.go b/pkg/cmd/project/item-archive/item_archive.go
index 7ae336242..5eecd6d8a 100644
--- a/pkg/cmd/project/item-archive/item_archive.go
+++ b/pkg/cmd/project/item-archive/item_archive.go
@@ -20,7 +20,7 @@ type archiveItemOpts struct {
 	undo      bool
 	itemID    string
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type archiveItemConfig struct {
@@ -82,7 +82,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,7 +109,7 @@ func runArchiveItem(config archiveItemConfig) error {
 			return err
 		}
 
-		if config.opts.format == "json" {
+		if config.opts.exporter != nil {
 			return printJSON(config, query.UnarchiveProjectItem.ProjectV2Item)
 		}
 
@@ -121,7 +121,7 @@ func runArchiveItem(config archiveItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.ArchiveProjectItem.ProjectV2Item)
 	}
 
@@ -161,11 +161,6 @@ func printResults(config archiveItemConfig, item queries.ProjectItem) error {
 }
 
 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
+	projectItemJSON := format.JSONProjectItem(item)
+	return config.opts.exporter.Write(config.io, projectItemJSON)
 }
diff --git a/pkg/cmd/project/item-create/item_create.go b/pkg/cmd/project/item-create/item_create.go
index 6ee32e9f6..3b686916f 100644
--- a/pkg/cmd/project/item-create/item_create.go
+++ b/pkg/cmd/project/item-create/item_create.go
@@ -20,7 +20,7 @@ type createItemOpts struct {
 	owner     string
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type createItemConfig struct {
@@ -76,7 +76,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,7 +103,7 @@ func runCreateItem(config createItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, query.CreateProjectDraftItem.ProjectV2Item)
 	}
 
@@ -130,11 +130,6 @@ func printResults(config createItemConfig, item queries.ProjectItem) error {
 }
 
 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
+	projectItemJSON := format.JSONProjectItem(item)
+	return config.opts.exporter.Write(config.io, projectItemJSON)
 }
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..b55d0b48f 100644
--- a/pkg/cmd/project/item-edit/item_edit.go
+++ b/pkg/cmd/project/item-edit/item_edit.go
@@ -30,7 +30,7 @@ type editItemOpts struct {
 	iterationID          string
 	clear                bool
 	// format
-	format string
+	exporter cmdutil.Exporter
 }
 
 type editItemConfig struct {
@@ -116,7 +116,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")
@@ -220,12 +220,8 @@ func printDraftIssueResults(config editItemConfig, item queries.DraftIssue) erro
 }
 
 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
+	projectDraftItemJSON := format.JSONProjectDraftIssue(item)
+	return config.opts.exporter.Write(config.io, projectDraftItemJSON)
 }
 
 func printItemResults(config editItemConfig, item *queries.ProjectItem) error {
@@ -237,13 +233,8 @@ func printItemResults(config editItemConfig, item *queries.ProjectItem) error {
 }
 
 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
-
+	projectItemJSON := format.JSONProjectItem(*item)
+	return config.opts.exporter.Write(config.io, projectItemJSON)
 }
 
 func clearItemFieldValue(config editItemConfig) error {
@@ -256,7 +247,7 @@ func clearItemFieldValue(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printItemJSON(config, &query.Clear.Item)
 	}
 
@@ -275,7 +266,7 @@ func updateDraftIssue(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printDraftIssueJSON(config, query.UpdateProjectV2DraftIssue.DraftIssue)
 	}
 
@@ -302,7 +293,7 @@ func updateItemValues(config editItemConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printItemJSON(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..5f711f8e8 100644
--- a/pkg/cmd/project/item-list/item_list.go
+++ b/pkg/cmd/project/item-list/item_list.go
@@ -15,10 +15,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 +66,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,7 +93,7 @@ func runList(config listConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, project)
 	}
 
@@ -124,10 +124,6 @@ func printResults(config listConfig, items []queries.ProjectItem, login string)
 }
 
 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
+	projectDetailedItemsJSON := format.JSONProjectDetailedItems(project)
+	return config.opts.exporter.Write(config.io, projectDetailedItemsJSON)
 }
diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go
index 88f419b90..e345e3166 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
@@ -103,7 +103,7 @@ func runList(config listConfig) error {
 	}
 	projects = filterProjects(projects, config)
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, projects, totalCount)
 	}
 
@@ -156,16 +156,17 @@ func printResults(config listConfig, projects []queries.Project, owner string) e
 
 	tp := tableprinter.New(config.io, tableprinter.WithHeader("Number", "Title", "State", "ID"))
 
+	cs := config.io.ColorScheme()
 	for _, p := range projects {
-		tp.AddField(strconv.Itoa(int(p.Number)), tableprinter.WithTruncate(nil))
+		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()
 	}
@@ -174,11 +175,6 @@ func printResults(config listConfig, projects []queries.Project, owner string) e
 }
 
 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
+	projectsJSON := format.JSONProjects(projects, totalCount)
+	return config.opts.exporter.Write(config.io, projectsJSON)
 }
diff --git a/pkg/cmd/project/mark-template/mark_template.go b/pkg/cmd/project/mark-template/mark_template.go
index 92eb87ca6..8931a2816 100644
--- a/pkg/cmd/project/mark-template/mark_template.go
+++ b/pkg/cmd/project/mark-template/mark_template.go
@@ -19,7 +19,7 @@ type markTemplateOpts struct {
 	undo      bool
 	number    int32
 	projectID string
-	format    string
+	exporter  cmdutil.Exporter
 }
 
 type markTemplateConfig struct {
@@ -82,7 +82,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,7 +107,7 @@ func runMarkTemplate(config markTemplateConfig) error {
 			return err
 		}
 
-		if config.opts.format == "json" {
+		if config.opts.exporter != nil {
 			return printJSON(config, *project)
 		}
 
@@ -120,8 +120,8 @@ func runMarkTemplate(config markTemplateConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
-		return printJSON(config, *project)
+	if config.opts.exporter != nil {
+		return printJSON(config, query.TemplateProject.Project)
 	}
 
 	return printResults(config, query.TemplateProject.Project)
@@ -166,11 +166,6 @@ func printResults(config markTemplateConfig, project queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
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
index efe9f4800..8f09f8ea4 100644
--- a/pkg/cmd/project/shared/format/json.go
+++ b/pkg/cmd/project/shared/format/json.go
@@ -1,15 +1,14 @@
 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{
+func JSONProject(project queries.Project) ProjectJSON {
+	return ProjectJSON{
 		Number:           project.Number,
 		URL:              project.URL,
 		ShortDescription: project.ShortDescription,
@@ -39,15 +38,15 @@ func JSONProject(project queries.Project) ([]byte, error) {
 			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
+func JSONProjects(projects []queries.Project, totalCount int) ProjectsJSON {
+	var result []ProjectJSON
 	for _, p := range projects {
-		result = append(result, projectJSON{
+		result = append(result, ProjectJSON{
 			Number:           p.Number,
 			URL:              p.URL,
 			ShortDescription: p.ShortDescription,
@@ -80,16 +79,13 @@ func JSONProjects(projects []queries.Project, totalCount int) ([]byte, error) {
 		})
 	}
 
-	return json.Marshal(struct {
-		Projects   []projectJSON `json:"projects"`
-		TotalCount int           `json:"totalCount"`
-	}{
+	return ProjectsJSON{
 		Projects:   result,
 		TotalCount: totalCount,
-	})
+	}
 }
 
-type projectJSON struct {
+type ProjectJSON struct {
 	Number           int32  `json:"number"`
 	URL              string `json:"url"`
 	ShortDescription string `json:"shortDescription"`
@@ -114,35 +110,40 @@ type projectJSON struct {
 	} `json:"owner"`
 }
 
+type ProjectsJSON struct {
+	Projects   []ProjectJSON `json:"projects"`
+	TotalCount int           `json:"totalCount"`
+}
+
 // JSONProjectField serializes a ProjectField to JSON.
-func JSONProjectField(field queries.ProjectField) ([]byte, error) {
-	val := projectFieldJSON{
+func JSONProjectField(field queries.ProjectField) ProjectFieldJSON {
+	val := ProjectFieldJSON{
 		ID:   field.ID(),
 		Name: field.Name(),
 		Type: field.Type(),
 	}
 	for _, o := range field.Options() {
-		val.Options = append(val.Options, singleSelectOptionJSON{
+		val.Options = append(val.Options, SingleSelectOptionJSON{
 			Name: o.Name,
 			ID:   o.ID,
 		})
 	}
 
-	return json.Marshal(val)
+	return 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
+func JSONProjectFields(project *queries.Project) ProjectFieldsJSON {
+	var result []ProjectFieldJSON
 	for _, f := range project.Fields.Nodes {
-		val := projectFieldJSON{
+		val := ProjectFieldJSON{
 			ID:   f.ID(),
 			Name: f.Name(),
 			Type: f.Type(),
 		}
 		for _, o := range f.Options() {
-			val.Options = append(val.Options, singleSelectOptionJSON{
+			val.Options = append(val.Options, SingleSelectOptionJSON{
 				Name: o.Name,
 				ID:   o.ID,
 			})
@@ -151,39 +152,41 @@ func JSONProjectFields(project *queries.Project) ([]byte, error) {
 		result = append(result, val)
 	}
 
-	return json.Marshal(struct {
-		Fields     []projectFieldJSON `json:"fields"`
-		TotalCount int                `json:"totalCount"`
-	}{
+	return ProjectFieldsJSON{
 		Fields:     result,
 		TotalCount: project.Fields.TotalCount,
-	})
+	}
 }
 
-type projectFieldJSON struct {
+type ProjectFieldJSON struct {
 	ID      string                   `json:"id"`
 	Name    string                   `json:"name"`
 	Type    string                   `json:"type"`
-	Options []singleSelectOptionJSON `json:"options,omitempty"`
+	Options []SingleSelectOptionJSON `json:"options,omitempty"`
+}
+
+type ProjectFieldsJSON struct {
+	Fields     []ProjectFieldJSON `json:"fields"`
+	TotalCount int                `json:"totalCount"`
 }
 
-type singleSelectOptionJSON struct {
+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{
+func JSONProjectItem(item queries.ProjectItem) ProjectItemJSON {
+	return ProjectItemJSON{
 		ID:    item.ID(),
 		Title: item.Title(),
 		Body:  item.Body(),
 		Type:  item.Type(),
 		URL:   item.URL(),
-	})
+	}
 }
 
-type projectItemJSON struct {
+type ProjectItemJSON struct {
 	ID    string `json:"id"`
 	Title string `json:"title"`
 	Body  string `json:"body"`
@@ -196,17 +199,17 @@ type projectItemJSON struct {
 // 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) {
+func JSONProjectDraftIssue(item queries.DraftIssue) DraftIssueJSON {
 
-	return json.Marshal(draftIssueJSON{
+	return DraftIssueJSON{
 		ID:    item.ID,
 		Title: item.Title,
 		Body:  item.Body,
 		Type:  "DraftIssue",
-	})
+	}
 }
 
-type draftIssueJSON struct {
+type DraftIssueJSON struct {
 	ID    string `json:"id"`
 	Title string `json:"title"`
 	Body  string `json:"body"`
@@ -334,7 +337,7 @@ func serializeProjectWithItems(project *queries.Project) []map[string]any {
 
 	// make a map of fields by ID
 	for _, f := range project.Fields.Nodes {
-		fields[f.ID()] = CamelCase(f.Name())
+		fields[f.ID()] = camelCase(f.Name())
 	}
 	itemsSlice := make([]map[string]any, 0)
 
@@ -357,19 +360,21 @@ func serializeProjectWithItems(project *queries.Project) []map[string]any {
 
 // JSONProjectWithItems returns a detailed JSON representation of project items.
 // JSON fields are `totalCount` and `items`.
-func JSONProjectDetailedItems(project *queries.Project) ([]byte, error) {
+func JSONProjectDetailedItems(project *queries.Project) ProjectDetailedItems {
 	items := serializeProjectWithItems(project)
-	return json.Marshal(struct {
-		Items      []map[string]any `json:"items"`
-		TotalCount int              `json:"totalCount"`
-	}{
+	return ProjectDetailedItems{
 		Items:      items,
 		TotalCount: project.Items.TotalCount,
-	})
+	}
+}
+
+type ProjectDetailedItems struct {
+	Items      []map[string]any `json:"items"`
+	TotalCount int              `json:"totalCount"`
 }
 
-// CamelCase converts a string to camelCase, which is useful for turning Go field names to JSON keys.
-func CamelCase(s string) string {
+// 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 ""
 	}
diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go
index 36eb148e3..8340149b5 100644
--- a/pkg/cmd/project/view/view.go
+++ b/pkg/cmd/project/view/view.go
@@ -16,10 +16,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 +77,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,7 +106,7 @@ func runView(config viewConfig) error {
 		return err
 	}
 
-	if config.opts.format == "json" {
+	if config.opts.exporter != nil {
 		return printJSON(config, *project)
 	}
 
@@ -193,11 +193,6 @@ func printResults(config viewConfig, project *queries.Project) error {
 }
 
 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
+	projectJSON := format.JSONProject(project)
+	return config.opts.exporter.Write(config.io, projectJSON)
 }
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() {


Test patch:
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..9734c2b0d 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,45 @@ 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{}{
+						"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":"","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/format/json_test.go
index c05a3e5ff..374fa8751 100644
--- a/pkg/cmd/project/shared/format/json_test.go
+++ b/pkg/cmd/project/shared/format/json_test.go
@@ -1,6 +1,7 @@
 package format
 
 import (
+	"encoding/json"
 	"testing"
 
 	"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@@ -22,7 +23,7 @@ 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(JSONProject(project))
 	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))
@@ -42,7 +43,7 @@ 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(JSONProject(project))
 	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))
@@ -76,7 +77,8 @@ 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)
+	projectsJSON := JSONProjects([]queries.Project{userProject, orgProject}, 2)
+	b, err := json.Marshal(projectsJSON)
 	assert.NoError(t, err)
 
 	assert.Equal(
@@ -91,7 +93,7 @@ func TestJSONProjectField_FieldType(t *testing.T) {
 	field.Field.ID = "123"
 	field.Field.Name = "name"
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(JSONProjectField(field))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2Field"}`, string(b))
@@ -113,7 +115,7 @@ func TestJSONProjectField_SingleSelectType(t *testing.T) {
 		},
 	}
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(JSONProjectField(field))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}`, string(b))
@@ -125,7 +127,7 @@ func TestJSONProjectField_ProjectV2IterationField(t *testing.T) {
 	field.IterationField.ID = "123"
 	field.IterationField.Name = "name"
 
-	b, err := JSONProjectField(field)
+	b, err := json.Marshal(JSONProjectField(field))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2IterationField"}`, string(b))
@@ -162,7 +164,8 @@ func TestJSONProjectFields(t *testing.T) {
 			TotalCount: 5,
 		},
 	}
-	b, err := JSONProjectFields(p)
+	projectFieldsJSON := JSONProjectFields(p)
+	b, err := json.Marshal(projectFieldsJSON)
 	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))
@@ -175,7 +178,7 @@ func TestJSONProjectItem_DraftIssue(t *testing.T) {
 	item.Content.DraftIssue.Title = "title"
 	item.Content.DraftIssue.Body = "a body"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(JSONProjectItem(item))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
@@ -189,7 +192,7 @@ func TestJSONProjectItem_Issue(t *testing.T) {
 	item.Content.Issue.Body = "a body"
 	item.Content.Issue.URL = "a-url"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(JSONProjectItem(item))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"Issue","url":"a-url"}`, string(b))
@@ -203,7 +206,7 @@ func TestJSONProjectItem_PullRequest(t *testing.T) {
 	item.Content.PullRequest.Body = "a body"
 	item.Content.PullRequest.URL = "a-url"
 
-	b, err := JSONProjectItem(item)
+	b, err := json.Marshal(JSONProjectItem(item))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"PullRequest","url":"a-url"}`, string(b))
@@ -259,7 +262,7 @@ func TestJSONProjectDetailedItems(t *testing.T) {
 		},
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(JSONProjectDetailedItems(p))
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
@@ -273,7 +276,7 @@ func TestJSONProjectDraftIssue(t *testing.T) {
 	item.Title = "title"
 	item.Body = "a body"
 
-	b, err := JSONProjectDraftIssue(item)
+	b, err := json.Marshal(JSONProjectDraftIssue(item))
 	assert.NoError(t, err)
 
 	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
@@ -308,7 +311,7 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.
 		draftIssue,
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(JSONProjectDetailedItems(p))
 	assert.NoError(t, err)
 	assert.JSONEq(
 		t,
@@ -346,7 +349,7 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldMilestoneValue(t *testing.
 		draftIssue,
 	}
 
-	out, err := JSONProjectDetailedItems(p)
+	out, err := json.Marshal(JSONProjectDetailedItems(p))
 	assert.NoError(t, err)
 	assert.JSONEq(
 		t,
@@ -356,8 +359,8 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldMilestoneValue(t *testing.
 }
 
 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(""))
+	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/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"},
 			},