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.
Files changed (34) hide show
  1. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/PKG-INFO +37 -37
  2. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/README.md +36 -36
  3. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/auth.py +30 -2
  4. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/client.py +8 -4
  5. expedait_cli-0.2.2/expedait_cli/commands/init_cmd.py +77 -0
  6. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/pages.py +12 -6
  7. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/projects.py +11 -5
  8. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/main.py +2 -0
  9. expedait_cli-0.2.2/expedait_cli/settings.py +31 -0
  10. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/pyproject.toml +1 -1
  11. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_auth.py +30 -3
  12. expedait_cli-0.2.2/tests/test_commands/test_init_cmd.py +161 -0
  13. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_pages.py +32 -0
  14. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_projects.py +32 -0
  15. expedait_cli-0.2.2/tests/test_settings.py +33 -0
  16. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/uv.lock +1 -1
  17. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  18. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  19. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/.gitignore +0 -0
  20. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/CLAUDE.md +0 -0
  21. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/LICENSE +0 -0
  22. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/__init__.py +0 -0
  23. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/__init__.py +0 -0
  24. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/auth_cmd.py +0 -0
  25. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/commands/comments.py +0 -0
  26. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/config.py +0 -0
  27. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/expedait_cli/formatters.py +0 -0
  28. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/__init__.py +0 -0
  29. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/conftest.py +0 -0
  30. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_client.py +0 -0
  31. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/__init__.py +0 -0
  32. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_auth_cmd.py +0 -0
  33. {expedait_cli-0.2.1 → expedait_cli-0.2.2}/tests/test_commands/test_comments.py +0 -0
  34. {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.1
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
+ [![PyPI](https://img.shields.io/pypi/v/expedait-cli)](https://pypi.org/project/expedait-cli/)
14
15
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
15
16
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
16
17
 
17
- Command-line interface for [Expedait](https://expedait.com) — download project specs and post comments from AI coding agents.
18
+ CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
18
19
 
19
- ## Quickstart
20
+ ## Usage
20
21
 
21
- ```bash
22
- # Install with uv (recommended)
23
- uv pip install expedait-cli
22
+ ### Run with `uvx` (recommended)
24
23
 
25
- # Or install from source
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
- # Login to your Expedait instance
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
- # List your projects
34
- expedait projects list
34
+ If your AI agent needs it available in the project environment:
35
35
 
36
- # Download all specs for a project
37
- expedait projects download 1 --output-dir ./specs
36
+ ```bash
37
+ uv add --group dev expedait-cli
38
38
  ```
39
39
 
40
- ## Installation
40
+ Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
41
41
 
42
- ### From PyPI
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
- pip install expedait-cli
47
+ uvx expedait-cli init
46
48
  ```
47
49
 
48
- ### From source
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
- git clone https://github.com/Expedait/expedait-cli.git
52
- cd expedait-cli
53
- uv sync
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
- This creates a virtual environment with the `expedait` command available.
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.com"
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 # Download all pages as ZIP
96
- expedait projects download PROJECT_ID --output-dir ./specs # Extract to directory
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 --output-dir ./out # Download page as ZIP
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
- ## Global Options
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
- This project is licensed under the [Apache License 2.0](LICENSE).
153
+ [Apache License 2.0](LICENSE)
@@ -1,57 +1,59 @@
1
1
  # Expedait CLI
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/expedait-cli)](https://pypi.org/project/expedait-cli/)
3
4
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
5
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
5
6
 
6
- Command-line interface for [Expedait](https://expedait.com) — download project specs and post comments from AI coding agents.
7
+ CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
7
8
 
8
- ## Quickstart
9
+ ## Usage
9
10
 
10
- ```bash
11
- # Install with uv (recommended)
12
- uv pip install expedait-cli
11
+ ### Run with `uvx` (recommended)
13
12
 
14
- # Or install from source
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
- # Login to your Expedait instance
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
- # List your projects
23
- expedait projects list
23
+ If your AI agent needs it available in the project environment:
24
24
 
25
- # Download all specs for a project
26
- expedait projects download 1 --output-dir ./specs
25
+ ```bash
26
+ uv add --group dev expedait-cli
27
27
  ```
28
28
 
29
- ## Installation
29
+ Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
30
30
 
31
- ### From PyPI
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
- pip install expedait-cli
36
+ uvx expedait-cli init
35
37
  ```
36
38
 
37
- ### From source
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
- git clone https://github.com/Expedait/expedait-cli.git
41
- cd expedait-cli
42
- uv sync
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
- This creates a virtual environment with the `expedait` command available.
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.com"
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 # Download all pages as ZIP
85
- expedait projects download PROJECT_ID --output-dir ./specs # Extract to directory
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 --output-dir ./out # Download page as ZIP
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
- ## Global Options
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
- This project is licensed under the [Apache License 2.0](LICENSE).
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(explicit: int | None = None, config_path=None) -> int | None:
45
- """Return tenant ID from flag > env > config."""
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("GET", f"/api/v1/projects/{project_id}/download")
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("GET", f"/api/v1/pages/{page_id}/download")
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=True, type=int, help="Project ID to list pages for.")
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")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "expedait-cli"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "CLI for Expedait project management — download specs, post comments"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -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 test_config_file(self, saved_config: Path):
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
@@ -47,7 +47,7 @@ wheels = [
47
47
 
48
48
  [[package]]
49
49
  name = "expedait-cli"
50
- version = "0.2.0"
50
+ version = "0.2.2"
51
51
  source = { editable = "." }
52
52
  dependencies = [
53
53
  { name = "click" },
File without changes
File without changes
File without changes