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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,25 @@ quickup logout

This only removes the OAuth token — it does not affect tokens set via `CLICKUP_TOKEN` or `.env`.

### `quickup` (default) - List Tasks
### `quickup` (no arguments) - Recent Commands

When run with no arguments, QuickUp! displays the 10 most recent commands you've run:

```bash
quickup
```

```
Recent commands:

1. 2026-03-28 14:32 quickup sprint --assignee john
2. 2026-03-28 10:15 quickup --team 123 --list 456
3. 2026-03-27 09:00 quickup task abc123 --comments
```

Command history is stored at `~/.quickup/history.json` (last 50 entries).

### `quickup [OPTIONS]` - List Tasks

List all tasks from a ClickUp list, grouped by status.

Expand Down
27 changes: 26 additions & 1 deletion docs/source/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,32 @@ Examples

quickup logout

``quickup`` (default) - List Tasks
``quickup`` (no arguments) - Recent Commands
---------------------------------------------

When run with no arguments, QuickUp! displays the 10 most recent commands you've run.

Synopsis
~~~~~~~~

.. code-block:: bash

quickup

Example output
~~~~~~~~~~~~~~

.. code-block:: text

Recent commands:

1. 2026-03-28 14:32 quickup sprint --assignee john
2. 2026-03-28 10:15 quickup --team 123 --list 456
3. 2026-03-27 09:00 quickup task abc123 --comments

Command history is stored at ``~/.quickup/history.json`` (last 50 entries).

``quickup [OPTIONS]`` - List Tasks
----------------------------------

List all tasks from a ClickUp list, grouped by status.
Expand Down
3 changes: 2 additions & 1 deletion quickup/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ def _respond(self, status: int, message: str) -> None:
<div style="text-align: center; padding: 2rem; background: white; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2>{'✅' if status == 200 else '❌'} {message}</h2>
</div></body></html>l>l>l>l>"""
</div></body></html>
"""
self.wfile.write(html.encode())

def log_message(self, format: str, *args: object) -> None:
Expand Down
41 changes: 41 additions & 0 deletions quickup/cli/history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Command history tracking for QuickUp! CLI."""

from datetime import datetime, timezone
import json
from pathlib import Path

HISTORY_FILE = Path.home() / ".quickup" / "history.json"
MAX_ENTRIES = 50


def record(argv: list[str]) -> None:
"""Append a command invocation to the history file."""
# Normalize: replace the full interpreter path with "quickup"
normalized = ["quickup", *argv[1:]]
entries = _load()
entries.append(
{
"command": " ".join(normalized),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
# Keep only the most recent entries
entries = entries[-MAX_ENTRIES:]
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(entries, indent=2))


def get_recent(n: int = 10) -> list[dict]:
"""Return the most recent n history entries, newest first."""
entries = _load()
return list(reversed(entries[-n:]))


def _load() -> list[dict]:
"""Load history entries from disk."""
if not HISTORY_FILE.exists():
return []
try:
return json.loads(HISTORY_FILE.read_text())
except (json.JSONDecodeError, OSError):
return []
42 changes: 42 additions & 0 deletions quickup/cli/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Main CLI entry point for QuickUp! using cyclopts."""

from datetime import datetime
import sys
from typing import Annotated, cast

from cyclopts import App, Parameter
import inquirer
from pyclickup import ClickUp
import requests

Expand All @@ -12,6 +14,7 @@
from .cache import get_task_data, maybe_warmup
from .config import init_environ
from .exceptions import ClickupyError, OAuthError, TokenError, handle_exception
from .history import get_recent, record
from .renderer import render_comment_posted, render_list, render_task_comments, render_task_detail, render_task_update

app = App(name="quickup", help="A simple and beautiful console-based client for ClickUp.")
Expand Down Expand Up @@ -91,14 +94,53 @@ def list_tasks(
)


_SUBCOMMANDS = {"sprint", "task", "update", "comment", "login", "logout"}


def _pick_and_run_recent() -> None:
"""Show recent commands via inquirer and re-execute the chosen one."""
entries = get_recent(10)
if not entries:
print("No command history yet. Try running a subcommand first!")
return

choices = []
for entry in entries:
ts = datetime.fromisoformat(entry["timestamp"]).astimezone()
label = f"{ts:%Y-%m-%d %H:%M} {entry['command']}"
choices.append((label, entry["command"]))
choices.append(("help", "__help__"))
choices.append(("quit", "__quit__"))

answers = inquirer.prompt([inquirer.List("cmd", message="Select a recent command to run", choices=choices)])
if not answers:
return

