Implement complete CLI commands for MVP-1 Personal Tracker

- Refactored CLI commands from nested Typer subapps to direct command functions
- Fixed main.py to use app.command() instead of app.add_typer_command()
- Fixed project_service.py to properly load projects from YAML
- Fixed file_storage.py to save session JSON files alongside markdown
- Added missing methods: write_file, read_file, extract_autogen_section, get_recent_sessions
- Fixed root_path and repo_path to use strings instead of Path objects
This commit is contained in:
2026-03-23 09:02:21 -03:00
parent 40a33d773b
commit b36b60353d
4 changed files with 148 additions and 66 deletions

View File

@@ -35,11 +35,7 @@ markdown_writer = MarkdownWriter()
# ============================================================================= # =============================================================================
# init-project command # init-project command
# ============================================================================= # =============================================================================
init_project = typer.Typer(help="Create a new project with standard structure.") def init_project(
@init_project.command("init-project")
def cmd_init_project(
name: str = typer.Argument(..., help="Project name"), name: str = typer.Argument(..., help="Project name"),
type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"), type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"),
tags: str = typer.Option("", help="Comma-separated tags"), tags: str = typer.Option("", help="Comma-separated tags"),
@@ -86,11 +82,7 @@ def cmd_init_project(
# ============================================================================= # =============================================================================
# list command # list command
# ============================================================================= # =============================================================================
list_projects = typer.Typer(help="List all projects.") def list_projects_cmd() -> None:
@list_projects.command("list")
def cmd_list_projects() -> None:
"""Show all projects with their status, last session, and next steps.""" """Show all projects with their status, last session, and next steps."""
projects = list_projects() projects = list_projects()
@@ -125,11 +117,7 @@ def cmd_list_projects() -> None:
# ============================================================================= # =============================================================================
# show command # show command
# ============================================================================= # =============================================================================
show_project = typer.Typer(help="Show project details.") def show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
@show_project.command("show")
def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Show detailed project information including status, context, last summary, blockers, and next steps.""" """Show detailed project information including status, context, last summary, blockers, and next steps."""
# Load project # Load project
project_dict = storage.read_project_meta(slug) 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 command
# ============================================================================= # =============================================================================
start_session = typer.Typer(help="Start a work session.") def start_session(
@start_session.command("start")
def cmd_start_session(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
objective: Optional[str] = typer.Option(None, help="Session objective"), objective: Optional[str] = typer.Option(None, help="Session objective"),
) -> None: ) -> None:
@@ -248,11 +232,7 @@ def cmd_start_session(
# ============================================================================= # =============================================================================
# note command # note command
# ============================================================================= # =============================================================================
add_note_cmd = typer.Typer(help="Add a note to the active session.") def add_note_cmd(
@add_note_cmd.command("note")
def cmd_add_note(
text: str = typer.Argument(..., help="Note text"), text: str = typer.Argument(..., help="Note text"),
type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"), type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"),
) -> None: ) -> None:
@@ -291,11 +271,7 @@ def cmd_add_note(
# ============================================================================= # =============================================================================
# stop command # stop command
# ============================================================================= # =============================================================================
stop_session = typer.Typer(help="Stop the current session.") def stop_session(
@stop_session.command("stop")
def cmd_stop_session(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"), add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"),
) -> None: ) -> None:
@@ -386,11 +362,7 @@ def cmd_stop_session(
# ============================================================================= # =============================================================================
# change command # change command
# ============================================================================= # =============================================================================
add_change = typer.Typer(help="Add a change entry to CHANGELOG.md.") def add_change(
@add_change.command("change")
def cmd_add_change(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"), type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"),
title: str = typer.Option(..., help="Change title"), title: str = typer.Option(..., help="Change title"),
@@ -418,11 +390,7 @@ def cmd_add_change(
# ============================================================================= # =============================================================================
# next command # next command
# ============================================================================= # =============================================================================
suggest_next = typer.Typer(help="Suggest next steps for a project.") def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
@suggest_next.command("next")
def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Suggest next steps based on project history and heuristics. """Suggest next steps based on project history and heuristics.
Uses simple rules: Uses simple rules:
@@ -487,11 +455,7 @@ def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> No
# ============================================================================= # =============================================================================
# review command # review command
# ============================================================================= # =============================================================================
review = typer.Typer(help="Review all projects.") def review() -> None:
@review.command("review")
def cmd_review() -> None:
"""Show an overview of all projects. """Show an overview of all projects.
Displays: Displays:
@@ -698,7 +662,7 @@ def _update_readme_autogen(slug: str, session: Session) -> None:
# Register all commands at module level for direct access # Register all commands at module level for direct access
__all__ = [ __all__ = [
"init_project", "init_project",
"list_projects", "list_projects_cmd",
"show_project", "show_project",
"start_session", "start_session",
"add_note_cmd", "add_note_cmd",

View File

@@ -4,7 +4,7 @@ import typer
from tracker.cli.commands import ( from tracker.cli.commands import (
init_project, init_project,
list_projects, list_projects_cmd,
show_project, show_project,
start_session, start_session,
add_note_cmd, add_note_cmd,
@@ -20,16 +20,16 @@ app = typer.Typer(
) )
# Register all subcommands # Register all commands
app.add_typer_command(init_project, name="init-project") app.command("init-project")(init_project)
app.add_typer_command(list_projects, name="list") app.command("list")(list_projects_cmd)
app.add_typer_command(show_project, name="show") app.command("show")(show_project)
app.add_typer_command(start_session, name="start") app.command("start")(start_session)
app.add_typer_command(add_note_cmd, name="note") app.command("note")(add_note_cmd)
app.add_typer_command(stop_session, name="stop") app.command("stop")(stop_session)
app.add_typer_command(add_change, name="change") app.command("change")(add_change)
app.add_typer_command(suggest_next, name="next") app.command("next")(suggest_next)
app.add_typer_command(review, name="review") app.command("review")(review)
@app.callback() @app.callback()

View File

@@ -5,6 +5,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml
from ..models import Project from ..models import Project
@@ -49,8 +51,8 @@ def create_project(
type=type, type=type,
status="inbox", status="inbox",
tags=tags, tags=tags,
root_path=_PROJECTS_ROOT / slug, root_path=str(_PROJECTS_ROOT / slug),
repo_path=repo_path, repo_path=str(repo_path) if repo_path else None,
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
) )
@@ -60,12 +62,20 @@ def create_project(
def get_project(slug: str) -> Optional[Project]: def get_project(slug: str) -> Optional[Project]:
""" """
Get a project by slug. 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) meta_path = _get_project_meta_path(slug)
if not meta_path.exists(): if not meta_path.exists():
return None 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 return None

View File

@@ -1,6 +1,7 @@
"""Storage layer for file-based persistence.""" """Storage layer for file-based persistence."""
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -117,22 +118,34 @@ class FileStorage:
f.write(new_content) f.write(new_content)
def write_session_file(self, session: Session) -> None: def write_session_file(self, session: Session) -> None:
"""Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md""" """Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente."""
from tracker.storage.markdown_writer import MarkdownWriter from tracker.storage.markdown_writer import MarkdownWriter
sessions_path = self._sessions_path(session.project_slug) sessions_path = self._sessions_path(session.project_slug)
sessions_path.mkdir(parents=True, exist_ok=True) sessions_path.mkdir(parents=True, exist_ok=True)
started = session.started_at started = session.started_at
filename = started.strftime("%Y-%m-%d_%H%M.md") md_filename = started.strftime("%Y-%m-%d_%H%M.md")
session_path = sessions_path / filename json_filename = f"{session.id}.json"
# Write markdown file
writer = MarkdownWriter() writer = MarkdownWriter()
content = writer.format_session_file(session) content = writer.format_session_file(session)
md_path = sessions_path / md_filename
with open(session_path, "w", encoding="utf-8") as f: with open(md_path, "w", encoding="utf-8") as f:
f.write(content) 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: def active_session_path(self) -> Path:
"""Returns Path to projects/.active_session.json""" """Returns Path to projects/.active_session.json"""
return self.projects_root / ".active_session.json" return self.projects_root / ".active_session.json"
@@ -156,3 +169,98 @@ class FileStorage:
path = self.active_session_path() path = self.active_session_path()
if path.exists(): if path.exists():
path.unlink() 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()