Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ linear issues update ENG-123 --attach /tmp/additional-context.png
linear issues comment ENG-123 --body "Here's the screenshot:" --attach /tmp/bug.png

# Piping content (powerful!)
# Stdin is read only with the explicit - value; surrounding whitespace is trimmed.
cat .claude/plans/feature-plan.md | linear issues create "Implementation plan" --team ENG -d -
cat prd.md | linear issues create "Feature: OAuth" --team ENG --description -
cat bug-report.txt | linear issues comment ENG-123 --body -
Expand Down
40 changes: 24 additions & 16 deletions internal/cli/dependencies.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package cli

import (
"github.com/joa23/linear-cli/pkg/linear"
"io"
"os"

"github.com/joa23/linear-cli/internal/service"
"github.com/joa23/linear-cli/pkg/linear"
)

// Dependencies holds all injectable dependencies for CLI commands
Expand All @@ -11,14 +14,18 @@ type Dependencies struct {
// Client is the Linear API client
Client *linear.Client

// Stdin is read only when a body or description flag is exactly "-".
// Commands return an error if explicit stdin is requested without a reader.
Stdin io.Reader

// Services provide business logic and formatting
Issues service.IssueServiceInterface
Cycles service.CycleServiceInterface
Projects service.ProjectServiceInterface
Search service.SearchServiceInterface
Teams service.TeamServiceInterface
Users service.UserServiceInterface
Labels service.LabelServiceInterface
Issues service.IssueServiceInterface
Cycles service.CycleServiceInterface
Projects service.ProjectServiceInterface
Search service.SearchServiceInterface
Teams service.TeamServiceInterface
Users service.UserServiceInterface
Labels service.LabelServiceInterface
TaskExport service.TaskExportServiceInterface
Attachments service.AttachmentServiceInterface
IssueExport service.IssueExportServiceInterface
Expand All @@ -29,14 +36,15 @@ func NewDependencies(client *linear.Client) *Dependencies {
services := service.New(client)

return &Dependencies{
Client: client,
Issues: services.Issues,
Cycles: services.Cycles,
Projects: services.Projects,
Search: services.Search,
Teams: services.Teams,
Users: services.Users,
Labels: services.Labels,
Client: client,
Stdin: os.Stdin,
Issues: services.Issues,
Cycles: services.Cycles,
Projects: services.Projects,
Search: services.Search,
Teams: services.Teams,
Users: services.Users,
Labels: services.Labels,
TaskExport: services.TaskExport,
Attachments: services.Attachments,
IssueExport: services.IssueExport,
Expand Down
33 changes: 17 additions & 16 deletions internal/cli/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ has its own file:
## Team Resolution

Commands that require team context follow a consistent resolution order:
1. Explicit --team flag
2. Default team from .linear.yaml (set via 'linear init')
3. Error if neither is provided
1. Explicit --team flag
2. Default team from .linear.yaml (set via 'linear init')
3. Error if neither is provided

Use [GetDefaultTeam] to retrieve the configured default team.

## Project Resolution

Commands that accept a --project flag follow a consistent resolution order:
1. Explicit --project flag
2. Default project from .linear.yaml (manually configured)
3. No project filter (all projects shown)
1. Explicit --project flag
2. Default project from .linear.yaml (manually configured)
3. No project filter (all projects shown)

Use [GetDefaultProject] to retrieve the configured default project.

Expand Down Expand Up @@ -64,12 +64,13 @@ Reusable flag descriptions are centralized in flags.go:
## Helpers

Common utilities in helpers.go:
- [readStdin] - Read from stdin
- [parseCommaSeparated] - Parse comma-separated values
- [getDescriptionFromFlagOrStdin] - Get text from flag or stdin (use "-" for stdin)
- [uploadAndAppendAttachments] - Upload files and generate markdown
- [validateAndNormalizeLimit] - Validate --limit flags
- [looksLikeCycleNumber] - Detect numeric cycle IDs
- [readStdinFrom] - Read and trim from an injected reader
- [getDescriptionFromFlagOrStdinWithReader] - Resolve a flag or explicit "-" stdin input
- [getDescriptionFromFlagOrStdin] - Resolve a flag or process stdin for standalone callers
- [parseCommaSeparated] - Parse comma-separated values
- [uploadAndAppendAttachments] - Upload files and generate markdown
- [validateAndNormalizeLimit] - Validate --limit flags
- [looksLikeCycleNumber] - Detect numeric cycle IDs

# Dependency Management

Expand Down Expand Up @@ -105,10 +106,10 @@ Services are obtained via helper functions:
# State Management

The CLI is stateless between invocations. Each command:
1. Creates a fresh linear.Client from stored OAuth tokens
2. Constructs service layer instances as needed
3. Executes the operation
4. Exits
1. Creates a fresh linear.Client from stored OAuth tokens
2. Constructs service layer instances as needed
3. Executes the operation
4. Exits

State persisted between runs:
- OAuth tokens (via token.Storage in ~/.linear/)
Expand Down
26 changes: 21 additions & 5 deletions internal/cli/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ const (
MaxLimit = 250
)

// readStdin reads all piped content from stdin
// readStdin reads all content from the process stdin.
// Commands should use getDescriptionFromFlagOrStdinWithReader so stdin remains injectable.
func readStdin() (string, error) {
reader := bufio.NewReader(os.Stdin)
return readStdinFrom(os.Stdin)
}

// readStdinFrom reads all content from reader and trims surrounding whitespace.
func readStdinFrom(input io.Reader) (string, error) {
if input == nil {
return "", fmt.Errorf("stdin reader is not configured")
}
reader := bufio.NewReader(input)
var builder strings.Builder

for {
Expand Down Expand Up @@ -60,11 +69,18 @@ func parseCommaSeparated(s string) []string {
return result
}

// getDescriptionFromFlagOrStdin returns description from flag or stdin
// Use "-" as flagValue to explicitly read from stdin (e.g., -d -)
// getDescriptionFromFlagOrStdin returns the flag value, or reads process stdin when
// the flag is exactly "-". Commands use the reader-injected variant below.
func getDescriptionFromFlagOrStdin(flagValue string) (string, error) {
return getDescriptionFromFlagOrStdinWithReader(flagValue, os.Stdin)
}

// getDescriptionFromFlagOrStdinWithReader resolves command body input through an
// injected reader. A nil reader is an invalid dependency and returns an error;
// it never falls back to process stdin.
func getDescriptionFromFlagOrStdinWithReader(flagValue string, input io.Reader) (string, error) {
if flagValue == "-" {
return readStdin()
return readStdinFrom(input)
}

return flagValue, nil
Expand Down
70 changes: 38 additions & 32 deletions internal/cli/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import (
"strconv"

"github.com/joa23/linear-cli/internal/format"
paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination"
"github.com/joa23/linear-cli/internal/service"
paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -40,21 +40,21 @@ func newIssuesCmd() *cobra.Command {

func newIssuesListCmd() *cobra.Command {
var (
teamID string
project string
state string
priority string
assignee string
cycle string
labels string
teamID string
project string
state string
priority string
assignee string
cycle string
labels string
excludeLabels string
sortBy string
sortBy string
createdSince string
createdAfter string
createdBefore string
limit int
formatStr string
outputType string
limit int
formatStr string
outputType string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -243,7 +243,7 @@ Images in the description (uploads.linear.app/...) require auth — use:

# Download a private image from the issue description
linear attachments download "https://uploads.linear.app/..."`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
issueID := args[0]

Expand Down Expand Up @@ -313,7 +313,10 @@ OUTPUT: On success, prints the new issue's identifier on the FIRST line, then it
URL — and nothing else. The description is not echoed back. On failure it exits
non-zero. Scripts should use --output json and read .identifier.

TIP: Run 'linear init' first to set default team.`,
TIP: Run 'linear init' first to set default team.

STDIN: To read a description from a pipe, pass --description -. Stdin is not
read unless the flag value is exactly '-'; surrounding whitespace is trimmed.`,
Example: ` # Minimal - create with just title (requires 'linear init')
linear issues create "Fix login bug"

Expand Down Expand Up @@ -373,7 +376,7 @@ TIP: Run 'linear init' first to set default team.`,
}

// Get description from flag or stdin
desc, err := getDescriptionFromFlagOrStdin(description)
desc, err := getDescriptionFromFlagOrStdinWithReader(description, deps.Stdin)
if err != nil {
return fmt.Errorf("failed to read description: %w", err)
}
Expand Down Expand Up @@ -452,7 +455,7 @@ TIP: Run 'linear init' first to set default team.`,

// Add flags (with short versions for common flags)
cmd.Flags().StringVarP(&team, "team", "t", "", TeamFlagDescription)
cmd.Flags().StringVarP(&description, "description", "d", "", "Issue description (or pipe to stdin)")
cmd.Flags().StringVarP(&description, "description", "d", "", "Issue description (use --description - to read from stdin)")
cmd.Flags().StringVarP(&state, "state", "s", "", "Workflow state name (e.g., 'In Progress', 'Backlog')")
cmd.Flags().StringVarP(&priority, "priority", "p", "", "Priority: 0-4 or none/urgent/high/normal/low")
cmd.Flags().Float64VarP(&estimate, "estimate", "e", 0, "Story points estimate")
Expand Down Expand Up @@ -502,7 +505,10 @@ LABEL MODES:
--remove-labels Remove specific labels without affecting others

--add-labels and --remove-labels can be used together.
--labels cannot be combined with --add-labels or --remove-labels.`,
--labels cannot be combined with --add-labels or --remove-labels.

STDIN: To read a description from a pipe, pass --description -. Stdin is not
read unless the flag value is exactly '-'; surrounding whitespace is trimmed.`,
Example: ` # Update state and priority
linear issues update CEN-123 --state Done --priority 0

Expand Down Expand Up @@ -534,7 +540,7 @@ LABEL MODES:
return err
}

// Get team from flag or config (for cycle resolution)
// Get team from flag or config (for cycle resolution)
if team == "" {
team = GetDefaultTeam()
}
Expand All @@ -558,7 +564,7 @@ LABEL MODES:
}

// Get description from flag or stdin
desc, err := getDescriptionFromFlagOrStdin(description)
desc, err := getDescriptionFromFlagOrStdinWithReader(description, deps.Stdin)
if err != nil {
return fmt.Errorf("failed to read description: %w", err)
}
Expand Down Expand Up @@ -646,7 +652,7 @@ LABEL MODES:

// Add flags (with short versions for common flags)
cmd.Flags().StringVarP(&title, "title", "T", "", "Update issue title")
cmd.Flags().StringVarP(&description, "description", "d", "", "Update description (or pipe to stdin)")
cmd.Flags().StringVarP(&description, "description", "d", "", "Update description (use --description - to read from stdin)")
cmd.Flags().StringVarP(&state, "state", "s", "", "Update workflow state name (e.g., 'In Progress', 'Backlog')")
cmd.Flags().StringVarP(&priority, "priority", "p", "", "Priority: 0-4 or none/urgent/high/normal/low")
cmd.Flags().StringVarP(&estimate, "estimate", "e", "", "Update story points estimate")
Expand Down Expand Up @@ -675,15 +681,15 @@ func newIssuesCommentCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "comment <issue-id>",
Short: "Add a comment to an issue",
Long: `Add a comment to an issue. Comment body can be provided via --body flag or piped from stdin.`,
Long: `Add a comment to an issue. Provide the body with --body, or pass --body - to read it from stdin. Stdin is not read unless the flag value is exactly '-'. Surrounding whitespace is trimmed.`,
Example: ` # Add a simple comment
linear issues comment CEN-123 --body "This is a comment"

# Comment with screenshot attachment
linear issues comment CEN-123 --body "Bug screenshot:" --attach /tmp/screenshot.png

# Pipe content from file
cat notes.md | linear issues comment CEN-123`,
# Pipe content from file (use --body -)
cat notes.md | linear issues comment CEN-123 --body -`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
issueID := args[0]
Expand All @@ -695,7 +701,7 @@ func newIssuesCommentCmd() *cobra.Command {
}

// Get body from flag or stdin
commentBody, err := getDescriptionFromFlagOrStdin(body)
commentBody, err := getDescriptionFromFlagOrStdinWithReader(body, deps.Stdin)
if err != nil {
return fmt.Errorf("failed to read comment body: %w", err)
}
Expand All @@ -709,7 +715,7 @@ func newIssuesCommentCmd() *cobra.Command {
}

if commentBody == "" {
return fmt.Errorf("comment body is required. Use --body flag or pipe content to stdin")
return fmt.Errorf("comment body is required. Use --body <text> or --body - to read content from stdin")
}

// Get the issue first to get its ID (comments need issue ID, not identifier)
Expand All @@ -732,7 +738,7 @@ func newIssuesCommentCmd() *cobra.Command {
},
}

cmd.Flags().StringVarP(&body, "body", "b", "", "Comment body (or pipe to stdin)")
cmd.Flags().StringVarP(&body, "body", "b", "", "Comment body (use --body - to read from stdin)")
cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Embed file as inline image in body (repeatable); for sidebar cards use: attachments create")

return cmd
Expand Down Expand Up @@ -864,15 +870,15 @@ func newIssuesReplyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "reply <issue-id> <comment-id>",
Short: "Reply to a comment",
Long: `Reply to an existing comment on an issue. Reply body can be provided via --body flag or piped from stdin.`,
Long: `Reply to an existing comment on an issue. Provide the body with --body, or pass --body - to read it from stdin. Stdin is not read unless the flag value is exactly '-'. Surrounding whitespace is trimmed.`,
Example: ` # Reply to a comment
linear issues reply CEN-123 abc-comment-id --body "Thanks for the feedback!"

# Reply with attachment
linear issues reply CEN-123 abc-comment-id --body "Here's the fix:" --attach /tmp/screenshot.png

# Pipe content from file
cat response.md | linear issues reply CEN-123 abc-comment-id`,
# Pipe content from file (use --body -)
cat response.md | linear issues reply CEN-123 abc-comment-id --body -`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
issueID := args[0]
Expand All @@ -883,7 +889,7 @@ func newIssuesReplyCmd() *cobra.Command {
}

// Get body from flag or stdin
replyBody, err := getDescriptionFromFlagOrStdin(body)
replyBody, err := getDescriptionFromFlagOrStdinWithReader(body, deps.Stdin)
if err != nil {
return fmt.Errorf("failed to read reply body: %w", err)
}
Expand All @@ -897,7 +903,7 @@ func newIssuesReplyCmd() *cobra.Command {
}

if replyBody == "" {
return fmt.Errorf("reply body is required. Use --body flag or pipe content to stdin")
return fmt.Errorf("reply body is required. Use --body <text> or --body - to read content from stdin")
}

comment, err := deps.Issues.ReplyToComment(issueID, commentID, replyBody)
Expand All @@ -912,7 +918,7 @@ func newIssuesReplyCmd() *cobra.Command {
},
}

cmd.Flags().StringVarP(&body, "body", "b", "", "Reply body (or pipe to stdin)")
cmd.Flags().StringVarP(&body, "body", "b", "", "Reply body (use --body - to read from stdin)")
cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Embed file as inline image in body (repeatable); for sidebar cards use: attachments create")

return cmd
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/onboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func runOnboard() error {
fmt.Println(" linear auth status Check login status")
fmt.Println()
fmt.Println("Create issue (full example):")
fmt.Println(" cat feature.md | linear i create \"Add user authentication\" \\")
fmt.Println(" cat feature.md | linear i create \"Add user authentication\" --description - \\")
fmt.Println(" -t CEN \\")
fmt.Println(" -s \"In Progress\" \\")
fmt.Println(" -p 1 \\")
Expand Down
Loading
Loading