onesearch-cli 0.12.1__py3-none-any.whl
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.
- onesearch/__init__.py +6 -0
- onesearch/api.py +247 -0
- onesearch/banner.py +84 -0
- onesearch/commands/__init__.py +4 -0
- onesearch/commands/auth.py +64 -0
- onesearch/commands/config.py +166 -0
- onesearch/commands/search.py +142 -0
- onesearch/commands/source.py +232 -0
- onesearch/commands/status.py +221 -0
- onesearch/config.py +185 -0
- onesearch/context.py +51 -0
- onesearch/main.py +153 -0
- onesearch_cli-0.12.1.dist-info/METADATA +222 -0
- onesearch_cli-0.12.1.dist-info/RECORD +16 -0
- onesearch_cli-0.12.1.dist-info/WHEEL +4 -0
- onesearch_cli-0.12.1.dist-info/entry_points.txt +2 -0
onesearch/__init__.py
ADDED
onesearch/api.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""API client wrapper for OneSearch backend."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(Exception):
|
|
13
|
+
"""Exception raised for API errors."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, status_code: int | None = None, details: Any = None):
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.details = details
|
|
19
|
+
super().__init__(self.message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OneSearchAPI:
|
|
23
|
+
"""Client for the OneSearch backend API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30, token: str | None = None):
|
|
26
|
+
"""Initialize the API client.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
base_url: Backend API base URL.
|
|
30
|
+
timeout: Request timeout in seconds.
|
|
31
|
+
token: Optional bearer token.
|
|
32
|
+
"""
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self.session = requests.Session()
|
|
36
|
+
if token:
|
|
37
|
+
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
|
38
|
+
|
|
39
|
+
def _url(self, endpoint: str) -> str:
|
|
40
|
+
"""Build full URL for an endpoint."""
|
|
41
|
+
return urljoin(self.base_url + "/", endpoint.lstrip("/"))
|
|
42
|
+
|
|
43
|
+
def _request(
|
|
44
|
+
self,
|
|
45
|
+
method: str,
|
|
46
|
+
endpoint: str,
|
|
47
|
+
params: dict | None = None,
|
|
48
|
+
json: dict | None = None,
|
|
49
|
+
allow_status_codes: set[int] | None = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Make an API request.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
55
|
+
endpoint: API endpoint path.
|
|
56
|
+
params: Query parameters.
|
|
57
|
+
json: JSON body data.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Response JSON data.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
APIError: If the request fails.
|
|
64
|
+
"""
|
|
65
|
+
url = self._url(endpoint)
|
|
66
|
+
try:
|
|
67
|
+
response = self.session.request(
|
|
68
|
+
method=method,
|
|
69
|
+
url=url,
|
|
70
|
+
params=params,
|
|
71
|
+
json=json,
|
|
72
|
+
timeout=self.timeout,
|
|
73
|
+
)
|
|
74
|
+
if allow_status_codes and response.status_code in allow_status_codes:
|
|
75
|
+
if response.content:
|
|
76
|
+
return response.json()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
if response.content:
|
|
81
|
+
return response.json()
|
|
82
|
+
return None
|
|
83
|
+
except requests.exceptions.ConnectionError as err:
|
|
84
|
+
raise APIError(
|
|
85
|
+
f"Could not connect to OneSearch at {self.base_url}. "
|
|
86
|
+
"Is the server running?"
|
|
87
|
+
) from err
|
|
88
|
+
except requests.exceptions.Timeout as err:
|
|
89
|
+
raise APIError(f"Request to {url} timed out after {self.timeout}s") from err
|
|
90
|
+
except requests.exceptions.HTTPError as e:
|
|
91
|
+
status_code = e.response.status_code
|
|
92
|
+
try:
|
|
93
|
+
details = e.response.json()
|
|
94
|
+
message = details.get("detail", str(e))
|
|
95
|
+
except Exception:
|
|
96
|
+
details = None
|
|
97
|
+
message = str(e)
|
|
98
|
+
|
|
99
|
+
if status_code in (401, 403):
|
|
100
|
+
message = f"{message}. Run 'onesearch login' or set ONESEARCH_TOKEN."
|
|
101
|
+
|
|
102
|
+
raise APIError(message, status_code=status_code, details=details) from e
|
|
103
|
+
|
|
104
|
+
def login(self, username: str, password: str) -> dict:
|
|
105
|
+
"""Authenticate and return token payload."""
|
|
106
|
+
return self._request(
|
|
107
|
+
"POST",
|
|
108
|
+
"/api/auth/login",
|
|
109
|
+
json={"username": username, "password": password},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def logout(self) -> dict:
|
|
113
|
+
"""Logout current user."""
|
|
114
|
+
return self._request("POST", "/api/auth/logout")
|
|
115
|
+
|
|
116
|
+
def whoami(self) -> dict:
|
|
117
|
+
"""Return current user info."""
|
|
118
|
+
return self._request("GET", "/api/auth/me")
|
|
119
|
+
|
|
120
|
+
def auth_status(self) -> dict:
|
|
121
|
+
"""Return auth setup status."""
|
|
122
|
+
return self._request("GET", "/api/auth/status")
|
|
123
|
+
|
|
124
|
+
# Health endpoints
|
|
125
|
+
def health(self, allow_degraded: bool = False) -> dict:
|
|
126
|
+
"""Check system health."""
|
|
127
|
+
allowed = {503} if allow_degraded else None
|
|
128
|
+
return self._request("GET", "/api/health", allow_status_codes=allowed)
|
|
129
|
+
|
|
130
|
+
def status(self) -> dict:
|
|
131
|
+
"""Get system status."""
|
|
132
|
+
return self._request("GET", "/api/status")
|
|
133
|
+
|
|
134
|
+
def source_status(self, source_id: str) -> dict:
|
|
135
|
+
"""Get status for a specific source."""
|
|
136
|
+
return self._request("GET", f"/api/status/{source_id}")
|
|
137
|
+
|
|
138
|
+
# Source endpoints
|
|
139
|
+
def list_sources(self) -> list[dict]:
|
|
140
|
+
"""List all sources."""
|
|
141
|
+
return self._request("GET", "/api/sources")
|
|
142
|
+
|
|
143
|
+
def get_source(self, source_id: str) -> dict:
|
|
144
|
+
"""Get a specific source."""
|
|
145
|
+
return self._request("GET", f"/api/sources/{source_id}")
|
|
146
|
+
|
|
147
|
+
def create_source(
|
|
148
|
+
self,
|
|
149
|
+
name: str,
|
|
150
|
+
root_path: str,
|
|
151
|
+
include_patterns: list[str] | None = None,
|
|
152
|
+
exclude_patterns: list[str] | None = None,
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""Create a new source.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
name: Source display name.
|
|
158
|
+
root_path: Root path to index.
|
|
159
|
+
include_patterns: List of glob patterns to include.
|
|
160
|
+
exclude_patterns: List of glob patterns to exclude.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Created source data.
|
|
164
|
+
"""
|
|
165
|
+
data = {
|
|
166
|
+
"name": name,
|
|
167
|
+
"root_path": root_path,
|
|
168
|
+
}
|
|
169
|
+
if include_patterns:
|
|
170
|
+
data["include_patterns"] = include_patterns
|
|
171
|
+
if exclude_patterns:
|
|
172
|
+
data["exclude_patterns"] = exclude_patterns
|
|
173
|
+
return self._request("POST", "/api/sources", json=data)
|
|
174
|
+
|
|
175
|
+
def update_source(
|
|
176
|
+
self,
|
|
177
|
+
source_id: str,
|
|
178
|
+
name: str | None = None,
|
|
179
|
+
include_patterns: list[str] | None = None,
|
|
180
|
+
exclude_patterns: list[str] | None = None,
|
|
181
|
+
) -> dict:
|
|
182
|
+
"""Update a source.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
source_id: Source ID.
|
|
186
|
+
name: New source name.
|
|
187
|
+
include_patterns: New include patterns (list of globs).
|
|
188
|
+
exclude_patterns: New exclude patterns (list of globs).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Updated source data.
|
|
192
|
+
"""
|
|
193
|
+
data = {}
|
|
194
|
+
if name is not None:
|
|
195
|
+
data["name"] = name
|
|
196
|
+
if include_patterns is not None:
|
|
197
|
+
data["include_patterns"] = include_patterns
|
|
198
|
+
if exclude_patterns is not None:
|
|
199
|
+
data["exclude_patterns"] = exclude_patterns
|
|
200
|
+
return self._request("PUT", f"/api/sources/{source_id}", json=data)
|
|
201
|
+
|
|
202
|
+
def delete_source(self, source_id: str) -> None:
|
|
203
|
+
"""Delete a source."""
|
|
204
|
+
self._request("DELETE", f"/api/sources/{source_id}")
|
|
205
|
+
|
|
206
|
+
def reindex_source(self, source_id: str) -> dict:
|
|
207
|
+
"""Trigger reindex for a source.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
source_id: Source ID.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Reindex result with statistics.
|
|
214
|
+
"""
|
|
215
|
+
return self._request("POST", f"/api/sources/{source_id}/reindex")
|
|
216
|
+
|
|
217
|
+
# Search endpoints
|
|
218
|
+
def search(
|
|
219
|
+
self,
|
|
220
|
+
query: str,
|
|
221
|
+
source_id: str | None = None,
|
|
222
|
+
file_type: str | None = None,
|
|
223
|
+
limit: int = 20,
|
|
224
|
+
offset: int = 0,
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""Search indexed documents.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
query: Search query string.
|
|
230
|
+
source_id: Filter by source ID.
|
|
231
|
+
file_type: Filter by file type (text, markdown, pdf).
|
|
232
|
+
limit: Maximum results to return.
|
|
233
|
+
offset: Result offset for pagination.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Search results with metadata.
|
|
237
|
+
"""
|
|
238
|
+
data: dict = {
|
|
239
|
+
"q": query,
|
|
240
|
+
"limit": limit,
|
|
241
|
+
"offset": offset,
|
|
242
|
+
}
|
|
243
|
+
if source_id is not None:
|
|
244
|
+
data["source_id"] = source_id
|
|
245
|
+
if file_type is not None:
|
|
246
|
+
data["type"] = file_type
|
|
247
|
+
return self._request("POST", "/api/search", json=data)
|
onesearch/banner.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Startup banner rendering for the OneSearch CLI."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from rich.console import Group
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version_tuple(version: str | None) -> tuple[int, ...]:
|
|
14
|
+
if not version:
|
|
15
|
+
return ()
|
|
16
|
+
parts = []
|
|
17
|
+
for chunk in version.split("."):
|
|
18
|
+
digits = "".join(ch for ch in chunk if ch.isdigit())
|
|
19
|
+
if not digits:
|
|
20
|
+
break
|
|
21
|
+
parts.append(int(digits))
|
|
22
|
+
return tuple(parts)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_startup_panel(
|
|
26
|
+
*,
|
|
27
|
+
configured: bool,
|
|
28
|
+
backend_url: str | None,
|
|
29
|
+
server_status: str | None = None,
|
|
30
|
+
auth_state: str | None = None,
|
|
31
|
+
cli_version: str | None = None,
|
|
32
|
+
server_version: str | None = None,
|
|
33
|
+
error_message: str | None = None,
|
|
34
|
+
):
|
|
35
|
+
"""Build the startup banner panel."""
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
title = Text("OneSearch", style="bold cyan")
|
|
38
|
+
|
|
39
|
+
if cli_version:
|
|
40
|
+
lines.append(f"CLI: {cli_version}")
|
|
41
|
+
if server_version:
|
|
42
|
+
lines.append(f"Server: {server_version}")
|
|
43
|
+
if backend_url:
|
|
44
|
+
lines.append(f"Backend: {backend_url}")
|
|
45
|
+
|
|
46
|
+
if not configured:
|
|
47
|
+
lines.extend(
|
|
48
|
+
[
|
|
49
|
+
"",
|
|
50
|
+
"No backend configured.",
|
|
51
|
+
"Run: onesearch config set backend_url http://host:8000",
|
|
52
|
+
"Then: onesearch login",
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
return Panel("\n".join(lines), title=title)
|
|
56
|
+
|
|
57
|
+
if auth_state:
|
|
58
|
+
lines.append(f"Auth: {auth_state}")
|
|
59
|
+
if server_status:
|
|
60
|
+
lines.append(f"Status: {server_status}")
|
|
61
|
+
|
|
62
|
+
if error_message:
|
|
63
|
+
lines.extend(["", f"Error: {error_message}"])
|
|
64
|
+
|
|
65
|
+
if _version_tuple(server_version) > _version_tuple(cli_version):
|
|
66
|
+
lines.extend(
|
|
67
|
+
[
|
|
68
|
+
"",
|
|
69
|
+
"Update available: server is newer than this CLI. Consider updating onesearch-cli.",
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not error_message:
|
|
74
|
+
lines.extend(
|
|
75
|
+
[
|
|
76
|
+
"",
|
|
77
|
+
"Try:",
|
|
78
|
+
' onesearch search "compose"',
|
|
79
|
+
" onesearch source list",
|
|
80
|
+
" onesearch status",
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return Panel(Group(*lines), title=title)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Authentication commands."""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from onesearch.api import APIError
|
|
9
|
+
from onesearch.config import delete_config_value, set_config_value
|
|
10
|
+
from onesearch.context import Context, console, err_console, pass_context
|
|
11
|
+
from onesearch.main import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cli.command()
|
|
15
|
+
@click.option("--token", "use_token", is_flag=True, help="Prompt for a bearer token instead of username/password.")
|
|
16
|
+
@pass_context
|
|
17
|
+
def login(ctx: Context, use_token: bool):
|
|
18
|
+
"""Authenticate and store a CLI token."""
|
|
19
|
+
if use_token:
|
|
20
|
+
token = click.prompt("Token", hide_input=False)
|
|
21
|
+
set_config_value("auth.token", token)
|
|
22
|
+
ctx.reset_api()
|
|
23
|
+
console.print("[green]✓[/green] Token stored for OneSearch CLI")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
username = click.prompt("Username")
|
|
27
|
+
password = click.prompt("Password", hide_input=True)
|
|
28
|
+
|
|
29
|
+
api = ctx.get_api()
|
|
30
|
+
try:
|
|
31
|
+
result = api.login(username, password)
|
|
32
|
+
token = result.get("access_token")
|
|
33
|
+
if not token:
|
|
34
|
+
raise click.ClickException("Login succeeded but no access token was returned.")
|
|
35
|
+
|
|
36
|
+
set_config_value("auth.token", token)
|
|
37
|
+
ctx.reset_api()
|
|
38
|
+
console.print(f"[green]✓[/green] Logged in as [cyan]{username}[/cyan]")
|
|
39
|
+
except APIError as e:
|
|
40
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
41
|
+
raise SystemExit(1) from e
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli.command()
|
|
45
|
+
@pass_context
|
|
46
|
+
def logout(ctx: Context):
|
|
47
|
+
"""Remove the stored CLI token."""
|
|
48
|
+
delete_config_value("auth.token")
|
|
49
|
+
ctx.reset_api()
|
|
50
|
+
console.print("[green]✓[/green] Logged out")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
@pass_context
|
|
55
|
+
def whoami(ctx: Context):
|
|
56
|
+
"""Show the current authenticated user."""
|
|
57
|
+
api = ctx.get_api()
|
|
58
|
+
try:
|
|
59
|
+
user = api.whoami()
|
|
60
|
+
console.print(f"Logged in as [cyan]{user.get('username', 'unknown')}[/cyan]")
|
|
61
|
+
console.print(f"Backend: [dim]{ctx.url}[/dim]")
|
|
62
|
+
except APIError as e:
|
|
63
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
64
|
+
raise SystemExit(1) from e
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Configuration management commands."""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from onesearch.config import (
|
|
10
|
+
DEFAULT_CONFIG,
|
|
11
|
+
delete_config_value,
|
|
12
|
+
get_config_path,
|
|
13
|
+
get_config_value,
|
|
14
|
+
load_config,
|
|
15
|
+
set_config_value,
|
|
16
|
+
)
|
|
17
|
+
from onesearch.context import console, err_console
|
|
18
|
+
from onesearch.main import cli
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cli.group()
|
|
22
|
+
def config():
|
|
23
|
+
"""Manage CLI configuration.
|
|
24
|
+
|
|
25
|
+
\b
|
|
26
|
+
Configuration priority (highest to lowest):
|
|
27
|
+
1. CLI flags (--url)
|
|
28
|
+
2. Environment variables (ONESEARCH_URL)
|
|
29
|
+
3. Config file (~/.config/onesearch/config.yml)
|
|
30
|
+
4. Defaults
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Examples:
|
|
34
|
+
onesearch config show
|
|
35
|
+
onesearch config set backend_url http://onesearch.local:8000
|
|
36
|
+
onesearch config get backend_url
|
|
37
|
+
onesearch config path
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@config.command("show")
|
|
43
|
+
@click.option("--path", is_flag=True, help="Show config file path only.")
|
|
44
|
+
def config_show(path: bool):
|
|
45
|
+
"""Show current configuration."""
|
|
46
|
+
config_path = get_config_path()
|
|
47
|
+
|
|
48
|
+
if path:
|
|
49
|
+
console.print(str(config_path))
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
console.print(f"\n[dim]Config file:[/dim] {config_path}")
|
|
53
|
+
console.print(f"[dim]Exists:[/dim] {config_path.exists()}\n")
|
|
54
|
+
|
|
55
|
+
config_data = load_config()
|
|
56
|
+
if not config_data:
|
|
57
|
+
console.print("[dim]No configuration set.[/dim]")
|
|
58
|
+
console.print("\nCreate a config with: [cyan]onesearch config set <key> <value>[/cyan]")
|
|
59
|
+
console.print("Or initialize defaults: [cyan]onesearch config init[/cyan]")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Pretty print config
|
|
63
|
+
console.print("[bold]Current Configuration:[/bold]")
|
|
64
|
+
yaml_str = yaml.safe_dump(config_data, default_flow_style=False, sort_keys=False)
|
|
65
|
+
for line in yaml_str.strip().split("\n"):
|
|
66
|
+
if ":" in line and not line.strip().startswith("#"):
|
|
67
|
+
key, _, value = line.partition(":")
|
|
68
|
+
console.print(f" [cyan]{key}[/cyan]:{value}")
|
|
69
|
+
else:
|
|
70
|
+
console.print(f" {line}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@config.command("get")
|
|
74
|
+
@click.argument("key")
|
|
75
|
+
def config_get(key: str):
|
|
76
|
+
"""Get a configuration value.
|
|
77
|
+
|
|
78
|
+
\b
|
|
79
|
+
Arguments:
|
|
80
|
+
KEY Configuration key (e.g., backend_url, output.colors)
|
|
81
|
+
"""
|
|
82
|
+
value = get_config_value(key)
|
|
83
|
+
if value is None:
|
|
84
|
+
err_console.print(f"[yellow]Key not found:[/yellow] {key}")
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
|
|
87
|
+
if isinstance(value, (dict, list)):
|
|
88
|
+
console.print(yaml.safe_dump(value, default_flow_style=False))
|
|
89
|
+
else:
|
|
90
|
+
console.print(str(value))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@config.command("set")
|
|
94
|
+
@click.argument("key")
|
|
95
|
+
@click.argument("value")
|
|
96
|
+
def config_set(key: str, value: str):
|
|
97
|
+
"""Set a configuration value.
|
|
98
|
+
|
|
99
|
+
\b
|
|
100
|
+
Arguments:
|
|
101
|
+
KEY Configuration key (e.g., backend_url, output.colors)
|
|
102
|
+
VALUE Value to set
|
|
103
|
+
|
|
104
|
+
\b
|
|
105
|
+
Examples:
|
|
106
|
+
onesearch config set backend_url http://onesearch.local:8000
|
|
107
|
+
onesearch config set output.colors false
|
|
108
|
+
onesearch config set defaults.search_limit 50
|
|
109
|
+
"""
|
|
110
|
+
# Try to parse value as YAML for proper types
|
|
111
|
+
try:
|
|
112
|
+
parsed_value = yaml.safe_load(value)
|
|
113
|
+
except Exception:
|
|
114
|
+
parsed_value = value
|
|
115
|
+
|
|
116
|
+
set_config_value(key, parsed_value)
|
|
117
|
+
console.print(f"[green]✓[/green] Set [cyan]{key}[/cyan] = {parsed_value}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@config.command("unset")
|
|
121
|
+
@click.argument("key")
|
|
122
|
+
def config_unset(key: str):
|
|
123
|
+
"""Remove a configuration value.
|
|
124
|
+
|
|
125
|
+
\b
|
|
126
|
+
Arguments:
|
|
127
|
+
KEY Configuration key to remove
|
|
128
|
+
"""
|
|
129
|
+
if delete_config_value(key):
|
|
130
|
+
console.print(f"[green]✓[/green] Removed [cyan]{key}[/cyan]")
|
|
131
|
+
else:
|
|
132
|
+
err_console.print(f"[yellow]Key not found:[/yellow] {key}")
|
|
133
|
+
raise SystemExit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@config.command("init")
|
|
137
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite existing config.")
|
|
138
|
+
def config_init(force: bool):
|
|
139
|
+
"""Initialize configuration file with defaults.
|
|
140
|
+
|
|
141
|
+
Creates a config file at ~/.config/onesearch/config.yml (Linux/Mac)
|
|
142
|
+
or %APPDATA%/onesearch/config.yml (Windows).
|
|
143
|
+
"""
|
|
144
|
+
config_path = get_config_path()
|
|
145
|
+
|
|
146
|
+
if config_path.exists() and not force:
|
|
147
|
+
console.print(f"[yellow]Config file already exists:[/yellow] {config_path}")
|
|
148
|
+
console.print("\nUse [cyan]--force[/cyan] to overwrite.")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Create config directory
|
|
152
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
|
|
154
|
+
# Write default config
|
|
155
|
+
default_content = DEFAULT_CONFIG.format(config_path=config_path)
|
|
156
|
+
with open(config_path, "w") as f:
|
|
157
|
+
f.write(default_content)
|
|
158
|
+
|
|
159
|
+
console.print(f"[green]✓[/green] Created config file: {config_path}")
|
|
160
|
+
console.print("\nEdit this file or use [cyan]onesearch config set <key> <value>[/cyan]")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@config.command("path")
|
|
164
|
+
def config_path_command():
|
|
165
|
+
"""Show the configuration file path."""
|
|
166
|
+
console.print(str(get_config_path()))
|