diff --git a/tracker/cli/commands.py b/tracker/cli/commands.py index 34240a3..648210f 100644 --- a/tracker/cli/commands.py +++ b/tracker/cli/commands.py @@ -35,11 +35,7 @@ markdown_writer = MarkdownWriter() # ============================================================================= # init-project command # ============================================================================= -init_project = typer.Typer(help="Create a new project with standard structure.") - - -@init_project.command("init-project") -def cmd_init_project( +def init_project( name: str = typer.Argument(..., help="Project name"), type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"), tags: str = typer.Option("", help="Comma-separated tags"), @@ -86,11 +82,7 @@ def cmd_init_project( # ============================================================================= # list command # ============================================================================= -list_projects = typer.Typer(help="List all projects.") - - -@list_projects.command("list") -def cmd_list_projects() -> None: +def list_projects_cmd() -> None: """Show all projects with their status, last session, and next steps.""" projects = list_projects() @@ -125,11 +117,7 @@ def cmd_list_projects() -> None: # ============================================================================= # show command # ============================================================================= -show_project = typer.Typer(help="Show project details.") - - -@show_project.command("show") -def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> None: +def show_project(slug: str = typer.Argument(..., help="Project slug")) -> None: """Show detailed project information including status, context, last summary, blockers, and next steps.""" # Load project project_dict = storage.read_project_meta(slug) @@ -185,11 +173,7 @@ def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> No # ============================================================================= # start command # ============================================================================= -start_session = typer.Typer(help="Start a work session.") - - -@start_session.command("start") -def cmd_start_session( +def start_session( slug: str = typer.Argument(..., help="Project slug"), objective: Optional[str] = typer.Option(None, help="Session objective"), ) -> None: @@ -248,11 +232,7 @@ def cmd_start_session( # ============================================================================= # note command # ============================================================================= -add_note_cmd = typer.Typer(help="Add a note to the active session.") - - -@add_note_cmd.command("note") -def cmd_add_note( +def add_note_cmd( text: str = typer.Argument(..., help="Note text"), type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"), ) -> None: @@ -291,11 +271,7 @@ def cmd_add_note( # ============================================================================= # stop command # ============================================================================= -stop_session = typer.Typer(help="Stop the current session.") - - -@stop_session.command("stop") -def cmd_stop_session( +def stop_session( slug: str = typer.Argument(..., help="Project slug"), add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"), ) -> None: @@ -386,11 +362,7 @@ def cmd_stop_session( # ============================================================================= # change command # ============================================================================= -add_change = typer.Typer(help="Add a change entry to CHANGELOG.md.") - - -@add_change.command("change") -def cmd_add_change( +def add_change( slug: str = typer.Argument(..., help="Project slug"), type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"), title: str = typer.Option(..., help="Change title"), @@ -418,11 +390,7 @@ def cmd_add_change( # ============================================================================= # next command # ============================================================================= -suggest_next = typer.Typer(help="Suggest next steps for a project.") - - -@suggest_next.command("next") -def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None: +def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None: """Suggest next steps based on project history and heuristics. Uses simple rules: @@ -487,11 +455,7 @@ def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> No # ============================================================================= # review command # ============================================================================= -review = typer.Typer(help="Review all projects.") - - -@review.command("review") -def cmd_review() -> None: +def review() -> None: """Show an overview of all projects. Displays: @@ -698,7 +662,7 @@ def _update_readme_autogen(slug: str, session: Session) -> None: # Register all commands at module level for direct access __all__ = [ "init_project", - "list_projects", + "list_projects_cmd", "show_project", "start_session", "add_note_cmd", diff --git a/tracker/cli/main.py b/tracker/cli/main.py index e565b13..a8d0edf 100644 --- a/tracker/cli/main.py +++ b/tracker/cli/main.py @@ -4,7 +4,7 @@ import typer from tracker.cli.commands import ( init_project, - list_projects, + list_projects_cmd, show_project, start_session, add_note_cmd, @@ -20,16 +20,16 @@ app = typer.Typer( ) -# 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") +# Register all commands +app.command("init-project")(init_project) +app.command("list")(list_projects_cmd) +app.command("show")(show_project) +app.command("start")(start_session) +app.command("note")(add_note_cmd) +app.command("stop")(stop_session) +app.command("change")(add_change) +app.command("next")(suggest_next) +app.command("review")(review) @app.callback() diff --git a/tracker/services/project_service.py b/tracker/services/project_service.py index 3b17f8f..2402e7e 100644 --- a/tracker/services/project_service.py +++ b/tracker/services/project_service.py @@ -5,6 +5,8 @@ from datetime import datetime from pathlib import Path from typing import Optional +import yaml + from ..models import Project @@ -49,8 +51,8 @@ def create_project( type=type, status="inbox", tags=tags, - root_path=_PROJECTS_ROOT / slug, - repo_path=repo_path, + root_path=str(_PROJECTS_ROOT / slug), + repo_path=str(repo_path) if repo_path else None, created_at=datetime.now(), updated_at=datetime.now(), ) @@ -60,12 +62,20 @@ def create_project( def get_project(slug: str) -> Optional[Project]: """ Get a project by slug. - Note: This reads from file system - placeholder for storage integration. + Reads from meta/project.yaml in the project directory. """ meta_path = _get_project_meta_path(slug) if not meta_path.exists(): return None - # TODO: Load from storage (YAML) + + try: + with open(meta_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if data: + return Project(**data) + except (yaml.YAMLError, TypeError): + pass + return None diff --git a/tracker/storage/file_storage.py b/tracker/storage/file_storage.py index ffdce31..f64decd 100644 --- a/tracker/storage/file_storage.py +++ b/tracker/storage/file_storage.py @@ -1,6 +1,7 @@ """Storage layer for file-based persistence.""" import json +from datetime import datetime from pathlib import Path from typing import Optional @@ -117,22 +118,34 @@ class FileStorage: f.write(new_content) def write_session_file(self, session: Session) -> None: - """Crea projects//sessions/YYYY-MM-DD_HHMM.md""" + """Crea projects//sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente.""" from tracker.storage.markdown_writer import MarkdownWriter sessions_path = self._sessions_path(session.project_slug) sessions_path.mkdir(parents=True, exist_ok=True) started = session.started_at - filename = started.strftime("%Y-%m-%d_%H%M.md") - session_path = sessions_path / filename + md_filename = started.strftime("%Y-%m-%d_%H%M.md") + json_filename = f"{session.id}.json" + # Write markdown file writer = MarkdownWriter() content = writer.format_session_file(session) - - with open(session_path, "w", encoding="utf-8") as f: + md_path = sessions_path / md_filename + with open(md_path, "w", encoding="utf-8") as f: f.write(content) + # Write JSON file for tracking + json_path = sessions_path / json_filename + session_data = session.model_dump(mode="json") + # Serialize datetime objects to ISO format + if isinstance(session_data.get("started_at"), datetime): + session_data["started_at"] = session_data["started_at"].isoformat() + if isinstance(session_data.get("ended_at"), datetime): + session_data["ended_at"] = session_data["ended_at"].isoformat() + with open(json_path, "w", encoding="utf-8") as f: + json.dump(session_data, f, indent=2, ensure_ascii=False, default=str) + def active_session_path(self) -> Path: """Returns Path to projects/.active_session.json""" return self.projects_root / ".active_session.json" @@ -156,3 +169,98 @@ class FileStorage: path = self.active_session_path() if path.exists(): path.unlink() + + def write_file(self, slug: str, relative_path: str, content: str) -> None: + """Escribe contenido a un archivo en el proyecto. + + Args: + slug: Project slug. + relative_path: Relative path within the project. + content: Content to write. + """ + file_path = self._project_path(slug) / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + def read_file(self, slug: str, relative_path: str) -> str: + """Lee contenido de un archivo en el proyecto. + + Args: + slug: Project slug. + relative_path: Relative path within the project. + + Returns: + File content or empty string if not found. + """ + file_path = self._project_path(slug) / relative_path + if not file_path.exists(): + return "" + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + + def extract_autogen_section(self, slug: str, section: str) -> str: + """Extrae contenido de una seccion AUTOGEN del README.md. + + Args: + slug: Project slug. + section: Section name (e.g., "SESSIONS", "NEXT_STEPS"). + + Returns: + Content between AUTOGEN markers, or empty string if not found. + """ + from tracker.storage.markdown_reader import MarkdownReader + reader = MarkdownReader() + content = self.read_readme(slug) + return reader.extract_autogen_section(content, section) + + def get_recent_sessions(self, slug: str, limit: int = 5) -> list: + """Obtiene las sesiones mas recientes de un proyecto. + + Args: + slug: Project slug. + limit: Maximum number of sessions to return. + + Returns: + List of Session objects sorted by date (most recent first). + """ + from tracker.models.session import Session + + sessions_path = self._sessions_path(slug) + if not sessions_path.exists(): + return [] + + sessions = [] + for json_file in sessions_path.glob("*.json"): + try: + with open(json_file, "r", encoding="utf-8") as f: + data = json.load(f) + session = Session(**data) + sessions.append(session) + except (json.JSONDecodeError, TypeError): + continue + + # Sort by started_at descending + sessions.sort(key=lambda s: s.started_at, reverse=True) + return sessions[:limit] + + def list_projects(self) -> list[str]: + """Lista todos los slugs de proyectos. + + Returns: + List of project slugs. + """ + if not self.projects_root.exists(): + return [] + return [d.name for d in self.projects_root.iterdir() if d.is_dir() and not d.name.startswith(".")] + + def project_exists(self, slug: str) -> bool: + """Verifica si un proyecto existe. + + Args: + slug: Project slug. + + Returns: + True if project exists. + """ + return self._project_path(slug).exists()