← 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
A linear-cli/.gitignoreA linear-cli/.golangci.ymlA linear-cli/.goreleaser.yamlA linear-cli/MakefileA linear-cli/README.mdA linear-cli/cmd/linear-cli/main.goA linear-cli/go.modA linear-cli/go.sumA linear-cli/internal/cli/comments.goA linear-cli/internal/cli/cycles.goA linear-cli/internal/cli/doctor.goA linear-cli/internal/cli/documents.goA linear-cli/internal/cli/graphql.goA linear-cli/internal/cli/helpers.goA linear-cli/internal/cli/issues.goA linear-cli/internal/cli/labels.goA linear-cli/internal/cli/notifications.goA linear-cli/internal/cli/organization.goA linear-cli/internal/cli/projects.goA linear-cli/internal/cli/root.goA linear-cli/internal/cli/teams.goA linear-cli/internal/cli/users.goA linear-cli/internal/cli/webhooks.goA linear-cli/internal/cli/workflows.goA linear-cli/internal/client/client.goA linear-cli/internal/config/config.goA linear-cli/internal/types/types.goA linear-spec.yaml
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 →