expedait-cli 0.2.0__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 (35) hide show
  1. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/.github/workflows/publish.yml +6 -1
  2. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/CLAUDE.md +6 -0
  3. expedait_cli-0.2.2/PKG-INFO +153 -0
  4. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/README.md +37 -37
  5. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/auth.py +30 -2
  6. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/client.py +8 -4
  7. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/commands/auth_cmd.py +0 -1
  8. expedait_cli-0.2.2/expedait_cli/commands/init_cmd.py +77 -0
  9. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/commands/pages.py +12 -6
  10. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/commands/projects.py +11 -5
  11. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/main.py +2 -0
  12. expedait_cli-0.2.2/expedait_cli/settings.py +31 -0
  13. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/pyproject.toml +3 -1
  14. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_auth.py +30 -3
  15. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_commands/test_auth_cmd.py +6 -6
  16. expedait_cli-0.2.2/tests/test_commands/test_init_cmd.py +161 -0
  17. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_commands/test_pages.py +32 -0
  18. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_commands/test_projects.py +32 -0
  19. expedait_cli-0.2.2/tests/test_settings.py +33 -0
  20. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/uv.lock +1 -1
  21. expedait_cli-0.2.0/PKG-INFO +0 -8
  22. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  23. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/.gitignore +0 -0
  24. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/LICENSE +0 -0
  25. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/__init__.py +0 -0
  26. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/commands/__init__.py +0 -0
  27. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/commands/comments.py +0 -0
  28. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/config.py +0 -0
  29. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/expedait_cli/formatters.py +0 -0
  30. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/__init__.py +0 -0
  31. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/conftest.py +0 -0
  32. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_client.py +0 -0
  33. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_commands/__init__.py +0 -0
  34. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_commands/test_comments.py +0 -0
  35. {expedait_cli-0.2.0 → expedait_cli-0.2.2}/tests/test_config.py +0 -0
@@ -9,7 +9,7 @@ jobs:
9
9
  publish:
10
10
  runs-on: ubuntu-latest
11
11
  permissions:
12
- contents: read
12
+ contents: write
13
13
  id-token: write
14
14
  environment: pypi
15
15
  steps:
@@ -35,3 +35,8 @@ jobs:
35
35
 
36
36
  - name: Publish to PyPI
37
37
  uses: pypa/gh-action-pypi-publish@release/v1
38
+
39
+ - name: Create GitHub Release with PR changelog
40
+ env:
41
+ GH_TOKEN: ${{ github.token }}
42
+ run: gh release create "$GITHUB_REF_NAME" --generate-notes
@@ -24,6 +24,12 @@ uv run python -m pytest tests/test_auth.py # run a single file
24
24
  - `expedait_cli/` — source: `main.py` (Click entrypoint), `client.py` (httpx API client), `auth.py`, `config.py`, `formatters.py`, `commands/` (Click subcommands)
25
25
  - `tests/` — pytest tests using `pytest-httpx` for mocking HTTP
26
26
 
27
+ ## Versioning
28
+
29
+ When creating a PR, bump the patch version in `pyproject.toml` (the `version` field) and include that change in the PR.
30
+
31
+ To publish a release, push a git tag matching the version (e.g. `git tag v0.2.1 && git push origin v0.2.1`). This triggers CI to publish to PyPI and create a GitHub Release with auto-generated notes listing the PRs since the previous tag.
32
+
27
33
  ## Style
28
34
 
