gulp-cli 0.0.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.
- gulp_cli/__init__.py +1 -0
- gulp_cli/__main__.py +11 -0
- gulp_cli/cli.py +86 -0
- gulp_cli/client.py +15 -0
- gulp_cli/config.py +182 -0
- gulp_cli/extension_helpers.py +50 -0
- gulp_cli/extensions.py +140 -0
- gulp_cli/output.py +78 -0
- gulp_cli/utils.py +44 -0
- gulp_cli-0.0.0.dist-info/METADATA +85 -0
- gulp_cli-0.0.0.dist-info/RECORD +14 -0
- gulp_cli-0.0.0.dist-info/WHEEL +5 -0
- gulp_cli-0.0.0.dist-info/entry_points.txt +2 -0
- gulp_cli-0.0.0.dist-info/top_level.txt +1 -0
gulp_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gulp-cli package."""
|
gulp_cli/__main__.py
ADDED
gulp_cli/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from gulp_cli.commands.acl import app as acl_app
|
|
6
|
+
from gulp_cli.commands.auth import app as auth_app
|
|
7
|
+
from gulp_cli.commands.collab import app as collab_app
|
|
8
|
+
from gulp_cli.commands.context import app as context_app
|
|
9
|
+
from gulp_cli.commands.db import app as db_app
|
|
10
|
+
from gulp_cli.commands.enhance_map import app as enhance_map_app
|
|
11
|
+
from gulp_cli.commands.glyph import app as glyph_app
|
|
12
|
+
from gulp_cli.commands.ingest import app as ingest_app
|
|
13
|
+
from gulp_cli.commands.mapping import app as mapping_app
|
|
14
|
+
from gulp_cli.commands.operations import app as operation_app
|
|
15
|
+
from gulp_cli.commands.plugin import app as plugin_app
|
|
16
|
+
from gulp_cli.commands.query import app as query_app
|
|
17
|
+
from gulp_cli.commands.source import app as source_app
|
|
18
|
+
from gulp_cli.commands.stats import app as stats_app
|
|
19
|
+
from gulp_cli.commands.storage import app as storage_app
|
|
20
|
+
from gulp_cli.commands.user_group import app as user_group_app
|
|
21
|
+
from gulp_cli.commands.users import app as user_app
|
|
22
|
+
from gulp_cli.config import set_runtime_as_user, set_runtime_verbose
|
|
23
|
+
from gulp_cli.extensions import load_extensions
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
help="Modern CLI for gULP, powered by gulp-sdk",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def main(
|
|
33
|
+
as_user: str | None = typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
"--as-user",
|
|
36
|
+
help="Use the saved session of a different already-logged-in user for this command only.",
|
|
37
|
+
),
|
|
38
|
+
verbose: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--verbose",
|
|
41
|
+
help="Print complete result JSON instead of summary (global override).",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
set_runtime_as_user(as_user)
|
|
45
|
+
set_runtime_verbose(verbose)
|
|
46
|
+
|
|
47
|
+
app.add_typer(auth_app, name="auth")
|
|
48
|
+
app.add_typer(user_app, name="user")
|
|
49
|
+
app.add_typer(user_group_app, name="user-group")
|
|
50
|
+
app.add_typer(operation_app, name="operation")
|
|
51
|
+
app.add_typer(context_app, name="context")
|
|
52
|
+
app.add_typer(source_app, name="source")
|
|
53
|
+
app.add_typer(ingest_app, name="ingest")
|
|
54
|
+
app.add_typer(query_app, name="query")
|
|
55
|
+
app.add_typer(stats_app, name="stats")
|
|
56
|
+
app.add_typer(storage_app, name="storage")
|
|
57
|
+
app.add_typer(collab_app, name="collab")
|
|
58
|
+
app.add_typer(db_app, name="db")
|
|
59
|
+
app.add_typer(acl_app, name="acl")
|
|
60
|
+
app.add_typer(plugin_app, name="plugin")
|
|
61
|
+
app.add_typer(mapping_app, name="mapping")
|
|
62
|
+
app.add_typer(enhance_map_app, name="enhance-map")
|
|
63
|
+
app.add_typer(glyph_app, name="glyph")
|
|
64
|
+
|
|
65
|
+
load_extensions(
|
|
66
|
+
app,
|
|
67
|
+
command_groups={
|
|
68
|
+
"auth": auth_app,
|
|
69
|
+
"user": user_app,
|
|
70
|
+
"user-group": user_group_app,
|
|
71
|
+
"operation": operation_app,
|
|
72
|
+
"context": context_app,
|
|
73
|
+
"source": source_app,
|
|
74
|
+
"ingest": ingest_app,
|
|
75
|
+
"query": query_app,
|
|
76
|
+
"stats": stats_app,
|
|
77
|
+
"storage": storage_app,
|
|
78
|
+
"collab": collab_app,
|
|
79
|
+
"db": db_app,
|
|
80
|
+
"acl": acl_app,
|
|
81
|
+
"plugin": plugin_app,
|
|
82
|
+
"mapping": mapping_app,
|
|
83
|
+
"enhance-map": enhance_map_app,
|
|
84
|
+
"glyph": glyph_app,
|
|
85
|
+
},
|
|
86
|
+
)
|
gulp_cli/client.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from gulp_sdk.client import GulpClient
|
|
7
|
+
|
|
8
|
+
from gulp_cli.config import get_required_url_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@asynccontextmanager
|
|
12
|
+
async def get_client() -> AsyncIterator[GulpClient]:
|
|
13
|
+
url, token = get_required_url_token()
|
|
14
|
+
async with GulpClient(url, token=token) as client:
|
|
15
|
+
yield client
|
gulp_cli/config.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".config" / "gulp-cli"
|
|
9
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
10
|
+
_AS_USER_OVERRIDE: ContextVar[str | None] = ContextVar("gulp_cli_as_user_override", default=None)
|
|
11
|
+
_VERBOSE_OVERRIDE: ContextVar[bool] = ContextVar("gulp_cli_verbose_override", default=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CLIConfigError(Exception):
|
|
15
|
+
"""Raised when CLI config is missing or invalid."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalize_config(data: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
sessions_raw = data.get("sessions")
|
|
20
|
+
sessions: dict[str, dict[str, Any]] = {}
|
|
21
|
+
|
|
22
|
+
if isinstance(sessions_raw, dict):
|
|
23
|
+
for username, session in sessions_raw.items():
|
|
24
|
+
if not isinstance(session, dict):
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
name = str(username).strip()
|
|
28
|
+
url = str(session.get("url") or data.get("url") or "").strip()
|
|
29
|
+
token = str(session.get("token") or "").strip()
|
|
30
|
+
user_id = str(session.get("user_id") or name).strip()
|
|
31
|
+
|
|
32
|
+
if not name or not url or not token:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
sessions[name] = {
|
|
36
|
+
"url": url.rstrip("/"),
|
|
37
|
+
"token": token,
|
|
38
|
+
"user_id": user_id,
|
|
39
|
+
"expires_at": session.get("expires_at"),
|
|
40
|
+
}
|
|
41
|
+
else:
|
|
42
|
+
url = str(data.get("url") or "").strip()
|
|
43
|
+
token = str(data.get("token") or "").strip()
|
|
44
|
+
user_id = str(data.get("user_id") or "").strip()
|
|
45
|
+
if url and token:
|
|
46
|
+
username = user_id or "default"
|
|
47
|
+
sessions[username] = {
|
|
48
|
+
"url": url.rstrip("/"),
|
|
49
|
+
"token": token,
|
|
50
|
+
"user_id": user_id or username,
|
|
51
|
+
"expires_at": data.get("expires_at"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
active_user = str(data.get("active_user") or "").strip()
|
|
55
|
+
if active_user not in sessions:
|
|
56
|
+
active_user = next(iter(sessions), "")
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"active_user": active_user,
|
|
60
|
+
"sessions": sessions,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_config() -> dict[str, Any]:
|
|
65
|
+
if not CONFIG_PATH.exists():
|
|
66
|
+
return {"active_user": "", "sessions": {}}
|
|
67
|
+
try:
|
|
68
|
+
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
69
|
+
except json.JSONDecodeError as exc:
|
|
70
|
+
raise CLIConfigError(f"Invalid config JSON in {CONFIG_PATH}: {exc}") from exc
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
raise CLIConfigError(f"Invalid config JSON in {CONFIG_PATH}: root object must be a JSON object")
|
|
73
|
+
return _normalize_config(raw)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_config(data: dict[str, Any]) -> None:
|
|
77
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
normalized = _normalize_config(data)
|
|
79
|
+
CONFIG_PATH.write_text(json.dumps(normalized, indent=2, sort_keys=True), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_config() -> None:
|
|
83
|
+
if CONFIG_PATH.exists():
|
|
84
|
+
CONFIG_PATH.unlink()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_runtime_as_user(user_name: str | None) -> None:
|
|
88
|
+
value = str(user_name or "").strip() or None
|
|
89
|
+
_AS_USER_OVERRIDE.set(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_runtime_as_user() -> str | None:
|
|
93
|
+
return _AS_USER_OVERRIDE.get()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def set_runtime_verbose(verbose: bool) -> None:
|
|
97
|
+
_VERBOSE_OVERRIDE.set(bool(verbose))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_runtime_verbose() -> bool:
|
|
101
|
+
return _VERBOSE_OVERRIDE.get()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_saved_users() -> list[str]:
|
|
105
|
+
return list(load_config().get("sessions", {}).keys())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_session(
|
|
109
|
+
*,
|
|
110
|
+
url: str,
|
|
111
|
+
username: str,
|
|
112
|
+
token: str,
|
|
113
|
+
user_id: str,
|
|
114
|
+
expires_at: Any,
|
|
115
|
+
make_active: bool = True,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
cfg = load_config()
|
|
118
|
+
sessions = dict(cfg.get("sessions") or {})
|
|
119
|
+
name = username.strip()
|
|
120
|
+
sessions[name] = {
|
|
121
|
+
"url": url.rstrip("/"),
|
|
122
|
+
"token": token,
|
|
123
|
+
"user_id": user_id.strip() or name,
|
|
124
|
+
"expires_at": expires_at,
|
|
125
|
+
}
|
|
126
|
+
cfg["sessions"] = sessions
|
|
127
|
+
if make_active or not cfg.get("active_user"):
|
|
128
|
+
cfg["active_user"] = name
|
|
129
|
+
save_config(cfg)
|
|
130
|
+
return cfg
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_selected_session(user_name: str | None = None) -> dict[str, Any]:
|
|
134
|
+
cfg = load_config()
|
|
135
|
+
sessions = cfg.get("sessions") or {}
|
|
136
|
+
selected = (user_name or get_runtime_as_user() or cfg.get("active_user") or "").strip()
|
|
137
|
+
|
|
138
|
+
if not sessions:
|
|
139
|
+
raise CLIConfigError("Not authenticated. Run: gulp-cli auth login --url <url> --username <u> --password <p>")
|
|
140
|
+
|
|
141
|
+
if not selected:
|
|
142
|
+
selected = next(iter(sessions), "")
|
|
143
|
+
|
|
144
|
+
session = sessions.get(selected)
|
|
145
|
+
if not isinstance(session, dict):
|
|
146
|
+
available = ", ".join(sorted(sessions))
|
|
147
|
+
raise CLIConfigError(
|
|
148
|
+
f"User '{selected}' is not logged in. Logged-in users: {available}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return {"username": selected, **session}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def delete_session(user_name: str | None = None) -> dict[str, Any] | None:
|
|
155
|
+
cfg = load_config()
|
|
156
|
+
sessions = dict(cfg.get("sessions") or {})
|
|
157
|
+
selected = (user_name or get_runtime_as_user() or cfg.get("active_user") or "").strip()
|
|
158
|
+
if not selected:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
removed = sessions.pop(selected, None)
|
|
162
|
+
if removed is None:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
if sessions:
|
|
166
|
+
if cfg.get("active_user") == selected:
|
|
167
|
+
cfg["active_user"] = next(iter(sessions))
|
|
168
|
+
cfg["sessions"] = sessions
|
|
169
|
+
save_config(cfg)
|
|
170
|
+
else:
|
|
171
|
+
clear_config()
|
|
172
|
+
|
|
173
|
+
return {"username": selected, **removed}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_required_url_token() -> tuple[str, str]:
|
|
177
|
+
session = get_selected_session()
|
|
178
|
+
url = str(session.get("url") or "").strip()
|
|
179
|
+
token = str(session.get("token") or "").strip()
|
|
180
|
+
if not url or not token:
|
|
181
|
+
raise CLIConfigError("Not authenticated. Run: gulp-cli auth login --url <url> --username <u> --password <p>")
|
|
182
|
+
return url, token
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from gulp_sdk.api.request_utils import wait_for_request_stats
|
|
6
|
+
from gulp_sdk.websocket import WSMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def call_custom_endpoint(
|
|
10
|
+
client: Any,
|
|
11
|
+
*,
|
|
12
|
+
method: str,
|
|
13
|
+
path: str,
|
|
14
|
+
params: dict[str, Any] | None = None,
|
|
15
|
+
json_body: Any = None,
|
|
16
|
+
ensure_websocket: bool = False,
|
|
17
|
+
wait: bool = False,
|
|
18
|
+
timeout: int = 120,
|
|
19
|
+
ws_callback: Callable[[WSMessage], None] | None = None,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
"""
|
|
22
|
+
Execute a direct API call for extension endpoints.
|
|
23
|
+
|
|
24
|
+
Optionally ensures websocket connection and waits for terminal request stats
|
|
25
|
+
when server responds with JSend pending status.
|
|
26
|
+
"""
|
|
27
|
+
if ensure_websocket or wait:
|
|
28
|
+
await client.ensure_websocket()
|
|
29
|
+
|
|
30
|
+
response: dict[str, Any] = await client._request(
|
|
31
|
+
method,
|
|
32
|
+
path,
|
|
33
|
+
params=params,
|
|
34
|
+
json=json_body,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
wait
|
|
39
|
+
and isinstance(response, dict)
|
|
40
|
+
and response.get("status") == "pending"
|
|
41
|
+
and response.get("req_id")
|
|
42
|
+
):
|
|
43
|
+
return await wait_for_request_stats(
|
|
44
|
+
client,
|
|
45
|
+
str(response.get("req_id")),
|
|
46
|
+
timeout,
|
|
47
|
+
ws_callback=ws_callback,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return response
|
gulp_cli/extensions.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from gulp_cli.client import get_client
|
|
11
|
+
from gulp_cli.config import CONFIG_DIR
|
|
12
|
+
from gulp_cli.output import print_warning
|
|
13
|
+
|
|
14
|
+
INTERNAL_EXTENSIONS_DIR = Path(__file__).resolve().parent / "extension"
|
|
15
|
+
EXTERNAL_EXTENSIONS_DIR = CONFIG_DIR / "extension"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_valid_extension_file(path: Path) -> bool:
|
|
19
|
+
return path.is_file() and path.suffix == ".py" and not path.name.startswith("_")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def discover_extensions() -> dict[str, Path]:
|
|
23
|
+
"""
|
|
24
|
+
Discover extension modules from internal and external extension folders.
|
|
25
|
+
|
|
26
|
+
External modules have priority when a filename exists in both folders.
|
|
27
|
+
"""
|
|
28
|
+
discovered: dict[str, Path] = {}
|
|
29
|
+
|
|
30
|
+
if INTERNAL_EXTENSIONS_DIR.exists():
|
|
31
|
+
for path in sorted(INTERNAL_EXTENSIONS_DIR.iterdir()):
|
|
32
|
+
if _is_valid_extension_file(path):
|
|
33
|
+
discovered[path.name] = path
|
|
34
|
+
|
|
35
|
+
if EXTERNAL_EXTENSIONS_DIR.exists():
|
|
36
|
+
for path in sorted(EXTERNAL_EXTENSIONS_DIR.iterdir()):
|
|
37
|
+
if _is_valid_extension_file(path):
|
|
38
|
+
discovered[path.name] = path
|
|
39
|
+
|
|
40
|
+
return discovered
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_extension_module(module_name: str, path: Path) -> Any:
|
|
44
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
45
|
+
if spec is None or spec.loader is None:
|
|
46
|
+
raise RuntimeError(f"Cannot load extension module spec from {path}")
|
|
47
|
+
module = importlib.util.module_from_spec(spec)
|
|
48
|
+
spec.loader.exec_module(module)
|
|
49
|
+
return module
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _call_register_extension(
|
|
53
|
+
register_fn: Callable[..., Any],
|
|
54
|
+
*,
|
|
55
|
+
app: typer.Typer,
|
|
56
|
+
command_groups: dict[str, typer.Typer],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Call register_extension with a flexible, backward-compatible signature.
|
|
60
|
+
"""
|
|
61
|
+
supplied_kwargs: dict[str, Any] = {
|
|
62
|
+
"app": app,
|
|
63
|
+
"root_app": app,
|
|
64
|
+
"command_groups": command_groups,
|
|
65
|
+
"get_client": get_client,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
signature = inspect.signature(register_fn)
|
|
69
|
+
accepts_var_kwargs = any(
|
|
70
|
+
p.kind == inspect.Parameter.VAR_KEYWORD
|
|
71
|
+
for p in signature.parameters.values()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
kwargs: dict[str, Any] = {}
|
|
75
|
+
for name in signature.parameters:
|
|
76
|
+
if name in supplied_kwargs:
|
|
77
|
+
kwargs[name] = supplied_kwargs[name]
|
|
78
|
+
|
|
79
|
+
if accepts_var_kwargs:
|
|
80
|
+
kwargs = dict(supplied_kwargs)
|
|
81
|
+
|
|
82
|
+
if kwargs:
|
|
83
|
+
register_fn(**kwargs)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if signature.parameters:
|
|
87
|
+
register_fn(app)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
register_fn()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_extensions(
|
|
94
|
+
app: typer.Typer,
|
|
95
|
+
*,
|
|
96
|
+
command_groups: dict[str, typer.Typer] | None = None,
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
"""
|
|
99
|
+
Load and register CLI extensions.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of loaded extension filenames.
|
|
103
|
+
"""
|
|
104
|
+
loaded: list[str] = []
|
|
105
|
+
groups = command_groups or {}
|
|
106
|
+
|
|
107
|
+
for filename, path in sorted(discover_extensions().items()):
|
|
108
|
+
module_name = f"gulp_cli_extension_{path.stem}"
|
|
109
|
+
try:
|
|
110
|
+
module = _load_extension_module(module_name, path)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
print_warning(f"Failed to load extension {filename}: {exc}")
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
register_fn = getattr(module, "register_extension", None)
|
|
116
|
+
if register_fn is None:
|
|
117
|
+
print_warning(
|
|
118
|
+
f"Skipping extension {filename}: missing register_extension function"
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if not callable(register_fn):
|
|
123
|
+
print_warning(
|
|
124
|
+
f"Skipping extension {filename}: register_extension is not callable"
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
_call_register_extension(
|
|
130
|
+
register_fn,
|
|
131
|
+
app=app,
|
|
132
|
+
command_groups=groups,
|
|
133
|
+
)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
print_warning(f"Failed to register extension {filename}: {exc}")
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
loaded.append(filename)
|
|
139
|
+
|
|
140
|
+
return loaded
|
gulp_cli/output.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.json import JSON
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from gulp_cli.config import get_runtime_verbose
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_json(data: Any) -> None:
|
|
15
|
+
console.print(JSON.from_data(data))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_error(message: str) -> None:
|
|
19
|
+
console.print(f"[red]Error: {message}[/red]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_warning(message: str) -> None:
|
|
23
|
+
console.print(f"[yellow]Warning: {message}[/yellow]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_kv_table(rows: list[tuple[str, Any]], title: str | None = None) -> None:
|
|
27
|
+
table = Table(title=title)
|
|
28
|
+
table.add_column("Key", style="cyan")
|
|
29
|
+
table.add_column("Value", style="white")
|
|
30
|
+
for key, value in rows:
|
|
31
|
+
table.add_row(str(key), str(value))
|
|
32
|
+
console.print(table)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_records(records: list[dict[str, Any]], title: str | None = None) -> None:
|
|
36
|
+
if not records:
|
|
37
|
+
console.print("[yellow]No results[/yellow]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
keys: list[str] = []
|
|
41
|
+
for record in records:
|
|
42
|
+
for key in record.keys():
|
|
43
|
+
if key not in keys:
|
|
44
|
+
keys.append(key)
|
|
45
|
+
|
|
46
|
+
table = Table(title=title)
|
|
47
|
+
for key in keys:
|
|
48
|
+
table.add_column(str(key), overflow="fold")
|
|
49
|
+
|
|
50
|
+
for record in records:
|
|
51
|
+
table.add_row(*[str(record.get(key, "")) for key in keys])
|
|
52
|
+
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_result(
|
|
57
|
+
data: Any,
|
|
58
|
+
verbose: bool | None = None,
|
|
59
|
+
formatter: Callable[[Any], None] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Print result data with conditional verbose mode.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
data: The result data to print
|
|
66
|
+
verbose: If True, always print full JSON output; if None, use global runtime verbose
|
|
67
|
+
formatter: Callable that formats/prints the summary (called only if verbose=False)
|
|
68
|
+
"""
|
|
69
|
+
if verbose is None:
|
|
70
|
+
verbose = get_runtime_verbose()
|
|
71
|
+
|
|
72
|
+
if verbose:
|
|
73
|
+
print_json(data)
|
|
74
|
+
elif formatter:
|
|
75
|
+
formatter(data)
|
|
76
|
+
else:
|
|
77
|
+
# Default: print full JSON if no formatter provided
|
|
78
|
+
print_json(data)
|
gulp_cli/utils.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_json_option(raw: str | None, *, field_name: str) -> dict[str, Any] | None:
|
|
10
|
+
if raw is None:
|
|
11
|
+
return None
|
|
12
|
+
text = raw.strip()
|
|
13
|
+
if not text:
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
data = json.loads(text)
|
|
17
|
+
except json.JSONDecodeError as exc:
|
|
18
|
+
raise typer.BadParameter(f"Invalid JSON for --{field_name}: {exc}") from exc
|
|
19
|
+
if not isinstance(data, dict):
|
|
20
|
+
raise typer.BadParameter(f"--{field_name} must be a JSON object")
|
|
21
|
+
return data
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_json_list_option(raw: str | None, *, field_name: str) -> list[dict[str, Any]] | None:
|
|
25
|
+
if raw is None:
|
|
26
|
+
return None
|
|
27
|
+
text = raw.strip()
|
|
28
|
+
if not text:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(text)
|
|
32
|
+
except json.JSONDecodeError as exc:
|
|
33
|
+
raise typer.BadParameter(f"Invalid JSON for --{field_name}: {exc}") from exc
|
|
34
|
+
if isinstance(data, dict):
|
|
35
|
+
return [data]
|
|
36
|
+
if isinstance(data, list) and all(isinstance(item, dict) for item in data):
|
|
37
|
+
return data
|
|
38
|
+
raise typer.BadParameter(f"--{field_name} must be a JSON object or a list of JSON objects")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def comma_split(raw: str | None) -> list[str]:
|
|
42
|
+
if not raw:
|
|
43
|
+
return []
|
|
44
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gulp-cli
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Command-line client for gULP
|
|
5
|
+
Author-email: Mentat <info@mentat.is>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: typer>=0.12.5
|
|
9
|
+
Requires-Dist: rich>=13.7.1
|
|
10
|
+
Requires-Dist: click==8.1.7
|
|
11
|
+
Requires-Dist: gulp-sdk
|
|
12
|
+
|
|
13
|
+
# π gulp-cli
|
|
14
|
+
|
|
15
|
+
> THIS IS STILL WIP! for the cli to work, set `"ws_ignore_missing": true` in your `gulp_cfg.json` to prevent the backend from halting operations when the CLI disconnects its websocket after sending an async request. This is a temporary workaround until (if)we implement a proper solution for maintaining the websocket lifecycle in the CLI.
|
|
16
|
+
|
|
17
|
+
**A modern, powerful command-line interface for gULP** β manage forensic document ingestion, querying, enrichment, and collaboration entirely from your terminal.
|
|
18
|
+
|
|
19
|
+
## β¨ What can you do?
|
|
20
|
+
|
|
21
|
+
- π **Authentication** β secure login with token persistence
|
|
22
|
+
- π₯ **Ingestion** β ingest files (single/batch/wildcard), zip archives, with concurrent uploads
|
|
23
|
+
- π **Querying** β raw OpenSearch queries, Sigma rules, external plugins
|
|
24
|
+
- π·οΈ **Enrichment** β enrich documents, tag/untag, update fields
|
|
25
|
+
- π₯ **User Management** β create users, manage permissions (admin only)
|
|
26
|
+
- π **Operations** β create/list/manage operations and contexts
|
|
27
|
+
- π **Plugins** β list/upload/download plugins and mapping files
|
|
28
|
+
- πΊοΈ **Enhance Maps** β map `gulp.event_code` to glyph/color per plugin
|
|
29
|
+
- πΌοΈ **Glyphs** β create/list/update/delete custom glyphs
|
|
30
|
+
- π§© **Dynamic Extensions** β load custom CLI commands from internal or user extension folders
|
|
31
|
+
- π **Stats** β monitor ingestion and query requests
|
|
32
|
+
- π― **Collaboration** β manage notes, links, highlights
|
|
33
|
+
|
|
34
|
+
All with **beautiful terminal output**, **automatic tab completion**, and **async-first design**.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## π Quick Start
|
|
39
|
+
|
|
40
|
+
### Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Temporary setup: gulp-sdk is currently installed from GitHub via gulp-cli dependencies.
|
|
44
|
+
# In the future, both gulp-sdk and gulp-cli will be installable directly from PyPI.
|
|
45
|
+
# for now, clone this repository and install in development mode.
|
|
46
|
+
# you can use your existing gulp .venv or create a new one ...
|
|
47
|
+
python3 -m venv ./.venv
|
|
48
|
+
source ./.venv/bin/activate
|
|
49
|
+
git clone https://github.com/mentat-is/gulp-cli
|
|
50
|
+
cd gulp-cli && pip install -e .
|
|
51
|
+
|
|
52
|
+
# Verify installation
|
|
53
|
+
gulp-cli --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Basic Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Login to your gULP instance
|
|
60
|
+
gulp-cli auth login --url http://localhost:8080 --username admin --password admin
|
|
61
|
+
|
|
62
|
+
# Check who you are
|
|
63
|
+
gulp-cli auth whoami
|
|
64
|
+
|
|
65
|
+
# List operations
|
|
66
|
+
gulp-cli operation list
|
|
67
|
+
|
|
68
|
+
# Ingest files with wildcard
|
|
69
|
+
gulp-cli ingest file my_operation win_evtx 'samples/win_evtx/*.evtx'
|
|
70
|
+
|
|
71
|
+
# Query documents
|
|
72
|
+
gulp-cli query raw my_operation --q '{"query":{"match_all":{}}}'
|
|
73
|
+
```
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## π Documentation
|
|
77
|
+
|
|
78
|
+
- **[Getting Started Guide](./docs/getting-started.md)** β auth, first operation, first ingest
|
|
79
|
+
- **[Command Reference](./docs/command-reference.md)** β all available commands and options
|
|
80
|
+
- **[Extensions Guide](./docs/extensions.md)** β dynamic extension loading and custom command contract
|
|
81
|
+
- **[Resource Management Commands](./docs/resource-management.md)** β context, source, plugin, mapping, enhance-map, glyph
|
|
82
|
+
- **[Practical Examples](./docs/examples.md)** β real-world workflows and recipes
|
|
83
|
+
- **[Troubleshooting](./docs/troubleshooting-cli.md)** β common issues and solutions
|
|
84
|
+
|
|
85
|
+
---
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
gulp_cli/__init__.py,sha256=9eyhAp_FfXQO5PdWKgLM-WRLqaO294EP888aszYDwRQ,24
|
|
2
|
+
gulp_cli/__main__.py,sha256=cneTsFHHzojBDytw_a8L1dNnCxIFYW-JBxz8IuCYcuA,137
|
|
3
|
+
gulp_cli/cli.py,sha256=9HUXprfMaalDusiLnssKJS8b4Z37sLW6X1LctsoCotg,2920
|
|
4
|
+
gulp_cli/client.py,sha256=R_cXA94qOINW1Hfpepsq3ihgzjk9ibAAWl66tbIakmA,398
|
|
5
|
+
gulp_cli/config.py,sha256=pMRx_Mr-Hzg3egO3FckXZw7b0Ie6Nb89j6eNhfam4dc,5632
|
|
6
|
+
gulp_cli/extension_helpers.py,sha256=j7QxrSPP3JAZFuDi6ok37Ch3nwF9flSISqKkVxn4ckU,1260
|
|
7
|
+
gulp_cli/extensions.py,sha256=m-C0gJ6-_RG2NI-hajLIhXP80qXSUXOPDCs9vdbr5Vs,3923
|
|
8
|
+
gulp_cli/output.py,sha256=VIbDBcN9Ohnyi7PcV2DpQCPDBYqD1Ij-IeetD-Ss8ts,2033
|
|
9
|
+
gulp_cli/utils.py,sha256=d0tqpRW5V5t5qTHXejIqjBQS4rSF1I7ENeB9oKsjgqg,1328
|
|
10
|
+
gulp_cli-0.0.0.dist-info/METADATA,sha256=x6OBoBzQVSFbuMpnQelkCRmiyK4V2HDnTi7eEJ5wCLc,3410
|
|
11
|
+
gulp_cli-0.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
gulp_cli-0.0.0.dist-info/entry_points.txt,sha256=_1KOhvP8p49Nwm9KR383JAYVfCvUia2aKdArCJ43ORY,52
|
|
13
|
+
gulp_cli-0.0.0.dist-info/top_level.txt,sha256=0-7D6OZIho4wqM2BZdxk6xOAdAVwo6orXeZW-TqeMfk,9
|
|
14
|
+
gulp_cli-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gulp_cli
|