chosen = answers["cmd"]
if chosen == "__quit__":
return
if chosen == "__help__":
sys.argv = ["quickup", "--help"]
else:
sys.argv = chosen.split()
run_app()


def run_app():
"""Run the QuickUp! CLI application."""
if len(sys.argv) == 1:
_pick_and_run_recent()
return

environ = init_environ()
token = environ.get("TOKEN")
if token:
maybe_warmup(token)

try:
first_arg = sys.argv[1] if len(sys.argv) > 1 else ""
if first_arg in _SUBCOMMANDS:
record(sys.argv)
app()
except ClickupyError as e:
handle_exception(e)
Expand Down
120 changes: 120 additions & 0 deletions tests/test_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Tests for QuickUp! command history module."""

import json
import sys
from unittest.mock import patch

import pytest

from quickup.cli.history import MAX_ENTRIES, get_recent, record
from quickup.cli.main import run_app


@pytest.fixture
def history_file(tmp_path):
"""Use a temporary history file."""
hfile = tmp_path / "history.json"
with patch("quickup.cli.history.HISTORY_FILE", hfile):
yield hfile


class TestRecord:
"""Tests for recording command history."""

def test_record_creates_file(self, history_file):
record(["quickup", "sprint"])

data = json.loads(history_file.read_text())
assert len(data) == 1
assert data[0]["command"] == "quickup sprint"
assert "timestamp" in data[0]

def test_record_normalizes_argv(self, history_file):
record(["/usr/local/bin/python", "sprint", "--team", "123"])

data = json.loads(history_file.read_text())
assert data[0]["command"] == "quickup sprint --team 123"

def test_record_appends(self, history_file):
record(["quickup", "sprint"])
record(["quickup", "--team", "123"])

data = json.loads(history_file.read_text())
assert len(data) == 2

def test_record_caps_at_max(self, history_file):
for i in range(MAX_ENTRIES + 10):
record(["quickup", f"cmd-{i}"])

data = json.loads(history_file.read_text())
assert len(data) == MAX_ENTRIES
assert data[-1]["command"] == f"quickup cmd-{MAX_ENTRIES + 9}"


class TestGetRecent:
"""Tests for retrieving recent commands."""

def test_empty_history(self, history_file):
assert get_recent() == []

def test_returns_newest_first(self, history_file):
record(["quickup", "first"])
record(["quickup", "second"])

recent = get_recent(10)
assert recent[0]["command"] == "quickup second"
assert recent[1]["command"] == "quickup first"

def test_respects_limit(self, history_file):
for i in range(5):
record(["quickup", f"cmd-{i}"])

assert len(get_recent(3)) == 3

def test_corrupted_file(self, history_file):
history_file.write_text("not json")
assert get_recent() == []


class TestPickAndRunRecent:
"""Tests for the no-args behavior in run_app."""

@patch("quickup.cli.main.get_recent", return_value=[])
def test_no_history(self, mock_recent, capsys, monkeypatch):
monkeypatch.setattr(sys, "argv", ["quickup"])
run_app()

output = capsys.readouterr().out
assert "No command history" in output

@patch("quickup.cli.main.inquirer")
@patch("quickup.cli.main.get_recent")
def test_prompts_with_recent_commands(self, mock_recent, mock_inquirer, monkeypatch):
monkeypatch.setattr(sys, "argv", ["quickup"])
mock_recent.return_value = [
{"command": "quickup sprint", "timestamp": "2026-03-28T14:32:00+00:00"},
]
mock_inquirer.prompt.return_value = None # user cancelled

run_app()

mock_inquirer.prompt.assert_called_once()
# Check List was built with the command as a choice value
list_kwargs = mock_inquirer.List.call_args
choices = list_kwargs[1]["choices"] if list_kwargs[1] else list_kwargs[0][2]
assert any("quickup sprint" in val for _, val in choices)

@patch("quickup.cli.main.app")
@patch("quickup.cli.main.inquirer")
@patch("quickup.cli.main.get_recent")
def test_runs_selected_command(self, mock_recent, mock_inquirer, mock_app, monkeypatch):
monkeypatch.setattr(sys, "argv", ["quickup"])
mock_recent.return_value = [
{"command": "quickup sprint", "timestamp": "2026-03-28T14:32:00+00:00"},
]
mock_inquirer.prompt.return_value = {"cmd": "quickup sprint"}

run_app()

assert sys.argv == ["quickup", "sprint"]
mock_app.assert_called_once()