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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Search command."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from onesearch.api import APIError
|
|
13
|
+
from onesearch.context import Context, console, err_console, pass_context
|
|
14
|
+
from onesearch.main import cli
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_size(size_bytes: int | None) -> str:
|
|
18
|
+
"""Format bytes as human-readable size."""
|
|
19
|
+
if size_bytes is None:
|
|
20
|
+
return "-"
|
|
21
|
+
size: float = float(size_bytes)
|
|
22
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
23
|
+
if abs(size) < 1024:
|
|
24
|
+
return f"{size:.1f} {unit}"
|
|
25
|
+
size /= 1024
|
|
26
|
+
return f"{size:.1f} TB"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def highlight_snippet(text: str, query: str) -> Text:
|
|
30
|
+
"""Create a Rich Text with query terms highlighted."""
|
|
31
|
+
result = Text()
|
|
32
|
+
text_lower = text.lower()
|
|
33
|
+
query_terms = query.lower().split()
|
|
34
|
+
|
|
35
|
+
i = 0
|
|
36
|
+
while i < len(text):
|
|
37
|
+
matched = False
|
|
38
|
+
for term in query_terms:
|
|
39
|
+
if text_lower[i:].startswith(term):
|
|
40
|
+
result.append(text[i:i + len(term)], style="bold yellow")
|
|
41
|
+
i += len(term)
|
|
42
|
+
matched = True
|
|
43
|
+
break
|
|
44
|
+
if not matched:
|
|
45
|
+
result.append(text[i])
|
|
46
|
+
i += 1
|
|
47
|
+
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cli.command()
|
|
52
|
+
@click.argument("query")
|
|
53
|
+
@click.option("--source", "-s", "source_id", help="Filter by source ID.")
|
|
54
|
+
@click.option("--type", "-t", "file_type", type=click.Choice(["text", "markdown", "pdf"]), help="Filter by file type.")
|
|
55
|
+
@click.option("--limit", "-l", default=20, help="Max results to return.", show_default=True)
|
|
56
|
+
@click.option("--offset", "-o", default=0, help="Result offset for pagination.")
|
|
57
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
|
|
58
|
+
@pass_context
|
|
59
|
+
def search(
|
|
60
|
+
ctx: Context,
|
|
61
|
+
query: str,
|
|
62
|
+
source_id: str | None,
|
|
63
|
+
file_type: str | None,
|
|
64
|
+
limit: int,
|
|
65
|
+
offset: int,
|
|
66
|
+
as_json: bool,
|
|
67
|
+
):
|
|
68
|
+
"""Search indexed documents.
|
|
69
|
+
|
|
70
|
+
\b
|
|
71
|
+
Arguments:
|
|
72
|
+
QUERY Search query string
|
|
73
|
+
|
|
74
|
+
\b
|
|
75
|
+
Examples:
|
|
76
|
+
onesearch search "kubernetes deployment"
|
|
77
|
+
onesearch search "python" --source 1 --type pdf --limit 10
|
|
78
|
+
onesearch search "error" --json | jq '.results[].path'
|
|
79
|
+
"""
|
|
80
|
+
api = ctx.get_api()
|
|
81
|
+
try:
|
|
82
|
+
result = api.search(
|
|
83
|
+
query=query,
|
|
84
|
+
source_id=source_id,
|
|
85
|
+
file_type=file_type,
|
|
86
|
+
limit=limit,
|
|
87
|
+
offset=offset,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
out = ctx.get_console()
|
|
91
|
+
|
|
92
|
+
if as_json:
|
|
93
|
+
console.print(json.dumps(result, indent=2))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Backend returns: {results, total, limit, offset, processing_time_ms}
|
|
97
|
+
results = result.get("results", [])
|
|
98
|
+
total = result.get("total", len(results))
|
|
99
|
+
processing_time = result.get("processing_time_ms", 0)
|
|
100
|
+
|
|
101
|
+
if not results:
|
|
102
|
+
out.print(f"[dim]No results found for:[/dim] [cyan]{escape(query)}[/cyan]")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
out.print(f"\nFound [green]{total}[/green] results in [dim]{processing_time}ms[/dim]\n")
|
|
106
|
+
|
|
107
|
+
for i, hit in enumerate(results, start=offset + 1):
|
|
108
|
+
# Title line - backend returns: basename, source_name
|
|
109
|
+
filename = hit.get("basename", hit.get("path", "Unknown").split("/")[-1])
|
|
110
|
+
source_name = hit.get("source_name", "Unknown")
|
|
111
|
+
console.print(f"[bold][{i}][/bold] [cyan]{escape(filename)}[/cyan] [dim]({source_name})[/dim]")
|
|
112
|
+
|
|
113
|
+
# Metadata line - backend returns: type, size_bytes, modified_at
|
|
114
|
+
file_type_display = hit.get("type", "unknown")
|
|
115
|
+
size = format_size(hit.get("size_bytes"))
|
|
116
|
+
modified = hit.get("modified_at", "-")
|
|
117
|
+
# modified_at is Unix timestamp
|
|
118
|
+
if isinstance(modified, int):
|
|
119
|
+
from datetime import datetime
|
|
120
|
+
modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%d")
|
|
121
|
+
console.print(f" Path: {escape(hit.get('path', '-'))}")
|
|
122
|
+
console.print(f" Type: {file_type_display} | Size: {size} | Modified: {modified}")
|
|
123
|
+
|
|
124
|
+
# Snippet - backend returns pre-formatted snippet
|
|
125
|
+
snippet = hit.get("snippet", "")
|
|
126
|
+
if snippet:
|
|
127
|
+
snippet = snippet.replace("\n", " ").strip()
|
|
128
|
+
highlighted = highlight_snippet(snippet, query)
|
|
129
|
+
console.print(" ", end="")
|
|
130
|
+
console.print(highlighted)
|
|
131
|
+
|
|
132
|
+
console.print()
|
|
133
|
+
|
|
134
|
+
# Pagination hint (only in non-quiet mode)
|
|
135
|
+
if offset + len(results) < total:
|
|
136
|
+
next_offset = offset + limit
|
|
137
|
+
out.print(f"[dim]Showing {offset + 1}-{offset + len(results)} of {total}. ")
|
|
138
|
+
out.print(f"Use --offset {next_offset} for more results.[/dim]")
|
|
139
|
+
|
|
140
|
+
except APIError as e:
|
|
141
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
142
|
+
raise SystemExit(1) from e
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Source management commands."""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from onesearch.api import APIError
|
|
10
|
+
from onesearch.context import Context, console, err_console, pass_context
|
|
11
|
+
from onesearch.main import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_patterns(patterns: str | None) -> list[str] | None:
|
|
15
|
+
"""Parse comma-separated patterns into a list."""
|
|
16
|
+
if not patterns:
|
|
17
|
+
return None
|
|
18
|
+
return [p.strip() for p in patterns.split(",") if p.strip()]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_patterns(patterns: list[str] | None) -> str:
|
|
22
|
+
"""Format a list of patterns for display."""
|
|
23
|
+
if not patterns:
|
|
24
|
+
return "*"
|
|
25
|
+
return ", ".join(patterns)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.group()
|
|
29
|
+
def source():
|
|
30
|
+
"""Manage search sources.
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Examples:
|
|
34
|
+
onesearch source list
|
|
35
|
+
onesearch source add "Documents" /data/docs
|
|
36
|
+
onesearch source show 1
|
|
37
|
+
onesearch source reindex 1
|
|
38
|
+
onesearch source delete 1
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@source.command("list")
|
|
44
|
+
@pass_context
|
|
45
|
+
def source_list(ctx: Context):
|
|
46
|
+
"""List all configured sources."""
|
|
47
|
+
api = ctx.get_api()
|
|
48
|
+
try:
|
|
49
|
+
sources = api.list_sources()
|
|
50
|
+
|
|
51
|
+
if not sources:
|
|
52
|
+
console.print("[dim]No sources configured.[/dim]")
|
|
53
|
+
console.print("\nAdd a source with: [cyan]onesearch source add <name> <path>[/cyan]")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
table = Table(title="Sources")
|
|
57
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
58
|
+
table.add_column("Name", style="green")
|
|
59
|
+
table.add_column("Root Path")
|
|
60
|
+
table.add_column("Include Patterns", style="dim")
|
|
61
|
+
table.add_column("Exclude Patterns", style="dim")
|
|
62
|
+
|
|
63
|
+
for s in sources:
|
|
64
|
+
table.add_row(
|
|
65
|
+
str(s["id"]),
|
|
66
|
+
s["name"],
|
|
67
|
+
s["root_path"],
|
|
68
|
+
format_patterns(s.get("include_patterns")),
|
|
69
|
+
format_patterns(s.get("exclude_patterns")) if s.get("exclude_patterns") else "-",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(table)
|
|
73
|
+
except APIError as e:
|
|
74
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
75
|
+
raise SystemExit(1) from e
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@source.command("add")
|
|
79
|
+
@click.argument("name")
|
|
80
|
+
@click.argument("path", type=click.Path())
|
|
81
|
+
@click.option("--include", "-i", help="Include patterns (comma-separated globs).")
|
|
82
|
+
@click.option("--exclude", "-e", help="Exclude patterns (comma-separated globs).")
|
|
83
|
+
@click.option("--no-validate", is_flag=True, help="Skip path validation (for remote/container paths).")
|
|
84
|
+
@pass_context
|
|
85
|
+
def source_add(ctx: Context, name: str, path: str, include: str | None, exclude: str | None, no_validate: bool):
|
|
86
|
+
"""Add a new source.
|
|
87
|
+
|
|
88
|
+
\b
|
|
89
|
+
Arguments:
|
|
90
|
+
NAME Friendly name for the source
|
|
91
|
+
PATH Root path to index
|
|
92
|
+
|
|
93
|
+
\b
|
|
94
|
+
Examples:
|
|
95
|
+
onesearch source add "Documents" /data/docs
|
|
96
|
+
onesearch source add "Notes" /data/notes --include "**/*.md,**/*.txt"
|
|
97
|
+
onesearch source add "Code" /data/code --exclude "**/node_modules/**,**/.git/**"
|
|
98
|
+
|
|
99
|
+
\b
|
|
100
|
+
Note: Use --no-validate for paths that exist only inside Docker containers.
|
|
101
|
+
"""
|
|
102
|
+
# Validate path exists locally (unless skipped)
|
|
103
|
+
if not no_validate:
|
|
104
|
+
from pathlib import Path as PathLib
|
|
105
|
+
path_obj = PathLib(path)
|
|
106
|
+
if not path_obj.exists():
|
|
107
|
+
err_console.print(f"[yellow]Warning:[/yellow] Path does not exist locally: {path}")
|
|
108
|
+
err_console.print("[dim]If this path exists inside a Docker container, use --no-validate[/dim]")
|
|
109
|
+
if not click.confirm("Continue anyway?"):
|
|
110
|
+
err_console.print("[dim]Aborted.[/dim]")
|
|
111
|
+
raise SystemExit(1)
|
|
112
|
+
elif not path_obj.is_dir():
|
|
113
|
+
err_console.print(f"[red]Error:[/red] Path is not a directory: {path}")
|
|
114
|
+
raise SystemExit(1)
|
|
115
|
+
|
|
116
|
+
api = ctx.get_api()
|
|
117
|
+
try:
|
|
118
|
+
result = api.create_source(
|
|
119
|
+
name=name,
|
|
120
|
+
root_path=path,
|
|
121
|
+
include_patterns=parse_patterns(include),
|
|
122
|
+
exclude_patterns=parse_patterns(exclude),
|
|
123
|
+
)
|
|
124
|
+
out = ctx.get_console()
|
|
125
|
+
out.print(f"[green]✓[/green] Created source [cyan]{result['name']}[/cyan] (ID: {result['id']})")
|
|
126
|
+
out.print(f" Path: {result['root_path']}")
|
|
127
|
+
if result.get("include_patterns"):
|
|
128
|
+
out.print(f" Include: {format_patterns(result['include_patterns'])}")
|
|
129
|
+
if result.get("exclude_patterns"):
|
|
130
|
+
out.print(f" Exclude: {format_patterns(result['exclude_patterns'])}")
|
|
131
|
+
out.print("\nRun [cyan]onesearch source reindex {id}[/cyan] to start indexing.".format(id=result['id']))
|
|
132
|
+
except APIError as e:
|
|
133
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
134
|
+
raise SystemExit(1) from e
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@source.command("show")
|
|
138
|
+
@click.argument("source_id")
|
|
139
|
+
@pass_context
|
|
140
|
+
def source_show(ctx: Context, source_id: str):
|
|
141
|
+
"""Show details for a source.
|
|
142
|
+
|
|
143
|
+
\b
|
|
144
|
+
Arguments:
|
|
145
|
+
SOURCE_ID The source ID to show
|
|
146
|
+
"""
|
|
147
|
+
api = ctx.get_api()
|
|
148
|
+
try:
|
|
149
|
+
s = api.get_source(source_id)
|
|
150
|
+
console.print(f"\n[bold cyan]{s['name']}[/bold cyan] (ID: {s['id']})")
|
|
151
|
+
console.print(f" Root Path: {s['root_path']}")
|
|
152
|
+
console.print(f" Include Patterns: {format_patterns(s.get('include_patterns'))}")
|
|
153
|
+
console.print(f" Exclude Patterns: {format_patterns(s.get('exclude_patterns')) if s.get('exclude_patterns') else '-'}")
|
|
154
|
+
if s.get("created_at"):
|
|
155
|
+
console.print(f" Created: {s['created_at']}")
|
|
156
|
+
if s.get("updated_at"):
|
|
157
|
+
console.print(f" Updated: {s['updated_at']}")
|
|
158
|
+
except APIError as e:
|
|
159
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
160
|
+
raise SystemExit(1) from e
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@source.command("delete")
|
|
164
|
+
@click.argument("source_id")
|
|
165
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
|
|
166
|
+
@pass_context
|
|
167
|
+
def source_delete(ctx: Context, source_id: str, yes: bool):
|
|
168
|
+
"""Delete a source.
|
|
169
|
+
|
|
170
|
+
\b
|
|
171
|
+
Arguments:
|
|
172
|
+
SOURCE_ID The source ID to delete
|
|
173
|
+
|
|
174
|
+
This will remove the source configuration and all indexed documents
|
|
175
|
+
from this source.
|
|
176
|
+
"""
|
|
177
|
+
api = ctx.get_api()
|
|
178
|
+
try:
|
|
179
|
+
# Get source info for confirmation
|
|
180
|
+
s = api.get_source(source_id)
|
|
181
|
+
|
|
182
|
+
if not yes:
|
|
183
|
+
console.print(f"[yellow]Warning:[/yellow] This will delete source [cyan]{s['name']}[/cyan] (ID: {source_id})")
|
|
184
|
+
console.print(f" Path: {s['root_path']}")
|
|
185
|
+
console.print("\nAll indexed documents from this source will be removed.")
|
|
186
|
+
if not click.confirm("Are you sure?"):
|
|
187
|
+
console.print("[dim]Aborted.[/dim]")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
api.delete_source(source_id)
|
|
191
|
+
ctx.get_console().print(f"[green]✓[/green] Deleted source [cyan]{s['name']}[/cyan]")
|
|
192
|
+
except APIError as e:
|
|
193
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
194
|
+
raise SystemExit(1) from e
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@source.command("reindex")
|
|
198
|
+
@click.argument("source_id")
|
|
199
|
+
@pass_context
|
|
200
|
+
def source_reindex(ctx: Context, source_id: str):
|
|
201
|
+
"""Trigger reindex for a source.
|
|
202
|
+
|
|
203
|
+
\b
|
|
204
|
+
Arguments:
|
|
205
|
+
SOURCE_ID The source ID to reindex
|
|
206
|
+
|
|
207
|
+
This will scan the source path and index all matching files.
|
|
208
|
+
"""
|
|
209
|
+
api = ctx.get_api()
|
|
210
|
+
out = ctx.get_console()
|
|
211
|
+
try:
|
|
212
|
+
s = api.get_source(source_id)
|
|
213
|
+
out.print(f"[dim]Reindexing source [cyan]{s['name']}[/cyan]...[/dim]")
|
|
214
|
+
|
|
215
|
+
result = api.reindex_source(source_id)
|
|
216
|
+
|
|
217
|
+
# Backend returns: {"message": ..., "stats": {...}}
|
|
218
|
+
stats = result.get("stats", {})
|
|
219
|
+
|
|
220
|
+
out.print(f"\n[green]✓[/green] Reindex completed for [cyan]{s['name']}[/cyan]")
|
|
221
|
+
out.print(f" Files scanned: {stats.get('total_scanned', 0)}")
|
|
222
|
+
out.print(f" New files: {stats.get('new_files', 0)}")
|
|
223
|
+
out.print(f" Modified: {stats.get('modified_files', 0)}")
|
|
224
|
+
out.print(f" Unchanged: {stats.get('unchanged_files', 0)}")
|
|
225
|
+
out.print(f" [green]Successful: {stats.get('successful', 0)}[/green]")
|
|
226
|
+
if stats.get("failed", 0) > 0:
|
|
227
|
+
out.print(f" [yellow]Failed: {stats.get('failed', 0)}[/yellow]")
|
|
228
|
+
if stats.get("skipped", 0) > 0:
|
|
229
|
+
out.print(f" Skipped: {stats.get('skipped', 0)}")
|
|
230
|
+
except APIError as e:
|
|
231
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
232
|
+
raise SystemExit(1) from e
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Copyright (C) 2025 demigodmode
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
"""Status and health commands."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from onesearch.api import APIError
|
|
13
|
+
from onesearch.config import get_config_path
|
|
14
|
+
from onesearch.context import Context, console, err_console, pass_context
|
|
15
|
+
from onesearch.main import cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_timestamp(ts) -> str:
|
|
19
|
+
"""Format a timestamp for display."""
|
|
20
|
+
if not ts:
|
|
21
|
+
return "-"
|
|
22
|
+
if isinstance(ts, str):
|
|
23
|
+
# ISO format string
|
|
24
|
+
if "T" in ts:
|
|
25
|
+
return ts.replace("T", " ").split(".")[0]
|
|
26
|
+
return ts
|
|
27
|
+
if isinstance(ts, (int, float)):
|
|
28
|
+
# Unix timestamp
|
|
29
|
+
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
30
|
+
return str(ts)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cli.command()
|
|
34
|
+
@click.argument("source_id", required=False)
|
|
35
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
|
|
36
|
+
@pass_context
|
|
37
|
+
def status(ctx: Context, source_id: str | None, as_json: bool):
|
|
38
|
+
"""Show indexing status for sources.
|
|
39
|
+
|
|
40
|
+
\b
|
|
41
|
+
Arguments:
|
|
42
|
+
SOURCE_ID Optional source ID for detailed status
|
|
43
|
+
|
|
44
|
+
\b
|
|
45
|
+
Examples:
|
|
46
|
+
onesearch status # All sources
|
|
47
|
+
onesearch status my-docs # Specific source
|
|
48
|
+
onesearch status --json # JSON output
|
|
49
|
+
"""
|
|
50
|
+
api = ctx.get_api()
|
|
51
|
+
try:
|
|
52
|
+
if source_id is not None:
|
|
53
|
+
# Detailed status for specific source
|
|
54
|
+
# Backend returns flat: {source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, failed_files}
|
|
55
|
+
result = api.source_status(source_id)
|
|
56
|
+
if as_json:
|
|
57
|
+
console.print(json.dumps(result, indent=2, default=str))
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
console.print(f"\n[bold cyan]{result.get('source_name', 'Unknown')}[/bold cyan] (ID: {result.get('source_id', source_id)})")
|
|
61
|
+
console.print()
|
|
62
|
+
|
|
63
|
+
console.print("[bold]Indexing Statistics:[/bold]")
|
|
64
|
+
console.print(f" Total Files: {result.get('total_files', 0)}")
|
|
65
|
+
console.print(f" [green]Successful: {result.get('successful', 0)} ✓[/green]")
|
|
66
|
+
|
|
67
|
+
failed = result.get("failed", 0)
|
|
68
|
+
if failed > 0:
|
|
69
|
+
console.print(f" [red]Failed: {failed} ✗[/red]")
|
|
70
|
+
else:
|
|
71
|
+
console.print(" Failed: 0")
|
|
72
|
+
|
|
73
|
+
skipped = result.get("skipped", 0)
|
|
74
|
+
if skipped > 0:
|
|
75
|
+
console.print(f" [yellow]Skipped: {skipped}[/yellow]")
|
|
76
|
+
|
|
77
|
+
last_indexed = format_timestamp(result.get("last_indexed_at"))
|
|
78
|
+
console.print(f" Last Index: {last_indexed}")
|
|
79
|
+
|
|
80
|
+
# Show failed files if any
|
|
81
|
+
failed_files = result.get("failed_files", [])
|
|
82
|
+
if failed_files:
|
|
83
|
+
console.print(f"\n[bold red]Failed Files ({len(failed_files)}):[/bold red]")
|
|
84
|
+
for f in failed_files[:10]: # Show first 10
|
|
85
|
+
console.print(f" • {f.get('path', 'Unknown')}")
|
|
86
|
+
if f.get("error"):
|
|
87
|
+
console.print(f" [dim]{f['error']}[/dim]")
|
|
88
|
+
if len(failed_files) > 10:
|
|
89
|
+
console.print(f" [dim]... and {len(failed_files) - 10} more[/dim]")
|
|
90
|
+
else:
|
|
91
|
+
# Overview status for all sources
|
|
92
|
+
# Backend returns: {sources: [{source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, ...}]}
|
|
93
|
+
result = api.status()
|
|
94
|
+
if as_json:
|
|
95
|
+
console.print(json.dumps(result, indent=2, default=str))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
sources = result.get("sources", [])
|
|
99
|
+
if not sources:
|
|
100
|
+
console.print("[dim]No sources configured.[/dim]")
|
|
101
|
+
console.print("\nAdd a source with: [cyan]onesearch source add <name> <path>[/cyan]")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
table = Table(title="Source Status")
|
|
105
|
+
table.add_column("Source", style="cyan")
|
|
106
|
+
table.add_column("Total", justify="right")
|
|
107
|
+
table.add_column("Success", justify="right", style="green")
|
|
108
|
+
table.add_column("Failed", justify="right")
|
|
109
|
+
table.add_column("Skipped", justify="right")
|
|
110
|
+
table.add_column("Last Indexed")
|
|
111
|
+
|
|
112
|
+
for s in sources:
|
|
113
|
+
# Check for error status
|
|
114
|
+
if s.get("error"):
|
|
115
|
+
table.add_row(
|
|
116
|
+
f"{s.get('source_name', 'Unknown')} ({s.get('source_id', '?')})",
|
|
117
|
+
"-",
|
|
118
|
+
"-",
|
|
119
|
+
"[red]Error[/red]",
|
|
120
|
+
"-",
|
|
121
|
+
"-",
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
failed = s.get("failed", 0)
|
|
126
|
+
failed_str = f"[red]{failed} ✗[/red]" if failed > 0 else "0"
|
|
127
|
+
|
|
128
|
+
last_indexed = format_timestamp(s.get("last_indexed_at"))
|
|
129
|
+
|
|
130
|
+
table.add_row(
|
|
131
|
+
f"{s.get('source_name', 'Unknown')} ({s.get('source_id', '?')})",
|
|
132
|
+
str(s.get("total_files", 0)),
|
|
133
|
+
f"{s.get('successful', 0)} ✓",
|
|
134
|
+
failed_str,
|
|
135
|
+
str(s.get("skipped", 0)),
|
|
136
|
+
last_indexed,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
console.print()
|
|
140
|
+
console.print(table)
|
|
141
|
+
console.print()
|
|
142
|
+
|
|
143
|
+
except APIError as e:
|
|
144
|
+
err_console.print(f"[red]Error:[/red] {e.message}")
|
|
145
|
+
raise SystemExit(1) from e
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
|
|
150
|
+
@pass_context
|
|
151
|
+
def health(ctx: Context, as_json: bool):
|
|
152
|
+
"""Check system health.
|
|
153
|
+
|
|
154
|
+
Verifies connectivity to the backend and Meilisearch,
|
|
155
|
+
and displays current configuration.
|
|
156
|
+
|
|
157
|
+
\b
|
|
158
|
+
Examples:
|
|
159
|
+
onesearch health
|
|
160
|
+
onesearch health --json
|
|
161
|
+
"""
|
|
162
|
+
api = ctx.get_api()
|
|
163
|
+
config_path = get_config_path()
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = api.health()
|
|
167
|
+
|
|
168
|
+
if as_json:
|
|
169
|
+
# Include config info in JSON output
|
|
170
|
+
result["config"] = {
|
|
171
|
+
"config_file": str(config_path),
|
|
172
|
+
"config_exists": config_path.exists(),
|
|
173
|
+
"backend_url": ctx.url,
|
|
174
|
+
}
|
|
175
|
+
console.print(json.dumps(result, indent=2))
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Essential status always shown (even in quiet mode)
|
|
179
|
+
overall = result.get("status", "unknown")
|
|
180
|
+
if overall == "healthy":
|
|
181
|
+
console.print("[bold green]✓ System Healthy[/bold green]")
|
|
182
|
+
else:
|
|
183
|
+
console.print(f"[bold yellow]⚠ System Status: {overall}[/bold yellow]")
|
|
184
|
+
|
|
185
|
+
# Detailed info only in non-quiet mode
|
|
186
|
+
out = ctx.get_console()
|
|
187
|
+
out.print()
|
|
188
|
+
out.print("[bold]Services:[/bold]")
|
|
189
|
+
out.print(f" Backend: {ctx.url} [green]✓[/green]")
|
|
190
|
+
out.print(f" Service: {result.get('service', 'onesearch-backend')} v{result.get('version', '?')}")
|
|
191
|
+
|
|
192
|
+
# Meilisearch status - backend returns {status: "available"|"degraded"|...}
|
|
193
|
+
meili = result.get("meilisearch", {})
|
|
194
|
+
meili_status = meili.get("status", "unknown")
|
|
195
|
+
if meili_status in ("available", "healthy"):
|
|
196
|
+
out.print(" Meilisearch: Connected [green]✓[/green]")
|
|
197
|
+
else:
|
|
198
|
+
out.print(f" Meilisearch: [yellow]{meili_status}[/yellow]")
|
|
199
|
+
|
|
200
|
+
out.print()
|
|
201
|
+
out.print("[bold]Configuration:[/bold]")
|
|
202
|
+
out.print(f" Config file: {config_path}")
|
|
203
|
+
if config_path.exists():
|
|
204
|
+
out.print(" Config: [green]Loaded[/green]")
|
|
205
|
+
else:
|
|
206
|
+
out.print(" Config: [dim]Using defaults[/dim]")
|
|
207
|
+
out.print()
|
|
208
|
+
|
|
209
|
+
except APIError as e:
|
|
210
|
+
err_console.print("[red]✗ System Unhealthy[/red]")
|
|
211
|
+
err_console.print(f"\n Backend: {ctx.url} [red]✗[/red]")
|
|
212
|
+
err_console.print(f" Error: {e.message}")
|
|
213
|
+
err_console.print()
|
|
214
|
+
err_console.print("[bold]Configuration:[/bold]")
|
|
215
|
+
err_console.print(f" Config file: {config_path}")
|
|
216
|
+
if config_path.exists():
|
|
217
|
+
err_console.print(" Config: [green]Loaded[/green]")
|
|
218
|
+
else:
|
|
219
|
+
err_console.print(" Config: [dim]Using defaults[/dim]")
|
|
220
|
+
console.print()
|
|
221
|
+
raise SystemExit(1) from e
|