Back to skills
SkillHub ClubShip Full StackFull StackBackend

linear-todos

A CLI tool that executes Python source code to manage todos via Linear's API. Creates tasks with natural language dates, priorities, and scheduling. This is a source-execution skill - code in src/linear_todos/ runs when commands are invoked.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,091
Hot score
99
Updated
March 20, 2026
Overall rating
C0.0
Composite score
0.0
Best-practice grade
C62.8

Install command

npx @skill-hub/cli install openclaw-skills-linear-todos
todoslineartasksremindersproductivity

Repository

openclaw/skills

Skill path: skills/avegancafe/linear-todos

A CLI tool that executes Python source code to manage todos via Linear's API. Creates tasks with natural language dates, priorities, and scheduling. This is a source-execution skill - code in src/linear_todos/ runs when commands are invoked.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install linear-todos into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding linear-todos to shared team environments
  • Use linear-todos for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: linear-todos
description: A CLI tool that executes Python source code to manage todos via Linear's API. Creates tasks with natural language dates, priorities, and scheduling. This is a source-execution skill - code in src/linear_todos/ runs when commands are invoked.
author: K
tags: [todos, linear, tasks, reminders, productivity]
metadata:
  openclaw:
    primaryEnv: LINEAR_API_KEY
    requires:
      env: [LINEAR_API_KEY]
      config: [~/.config/linear-todos/config.json]
    install:
      - kind: uv
        id: linear-todos
        label: Linear Todos CLI
---

# Linear Todos

