expedait-cli 0.2.2__tar.gz → 0.3.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 (47) hide show
  1. expedait_cli-0.3.0/CHANGELOG.md +35 -0
  2. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/PKG-INFO +64 -22
  3. expedait_cli-0.3.0/README.md +184 -0
  4. expedait_cli-0.3.0/expedait_cli/client.py +184 -0
  5. expedait_cli-0.3.0/expedait_cli/commands/comments.py +146 -0
  6. expedait_cli-0.3.0/expedait_cli/commands/context_cmd.py +40 -0
  7. expedait_cli-0.3.0/expedait_cli/commands/deliverables.py +245 -0
  8. expedait_cli-0.3.0/expedait_cli/commands/objectives.py +62 -0
  9. expedait_cli-0.3.0/expedait_cli/commands/review.py +81 -0
  10. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/main.py +10 -2
  11. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/pyproject.toml +3 -1
  12. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_client.py +26 -2
  13. expedait_cli-0.3.0/tests/test_commands/test_comments.py +122 -0
  14. expedait_cli-0.3.0/tests/test_commands/test_context.py +32 -0
  15. expedait_cli-0.3.0/tests/test_commands/test_deliverables.py +141 -0
  16. expedait_cli-0.3.0/tests/test_commands/test_objectives.py +49 -0
  17. expedait_cli-0.3.0/tests/test_commands/test_review.py +62 -0
  18. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/uv.lock +1 -1
  19. expedait_cli-0.2.2/README.md +0 -142
  20. expedait_cli-0.2.2/expedait_cli/client.py +0 -134
  21. expedait_cli-0.2.2/expedait_cli/commands/comments.py +0 -102
  22. expedait_cli-0.2.2/expedait_cli/commands/pages.py +0 -110
  23. expedait_cli-0.2.2/tests/test_commands/test_comments.py +0 -138
  24. expedait_cli-0.2.2/tests/test_commands/test_pages.py +0 -153
  25. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  26. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  27. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/.gitignore +0 -0
  28. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/CLAUDE.md +0 -0
  29. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/LICENSE +0 -0
  30. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/__init__.py +0 -0
  31. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/auth.py +0 -0
  32. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/commands/__init__.py +0 -0
  33. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/commands/auth_cmd.py +0 -0
  34. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/commands/init_cmd.py +0 -0
  35. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/commands/projects.py +0 -0
  36. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/config.py +0 -0
  37. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/formatters.py +0 -0
  38. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/expedait_cli/settings.py +0 -0
  39. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/__init__.py +0 -0
  40. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/conftest.py +0 -0
  41. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_auth.py +0 -0
  42. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_commands/__init__.py +0 -0
  43. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_commands/test_auth_cmd.py +0 -0
  44. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_commands/test_init_cmd.py +0 -0
  45. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_commands/test_projects.py +0 -0
  46. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_config.py +0 -0
  47. {expedait_cli-0.2.2 → expedait_cli-0.3.0}/tests/test_settings.py +0 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ Adapt the CLI to the product's four-primitive domain model (objectives,
6
+ deliverables, context, review).
7
+
8
+ ### Added
9
+ - `deliverables` command group (`list`, `get`, `inspect`, `download`) — the
10
+ rename of `pages`, pointed at `/api/v1/deliverables/...`.
11
+ - `deliverables get --include` — comma-separated section reads (`meta`,
12
+ `content`, `template`, `requirements`, `writer_instructions`, `dependencies`,
13
+ `external_context`, `score`, `comments`, `versions`), defaulting to `content`.
14
+ `meta` surfaces `parent_deliverable_id`.
15
+ - `objectives overview DELIVERABLE_ID` — objective metadata plus its full
16
+ descendant tree.
17
+ - `context get DELIVERABLE_ID` — read-only LLM context snapshot for a
18
+ deliverable.
19
+ - `review` command group: `review issues DELIVERABLE_ID [--state open|muted|all]`
20
+ and `review mute ISSUE_ID [--note TEXT] [--unmute]`.
21
+ - `comments create --agent-run-id` to link a comment to a build run.
22
+ - `expedait-cli` console-script alias so `uvx expedait-cli …` keeps working.
23
+
24
+ ### Changed
25
+ - `comments create` now resolves anchor offsets from the deliverable content;
26
+ only `--text` and `--selected-text` are required. `--start-offset` /
27
+ `--end-offset` remain available as explicit overrides.
28
+ - Renamed `comments create --source-page-id` → `--source-deliverable-id`
29
+ (payload field `source_page_id` → `source_deliverable_id`).
30
+ - `comments resolve` / `comments delete` now use the deliverable-scoped routes
31
+ `/api/v1/deliverables/{id}/comments/{comment_id}`.
32
+
33
+ ### Deprecated
34
+ - The `pages` command group. `expedait pages …` still works for one release
35
+ (warns and forwards to `deliverables`) and will then be removed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expedait-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: CLI for Expedait project management — download specs, post comments
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -17,6 +17,15 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
19
19
 
