Files

802 lines
31 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Task Management Script.
Usage:
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
python3 task.py validate <dir> # Validate jsonl files
python3 task.py list-context <dir> # List jsonl entries
python3 task.py start <dir> # Set active task, record current branch
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title
python3 task.py set-meta <dir> <key> <value> # Set a task metadata key
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
python3 task.py list # List active tasks
python3 task.py list-archive [month] # List archived tasks
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from common.log import Colors, colored
from common.paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_TASKS,
FILE_TASK_JSON,
get_repo_root,
get_developer,
get_tasks_dir,
get_current_task,
)
from common.active_task import (
clear_active_task,
resolve_active_task,
resolve_context_key,
set_active_task,
)
from common.git import current_branch_name
from common.io import (
describe_json_read_failure,
read_json_checked,
write_json,
)
from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress
# Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import (
cmd_create,
cmd_rename,
cmd_archive,
cmd_set_branch,
cmd_set_base_branch,
cmd_set_scope,
cmd_set_meta,
cmd_add_subtask,
cmd_remove_subtask,
)
from common.task_context import (
cmd_add_context,
cmd_validate,
cmd_list_context,
curated_entry_count,
)
# =============================================================================
# Command: start / finish
# =============================================================================
def _record_start_state(
task_json_path: Path,
repo_root: Path,
label: str = "",
) -> None:
"""Move a freshly started task to in_progress and record its branch.
Both updates share one read/write: the status flip from planning, and the
checked-out branch when `branch` is still empty. Recording at start is what
keeps `branch` trustworthy at archive time — a task whose branch is only
ever set by hand tends to reach archive with `branch: null`.
Tolerant on purpose — a broken task.json does not fail `start`, because the
session pointer is the point of the command. But the read overwrites the
file it just read, so no failure may be silent: without a message the
absent status line looks like the task simply was not in planning.
"""
data, reason = read_json_checked(task_json_path)
if data is None:
problem, hint = describe_json_read_failure(task_json_path, reason)
print(
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
file=sys.stderr,
)
print(hint, file=sys.stderr)
return
applied: list[str] = []
if data.get("status") == "planning":
data["status"] = "in_progress"
applied.append(f"✓ Status: planning → in_progress{label}")
# Only fill an empty field: an explicit `set-branch` must survive a later
# `start` (re-starting a task after a checkout is a normal thing to do).
base_branch_conflict: str | None = None
if not data.get("branch"):
branch = current_branch_name(repo_root)
if branch:
data["branch"] = branch
applied.append(f"✓ Branch recorded: {branch}{label}")
if branch == data.get("base_branch"):
base_branch_conflict = branch
else:
print(
colored(
"Note: no checked-out branch (detached HEAD, or not a git "
"repository); task branch not recorded.",
Colors.YELLOW,
),
file=sys.stderr,
)
if not applied:
return
if not write_json(task_json_path, data):
print(
colored(
f"Warning: Failed to write {task_json_path}; "
"status and branch are unchanged.",
Colors.YELLOW,
),
file=sys.stderr,
)
return
for line in applied:
print(colored(line, Colors.GREEN))
if base_branch_conflict:
# Recorded anyway — the value is true, it just cannot describe a PR.
# Archive refuses this shape, so say so now rather than at the gate.
print(
colored(
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
"a PR cannot target its own branch, and archive will refuse it.",
Colors.YELLOW,
),
file=sys.stderr,
)
print(
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
"set-branch <task> <feature-branch>",
file=sys.stderr,
)
def cmd_start(args: argparse.Namespace) -> int:
"""Set active task."""
repo_root = get_repo_root()
task_input = args.dir
if not task_input:
print(colored("Error: task directory or name required", Colors.RED))
return 1
# Resolve task directory (supports task name, relative path, or absolute path)
full_path = resolve_task_dir(task_input, repo_root)
if full_path is None:
# resolve_task_dir already named the exact reason on stderr. A second,
# generic line on stdout would split one diagnosis across two streams
# and bury the specific message.
return 1
if not full_path.is_dir():
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
# manifest means every sub-agent dispatched for this task runs with zero
# spec context, and nothing downstream surfaces that to the main session.
# An absent manifest is not gated — create seeds the files only on
# sub-agent-capable platforms, so absence means no sub-agent reads them.
if not getattr(args, "allow_empty_context", False):
empty_manifests = [
name
for name in ("implement.jsonl", "check.jsonl")
if curated_entry_count(full_path / name) == 0
]
if empty_manifests:
print(colored(
f"Error: {' and '.join(empty_manifests)} "
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
Colors.RED,
))
print("Sub-agents (implement/check) would run with zero spec context.")
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
print(" Intentionally empty? Re-run start with --allow-empty-context")
return 1
# Convert to relative path for storage. repo_root is resolved because
# full_path already is (resolve_task_dir only returns paths inside the
# resolved root), so an unresolved repo_root would mismatch under a
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
try:
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
except ValueError:
# resolve_task_dir already refused everything outside the repo, so
# this is unreachable in practice. Refuse rather than fall back to
# str(full_path) — that fallback (a lexical relative_to() paired with
# an absolute-path fallback) is exactly the pattern that let a `..`
# ref escape into storage before this fix.
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
task_json_path = full_path / FILE_TASK_JSON
if not resolve_context_key():
# Degraded mode: no session identity available.
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
# per-session pointer write; AI continues based on conversation context.
print(colored(
"ℹ Session identity not available; active-task pointer not persisted "
"this session (degraded mode). AI continues based on conversation context.",
Colors.YELLOW,
))
print(colored(
"Hint: run inside an AI IDE/session that exposes session identity, "
"or set TRELLIS_CONTEXT_ID before running task.py start.",
Colors.YELLOW,
))
# Still flip task.json status: planning → in_progress so downstream phases proceed.
if task_json_path.is_file():
_record_start_state(task_json_path, repo_root, " (degraded)")
run_task_hooks("after_start", task_json_path, repo_root)
return 0
active = set_active_task(task_dir, repo_root)
if active:
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
print(f"Source: {active.source}")
if task_json_path.is_file():
_record_start_state(task_json_path, repo_root)
print()
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
run_task_hooks("after_start", task_json_path, repo_root)
return 0
else:
print(colored("Error: Failed to set current task", Colors.RED))
return 1
def cmd_finish(args: argparse.Namespace) -> int:
"""Clear active task."""
repo_root = get_repo_root()
active = clear_active_task(repo_root)
current = active.task_path
if not current:
print(colored("No current task set", Colors.YELLOW))
return 0
# Resolve task.json path before clearing
task_json_path = repo_root / current / FILE_TASK_JSON
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
print(f"Source: {active.source}")
if task_json_path.is_file():
run_task_hooks("after_finish", task_json_path, repo_root)
return 0
def cmd_current(args: argparse.Namespace) -> int:
"""Show active task."""
repo_root = get_repo_root()
active = resolve_active_task(repo_root)
if getattr(args, "json", False):
task_obj = None
read_error = None
if active.task_path:
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
data, reason = read_json_checked(task_json_path)
if data is None:
# Without this, a corrupt task.json emits null for every field
# — indistinguishable from a task whose fields really are null.
problem, hint = describe_json_read_failure(task_json_path, reason)
read_error = {
"file": str(task_json_path),
"reason": reason,
"message": f"{problem}. {hint}",
}
data = {}
task_obj = {
"dir": active.task_path,
"id": data.get("id") or data.get("name"),
"title": data.get("title"),
"status": data.get("status"),
"parent": data.get("parent"),
"children": data.get("children", []),
"branch": data.get("branch"),
"base_branch": data.get("base_branch"),
}
payload = {
"current_task": task_obj,
"source": active.source,
"stale": active.stale,
}
# Only present when the read failed, so the healthy shape is unchanged.
if read_error:
payload["error"] = read_error
print(json.dumps(payload, ensure_ascii=False))
return 0 if active.task_path else 1
if args.source:
print(f"Current task: {active.task_path or '(none)'}")
print(f"Source: {active.source}")
if active.stale:
print("State: stale")
return 0 if active.task_path else 1
if active.task_path:
print(active.task_path)
return 0
return 1
# =============================================================================
# Command: list
# =============================================================================
def _display_status(t, all_statuses: dict) -> str:
"""Return the status label to show for a task in `list` output.
A parent task's stored status stays "planning" until someone runs
`task.py start` on the parent directly, even while its children are
actively being worked — a misleading label for anyone scanning the
list (#399 item 3). Show "active" instead when at least one child is
past planning; the stored status.json value is left untouched.
"""
if t.status == "planning" and t.children:
child_in_flight = any(
all_statuses.get(c) not in (None, "planning") for c in t.children
)
if child_in_flight:
return "active"
return t.status
def cmd_list(args: argparse.Namespace) -> int:
"""List active tasks."""
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
current_task = get_current_task(repo_root)
developer = get_developer(repo_root)
filter_mine = args.mine
filter_status = args.status
as_json = getattr(args, "json", False)
# Single pass: collect all tasks via shared iterator
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
all_statuses = {name: t.status for name, t in all_tasks.items()}
if as_json:
if filter_mine and not developer:
print(
json.dumps({"error": "No developer set", "hint": DEVELOPER_HINT}),
file=sys.stderr,
)
return 1
items = []
for dir_name in sorted(all_tasks.keys()):
t = all_tasks[dir_name]
if filter_mine and (t.assignee or "-") != developer:
continue
if filter_status and t.status != filter_status:
continue
items.append({
"dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}",
"id": t.raw.get("id") or dir_name,
"title": t.title,
"status": t.status,
"display_status": _display_status(t, all_statuses),
"priority": t.priority,
"assignee": t.assignee or None,
"parent": t.parent,
"children": list(t.children),
"package": t.package,
})
print(json.dumps({"tasks": items}, ensure_ascii=False))
return 0
if filter_mine:
if not developer:
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
return 1
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
else:
print(colored("All active tasks:", Colors.BLUE))
print()
# Display tasks hierarchically
count = 0
def _print_task(dir_name: str, indent: int = 0) -> None:
nonlocal count
t = all_tasks[dir_name]
# Apply --mine filter
if filter_mine and (t.assignee or "-") != developer:
return
# Apply --status filter
if filter_status and t.status != filter_status:
return
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
marker = ""
if relative_path == current_task:
marker = f" {colored('<- current', Colors.GREEN)}"
# Children progress
progress = children_progress(t.children, all_statuses)
status_label = _display_status(t, all_statuses)
# Package tag
pkg_tag = f" @{t.package}" if t.package else ""
prefix = " " * indent + " - "
if filter_mine:
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}")
else:
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
count += 1
# Print children indented
for child_name in t.children:
if child_name in all_tasks:
_print_task(child_name, indent + 1)
# Display only top-level tasks: those without a parent, plus orphans
# whose recorded parent is not (or no longer) in the active set — a
# dangling parent ref must still render flat instead of disappearing.
for dir_name in sorted(all_tasks.keys()):
parent = all_tasks[dir_name].parent
if not parent or parent not in all_tasks:
_print_task(dir_name)
if count == 0:
if filter_mine:
print(" (no tasks assigned to you)")
else:
print(" (no active tasks)")
print()
print(f"Total: {count} task(s)")
return 0
# =============================================================================
# Command: list-archive
# =============================================================================
def cmd_list_archive(args: argparse.Namespace) -> int:
"""List archived tasks."""
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
archive_dir = tasks_dir / "archive"
month = args.month
print(colored("Archived tasks:", Colors.BLUE))
print()
if month:
month_dir = archive_dir / month
if month_dir.is_dir():
print(f"[{month}]")
for d in sorted(month_dir.iterdir()):
if d.is_dir():
print(f" - {d.name}/")
else:
print(f" No archives for {month}")
else:
if archive_dir.is_dir():
for month_dir in sorted(archive_dir.iterdir()):
if month_dir.is_dir():
month_name = month_dir.name
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
print(f"[{month_name}] - {count} task(s)")
return 0
# =============================================================================
# Help
# =============================================================================
def show_usage() -> None:
"""Show usage help."""
print("""Task Management Script
Usage:
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task; records the checked-out branch when unset
python3 task.py current [--source] Show active task
python3 task.py finish Clear active task
python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title
python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
python3 task.py archive <task-dir> Archive completed task
python3 task.py add-subtask <parent> <child> Link child task to parent
python3 task.py remove-subtask <parent> <child> Unlink child from parent
python3 task.py list [--mine] [--status <status>] [--json] List tasks
python3 task.py list-archive [YYYY-MM] List archived tasks
Monorepo options:
--package <pkg> Package name (validated against config.yaml packages)
Rename options:
--dry-run Print the change set without writing anything
Archive options:
--no-commit Skip the auto git commit after archiving
--skip-branch-validation Archive despite missing or self-referential branch metadata.
Archive normally refuses a task with no `branch` when it has a
`base_branch` and the repo has a remote, or with
`branch == base_branch`; repair those with `set-branch` /
`set-base-branch` instead. Use this flag only for tasks that
were never PR-backed. A recorded branch that was merged and
deleted is only a warning and needs no flag.
List options:
--mine, -m Show only tasks assigned to current developer
--status, -s <s> Filter by status (planning, in_progress, review, completed)
--json Output machine-readable JSON (also available on `current`)
Examples:
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --description "Session cookie handling" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
python3 task.py set-branch <dir> task/add-login
python3 task.py start .trellis/tasks/01-21-add-login
python3 task.py current --source
python3 task.py finish
python3 task.py rename add-login add-sso --dry-run # Preview the change set
python3 task.py rename add-login add-sso
python3 task.py archive add-login
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
python3 task.py add-subtask parent-task child-task # Link existing tasks
python3 task.py remove-subtask parent-task child-task
python3 task.py list # List all active tasks
python3 task.py list --mine # List my tasks only
python3 task.py list --mine --status in_progress # List my in-progress tasks
""")
# =============================================================================
# Main Entry
# =============================================================================
def main() -> int:
"""CLI entry point."""
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
# Detect early so argparse doesn't mask the real reason with a generic
# "invalid choice" error.
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
print(
colored(
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
Colors.RED,
),
file=sys.stderr,
)
print(
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
file=sys.stderr,
)
print(
"sub-agent-capable platforms and curated by the AI during planning when needed.",
file=sys.stderr,
)
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
print(
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
file=sys.stderr,
)
print(
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
file=sys.stderr,
)
return 2
parser = argparse.ArgumentParser(
description="Task Management Script",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# create
p_create = subparsers.add_parser("create", help="Create new task")
p_create.add_argument("title", help="Task title (required, non-empty)")
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
p_create.add_argument("--assignee", "-a", help="Assignee developer")
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
p_create.add_argument(
"--description",
"-d",
help="Task description (required, non-empty — an empty one is refused at archive)",
)
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
p_create.add_argument("--package", help="Package name for monorepo projects")
p_create.add_argument(
"--base-branch",
help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)",
)
p_create.add_argument(
"--meta",
action="append",
help="Task metadata key=value (repeatable)",
)
p_create.add_argument(
"--no-start",
action="store_true",
help="Create the task without making it active in this session",
)
p_create.add_argument(
"--force",
action="store_true",
help="Overwrite task.json when the task directory already exists",
)
# add-context
p_add = subparsers.add_parser("add-context", help="Add context entry")
p_add.add_argument("dir", help="Task directory")
p_add.add_argument("file", help="JSONL file (implement|check)")
p_add.add_argument("path", help="File path to add")
p_add.add_argument("reason", nargs="?", help="Reason for adding")
# validate
p_validate = subparsers.add_parser("validate", help="Validate context files")
p_validate.add_argument("dir", help="Task directory")
# list-context
p_listctx = subparsers.add_parser("list-context", help="List context entries")
p_listctx.add_argument("dir", help="Task directory")
# start
p_start = subparsers.add_parser("start", help="Set active task")
p_start.add_argument("dir", help="Task directory")
p_start.add_argument(
"--allow-empty-context",
action="store_true",
help="Start even when implement.jsonl / check.jsonl have no curated entries",
)
# current
p_current = subparsers.add_parser("current", help="Show active task")
p_current.add_argument("--source", action="store_true",
help="Show active task source")
p_current.add_argument("--json", action="store_true",
help="Output machine-readable JSON")
# finish
subparsers.add_parser("finish", help="Clear active task")
# set-branch
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
p_branch.add_argument("dir", help="Task directory")
p_branch.add_argument("branch", help="Branch name")
# set-base-branch
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
p_base.add_argument("dir", help="Task directory")
p_base.add_argument("base_branch", help="Base branch name (PR target)")
# set-scope
p_scope = subparsers.add_parser("set-scope", help="Set scope")
p_scope.add_argument("dir", help="Task directory")
p_scope.add_argument("scope", help="Scope name")
# set-meta
p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key")
p_setmeta.add_argument("dir", help="Task directory")
p_setmeta.add_argument("key", help="Metadata key")
p_setmeta.add_argument("value", help="Metadata value")
# rename
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
p_rename.add_argument("name", help="Task directory or name")
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
p_rename.add_argument(
"--dry-run",
action="store_true",
help="Print the change set without writing anything",
)
# archive
p_archive = subparsers.add_parser("archive", help="Archive task")
p_archive.add_argument("name", help="Task directory or name")
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
p_archive.add_argument(
"--skip-branch-validation",
action="store_true",
help=(
"Archive even when branch metadata is missing or self-referential "
"(for tasks that were never PR-backed)"
),
)
# list
p_list = subparsers.add_parser("list", help="List tasks")
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
p_list.add_argument("--status", "-s", help="Filter by status")
p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON")
# add-subtask
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
p_addsub.add_argument("parent_dir", help="Parent task directory")
p_addsub.add_argument("child_dir", help="Child task directory")
# remove-subtask
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
p_rmsub.add_argument("parent_dir", help="Parent task directory")
p_rmsub.add_argument("child_dir", help="Child task directory")
# list-archive
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
args = parser.parse_args()
if not args.command:
show_usage()
return 1
commands = {
"create": cmd_create,
"add-context": cmd_add_context,
"validate": cmd_validate,
"list-context": cmd_list_context,
"start": cmd_start,
"current": cmd_current,
"finish": cmd_finish,
"set-branch": cmd_set_branch,
"set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope,
"set-meta": cmd_set_meta,
"rename": cmd_rename,
"archive": cmd_archive,
"add-subtask": cmd_add_subtask,
"remove-subtask": cmd_remove_subtask,
"list": cmd_list,
"list-archive": cmd_list_archive,
}
if args.command in commands:
return commands[args.command](args)
else:
show_usage()
return 1
if __name__ == "__main__":
sys.exit(main())