moss-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (32) hide show
  1. {moss_cli-0.1.0/src/moss_cli.egg-info → moss_cli-0.2.0}/PKG-INFO +71 -27
  2. {moss_cli-0.1.0 → moss_cli-0.2.0}/README.md +61 -17
  3. {moss_cli-0.1.0 → moss_cli-0.2.0}/pyproject.toml +10 -10
  4. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/__init__.py +1 -1
  5. moss_cli-0.2.0/src/moss_cli/api.py +106 -0
  6. moss_cli-0.2.0/src/moss_cli/commands/auth.py +249 -0
  7. moss_cli-0.2.0/src/moss_cli/commands/common.py +29 -0
  8. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/init_cmd.py +3 -2
  9. moss_cli-0.2.0/src/moss_cli/commands/keys.py +90 -0
  10. moss_cli-0.2.0/src/moss_cli/commands/projects.py +37 -0
  11. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/search.py +4 -13
  12. moss_cli-0.2.0/src/moss_cli/config.py +135 -0
  13. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/main.py +19 -1
  14. {moss_cli-0.1.0 → moss_cli-0.2.0/src/moss_cli.egg-info}/PKG-INFO +71 -27
  15. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/SOURCES.txt +7 -1
  16. moss_cli-0.2.0/src/moss_cli.egg-info/requires.txt +12 -0
  17. moss_cli-0.2.0/tests/test_login_and_keys.py +204 -0
  18. moss_cli-0.1.0/src/moss_cli/config.py +0 -57
  19. moss_cli-0.1.0/src/moss_cli.egg-info/requires.txt +0 -12
  20. {moss_cli-0.1.0 → moss_cli-0.2.0}/LICENSE +0 -0
  21. {moss_cli-0.1.0 → moss_cli-0.2.0}/setup.cfg +0 -0
  22. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/__init__.py +0 -0
  23. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/doc.py +0 -0
  24. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/index.py +0 -0
  25. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/job.py +0 -0
  26. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/version.py +0 -0
  27. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/documents.py +0 -0
  28. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/job_waiter.py +0 -0
  29. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/output.py +0 -0
  30. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/dependency_links.txt +0 -0
  31. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/entry_points.txt +0 -0
  32. {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moss-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Command-line interface for Moss semantic search
5
5
  Author-email: "InferEdge Inc." <contact@usemoss.dev>
6
6
  Keywords: search,semantic,cli,moss
@@ -16,35 +16,36 @@ Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: moss>=1.0.0
19
- Requires-Dist: typer>=0.9.0
20
- Requires-Dist: rich>=13.0.0
19
+ Requires-Dist: typer>=0.26.8
20
+ Requires-Dist: rich>=15.0.0
21
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"
22
+ Requires-Dist: pytest>=9.1.1; extra == "dev"
23
+ Requires-Dist: black<26,>=25.9.0; extra == "dev"
24
+ Requires-Dist: isort<8,>=7.0.0; extra == "dev"
25
+ Requires-Dist: flake8>=7.3.0; extra == "dev"
26
+ Requires-Dist: mypy>=2.1.0; extra == "dev"
27
+ Requires-Dist: build>=1.5.0; extra == "dev"
28
+ Requires-Dist: twine>=6.2.0; extra == "dev"
29
29
  Dynamic: license-file
30
30
 
31
31
  # Moss CLI
32
32
 
33
33
  **Command-line interface for Moss semantic search** — manage indexes, documents, and queries directly from your terminal.
34
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.
35
+ Moss CLI wraps the [Moss Python SDK](https://docs.moss.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
36
 
37
37
  ## Features
38
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
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 and queries them on-device
44
+ - **Flexible auth** — CLI flags, environment variables, or config file
45
+ - **Log in from the terminal** — `moss login`, then create project keys with `moss keys create`
46
+ - **Multiple output formats** — rich tables for humans, `--json` for scripts
47
+ - **Job tracking** — poll background jobs with live progress display
48
+ - **Pipe-friendly** — stdin/stdout support for composing with other tools
48
49
 
49
50
  ## Installation
50
51
 
@@ -66,9 +67,6 @@ moss index create my-index -f docs.json --wait
66
67
 
67
68
  # 4. Search it
68
69
  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
70
  ```
73
71
 
74
72
  ## Authentication
@@ -91,6 +89,53 @@ export MOSS_PROJECT_KEY="your-project-key"
91
89
  moss index list --project-id "..." --project-key "..."
92
90
  ```
93
91
 
92
+ ## Log in and create keys
93
+
94
+ `moss login` connects the CLI to your Moss account, so you can create project keys without opening the dashboard.
95
+
96
+ ```bash
97
+ # Opens the browser. Check the code matches, then click Approve.
98
+ moss login
99
+
100
+ moss whoami
101
+ moss projects list
102
+
103
+ # Prints the key once, plus MOSS_PROJECT_ID / MOSS_PROJECT_KEY lines
104
+ moss keys create --project <project-id> --name "prod server"
105
+ moss keys list --project <project-id>
106
+ moss keys delete --project <project-id> <key-id>
107
+
108
+ # Revoke the token and remove it from ~/.moss/config.json
109
+ moss logout
110
+ ```
111
+
112
+ The token is saved to `~/.moss/config.json`. It lives until you run `moss logout` or revoke it in Settings > API Keys. It acts as you: only workspace owners, admins and a project's owner can create or delete its keys. `--project` defaults to the project saved by `moss init`.
113
+
114
+ Use `moss login --no-browser` on a machine without a browser (for example over SSH) and open the printed link anywhere.
115
+
116
+ ## Agents and CI
117
+
118
+ CI that only queries or uploads to one project needs a project key, not a login. Create one with `moss keys create` and store it as `MOSS_PROJECT_ID` / `MOSS_PROJECT_KEY`.
119
+
120
+ Agents that create or delete keys need a CLI token. Make a separate one for each agent, so logging in or out on your machine doesn't break it:
121
+
122
+ ```bash
123
+ # Approve in the browser; the token is printed, not saved
124
+ moss login --print-token --name "deploy agent"
125
+ ```
126
+
127
+ Store it as `MOSS_TOKEN` in your secret store, then:
128
+
129
+ ```bash
130
+ export MOSS_TOKEN="mossc_..."
131
+ moss keys create --project <project-id> --json
132
+ # {"project_id": "...", "key_id": "...", "secret": "moss_..."}
133
+ ```
134
+
135
+ The token acts as you, so it loses access to a workspace when you do. Revoke it in Settings > API Keys.
136
+
137
+ The token is resolved from `--token`, then `MOSS_TOKEN`, then the config file. The saved token is only sent to the dashboard that issued it, so with `--api-url` pointing elsewhere, log in there or pass `--token`. A `--token` or `MOSS_TOKEN` goes to production unless `--api-url` / `MOSS_API_URL` says otherwise.
138
+
94
139
  ## Commands
95
140
 
96
141
  ### Index Management
@@ -141,9 +186,6 @@ moss query my-index "what is deep learning"
141
186
  # Tune results: more results, keyword-heavy
142
187
  moss query my-index "neural networks" --top-k 20 --alpha 0.3
143
188
 
144
- # Cloud mode (skip download, query via cloud API)
145
- moss query my-index "transformers" --cloud
146
-
147
189
  # With metadata filter (local only)
148
190
  moss query my-index "shoes" --filter '{"field": "category", "condition": {"$eq": "footwear"}}'
149
191
 
@@ -211,6 +253,8 @@ cat docs.json | moss doc add my-index -f -
211
253
  |---|---|---|
212
254
  | `--project-id` | `-p` | Project ID (overrides env/config) |
213
255
  | `--project-key` | | Project key (overrides env/config) |
256
+ | `--token` | | CLI token from `moss login` (or `MOSS_TOKEN`) |
257
+ | `--api-url` | | Dashboard URL, for staging or local testing (or `MOSS_API_URL`) |
214
258
  | `--json` | | Machine-readable JSON output |
215
259
  | `--verbose` | `-v` | Enable debug logging |
216
260
 
@@ -226,4 +270,4 @@ cat docs.json | moss doc add my-index -f -
226
270
 
227
271
  Copyright (c) 2026 InferEdge Inc. — PolyForm Shield License 1.0.0.
228
272
 
229
- See [LICENSE](LICENSE) for full terms. For commercial licensing, contact contact@usemoss.dev.
273
+ See [LICENSE](LICENSE) for full terms. For commercial licensing, contact contact@moss.dev.
@@ -2,19 +2,20 @@
2
2
 
3
3
  **Command-line interface for Moss semantic search** — manage indexes, documents, and queries directly from your terminal.
4
4
 
5
- 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.
5
+ Moss CLI wraps the [Moss Python SDK](https://docs.moss.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.
6
6
 
7
7
  ## Features
8
8
 
9
- - ⚡ **Full SDK coverage** — every SDK operation available as a CLI command
10
- - 🔍 **Semantic search** — query indexes with configurable hybrid search (semantic + keyword)
11
- - 📦 **Index management** — create, list, inspect, and delete indexes
12
- - 📄 **Document management** — add, update, retrieve, and delete documents
13
- - 🚀 **Local by default** — downloads indexes for on-device queries, `--cloud` to skip
14
- - 🔧 **Flexible auth** — CLI flags, environment variables, or config file
15
- - 📊 **Multiple output formats** — rich tables for humans, `--json` for scripts
16
- - ⏳ **Job tracking** — poll background jobs with live progress display
17
- - 🔗 **Pipe-friendly** — stdin/stdout support for composing with other tools
9
+ - **Full SDK coverage** — every SDK operation available as a CLI command
10
+ - **Semantic search** — query indexes with configurable hybrid search (semantic + keyword)
11
+ - **Index management** — create, list, inspect, and delete indexes
12
+ - **Document management** — add, update, retrieve, and delete documents
13
+ - **Local by default** — downloads indexes and queries them on-device
14
+ - **Flexible auth** — CLI flags, environment variables, or config file
15
+ - **Log in from the terminal** — `moss login`, then create project keys with `moss keys create`
16
+ - **Multiple output formats** — rich tables for humans, `--json` for scripts
17
+ - **Job tracking** — poll background jobs with live progress display
18
+ - **Pipe-friendly** — stdin/stdout support for composing with other tools
18
19
 
19
20
  ## Installation
20
21
 
@@ -36,9 +37,6 @@ moss index create my-index -f docs.json --wait
36
37
 
37
38
  # 4. Search it
38
39
  moss query my-index "what is machine learning"
39
-
40
- # 5. Search via cloud API (skips local download)
41
- moss query my-index "neural networks" --cloud
42
40
  ```
43
41
 
44
42
  ## Authentication
@@ -61,6 +59,53 @@ export MOSS_PROJECT_KEY="your-project-key"
61
59
  moss index list --project-id "..." --project-key "..."
62
60
  ```
63
61
 
62
+ ## Log in and create keys
63
+
64
+ `moss login` connects the CLI to your Moss account, so you can create project keys without opening the dashboard.
65
+
66
+ ```bash
67
+ # Opens the browser. Check the code matches, then click Approve.
68
+ moss login
69
+
70
+ moss whoami
71
+ moss projects list
72
+
73
+ # Prints the key once, plus MOSS_PROJECT_ID / MOSS_PROJECT_KEY lines
74
+ moss keys create --project <project-id> --name "prod server"
75
+ moss keys list --project <project-id>
76
+ moss keys delete --project <project-id> <key-id>
77
+
78
+ # Revoke the token and remove it from ~/.moss/config.json
79
+ moss logout
80
+ ```
81
+
82
+ The token is saved to `~/.moss/config.json`. It lives until you run `moss logout` or revoke it in Settings > API Keys. It acts as you: only workspace owners, admins and a project's owner can create or delete its keys. `--project` defaults to the project saved by `moss init`.
83
+
84
+ Use `moss login --no-browser` on a machine without a browser (for example over SSH) and open the printed link anywhere.
85
+
86
+ ## Agents and CI
87
+
88
+ CI that only queries or uploads to one project needs a project key, not a login. Create one with `moss keys create` and store it as `MOSS_PROJECT_ID` / `MOSS_PROJECT_KEY`.
89
+
90
+ Agents that create or delete keys need a CLI token. Make a separate one for each agent, so logging in or out on your machine doesn't break it:
91
+
92
+ ```bash
93
+ # Approve in the browser; the token is printed, not saved
94
+ moss login --print-token --name "deploy agent"
95
+ ```
96
+
97
+ Store it as `MOSS_TOKEN` in your secret store, then:
98
+
99
+ ```bash
100
+ export MOSS_TOKEN="mossc_..."
101
+ moss keys create --project <project-id> --json
102
+ # {"project_id": "...", "key_id": "...", "secret": "moss_..."}
103
+ ```
104
+
105
+ The token acts as you, so it loses access to a workspace when you do. Revoke it in Settings > API Keys.
106
+
107
+ The token is resolved from `--token`, then `MOSS_TOKEN`, then the config file. The saved token is only sent to the dashboard that issued it, so with `--api-url` pointing elsewhere, log in there or pass `--token`. A `--token` or `MOSS_TOKEN` goes to production unless `--api-url` / `MOSS_API_URL` says otherwise.
108
+
64
109
  ## Commands
65
110
 
66
111
  ### Index Management
@@ -111,9 +156,6 @@ moss query my-index "what is deep learning"
111
156
  # Tune results: more results, keyword-heavy
112
157
  moss query my-index "neural networks" --top-k 20 --alpha 0.3
113
158
 
114
- # Cloud mode (skip download, query via cloud API)
115
- moss query my-index "transformers" --cloud
116
-
117
159
  # With metadata filter (local only)
118
160
  moss query my-index "shoes" --filter '{"field": "category", "condition": {"$eq": "footwear"}}'
119
161
 
@@ -181,6 +223,8 @@ cat docs.json | moss doc add my-index -f -
181
223
  |---|---|---|
182
224
  | `--project-id` | `-p` | Project ID (overrides env/config) |
183
225
  | `--project-key` | | Project key (overrides env/config) |
226
+ | `--token` | | CLI token from `moss login` (or `MOSS_TOKEN`) |
227
+ | `--api-url` | | Dashboard URL, for staging or local testing (or `MOSS_API_URL`) |
184
228
  | `--json` | | Machine-readable JSON output |
185
229
  | `--verbose` | `-v` | Enable debug logging |
186
230
 
@@ -196,4 +240,4 @@ cat docs.json | moss doc add my-index -f -
196
240
 
197
241
  Copyright (c) 2026 InferEdge Inc. — PolyForm Shield License 1.0.0.
198
242
 
199
- See [LICENSE](LICENSE) for full terms. For commercial licensing, contact contact@usemoss.dev.
243
+ See [LICENSE](LICENSE) for full terms. For commercial licensing, contact contact@moss.dev.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moss-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Command-line interface for Moss semantic search"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,19 +25,19 @@ classifiers = [
25
25
  ]
26
26
  dependencies = [
27
27
  "moss>=1.0.0",
28
- "typer>=0.9.0",
29
- "rich>=13.0.0",
28
+ "typer>=0.26.8",
29
+ "rich>=15.0.0",
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
33
33
  dev = [
34
- "pytest>=8.0.0",
35
- "black>=24.0.0",
36
- "isort>=5.0.0",
37
- "flake8>=7.0.0",
38
- "mypy>=1.0.0",
39
- "build>=1.0.0",
40
- "twine>=5.0.0",
34
+ "pytest>=9.1.1",
35
+ "black>=25.9.0,<26",
36
+ "isort>=7.0.0,<8",
37
+ "flake8>=7.3.0",
38
+ "mypy>=2.1.0",
39
+ "build>=1.5.0",
40
+ "twine>=6.2.0",
41
41
  ]
42
42
 
43
43
  [project.scripts]
@@ -1,3 +1,3 @@
1
1
  """Moss CLI - Command-line interface for Moss semantic search."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,106 @@
1
+ """HTTP client for the dashboard's CLI routes (login, projects, keys)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ import moss_cli
11
+
12
+
13
+ class ApiError(Exception):
14
+ def __init__(self, status: int, message: str, code: Optional[str] = None) -> None:
15
+ super().__init__(message)
16
+ self.status = status
17
+ self.code = code
18
+
19
+
20
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
21
+ # urllib copies the Authorization header onto a redirect, even to another host.
22
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
23
+ return None
24
+
25
+
26
+ _opener = urllib.request.build_opener(_NoRedirect)
27
+
28
+
29
+ def request(
30
+ api_url: str,
31
+ method: str,
32
+ path: str,
33
+ token: Optional[str] = None,
34
+ body: Optional[Dict[str, Any]] = None,
35
+ ) -> Tuple[int, Dict[str, Any]]:
36
+ """Send a request and return (status, json body). Never raises on an HTTP error status.
37
+
38
+ Raises ApiError only when the server doesn't answer like a Moss dashboard with CLI
39
+ login: a redirect or an HTML page. That's the sign of an old dashboard or a wrong
40
+ --api-url, not a transient failure, so a retry loop must not swallow it.
41
+ """
42
+ data = json.dumps(body).encode() if body is not None else None
43
+ headers = {
44
+ "Accept": "application/json",
45
+ "User-Agent": f"moss-cli/{moss_cli.__version__}",
46
+ }
47
+ if data is not None:
48
+ headers["Content-Type"] = "application/json"
49
+ if token:
50
+ headers["Authorization"] = f"Bearer {token}"
51
+ req = urllib.request.Request(
52
+ api_url.rstrip("/") + path, data=data, headers=headers, method=method
53
+ )
54
+ try:
55
+ with _opener.open(req, timeout=30) as res:
56
+ status, content_type, raw = (
57
+ res.status,
58
+ res.headers.get("Content-Type"),
59
+ res.read(),
60
+ )
61
+ except urllib.error.HTTPError as e:
62
+ status, content_type, raw = e.code, e.headers.get("Content-Type"), e.read()
63
+
64
+ # An old dashboard answers these routes with a redirect to the login page, and a wrong
65
+ # --api-url may answer with HTML. A 5xx or 429 may be a gateway page, which callers
66
+ # retry, so it stays a plain status.
67
+ retryable = status >= 500 or status == 429
68
+ if 300 <= status < 400 or (not retryable and raw and not _is_json(content_type)):
69
+ raise ApiError(
70
+ status,
71
+ f"{api_url} did not answer like a Moss dashboard with CLI login (HTTP {status}). "
72
+ "Check --api-url / MOSS_API_URL, or ask for the dashboard to be updated.",
73
+ )
74
+ return status, _parse(raw)
75
+
76
+
77
+ def call(
78
+ api_url: str,
79
+ method: str,
80
+ path: str,
81
+ token: Optional[str] = None,
82
+ body: Optional[Dict[str, Any]] = None,
83
+ ) -> Dict[str, Any]:
84
+ """Like request(), but raises ApiError for any non-2xx status."""
85
+ status, payload = request(api_url, method, path, token, body)
86
+ if status >= 400:
87
+ message = payload.get("error") or f"Request failed ({status})"
88
+ if status == 401 and token:
89
+ message = (
90
+ "Your CLI token is invalid or revoked. Run 'moss login' again, or replace "
91
+ "MOSS_TOKEN / --token with a new one from 'moss login --print-token'."
92
+ )
93
+ raise ApiError(status, message, payload.get("code"))
94
+ return payload
95
+
96
+
97
+ def _is_json(content_type: Optional[str]) -> bool:
98
+ return "json" in (content_type or "").lower()
99
+
100
+
101
+ def _parse(raw: bytes) -> Dict[str, Any]:
102
+ try:
103
+ parsed = json.loads(raw or b"{}")
104
+ except json.JSONDecodeError:
105
+ return {}
106
+ return parsed if isinstance(parsed, dict) else {}
@@ -0,0 +1,249 @@
1
+ """moss login, logout and whoami: the device login flow and the saved CLI token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import os
7
+ import socket
8
+ import sys
9
+ import time
10
+ import webbrowser
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from .. import api
17
+ from ..config import (
18
+ DEFAULT_API_URL,
19
+ get_config_path,
20
+ resolve_api_url,
21
+ saved_login,
22
+ update_config,
23
+ )
24
+ from ..output import _print_json, err_console
25
+ from .common import call_api, json_mode
26
+
27
+ console = Console()
28
+
29
+ JSON_HELP = "Output as JSON"
30
+
31
+
32
+ def _can_open_browser() -> bool:
33
+ # Over SSH or in a headless Linux box there is nothing to open.
34
+ if os.getenv("SSH_CONNECTION") or os.getenv("SSH_TTY"):
35
+ return False
36
+ if sys.platform.startswith("linux"):
37
+ return bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
38
+ return True
39
+
40
+
41
+ def login_command(
42
+ ctx: typer.Context,
43
+ no_browser: bool = typer.Option(
44
+ False, "--no-browser", help="Print the link instead of opening a browser"
45
+ ),
46
+ print_token: bool = typer.Option(
47
+ False,
48
+ "--print-token",
49
+ help="Print a new token for an agent instead of saving it. Your saved login is kept.",
50
+ ),
51
+ name: Optional[str] = typer.Option(
52
+ None, "--name", help="Token name shown in Settings, e.g. 'deploy agent'"
53
+ ),
54
+ json_flag: bool = typer.Option(False, "--json", help=JSON_HELP),
55
+ ) -> None:
56
+ """Log in through the browser and save a CLI token to ~/.moss/config.json."""
57
+ as_json = json_mode(ctx, json_flag)
58
+ api_url = resolve_api_url(ctx.obj.get("api_url"))
59
+
60
+ start = api.call(
61
+ api_url,
62
+ "POST",
63
+ "/api/cli/device",
64
+ body={"client_name": name or f"moss-cli on {socket.gethostname()}"},
65
+ )
66
+ link = start["verification_uri_complete"]
67
+
68
+ # When stdout carries the result (JSON or a printed token), the instructions go to stderr.
69
+ out = err_console if as_json or print_token else console
70
+ out.print(f"Your login code: [bold cyan]{start['user_code']}[/bold cyan]")
71
+ out.print(f"Open this link to approve it: {link}")
72
+ if not no_browser and _can_open_browser():
73
+ try:
74
+ webbrowser.open(link)
75
+ except Exception:
76
+ pass
77
+ out.print("[dim]Waiting for approval...[/dim]")
78
+
79
+ interval = max(int(start.get("interval", 5)), 1)
80
+ deadline = time.monotonic() + int(start.get("expires_in", 900))
81
+ reply_lost = False
82
+ while time.monotonic() < deadline:
83
+ time.sleep(interval)
84
+ # A dropped connection, a busy server or a proxy's HTML error page shouldn't lose
85
+ # a code the user may already have approved, so keep polling until it expires.
86
+ # The start call above already showed this is a dashboard with CLI login.
87
+ try:
88
+ status, body = api.request(
89
+ api_url,
90
+ "POST",
91
+ "/api/cli/device/token",
92
+ body={"device_code": start["device_code"]},
93
+ )
94
+ except (OSError, http.client.HTTPException, api.ApiError):
95
+ reply_lost = True
96
+ continue
97
+ if status >= 500:
98
+ reply_lost = True
99
+ continue
100
+ if status == 200:
101
+ token = body.get("token")
102
+ if not token:
103
+ raise api.ApiError(
104
+ status,
105
+ "Login was approved, but the dashboard sent no token. Run 'moss login' "
106
+ "again, and revoke any extra token in Settings > API Keys.",
107
+ )
108
+ if print_token:
109
+ _print_new_token(token, body.get("user"), as_json, out)
110
+ return
111
+ _revoke_saved_token()
112
+ update_config(
113
+ token=token,
114
+ api_url=api_url if api_url != DEFAULT_API_URL else None,
115
+ )
116
+ email = (body.get("user") or {}).get("email")
117
+ if as_json:
118
+ _print_json({"status": "ok", "user": body.get("user")})
119
+ else:
120
+ console.print(
121
+ f"[green]Logged in{f' as {email}' if email else ''}.[/green]"
122
+ )
123
+ console.print(f"[dim]Token saved to {get_config_path()}[/dim]")
124
+ if ctx.obj.get("token"):
125
+ err_console.print(
126
+ "[yellow]MOSS_TOKEN / --token is set, so other commands keep using "
127
+ "it instead of this login. Unset it to use this login.[/yellow]"
128
+ )
129
+ return
130
+ error = body.get("error")
131
+ if error == "authorization_pending":
132
+ # Still pending, so no earlier poll was issued a token.
133
+ reply_lost = False
134
+ continue
135
+ if error == "slow_down" or status == 429:
136
+ interval += 5
137
+ continue
138
+ if error == "access_denied":
139
+ raise api.ApiError(status, "Login was denied in the browser.")
140
+ if error == "expired_token":
141
+ if reply_lost:
142
+ # The server may have issued the token on the poll whose reply we lost.
143
+ raise api.ApiError(
144
+ status,
145
+ "Login may have finished, but the reply was lost. Run 'moss login' "
146
+ "again, and revoke the extra token in Settings > API Keys.",
147
+ )
148
+ break
149
+ raise api.ApiError(status, error or f"Login failed ({status})")
150
+
151
+ raise api.ApiError(400, "The login code expired. Run 'moss login' again.")
152
+
153
+
154
+ def _print_new_token(
155
+ token: str, user: Optional[dict], as_json: bool, out: Console
156
+ ) -> None:
157
+ """--print-token: hand the token over on stdout and leave the saved login alone."""
158
+ if as_json:
159
+ _print_json({"token": token, "user": user})
160
+ return
161
+ out.print(
162
+ "[yellow]This token acts as you across all your projects and won't be shown "
163
+ "again. Store it as MOSS_TOKEN, and revoke it in Settings > API Keys.[/yellow]"
164
+ )
165
+ print(token)
166
+
167
+
168
+ def _revoke_saved_token() -> None:
169
+ """Logging in again replaces the saved token, so revoke the old one.
170
+
171
+ Otherwise every login leaves one more token listed in Settings, or, after a login
172
+ to another dashboard, a live token the CLI no longer knows about.
173
+
174
+ Best effort: it may already be revoked. It is revoked on the dashboard that
175
+ issued it, so it is never sent to another server.
176
+ """
177
+ old, saved_url = saved_login()
178
+ if not old:
179
+ return
180
+ try:
181
+ api.call(saved_url, "DELETE", "/api/cli/tokens/current", old)
182
+ except Exception:
183
+ pass
184
+
185
+
186
+ def logout_command(
187
+ ctx: typer.Context,
188
+ json_flag: bool = typer.Option(False, "--json", help=JSON_HELP),
189
+ ) -> None:
190
+ """Revoke the saved CLI token and remove it from ~/.moss/config.json."""
191
+ as_json = json_mode(ctx, json_flag)
192
+ token, saved_url = saved_login()
193
+ if not token:
194
+ if as_json:
195
+ _print_json({"status": "ok", "message": "Not logged in"})
196
+ else:
197
+ console.print("[dim]Not logged in.[/dim]")
198
+ _warn_env_token(ctx)
199
+ return
200
+
201
+ revoked = True
202
+ try:
203
+ # Revoke on the dashboard that issued it, whatever --api-url says.
204
+ api.call(saved_url, "DELETE", "/api/cli/tokens/current", token)
205
+ except api.ApiError as e:
206
+ # A 401 means the token is already gone (revoked in Settings), which is what we want.
207
+ revoked = e.status == 401
208
+ except Exception:
209
+ # Still forget it locally; it can be revoked from Settings > API Keys.
210
+ revoked = False
211
+
212
+ # Also forget its dashboard, so the next plain `moss login` goes to production.
213
+ update_config(token=None, api_url=None)
214
+ if as_json:
215
+ _print_json({"status": "ok", "revoked": revoked})
216
+ elif revoked:
217
+ console.print("[green]Logged out.[/green]")
218
+ else:
219
+ console.print(
220
+ "[yellow]Logged out locally, but the server could not revoke the token. "
221
+ "Revoke it in Settings > API Keys.[/yellow]"
222
+ )
223
+ _warn_env_token(ctx)
224
+
225
+
226
+ def _warn_env_token(ctx: typer.Context) -> None:
227
+ # Logout only touches the saved login, so an env token keeps every command working.
228
+ if ctx.obj.get("token"):
229
+ err_console.print(
230
+ "[yellow]MOSS_TOKEN / --token is still set, so other commands keep using "
231
+ "it. Unset it to log out fully.[/yellow]"
232
+ )
233
+
234
+
235
+ def whoami_command(
236
+ ctx: typer.Context,
237
+ json_flag: bool = typer.Option(False, "--json", help=JSON_HELP),
238
+ ) -> None:
239
+ """Show the account the CLI token belongs to."""
240
+ me = call_api(ctx, "GET", "/api/cli/me")
241
+ if json_mode(ctx, json_flag):
242
+ _print_json(me)
243
+ return
244
+ console.print(
245
+ f"Logged in as [bold]{me['user'].get('email') or me['user']['id']}[/bold]"
246
+ )
247
+ name: Optional[str] = (me.get("token") or {}).get("name")
248
+ if name:
249
+ console.print(f"[dim]Token: {name}[/dim]")