20
+ ## The model
21
+
22
+ Expedait organizes specs around four primitives:
23
+
24
+ - **Objectives** — top-level goals. An objective is itself a deliverable that nests child deliverables beneath it (`parent_deliverable_id`).
25
+ - **Deliverables** — the individual spec documents (formerly "pages").
26
+ - **Context** — the assembled LLM context for one deliverable: dependency deliverables, linked external sources, uploaded files, and aggregate sizes.
27
+ - **Review** — scoring findings raised on a deliverable: severity, description, the criteria that flagged them, and anchor offsets.
28
+
20
29
  ## Usage
21
30
 
22
31
  ### Run with `uvx` (recommended)
@@ -53,8 +62,8 @@ Once initialized, commands that need a project ID will resolve it automatically.
53
62
 
54
63
  ```bash
55
64
  expedait projects download # downloads to .expedait/context/
56
- expedait pages list # no --project-id needed
57
- expedait pages download 42 # downloads to .expedait/context/
65
+ expedait deliverables list # no --project-id needed
66
+ expedait deliverables download 42 # downloads to .expedait/context/
58
67
  ```
59
68
 
60
69
  **Resolution order for tenant/project:** CLI flag > env var > `.expedait/settings.json` > `~/.expedait/config.json`.
@@ -92,37 +101,66 @@ expedait auth logout # Clear stored credentials
92
101
  ### Projects
93
102
 
94
103
  ```bash
95
- expedait projects list # List all projects
96
- expedait projects get PROJECT_ID # Get project details
97
- expedait projects download PROJECT_ID # Extract markdown to .expedait/context/
98
- expedait projects download PROJECT_ID --download-format json # Download as JSON
99
- expedait projects download PROJECT_ID --output-dir ./specs # Extract to custom directory
104
+ expedait projects list # List all projects
105
+ expedait projects get PROJECT_ID # Get project details
106
+ expedait projects download PROJECT_ID # Extract all deliverables to .expedait/context/
107
+ expedait projects download PROJECT_ID --output-dir ./specs # Extract to a custom directory
108
+ ```
109
+
110
+ ### Deliverables
111
+
112
+ ```bash
113
+ expedait deliverables list --project-id PROJECT_ID # List deliverables in a project
114
+ expedait deliverables get DELIVERABLE_ID # Print deliverable markdown content
115
+ expedait deliverables get DELIVERABLE_ID --include meta,content,dependencies,score
116
+ expedait deliverables inspect DELIVERABLE_ID # Full context (content + comments + deps + lock)
117
+ expedait deliverables download DELIVERABLE_ID # Extract to .expedait/context/
118
+ ```
119
+
120
+ `--include` accepts a comma-separated subset of: `meta`, `content`, `template`,
121
+ `requirements`, `writer_instructions`, `dependencies`, `external_context`,
122
+ `score`, `comments`, `versions`. It defaults to `content`. `meta` surfaces
123
+ `parent_deliverable_id` (non-null ⇒ this deliverable is a child nested under an
124
+ objective).
125
+
126
+ ### Objectives
127
+
128
+ ```bash
129
+ expedait objectives overview DELIVERABLE_ID # Objective metadata + full descendant tree
100
130
  ```
