From e5e0db1bb9223ee6148141ce54506c03c89d4ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20M=C3=A9ndez?= Date: Mon, 30 Mar 2026 22:04:19 -0400 Subject: [PATCH] Add command history: show recent subcommands when run with no args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New history.py module stores last 50 subcommand invocations in ~/.quickup/history.json - Running quickup with no arguments shows an interactive inquirer prompt to pick and re-run a recent command - Only named subcommands (sprint, task, update, etc.) are recorded — not default list-tasks or help flags --- README.md | 20 ++++++- docs/source/commands.rst | 27 ++++++++- quickup/cli/auth.py | 3 +- quickup/cli/history.py | 41 +++++++++++++ quickup/cli/main.py | 42 ++++++++++++++ tests/test_history.py | 120 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 quickup/cli/history.py create mode 100644 tests/test_history.py diff --git a/README.md b/README.md index c0b4dc5..879ca54 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/source/commands.rst b/docs/source/commands.rst index f076b1f..37cff9d 100644 --- a/docs/source/commands.rst +++ b/docs/source/commands.rst @@ -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. diff --git a/quickup/cli/auth.py b/quickup/cli/auth.py index 61c9d42..e48865e 100644 --- a/quickup/cli/auth.py +++ b/quickup/cli/auth.py @@ -112,7 +112,8 @@ def _respond(self, status: int, message: str) -> None:

{'✅' if status == 200 else '❌'} {message}

-
l>l>l>l>""" + +""" self.wfile.write(html.encode()) def log_message(self, format: str, *args: object) -> None: diff --git a/quickup/cli/history.py b/quickup/cli/history.py new file mode 100644 index 0000000..3c7582e --- /dev/null +++ b/quickup/cli/history.py @@ -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 [] diff --git a/quickup/cli/main.py b/quickup/cli/main.py index 7817e4a..24feace 100644 --- a/quickup/cli/main.py +++ b/quickup/cli/main.py @@ -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 @@ -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.") @@ -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) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..b7c7554 --- /dev/null +++ b/tests/test_history.py @@ -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()