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 ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (C) 2025 demigodmode
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ """OneSearch CLI - Command-line interface for OneSearch."""
5
+
6
+ __version__ = "0.12.1"
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,4 @@
1
+ # Copyright (C) 2025 demigodmode
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ """CLI command modules."""
@@ -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()))