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.
- kitchenowl_cli-0.1.0/PKG-INFO +106 -0
- kitchenowl_cli-0.1.0/README.md +94 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/__init__.py +2 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/api.py +194 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/__init__.py +1 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/auth.py +162 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/config_cmd.py +97 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/household.py +309 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/recipe.py +451 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/shoppinglist.py +248 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/commands/user.py +186 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/config.py +52 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli/main.py +27 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/PKG-INFO +106 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/SOURCES.txt +21 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/dependency_links.txt +1 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/entry_points.txt +2 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/requires.txt +5 -0
- kitchenowl_cli-0.1.0/kitchenowl_cli.egg-info/top_level.txt +1 -0
- kitchenowl_cli-0.1.0/pyproject.toml +23 -0
- kitchenowl_cli-0.1.0/setup.cfg +4 -0
- kitchenowl_cli-0.1.0/tests/test_api_helpers.py +30 -0
- kitchenowl_cli-0.1.0/tests/test_recipe_helpers.py +23 -0
|
@@ -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,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)
|