kitchenowl-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: kitchenowl-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for interacting with the KitchenOwl API.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1.7
8
+ Requires-Dist: requests>=2.31.0
9
+ Requires-Dist: PyYAML>=6.0.1
10
+ Requires-Dist: rich>=13.7.1
11
+ Requires-Dist: pytest>=8.1.0
12
+
13
+ # kitchenowl-cli
14
+
15
+ Command-line client for KitchenOwl's `/api` endpoints, covering auth, household, recipe, shopping list, and user workflows.
16
+
17
+ ## Supported CLI surface
18
+
19
+ - `kitchenowl auth [login|logout|status|signup]` — JWT login with refresh, logout and signup helpers.
20
+ - `kitchenowl config [show|set-default-household|server-settings]` — manage stored tokens/default household and inspect read-only server flags from `/api/health`.
21
+ - `kitchenowl household [list|use|get|create|update|delete]` and `kitchenowl household member [list|add|remove]`.
22
+ - `kitchenowl recipe [list|get|add|edit|delete]` with JSON/YAML payload support and flag-based editors.
23
+ - `kitchenowl shoppinglist [list|create|delete|items|add-item|add-item-by-name|suggested|remove-item]` plus the dedicated `remove-item` command to mark items done.
24
+ - `kitchenowl user [list|get|search|create|update|delete]` for admins.
25
+ - `run_cli_e2e.sh` script exercises login, household creation, lists, recipes, planner, expenses, and optionally house cleanup.
26
+
27
+ ## Quick install
28
+
29
+ ```bash
30
+ cd kitchenowl-cli
31
+ python3 -m pip install -e .
32
+ ```
33
+
34
+ ## Quick start examples
35
+
36
+ ```bash
37
+ kitchenowl auth login --server https://your-kitchenowl.example.com
38
+ # tip: both https://host and https://host/api are accepted
39
+ kitchenowl config server-settings
40
+ kitchenowl household list
41
+ kitchenowl household member list --household-id 42
42
+ kitchenowl household member add 17 --household-id 42 --admin
43
+ kitchenowl shoppinglist create "Weekly List" --household-id 42
44
+ kitchenowl shoppinglist add-item-by-name 12 Milk --description "2L"
45
+ kitchenowl shoppinglist remove-item 12 456 -y
46
+ kitchenowl recipe add --name "Tomato Soup" --description "Simple soup" --household-id 42 --yields 2 --time 25
47
+ kitchenowl recipe edit 123 --description "Updated"
48
+ kitchenowl recipe delete 123
49
+ kitchenowl user list
50
+ kitchenowl auth signup --username newuser --name "New User"
51
+ ```
52
+
53
+ `auth login` / `auth signup` will always ask for the server URL when `--server` is not provided, using your last saved server as the default.
54
+
55
+ ## Read-only server settings
56
+
57
+ Inspect the public read-only settings exposed by the server health endpoint (works without login if you pass `--server`):
58
+
59
+ ```bash
60
+ kitchenowl config server-settings --server https://your-kitchenowl.example.com
61
+ kitchenowl config server-settings --json
62
+ ```
63
+
64
+ This currently shows:
65
+ - `open_registration`
66
+ - `email_mandatory`
67
+ - `oidc_provider`
68
+ - `privacy_policy` (if configured)
69
+ - `terms` (if configured)
70
+
71
+ ## File-based recipe editing
72
+
73
+ ```bash
74
+ kitchenowl recipe add --household-id 1 --from-file recipe.yml
75
+ kitchenowl recipe edit 42 --from-file recipe.yml
76
+ ```
77
+
78
+ Example `recipe.yml`:
79
+
80
+ ```yaml
81
+ name: Tomato Soup
82
+ description: Simple soup
83
+ time: 25
84
+ cook_time: 20
85
+ prep_time: 5
86
+ yields: 2
87
+ visibility: 0
88
+ source: ""
89
+ items:
90
+ - name: Tomatoes
91
+ description: 6 pcs
92
+ optional: false
93
+ - name: Salt
94
+ description: 1 tsp
95
+ optional: false
96
+ tags:
97
+ - soup
98
+ - vegetarian
99
+ ```
100
+
101
+ ## What’s not implemented yet
102
+
103
+ - Planner CLI commands (beyond the end-to-end script that calls `/planner/recipe`).
104
+ - Expense management (create/edit/delete categories or entries) via CLI wrappers.
105
+ - Shopping list item bulk operations (remove multiple items at once) and the planner suggestion refresh endpoints.
106
+ - More advanced user workflows (password resets, token management, server admin tooling) are still API-only.
@@ -0,0 +1,94 @@
1
+ # kitchenowl-cli
2
+
3
+ Command-line client for KitchenOwl's `/api` endpoints, covering auth, household, recipe, shopping list, and user workflows.
4
+
5
+ ## Supported CLI surface
6
+
7
+ - `kitchenowl auth [login|logout|status|signup]` — JWT login with refresh, logout and signup helpers.
8
+ - `kitchenowl config [show|set-default-household|server-settings]` — manage stored tokens/default household and inspect read-only server flags from `/api/health`.
9
+ - `kitchenowl household [list|use|get|create|update|delete]` and `kitchenowl household member [list|add|remove]`.
10
+ - `kitchenowl recipe [list|get|add|edit|delete]` with JSON/YAML payload support and flag-based editors.
11
+ - `kitchenowl shoppinglist [list|create|delete|items|add-item|add-item-by-name|suggested|remove-item]` plus the dedicated `remove-item` command to mark items done.
12
+ - `kitchenowl user [list|get|search|create|update|delete]` for admins.
13
+ - `run_cli_e2e.sh` script exercises login, household creation, lists, recipes, planner, expenses, and optionally house cleanup.
14
+
15
+ ## Quick install
16
+
17
+ ```bash
18
+ cd kitchenowl-cli
19
+ python3 -m pip install -e .
20
+ ```
21
+
22
+ ## Quick start examples
23
+
24
+ ```bash
25
+ kitchenowl auth login --server https://your-kitchenowl.example.com
26
+ # tip: both https://host and https://host/api are accepted
27
+ kitchenowl config server-settings
28
+ kitchenowl household list
29
+ kitchenowl household member list --household-id 42
30
+ kitchenowl household member add 17 --household-id 42 --admin
31
+ kitchenowl shoppinglist create "Weekly List" --household-id 42
32
+ kitchenowl shoppinglist add-item-by-name 12 Milk --description "2L"
33
+ kitchenowl shoppinglist remove-item 12 456 -y
34
+ kitchenowl recipe add --name "Tomato Soup" --description "Simple soup" --household-id 42 --yields 2 --time 25
35
+ kitchenowl recipe edit 123 --description "Updated"
36
+ kitchenowl recipe delete 123
37
+ kitchenowl user list
38
+ kitchenowl auth signup --username newuser --name "New User"
39
+ ```
40
+
41
+ `auth login` / `auth signup` will always ask for the server URL when `--server` is not provided, using your last saved server as the default.
42
+
43
+ ## Read-only server settings
44
+
45
+ Inspect the public read-only settings exposed by the server health endpoint (works without login if you pass `--server`):
46
+
47
+ ```bash
48
+ kitchenowl config server-settings --server https://your-kitchenowl.example.com
49
+ kitchenowl config server-settings --json
50
+ ```
51
+
52
+ This currently shows:
53
+ - `open_registration`
54
+ - `email_mandatory`
55
+ - `oidc_provider`
56
+ - `privacy_policy` (if configured)
57
+ - `terms` (if configured)
58
+
59
+ ## File-based recipe editing
60
+
61
+ ```bash
62
+ kitchenowl recipe add --household-id 1 --from-file recipe.yml
63
+ kitchenowl recipe edit 42 --from-file recipe.yml
64
+ ```
65
+
66
+ Example `recipe.yml`:
67
+
68
+ ```yaml
69
+ name: Tomato Soup
70
+ description: Simple soup
71
+ time: 25
72
+ cook_time: 20
73
+ prep_time: 5
74
+ yields: 2
75
+ visibility: 0
76
+ source: ""
77
+ items:
78
+ - name: Tomatoes
79
+ description: 6 pcs
80
+ optional: false
81
+ - name: Salt
82
+ description: 1 tsp
83
+ optional: false
84
+ tags:
85
+ - soup
86
+ - vegetarian
87
+ ```
88
+
89
+ ## What’s not implemented yet
90
+
91
+ - Planner CLI commands (beyond the end-to-end script that calls `/planner/recipe`).
92
+ - Expense management (create/edit/delete categories or entries) via CLI wrappers.
93
+ - Shopping list item bulk operations (remove multiple items at once) and the planner suggestion refresh endpoints.
94
+ - More advanced user workflows (password resets, token management, server admin tooling) are still API-only.
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ from .config import save_config
9
+
10
+
11
+ def normalize_server_url(url: str) -> str:
12
+ normalized = url.rstrip("/")
13
+ # Accept both base URLs (https://host) and API URLs (https://host/api)
14
+ # from user input/config without producing /api/api/* requests.
15
+ if normalized.endswith("/api"):
16
+ normalized = normalized[:-4]
17
+ return normalized
18
+
19
+
20
+ def _extract_error(response: requests.Response) -> str:
21
+ try:
22
+ payload = response.json()
23
+ except ValueError:
24
+ return response.text.strip() or f"HTTP {response.status_code}"
25
+
26
+ if isinstance(payload, dict):
27
+ for key in ("msg", "message", "error", "detail"):
28
+ if key in payload and payload[key]:
29
+ return str(payload[key])
30
+ return str(payload)
31
+
32
+
33
+ @dataclass
34
+ class ApiError(Exception):
35
+ message: str
36
+ status_code: int | None = None
37
+
38
+ def __str__(self) -> str:
39
+ if self.status_code is None:
40
+ return self.message
41
+ return f"{self.message} (HTTP {self.status_code})"
42
+
43
+
44
+ class ApiClient:
45
+ def __init__(self, config: dict[str, Any]):
46
+ server_url = config.get("server_url")
47
+ if not server_url:
48
+ raise ApiError("No server configured. Run `kitchenowl auth login` first.")
49
+ self.config = config
50
+ self.server_url = normalize_server_url(server_url)
51
+ self.session = requests.Session()
52
+
53
+ def _url(self, path: str) -> str:
54
+ if path.startswith("http://") or path.startswith("https://"):
55
+ return path
56
+ if not path.startswith("/"):
57
+ path = f"/{path}"
58
+ return f"{self.server_url}{path}"
59
+
60
+ def _auth_header(self, token_key: str) -> dict[str, str]:
61
+ token = self.config.get(token_key)
62
+ if not token:
63
+ raise ApiError("Not authenticated. Run `kitchenowl auth login`.")
64
+ return {"Authorization": f"Bearer {token}"}
65
+
66
+ def refresh_tokens(self) -> None:
67
+ headers = self._auth_header("refresh_token")
68
+ response = self.session.get(
69
+ self._url("/api/auth/refresh"),
70
+ headers=headers,
71
+ timeout=30,
72
+ )
73
+ if not response.ok:
74
+ raise ApiError(
75
+ f"Token refresh failed: {_extract_error(response)}",
76
+ response.status_code,
77
+ )
78
+ payload = response.json()
79
+ self.config["access_token"] = payload["access_token"]
80
+ self.config["refresh_token"] = payload["refresh_token"]
81
+ if "user" in payload:
82
+ self.config["user"] = payload["user"]
83
+ save_config(self.config)
84
+
85
+ def request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ json: dict[str, Any] | None = None,
91
+ params: dict[str, Any] | None = None,
92
+ auth: str = "access",
93
+ _retry: bool = True,
94
+ ) -> Any:
95
+ headers: dict[str, str] = {}
96
+ if auth == "access":
97
+ headers.update(self._auth_header("access_token"))
98
+ elif auth == "refresh":
99
+ headers.update(self._auth_header("refresh_token"))
100
+
101
+ response = self.session.request(
102
+ method=method.upper(),
103
+ url=self._url(path),
104
+ json=json,
105
+ params=params,
106
+ headers=headers,
107
+ timeout=30,
108
+ )
109
+
110
+ if response.status_code == 401 and auth == "access" and _retry:
111
+ self.refresh_tokens()
112
+ return self.request(
113
+ method,
114
+ path,
115
+ json=json,
116
+ params=params,
117
+ auth=auth,
118
+ _retry=False,
119
+ )
120
+
121
+ if not response.ok:
122
+ raise ApiError(_extract_error(response), response.status_code)
123
+
124
+ if response.text.strip():
125
+ try:
126
+ return response.json()
127
+ except ValueError:
128
+ return response.text
129
+ return None
130
+
131
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
132
+ return self.request("GET", path, params=params)
133
+
134
+ def get_public(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
135
+ return self.request("GET", path, params=params, auth="none")
136
+
137
+ def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
138
+ return self.request("POST", path, json=json)
139
+
140
+ def delete(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
141
+ return self.request("DELETE", path, json=json)
142
+
143
+ def put(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
144
+ return self.request("PUT", path, json=json)
145
+
146
+
147
+ def login(
148
+ server_url: str,
149
+ username: str,
150
+ password: str,
151
+ *,
152
+ device: str = "kitchenowl-cli",
153
+ ) -> dict[str, Any]:
154
+ url = f"{normalize_server_url(server_url)}/api/auth"
155
+ response = requests.post(
156
+ url,
157
+ json={
158
+ "username": username,
159
+ "password": password,
160
+ "device": device,
161
+ },
162
+ timeout=30,
163
+ )
164
+ if not response.ok:
165
+ raise ApiError(_extract_error(response), response.status_code)
166
+ return response.json()
167
+
168
+
169
+ def signup(
170
+ server_url: str,
171
+ username: str,
172
+ password: str,
173
+ name: str,
174
+ *,
175
+ email: str | None = None,
176
+ device: str = "kitchenowl-cli",
177
+ ) -> dict[str, Any]:
178
+ url = f"{normalize_server_url(server_url)}/api/auth/signup"
179
+ body: dict[str, Any] = {
180
+ "username": username,
181
+ "password": password,
182
+ "name": name,
183
+ "device": device,
184
+ }
185
+ if email:
186
+ body["email"] = email
187
+ response = requests.post(
188
+ url,
189
+ json=body,
190
+ timeout=30,
191
+ )
192
+ if not response.ok:
193
+ raise ApiError(_extract_error(response), response.status_code)
194
+ return response.json()
@@ -0,0 +1 @@
1
+ # Command package marker.
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from kitchenowl_cli.api import ApiClient, ApiError, login as api_login, normalize_server_url, signup as api_signup
8
+ from kitchenowl_cli.config import clear_auth_tokens, load_config, save_config
9
+
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group()
15
+ def auth() -> None:
16
+ """Authentication commands."""
17
+
18
+
19
+ @auth.command()
20
+ @click.option("--server", help="KitchenOwl server URL (e.g. https://kitchenowl.example.com).")
21
+ @click.option("--username", help="Username or email.")
22
+ @click.option("--password", help="Password.")
23
+ @click.option("--device", default="kitchenowl-cli", show_default=True, help="Device label for token tracking.")
24
+ def login(server: str | None, username: str | None, password: str | None, device: str) -> None:
25
+ """Login and store access + refresh tokens."""
26
+ cfg = load_config()
27
+ if server is not None:
28
+ server_url = server
29
+ else:
30
+ default_server = cfg.get("server_url", "")
31
+ server_url = click.prompt(
32
+ "Server URL",
33
+ default=default_server,
34
+ show_default=bool(default_server),
35
+ )
36
+ if not username:
37
+ username = click.prompt("Username or email")
38
+ if not password:
39
+ password = click.prompt("Password", hide_input=True)
40
+
41
+ try:
42
+ payload = api_login(server_url, username, password, device=device)
43
+ except ApiError as exc:
44
+ raise click.ClickException(str(exc)) from exc
45
+
46
+ cfg["server_url"] = normalize_server_url(server_url)
47
+ cfg["access_token"] = payload["access_token"]
48
+ cfg["refresh_token"] = payload["refresh_token"]
49
+ cfg["user"] = payload.get("user")
50
+ save_config(cfg)
51
+
52
+ try:
53
+ client = ApiClient(cfg)
54
+ households = client.get("/api/household")
55
+ if households and isinstance(households, list) and not cfg.get("default_household"):
56
+ cfg["default_household"] = households[0]["id"]
57
+ save_config(cfg)
58
+ except ApiError:
59
+ pass
60
+
61
+ console.print("[green]Login successful.[/green]")
62
+ if cfg.get("default_household"):
63
+ console.print(f"Default household: {cfg['default_household']}")
64
+
65
+
66
+ @auth.command()
67
+ @click.option("--server", help="KitchenOwl server URL (e.g. https://kitchenowl.example.com).")
68
+ @click.option("--username", help="New username.")
69
+ @click.option("--name", help="Full name.")
70
+ @click.option("--password", help="Password.")
71
+ @click.option("--email", help="Optional email.")
72
+ @click.option("--device", default="kitchenowl-cli", show_default=True, help="Device label for token tracking.")
73
+ def signup(
74
+ server: str | None,
75
+ username: str | None,
76
+ name: str | None,
77
+ password: str | None,
78
+ email: str | None,
79
+ device: str,
80
+ ) -> None:
81
+ """Register a new user and store its tokens."""
82
+ cfg = load_config()
83
+ if server is not None:
84
+ server_url = server
85
+ else:
86
+ default_server = cfg.get("server_url", "")
87
+ server_url = click.prompt(
88
+ "Server URL",
89
+ default=default_server,
90
+ show_default=bool(default_server),
91
+ )
92
+ if not username:
93
+ username = click.prompt("Username or email")
94
+ if not name:
95
+ name = click.prompt("Full name")
96
+ if not password:
97
+ password = click.prompt("Password", hide_input=True)
98
+
99
+ try:
100
+ payload = api_signup(
101
+ server_url,
102
+ username,
103
+ password,
104
+ name,
105
+ email=email,
106
+ device=device,
107
+ )
108
+ except ApiError as exc:
109
+ raise click.ClickException(str(exc)) from exc
110
+
111
+ cfg["server_url"] = normalize_server_url(server_url)
112
+ cfg["access_token"] = payload["access_token"]
113
+ cfg["refresh_token"] = payload["refresh_token"]
114
+ cfg["user"] = payload.get("user")
115
+ save_config(cfg)
116
+
117
+ try:
118
+ client = ApiClient(cfg)
119
+ households = client.get("/api/household")
120
+ if households and isinstance(households, list) and not cfg.get("default_household"):
121
+ cfg["default_household"] = households[0]["id"]
122
+ save_config(cfg)
123
+ except ApiError:
124
+ pass
125
+
126
+ console.print("[green]Signup successful.[/green]")
127
+ if cfg.get("default_household"):
128
+ console.print(f"Default household: {cfg['default_household']}")
129
+
130
+
131
+ @auth.command()
132
+ def status() -> None:
133
+ """Show current auth status."""
134
+ cfg = load_config()
135
+ if not cfg.get("server_url"):
136
+ console.print("No server configured.")
137
+ return
138
+
139
+ table = Table(show_header=False, box=None)
140
+ table.add_row("Server", str(cfg["server_url"]))
141
+ table.add_row("Default household", str(cfg.get("default_household", "-")))
142
+
143
+ if not cfg.get("access_token"):
144
+ table.add_row("Logged in", "No")
145
+ console.print(table)
146
+ return
147
+
148
+ try:
149
+ client = ApiClient(cfg)
150
+ user = client.get("/api/user")
151
+ table.add_row("Logged in", "Yes")
152
+ table.add_row("User", f"{user.get('name', '-')} (id={user.get('id', '-')})")
153
+ except ApiError as exc:
154
+ table.add_row("Logged in", f"No ({exc})")
155
+ console.print(table)
156
+
157
+
158
+ @auth.command()
159
+ def logout() -> None:
160
+ """Clear stored auth tokens."""
161
+ clear_auth_tokens()
162
+ console.print("[green]Logged out.[/green]")
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from kitchenowl_cli.api import ApiClient, ApiError, normalize_server_url
10
+ from kitchenowl_cli.config import load_config, save_config
11
+
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group("config")
17
+ def config_group() -> None:
18
+ """Configuration commands."""
19
+
20
+
21
+ @config_group.command("show")
22
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
23
+ def show_config(as_json: bool) -> None:
24
+ """Show saved CLI configuration."""
25
+ cfg = load_config()
26
+ safe = dict(cfg)
27
+ for key in ("access_token", "refresh_token"):
28
+ if safe.get(key):
29
+ safe[key] = f"{str(safe[key])[:12]}..."
30
+
31
+ if as_json:
32
+ click.echo(json.dumps(safe, indent=2, sort_keys=True))
33
+ return
34
+
35
+ if not safe:
36
+ console.print("No config found.")
37
+ return
38
+ for key, value in safe.items():
39
+ console.print(f"{key}: {value}")
40
+
41
+
42
+ @config_group.command("set-default-household")
43
+ @click.argument("household_id", type=int)
44
+ def set_default_household(household_id: int) -> None:
45
+ """Set default household ID for recipe commands."""
46
+ cfg = load_config()
47
+ cfg["default_household"] = household_id
48
+ save_config(cfg)
49
+ console.print(f"[green]Default household set to {household_id}.[/green]")
50
+
51
+
52
+ @config_group.command("server-settings")
53
+ @click.option("--server", help="KitchenOwl server URL (overrides saved config).")
54
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
55
+ def server_settings(server: str | None, as_json: bool) -> None:
56
+ """Show read-only server settings exposed by /api/health."""
57
+ cfg = load_config()
58
+ server_url = server or cfg.get("server_url")
59
+ if not server_url:
60
+ raise click.ClickException("No server configured. Provide --server or login first.")
61
+
62
+ client = ApiClient({"server_url": normalize_server_url(server_url)})
63
+ health = None
64
+ last_error: ApiError | None = None
65
+ for path in (
66
+ "/api/health",
67
+ "/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V",
68
+ ):
69
+ try:
70
+ health = client.get_public(path)
71
+ break
72
+ except ApiError as exc:
73
+ last_error = exc
74
+ continue
75
+
76
+ if health is None:
77
+ raise click.ClickException(str(last_error) if last_error else "Could not read server settings.")
78
+
79
+ settings = {
80
+ "open_registration": bool(health.get("open_registration", False)),
81
+ "email_mandatory": bool(health.get("email_mandatory", False)),
82
+ "oidc_provider": health.get("oidc_provider", []),
83
+ }
84
+ for key in ("privacy_policy", "terms"):
85
+ if key in health:
86
+ settings[key] = health[key]
87
+
88
+ if as_json:
89
+ click.echo(json.dumps(settings, indent=2, sort_keys=True))
90
+ return
91
+
92
+ table = Table(show_header=False, box=None)
93
+ table.add_row("server", normalize_server_url(server_url))
94
+ for key, value in settings.items():
95
+ rendered = ", ".join(value) if isinstance(value, list) else str(value)
96
+ table.add_row(key, rendered)
97
+ console.print(table)