> **āš ļø  This is a SOURCE-EXECUTION skill.** The agent runs Python code from `src/linear_todos/` when you invoke CLI commands. This is **not** instruction-only. Review `src/linear_todos/api.py` before first use.
>
> **šŸ” Security Note:** This skill stores your Linear API key in plaintext JSON at `~/.config/linear-todos/config.json` **only if you run the `setup` command**. Use a dedicated API key with minimal scope. The key is only used for Linear API calls and is never transmitted elsewhere. Prefer environment variables (`LINEAR_API_KEY`) to avoid persisted state.
>
> **Audit Info:** This skill makes HTTPS requests **only** to `api.linear.app` (Linear's official GraphQL API). No data is sent elsewhere. See `src/linear_todos/api.py` for the API client implementation.

## Credentials

| Variable | Required | Description |
|----------|----------|-------------|
| `LINEAR_API_KEY` | **Yes** | Your Linear API key from [linear.app/settings/api](https://linear.app/settings/api) |
| `LINEAR_TEAM_ID` | No | Default team ID for todos |
| `LINEAR_STATE_ID` | No | Default state for new todos |
| `LINEAR_DONE_STATE_ID` | No | State for completed todos |
| `LINEAR_TIMEZONE` | No | Your local timezone (e.g., `America/New_York`, `Europe/London`). Used for "end of day" calculations. Falls back to OpenClaw `USER.md` timezone if available. |

**Config Path:** `~/.config/linear-todos/config.json` (created by `setup`, permissions 0o600)

## Security & Auditing

### What This Skill Does

- **HTTP Requests:** Makes HTTPS requests **only** to `https://api.linear.app/graphql` (Linear's official API). No telemetry, no third-party services.
- **Data Storage:** Stores your API key and config in `~/.config/linear-todos/config.json` (plaintext, permissions 0o600) **only if you run the `setup` command**. Team/issue data is fetched fresh each run — nothing is cached locally except your config.
- **Runtime Behavior:** This skill runs from bundled Python source code (not preinstalled system tools). The agent executes `main.py` and code in `src/linear_todos/` when you run CLI commands.
- **Setup Behavior:** During interactive setup, the wizard temporarily sets `LINEAR_API_KEY` in the process environment to test the key. This is only during the setup session and is not persisted.
- **No Auto-Enable:** Does not request platform privileges (`always: false`). Will not auto-enable itself for all agents.
- **Code Locations:**
  - `src/linear_todos/api.py` — All HTTP requests to Linear
  - `src/linear_todos/config.py` — Config file handling
  - `src/linear_todos/setup_wizard.py` — Interactive setup
  - `src/linear_todos/cli.py` — CLI commands

### Recommended Security Practices

1. **Use a dedicated API key:** Create a separate Linear API token with minimal scope for this skill. Revoke it if you uninstall or stop using the skill.
2. **Prefer environment variables:** Set `LINEAR_API_KEY` in your shell instead of running `setup` — no plaintext file is created.
3. **Audit the code:** Review `src/linear_todos/api.py` to verify HTTP destinations before first use.
4. **Run initial setup in isolation:** If unsure, run the skill in a container/VM for the first setup to inspect behavior.

### Cron Jobs (Optional)

The file `cron-jobs.txt` contains **example** cron entries for daily digests. **It does NOT automatically install them.** Adding cron jobs requires manual action:

```bash
# Review the examples first:
cat cron-jobs.txt

# If you want to use them, edit your crontab:
crontab -e
```

**Preferred alternative:** Use OpenClaw's built-in cron instead of system crontab:
```bash
openclaw cron add --name "morning-digest" --schedule "0 8 * * *" \
  --payload "linear-todos digest" --session-target isolated
```

A powerful todo management system built on Linear with smart date parsing, priorities, and a complete CLI workflow.

## Quick Start

```bash
# Setup (run once)
uv run python main.py setup

# Create todos
uv run python main.py create "Call mom" --when day
uv run python main.py create "Pay taxes" --date 2025-04-15
uv run python main.py create "Review PR" --priority high --when week

# Natural language dates
uv run python main.py create "Meeting prep" --date "tomorrow"
uv run python main.py create "Weekly report" --date "next Monday"
uv run python main.py create "Dentist" --date "in 3 days"

# Manage todos
uv run python main.py list
uv run python main.py done ABC-123
uv run python main.py snooze ABC-123 "next week"

# Daily review
uv run python main.py review
```

## Setup

### 1. Get API Key

Get your API key from [linear.app/settings/api](https://linear.app/settings/api). **Recommendation:** Create a dedicated API key with minimal scope for this skill.

### 2. Run Setup

```bash
uv run python main.py setup
```

This interactive wizard will:
- Verify your API key
- List your Linear teams
- Let you select your todo team
- Configure initial and done states
- Save settings to `~/.config/linear-todos/config.json` (plaintext JSON)

### 3. Manual Configuration (optional)

Instead of running setup, you can use environment variables:

```bash
export LINEAR_API_KEY="lin_api_..."
export LINEAR_TEAM_ID="your-team-id"
export LINEAR_STATE_ID="your-todo-state-id"
export LINEAR_DONE_STATE_ID="your-done-state-id"
```

Or create `~/.config/linear-todos/config.json`:

```json
{
  "apiKey": "lin_api_...",
  "teamId": "team-uuid",
  "stateId": "todo-state-uuid",
  "doneStateId": "done-state-uuid",
  "timezone": "America/New_York"
}
```

## Commands

### create

Create a new todo with optional timing, priority, and description.

```bash
uv run python main.py create "Title" [options]

Options:
  --when day|week|month     Relative due date
  --date DATE               Specific due date (supports natural language)
  --priority LEVEL          urgent, high, normal, low, none
  --desc "Description"      Add description
```

**Natural Date Examples:**

```bash
uv run python main.py create "Task" --date "tomorrow"
uv run python main.py create "Task" --date "Friday"
uv run python main.py create "Task" --date "next Monday"
uv run python main.py create "Task" --date "in 3 days"
uv run python main.py create "Task" --date "in 2 weeks"
uv run python main.py create "Task" --date "2025-04-15"
```

**Complete Examples:**

```bash
# Due by end of today
uv run python main.py create "Call mom" --when day

# Due in 7 days
uv run python main.py create "Submit report" --when week

# Specific date with high priority
uv run python main.py create "Launch feature" --date 2025-03-15 --priority high

# Natural language date with description
uv run python main.py create "Team meeting prep" --date "next Monday" --desc "Prepare slides"

# Urgent priority, due tomorrow
uv run python main.py create "Fix production bug" --priority urgent --date tomorrow
```

### list

List all your todos.

```bash
uv run python main.py list [options]

Options:
  --all       Include completed todos
  --json      Output as JSON
```

### done

Mark a todo as completed.

```bash
uv run python main.py done ISSUE_ID

# Examples
uv run python main.py done TODO-123
uv run python main.py done ABC-456
```

### snooze

Reschedule a todo to a later date.

```bash
uv run python main.py snooze ISSUE_ID [when]

# Examples
uv run python main.py snooze TODO-123 "tomorrow"
uv run python main.py snooze TODO-123 "next Friday"
uv run python main.py snooze TODO-123 "in 1 week"
```

### review

Daily review command that organizes todos by urgency.

```bash
uv run python main.py review
```

Output sections:
- 🚨 **OVERDUE** - Past due date
- šŸ“… **Due Today** - Due today
- ⚔ **High Priority** - Urgent/high priority items
- šŸ“Š **This Week** - Due within 7 days
- šŸ“… **This Month** - Due within 28 days
- šŸ“ **No Due Date** - Items without dates

### setup

Interactive setup wizard to configure your Linear integration.

```bash
uv run python main.py setup
```

This will guide you through:
- Verifying your API key
- Selecting your Linear team
- Configuring initial and done states
- Saving settings to `~/.config/linear-todos/config.json`

## For Agents

When the user asks for reminders or todos:

### 1. Parse Natural Language Dates

Convert user input to specific dates:

```bash
# "remind me Friday to call mom"
uv run python main.py create "Call mom" --date "2025-02-21"

# "remind me to pay taxes by April 15"
uv run python main.py create "Pay taxes" --date "2025-04-15"

# "remind me next week about the meeting"
uv run python main.py create "Meeting" --date "next Monday"
```

### 2. Determine Priority

Ask if not specified:
- **Urgent** (šŸ”„) - Critical, do immediately
- **High** (⚔) - Important, do soon
- **Normal** (šŸ“Œ) - Standard priority (default)
- **Low** (šŸ’¤) - Can wait

### 3. Daily Briefing

When asked "what do I have to do today", run:

```bash
uv run python main.py review
```

Present the output **exactly as formatted** - don't reformat or summarize.

### 4. Complete Todos

When user says they completed something, mark it done:

```bash
uv run python main.py done ISSUE-123
```

## Date Parsing Reference

| Input | Result |
|-------|--------|
| `today` | Today |
| `tomorrow` | Next day |
| `Friday` | Next occurrence of Friday |
| `next Monday` | Monday of next week |
| `this Friday` | Friday of current week (or next if passed) |
| `in 3 days` | 3 days from now |
| `in 2 weeks` | 14 days from now |
| `2025-04-15` | Specific date |

## Priority Levels

| Level | Number | Icon | Use For |
|-------|--------|------|---------|
| Urgent | 1 | šŸ”„ | Critical, blocking issues |
| High | 2 | ⚔ | Important, time-sensitive |
| Normal | 3 | šŸ“Œ | Standard tasks (default) |
| Low | 4 | šŸ’¤ | Nice-to-have, can wait |
| None | 0 | šŸ“‹ | No priority set |

## Timezone Support

By default, due dates are calculated in UTC (end of day = 23:59:59 UTC). To use your local timezone for "end of day" calculations:

```bash
# Set via environment variable
export LINEAR_TIMEZONE="America/New_York"

# Or add to config.json
{
  "timezone": "America/New_York"
}
```

**OpenClaw Integration:** If running inside an OpenClaw workspace, the skill will automatically detect your timezone from `USER.md` (e.g., `timezone: America/New_York`). No manual configuration needed!

When a timezone is configured:
- `--when day` sets due date to end of today in your timezone (converted to UTC for Linear)
- `--when week` sets due date to 7 days from now, end of day in your timezone
- `--date "tomorrow"` sets due date to end of tomorrow in your timezone

Common timezone values: `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Paris`, `Asia/Tokyo`

## Configuration Precedence

Settings are loaded in this order (later overrides earlier):

1. Default values (none)
2. Config file: `~/.config/linear-todos/config.json`
3. Environment variables: `LINEAR_*`
4. Command-line flags: `--team`, `--state`

## Files

| File | Purpose |
|------|---------|
| `main.py` | Main entry point for the CLI |
| `src/linear_todos/cli.py` | CLI implementation with all commands |
| `src/linear_todos/api.py` | Linear API client |
| `src/linear_todos/config.py` | Configuration management |
| `src/linear_todos/dates.py` | Date parsing utilities |
| `src/linear_todos/setup_wizard.py` | Interactive setup wizard |


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### src/linear_todos/api.py

```python
"""Linear API client for GraphQL operations."""

import json
from typing import Any, Optional, Dict, List

import requests


class LinearError(Exception):
    """Base exception for Linear API errors."""
    pass


class LinearAPIError(LinearError):
    """Exception raised when the Linear API returns an error."""
    
    def __init__(self, message: str, errors: Optional[List[Dict]] = None):
        super().__init__(message)
        self.errors = errors or []


class LinearAPI:
    """Client for the Linear GraphQL API."""
    
    API_URL = "https://api.linear.app/graphql"
    
    def __init__(self, api_key: Optional[str] = None, config=None):
        """Initialize the Linear API client.
        
        Args:
            api_key: Linear API key. If not provided, will use config.
            config: Config instance. If not provided, a new one will be created.
            
        Raises:
            LinearError: If no API key is available
        """
        if config is None:
            from linear_todos.config import Config
            config = Config()
        
        self.api_key = api_key or config.api_key
        self.config = config
        
        if not self.api_key:
            raise LinearError("No Linear API key found. Run 'linear-todo-setup' or set LINEAR_API_KEY.")
    
    def _make_request(self, query: str, variables: Optional[Dict] = None) -> Dict[str, Any]:
        """Make a GraphQL request to the Linear API.
        
        Args:
            query: GraphQL query string
            variables: Optional variables for the query
            
        Returns:
            JSON response as dictionary
            
        Raises:
            LinearAPIError: If the API returns errors
            requests.RequestException: If the HTTP request fails
        """
        headers = {
            "Content-Type": "application/json",
            "Authorization": self.api_key,
        }
        
        payload = {"query": query}
        if variables:
            payload["variables"] = variables
        
        response = requests.post(
            self.API_URL,
            headers=headers,
            json=payload,
            timeout=30
        )
        response.raise_for_status()
        
        data = response.json()
        
        if "errors" in data:
            raise LinearAPIError(
                f"GraphQL error: {data['errors'][0].get('message', 'Unknown error')}",
                errors=data["errors"]
            )
        
        return data
    
    def get_viewer(self) -> Dict[str, Any]:
        """Get the current user's information.
        
        Returns:
            User info dictionary with id, name, email
        """
        query = """
        {
            viewer {
                id
                name
                email
            }
        }
        """
        response = self._make_request(query)
        return response["data"]["viewer"]
    
    def get_teams(self) -> List[Dict[str, Any]]:
        """Get all teams in the workspace.
        
        Returns:
            List of team dictionaries with id, name, key
        """
        query = """
        {
            teams {
                nodes {
                    id
                    name
                    key
                }
            }
        }
        """
        response = self._make_request(query)
        return response["data"]["teams"]["nodes"]
    
    def get_team_states(self, team_id: str) -> List[Dict[str, Any]]:
        """Get workflow states for a team.
        
        Args:
            team_id: The team ID
            
        Returns:
            List of state dictionaries with id, name, type
        """
        query = """
        query GetTeamStates($teamId: String!) {
            team(id: $teamId) {
                states {
                    nodes {
                        id
                        name
                        type
                    }
                }
            }
        }
        """
        response = self._make_request(query, {"teamId": team_id})
        return response["data"]["team"]["states"]["nodes"]
    
    def get_team_issues(self, team_id: str, include_completed: bool = False,
                        first: int = 100) -> List[Dict[str, Any]]:
        """Get issues for a team.
        
        Args:
            team_id: The team ID
            include_completed: If True, include completed and canceled issues
            first: Maximum number of issues to fetch
            
        Returns:
            List of issue dictionaries
        """
        query = """
        query GetTeamIssues($teamId: String!, $first: Int) {
            team(id: $teamId) {
                issues(first: $first) {
                    nodes {
                        id
                        identifier
                        title
                        description
                        priority
                        dueDate
                        archivedAt
                        state {
                            id
                            name
                            type
                        }
                        assignee {
                            id
                            name
                        }
                    }
                }
            }
        }
        """
        variables = {"teamId": team_id, "first": first}
        response = self._make_request(query, variables)
        issues = response["data"]["team"]["issues"]["nodes"]
        
        # Filter client-side to avoid GraphQL injection
        if not include_completed:
            issues = [
                issue for issue in issues
                if issue.get("state", {}).get("type") not in ("completed", "canceled")
            ]
        
        return issues
    
    def get_issue(self, issue_id: str) -> Dict[str, Any]:
        """Get a single issue by ID.
        
        Args:
            issue_id: Issue identifier (e.g., "TODO-123")
            
        Returns:
            Issue dictionary
        """
        query = """
        query GetIssue($issueId: String!) {
            issue(id: $issueId) {
                id
                identifier
                title
                description
                state {
                    id
                    name
                    type
                }
                assignee {
                    id
                    name
                }
                team {
                    id
                    name
                }
                priority
                dueDate
                url
                labels {
                    nodes {
                        name
                    }
                }
            }
        }
        """
        response = self._make_request(query, {"issueId": issue_id})
        return response["data"]["issue"]
    
    def create_issue(self, team_id: str, title: str, 
                     description: Optional[str] = None,
                     state_id: Optional[str] = None,
                     priority: Optional[int] = None,
                     due_date: Optional[str] = None,
                     assignee_id: Optional[str] = None) -> Dict[str, Any]:
        """Create a new issue.
        
        Args:
            team_id: Team ID (required)
            title: Issue title (required)
            description: Issue description
            state_id: Initial state ID
            priority: Priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)
            due_date: Due date in ISO format
            assignee_id: Assignee user ID
            
        Returns:
            Created issue dictionary with success flag
        """
        query = """
        mutation CreateIssue($input: IssueCreateInput!) {
            issueCreate(input: $input) {
                success
                issue {
                    id
                    identifier
                    title
                    url
                    dueDate
                    state {
                        name
                    }
                }
            }
        }
        """
        
        # Build input variables safely
        variables = {
            "input": {
                "teamId": team_id,
                "title": title
            }
        }
        
        if description:
            variables["input"]["description"] = description
        if state_id:
            variables["input"]["stateId"] = state_id
        if priority is not None:
            variables["input"]["priority"] = priority
        if due_date:
            variables["input"]["dueDate"] = due_date
        if assignee_id:
            variables["input"]["assigneeId"] = assignee_id
        
        response = self._make_request(query, variables)
        return response["data"]["issueCreate"]
    
    def update_issue(self, issue_id: str,
                     title: Optional[str] = None,
                     description: Optional[str] = None,
                     state_id: Optional[str] = None,
                     priority: Optional[int] = None,
                     due_date: Optional[str] = None,
                     assignee_id: Optional[str] = None) -> Dict[str, Any]:
        """Update an existing issue.
        
        Args:
            issue_id: Issue identifier (e.g., "TODO-123")
            title: New title
            description: New description
            state_id: New state ID
            priority: New priority
            due_date: New due date
            assignee_id: New assignee
            
        Returns:
            Updated issue dictionary with success flag
        """
        query = """
        mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
            issueUpdate(id: $id, input: $input) {
                success
                issue {
                    id
                    identifier
                    title
                    dueDate
                    state {
                        id
                        name
                    }
                }
            }
        }
        """
        
        # Build input variables safely
        variables = {"input": {}}
        
        if title is not None:
            variables["input"]["title"] = title
        if description is not None:
            variables["input"]["description"] = description
        if state_id is not None:
            variables["input"]["stateId"] = state_id
        if priority is not None:
            variables["input"]["priority"] = priority
        if due_date is not None:
            variables["input"]["dueDate"] = due_date
        if assignee_id is not None:
            variables["input"]["assigneeId"] = assignee_id
        
        if not variables["input"]:
            raise LinearError("No update fields provided")
        
        variables["id"] = issue_id
        
        response = self._make_request(query, variables)
        return response["data"]["issueUpdate"]
    
    @staticmethod
    def priority_to_label(priority: int) -> str:
        """Convert priority number to human-readable label.
        
        Args:
            priority: Priority number (0-4)
            
        Returns:
            Human-readable priority label
        """
        labels = {
            0: "None",
            1: "Urgent",
            2: "High",
            3: "Normal",
            4: "Low",
        }
        return labels.get(priority, "None")
    
    @staticmethod
    def priority_to_number(priority: str) -> Optional[int]:
        """Convert priority string to number.
        
        Args:
            priority: Priority string (urgent, high, normal, low, none)
            
        Returns:
            Priority number or None if invalid
        """
        mapping = {
            "urgent": 1,
            "high": 2,
            "normal": 3,
            "low": 4,
            "none": 0,
            "1": 1,
            "2": 2,
            "3": 3,
            "4": 4,
            "0": 0,
        }
        return mapping.get(priority.lower())
    
    @staticmethod
    def priority_icon(priority: int) -> str:
        """Get an icon for a priority level.
        
        Args:
            priority: Priority number
            
        Returns:
            Icon emoji
        """
        icons = {
            0: "šŸ“‹",
            1: "šŸ”„",
            2: "⚔",
            3: "šŸ“Œ",
            4: "šŸ’¤",
        }
        return icons.get(priority, "šŸ“‹")

```

### src/linear_todos/config.py

```python
"""Configuration management for Linear Todos."""

import json
import os
import re
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo


def _find_openclaw_user_timezone() -> Optional[str]:
    """Try to extract timezone from OpenClaw's USER.md if present.

    Searches up the directory tree from the skill location for a workspace
    containing USER.md, then parses the timezone field.
    """
    # Start from the skill directory and search upward for workspace
    skill_dir = Path(__file__).resolve().parent
    current = skill_dir

    # Search up a few levels for workspace root
    for _ in range(5):
        user_md = current / "USER.md"
        if user_md.exists():
            try:
                content = user_md.read_text()
                # Look for timezone: America/New_York or similar
                match = re.search(r'(?:timezone|time.?zone)\s*[:=]\s*["\']?([^\n"\']+)["\']?', content, re.IGNORECASE)
                if match:
                    tz = match.group(1).strip()
                    # Clean up common markdown/formatting artifacts
                    tz = tz.split('(')[0].strip()  # Remove " (EST/EDT)" suffix
                    tz = tz.replace('*', '').strip()  # Remove markdown asterisks
                    # Validate it looks like a timezone (has a slash for region/city)
                    if '/' in tz and not tz.startswith('http'):
                        return tz
            except (IOError, OSError):
                pass
            break

        # Also check if we're in skills/ subdirectory of workspace
        if current.name == "skills":
            user_md = current.parent / "USER.md"
            if user_md.exists():
                try:
                    content = user_md.read_text()
                    match = re.search(r'(?:timezone|time.?zone)\s*[:=]\s*["\']?([^\n"\']+)["\']?', content, re.IGNORECASE)
                    if match:
                        tz = match.group(1).strip()
                        # Clean up common markdown/formatting artifacts
                        tz = tz.split('(')[0].strip()  # Remove " (EST/EDT)" suffix
                        tz = tz.replace('*', '').strip()  # Remove markdown asterisks
                        if '/' in tz and not tz.startswith('http'):
                            return tz
                except (IOError, OSError):
                    pass
                break

        parent = current.parent
        if parent == current:
            break
        current = parent

    return None


class Config:
    """Manages configuration for Linear Todos.
    
    Configuration is loaded in this order (later overrides earlier):
    1. Default values (none)
    2. Config file: ~/.config/linear-todos/config.json
    3. Environment variables: LINEAR_*
    """
    
    CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "linear-todos"
    CONFIG_FILE = CONFIG_DIR / "config.json"
    
    def __init__(self):
        self._config = {}
        self._load_config()
    
    def _load_config(self) -> None:
        """Load configuration from file and environment variables."""
        # Start with config file if it exists
        if self.CONFIG_FILE.exists():
            try:
                with open(self.CONFIG_FILE, "r") as f:
                    self._config = json.load(f)
            except (json.JSONDecodeError, IOError):
                self._config = {}
        
        # Environment variables override config file
        env_mappings = {
            "LINEAR_API_KEY": "apiKey",
            "LINEAR_TEAM_ID": "teamId",
            "LINEAR_STATE_ID": "stateId",
            "LINEAR_DONE_STATE_ID": "doneStateId",
            "LINEAR_TIMEZONE": "timezone",
        }
        
        for env_var, config_key in env_mappings.items():
            value = os.environ.get(env_var)
            if value:
                self._config[config_key] = value
    
    @property
    def api_key(self) -> Optional[str]:
        """Get the Linear API key."""
        return self._config.get("apiKey")
    
    @property
    def team_id(self) -> Optional[str]:
        """Get the team ID."""
        return self._config.get("teamId")
    
    @property
    def state_id(self) -> Optional[str]:
        """Get the initial state ID for new todos."""
        return self._config.get("stateId")
    
    @property
    def done_state_id(self) -> Optional[str]:
        """Get the done state ID."""
        return self._config.get("doneStateId")

    @property
    def timezone(self) -> Optional[str]:
        """Get the timezone string (e.g., 'America/New_York')."""
        return self._config.get("timezone")

    def get_timezone(self) -> Optional[ZoneInfo]:
        """Get the timezone as a ZoneInfo object.

        Checks configuration in this order:
        1. LINEAR_TIMEZONE environment variable
        2. timezone key in config.json
        3. OpenClaw USER.md (if running inside OpenClaw workspace)
        4. Returns None (defaults to UTC behavior)

        Returns:
            ZoneInfo object if timezone is found, None otherwise.
        """
        tz_str = self.timezone

        # Fallback: Check OpenClaw USER.md if no config timezone set
        # Disable with LINEAR_TODOS_NO_USERMD_FALLBACK=1 (for testing)
        if not tz_str and not os.environ.get("LINEAR_TODOS_NO_USERMD_FALLBACK"):
            tz_str = _find_openclaw_user_timezone()

        if tz_str:
            try:
                return ZoneInfo(tz_str)
            except Exception:
                return None
        return None

    def save(self, api_key: str, team_id: str, state_id: str,
             done_state_id: Optional[str] = None) -> None:
        """Save configuration to file.

        Args:
            api_key: Linear API key
            team_id: Team ID for todos
            state_id: Initial state ID for new todos
            done_state_id: State ID for completed todos
        """
        self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)

        config = {
            "apiKey": api_key,
            "teamId": team_id,
            "stateId": state_id,
        }

        if done_state_id:
            config["doneStateId"] = done_state_id

        # Write with restrictive permissions (user read/write only)
        import stat
        with open(self.CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)
        self.CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)

        self._config = config
    
    def is_configured(self) -> bool:
        """Check if the minimum required configuration is present.
        
        Returns:
            True if api_key and team_id are set
        """
        return bool(self.api_key and self.team_id)
    
    def __repr__(self) -> str:
        return f"Config(team_id={self.team_id}, configured={self.is_configured()})"

```

### src/linear_todos/setup_wizard.py

```python
"""Setup wizard for Linear Todos."""

import click

from linear_todos.config import Config
from linear_todos.api import LinearAPI, LinearError, LinearAPIError


def run_setup():
    """Run the interactive setup wizard."""
    click.echo("šŸš€ Linear Todo Setup")
    click.echo("====================")
    click.echo()
    
    config = Config()
    
    # Check for API key
    api_key = config.api_key
    
    if not api_key:
        click.echo("āŒ No Linear API key found!")
        click.echo()
        click.echo("Please get an API key from: https://linear.app/settings/api")
        click.echo()
        click.echo("You can store it in one of these ways:")
        click.echo("  1. Environment variable: export LINEAR_API_KEY='lin_api_...'")
        click.echo("  2. Config file (created by this wizard): ~/.config/linear-todos/config.json")
        click.echo("     (Recommended: use env var for better security)")
        click.echo()
        api_key = click.prompt("Enter your Linear API key", hide_input=True)
        
        if not api_key:
            click.echo("Error: API key is required")
            raise click.Abort()
    
    # Test API key
    click.echo()
    click.echo("šŸ”‘ Testing API key...")
    
    try:
        # Temporarily set the API key for testing
        import os
        os.environ['LINEAR_API_KEY'] = api_key
        test_config = Config()
        test_config._config['apiKey'] = api_key
        api = LinearAPI(config=test_config)
        viewer = api.get_viewer()
    except LinearAPIError as e:
        click.echo(f"āŒ API key is invalid!")
        click.echo(f"Error: {e}")
        raise click.Abort()
    except LinearError as e:
        click.echo(f"āŒ Error: {e}")
        raise click.Abort()
    
    click.echo(f"āœ“ Authenticated as: {viewer['name']} ({viewer['email']})")
    
    # Fetch teams
    click.echo()
    click.echo("šŸ“Š Fetching teams...")
    
    try:
        teams = api.get_teams()
    except LinearAPIError as e:
        click.echo(f"āŒ Error fetching teams: {e}")
        raise click.Abort()
    
    if not teams:
        click.echo("No teams found in your Linear workspace.")
        click.echo("Please create a team first at https://linear.app")
        raise click.Abort()
    
    # Display teams
    click.echo()
    click.echo("Available teams:")
    click.echo("----------------")
    for team in teams:
        click.echo(f"  {team['key']} - {team['name']} (ID: {team['id']})")
    
    # Select team
    click.echo()
    team_key = click.prompt("Enter the KEY of the team for your todos (e.g., TODO)")
    
    team_info = None
    for team in teams:
        if team['key'] == team_key:
            team_info = team
            break
    
    if not team_info:
        click.echo(f"āŒ Team '{team_key}' not found!")
        raise click.Abort()
    
    team_id = team_info['id']
    team_name = team_info['name']
    
    click.echo(f"āœ“ Selected team: {team_name} ({team_key})")
    
    # Fetch states for this team
    click.echo()
    click.echo(f"šŸ“‹ Fetching workflow states for {team_name}...")
    
    try:
        states = api.get_team_states(team_id)
    except LinearAPIError as e:
        click.echo(f"āŒ Error fetching states: {e}")
        raise click.Abort()
    
    # Display states
    click.echo()
    click.echo("Available states:")
    click.echo("-----------------")
    for state in states:
        click.echo(f"  {state['name']} (Type: {state['type']}, ID: {state['id']})")
    
    # Select initial state (todo/unstarted)
    click.echo()
    state_name = click.prompt(
        "Select the state for NEW todos (usually 'Todo' or 'Backlog')",
        default=""
    )
    
    state_info = None
    if state_name:
        for state in states:
            if state['name'] == state_name:
                state_info = state
                break
    else:
        # Auto-select first unstarted state
        for state in states:
            if state['type'] == 'unstarted':
                state_info = state
                break
        if not state_info and states:
            state_info = states[0]
    
    if not state_info:
        click.echo("āŒ State not found!")
        raise click.Abort()
    
    state_id = state_info['id']
    state_name = state_info['name']
    
    click.echo(f"āœ“ Selected initial state: {state_name}")
    
    # Select done state
    click.echo()
    done_state_name = click.prompt(
        "Select the state for DONE todos (usually 'Done' or 'Completed')",
        default=""
    )
    
    done_state_info = None
    done_state_id = None
    
    if done_state_name:
        for state in states:
            if state['name'] == done_state_name:
                done_state_info = state
                break
    else:
        # Auto-select first completed state
        for state in states:
            if state['type'] == 'completed':
                done_state_info = state
                break
    
    if done_state_info:
        done_state_id = done_state_info['id']
        done_state_name = done_state_info['name']
        click.echo(f"āœ“ Selected done state: {done_state_name}")
    else:
        click.echo("āš ļø  No completed state found. You'll need to manually update issue status.")
    
    # Save configuration
    click.echo()
    click.echo("šŸ’¾ Saving configuration...")
    
    config.save(
        api_key=api_key,
        team_id=team_id,
        state_id=state_id,
        done_state_id=done_state_id
    )
    
    click.echo(f"āœ“ Configuration saved to {config.CONFIG_FILE}")
    
    # Summary
    click.echo()
    click.echo("šŸŽ‰ Setup complete!")
    click.echo("==================")
    click.echo()
    click.echo("Your Linear Todo configuration:")
    click.echo(f"  Team: {team_name} ({team_key})")
    click.echo(f"  Team ID: {team_id}")
    click.echo(f"  New Todo State: {state_name}")
    if done_state_name:
        click.echo(f"  Done State: {done_state_name}")
    click.echo()
    click.echo("Try these commands:")
    click.echo('  uv run python main.py create "My first todo" --when day')
    click.echo('  uv run python main.py create "Important task" --priority high --date tomorrow')
    click.echo("  uv run python main.py list")
    click.echo("  uv run python main.py review")
    click.echo()
    click.echo("For help: uv run python main.py --help")


@click.command()
def setup():
    """Run the interactive setup wizard for Linear Todos."""
    try:
        run_setup()
    except click.Abort:
        raise
    except Exception as e:
        click.echo(f"\nāŒ Setup failed: {e}", err=True)
        raise click.Abort()


if __name__ == '__main__':
    setup()

```

### src/linear_todos/cli.py

```python
"""Click CLI for Linear Todos."""

import json
import secrets
import sys
from datetime import datetime
from typing import Optional

import click

from linear_todos.config import Config
from linear_todos.api import LinearAPI, LinearError, LinearAPIError
from linear_todos.dates import DateParser
from linear_todos.setup_wizard import run_setup


# Priority option helper
PRIORITY_OPTIONS = ["urgent", "high", "normal", "low", "none"]


@click.group()
@click.version_option(version=Config.__module__)
@click.pass_context
def cli(ctx):
    """Linear Todo CLI - Manage todos with smart date parsing."""
    # Ensure context object is initialized
    ctx.ensure_object(dict)
    ctx.obj['config'] = Config()


@cli.command()
def setup():
    """Run the interactive setup wizard for Linear Todos."""
    try:
        run_setup()
    except click.Abort:
        raise
    except Exception as e:
        click.echo(f"\nāŒ Setup failed: {e}", err=True)
        raise click.Abort()


@cli.command()
@click.argument('title', required=True, nargs=-1)
@click.option('--when', type=click.Choice(['day', 'week', 'month']), 
              help='Set due date relative to now (day=end of today, week=7 days, month=28 days)')
@click.option('--date', 'date_input', metavar='DATE',
              help='Set specific due date (YYYY-MM-DD or natural language like "tomorrow", "next Monday")')
@click.option('--priority', type=click.Choice(PRIORITY_OPTIONS),
              help='Set priority: urgent, high, normal, low, none')
@click.option('--desc', '--description', 'description',
              help='Add description')
@click.option('--team', 'team_id',
              help='Override team ID (default from config)')
@click.option('--state', 'state_id',
              help='Override state ID (default from config)')
@click.pass_context
def create(ctx, title, when, date_input, priority, description, team_id, state_id):
    """Create a new todo.
    
    Examples:
        linear-todo create "Call mom" --when day
        linear-todo create "Pay taxes" --date 2025-04-15
        linear-todo create "Review PR" --priority high --when week
        linear-todo create "Urgent bug" --priority urgent --date "tomorrow"
    """
    config = ctx.obj['config']
    
    # Join title arguments
    title_str = ' '.join(title)
    if not title_str:
        click.echo("Error: Title is required", err=True)
        sys.exit(1)
    
    # Use config values as defaults
    team_id = team_id or config.team_id
    state_id = state_id or config.state_id
    
    if not team_id:
        click.echo("Error: Team ID not configured. Run 'uv run python main.py setup' first.", err=True)
        sys.exit(1)
    
    # Validate --when and --date conflict
    if when and date_input:
        click.echo("Error: Cannot use both --when and --date. Choose one.", err=True)
        sys.exit(1)
    
    # Calculate due date using configured timezone
    due_date = None
    display_timing = None

    # Get current time in configured timezone (or UTC if not set)
    tz = config.get_timezone()
    if tz:
        base_datetime = datetime.now(tz)
    else:
        base_datetime = datetime.utcnow()

    if when:
        due_date = DateParser.get_relative_date(when, base_datetime)
        display_timing = when.capitalize()
    elif date_input:
        parsed_date = DateParser.parse(date_input, base_datetime)
        if not parsed_date:
            click.echo(f"Error: Could not parse date: {date_input}", err=True)
            click.echo('Try formats like: YYYY-MM-DD, tomorrow, Friday, next Monday, in 3 days', err=True)
            sys.exit(1)
        due_date = DateParser.to_iso_datetime(parsed_date, end_of_day=True, tz=tz)
        display_timing = f"Due: {parsed_date}"
    
    # Convert priority to number
    priority_num = None
    if priority:
        priority_num = LinearAPI.priority_to_number(priority)
        if priority_num is None:
            click.echo(f"Error: Invalid priority: {priority}", err=True)
            click.echo("Valid priorities: urgent, high, normal, low, none", err=True)
            sys.exit(1)
    
    click.echo(f"Creating todo: {title_str}")
    
    try:
        api = LinearAPI(config=config)
        result = api.create_issue(
            team_id=team_id,
            title=title_str,
            description=description,
            state_id=state_id,
            priority=priority_num,
            due_date=due_date
        )
        
        if result.get('success'):
            issue = result['issue']
            click.echo(f"āœ“ Created: {issue['identifier']} - {title_str}")
            if priority_num is not None:
                click.echo(f"  Priority: {LinearAPI.priority_to_label(priority_num)}")
            if display_timing:
                click.echo(f"  Due: {display_timing}")
            elif issue.get('dueDate'):
                due = issue['dueDate'].split('T')[0]
                click.echo(f"  Due: {due}")
            click.echo(f"  URL: {issue['url']}")
        else:
            click.echo("Error: Failed to create issue", err=True)
            sys.exit(1)
            
    except LinearAPIError as e:
        click.echo(f"Error creating todo: {e}", err=True)
        if e.errors:
            click.echo(json.dumps(e.errors, indent=2), err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@cli.command(name='list')
@click.option('--all', 'show_all', is_flag=True, help='Show all todos including completed')
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
@click.option('--team', 'team_id', help='Override team ID')
@click.pass_context
def list_command(ctx, show_all, output_json, team_id):
    """List all todos."""
    config = ctx.obj['config']
    team_id = team_id or config.team_id
    
    if not team_id:
        click.echo("Error: Team ID not configured. Run 'uv run python main.py setup' first.", err=True)
        sys.exit(1)
    
    try:
        api = LinearAPI(config=config)
        issues = api.get_team_issues(team_id, include_completed=show_all)
        
        if output_json:
            click.echo(json.dumps(issues, indent=2))
            return
        
        if not issues:
            click.echo("No todos found.")
            return
        
        # Pretty print table
        click.echo(f"{'ID':<10} {'State':<12} {'Prio':<8} {'Due Date':<20} Title")
        click.echo("-" * 80)
        
        for issue in issues:
            issue_id = issue.get('identifier', 'N/A')
            state = issue.get('state', {}).get('name', 'Unknown')[:11]
            prio = LinearAPI.priority_to_label(issue.get('priority', 0)) or 'None'
            due = issue.get('dueDate')
            due = due.split('T')[0] if due else '-'
            title = issue.get('title', 'Untitled')
            if len(title) > 30:
                title = title[:27] + '...'
            
            click.echo(f"{issue_id:<10} {state:<12} {prio:<8} {due:<20} {title}")
            
    except LinearAPIError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@cli.command()
@click.argument('issue_id', required=True)
@click.pass_context
def done(ctx, issue_id):
    """Mark a todo as done.
    
    ISSUE_ID is the Linear issue identifier (e.g., TODO-123)
    """
    config = ctx.obj['config']
    done_state_id = config.done_state_id
    
    if not done_state_id:
        click.echo("Error: Done state ID not configured. Run 'uv run python main.py setup' first.", err=True)
        sys.exit(1)
    
    click.echo(f"Marking {issue_id} as done...")
    
    try:
        api = LinearAPI(config=config)
        result = api.update_issue(issue_id, state_id=done_state_id)
        
        if result.get('success'):
            issue = result['issue']
            click.echo(f"āœ“ {issue['identifier']} marked as {issue['state']['name']}: {issue['title']}")
        else:
            click.echo("Error: Failed to update issue", err=True)
            sys.exit(1)
            
    except LinearAPIError as e:
        click.echo(f"Error: {e}", err=True)
        if e.errors:
            click.echo(json.dumps(e.errors, indent=2), err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@cli.command()
@click.argument('issue_id', required=True)
@click.argument('when', required=False, default='tomorrow')
@click.pass_context
def snooze(ctx, issue_id, when):
    """Snooze a todo to a later date.

    ISSUE_ID is the Linear issue identifier (e.g., TODO-123)
    WHEN is a natural language date (default: tomorrow)

    Examples:
        linear-todo snooze TODO-123 "tomorrow"
        linear-todo snooze TODO-123 "next Friday"
        linear-todo snooze TODO-123 "in 3 days"
    """
    config = ctx.obj['config']

    # Get current time in configured timezone (or UTC if not set)
    tz = config.get_timezone()
    if tz:
        base_datetime = datetime.now(tz)
    else:
        base_datetime = datetime.utcnow()

    # Parse the new date
    new_date = DateParser.parse(when, base_datetime)
    if not new_date:
        click.echo(f"Error: Could not parse date: {when}", err=True)
        click.echo('Try formats like: tomorrow, Friday, next Monday, in 3 days', err=True)
        sys.exit(1)

    click.echo(f"Snoozing {issue_id} to {new_date}...")

    try:
        api = LinearAPI(config=config)
        due_date = DateParser.to_iso_datetime(new_date, end_of_day=True, tz=tz)
        result = api.update_issue(issue_id, due_date=due_date)
        
        if result.get('success'):
            issue = result['issue']
            click.echo(f"āœ“ {issue['identifier']} snoozed to {new_date}: {issue['title']}")
        else:
            click.echo("Error: Failed to update issue", err=True)
            sys.exit(1)
            
    except LinearAPIError as e:
        click.echo(f"Error: {e}", err=True)
        if e.errors:
            click.echo(json.dumps(e.errors, indent=2), err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


# Fun morning greetings for digest
# Note: secrets.choice used below for security scan compliance (B311)
MORNING_GREETINGS = [
    "šŸŒ… Rise and grind!",
    "ā˜• Morning! Coffee's brewing, here's what's cooking:",
    "šŸŒž Good morning — let's knock these out:",
    "✨ Today's the day to tackle:",
    "šŸš€ Morning! Here's your hit list:",
    "šŸŽÆ Locked and loaded for today:",
    "šŸŒ¤ļø Rise and shine, here's what's due:",
]

NO_TASK_GREETINGS = [
    "šŸŒ… Morning! Nothing urgent today — you're free.",
    "ā˜• Coffee time, no fires to put out today.",
    "šŸŒž Good morning! Clear skies, zero TODOs.",
    "✨ Morning! Looks like a chill day ahead.",
]


@cli.command()
@click.pass_context
def digest(ctx):
    """Show morning digest of today's todos with fun greetings."""
    import random
    from datetime import datetime
    
    config = ctx.obj['config']
    team_id = config.team_id
    
    if not team_id:
        click.echo("Error: Team ID not configured. Run 'uv run python main.py setup' first.", err=True)
        sys.exit(1)
    
    try:
        api = LinearAPI(config=config)
        issues = api.get_team_issues(team_id, include_completed=False)
        
        # Get today's date
        today = datetime.utcnow().date()
        today_epoch = today.toordinal()
        
        due_today = []
        
        for issue in issues:
            issue_id = issue.get('identifier')
            title = issue.get('title', 'Untitled')
            due_date = issue.get('dueDate')
            archived_at = issue.get('archivedAt')
            
            # Skip archived issues
            if archived_at:
                continue
            
            # Check if due today or overdue
            if due_date:
                due_date_str = due_date.split('T')[0]
                try:
                    due_date_obj = datetime.strptime(due_date_str, "%Y-%m-%d").date()
                    due_epoch = due_date_obj.toordinal()
                    
                    # Include overdue and today
                    if due_epoch <= today_epoch:
                        line = f"  • [{issue_id}](https://linear.app/issue/{issue_id}): {title}"
                        due_today.append(line)
                except ValueError:
                    pass
        
        # Pick random greeting
        if due_today:
            greeting = secrets.choice(MORNING_GREETINGS)
            click.echo(greeting)
            click.echo("")
            for line in due_today:
                click.echo(line)
        else:
            greeting = secrets.choice(NO_TASK_GREETINGS)
            click.echo(greeting)
            
    except LinearAPIError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@cli.command()
@click.pass_context
def review(ctx):
    """Show daily review of todos organized by urgency."""
    from datetime import datetime
    
    config = ctx.obj['config']
    team_id = config.team_id
    
    if not team_id:
        click.echo("Error: Team ID not configured. Run 'uv run python main.py setup' first.", err=True)
        sys.exit(1)
    
    try:
        api = LinearAPI(config=config)
        issues = api.get_team_issues(team_id, include_completed=False)
        
        # Get today's date boundaries
        today = datetime.utcnow().date()
        today_epoch = today.toordinal()
        next_7_days_epoch = today_epoch + 7
        next_28_days_epoch = today_epoch + 28
        
        # Categorize issues
        due_today = []
        due_this_week = []
        due_this_month = []
        no_due_date = []
        
        for issue in issues:
            issue_id = issue.get('identifier')
            title = issue.get('title', 'Untitled')
            due_date = issue.get('dueDate')
            archived_at = issue.get('archivedAt')
            
            # Skip archived issues
            if archived_at:
                continue
            
            line = f"  • [{issue_id}](https://linear.app/issue/{issue_id}): {title}"
            
            # Categorize by due date
            if not due_date:
                no_due_date.append(line)
            else:
                # Parse due date (format: 2024-02-11T23:59:59.000Z)
                due_date_str = due_date.split('T')[0]
                try:
                    due_date_obj = datetime.strptime(due_date_str, "%Y-%m-%d").date()
                    due_epoch = due_date_obj.toordinal()
                    
                    if due_epoch <= today_epoch:
                        # Overdue or due today
                        due_today.append(line)
                    elif due_epoch <= next_7_days_epoch:
                        due_this_week.append(line)
                    elif due_epoch <= next_28_days_epoch:
                        due_this_month.append(line)
                except ValueError:
                    no_due_date.append(line)
        
        # Build output - match daily-todo-review.sh format
        output = []
        
        # DO TODAY section
        output.append("**🚨 Do Today:**")
        if due_today:
            output.extend(due_today)
        else:
            output.append("  • nothing to see here")
        output.append("")
        
        # Board overview by due date ranges
        output.append("**šŸ“Š Board Overview:**")
        output.append("")
        
        output.append("**By End of Week:**")
        if due_this_week:
            output.extend(due_this_week)
        else:
            output.append("  • nothing to see here")
        output.append("")
        
        output.append("**By End of Month:**")
        if due_this_month:
            output.extend(due_this_month)
        else:
            output.append("  • nothing to see here")
        
        # Tickets without due dates
        if no_due_date:
            output.append("")
            output.append("**No Due Date:**")
            output.extend(no_due_date)
        
        click.echo("\n".join(output))
            
    except LinearAPIError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)
    except LinearError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


# Keep aliases for backward compatibility
@cli.command(name='ls', hidden=True)
@click.pass_context
def ls_alias(ctx):
    """Alias for list command."""
    ctx.invoke(list_command)


@cli.command(name='complete', hidden=True)
@click.argument('issue_id', required=True)
@click.pass_context
def complete_alias(ctx, issue_id):
    """Alias for done command."""
    ctx.invoke(done, issue_id=issue_id)


@cli.command(name='finish', hidden=True)
@click.argument('issue_id', required=True)
@click.pass_context
def finish_alias(ctx, issue_id):
    """Alias for done command."""
    ctx.invoke(done, issue_id=issue_id)


@cli.command(name='defer', hidden=True)
@click.argument('issue_id', required=True)
@click.argument('when', required=False, default='tomorrow')
@click.pass_context
def defer_alias(ctx, issue_id, when):
    """Alias for snooze command."""
    ctx.invoke(snooze, issue_id=issue_id, when=when)


# Entry point for the CLI
def main():
    """Entry point for the CLI."""
    cli()


if __name__ == '__main__':
    main()

```

### src/linear_todos/dates.py

```python
"""Smart date parsing for natural language dates."""

import re
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo

import dateparser


class DateParser:
    """Parses natural language dates into ISO format dates."""
    
    # Day name to number mapping (Monday=1, Sunday=7)
    DAY_MAP = {
        "monday": 1,
        "tuesday": 2,
        "wednesday": 3,
        "thursday": 4,
        "friday": 5,
        "saturday": 6,
        "sunday": 7,
    }
    
    @classmethod
    def parse(cls, input_str: str, base_date: Optional[datetime] = None) -> Optional[str]:
        """Parse a natural language date string into YYYY-MM-DD format.
        
        Supports:
        - today, tomorrow
        - in X days/weeks
        - next Monday, next Tuesday, etc.
        - this Monday, this Tuesday, etc.
        - Monday, Tuesday, etc. (next occurrence)
        - ISO dates (2025-04-15)
        
        Args:
            input_str: Natural language date string
            base_date: Base date for relative calculations (default: now)
            
        Returns:
            Date string in YYYY-MM-DD format or None if parsing fails
        """
        if not input_str:
            return None
        
        if base_date is None:
            base_date = datetime.utcnow()
        
        input_lower = input_str.lower().strip()
        today = base_date.date()
        today_weekday = today.isoweekday()  # 1=Monday, 7=Sunday
        
        # Handle "today"
        if input_lower == "today":
            return today.isoformat()
        
        # Handle "tomorrow"
        if input_lower == "tomorrow":
            return (today + timedelta(days=1)).isoformat()
        
        # Handle "in X days/weeks"
        match = re.match(r'^in\s+(\d+)\s+(day|days|week|weeks)$', input_lower)
        if match:
            num = int(match.group(1))
            unit = match.group(2)
            if unit.startswith("week"):
                num *= 7
            return (today + timedelta(days=num)).isoformat()
        
        # Handle "next Monday", "next Tuesday", etc.
        match = re.match(r'^next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)$', input_lower)
        if match:
            target_day = match.group(1)
            target_num = cls.DAY_MAP[target_day]
            # Days until next occurrence (always at least 7 days away for "next X")
            days_until = (7 - today_weekday + target_num)
            if days_until <= 7:
                days_until += 7
            return (today + timedelta(days=days_until)).isoformat()
        
        # Handle "this Monday", "this Tuesday", etc. (current week or next if passed)
        match = re.match(r'^this\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)$', input_lower)
        if match:
            target_day = match.group(1)
            target_num = cls.DAY_MAP[target_day]
            # Days until target day
            days_until = target_num - today_weekday
            if days_until <= 0:
                days_until += 7
            return (today + timedelta(days=days_until)).isoformat()
        
        # Handle standalone day names (same as "this X")
        if input_lower in cls.DAY_MAP:
            target_num = cls.DAY_MAP[input_lower]
            days_until = target_num - today_weekday
            if days_until <= 0:
                days_until += 7
            return (today + timedelta(days=days_until)).isoformat()
        
        # Handle "in X weeks on Day" (e.g., "in 2 weeks on Monday")
        match = re.match(r'^in\s+(\d+)\s+weeks?\s+on\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)$', input_lower)
        if match:
            weeks = int(match.group(1))
            target_day = match.group(2)
            target_num = cls.DAY_MAP[target_day]
            # Calculate base days from weeks
            base_days = weeks * 7
            days_until = target_num - today_weekday
            if days_until <= 0:
                days_until += 7
            total_days = base_days + days_until
            return (today + timedelta(days=total_days)).isoformat()
        
        # Try ISO date format directly
        try:
            datetime.strptime(input_str, "%Y-%m-%d")
            return input_str
        except ValueError:
            pass
        
        # Try dateparser as fallback
        parsed = dateparser.parse(input_str, settings={
            'PREFER_DATES_FROM': 'future',
            'RELATIVE_BASE': base_date,
        })
        if parsed:
            return parsed.date().isoformat()
        
        return None
    
    @classmethod
    def to_iso_datetime(cls, date_str: str, end_of_day: bool = True, tz: Optional[ZoneInfo] = None) -> str:
        """Convert a date string to ISO datetime format for Linear API.
        
        Args:
            date_str: Date in YYYY-MM-DD format
            end_of_day: If True, set time to 23:59:59 in the specified timezone
            tz: Timezone to use for end-of-day calculation (default: UTC)
            
        Returns:
            ISO datetime string in UTC (with Z suffix)
        """
        # Parse the date
        date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
        
        if tz is None:
            # Default to UTC behavior for backward compatibility
            if end_of_day:
                return f"{date_str}T23:59:59.000Z"
            return f"{date_str}T00:00:00.000Z"
        
        # Create datetime in the specified timezone
        if end_of_day:
            # End of day is 23:59:59 in local timezone
            local_dt = datetime.combine(date_obj, datetime.strptime("23:59:59", "%H:%M:%S").time())
        else:
            # Start of day is 00:00:00 in local timezone
            local_dt = datetime.combine(date_obj, datetime.min.time())
        
        # Localize to the specified timezone
        local_dt = local_dt.replace(tzinfo=tz)
        
        # Convert to UTC using the imported timezone.utc
        utc_dt = local_dt.astimezone(timezone.utc)
        
        # Format as ISO string with Z suffix
        return utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
    
    @classmethod
    def get_relative_date(cls, when: str, base_date: Optional[datetime] = None) -> Optional[str]:
        """Get a relative date for 'day', 'week', or 'month'.
        
        Args:
            when: One of 'day', 'week', 'month'
            base_date: Base date for calculations (default: now). If timezone-aware,
                      calculations are done in that timezone.
            
        Returns:
            ISO datetime string in UTC or None
        """
        # Extract timezone if base_date is timezone-aware
        tz = None
        if base_date is not None and base_date.tzinfo is not None:
            tz = base_date.tzinfo
        
        if base_date is None:
            base_date = datetime.utcnow()
        
        # Get the date in local timezone (or UTC if no timezone)
        today = base_date.date()
        
        if when == "day":
            # End of today
            date = today
        elif when == "week":
            # 7 days from now
            date = today + timedelta(days=7)
        elif when == "month":
            # 28 days from now
            date = today + timedelta(days=28)
        else:
            return None
        
        return cls.to_iso_datetime(date.isoformat(), end_of_day=True, tz=tz)
    
    @classmethod
    def parse_to_datetime(cls, when: str, base_date: Optional[datetime] = None) -> Optional[str]:
        """Parse a date string to ISO datetime format.
        
        This is a convenience method that parses the date and converts to datetime.
        
        Args:
            when: Natural language date or relative time ('day', 'week', 'month')
            base_date: Base date for calculations. If timezone-aware, the result
                      will be end-of-day in that timezone, converted to UTC.
            
        Returns:
            ISO datetime string in UTC or None
        """
        # Extract timezone if base_date is timezone-aware
        tz = None
        if base_date is not None and base_date.tzinfo is not None:
            tz = base_date.tzinfo
        
        # Handle relative keywords
        if when in ("day", "week", "month"):
            return cls.get_relative_date(when, base_date)
        
        # Parse as natural language date
        date_str = cls.parse(when, base_date)
        if date_str:
            return cls.to_iso_datetime(date_str, end_of_day=True, tz=tz)
        
        return None

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### README.md

```markdown
# Linear Todos

A complete todo management system built on Linear with smart date parsing, priorities, and CLI tools.

## Features

- šŸ“ Natural language dates ("tomorrow", "next Monday", "in 3 days")
- ⚔ Priority levels (urgent, high, normal, low)
- šŸ“… Smart scheduling (day, week, month)
- āœ… Mark todos as done
- šŸ’¤ Snooze todos to later dates
- šŸ“Š Daily review with organized output
- ā˜• Morning digest with fun greetings

## Installation

```bash
clawhub install linear-todos
```

## Setup

### 1. Install Prerequisites

You need [uv](https://docs.astral.sh/uv/) installed:

```bash
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Or with Homebrew
brew install uv
```

### 2. Install Dependencies

```bash
uv sync
```

### 3. Get a Linear API Key

1. Go to [linear.app/settings/api](https://linear.app/settings/api)
2. Create a new API key (name it "Linear Todos" or whatever you prefer)
3. Copy the key — you'll need it for the next step

### 4. Run Setup Wizard

```bash
uv run python main.py setup
```

This will:
- Verify your API key
- Show your Linear teams
- Let you pick which team to use for todos
- Save settings to `~/.config/linear-todos/config.json`

**Or use environment variables instead of the wizard:**

```bash
export LINEAR_API_KEY="lin_api_xxxxxxxx"
export LINEAR_TEAM_ID="your-team-id"  # optional
```

## Usage

```bash
# Create todos
uv run python main.py create "Call mom" --when day
uv run python main.py create "Pay taxes" --date 2025-04-15
uv run python main.py create "Review PR" --date "next Monday" --priority high

# List todos
uv run python main.py list

# Mark done
uv run python main.py done TODO-123

# Snooze to later
uv run python main.py snooze TODO-123 "next week"

# Daily review
uv run python main.py review
```

**See [SKILL.md](SKILL.md) for complete documentation.**

## Testing

```bash
uv run pytest tests/ -v
```

106 tests. All pass.

## License

MIT

```

### _meta.json

```json
{
  "owner": "avegancafe",
  "slug": "linear-todos",
  "displayName": "Linear Todos",
  "latest": {
    "version": "1.1.0",
    "publishedAt": 1771447627201,
    "commit": "https://github.com/openclaw/skills/commit/586e49820f9374adc7ed88dce5ca4bbfea9a1c07"
  },
  "history": [
    {
      "version": "1.0.4",
      "publishedAt": 1771343174042,
      "commit": "https://github.com/openclaw/skills/commit/e1ec931b1b9c7c7e4834001f22a43dbd2675006f"
    },
    {
      "version": "1.0.3",
      "publishedAt": 1771334542664,
      "commit": "https://github.com/openclaw/skills/commit/bfa5f9bfdeaa1928d3d8a754d6b9f535502396b0"
    },
    {
      "version": "0.0.2",
      "publishedAt": 1771274476866,
      "commit": "https://github.com/openclaw/skills/commit/248e9cf91e68107e2ce8c243170ddeed8522ccf0"
    }
  ]
}

```

linear-todos | SkillHub