From 88a474a78d1680c2cc1a0fa2b66f7fdaa3e21c1a Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Mon, 23 Mar 2026 08:55:41 -0300 Subject: [PATCH] Implement CLI commands and utility functions for MVP-1 - Add complete CLI command implementations with Typer subcommands - Implement utils/time.py with duration formatting and datetime utilities - Implement utils/path.py with project path management utilities - Wire up all commands to main CLI entry point --- tracker/cli/main.py | 37 +++++++++++++++++- tracker/utils/path.py | 86 +++++++++++++++++++++++++++++++++++++++++ tracker/utils/time.py | 90 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/tracker/cli/main.py b/tracker/cli/main.py index 99e4f10..e565b13 100644 --- a/tracker/cli/main.py +++ b/tracker/cli/main.py @@ -1,7 +1,42 @@ """Main CLI entry point.""" + import typer +from tracker.cli.commands import ( + init_project, + list_projects, + show_project, + start_session, + add_note_cmd, + stop_session, + add_change, + suggest_next, + review, +) + app = typer.Typer( name="tracker", - help="Personal project tracker CLI", + help="Personal Project Tracker CLI - Track your projects with Markdown and YAML", ) + + +# Register all subcommands +app.add_typer_command(init_project, name="init-project") +app.add_typer_command(list_projects, name="list") +app.add_typer_command(show_project, name="show") +app.add_typer_command(start_session, name="start") +app.add_typer_command(add_note_cmd, name="note") +app.add_typer_command(stop_session, name="stop") +app.add_typer_command(add_change, name="change") +app.add_typer_command(suggest_next, name="next") +app.add_typer_command(review, name="review") + + +@app.callback() +def callback(): + """Personal Project Tracker - Track your projects locally with Markdown.""" + pass + + +if __name__ == "__main__": + app() diff --git a/tracker/utils/path.py b/tracker/utils/path.py index 32eb46c..84bea88 100644 --- a/tracker/utils/path.py +++ b/tracker/utils/path.py @@ -1 +1,87 @@ """Path utility functions.""" + +from pathlib import Path +from typing import Optional + + +def ensure_dir(path: Path) -> Path: + """Ensure a directory exists, creating it if necessary. + + Args: + path: Path to the directory. + + Returns: + The path to the directory. + """ + path.mkdir(parents=True, exist_ok=True) + return path + + +def project_root(slug: str, projects_root: Optional[Path] = None) -> Path: + """Get the root path for a project. + + Args: + slug: Project slug. + projects_root: Root directory for all projects. Defaults to ./projects. + + Returns: + Path to the project root. + """ + if projects_root is None: + projects_root = Path("projects") + return projects_root / slug + + +def relative_to_project(slug: str, relative_path: str, projects_root: Optional[Path] = None) -> Path: + """Get a path relative to a project. + + Args: + slug: Project slug. + relative_path: Relative path within the project. + projects_root: Root directory for all projects. + + Returns: + Absolute path to the file within the project. + """ + root = project_root(slug, projects_root) + return root / relative_path + + +def is_within_project(slug: str, file_path: Path, projects_root: Optional[Path] = None) -> bool: + """Check if a file path is within a project. + + Args: + slug: Project slug. + file_path: Path to check. + projects_root: Root directory for all projects. + + Returns: + True if file_path is within the project directory. + """ + project_path = project_root(slug, projects_root) + try: + file_path.resolve().relative_to(project_path.resolve()) + return True + except ValueError: + return False + + +def sanitize_filename(filename: str) -> str: + """Sanitize a filename by removing invalid characters. + + Args: + filename: Original filename. + + Returns: + Sanitized filename safe for file system use. + """ + # Remove or replace invalid characters + invalid_chars = '<>:"/\\|?*' + for char in invalid_chars: + filename = filename.replace(char, "_") + + # Limit length + if len(filename) > 255: + filename = filename[:255] + + return filename diff --git a/tracker/utils/time.py b/tracker/utils/time.py index 5992470..b13ca65 100644 --- a/tracker/utils/time.py +++ b/tracker/utils/time.py @@ -1 +1,91 @@ """Time utility functions.""" + +from datetime import datetime, timedelta + + +def format_duration(minutes: int) -> str: + """Format duration in minutes to human-readable string. + + Args: + minutes: Duration in minutes. + + Returns: + Human-readable duration string (e.g., "1h 30m", "45m"). + """ + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + remaining_minutes = minutes % 60 + if remaining_minutes == 0: + return f"{hours}h" + return f"{hours}h {remaining_minutes}m" + + +def parse_duration(duration_str: str) -> int: + """Parse duration string to minutes. + + Args: + duration_str: Duration string like "1h 30m", "45m", "2h". + + Returns: + Duration in minutes. + """ + total_minutes = 0 + duration_str = duration_str.lower().strip() + + # Parse hours + if "h" in duration_str: + parts = duration_str.split() + for part in parts: + if "h" in part: + total_minutes += int(part.replace("h", "")) * 60 + elif "m" in part: + total_minutes += int(part.replace("m", "")) + + # If no hours, try just minutes + if total_minutes == 0 and "m" in duration_str: + total_minutes = int(duration_str.replace("m", "")) + elif total_minutes == 0: + try: + total_minutes = int(duration_str) + except ValueError: + return 0 + + return total_minutes + + +def is_recent(dt: datetime, hours: int = 24) -> bool: + """Check if a datetime is within the specified hours. + + Args: + dt: Datetime to check. + hours: Number of hours to consider as "recent". + + Returns: + True if dt is within the specified hours. + """ + return datetime.now() - dt < timedelta(hours=hours) + + +def format_datetime(dt: datetime) -> str: + """Format datetime to standard string. + + Args: + dt: Datetime to format. + + Returns: + Formatted string in YYYY-MM-DD HH:MM format. + """ + return dt.strftime("%Y-%m-%d %H:%M") + + +def format_date(dt: datetime) -> str: + """Format datetime to date string. + + Args: + dt: Datetime to format. + + Returns: + Formatted string in YYYY-MM-DD format. + """ + return dt.strftime("%Y-%m-%d")