Skill: avegancafe/linear-todos (version 1.1.0)

--- 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 |

path: README.md
content: # 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


path: SECURITY.md
content: # Security Information

## Overview

Linear Todos is a CLI tool that interacts with Linear's API. This document describes security considerations.

## Network Activity

**Outbound HTTPS requests are made ONLY to:**
- `https://api.linear.app/graphql` — Linear's official GraphQL API

No telemetry, analytics, or third-party services are contacted.

## Credential Storage

### Option 1: Environment Variables (Recommended)
Set in your shell profile:
```bash
export LINEAR_API_KEY="lin_api_..."
```
No files are written. Credentials exist only in memory.

### Option 2: Config File
The setup wizard writes to:
```
~/.config/linear-todos/config.json
```

**Format:** Plaintext JSON
```json
{
  "apiKey": "lin_api_...",
  "teamId": "...",
  "stateId": "...",
  "doneStateId": "..."
}
```

**Permissions:** Created with user-only read/write (0o600).

### Setup Behavior
During interactive setup, the wizard temporarily sets `LINEAR_API_KEY` in the process environment to validate the API key before saving. This only occurs during the setup session and is not persisted beyond the setup process.

## Recommended Practices

1. **Use a dedicated API key** — Create a Linear API token just for this tool. If compromised, revoke it without affecting other integrations.

2. **Review the code** — Key files to inspect:
   - `src/linear_todos/api.py` — All HTTP request logic
   - `src/linear_todos/config.py` — How credentials are loaded/saved
   - `src/linear_todos/setup_wizard.py` — Interactive setup flow

3. **Run in isolation first** — If unsure, test in a container:
   ```bash
   docker run -it --rm -v $(pwd):/app python:3.12 bash
   cd /app && pip install -e . && python main.py setup
   ```

4. **Prefer env vars over persisted config** — No file on disk = smaller attack surface.

## What This Tool Does NOT Do

- ❌ Send data to any service except Linear
- ❌ Modify your system crontab (cron-jobs.txt is examples only)
- ❌ Cache issue data locally (fetched fresh each run)
- ❌ Log API keys (keys are never logged)

## Reporting Issues

If you find a security issue:
1. Do not open a public issue
2. Review the code in `src/linear_todos/api.py` to confirm
3. Report via your preferred private channel


path: _meta.json
content: {
  "ownerId": "openclaw",
  "slug": "linear-todos",
  "version": "1.0.2",
  "publishedAt": null,
  "author": "K",
  "tags": ["todos", "linear", "tasks", "reminders", "productivity"],
  "type": "source",
  "always": false,
  "openclaw": {
    "always": false,
    "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"
      }
    ]
  },
  "platform": {
    "requires": [],
    "note": "Does not request any platform privileges."
  },
  "install": {
    "type": "source",
    "entry": "main.py",
    "note": "This skill executes bundled Python source code. The agent runs src/linear_todos/ when CLI commands are invoked. No external downloads or package installs required. Review src/linear_todos/api.py before first use."
  },
  "execution": {
    "type": "python",
    "note": "Executes Python source code from src/linear_todos/. Not instruction-only."
  },
  "credentials": {
    "primary": {
      "name": "LINEAR_API_KEY",
      "description": "Linear API key from https://linear.app/settings/api",
      "required": true,
      "sensitive": true
    }
  },
  "env": {
    "required": ["LINEAR_API_KEY"],
    "optional": ["LINEAR_TEAM_ID", "LINEAR_STATE_ID", "LINEAR_DONE_STATE_ID", "LINEAR_TIMEZONE"]
  },
  "config": {
    "paths": ["~/.config/linear-todos/config.json"],
    "sensitive": true,
    "autoWrite": false,
    "permissions": "0o600",
    "note": "Config file is written ONLY during manual 'setup' command with 0o600 permissions (user read/write only). Environment variables can be used instead to avoid persisted state."
  },
  "network": {
    "destinations": ["https://api.linear.app"],
    "note": "Makes HTTPS requests only to Linear's official GraphQL API. No third-party services."
  },
  "securityNote": "This is a source-execution skill. Python code in src/linear_todos/ runs when CLI commands are invoked. API key is stored in plaintext JSON at ~/.config/linear-todos/config.json (0o600 permissions) only if 'setup' command is run. Setup wizard temporarily sets LINEAR_API_KEY in process environment to test the key. Use environment variables to avoid persisted state. Skill does not auto-enable or auto-install cron jobs.",
  "auditing": {
    "keyFiles": ["src/linear_todos/api.py", "src/linear_todos/config.py", "src/linear_todos/cli.py"],
    "recommendation": "Review api.py to verify HTTP destinations, config.py for credential handling, and cli.py for command logic. Run tests with pytest in an isolated environment."
  },
  "warnings": [
    "EXECUTES SOURCE CODE: This skill runs Python code from src/linear_todos/ - not instruction-only",
    "Cron job examples in cron-jobs.txt are NOT auto-installed; manual user action required via 'crontab -e'",
    "Config file stores API key in plaintext (0o600 permissions); prefer LINEAR_API_KEY env var to avoid persisted state",
    "Setup wizard temporarily sets LINEAR_API_KEY in process environment during key validation",
    "Skill runs from bundled source; review code before first use",
    "Does not request platform privileges; will not auto-enable for all agents"
  ]
}


