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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "linear-cli",
"version": "0.2.10",
"version": "0.3.0",
"description": "Linear CLI integration for Claude Code - manage issues, teams, and projects",
"author": {
"name": "0xbigboss"
},
"repository": "https://github.com/0xbigboss/linear-cli"
"repository": "https://github.com/alleneubank/linear-cli"
}
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# CI: the offline verifier every push has to clear.
#
# Deliberately not running ziglint — it is a `zig build lint` step that shells
# out to a binary this workflow would have to build from source on every run,
# and it is advisory. `zig fmt --check` and the unit suite are the floors.
#
# The online suite (`zig build online`) is not here on purpose: it needs a real
# LINEAR_API_KEY and mutates a live workspace.
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# build.zig derives --version from `git describe --tags`; a shallow
# clone makes it report a bare hash.
fetch-depth: 0

- uses: mlugg/setup-zig@v2
with:
# Keep in lockstep with the release workflow and README.
version: 0.16.0

- name: Check formatting
run: zig fmt --check build.zig src

- name: Unit tests
run: zig build test

- name: Version manifests agree
run: ./scripts/check-versions.sh

cross-build:
# The npm dist targets are cross-compiled and never exercised by `zig build
# test`, so a target-specific break (libc, tcsetattr, file modes) only shows
# up at release time. Build them on every push instead.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- run: zig build npm
85 changes: 85 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,108 @@
# Release: cut on a pushed v* tag.
#
# Two distribution channels, one tag:
# 1. npm — @0xbigboss/linear-cli plus four platform packages.
# 2. GitHub release assets — per-platform tarballs consumed by mise's
# `github:` backend (dotfiles config/.config/mise/config.toml).
#
# Asset naming mirrors zmx and alleneubank/agent-statusline
# (`linear-<version>-<os>-<arch>.tar.gz` + `.sha256`), which is the shape mise
# already resolves per platform. Do not add an asset_pattern on the consuming
# side: mise's per-arch matching handles this layout, and a pattern REPLACES
# that matching rather than narrowing it.
#
# Linux assets are musl-static so they run on any distro; the npm packages keep
# their gnu builds (npm installs land on a host with a libc that matches).
#
# No code signing: Zig ad-hoc signs macOS binaries at link time, and mise
# downloads carry no quarantine xattr, so Gatekeeper never engages.
name: Release
on:
push:
tags: ["v*"]

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# build.zig derives --version from `git describe --tags`. Without the
# tag history the published binary reports a bare commit hash.
fetch-depth: 0

- uses: mlugg/setup-zig@v2
with:
version: 0.16.0

# Fail before publishing rather than after. The tag names the npm
# packages and the release assets; a manifest that disagrees ships a
# release whose parts describe different versions.
- name: Check version consistency
run: ./scripts/check-versions.sh --expect "${GITHUB_REF_NAME#v}"

- run: zig build npm

- name: Copy binaries to npm packages
run: |
for p in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do
cp zig-out/npm/linear-cli-${p}/linear npm/linear-cli-${p}/
done

- name: Build GitHub release tarballs
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
mkdir -p dist
# asset label -> zig target triple
for pair in \
"macos-aarch64 aarch64-macos" \
"macos-x86_64 x86_64-macos" \
"linux-aarch64 aarch64-linux-musl" \
"linux-x86_64 x86_64-linux-musl"; do
label="${pair%% *}"
target="${pair##* }"
zig build -Doptimize=ReleaseSafe -Dtarget="$target"
asset="linear-${version}-${label}.tar.gz"
tar -C zig-out/bin -czf "dist/$asset" linear
(cd dist && sha256sum "$asset" > "$asset.sha256")
done
ls -l dist

- uses: actions/upload-artifact@v4
with:
name: npm-packages
path: npm/

- uses: actions/upload-artifact@v4
with:
name: release-assets
path: dist/

