From f84f510f77089e358acf5077b12c721c6cbc9d40 Mon Sep 17 00:00:00 2001 From: Ben Drucker Date: Mon, 2 Feb 2026 21:21:45 -0800 Subject: [PATCH 1/9] feat: add `api` subcommand for raw GraphQL access --- skills/linear-cli/SKILL.md | 46 ++--- skills/linear-cli/SKILL.template.md | 42 ++--- skills/linear-cli/references/api.md | 21 +++ skills/linear-cli/references/auth.md | 14 +- skills/linear-cli/references/commands.md | 3 +- skills/linear-cli/references/config.md | 2 +- skills/linear-cli/references/document.md | 19 +- .../references/initiative-update.md | 9 +- skills/linear-cli/references/initiative.md | 37 ++-- skills/linear-cli/references/issue.md | 123 ++++++++++--- skills/linear-cli/references/label.md | 8 +- skills/linear-cli/references/milestone.md | 12 +- .../linear-cli/references/project-update.md | 9 +- skills/linear-cli/references/project.md | 11 +- skills/linear-cli/references/schema.md | 2 +- skills/linear-cli/references/team.md | 15 +- src/commands/api.ts | 172 ++++++++++++++++++ src/main.ts | 2 + test/commands/__snapshots__/api.test.ts.snap | 61 +++++++ test/commands/api.test.ts | 168 +++++++++++++++++ 20 files changed, 632 insertions(+), 144 deletions(-) create mode 100644 skills/linear-cli/references/api.md create mode 100644 src/commands/api.ts create mode 100644 test/commands/__snapshots__/api.test.ts.snap create mode 100644 test/commands/api.test.ts diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index b0368e6..a4b4a53 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Bash(linear:*), Bash(curl:*) A CLI to manage Linear issues from the command line, with git and jj integration. -Generated from linear CLI v1.8.1 +Generated from linear CLI v1.9.1 ## Prerequisites @@ -36,6 +36,7 @@ linear label # Manage Linear issue labels linear document # Manage Linear documents linear config # Interactively generate .linear.toml configuration linear schema # Print the GraphQL schema to stdout +linear api # Make a raw GraphQL API request ``` ## Reference Documentation @@ -52,6 +53,7 @@ linear schema # Print the GraphQL schema to stdout - [document](references/document.md) - Manage Linear documents - [config](references/config.md) - Interactively generate .linear.toml configuration - [schema](references/schema.md) - Print the GraphQL schema to stdout +- [api](references/api.md) - Make a raw GraphQL API request For curated examples of organization features (initiatives, labels, projects, bulk operations), see [organization-features](references/organization-features.md). @@ -70,47 +72,45 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** Direct API calls via curl are slower and should only be used as a fallback for advanced queries not covered by the CLI. For complex queries involving multiple calls, write and execute a script. +**Prefer the CLI for all supported operations.** The `api` command should only be +used as a fallback for queries not covered by the CLI. -To make direct API calls, use `linear schema` and `linear auth token`: - -### 1. Check the schema for available types and fields +### Check the schema for available types and fields Write the schema to a tempfile, then search it: ```bash -# Write schema to a tempfile (cross-platform) linear schema -o "${TMPDIR:-/tmp}/linear-schema.graphql" - -# Search for specific types or fields grep -i "cycle" "${TMPDIR:-/tmp}/linear-schema.graphql" grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" - -# View filter options -grep -A 50 "^input IssueFilter" "${TMPDIR:-/tmp}/linear-schema.graphql" ``` -### 2. Get the auth token +### Make a GraphQL request ```bash -linear auth token -``` +# Simple query +linear api '{ viewer { id name email } }' -### 3. Make a curl request +# Query with string variables +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' -f teamId=abc123 -```bash -curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ issues(filter: { team: { key: { eq: \"CLI\" } } }, first: 5) { nodes { identifier title state { name } } } }"}' +# Typed variables (numbers, booleans) +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' -F first=5 + +# Read query from stdin +echo '{ viewer { id } }' | linear api + +# Pipe to jq for filtering +linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.data.issues.nodes[].title' ``` -### Example queries +### Advanced: Using curl directly + +For cases where you need full HTTP control, use `linear auth token`: ```bash -# Get issues assigned to current user curl -s -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ viewer { assignedIssues(first: 10) { nodes { identifier title state { name } } } } }"}' + -d '{"query": "{ viewer { id } }"}' ``` diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index f17497e..4091762 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -46,47 +46,45 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** Direct API calls via curl are slower and should only be used as a fallback for advanced queries not covered by the CLI. For complex queries involving multiple calls, write and execute a script. +**Prefer the CLI for all supported operations.** The `api` command should only be +used as a fallback for queries not covered by the CLI. -To make direct API calls, use `linear schema` and `linear auth token`: - -### 1. Check the schema for available types and fields +### Check the schema for available types and fields Write the schema to a tempfile, then search it: ```bash -# Write schema to a tempfile (cross-platform) linear schema -o "${TMPDIR:-/tmp}/linear-schema.graphql" - -# Search for specific types or fields grep -i "cycle" "${TMPDIR:-/tmp}/linear-schema.graphql" grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" - -# View filter options -grep -A 50 "^input IssueFilter" "${TMPDIR:-/tmp}/linear-schema.graphql" ``` -### 2. Get the auth token +### Make a GraphQL request ```bash -linear auth token -``` +# Simple query +linear api '{ viewer { id name email } }' -### 3. Make a curl request +# Query with string variables +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' -f teamId=abc123 -```bash -curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ issues(filter: { team: { key: { eq: \"CLI\" } } }, first: 5) { nodes { identifier title state { name } } } }"}' +# Typed variables (numbers, booleans) +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' -F first=5 + +# Read query from stdin +echo '{ viewer { id } }' | linear api + +# Pipe to jq for filtering +linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.data.issues.nodes[].title' ``` -### Example queries +### Advanced: Using curl directly + +For cases where you need full HTTP control, use `linear auth token`: ```bash -# Get issues assigned to current user curl -s -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ viewer { assignedIssues(first: 10) { nodes { identifier title state { name } } } } }"}' + -d '{"query": "{ viewer { id } }"}' ``` diff --git a/skills/linear-cli/references/api.md b/skills/linear-cli/references/api.md new file mode 100644 index 0000000..2fef081 --- /dev/null +++ b/skills/linear-cli/references/api.md @@ -0,0 +1,21 @@ +# api + +> Make a raw GraphQL API request + +## Usage + +``` +Usage: linear api [query] +Version: 1.9.1 + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + -f, --field - String variable in key=value format + -F, --typed-field - Typed variable in key=value format (coerces booleans, numbers, null) +``` diff --git a/skills/linear-cli/references/auth.md b/skills/linear-cli/references/auth.md index 298360f..0ad554a 100644 --- a/skills/linear-cli/references/auth.md +++ b/skills/linear-cli/references/auth.md @@ -6,7 +6,7 @@ ``` Usage: linear auth -Version: 1.8.1 +Version: 1.9.1 Description: @@ -35,7 +35,7 @@ Commands: ``` Usage: linear auth login -Version: 1.8.1 +Version: 1.9.1 Description: @@ -54,7 +54,7 @@ Options: ``` Usage: linear auth logout [workspace] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -73,7 +73,7 @@ Options: ``` Usage: linear auth list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -91,7 +91,7 @@ Options: ``` Usage: linear auth default [workspace] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -109,7 +109,7 @@ Options: ``` Usage: linear auth token -Version: 1.8.1 +Version: 1.9.1 Description: @@ -127,7 +127,7 @@ Options: ``` Usage: linear auth whoami -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/commands.md b/skills/linear-cli/references/commands.md index 5e89873..e9fc570 100644 --- a/skills/linear-cli/references/commands.md +++ b/skills/linear-cli/references/commands.md @@ -1,6 +1,6 @@ # Linear CLI Command Reference -Generated from linear CLI v1.8.1 +Generated from linear CLI v1.9.1 ## Commands @@ -16,6 +16,7 @@ Generated from linear CLI v1.8.1 - [document](./document.md) - Manage Linear documents - [config](./config.md) - Interactively generate .linear.toml configuration - [schema](./schema.md) - Print the GraphQL schema to stdout +- [api](./api.md) - Make a raw GraphQL API request ## Quick Reference diff --git a/skills/linear-cli/references/config.md b/skills/linear-cli/references/config.md index 2592877..9dd9a79 100644 --- a/skills/linear-cli/references/config.md +++ b/skills/linear-cli/references/config.md @@ -6,7 +6,7 @@ ``` Usage: linear config -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 403784d..2a547ba 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -6,7 +6,7 @@ ``` Usage: linear document -Version: 1.8.1 +Version: 1.9.1 Description: @@ -34,7 +34,7 @@ Commands: ``` Usage: linear document list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -56,7 +56,7 @@ Options: ``` Usage: linear document view -Version: 1.8.1 +Version: 1.9.1 Description: @@ -77,7 +77,7 @@ Options: ``` Usage: linear document create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -93,8 +93,7 @@ Options: --project - Attach to project (slug or ID) --issue - Attach to issue (identifier like TC-123) --icon - Document icon (emoji) - -i, --interactive - Interactive mode with prompts - --no-color - Disable colored output + -i, --interactive - Interactive mode with prompts ``` ### update @@ -103,7 +102,7 @@ Options: ``` Usage: linear document update -Version: 1.8.1 +Version: 1.9.1 Description: @@ -117,8 +116,7 @@ Options: -c, --content - New markdown content (inline) -f, --content-file - Read new content from file --icon - New icon (emoji) - -e, --edit - Open current content in $EDITOR for editing - --no-color - Disable colored output + -e, --edit - Open current content in $EDITOR for editing ``` ### delete @@ -127,7 +125,7 @@ Options: ``` Usage: linear document delete [documentId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -138,7 +136,6 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -y, --yes - Skip confirmation prompt - --no-color - Disable colored output --bulk - Delete multiple documents by slug or ID --bulk-file - Read document slugs/IDs from a file (one per line) --bulk-stdin - Read document slugs/IDs from stdin diff --git a/skills/linear-cli/references/initiative-update.md b/skills/linear-cli/references/initiative-update.md index 32a339d..07264f9 100644 --- a/skills/linear-cli/references/initiative-update.md +++ b/skills/linear-cli/references/initiative-update.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative-update -Version: 1.8.1 +Version: 1.9.1 Description: @@ -31,7 +31,7 @@ Commands: ``` Usage: linear initiative-update create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -44,8 +44,7 @@ Options: --body - Update content (markdown) --body-file - Read content from file --health - Health status (onTrack, atRisk, offTrack) - -i, --interactive - Interactive mode with prompts - --no-color - Disable colored output + -i, --interactive - Interactive mode with prompts ``` ### list @@ -54,7 +53,7 @@ Options: ``` Usage: linear initiative-update list -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/initiative.md b/skills/linear-cli/references/initiative.md index 0b0400f..32c35d3 100644 --- a/skills/linear-cli/references/initiative.md +++ b/skills/linear-cli/references/initiative.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative -Version: 1.8.1 +Version: 1.9.1 Description: @@ -38,7 +38,7 @@ Commands: ``` Usage: linear initiative list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -63,7 +63,7 @@ Options: ``` Usage: linear initiative view -Version: 1.8.1 +Version: 1.9.1 Description: @@ -84,7 +84,7 @@ Options: ``` Usage: linear initiative create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -101,8 +101,7 @@ Options: --target-date - Target completion date (YYYY-MM-DD) -c, --color - Color hex code (e.g., #5E6AD2) --icon - Icon name - -i, --interactive - Interactive mode (default if no flags provided) - --no-color - Disable colored output + -i, --interactive - Interactive mode (default if no flags provided) ``` ### archive @@ -111,7 +110,7 @@ Options: ``` Usage: linear initiative archive [initiativeId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -122,7 +121,6 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -y, --force - Skip confirmation prompt - --no-color - Disable colored output --bulk - Archive multiple initiatives by ID, slug, or name --bulk-file - Read initiative IDs from a file (one per line) --bulk-stdin - Read initiative IDs from stdin @@ -134,7 +132,7 @@ Options: ``` Usage: linear initiative update -Version: 1.8.1 +Version: 1.9.1 Description: @@ -151,8 +149,7 @@ Options: --target-date - Target completion date (YYYY-MM-DD) --color - Initiative color (hex, e.g., #5E6AD2) --icon - Initiative icon name - -i, --interactive - Interactive mode for updates - --no-color - Disable colored output + -i, --interactive - Interactive mode for updates ``` ### unarchive @@ -161,7 +158,7 @@ Options: ``` Usage: linear initiative unarchive -Version: 1.8.1 +Version: 1.9.1 Description: @@ -171,8 +168,7 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) - -y, --force - Skip confirmation prompt - --no-color - Disable colored output + -y, --force - Skip confirmation prompt ``` ### delete @@ -181,7 +177,7 @@ Options: ``` Usage: linear initiative delete [initiativeId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -192,7 +188,6 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -y, --force - Skip confirmation prompt - --no-color - Disable colored output --bulk - Delete multiple initiatives by ID, slug, or name --bulk-file - Read initiative IDs from a file (one per line) --bulk-stdin - Read initiative IDs from stdin @@ -204,7 +199,7 @@ Options: ``` Usage: linear initiative add-project -Version: 1.8.1 +Version: 1.9.1 Description: @@ -214,8 +209,7 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) - --sort-order - Sort order within initiative - --no-color - Disable colored output + --sort-order - Sort order within initiative ``` ### remove-project @@ -224,7 +218,7 @@ Options: ``` Usage: linear initiative remove-project -Version: 1.8.1 +Version: 1.9.1 Description: @@ -234,6 +228,5 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) - -y, --force - Skip confirmation prompt - --no-color - Disable colored output + -y, --force - Skip confirmation prompt ``` diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 3d06858..b01d690 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -6,7 +6,7 @@ ``` Usage: linear issue -Version: 1.8.1 +Version: 1.9.1 Description: @@ -32,7 +32,8 @@ Commands: create - Create a linear issue update [issueId] - Update a linear issue comment - Manage issue comments - attach - Attach a file to an issue + attach - Attach a file to an issue + relation - Manage issue relations (dependencies) ``` ## Subcommands @@ -43,7 +44,7 @@ Commands: ``` Usage: linear issue id -Version: 1.8.1 +Version: 1.9.1 Description: @@ -61,7 +62,7 @@ Options: ``` Usage: linear issue list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -92,7 +93,7 @@ Options: ``` Usage: linear issue title [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -110,7 +111,7 @@ Options: ``` Usage: linear issue start [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -132,7 +133,7 @@ Options: ``` Usage: linear issue view [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -156,7 +157,7 @@ Options: ``` Usage: linear issue url [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -174,7 +175,7 @@ Options: ``` Usage: linear issue describe [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -193,7 +194,7 @@ Options: ``` Usage: linear issue commits [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -211,7 +212,7 @@ Options: ``` Usage: linear issue pull-request [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -234,7 +235,7 @@ Options: ``` Usage: linear issue delete [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -245,7 +246,6 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -y, --confirm - Skip confirmation prompt - --no-color - Disable colored output --bulk - Delete multiple issues by identifier (e.g., TC-123 TC-124) --bulk-file - Read issue identifiers from a file (one per line) --bulk-stdin - Read issue identifiers from stdin @@ -257,7 +257,7 @@ Options: ``` Usage: linear issue create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -279,7 +279,6 @@ Options: --project - Name of the project with the issue -s, --state - Workflow state for the issue (by name or type) --no-use-default-template - Do not use default template for the issue - --no-color - Disable colored output --no-interactive - Disable interactive prompts -t, --title - Title of the issue ``` @@ -290,7 +289,7 @@ Options: ``` Usage: linear issue update [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -310,7 +309,6 @@ Options: --team <team> - Team associated with the issue (if not your default team) --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) - --no-color - Disable colored output -t, --title <title> - Title of the issue ``` @@ -320,7 +318,7 @@ Options: ``` Usage: linear issue comment -Version: 1.8.1 +Version: 1.9.1 Description: @@ -344,7 +342,7 @@ Commands: ``` Usage: linear issue comment add [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -363,7 +361,7 @@ Options: ``` Usage: linear issue comment update <commentId> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -380,7 +378,7 @@ Options: ``` Usage: linear issue comment list [issueId] -Version: 1.8.1 +Version: 1.9.1 Description: @@ -399,7 +397,7 @@ Options: ``` Usage: linear issue attach <issueId> <filepath> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -412,3 +410,84 @@ Options: -t, --title <title> - Custom title for the attachment -c, --comment <body> - Add a comment body linked to the attachment ``` + +### relation + +> Manage issue relations (dependencies) + +``` +Usage: linear issue relation +Version: 1.9.1 + +Description: + + Manage issue relations (dependencies) + +Options: + + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) + +Commands: + + add <issueId> <relationType> <relatedIssueId> - Add a relation between two issues + delete <issueId> <relationType> <relatedIssueId> - Delete a relation between two issues + list [issueId] - List relations for an issue +``` + +#### relation subcommands + +##### add + +``` +Usage: linear issue relation add <issueId> <relationType> <relatedIssueId> +Version: 1.9.1 + +Description: + + Add a relation between two issues + +Options: + + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) + +Examples: + + Mark issue as blocked by another linear issue relation add ENG-123 blocked-by ENG-100 + Mark issue as blocking another linear issue relation add ENG-123 blocks ENG-456 + Mark issues as related linear issue relation add ENG-123 related ENG-456 + Mark issue as duplicate linear issue relation add ENG-123 duplicate ENG-100 +``` + +##### delete + +``` +Usage: linear issue relation delete <issueId> <relationType> <relatedIssueId> +Version: 1.9.1 + +Description: + + Delete a relation between two issues + +Options: + + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) +``` + +##### list + +``` +Usage: linear issue relation list [issueId] +Version: 1.9.1 + +Description: + + List relations for an issue + +Options: + + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) +``` diff --git a/skills/linear-cli/references/label.md b/skills/linear-cli/references/label.md index e46c6cd..ec500c0 100644 --- a/skills/linear-cli/references/label.md +++ b/skills/linear-cli/references/label.md @@ -6,7 +6,7 @@ ``` Usage: linear label -Version: 1.8.1 +Version: 1.9.1 Description: @@ -32,7 +32,7 @@ Commands: ``` Usage: linear label list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -53,7 +53,7 @@ Options: ``` Usage: linear label create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -76,7 +76,7 @@ Options: ``` Usage: linear label delete <nameOrId> -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/milestone.md b/skills/linear-cli/references/milestone.md index 7e6593b..008fca6 100644 --- a/skills/linear-cli/references/milestone.md +++ b/skills/linear-cli/references/milestone.md @@ -6,7 +6,7 @@ ``` Usage: linear milestone -Version: 1.8.1 +Version: 1.9.1 Description: @@ -34,7 +34,7 @@ Commands: ``` Usage: linear milestone list --project <projectId> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -53,7 +53,7 @@ Options: ``` Usage: linear milestone view <milestoneId> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -71,7 +71,7 @@ Options: ``` Usage: linear milestone create --project <projectId> --name <name> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -93,7 +93,7 @@ Options: ``` Usage: linear milestone update <id> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -115,7 +115,7 @@ Options: ``` Usage: linear milestone delete <id> -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/project-update.md b/skills/linear-cli/references/project-update.md index 3662ec7..c722c10 100644 --- a/skills/linear-cli/references/project-update.md +++ b/skills/linear-cli/references/project-update.md @@ -6,7 +6,7 @@ ``` Usage: linear project-update -Version: 1.8.1 +Version: 1.9.1 Description: @@ -31,7 +31,7 @@ Commands: ``` Usage: linear project-update create <projectId> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -44,8 +44,7 @@ Options: --body <body> - Update content (inline) --body-file <path> - Read content from file --health <health> - Project health status (onTrack, atRisk, offTrack) - -i, --interactive - Interactive mode with prompts - --no-color - Disable colored output + -i, --interactive - Interactive mode with prompts ``` ### list @@ -54,7 +53,7 @@ Options: ``` Usage: linear project-update list <projectId> -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index ed26e26..b0191e4 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -6,7 +6,7 @@ ``` Usage: linear project -Version: 1.8.1 +Version: 1.9.1 Description: @@ -32,7 +32,7 @@ Commands: ``` Usage: linear project list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -55,7 +55,7 @@ Options: ``` Usage: linear project view <projectId> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -75,7 +75,7 @@ Options: ``` Usage: linear project create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -93,6 +93,5 @@ Options: --start-date <startDate> - Start date (YYYY-MM-DD) --target-date <targetDate> - Target completion date (YYYY-MM-DD) --initiative <initiative> - Add to initiative immediately (ID, slug, or name) - -i, --interactive - Interactive mode (default if no flags provided) - --no-color - Disable colored output + -i, --interactive - Interactive mode (default if no flags provided) ``` diff --git a/skills/linear-cli/references/schema.md b/skills/linear-cli/references/schema.md index 549ed7d..2370ddc 100644 --- a/skills/linear-cli/references/schema.md +++ b/skills/linear-cli/references/schema.md @@ -6,7 +6,7 @@ ``` Usage: linear schema -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/skills/linear-cli/references/team.md b/skills/linear-cli/references/team.md index 2d03925..c7c49e9 100644 --- a/skills/linear-cli/references/team.md +++ b/skills/linear-cli/references/team.md @@ -6,7 +6,7 @@ ``` Usage: linear team -Version: 1.8.1 +Version: 1.9.1 Description: @@ -35,7 +35,7 @@ Commands: ``` Usage: linear team create -Version: 1.8.1 +Version: 1.9.1 Description: @@ -49,7 +49,6 @@ Options: -d, --description <description> - Description of the team -k, --key <key> - Team key (if not provided, will be generated from name) --private - Make the team private - --no-color - Disable colored output --no-interactive - Disable interactive prompts ``` @@ -59,7 +58,7 @@ Options: ``` Usage: linear team delete <teamKey> -Version: 1.8.1 +Version: 1.9.1 Description: @@ -79,7 +78,7 @@ Options: ``` Usage: linear team list -Version: 1.8.1 +Version: 1.9.1 Description: @@ -99,7 +98,7 @@ Options: ``` Usage: linear team id -Version: 1.8.1 +Version: 1.9.1 Description: @@ -117,7 +116,7 @@ Options: ``` Usage: linear team autolinks -Version: 1.8.1 +Version: 1.9.1 Description: @@ -135,7 +134,7 @@ Options: ``` Usage: linear team members [teamKey] -Version: 1.8.1 +Version: 1.9.1 Description: diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 0000000..04475ae --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,172 @@ +import { Command } from "@cliffy/command" +import denoConfig from "../../deno.json" with { type: "json" } +import { getGraphQLEndpoint, getResolvedApiKey } from "../utils/graphql.ts" +import { handleError, ValidationError } from "../utils/errors.ts" + +export const apiCommand = new Command() + .name("api") + .description("Make a raw GraphQL API request") + .arguments("[query:string]") + .option( + "-f, --field <field:string>", + "String variable in key=value format", + { collect: true }, + ) + .option( + "-F, --typed-field <field:string>", + "Typed variable in key=value format (coerces booleans, numbers, null)", + { collect: true }, + ) + .action(async (options, query?: string) => { + try { + const resolvedQuery = await resolveQuery(query) + const variables = buildVariables(options.field, options.typedField) + + const apiKey = getResolvedApiKey() + if (!apiKey) { + throw new ValidationError( + "No API key configured", + { + suggestion: + "Set LINEAR_API_KEY, add api_key to .linear.toml, or run `linear auth login`.", + }, + ) + } + + const body: Record<string, unknown> = { query: resolvedQuery } + if (Object.keys(variables).length > 0) { + body.variables = variables + } + + const response = await fetch(getGraphQLEndpoint(), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + "User-Agent": `schpet-linear-cli/${denoConfig.version}`, + }, + body: JSON.stringify(body), + }) + + const text = await response.text() + + if (response.status >= 400) { + console.error(text) + Deno.exit(1) + } + + if (Deno.stdout.isTerminal()) { + try { + const parsed = JSON.parse(text) + console.log(JSON.stringify(parsed, null, 2)) + } catch { + console.log(text) + } + } else { + Deno.stdout.writeSync(new TextEncoder().encode(text)) + } + } catch (error) { + handleError(error, "API request failed") + } + }) + +async function resolveQuery(positionalArg?: string): Promise<string> { + if (positionalArg && positionalArg !== "-") { + return positionalArg + } + + const explicit = positionalArg === "-" + + if (explicit || !Deno.stdin.isTerminal()) { + const content = explicit + ? await readAllStdin() + : await readStdinWithTimeout() + if (content) { + return content + } + } + + throw new ValidationError("No query provided", { + suggestion: + "Provide a query as an argument: linear api '{ viewer { id } }'\n Or pipe from stdin: echo '{ viewer { id } }' | linear api", + }) +} + +async function readAllStdin(): Promise<string | undefined> { + const chunks: Uint8Array[] = [] + for await (const chunk of Deno.stdin.readable) { + chunks.push(chunk) + } + const text = new TextDecoder().decode(concatChunks(chunks)).trim() + return text.length > 0 ? text : undefined +} + +async function readStdinWithTimeout(): Promise<string | undefined> { + try { + const timeoutPromise = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("stdin timeout")), 100) + }) + const result = await Promise.race([readAllStdin(), timeoutPromise]) + return result + } catch { + return undefined + } +} + +function concatChunks(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0) + const combined = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined +} + +function buildVariables( + fields?: string[], + typedFields?: string[], +): Record<string, unknown> { + const variables: Record<string, unknown> = {} + + if (fields) { + for (const entry of fields) { + const [key, value] = parseFieldEntry(entry) + variables[key] = value + } + } + + if (typedFields) { + for (const entry of typedFields) { + const [key, rawValue] = parseFieldEntry(entry) + variables[key] = coerceValue(rawValue) + } + } + + return variables +} + +function parseFieldEntry(entry: string): [string, string] { + const eqIndex = entry.indexOf("=") + if (eqIndex === -1) { + throw new ValidationError( + `Invalid variable format: ${entry}`, + { + suggestion: "Variables must be in key=value format, e.g. -f teamId=abc", + }, + ) + } + return [entry.slice(0, eqIndex), entry.slice(eqIndex + 1)] +} + +function coerceValue(value: string): unknown { + if (value === "true") return true + if (value === "false") return false + if (value === "null") return null + + const num = Number(value) + if (value !== "" && !isNaN(num)) return num + + return value +} diff --git a/src/main.ts b/src/main.ts index 3d33feb..bcc9663 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import { labelCommand } from "./commands/label/label.ts" import { documentCommand } from "./commands/document/document.ts" import { configCommand } from "./commands/config.ts" import { schemaCommand } from "./commands/schema.ts" +import { apiCommand } from "./commands/api.ts" import { setCliWorkspace } from "./config.ts" // Import config and credentials setup @@ -59,4 +60,5 @@ Environment Variables: .command("completions", new CompletionsCommand()) .command("config", configCommand) .command("schema", schemaCommand) + .command("api", apiCommand) .parse(Deno.args) diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap new file mode 100644 index 0000000..e316fc4 --- /dev/null +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -0,0 +1,61 @@ +export const snapshot = {}; + +snapshot[`API Command - Help Text 1`] = ` +stdout: +" +Usage: api [query] + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + -f, --field <field> - String variable in key=value format + -F, --typed-field <field> - Typed variable in key=value format (coerces booleans, numbers, null) + +" +stderr: +"" +`; + +snapshot[`API Command - Basic Query 1`] = ` +stdout: +'{"data":{"viewer":{"id":"user-1","name":"Test User"}}}' +stderr: +"" +`; + +snapshot[`API Command - String Variables 1`] = ` +stdout: +'{"data":{"team":{"name":"Backend Team"}}}' +stderr: +"" +`; + +snapshot[`API Command - Typed Variables 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"},{"title":"Issue Two"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - No Query Error 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: No query provided + Provide a query as an argument: linear api '{ viewer { id } }' + Or pipe from stdin: echo '{ viewer { id } }' | linear api +" +`; + +snapshot[`API Command - Invalid Variable Format 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: Invalid variable format: badformat + Variables must be in key=value format, e.g. -f teamId=abc +" +`; diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts new file mode 100644 index 0000000..7d1a3e9 --- /dev/null +++ b/test/commands/api.test.ts @@ -0,0 +1,168 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { apiCommand } from "../../src/commands/api.ts" +import { MockLinearServer } from "../utils/mock_linear_server.ts" + +const denoArgs = ["--allow-all", "--quiet"] + +await cliffySnapshotTest({ + name: "API Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await apiCommand.parse() + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Basic Query", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id name } }"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { + id: "user-1", + name: "Test User", + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - String Variables", + meta: import.meta, + colors: false, + args: [ + "query GetTeam($teamId: String!) { team(id: $teamId) { name } }", + "-f", + "teamId=abc123", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeam", + variables: { teamId: "abc123" }, + response: { + data: { + team: { + name: "Backend Team", + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Typed Variables", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "-F", + "first=5", + "-F", + "active=true", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: true }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + { title: "Issue Two" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - No Query Error", + meta: import.meta, + colors: false, + args: [], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Invalid Variable Format", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id } }", "-f", "badformat"], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) From 4dc112425bde0a13c51bce8cff4efb723204bc43 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Mon, 2 Feb 2026 21:31:49 -0800 Subject: [PATCH 2/9] fix: join long line to pass deno fmt check --- skills/linear-cli/SKILL.md | 3 +-- skills/linear-cli/SKILL.template.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index a4b4a53..76a6b56 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -72,8 +72,7 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** The `api` command should only be -used as a fallback for queries not covered by the CLI. +**Prefer the CLI for all supported operations.** The `api` command should only be used as a fallback for queries not covered by the CLI. ### Check the schema for available types and fields diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index 4091762..4feb87a 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -46,8 +46,7 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** The `api` command should only be -used as a fallback for queries not covered by the CLI. +**Prefer the CLI for all supported operations.** The `api` command should only be used as a fallback for queries not covered by the CLI. ### Check the schema for available types and fields From 4f99d661e92ff44cf9ad436910c401e862060d39 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Mon, 2 Feb 2026 21:34:59 -0800 Subject: [PATCH 3/9] revert unrelated regenerated reference docs --- skills/linear-cli/references/auth.md | 14 +- skills/linear-cli/references/config.md | 2 +- skills/linear-cli/references/document.md | 19 +-- .../references/initiative-update.md | 9 +- skills/linear-cli/references/initiative.md | 37 +++--- skills/linear-cli/references/issue.md | 123 ++++-------------- skills/linear-cli/references/label.md | 8 +- skills/linear-cli/references/milestone.md | 12 +- .../linear-cli/references/project-update.md | 9 +- skills/linear-cli/references/project.md | 11 +- skills/linear-cli/references/schema.md | 2 +- skills/linear-cli/references/team.md | 15 ++- 12 files changed, 98 insertions(+), 163 deletions(-) diff --git a/skills/linear-cli/references/auth.md b/skills/linear-cli/references/auth.md index 0ad554a..298360f 100644 --- a/skills/linear-cli/references/auth.md +++ b/skills/linear-cli/references/auth.md @@ -6,7 +6,7 @@ ``` Usage: linear auth -Version: 1.9.1 +Version: 1.8.1 Description: @@ -35,7 +35,7 @@ Commands: ``` Usage: linear auth login -Version: 1.9.1 +Version: 1.8.1 Description: @@ -54,7 +54,7 @@ Options: ``` Usage: linear auth logout [workspace] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -73,7 +73,7 @@ Options: ``` Usage: linear auth list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -91,7 +91,7 @@ Options: ``` Usage: linear auth default [workspace] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -109,7 +109,7 @@ Options: ``` Usage: linear auth token -Version: 1.9.1 +Version: 1.8.1 Description: @@ -127,7 +127,7 @@ Options: ``` Usage: linear auth whoami -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/config.md b/skills/linear-cli/references/config.md index 9dd9a79..2592877 100644 --- a/skills/linear-cli/references/config.md +++ b/skills/linear-cli/references/config.md @@ -6,7 +6,7 @@ ``` Usage: linear config -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 2a547ba..403784d 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -6,7 +6,7 @@ ``` Usage: linear document -Version: 1.9.1 +Version: 1.8.1 Description: @@ -34,7 +34,7 @@ Commands: ``` Usage: linear document list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -56,7 +56,7 @@ Options: ``` Usage: linear document view <id> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -77,7 +77,7 @@ Options: ``` Usage: linear document create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -93,7 +93,8 @@ Options: --project <project> - Attach to project (slug or ID) --issue <issue> - Attach to issue (identifier like TC-123) --icon <icon> - Document icon (emoji) - -i, --interactive - Interactive mode with prompts + -i, --interactive - Interactive mode with prompts + --no-color - Disable colored output ``` ### update @@ -102,7 +103,7 @@ Options: ``` Usage: linear document update <documentId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -116,7 +117,8 @@ Options: -c, --content <content> - New markdown content (inline) -f, --content-file <path> - Read new content from file --icon <icon> - New icon (emoji) - -e, --edit - Open current content in $EDITOR for editing + -e, --edit - Open current content in $EDITOR for editing + --no-color - Disable colored output ``` ### delete @@ -125,7 +127,7 @@ Options: ``` Usage: linear document delete [documentId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -136,6 +138,7 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) -y, --yes - Skip confirmation prompt + --no-color - Disable colored output --bulk <ids...> - Delete multiple documents by slug or ID --bulk-file <file> - Read document slugs/IDs from a file (one per line) --bulk-stdin - Read document slugs/IDs from stdin diff --git a/skills/linear-cli/references/initiative-update.md b/skills/linear-cli/references/initiative-update.md index 07264f9..32a339d 100644 --- a/skills/linear-cli/references/initiative-update.md +++ b/skills/linear-cli/references/initiative-update.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative-update -Version: 1.9.1 +Version: 1.8.1 Description: @@ -31,7 +31,7 @@ Commands: ``` Usage: linear initiative-update create <initiativeId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -44,7 +44,8 @@ Options: --body <body> - Update content (markdown) --body-file <path> - Read content from file --health <health> - Health status (onTrack, atRisk, offTrack) - -i, --interactive - Interactive mode with prompts + -i, --interactive - Interactive mode with prompts + --no-color - Disable colored output ``` ### list @@ -53,7 +54,7 @@ Options: ``` Usage: linear initiative-update list <initiativeId> -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/initiative.md b/skills/linear-cli/references/initiative.md index 32c35d3..0b0400f 100644 --- a/skills/linear-cli/references/initiative.md +++ b/skills/linear-cli/references/initiative.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative -Version: 1.9.1 +Version: 1.8.1 Description: @@ -38,7 +38,7 @@ Commands: ``` Usage: linear initiative list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -63,7 +63,7 @@ Options: ``` Usage: linear initiative view <initiativeId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -84,7 +84,7 @@ Options: ``` Usage: linear initiative create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -101,7 +101,8 @@ Options: --target-date <targetDate> - Target completion date (YYYY-MM-DD) -c, --color <color> - Color hex code (e.g., #5E6AD2) --icon <icon> - Icon name - -i, --interactive - Interactive mode (default if no flags provided) + -i, --interactive - Interactive mode (default if no flags provided) + --no-color - Disable colored output ``` ### archive @@ -110,7 +111,7 @@ Options: ``` Usage: linear initiative archive [initiativeId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -121,6 +122,7 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) -y, --force - Skip confirmation prompt + --no-color - Disable colored output --bulk <ids...> - Archive multiple initiatives by ID, slug, or name --bulk-file <file> - Read initiative IDs from a file (one per line) --bulk-stdin - Read initiative IDs from stdin @@ -132,7 +134,7 @@ Options: ``` Usage: linear initiative update <initiativeId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -149,7 +151,8 @@ Options: --target-date <targetDate> - Target completion date (YYYY-MM-DD) --color <color> - Initiative color (hex, e.g., #5E6AD2) --icon <icon> - Initiative icon name - -i, --interactive - Interactive mode for updates + -i, --interactive - Interactive mode for updates + --no-color - Disable colored output ``` ### unarchive @@ -158,7 +161,7 @@ Options: ``` Usage: linear initiative unarchive <initiativeId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -168,7 +171,8 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) - -y, --force - Skip confirmation prompt + -y, --force - Skip confirmation prompt + --no-color - Disable colored output ``` ### delete @@ -177,7 +181,7 @@ Options: ``` Usage: linear initiative delete [initiativeId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -188,6 +192,7 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) -y, --force - Skip confirmation prompt + --no-color - Disable colored output --bulk <ids...> - Delete multiple initiatives by ID, slug, or name --bulk-file <file> - Read initiative IDs from a file (one per line) --bulk-stdin - Read initiative IDs from stdin @@ -199,7 +204,7 @@ Options: ``` Usage: linear initiative add-project <initiative> <project> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -209,7 +214,8 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) - --sort-order <sortOrder> - Sort order within initiative + --sort-order <sortOrder> - Sort order within initiative + --no-color - Disable colored output ``` ### remove-project @@ -218,7 +224,7 @@ Options: ``` Usage: linear initiative remove-project <initiative> <project> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -228,5 +234,6 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) - -y, --force - Skip confirmation prompt + -y, --force - Skip confirmation prompt + --no-color - Disable colored output ``` diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index b01d690..3d06858 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -6,7 +6,7 @@ ``` Usage: linear issue -Version: 1.9.1 +Version: 1.8.1 Description: @@ -32,8 +32,7 @@ Commands: create - Create a linear issue update [issueId] - Update a linear issue comment - Manage issue comments - attach <issueId> <filepath> - Attach a file to an issue - relation - Manage issue relations (dependencies) + attach <issueId> <filepath> - Attach a file to an issue ``` ## Subcommands @@ -44,7 +43,7 @@ Commands: ``` Usage: linear issue id -Version: 1.9.1 +Version: 1.8.1 Description: @@ -62,7 +61,7 @@ Options: ``` Usage: linear issue list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -93,7 +92,7 @@ Options: ``` Usage: linear issue title [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -111,7 +110,7 @@ Options: ``` Usage: linear issue start [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -133,7 +132,7 @@ Options: ``` Usage: linear issue view [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -157,7 +156,7 @@ Options: ``` Usage: linear issue url [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -175,7 +174,7 @@ Options: ``` Usage: linear issue describe [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -194,7 +193,7 @@ Options: ``` Usage: linear issue commits [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -212,7 +211,7 @@ Options: ``` Usage: linear issue pull-request [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -235,7 +234,7 @@ Options: ``` Usage: linear issue delete [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -246,6 +245,7 @@ Options: -h, --help - Show this help. -w, --workspace <slug> - Target workspace (uses credentials) -y, --confirm - Skip confirmation prompt + --no-color - Disable colored output --bulk <ids...> - Delete multiple issues by identifier (e.g., TC-123 TC-124) --bulk-file <file> - Read issue identifiers from a file (one per line) --bulk-stdin - Read issue identifiers from stdin @@ -257,7 +257,7 @@ Options: ``` Usage: linear issue create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -279,6 +279,7 @@ Options: --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) --no-use-default-template - Do not use default template for the issue + --no-color - Disable colored output --no-interactive - Disable interactive prompts -t, --title <title> - Title of the issue ``` @@ -289,7 +290,7 @@ Options: ``` Usage: linear issue update [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -309,6 +310,7 @@ Options: --team <team> - Team associated with the issue (if not your default team) --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) + --no-color - Disable colored output -t, --title <title> - Title of the issue ``` @@ -318,7 +320,7 @@ Options: ``` Usage: linear issue comment -Version: 1.9.1 +Version: 1.8.1 Description: @@ -342,7 +344,7 @@ Commands: ``` Usage: linear issue comment add [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -361,7 +363,7 @@ Options: ``` Usage: linear issue comment update <commentId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -378,7 +380,7 @@ Options: ``` Usage: linear issue comment list [issueId] -Version: 1.9.1 +Version: 1.8.1 Description: @@ -397,7 +399,7 @@ Options: ``` Usage: linear issue attach <issueId> <filepath> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -410,84 +412,3 @@ Options: -t, --title <title> - Custom title for the attachment -c, --comment <body> - Add a comment body linked to the attachment ``` - -### relation - -> Manage issue relations (dependencies) - -``` -Usage: linear issue relation -Version: 1.9.1 - -Description: - - Manage issue relations (dependencies) - -Options: - - -h, --help - Show this help. - -w, --workspace <slug> - Target workspace (uses credentials) - -Commands: - - add <issueId> <relationType> <relatedIssueId> - Add a relation between two issues - delete <issueId> <relationType> <relatedIssueId> - Delete a relation between two issues - list [issueId] - List relations for an issue -``` - -#### relation subcommands - -##### add - -``` -Usage: linear issue relation add <issueId> <relationType> <relatedIssueId> -Version: 1.9.1 - -Description: - - Add a relation between two issues - -Options: - - -h, --help - Show this help. - -w, --workspace <slug> - Target workspace (uses credentials) - -Examples: - - Mark issue as blocked by another linear issue relation add ENG-123 blocked-by ENG-100 - Mark issue as blocking another linear issue relation add ENG-123 blocks ENG-456 - Mark issues as related linear issue relation add ENG-123 related ENG-456 - Mark issue as duplicate linear issue relation add ENG-123 duplicate ENG-100 -``` - -##### delete - -``` -Usage: linear issue relation delete <issueId> <relationType> <relatedIssueId> -Version: 1.9.1 - -Description: - - Delete a relation between two issues - -Options: - - -h, --help - Show this help. - -w, --workspace <slug> - Target workspace (uses credentials) -``` - -##### list - -``` -Usage: linear issue relation list [issueId] -Version: 1.9.1 - -Description: - - List relations for an issue - -Options: - - -h, --help - Show this help. - -w, --workspace <slug> - Target workspace (uses credentials) -``` diff --git a/skills/linear-cli/references/label.md b/skills/linear-cli/references/label.md index ec500c0..e46c6cd 100644 --- a/skills/linear-cli/references/label.md +++ b/skills/linear-cli/references/label.md @@ -6,7 +6,7 @@ ``` Usage: linear label -Version: 1.9.1 +Version: 1.8.1 Description: @@ -32,7 +32,7 @@ Commands: ``` Usage: linear label list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -53,7 +53,7 @@ Options: ``` Usage: linear label create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -76,7 +76,7 @@ Options: ``` Usage: linear label delete <nameOrId> -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/milestone.md b/skills/linear-cli/references/milestone.md index 008fca6..7e6593b 100644 --- a/skills/linear-cli/references/milestone.md +++ b/skills/linear-cli/references/milestone.md @@ -6,7 +6,7 @@ ``` Usage: linear milestone -Version: 1.9.1 +Version: 1.8.1 Description: @@ -34,7 +34,7 @@ Commands: ``` Usage: linear milestone list --project <projectId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -53,7 +53,7 @@ Options: ``` Usage: linear milestone view <milestoneId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -71,7 +71,7 @@ Options: ``` Usage: linear milestone create --project <projectId> --name <name> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -93,7 +93,7 @@ Options: ``` Usage: linear milestone update <id> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -115,7 +115,7 @@ Options: ``` Usage: linear milestone delete <id> -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/project-update.md b/skills/linear-cli/references/project-update.md index c722c10..3662ec7 100644 --- a/skills/linear-cli/references/project-update.md +++ b/skills/linear-cli/references/project-update.md @@ -6,7 +6,7 @@ ``` Usage: linear project-update -Version: 1.9.1 +Version: 1.8.1 Description: @@ -31,7 +31,7 @@ Commands: ``` Usage: linear project-update create <projectId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -44,7 +44,8 @@ Options: --body <body> - Update content (inline) --body-file <path> - Read content from file --health <health> - Project health status (onTrack, atRisk, offTrack) - -i, --interactive - Interactive mode with prompts + -i, --interactive - Interactive mode with prompts + --no-color - Disable colored output ``` ### list @@ -53,7 +54,7 @@ Options: ``` Usage: linear project-update list <projectId> -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index b0191e4..ed26e26 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -6,7 +6,7 @@ ``` Usage: linear project -Version: 1.9.1 +Version: 1.8.1 Description: @@ -32,7 +32,7 @@ Commands: ``` Usage: linear project list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -55,7 +55,7 @@ Options: ``` Usage: linear project view <projectId> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -75,7 +75,7 @@ Options: ``` Usage: linear project create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -93,5 +93,6 @@ Options: --start-date <startDate> - Start date (YYYY-MM-DD) --target-date <targetDate> - Target completion date (YYYY-MM-DD) --initiative <initiative> - Add to initiative immediately (ID, slug, or name) - -i, --interactive - Interactive mode (default if no flags provided) + -i, --interactive - Interactive mode (default if no flags provided) + --no-color - Disable colored output ``` diff --git a/skills/linear-cli/references/schema.md b/skills/linear-cli/references/schema.md index 2370ddc..549ed7d 100644 --- a/skills/linear-cli/references/schema.md +++ b/skills/linear-cli/references/schema.md @@ -6,7 +6,7 @@ ``` Usage: linear schema -Version: 1.9.1 +Version: 1.8.1 Description: diff --git a/skills/linear-cli/references/team.md b/skills/linear-cli/references/team.md index c7c49e9..2d03925 100644 --- a/skills/linear-cli/references/team.md +++ b/skills/linear-cli/references/team.md @@ -6,7 +6,7 @@ ``` Usage: linear team -Version: 1.9.1 +Version: 1.8.1 Description: @@ -35,7 +35,7 @@ Commands: ``` Usage: linear team create -Version: 1.9.1 +Version: 1.8.1 Description: @@ -49,6 +49,7 @@ Options: -d, --description <description> - Description of the team -k, --key <key> - Team key (if not provided, will be generated from name) --private - Make the team private + --no-color - Disable colored output --no-interactive - Disable interactive prompts ``` @@ -58,7 +59,7 @@ Options: ``` Usage: linear team delete <teamKey> -Version: 1.9.1 +Version: 1.8.1 Description: @@ -78,7 +79,7 @@ Options: ``` Usage: linear team list -Version: 1.9.1 +Version: 1.8.1 Description: @@ -98,7 +99,7 @@ Options: ``` Usage: linear team id -Version: 1.9.1 +Version: 1.8.1 Description: @@ -116,7 +117,7 @@ Options: ``` Usage: linear team autolinks -Version: 1.9.1 +Version: 1.8.1 Description: @@ -134,7 +135,7 @@ Options: ``` Usage: linear team members [teamKey] -Version: 1.9.1 +Version: 1.8.1 Description: From 02a9fad1438ab3aa2774b9e31f78ace2d1e892d2 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Mon, 2 Feb 2026 22:52:35 -0800 Subject: [PATCH 4/9] feat: add --paginate, --silent, @file variables, and GraphQL error exit codes --- src/commands/api.ts | 268 ++++++++++++++++--- test/commands/__snapshots__/api.test.ts.snap | 37 ++- test/commands/api.test.ts | 171 ++++++++++++ test/commands/fixtures/api-filter.json | 1 + 4 files changed, 440 insertions(+), 37 deletions(-) create mode 100644 test/commands/fixtures/api-filter.json diff --git a/src/commands/api.ts b/src/commands/api.ts index 04475ae..9c487f3 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,7 +1,7 @@ import { Command } from "@cliffy/command" import denoConfig from "../../deno.json" with { type: "json" } import { getGraphQLEndpoint, getResolvedApiKey } from "../utils/graphql.ts" -import { handleError, ValidationError } from "../utils/errors.ts" +import { CliError, handleError, ValidationError } from "../utils/errors.ts" export const apiCommand = new Command() .name("api") @@ -14,13 +14,18 @@ export const apiCommand = new Command() ) .option( "-F, --typed-field <field:string>", - "Typed variable in key=value format (coerces booleans, numbers, null)", + "Typed variable in key=value format (coerces booleans, numbers, null; @file reads from path)", { collect: true }, ) + .option("--paginate", "Automatically fetch all pages using cursor pagination") + .option( + "--silent", + "Suppress response output (exit code still reflects errors)", + ) .action(async (options, query?: string) => { try { const resolvedQuery = await resolveQuery(query) - const variables = buildVariables(options.field, options.typedField) + const variables = await buildVariables(options.field, options.typedField) const apiKey = getResolvedApiKey() if (!apiKey) { @@ -33,43 +38,202 @@ export const apiCommand = new Command() ) } - const body: Record<string, unknown> = { query: resolvedQuery } - if (Object.keys(variables).length > 0) { - body.variables = variables - } - - const response = await fetch(getGraphQLEndpoint(), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: apiKey, - "User-Agent": `schpet-linear-cli/${denoConfig.version}`, - }, - body: JSON.stringify(body), - }) - - const text = await response.text() - - if (response.status >= 400) { - console.error(text) - Deno.exit(1) + const headers = { + "Content-Type": "application/json", + Authorization: apiKey, + "User-Agent": `schpet-linear-cli/${denoConfig.version}`, } - if (Deno.stdout.isTerminal()) { - try { - const parsed = JSON.parse(text) - console.log(JSON.stringify(parsed, null, 2)) - } catch { - console.log(text) - } + if (options.paginate) { + await executePaginated( + resolvedQuery, + variables, + headers, + options.silent ?? false, + ) } else { - Deno.stdout.writeSync(new TextEncoder().encode(text)) + await executeSingle( + resolvedQuery, + variables, + headers, + options.silent ?? false, + ) } } catch (error) { handleError(error, "API request failed") } }) +async function executeSingle( + query: string, + variables: Record<string, unknown>, + headers: Record<string, string>, + silent: boolean, +): Promise<void> { + const body: Record<string, unknown> = { query } + if (Object.keys(variables).length > 0) { + body.variables = variables + } + + const response = await fetch(getGraphQLEndpoint(), { + method: "POST", + headers, + body: JSON.stringify(body), + }) + + const text = await response.text() + + if (response.status >= 400) { + console.error(text) + Deno.exit(1) + } + + let hasGraphQLErrors = false + try { + const parsed = JSON.parse(text) + hasGraphQLErrors = Array.isArray(parsed.errors) && parsed.errors.length > 0 + if (!silent) { + outputJSON(parsed, text) + } + } catch { + if (!silent) { + console.log(text) + } + } + + if (hasGraphQLErrors) { + Deno.exit(1) + } +} + +async function executePaginated( + query: string, + variables: Record<string, unknown>, + headers: Record<string, string>, + silent: boolean, +): Promise<void> { + const allNodes: unknown[] = [] + let cursor: string | undefined + + for (;;) { + const vars = { ...variables, endCursor: cursor ?? null } + + const body: Record<string, unknown> = { query } + if (Object.keys(vars).length > 0) { + body.variables = vars + } + + const response = await fetch(getGraphQLEndpoint(), { + method: "POST", + headers, + body: JSON.stringify(body), + }) + + const text = await response.text() + + if (response.status >= 400) { + console.error(text) + Deno.exit(1) + } + + let parsed: Record<string, unknown> + try { + parsed = JSON.parse(text) + } catch { + if (!silent) { + console.log(text) + } + Deno.exit(1) + } + + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { + if (!silent) { + outputJSON(parsed, text) + } + Deno.exit(1) + } + + const pageResult = extractPageInfo(parsed) + + if (!pageResult) { + if (!silent) { + outputJSON(parsed, text) + } + return + } + + allNodes.push(...pageResult.nodes) + + if (!pageResult.hasNextPage || !pageResult.endCursor) { + break + } + + cursor = pageResult.endCursor + } + + if (!silent) { + outputJSON(allNodes, JSON.stringify(allNodes)) + } +} + +interface PageResult { + nodes: unknown[] + hasNextPage: boolean + endCursor: string | null + connectionPath: string[] +} + +function extractPageInfo( + data: Record<string, unknown>, +): PageResult | null { + return findPageInfo(data, []) +} + +function findPageInfo( + obj: unknown, + path: string[], +): PageResult | null { + if (obj == null || typeof obj !== "object") return null + + const record = obj as Record<string, unknown> + + if ( + "pageInfo" in record && + "nodes" in record && + record.pageInfo != null && + typeof record.pageInfo === "object" + ) { + const pageInfo = record.pageInfo as Record<string, unknown> + return { + nodes: Array.isArray(record.nodes) ? record.nodes : [], + hasNextPage: Boolean(pageInfo.hasNextPage), + endCursor: (pageInfo.endCursor as string) ?? null, + connectionPath: path, + } + } + + for (const [key, value] of Object.entries(record)) { + const result = findPageInfo(value, [...path, key]) + if (result) return result + } + + return null +} + +function outputJSON(parsed: unknown, rawText: string): void { + if (Deno.stdout.isTerminal()) { + try { + console.log(JSON.stringify(parsed, null, 2)) + } catch { + console.log(rawText) + } + } else { + Deno.stdout.writeSync(new TextEncoder().encode( + typeof parsed === "string" ? rawText : JSON.stringify(parsed), + )) + } +} + async function resolveQuery(positionalArg?: string): Promise<string> { if (positionalArg && positionalArg !== "-") { return positionalArg @@ -124,10 +288,10 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array { return combined } -function buildVariables( +async function buildVariables( fields?: string[], typedFields?: string[], -): Record<string, unknown> { +): Promise<Record<string, unknown>> { const variables: Record<string, unknown> = {} if (fields) { @@ -140,7 +304,7 @@ function buildVariables( if (typedFields) { for (const entry of typedFields) { const [key, rawValue] = parseFieldEntry(entry) - variables[key] = coerceValue(rawValue) + variables[key] = await resolveTypedValue(rawValue) } } @@ -160,6 +324,42 @@ function parseFieldEntry(entry: string): [string, string] { return [entry.slice(0, eqIndex), entry.slice(eqIndex + 1)] } +async function resolveTypedValue(value: string): Promise<unknown> { + if (value === "@-") { + const content = await readAllStdin() + if (content == null) { + throw new ValidationError("No data on stdin for @- value") + } + return parseJSONOrString(content) + } + + if (value.startsWith("@")) { + const filePath = value.slice(1) + try { + const content = await Deno.readTextFile(filePath) + return parseJSONOrString(content.trim()) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new ValidationError(`File not found: ${filePath}`) + } + throw new CliError( + `Failed to read file: ${filePath}`, + { cause: error }, + ) + } + } + + return coerceValue(value) +} + +function parseJSONOrString(content: string): unknown { + try { + return JSON.parse(content) + } catch { + return content + } +} + function coerceValue(value: string): unknown { if (value === "true") return true if (value === "false") return false diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index e316fc4..70eaf2c 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -11,9 +11,12 @@ Description: Options: - -h, --help - Show this help. - -f, --field <field> - String variable in key=value format - -F, --typed-field <field> - Typed variable in key=value format (coerces booleans, numbers, null) + -h, --help - Show this help. + -f, --field <field> - String variable in key=value format + -F, --typed-field <field> - Typed variable in key=value format (coerces booleans, numbers, null; @file reads + from path) + --paginate - Automatically fetch all pages using cursor pagination + --silent - Suppress response output (exit code still reflects errors) " stderr: @@ -59,3 +62,31 @@ stderr: Variables must be in key=value format, e.g. -f teamId=abc " `; + +snapshot[`API Command - GraphQL Errors Exit Non-Zero 1`] = ` +stdout: +\`{"data":null,"errors":[{"message":"Cannot query field 'nonexistent' on type 'Query'"}]}\` +stderr: +"" +`; + +snapshot[`API Command - Silent Flag 1`] = ` +stdout: +"" +stderr: +"" +`; + +snapshot[`API Command - Typed Variable From File 1`] = ` +stdout: +'{"data":{"teams":{"nodes":[{"name":"Backend"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Paginate 1`] = ` +stdout: +'[{"title":"Issue 1"},{"title":"Issue 2"},{"title":"Issue 3"}]' +stderr: +"" +`; diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 7d1a3e9..57d7d0f 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -166,3 +166,174 @@ await cliffySnapshotTest({ } }, }) + +await cliffySnapshotTest({ + name: "API Command - GraphQL Errors Exit Non-Zero", + meta: import.meta, + colors: false, + args: ["query BadQuery { nonexistent { id } }"], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "BadQuery", + response: { + data: null, + errors: [ + { + message: "Cannot query field 'nonexistent' on type 'Query'", + }, + ], + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Silent Flag", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--silent", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { id: "user-1" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Typed Variable From File", + meta: import.meta, + colors: false, + args: [ + "query GetTeam($filter: TeamFilter!) { teams(filter: $filter) { nodes { name } } }", + "-F", + `filter=@${Deno.cwd()}/test/commands/fixtures/api-filter.json`, + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeam", + response: { + data: { + teams: { + nodes: [{ name: "Backend" }], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($endCursor: String) { issues(first: 2, after: $endCursor) { nodes { title } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { endCursor: null }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue 1" }, + { title: "Issue 2" }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "cursor-1", + }, + }, + }, + }, + }, + { + queryName: "GetIssues", + variables: { endCursor: "cursor-1" }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue 3" }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/fixtures/api-filter.json b/test/commands/fixtures/api-filter.json new file mode 100644 index 0000000..5f742f6 --- /dev/null +++ b/test/commands/fixtures/api-filter.json @@ -0,0 +1 @@ +{"name": {"eq": "Backend"}} From 9b75852878a536ab2c69904f4da612d430151e78 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Mon, 2 Feb 2026 23:00:28 -0800 Subject: [PATCH 5/9] feat: expand test coverage and fix pagination variable name Use `after` instead of `endCursor` as the injected pagination variable to match Linear's GraphQL schema conventions. Add tests for: no API key, null/false coercion, values containing equals signs, single-page pagination, non-connection pagination with --paginate, and file-not-found errors for -F @path. --- src/commands/api.ts | 2 +- test/commands/__snapshots__/api.test.ts.snap | 45 +++++ test/commands/api.test.ts | 195 ++++++++++++++++++- 3 files changed, 238 insertions(+), 4 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 9c487f3..1053176 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -116,7 +116,7 @@ async function executePaginated( let cursor: string | undefined for (;;) { - const vars = { ...variables, endCursor: cursor ?? null } + const vars = { ...variables, after: cursor ?? null } const body: Record<string, unknown> = { query } if (Object.keys(vars).length > 0) { diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index 70eaf2c..610eab9 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -90,3 +90,48 @@ stdout: stderr: "" `; + +snapshot[`API Command - No API Key 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: No API key configured + Set LINEAR_API_KEY, add api_key to .linear.toml, or run \`linear auth login\`. +" +`; + +snapshot[`API Command - Typed Variable Coercion Null And False 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[]}}}' +stderr: +"" +`; + +snapshot[`API Command - Value Containing Equals Sign 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Test"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Paginate Single Page 1`] = ` +stdout: +'[{"title":"Only Issue"}]' +stderr: +"" +`; + +snapshot[`API Command - Paginate Non-Connection Query 1`] = ` +stdout: +'{"data":{"viewer":{"id":"user-1","name":"Test"}}}' +stderr: +"" +`; + +snapshot[`API Command - File Not Found For Typed Variable 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: File not found: /nonexistent/path.json +" +`; diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 57d7d0f..aaf1acc 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -281,7 +281,7 @@ await cliffySnapshotTest({ meta: import.meta, colors: false, args: [ - "query GetIssues($endCursor: String) { issues(first: 2, after: $endCursor) { nodes { title } pageInfo { hasNextPage endCursor } } }", + "query GetIssues($after: String) { issues(first: 2, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } }", "--paginate", ], denoArgs, @@ -289,7 +289,7 @@ await cliffySnapshotTest({ const server = new MockLinearServer([ { queryName: "GetIssues", - variables: { endCursor: null }, + variables: { after: null }, response: { data: { issues: { @@ -307,7 +307,7 @@ await cliffySnapshotTest({ }, { queryName: "GetIssues", - variables: { endCursor: "cursor-1" }, + variables: { after: "cursor-1" }, response: { data: { issues: { @@ -337,3 +337,192 @@ await cliffySnapshotTest({ } }, }) + +await cliffySnapshotTest({ + name: "API Command - No API Key", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id } }"], + denoArgs, + canFail: true, + async fn() { + Deno.env.delete("LINEAR_API_KEY") + await apiCommand.parse() + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Typed Variable Coercion Null And False", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($active: Boolean, $label: String) { issues(filter: { active: $active, label: $label }) { nodes { title } } }", + "-F", + "active=false", + "-F", + "label=null", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { active: false, label: null }, + response: { + data: { + issues: { nodes: [] }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Value Containing Equals Sign", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($filter: String!) { issues(filter: $filter) { nodes { title } } }", + "-f", + "filter=name eq backend", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { filter: "name eq backend" }, + response: { + data: { + issues: { nodes: [{ title: "Test" }] }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate Single Page", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($after: String) { issues(first: 10, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [ + { title: "Only Issue" }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate Non-Connection Query", + meta: import.meta, + colors: false, + args: [ + "query GetViewer($after: String) { viewer { id name } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { id: "user-1", name: "Test" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - File Not Found For Typed Variable", + meta: import.meta, + colors: false, + args: [ + "query GetTeam { team { name } }", + "-F", + "filter=@/nonexistent/path.json", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) From e200d376e92733687b2ec130565818b9c5ab5592 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Wed, 4 Feb 2026 00:50:24 -0800 Subject: [PATCH 6/9] feat: replace -f/-F with --variable/--variables-json Redesign the api subcommand variable interface for GraphQL-native semantics: - --variable key=value (repeated) with type coercion and @file support - --variables-json for complex nested objects - --variable takes precedence over --variables-json on key conflicts - Custom Cliffy Type for key=value parsing instead of hand-rolled split - Fix deno fmt issue on api-filter.json fixture --- deno.lock | 1 + skills/linear-cli/SKILL.md | 12 +- skills/linear-cli/SKILL.template.md | 12 +- skills/linear-cli/references/api.md | 12 +- src/commands/api.ts | 98 +++++++---- test/commands/__snapshots__/api.test.ts.snap | 76 +++++++-- test/commands/api.test.ts | 155 ++++++++++++++++-- test/commands/fixtures/api-filter.json | 2 +- .../__snapshots__/issue-view.test.ts.snap | 16 +- 9 files changed, 300 insertions(+), 84 deletions(-) diff --git a/deno.lock b/deno.lock index a4c571e..5e750bd 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,7 @@ "jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/ansi@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/command@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/command@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/flags@1.0.0-rc.7": "1.0.0-rc.7", "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index 76a6b56..368fc3d 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -90,11 +90,15 @@ grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" # Simple query linear api '{ viewer { id name email } }' -# Query with string variables -linear api 'query($teamId: String!) { team(id: $teamId) { name } }' -f teamId=abc123 +# Query with variables (coerces types: booleans, numbers, null) +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' --variable teamId=abc123 -# Typed variables (numbers, booleans) -linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' -F first=5 +# Numeric and boolean variables +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' --variable first=5 + +# Complex variables via JSON +linear api 'query($filter: IssueFilter!) { issues(filter: $filter) { nodes { title } } }' \ + --variables-json '{"filter": {"state": {"name": {"eq": "In Progress"}}}}' # Read query from stdin echo '{ viewer { id } }' | linear api diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index 4feb87a..0bb0823 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -64,11 +64,15 @@ grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" # Simple query linear api '{ viewer { id name email } }' -# Query with string variables -linear api 'query($teamId: String!) { team(id: $teamId) { name } }' -f teamId=abc123 +# Query with variables (coerces types: booleans, numbers, null) +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' --variable teamId=abc123 -# Typed variables (numbers, booleans) -linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' -F first=5 +# Numeric and boolean variables +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' --variable first=5 + +# Complex variables via JSON +linear api 'query($filter: IssueFilter!) { issues(filter: $filter) { nodes { title } } }' \ + --variables-json '{"filter": {"state": {"name": {"eq": "In Progress"}}}}' # Read query from stdin echo '{ viewer { id } }' | linear api diff --git a/skills/linear-cli/references/api.md b/skills/linear-cli/references/api.md index 2fef081..919885e 100644 --- a/skills/linear-cli/references/api.md +++ b/skills/linear-cli/references/api.md @@ -6,7 +6,7 @@ ``` Usage: linear api [query] -Version: 1.9.1 +Version: 1.9.1 Description: @@ -14,8 +14,10 @@ Description: Options: - -h, --help - Show this help. - -w, --workspace <slug> - Target workspace (uses credentials) - -f, --field <field> - String variable in key=value format - -F, --typed-field <field> - Typed variable in key=value format (coerces booleans, numbers, null) + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) + --variable <variable> - Variable in key=value format (coerces booleans, numbers, null; @file reads from path) + --variables-json <json> - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Automatically fetch all pages using cursor pagination + --silent - Suppress response output (exit code still reflects errors) ``` diff --git a/src/commands/api.ts b/src/commands/api.ts index 1053176..bb1edd7 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,21 +1,42 @@ -import { Command } from "@cliffy/command" +import { + type ArgumentValue, + Command, + Type, + ValidationError, +} from "@cliffy/command" import denoConfig from "../../deno.json" with { type: "json" } import { getGraphQLEndpoint, getResolvedApiKey } from "../utils/graphql.ts" -import { CliError, handleError, ValidationError } from "../utils/errors.ts" +import { + CliError, + handleError, + ValidationError as AppValidationError, +} from "../utils/errors.ts" + +class VariableType extends Type<[string, string]> { + parse({ value }: ArgumentValue): [string, string] { + const [key, ...rest] = value.split("=") + if (rest.length === 0) { + throw new ValidationError( + `Invalid variable format: ${value}. Variables must be in key=value format, e.g. --variable teamId=abc`, + ) + } + return [key, rest.join("=")] + } +} export const apiCommand = new Command() .name("api") .description("Make a raw GraphQL API request") + .type("variable", new VariableType()) .arguments("[query:string]") .option( - "-f, --field <field:string>", - "String variable in key=value format", + "--variable <variable:variable>", + "Variable in key=value format (coerces booleans, numbers, null; @file reads from path)", { collect: true }, ) .option( - "-F, --typed-field <field:string>", - "Typed variable in key=value format (coerces booleans, numbers, null; @file reads from path)", - { collect: true }, + "--variables-json <json:string>", + "JSON object of variables (merged with --variable, which takes precedence)", ) .option("--paginate", "Automatically fetch all pages using cursor pagination") .option( @@ -25,11 +46,14 @@ export const apiCommand = new Command() .action(async (options, query?: string) => { try { const resolvedQuery = await resolveQuery(query) - const variables = await buildVariables(options.field, options.typedField) + const variables = await buildVariables( + options.variable, + options.variablesJson, + ) const apiKey = getResolvedApiKey() if (!apiKey) { - throw new ValidationError( + throw new AppValidationError( "No API key configured", { suggestion: @@ -250,7 +274,7 @@ async function resolveQuery(positionalArg?: string): Promise<string> { } } - throw new ValidationError("No query provided", { + throw new AppValidationError("No query provided", { suggestion: "Provide a query as an argument: linear api '{ viewer { id } }'\n Or pipe from stdin: echo '{ viewer { id } }' | linear api", }) @@ -289,21 +313,40 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array { } async function buildVariables( - fields?: string[], - typedFields?: string[], + variableEntries?: [string, string][], + variablesJson?: string, ): Promise<Record<string, unknown>> { const variables: Record<string, unknown> = {} - if (fields) { - for (const entry of fields) { - const [key, value] = parseFieldEntry(entry) - variables[key] = value + if (variablesJson) { + let parsed: unknown + try { + parsed = JSON.parse(variablesJson) + } catch { + throw new AppValidationError( + `Invalid JSON for --variables-json: ${variablesJson}`, + { + suggestion: + 'Provide a valid JSON object, e.g. --variables-json \'{"key": "value"}\'', + }, + ) + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new AppValidationError( + `--variables-json must be a JSON object, got ${ + Array.isArray(parsed) ? "array" : typeof parsed + }`, + { + suggestion: + 'Provide a JSON object, e.g. --variables-json \'{"key": "value"}\'', + }, + ) } + Object.assign(variables, parsed) } - if (typedFields) { - for (const entry of typedFields) { - const [key, rawValue] = parseFieldEntry(entry) + if (variableEntries) { + for (const [key, rawValue] of variableEntries) { variables[key] = await resolveTypedValue(rawValue) } } @@ -311,24 +354,11 @@ async function buildVariables( return variables } -function parseFieldEntry(entry: string): [string, string] { - const eqIndex = entry.indexOf("=") - if (eqIndex === -1) { - throw new ValidationError( - `Invalid variable format: ${entry}`, - { - suggestion: "Variables must be in key=value format, e.g. -f teamId=abc", - }, - ) - } - return [entry.slice(0, eqIndex), entry.slice(eqIndex + 1)] -} - async function resolveTypedValue(value: string): Promise<unknown> { if (value === "@-") { const content = await readAllStdin() if (content == null) { - throw new ValidationError("No data on stdin for @- value") + throw new AppValidationError("No data on stdin for @- value") } return parseJSONOrString(content) } @@ -340,7 +370,7 @@ async function resolveTypedValue(value: string): Promise<unknown> { return parseJSONOrString(content.trim()) } catch (error) { if (error instanceof Deno.errors.NotFound) { - throw new ValidationError(`File not found: ${filePath}`) + throw new AppValidationError(`File not found: ${filePath}`) } throw new CliError( `Failed to read file: ${filePath}`, diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index 610eab9..1b5dbb5 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -11,12 +11,12 @@ Description: Options: - -h, --help - Show this help. - -f, --field <field> - String variable in key=value format - -F, --typed-field <field> - Typed variable in key=value format (coerces booleans, numbers, null; @file reads - from path) - --paginate - Automatically fetch all pages using cursor pagination - --silent - Suppress response output (exit code still reflects errors) + -h, --help - Show this help. + --variable <variable> - Variable in key=value format (coerces booleans, numbers, null; @file reads from + path) + --variables-json <json> - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Automatically fetch all pages using cursor pagination + --silent - Suppress response output (exit code still reflects errors) " stderr: @@ -30,14 +30,14 @@ stderr: "" `; -snapshot[`API Command - String Variables 1`] = ` +snapshot[`API Command - Variable Flag 1`] = ` stdout: '{"data":{"team":{"name":"Backend Team"}}}' stderr: "" `; -snapshot[`API Command - Typed Variables 1`] = ` +snapshot[`API Command - Variable Type Coercion 1`] = ` stdout: '{"data":{"issues":{"nodes":[{"title":"Issue One"},{"title":"Issue Two"}]}}}' stderr: @@ -56,10 +56,26 @@ stderr: snapshot[`API Command - Invalid Variable Format 1`] = ` stdout: -"" +" +Usage: api [query] + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + --variable <variable> - Variable in key=value format (coerces booleans, numbers, null; @file reads from + path) + --variables-json <json> - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Automatically fetch all pages using cursor pagination + --silent - Suppress response output (exit code still reflects errors) + +" stderr: -"✗ API request failed: Invalid variable format: badformat - Variables must be in key=value format, e.g. -f teamId=abc +" error: Invalid variable format: badformat. Variables must be in key=value format, e.g. --variable teamId=abc + " `; @@ -77,7 +93,7 @@ stderr: "" `; -snapshot[`API Command - Typed Variable From File 1`] = ` +snapshot[`API Command - Variable From File 1`] = ` stdout: '{"data":{"teams":{"nodes":[{"name":"Backend"}]}}}' stderr: @@ -100,7 +116,7 @@ stderr: " `; -snapshot[`API Command - Typed Variable Coercion Null And False 1`] = ` +snapshot[`API Command - Variable Coercion Null And False 1`] = ` stdout: '{"data":{"issues":{"nodes":[]}}}' stderr: @@ -128,10 +144,42 @@ stderr: "" `; -snapshot[`API Command - File Not Found For Typed Variable 1`] = ` +snapshot[`API Command - File Not Found For Variable 1`] = ` stdout: "" stderr: "✗ API request failed: File not found: /nonexistent/path.json " `; + +snapshot[`API Command - Variables JSON 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"},{"title":"Issue Two"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Variables JSON Malformed 1`] = ` +stdout: +"" +stderr: +\`✗ API request failed: Invalid JSON for --variables-json: {bad json + Provide a valid JSON object, e.g. --variables-json '{"key": "value"}' +\` +`; + +snapshot[`API Command - Variables JSON Non-Object 1`] = ` +stdout: +"" +stderr: +\`✗ API request failed: --variables-json must be a JSON object, got array + Provide a JSON object, e.g. --variables-json '{"key": "value"}' +\` +`; + +snapshot[`API Command - Variable Overrides Variables JSON 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"}]}}}' +stderr: +"" +`; diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index aaf1acc..369a8cc 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -51,12 +51,12 @@ await cliffySnapshotTest({ }) await cliffySnapshotTest({ - name: "API Command - String Variables", + name: "API Command - Variable Flag", meta: import.meta, colors: false, args: [ "query GetTeam($teamId: String!) { team(id: $teamId) { name } }", - "-f", + "--variable", "teamId=abc123", ], denoArgs, @@ -90,14 +90,14 @@ await cliffySnapshotTest({ }) await cliffySnapshotTest({ - name: "API Command - Typed Variables", + name: "API Command - Variable Type Coercion", meta: import.meta, colors: false, args: [ "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", - "-F", + "--variable", "first=5", - "-F", + "--variable", "active=true", ], denoArgs, @@ -154,7 +154,7 @@ await cliffySnapshotTest({ name: "API Command - Invalid Variable Format", meta: import.meta, colors: false, - args: ["query GetViewer { viewer { id } }", "-f", "badformat"], + args: ["query GetViewer { viewer { id } }", "--variable", "badformat"], denoArgs, canFail: true, async fn() { @@ -239,12 +239,12 @@ await cliffySnapshotTest({ }) await cliffySnapshotTest({ - name: "API Command - Typed Variable From File", + name: "API Command - Variable From File", meta: import.meta, colors: false, args: [ "query GetTeam($filter: TeamFilter!) { teams(filter: $filter) { nodes { name } } }", - "-F", + "--variable", `filter=@${Deno.cwd()}/test/commands/fixtures/api-filter.json`, ], denoArgs, @@ -352,14 +352,14 @@ await cliffySnapshotTest({ }) await cliffySnapshotTest({ - name: "API Command - Typed Variable Coercion Null And False", + name: "API Command - Variable Coercion Null And False", meta: import.meta, colors: false, args: [ "query GetIssues($active: Boolean, $label: String) { issues(filter: { active: $active, label: $label }) { nodes { title } } }", - "-F", + "--variable", "active=false", - "-F", + "--variable", "label=null", ], denoArgs, @@ -396,7 +396,7 @@ await cliffySnapshotTest({ colors: false, args: [ "query GetIssues($filter: String!) { issues(filter: $filter) { nodes { title } } }", - "-f", + "--variable", "filter=name eq backend", ], denoArgs, @@ -507,12 +507,12 @@ await cliffySnapshotTest({ }) await cliffySnapshotTest({ - name: "API Command - File Not Found For Typed Variable", + name: "API Command - File Not Found For Variable", meta: import.meta, colors: false, args: [ "query GetTeam { team { name } }", - "-F", + "--variable", "filter=@/nonexistent/path.json", ], denoArgs, @@ -526,3 +526,130 @@ await cliffySnapshotTest({ } }, }) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "--variables-json", + '{"first": 5, "active": true}', + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: true }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + { title: "Issue Two" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON Malformed", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--variables-json", + "{bad json", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON Non-Object", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--variables-json", + "[1, 2, 3]", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Overrides Variables JSON", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "--variables-json", + '{"first": 10, "active": false}', + "--variable", + "first=5", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: false }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/fixtures/api-filter.json b/test/commands/fixtures/api-filter.json index 5f742f6..0425a9d 100644 --- a/test/commands/fixtures/api-filter.json +++ b/test/commands/fixtures/api-filter.json @@ -1 +1 @@ -{"name": {"eq": "Backend"}} +{ "name": { "eq": "Backend" } } diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 3aee718..326f01e 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -86,6 +86,14 @@ stderr: "" `; +snapshot[`Issue View Command - Issue Not Found 1`] = ` +stdout: +"" +stderr: +"✗ Failed to view issue: Issue not found: TEST-999 +" +`; + snapshot[`Issue View Command - JSON Output No Comments 1`] = ` stdout: '{ @@ -154,14 +162,6 @@ stderr: "" `; -snapshot[`Issue View Command - Issue Not Found 1`] = ` -stdout: -"" -stderr: -"✗ Failed to view issue: Issue not found: TEST-999 -" -`; - snapshot[`Issue View Command - With Parent And Sub-issues 1`] = ` stdout: "# TEST-456: Implement user authentication From 9647a7d096a74331e5ad7d2f40e6dc25abf8f51e Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Fri, 6 Feb 2026 09:26:03 -0800 Subject: [PATCH 7/9] fix: respect --silent flag for HTTP error responses The --silent flag was not suppressing output when the API returned HTTP 400+ status codes in both executeSingle and executePaginated paths. --- src/commands/api.ts | 8 +++-- test/commands/__snapshots__/api.test.ts.snap | 7 ++++ test/commands/api.test.ts | 38 ++++++++++++++++++++ test/utils/mock_linear_server.ts | 3 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index bb1edd7..12c0faa 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -108,7 +108,9 @@ async function executeSingle( const text = await response.text() if (response.status >= 400) { - console.error(text) + if (!silent) { + console.error(text) + } Deno.exit(1) } @@ -156,7 +158,9 @@ async function executePaginated( const text = await response.text() if (response.status >= 400) { - console.error(text) + if (!silent) { + console.error(text) + } Deno.exit(1) } diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index 1b5dbb5..9e2af62 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -177,6 +177,13 @@ stderr: \` `; +snapshot[`API Command - Silent Flag With HTTP Error 1`] = ` +stdout: +"" +stderr: +"" +`; + snapshot[`API Command - Variable Overrides Variables JSON 1`] = ` stdout: '{"data":{"issues":{"nodes":[{"title":"Issue One"}]}}}' diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 369a8cc..e8a8bf7 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -611,6 +611,44 @@ await cliffySnapshotTest({ }, }) +await cliffySnapshotTest({ + name: "API Command - Silent Flag With HTTP Error", + meta: import.meta, + colors: false, + args: [ + "query BadQuery { nonexistent { id } }", + "--silent", + ], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "BadQuery", + status: 400, + response: { + errors: [{ + message: "Cannot query field 'nonexistent' on type 'Query'", + extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, + }], + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + await cliffySnapshotTest({ name: "API Command - Variable Overrides Variables JSON", meta: import.meta, diff --git a/test/utils/mock_linear_server.ts b/test/utils/mock_linear_server.ts index 2a12e1c..025036b 100644 --- a/test/utils/mock_linear_server.ts +++ b/test/utils/mock_linear_server.ts @@ -15,6 +15,7 @@ interface MockResponse { queryName: string variables?: Record<string, unknown> response: Record<string, unknown> + status?: number } export class MockLinearServer { @@ -84,7 +85,7 @@ export class MockLinearServer { if (mockResponse) { return new Response( JSON.stringify(mockResponse.response), - { status: 200, headers }, + { status: mockResponse.status ?? 200, headers }, ) } From 63921a3e088f0ad1cc9591905e1cb4089f6dcdf2 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Sat, 7 Feb 2026 16:48:56 -0800 Subject: [PATCH 8/9] fix: use roundtrip check for --variable number coercion String(Number(value)) === value prevents "007" from becoming 7 and "1e5" from becoming 100000. --- src/commands/api.ts | 2 +- test/commands/__snapshots__/api.test.ts.snap | 14 ++++ test/commands/api.test.ts | 74 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 12c0faa..a251db6 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -400,7 +400,7 @@ function coerceValue(value: string): unknown { if (value === "null") return null const num = Number(value) - if (value !== "" && !isNaN(num)) return num + if (value !== "" && !isNaN(num) && String(num) === value) return num return value } diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index 9e2af62..a6f1786 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -184,6 +184,20 @@ stderr: "" `; +snapshot[`API Command - Variable Coercion Preserves Leading Zeros 1`] = ` +stdout: +'{"data":{"issue":{"title":"Issue 007"}}}' +stderr: +"" +`; + +snapshot[`API Command - Variable Coercion Preserves Scientific Notation 1`] = ` +stdout: +'{"data":{"issue":{"title":"Issue 1e5"}}}' +stderr: +"" +`; + snapshot[`API Command - Variable Overrides Variables JSON 1`] = ` stdout: '{"data":{"issues":{"nodes":[{"title":"Issue One"}]}}}' diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index e8a8bf7..0fc213a 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -649,6 +649,80 @@ await cliffySnapshotTest({ }, }) +await cliffySnapshotTest({ + name: "API Command - Variable Coercion Preserves Leading Zeros", + meta: import.meta, + colors: false, + args: [ + "query GetIssue($id: String!) { issue(id: $id) { title } }", + "--variable", + "id=007", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssue", + variables: { id: "007" }, + response: { + data: { + issue: { title: "Issue 007" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Coercion Preserves Scientific Notation", + meta: import.meta, + colors: false, + args: [ + "query GetIssue($id: String!) { issue(id: $id) { title } }", + "--variable", + "id=1e5", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssue", + variables: { id: "1e5" }, + response: { + data: { + issue: { title: "Issue 1e5" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + await cliffySnapshotTest({ name: "API Command - Variable Overrides Variables JSON", meta: import.meta, From b4b50eb58637d663fb5155a384272e0889d09ea3 Mon Sep 17 00:00:00 2001 From: Ben Drucker <bvdrucker@gmail.com> Date: Sat, 7 Feb 2026 16:50:12 -0800 Subject: [PATCH 9/9] feat: detect and error on multi-connection --paginate queries gh api --paginate silently paginates only the first connection. Instead of replicating that footgun, detect multiple connections in the response and error with guidance to paginate manually. --- src/commands/api.ts | 32 ++++++- test/commands/__snapshots__/api.test.ts.snap | 20 ++++- test/commands/api.test.ts | 91 ++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index a251db6..3d6f2ea 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -38,7 +38,10 @@ export const apiCommand = new Command() "--variables-json <json:string>", "JSON object of variables (merged with --variable, which takes precedence)", ) - .option("--paginate", "Automatically fetch all pages using cursor pagination") + .option( + "--paginate", + "Auto-paginate a single connection field using cursor pagination", + ) .option( "--silent", "Suppress response output (exit code still reflects errors)", @@ -181,6 +184,16 @@ async function executePaginated( Deno.exit(1) } + if (allNodes.length === 0 && countConnections(parsed.data) > 1) { + throw new AppValidationError( + "--paginate does not support queries with multiple paginated connections", + { + suggestion: + "Use cursor-based pagination manually with $after and pageInfo { hasNextPage endCursor }.", + }, + ) + } + const pageResult = extractPageInfo(parsed) if (!pageResult) { @@ -248,6 +261,23 @@ function findPageInfo( return null } +function countConnections(obj: unknown): number { + if (obj == null || typeof obj !== "object") return 0 + + const record = obj as Record<string, unknown> + + if ("pageInfo" in record && "nodes" in record) { + return 1 + } + + let count = 0 + for (const value of Object.values(record)) { + count += countConnections(value) + } + + return count +} + function outputJSON(parsed: unknown, rawText: string): void { if (Deno.stdout.isTerminal()) { try { diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap index a6f1786..82213c2 100644 --- a/test/commands/__snapshots__/api.test.ts.snap +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -15,7 +15,7 @@ Options: --variable <variable> - Variable in key=value format (coerces booleans, numbers, null; @file reads from path) --variables-json <json> - JSON object of variables (merged with --variable, which takes precedence) - --paginate - Automatically fetch all pages using cursor pagination + --paginate - Auto-paginate a single connection field using cursor pagination --silent - Suppress response output (exit code still reflects errors) " @@ -69,7 +69,7 @@ Options: --variable <variable> - Variable in key=value format (coerces booleans, numbers, null; @file reads from path) --variables-json <json> - JSON object of variables (merged with --variable, which takes precedence) - --paginate - Automatically fetch all pages using cursor pagination + --paginate - Auto-paginate a single connection field using cursor pagination --silent - Suppress response output (exit code still reflects errors) " @@ -198,6 +198,22 @@ stderr: "" `; +snapshot[`API Command - Paginate Multiple Connections Error 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: --paginate does not support queries with multiple paginated connections + Use cursor-based pagination manually with \$after and pageInfo { hasNextPage endCursor }. +" +`; + +snapshot[`API Command - Paginate With Nested Connections 1`] = ` +stdout: +'[{"title":"Parent Issue","subIssues":{"nodes":[{"title":"Child Issue"}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}]' +stderr: +"" +`; + snapshot[`API Command - Variable Overrides Variables JSON 1`] = ` stdout: '{"data":{"issues":{"nodes":[{"title":"Issue One"}]}}}' diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 0fc213a..17f4e86 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -723,6 +723,97 @@ await cliffySnapshotTest({ }, }) +await cliffySnapshotTest({ + name: "API Command - Paginate Multiple Connections Error", + meta: import.meta, + colors: false, + args: [ + "query GetAll($after: String) { issues(first: 10, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } projects(first: 10, after: $after) { nodes { name } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetAll", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [{ title: "Issue 1" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + projects: { + nodes: [{ name: "Project 1" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate With Nested Connections", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($after: String) { issues(first: 2, after: $after) { nodes { title subIssues { nodes { title } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [ + { + title: "Parent Issue", + subIssues: { + nodes: [{ title: "Child Issue" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + await cliffySnapshotTest({ name: "API Command - Variable Overrides Variables JSON", meta: import.meta,