101
131
 
102
- ### Pages
132
+ ### Context
103
133
 
104
134
  ```bash
105
- expedait pages list --project-id PROJECT_ID # List pages in a project
106
- expedait pages get PAGE_ID # Print page markdown content
107
- expedait pages full PAGE_ID # Full context (content + comments + deps)
108
- expedait pages download PAGE_ID # Extract markdown to .expedait/context/
109
- expedait pages download PAGE_ID --download-format json # Download as JSON
135
+ expedait context get DELIVERABLE_ID # The LLM context snapshot for one deliverable
136
+ ```
137
+
138
+ ### Review
139
+
140
+ ```bash
141
+ expedait review issues DELIVERABLE_ID # List scoring findings (default: all)
142
+ expedait review issues DELIVERABLE_ID --state open # Only open findings
143
+ expedait review mute ISSUE_ID --note "by design" # Mute a finding
144
+ expedait review mute ISSUE_ID --unmute # Unmute a finding
110
145
  ```
111
146
 
112
147
  ### Comments
113
148
 
114
149
  ```bash
115
- expedait comments list PAGE_ID # List comments on a page
116
- expedait comments create PAGE_ID \ # Create a comment
150
+ expedait comments list DELIVERABLE_ID # List comments on a deliverable
151
+ expedait comments create DELIVERABLE_ID \ # Create a comment (offsets resolved automatically)
117
152
  --text "Comment content" \
118
- --selected-text "text from page" \
119
- --start-offset 100 \
120
- --end-offset 120 \
121
- --source-page-id 5 # Optional: agent's source page
122
- expedait comments resolve PAGE_ID COMMENT_ID # Mark as resolved
123
- expedait comments delete PAGE_ID COMMENT_ID # Delete a comment
153
+ --selected-text "text from the deliverable" \
154
+ --source-deliverable-id 5 # Optional: agent's source deliverable
155
+ expedait comments resolve DELIVERABLE_ID COMMENT_ID # Mark as resolved
156
+ expedait comments delete DELIVERABLE_ID COMMENT_ID # Delete a comment
124
157
  ```
125
158
 
159
+ Only `--text` and `--selected-text` are required; the CLI locates the selected
160
+ text in the deliverable to compute anchor offsets. Pass `--start-offset` and
161
+ `--end-offset` to anchor explicitly (e.g. when the selected text appears more
162
+ than once).
163
+
126
164
  ### Global Options
127
165
 
