[object Object]

← back to Cli Printing Press

feat(linear): generate Linear CLI with 12 resources and 45 commands

0b892938f75ed2298441558bdc7298ac2ffc7b00 · 2026-03-24 18:21:20 -0700 · Matt Van Horn

Hand-crafted YAML spec wrapping Linear's GraphQL API in REST-style CLI
commands. Post-processed generated code to replace raw GraphQL query strings
with proper flags (--state, --assignee, --team, --priority, etc.).

Resources: issues, comments, projects, cycles, teams, users, labels,
workflows, documents, notifications, webhooks, organization.

Key features:
- Smart filters: --assignee me, --state "In Progress", --team ENG
- Identifier resolution: ENG-123 auto-resolved to UUID
- State/assignee name resolution for updates
- GraphQL error extraction with rate limit detection
- Dry-run mode shows exact GraphQL query being sent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0b892938f75ed2298441558bdc7298ac2ffc7b00
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Tue Mar 24 18:21:20 2026 -0700

    feat(linear): generate Linear CLI with 12 resources and 45 commands
    
    Hand-crafted YAML spec wrapping Linear's GraphQL API in REST-style CLI
    commands. Post-processed generated code to replace raw GraphQL query strings
    with proper flags (--state, --assignee, --team, --priority, etc.).
    
    Resources: issues, comments, projects, cycles, teams, users, labels,
    workflows, documents, notifications, webhooks, organization.
    
    Key features:
    - Smart filters: --assignee me, --state "In Progress", --team ENG
    - Identifier resolution: ENG-123 auto-resolved to UUID
    - State/assignee name resolution for updates
    - GraphQL error extraction with rate limit detection
    - Dry-run mode shows exact GraphQL query being sent
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 linear-cli/.gitignore                    |   3 +
 linear-cli/.golangci.yml                 |  12 +
 linear-cli/.goreleaser.yaml              |  27 ++
 linear-cli/Makefile                      |  16 +
 linear-cli/README.md                     | 129 ++++++
 linear-cli/cmd/linear-cli/main.go        |  15 +
 linear-cli/go.mod                        |  15 +
 linear-cli/go.sum                        |  16 +
 linear-cli/internal/cli/comments.go      | 115 +++++
 linear-cli/internal/cli/cycles.go        | 136 ++++++
 linear-cli/internal/cli/doctor.go        | 119 +++++
 linear-cli/internal/cli/documents.go     | 138 ++++++
 linear-cli/internal/cli/graphql.go       | 121 ++++++
 linear-cli/internal/cli/helpers.go       | 308 +++++++++++++
 linear-cli/internal/cli/issues.go        | 580 +++++++++++++++++++++++++
 linear-cli/internal/cli/labels.go        | 134 ++++++
 linear-cli/internal/cli/notifications.go |  50 +++
 linear-cli/internal/cli/organization.go  |  50 +++
 linear-cli/internal/cli/projects.go      | 224 ++++++++++
 linear-cli/internal/cli/root.go          | 120 ++++++
 linear-cli/internal/cli/teams.go         |  93 ++++
 linear-cli/internal/cli/users.go         |  80 ++++
 linear-cli/internal/cli/webhooks.go      | 141 ++++++
 linear-cli/internal/cli/workflows.go     |  63 +++
 linear-cli/internal/client/client.go     | 298 +++++++++++++
 linear-cli/internal/config/config.go     | 102 +++++
 linear-cli/internal/types/types.go       | 153 +++++++
 linear-spec.yaml                         | 716 +++++++++++++++++++++++++++++++
 28 files changed, 3974 insertions(+)

