@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.
Files changed (121) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +141 -0
  3. package/bin/taskpilot +454 -0
  4. package/package.json +30 -0
  5. package/requirements.lock +66 -0
  6. package/src/taskpilot/__init__.py +1 -0
  7. package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
  8. package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
  9. package/src/taskpilot/cli/__init__.py +6 -0
  10. package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
  11. package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
  12. package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
  13. package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
  14. package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
  15. package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
  16. package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
  17. package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
  18. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
  19. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
  20. package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
  21. package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
  22. package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
  23. package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
  24. package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
  25. package/src/taskpilot/cli/app.py +61 -0
  26. package/src/taskpilot/cli/commands/__init__.py +6 -0
  27. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
  28. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
  30. package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
  31. package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
  32. package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
  33. package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
  34. package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
  35. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
  36. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
  37. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
  38. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
  39. package/src/taskpilot/cli/commands/init.py +116 -0
  40. package/src/taskpilot/cli/commands/item.py +305 -0
  41. package/src/taskpilot/cli/commands/project.py +50 -0
  42. package/src/taskpilot/cli/commands/serve.py +78 -0
  43. package/src/taskpilot/cli/commands/validate.py +61 -0
  44. package/src/taskpilot/cli/context.py +36 -0
  45. package/src/taskpilot/cli/errors.py +53 -0
  46. package/src/taskpilot/cli/exit_codes.py +20 -0
  47. package/src/taskpilot/cli/output.py +77 -0
  48. package/src/taskpilot/cli/workspace.py +33 -0
  49. package/src/taskpilot/core/__init__.py +5 -0
  50. package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
  51. package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
  52. package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
  53. package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
  54. package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
  55. package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
  56. package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
  57. package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
  58. package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
  59. package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
  60. package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
  61. package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
  62. package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
  63. package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
  64. package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
  65. package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
  66. package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
  67. package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
  68. package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
  69. package/src/taskpilot/core/comments.py +238 -0
  70. package/src/taskpilot/core/item_io.py +123 -0
  71. package/src/taskpilot/core/layout.py +137 -0
  72. package/src/taskpilot/core/loader.py +102 -0
  73. package/src/taskpilot/core/models.py +114 -0
  74. package/src/taskpilot/core/project.py +151 -0
  75. package/src/taskpilot/core/timestamps.py +54 -0
  76. package/src/taskpilot/core/validation.py +385 -0
  77. package/src/taskpilot/core/yaml_io.py +57 -0
  78. package/src/taskpilot/server/__init__.py +0 -0
  79. package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
  81. package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
  82. package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
  83. package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
  84. package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
  85. package/src/taskpilot/server/app.py +134 -0
  86. package/src/taskpilot/server/routes/__init__.py +0 -0
  87. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
  88. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
  90. package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
  91. package/src/taskpilot/server/routes/projects.py +160 -0
  92. package/src/taskpilot/server/schemas.py +76 -0
  93. package/src/taskpilot/services/__init__.py +8 -0
  94. package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
  95. package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
  97. package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
  98. package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
  99. package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
  100. package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
  101. package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
  102. package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
  103. package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
  104. package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
  105. package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
  106. package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
  107. package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
  108. package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
  109. package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
  110. package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
  111. package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
  112. package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
  113. package/src/taskpilot/services/comment_service.py +62 -0
  114. package/src/taskpilot/services/errors.py +26 -0
  115. package/src/taskpilot/services/hierarchy.py +107 -0
  116. package/src/taskpilot/services/item_service.py +264 -0
  117. package/src/taskpilot/services/link_service.py +97 -0
  118. package/src/taskpilot/services/operation_validation.py +52 -0
  119. package/src/taskpilot/services/project_service.py +111 -0
  120. package/src/taskpilot/services/registry.py +194 -0
  121. package/src/taskpilot/services/reverse_links.py +60 -0
@@ -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