@alexey_platkovsky/taskpilot 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +141 -0
- package/bin/taskpilot +454 -0
- package/package.json +30 -0
- package/requirements.lock +66 -0
- package/src/taskpilot/__init__.py +1 -0
- package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__init__.py +6 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/app.py +61 -0
- package/src/taskpilot/cli/commands/__init__.py +6 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/init.py +116 -0
- package/src/taskpilot/cli/commands/item.py +305 -0
- package/src/taskpilot/cli/commands/project.py +50 -0
- package/src/taskpilot/cli/commands/serve.py +78 -0
- package/src/taskpilot/cli/commands/validate.py +61 -0
- package/src/taskpilot/cli/context.py +36 -0
- package/src/taskpilot/cli/errors.py +53 -0
- package/src/taskpilot/cli/exit_codes.py +20 -0
- package/src/taskpilot/cli/output.py +77 -0
- package/src/taskpilot/cli/workspace.py +33 -0
- package/src/taskpilot/core/__init__.py +5 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/comments.py +238 -0
- package/src/taskpilot/core/item_io.py +123 -0
- package/src/taskpilot/core/layout.py +137 -0
- package/src/taskpilot/core/loader.py +102 -0
- package/src/taskpilot/core/models.py +114 -0
- package/src/taskpilot/core/project.py +151 -0
- package/src/taskpilot/core/timestamps.py +54 -0
- package/src/taskpilot/core/validation.py +385 -0
- package/src/taskpilot/core/yaml_io.py +57 -0
- package/src/taskpilot/server/__init__.py +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
- package/src/taskpilot/server/app.py +134 -0
- package/src/taskpilot/server/routes/__init__.py +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/projects.py +160 -0
- package/src/taskpilot/server/schemas.py +76 -0
- package/src/taskpilot/services/__init__.py +8 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
- package/src/taskpilot/services/comment_service.py +62 -0
- package/src/taskpilot/services/errors.py +26 -0
- package/src/taskpilot/services/hierarchy.py +107 -0
- package/src/taskpilot/services/item_service.py +264 -0
- package/src/taskpilot/services/link_service.py +97 -0
- package/src/taskpilot/services/operation_validation.py +52 -0
- package/src/taskpilot/services/project_service.py +111 -0
- package/src/taskpilot/services/registry.py +194 -0
- package/src/taskpilot/services/reverse_links.py +60 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""``taskpilot init <path>`` — workspace creation + registration (task F003-T2, F003-R1).
|
|
2
|
+
|
|
3
|
+
Creates the repository-local ``.taskpilot/`` structure and ``project.yaml`` via
|
|
4
|
+
the F002 project service, then adds/enables the project in the local system
|
|
5
|
+
registry (spec ``0002`` "Project Initialization"). Identity is derived from the
|
|
6
|
+
target folder name and overridable with ``--id`` / ``--key`` / ``--name``.
|
|
7
|
+
|
|
8
|
+
``init`` is idempotent: re-running it on an already-initialized workspace does
|
|
9
|
+
not overwrite canonical identity (the service refuses that) but still re-enables
|
|
10
|
+
the project in the registry and reports it as already initialized.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from taskpilot.services import registry
|
|
21
|
+
from taskpilot.cli.context import get_state
|
|
22
|
+
from taskpilot.cli.errors import service_errors
|
|
23
|
+
from taskpilot.cli.output import print_json, print_line
|
|
24
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
25
|
+
from taskpilot.services import project_service
|
|
26
|
+
from taskpilot.services.errors import ConflictError, ValidationFailed
|
|
27
|
+
|
|
28
|
+
__all__ = ["register", "derive_key"]
|
|
29
|
+
|
|
30
|
+
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def derive_key(name: str) -> str:
|
|
34
|
+
"""Derive a default project key (item-id prefix) from a display ``name``.
|
|
35
|
+
|
|
36
|
+
Multi-word names use the uppercased first letter of each token
|
|
37
|
+
(``"Voice Pilot"`` / ``"voice-pilot"`` -> ``"VP"``); a single token is
|
|
38
|
+
uppercased whole (``"voicepilot"`` -> ``"VOICEPILOT"``). Returns ``""`` when
|
|
39
|
+
``name`` has no alphanumeric characters, so the caller can prompt for ``--key``.
|
|
40
|
+
"""
|
|
41
|
+
tokens = _TOKEN_RE.findall(name)
|
|
42
|
+
if not tokens:
|
|
43
|
+
return ""
|
|
44
|
+
if len(tokens) == 1:
|
|
45
|
+
return tokens[0].upper()
|
|
46
|
+
return "".join(token[0] for token in tokens).upper()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init_command(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
path: str = typer.Argument(
|
|
52
|
+
".", help="Repository root to initialize (only this folder is inspected)."
|
|
53
|
+
),
|
|
54
|
+
key: str = typer.Option(
|
|
55
|
+
None,
|
|
56
|
+
"--key",
|
|
57
|
+
help="Project key used as the item-ID prefix. Derived from the folder name if omitted.",
|
|
58
|
+
),
|
|
59
|
+
name: str = typer.Option(
|
|
60
|
+
None, "--name", help="Project display name. Defaults to the folder name."
|
|
61
|
+
),
|
|
62
|
+
project_id: str = typer.Option(
|
|
63
|
+
None, "--id", help="Project id. Derived from the name if omitted."
|
|
64
|
+
),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize a TaskPilot workspace and register the project."""
|
|
67
|
+
state = get_state(ctx)
|
|
68
|
+
target = Path(path).resolve()
|
|
69
|
+
resolved_name = name or target.name
|
|
70
|
+
resolved_key = key or derive_key(resolved_name)
|
|
71
|
+
paths = WorkspacePaths.for_root(target)
|
|
72
|
+
|
|
73
|
+
with service_errors():
|
|
74
|
+
if not resolved_key:
|
|
75
|
+
raise ValidationFailed(
|
|
76
|
+
f"Cannot derive a project key from {resolved_name!r}; pass --key"
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
meta = project_service.create_project(
|
|
80
|
+
paths, key=resolved_key, name=resolved_name, project_id=project_id
|
|
81
|
+
)
|
|
82
|
+
created = True
|
|
83
|
+
except ConflictError:
|
|
84
|
+
# Already initialized: keep canonical identity, just re-enable in the registry.
|
|
85
|
+
meta = project_service.read_project(paths)
|
|
86
|
+
created = False
|
|
87
|
+
|
|
88
|
+
entry = registry.register_project(
|
|
89
|
+
registry.default_registry_dir(),
|
|
90
|
+
id=meta.id,
|
|
91
|
+
key=meta.key,
|
|
92
|
+
name=meta.name,
|
|
93
|
+
path=str(target),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if state.json:
|
|
97
|
+
print_json(
|
|
98
|
+
{
|
|
99
|
+
"created": created,
|
|
100
|
+
"workspace": paths.relative_posix(paths.workspace_dir),
|
|
101
|
+
"project": meta.model_dump(),
|
|
102
|
+
"registered": entry.model_dump(),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if created:
|
|
108
|
+
print_line(f"Initialized TaskPilot workspace at {target / '.taskpilot'}")
|
|
109
|
+
else:
|
|
110
|
+
print_line(f"Workspace already initialized at {target / '.taskpilot'}")
|
|
111
|
+
print_line(f"Registered project {meta.key} ({meta.name}).")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def register(app: typer.Typer) -> None:
|
|
115
|
+
"""Attach the ``init`` command to ``app``."""
|
|
116
|
+
app.command("init")(init_command)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""``taskpilot item`` commands — list/show/create/update (task F003-T4, requirement F003-R4).
|
|
2
|
+
|
|
3
|
+
Thin adapters over the F002 item service. Enum-like fields (``type``, ``status``,
|
|
4
|
+
``priority``) are passed through as strings and validated by the service, which
|
|
5
|
+
is the single source of truth for domain rules — invalid values surface as a
|
|
6
|
+
``ValidationFailed`` mapped to exit code 1, before any file is written (F003-R4).
|
|
7
|
+
JSON output dumps the item model in canonical field order for determinism
|
|
8
|
+
(F003-R8).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import getpass
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from taskpilot.cli.context import get_state
|
|
19
|
+
from taskpilot.cli.errors import service_errors
|
|
20
|
+
from taskpilot.cli.output import print_json, print_line, render_key_values, render_table
|
|
21
|
+
from taskpilot.cli.workspace import find_workspace
|
|
22
|
+
from taskpilot.core.models import Item
|
|
23
|
+
from taskpilot.services import comment_service, item_service, link_service
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_author() -> str:
|
|
27
|
+
"""Best-effort local author identity for comments when ``--author`` is omitted."""
|
|
28
|
+
try:
|
|
29
|
+
return getpass.getuser()
|
|
30
|
+
except Exception: # pragma: no cover - getuser is environment-dependent
|
|
31
|
+
return "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["register"]
|
|
35
|
+
|
|
36
|
+
_HUMAN_SKIP = frozenset({"schema_version"})
|
|
37
|
+
|
|
38
|
+
item_app = typer.Typer(
|
|
39
|
+
name="item",
|
|
40
|
+
help="List, show, create, and update items.",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _emit_item(
|
|
47
|
+
ctx: typer.Context, item: Item, *, human_prefix: str | None = None
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Render a single item as JSON or a human key/value block (or a one-liner)."""
|
|
50
|
+
if get_state(ctx).json:
|
|
51
|
+
print_json(item.model_dump())
|
|
52
|
+
return
|
|
53
|
+
if human_prefix is not None:
|
|
54
|
+
print_line(f"{human_prefix} {item.id}")
|
|
55
|
+
return
|
|
56
|
+
pairs = [
|
|
57
|
+
(name, str(value))
|
|
58
|
+
for name, value in item.model_dump().items()
|
|
59
|
+
if value is not None and name not in _HUMAN_SKIP
|
|
60
|
+
]
|
|
61
|
+
print_line(render_key_values(pairs))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@item_app.command("list")
|
|
65
|
+
def item_list(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
status: Optional[str] = typer.Option(None, "--status", help="Filter by status."),
|
|
68
|
+
type: Optional[str] = typer.Option(None, "--type", help="Filter by item type."),
|
|
69
|
+
project: Optional[str] = typer.Option(
|
|
70
|
+
None, "--project", help="Filter by project key (id prefix)."
|
|
71
|
+
),
|
|
72
|
+
include_deleted: bool = typer.Option(
|
|
73
|
+
False, "--include-deleted", help="Include soft-deleted items."
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""List items in the current workspace."""
|
|
77
|
+
with service_errors():
|
|
78
|
+
paths = find_workspace()
|
|
79
|
+
items = item_service.list_items(
|
|
80
|
+
paths,
|
|
81
|
+
project=project,
|
|
82
|
+
status=status,
|
|
83
|
+
type=type,
|
|
84
|
+
include_deleted=include_deleted,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if get_state(ctx).json:
|
|
88
|
+
print_json([item.model_dump() for item in items])
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not items:
|
|
92
|
+
print_line("No items found.")
|
|
93
|
+
return
|
|
94
|
+
rows = [[i.id, i.type, i.status, i.priority, i.title] for i in items]
|
|
95
|
+
print_line(render_table(["ID", "TYPE", "STATUS", "PRIORITY", "TITLE"], rows))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@item_app.command("show")
|
|
99
|
+
def item_show(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
item_id: str = typer.Argument(..., help="Item id, e.g. VP-1."),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Show a single item by id."""
|
|
104
|
+
with service_errors():
|
|
105
|
+
paths = find_workspace()
|
|
106
|
+
item = item_service.read_item(paths, item_id)
|
|
107
|
+
_emit_item(ctx, item)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@item_app.command("create")
|
|
111
|
+
def item_create(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
title: str = typer.Option(..., "--title", help="Item title (required)."),
|
|
114
|
+
type: str = typer.Option(
|
|
115
|
+
..., "--type", help="Item type: epic|feature|task|bug (required)."
|
|
116
|
+
),
|
|
117
|
+
priority: str = typer.Option(
|
|
118
|
+
"normal", "--priority", help="Priority: low|normal|high."
|
|
119
|
+
),
|
|
120
|
+
status: str = typer.Option("backlog", "--status", help="Initial status."),
|
|
121
|
+
description: Optional[str] = typer.Option(
|
|
122
|
+
None, "--description", help="Item description."
|
|
123
|
+
),
|
|
124
|
+
parent: Optional[str] = typer.Option(None, "--parent", help="Parent item id."),
|
|
125
|
+
tag: Optional[list[str]] = typer.Option(None, "--tag", help="Tag (repeatable)."),
|
|
126
|
+
created_by: Optional[str] = typer.Option(
|
|
127
|
+
None, "--created-by", help="Author identity."
|
|
128
|
+
),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Create a new item and print its id."""
|
|
131
|
+
with service_errors():
|
|
132
|
+
paths = find_workspace()
|
|
133
|
+
item = item_service.create_item(
|
|
134
|
+
paths,
|
|
135
|
+
title=title,
|
|
136
|
+
type=type,
|
|
137
|
+
priority=priority,
|
|
138
|
+
status=status,
|
|
139
|
+
description=description,
|
|
140
|
+
parent_id=parent,
|
|
141
|
+
tags=tag or None,
|
|
142
|
+
created_by=created_by,
|
|
143
|
+
)
|
|
144
|
+
_emit_item(ctx, item, human_prefix="Created")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@item_app.command("update")
|
|
148
|
+
def item_update(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
item_id: str = typer.Argument(..., help="Item id to update."),
|
|
151
|
+
title: Optional[str] = typer.Option(None, "--title", help="New title."),
|
|
152
|
+
type: Optional[str] = typer.Option(None, "--type", help="New type."),
|
|
153
|
+
priority: Optional[str] = typer.Option(None, "--priority", help="New priority."),
|
|
154
|
+
status: Optional[str] = typer.Option(None, "--status", help="New status."),
|
|
155
|
+
description: Optional[str] = typer.Option(
|
|
156
|
+
None, "--description", help="New description."
|
|
157
|
+
),
|
|
158
|
+
parent: Optional[str] = typer.Option(None, "--parent", help="New parent item id."),
|
|
159
|
+
tag: Optional[list[str]] = typer.Option(
|
|
160
|
+
None, "--tag", help="Replace tags (repeatable)."
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Update fields on an existing item.
|
|
164
|
+
|
|
165
|
+
Only the options you pass are changed; ``--parent`` and ``--tag`` set new
|
|
166
|
+
values (they do not merge). Every update refreshes ``updated_at``.
|
|
167
|
+
"""
|
|
168
|
+
fields: dict[str, Any] = {}
|
|
169
|
+
if title is not None:
|
|
170
|
+
fields["title"] = title
|
|
171
|
+
if type is not None:
|
|
172
|
+
fields["type"] = type
|
|
173
|
+
if priority is not None:
|
|
174
|
+
fields["priority"] = priority
|
|
175
|
+
if status is not None:
|
|
176
|
+
fields["status"] = status
|
|
177
|
+
if description is not None:
|
|
178
|
+
fields["description"] = description
|
|
179
|
+
if parent is not None:
|
|
180
|
+
fields["parent_id"] = parent
|
|
181
|
+
if tag is not None:
|
|
182
|
+
fields["tags"] = tag
|
|
183
|
+
|
|
184
|
+
with service_errors():
|
|
185
|
+
paths = find_workspace()
|
|
186
|
+
item = item_service.update_item(paths, item_id, **fields)
|
|
187
|
+
_emit_item(ctx, item, human_prefix="Updated")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _emit_relationship(ctx: typer.Context, item: Item, message: str) -> None:
|
|
191
|
+
"""Render a relationship change: the updated source item (JSON) or a confirmation."""
|
|
192
|
+
if get_state(ctx).json:
|
|
193
|
+
print_json(item.model_dump())
|
|
194
|
+
return
|
|
195
|
+
print_line(message)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@item_app.command("parent")
|
|
199
|
+
def item_parent(
|
|
200
|
+
ctx: typer.Context,
|
|
201
|
+
child_id: str = typer.Argument(..., help="Child item id."),
|
|
202
|
+
parent_id: str = typer.Argument(..., help="Parent item id."),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Set ``child_id``'s parent to ``parent_id`` (hierarchy rules apply)."""
|
|
205
|
+
with service_errors():
|
|
206
|
+
paths = find_workspace()
|
|
207
|
+
item = item_service.update_item(paths, child_id, parent_id=parent_id)
|
|
208
|
+
_emit_relationship(ctx, item, f"{child_id} parent set to {parent_id}.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@item_app.command("unparent")
|
|
212
|
+
def item_unparent(
|
|
213
|
+
ctx: typer.Context,
|
|
214
|
+
child_id: str = typer.Argument(..., help="Child item id."),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Clear ``child_id``'s parent."""
|
|
217
|
+
with service_errors():
|
|
218
|
+
paths = find_workspace()
|
|
219
|
+
item = item_service.update_item(paths, child_id, parent_id=None)
|
|
220
|
+
_emit_relationship(ctx, item, f"{child_id} parent cleared.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@item_app.command("blocks")
|
|
224
|
+
def item_blocks(
|
|
225
|
+
ctx: typer.Context,
|
|
226
|
+
source_id: str = typer.Argument(..., help="Item that blocks."),
|
|
227
|
+
target_id: str = typer.Argument(..., help="Item that is blocked."),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Record that ``source_id`` blocks ``target_id`` (idempotent)."""
|
|
230
|
+
with service_errors():
|
|
231
|
+
paths = find_workspace()
|
|
232
|
+
item = link_service.add_link(paths, source_id, "blocks", target_id)
|
|
233
|
+
_emit_relationship(ctx, item, f"{source_id} now blocks {target_id}.")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@item_app.command("unblocks")
|
|
237
|
+
def item_unblocks(
|
|
238
|
+
ctx: typer.Context,
|
|
239
|
+
source_id: str = typer.Argument(..., help="Item that blocks."),
|
|
240
|
+
target_id: str = typer.Argument(..., help="Item that is blocked."),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Remove a ``blocks`` link from ``source_id`` to ``target_id`` (idempotent)."""
|
|
243
|
+
with service_errors():
|
|
244
|
+
paths = find_workspace()
|
|
245
|
+
item = link_service.remove_link(paths, source_id, "blocks", target_id)
|
|
246
|
+
_emit_relationship(ctx, item, f"{source_id} no longer blocks {target_id}.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@item_app.command("relates")
|
|
250
|
+
def item_relates(
|
|
251
|
+
ctx: typer.Context,
|
|
252
|
+
source_id: str = typer.Argument(..., help="Source item."),
|
|
253
|
+
target_id: str = typer.Argument(..., help="Related item."),
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Record that ``source_id`` relates to ``target_id`` (idempotent)."""
|
|
256
|
+
with service_errors():
|
|
257
|
+
paths = find_workspace()
|
|
258
|
+
item = link_service.add_link(paths, source_id, "relates_to", target_id)
|
|
259
|
+
_emit_relationship(ctx, item, f"{source_id} now relates to {target_id}.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@item_app.command("unrelates")
|
|
263
|
+
def item_unrelates(
|
|
264
|
+
ctx: typer.Context,
|
|
265
|
+
source_id: str = typer.Argument(..., help="Source item."),
|
|
266
|
+
target_id: str = typer.Argument(..., help="Related item."),
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Remove a ``relates_to`` link from ``source_id`` to ``target_id`` (idempotent)."""
|
|
269
|
+
with service_errors():
|
|
270
|
+
paths = find_workspace()
|
|
271
|
+
item = link_service.remove_link(paths, source_id, "relates_to", target_id)
|
|
272
|
+
_emit_relationship(ctx, item, f"{source_id} no longer relates to {target_id}.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@item_app.command("comment")
|
|
276
|
+
def item_comment(
|
|
277
|
+
ctx: typer.Context,
|
|
278
|
+
item_id: str = typer.Argument(..., help="Item id to comment on."),
|
|
279
|
+
text: str = typer.Argument(..., help="Comment body."),
|
|
280
|
+
author: Optional[str] = typer.Option(
|
|
281
|
+
None, "--author", help="Comment author (defaults to the local user)."
|
|
282
|
+
),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Add a comment to an item and print the comment filename."""
|
|
285
|
+
with service_errors():
|
|
286
|
+
paths = find_workspace()
|
|
287
|
+
written = comment_service.add_comment(
|
|
288
|
+
paths, item_id, body=text, created_by=author or _default_author()
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if get_state(ctx).json:
|
|
292
|
+
print_json(
|
|
293
|
+
{
|
|
294
|
+
"item_id": item_id,
|
|
295
|
+
"filename": written.name,
|
|
296
|
+
"path": paths.relative_posix(written),
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
print_line(written.name)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def register(app: typer.Typer) -> None:
|
|
304
|
+
"""Attach the ``item`` command group to ``app``."""
|
|
305
|
+
app.add_typer(item_app)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``taskpilot project list`` — list registered projects (task F003-T3, requirement F003-R3).
|
|
2
|
+
|
|
3
|
+
Lists entries from the local system registry (spec ``0002``: "``project list``
|
|
4
|
+
shows all registry entries, including disabled entries ... sorts by project
|
|
5
|
+
name"), not the single in-repo project. Output is deterministic (entries sorted
|
|
6
|
+
by name then id, canonical field order) so repeated ``--json`` calls are
|
|
7
|
+
byte-identical (F003-R8).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from taskpilot.services import registry
|
|
15
|
+
from taskpilot.cli.context import get_state
|
|
16
|
+
from taskpilot.cli.output import print_json, print_line, render_table
|
|
17
|
+
|
|
18
|
+
__all__ = ["register"]
|
|
19
|
+
|
|
20
|
+
project_app = typer.Typer(
|
|
21
|
+
name="project",
|
|
22
|
+
help="Inspect registered TaskPilot projects.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@project_app.command("list")
|
|
29
|
+
def project_list(ctx: typer.Context) -> None:
|
|
30
|
+
"""List projects registered on this machine."""
|
|
31
|
+
state = get_state(ctx)
|
|
32
|
+
entries = registry.list_projects(registry.default_registry_dir())
|
|
33
|
+
|
|
34
|
+
if state.json:
|
|
35
|
+
print_json([entry.model_dump() for entry in entries])
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if not entries:
|
|
39
|
+
print_line(
|
|
40
|
+
"No registered projects. Run `taskpilot init .` in a project repository."
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
rows = [[e.id, e.key, e.name, "yes" if e.active else "no", e.path] for e in entries]
|
|
45
|
+
print_line(render_table(["ID", "KEY", "NAME", "ACTIVE", "PATH"], rows))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register(app: typer.Typer) -> None:
|
|
49
|
+
"""Attach the ``project`` command group to ``app``."""
|
|
50
|
+
app.add_typer(project_app)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``taskpilot serve`` — start the local REST API server (task F005-T6, requirement F005-R6).
|
|
2
|
+
|
|
3
|
+
Resolves the TaskPilot workspace and registry, then runs the FastAPI application
|
|
4
|
+
via uvicorn. The server binds to ``127.0.0.1`` on port ``7152`` by default,
|
|
5
|
+
matching the Vite dev proxy target in ``web/vite.config.ts``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import uvicorn
|
|
15
|
+
|
|
16
|
+
from taskpilot.cli.exit_codes import EXIT_USER_ERROR
|
|
17
|
+
from taskpilot.cli.workspace import find_workspace
|
|
18
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
19
|
+
from taskpilot.services.errors import NotFound
|
|
20
|
+
from taskpilot.services.registry import default_registry_dir
|
|
21
|
+
|
|
22
|
+
#: Import-string for uvicorn's app factory. Passing a string (not an imported symbol)
|
|
23
|
+
#: keeps the CLI adapter from importing the server adapter directly (TP-4).
|
|
24
|
+
_APP_FACTORY = "taskpilot.server.app:create_app_from_env"
|
|
25
|
+
_REGISTRY_DIR_ENV = "TASKPILOT_REGISTRY_DIR"
|
|
26
|
+
|
|
27
|
+
__all__ = ["register"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_workspace(ws: WorkspacePaths) -> bool:
|
|
31
|
+
"""Return True only when both the workspace dir and project.yaml exist."""
|
|
32
|
+
return ws.exists() and ws.project_file.is_file()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def serve_command(
|
|
36
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Host interface to bind."),
|
|
37
|
+
port: int = typer.Option(7152, "--port", help="Port to listen on."),
|
|
38
|
+
workspace: str | None = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--workspace",
|
|
41
|
+
help="Workspace root path (default: auto-detect from cwd).",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Start the local REST API server."""
|
|
45
|
+
if workspace is not None:
|
|
46
|
+
ws = WorkspacePaths.for_root(Path(workspace))
|
|
47
|
+
if not _validate_workspace(ws):
|
|
48
|
+
typer.echo(
|
|
49
|
+
f"Error: no initialized TaskPilot workspace at {ws.root} "
|
|
50
|
+
f"(missing .taskpilot/ or project.yaml)",
|
|
51
|
+
err=True,
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(EXIT_USER_ERROR)
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
ws = find_workspace()
|
|
57
|
+
except NotFound as exc:
|
|
58
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
59
|
+
raise typer.Exit(EXIT_USER_ERROR) from exc
|
|
60
|
+
|
|
61
|
+
if not _validate_workspace(ws):
|
|
62
|
+
typer.echo(
|
|
63
|
+
f"Error: workspace at {ws.root} is missing project.yaml",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
raise typer.Exit(EXIT_USER_ERROR)
|
|
67
|
+
|
|
68
|
+
registry_dir = default_registry_dir()
|
|
69
|
+
os.environ[_REGISTRY_DIR_ENV] = str(registry_dir)
|
|
70
|
+
|
|
71
|
+
typer.echo(f"TaskPilot API server starting on http://{host}:{port}")
|
|
72
|
+
typer.echo(f"OpenAPI docs at http://{host}:{port}/docs")
|
|
73
|
+
uvicorn.run(_APP_FACTORY, factory=True, host=host, port=port)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register(app: typer.Typer) -> None:
|
|
77
|
+
"""Attach the ``serve`` command to ``app``."""
|
|
78
|
+
app.command("serve")(serve_command)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``taskpilot validate`` — workspace validation (task F003-T7, requirement F003-R7).
|
|
2
|
+
|
|
3
|
+
Runs the F001 validator over the current workspace and reports findings. Exit
|
|
4
|
+
code follows the report: ``0`` when there are no error-severity findings,
|
|
5
|
+
``1`` otherwise (warnings alone do not fail). Output channels follow the CLI
|
|
6
|
+
contract: human findings go to stderr (so a clean run leaves stderr empty,
|
|
7
|
+
scenario F003-S7); the structured ``--json`` report goes to stdout.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from taskpilot.cli.context import get_state
|
|
15
|
+
from taskpilot.cli.errors import service_errors
|
|
16
|
+
from taskpilot.cli.exit_codes import EXIT_OK, EXIT_SYSTEM_ERROR, EXIT_USER_ERROR
|
|
17
|
+
from taskpilot.cli.output import print_json, print_line
|
|
18
|
+
from taskpilot.cli.workspace import find_workspace
|
|
19
|
+
from taskpilot.core.validation import Finding, ValidationReport, validate_workspace
|
|
20
|
+
|
|
21
|
+
__all__ = ["register"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _format_finding(finding: Finding) -> str:
|
|
25
|
+
"""Render a finding as ``<severity>: <path>[:<field>]: <message>``."""
|
|
26
|
+
location = finding.path
|
|
27
|
+
if finding.field:
|
|
28
|
+
location = f"{location}:{finding.field}"
|
|
29
|
+
return f"{finding.severity.value}: {location}: {finding.message}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_command(ctx: typer.Context) -> None:
|
|
33
|
+
"""Validate the current workspace and exit non-zero when errors are found."""
|
|
34
|
+
state = get_state(ctx)
|
|
35
|
+
with service_errors():
|
|
36
|
+
paths = find_workspace()
|
|
37
|
+
try:
|
|
38
|
+
report: ValidationReport = validate_workspace(paths)
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
41
|
+
raise typer.Exit(EXIT_SYSTEM_ERROR) from exc
|
|
42
|
+
|
|
43
|
+
if state.json:
|
|
44
|
+
print_json(report.to_dict())
|
|
45
|
+
else:
|
|
46
|
+
for finding in report.findings:
|
|
47
|
+
typer.echo(_format_finding(finding), err=True)
|
|
48
|
+
if report.ok:
|
|
49
|
+
print_line("Workspace is valid.")
|
|
50
|
+
else:
|
|
51
|
+
typer.echo(
|
|
52
|
+
f"Found {report.error_count} error(s), {report.warning_count} warning(s).",
|
|
53
|
+
err=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
raise typer.Exit(EXIT_OK if report.ok else EXIT_USER_ERROR)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def register(app: typer.Typer) -> None:
|
|
60
|
+
"""Attach the ``validate`` command to ``app``."""
|
|
61
|
+
app.command("validate")(validate_command)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Per-invocation CLI state carried through Typer's context (task F003-T1).
|
|
2
|
+
|
|
3
|
+
The root callback stores a :class:`CLIState` on ``ctx.obj`` so every subcommand
|
|
4
|
+
can read global options — currently the ``--json`` switch — without re-declaring
|
|
5
|
+
them. Subcommands fetch it with :func:`get_state`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
__all__ = ["CLIState", "get_state"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CLIState:
|
|
19
|
+
"""Global options resolved once by the root callback.
|
|
20
|
+
|
|
21
|
+
``json`` selects machine-readable output (F003-R8); ``False`` means
|
|
22
|
+
human-readable output.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
json: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_state(ctx: typer.Context) -> CLIState:
|
|
29
|
+
"""Return the :class:`CLIState` for this invocation, creating a default if absent.
|
|
30
|
+
|
|
31
|
+
A default is created when a command is exercised without the root callback
|
|
32
|
+
(e.g. some test harnesses), so subcommands never see ``None``.
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(ctx.obj, CLIState):
|
|
35
|
+
ctx.obj = CLIState()
|
|
36
|
+
return ctx.obj
|