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.
Install command
npx @skill-hub/cli install openclaw-skills-linear-todos
Repository
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 repositoryBest 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
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"
}
]
}
```