expedait-cli 0.2.1__tar.gz → 0.2.2__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.
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/PKG-INFO +37 -37
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/README.md +36 -36
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/auth.py +30 -2
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/client.py +8 -4
- expedait_cli-0.2.2/expedait_cli/commands/init_cmd.py +77 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/pages.py +12 -6
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/projects.py +11 -5
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/main.py +2 -0
- expedait_cli-0.2.2/expedait_cli/settings.py +31 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/pyproject.toml +1 -1
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_auth.py +30 -3
- expedait_cli-0.2.2/tests/test_commands/test_init_cmd.py +161 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_pages.py +32 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_projects.py +32 -0
- expedait_cli-0.2.2/tests/test_settings.py +33 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/uv.lock +1 -1
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.github/workflows/publish.yml +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.gitignore +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/CLAUDE.md +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/LICENSE +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/__init__.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/__init__.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/auth_cmd.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/comments.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/config.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/formatters.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/__init__.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/conftest.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_client.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/__init__.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_auth_cmd.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_comments.py +0 -0
- {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expedait-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI for Expedait project management — download specs, post comments
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -11,58 +11,60 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
|
|
12
12
|
# Expedait CLI
|
|
13
13
|
|
|
14
|
+
[](https://pypi.org/project/expedait-cli/)
|
|
14
15
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
15
16
|
[](https://www.python.org/downloads/)
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
|
|
18
19
|
|
|
19
|
-
##
|
|
20
|
+
## Usage
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
# Install with uv (recommended)
|
|
23
|
-
uv pip install expedait-cli
|
|
22
|
+
### Run with `uvx` (recommended)
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
git clone https://github.com/Expedait/expedait-cli.git
|
|
27
|
-
cd expedait-cli
|
|
28
|
-
uv sync
|
|
24
|
+
No installation needed — run directly:
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
expedait auth login
|
|
26
|
+
```bash
|
|
27
|
+
uvx expedait-cli auth login
|
|
28
|
+
uvx expedait-cli projects list
|
|
29
|
+
uvx expedait-cli projects download 1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Add as a dev dependency
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
expedait projects list
|
|
34
|
+
If your AI agent needs it available in the project environment:
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
```bash
|
|
37
|
+
uv add --group dev expedait-cli
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
## Project Setup
|
|
43
|
+
|
|
44
|
+
After authenticating, run `init` inside your project directory to store your tenant and project settings locally:
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
|
-
|
|
47
|
+
uvx expedait-cli init
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
This creates `.expedait/settings.json` with your `tenant_id` and `project_id`. Add `.expedait/` to your `.gitignore`.
|
|
51
|
+
|
|
52
|
+
Once initialized, commands that need a project ID will resolve it automatically. Downloads default to `.expedait/context/`:
|
|
49
53
|
|
|
50
54
|
```bash
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
expedait projects download # downloads to .expedait/context/
|
|
56
|
+
expedait pages list # no --project-id needed
|
|
57
|
+
expedait pages download 42 # downloads to .expedait/context/
|
|
54
58
|
```
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
**Requirements:** Python 3.11+
|
|
60
|
+
**Resolution order for tenant/project:** CLI flag > env var > `.expedait/settings.json` > `~/.expedait/config.json`.
|
|
59
61
|
|
|
60
62
|
## Authentication
|
|
61
63
|
|
|
62
64
|
### Interactive login
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
|
-
expedait auth login
|
|
67
|
+
uvx expedait-cli auth login
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
Prompts for login method (SSO or email/password). Stores credentials in `~/.expedait/config.json`.
|
|
@@ -71,7 +73,7 @@ Prompts for login method (SSO or email/password). Stores credentials in `~/.expe
|
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
export EXPEDAIT_TOKEN="your-jwt-token"
|
|
74
|
-
export EXPEDAIT_API_URL="https://your-instance.expedait.
|
|
76
|
+
export EXPEDAIT_API_URL="https://your-instance.expedait.org"
|
|
75
77
|
export EXPEDAIT_TENANT_ID=1
|
|
76
78
|
```
|
|
77
79
|
|
|
@@ -92,8 +94,9 @@ expedait auth logout # Clear stored credentials
|
|
|
92
94
|
```bash
|
|
93
95
|
expedait projects list # List all projects
|
|
94
96
|
expedait projects get PROJECT_ID # Get project details
|
|
95
|
-
expedait projects download PROJECT_ID #
|
|
96
|
-
expedait projects download PROJECT_ID --
|
|
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
|
|
97
100
|
```
|
|
98
101
|
|
|
99
102
|
### Pages
|
|
@@ -102,7 +105,8 @@ expedait projects download PROJECT_ID --output-dir ./specs # Extract to directo
|
|
|
102
105
|
expedait pages list --project-id PROJECT_ID # List pages in a project
|
|
103
106
|
expedait pages get PAGE_ID # Print page markdown content
|
|
104
107
|
expedait pages full PAGE_ID # Full context (content + comments + deps)
|
|
105
|
-
expedait pages download PAGE_ID
|
|
108
|
+
expedait pages download PAGE_ID # Extract markdown to .expedait/context/
|
|
109
|
+
expedait pages download PAGE_ID --download-format json # Download as JSON
|
|
106
110
|
```
|
|
107
111
|
|
|
108
112
|
### Comments
|
|
@@ -119,7 +123,7 @@ expedait comments resolve PAGE_ID COMMENT_ID # Mark as resolved
|
|
|
119
123
|
expedait comments delete PAGE_ID COMMENT_ID # Delete a comment
|
|
120
124
|
```
|
|
121
125
|
|
|
122
|
-
|
|
126
|
+
### Global Options
|
|
123
127
|
|
|
124
128
|
```bash
|
|
125
129
|
expedait --api-url https://host:8000 ... # Override API URL
|
|
@@ -144,10 +148,6 @@ uv sync --group dev
|
|
|
144
148
|
uv run python -m pytest
|
|
145
149
|
```
|
|
146
150
|
|
|
147
|
-
## Contributing
|
|
148
|
-
|
|
149
|
-
Contributions are welcome! Please open an issue or submit a pull request.
|
|
150
|
-
|
|
151
151
|
## License
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
[Apache License 2.0](LICENSE)
|
|
@@ -1,57 +1,59 @@
|
|
|
1
1
|
# Expedait CLI
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/expedait-cli/)
|
|
3
4
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
4
5
|
[](https://www.python.org/downloads/)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
|
|
7
8
|
|
|
8
|
-
##
|
|
9
|
+
## Usage
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
# Install with uv (recommended)
|
|
12
|
-
uv pip install expedait-cli
|
|
11
|
+
### Run with `uvx` (recommended)
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
git clone https://github.com/Expedait/expedait-cli.git
|
|
16
|
-
cd expedait-cli
|
|
17
|
-
uv sync
|
|
13
|
+
No installation needed — run directly:
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
expedait auth login
|
|
15
|
+
```bash
|
|
16
|
+
uvx expedait-cli auth login
|
|
17
|
+
uvx expedait-cli projects list
|
|
18
|
+
uvx expedait-cli projects download 1
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Add as a dev dependency
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
expedait projects list
|
|
23
|
+
If your AI agent needs it available in the project environment:
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
```bash
|
|
26
|
+
uv add --group dev expedait-cli
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
## Project Setup
|
|
32
|
+
|
|
33
|
+
After authenticating, run `init` inside your project directory to store your tenant and project settings locally:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
|
|
36
|
+
uvx expedait-cli init
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
This creates `.expedait/settings.json` with your `tenant_id` and `project_id`. Add `.expedait/` to your `.gitignore`.
|
|
40
|
+
|
|
41
|
+
Once initialized, commands that need a project ID will resolve it automatically. Downloads default to `.expedait/context/`:
|
|
38
42
|
|
|
39
43
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
expedait projects download # downloads to .expedait/context/
|
|
45
|
+
expedait pages list # no --project-id needed
|
|
46
|
+
expedait pages download 42 # downloads to .expedait/context/
|
|
43
47
|
```
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
**Requirements:** Python 3.11+
|
|
49
|
+
**Resolution order for tenant/project:** CLI flag > env var > `.expedait/settings.json` > `~/.expedait/config.json`.
|
|
48
50
|
|
|
49
51
|
## Authentication
|
|
50
52
|
|
|
51
53
|
### Interactive login
|
|
52
54
|
|
|
53
55
|
```bash
|
|
54
|
-
expedait auth login
|
|
56
|
+
uvx expedait-cli auth login
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
Prompts for login method (SSO or email/password). Stores credentials in `~/.expedait/config.json`.
|
|
@@ -60,7 +62,7 @@ Prompts for login method (SSO or email/password). Stores credentials in `~/.expe
|
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
64
|
export EXPEDAIT_TOKEN="your-jwt-token"
|
|
63
|
-
export EXPEDAIT_API_URL="https://your-instance.expedait.
|
|
65
|
+
export EXPEDAIT_API_URL="https://your-instance.expedait.org"
|
|
64
66
|
export EXPEDAIT_TENANT_ID=1
|
|
65
67
|
```
|
|
66
68
|
|
|
@@ -81,8 +83,9 @@ expedait auth logout # Clear stored credentials
|
|
|
81
83
|
```bash
|
|
82
84
|
expedait projects list # List all projects
|
|
83
85
|
expedait projects get PROJECT_ID # Get project details
|
|
84
|
-
expedait projects download PROJECT_ID #
|
|
85
|
-
expedait projects download PROJECT_ID --
|
|
86
|
+
expedait projects download PROJECT_ID # Extract markdown to .expedait/context/
|
|
87
|
+
expedait projects download PROJECT_ID --download-format json # Download as JSON
|
|
88
|
+
expedait projects download PROJECT_ID --output-dir ./specs # Extract to custom directory
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
### Pages
|
|
@@ -91,7 +94,8 @@ expedait projects download PROJECT_ID --output-dir ./specs # Extract to directo
|
|
|
91
94
|
expedait pages list --project-id PROJECT_ID # List pages in a project
|
|
92
95
|
expedait pages get PAGE_ID # Print page markdown content
|
|
93
96
|
expedait pages full PAGE_ID # Full context (content + comments + deps)
|
|
94
|
-
expedait pages download PAGE_ID
|
|
97
|
+
expedait pages download PAGE_ID # Extract markdown to .expedait/context/
|
|
98
|
+
expedait pages download PAGE_ID --download-format json # Download as JSON
|
|
95
99
|
```
|
|
96
100
|
|
|
97
101
|
### Comments
|
|
@@ -108,7 +112,7 @@ expedait comments resolve PAGE_ID COMMENT_ID # Mark as resolved
|
|
|
108
112
|
expedait comments delete PAGE_ID COMMENT_ID # Delete a comment
|
|
109
113
|
```
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
### Global Options
|
|
112
116
|
|
|
113
117
|
```bash
|
|
114
118
|
expedait --api-url https://host:8000 ... # Override API URL
|
|
@@ -133,10 +137,6 @@ uv sync --group dev
|
|
|
133
137
|
uv run python -m pytest
|
|
134
138
|
```
|
|
135
139
|
|
|
136
|
-
## Contributing
|
|
137
|
-
|
|
138
|
-
Contributions are welcome! Please open an issue or submit a pull request.
|
|
139
|
-
|
|
140
140
|
## License
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
[Apache License 2.0](LICENSE)
|
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
9
|
from .config import load_config
|
|
10
|
+
from .settings import load_settings
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def resolve_token(config_path=None) -> str:
|
|
@@ -41,15 +42,42 @@ def resolve_api_url(explicit: str | None = None, config_path=None) -> str:
|
|
|
41
42
|
return "https://app.expedait.org"
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def resolve_tenant_id(
|
|
45
|
-
|
|
45
|
+
def resolve_tenant_id(
|
|
46
|
+
explicit: int | None = None,
|
|
47
|
+
config_path=None,
|
|
48
|
+
settings_path=None,
|
|
49
|
+
) -> int | None:
|
|
50
|
+
"""Return tenant ID from flag > env > local settings > config."""
|
|
46
51
|
if explicit is not None:
|
|
47
52
|
return explicit
|
|
48
53
|
env = os.environ.get("EXPEDAIT_TENANT_ID")
|
|
49
54
|
if env:
|
|
50
55
|
return int(env)
|
|
56
|
+
# Local project settings
|
|
57
|
+
settings = load_settings(settings_path)
|
|
58
|
+
tid = settings.get("tenant_id")
|
|
59
|
+
if tid is not None:
|
|
60
|
+
return int(tid)
|
|
61
|
+
# Global config
|
|
51
62
|
cfg = load_config(config_path)
|
|
52
63
|
tid = cfg.get("tenant_id")
|
|
53
64
|
if tid is not None:
|
|
54
65
|
return int(tid)
|
|
55
66
|
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_project_id(
|
|
70
|
+
explicit: int | None = None,
|
|
71
|
+
settings_path=None,
|
|
72
|
+
) -> int | None:
|
|
73
|
+
"""Return project ID from flag > env > local settings."""
|
|
74
|
+
if explicit is not None:
|
|
75
|
+
return explicit
|
|
76
|
+
env = os.environ.get("EXPEDAIT_PROJECT_ID")
|
|
77
|
+
if env:
|
|
78
|
+
return int(env)
|
|
79
|
+
settings = load_settings(settings_path)
|
|
80
|
+
pid = settings.get("project_id")
|
|
81
|
+
if pid is not None:
|
|
82
|
+
return int(pid)
|
|
83
|
+
return None
|
|
@@ -96,8 +96,10 @@ class ExpedaitClient:
|
|
|
96
96
|
def get_workspace(self, project_id: int) -> dict[str, Any]:
|
|
97
97
|
return self._request("GET", f"/api/v1/projects/{project_id}/workspace")
|
|
98
98
|
|
|
99
|
-
def download_project(self, project_id: int) -> bytes:
|
|
100
|
-
resp = self._request_raw(
|
|
99
|
+
def download_project(self, project_id: int, fmt: str = "markdown") -> bytes:
|
|
100
|
+
resp = self._request_raw(
|
|
101
|
+
"GET", f"/api/v1/projects/{project_id}/download", params={"format": fmt},
|
|
102
|
+
)
|
|
101
103
|
return resp.content
|
|
102
104
|
|
|
103
105
|
# -- pages ------------------------------------------------------------
|
|
@@ -111,8 +113,10 @@ class ExpedaitClient:
|
|
|
111
113
|
def get_page_full(self, page_id: int) -> dict[str, Any]:
|
|
112
114
|
return self._request("GET", f"/api/v1/pages/{page_id}/full")
|
|
113
115
|
|
|
114
|
-
def download_page(self, page_id: int) -> bytes:
|
|
115
|
-
resp = self._request_raw(
|
|
116
|
+
def download_page(self, page_id: int, fmt: str = "markdown") -> bytes:
|
|
117
|
+
resp = self._request_raw(
|
|
118
|
+
"GET", f"/api/v1/pages/{page_id}/download", params={"format": fmt},
|
|
119
|
+
)
|
|
116
120
|
return resp.content
|
|
117
121
|
|
|
118
122
|
# -- comments ---------------------------------------------------------
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Init command: configure project-local settings."""
|
|
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 ..settings import save_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def init(ctx: click.Context) -> None:
|
|
15
|
+
"""Initialize Expedait settings for this project directory.
|
|
16
|
+
|
|
17
|
+
Requires authentication. Prompts for tenant and project selection,
|
|
18
|
+
then writes .expedait/settings.json in the current directory.
|
|
19
|
+
"""
|
|
20
|
+
# Verify auth
|
|
21
|
+
token = resolve_token()
|
|
22
|
+
api_url = resolve_api_url(ctx.obj.get("api_url"))
|
|
23
|
+
|
|
24
|
+
client = ExpedaitClient(api_url, token)
|
|
25
|
+
try:
|
|
26
|
+
me = client.get_me()
|
|
27
|
+
except click.UsageError:
|
|
28
|
+
raise click.UsageError(
|
|
29
|
+
"Authentication failed. Run 'expedait auth login' first."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Select tenant
|
|
33
|
+
memberships = me.get("tenant_memberships", [])
|
|
34
|
+
explicit_tenant = ctx.obj.get("tenant_id")
|
|
35
|
+
|
|
36
|
+
if explicit_tenant is not None:
|
|
37
|
+
tenant_id = explicit_tenant
|
|
38
|
+
elif len(memberships) == 1:
|
|
39
|
+
tenant_id = memberships[0]["tenant_id"]
|
|
40
|
+
click.echo(f"Using tenant: {memberships[0].get('tenant_name', tenant_id)}")
|
|
41
|
+
elif len(memberships) > 1:
|
|
42
|
+
click.echo("Available tenants:")
|
|
43
|
+
for m in memberships:
|
|
44
|
+
click.echo(f" [{m['tenant_id']}] {m.get('tenant_name', 'Unknown')} ({m['role']})")
|
|
45
|
+
tenant_id = click.prompt("Select tenant ID", type=int)
|
|
46
|
+
else:
|
|
47
|
+
raise click.UsageError("No tenant memberships found for this user.")
|
|
48
|
+
|
|
49
|
+
# Fetch projects for selected tenant
|
|
50
|
+
tenant_client = ExpedaitClient(api_url, token, tenant_id)
|
|
51
|
+
try:
|
|
52
|
+
projects = tenant_client.list_projects()
|
|
53
|
+
finally:
|
|
54
|
+
tenant_client.close()
|
|
55
|
+
|
|
56
|
+
if not projects:
|
|
57
|
+
raise click.UsageError("No projects found in this tenant.")
|
|
58
|
+
|
|
59
|
+
if len(projects) == 1:
|
|
60
|
+
project = projects[0]
|
|
61
|
+
click.echo(f"Using project: {project['name']}")
|
|
62
|
+
else:
|
|
63
|
+
click.echo("Available projects:")
|
|
64
|
+
for p in projects:
|
|
65
|
+
click.echo(f" [{p['id']}] {p['name']}")
|
|
66
|
+
project_id = click.prompt("Select project ID", type=int)
|
|
67
|
+
project = next((p for p in projects if p["id"] == project_id), None)
|
|
68
|
+
if project is None:
|
|
69
|
+
raise click.UsageError(f"Project {project_id} not found in list.")
|
|
70
|
+
|
|
71
|
+
save_settings({
|
|
72
|
+
"tenant_id": tenant_id,
|
|
73
|
+
"project_id": project["id"],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
click.echo(f"Saved .expedait/settings.json (tenant={tenant_id}, project={project['id']}).")
|
|
77
|
+
client.close()
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
11
|
-
from ..auth import resolve_api_url, resolve_tenant_id, resolve_token
|
|
11
|
+
from ..auth import resolve_api_url, resolve_project_id, resolve_tenant_id, resolve_token
|
|
12
12
|
from ..client import ExpedaitClient
|
|
13
13
|
from ..formatters import output
|
|
14
14
|
|
|
@@ -26,10 +26,15 @@ def pages() -> None:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@pages.command("list")
|
|
29
|
-
@click.option("--project-id", required=
|
|
29
|
+
@click.option("--project-id", required=False, type=int, default=None, help="Project ID to list pages for.")
|
|
30
30
|
@click.pass_context
|
|
31
|
-
def list_pages(ctx: click.Context, project_id: int) -> None:
|
|
31
|
+
def list_pages(ctx: click.Context, project_id: int | None) -> None:
|
|
32
32
|
"""List pages in a project."""
|
|
33
|
+
project_id = resolve_project_id(project_id)
|
|
34
|
+
if project_id is None:
|
|
35
|
+
raise click.UsageError(
|
|
36
|
+
"No project ID given. Pass --project-id or run 'expedait init'."
|
|
37
|
+
)
|
|
33
38
|
client = _make_client(ctx)
|
|
34
39
|
try:
|
|
35
40
|
data = client.list_pages(project_id)
|
|
@@ -80,13 +85,14 @@ def full_page(ctx: click.Context, page_id: int) -> None:
|
|
|
80
85
|
|
|
81
86
|
@pages.command("download")
|
|
82
87
|
@click.argument("page_id", type=int)
|
|
83
|
-
@click.option("--output-dir", type=click.Path(), default=".", help="Extract to directory.")
|
|
88
|
+
@click.option("--output-dir", type=click.Path(), default=".expedait/context", help="Extract to directory.")
|
|
89
|
+
@click.option("--download-format", type=click.Choice(["markdown", "json"]), default="markdown", help="Content format.")
|
|
84
90
|
@click.pass_context
|
|
85
|
-
def download_page(ctx: click.Context, page_id: int, output_dir: str) -> None:
|
|
91
|
+
def download_page(ctx: click.Context, page_id: int, output_dir: str, download_format: str) -> None:
|
|
86
92
|
"""Download page as ZIP and extract."""
|
|
87
93
|
client = _make_client(ctx)
|
|
88
94
|
try:
|
|
89
|
-
data = client.download_page(page_id)
|
|
95
|
+
data = client.download_page(page_id, fmt=download_format)
|
|
90
96
|
finally:
|
|
91
97
|
client.close()
|
|
92
98
|
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
11
|
-
from ..auth import resolve_api_url, resolve_tenant_id, resolve_token
|
|
11
|
+
from ..auth import resolve_api_url, resolve_project_id, resolve_tenant_id, resolve_token
|
|
12
12
|
from ..client import ExpedaitClient
|
|
13
13
|
from ..formatters import output
|
|
14
14
|
|
|
@@ -60,14 +60,20 @@ def get_project(ctx: click.Context, project_id: int) -> None:
|
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
@projects.command("download")
|
|
63
|
-
@click.argument("project_id", type=int)
|
|
64
|
-
@click.option("--output-dir", type=click.Path(), default=".", help="Extract to directory.")
|
|
63
|
+
@click.argument("project_id", type=int, required=False, default=None)
|
|
64
|
+
@click.option("--output-dir", type=click.Path(), default=".expedait/context", help="Extract to directory.")
|
|
65
|
+
@click.option("--download-format", type=click.Choice(["markdown", "json"]), default="markdown", help="Content format.")
|
|
65
66
|
@click.pass_context
|
|
66
|
-
def download_project(ctx: click.Context, project_id: int, output_dir: str) -> None:
|
|
67
|
+
def download_project(ctx: click.Context, project_id: int | None, output_dir: str, download_format: str) -> None:
|
|
67
68
|
"""Download all project pages as a ZIP and extract."""
|
|
69
|
+
project_id = resolve_project_id(project_id)
|
|
70
|
+
if project_id is None:
|
|
71
|
+
raise click.UsageError(
|
|
72
|
+
"No project ID given. Pass PROJECT_ID or run 'expedait init'."
|
|
73
|
+
)
|
|
68
74
|
client = _make_client(ctx)
|
|
69
75
|
try:
|
|
70
|
-
data = client.download_project(project_id)
|
|
76
|
+
data = client.download_project(project_id, fmt=download_format)
|
|
71
77
|
finally:
|
|
72
78
|
client.close()
|
|
73
79
|
|
|
@@ -6,6 +6,7 @@ import click
|
|
|
6
6
|
|
|
7
7
|
from . import __version__
|
|
8
8
|
from .commands.auth_cmd import auth
|
|
9
|
+
from .commands.init_cmd import init
|
|
9
10
|
from .commands.projects import projects
|
|
10
11
|
from .commands.pages import pages
|
|
11
12
|
from .commands.comments import comments
|
|
@@ -26,6 +27,7 @@ def cli(ctx: click.Context, api_url: str | None, tenant_id: int | None, fmt: str
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
cli.add_command(auth)
|
|
30
|
+
cli.add_command(init)
|
|
29
31
|
cli.add_command(projects)
|
|
30
32
|
cli.add_command(pages)
|
|
31
33
|
cli.add_command(comments)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Project-local settings stored in .expedait/settings.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SETTINGS_DIR = ".expedait"
|
|
11
|
+
SETTINGS_FILE = "settings.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_settings_path() -> Path:
|
|
15
|
+
"""Return .expedait/settings.json relative to cwd."""
|
|
16
|
+
return Path.cwd() / SETTINGS_DIR / SETTINGS_FILE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_settings(path: Path | None = None) -> dict[str, Any]:
|
|
20
|
+
"""Load project-local settings. Returns empty dict if missing."""
|
|
21
|
+
p = path or _find_settings_path()
|
|
22
|
+
if not p.exists():
|
|
23
|
+
return {}
|
|
24
|
+
return json.loads(p.read_text())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_settings(data: dict[str, Any], path: Path | None = None) -> None:
|
|
28
|
+
"""Write project-local settings, creating .expedait/ if needed."""
|
|
29
|
+
p = path or _find_settings_path()
|
|
30
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
p.write_text(json.dumps(data, indent=2) + "\n")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for token resolution logic."""
|
|
1
|
+
"""Tests for token and ID resolution logic."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
import click
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from expedait_cli.auth import resolve_token, resolve_api_url, resolve_tenant_id
|
|
9
|
+
from expedait_cli.auth import resolve_token, resolve_api_url, resolve_tenant_id, resolve_project_id
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class TestResolveToken:
|
|
@@ -48,8 +48,35 @@ class TestResolveTenantId:
|
|
|
48
48
|
monkeypatch.setenv("EXPEDAIT_TENANT_ID", "7")
|
|
49
49
|
assert resolve_tenant_id(None, tmp_path / "missing.json") == 7
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def test_local_settings_before_config(self, tmp_path: Path):
|
|
52
|
+
"""Local .expedait/settings.json takes priority over global config."""
|
|
53
|
+
settings = tmp_path / "settings.json"
|
|
54
|
+
settings.write_text(json.dumps({"tenant_id": 99}))
|
|
55
|
+
config = tmp_path / "config.json"
|
|
56
|
+
config.write_text(json.dumps({"tenant_id": 1}))
|
|
57
|
+
assert resolve_tenant_id(None, config, settings) == 99
|
|
58
|
+
|
|
59
|
+
def test_config_file_fallback(self, saved_config: Path):
|
|
52
60
|
assert resolve_tenant_id(None, saved_config) == 1
|
|
53
61
|
|
|
54
62
|
def test_none_when_missing(self, tmp_path: Path):
|
|
55
63
|
assert resolve_tenant_id(None, tmp_path / "missing.json") is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestResolveProjectId:
|
|
67
|
+
def test_explicit_wins(self, tmp_path: Path):
|
|
68
|
+
settings = tmp_path / "settings.json"
|
|
69
|
+
settings.write_text(json.dumps({"project_id": 5}))
|
|
70
|
+
assert resolve_project_id(42, settings) == 42
|
|
71
|
+
|
|
72
|
+
def test_env_var(self, monkeypatch, tmp_path: Path):
|
|
73
|
+
monkeypatch.setenv("EXPEDAIT_PROJECT_ID", "8")
|
|
74
|
+
assert resolve_project_id(None, tmp_path / "missing.json") == 8
|
|
75
|
+
|
|
76
|
+
def test_local_settings(self, tmp_path: Path):
|
|
77
|
+
settings = tmp_path / "settings.json"
|
|
78
|
+
settings.write_text(json.dumps({"project_id": 5}))
|
|
79
|
+
assert resolve_project_id(None, settings) == 5
|
|
80
|
+
|
|
81
|
+
def test_none_when_missing(self, tmp_path: Path):
|
|
82
|
+
assert resolve_project_id(None, tmp_path / "missing.json") is None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for the init command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from expedait_cli.main import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _mock_client(me_response, projects_response):
|
|
15
|
+
"""Return a mock ExpedaitClient class that returns given responses."""
|
|
16
|
+
instance = MagicMock()
|
|
17
|
+
instance.get_me.return_value = me_response
|
|
18
|
+
instance.list_projects.return_value = projects_response
|
|
19
|
+
instance.close = MagicMock()
|
|
20
|
+
return MagicMock(return_value=instance)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ME_SINGLE_TENANT = {
|
|
24
|
+
"id": 1,
|
|
25
|
+
"email": "dev@test.com",
|
|
26
|
+
"tenant_memberships": [
|
|
27
|
+
{"tenant_id": 10, "tenant_name": "Acme Corp", "role": "admin"},
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ME_MULTI_TENANT = {
|
|
32
|
+
"id": 1,
|
|
33
|
+
"email": "dev@test.com",
|
|
34
|
+
"tenant_memberships": [
|
|
35
|
+
{"tenant_id": 10, "tenant_name": "Acme Corp", "role": "admin"},
|
|
36
|
+
{"tenant_id": 20, "tenant_name": "Other Inc", "role": "member"},
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
PROJECTS = [
|
|
41
|
+
{"id": 1, "name": "Project Alpha"},
|
|
42
|
+
{"id": 2, "name": "Project Beta"},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestInit:
|
|
47
|
+
def test_single_tenant_single_project(self, tmp_path: Path):
|
|
48
|
+
"""Auto-selects tenant and project when only one of each."""
|
|
49
|
+
mock_cls = _mock_client(ME_SINGLE_TENANT, [PROJECTS[0]])
|
|
50
|
+
settings_file = tmp_path / ".expedait" / "settings.json"
|
|
51
|
+
|
|
52
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
53
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
54
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls), \
|
|
55
|
+
patch("expedait_cli.commands.init_cmd.save_settings") as mock_save:
|
|
56
|
+
|
|
57
|
+
runner = CliRunner()
|
|
58
|
+
result = runner.invoke(cli, ["init"])
|
|
59
|
+
|
|
60
|
+
assert result.exit_code == 0
|
|
61
|
+
assert "Project Alpha" in result.output
|
|
62
|
+
mock_save.assert_called_once_with({
|
|
63
|
+
"tenant_id": 10,
|
|
64
|
+
"project_id": 1,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
def test_multi_tenant_prompts(self, tmp_path: Path):
|
|
68
|
+
"""Prompts for tenant selection when multiple are available."""
|
|
69
|
+
mock_cls = _mock_client(ME_MULTI_TENANT, [PROJECTS[0]])
|
|
70
|
+
|
|
71
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
72
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
73
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls), \
|
|
74
|
+
patch("expedait_cli.commands.init_cmd.save_settings") as mock_save:
|
|
75
|
+
|
|
76
|
+
runner = CliRunner()
|
|
77
|
+
result = runner.invoke(cli, ["init"], input="10\n")
|
|
78
|
+
|
|
79
|
+
assert result.exit_code == 0
|
|
80
|
+
assert "Acme Corp" in result.output
|
|
81
|
+
assert "Other Inc" in result.output
|
|
82
|
+
mock_save.assert_called_once()
|
|
83
|
+
saved = mock_save.call_args[0][0]
|
|
84
|
+
assert saved["tenant_id"] == 10
|
|
85
|
+
|
|
86
|
+
def test_multi_project_prompts(self, tmp_path: Path):
|
|
87
|
+
"""Prompts for project selection when multiple are available."""
|
|
88
|
+
mock_cls = _mock_client(ME_SINGLE_TENANT, PROJECTS)
|
|
89
|
+
|
|
90
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
91
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
92
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls), \
|
|
93
|
+
patch("expedait_cli.commands.init_cmd.save_settings") as mock_save:
|
|
94
|
+
|
|
95
|
+
runner = CliRunner()
|
|
96
|
+
result = runner.invoke(cli, ["init"], input="2\n")
|
|
97
|
+
|
|
98
|
+
assert result.exit_code == 0
|
|
99
|
+
assert "Project Alpha" in result.output
|
|
100
|
+
assert "Project Beta" in result.output
|
|
101
|
+
mock_save.assert_called_once()
|
|
102
|
+
saved = mock_save.call_args[0][0]
|
|
103
|
+
assert saved["project_id"] == 2
|
|
104
|
+
|
|
105
|
+
def test_explicit_tenant_id_skips_prompt(self):
|
|
106
|
+
"""--tenant-id flag bypasses tenant selection."""
|
|
107
|
+
mock_cls = _mock_client(ME_MULTI_TENANT, [PROJECTS[0]])
|
|
108
|
+
|
|
109
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
110
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
111
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls), \
|
|
112
|
+
patch("expedait_cli.commands.init_cmd.save_settings") as mock_save:
|
|
113
|
+
|
|
114
|
+
runner = CliRunner()
|
|
115
|
+
result = runner.invoke(cli, ["--tenant-id", "20", "init"])
|
|
116
|
+
|
|
117
|
+
assert result.exit_code == 0
|
|
118
|
+
# Should not show tenant selection prompt
|
|
119
|
+
assert "Select tenant" not in result.output
|
|
120
|
+
saved = mock_save.call_args[0][0]
|
|
121
|
+
assert saved["tenant_id"] == 20
|
|
122
|
+
|
|
123
|
+
def test_not_authenticated_fails(self):
|
|
124
|
+
"""Fails gracefully when not logged in."""
|
|
125
|
+
import click
|
|
126
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token",
|
|
127
|
+
side_effect=click.UsageError("Not authenticated")):
|
|
128
|
+
|
|
129
|
+
runner = CliRunner()
|
|
130
|
+
result = runner.invoke(cli, ["init"])
|
|
131
|
+
|
|
132
|
+
assert result.exit_code != 0
|
|
133
|
+
assert "Not authenticated" in result.output
|
|
134
|
+
|
|
135
|
+
def test_no_projects_fails(self):
|
|
136
|
+
"""Fails when tenant has no projects."""
|
|
137
|
+
mock_cls = _mock_client(ME_SINGLE_TENANT, [])
|
|
138
|
+
|
|
139
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
140
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
141
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls):
|
|
142
|
+
|
|
143
|
+
runner = CliRunner()
|
|
144
|
+
result = runner.invoke(cli, ["init"])
|
|
145
|
+
|
|
146
|
+
assert result.exit_code != 0
|
|
147
|
+
assert "No projects found" in result.output
|
|
148
|
+
|
|
149
|
+
def test_invalid_project_id_fails(self):
|
|
150
|
+
"""Fails when user enters a project ID not in the list."""
|
|
151
|
+
mock_cls = _mock_client(ME_SINGLE_TENANT, PROJECTS)
|
|
152
|
+
|
|
153
|
+
with patch("expedait_cli.commands.init_cmd.resolve_token", return_value="tok"), \
|
|
154
|
+
patch("expedait_cli.commands.init_cmd.resolve_api_url", return_value="http://x"), \
|
|
155
|
+
patch("expedait_cli.commands.init_cmd.ExpedaitClient", mock_cls):
|
|
156
|
+
|
|
157
|
+
runner = CliRunner()
|
|
158
|
+
result = runner.invoke(cli, ["init"], input="999\n")
|
|
159
|
+
|
|
160
|
+
assert result.exit_code != 0
|
|
161
|
+
assert "not found" in result.output
|
|
@@ -119,3 +119,35 @@ class TestPagesDownload:
|
|
|
119
119
|
|
|
120
120
|
assert result.exit_code == 0
|
|
121
121
|
assert (Path(dest) / "vision.md").read_text() == "# Vision"
|
|
122
|
+
|
|
123
|
+
def test_default_format_is_markdown(self, tmp_path: Path):
|
|
124
|
+
zip_bytes = _make_zip({"vision.md": "# Vision"})
|
|
125
|
+
mock_download = MagicMock(return_value=zip_bytes)
|
|
126
|
+
mock_instance = MagicMock(download_page=mock_download, close=MagicMock())
|
|
127
|
+
with patch("expedait_cli.commands.pages.resolve_token", return_value="tok"), \
|
|
128
|
+
patch("expedait_cli.commands.pages.resolve_api_url", return_value="http://x"), \
|
|
129
|
+
patch("expedait_cli.commands.pages.resolve_tenant_id", return_value=1), \
|
|
130
|
+
patch("expedait_cli.commands.pages.ExpedaitClient", return_value=mock_instance):
|
|
131
|
+
|
|
132
|
+
runner = CliRunner()
|
|
133
|
+
dest = str(tmp_path / "out")
|
|
134
|
+
result = runner.invoke(cli, ["pages", "download", "1", "--output-dir", dest])
|
|
135
|
+
|
|
136
|
+
assert result.exit_code == 0
|
|
137
|
+
mock_download.assert_called_once_with(1, fmt="markdown")
|
|
138
|
+
|
|
139
|
+
def test_json_format(self, tmp_path: Path):
|
|
140
|
+
zip_bytes = _make_zip({"vision.json": '{"title": "Vision"}'})
|
|
141
|
+
mock_download = MagicMock(return_value=zip_bytes)
|
|
142
|
+
mock_instance = MagicMock(download_page=mock_download, close=MagicMock())
|
|
143
|
+
with patch("expedait_cli.commands.pages.resolve_token", return_value="tok"), \
|
|
144
|
+
patch("expedait_cli.commands.pages.resolve_api_url", return_value="http://x"), \
|
|
145
|
+
patch("expedait_cli.commands.pages.resolve_tenant_id", return_value=1), \
|
|
146
|
+
patch("expedait_cli.commands.pages.ExpedaitClient", return_value=mock_instance):
|
|
147
|
+
|
|
148
|
+
runner = CliRunner()
|
|
149
|
+
dest = str(tmp_path / "out")
|
|
150
|
+
result = runner.invoke(cli, ["pages", "download", "1", "--output-dir", dest, "--download-format", "json"])
|
|
151
|
+
|
|
152
|
+
assert result.exit_code == 0
|
|
153
|
+
mock_download.assert_called_once_with(1, fmt="json")
|
|
@@ -89,3 +89,35 @@ class TestProjectsDownload:
|
|
|
89
89
|
assert result.exit_code == 0
|
|
90
90
|
assert (Path(dest) / "page1.md").read_text() == "# Hello"
|
|
91
91
|
assert (Path(dest) / "page2.md").read_text() == "# World"
|
|
92
|
+
|
|
93
|
+
def test_default_format_is_markdown(self, tmp_path: Path):
|
|
94
|
+
zip_bytes = _make_zip({"page.md": "# Hello"})
|
|
95
|
+
mock_download = MagicMock(return_value=zip_bytes)
|
|
96
|
+
mock_instance = MagicMock(download_project=mock_download, close=MagicMock())
|
|
97
|
+
with patch("expedait_cli.commands.projects.resolve_token", return_value="tok"), \
|
|
98
|
+
patch("expedait_cli.commands.projects.resolve_api_url", return_value="http://x"), \
|
|
99
|
+
patch("expedait_cli.commands.projects.resolve_tenant_id", return_value=1), \
|
|
100
|
+
patch("expedait_cli.commands.projects.ExpedaitClient", return_value=mock_instance):
|
|
101
|
+
|
|
102
|
+
runner = CliRunner()
|
|
103
|
+
dest = str(tmp_path / "out")
|
|
104
|
+
result = runner.invoke(cli, ["projects", "download", "1", "--output-dir", dest])
|
|
105
|
+
|
|
106
|
+
assert result.exit_code == 0
|
|
107
|
+
mock_download.assert_called_once_with(1, fmt="markdown")
|
|
108
|
+
|
|
109
|
+
def test_json_format(self, tmp_path: Path):
|
|
110
|
+
zip_bytes = _make_zip({"page.json": '{"title": "Hello"}'})
|
|
111
|
+
mock_download = MagicMock(return_value=zip_bytes)
|
|
112
|
+
mock_instance = MagicMock(download_project=mock_download, close=MagicMock())
|
|
113
|
+
with patch("expedait_cli.commands.projects.resolve_token", return_value="tok"), \
|
|
114
|
+
patch("expedait_cli.commands.projects.resolve_api_url", return_value="http://x"), \
|
|
115
|
+
patch("expedait_cli.commands.projects.resolve_tenant_id", return_value=1), \
|
|
116
|
+
patch("expedait_cli.commands.projects.ExpedaitClient", return_value=mock_instance):
|
|
117
|
+
|
|
118
|
+
runner = CliRunner()
|
|
119
|
+
dest = str(tmp_path / "out")
|
|
120
|
+
result = runner.invoke(cli, ["projects", "download", "1", "--output-dir", dest, "--download-format", "json"])
|
|
121
|
+
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
mock_download.assert_called_once_with(1, fmt="json")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for project-local settings."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from expedait_cli.settings import load_settings, save_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestLoadSettings:
|
|
10
|
+
def test_missing_file_returns_empty(self, tmp_path: Path):
|
|
11
|
+
assert load_settings(tmp_path / "nope.json") == {}
|
|
12
|
+
|
|
13
|
+
def test_reads_valid_file(self, tmp_path: Path):
|
|
14
|
+
p = tmp_path / ".expedait" / "settings.json"
|
|
15
|
+
p.parent.mkdir()
|
|
16
|
+
p.write_text(json.dumps({"tenant_id": 3, "project_id": 7}))
|
|
17
|
+
assert load_settings(p) == {"tenant_id": 3, "project_id": 7}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestSaveSettings:
|
|
21
|
+
def test_creates_file_and_dir(self, tmp_path: Path):
|
|
22
|
+
p = tmp_path / ".expedait" / "settings.json"
|
|
23
|
+
save_settings({"tenant_id": 1, "project_id": 2}, p)
|
|
24
|
+
assert p.exists()
|
|
25
|
+
data = json.loads(p.read_text())
|
|
26
|
+
assert data["tenant_id"] == 1
|
|
27
|
+
assert data["project_id"] == 2
|
|
28
|
+
|
|
29
|
+
def test_overwrites_existing(self, tmp_path: Path):
|
|
30
|
+
p = tmp_path / ".expedait" / "settings.json"
|
|
31
|
+
save_settings({"project_id": 1}, p)
|
|
32
|
+
save_settings({"project_id": 2}, p)
|
|
33
|
+
assert json.loads(p.read_text())["project_id"] == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|