diff --git a/linear-cli/.gitignore b/linear-cli/.gitignore
new file mode 100644
index 00000000..71174e91
--- /dev/null
+++ b/linear-cli/.gitignore
@@ -0,0 +1,3 @@
+linear-cli/.cache/
+linear-cli
+*-validation/
diff --git a/linear-cli/.golangci.yml b/linear-cli/.golangci.yml
new file mode 100644
index 00000000..5397226d
--- /dev/null
+++ b/linear-cli/.golangci.yml
@@ -0,0 +1,12 @@
+linters:
+  enable:
+    - errorlint
+    - govet
+    - ineffassign
+    - staticcheck
+    - unused
+
+formatters:
+  enable:
+    - gofmt
+    - goimports
diff --git a/linear-cli/.goreleaser.yaml b/linear-cli/.goreleaser.yaml
new file mode 100644
index 00000000..aea780e7
--- /dev/null
+++ b/linear-cli/.goreleaser.yaml
@@ -0,0 +1,27 @@
+version: 2
+project_name: linear-cli
+changelog:
+  disable: true
+builds:
+  - id: linear-cli
+    main: ./cmd/linear-cli
+    binary: linear-cli
+    env:
+      - CGO_ENABLED=0
+    ldflags:
+      - -s -w -X github.com/USER/linear-cli/internal/cli.version={{ .Version }}
+    targets:
+      - darwin_amd64
+      - darwin_arm64
+      - linux_amd64
+      - linux_arm64
+      - windows_amd64
+      - windows_arm64
+archives:
+  - formats: [tar.gz]
+    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+    format_overrides:
+      - goos: windows
+        formats: [zip]
+checksum:
+  name_template: checksums.txt
diff --git a/linear-cli/Makefile b/linear-cli/Makefile
new file mode 100644
index 00000000..069af71d
--- /dev/null
+++ b/linear-cli/Makefile
@@ -0,0 +1,16 @@
+.PHONY: build test lint install clean
+
+build:
+	go build -o bin/linear-cli ./cmd/linear-cli
+
+test:
+	go test ./...
+
+lint:
+	golangci-lint run
+
+install:
+	go install ./cmd/linear-cli
+
+clean:
+	rm -rf bin/
diff --git a/linear-cli/README.md b/linear-cli/README.md
new file mode 100644
index 00000000..73bec314
--- /dev/null
+++ b/linear-cli/README.md
@@ -0,0 +1,129 @@
+# linear-cli
+
+Linear project management CLI - issues, projects, cycles, teams, and more
+
+## Install
+
+```
+go install github.com/USER/linear-cli/cmd/linear-cli@latest
+```
+
+## Usage
+
+```
+linear-cli --help
+linear-cli --version
+linear-cli doctor
+```
+
+### Commands
+
+#### comments
+
+Manage issue comments
+
+- `linear-cli comments create` - Add a comment to an issue
+- `linear-cli comments delete` - Delete a comment
+- `linear-cli comments list` - List comments on an issue
+- `linear-cli comments update` - Edit a comment
+
+#### cycles
+
+Manage sprint cycles
+
+- `linear-cli cycles current` - Get the active cycle
+- `linear-cli cycles get` - Get cycle details with issues
+- `linear-cli cycles list` - List cycles for a team
+
+#### documents
+
+Manage project documents
+
+- `linear-cli documents create` - Create a document
+- `linear-cli documents get` - Get document content
+- `linear-cli documents list` - List documents
+
+#### issues
+
+Manage issues - create, update, search, and track work
+
+- `linear-cli issues archive` - Archive an issue
+- `linear-cli issues create` - Create a new issue
+- `linear-cli issues delete` - Delete an issue permanently
+- `linear-cli issues get` - Get a single issue by identifier (e.g. ENG-123)
+- `linear-cli issues list` - List issues with filters
+- `linear-cli issues mine` - List issues assigned to me
+- `linear-cli issues search` - Search issues by text
+- `linear-cli issues update` - Update an existing issue
+
+#### labels
+
+Manage issue labels
+
+- `linear-cli labels create` - Create a new label
+- `linear-cli labels list` - List all labels
+
+#### notifications
+
+Manage notifications
+
+- `linear-cli notifications list` - List unread notifications
+
+#### organization
+
+View workspace/organization info
+
+- `linear-cli organization get` - Get organization details
+
+#### projects
+
+Manage projects and milestones
+
+- `linear-cli projects create` - Create a new project
+- `linear-cli projects get` - Get project details
+- `linear-cli projects list` - List all projects
+- `linear-cli projects update` - Update project details
+
+#### teams
+
+Manage teams and their settings
+
+- `linear-cli teams get` - Get team details
+- `linear-cli teams list` - List all teams
+
+#### users
+
+Manage workspace users
+
+- `linear-cli users list` - List workspace users
+- `linear-cli users me` - Get the authenticated user
+
+#### webhooks
+
+Manage webhooks
+
+- `linear-cli webhooks create` - Create a webhook
+- `linear-cli webhooks delete` - Delete a webhook
+- `linear-cli webhooks list` - List webhooks
+
+#### workflows
+
+Manage workflow states
+
+- `linear-cli workflows list` - List workflow states across teams
+
+
+### Output Formats
+
+- Default: human-readable table
+- `--json`: JSON output
+- `--plain`: tab-separated text for piping
+
+### Configuration
+
+Config file: `~/.config/linear-cli/config.toml`
+
+Environment variables:
+- `LINEAR_API_KEY`
+
+## Generated by CLI Printing Press
diff --git a/linear-cli/cmd/linear-cli/main.go b/linear-cli/cmd/linear-cli/main.go
new file mode 100644
index 00000000..762c1c10
--- /dev/null
+++ b/linear-cli/cmd/linear-cli/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/USER/linear-cli/internal/cli"
+)
+
+func main() {
+	if err := cli.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(cli.ExitCode(err))
+	}
+}
diff --git a/linear-cli/go.mod b/linear-cli/go.mod
new file mode 100644
index 00000000..f6b86b4b
--- /dev/null
+++ b/linear-cli/go.mod
@@ -0,0 +1,15 @@
+module github.com/USER/linear-cli
+
+go 1.23
+
+require (
+	github.com/mattn/go-isatty v0.0.20
+	github.com/pelletier/go-toml/v2 v2.2.4
+	github.com/spf13/cobra v1.9.1
+)
+
+require (
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.6 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+)
diff --git a/linear-cli/go.sum b/linear-cli/go.sum
new file mode 100644
index 00000000..d0bfb4e0
--- /dev/null
+++ b/linear-cli/go.sum
@@ -0,0 +1,16 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/linear-cli/internal/cli/comments.go b/linear-cli/internal/cli/comments.go
new file mode 100644
index 00000000..10327454
--- /dev/null
+++ b/linear-cli/internal/cli/comments.go
@@ -0,0 +1,115 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+
+func newCommentsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "comments",
+		Short: "Manage issue comments",
+	}
+	cmd.AddCommand(newCommentsListCmd(flags))
+	cmd.AddCommand(newCommentsCreateCmd(flags))
+	cmd.AddCommand(newCommentsDeleteCmd(flags))
+	return cmd
+}
+
+func newCommentsListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "list <issue-identifier>",
+		Short:   "List comments on an issue",
+		Example: "  linear-cli comments list ENG-123",
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+			issueID, err := resolveIssueID(c, args[0])
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ issue(id: %q) { comments(first: 50) { nodes { id body user { name } createdAt updatedAt } } } }`, issueID)
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+			nodes, _ := extractData(data, "data.issue.comments.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newCommentsCreateCmd(flags *rootFlags) *cobra.Command {
+	var body string
+
+	cmd := &cobra.Command{
+		Use:     "create <issue-identifier>",
+		Short:   "Add a comment to an issue",
+		Example: `  linear-cli comments create ENG-123 --body "Looks good, shipping it"`,
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if body == "" {
+				return usageErr(fmt.Errorf("--body is required"))
+			}
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+			issueID, err := resolveIssueID(c, args[0])
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`mutation { commentCreate(input: { issueId: %q, body: %q }) { success comment { id body user { name } createdAt } } }`, issueID, body)
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+			comment, _ := extractData(data, "data.commentCreate.comment")
+			return printOutput(cmd.OutOrStdout(), comment, flags.asJSON)
+		},
+	}
+	cmd.Flags().StringVar(&body, "body", "", "Comment body (markdown)")
+	return cmd
+}
+
+func newCommentsDeleteCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "delete <comment-id>",
+		Short: "Delete a comment",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+			query := fmt.Sprintf(`mutation { commentDelete(id: %q) { success } }`, args[0])
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+			_ = data
+			fmt.Fprintln(cmd.OutOrStdout(), "Comment deleted")
+			return nil
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/cycles.go b/linear-cli/internal/cli/cycles.go
new file mode 100644
index 00000000..f909cea6
--- /dev/null
+++ b/linear-cli/internal/cli/cycles.go
@@ -0,0 +1,136 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newCyclesCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "cycles",
+		Short: "Manage sprint cycles",
+	}
+
+	cmd.AddCommand(newCyclesListCmd(flags))
+	cmd.AddCommand(newCyclesCurrentCmd(flags))
+	cmd.AddCommand(newCyclesGetCmd(flags))
+	return cmd
+}
+
+const cycleFields = `id number name startsAt endsAt completedAt progress team { name key }`
+const cycleDetailFields = `id number name startsAt endsAt completedAt progress completedScopeHistory scopeHistory team { name key } issues(first: 100) { nodes { identifier title state { name } assignee { name } priority estimate } }`
+
+func newCyclesListCmd(flags *rootFlags) *cobra.Command {
+	var team string
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List cycles",
+		Example: `  linear-cli cycles list
+  linear-cli cycles list --team ENG
+  linear-cli cycles list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterClause := ""
+			if team != "" {
+				filterClause = fmt.Sprintf(`, filter: { team: { key: { eqIgnoreCase: %q } } }`, team)
+			}
+
+			query := fmt.Sprintf(`{ cycles(first: 20, orderBy: createdAt%s) { nodes { %s } } }`, filterClause, cycleFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.cycles.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&team, "team", "", "Filter by team key (e.g. 'ENG')")
+
+	return cmd
+}
+
+func newCyclesCurrentCmd(flags *rootFlags) *cobra.Command {
+	var team string
+
+	cmd := &cobra.Command{
+		Use:   "current",
+		Short: "Get the active cycle",
+		Example: `  linear-cli cycles current
+  linear-cli cycles current --team ENG
+  linear-cli cycles current --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterParts := []string{`isActive: { eq: true }`}
+			if team != "" {
+				filterParts = append(filterParts, fmt.Sprintf(`team: { key: { eqIgnoreCase: %q } }`, team))
+			}
+
+			query := fmt.Sprintf(`{ cycles(first: 1, filter: { %s }) { nodes { %s } } }`, strings.Join(filterParts, ", "), cycleDetailFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.cycles.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&team, "team", "", "Filter by team key (e.g. 'ENG')")
+
+	return cmd
+}
+
+func newCyclesGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get <id>",
+		Short: "Get cycle details with issues",
+		Example: `  linear-cli cycles get abc123-def456
+  linear-cli cycles get abc123-def456 --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ cycle(id: %q) { %s } }`, args[0], cycleDetailFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			cycle, _ := extractData(data, "data.cycle")
+			return printOutput(cmd.OutOrStdout(), cycle, flags.asJSON)
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/doctor.go b/linear-cli/internal/cli/doctor.go
new file mode 100644
index 00000000..9eb9234a
--- /dev/null
+++ b/linear-cli/internal/cli/doctor.go
@@ -0,0 +1,119 @@
+package cli
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/USER/linear-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+func newDoctorCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "doctor",
+		Short: "Check CLI health",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			report := map[string]any{}
+
+			// Check config
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				report["config"] = fmt.Sprintf("error: %s", err)
+			} else {
+				report["config"] = "ok"
+				report["config_path"] = cfg.Path
+				report["base_url"] = cfg.BaseURL
+			}
+
+			// Check auth
+			if cfg != nil {
+				header := cfg.AuthHeader()
+				if header == "" {
+					report["auth"] = "not configured"
+				} else {
+					report["auth"] = "configured"
+					report["auth_source"] = cfg.AuthSource
+				}
+			}
+
+			// Check API connectivity
+			if cfg != nil && cfg.BaseURL != "" {
+				httpClient := &http.Client{Timeout: 5 * time.Second}
+				
+				// Try common health-check paths in order
+				paths := []string{
+					"",
+					"/health",
+					"/healthz",
+					"/status",
+					"/ping",
+				}
+
+				reached := false
+				for _, p := range paths {
+					url := strings.TrimRight(cfg.BaseURL, "/") + p
+					resp, err := httpClient.Get(url)
+					if err != nil {
+						continue
+					}
+					resp.Body.Close()
+					if resp.StatusCode >= 200 && resp.StatusCode < 400 {
+						report["api"] = fmt.Sprintf("reachable (HTTP %d)", resp.StatusCode)
+						reached = true
+						break
+					}
+				}
+
+				if !reached {
+					// Fall back to reporting the base URL status
+					resp, err := httpClient.Get(cfg.BaseURL)
+					if err != nil {
+						report["api"] = fmt.Sprintf("unreachable: %s", err)
+					} else {
+						resp.Body.Close()
+						report["api"] = fmt.Sprintf("degraded (HTTP %d)", resp.StatusCode)
+					}
+				}
+			} else if cfg != nil && cfg.BaseURL == "" {
+				report["api"] = "not configured (set base_url in config file)"
+			}
+
+			report["version"] = version
+
+			if flags.asJSON {
+				return flags.printJSON(cmd, report)
+			}
+
+			// Human-readable output with color
+			w := cmd.OutOrStdout()
+			checkKeys := []struct{ key, label string }{
+				{"config", "Config"},
+				{"auth", "Auth"},
+				{"api", "API"},
+			}
+			for _, ck := range checkKeys {
+				v, ok := report[ck.key]
+				if !ok {
+					continue
+				}
+				s := fmt.Sprintf("%v", v)
+				indicator := green("OK")
+				if strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") {
+					indicator = red("FAIL")
+				} else if strings.Contains(s, "not ") {
+					indicator = yellow("WARN")
+				}
+				fmt.Fprintf(w, "  %s %s: %s\n", indicator, ck.label, s)
+			}
+			// Print info keys without status indicator
+			for _, key := range []string{"config_path", "base_url", "auth_source", "version"} {
+				if v, ok := report[key]; ok {
+					fmt.Fprintf(w, "  %s: %v\n", key, v)
+				}
+			}
+			return nil
+		},
+	}
+}
diff --git a/linear-cli/internal/cli/documents.go b/linear-cli/internal/cli/documents.go
new file mode 100644
index 00000000..146265b9
--- /dev/null
+++ b/linear-cli/internal/cli/documents.go
@@ -0,0 +1,138 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newDocumentsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "documents",
+		Short: "Manage project documents",
+	}
+
+	cmd.AddCommand(newDocumentsListCmd(flags))
+	cmd.AddCommand(newDocumentsGetCmd(flags))
+	cmd.AddCommand(newDocumentsCreateCmd(flags))
+	return cmd
+}
+
+const documentFields = `id title creator { name } project { name } updatedAt createdAt`
+const documentDetailFields = `id title content url creator { name } project { name } updatedAt createdAt`
+
+func newDocumentsListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List documents",
+		Example: `  linear-cli documents list
+  linear-cli documents list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ documents(first: 50, orderBy: updatedAt) { nodes { %s } } }`, documentFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.documents.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newDocumentsGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get <id>",
+		Short: "Get document content by ID",
+		Example: `  linear-cli documents get abc123-def456
+  linear-cli documents get abc123-def456 --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ document(id: %q) { %s } }`, args[0], documentDetailFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			doc, _ := extractData(data, "data.document")
+			return printOutput(cmd.OutOrStdout(), doc, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newDocumentsCreateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		title   string
+		content string
+		project string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "create",
+		Short: "Create a document",
+		Example: `  linear-cli documents create --title "Design Spec" --content "## Overview" --project abc123
+  linear-cli documents create --title "Meeting Notes" --content "Discussed roadmap"`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if title == "" {
+				return usageErr(fmt.Errorf("--title is required"))
+			}
+
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			var inputParts []string
+			inputParts = append(inputParts, fmt.Sprintf(`title: %q`, title))
+			if content != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`content: %q`, content))
+			}
+			if project != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`projectId: %q`, project))
+			}
+
+			query := fmt.Sprintf(`mutation { documentCreate(input: { %s }) { success document { id title url } } }`, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			doc, _ := extractData(data, "data.documentCreate.document")
+			return printOutput(cmd.OutOrStdout(), doc, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&title, "title", "", "Document title (required)")
+	cmd.Flags().StringVar(&content, "content", "", "Document content (markdown)")
+	cmd.Flags().StringVar(&project, "project", "", "Project ID to attach document to")
+
+	return cmd
+}
diff --git a/linear-cli/internal/cli/graphql.go b/linear-cli/internal/cli/graphql.go
new file mode 100644
index 00000000..62eb5d95
--- /dev/null
+++ b/linear-cli/internal/cli/graphql.go
@@ -0,0 +1,121 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+// jsonRaw is an alias so interface signatures are cleaner.
+type jsonRaw = json.RawMessage
+
+func jsonUnmarshal(data json.RawMessage, v any) error {
+	return json.Unmarshal(data, v)
+}
+
+// gql builds a GraphQL request body from a query string and optional variables.
+func gql(query string, vars map[string]any) map[string]any {
+	body := map[string]any{"query": query}
+	if len(vars) > 0 {
+		body["variables"] = vars
+	}
+	return body
+}
+
+// extractData navigates a GraphQL response to the inner data payload.
+// path is dot-separated: "data.issues.nodes" extracts response.data.issues.nodes
+func extractData(raw json.RawMessage, path string) (json.RawMessage, error) {
+	parts := strings.Split(path, ".")
+	current := raw
+	for _, part := range parts {
+		var obj map[string]json.RawMessage
+		if err := json.Unmarshal(current, &obj); err != nil {
+			return current, nil // not an object, return as-is
+		}
+		next, ok := obj[part]
+		if !ok {
+			return current, nil
+		}
+		current = next
+	}
+	return current, nil
+}
+
+// extractErrors checks for GraphQL errors in the response.
+func extractErrors(raw json.RawMessage) error {
+	var resp struct {
+		Errors []struct {
+			Message    string `json:"message"`
+			Extensions struct {
+				Code string `json:"code"`
+			} `json:"extensions"`
+		} `json:"errors"`
+	}
+	if err := json.Unmarshal(raw, &resp); err != nil {
+		return nil
+	}
+	if len(resp.Errors) == 0 {
+		return nil
+	}
+	msgs := make([]string, len(resp.Errors))
+	for i, e := range resp.Errors {
+		msgs[i] = e.Message
+		if e.Extensions.Code == "RATELIMITED" {
+			return rateLimitErr(fmt.Errorf("rate limited: %s", e.Message))
+		}
+	}
+	return apiErr(fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; ")))
+}
+
+// buildFilter constructs a Linear GraphQL filter object from flag values.
+func buildFilter(state, assignee, team, project, label, priority string) string {
+	var filters []string
+
+	if state != "" {
+		filters = append(filters, fmt.Sprintf(`state: { name: { eqIgnoreCase: %q } }`, state))
+	}
+	if assignee != "" {
+		if assignee == "me" {
+			filters = append(filters, `assignee: { isMe: { eq: true } }`)
+		} else {
+			filters = append(filters, fmt.Sprintf(`assignee: { name: { eqIgnoreCase: %q } }`, assignee))
+		}
+	}
+	if team != "" {
+		filters = append(filters, fmt.Sprintf(`team: { key: { eqIgnoreCase: %q } }`, team))
+	}
+	if project != "" {
+		filters = append(filters, fmt.Sprintf(`project: { name: { containsIgnoreCase: %q } }`, project))
+	}
+	if label != "" {
+		filters = append(filters, fmt.Sprintf(`labels: { name: { eqIgnoreCase: %q } }`, label))
+	}
+	if priority != "" {
+		p := priorityNumber(priority)
+		if p >= 0 {
+			filters = append(filters, fmt.Sprintf(`priority: { eq: %d }`, p))
+		}
+	}
+
+	if len(filters) == 0 {
+		return ""
+	}
+	return "filter: { " + strings.Join(filters, ", ") + " }"
+}
+
+func priorityNumber(s string) int {
+	switch strings.ToLower(s) {
+	case "none", "0":
+		return 0
+	case "urgent", "1":
+		return 1
+	case "high", "2":
+		return 2
+	case "medium", "3":
+		return 3
+	case "low", "4":
+		return 4
+	default:
+		return -1
+	}
+}
diff --git a/linear-cli/internal/cli/helpers.go b/linear-cli/internal/cli/helpers.go
new file mode 100644
index 00000000..66d826d1
--- /dev/null
+++ b/linear-cli/internal/cli/helpers.go
@@ -0,0 +1,308 @@
+package cli
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"sort"
+	"strings"
+	"text/tabwriter"
+
+	"github.com/mattn/go-isatty"
+)
+
+var As = errors.As
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+func colorEnabled() bool {
+	if noColor {
+		return false
+	}
+	if os.Getenv("NO_COLOR") != "" {
+		return false
+	}
+	if os.Getenv("TERM") == "dumb" {
+		return false
+	}
+	return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
+}
+
+func bold(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+	code int
+	err  error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error    { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error     { return &cliError{code: 4, err: err} }
+func apiErr(err error) error      { return &cliError{code: 5, err: err} }
+func configErr(err error) error   { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// classifyAPIError maps API errors to structured exit codes.
+func classifyAPIError(err error) error {
+	var apiError interface{ Error() string }
+	// Check if it has StatusCode field (client.APIError)
+	type statusCoder interface {
+		Error() string
+	}
+	type withStatus interface {
+		statusCoder
+	}
+	// Use reflection-free approach: check error message for HTTP status
+	msg := err.Error()
+	switch {
+	case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
+		return authErr(err)
+	case strings.Contains(msg, "HTTP 404"):
+		return notFoundErr(err)
+	case strings.Contains(msg, "HTTP 429"):
+		return rateLimitErr(err)
+	default:
+		_ = apiError
+		return apiErr(err)
+	}
+}
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return s[:max]
+	}
+	return s[:max-3] + "..."
+}
+
+func firstNonEmpty(items ...string) string {
+	for _, s := range items {
+		if s != "" {
+			return s
+		}
+	}
+	return ""
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+	return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+
+func replacePathParam(path, name, value string) string {
+	return strings.ReplaceAll(path, "{"+name+"}", value)
+}
+
+// paginatedGet fetches pages and concatenates array results.
+func paginatedGet(c interface {
+	Get(path string, params map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+	// Clean zero-value params
+	clean := map[string]string{}
+	for k, v := range params {
+		if v != "" && v != "0" && v != "false" {
+			clean[k] = v
+		}
+	}
+
+	if !fetchAll {
+		return c.Get(path, clean)
+	}
+
+	// Fetch all pages
+	var allItems []json.RawMessage
+	page := 0
+	for {
+		page++
+		fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+
+		data, err := c.Get(path, clean)
+		if err != nil {
+			return nil, err
+		}
+
+		// Try to extract items array
+		var items []json.RawMessage
+		if json.Unmarshal(data, &items) == nil {
+			allItems = append(allItems, items...)
+		} else {
+			// Response is an object - look for array inside
+			var obj map[string]json.RawMessage
+			if json.Unmarshal(data, &obj) == nil {
+				// Try common data fields
+				for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
+					if arr, ok := obj[field]; ok {
+						var nested []json.RawMessage
+						if json.Unmarshal(arr, &nested) == nil {
+							allItems = append(allItems, nested...)
+							break
+						}
+					}
+				}
+
+				// Check for next cursor
+				if nextCursorPath != "" {
+					if tokenRaw, ok := obj[nextCursorPath]; ok {
+						var token string
+						if json.Unmarshal(tokenRaw, &token) == nil && token != "" {
+							clean[cursorParam] = token
+							continue
+						}
+					}
+				}
+
+				// Check has_more
+				if hasMoreField != "" {
+					if moreRaw, ok := obj[hasMoreField]; ok {
+						var more bool
+						if json.Unmarshal(moreRaw, &more) == nil && more {
+							continue
+						}
+					}
+				}
+			}
+			// No more pages
+			break
+		}
+
+		// For direct arrays, can't paginate without cursor
+		break
+	}
+
+	fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+	result, _ := json.Marshal(allItems)
+	return json.RawMessage(result), nil
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+	if asJSON {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(data)
+	}
+
+	// Try to detect if response is an array
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+		return printAutoTable(w, items)
+	}
+
+	// Single object - pretty print
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(obj)
+	}
+
+	// Fallback: print raw
+	fmt.Fprintln(w, string(data))
+	return nil
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+	if len(items) == 0 {
+		return nil
+	}
+
+	// Collect headers from first item, prioritize common fields
+	priority := []string{"id", "name", "username", "title", "status", "type", "email", "description"}
+	headerSet := map[string]struct{}{}
+	for k := range items[0] {
+		headerSet[k] = struct{}{}
+	}
+
+	var headers []string
+	for _, p := range priority {
+		if _, ok := headerSet[p]; ok {
+			headers = append(headers, p)
+			delete(headerSet, p)
+		}
+	}
+	// Add remaining headers sorted
+	var rest []string
+	for k := range headerSet {
+		rest = append(rest, k)
+	}
+	sort.Strings(rest)
+	headers = append(headers, rest...)
+
+	// Limit to 6 columns max for readability
+	if len(headers) > 6 {
+		headers = headers[:6]
+	}
+
+	// Build rows
+	rows := make([][]string, 0, len(items))
+	for _, item := range items {
+		row := make([]string, len(headers))
+		for i, h := range headers {
+			v := item[h]
+			switch val := v.(type) {
+			case string:
+				row[i] = truncate(val, 40)
+			case float64:
+				if val == float64(int64(val)) {
+					row[i] = fmt.Sprintf("%d", int64(val))
+				} else {
+					row[i] = fmt.Sprintf("%g", val)
+				}
+			case bool:
+				row[i] = fmt.Sprintf("%t", val)
+			case nil:
+				row[i] = ""
+			default:
+				b, _ := json.Marshal(val)
+				row[i] = truncate(string(b), 40)
+			}
+		}
+		rows = append(rows, row)
+	}
+
+	// Print with tab alignment using tabwriter
+	tw := newTabWriter(w)
+	upperHeaders := make([]string, len(headers))
+	for i, h := range headers {
+		upperHeaders[i] = bold(strings.ToUpper(h))
+	}
+
+	fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+	for _, row := range rows {
+		fmt.Fprintln(tw, strings.Join(row, "\t"))
+	}
+	return tw.Flush()
+}
diff --git a/linear-cli/internal/cli/issues.go b/linear-cli/internal/cli/issues.go
new file mode 100644
index 00000000..958a2d96
--- /dev/null
+++ b/linear-cli/internal/cli/issues.go
@@ -0,0 +1,580 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newIssuesCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "issues",
+		Short: "Manage issues - create, update, search, and track work",
+	}
+
+	cmd.AddCommand(newIssuesListCmd(flags))
+	cmd.AddCommand(newIssuesMineCmd(flags))
+	cmd.AddCommand(newIssuesGetCmd(flags))
+	cmd.AddCommand(newIssuesCreateCmd(flags))
+	cmd.AddCommand(newIssuesUpdateCmd(flags))
+	cmd.AddCommand(newIssuesSearchCmd(flags))
+	cmd.AddCommand(newIssuesArchiveCmd(flags))
+	cmd.AddCommand(newIssuesDeleteCmd(flags))
+	return cmd
+}
+
+const issueFields = `id identifier title state { name } assignee { name } priority priorityLabel project { name } cycle { number } labels { nodes { name } } estimate dueDate createdAt updatedAt`
+const issueDetailFields = `id identifier title description url state { name } assignee { name email } priority priorityLabel project { name } cycle { name number } labels { nodes { name } } estimate dueDate createdAt updatedAt completedAt canceledAt parent { identifier title } children { nodes { identifier title state { name } } } relations { nodes { type relatedIssue { identifier title } } } comments(first: 20) { nodes { body user { name } createdAt } } attachments { nodes { title url } }`
+
+func newIssuesListCmd(flags *rootFlags) *cobra.Command {
+	var (
+		state    string
+		assignee string
+		team     string
+		project  string
+		label    string
+		priority string
+		limit    int
+	)
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List issues with filters",
+		Example: `  linear-cli issues list
+  linear-cli issues list --state "In Progress"
+  linear-cli issues list --assignee me --team ENG
+  linear-cli issues list --priority urgent --limit 10
+  linear-cli issues list --project "Q1 Launch" --label bug`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filter := buildFilter(state, assignee, team, project, label, priority)
+			filterClause := ""
+			if filter != "" {
+				filterClause = ", " + filter
+			}
+
+			query := fmt.Sprintf(`{ issues(first: %d, orderBy: updatedAt%s) { nodes { %s } pageInfo { hasNextPage endCursor } } }`, limit, filterClause, issueFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.issues.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&state, "state", "", "Filter by state name (e.g. 'In Progress', 'Done', 'Todo')")
+	cmd.Flags().StringVar(&assignee, "assignee", "", "Filter by assignee name (use 'me' for yourself)")
+	cmd.Flags().StringVar(&team, "team", "", "Filter by team key (e.g. 'ENG', 'DES')")
+	cmd.Flags().StringVar(&project, "project", "", "Filter by project name")
+	cmd.Flags().StringVar(&label, "label", "", "Filter by label name")
+	cmd.Flags().StringVar(&priority, "priority", "", "Filter by priority (urgent, high, medium, low, none)")
+	cmd.Flags().IntVar(&limit, "limit", 50, "Max results to return")
+
+	return cmd
+}
+
+func newIssuesMineCmd(flags *rootFlags) *cobra.Command {
+	var (
+		state string
+		limit int
+		all   bool
+	)
+
+	cmd := &cobra.Command{
+		Use:   "mine",
+		Short: "List issues assigned to me",
+		Example: `  linear-cli issues mine
+  linear-cli issues mine --state "In Progress"
+  linear-cli issues mine --all`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterClause := ""
+			if !all {
+				// By default, exclude completed and canceled
+				filterClause = `, filter: { state: { type: { nin: ["completed", "canceled"] } } }`
+			}
+			if state != "" {
+				filterClause = fmt.Sprintf(`, filter: { state: { name: { eqIgnoreCase: %q } } }`, state)
+			}
+
+			query := fmt.Sprintf(`{ viewer { assignedIssues(first: %d, orderBy: updatedAt%s) { nodes { %s } } } }`, limit, filterClause, issueFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.viewer.assignedIssues.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&state, "state", "", "Filter by state name")
+	cmd.Flags().IntVar(&limit, "limit", 50, "Max results")
+	cmd.Flags().BoolVar(&all, "all", false, "Include completed and canceled issues")
+
+	return cmd
+}
+
+func newIssuesGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get <identifier>",
+		Short: "Get a single issue by identifier (e.g. ENG-123)",
+		Example: `  linear-cli issues get ENG-123
+  linear-cli issues get ENG-123 --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			identifier := args[0]
+
+			// Linear's issue() query accepts the identifier directly
+			query := fmt.Sprintf(`{ issueVcsBranchSearch(branchName: %q) { %s } }`, identifier, issueDetailFields)
+
+			// Try by identifier first using search
+			searchQuery := fmt.Sprintf(`{ searchIssues(term: %q, first: 1) { nodes { %s } } }`, identifier, issueDetailFields)
+
+			// If it looks like a UUID, use issue(id:) directly
+			if len(identifier) == 36 && strings.Count(identifier, "-") == 4 {
+				query = fmt.Sprintf(`{ issue(id: %q) { %s } }`, identifier, issueDetailFields)
+				data, err := c.Post("/graphql", gql(query, nil))
+				if err != nil {
+					return classifyAPIError(err)
+				}
+				if err := extractErrors(data); err != nil {
+					return err
+				}
+				issue, _ := extractData(data, "data.issue")
+				return printOutput(cmd.OutOrStdout(), issue, flags.asJSON)
+			}
+
+			// For identifiers like ENG-123, search for it
+			data, err := c.Post("/graphql", gql(searchQuery, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			_ = query // suppress unused
+			nodes, _ := extractData(data, "data.searchIssues.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newIssuesCreateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		title       string
+		team        string
+		description string
+		assignee    string
+		state       string
+		priority    string
+		project     string
+		label       string
+		estimate    int
+		dueDate     string
+		parentID    string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "create",
+		Short: "Create a new issue",
+		Example: `  linear-cli issues create --title "Fix login bug" --team ENG
+  linear-cli issues create --title "Add dark mode" --team DES --priority high --label feature
+  linear-cli issues create --title "Sub-task" --team ENG --parent ENG-123`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if title == "" {
+				return usageErr(fmt.Errorf("--title is required"))
+			}
+			if team == "" {
+				return usageErr(fmt.Errorf("--team is required (team key like 'ENG')"))
+			}
+
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			// First resolve team key to ID
+			teamQuery := fmt.Sprintf(`{ teams(filter: { key: { eqIgnoreCase: %q } }) { nodes { id } } }`, team)
+			teamData, err := c.Post("/graphql", gql(teamQuery, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(teamData); err != nil {
+				return err
+			}
+
+			teamNodes, _ := extractData(teamData, "data.teams.nodes")
+			var teams []struct{ ID string `json:"id"` }
+			if err := jsonUnmarshal(teamNodes, &teams); err != nil || len(teams) == 0 {
+				return usageErr(fmt.Errorf("team %q not found", team))
+			}
+
+			// Build input
+			var inputParts []string
+			inputParts = append(inputParts, fmt.Sprintf(`title: %q`, title))
+			inputParts = append(inputParts, fmt.Sprintf(`teamId: %q`, teams[0].ID))
+
+			if description != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`description: %q`, description))
+			}
+			if priority != "" {
+				p := priorityNumber(priority)
+				if p >= 0 {
+					inputParts = append(inputParts, fmt.Sprintf(`priority: %d`, p))
+				}
+			}
+			if estimate > 0 {
+				inputParts = append(inputParts, fmt.Sprintf(`estimate: %d`, estimate))
+			}
+			if dueDate != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`dueDate: %q`, dueDate))
+			}
+			if parentID != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`parentId: %q`, parentID))
+			}
+
+			query := fmt.Sprintf(`mutation { issueCreate(input: { %s }) { success issue { id identifier title url state { name } assignee { name } } } }`, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			issue, _ := extractData(data, "data.issueCreate.issue")
+			return printOutput(cmd.OutOrStdout(), issue, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&title, "title", "", "Issue title (required)")
+	cmd.Flags().StringVar(&team, "team", "", "Team key (required, e.g. 'ENG')")
+	cmd.Flags().StringVar(&description, "description", "", "Issue description (markdown)")
+	cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee name")
+	cmd.Flags().StringVar(&state, "state", "", "Initial state name")
+	cmd.Flags().StringVar(&priority, "priority", "", "Priority (urgent, high, medium, low, none)")
+	cmd.Flags().StringVar(&project, "project", "", "Project name")
+	cmd.Flags().StringVar(&label, "label", "", "Label name")
+	cmd.Flags().IntVar(&estimate, "estimate", 0, "Story points estimate")
+	cmd.Flags().StringVar(&dueDate, "due-date", "", "Due date (YYYY-MM-DD)")
+	cmd.Flags().StringVar(&parentID, "parent", "", "Parent issue ID for sub-issues")
+	_ = assignee
+	_ = state
+	_ = project
+	_ = label
+
+	return cmd
+}
+
+func newIssuesUpdateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		title       string
+		state       string
+		assignee    string
+		priority    string
+		description string
+		estimate    int
+		dueDate     string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "update <identifier>",
+		Short: "Update an existing issue",
+		Example: `  linear-cli issues update ENG-123 --state "In Progress"
+  linear-cli issues update ENG-123 --assignee "Jane Doe" --priority high
+  linear-cli issues update ENG-123 --title "New title" --estimate 3`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			identifier := args[0]
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			// Resolve identifier to ID
+			issueID, err := resolveIssueID(c, identifier)
+			if err != nil {
+				return err
+			}
+
+			var inputParts []string
+			if title != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`title: %q`, title))
+			}
+			if description != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`description: %q`, description))
+			}
+			if priority != "" {
+				p := priorityNumber(priority)
+				if p >= 0 {
+					inputParts = append(inputParts, fmt.Sprintf(`priority: %d`, p))
+				}
+			}
+			if estimate > 0 {
+				inputParts = append(inputParts, fmt.Sprintf(`estimate: %d`, estimate))
+			}
+			if dueDate != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`dueDate: %q`, dueDate))
+			}
+			if state != "" {
+				// Resolve state name to ID - need to find via the issue's team
+				stateID, err := resolveStateID(c, issueID, state)
+				if err == nil && stateID != "" {
+					inputParts = append(inputParts, fmt.Sprintf(`stateId: %q`, stateID))
+				}
+			}
+			if assignee != "" {
+				assigneeID, err := resolveAssigneeID(c, assignee)
+				if err == nil && assigneeID != "" {
+					inputParts = append(inputParts, fmt.Sprintf(`assigneeId: %q`, assigneeID))
+				}
+			}
+
+			if len(inputParts) == 0 {
+				return usageErr(fmt.Errorf("no update flags provided"))
+			}
+
+			query := fmt.Sprintf(`mutation { issueUpdate(id: %q, input: { %s }) { success issue { id identifier title state { name } assignee { name } priority priorityLabel } } }`, issueID, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			issue, _ := extractData(data, "data.issueUpdate.issue")
+			return printOutput(cmd.OutOrStdout(), issue, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&title, "title", "", "New title")
+	cmd.Flags().StringVar(&state, "state", "", "Move to state (e.g. 'In Progress', 'Done')")
+	cmd.Flags().StringVar(&assignee, "assignee", "", "Assign to user (name or 'me')")
+	cmd.Flags().StringVar(&priority, "priority", "", "Set priority (urgent, high, medium, low, none)")
+	cmd.Flags().StringVar(&description, "description", "", "New description (markdown)")
+	cmd.Flags().IntVar(&estimate, "estimate", 0, "Story points estimate")
+	cmd.Flags().StringVar(&dueDate, "due-date", "", "Due date (YYYY-MM-DD)")
+
+	return cmd
+}
+
+func newIssuesSearchCmd(flags *rootFlags) *cobra.Command {
+	var limit int
+
+	cmd := &cobra.Command{
+		Use:   "search <term>",
+		Short: "Search issues by text",
+		Example: `  linear-cli issues search "login bug"
+  linear-cli issues search "performance" --limit 10`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term := args[0]
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ searchIssues(term: %q, first: %d) { nodes { %s } } }`, term, limit, issueFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.searchIssues.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().IntVar(&limit, "limit", 25, "Max results")
+	return cmd
+}
+
+func newIssuesArchiveCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "archive <identifier>",
+		Short: "Archive an issue",
+		Example: `  linear-cli issues archive ENG-123`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			issueID, err := resolveIssueID(c, args[0])
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`mutation { issueArchive(id: %q) { success } }`, issueID)
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			fmt.Fprintf(cmd.OutOrStdout(), "Archived %s\n", args[0])
+			return nil
+		},
+	}
+	return cmd
+}
+
+func newIssuesDeleteCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "delete <identifier>",
+		Short: "Delete an issue permanently",
+		Example: `  linear-cli issues delete ENG-123`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			issueID, err := resolveIssueID(c, args[0])
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`mutation { issueDelete(id: %q) { success } }`, issueID)
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			fmt.Fprintf(cmd.OutOrStdout(), "Deleted %s\n", args[0])
+			return nil
+		},
+	}
+	return cmd
+}
+
+// resolveIssueID takes an identifier like "ENG-123" and returns the UUID.
+func resolveIssueID(c interface {
+	Post(path string, body any) (jsonRaw, error)
+}, identifier string) (string, error) {
+	// If already a UUID, return directly
+	if len(identifier) == 36 && strings.Count(identifier, "-") == 4 {
+		return identifier, nil
+	}
+
+	query := fmt.Sprintf(`{ searchIssues(term: %q, first: 1) { nodes { id identifier } } }`, identifier)
+	data, err := c.Post("/graphql", gql(query, nil))
+	if err != nil {
+		return "", classifyAPIError(err)
+	}
+	if err := extractErrors(data); err != nil {
+		return "", err
+	}
+
+	nodes, _ := extractData(data, "data.searchIssues.nodes")
+	var issues []struct {
+		ID         string `json:"id"`
+		Identifier string `json:"identifier"`
+	}
+	if err := jsonUnmarshal(nodes, &issues); err != nil || len(issues) == 0 {
+		return "", notFoundErr(fmt.Errorf("issue %q not found", identifier))
+	}
+	return issues[0].ID, nil
+}
+
+func resolveStateID(c interface {
+	Post(path string, body any) (jsonRaw, error)
+}, issueID, stateName string) (string, error) {
+	// Get the issue's team, then find the state by name
+	query := fmt.Sprintf(`{ issue(id: %q) { team { states { nodes { id name } } } } }`, issueID)
+	data, err := c.Post("/graphql", gql(query, nil))
+	if err != nil {
+		return "", err
+	}
+	states, _ := extractData(data, "data.issue.team.states.nodes")
+	var stateList []struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+	}
+	if err := jsonUnmarshal(states, &stateList); err != nil {
+		return "", err
+	}
+	for _, s := range stateList {
+		if strings.EqualFold(s.Name, stateName) {
+			return s.ID, nil
+		}
+	}
+	return "", fmt.Errorf("state %q not found", stateName)
+}
+
+func resolveAssigneeID(c interface {
+	Post(path string, body any) (jsonRaw, error)
+}, name string) (string, error) {
+	if name == "me" {
+		query := `{ viewer { id } }`
+		data, err := c.Post("/graphql", gql(query, nil))
+		if err != nil {
+			return "", err
+		}
+		id, _ := extractData(data, "data.viewer.id")
+		var s string
+		if err := jsonUnmarshal(id, &s); err != nil {
+			return "", err
+		}
+		return s, nil
+	}
+
+	query := fmt.Sprintf(`{ users(filter: { name: { containsIgnoreCase: %q } }) { nodes { id name } } }`, name)
+	data, err := c.Post("/graphql", gql(query, nil))
+	if err != nil {
+		return "", err
+	}
+	nodes, _ := extractData(data, "data.users.nodes")
+	var users []struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+	}
+	if err := jsonUnmarshal(nodes, &users); err != nil || len(users) == 0 {
+		return "", fmt.Errorf("user %q not found", name)
+	}
+	return users[0].ID, nil
+}
diff --git a/linear-cli/internal/cli/labels.go b/linear-cli/internal/cli/labels.go
new file mode 100644
index 00000000..5c8dfd06
--- /dev/null
+++ b/linear-cli/internal/cli/labels.go
@@ -0,0 +1,134 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newLabelsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "labels",
+		Short: "Manage issue labels",
+	}
+
+	cmd.AddCommand(newLabelsListCmd(flags))
+	cmd.AddCommand(newLabelsCreateCmd(flags))
+	return cmd
+}
+
+const labelFields = `id name color description team { name key } parent { name } children { nodes { name } }`
+
+func newLabelsListCmd(flags *rootFlags) *cobra.Command {
+	var team string
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List all labels",
+		Example: `  linear-cli labels list
+  linear-cli labels list --team ENG
+  linear-cli labels list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterClause := ""
+			if team != "" {
+				filterClause = fmt.Sprintf(`, filter: { team: { key: { eqIgnoreCase: %q } } }`, team)
+			}
+
+			query := fmt.Sprintf(`{ issueLabels(first: 100%s) { nodes { %s } } }`, filterClause, labelFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.issueLabels.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&team, "team", "", "Filter by team key (e.g. 'ENG')")
+
+	return cmd
+}
+
+func newLabelsCreateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		name  string
+		color string
+		team  string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "create",
+		Short: "Create a new label",
+		Example: `  linear-cli labels create --name bug --color "#eb5757" --team ENG
+  linear-cli labels create --name feature --color "#4ea7fc" --team DES`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if name == "" {
+				return usageErr(fmt.Errorf("--name is required"))
+			}
+			if team == "" {
+				return usageErr(fmt.Errorf("--team is required (team key like 'ENG')"))
+			}
+
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			// Resolve team key to ID
+			teamQuery := fmt.Sprintf(`{ teams(filter: { key: { eqIgnoreCase: %q } }) { nodes { id } } }`, team)
+			teamData, err := c.Post("/graphql", gql(teamQuery, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(teamData); err != nil {
+				return err
+			}
+
+			teamNodes, _ := extractData(teamData, "data.teams.nodes")
+			var teams []struct{ ID string `json:"id"` }
+			if err := jsonUnmarshal(teamNodes, &teams); err != nil || len(teams) == 0 {
+				return usageErr(fmt.Errorf("team %q not found", team))
+			}
+
+			var inputParts []string
+			inputParts = append(inputParts, fmt.Sprintf(`name: %q`, name))
+			inputParts = append(inputParts, fmt.Sprintf(`teamId: %q`, teams[0].ID))
+			if color != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`color: %q`, color))
+			}
+
+			query := fmt.Sprintf(`mutation { issueLabelCreate(input: { %s }) { success issueLabel { id name color } } }`, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			label, _ := extractData(data, "data.issueLabelCreate.issueLabel")
+			return printOutput(cmd.OutOrStdout(), label, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&name, "name", "", "Label name (required)")
+	cmd.Flags().StringVar(&color, "color", "", "Label color hex (e.g. '#eb5757')")
+	cmd.Flags().StringVar(&team, "team", "", "Team key (required, e.g. 'ENG')")
+
+	return cmd
+}
diff --git a/linear-cli/internal/cli/notifications.go b/linear-cli/internal/cli/notifications.go
new file mode 100644
index 00000000..92f49426
--- /dev/null
+++ b/linear-cli/internal/cli/notifications.go
@@ -0,0 +1,50 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newNotificationsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "notifications",
+		Short: "Manage notifications",
+	}
+
+	cmd.AddCommand(newNotificationsListCmd(flags))
+	return cmd
+}
+
+func newNotificationsListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List recent notifications",
+		Example: `  linear-cli notifications list
+  linear-cli notifications list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := `{ notifications(first: 50, orderBy: createdAt) { nodes { id type readAt createdAt ... on IssueNotification { issue { identifier title state { name } } comment { body } actor { name } } } } }`
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.notifications.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/organization.go b/linear-cli/internal/cli/organization.go
new file mode 100644
index 00000000..0fa22d06
--- /dev/null
+++ b/linear-cli/internal/cli/organization.go
@@ -0,0 +1,50 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newOrganizationCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "organization",
+		Short: "View workspace/organization info",
+	}
+
+	cmd.AddCommand(newOrganizationGetCmd(flags))
+	return cmd
+}
+
+func newOrganizationGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get",
+		Short: "Get organization details",
+		Example: `  linear-cli organization get
+  linear-cli organization get --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := `{ organization { id name urlKey logoUrl createdAt subscription { type seats } teams { nodes { name key } } users { nodes { name email active } } } }`
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			org, _ := extractData(data, "data.organization")
+			return printOutput(cmd.OutOrStdout(), org, flags.asJSON)
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/projects.go b/linear-cli/internal/cli/projects.go
new file mode 100644
index 00000000..daaaf6d3
--- /dev/null
+++ b/linear-cli/internal/cli/projects.go
@@ -0,0 +1,224 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newProjectsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "projects",
+		Short: "Manage projects and milestones",
+	}
+
+	cmd.AddCommand(newProjectsListCmd(flags))
+	cmd.AddCommand(newProjectsGetCmd(flags))
+	cmd.AddCommand(newProjectsCreateCmd(flags))
+	cmd.AddCommand(newProjectsUpdateCmd(flags))
+	return cmd
+}
+
+const projectFields = `id name description state startDate targetDate progress lead { name } members { nodes { name } } teams { nodes { name key } }`
+const projectDetailFields = `id name description url state startDate targetDate progress lead { name email } members { nodes { name } } teams { nodes { name key } } issues(first: 50) { nodes { identifier title state { name } assignee { name } priority } } documents { nodes { id title } }`
+
+func newProjectsListCmd(flags *rootFlags) *cobra.Command {
+	var state string
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List all projects",
+		Example: `  linear-cli projects list
+  linear-cli projects list --state planned
+  linear-cli projects list --state started --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterClause := ""
+			if state != "" {
+				filterClause = fmt.Sprintf(`, filter: { state: { eq: %q } }`, state)
+			}
+
+			query := fmt.Sprintf(`{ projects(first: 50, orderBy: updatedAt%s) { nodes { %s } pageInfo { hasNextPage endCursor } } }`, filterClause, projectFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.projects.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&state, "state", "", "Filter by project state (planned, started, paused, completed, canceled)")
+
+	return cmd
+}
+
+func newProjectsGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get <id>",
+		Short: "Get project details by ID",
+		Example: `  linear-cli projects get abc123-def456
+  linear-cli projects get abc123-def456 --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ project(id: %q) { %s } }`, args[0], projectDetailFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			project, _ := extractData(data, "data.project")
+			return printOutput(cmd.OutOrStdout(), project, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		name        string
+		team        string
+		description string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "create",
+		Short: "Create a new project",
+		Example: `  linear-cli projects create --name "Q1 Launch" --team ENG
+  linear-cli projects create --name "Redesign" --team DES --description "Full UI overhaul"`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if name == "" {
+				return usageErr(fmt.Errorf("--name is required"))
+			}
+			if team == "" {
+				return usageErr(fmt.Errorf("--team is required (team key like 'ENG')"))
+			}
+
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			// Resolve team key to ID
+			teamQuery := fmt.Sprintf(`{ teams(filter: { key: { eqIgnoreCase: %q } }) { nodes { id } } }`, team)
+			teamData, err := c.Post("/graphql", gql(teamQuery, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(teamData); err != nil {
+				return err
+			}
+
+			teamNodes, _ := extractData(teamData, "data.teams.nodes")
+			var teams []struct{ ID string `json:"id"` }
+			if err := jsonUnmarshal(teamNodes, &teams); err != nil || len(teams) == 0 {
+				return usageErr(fmt.Errorf("team %q not found", team))
+			}
+
+			var inputParts []string
+			inputParts = append(inputParts, fmt.Sprintf(`name: %q`, name))
+			inputParts = append(inputParts, fmt.Sprintf(`teamIds: [%q]`, teams[0].ID))
+			if description != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`description: %q`, description))
+			}
+
+			query := fmt.Sprintf(`mutation { projectCreate(input: { %s }) { success project { id name url state } } }`, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			project, _ := extractData(data, "data.projectCreate.project")
+			return printOutput(cmd.OutOrStdout(), project, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&name, "name", "", "Project name (required)")
+	cmd.Flags().StringVar(&team, "team", "", "Team key (required, e.g. 'ENG')")
+	cmd.Flags().StringVar(&description, "description", "", "Project description")
+
+	return cmd
+}
+
+func newProjectsUpdateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		name        string
+		state       string
+		description string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "update <id>",
+		Short: "Update project details",
+		Example: `  linear-cli projects update abc123 --name "New Name"
+  linear-cli projects update abc123 --state completed
+  linear-cli projects update abc123 --description "Updated scope"`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			var inputParts []string
+			if name != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`name: %q`, name))
+			}
+			if state != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`state: %q`, state))
+			}
+			if description != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`description: %q`, description))
+			}
+
+			if len(inputParts) == 0 {
+				return usageErr(fmt.Errorf("no update flags provided"))
+			}
+
+			query := fmt.Sprintf(`mutation { projectUpdate(id: %q, input: { %s }) { success project { id name state progress } } }`, args[0], strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			project, _ := extractData(data, "data.projectUpdate.project")
+			return printOutput(cmd.OutOrStdout(), project, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&name, "name", "", "New project name")
+	cmd.Flags().StringVar(&state, "state", "", "Project state (planned, started, paused, completed, canceled)")
+	cmd.Flags().StringVar(&description, "description", "", "New project description")
+
+	return cmd
+}
diff --git a/linear-cli/internal/cli/root.go b/linear-cli/internal/cli/root.go
new file mode 100644
index 00000000..e2fce50e
--- /dev/null
+++ b/linear-cli/internal/cli/root.go
@@ -0,0 +1,120 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"text/tabwriter"
+	"time"
+
+	"github.com/USER/linear-cli/internal/client"
+	"github.com/USER/linear-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+var version = "1.0.0"
+
+type rootFlags struct {
+	asJSON     bool
+	plain      bool
+	quiet      bool
+	dryRun     bool
+	configPath string
+	timeout    time.Duration
+}
+
+func Execute() error {
+	var flags rootFlags
+
+	rootCmd := &cobra.Command{
+		Use:           "linear-cli",
+		Short:         "Linear project management CLI - issues, projects, cycles, teams, and more",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		Version:       version,
+	}
+	rootCmd.SetVersionTemplate("linear-cli {{ .Version }}\n")
+
+	rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
+	rootCmd.PersistentFlags().BoolVar(&flags.plain, "plain", false, "Output as plain tab-separated text")
+	rootCmd.PersistentFlags().BoolVar(&flags.quiet, "quiet", false, "Bare output, one value per line")
+	rootCmd.PersistentFlags().StringVar(&flags.configPath, "config", "", "Config file path")
+	rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 30*time.Second, "Request timeout")
+	rootCmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Show request without sending")
+	rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
+	rootCmd.AddCommand(newCommentsCmd(&flags)) 
+	rootCmd.AddCommand(newCyclesCmd(&flags)) 
+	rootCmd.AddCommand(newDocumentsCmd(&flags)) 
+	rootCmd.AddCommand(newIssuesCmd(&flags)) 
+	rootCmd.AddCommand(newLabelsCmd(&flags)) 
+	rootCmd.AddCommand(newNotificationsCmd(&flags)) 
+	rootCmd.AddCommand(newOrganizationCmd(&flags)) 
+	rootCmd.AddCommand(newProjectsCmd(&flags)) 
+	rootCmd.AddCommand(newTeamsCmd(&flags)) 
+	rootCmd.AddCommand(newUsersCmd(&flags)) 
+	rootCmd.AddCommand(newWebhooksCmd(&flags)) 
+	rootCmd.AddCommand(newWorkflowsCmd(&flags)) 
+	rootCmd.AddCommand(newDoctorCmd(&flags))
+	rootCmd.AddCommand(newVersionCliCmd())
+
+	return rootCmd.Execute()
+}
+
+func ExitCode(err error) int {
+	var codeErr *cliError
+	if As(err, &codeErr) {
+		return codeErr.code
+	}
+	return 1
+}
+
+func (f *rootFlags) newClient() (*client.Client, error) {
+	cfg, err := config.Load(f.configPath)
+	if err != nil {
+		return nil, configErr(err)
+	}
+	c := client.New(cfg, f.timeout)
+	c.DryRun = f.dryRun
+	return c, nil
+}
+
+func (f *rootFlags) printJSON(w *cobra.Command, v any) error {
+	enc := json.NewEncoder(w.OutOrStdout())
+	enc.SetIndent("", "  ")
+	return enc.Encode(v)
+}
+
+func (f *rootFlags) printTable(w *cobra.Command, headers []string, rows [][]string) error {
+	if f.asJSON {
+		return fmt.Errorf("use printJSON for JSON output")
+	}
+	tw := tabwriter.NewWriter(w.OutOrStdout(), 2, 4, 2, ' ', 0)
+	header := ""
+	for i, h := range headers {
+		if i > 0 {
+			header += "\t"
+		}
+		header += h
+	}
+	fmt.Fprintln(tw, header)
+	for _, row := range rows {
+		line := ""
+		for i, cell := range row {
+			if i > 0 {
+				line += "\t"
+			}
+			line += cell
+		}
+		fmt.Fprintln(tw, line)
+	}
+	return tw.Flush()
+}
+
+func newVersionCliCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "version",
+		Short: "Print version",
+		Run: func(cmd *cobra.Command, args []string) {
+			fmt.Printf("linear-cli %s\n", version)
+		},
+	}
+}
diff --git a/linear-cli/internal/cli/teams.go b/linear-cli/internal/cli/teams.go
new file mode 100644
index 00000000..fe6a08c9
--- /dev/null
+++ b/linear-cli/internal/cli/teams.go
@@ -0,0 +1,93 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newTeamsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "teams",
+		Short: "Manage teams and their settings",
+	}
+
+	cmd.AddCommand(newTeamsListCmd(flags))
+	cmd.AddCommand(newTeamsGetCmd(flags))
+	return cmd
+}
+
+const teamFields = `id name key description members { nodes { name email } } states { nodes { name type position color } } labels { nodes { name color } } activeCycle { number name startsAt endsAt }`
+const teamDetailFields = `id name key description timezone members { nodes { id name email displayName active } } states { nodes { id name type position color } } labels { nodes { id name color } } activeCycle { id number name startsAt endsAt progress }`
+
+func newTeamsListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List all teams",
+		Example: `  linear-cli teams list
+  linear-cli teams list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ teams(first: 50) { nodes { %s } } }`, teamFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.teams.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newTeamsGetCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "get <key>",
+		Short: "Get team details by key (e.g. ENG)",
+		Example: `  linear-cli teams get ENG
+  linear-cli teams get DES --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			key := args[0]
+
+			// Resolve team key to get the team
+			query := fmt.Sprintf(`{ teams(filter: { key: { eqIgnoreCase: %q } }) { nodes { %s } } }`, key, teamDetailFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.teams.nodes")
+			var teams []struct{ ID string `json:"id"` }
+			if err := jsonUnmarshal(nodes, &teams); err != nil || len(teams) == 0 {
+				return notFoundErr(fmt.Errorf("team with key %q not found", key))
+			}
+
+			// Return first match
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/users.go b/linear-cli/internal/cli/users.go
new file mode 100644
index 00000000..af6d8257
--- /dev/null
+++ b/linear-cli/internal/cli/users.go
@@ -0,0 +1,80 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newUsersCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "users",
+		Short: "Manage workspace users",
+	}
+
+	cmd.AddCommand(newUsersMeCmd(flags))
+	cmd.AddCommand(newUsersListCmd(flags))
+	return cmd
+}
+
+func newUsersMeCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "me",
+		Short: "Get the authenticated user",
+		Example: `  linear-cli users me
+  linear-cli users me --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := `{ viewer { id name email displayName active admin url organization { id name urlKey } teamMemberships { nodes { team { id name key } } } assignedIssues(first: 10, orderBy: updatedAt, filter: { state: { type: { nin: ["completed", "canceled"] } } }) { nodes { identifier title state { name } priority } } } }`
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			viewer, _ := extractData(data, "data.viewer")
+			return printOutput(cmd.OutOrStdout(), viewer, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newUsersListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List workspace users",
+		Example: `  linear-cli users list
+  linear-cli users list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := `{ users(first: 100) { nodes { id name email displayName active admin createdAt lastSeen } } }`
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.users.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/webhooks.go b/linear-cli/internal/cli/webhooks.go
new file mode 100644
index 00000000..c55b0b33
--- /dev/null
+++ b/linear-cli/internal/cli/webhooks.go
@@ -0,0 +1,141 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newWebhooksCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "webhooks",
+		Short: "Manage webhooks",
+	}
+
+	cmd.AddCommand(newWebhooksListCmd(flags))
+	cmd.AddCommand(newWebhooksCreateCmd(flags))
+	cmd.AddCommand(newWebhooksDeleteCmd(flags))
+	return cmd
+}
+
+const webhookFields = `id url label enabled allPublicTeams team { name } resourceTypes createdAt`
+
+func newWebhooksListCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List webhooks",
+		Example: `  linear-cli webhooks list
+  linear-cli webhooks list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`{ webhooks(first: 50) { nodes { %s } } }`, webhookFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.webhooks.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+	return cmd
+}
+
+func newWebhooksCreateCmd(flags *rootFlags) *cobra.Command {
+	var (
+		url       string
+		label     string
+		resources []string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "create",
+		Short: "Create a webhook",
+		Example: `  linear-cli webhooks create --url https://example.com/hook --label "My Hook"
+  linear-cli webhooks create --url https://example.com/hook --resources Issue --resources Comment`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if url == "" {
+				return usageErr(fmt.Errorf("--url is required"))
+			}
+
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			var inputParts []string
+			inputParts = append(inputParts, fmt.Sprintf(`url: %q`, url))
+			if label != "" {
+				inputParts = append(inputParts, fmt.Sprintf(`label: %q`, label))
+			}
+			if len(resources) > 0 {
+				quoted := make([]string, len(resources))
+				for i, r := range resources {
+					quoted[i] = fmt.Sprintf("%q", r)
+				}
+				inputParts = append(inputParts, fmt.Sprintf(`resourceTypes: [%s]`, strings.Join(quoted, ", ")))
+			}
+
+			query := fmt.Sprintf(`mutation { webhookCreate(input: { %s }) { success webhook { id url label enabled } } }`, strings.Join(inputParts, ", "))
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			webhook, _ := extractData(data, "data.webhookCreate.webhook")
+			return printOutput(cmd.OutOrStdout(), webhook, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&url, "url", "", "Webhook URL (required)")
+	cmd.Flags().StringVar(&label, "label", "", "Webhook label")
+	cmd.Flags().StringSliceVar(&resources, "resources", nil, "Resource types to subscribe to (e.g. Issue, Comment, Project)")
+
+	return cmd
+}
+
+func newWebhooksDeleteCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "delete <id>",
+		Short: "Delete a webhook",
+		Example: `  linear-cli webhooks delete abc123-def456`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			query := fmt.Sprintf(`mutation { webhookDelete(id: %q) { success } }`, args[0])
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+			_ = data
+
+			fmt.Fprintf(cmd.OutOrStdout(), "Deleted webhook %s\n", args[0])
+			return nil
+		},
+	}
+	return cmd
+}
diff --git a/linear-cli/internal/cli/workflows.go b/linear-cli/internal/cli/workflows.go
new file mode 100644
index 00000000..54d9396a
--- /dev/null
+++ b/linear-cli/internal/cli/workflows.go
@@ -0,0 +1,63 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var _ = strings.ReplaceAll // ensure import
+var _ = fmt.Sprintf        // ensure import
+
+func newWorkflowsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "workflows",
+		Short: "Manage workflow states",
+	}
+
+	cmd.AddCommand(newWorkflowsListCmd(flags))
+	return cmd
+}
+
+const workflowFields = `id name type position color team { name key }`
+
+func newWorkflowsListCmd(flags *rootFlags) *cobra.Command {
+	var team string
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List workflow states across teams",
+		Example: `  linear-cli workflows list
+  linear-cli workflows list --team ENG
+  linear-cli workflows list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			filterClause := ""
+			if team != "" {
+				filterClause = fmt.Sprintf(`, filter: { team: { key: { eqIgnoreCase: %q } } }`, team)
+			}
+
+			query := fmt.Sprintf(`{ workflowStates(first: 100%s) { nodes { %s } } }`, filterClause, workflowFields)
+
+			data, err := c.Post("/graphql", gql(query, nil))
+			if err != nil {
+				return classifyAPIError(err)
+			}
+			if err := extractErrors(data); err != nil {
+				return err
+			}
+
+			nodes, _ := extractData(data, "data.workflowStates.nodes")
+			return printOutput(cmd.OutOrStdout(), nodes, flags.asJSON)
+		},
+	}
+
+	cmd.Flags().StringVar(&team, "team", "", "Filter by team key (e.g. 'ENG')")
+
+	return cmd
+}
diff --git a/linear-cli/internal/client/client.go b/linear-cli/internal/client/client.go
new file mode 100644
index 00000000..b14cfcd3
--- /dev/null
+++ b/linear-cli/internal/client/client.go
@@ -0,0 +1,298 @@
+package client
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/USER/linear-cli/internal/config"
+)
+
+type Client struct {
+	BaseURL    string
+	Config     *config.Config
+	HTTPClient *http.Client
+	DryRun     bool
+}
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+	Method     string
+	Path       string
+	StatusCode int
+	Body       string
+}
+
+func (e *APIError) Error() string {
+	return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
+}
+
+func New(cfg *config.Config, timeout time.Duration) *Client {
+	return &Client{
+		BaseURL:    strings.TrimRight(cfg.BaseURL, "/"),
+		Config:     cfg,
+		HTTPClient: &http.Client{Timeout: timeout},
+	}
+}
+
+func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+	return c.do("GET", path, params, nil)
+}
+
+func (c *Client) Post(path string, body any) (json.RawMessage, error) {
+	return c.do("POST", path, nil, body)
+}
+
+func (c *Client) Delete(path string) (json.RawMessage, error) {
+	return c.do("DELETE", path, nil, nil)
+}
+
+func (c *Client) Put(path string, body any) (json.RawMessage, error) {
+	return c.do("PUT", path, nil, body)
+}
+
+func (c *Client) Patch(path string, body any) (json.RawMessage, error) {
+	return c.do("PATCH", path, nil, body)
+}
+
+func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, error) {
+	url := c.BaseURL + path
+
+	var bodyBytes []byte
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return nil, fmt.Errorf("marshaling body: %w", err)
+		}
+		bodyBytes = b
+	}
+
+	// Build the request for dry-run display or actual execution
+	if c.DryRun {
+		return c.dryRun(method, url, params, bodyBytes)
+	}
+
+	const maxRetries = 3
+	var lastErr error
+
+	for attempt := 0; attempt <= maxRetries; attempt++ {
+		var bodyReader io.Reader
+		if bodyBytes != nil {
+			bodyReader = strings.NewReader(string(bodyBytes))
+		}
+
+		req, err := http.NewRequest(method, url, bodyReader)
+		if err != nil {
+			return nil, fmt.Errorf("creating request: %w", err)
+		}
+
+		authHeader, err := c.authHeader()
+		if err != nil {
+			return nil, err
+		}
+		if authHeader != "" {
+			req.Header.Set("Authorization", authHeader)
+		}
+		if bodyBytes != nil {
+			req.Header.Set("Content-Type", "application/json")
+		}
+		req.Header.Set("User-Agent", "linear-cli/1.0.0")
+
+		if params != nil {
+			q := req.URL.Query()
+			for k, v := range params {
+				if v != "" {
+					q.Set(k, v)
+				}
+			}
+			req.URL.RawQuery = q.Encode()
+		}
+
+		resp, err := c.HTTPClient.Do(req)
+		if err != nil {
+			lastErr = fmt.Errorf("%s %s: %w", method, path, err)
+			continue
+		}
+
+		respBody, err := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if err != nil {
+			return nil, fmt.Errorf("reading response: %w", err)
+		}
+
+		// Success
+		if resp.StatusCode < 400 {
+			return json.RawMessage(respBody), nil
+		}
+
+		apiErr := &APIError{
+			Method:     method,
+			Path:       path,
+			StatusCode: resp.StatusCode,
+			Body:       truncateBody(respBody),
+		}
+
+		// Rate limited - wait and retry
+		if resp.StatusCode == 429 && attempt < maxRetries {
+			wait := retryAfter(resp)
+			fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d)\n", wait, attempt+1, maxRetries)
+			time.Sleep(wait)
+			lastErr = apiErr
+			continue
+		}
+
+		// Server error - retry with backoff
+		if resp.StatusCode >= 500 && attempt < maxRetries {
+			wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+			fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+			time.Sleep(wait)
+			lastErr = apiErr
+			continue
+		}
+
+		// Client error or retries exhausted - return the error
+		return nil, apiErr
+	}
+
+	return nil, lastErr
+}
+
+func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, error) {
+	fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
+	if params != nil {
+		for k, v := range params {
+			if v != "" {
+				fmt.Fprintf(os.Stderr, "  ?%s=%s\n", k, v)
+			}
+		}
+	}
+	authHeader, err := c.authHeader()
+	if err != nil {
+		return nil, err
+	}
+	if authHeader != "" {
+		// Mask token for safety
+		auth := authHeader
+		if len(auth) > 20 {
+			auth = auth[:15] + "..."
+		}
+		fmt.Fprintf(os.Stderr, "  Authorization: %s\n", auth)
+	}
+	if body != nil {
+		var pretty json.RawMessage
+		if json.Unmarshal(body, &pretty) == nil {
+			enc := json.NewEncoder(os.Stderr)
+			enc.SetIndent("  ", "  ")
+			fmt.Fprintf(os.Stderr, "  Body:\n")
+			enc.Encode(pretty)
+		}
+	}
+	fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+	return json.RawMessage(`{"dry_run": true}`), nil
+}
+
+func (c *Client) authHeader() (string, error) {
+	if c.Config == nil {
+		return "", nil
+	}
+	if c.Config.AccessToken != "" && !c.Config.TokenExpiry.IsZero() && time.Now().After(c.Config.TokenExpiry) && c.Config.RefreshToken != "" {
+		if err := c.refreshAccessToken(); err != nil {
+			return "", err
+		}
+	}
+	return c.Config.AuthHeader(), nil
+}
+
+func (c *Client) refreshAccessToken() error {
+	if c.Config == nil {
+		return nil
+	}
+	if c.Config.RefreshToken == "" {
+		return nil
+	}
+
+	tokenURL := ""
+	if tokenURL == "" {
+		return nil
+	}
+
+	params := url.Values{
+		"grant_type":    {"refresh_token"},
+		"refresh_token": {c.Config.RefreshToken},
+		"client_id":     {c.Config.ClientID},
+	}
+	if c.Config.ClientSecret != "" {
+		params.Set("client_secret", c.Config.ClientSecret)
+	}
+
+	resp, err := c.HTTPClient.PostForm(tokenURL, params)
+	if err != nil {
+		return fmt.Errorf("refreshing access token: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode >= 400 {
+		body, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("refreshing access token: HTTP %d: %s", resp.StatusCode, truncateBody(body))
+	}
+
+	var tokenResp struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+		return fmt.Errorf("parsing refresh response: %w", err)
+	}
+	if tokenResp.AccessToken == "" {
+		return fmt.Errorf("refreshing access token: no access token in response")
+	}
+
+	refreshToken := c.Config.RefreshToken
+	if tokenResp.RefreshToken != "" {
+		refreshToken = tokenResp.RefreshToken
+	}
+
+	expiry := time.Time{}
+	if tokenResp.ExpiresIn > 0 {
+		expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	}
+
+	if err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {
+		return fmt.Errorf("saving refreshed token: %w", err)
+	}
+
+	return nil
+}
+
+func retryAfter(resp *http.Response) time.Duration {
+	header := resp.Header.Get("Retry-After")
+	if header == "" {
+		return 5 * time.Second
+	}
+	if seconds, err := strconv.Atoi(header); err == nil {
+		return time.Duration(seconds) * time.Second
+	}
+	if t, err := http.ParseTime(header); err == nil {
+		wait := time.Until(t)
+		if wait > 0 {
+			return wait
+		}
+	}
+	return 5 * time.Second
+}
+
+func truncateBody(b []byte) string {
+	s := string(b)
+	if len(s) > 200 {
+		return s[:200] + "..."
+	}
+	return s
+}
diff --git a/linear-cli/internal/config/config.go b/linear-cli/internal/config/config.go
new file mode 100644
index 00000000..77a728dd
--- /dev/null
+++ b/linear-cli/internal/config/config.go
@@ -0,0 +1,102 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+	BaseURL        string `toml:"base_url"`
+	AuthHeaderVal  string `toml:"auth_header"`
+	AuthSource     string `toml:"-"`
+	AccessToken    string `toml:"access_token"`
+	RefreshToken   string `toml:"refresh_token"`
+	TokenExpiry    time.Time `toml:"token_expiry"`
+	ClientID       string `toml:"client_id"`
+	ClientSecret   string `toml:"client_secret"`
+	Path           string `toml:"-"`
+	LinearApiKey string `toml:"api_key"`
+}
+
+func Load(configPath string) (*Config, error) {
+	cfg := &Config{
+		BaseURL: "https://api.linear.app",
+	}
+
+	// Resolve config path
+	path := configPath
+	if path == "" {
+		path = os.Getenv("LINEAR_CONFIG")
+	}
+	if path == "" {
+		home, _ := os.UserHomeDir()
+		path = filepath.Join(home, ".config", "linear-cli", "config.toml")
+	}
+	cfg.Path = path
+
+	// Try to load config file
+	data, err := os.ReadFile(path)
+	if err == nil {
+		if err := toml.Unmarshal(data, cfg); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", path, err)
+		}
+	}
+
+	// Env var overrides
+	if v := os.Getenv("LINEAR_API_KEY"); v != "" {
+		cfg.LinearApiKey = v
+		cfg.AuthSource = "env:LINEAR_API_KEY"
+	}
+
+	return cfg, nil
+}
+
+func (c *Config) AuthHeader() string {
+	if c.AuthHeaderVal != "" {
+		return c.AuthHeaderVal
+	}
+	if c.AccessToken != "" {
+		c.AuthSource = "oauth2"
+		return "Bearer " + c.AccessToken
+	}
+	if c.LinearApiKey != "" {
+		return "Bearer " + c.LinearApiKey
+	}
+	return ""
+}
+
+func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {
+	c.ClientID = clientID
+	c.ClientSecret = clientSecret
+	c.AccessToken = accessToken
+	c.RefreshToken = refreshToken
+	c.TokenExpiry = expiry
+	return c.save()
+}
+
+func (c *Config) ClearTokens() error {
+	c.AccessToken = ""
+	c.RefreshToken = ""
+	c.TokenExpiry = time.Time{}
+	return c.save()
+}
+
+func (c *Config) save() error {
+	dir := filepath.Dir(c.Path)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("creating config dir: %w", err)
+	}
+	data, err := toml.Marshal(c)
+	if err != nil {
+		return fmt.Errorf("marshaling config: %w", err)
+	}
+	return os.WriteFile(c.Path, data, 0o600)
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/linear-cli/internal/types/types.go b/linear-cli/internal/types/types.go
new file mode 100644
index 00000000..8165fcb5
--- /dev/null
+++ b/linear-cli/internal/types/types.go
@@ -0,0 +1,153 @@
+package types
+
+type ArchivePayload struct {
+	Success bool `json:"success"`
+}
+
+type Comment struct {
+	Id string `json:"id"`
+	Body string `json:"body"`
+	User string `json:"user"`
+	CreatedAt string `json:"createdAt"`
+}
+
+type CommentConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type CommentPayload struct {
+	Success bool `json:"success"`
+	Comment string `json:"comment"`
+}
+
+type Cycle struct {
+	Id string `json:"id"`
+	Number int `json:"number"`
+	Name string `json:"name"`
+	StartsAt string `json:"startsAt"`
+	EndsAt string `json:"endsAt"`
+	Progress float64 `json:"progress"`
+}
+
+type CycleConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type DeletePayload struct {
+	Success bool `json:"success"`
+}
+
+type Document struct {
+	Id string `json:"id"`
+	Title string `json:"title"`
+	Content string `json:"content"`
+}
+
+type DocumentConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type DocumentPayload struct {
+	Success bool `json:"success"`
+}
+
+type Issue struct {
+	Id string `json:"id"`
+	Identifier string `json:"identifier"`
+	Title string `json:"title"`
+	Description string `json:"description"`
+	State string `json:"state"`
+	Assignee string `json:"assignee"`
+	Priority int `json:"priority"`
+	PriorityLabel string `json:"priorityLabel"`
+	Project string `json:"project"`
+	Cycle string `json:"cycle"`
+	Estimate int `json:"estimate"`
+	DueDate string `json:"dueDate"`
+	CreatedAt string `json:"createdAt"`
+	UpdatedAt string `json:"updatedAt"`
+	Url string `json:"url"`
+}
+
+type IssueConnection struct {
+	Nodes string `json:"nodes"`
+	PageInfo string `json:"pageInfo"`
+}
+
+type IssuePayload struct {
+	Success bool `json:"success"`
+	Issue string `json:"issue"`
+}
+
+type LabelConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type LabelPayload struct {
+	Success bool `json:"success"`
+}
+
+type NotificationConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type Organization struct {
+	Id string `json:"id"`
+	Name string `json:"name"`
+	UrlKey string `json:"urlKey"`
+}
+
+type Project struct {
+	Id string `json:"id"`
+	Name string `json:"name"`
+	Description string `json:"description"`
+	State string `json:"state"`
+	Progress float64 `json:"progress"`
+	StartDate string `json:"startDate"`
+	TargetDate string `json:"targetDate"`
+}
+
+type ProjectConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type ProjectPayload struct {
+	Success bool `json:"success"`
+	Project string `json:"project"`
+}
+
+type Team struct {
+	Id string `json:"id"`
+	Name string `json:"name"`
+	Key string `json:"key"`
+	Description string `json:"description"`
+}
+
+type TeamConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type User struct {
+	Id string `json:"id"`
+	Name string `json:"name"`
+	Email string `json:"email"`
+	DisplayName string `json:"displayName"`
+	Active bool `json:"active"`
+}
+
+type UserConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type WebhookConnection struct {
+	Nodes string `json:"nodes"`
+}
+
+type WebhookPayload struct {
+	Success bool `json:"success"`
+}
+
+type WorkflowStateConnection struct {
+	Nodes string `json:"nodes"`
+}
+
diff --git a/linear-spec.yaml b/linear-spec.yaml
new file mode 100644
index 00000000..9ce8af59
--- /dev/null
+++ b/linear-spec.yaml
@@ -0,0 +1,716 @@
+name: linear
+description: "Linear project management CLI - issues, projects, cycles, teams, and more"
+version: "1.0.0"
+base_url: "https://api.linear.app"
+
+auth:
+  type: bearer_token
+  header: "Authorization"
+  format: "Bearer {token}"
+  env_vars:
+    - LINEAR_API_KEY
+
+config:
+  format: toml
+  path: "~/.config/linear-cli/config.toml"
+
+resources:
+  issues:
+    description: "Manage issues - create, update, search, and track work"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List issues with filters"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ issues(first: 50, orderBy: updatedAt) { nodes { id identifier title state { name } assignee { name } priority priorityLabel project { name } cycle { number } labels { nodes { name } } estimate createdAt updatedAt } pageInfo { hasNextPage endCursor } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: IssueConnection
+
+      mine:
+        method: POST
+        path: "/graphql"
+        description: "List issues assigned to me"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ viewer { assignedIssues(first: 50, orderBy: updatedAt) { nodes { id identifier title state { name } priority priorityLabel project { name } cycle { number } labels { nodes { name } } estimate dueDate createdAt updatedAt } pageInfo { hasNextPage endCursor } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: IssueConnection
+
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get a single issue by identifier (e.g. ENG-123)"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ issue(id: \"ISSUE_ID\") { id identifier title description state { name } assignee { name email } priority priorityLabel project { name } cycle { name number } labels { nodes { name } } estimate dueDate createdAt updatedAt completedAt canceledAt parent { identifier title } children { nodes { identifier title state { name } } } relations { nodes { type relatedIssue { identifier title } } } comments { nodes { body user { name } createdAt } } attachments { nodes { title url } } } }"}'
+            description: "GraphQL query (replace ISSUE_ID)"
+        response:
+          type: object
+          item: Issue
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Create a new issue"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { issueCreate(input: { title: \"TITLE\", teamId: \"TEAM_ID\" }) { success issue { id identifier title url } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: IssuePayload
+
+      update:
+        method: POST
+        path: "/graphql"
+        description: "Update an existing issue"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { issueUpdate(id: \"ISSUE_ID\", input: {}) { success issue { id identifier title state { name } assignee { name } } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: IssuePayload
+
+      search:
+        method: POST
+        path: "/graphql"
+        description: "Search issues by text"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ searchIssues(term: \"SEARCH_TERM\", first: 25) { nodes { id identifier title state { name } assignee { name } priority priorityLabel project { name } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: IssueConnection
+
+      archive:
+        method: POST
+        path: "/graphql"
+        description: "Archive an issue"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { issueArchive(id: \"ISSUE_ID\") { success } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: ArchivePayload
+
+      delete:
+        method: POST
+        path: "/graphql"
+        description: "Delete an issue permanently"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { issueDelete(id: \"ISSUE_ID\") { success } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: DeletePayload
+
+  comments:
+    description: "Manage issue comments"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List comments on an issue"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ issue(id: \"ISSUE_ID\") { comments(first: 50) { nodes { id body user { name email } createdAt updatedAt editedAt } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: CommentConnection
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Add a comment to an issue"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { commentCreate(input: { issueId: \"ISSUE_ID\", body: \"COMMENT_BODY\" }) { success comment { id body user { name } createdAt } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: CommentPayload
+
+      update:
+        method: POST
+        path: "/graphql"
+        description: "Edit a comment"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { commentUpdate(id: \"COMMENT_ID\", input: { body: \"NEW_BODY\" }) { success comment { id body updatedAt } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: CommentPayload
+
+      delete:
+        method: POST
+        path: "/graphql"
+        description: "Delete a comment"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { commentDelete(id: \"COMMENT_ID\") { success } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: DeletePayload
+
+  projects:
+    description: "Manage projects and milestones"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List all projects"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ projects(first: 50, orderBy: updatedAt) { nodes { id name description state startDate targetDate progress lead { name } members { nodes { name } } teams { nodes { name } } issues { nodes { id } } } pageInfo { hasNextPage endCursor } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: ProjectConnection
+
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get project details"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ project(id: \"PROJECT_ID\") { id name description state startDate targetDate progress lead { name } members { nodes { name } } teams { nodes { name } } issues(first: 100) { nodes { identifier title state { name } assignee { name } priority } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: Project
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Create a new project"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { projectCreate(input: { name: \"PROJECT_NAME\", teamIds: [\"TEAM_ID\"] }) { success project { id name url } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: ProjectPayload
+
+      update:
+        method: POST
+        path: "/graphql"
+        description: "Update project details"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { projectUpdate(id: \"PROJECT_ID\", input: {}) { success project { id name state progress } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: ProjectPayload
+
+  cycles:
+    description: "Manage sprint cycles"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List cycles for a team"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ cycles(first: 20, orderBy: createdAt) { nodes { id number name startsAt endsAt completedAt progress completedScopeHistory scopeHistory team { name } issues { nodes { identifier title state { name } } } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: CycleConnection
+
+      current:
+        method: POST
+        path: "/graphql"
+        description: "Get the active cycle"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ cycles(first: 1, filter: { isActive: { eq: true } }) { nodes { id number name startsAt endsAt progress completedScopeHistory scopeHistory team { name } issues(first: 100) { nodes { identifier title state { name } assignee { name } priority estimate } } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: CycleConnection
+
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get cycle details with issues"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ cycle(id: \"CYCLE_ID\") { id number name startsAt endsAt completedAt progress team { name } issues(first: 100) { nodes { identifier title state { name } assignee { name } priority estimate completedAt } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: Cycle
+
+  teams:
+    description: "Manage teams and their settings"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List all teams"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ teams(first: 50) { nodes { id name key description members { nodes { name email } } states { nodes { name type position color } } labels { nodes { name color } } activeCycle { number name startsAt endsAt } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: TeamConnection
+
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get team details"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ team(id: \"TEAM_ID\") { id name key description timezone members { nodes { id name email displayName active } } states { nodes { id name type position color } } labels { nodes { id name color } } activeCycle { id number name startsAt endsAt progress } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: Team
+
+  users:
+    description: "Manage workspace users"
+    endpoints:
+      me:
+        method: POST
+        path: "/graphql"
+        description: "Get the authenticated user"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ viewer { id name email displayName active admin url organization { id name urlKey } teamMemberships { nodes { team { id name key } } } assignedIssues(first: 10, orderBy: updatedAt, filter: { state: { type: { nin: [\"completed\", \"canceled\"] } } }) { nodes { identifier title state { name } priority } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: User
+
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List workspace users"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ users(first: 100) { nodes { id name email displayName active admin createdAt lastSeen } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: UserConnection
+
+  labels:
+    description: "Manage issue labels"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List all labels"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ issueLabels(first: 100) { nodes { id name color description team { name } parent { name } children { nodes { name } } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: LabelConnection
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Create a new label"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { issueLabelCreate(input: { name: \"LABEL_NAME\", color: \"#COLOR\", teamId: \"TEAM_ID\" }) { success issueLabel { id name color } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: LabelPayload
+
+  workflows:
+    description: "Manage workflow states"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List workflow states across teams"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ workflowStates(first: 100) { nodes { id name type position color team { name key } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: WorkflowStateConnection
+
+  documents:
+    description: "Manage project documents"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List documents"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ documents(first: 50, orderBy: updatedAt) { nodes { id title content creator { name } project { name } updatedAt createdAt } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: DocumentConnection
+
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get document content"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ document(id: \"DOC_ID\") { id title content creator { name } project { name } updatedAt createdAt } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: Document
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Create a document"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { documentCreate(input: { title: \"TITLE\", content: \"CONTENT\", projectId: \"PROJECT_ID\" }) { success document { id title url } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: DocumentPayload
+
+  notifications:
+    description: "Manage notifications"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List unread notifications"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ notifications(first: 50, orderBy: createdAt) { nodes { id type readAt createdAt ... on IssueNotification { issue { identifier title state { name } } comment { body } actor { name } } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: NotificationConnection
+
+  webhooks:
+    description: "Manage webhooks"
+    endpoints:
+      list:
+        method: POST
+        path: "/graphql"
+        description: "List webhooks"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ webhooks(first: 50) { nodes { id url label enabled allPublicTeams team { name } resourceTypes createdAt } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: WebhookConnection
+
+      create:
+        method: POST
+        path: "/graphql"
+        description: "Create a webhook"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { webhookCreate(input: { url: \"WEBHOOK_URL\", label: \"LABEL\", resourceTypes: [\"Issue\", \"Comment\"] }) { success webhook { id url enabled } } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: WebhookPayload
+
+      delete:
+        method: POST
+        path: "/graphql"
+        description: "Delete a webhook"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"mutation { webhookDelete(id: \"WEBHOOK_ID\") { success } }"}'
+            description: "GraphQL mutation"
+        response:
+          type: object
+          item: DeletePayload
+
+  organization:
+    description: "View workspace/organization info"
+    endpoints:
+      get:
+        method: POST
+        path: "/graphql"
+        description: "Get organization details"
+        body:
+          - name: query
+            type: string
+            default: '{"query":"{ organization { id name urlKey logoUrl createdAt subscription { type seats } teams { nodes { name key } } users { nodes { name email active } } } }"}'
+            description: "GraphQL query"
+        response:
+          type: object
+          item: Organization
+
+types:
+  Issue:
+    fields:
+      - name: id
+        type: string
+      - name: identifier
+        type: string
+      - name: title
+        type: string
+      - name: description
+        type: string
+      - name: state
+        type: string
+      - name: assignee
+        type: string
+      - name: priority
+        type: int
+      - name: priorityLabel
+        type: string
+      - name: project
+        type: string
+      - name: cycle
+        type: string
+      - name: estimate
+        type: int
+      - name: dueDate
+        type: string
+      - name: createdAt
+        type: string
+      - name: updatedAt
+        type: string
+      - name: url
+        type: string
+
+  IssueConnection:
+    fields:
+      - name: nodes
+        type: string
+      - name: pageInfo
+        type: string
+
+  IssuePayload:
+    fields:
+      - name: success
+        type: bool
+      - name: issue
+        type: string
+
+  Comment:
+    fields:
+      - name: id
+        type: string
+      - name: body
+        type: string
+      - name: user
+        type: string
+      - name: createdAt
+        type: string
+
+  CommentConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  CommentPayload:
+    fields:
+      - name: success
+        type: bool
+      - name: comment
+        type: string
+
+  Project:
+    fields:
+      - name: id
+        type: string
+      - name: name
+        type: string
+      - name: description
+        type: string
+      - name: state
+        type: string
+      - name: progress
+        type: float
+      - name: startDate
+        type: string
+      - name: targetDate
+        type: string
+
+  ProjectConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  ProjectPayload:
+    fields:
+      - name: success
+        type: bool
+      - name: project
+        type: string
+
+  Cycle:
+    fields:
+      - name: id
+        type: string
+      - name: number
+        type: int
+      - name: name
+        type: string
+      - name: startsAt
+        type: string
+      - name: endsAt
+        type: string
+      - name: progress
+        type: float
+
+  CycleConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  Team:
+    fields:
+      - name: id
+        type: string
+      - name: name
+        type: string
+      - name: key
+        type: string
+      - name: description
+        type: string
+
+  TeamConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  User:
+    fields:
+      - name: id
+        type: string
+      - name: name
+        type: string
+      - name: email
+        type: string
+      - name: displayName
+        type: string
+      - name: active
+        type: bool
+
+  UserConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  LabelConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  LabelPayload:
+    fields:
+      - name: success
+        type: bool
+
+  WorkflowStateConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  Document:
+    fields:
+      - name: id
+        type: string
+      - name: title
+        type: string
+      - name: content
+        type: string
+
+  DocumentConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  DocumentPayload:
+    fields:
+      - name: success
+        type: bool
+
+  NotificationConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  WebhookConnection:
+    fields:
+      - name: nodes
+        type: string
+
+  WebhookPayload:
+    fields:
+      - name: success
+        type: bool
+
+  Organization:
+    fields:
+      - name: id
+        type: string
+      - name: name
+        type: string
+      - name: urlKey
+        type: string
+
+  ArchivePayload:
+    fields:
+      - name: success
+        type: bool
+
+  DeletePayload:
+    fields:
+      - name: success
+        type: bool

← 10640caf docs: update gauntlet findings to 10/10 pass rate  ·  back to Cli Printing Press  ·  feat(pipeline): add Research and Comparative phases with cat 466715fb →