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:
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user