diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go
index 2b28aeee2a..b5c0e1015f 100644
--- a/cmd/gh-aw/main.go
+++ b/cmd/gh-aw/main.go
@@ -560,6 +560,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
upgradeCmd := cli.NewUpgradeCommand()
completionCmd := cli.NewCompletionCommand()
hashCmd := cli.NewHashCommand()
+ projectCmd := cli.NewProjectCommand()
// Assign commands to groups
// Setup Commands
@@ -594,6 +595,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
prCmd.GroupID = "utilities"
completionCmd.GroupID = "utilities"
hashCmd.GroupID = "utilities"
+ projectCmd.GroupID = "utilities"
// version command is intentionally left without a group (common practice)
@@ -622,6 +624,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
rootCmd.AddCommand(fixCmd)
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(hashCmd)
+ rootCmd.AddCommand(projectCmd)
}
func main() {
diff --git a/docs/src/content/docs/reference/tokens.md b/docs/src/content/docs/reference/tokens.md
index 02a3ac8f31..e8868f14f9 100644
--- a/docs/src/content/docs/reference/tokens.md
+++ b/docs/src/content/docs/reference/tokens.md
@@ -17,7 +17,7 @@ For GitHub Agentic Workflows, you only need to create a few **optional** secrets
| Copilot workflows (CLI, engine, agent sessions, etc.) | `COPILOT_GITHUB_TOKEN` | Needs Copilot Requests permission. For org-owned repos, needs org permissions: Members (read-only), GitHub Copilot Business (read-only). |
| Cross-repo Project Ops / remote GitHub tools | `GH_AW_GITHUB_TOKEN` | PAT or app token with cross-repo access. |
| Assigning agents/bots to issues or pull requests | `GH_AW_AGENT_TOKEN` | Used by `assign-to-agent` and Copilot assignee/reviewer flows. |
-| Any GitHub Projects v2 operations | `GH_AW_PROJECT_GITHUB_TOKEN` | **Required** for `update-project`. Default `GITHUB_TOKEN` cannot access Projects v2 API. |
+| Any GitHub Projects v2 operations | `GH_AW_PROJECT_GITHUB_TOKEN` | **Required** for `project new` CLI command, `create-project`, and `update-project`. Default `GITHUB_TOKEN` cannot access Projects v2 API. |
| Isolating Model Context Protocol (MCP) server permissions (advanced optional) | `GH_AW_GITHUB_MCP_SERVER_TOKEN` | Only if you want MCP to use a different token than other jobs. |
> [!TIP]
@@ -160,7 +160,11 @@ The compiler automatically sets `GITHUB_MCP_SERVER_TOKEN` and passes it as `GITH
**Type**: Personal Access Token (required for Projects v2 operations)
-A specialized token for GitHub Projects v2 operations used by the [`update-project`](/gh-aw/reference/safe-outputs/#project-board-updates-update-project) safe output. **Required** because the default `GITHUB_TOKEN` cannot access the GitHub Projects v2 GraphQL API.
+A specialized token for GitHub Projects v2 operations used by:
+- The [`project new`](/gh-aw/setup/cli/#project-new) CLI command for creating projects
+- The [`update-project`](/gh-aw/reference/safe-outputs/#project-board-updates-update-project) safe output for updating projects
+
+**Required** because the default `GITHUB_TOKEN` cannot access the GitHub Projects v2 GraphQL API.
**When to use**:
diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md
index 9162a21e60..df60e1cad9 100644
--- a/docs/src/content/docs/setup/cli.md
+++ b/docs/src/content/docs/setup/cli.md
@@ -564,6 +564,36 @@ gh aw completion powershell # Generate powershell script
See [Shell Completions](#shell-completions) for detailed installation instructions.
+#### `project`
+
+Create and manage GitHub Projects V2 boards. Use this to create project boards for tracking issues, pull requests, and tasks.
+
+##### `project new`
+
+Create a new GitHub Project V2 owned by a user or organization. Optionally link the project to a specific repository.
+
+```bash wrap
+gh aw project new "My Project" --owner @me # Create user project
+gh aw project new "Team Board" --owner myorg # Create org project
+gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo
+```
+
+**Options:**
+- `--owner` (required): Project owner - use `@me` for current user or specify organization name
+- `--link`: Repository to link project to (format: `owner/repo`)
+
+**Token Requirements:**
+
+> [!IMPORTANT]
+> The default `GITHUB_TOKEN` cannot create projects. You must use a Personal Access Token (PAT) with Projects permissions:
+>
+> - **Classic PAT**: `project` scope (user projects) or `project` + `repo` (org projects)
+> - **Fine-grained PAT**: Organization permissions → Projects: Read & Write
+>
+> Configure via `GH_AW_PROJECT_GITHUB_TOKEN` environment variable or use `gh auth login` with a suitable token.
+
+**Related:** See [Tokens Reference](/gh-aw/reference/tokens/) for complete token configuration guide.
+
## Shell Completions
Enable tab completion for workflow names, engines, and paths.
diff --git a/pkg/cli/project_command.go b/pkg/cli/project_command.go
new file mode 100644
index 0000000000..16c72b63c0
--- /dev/null
+++ b/pkg/cli/project_command.go
@@ -0,0 +1,339 @@
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/githubnext/gh-aw/pkg/console"
+ "github.com/githubnext/gh-aw/pkg/logger"
+ "github.com/githubnext/gh-aw/pkg/workflow"
+ "github.com/spf13/cobra"
+)
+
+var projectLog = logger.New("cli:project")
+
+// ProjectConfig holds configuration for creating a GitHub Project
+type ProjectConfig struct {
+ Title string // Project title
+ Owner string // Owner login (user or org)
+ OwnerType string // "user" or "org"
+ Description string // Project description (note: not currently supported by GitHub Projects V2 API during creation)
+ Repo string // Repository to link project to (optional, format: owner/repo)
+ Verbose bool // Verbose output
+}
+
+// NewProjectCommand creates the project command
+func NewProjectCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "project",
+ Short: "Manage GitHub Projects V2",
+ Long: `Manage GitHub Projects V2 boards linked to repositories.
+
+GitHub Projects V2 provides kanban-style project boards for tracking issues,
+pull requests, and tasks across repositories.
+
+This command allows you to create new projects owned by users or organizations
+and optionally link them to specific repositories.
+
+Examples:
+ gh aw project new "My Project" --owner @me # Create user project
+ gh aw project new "Team Board" --owner myorg # Create org project
+ gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo`,
+ }
+
+ // Add subcommands
+ cmd.AddCommand(NewProjectNewCommand())
+
+ return cmd
+}
+
+// NewProjectNewCommand creates the "project new" subcommand
+func NewProjectNewCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "new
",
+ Short: "Create a new GitHub Project V2",
+ Long: `Create a new GitHub Project V2 board owned by a user or organization.
+
+The project can optionally be linked to a specific repository.
+
+Token Requirements:
+ The default GITHUB_TOKEN cannot create projects. You must use a PAT with:
+ - Classic PAT: 'project' scope (user projects) or 'project' + 'repo' (org projects)
+ - Fine-grained PAT: Organization permissions → Projects: Read & Write
+
+ Set GH_AW_PROJECT_GITHUB_TOKEN environment variable or configure your gh CLI
+ with a token that has the required permissions.
+
+Examples:
+ gh aw project new "My Project" --owner @me # Create user project
+ gh aw project new "Team Board" --owner myorg # Create org project
+ gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ owner, _ := cmd.Flags().GetString("owner")
+ link, _ := cmd.Flags().GetString("link")
+ verbose, _ := cmd.Flags().GetBool("verbose")
+
+ if owner == "" {
+ return fmt.Errorf("--owner flag is required. Use '@me' for current user or specify org name")
+ }
+
+ config := ProjectConfig{
+ Title: args[0],
+ Owner: owner,
+ Repo: link,
+ Verbose: verbose,
+ }
+
+ return RunProjectNew(cmd.Context(), config)
+ },
+ }
+
+ cmd.Flags().StringP("owner", "o", "", "Project owner: '@me' for current user or organization name (required)")
+ cmd.Flags().StringP("link", "l", "", "Repository to link project to (format: owner/repo)")
+ _ = cmd.MarkFlagRequired("owner")
+
+ return cmd
+}
+
+// RunProjectNew executes the project creation logic
+func RunProjectNew(ctx context.Context, config ProjectConfig) error {
+ projectLog.Printf("Creating project: title=%s, owner=%s, repo=%s", config.Title, config.Owner, config.Repo)
+
+ // Resolve owner type
+ ownerType := "org"
+ ownerLogin := config.Owner
+ if config.Owner == "@me" {
+ ownerType = "user"
+ // Get current user
+ currentUser, err := getCurrentUser(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get current user: %w", err)
+ }
+ ownerLogin = currentUser
+ console.LogVerbose(config.Verbose, fmt.Sprintf("Resolved @me to user: %s", ownerLogin))
+ }
+
+ config.OwnerType = ownerType
+ config.Owner = ownerLogin
+
+ // Validate owner exists
+ if err := validateOwner(ctx, config.OwnerType, config.Owner, config.Verbose); err != nil {
+ return fmt.Errorf("owner validation failed: %w", err)
+ }
+
+ // Get owner ID
+ ownerId, err := getOwnerNodeId(ctx, config.OwnerType, config.Owner, config.Verbose)
+ if err != nil {
+ return fmt.Errorf("failed to get owner ID: %w", err)
+ }
+
+ // Create project
+ fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Creating project '%s' for %s %s...", config.Title, config.OwnerType, config.Owner)))
+
+ project, err := createProject(ctx, ownerId, config.Title, config.Verbose)
+ if err != nil {
+ return fmt.Errorf("failed to create project: %w", err)
+ }
+
+ // Link to repository if specified
+ if config.Repo != "" {
+ fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Linking project to repository %s...", config.Repo)))
+ if err := linkProjectToRepo(ctx, project["id"].(string), config.Repo, config.Verbose); err != nil {
+ fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to link project to repository: %v", err)))
+ } else {
+ fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Project linked to repository"))
+ }
+ }
+
+ // Output success
+ fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Created project #%v: %s", project["number"], config.Title)))
+ fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" URL: %s", project["url"])))
+
+ return nil
+}
+
+// getCurrentUser gets the current authenticated user's login
+func getCurrentUser(ctx context.Context) (string, error) {
+ projectLog.Print("Getting current user")
+
+ output, err := workflow.RunGH("Fetching user info...", "api", "user", "--jq", ".login")
+ if err != nil {
+ return "", fmt.Errorf("failed to get current user: %w", err)
+ }
+
+ login := strings.TrimSpace(string(output))
+ if login == "" {
+ return "", fmt.Errorf("failed to get current user login")
+ }
+
+ return login, nil
+}
+
+// validateOwner validates that the owner exists
+func validateOwner(ctx context.Context, ownerType, owner string, verbose bool) error {
+ projectLog.Printf("Validating %s: %s", ownerType, owner)
+ console.LogVerbose(verbose, fmt.Sprintf("Validating %s exists: %s", ownerType, owner))
+
+ var query string
+ if ownerType == "org" {
+ query = fmt.Sprintf(`query { organization(login: "%s") { id login } }`, escapeGraphQLString(owner))
+ } else {
+ query = fmt.Sprintf(`query { user(login: "%s") { id login } }`, escapeGraphQLString(owner))
+ }
+
+ _, err := workflow.RunGH("Validating owner...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query))
+ if err != nil {
+ if ownerType == "org" {
+ return fmt.Errorf("organization '%s' not found or not accessible", owner)
+ }
+ return fmt.Errorf("user '%s' not found or not accessible", owner)
+ }
+
+ console.LogVerbose(verbose, fmt.Sprintf("✓ %s '%s' validated", capitalizeFirst(ownerType), owner))
+ return nil
+}
+
+// capitalizeFirst capitalizes the first letter of a string
+func capitalizeFirst(s string) string {
+ if len(s) == 0 {
+ return s
+ }
+ return strings.ToUpper(s[:1]) + s[1:]
+}
+
+// getOwnerNodeId gets the node ID for the owner
+func getOwnerNodeId(ctx context.Context, ownerType, owner string, verbose bool) (string, error) {
+ projectLog.Printf("Getting node ID for %s: %s", ownerType, owner)
+ console.LogVerbose(verbose, fmt.Sprintf("Getting node ID for %s: %s", ownerType, owner))
+
+ var query string
+ var jqPath string
+ if ownerType == "org" {
+ query = fmt.Sprintf(`query { organization(login: "%s") { id } }`, escapeGraphQLString(owner))
+ jqPath = ".data.organization.id"
+ } else {
+ query = fmt.Sprintf(`query { user(login: "%s") { id } }`, escapeGraphQLString(owner))
+ jqPath = ".data.user.id"
+ }
+
+ output, err := workflow.RunGH("Getting owner ID...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query), "--jq", jqPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to get owner node ID: %w", err)
+ }
+
+ nodeId := strings.TrimSpace(string(output))
+ if nodeId == "" {
+ return "", fmt.Errorf("failed to get owner node ID from response")
+ }
+
+ console.LogVerbose(verbose, fmt.Sprintf("✓ Got node ID: %s", nodeId))
+ return nodeId, nil
+}
+
+// createProject creates a GitHub Project V2
+func createProject(ctx context.Context, ownerId, title string, verbose bool) (map[string]any, error) {
+ projectLog.Printf("Creating project: ownerId=%s, title=%s", ownerId, title)
+ console.LogVerbose(verbose, fmt.Sprintf("Creating project with owner ID: %s", ownerId))
+
+ mutation := fmt.Sprintf(`mutation {
+ createProjectV2(input: { ownerId: "%s", title: "%s" }) {
+ projectV2 {
+ id
+ number
+ title
+ url
+ }
+ }
+ }`, ownerId, escapeGraphQLString(title))
+
+ output, err := workflow.RunGH("Creating project...", "api", "graphql", "-f", fmt.Sprintf("query=%s", mutation))
+ if err != nil {
+ // Check for permission errors
+ if strings.Contains(err.Error(), "INSUFFICIENT_SCOPES") || strings.Contains(err.Error(), "NOT_FOUND") {
+ return nil, fmt.Errorf("insufficient permissions. You need a PAT with Projects access (classic: 'project' scope, fine-grained: Organization → Projects: Read & Write). Set GH_AW_PROJECT_GITHUB_TOKEN or configure gh CLI with a suitable token")
+ }
+ return nil, fmt.Errorf("GraphQL mutation failed: %w", err)
+ }
+
+ // Parse response
+ var response map[string]any
+ if err := json.Unmarshal(output, &response); err != nil {
+ return nil, fmt.Errorf("failed to parse GraphQL response: %w", err)
+ }
+
+ // Extract project data
+ data, ok := response["data"].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid response: missing 'data' field")
+ }
+
+ createResult, ok := data["createProjectV2"].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid response: missing 'createProjectV2' field")
+ }
+
+ project, ok := createResult["projectV2"].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid response: missing 'projectV2' field")
+ }
+
+ console.LogVerbose(verbose, fmt.Sprintf("✓ Project created: #%v", project["number"]))
+ return project, nil
+}
+
+// linkProjectToRepo links a project to a repository
+func linkProjectToRepo(ctx context.Context, projectId, repoSlug string, verbose bool) error {
+ projectLog.Printf("Linking project %s to repository %s", projectId, repoSlug)
+ console.LogVerbose(verbose, fmt.Sprintf("Linking project to repository: %s", repoSlug))
+
+ // Parse repo slug
+ parts := strings.Split(repoSlug, "/")
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid repository format. Expected 'owner/repo', got '%s'", repoSlug)
+ }
+ repoOwner := parts[0]
+ repoName := parts[1]
+
+ // Get repository ID
+ query := fmt.Sprintf(`query { repository(owner: "%s", name: "%s") { id } }`, escapeGraphQLString(repoOwner), escapeGraphQLString(repoName))
+ output, err := workflow.RunGH("Getting repository ID...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query), "--jq", ".data.repository.id")
+ if err != nil {
+ return fmt.Errorf("repository '%s' not found: %w", repoSlug, err)
+ }
+
+ repoId := strings.TrimSpace(string(output))
+ if repoId == "" {
+ return fmt.Errorf("failed to get repository ID")
+ }
+
+ // Link project to repository
+ mutation := fmt.Sprintf(`mutation {
+ linkProjectV2ToRepository(input: { projectId: "%s", repositoryId: "%s" }) {
+ repository {
+ id
+ }
+ }
+ }`, projectId, repoId)
+
+ _, err = workflow.RunGH("Linking project to repository...", "api", "graphql", "-f", fmt.Sprintf("query=%s", mutation))
+ if err != nil {
+ return fmt.Errorf("failed to link project to repository: %w", err)
+ }
+
+ console.LogVerbose(verbose, fmt.Sprintf("✓ Linked project to repository %s", repoSlug))
+ return nil
+}
+
+// escapeGraphQLString escapes special characters in GraphQL strings
+func escapeGraphQLString(s string) string {
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, "\"", "\\\"")
+ s = strings.ReplaceAll(s, "\n", "\\n")
+ s = strings.ReplaceAll(s, "\r", "\\r")
+ s = strings.ReplaceAll(s, "\t", "\\t")
+ return s
+}
diff --git a/pkg/cli/project_command_test.go b/pkg/cli/project_command_test.go
new file mode 100644
index 0000000000..0f5cedbd97
--- /dev/null
+++ b/pkg/cli/project_command_test.go
@@ -0,0 +1,163 @@
+//go:build !integration
+
+package cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewProjectCommand(t *testing.T) {
+ cmd := NewProjectCommand()
+ require.NotNil(t, cmd, "Command should be created")
+ assert.Equal(t, "project", cmd.Use, "Command name should be 'project'")
+ assert.Contains(t, cmd.Short, "GitHub Projects V2", "Short description should mention Projects V2")
+ assert.NotEmpty(t, cmd.Commands(), "Command should have subcommands")
+}
+
+func TestNewProjectNewCommand(t *testing.T) {
+ cmd := NewProjectNewCommand()
+ require.NotNil(t, cmd, "Command should be created")
+ assert.Equal(t, "new ", cmd.Use, "Command usage should be 'new '")
+ assert.Contains(t, cmd.Short, "Create a new GitHub Project V2", "Short description should be about creating projects")
+
+ // Check flags
+ ownerFlag := cmd.Flags().Lookup("owner")
+ require.NotNil(t, ownerFlag, "Should have --owner flag")
+ assert.Equal(t, "o", ownerFlag.Shorthand, "Owner flag should have short form 'o'")
+
+ linkFlag := cmd.Flags().Lookup("link")
+ require.NotNil(t, linkFlag, "Should have --link flag")
+ assert.Equal(t, "l", linkFlag.Shorthand, "Link flag should have short form 'l'")
+}
+
+func TestEscapeGraphQLString(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain text",
+ input: "Hello World",
+ expected: "Hello World",
+ },
+ {
+ name: "with quotes",
+ input: `Project "Alpha"`,
+ expected: `Project \"Alpha\"`,
+ },
+ {
+ name: "with backslash",
+ input: `Path\to\file`,
+ expected: `Path\\to\\file`,
+ },
+ {
+ name: "with newline",
+ input: "Line 1\nLine 2",
+ expected: "Line 1\\nLine 2",
+ },
+ {
+ name: "with tab",
+ input: "Name\tValue",
+ expected: "Name\\tValue",
+ },
+ {
+ name: "complex string",
+ input: "Test \"project\"\nWith\ttabs\\and backslashes",
+ expected: "Test \\\"project\\\"\\nWith\\ttabs\\\\and backslashes",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := escapeGraphQLString(tt.input)
+ assert.Equal(t, tt.expected, result, "GraphQL string should be properly escaped")
+ })
+ }
+}
+
+func TestProjectConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ config ProjectConfig
+ description string
+ }{
+ {
+ name: "user project",
+ config: ProjectConfig{
+ Title: "My Project",
+ Owner: "testuser",
+ OwnerType: "user",
+ },
+ description: "Should create user project",
+ },
+ {
+ name: "org project",
+ config: ProjectConfig{
+ Title: "Team Board",
+ Owner: "myorg",
+ OwnerType: "org",
+ },
+ description: "Should create org project",
+ },
+ {
+ name: "project with repo",
+ config: ProjectConfig{
+ Title: "Bugs",
+ Owner: "myorg",
+ OwnerType: "org",
+ Repo: "myorg/myrepo",
+ },
+ description: "Should create project linked to repo",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.NotEmpty(t, tt.config.Title, "Project title should not be empty")
+ assert.NotEmpty(t, tt.config.Owner, "Project owner should not be empty")
+ assert.NotEmpty(t, tt.config.OwnerType, "Owner type should not be empty")
+ assert.Contains(t, []string{"user", "org"}, tt.config.OwnerType, "Owner type should be 'user' or 'org'")
+ })
+ }
+}
+
+func TestProjectNewCommandArgs(t *testing.T) {
+ cmd := NewProjectNewCommand()
+
+ tests := []struct {
+ name string
+ args []string
+ shouldErr bool
+ }{
+ {
+ name: "no arguments",
+ args: []string{},
+ shouldErr: true,
+ },
+ {
+ name: "one argument",
+ args: []string{"My Project"},
+ shouldErr: false,
+ },
+ {
+ name: "too many arguments",
+ args: []string{"My Project", "Extra"},
+ shouldErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := cmd.Args(cmd, tt.args)
+ if tt.shouldErr {
+ assert.Error(t, err, "Should return error for invalid arguments")
+ } else {
+ assert.NoError(t, err, "Should not return error for valid arguments")
+ }
+ })
+ }
+}