path: cron-jobs.txt
content: # Linear Todos Cron Jobs - EXAMPLE ONLY
# ======================================
# ⚠️  WARNING: This file contains EXAMPLE cron entries. It does NOT
#    automatically modify your crontab. Adding these is a manual
#    user action. Review before using.
#
# To add these jobs, run: crontab -e
# Then paste the entries you want (modify paths first!)

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin

# IMPORTANT: Update the path below to where YOU installed the skill:
# - If installed via clawhub: check ~/.openclaw/skills/ or ~/.clawhub/skills/
# - If cloned manually: use your clone directory
# - The path below is an EXAMPLE only and likely won't work on your system

# Morning TODO Digest (8:00 AM daily)
# Sends a fun morning greeting with today's todos
# 0 8 * * * cd /PATH/TO/YOUR/linear-todos && uv run python main.py digest

# Daily TODO Review (5:00 PM daily)
# Shows full review of todos organized by urgency
# 0 17 * * * cd /PATH/TO/YOUR/linear-todos && uv run python main.py review

# ---
# Alternative: Use OpenClaw's built-in cron instead of system crontab:
# openclaw cron add --name "morning-digest" --schedule "0 8 * * *" --command "..."
# This keeps job management within OpenClaw and avoids modifying system crontab.


path: main.py
content: #!/usr/bin/env python3
"""
Linear Todos - Main entry point

A powerful todo management system built on Linear with smart date parsing.

Usage:
    python main.py --help
    python main.py create "Task title" --when day
    python main.py list
    python main.py done ISSUE-123
    python main.py review
"""

import sys
from pathlib import Path

# Add src directory to Python path
src_dir = Path(__file__).parent / "src"
if str(src_dir) not in sys.path:
    sys.path.insert(0, str(src_dir))

from linear_todos.cli import main

if __name__ == "__main__":
    main()


path: pyproject.toml
content: [project]
name = "linear-todos"
version = "1.0.1"
description = "A powerful todo management system built on Linear with smart date parsing"
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
authors = [
    {name = "OpenClaw"}
]
keywords = ["linear", "todos", "cli", "task-management"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
]
dependencies = [
    "click>=8.3.1",
    "dateparser>=1.3.0",
    "requests>=2.32.5",
]

[project.optional-dependencies]
dev = [
    "pytest>=9.0.2",
    "pytest-cov>=7.0.0",
    "responses>=0.25.8",
]

[project.scripts]
linear-todo = "linear_todos.cli:main"
linear-todo-setup = "linear_todos.setup_wizard:setup"
linear-todo-review = "linear_todos.cli:review"
linear-todo-digest = "linear_todos.cli:digest"

[project.urls]
Homepage = "https://github.com/openclaw/linear-todos"
Repository = "https://github.com/openclaw/linear-todos"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/linear_todos"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--cov=linear_todos --cov-report=term-missing --cov-report=html"
filterwarnings = [
    "ignore::DeprecationWarning",
]

[tool.coverage.run]
source = ["src/linear_todos"]
omit = ["*/tests/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]


path: src/linear_todos/__init__.py
content: """Linear Todos - A powerful todo management system built on Linear."""

__version__ = "1.0.1"
__all__ = ["Config", "LinearAPI", "DateParser", "cli"]

from linear_todos.config import Config
from linear_todos.api import LinearAPI
from linear_todos.dates import DateParser


path: src/linear_todos/api.py
content: """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, [REDACTED_SECRET] = None, config=None):
        """Initialize the Linear API client.
        
        Args:
            [REDACTED_SECRET] 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.[REDACTED_SECRET] 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, "📋")


path: src/linear_todos/cli.py
content: """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()


path: src/linear_todos/config.py
content: """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:
            [REDACTED_SECRET] 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()})"


path: src/linear_todos/dates.py
content: """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


path: src/linear_todos/setup_wizard.py
content: """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
    [REDACTED_SECRET]
    
    if not [REDACTED_SECRET]"❌ 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()
        [REDACTED_SECRET]"Enter your Linear API key", hide_input=True)
        
        if not [REDACTED_SECRET]"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(
        [REDACTED_SECRET],
        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()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)