Add task management commands to CLI

Implement task add and task move subcommands for managing tasks in TASKS.md.

task add <slug> <title> [--section <section>]:
- Adds a task to the specified section (default: Inbox)
- Valid sections: Inbox, Proximo, En curso, Bloqueado, En espera, Hecho
- Creates section if it doesn't exist

task move <slug> <task_title> <to_section>:
- Searches for task by title across all sections
- Moves task to destination section
- Marks task as completed ([x]) when moved to Hecho
- Updates checkbox state based on destination

Includes parsing and building functions for TASKS.md with:
- Section normalization (English to Spanish names)
- Merge support for duplicate normalized sections
- Standard section ordering

Uses app.add_typer to register task subcommand group.
This commit is contained in:
2026-03-25 00:26:37 -03:00
parent 4212a17e4b
commit bd48122db2
2 changed files with 236 additions and 0 deletions

View File

@@ -659,6 +659,229 @@ def _update_readme_autogen(slug: str, session: Session) -> None:
storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip()) storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip())
# =============================================================================
# task add command
# =============================================================================
def task_add(
slug: str = typer.Argument(..., help="Project slug"),
title: str = typer.Argument(..., help="Task title"),
section: str = typer.Option("Inbox", help="Section name (Inbox, Próximo, En curso, Bloqueado, En espera, Hecho)"),
) -> None:
"""Add a task to the project's TASKS.md.
Valid sections: Inbox, Próximo, En curso, Bloqueado, En espera, Hecho.
If the section doesn't exist, it will be created.
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections (Spanish names as per spec)
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if section not in valid_sections:
typer.echo(
f"Error: Invalid section '{section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Add task to the specified section
new_task = f"- [ ] {title}"
if section in sections:
sections[section].append(new_task)
else:
# Create new section with the task
sections[section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
typer.echo(f"Added task to '{section}' section: {title}")
# =============================================================================
# task move command
# =============================================================================
def task_move(
slug: str = typer.Argument(..., help="Project slug"),
task_title: str = typer.Argument(..., help="Task title to search for"),
to_section: str = typer.Argument(..., help="Destination section"),
) -> None:
"""Move a task to a different section in TASKS.md.
Searches for the task by title in any section and moves it to the destination.
Tasks moved to 'Hecho' will be marked as completed ([x]).
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if to_section not in valid_sections:
typer.echo(
f"Error: Invalid section '{to_section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Find the task
found_task = None
found_in_section = None
task_pattern = f"- [ ] {task_title}"
task_pattern_done = f"- [x] {task_title}"
for section_name, tasks in sections.items():
for task in tasks:
if task == task_pattern or task == task_pattern_done:
found_task = task
found_in_section = section_name
break
if found_task:
break
if not found_task:
typer.echo(f"Error: Task '{task_title}' not found in any section.", err=True)
raise typer.Exit(code=1)
if found_in_section == to_section:
typer.echo(f"Task '{task_title}' is already in '{to_section}' section.")
return
# Remove from original section
sections[found_in_section].remove(found_task)
if not sections[found_in_section]:
del sections[found_in_section]
# Determine checkbox state based on destination section
if to_section == "Hecho":
new_task = f"- [x] {task_title}"
else:
new_task = f"- [ ] {task_title}"
# Add to destination section
if to_section in sections:
sections[to_section].append(new_task)
else:
sections[to_section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
checkbox = "[x]" if to_section == "Hecho" else "[ ]"
typer.echo(f"Moved task '{task_title}' from '{found_in_section}' to '{to_section}' ({checkbox})")
# =============================================================================
# Helper functions for TASKS.md parsing
# =============================================================================
def _parse_tasks_sections(content: str) -> dict:
"""Parse TASKS.md content into sections and tasks.
Returns a dict mapping section names to lists of task strings.
Normalizes English section names to Spanish and merges duplicate sections.
"""
import re
# Mapping from English to Spanish section names
section_mapping = {
"Next": "Próximo",
"In Progress": "En curso",
"Blocked": "Bloqueado",
"Waiting": "En espera",
"Done": "Hecho",
"Inbox": "Inbox", # Same in both
}
sections = {}
current_section = None
current_tasks = []
# Match section headers (## Section Name)
section_pattern = re.compile(r"^##\s+(.+)$")
# Match task items (- [ ] task or - [x] task)
task_pattern = re.compile(r"^(- \[[ x]\]) (.+)$")
def save_current_section():
"""Save current section tasks, merging if normalized name already exists."""
if current_section is not None and current_tasks:
if current_section in sections:
sections[current_section].extend(current_tasks)
else:
sections[current_section] = current_tasks
for line in content.split("\n"):
section_match = section_pattern.match(line)
if section_match:
# Save previous section if exists
save_current_section()
raw_section = section_match.group(1)
# Normalize section name
current_section = section_mapping.get(raw_section, raw_section)
current_tasks = []
else:
task_match = task_pattern.match(line)
if task_match and current_section is not None:
checkbox = task_match.group(1)
title = task_match.group(2)
current_tasks.append(f"{checkbox} {title}")
# Save last section
save_current_section()
return sections
def _build_tasks_content(sections: dict) -> str:
"""Build TASKS.md content from sections dict.
Maintains the order of sections as specified.
"""
section_order = ["Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"]
lines = ["# Tasks", ""]
for section_name in section_order:
lines.append(f"## {section_name}")
tasks = sections.get(section_name, [])
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
# Add any sections not in the standard order
for section_name in sections:
if section_name not in section_order:
lines.append(f"## {section_name}")
tasks = sections[section_name]
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# Register all commands at module level for direct access # Register all commands at module level for direct access
__all__ = [ __all__ = [
"init_project", "init_project",
@@ -670,4 +893,6 @@ __all__ = [
"add_change", "add_change",
"suggest_next", "suggest_next",
"review", "review",
"task_add",
"task_move",
] ]

View File

@@ -12,6 +12,8 @@ from tracker.cli.commands import (
add_change, add_change,
suggest_next, suggest_next,
review, review,
task_add,
task_move,
) )
app = typer.Typer( app = typer.Typer(
@@ -19,6 +21,14 @@ app = typer.Typer(
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML", help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
) )
# Sub-command group for task management
task_app = typer.Typer(help="Task management commands")
# Register task subcommands
task_app.command("add")(task_add)
task_app.command("move")(task_move)
# Register all commands # Register all commands
app.command("init-project")(init_project) app.command("init-project")(init_project)
@@ -30,6 +40,7 @@ app.command("stop")(stop_session)
app.command("change")(add_change) app.command("change")(add_change)
app.command("next")(suggest_next) app.command("next")(suggest_next)
app.command("review")(review) app.command("review")(review)
app.add_typer(task_app, name="task")
@app.callback() @app.callback()