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 ADDED
@@ -0,0 +1,3 @@
1
+ """Moss CLI - Command-line interface for Moss semantic search."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -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]")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ moss = moss_cli.main:run
@@ -0,0 +1,6 @@
1
+ Copyright (c) 2026 InferEdge Inc.
2
+
3
+ Licensed under the PolyForm Shield License 1.0.0.
4
+ https://polyformproject.org/licenses/shield/1.0.0
5
+
6
+ For commercial licensing, contact contact@usemoss.dev.
@@ -0,0 +1 @@
1
+ moss_cli