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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Linear CLI

Command-line interface for the Linear API.

## Installation

Requires Python 3.8 or newer.

### From source (editable)

```bash
git clone https://github.com/mc-nv/linear-cli.git
cd linear-cli
pip install -e .
```

### From a built wheel

```bash
pip install build
python -m build
pip install dist/linear_cli-0.2.0-py3-none-any.whl
```

After installation, the `linear` command is available on your `PATH`.

## Configuration

Set your Linear API token via env var or config file:

```bash
export LINEAR_CLI_TOKEN="lin_api_xxxxxxxxxxxxx"
```

Or place a JSON config at `~/.config/linear/user/conf.json`:

```json
{
"LINEAR_CLI_TOKEN": "lin_api_xxxxxxxxxxxxx",
"LINEAR_CLI_USER_DATA_QUERIES": "~/my-linear-queries"
}
```

Override the config path with `LINEAR_CLI_CONFIG` (supports multiple paths
separated by `:`, with later paths overriding earlier ones). Real env vars
always take precedence over config files.

Inspect the resolved configuration and where each value came from:

```bash
linear config get
```

## Usage

```bash
linear ping # check API connectivity
linear query list # list available query templates
linear query my-issues # run a built-in query
linear config get # show resolved configuration
```

User-defined GraphQL templates can be loaded from a directory pointed to by
`LINEAR_CLI_USER_DATA_QUERIES`; user templates override built-in ones with
the same name.
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "linear-cli"
version = "0.1.0"
version = "0.2.0"
description = "A CLI application for interacting with the Linear API"
readme = "README.md"
license = {text = "MIT"}
Expand All @@ -22,3 +22,6 @@ include = ["src*"]

[tool.setuptools.package-data]
"src" = ["data/queries/*.graphql"]

[tool.isort]
profile = "black"
4 changes: 3 additions & 1 deletion src/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import sys

from src.commands import config as config_cmd
from src.commands import ping, query
from src.config import apply_config_to_env
from src.logger import setup_logger
Expand All @@ -25,10 +26,11 @@ def main():
parser = argparse.ArgumentParser(
prog="linear",
description="CLI for Linear API",
epilog="Configuration: ~/.config/linear/user/conf.json (override with LINEAR_CLI_CONFIG)",
epilog="Configuration: ~/.config/linear/user/conf.json (override with LINEAR_CLI_CONFIG; supports multiple paths separated by os.pathsep, later paths override earlier ones)",
parents=[parent_parser],
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
config_cmd.setup_parser(subparsers, parent_parser)
ping.setup_parser(subparsers, parent_parser)
query.setup_parser(subparsers, parent_parser)
args = parser.parse_args()
Expand Down
64 changes: 64 additions & 0 deletions src/commands/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import os

from src.config import get_env_snapshot, get_file_status, load_config_with_sources


def setup_parser(subparsers, parent_parser):
parser = subparsers.add_parser(
"config",
help="Inspect resolved configuration and its sources",
parents=[parent_parser],
)
parser.add_argument(
"action",
nargs="?",
default="get",
choices=["get"],
help="Action to perform (default: get)",
)
parser.set_defaults(func=execute)


def _redact(key, value):
if "TOKEN" in key.upper() and value:
if len(value) <= 8:
return "***"
return f"{value[:4]}...{value[-4:]}"
return value


def execute(args):
file_config, file_sources = load_config_with_sources()
env_snapshot = get_env_snapshot()
file_status = get_file_status()

keys = set(file_config.keys())
keys.update(k for k in os.environ if k.startswith("LINEAR_CLI_"))
keys.update(k for k in env_snapshot if k.startswith("LINEAR_CLI_"))

print("Resolved configuration:")
if not keys:
print(" (no LINEAR_CLI_* values set)")
for k in sorted(keys):
value = os.environ.get(k, "")
if k in env_snapshot:
source = "env"
elif k in file_sources:
source = f"file: {file_sources[k]}"
else:
source = "unset"
print(f" {k} = {_redact(k, value)} ({source})")

print("\nConfig files (in load order, later wins):")
if not file_status:
print(" (none — LINEAR_CLI_CONFIG is empty)")
for path, status in file_status:
print(f" - {path} [{status}]")

cli_config_env = env_snapshot.get("LINEAR_CLI_CONFIG")
print("\nLINEAR_CLI_CONFIG env var:")
print(
f" {cli_config_env if cli_config_env is not None else '(unset, using default)'}"
)

return 0
2 changes: 1 addition & 1 deletion src/commands/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def get_builtin_dir():
def get_user_dir():
"""Get user-defined templates directory from env var."""
user_dir = os.getenv("LINEAR_CLI_USER_DATA_QUERIES")
return Path(user_dir) if user_dir else None
return Path(user_dir).expanduser() if user_dir else None


def list_templates():
Expand Down
63 changes: 52 additions & 11 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,65 @@
import os
from pathlib import Path

_env_snapshot = None
_file_status = []


def _config_paths():
"""Paths from LINEAR_CLI_CONFIG (os.pathsep-separated) or the default."""
env = os.getenv("LINEAR_CLI_CONFIG")
if env is None:
return [Path.home() / ".config" / "linear" / "user" / "conf.json"]
return [Path(p).expanduser() for p in env.split(os.pathsep) if p]


def load_config_with_sources():
"""Load and merge config files. Later paths override earlier ones.
Returns (merged_dict, sources_dict) where sources_dict maps each key to
the Path of the file that last set it. Records each path's load status
in the module-level _file_status list."""
global _file_status
merged = {}
sources = {}
_file_status = []
for path in _config_paths():
if not path.exists():
_file_status.append((path, "missing"))
continue
try:
with open(path) as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
_file_status.append((path, f"invalid: {e}"))
continue
_file_status.append((path, "loaded"))
for k, v in data.items():
merged[k] = v
sources[k] = path
return merged, sources


def load_config():
"""Load config from LINEAR_CLI_CONFIG or ~/.config/linear/user/conf.json."""
config_path = Path(
os.getenv(
"LINEAR_CLI_CONFIG",
Path.home() / ".config" / "linear" / "user" / "conf.json",
)
)
try:
return json.load(open(config_path)) if config_path.exists() else {}
except (json.JSONDecodeError, IOError):
return {}
"""Load and merge config files. Later paths override earlier ones."""
merged, _ = load_config_with_sources()
return merged


def apply_config_to_env():
"""Apply config values to env vars. Actual env vars take precedence."""
global _env_snapshot
_env_snapshot = dict(os.environ)
config = load_config()
for key, value in config.items():
if key not in os.environ:
os.environ[key] = str(value)


def get_env_snapshot():
"""Snapshot of os.environ taken before apply_config_to_env() mutated it."""
return dict(_env_snapshot) if _env_snapshot is not None else dict(os.environ)


def get_file_status():
"""List of (path, status) tuples from the most recent load."""
return list(_file_status)