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.
- {moss_cli-0.1.0/src/moss_cli.egg-info → moss_cli-0.2.0}/PKG-INFO +71 -27
- {moss_cli-0.1.0 → moss_cli-0.2.0}/README.md +61 -17
- {moss_cli-0.1.0 → moss_cli-0.2.0}/pyproject.toml +10 -10
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/__init__.py +1 -1
- moss_cli-0.2.0/src/moss_cli/api.py +106 -0
- moss_cli-0.2.0/src/moss_cli/commands/auth.py +249 -0
- moss_cli-0.2.0/src/moss_cli/commands/common.py +29 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/init_cmd.py +3 -2
- moss_cli-0.2.0/src/moss_cli/commands/keys.py +90 -0
- moss_cli-0.2.0/src/moss_cli/commands/projects.py +37 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/search.py +4 -13
- moss_cli-0.2.0/src/moss_cli/config.py +135 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/main.py +19 -1
- {moss_cli-0.1.0 → moss_cli-0.2.0/src/moss_cli.egg-info}/PKG-INFO +71 -27
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/SOURCES.txt +7 -1
- moss_cli-0.2.0/src/moss_cli.egg-info/requires.txt +12 -0
- moss_cli-0.2.0/tests/test_login_and_keys.py +204 -0
- moss_cli-0.1.0/src/moss_cli/config.py +0 -57
- moss_cli-0.1.0/src/moss_cli.egg-info/requires.txt +0 -12
- {moss_cli-0.1.0 → moss_cli-0.2.0}/LICENSE +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/setup.cfg +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/__init__.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/doc.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/index.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/job.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/commands/version.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/documents.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/job_waiter.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli/output.py +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/dependency_links.txt +0 -0
- {moss_cli-0.1.0 → moss_cli-0.2.0}/src/moss_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
20
|
-
Requires-Dist: rich>=
|
|
19
|
+
Requires-Dist: typer>=0.26.8
|
|
20
|
+
Requires-Dist: rich>=15.0.0
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
-
Requires-Dist: pytest>=
|
|
23
|
-
Requires-Dist: black
|
|
24
|
-
Requires-Dist: isort
|
|
25
|
-
Requires-Dist: flake8>=7.
|
|
26
|
-
Requires-Dist: mypy>=1.0
|
|
27
|
-
Requires-Dist: build>=1.
|
|
28
|
-
Requires-Dist: twine>=
|
|
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.
|
|
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
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
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@
|
|
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.
|
|
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
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
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@
|
|
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.
|
|
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.
|
|
29
|
-
"rich>=
|
|
28
|
+
"typer>=0.26.8",
|
|
29
|
+
"rich>=15.0.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
dev = [
|
|
34
|
-
"pytest>=
|
|
35
|
-
"black>=
|
|
36
|
-
"isort>=
|
|
37
|
-
"flake8>=7.
|
|
38
|
-
"mypy>=1.0
|
|
39
|
-
"build>=1.
|
|
40
|
-
"twine>=
|
|
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]
|
|
@@ -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]")
|