a2c-cli 0.2.1__py3-none-any.whl

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.
a2c_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """A2C command-line interface — thin layer over a2c_core."""
@@ -0,0 +1 @@
1
+ """CLI command modules (Milestone 3+)."""
@@ -0,0 +1,111 @@
1
+ """Apply commands — write validated proposals into planning/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from a2c_cli.context import echo_issues
10
+ from a2c_cli.exit_codes import EXIT_NO_PROPOSAL, EXIT_NOT_FOUND, EXIT_VALIDATION_FAILED
11
+ from a2c_cli.output import (
12
+ format_apply_epic_summary,
13
+ format_apply_summary,
14
+ format_apply_task_intake_summary,
15
+ )
16
+ from a2c_core.errors import A2C_WORKFLOW_001, A2C_WORKFLOW_003, A2C_WORKFLOW_004
17
+ from a2c_core.services import apply_epic_draft, apply_task_intake_draft, apply_tasks_from_epic
18
+
19
+
20
+ def apply_epic_draft_cmd(
21
+ epic_id: str,
22
+ path: Path | None,
23
+ *,
24
+ force: bool,
25
+ ) -> None:
26
+ """Apply a cached epic draft into planning/epics/."""
27
+ result = apply_epic_draft(path, epic_id, force=force)
28
+ if not result.ok:
29
+ if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
30
+ typer.echo("A2C apply: no valid epic draft", err=True)
31
+ echo_issues("Errors", result.errors, err=True)
32
+ raise typer.Exit(EXIT_NO_PROPOSAL)
33
+ if any(issue.code == A2C_WORKFLOW_003 for issue in result.errors):
34
+ typer.echo("A2C apply: epic id conflict", err=True)
35
+ echo_issues("Errors", result.errors, err=True)
36
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
37
+ typer.echo("A2C apply: failed", err=True)
38
+ echo_issues("Errors", result.errors, err=True)
39
+ if result.warnings:
40
+ echo_issues("Warnings", result.warnings)
41
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
42
+
43
+ assert result.value is not None
44
+ format_apply_epic_summary(result.value)
45
+ if result.warnings:
46
+ typer.echo("")
47
+ echo_issues("Warnings", result.warnings)
48
+
49
+
50
+ def apply_task_intake_draft_cmd(
51
+ draft_key: str,
52
+ path: Path | None,
53
+ *,
54
+ force: bool,
55
+ ) -> None:
56
+ """Apply a cached single-task intake draft into planning/tasks/."""
57
+ result = apply_task_intake_draft(path, draft_key, force=force)
58
+ if not result.ok:
59
+ if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
60
+ typer.echo("A2C apply: no valid task draft", err=True)
61
+ echo_issues("Errors", result.errors, err=True)
62
+ raise typer.Exit(EXIT_NO_PROPOSAL)
63
+ if any(issue.code == A2C_WORKFLOW_003 for issue in result.errors):
64
+ typer.echo("A2C apply: task id conflict", err=True)
65
+ echo_issues("Errors", result.errors, err=True)
66
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
67
+ typer.echo("A2C apply: failed", err=True)
68
+ echo_issues("Errors", result.errors, err=True)
69
+ if result.warnings:
70
+ echo_issues("Warnings", result.warnings)
71
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
72
+
73
+ assert result.value is not None
74
+ task = result.value
75
+ format_apply_task_intake_summary(
76
+ task.id,
77
+ title=task.title,
78
+ epic_id=task.epic_id,
79
+ )
80
+ if result.warnings:
81
+ typer.echo("")
82
+ echo_issues("Warnings", result.warnings)
83
+
84
+
85
+ def apply_tasks_from_epic_cmd(
86
+ epic_id: str,
87
+ path: Path | None,
88
+ *,
89
+ force: bool,
90
+ ) -> None:
91
+ """Apply cached task drafts for an epic into planning/tasks/."""
92
+ result = apply_tasks_from_epic(path, epic_id, force=force)
93
+ if not result.ok:
94
+ if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
95
+ typer.echo("A2C apply: epic not found", err=True)
96
+ raise typer.Exit(EXIT_NOT_FOUND)
97
+ if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
98
+ typer.echo("A2C apply: no valid proposal", err=True)
99
+ echo_issues("Errors", result.errors, err=True)
100
+ raise typer.Exit(EXIT_NO_PROPOSAL)
101
+ typer.echo("A2C apply: failed", err=True)
102
+ echo_issues("Errors", result.errors, err=True)
103
+ if result.warnings:
104
+ echo_issues("Warnings", result.warnings)
105
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
106
+
107
+ assert result.value is not None
108
+ format_apply_summary(epic_id, result.value)
109
+ if result.warnings:
110
+ typer.echo("")
111
+ echo_issues("Warnings", result.warnings)
@@ -0,0 +1,23 @@
1
+ """Doctor command — repository health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_VALIDATION_FAILED
10
+ from a2c_cli.output import format_doctor_report
11
+ from a2c_core.services import check_repository_health
12
+
13
+
14
+ def doctor_cmd(path: Path | None = None) -> None:
15
+ """Check A2C repository structure and readiness."""
16
+ report = check_repository_health(path)
17
+ format_doctor_report(report)
18
+
19
+ if report.root is None:
20
+ raise typer.Exit(EXIT_NOT_A2C_REPO)
21
+ if not report.ok:
22
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
23
+ raise typer.Exit(0)
@@ -0,0 +1,26 @@
1
+ """List planning artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from a2c_cli.context import require_snapshot
8
+ from a2c_cli.output import format_epic_list, format_sprint_list, format_task_list
9
+
10
+
11
+ def list_epics(path: Path | None = None) -> None:
12
+ """List epics under planning/epics/."""
13
+ snapshot = require_snapshot(path)
14
+ format_epic_list(snapshot.epics)
15
+
16
+
17
+ def list_tasks(path: Path | None = None) -> None:
18
+ """List tasks under planning/tasks/."""
19
+ snapshot = require_snapshot(path)
20
+ format_task_list(snapshot.tasks)
21
+
22
+
23
+ def list_sprints(path: Path | None = None) -> None:
24
+ """List sprints under planning/sprints/."""
25
+ snapshot = require_snapshot(path)
26
+ format_sprint_list(snapshot.sprints)
@@ -0,0 +1,322 @@
1
+ """Plan commands — AI-assisted proposal generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from a2c_cli.context import echo_issues, require_inspection
11
+ from a2c_cli.exit_codes import EXIT_NOT_FOUND, EXIT_VALIDATION_FAILED
12
+ from a2c_cli.output import (
13
+ format_create_epic_summary,
14
+ format_create_task_summary,
15
+ format_decomposition_summary,
16
+ )
17
+ from a2c_core.errors import A2C_PROVIDER_004, A2C_WORKFLOW_004, ValidationIssue
18
+ from a2c_core.schemas.workflows_io import TaskIntakeKind
19
+ from a2c_core.services import (
20
+ create_epic_from_brief,
21
+ create_task_from_input,
22
+ create_task_from_task_json,
23
+ decompose_epic,
24
+ )
25
+ from a2c_core.workflows.provider_errors import ProviderError
26
+ from a2c_core.workflows.providers.resolver import (
27
+ merge_decomposition_settings,
28
+ resolve_create_epic_provider,
29
+ resolve_create_task_provider,
30
+ resolve_decomposition_provider,
31
+ )
32
+
33
+
34
+ def _collect_brief(
35
+ *,
36
+ from_text: str | None,
37
+ from_file: Path | None,
38
+ use_stdin: bool,
39
+ ) -> str:
40
+ modes = [from_text is not None, from_file is not None, use_stdin]
41
+ if sum(modes) != 1:
42
+ typer.echo(
43
+ "error: provide exactly one input: --from-text, --from-file, or --stdin",
44
+ err=True,
45
+ )
46
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
47
+
48
+ if from_text is not None:
49
+ return from_text
50
+ if from_file is not None:
51
+ if not from_file.is_file():
52
+ typer.echo(f"error: brief file not found: {from_file}", err=True)
53
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
54
+ return from_file.read_text(encoding="utf-8")
55
+ return sys.stdin.read()
56
+
57
+
58
+ def _resolve_task_kind(*, task_type: str | None, use_bug: bool) -> TaskIntakeKind:
59
+ if use_bug or task_type == "bug":
60
+ return "bug"
61
+ if task_type is not None and task_type != "feature":
62
+ typer.echo(
63
+ f"error: unsupported task type {task_type!r}; use feature or bug",
64
+ err=True,
65
+ )
66
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
67
+ return "feature"
68
+
69
+
70
+ def _collect_task_input(
71
+ *,
72
+ from_text: str | None,
73
+ from_file: Path | None,
74
+ from_task_json: Path | None,
75
+ use_stdin: bool,
76
+ ) -> str | None:
77
+ modes = [
78
+ from_text is not None,
79
+ from_file is not None,
80
+ from_task_json is not None,
81
+ use_stdin,
82
+ ]
83
+ if sum(modes) != 1:
84
+ typer.echo(
85
+ "error: provide exactly one input: --from-text, --from-file, "
86
+ "--from-task-json, or --stdin",
87
+ err=True,
88
+ )
89
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
90
+
91
+ if from_task_json is not None:
92
+ return None
93
+
94
+ if from_text is not None:
95
+ return from_text
96
+ if from_file is not None:
97
+ if not from_file.is_file():
98
+ typer.echo(f"error: intake file not found: {from_file}", err=True)
99
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
100
+ return from_file.read_text(encoding="utf-8")
101
+ return sys.stdin.read()
102
+
103
+
104
+ def _reject_provider_flags_with_task_json(
105
+ *,
106
+ from_task_json: Path | None,
107
+ provider_name: str | None,
108
+ fixture_path: Path | None,
109
+ model: str | None,
110
+ endpoint: str | None,
111
+ ) -> None:
112
+ if from_task_json is None:
113
+ return
114
+ blocked = [
115
+ name
116
+ for name, value in (
117
+ ("--provider", provider_name),
118
+ ("--fixture", fixture_path),
119
+ ("--model", model),
120
+ ("--endpoint", endpoint),
121
+ )
122
+ if value is not None
123
+ ]
124
+ if blocked:
125
+ joined = ", ".join(blocked)
126
+ typer.echo(
127
+ f"error: {joined} cannot be used with --from-task-json",
128
+ err=True,
129
+ )
130
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
131
+
132
+
133
+ def create_task_cmd(
134
+ path: Path | None,
135
+ *,
136
+ from_text: str | None,
137
+ from_file: Path | None,
138
+ from_task_json: Path | None,
139
+ use_stdin: bool,
140
+ task_type: str | None,
141
+ use_bug: bool,
142
+ epic_id: str | None,
143
+ provider_name: str | None,
144
+ fixture_path: Path | None,
145
+ model: str | None,
146
+ endpoint: str | None,
147
+ ) -> None:
148
+ """Generate and review a proposed single task draft from raw input."""
149
+ _reject_provider_flags_with_task_json(
150
+ from_task_json=from_task_json,
151
+ provider_name=provider_name,
152
+ fixture_path=fixture_path,
153
+ model=model,
154
+ endpoint=endpoint,
155
+ )
156
+
157
+ if from_task_json is not None:
158
+ if not from_task_json.is_file():
159
+ typer.echo(f"error: task JSON file not found: {from_task_json}", err=True)
160
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
161
+ result = create_task_from_task_json(path, from_task_json, epic_id=epic_id)
162
+ else:
163
+ input_text = _collect_task_input(
164
+ from_text=from_text,
165
+ from_file=from_file,
166
+ from_task_json=None,
167
+ use_stdin=use_stdin,
168
+ )
169
+ assert input_text is not None
170
+ task_kind = _resolve_task_kind(task_type=task_type, use_bug=use_bug)
171
+ inspection = require_inspection(path)
172
+ assert inspection.snapshot is not None
173
+ config = merge_decomposition_settings(
174
+ inspection.snapshot.config,
175
+ model=model,
176
+ endpoint=endpoint,
177
+ )
178
+ try:
179
+ provider = resolve_create_task_provider(
180
+ provider_name=provider_name,
181
+ config=config,
182
+ fixture_path=fixture_path,
183
+ repo_root=inspection.root,
184
+ )
185
+ except ProviderError as exc:
186
+ typer.echo("A2C plan: provider configuration failed", err=True)
187
+ echo_issues(
188
+ "Errors",
189
+ [ValidationIssue(code=exc.code, message=exc.message, path="provider")],
190
+ err=True,
191
+ )
192
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
193
+
194
+ result = create_task_from_input(
195
+ path,
196
+ input_text,
197
+ provider,
198
+ task_kind=task_kind,
199
+ epic_id=epic_id,
200
+ )
201
+
202
+ if not result.ok:
203
+ typer.echo("A2C plan: task authoring failed", err=True)
204
+ if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
205
+ raise typer.Exit(EXIT_NOT_FOUND)
206
+ if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
207
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
208
+ echo_issues("Errors", result.errors, err=True)
209
+ if result.warnings:
210
+ echo_issues("Warnings", result.warnings)
211
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
212
+
213
+ assert result.value is not None
214
+ format_create_task_summary(result.value)
215
+ if result.warnings:
216
+ typer.echo("")
217
+ echo_issues("Warnings", result.warnings)
218
+
219
+
220
+ def create_epic_cmd(
221
+ path: Path | None,
222
+ *,
223
+ from_text: str | None,
224
+ from_file: Path | None,
225
+ use_stdin: bool,
226
+ provider_name: str | None,
227
+ fixture_path: Path | None,
228
+ model: str | None,
229
+ endpoint: str | None,
230
+ ) -> None:
231
+ """Generate and review a proposed epic draft from a short brief."""
232
+ brief = _collect_brief(from_text=from_text, from_file=from_file, use_stdin=use_stdin)
233
+ inspection = require_inspection(path)
234
+ assert inspection.snapshot is not None
235
+ config = merge_decomposition_settings(
236
+ inspection.snapshot.config,
237
+ model=model,
238
+ endpoint=endpoint,
239
+ )
240
+ try:
241
+ provider = resolve_create_epic_provider(
242
+ provider_name=provider_name,
243
+ config=config,
244
+ fixture_path=fixture_path,
245
+ repo_root=inspection.root,
246
+ )
247
+ except ProviderError as exc:
248
+ typer.echo("A2C plan: provider configuration failed", err=True)
249
+ echo_issues(
250
+ "Errors",
251
+ [ValidationIssue(code=exc.code, message=exc.message, path="provider")],
252
+ err=True,
253
+ )
254
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
255
+
256
+ result = create_epic_from_brief(path, brief, provider)
257
+ if not result.ok:
258
+ typer.echo("A2C plan: epic authoring failed", err=True)
259
+ if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
260
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
261
+ echo_issues("Errors", result.errors, err=True)
262
+ if result.warnings:
263
+ echo_issues("Warnings", result.warnings)
264
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
265
+
266
+ assert result.value is not None
267
+ format_create_epic_summary(result.value)
268
+ if result.warnings:
269
+ typer.echo("")
270
+ echo_issues("Warnings", result.warnings)
271
+
272
+
273
+ def decompose_epic_cmd(
274
+ epic_id: str,
275
+ path: Path | None,
276
+ *,
277
+ provider_name: str | None,
278
+ fixture_path: Path | None,
279
+ model: str | None,
280
+ endpoint: str | None,
281
+ ) -> None:
282
+ """Generate and review proposed task drafts for an epic."""
283
+ inspection = require_inspection(path)
284
+ assert inspection.snapshot is not None
285
+ config = merge_decomposition_settings(
286
+ inspection.snapshot.config,
287
+ model=model,
288
+ endpoint=endpoint,
289
+ )
290
+ try:
291
+ provider = resolve_decomposition_provider(
292
+ provider_name=provider_name,
293
+ config=config,
294
+ fixture_path=fixture_path,
295
+ repo_root=inspection.root,
296
+ )
297
+ except ProviderError as exc:
298
+ typer.echo("A2C plan: provider configuration failed", err=True)
299
+ echo_issues(
300
+ "Errors",
301
+ [ValidationIssue(code=exc.code, message=exc.message, path="provider")],
302
+ err=True,
303
+ )
304
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
305
+
306
+ result = decompose_epic(path, epic_id, provider)
307
+ if not result.ok:
308
+ typer.echo("A2C plan: decomposition failed", err=True)
309
+ if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
310
+ raise typer.Exit(EXIT_NOT_FOUND)
311
+ if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
312
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
313
+ echo_issues("Errors", result.errors, err=True)
314
+ if result.warnings:
315
+ echo_issues("Warnings", result.warnings)
316
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
317
+
318
+ assert result.value is not None
319
+ format_decomposition_summary(result.value)
320
+ if result.warnings:
321
+ typer.echo("")
322
+ echo_issues("Warnings", result.warnings)
@@ -0,0 +1,98 @@
1
+ """Show a single planning artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from a2c_cli.context import echo_issues, exit_not_found, require_snapshot
10
+ from a2c_cli.exit_codes import EXIT_NO_PROPOSAL, EXIT_NOT_FOUND
11
+ from a2c_cli.output import (
12
+ format_draft_epic_show,
13
+ format_draft_task_intake_show,
14
+ format_draft_task_show,
15
+ format_epic_show,
16
+ format_sprint_show,
17
+ format_task_show,
18
+ )
19
+ from a2c_core.services import (
20
+ get_cached_epic_draft,
21
+ get_cached_proposal,
22
+ get_cached_task_intake_draft,
23
+ )
24
+
25
+
26
+ def show_epic(artifact_id: str, path: Path | None = None) -> None:
27
+ """Show one epic by id."""
28
+ snapshot = require_snapshot(path)
29
+ for epic in snapshot.epics:
30
+ if epic.id == artifact_id:
31
+ format_epic_show(epic)
32
+ return
33
+ exit_not_found("epic", artifact_id)
34
+
35
+
36
+ def show_task(artifact_id: str, path: Path | None = None) -> None:
37
+ """Show one task by id."""
38
+ snapshot = require_snapshot(path)
39
+ for task in snapshot.tasks:
40
+ if task.id == artifact_id:
41
+ format_task_show(task)
42
+ return
43
+ exit_not_found("task", artifact_id)
44
+
45
+
46
+ def show_sprint(artifact_id: str, path: Path | None = None) -> None:
47
+ """Show one sprint by id."""
48
+ snapshot = require_snapshot(path)
49
+ for sprint in snapshot.sprints:
50
+ if sprint.id == artifact_id:
51
+ format_sprint_show(sprint)
52
+ return
53
+ exit_not_found("sprint", artifact_id)
54
+
55
+
56
+ def show_draft_epic(epic_id: str, path: Path | None = None) -> None:
57
+ """Show a proposed epic draft from cache."""
58
+ result = get_cached_epic_draft(path, epic_id)
59
+ if not result.ok:
60
+ typer.echo("A2C show: epic draft not available", err=True)
61
+ echo_issues("Errors", result.errors, err=True)
62
+ raise typer.Exit(EXIT_NO_PROPOSAL)
63
+
64
+ assert result.value is not None
65
+ format_draft_epic_show(result.value)
66
+
67
+
68
+ def show_draft_task_intake(draft_key: str, path: Path | None = None) -> None:
69
+ """Show a proposed single-task intake draft from cache."""
70
+ result = get_cached_task_intake_draft(path, draft_key)
71
+ if not result.ok:
72
+ typer.echo("A2C show: task intake draft not available", err=True)
73
+ echo_issues("Errors", result.errors, err=True)
74
+ raise typer.Exit(EXIT_NO_PROPOSAL)
75
+
76
+ assert result.value is not None
77
+ format_draft_task_intake_show(result.value)
78
+
79
+
80
+ def show_draft_task(epic_id: str, draft_index: int, path: Path | None = None) -> None:
81
+ """Show one proposed task draft from a cached decomposition."""
82
+ result = get_cached_proposal(path, epic_id)
83
+ if not result.ok:
84
+ typer.echo("A2C show: draft task not available", err=True)
85
+ echo_issues("Errors", result.errors, err=True)
86
+ raise typer.Exit(EXIT_NO_PROPOSAL)
87
+
88
+ assert result.value is not None
89
+ proposal = result.value
90
+ if draft_index < 1 or draft_index > len(proposal.tasks):
91
+ typer.echo(
92
+ f"error: draft index {draft_index} out of range (1..{len(proposal.tasks)})",
93
+ err=True,
94
+ )
95
+ raise typer.Exit(EXIT_NOT_FOUND)
96
+
97
+ draft = proposal.tasks[draft_index - 1]
98
+ format_draft_task_show(draft, index=draft_index, epic_id=epic_id)
@@ -0,0 +1,22 @@
1
+ """Validate command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_VALIDATION_FAILED
10
+ from a2c_cli.output import format_validation_report
11
+ from a2c_core.services import inspect_repository
12
+
13
+
14
+ def validate_cmd(path: Path | None = None) -> None:
15
+ """Validate A2C config and planning artifacts."""
16
+ result = inspect_repository(path)
17
+ format_validation_report(result)
18
+
19
+ if result.root is None:
20
+ raise typer.Exit(EXIT_NOT_A2C_REPO)
21
+ if result.errors:
22
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
a2c_cli/context.py ADDED
@@ -0,0 +1,56 @@
1
+ """Shared CLI context — repository resolution and errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_NOT_FOUND
11
+ from a2c_core.errors import A2C_REPO_001
12
+ from a2c_core.services import inspect_repository
13
+ from a2c_core.services.inspection import InspectionResult
14
+ from a2c_core.services.repo import RepositorySnapshot
15
+
16
+ _NOT_A2C_MESSAGE = (
17
+ "Not inside an A2C repository. Expected a root marker such as "
18
+ "AGENTS.md, docs/workflow-manifest.yaml, or .a2c/config.yaml."
19
+ )
20
+
21
+
22
+ def require_inspection(start: Path | None = None) -> InspectionResult:
23
+ """Load repository inspection or exit when not in an A2C repo."""
24
+ result = inspect_repository(start)
25
+ if result.root is None:
26
+ issue = result.errors[0] if result.errors else None
27
+ if issue and issue.code == A2C_REPO_001:
28
+ typer.echo(f"error: {_NOT_A2C_MESSAGE}", err=True)
29
+ else:
30
+ typer.echo("error: unable to locate A2C repository root", err=True)
31
+ raise typer.Exit(EXIT_NOT_A2C_REPO)
32
+ return result
33
+
34
+
35
+ def require_snapshot(start: Path | None = None) -> RepositorySnapshot:
36
+ """Return a loaded snapshot for read-only list/show commands."""
37
+ result = require_inspection(start)
38
+ assert result.snapshot is not None
39
+ return result.snapshot
40
+
41
+
42
+ def exit_not_found(kind: str, artifact_id: str) -> None:
43
+ """Exit when a planning artifact id cannot be resolved."""
44
+ typer.echo(f"error: {kind} {artifact_id!r} not found", err=True)
45
+ raise typer.Exit(EXIT_NOT_FOUND)
46
+
47
+
48
+ def echo_issues(label: str, issues: list, *, err: bool = False) -> None:
49
+ """Print structured validation issues."""
50
+ if not issues:
51
+ return
52
+ stream = sys.stderr if err else sys.stdout
53
+ typer.echo(f"{label} ({len(issues)}):", err=err)
54
+ for issue in issues:
55
+ path = issue.path or "-"
56
+ typer.echo(f" {path}: [{issue.code}] {issue.message}", err=err, file=stream)
a2c_cli/exit_codes.py ADDED
@@ -0,0 +1,10 @@
1
+ """CLI exit codes for CI and scripting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_OK = 0
6
+ EXIT_VALIDATION_FAILED = 1
7
+ EXIT_NOT_A2C_REPO = 2
8
+ EXIT_NOT_FOUND = 3
9
+ EXIT_USAGE = 4
10
+ EXIT_NO_PROPOSAL = 5