128
166
  ```bash
@@ -135,6 +173,10 @@ expedait --version # Show version
135
173
 
136
174
  Output format defaults to `text` when connected to a terminal, `json` when piped.
137
175
 
176
+ > **Migration note:** the `pages` command group has been renamed to
177
+ > `deliverables`. `expedait pages …` still works for one release (it warns and
178
+ > forwards) but will be removed.
179
+
138
180
  ## Agent Skills
139
181
 
140
182
  For step-by-step guides on using the CLI from AI coding agents, see [expedait-skills](https://github.com/Expedait/expedait-skills).
@@ -0,0 +1,184 @@
1
+ # Expedait CLI
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/expedait-cli)](https://pypi.org/project/expedait-cli/)
4
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
5
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
6
+
7
+ CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
8
+
9
+ ## The model
10
+
11
+ Expedait organizes specs around four primitives:
12
+
13
+ - **Objectives** — top-level goals. An objective is itself a deliverable that nests child deliverables beneath it (`parent_deliverable_id`).
14
+ - **Deliverables** — the individual spec documents (formerly "pages").
15
+ - **Context** — the assembled LLM context for one deliverable: dependency deliverables, linked external sources, uploaded files, and aggregate sizes.
16
+ - **Review** — scoring findings raised on a deliverable: severity, description, the criteria that flagged them, and anchor offsets.
17
+
18
+ ## Usage
19
+
20
+ ### Run with `uvx` (recommended)
21
+
22
+ No installation needed — run directly:
23
+
24
+ ```bash
25
+ uvx expedait-cli auth login
26
+ uvx expedait-cli projects list
27
+ uvx expedait-cli projects download 1
28
+ ```
29
+
30
+ ### Add as a dev dependency
31
+
32
+ If your AI agent needs it available in the project environment:
33
+
34
+ ```bash
35
+ uv add --group dev expedait-cli
36
+ ```
37
+
38
+ Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
39
+
40
+ ## Project Setup
41
+
42
+ After authenticating, run `init` inside your project directory to store your tenant and project settings locally:
43
+
44
+ ```bash
45
+ uvx expedait-cli init
46
+ ```
47
+
48
+ This creates `.expedait/settings.json` with your `tenant_id` and `project_id`. Add `.expedait/` to your `.gitignore`.
49
+
50
+ Once initialized, commands that need a project ID will resolve it automatically. Downloads default to `.expedait/context/`:
51
+
52
+ ```bash
53
+ expedait projects download # downloads to .expedait/context/
54
+ expedait deliverables list # no --project-id needed
55
+ expedait deliverables download 42 # downloads to .expedait/context/
56
+ ```
57
+
58
+ **Resolution order for tenant/project:** CLI flag > env var > `.expedait/settings.json` > `~/.expedait/config.json`.
59
+
60
+ ## Authentication
61
+
62
+ ### Interactive login
63
+
64
+ ```bash
65
+ uvx expedait-cli auth login
66
+ ```
67
+
68
+ Prompts for login method (SSO or email/password). Stores credentials in `~/.expedait/config.json`.
69
+
70
+ ### Environment variables (CI / agents)
71
+
72
+ ```bash
73
+ export EXPEDAIT_TOKEN="your-jwt-token"
74
+ export EXPEDAIT_API_URL="https://your-instance.expedait.org"
75
+ export EXPEDAIT_TENANT_ID=1
76
+ ```
77
+
78
+ **Token resolution order:** `EXPEDAIT_TOKEN` env var > `~/.expedait/config.json` > error.
79
+
80
+ ## Commands
81
+
82
+ ### Auth
83
+
84
+ ```bash
85
+ expedait auth login # Interactive login
86
+ expedait auth status # Show current user and tenant
87
+ expedait auth logout # Clear stored credentials
88
+ ```
89
+
90
+ ### Projects
91
+
92
+ ```bash
93
+ expedait projects list # List all projects
94
+ expedait projects get PROJECT_ID # Get project details
95
+ expedait projects download PROJECT_ID # Extract all deliverables to .expedait/context/
96
+ expedait projects download PROJECT_ID --output-dir ./specs # Extract to a custom directory
97
+ ```
98
+
99
+ ### Deliverables
100
+
101
+ ```bash
102
+ expedait deliverables list --project-id PROJECT_ID # List deliverables in a project
103
+ expedait deliverables get DELIVERABLE_ID # Print deliverable markdown content
104
+ expedait deliverables get DELIVERABLE_ID --include meta,content,dependencies,score
105
+ expedait deliverables inspect DELIVERABLE_ID # Full context (content + comments + deps + lock)
106
+ expedait deliverables download DELIVERABLE_ID # Extract to .expedait/context/
107
+ ```
108
+
109
+ `--include` accepts a comma-separated subset of: `meta`, `content`, `template`,
110
+ `requirements`, `writer_instructions`, `dependencies`, `external_context`,
111
+ `score`, `comments`, `versions`. It defaults to `content`. `meta` surfaces
112
+ `parent_deliverable_id` (non-null ⇒ this deliverable is a child nested under an
113
+ objective).
114
+
115
+ ### Objectives
116
+
117
+ ```bash
118
+ expedait objectives overview DELIVERABLE_ID # Objective metadata + full descendant tree
119
+ ```
120
+
121
+ ### Context
122
+
123
+ ```bash
124
+ expedait context get DELIVERABLE_ID # The LLM context snapshot for one deliverable
125
+ ```
126
+
127
+ ### Review
128
+
129
+ ```bash
130
+ expedait review issues DELIVERABLE_ID # List scoring findings (default: all)
131
+ expedait review issues DELIVERABLE_ID --state open # Only open findings
132
+ expedait review mute ISSUE_ID --note "by design" # Mute a finding
133
+ expedait review mute ISSUE_ID --unmute # Unmute a finding
134
+ ```
135
+
136
+ ### Comments
137
+
138
+ ```bash
139
+ expedait comments list DELIVERABLE_ID # List comments on a deliverable
140
+ expedait comments create DELIVERABLE_ID \ # Create a comment (offsets resolved automatically)
141
+ --text "Comment content" \
142
+ --selected-text "text from the deliverable" \
143
+ --source-deliverable-id 5 # Optional: agent's source deliverable
144
+ expedait comments resolve DELIVERABLE_ID COMMENT_ID # Mark as resolved
145
+ expedait comments delete DELIVERABLE_ID COMMENT_ID # Delete a comment
146
+ ```
147
+
148
+ Only `--text` and `--selected-text` are required; the CLI locates the selected
149
+ text in the deliverable to compute anchor offsets. Pass `--start-offset` and
150
+ `--end-offset` to anchor explicitly (e.g. when the selected text appears more
151
+ than once).
152
+
153
+ ### Global Options
154
+
155
+ ```bash
156
+ expedait --api-url https://host:8000 ... # Override API URL
157
+ expedait --tenant-id 2 ... # Override tenant
158
+ expedait --format json ... # Force JSON output
159
+ expedait --format text ... # Force human-readable output
160
+ expedait --version # Show version
161
+ ```
162
+
163
+ Output format defaults to `text` when connected to a terminal, `json` when piped.
164
+
165
+ > **Migration note:** the `pages` command group has been renamed to
166
+ > `deliverables`. `expedait pages …` still works for one release (it warns and
167
+ > forwards) but will be removed.
168
+
169
+ ## Agent Skills
170
+
171
+ For step-by-step guides on using the CLI from AI coding agents, see [expedait-skills](https://github.com/Expedait/expedait-skills).
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ git clone https://github.com/Expedait/expedait-cli.git
177
+ cd expedait-cli
178
+ uv sync --group dev
179
+ uv run python -m pytest
180
+ ```
181
+
182
+ ## License
183
+
184
+ [Apache License 2.0](LICENSE)
@@ -0,0 +1,184 @@
1
+ """HTTP client wrapper for Expedait API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+ import httpx
9
+
10
+
11
+ class ExpedaitClient:
12
+ """Thin wrapper around httpx with auth and tenant headers."""
13
+
14
+ def __init__(self, api_url: str, token: str, tenant_id: int | None = None):
15
+ headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
16
+ if tenant_id is not None:
17
+ headers["X-Active-Tenant-Id"] = str(tenant_id)
18
+ # follow_redirects: collection endpoints are mounted at "/", so the
19
+ # API 307-redirects e.g. /api/v1/deliverables -> /api/v1/deliverables/.
20
+ self._http = httpx.Client(
21
+ base_url=api_url, headers=headers, timeout=30.0, follow_redirects=True,
22
+ )
23
+
24
+ def close(self) -> None:
25
+ self._http.close()
26
+
27
+ # -- helpers ----------------------------------------------------------
28
+
29
+ def _check(self, resp: httpx.Response) -> None:
30
+ if resp.status_code == 401:
31
+ raise click.UsageError(
32
+ "Authentication failed (401). Run 'expedait auth login'."
33
+ )
34
+ if resp.status_code == 403:
35
+ raise click.UsageError(
36
+ "Permission denied (403). Check your tenant access."
37
+ )
38
+ if resp.status_code == 404:
39
+ raise click.UsageError("Resource not found (404).")
40
+ if resp.status_code >= 400:
41
+ detail = ""
42
+ try:
43
+ detail = resp.json().get("detail", resp.text)
44
+ except Exception:
45
+ detail = resp.text
46
+ raise click.ClickException(f"API error {resp.status_code}: {detail}")
47
+
48
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
49
+ """Make request, handle errors, return parsed JSON."""
50
+ resp = self._http.request(method, path, **kwargs)
51
+ self._check(resp)
52
+ if resp.status_code == 204 or not resp.content:
53
+ return None
54
+ return resp.json()
55
+
56
+ def _request_raw(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
57
+ """Make request, handle errors, return raw response."""
58
+ resp = self._http.request(method, path, **kwargs)
59
+ self._check(resp)
60
+ return resp
61
+
62
+ # -- auth -------------------------------------------------------------
63
+
64
+ @staticmethod
65
+ def login(api_url: str, email: str, password: str) -> dict[str, Any]:
66
+ """POST /api/v1/auth/login — returns token payload."""
67
+ resp = httpx.post(
68
+ f"{api_url}/api/v1/auth/login",
69
+ json={"email": email, "password": password},
70
+ timeout=15.0,
71
+ )
72
+ if resp.status_code == 401:
73
+ raise click.UsageError("Invalid email or password.")
74
+ if resp.status_code >= 400:
75
+ raise click.ClickException(f"Login failed ({resp.status_code}).")
76
+ return resp.json()
77
+
78
+ def get_me(self) -> dict[str, Any]:
79
+ return self._request("GET", "/api/v1/auth/me")
80
+
81
+ # -- projects ---------------------------------------------------------
82
+
83
+ def list_projects(self) -> list[dict[str, Any]]:
84
+ return self._request("GET", "/api/v1/projects")
85
+
86
+ def get_project(self, project_id: int) -> dict[str, Any]:
87
+ return self._request("GET", f"/api/v1/projects/{project_id}")
88
+
89
+ def get_workspace(self, project_id: int) -> dict[str, Any]:
90
+ return self._request("GET", f"/api/v1/projects/{project_id}/workspace")
91
+
92
+ def download_project(self, project_id: int) -> bytes:
93
+ resp = self._request_raw("GET", f"/api/v1/projects/{project_id}/download")
94
+ return resp.content
95
+
96
+ # -- deliverables -----------------------------------------------------
97
+
98
+ def list_deliverables(
99
+ self, project_id: int, skip: int = 0, limit: int = 100,
100
+ ) -> list[dict[str, Any]]:
101
+ return self._request(
102
+ "GET", "/api/v1/deliverables",
103
+ params={"project_id": project_id, "skip": skip, "limit": limit},
104
+ )
105
+
106
+ def get_deliverable(self, deliverable_id: int) -> dict[str, Any]:
107
+ return self._request("GET", f"/api/v1/deliverables/{deliverable_id}")
108
+
109
+ def get_deliverable_full(self, deliverable_id: int) -> dict[str, Any]:
110
+ """Full payload: dependencies, comments, versions, lock status."""
111
+ return self._request("GET", f"/api/v1/deliverables/{deliverable_id}/full")
112
+
113
+ def get_deliverable_type(self, type_id: int) -> dict[str, Any]:
114
+ return self._request("GET", f"/api/v1/deliverables/types/{type_id}")
115
+
116
+ def get_deliverable_sources(self, deliverable_id: int) -> list[dict[str, Any]]:
117
+ return self._request("GET", f"/api/v1/deliverables/{deliverable_id}/sources")
118
+
119
+ def download_deliverable(self, deliverable_id: int) -> bytes:
120
+ resp = self._request_raw(
121
+ "GET", f"/api/v1/deliverables/{deliverable_id}/download",
122
+ )
123
+ return resp.content
124
+
125
+ def get_objective_overview(self, deliverable_id: int) -> dict[str, Any]:
126
+ """Objective metadata + descendant tree. 400 if not an objective."""
127
+ resp = self._http.request(
128
+ "GET", f"/api/v1/deliverables/{deliverable_id}/objective-overview",
129
+ )
130
+ if resp.status_code == 400:
131
+ raise click.UsageError(
132
+ f"Deliverable {deliverable_id} is not an objective."
133
+ )
134
+ self._check(resp)
135
+ return resp.json()
136
+
137
+ def get_deliverable_context(self, deliverable_id: int) -> dict[str, Any]:
138
+ """Read-only LLM context snapshot for a deliverable."""
139
+ return self._request(
140
+ "GET", f"/api/v1/deliverables/{deliverable_id}/context-summary",
141
+ )
142
+
143
+ # -- review issues ----------------------------------------------------
144
+
145
+ def list_review_issues(
146
+ self, deliverable_id: int, state: str = "all",
147
+ ) -> list[dict[str, Any]]:
148
+ # Backend default (no state param) is open + muted == 'all'.
149
+ params = {} if state == "all" else {"state": state}
150
+ return self._request(
151
+ "GET", f"/api/v1/deliverables/{deliverable_id}/issues", params=params,
152
+ )
153
+
154
+ def mute_review_issue(
155
+ self, issue_id: int, muted: bool = True, note: str | None = None,
156
+ ) -> dict[str, Any]:
157
+ payload: dict[str, Any] = {"state": "muted" if muted else "open"}
158
+ if muted and note is not None:
159
+ payload["muted_note"] = note
160
+ return self._request(
161
+ "PATCH", f"/api/v1/deliverables/issues/{issue_id}", json=payload,
162
+ )
163
+
164
+ # -- comments ---------------------------------------------------------
165
+
166
+ def list_comments(self, deliverable_id: int) -> list[dict[str, Any]]:
167
+ return self._request("GET", f"/api/v1/deliverables/{deliverable_id}/comments")
168
+
169
+ def create_comment(self, deliverable_id: int, data: dict[str, Any]) -> dict[str, Any]:
170
+ return self._request(
171
+ "POST", f"/api/v1/deliverables/{deliverable_id}/comments", json=data,
172
+ )
173
+
174
+ def resolve_comment(self, deliverable_id: int, comment_id: int) -> dict[str, Any]:
175
+ return self._request(
176
+ "PUT",
177
+ f"/api/v1/deliverables/{deliverable_id}/comments/{comment_id}",
178
+ params={"is_resolved": "true"},
179
+ )
180
+
181
+ def delete_comment(self, deliverable_id: int, comment_id: int) -> Any:
182
+ return self._request(
183
+ "DELETE", f"/api/v1/deliverables/{deliverable_id}/comments/{comment_id}",
184
+ )
@@ -0,0 +1,146 @@
1
+ """Comment commands: list, create, resolve, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..auth import resolve_api_url, resolve_tenant_id, resolve_token
8
+ from ..client import ExpedaitClient
9
+ from ..formatters import output
10
+
11
+
12
+ def _make_client(ctx: click.Context) -> ExpedaitClient:
13
+ token = resolve_token()
14
+ api_url = resolve_api_url(ctx.obj.get("api_url"))
15
+ tenant_id = resolve_tenant_id(ctx.obj.get("tenant_id"))
16
+ return ExpedaitClient(api_url, token, tenant_id)
17
+
18
+
19
+ def _resolve_offsets(
20
+ client: ExpedaitClient,
21
+ deliverable_id: int,
22
+ selected_text: str,
23
+ start_offset: int | None,
24
+ end_offset: int | None,
25
+ ) -> tuple[int, int]:
26
+ """Resolve anchor offsets from the deliverable content.
27
+
28
+ The backend requires start/end offsets, but callers should only have to
29
+ supply the selected text. If both offsets are given we trust them;
30
+ otherwise we locate the selected text in the current content.
31
+ """
32
+ if start_offset is not None and end_offset is not None:
33
+ return start_offset, end_offset
34
+
35
+ deliverable = client.get_deliverable(deliverable_id)
36
+ content = deliverable.get("content") or ""
37
+ idx = content.find(selected_text)
38
+ if idx < 0:
39
+ raise click.UsageError(
40
+ "Could not find the selected text in deliverable "
41
+ f"{deliverable_id}. Pass --start-offset/--end-offset explicitly."
42
+ )
43
+ if content.find(selected_text, idx + 1) >= 0:
44
+ click.echo(
45
+ "Warning: selected text appears multiple times; anchoring to the "
46
+ "first occurrence. Pass --start-offset/--end-offset to disambiguate.",
47
+ err=True,
48
+ )
49
+ return idx, idx + len(selected_text)
50
+
51
+
52
+ @click.group()
53
+ def comments() -> None:
54
+ """Manage deliverable comments."""
55
+
56
+
57
+ @comments.command("list")
58
+ @click.argument("deliverable_id", type=int)
59
+ @click.pass_context
60
+ def list_comments(ctx: click.Context, deliverable_id: int) -> None:
61
+ """List comments on a deliverable."""
62
+ client = _make_client(ctx)
63
+ try:
64
+ data = client.list_comments(deliverable_id)
65
+ finally:
66
+ client.close()
67
+ output(data, ctx.obj.get("fmt"))
68
+
69
+
70
+ @comments.command("create")
71
+ @click.argument("deliverable_id", type=int)
72
+ @click.option("--text", required=True, help="Comment content.")
73
+ @click.option("--selected-text", required=True, help="The text being commented on.")
74
+ @click.option("--start-offset", type=int, default=None, help="Start char offset (resolved from content if omitted).")
75
+ @click.option("--end-offset", type=int, default=None, help="End char offset (resolved from content if omitted).")
76
+ @click.option("--source-deliverable-id", type=int, default=None, help="Agent's source deliverable ID.")
77
+ @click.option("--parent-comment-id", type=int, default=None, help="Reply to comment ID.")
78
+ @click.option("--agent-run-id", type=int, default=None, help="Link comment to a build run.")
79
+ @click.pass_context
80
+ def create_comment(
81
+ ctx: click.Context,
82
+ deliverable_id: int,
83
+ text: str,
84
+ selected_text: str,
85
+ start_offset: int | None,
86
+ end_offset: int | None,
87
+ source_deliverable_id: int | None,
88
+ parent_comment_id: int | None,
89
+ agent_run_id: int | None,
90
+ ) -> None:
91
+ """Create a comment on a deliverable.
92
+
93
+ Only --text and --selected-text are required; anchor offsets are resolved
94
+ from the deliverable content automatically.
95
+ """
96
+ client = _make_client(ctx)
97
+ try:
98
+ start, end = _resolve_offsets(
99
+ client, deliverable_id, selected_text, start_offset, end_offset,
100
+ )
101
+ payload: dict = {
102
+ "comment_text": text,
103
+ "selected_text": selected_text,
104
+ "start_offset": start,
105
+ "end_offset": end,
106
+ "is_agent_comment": True,
107
+ }
108
+ if source_deliverable_id is not None:
109
+ payload["source_deliverable_id"] = source_deliverable_id
110
+ if parent_comment_id is not None:
111
+ payload["parent_comment_id"] = parent_comment_id
112
+ if agent_run_id is not None:
113
+ payload["agent_run_id"] = agent_run_id
114
+
115
+ data = client.create_comment(deliverable_id, payload)
116
+ finally:
117
+ client.close()
118
+ output(data, ctx.obj.get("fmt"))
119
+
120
+
121
+ @comments.command("resolve")
122
+ @click.argument("deliverable_id", type=int)
123
+ @click.argument("comment_id", type=int)
124
+ @click.pass_context
125
+ def resolve_comment(ctx: click.Context, deliverable_id: int, comment_id: int) -> None:
126
+ """Mark a comment as resolved."""
127
+ client = _make_client(ctx)
128
+ try:
129
+ data = client.resolve_comment(deliverable_id, comment_id)
130
+ finally:
131
+ client.close()
132
+ output(data, ctx.obj.get("fmt"))
133
+
134
+
135
+ @comments.command("delete")
136
+ @click.argument("deliverable_id", type=int)
137
+ @click.argument("comment_id", type=int)
138
+ @click.pass_context
139
+ def delete_comment(ctx: click.Context, deliverable_id: int, comment_id: int) -> None:
140
+ """Delete a comment."""
141
+ client = _make_client(ctx)
142
+ try:
143
+ client.delete_comment(deliverable_id, comment_id)
144
+ finally:
145
+ client.close()
146
+ click.echo("Comment deleted.")