github-release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
name: release-assets
path: dist
- name: Create release with assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Create the release for this tag, or attach assets if it already
# exists (e.g. a hand-drafted release).
gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes dist/* \
|| gh release upload "$GITHUB_REF_NAME" dist/* --clobber

publish:
needs: build
runs-on: ubuntu-latest
Expand All @@ -32,6 +113,10 @@ jobs:
with:
name: npm-packages
path: npm
- name: Restore executable bits
# upload-artifact does not preserve the mode; the packaged binary must
# stay executable or the npm wrapper spawns a non-executable file.
run: chmod +x npm/linear-cli-*/linear
- env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./scripts/publish-npm.sh ${GITHUB_REF_NAME#v}
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Changelog

Notable changes per release. Versions before 0.3.0 are recorded in the
[GitHub releases](https://github.com/alleneubank/linear-cli/releases).

## 0.3.0

The first release since v0.2.11, covering the Zig 0.16 migration, a
credential-handling security audit, and a large expansion of the command
surface.

### Breaking

- **`--api-key` removed.** It put the secret on argv, where it is visible in
`ps` and saved to shell history. Use piped stdin (`echo "$KEY" | linear auth
set`), the no-echo prompt, `LINEAR_API_KEY`, or a credential helper.
- **`auth show` redacts by default.** It previously printed the full key to
stdout with `--redacted` as opt-in. `--reveal` is now required, and is
refused when stdout is not a TTY so the key cannot be piped into a log.
- **`linear gql` requires `--yes` for mutations.** Detection scans top-level
tokens only, so comments, string literals, and a field named `mutation` do
not trip it. Adds `--dry-run`.
- **`--endpoint` is allowlisted** to `https` on `api.linear.app` unless
`LINEAR_ALLOW_INSECURE_ENDPOINT=1`. It previously accepted plain `http://`
to any host, making it a key-exfiltration channel.
- **`issue view` no longer downloads attachments by default.** It defaulted
`attachment_dir` to `/tmp`; downloads are now opt-in via `--attachment-dir`
and written 0600.
- **`issue delete --reason` removed.** `issueDelete` takes only
`(id, permanentlyDelete)`; the value never reached the API.
- **`search --fields` now selects printed columns**, matching every other list
command. The old meaning (which fields to search) moved to `--search-fields`.
- **No `auth migrate`.** Scrubbing a key from disk cannot beat APFS
copy-on-write, snapshots, or backups, so a migrated key has to be rotated
anyway. Delete `~/.config/linear/config.json` and set up fresh instead.
- A config file of exactly 64 KiB now errors (Zig 0.16 `.limited(n)` semantics).

### Security

- Env-derived keys are no longer written to disk. `auth set` fell back to the
env key when none was supplied, and the persist gate then passed. The same
ordering bug silently deleted a *stored* key from disk on any later `save()`
whenever `LINEAR_API_KEY` was set.
- Config file TOCTOU: created 0644 then `chmod`'d: now created 0600 atomically
inside a 0700 directory.
- API keys are charset/length validated (`[A-Za-z0-9_-]`, 4-512) at every
ingestion point, closing CRLF header injection via a tampered config.
- `redactKey` returned the whole secret for short keys; returns a constant
below a minimum length.
- A failed `disableEcho` now aborts instead of continuing to read with echo on.

### Added

- **Credential provider chain**: `LINEAR_API_KEY` -> `credential_helper` ->
macOS keychain -> config file (deprecated, warns). `credential_helper` runs
an argv array (never a shell) whose stdout is the key, covering 1Password,
`pass`, `gopass`, `secret-tool`, and Vault with no platform-specific code.
- `auth status` (offline backend report) and `auth set --to keychain|file`.
- New commands: `labels list`, `users list`, `states list`, `milestone
list|view|create|update|delete`, `issue comment list|update|delete`,
`issue start`, `issue pr`, `issue id|url|title|describe`.
- Real cursor pagination on every list command: `--limit` page size,
`--pages N`/`--all`, `--cursor`, `--max-items`, with a stderr page summary.
`gql --paginate` is the generic equivalent for arbitrary documents.
- `issues list --sort FIELD[:asc|desc]` over the full `IssueSortInput` field
set, plus `--sort-nulls first|last`.
- `--bulk`/`--bulk-file`/`--bulk-stdin` on delete commands; execution is serial.
- `--description-file`/`--body-file`/`--content-file` companions for long-form
text, where `-` means stdin.
- Git integration: branch-name issue inference, using Linear's own
`Issue.branchName` rather than local slugification.
- pi package manifest exposing the `linear` skill (`pi install
git:git@github.com:alleneubank/linear-cli.git`).

### Changed

- **Toolchain: Zig 0.15.2 -> 0.16.0.** 0.15.2 cannot link on macOS 26 - its
bundled `libSystem.tbd` carries no symbol availability for macOS 26, so every
libc symbol resolves as undefined. 0.16's explicit `std.Io` now threads
through every command `Context`, `Config`, and `GraphqlClient`.
- Retry backoff is cancelable, so `error.Canceled` propagates out of `send()`
instead of being swallowed.

### Release engineering

- GitHub releases now carry per-platform tarballs
(`linear-<version>-<os>-<arch>.tar.gz` + `.sha256`) for macOS and Linux on
both architectures, installable via mise's `github:` backend.
- CI runs `zig fmt --check`, the unit suite, the cross-compile targets, and a
version-manifest consistency gate (`scripts/check-versions.sh`) on every push.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@
- Validation that does not gate the write is worse than none: it implies a check happened while the exit status says success. `config set default_team_id` refuses to persist a team the workspace does not have, and keeps that verdict distinct from a lookup that never completed (`could not verify team` vs `not found in workspace`) so a timeout is never reported as a missing team. Same rule anywhere else validation is added.
- Flags must reach the API or not exist. `issue delete --reason` was removed rather than deprecated because `issueDelete` takes only `(id, permanentlyDelete)` — the value was echoed into this CLI's own output and read like an audit trail in Linear. Do not add a flag whose only effect is on local output while naming something the API owns.
- GraphQL client already handles HTTP status + GraphQL errors; retries only for 5xx with small backoff. Preserve explicit error messaging.
- Releases are cut by pushing a `v*` tag. `build.zig` derives `--version` from `git describe --tags`, so the tag is the only source of truth for the binary; the four hand-maintained manifests (`package.json`, `.claude-plugin/plugin.json`, `npm/*/package.json` including its `optionalDependencies` pins) must agree with it, and `scripts/check-versions.sh` is the gate that proves it — it runs in CI on every push and again in the release workflow *before* anything is published. Bump all four together or the gate fails closed. One tag drives two channels: npm (`scripts/publish-npm.sh`) and GitHub release assets. The assets are `linear-<version>-<os>-<arch>.tar.gz` + `.sha256`, macOS/Linux on both arches, Linux statically linked against musl; the naming mirrors zmx and `alleneubank/agent-statusline` because that is the shape mise's `github:` backend resolves per platform. Do not add an `asset_pattern` on the consuming side — it replaces mise's per-arch matching instead of narrowing it, collapsing every platform onto whichever asset sorts first.
- Tests: offline unit coverage exists for config, flag parsing, printer; keep them passing. Online tests are gated by env and should remain optional.
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,44 @@

Single-binary Linear client built with Zig 0.16.0. Uses stdlib only, defaults to human-readable tables with a `--json` override, and stores auth securely at `~/.config/linear/config.json` (0600). Use `linear help <command>` to see command-specific flags and examples.

## Install

One owner per machine: the npm wrapper, mise, and a hand-placed binary all put
`linear` on PATH and will shadow each other.

**npm** — any platform with node:
```bash
npm install -g @0xbigboss/linear-cli
```

**mise** — tracks the GitHub release assets, so every host on a fleet resolves
the same pinned build:
```toml
# ~/.config/mise/config.toml
[tools]
"github:alleneubank/linear-cli" = { version = "latest", exe = "linear" }
```
Then `mise install`. `exe` is required: the repo is `linear-cli`, the binary is
`linear`.

**Direct download** — each release carries
`linear-<version>-<os>-<arch>.tar.gz` plus a `.sha256` sidecar for macOS
(aarch64, x86_64) and Linux (aarch64, x86_64; statically linked against musl,
so any distro works). The tarball holds one root `linear` executable.

**From source** — Zig 0.16.0, stdlib only:
```bash
zig build -Doptimize=ReleaseSafe # -> zig-out/bin/linear
```

Then configure a key (see [Config & Auth](#config--auth)) and check it:
```bash
linear config set credential_helper "op read op://<vault>/<item>/<field>"
linear auth test
```

## Build & Test
- Build: `zig build -Drelease-safe` (debug is default). Binary installs to `zig-out/bin/linear`.
- Build: `zig build -Doptimize=ReleaseSafe` (debug is default). Binary installs to `zig-out/bin/linear`.
- Tests: `zig build test`. Online suite runs with `LINEAR_ONLINE_TESTS=1`: `LINEAR_ONLINE_TESTS=1 LINEAR_TEST_TEAM_ID=<TEAM_ID> zig build online` (requires `LINEAR_API_KEY`; optional `LINEAR_TEST_ISSUE_ID`, `LINEAR_TEST_PROJECT_ID`, `LINEAR_TEST_MILESTONE_ID`; opt-in mutations with `LINEAR_TEST_ALLOW_MUTATIONS=1`).

## Manual QA (Live API)
Expand Down Expand Up @@ -238,7 +274,7 @@ linear auth set # configure your API key

**1. Add the marketplace:**
```
/plugin marketplace add https://github.com/0xbigboss/linear-cli
/plugin marketplace add https://github.com/alleneubank/linear-cli
```

**2. Install the plugin:**
Expand Down
7 changes: 7 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/tests/main.zig"),
.target = target,
.optimize = optimize,
// The env-mutation tests call libc `setenv`/`unsetenv` and rebuild
// their view of the environment from `std.c.environ`, because 0.16
// hands `main` a snapshot of `envp` that goes stale the moment libc
// reallocates `environ`. macOS links libSystem implicitly, so this
// only ever failed on Linux — which is why it survived until CI ran
// there. The exe itself stays libc-free.
.link_libc = true,
}),
});
tests.root_module.addOptions("build_options", build_options);
Expand Down
2 changes: 1 addition & 1 deletion npm/linear-cli-darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@0xbigboss/linear-cli-darwin-arm64",
"version": "0.2.11",
"version": "0.3.0",
"os": [
"darwin"
],
Expand Down
2 changes: 1 addition & 1 deletion npm/linear-cli-darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@0xbigboss/linear-cli-darwin-x64",
"version": "0.2.11",
"version": "0.3.0",
"os": [
"darwin"
],
Expand Down
2 changes: 1 addition & 1 deletion npm/linear-cli-linux-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@0xbigboss/linear-cli-linux-arm64",
"version": "0.2.11",
"version": "0.3.0",
"os": [
"linux"
],
Expand Down
2 changes: 1 addition & 1 deletion npm/linear-cli-linux-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@0xbigboss/linear-cli-linux-x64",
"version": "0.2.11",
"version": "0.3.0",
"os": [
"linux"
],
Expand Down
Loading
Loading