29
35
  - Python 3.11+, no type-checker configured
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: expedait-cli
3
+ Version: 0.2.2
4
+ Summary: CLI for Expedait project management — download specs, post comments
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: httpx>=0.27
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Expedait CLI
13
+
14
+ [![PyPI](https://img.shields.io/pypi/v/expedait-cli)](https://pypi.org/project/expedait-cli/)
15
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
16
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
17
+
18
+ CLI for [Expedait](https://expedait.org) — lets AI coding agents download project specs and post comments via the Expedait API.
19
+
20
+ ## Usage
21
+
22
+ ### Run with `uvx` (recommended)
23
+
24
+ No installation needed — run directly:
25
+
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
33
+
34
+ If your AI agent needs it available in the project environment:
35
+
36
+ ```bash
37
+ uv add --group dev expedait-cli
38
+ ```
39
+
40
+ Then reference it in your agent configuration (e.g. `CLAUDE.md`, `.cursor/rules`, etc.).
41
+
42
+ ## Project Setup
43
+
44
+ After authenticating, run `init` inside your project directory to store your tenant and project settings locally:
45
+
46
+ ```bash
47
+ uvx expedait-cli init
48
+ ```
49
+
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/`:
53
+
54
+ ```bash
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/
58
+ ```
59
+
60
+ **Resolution order for tenant/project:** CLI flag > env var > `.expedait/settings.json` > `~/.expedait/config.json`.
61
+
62
+ ## Authentication
63
+
64
+ ### Interactive login
65
+
66
+ ```bash
67
+ uvx expedait-cli auth login
68
+ ```
69
+
70
+ Prompts for login method (SSO or email/password). Stores credentials in `~/.expedait/config.json`.
71
+
72
+ ### Environment variables (CI / agents)
73
+
74
+ ```bash
75
+ export EXPEDAIT_TOKEN="your-jwt-token"
76
+ export EXPEDAIT_API_URL="https://your-instance.expedait.org"
77
+ export EXPEDAIT_TENANT_ID=1
78
+ ```
79
+
80
+ **Token resolution order:** `EXPEDAIT_TOKEN` env var > `~/.expedait/config.json` > error.
81
+
82
+ ## Commands
83
+
84
+ ### Auth
85
+
86
+ ```bash
87
+ expedait auth login # Interactive login
88
+ expedait auth status # Show current user and tenant
89
+ expedait auth logout # Clear stored credentials
90
+ ```
91
+
92
+ ### Projects
93
+
94
+ ```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
100
+ ```
101
+
102
+ ### Pages
103
+
104
+ ```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
110
+ ```
111
+
112
+ ### Comments
113
+
114
+ ```bash
115
+ expedait comments list PAGE_ID # List comments on a page
116
+ expedait comments create PAGE_ID \ # Create a comment
117
+ --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
124
+ ```
125
+
126
+ ### Global Options
127
+
128
+ ```bash
129
+ expedait --api-url https://host:8000 ... # Override API URL
130
+ expedait --tenant-id 2 ... # Override tenant
131
+ expedait --format json ... # Force JSON output
132
+ expedait --format text ... # Force human-readable output
133
+ expedait --version # Show version
134
+ ```
135
+
136
+ Output format defaults to `text` when connected to a terminal, `json` when piped.
137
+
138
+ ## Agent Skills
139
+
140
+ For step-by-step guides on using the CLI from AI coding agents, see [expedait-skills](https://github.com/Expedait/expedait-skills).
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ git clone https://github.com/Expedait/expedait-cli.git
146
+ cd expedait-cli
147
+ uv sync --group dev
148
+ uv run python -m pytest
149
+ ```
150
+
151
+ ## License
152
+
153
+ [Apache License 2.0](LICENSE)
@@ -1,66 +1,68 @@
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
- Prompts for API URL, email, and password. Stores credentials in `~/.expedait/config.json`.
59
+ Prompts for login method (SSO or email/password). Stores credentials in `~/.expedait/config.json`.
58
60
 
59
61
  ### Environment variables (CI / agents)
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 ---------------------------------------------------------
@@ -117,7 +117,6 @@ def _login_sso(api_url: str) -> tuple[str, dict]:
117
117
  def login(ctx: click.Context) -> None:
118
118
  """Login interactively via browser SSO or email/password."""
119
119
  api_url = resolve_api_url(ctx.obj.get("api_url"))
120
- api_url = click.prompt("API URL", default=api_url)
121
120
 
122
121
  method = click.prompt(
123
122
  "Login method",
@@ -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,8 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "expedait-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "CLI for Expedait project management — download specs, post comments"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
9
11
  requires-python = ">=3.11"
10
12
  dependencies = [
11
13
  "click>=8.1",
@@ -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
@@ -29,7 +29,7 @@ class TestAuthLoginPassword:
29
29
  result = runner.invoke(
30
30
  cli,
31
31
  ["auth", "login"],
32
- input="https://app.expedait.org\npassword\nuser@test.com\npass123\n",
32
+ input="password\nuser@test.com\npass123\n",
33
33
  )
34
34
 
35
35
  assert result.exit_code == 0
@@ -46,7 +46,7 @@ class TestAuthLoginPassword:
46
46
  result = runner.invoke(
47
47
  cli,
48
48
  ["auth", "login"],
49
- input="https://app.expedait.org\npassword\nbad@test.com\nwrong\n",
49
+ input="password\nbad@test.com\nwrong\n",
50
50
  )
51
51
 
52
52
  assert result.exit_code != 0
@@ -90,7 +90,7 @@ class TestAuthLoginSSO:
90
90
  result = runner.invoke(
91
91
  cli,
92
92
  ["auth", "login"],
93
- input="https://app.expedait.org\nsso\n",
93
+ input="sso\n",
94
94
  )
95
95
 
96
96
  assert result.exit_code == 0
@@ -110,7 +110,7 @@ class TestAuthLoginSSO:
110
110
  result = runner.invoke(
111
111
  cli,
112
112
  ["auth", "login"],
113
- input="https://app.expedait.org\nsso\n",
113
+ input="sso\n",
114
114
  )
115
115
 
116
116
  assert result.exit_code != 0
@@ -139,7 +139,7 @@ class TestAuthLoginSSO:
139
139
  result = runner.invoke(
140
140
  cli,
141
141
  ["auth", "login"],
142
- input="https://app.expedait.org\nsso\n",
142
+ input="sso\n",
143
143
  )
144
144
 
145
145
  assert result.exit_code != 0
@@ -169,7 +169,7 @@ class TestAuthLoginMultipleTenants:
169
169
  result = runner.invoke(
170
170
  cli,
171
171
  ["auth", "login"],
172
- input="https://app.expedait.org\npassword\nuser@test.com\npass123\n2\n",
172
+ input="password\nuser@test.com\npass123\n2\n",
173
173
  )
174
174
 
175
175
  assert result.exit_code == 0
@@ -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" },
@@ -1,8 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: expedait-cli
3
- Version: 0.2.0
4
- Summary: CLI for Expedait project management — download specs, post comments
5
- License-File: LICENSE
6
- Requires-Python: >=3.11
7
- Requires-Dist: click>=8.1
8
- Requires-Dist: httpx>=0.27
File without changes
File without changes