moss-cli 0.1.0__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.
- moss_cli/__init__.py +3 -0
- moss_cli/commands/__init__.py +0 -0
- moss_cli/commands/doc.py +101 -0
- moss_cli/commands/index.py +92 -0
- moss_cli/commands/init_cmd.py +52 -0
- moss_cli/commands/job.py +42 -0
- moss_cli/commands/search.py +74 -0
- moss_cli/commands/version.py +24 -0
- moss_cli/config.py +57 -0
- moss_cli/documents.py +116 -0
- moss_cli/job_waiter.py +71 -0
- moss_cli/main.py +77 -0
- moss_cli/output.py +221 -0
- moss_cli-0.1.0.dist-info/METADATA +229 -0
- moss_cli-0.1.0.dist-info/RECORD +19 -0
- moss_cli-0.1.0.dist-info/WHEEL +5 -0
- moss_cli-0.1.0.dist-info/entry_points.txt +2 -0
- moss_cli-0.1.0.dist-info/licenses/LICENSE +6 -0
- moss_cli-0.1.0.dist-info/top_level.txt +1 -0
moss_cli/__init__.py
ADDED
|
File without changes
|
moss_cli/commands/doc.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""moss doc {add, delete, get} commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from moss import MossClient, GetDocumentsOptions, MutationOptions
|
|
12
|
+
|
|
13
|
+
from .. import output
|
|
14
|
+
from ..config import resolve_credentials
|
|
15
|
+
from ..documents import load_documents
|
|
16
|
+
from ..job_waiter import wait_for_job
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
doc_app = typer.Typer(name="doc", help="Manage documents in an index")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client(ctx: typer.Context) -> MossClient:
|
|
23
|
+
pid, pkey = resolve_credentials(
|
|
24
|
+
ctx.obj.get("project_id"), ctx.obj.get("project_key")
|
|
25
|
+
)
|
|
26
|
+
return MossClient(pid, pkey)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@doc_app.command(name="add")
|
|
30
|
+
def add(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
index_name: str = typer.Argument(..., help="Index name"),
|
|
33
|
+
file: str = typer.Option(..., "--file", "-f", help="Path to JSON/CSV document file, or '-' for stdin"),
|
|
34
|
+
upsert: bool = typer.Option(False, "--upsert", "-u", help="Update existing documents"),
|
|
35
|
+
wait: bool = typer.Option(False, "--wait", "-w", help="Wait for job to complete"),
|
|
36
|
+
poll_interval: float = typer.Option(2.0, "--poll-interval", help="Seconds between status checks"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Add documents to an index."""
|
|
39
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
40
|
+
client = _client(ctx)
|
|
41
|
+
docs = load_documents(file)
|
|
42
|
+
|
|
43
|
+
options = MutationOptions(upsert=True) if upsert else None
|
|
44
|
+
|
|
45
|
+
if not json_mode:
|
|
46
|
+
console.print(
|
|
47
|
+
f"Adding {len(docs)} document(s) to [cyan]{index_name}[/cyan]"
|
|
48
|
+
f"{' (upsert)' if upsert else ''}..."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = asyncio.run(client.add_docs(index_name, docs, options))
|
|
52
|
+
output.print_mutation_result(result, json_mode=json_mode)
|
|
53
|
+
|
|
54
|
+
if wait:
|
|
55
|
+
asyncio.run(wait_for_job(client, result.job_id, poll_interval, json_mode))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@doc_app.command(name="delete")
|
|
59
|
+
def delete(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
index_name: str = typer.Argument(..., help="Index name"),
|
|
62
|
+
ids: str = typer.Option(..., "--ids", "-i", help="Comma-separated document IDs"),
|
|
63
|
+
wait: bool = typer.Option(False, "--wait", "-w", help="Wait for job to complete"),
|
|
64
|
+
poll_interval: float = typer.Option(2.0, "--poll-interval", help="Seconds between status checks"),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Delete documents from an index by ID."""
|
|
67
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
68
|
+
client = _client(ctx)
|
|
69
|
+
doc_ids = [i.strip() for i in ids.split(",") if i.strip()]
|
|
70
|
+
|
|
71
|
+
if not doc_ids:
|
|
72
|
+
output.print_error("No document IDs provided.", json_mode=json_mode)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
if not json_mode:
|
|
76
|
+
console.print(f"Deleting {len(doc_ids)} document(s) from [cyan]{index_name}[/cyan]...")
|
|
77
|
+
|
|
78
|
+
result = asyncio.run(client.delete_docs(index_name, doc_ids))
|
|
79
|
+
output.print_mutation_result(result, json_mode=json_mode)
|
|
80
|
+
|
|
81
|
+
if wait:
|
|
82
|
+
asyncio.run(wait_for_job(client, result.job_id, poll_interval, json_mode))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@doc_app.command(name="get")
|
|
86
|
+
def get(
|
|
87
|
+
ctx: typer.Context,
|
|
88
|
+
index_name: str = typer.Argument(..., help="Index name"),
|
|
89
|
+
ids: Optional[str] = typer.Option(None, "--ids", "-i", help="Comma-separated document IDs (omit for all)"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Retrieve documents from an index."""
|
|
92
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
93
|
+
client = _client(ctx)
|
|
94
|
+
|
|
95
|
+
options = None
|
|
96
|
+
if ids:
|
|
97
|
+
doc_ids = [i.strip() for i in ids.split(",") if i.strip()]
|
|
98
|
+
options = GetDocumentsOptions(doc_ids=doc_ids)
|
|
99
|
+
|
|
100
|
+
docs = asyncio.run(client.get_docs(index_name, options))
|
|
101
|
+
output.print_doc_table(docs, json_mode=json_mode)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""moss index {create, list, get, delete} commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from moss import MossClient
|
|
12
|
+
|
|
13
|
+
from .. import output
|
|
14
|
+
from ..config import resolve_credentials
|
|
15
|
+
from ..documents import load_documents
|
|
16
|
+
from ..job_waiter import wait_for_job
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
index_app = typer.Typer(name="index", help="Manage indexes")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client(ctx: typer.Context) -> MossClient:
|
|
23
|
+
pid, pkey = resolve_credentials(
|
|
24
|
+
ctx.obj.get("project_id"), ctx.obj.get("project_key")
|
|
25
|
+
)
|
|
26
|
+
return MossClient(pid, pkey)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@index_app.command(name="create")
|
|
30
|
+
def create(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
name: str = typer.Argument(..., help="Index name"),
|
|
33
|
+
file: str = typer.Option(..., "--file", "-f", help="Path to JSON/CSV document file, or '-' for stdin"),
|
|
34
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model ID (default: moss-minilm)"),
|
|
35
|
+
wait: bool = typer.Option(False, "--wait", "-w", help="Wait for job to complete"),
|
|
36
|
+
poll_interval: float = typer.Option(2.0, "--poll-interval", help="Seconds between status checks"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a new index with documents."""
|
|
39
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
40
|
+
client = _client(ctx)
|
|
41
|
+
docs = load_documents(file)
|
|
42
|
+
|
|
43
|
+
if not json_mode:
|
|
44
|
+
console.print(f"Creating index [cyan]{name}[/cyan] with {len(docs)} document(s)...")
|
|
45
|
+
|
|
46
|
+
result = asyncio.run(client.create_index(name, docs, model))
|
|
47
|
+
output.print_mutation_result(result, json_mode=json_mode)
|
|
48
|
+
|
|
49
|
+
if wait:
|
|
50
|
+
asyncio.run(wait_for_job(client, result.job_id, poll_interval, json_mode))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@index_app.command(name="list")
|
|
54
|
+
def list_indexes(ctx: typer.Context) -> None:
|
|
55
|
+
"""List all indexes."""
|
|
56
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
57
|
+
client = _client(ctx)
|
|
58
|
+
indexes = asyncio.run(client.list_indexes())
|
|
59
|
+
output.print_index_table(indexes, json_mode=json_mode)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@index_app.command(name="get")
|
|
63
|
+
def get(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
name: str = typer.Argument(..., help="Index name"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Get details of an index."""
|
|
68
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
69
|
+
client = _client(ctx)
|
|
70
|
+
info = asyncio.run(client.get_index(name))
|
|
71
|
+
output.print_index_detail(info, json_mode=json_mode)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@index_app.command(name="delete")
|
|
75
|
+
def delete(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
name: str = typer.Argument(..., help="Index name"),
|
|
78
|
+
confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Delete an index."""
|
|
81
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
82
|
+
if not confirm and not json_mode:
|
|
83
|
+
typer.confirm(f"Delete index '{name}'?", abort=True)
|
|
84
|
+
|
|
85
|
+
client = _client(ctx)
|
|
86
|
+
result = asyncio.run(client.delete_index(name))
|
|
87
|
+
|
|
88
|
+
if result:
|
|
89
|
+
output.print_success(f"Index '{name}' deleted.", json_mode=json_mode)
|
|
90
|
+
else:
|
|
91
|
+
output.print_error(f"Failed to delete index '{name}'.", json_mode=json_mode)
|
|
92
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""moss init — interactive credential setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.prompt import Prompt
|
|
10
|
+
|
|
11
|
+
from moss import MossClient
|
|
12
|
+
|
|
13
|
+
from ..config import get_config_path, load_config, save_config
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_command(
|
|
19
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing config"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Save project credentials to ~/.moss/config.json."""
|
|
22
|
+
path = get_config_path()
|
|
23
|
+
if path.exists() and not force:
|
|
24
|
+
existing = load_config()
|
|
25
|
+
if existing.get("project_id"):
|
|
26
|
+
console.print(f"[yellow]Config already exists at {path}[/yellow]")
|
|
27
|
+
console.print(f" Project ID: {existing['project_id'][:8]}...")
|
|
28
|
+
overwrite = Prompt.ask("Overwrite?", choices=["y", "n"], default="n")
|
|
29
|
+
if overwrite != "y":
|
|
30
|
+
raise typer.Abort()
|
|
31
|
+
|
|
32
|
+
project_id = Prompt.ask("Project ID")
|
|
33
|
+
project_key = Prompt.ask("Project Key", password=True)
|
|
34
|
+
|
|
35
|
+
if not project_id or not project_key:
|
|
36
|
+
console.print("[red]Both project ID and key are required.[/red]")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
# Test credentials
|
|
40
|
+
console.print("[dim]Validating credentials...[/dim]")
|
|
41
|
+
try:
|
|
42
|
+
client = MossClient(project_id, project_key)
|
|
43
|
+
indexes = asyncio.run(client.list_indexes())
|
|
44
|
+
console.print(f"[green]Authenticated. Found {len(indexes)} index(es).[/green]")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
console.print(f"[red]Authentication failed: {e}[/red]")
|
|
47
|
+
save_anyway = Prompt.ask("Save anyway?", choices=["y", "n"], default="n")
|
|
48
|
+
if save_anyway != "y":
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
save_config({"project_id": project_id, "project_key": project_key})
|
|
52
|
+
console.print(f"[green]Config saved to {path}[/green]")
|
moss_cli/commands/job.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""moss job status command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from moss import MossClient
|
|
11
|
+
|
|
12
|
+
from .. import output
|
|
13
|
+
from ..config import resolve_credentials
|
|
14
|
+
from ..job_waiter import wait_for_job
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
job_app = typer.Typer(name="job", help="Track background jobs")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client(ctx: typer.Context) -> MossClient:
|
|
21
|
+
pid, pkey = resolve_credentials(
|
|
22
|
+
ctx.obj.get("project_id"), ctx.obj.get("project_key")
|
|
23
|
+
)
|
|
24
|
+
return MossClient(pid, pkey)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@job_app.command(name="status")
|
|
28
|
+
def status(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
31
|
+
wait: bool = typer.Option(False, "--wait", "-w", help="Poll until job completes"),
|
|
32
|
+
poll_interval: float = typer.Option(2.0, "--poll-interval", help="Seconds between status checks"),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Get the status of a background job."""
|
|
35
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
36
|
+
client = _client(ctx)
|
|
37
|
+
|
|
38
|
+
if wait:
|
|
39
|
+
asyncio.run(wait_for_job(client, job_id, poll_interval, json_mode))
|
|
40
|
+
else:
|
|
41
|
+
result = asyncio.run(client.get_job_status(job_id))
|
|
42
|
+
output.print_job_status(result, json_mode=json_mode)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""moss query command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from moss import MossClient, QueryOptions
|
|
14
|
+
|
|
15
|
+
from .. import output
|
|
16
|
+
from ..config import resolve_credentials
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def query_command(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
index_name: str = typer.Argument(..., help="Index name"),
|
|
24
|
+
query_text: Optional[str] = typer.Argument(None, help="Search query (reads from stdin if omitted)"),
|
|
25
|
+
top_k: int = typer.Option(10, "--top-k", "-k", help="Number of results"),
|
|
26
|
+
alpha: float = typer.Option(0.8, "--alpha", "-a", help="Semantic weight (0.0=keyword, 1.0=semantic)"),
|
|
27
|
+
filter_json: Optional[str] = typer.Option(None, "--filter", help="Metadata filter as JSON string"),
|
|
28
|
+
cloud: bool = typer.Option(False, "--cloud", "-c", help="Query via cloud API instead of downloading the index"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Query an index. Downloads the index and queries on-device by default. Use --cloud to skip the download and query via the cloud API."""
|
|
31
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
32
|
+
|
|
33
|
+
# Resolve query text
|
|
34
|
+
if query_text is None:
|
|
35
|
+
if sys.stdin.isatty():
|
|
36
|
+
output.print_error("No query provided. Pass as argument or pipe via stdin.", json_mode)
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
query_text = sys.stdin.read().strip()
|
|
39
|
+
if not query_text:
|
|
40
|
+
output.print_error("Empty query from stdin.", json_mode)
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
# Parse filter
|
|
44
|
+
parsed_filter = None
|
|
45
|
+
if filter_json:
|
|
46
|
+
try:
|
|
47
|
+
parsed_filter = json.loads(filter_json)
|
|
48
|
+
except json.JSONDecodeError as e:
|
|
49
|
+
output.print_error(f"Invalid --filter JSON: {e}", json_mode)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
pid, pkey = resolve_credentials(
|
|
53
|
+
ctx.obj.get("project_id"), ctx.obj.get("project_key")
|
|
54
|
+
)
|
|
55
|
+
if cloud and parsed_filter:
|
|
56
|
+
output.print_error(
|
|
57
|
+
"Metadata filters are only supported for local queries. Remove --cloud or --filter.",
|
|
58
|
+
json_mode,
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
client = MossClient(pid, pkey)
|
|
63
|
+
|
|
64
|
+
async def _run() -> None:
|
|
65
|
+
if not cloud:
|
|
66
|
+
if not json_mode:
|
|
67
|
+
console.print(f"Loading index [cyan]{index_name}[/cyan] locally...")
|
|
68
|
+
await client.load_index(index_name)
|
|
69
|
+
|
|
70
|
+
options = QueryOptions(top_k=top_k, alpha=alpha, filter=parsed_filter)
|
|
71
|
+
result = await client.query(index_name, query_text, options)
|
|
72
|
+
output.print_search_results(result, json_mode=json_mode)
|
|
73
|
+
|
|
74
|
+
asyncio.run(_run())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""moss version command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def version_command(ctx: typer.Context) -> None:
|
|
12
|
+
"""Print CLI and SDK version information."""
|
|
13
|
+
import moss_cli
|
|
14
|
+
import moss
|
|
15
|
+
|
|
16
|
+
json_mode = ctx.obj.get("json_output", False)
|
|
17
|
+
|
|
18
|
+
if json_mode:
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
print(json.dumps({"cli": moss_cli.__version__, "sdk": moss.__version__}))
|
|
22
|
+
else:
|
|
23
|
+
console.print(f"moss-cli {moss_cli.__version__}")
|
|
24
|
+
console.print(f"moss SDK {moss.__version__}")
|
moss_cli/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Auth resolution: CLI flags > env vars > config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_config_path() -> Path:
|
|
14
|
+
return Path.home() / ".moss" / "config.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
path = get_config_path()
|
|
19
|
+
if not path.exists():
|
|
20
|
+
return {}
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(path.read_text())
|
|
23
|
+
except (json.JSONDecodeError, OSError):
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(data: dict) -> None:
|
|
28
|
+
path = get_config_path()
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
30
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
31
|
+
with os.fdopen(fd, "w") as f:
|
|
32
|
+
json.dump(data, f, indent=2)
|
|
33
|
+
f.write("\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_credentials(
|
|
37
|
+
project_id: Optional[str] = None,
|
|
38
|
+
project_key: Optional[str] = None,
|
|
39
|
+
) -> Tuple[str, str]:
|
|
40
|
+
"""Resolve credentials from flags, env vars, or config file."""
|
|
41
|
+
pid = project_id or os.getenv("MOSS_PROJECT_ID")
|
|
42
|
+
pkey = project_key or os.getenv("MOSS_PROJECT_KEY")
|
|
43
|
+
|
|
44
|
+
if pid and pkey:
|
|
45
|
+
return pid, pkey
|
|
46
|
+
|
|
47
|
+
config = load_config()
|
|
48
|
+
pid = pid or config.get("project_id")
|
|
49
|
+
pkey = pkey or config.get("project_key")
|
|
50
|
+
|
|
51
|
+
if not pid or not pkey:
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
"Missing credentials. Provide --project-id/--project-key, "
|
|
54
|
+
"set MOSS_PROJECT_ID/MOSS_PROJECT_KEY env vars, "
|
|
55
|
+
"or run 'moss init' to save them."
|
|
56
|
+
)
|
|
57
|
+
return pid, pkey
|
moss_cli/documents.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Load documents from JSON/CSV files or stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, List
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from moss import DocumentInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_documents(file_path: str) -> List[DocumentInfo]:
|
|
16
|
+
"""Load documents from a JSON/CSV file or stdin ('-')."""
|
|
17
|
+
if file_path == "-":
|
|
18
|
+
raw = sys.stdin.read()
|
|
19
|
+
return _parse_json_docs(raw, source="stdin")
|
|
20
|
+
|
|
21
|
+
path = Path(file_path)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise typer.BadParameter(f"File not found: {file_path}")
|
|
24
|
+
|
|
25
|
+
suffix = path.suffix.lower()
|
|
26
|
+
content = path.read_text()
|
|
27
|
+
|
|
28
|
+
if suffix == ".csv":
|
|
29
|
+
return _parse_csv_docs(content)
|
|
30
|
+
elif suffix == ".jsonl":
|
|
31
|
+
return _parse_jsonl_docs(content, source=file_path)
|
|
32
|
+
elif suffix == ".json":
|
|
33
|
+
return _parse_json_docs(content, source=file_path)
|
|
34
|
+
else:
|
|
35
|
+
return _parse_json_docs(content, source=file_path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_json_docs(raw: str, source: str = "input") -> List[DocumentInfo]:
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(raw)
|
|
41
|
+
except json.JSONDecodeError as e:
|
|
42
|
+
raise typer.BadParameter(f"Invalid JSON in {source}: {e}")
|
|
43
|
+
|
|
44
|
+
if isinstance(data, dict):
|
|
45
|
+
data = data.get("documents", data.get("docs", []))
|
|
46
|
+
|
|
47
|
+
if not isinstance(data, list):
|
|
48
|
+
raise typer.BadParameter(
|
|
49
|
+
f"Expected a JSON array of documents, got {type(data).__name__}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return [_dict_to_doc(d, i) for i, d in enumerate(data)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_jsonl_docs(raw: str, source: str = "input") -> List[DocumentInfo]:
|
|
56
|
+
docs = []
|
|
57
|
+
for line_no, line in enumerate(raw.splitlines(), start=1):
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
obj = json.loads(line)
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
raise typer.BadParameter(f"Invalid JSON on line {line_no} in {source}: {e}")
|
|
65
|
+
docs.append(_dict_to_doc(obj, line_no - 1))
|
|
66
|
+
return docs
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_csv_docs(content: str) -> List[DocumentInfo]:
|
|
70
|
+
reader = csv.DictReader(content.splitlines())
|
|
71
|
+
docs = []
|
|
72
|
+
for i, row in enumerate(reader):
|
|
73
|
+
if "id" not in row or "text" not in row:
|
|
74
|
+
raise typer.BadParameter(
|
|
75
|
+
f"CSV row {i + 1}: missing required 'id' or 'text' column"
|
|
76
|
+
)
|
|
77
|
+
metadata = None
|
|
78
|
+
if "metadata" in row and row["metadata"]:
|
|
79
|
+
try:
|
|
80
|
+
metadata = json.loads(row["metadata"])
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
raise typer.BadParameter(
|
|
83
|
+
f"CSV row {i + 1}: invalid JSON in 'metadata' column"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
embedding = None
|
|
87
|
+
if "embedding" in row and row["embedding"]:
|
|
88
|
+
try:
|
|
89
|
+
embedding = json.loads(row["embedding"])
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
raise typer.BadParameter(
|
|
92
|
+
f"CSV row {i + 1}: invalid JSON in 'embedding' column"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
docs.append(
|
|
96
|
+
DocumentInfo(
|
|
97
|
+
id=row["id"],
|
|
98
|
+
text=row["text"],
|
|
99
|
+
metadata=metadata,
|
|
100
|
+
embedding=embedding,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return docs
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dict_to_doc(d: Any, index: int) -> DocumentInfo:
|
|
107
|
+
if not isinstance(d, dict):
|
|
108
|
+
raise typer.BadParameter(f"Document at index {index}: expected object, got {type(d).__name__}")
|
|
109
|
+
if "id" not in d or "text" not in d:
|
|
110
|
+
raise typer.BadParameter(f"Document at index {index}: missing required 'id' or 'text' field")
|
|
111
|
+
return DocumentInfo(
|
|
112
|
+
id=str(d["id"]),
|
|
113
|
+
text=str(d["text"]),
|
|
114
|
+
metadata=d.get("metadata"),
|
|
115
|
+
embedding=d.get("embedding"),
|
|
116
|
+
)
|
moss_cli/job_waiter.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Poll job status with a rich progress bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.spinner import Spinner
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from moss import MossClient
|
|
13
|
+
|
|
14
|
+
from . import output
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _status_str(status_obj: object) -> str:
|
|
20
|
+
raw = status_obj.status.value if hasattr(status_obj.status, "value") else str(status_obj.status)
|
|
21
|
+
return raw.upper()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _progress_float(status_obj: object) -> float:
|
|
25
|
+
p = float(status_obj.progress)
|
|
26
|
+
return p / 100.0 if p > 1 else p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def wait_for_job(
|
|
30
|
+
client: MossClient,
|
|
31
|
+
job_id: str,
|
|
32
|
+
poll_interval: float = 2.0,
|
|
33
|
+
json_mode: bool = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Poll job status until terminal state, showing progress."""
|
|
36
|
+
terminal = {"COMPLETED", "FAILED"}
|
|
37
|
+
|
|
38
|
+
if json_mode:
|
|
39
|
+
while True:
|
|
40
|
+
status = await client.get_job_status(job_id)
|
|
41
|
+
status_val = _status_str(status)
|
|
42
|
+
if status_val in terminal:
|
|
43
|
+
output.print_job_status(status, json_mode=True)
|
|
44
|
+
if status_val == "FAILED":
|
|
45
|
+
raise SystemExit(1)
|
|
46
|
+
return
|
|
47
|
+
await asyncio.sleep(poll_interval)
|
|
48
|
+
|
|
49
|
+
with Live(Spinner("dots", text="Waiting for job..."), console=console, transient=True) as live:
|
|
50
|
+
while True:
|
|
51
|
+
status = await client.get_job_status(job_id)
|
|
52
|
+
status_val = _status_str(status)
|
|
53
|
+
|
|
54
|
+
phase = getattr(status, "current_phase", None)
|
|
55
|
+
phase_str = ""
|
|
56
|
+
if phase is not None:
|
|
57
|
+
phase_str = f" ({phase.value if hasattr(phase, 'value') else str(phase)})"
|
|
58
|
+
|
|
59
|
+
progress_pct = f"{_progress_float(status):.0%}"
|
|
60
|
+
text = Text.from_markup(
|
|
61
|
+
f"[yellow]{status_val}[/yellow] {progress_pct}{phase_str}"
|
|
62
|
+
)
|
|
63
|
+
live.update(Spinner("dots", text=text))
|
|
64
|
+
|
|
65
|
+
if status_val in terminal:
|
|
66
|
+
break
|
|
67
|
+
await asyncio.sleep(poll_interval)
|
|
68
|
+
|
|
69
|
+
output.print_job_status(status, json_mode=False)
|
|
70
|
+
if status_val == "FAILED":
|
|
71
|
+
raise SystemExit(1)
|
moss_cli/main.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Main CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .commands.doc import doc_app
|
|
12
|
+
from .commands.index import index_app
|
|
13
|
+
from .commands.init_cmd import init_command
|
|
14
|
+
from .commands.job import job_app
|
|
15
|
+
from .commands.search import query_command
|
|
16
|
+
from .commands.version import version_command
|
|
17
|
+
from .output import print_error
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="moss",
|
|
23
|
+
help="Moss Semantic Search CLI",
|
|
24
|
+
add_completion=True,
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Register subgroups
|
|
29
|
+
app.add_typer(index_app, name="index", help="Manage indexes")
|
|
30
|
+
app.add_typer(doc_app, name="doc", help="Manage documents")
|
|
31
|
+
app.add_typer(job_app, name="job", help="Track background jobs")
|
|
32
|
+
|
|
33
|
+
# Register top-level commands
|
|
34
|
+
app.command(name="query")(query_command)
|
|
35
|
+
app.command(name="init")(init_command)
|
|
36
|
+
app.command(name="version")(version_command)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def main(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
project_id: Optional[str] = typer.Option(
|
|
43
|
+
None, "--project-id", "-p", envvar="MOSS_PROJECT_ID", help="Project ID"
|
|
44
|
+
),
|
|
45
|
+
project_key: Optional[str] = typer.Option(
|
|
46
|
+
None, "--project-key", envvar="MOSS_PROJECT_KEY", help="Project key"
|
|
47
|
+
),
|
|
48
|
+
json_output: bool = typer.Option(
|
|
49
|
+
False, "--json", help="Output as JSON"
|
|
50
|
+
),
|
|
51
|
+
verbose: bool = typer.Option(
|
|
52
|
+
False, "--verbose", "-v", help="Enable debug logging"
|
|
53
|
+
),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Moss Semantic Search CLI."""
|
|
56
|
+
ctx.ensure_object(dict)
|
|
57
|
+
ctx.obj["project_id"] = project_id
|
|
58
|
+
ctx.obj["project_key"] = project_key
|
|
59
|
+
ctx.obj["json_output"] = json_output
|
|
60
|
+
|
|
61
|
+
if verbose:
|
|
62
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run() -> None:
|
|
66
|
+
"""Entry point for the console script."""
|
|
67
|
+
try:
|
|
68
|
+
app()
|
|
69
|
+
except (typer.Exit, typer.Abort, SystemExit):
|
|
70
|
+
raise
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print_error(str(e))
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
run()
|
moss_cli/output.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Output formatting: tables, JSON, search results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
err_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _index_to_dict(info: Any) -> Dict[str, Any]:
|
|
17
|
+
return {
|
|
18
|
+
"id": info.id,
|
|
19
|
+
"name": info.name,
|
|
20
|
+
"version": info.version,
|
|
21
|
+
"status": info.status,
|
|
22
|
+
"doc_count": info.doc_count,
|
|
23
|
+
"created_at": info.created_at,
|
|
24
|
+
"updated_at": info.updated_at,
|
|
25
|
+
"model": {"id": info.model.id, "version": info.model.version},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _doc_to_dict(doc: Any) -> Dict[str, Any]:
|
|
30
|
+
d: Dict[str, Any] = {"id": doc.id, "text": doc.text}
|
|
31
|
+
meta = getattr(doc, "metadata", None)
|
|
32
|
+
if meta is not None:
|
|
33
|
+
d["metadata"] = dict(meta)
|
|
34
|
+
emb = getattr(doc, "embedding", None)
|
|
35
|
+
if emb is not None:
|
|
36
|
+
d["embedding"] = list(emb)
|
|
37
|
+
return d
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _result_doc_to_dict(doc: Any) -> Dict[str, Any]:
|
|
41
|
+
d: Dict[str, Any] = {"id": doc.id, "text": doc.text, "score": doc.score}
|
|
42
|
+
meta = getattr(doc, "metadata", None)
|
|
43
|
+
if meta is not None:
|
|
44
|
+
d["metadata"] = dict(meta)
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _search_result_to_dict(result: Any) -> Dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"query": result.query,
|
|
51
|
+
"index_name": result.index_name,
|
|
52
|
+
"time_taken_ms": result.time_taken_ms,
|
|
53
|
+
"docs": [_result_doc_to_dict(d) for d in result.docs],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mutation_to_dict(result: Any) -> Dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"job_id": result.job_id,
|
|
60
|
+
"index_name": result.index_name,
|
|
61
|
+
"doc_count": result.doc_count,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _job_status_to_dict(status: Any) -> Dict[str, Any]:
|
|
66
|
+
d: Dict[str, Any] = {
|
|
67
|
+
"job_id": status.job_id,
|
|
68
|
+
"status": status.status.value if hasattr(status.status, "value") else str(status.status),
|
|
69
|
+
"progress": status.progress,
|
|
70
|
+
"created_at": status.created_at,
|
|
71
|
+
"updated_at": status.updated_at,
|
|
72
|
+
"completed_at": status.completed_at,
|
|
73
|
+
}
|
|
74
|
+
phase = getattr(status, "current_phase", None)
|
|
75
|
+
if phase is not None:
|
|
76
|
+
d["current_phase"] = phase.value if hasattr(phase, "value") else str(phase)
|
|
77
|
+
else:
|
|
78
|
+
d["current_phase"] = None
|
|
79
|
+
error = getattr(status, "error", None)
|
|
80
|
+
if error is not None:
|
|
81
|
+
d["error"] = error
|
|
82
|
+
return d
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_json(data: Any) -> None:
|
|
86
|
+
print(json.dumps(data, indent=2, default=str))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Public API ---
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_index_table(indexes: list, json_mode: bool = False) -> None:
|
|
93
|
+
if json_mode:
|
|
94
|
+
_print_json([_index_to_dict(i) for i in indexes])
|
|
95
|
+
return
|
|
96
|
+
if not indexes:
|
|
97
|
+
console.print("[dim]No indexes found.[/dim]")
|
|
98
|
+
return
|
|
99
|
+
table = Table(title="Indexes")
|
|
100
|
+
table.add_column("Name", style="cyan")
|
|
101
|
+
table.add_column("Status", style="green")
|
|
102
|
+
table.add_column("Docs", justify="right")
|
|
103
|
+
table.add_column("Model")
|
|
104
|
+
table.add_column("Created")
|
|
105
|
+
table.add_column("Updated")
|
|
106
|
+
for idx in indexes:
|
|
107
|
+
table.add_row(
|
|
108
|
+
idx.name,
|
|
109
|
+
idx.status,
|
|
110
|
+
str(idx.doc_count),
|
|
111
|
+
idx.model.id,
|
|
112
|
+
idx.created_at,
|
|
113
|
+
idx.updated_at,
|
|
114
|
+
)
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def print_index_detail(info: Any, json_mode: bool = False) -> None:
|
|
119
|
+
if json_mode:
|
|
120
|
+
_print_json(_index_to_dict(info))
|
|
121
|
+
return
|
|
122
|
+
console.print(f"[bold cyan]Index:[/bold cyan] {info.name}")
|
|
123
|
+
console.print(f" ID: {info.id}")
|
|
124
|
+
console.print(f" Status: {info.status}")
|
|
125
|
+
console.print(f" Documents: {info.doc_count}")
|
|
126
|
+
console.print(f" Model: {info.model.id} v{info.model.version}")
|
|
127
|
+
console.print(f" Version: {info.version}")
|
|
128
|
+
console.print(f" Created: {info.created_at}")
|
|
129
|
+
console.print(f" Updated: {info.updated_at}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def print_doc_table(docs: list, json_mode: bool = False) -> None:
|
|
133
|
+
if json_mode:
|
|
134
|
+
_print_json([_doc_to_dict(d) for d in docs])
|
|
135
|
+
return
|
|
136
|
+
if not docs:
|
|
137
|
+
console.print("[dim]No documents found.[/dim]")
|
|
138
|
+
return
|
|
139
|
+
table = Table(title="Documents")
|
|
140
|
+
table.add_column("ID", style="cyan")
|
|
141
|
+
table.add_column("Text", max_width=80)
|
|
142
|
+
table.add_column("Metadata")
|
|
143
|
+
for doc in docs:
|
|
144
|
+
meta = getattr(doc, "metadata", None)
|
|
145
|
+
meta_str = json.dumps(dict(meta), default=str) if meta else ""
|
|
146
|
+
text = doc.text
|
|
147
|
+
if len(text) > 80:
|
|
148
|
+
text = text[:77] + "..."
|
|
149
|
+
table.add_row(doc.id, text, meta_str)
|
|
150
|
+
console.print(table)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def print_search_results(result: Any, json_mode: bool = False) -> None:
|
|
154
|
+
if json_mode:
|
|
155
|
+
_print_json(_search_result_to_dict(result))
|
|
156
|
+
return
|
|
157
|
+
time_str = f" in {result.time_taken_ms}ms" if result.time_taken_ms else ""
|
|
158
|
+
console.print(
|
|
159
|
+
f'[bold]Query:[/bold] "{result.query}" '
|
|
160
|
+
f"[dim]index={result.index_name}{time_str}[/dim]\n"
|
|
161
|
+
)
|
|
162
|
+
if not result.docs:
|
|
163
|
+
console.print("[dim]No results.[/dim]")
|
|
164
|
+
return
|
|
165
|
+
for i, doc in enumerate(result.docs, 1):
|
|
166
|
+
meta = getattr(doc, "metadata", None)
|
|
167
|
+
meta_str = f" [dim]{json.dumps(dict(meta), default=str)}[/dim]" if meta else ""
|
|
168
|
+
console.print(f"[bold cyan]{i}.[/bold cyan] [green]{doc.score:.4f}[/green] {doc.id}")
|
|
169
|
+
console.print(f" {doc.text}")
|
|
170
|
+
if meta_str:
|
|
171
|
+
console.print(meta_str)
|
|
172
|
+
console.print()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def print_mutation_result(result: Any, json_mode: bool = False) -> None:
|
|
176
|
+
if json_mode:
|
|
177
|
+
_print_json(_mutation_to_dict(result))
|
|
178
|
+
return
|
|
179
|
+
console.print(f"[green]Job submitted[/green]")
|
|
180
|
+
console.print(f" Job ID: {result.job_id}")
|
|
181
|
+
console.print(f" Index: {result.index_name}")
|
|
182
|
+
console.print(f" Docs: {result.doc_count}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def print_job_status(status: Any, json_mode: bool = False) -> None:
|
|
186
|
+
if json_mode:
|
|
187
|
+
_print_json(_job_status_to_dict(status))
|
|
188
|
+
return
|
|
189
|
+
status_val = (status.status.value if hasattr(status.status, "value") else str(status.status)).upper()
|
|
190
|
+
color = "green" if status_val == "COMPLETED" else "red" if status_val == "FAILED" else "yellow"
|
|
191
|
+
progress = float(status.progress)
|
|
192
|
+
if progress > 1:
|
|
193
|
+
progress /= 100.0
|
|
194
|
+
console.print(f"[bold]Job:[/bold] {status.job_id}")
|
|
195
|
+
console.print(f" Status: [{color}]{status_val}[/{color}]")
|
|
196
|
+
console.print(f" Progress: {progress:.0%}")
|
|
197
|
+
phase = getattr(status, "current_phase", None)
|
|
198
|
+
if phase is not None:
|
|
199
|
+
phase_val = phase.value if hasattr(phase, "value") else str(phase)
|
|
200
|
+
console.print(f" Phase: {phase_val}")
|
|
201
|
+
error = getattr(status, "error", None)
|
|
202
|
+
if error:
|
|
203
|
+
console.print(f" [red]Error: {error}[/red]")
|
|
204
|
+
console.print(f" Created: {status.created_at}")
|
|
205
|
+
console.print(f" Updated: {status.updated_at}")
|
|
206
|
+
if status.completed_at:
|
|
207
|
+
console.print(f" Completed: {status.completed_at}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def print_success(message: str, json_mode: bool = False) -> None:
|
|
211
|
+
if json_mode:
|
|
212
|
+
_print_json({"status": "ok", "message": message})
|
|
213
|
+
return
|
|
214
|
+
console.print(f"[green]{message}[/green]")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def print_error(message: str, json_mode: bool = False) -> None:
|
|
218
|
+
if json_mode:
|
|
219
|
+
print(json.dumps({"error": message}), file=sys.stderr)
|
|
220
|
+
else:
|
|
221
|
+
err_console.print(f"[red]{message}[/red]")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for Moss semantic search
|
|
5
|
+
Author-email: "InferEdge Inc." <contact@usemoss.dev>
|
|
6
|
+
Keywords: search,semantic,cli,moss
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: moss>=1.0.0
|
|
19
|
+
Requires-Dist: typer>=0.9.0
|
|
20
|
+
Requires-Dist: rich>=13.0.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: isort>=5.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: flake8>=7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Moss CLI
|
|
32
|
+
|
|
33
|
+
**Command-line interface for Moss semantic search** — manage indexes, documents, and queries directly from your terminal.
|
|
34
|
+
|
|
35
|
+
Moss CLI wraps the [Moss Python SDK](https://docs.usemoss.dev/) so you can build and query semantic search indexes without writing any code. Ideal for quick prototyping, scripting, CI/CD pipelines, and data workflows.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- ⚡ **Full SDK coverage** — every SDK operation available as a CLI command
|
|
40
|
+
- 🔍 **Semantic search** — query indexes with configurable hybrid search (semantic + keyword)
|
|
41
|
+
- 📦 **Index management** — create, list, inspect, and delete indexes
|
|
42
|
+
- 📄 **Document management** — add, update, retrieve, and delete documents
|
|
43
|
+
- 🚀 **Local by default** — downloads indexes for on-device queries, `--cloud` to skip
|
|
44
|
+
- 🔧 **Flexible auth** — CLI flags, environment variables, or config file
|
|
45
|
+
- 📊 **Multiple output formats** — rich tables for humans, `--json` for scripts
|
|
46
|
+
- ⏳ **Job tracking** — poll background jobs with live progress display
|
|
47
|
+
- 🔗 **Pipe-friendly** — stdin/stdout support for composing with other tools
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install moss-cli
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 1. Save your credentials
|
|
59
|
+
moss init
|
|
60
|
+
|
|
61
|
+
# 2. List your indexes
|
|
62
|
+
moss index list
|
|
63
|
+
|
|
64
|
+
# 3. Create an index from a JSON file
|
|
65
|
+
moss index create my-index -f docs.json --wait
|
|
66
|
+
|
|
67
|
+
# 4. Search it
|
|
68
|
+
moss query my-index "what is machine learning"
|
|
69
|
+
|
|
70
|
+
# 5. Search via cloud API (skips local download)
|
|
71
|
+
moss query my-index "neural networks" --cloud
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Authentication
|
|
75
|
+
|
|
76
|
+
Credentials are resolved in this order:
|
|
77
|
+
|
|
78
|
+
1. **CLI flags**: `--project-id` and `--project-key`
|
|
79
|
+
2. **Environment variables**: `MOSS_PROJECT_ID` and `MOSS_PROJECT_KEY`
|
|
80
|
+
3. **Config file**: `~/.moss/config.json` (created by `moss init`)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Option 1: Interactive setup (recommended)
|
|
84
|
+
moss init
|
|
85
|
+
|
|
86
|
+
# Option 2: Environment variables
|
|
87
|
+
export MOSS_PROJECT_ID="your-project-id"
|
|
88
|
+
export MOSS_PROJECT_KEY="your-project-key"
|
|
89
|
+
|
|
90
|
+
# Option 3: Inline flags
|
|
91
|
+
moss index list --project-id "..." --project-key "..."
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
### Index Management
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Create an index with documents from a JSON file
|
|
100
|
+
moss index create my-index -f documents.json --model moss-minilm
|
|
101
|
+
|
|
102
|
+
# Create and wait for completion
|
|
103
|
+
moss index create my-index -f documents.json --wait
|
|
104
|
+
|
|
105
|
+
# List all indexes
|
|
106
|
+
moss index list
|
|
107
|
+
|
|
108
|
+
# Get index details
|
|
109
|
+
moss index get my-index
|
|
110
|
+
|
|
111
|
+
# Delete an index
|
|
112
|
+
moss index delete my-index
|
|
113
|
+
moss index delete my-index --confirm # skip prompt
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Document Management
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# Add documents to an existing index
|
|
120
|
+
moss doc add my-index -f new-docs.json
|
|
121
|
+
|
|
122
|
+
# Add with upsert (update existing, insert new)
|
|
123
|
+
moss doc add my-index -f docs.json --upsert --wait
|
|
124
|
+
|
|
125
|
+
# Get all documents
|
|
126
|
+
moss doc get my-index
|
|
127
|
+
|
|
128
|
+
# Get specific documents
|
|
129
|
+
moss doc get my-index --ids doc1,doc2,doc3
|
|
130
|
+
|
|
131
|
+
# Delete documents
|
|
132
|
+
moss doc delete my-index --ids doc1,doc2
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Query
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Search (downloads index and queries on-device by default)
|
|
139
|
+
moss query my-index "what is deep learning"
|
|
140
|
+
|
|
141
|
+
# Tune results: more results, keyword-heavy
|
|
142
|
+
moss query my-index "neural networks" --top-k 20 --alpha 0.3
|
|
143
|
+
|
|
144
|
+
# Cloud mode (skip download, query via cloud API)
|
|
145
|
+
moss query my-index "transformers" --cloud
|
|
146
|
+
|
|
147
|
+
# With metadata filter (local only)
|
|
148
|
+
moss query my-index "shoes" --filter '{"field": "category", "condition": {"$eq": "footwear"}}'
|
|
149
|
+
|
|
150
|
+
# Pipe query from stdin
|
|
151
|
+
echo "what is AI" | moss query my-index
|
|
152
|
+
|
|
153
|
+
# JSON output for scripting
|
|
154
|
+
moss query my-index "query" --json | jq '.docs[0].text'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Job Tracking
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
# Check job status
|
|
161
|
+
moss job status <job-id>
|
|
162
|
+
|
|
163
|
+
# Wait for job to finish (with live progress)
|
|
164
|
+
moss job status <job-id> --wait
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Other
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Print version info
|
|
171
|
+
moss version
|
|
172
|
+
|
|
173
|
+
# Global JSON output
|
|
174
|
+
moss index list --json
|
|
175
|
+
moss doc get my-index --json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Document File Format
|
|
179
|
+
|
|
180
|
+
### JSON (recommended)
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
[
|
|
184
|
+
{"id": "doc1", "text": "Machine learning fundamentals", "metadata": {"topic": "ml"}},
|
|
185
|
+
{"id": "doc2", "text": "Deep learning with neural networks"},
|
|
186
|
+
{"id": "doc3", "text": "Natural language processing", "metadata": {"topic": "nlp"}}
|
|
187
|
+
]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Also supports a wrapper format: `{"documents": [...]}`.
|
|
191
|
+
|
|
192
|
+
### CSV
|
|
193
|
+
|
|
194
|
+
```csv
|
|
195
|
+
id,text,metadata
|
|
196
|
+
doc1,Machine learning fundamentals,"{""topic"": ""ml""}"
|
|
197
|
+
doc2,Deep learning with neural networks,
|
|
198
|
+
doc3,Natural language processing,"{""topic"": ""nlp""}"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### stdin
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
cat docs.json | moss index create my-index -f -
|
|
205
|
+
cat docs.json | moss doc add my-index -f -
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Global Options
|
|
209
|
+
|
|
210
|
+
| Flag | Short | Description |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `--project-id` | `-p` | Project ID (overrides env/config) |
|
|
213
|
+
| `--project-key` | | Project key (overrides env/config) |
|
|
214
|
+
| `--json` | | Machine-readable JSON output |
|
|
215
|
+
| `--verbose` | `-v` | Enable debug logging |
|
|
216
|
+
|
|
217
|
+
## Available Models
|
|
218
|
+
|
|
219
|
+
| Model | Description |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `moss-minilm` | Lightweight, optimized for speed (default) |
|
|
222
|
+
| `moss-mediumlm` | Balanced accuracy and performance |
|
|
223
|
+
| `custom` | Used automatically when documents include embeddings |
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
Copyright (c) 2026 InferEdge Inc. — PolyForm Shield License 1.0.0.
|
|
228
|
+
|
|
229
|
+
See [LICENSE](LICENSE) for full terms. For commercial licensing, contact contact@usemoss.dev.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
moss_cli/__init__.py,sha256=sR54tN-3tE31q3Bx22CmeXpI6-hAAq9vJ-1nZV0VMow,89
|
|
2
|
+
moss_cli/config.py,sha256=NBtpQNpw1S7SxfVjEZv-v3VIzbS6yvvf6d8kJVdz1aI,1529
|
|
3
|
+
moss_cli/documents.py,sha256=Uj4IcOWpCn4GmvDY9Yoh4dd8Jq09vk0jp-OpWIEopNc,3656
|
|
4
|
+
moss_cli/job_waiter.py,sha256=reVeioDD0aRBkWIrpYvKmLIe48-uXv_8nja2-nSAHR0,2124
|
|
5
|
+
moss_cli/main.py,sha256=ERH1sfvJVEPQquNQiAgocdTIyathsq2yr20eyZRAPbQ,1989
|
|
6
|
+
moss_cli/output.py,sha256=ValjrhEUVVUV0WFnFHidS0D8N1UOhGcKyLCbf1m8y7w,7265
|
|
7
|
+
moss_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
moss_cli/commands/doc.py,sha256=tY2sA4m0qwHM33a72Dk0MDJw93qmQv0dvLdVKo7YFmI,3520
|
|
9
|
+
moss_cli/commands/index.py,sha256=ASbJkF6W1KsrHLiFoUgIQAMaCcrK6ydTJuIETh-Lgko,2993
|
|
10
|
+
moss_cli/commands/init_cmd.py,sha256=q9N6nDy76Wulippn45ys8LClIzZOqLqcUfYKW8JbZ1A,1812
|
|
11
|
+
moss_cli/commands/job.py,sha256=BDdVdJTPYIg4VZ6VOYsmJby2KEK9AyiBUIDH9scqTWg,1201
|
|
12
|
+
moss_cli/commands/search.py,sha256=MCkbhtzmSh6xSOSfb2ii4961BuwW39ge5Qlzjo9yo7o,2608
|
|
13
|
+
moss_cli/commands/version.py,sha256=YJI0dE1jkxJrLl5F8fS_fFt2p8O6hGcHAx5LjBdg9go,565
|
|
14
|
+
moss_cli-0.1.0.dist-info/licenses/LICENSE,sha256=vxxHLNz1Z_C_D6GyOS8mAyFwpjjSqkkkSnbp3BVIUks,191
|
|
15
|
+
moss_cli-0.1.0.dist-info/METADATA,sha256=I934fiOyNlKmec7JP9fqCuh6AF0K6dSbbFt_YSXYVPg,6238
|
|
16
|
+
moss_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
moss_cli-0.1.0.dist-info/entry_points.txt,sha256=xlsFrsY-yJUfEJZTTAtfPflXHI5STBkQL7JsDtr-I2g,43
|
|
18
|
+
moss_cli-0.1.0.dist-info/top_level.txt,sha256=7Uft9WhtCk6F02s2ouw-avZ8qHIzRMM6bQQNvxSiyI0,9
|
|
19
|
+
moss_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moss_cli
|