core-cli 2.0.0__tar.gz → 2.1.0__tar.gz

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 (52) hide show
  1. {core_cli-2.0.0 → core_cli-2.1.0}/PKG-INFO +7 -1
  2. {core_cli-2.0.0 → core_cli-2.1.0}/README.md +6 -0
  3. {core_cli-2.0.0 → core_cli-2.1.0}/pyproject.toml +1 -1
  4. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/client.py +33 -3
  5. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/manage.py +114 -1
  6. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_client.py +53 -0
  7. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_proposals.py +96 -18
  8. {core_cli-2.0.0 → core_cli-2.1.0}/.github/workflows/ci.yml +0 -0
  9. {core_cli-2.0.0 → core_cli-2.1.0}/.gitignore +0 -0
  10. {core_cli-2.0.0 → core_cli-2.1.0}/LICENSE +0 -0
  11. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/__init__.py +0 -0
  12. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/command.py +0 -0
  13. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/main.py +0 -0
  14. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/__init__.py +0 -0
  15. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/__init__.py +0 -0
  16. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/actions.py +0 -0
  17. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/audit_duplicates.py +0 -0
  18. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/bridges.py +0 -0
  19. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/check_imports.py +0 -0
  20. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/docstrings.py +0 -0
  21. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/fix_atomic.py +0 -0
  22. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/format.py +0 -0
  23. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/hub.py +0 -0
  24. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/integrity.py +0 -0
  25. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/lint.py +0 -0
  26. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/logging.py +0 -0
  27. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/test.py +0 -0
  28. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/__init__.py +0 -0
  29. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/claim.py +0 -0
  30. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/list.py +0 -0
  31. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/next.py +0 -0
  32. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/propose.py +0 -0
  33. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/__init__.py +0 -0
  34. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/onboard.py +0 -0
  35. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/scout.py +0 -0
  36. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/__init__.py +0 -0
  37. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/create.py +0 -0
  38. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/integrate.py +0 -0
  39. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/list.py +0 -0
  40. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/__init__.py +0 -0
  41. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/audit.py +0 -0
  42. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/fix_ids.py +0 -0
  43. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/hub.py +0 -0
  44. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/resolve_duplicates.py +0 -0
  45. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/__init__.py +0 -0
  46. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/hub.py +0 -0
  47. {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/query.py +0 -0
  48. {core_cli-2.0.0 → core_cli-2.1.0}/tests/__init__.py +0 -0
  49. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_code.py +0 -0
  50. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_contract.py +0 -0
  51. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_lane.py +0 -0
  52. {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_project.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: core-cli
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Consumer governance CLI for CORE — an HTTP client for governing repositories against CORE constitutional rules.
5
5
  Project-URL: Homepage, https://github.com/DariuszNewecki/CORE
6
6
  Project-URL: Repository, https://github.com/DariuszNewecki/core-cli
@@ -66,6 +66,12 @@ Its only dependencies are `typer`, `rich`, `httpx` and `PyYAML`.
66
66
  export CORE_API_URL=http://127.0.0.1:8000
67
67
  ```
68
68
 
69
+ On the machine running CORE, `core` can use CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is calling; over TCP it cannot:
70
+
71
+ ```bash
72
+ export CORE_API_URL=unix:///run/core/api.sock
73
+ ```
74
+
69
75
  ## Use
70
76
 
71
77
  ```bash
@@ -37,6 +37,12 @@ Its only dependencies are `typer`, `rich`, `httpx` and `PyYAML`.
37
37
  export CORE_API_URL=http://127.0.0.1:8000
38
38
  ```
39
39
 
40
+ On the machine running CORE, `core` can use CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is calling; over TCP it cannot:
41
+
42
+ ```bash
43
+ export CORE_API_URL=unix:///run/core/api.sock
44
+ ```
45
+
40
46
  ## Use
41
47
 
42
48
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "core-cli"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Consumer governance CLI for CORE — an HTTP client for governing repositories against CORE constitutional rules."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -6,7 +6,9 @@ here is in CORE's published OpenAPI contract, ``docs/reference/openapi.json``
6
6
  in the CORE repository; ``tests/test_contract.py`` checks that.
7
7
 
8
8
  The base URL comes from the ``CORE_API_URL`` environment variable, defaulting
9
- to the loopback address CORE binds to.
9
+ to the loopback address CORE binds to. ``unix:///path/to/api.sock`` selects
10
+ CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is
11
+ calling (ADR-132 D10.1 in the CORE repository); over TCP it cannot.
10
12
  """
11
13
 
12
14
  from __future__ import annotations
@@ -19,12 +21,36 @@ import httpx
19
21
 
20
22
 
21
23
  DEFAULT_BASE_URL = "http://127.0.0.1:8000"
24
+ UNIX_SCHEME = "unix://"
25
+ # HTTP needs a host for the request line and Host header; over a Unix socket
26
+ # it names nothing, and CORE does not route on it.
27
+ _UNIX_HTTP_BASE = "http://localhost"
22
28
  _TIMEOUT_SECONDS = 30.0
23
29
  _LONG_TIMEOUT_SECONDS = 300.0
24
30
  _POLL_INTERVAL_SECONDS = 1.0
25
31
  _POLL_TERMINAL_STATES = frozenset({"completed", "failed"})
26
32
 
27
33
 
34
+ # ID: b50ad322-810a-4014-813e-a02a71cd55e7
35
+ def parse_base_url(base_url: str) -> tuple[str, str | None]:
36
+ """Split a ``CORE_API_URL`` value into (HTTP base URL, Unix socket path).
37
+
38
+ ``unix:///run/core/api.sock`` gives ``("http://localhost", "/run/core/api.sock")``;
39
+ any other value is an HTTP base URL and gives ``(base_url, None)``. The
40
+ socket path must be absolute, so a mistyped ``unix://`` URL fails here
41
+ rather than as a confusing connection error.
42
+ """
43
+ if not base_url.startswith(UNIX_SCHEME):
44
+ return base_url, None
45
+ socket_path = base_url[len(UNIX_SCHEME) :]
46
+ if not socket_path.startswith("/"):
47
+ raise ValueError(
48
+ f"CORE_API_URL {base_url!r}: a Unix socket URL needs an absolute "
49
+ "path, e.g. unix:///run/core/api.sock"
50
+ )
51
+ return _UNIX_HTTP_BASE, socket_path
52
+
53
+
28
54
  # ID: e5aeda69-d26e-4353-81eb-83e998e968e9
29
55
  class CoreApiError(httpx.HTTPStatusError):
30
56
  """An error response (status >= 400) from the CORE API.
@@ -59,6 +85,7 @@ class CoreApiClient:
59
85
 
60
86
  def __init__(self, base_url: str | None = None) -> None:
61
87
  self.base_url = base_url or os.environ.get("CORE_API_URL") or DEFAULT_BASE_URL
88
+ self._http_base, self.socket_path = parse_base_url(self.base_url)
62
89
  self.inspect = _Inspect(self)
63
90
  self.lane = _Lane(self)
64
91
  self.project = _Project(self)
@@ -73,8 +100,11 @@ class CoreApiClient:
73
100
  timeout: float = _TIMEOUT_SECONDS,
74
101
  **kwargs: Any,
75
102
  ) -> dict:
76
- async with httpx.AsyncClient(timeout=timeout) as http:
77
- response = await http.request(method, f"{self.base_url}{path}", **kwargs)
103
+ client_kwargs: dict[str, Any] = {"timeout": timeout}
104
+ if self.socket_path is not None:
105
+ client_kwargs["transport"] = httpx.AsyncHTTPTransport(uds=self.socket_path)
106
+ async with httpx.AsyncClient(**client_kwargs) as http:
107
+ response = await http.request(method, f"{self._http_base}{path}", **kwargs)
78
108
  if response.status_code >= 400:
79
109
  raise CoreApiError(response)
80
110
  return response.json()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
5
6
  from datetime import datetime
6
7
 
7
8
  import httpx
@@ -62,6 +63,102 @@ def _print_detailed_info(p: dict) -> None:
62
63
  console.print(f"\n[red]Failure Reason: {failure_reason}[/red]")
63
64
 
64
65
 
66
+ def _mark(ok: object) -> str:
67
+ return "[green]yes[/green]" if ok else "[red]NO[/red]"
68
+
69
+
70
+ def _print_review(p: dict) -> None:
71
+ """The approver's view (CORE ADR-168 Amendment 2026-10-10 R3/R4): who
72
+ wrote it and why, what it retires, what CORE checked, what resembles it,
73
+ which decisions touch it. Shown only when the proposal carries them."""
74
+ constraints = p.get("constitutional_constraints") or {}
75
+ prov = constraints.get("provenance")
76
+ if not isinstance(prov, dict):
77
+ console.print(
78
+ "\n[yellow]This proposal predates who-and-why records "
79
+ "(no provenance).[/yellow]"
80
+ )
81
+ return
82
+ console.print("\n[bold]Why it exists:[/bold]")
83
+ console.print(f" Anchor: {prov.get('anchor_kind')}")
84
+ for ref in prov.get("anchor_refs") or []:
85
+ console.print(f" {ref}")
86
+ console.print(f" Problem owner: {prov.get('problem_owner')}")
87
+ console.print(f" Written by: {prov.get('producer')}")
88
+
89
+ step_zero = constraints.get("step_zero") or {}
90
+ retires = step_zero.get("retires")
91
+ console.print("\n[bold]What it retires:[/bold]")
92
+ if not retires:
93
+ console.print(" nothing claimed")
94
+ for row in retires or []:
95
+ console.print(
96
+ f" {_mark(row.get('verified'))} {row.get('entry')} - {row.get('reason')}"
97
+ )
98
+
99
+ results = p.get("validation_results") or {}
100
+ if results:
101
+ console.print("\n[bold]What CORE checked:[/bold]")
102
+ for check, ok in sorted(results.items()):
103
+ console.print(f" {_mark(ok)} {check}")
104
+
105
+ look = step_zero.get("look_alikes") or {}
106
+ if look:
107
+ console.print("\n[bold]Similar existing code:[/bold]")
108
+ status = look.get("status")
109
+ if status == "unavailable":
110
+ console.print(f" [yellow]not searched: {look.get('reason')}[/yellow]")
111
+ elif status == "nothing_new":
112
+ console.print(" no new public function or class")
113
+ for sym in look.get("symbols") or []:
114
+ console.print(f" {sym.get('file')}::{sym.get('symbol')}")
115
+ for m in (sym.get("matches") or [])[:3]:
116
+ console.print(
117
+ f" {m.get('score')} {m.get('file')}::{m.get('symbol')}"
118
+ )
119
+
120
+ decisions = step_zero.get("decisions") or {}
121
+ mentions = decisions.get("mentions") or {}
122
+ history = decisions.get("history") or {}
123
+ if mentions or history:
124
+ console.print("\n[bold]Decisions touching these files:[/bold]")
125
+ for path in sorted(set(mentions) | set(history)):
126
+ named = [
127
+ f"{a.get('id')} ({a.get('status')})" for a in mentions.get(path) or []
128
+ ]
129
+ cited = history.get(path)
130
+ cited_text = (
131
+ "history unreadable"
132
+ if cited is None and path in history
133
+ else ", ".join(cited or [])
134
+ )
135
+ console.print(f" {path}")
136
+ if named:
137
+ console.print(f" mentioned in: {', '.join(named)}")
138
+ if cited_text:
139
+ console.print(f" cited by past commits: {cited_text}")
140
+
141
+
142
+ def _typed_confirmation(proposal_id: str) -> bool:
143
+ """CORE ADR-168 R2: approval is typed by the human at a real terminal.
144
+
145
+ A speed bump, not identity proof (CORE #942): a session without a
146
+ terminal is refused; a person must type the proposal's short id.
147
+ """
148
+ if not sys.stdin.isatty():
149
+ console.print(
150
+ "[red]Approval needs a person at a real terminal (CORE ADR-168 R2). "
151
+ "Not approved.[/red]"
152
+ )
153
+ return False
154
+ expected = proposal_id[:8]
155
+ typed = typer.prompt(f"Type {expected} to approve this proposal", default="")
156
+ if typed.strip() != expected:
157
+ console.print("[yellow]Did not match. Not approved.[/yellow]")
158
+ return False
159
+ return True
160
+
161
+
65
162
  def _print_execution_summary(result: dict) -> None:
66
163
  if not result.get("ok") and "actions_executed" not in result:
67
164
  console.print(f"Error: {result.get('error', 'Unknown error')}")
@@ -92,6 +189,7 @@ async def show_proposal(proposal_id: str = typer.Argument(...)) -> None:
92
189
  raise typer.Exit(1) from exc
93
190
  raise
94
191
  _print_detailed_info(proposal)
192
+ _print_review(proposal)
95
193
 
96
194
 
97
195
  @core_command(dangerous=False)
@@ -105,8 +203,23 @@ async def approve_proposal(
105
203
  help="Authority under which approval is granted (URS NFR.5).",
106
204
  ),
107
205
  ) -> None:
108
- """Authorize a pending proposal for execution."""
206
+ """Authorize a pending proposal for execution.
207
+
208
+ Shows the proposal and its review, then asks the person at the terminal
209
+ to type the proposal's short id. Refused without a terminal.
210
+ """
109
211
  client = CoreApiClient()
212
+ try:
213
+ proposal = await client.get_proposal(proposal_id)
214
+ except httpx.HTTPStatusError as exc:
215
+ if exc.response.status_code == 404:
216
+ console.print(f"[red]Proposal {proposal_id} not found.[/red]")
217
+ raise typer.Exit(1) from exc
218
+ raise
219
+ _print_detailed_info(proposal)
220
+ _print_review(proposal)
221
+ if not _typed_confirmation(proposal["proposal_id"]):
222
+ raise typer.Exit(1)
110
223
  try:
111
224
  response = await client.approve_proposal(
112
225
  proposal_id, approved_by=by, approval_authority=authority
@@ -8,6 +8,7 @@ raises what those handlers catch.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import asyncio
11
12
  from unittest.mock import patch
12
13
 
13
14
  import httpx
@@ -52,3 +53,55 @@ async def test_lane_next_404_reports_empty_lane(capsys: pytest.CaptureFixture) -
52
53
  with _serve(404, {"detail": "no delegated findings"}):
53
54
  await next_finding.__wrapped__()
54
55
  assert "Lane is empty" in capsys.readouterr().out
56
+
57
+
58
+ # -- Unix socket transport (CORE_API_URL=unix:///...) ------------------------
59
+
60
+
61
+ def test_parse_base_url_http_is_unchanged() -> None:
62
+ assert client_mod.parse_base_url("http://10.0.0.5:8000") == (
63
+ "http://10.0.0.5:8000",
64
+ None,
65
+ )
66
+
67
+
68
+ def test_parse_base_url_unix_gives_socket_path() -> None:
69
+ assert client_mod.parse_base_url("unix:///run/core/api.sock") == (
70
+ "http://localhost",
71
+ "/run/core/api.sock",
72
+ )
73
+
74
+
75
+ def test_parse_base_url_unix_relative_path_is_refused() -> None:
76
+ with pytest.raises(ValueError, match="absolute path"):
77
+ client_mod.parse_base_url("unix://run/core/api.sock")
78
+
79
+
80
+ def test_env_unix_url_selects_socket(monkeypatch: pytest.MonkeyPatch) -> None:
81
+ monkeypatch.setenv("CORE_API_URL", "unix:///run/core/api.sock")
82
+ assert CoreApiClient().socket_path == "/run/core/api.sock"
83
+
84
+
85
+ async def test_request_travels_over_the_unix_socket(
86
+ tmp_path_factory: pytest.TempPathFactory,
87
+ ) -> None:
88
+ """A real HTTP exchange over a real Unix socket, not a mocked transport."""
89
+ socket_path = tmp_path_factory.mktemp("uds") / "api.sock"
90
+ seen: list[bytes] = []
91
+
92
+ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
93
+ seen.append(await reader.readuntil(b"\r\n\r\n"))
94
+ body = b'{"proposals": []}'
95
+ writer.write(
96
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
97
+ b"Content-Length: " + str(len(body)).encode() + b"\r\n"
98
+ b"Connection: close\r\n\r\n" + body
99
+ )
100
+ await writer.drain()
101
+ writer.close()
102
+
103
+ server = await asyncio.start_unix_server(handle, path=str(socket_path))
104
+ async with server:
105
+ client = CoreApiClient(f"unix://{socket_path}")
106
+ assert await client.list_proposals() == {"proposals": []}
107
+ assert seen[0].startswith(b"GET /v1/proposals?limit=50 HTTP/1.1")
@@ -4,6 +4,7 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ from contextlib import contextmanager
7
8
  from unittest.mock import AsyncMock, MagicMock, patch
8
9
 
9
10
  import httpx
@@ -168,16 +169,36 @@ async def test_show_proposal_not_found() -> None:
168
169
  # ---------------------------------------------------------------------------
169
170
 
170
171
 
171
- async def test_approve_proposal_success() -> None:
172
- mock_client = _make_client()
172
+ @contextmanager
173
+ def _terminal(typed: str | None = "abc123de", tty: bool = True):
174
+ """A person at a real terminal typing *typed* (CORE ADR-168 R2)."""
175
+ stdin = MagicMock()
176
+ stdin.isatty.return_value = tty
177
+ with (
178
+ patch("core_cli.resources.proposals.manage.sys.stdin", stdin),
179
+ patch(
180
+ "core_cli.resources.proposals.manage.typer.prompt", return_value=typed
181
+ ) as prompt,
182
+ ):
183
+ yield prompt
184
+
173
185
 
186
+ async def _approve(mock_client: AsyncMock, proposal_id: str = "abc123") -> None:
174
187
  with patch(_MANAGE_CLIENT, return_value=mock_client):
175
188
  await approve_proposal.__wrapped__(
176
- proposal_id="abc123",
189
+ proposal_id=proposal_id,
177
190
  by="cli_admin",
178
191
  authority="principal.governor",
179
192
  )
180
193
 
194
+
195
+ async def test_approve_proposal_success() -> None:
196
+ mock_client = _make_client()
197
+
198
+ with _terminal() as prompt:
199
+ await _approve(mock_client)
200
+
201
+ assert "abc123de" in prompt.call_args.args[0]
181
202
  mock_client.approve_proposal.assert_called_once_with(
182
203
  "abc123",
183
204
  approved_by="cli_admin",
@@ -185,36 +206,93 @@ async def test_approve_proposal_success() -> None:
185
206
  )
186
207
 
187
208
 
209
+ async def test_approve_refused_without_a_terminal() -> None:
210
+ """A session with no terminal (e.g. a model's shell) cannot approve."""
211
+ mock_client = _make_client()
212
+
213
+ with _terminal(tty=False) as prompt, pytest.raises(typer.Exit) as exc_info:
214
+ await _approve(mock_client)
215
+
216
+ assert exc_info.value.exit_code == 1
217
+ prompt.assert_not_called()
218
+ mock_client.approve_proposal.assert_not_called()
219
+
220
+
221
+ async def test_approve_refused_when_the_typed_id_does_not_match() -> None:
222
+ mock_client = _make_client()
223
+
224
+ with _terminal(typed="yes"), pytest.raises(typer.Exit):
225
+ await _approve(mock_client)
226
+
227
+ mock_client.approve_proposal.assert_not_called()
228
+
229
+
188
230
  async def test_approve_proposal_not_found() -> None:
189
231
  mock_client = _make_client()
190
- mock_client.approve_proposal.side_effect = _http_error(404)
232
+ mock_client.get_proposal.side_effect = _http_error(404)
191
233
 
192
- with patch(_MANAGE_CLIENT, return_value=mock_client):
193
- with pytest.raises(typer.Exit) as exc_info:
194
- await approve_proposal.__wrapped__(
195
- proposal_id="missing",
196
- by="cli_admin",
197
- authority="principal.governor",
198
- )
234
+ with _terminal(), pytest.raises(typer.Exit) as exc_info:
235
+ await _approve(mock_client, "missing")
199
236
 
200
237
  assert exc_info.value.exit_code == 1
238
+ mock_client.approve_proposal.assert_not_called()
201
239
 
202
240
 
203
241
  async def test_approve_proposal_bad_request_prints_detail() -> None:
204
242
  mock_client = _make_client()
205
243
  mock_client.approve_proposal.side_effect = _http_error(400, "already approved")
206
244
 
207
- with patch(_MANAGE_CLIENT, return_value=mock_client):
208
- with pytest.raises(typer.Exit) as exc_info:
209
- await approve_proposal.__wrapped__(
210
- proposal_id="abc123",
211
- by="cli_admin",
212
- authority="principal.governor",
213
- )
245
+ with _terminal(), pytest.raises(typer.Exit) as exc_info:
246
+ await _approve(mock_client)
214
247
 
215
248
  assert exc_info.value.exit_code == 1
216
249
 
217
250
 
251
+ async def test_show_renders_who_why_and_step_zero(capsys) -> None:
252
+ proposal = _sample_proposal()
253
+ proposal["validation_results"] = {"full_audit": True, "tests": False}
254
+ proposal["constitutional_constraints"] = {
255
+ "provenance": {
256
+ "anchor_kind": "governor_request",
257
+ "anchor_refs": ["add a status line"],
258
+ "problem_owner": "governor",
259
+ "producer": "claude-session:core-darek",
260
+ "retires": ["src/old.py"],
261
+ },
262
+ "step_zero": {
263
+ "retires": [
264
+ {
265
+ "entry": "src/old.py",
266
+ "verified": True,
267
+ "reason": "deleted by the patch",
268
+ }
269
+ ],
270
+ "look_alikes": {"status": "unavailable", "reason": "qdrant down"},
271
+ "decisions": {
272
+ "mentions": {"src/old.py": [{"id": "ADR-005", "status": "accepted"}]},
273
+ "history": {"src/old.py": None},
274
+ },
275
+ },
276
+ }
277
+ mock_client = _make_client()
278
+ mock_client.get_proposal.return_value = proposal
279
+
280
+ with patch(_MANAGE_CLIENT, return_value=mock_client):
281
+ await show_proposal.__wrapped__(proposal_id="abc123")
282
+
283
+ out = capsys.readouterr().out
284
+ for text in (
285
+ "add a status line",
286
+ "claude-session:core-darek",
287
+ "deleted by the patch",
288
+ "full_audit",
289
+ "not searched: qdrant down",
290
+ "ADR-005 (accepted)",
291
+ "history unreadable",
292
+ ):
293
+ assert text in out, text
294
+
295
+
218
296
  # ---------------------------------------------------------------------------
219
297
  # execute_proposal
220
298
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes