mammoth-cli 1.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.
- mammoth_cli/__init__.py +14 -0
- mammoth_cli/__main__.py +27 -0
- mammoth_cli/app.py +565 -0
- mammoth_cli/bundled_skill/mammoth-cli/SKILL.md +109 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/auth.md +27 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/input.md +24 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/jobs-drafts.md +35 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/machine-output.md +30 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/recovery.md +28 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/safety.md +26 -0
- mammoth_cli/commands/__init__.py +29 -0
- mammoth_cli/commands/activity.py +123 -0
- mammoth_cli/commands/addon.py +173 -0
- mammoth_cli/commands/agent.py +199 -0
- mammoth_cli/commands/ai.py +274 -0
- mammoth_cli/commands/annotation.py +166 -0
- mammoth_cli/commands/auth.py +567 -0
- mammoth_cli/commands/automation.py +245 -0
- mammoth_cli/commands/batch.py +204 -0
- mammoth_cli/commands/billing.py +462 -0
- mammoth_cli/commands/browse.py +151 -0
- mammoth_cli/commands/capability.py +43 -0
- mammoth_cli/commands/client_app.py +233 -0
- mammoth_cli/commands/completion.py +82 -0
- mammoth_cli/commands/config.py +309 -0
- mammoth_cli/commands/connector.py +464 -0
- mammoth_cli/commands/context.py +205 -0
- mammoth_cli/commands/dashboard.py +419 -0
- mammoth_cli/commands/data_app.py +239 -0
- mammoth_cli/commands/dataset.py +332 -0
- mammoth_cli/commands/doctor.py +90 -0
- mammoth_cli/commands/external_key.py +163 -0
- mammoth_cli/commands/file.py +243 -0
- mammoth_cli/commands/folder.py +224 -0
- mammoth_cli/commands/job.py +147 -0
- mammoth_cli/commands/notification.py +201 -0
- mammoth_cli/commands/parameter.py +284 -0
- mammoth_cli/commands/project.py +327 -0
- mammoth_cli/commands/registry.py +587 -0
- mammoth_cli/commands/report.py +61 -0
- mammoth_cli/commands/schedule.py +214 -0
- mammoth_cli/commands/schema.py +271 -0
- mammoth_cli/commands/skill.py +59 -0
- mammoth_cli/commands/snippet.py +200 -0
- mammoth_cli/commands/support.py +800 -0
- mammoth_cli/commands/template.py +134 -0
- mammoth_cli/commands/trash.py +115 -0
- mammoth_cli/commands/user.py +190 -0
- mammoth_cli/commands/view.py +1143 -0
- mammoth_cli/commands/view_ops.py +596 -0
- mammoth_cli/commands/webhook.py +235 -0
- mammoth_cli/commands/workflow.py +340 -0
- mammoth_cli/commands/workspace.py +359 -0
- mammoth_cli/context/__init__.py +1 -0
- mammoth_cli/context/credentials.py +223 -0
- mammoth_cli/context/endpoint.py +57 -0
- mammoth_cli/context/profiles.py +370 -0
- mammoth_cli/context/resolver.py +240 -0
- mammoth_cli/contracts/__init__.py +1 -0
- mammoth_cli/contracts/auth.py +32 -0
- mammoth_cli/errors/__init__.py +1 -0
- mammoth_cli/errors/envelope.py +144 -0
- mammoth_cli/manifest/__init__.py +1 -0
- mammoth_cli/manifest/loader.py +96 -0
- mammoth_cli/messages/__init__.py +1 -0
- mammoth_cli/output/__init__.py +1 -0
- mammoth_cli/output/envelope.py +42 -0
- mammoth_cli/output/normalize.py +84 -0
- mammoth_cli/output/policy.py +71 -0
- mammoth_cli/output/render.py +83 -0
- mammoth_cli/py.typed +0 -0
- mammoth_cli/runtime/__init__.py +0 -0
- mammoth_cli/runtime/confirm.py +124 -0
- mammoth_cli/runtime/executor.py +126 -0
- mammoth_cli/runtime/input_loader.py +140 -0
- mammoth_cli/runtime/invocation.py +87 -0
- mammoth_cli/runtime/options.py +172 -0
- mammoth_cli/runtime/session.py +82 -0
- mammoth_cli/runtime/strict.py +240 -0
- mammoth_cli/runtime/validate.py +120 -0
- mammoth_cli/services/__init__.py +1 -0
- mammoth_cli/services/argspec.py +301 -0
- mammoth_cli/services/coerce.py +172 -0
- mammoth_cli/services/conditions.py +85 -0
- mammoth_cli/services/dispatch.py +63 -0
- mammoth_cli/services/factory.py +45 -0
- mammoth_cli/services/input_fields.py +44 -0
- mammoth_cli/services/mapping.py +74 -0
- mammoth_cli/services/openapi_types.py +166 -0
- mammoth_cli/services/positionals.py +408 -0
- mammoth_cli/services/protocol.py +123 -0
- mammoth_cli/services/sdk_service.py +274 -0
- mammoth_cli/services/testing.py +153 -0
- mammoth_cli/services/type_system.py +427 -0
- mammoth_cli/skills/__init__.py +1 -0
- mammoth_cli/skills/installer.py +321 -0
- mammoth_cli/testing.py +55 -0
- mammoth_cli-1.0.0.dist-info/METADATA +92 -0
- mammoth_cli-1.0.0.dist-info/RECORD +150 -0
- mammoth_cli-1.0.0.dist-info/WHEEL +4 -0
- mammoth_cli-1.0.0.dist-info/entry_points.txt +3 -0
- mammoth_cli-1.0.0.dist-info/licenses/LICENSE +23 -0
- spec/manifests/_sdk_introspection.json +1459 -0
- spec/manifests/commands/activity.yaml +72 -0
- spec/manifests/commands/addon.yaml +239 -0
- spec/manifests/commands/agent.yaml +192 -0
- spec/manifests/commands/ai.yaml +152 -0
- spec/manifests/commands/annotation.yaml +191 -0
- spec/manifests/commands/auth.yaml +101 -0
- spec/manifests/commands/automation.yaml +273 -0
- spec/manifests/commands/batch.yaml +262 -0
- spec/manifests/commands/billing.yaml +813 -0
- spec/manifests/commands/browse.yaml +145 -0
- spec/manifests/commands/capability.yaml +74 -0
- spec/manifests/commands/client-app.yaml +193 -0
- spec/manifests/commands/completion.yaml +68 -0
- spec/manifests/commands/config.yaml +134 -0
- spec/manifests/commands/connector.yaml +944 -0
- spec/manifests/commands/context.yaml +101 -0
- spec/manifests/commands/dashboard.yaml +3595 -0
- spec/manifests/commands/data-app.yaml +485 -0
- spec/manifests/commands/dataset.yaml +580 -0
- spec/manifests/commands/doctor.yaml +35 -0
- spec/manifests/commands/external-key.yaml +151 -0
- spec/manifests/commands/file.yaml +341 -0
- spec/manifests/commands/folder.yaml +334 -0
- spec/manifests/commands/job.yaml +151 -0
- spec/manifests/commands/notification.yaml +188 -0
- spec/manifests/commands/parameter.yaml +527 -0
- spec/manifests/commands/project.yaml +669 -0
- spec/manifests/commands/report.yaml +36 -0
- spec/manifests/commands/schedule.yaml +194 -0
- spec/manifests/commands/schema.yaml +74 -0
- spec/manifests/commands/skill.yaml +167 -0
- spec/manifests/commands/snippet.yaml +311 -0
- spec/manifests/commands/support.yaml +1805 -0
- spec/manifests/commands/template.yaml +191 -0
- spec/manifests/commands/trash.yaml +105 -0
- spec/manifests/commands/user.yaml +280 -0
- spec/manifests/commands/version.yaml +35 -0
- spec/manifests/commands/view.yaml +4232 -0
- spec/manifests/commands/webhook.yaml +261 -0
- spec/manifests/commands/workflow.yaml +622 -0
- spec/manifests/commands/workspace.yaml +705 -0
- spec/manifests/openapi-operations.yaml +8853 -0
- spec/manifests/schema-v1.json +212 -0
- spec/manifests/sdk-catalog.source.yaml +4069 -0
- spec/manifests/sdk-methods.yaml +6971 -0
- spec/openapi/metadata.json +10 -0
- spec/openapi/openapi.json +1 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Handlers for the ``workspace`` command family.
|
|
2
|
+
|
|
3
|
+
None of these commands are project-scoped: every handler resolves an
|
|
4
|
+
authenticated service and dispatches to the public SDK method named by the
|
|
5
|
+
command's reviewed manifest ``sdk_symbol``, using the generic
|
|
6
|
+
:meth:`~mammoth_cli.services.protocol.MammothService.call` seam. A handful of
|
|
7
|
+
commands target a single, specific workspace (``get``, ``delete``,
|
|
8
|
+
``reactivate``, ``update``); for those, the workspace id comes from the first
|
|
9
|
+
CLI positional when given, and is otherwise left unset so the SDK falls back
|
|
10
|
+
to the client's own workspace. All other required fields (tokens, bodies,
|
|
11
|
+
patch lists, email lists) come from the strict ``--input`` document.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from mammoth_cli.errors.envelope import (
|
|
19
|
+
CODE_INVALID_ARGUMENT,
|
|
20
|
+
CODE_MISSING_ARGUMENT,
|
|
21
|
+
CODE_MISSING_FIELD,
|
|
22
|
+
CODE_SDK_SYMBOL_UNRESOLVED,
|
|
23
|
+
EXIT_USAGE,
|
|
24
|
+
CliError,
|
|
25
|
+
)
|
|
26
|
+
from mammoth_cli.manifest.loader import command_by_id
|
|
27
|
+
from mammoth_cli.runtime.confirm import (
|
|
28
|
+
POLICY_CONFIRM_TARGET,
|
|
29
|
+
POLICY_PROMPT_OR_YES,
|
|
30
|
+
enforce_confirmation,
|
|
31
|
+
)
|
|
32
|
+
from mammoth_cli.runtime.invocation import Invocation
|
|
33
|
+
from mammoth_cli.runtime.session import open_service
|
|
34
|
+
|
|
35
|
+
HandlerResult = tuple[Any, dict[str, Any]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _symbol(invocation: Invocation) -> str:
|
|
39
|
+
"""Return the reviewed backing SDK symbol for this command."""
|
|
40
|
+
record = command_by_id(invocation.command_id)
|
|
41
|
+
if record is None or not record.get("sdk_symbol"):
|
|
42
|
+
raise CliError(
|
|
43
|
+
code=CODE_SDK_SYMBOL_UNRESOLVED,
|
|
44
|
+
message=f"No SDK symbol is recorded for '{invocation.command_id}'.",
|
|
45
|
+
exit_status=EXIT_USAGE,
|
|
46
|
+
)
|
|
47
|
+
return str(record["sdk_symbol"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _string_positional(invocation: Invocation) -> str | None:
|
|
51
|
+
"""Return the first positional argument, or None if absent."""
|
|
52
|
+
return invocation.extra_args[0] if invocation.extra_args else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_string_positional(invocation: Invocation, name: str) -> str:
|
|
56
|
+
"""Return the first positional argument, or raise a usage error."""
|
|
57
|
+
value = _string_positional(invocation)
|
|
58
|
+
if not value:
|
|
59
|
+
raise CliError(
|
|
60
|
+
code=CODE_MISSING_ARGUMENT,
|
|
61
|
+
message=f"This command requires a {name} argument.",
|
|
62
|
+
exit_status=EXIT_USAGE,
|
|
63
|
+
hint=f"Pass the {name} as a positional argument.",
|
|
64
|
+
)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _int_positional(invocation: Invocation, name: str) -> int | None:
|
|
69
|
+
"""Parse the first positional argument as an int, or return None if absent."""
|
|
70
|
+
if not invocation.extra_args:
|
|
71
|
+
return None
|
|
72
|
+
raw = invocation.extra_args[0]
|
|
73
|
+
try:
|
|
74
|
+
return int(raw)
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise CliError(
|
|
77
|
+
code=CODE_INVALID_ARGUMENT,
|
|
78
|
+
message=f"The {name} argument '{raw}' is not an integer.",
|
|
79
|
+
exit_status=EXIT_USAGE,
|
|
80
|
+
) from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _require_int_positional(invocation: Invocation, name: str) -> int:
|
|
84
|
+
"""Parse the first positional argument as an int, or raise a usage error."""
|
|
85
|
+
value = _int_positional(invocation, name)
|
|
86
|
+
if value is None:
|
|
87
|
+
raise CliError(
|
|
88
|
+
code=CODE_MISSING_ARGUMENT,
|
|
89
|
+
message=f"This command requires a {name} argument.",
|
|
90
|
+
exit_status=EXIT_USAGE,
|
|
91
|
+
hint=f"Pass the {name} as a positional argument.",
|
|
92
|
+
)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _require_field(document: dict[str, Any] | None, field: str) -> Any:
|
|
97
|
+
"""Return a required field from the ``--input`` document, or raise usage."""
|
|
98
|
+
if document is None or field not in document:
|
|
99
|
+
raise CliError(
|
|
100
|
+
code=CODE_MISSING_FIELD,
|
|
101
|
+
message=f"This command requires the '{field}' input field.",
|
|
102
|
+
exit_status=EXIT_USAGE,
|
|
103
|
+
hint=f"Pass it via --input, for example: --input '{{\"{field}\": ...}}'.",
|
|
104
|
+
)
|
|
105
|
+
return document[field]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _forward_optional(
|
|
109
|
+
document: dict[str, Any], kwargs: dict[str, Any], fields: tuple[str, ...]
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Copy each present field from ``document`` into ``kwargs`` under the same name."""
|
|
112
|
+
for field in fields:
|
|
113
|
+
if field in document:
|
|
114
|
+
kwargs[field] = document[field]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _meta(invocation: Invocation, workspace_id: int) -> dict[str, Any]:
|
|
118
|
+
"""Build the common envelope metadata for a workspace command (no project scope)."""
|
|
119
|
+
return {
|
|
120
|
+
"profile": invocation.profile,
|
|
121
|
+
"workspace_id": workspace_id,
|
|
122
|
+
"project_id": None,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def workspace_accept_invite(invocation: Invocation) -> HandlerResult:
|
|
127
|
+
"""Accept a pending workspace invite using a token from ``--input``."""
|
|
128
|
+
document = invocation.load_input()
|
|
129
|
+
token = _require_field(document, "token")
|
|
130
|
+
with open_service(invocation) as (service, auth):
|
|
131
|
+
data = service.call(_symbol(invocation), token=token)
|
|
132
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def workspace_app_usage(invocation: Invocation) -> HandlerResult:
|
|
136
|
+
"""Report app usage stats for the current workspace."""
|
|
137
|
+
document = invocation.load_input() or {}
|
|
138
|
+
kwargs: dict[str, Any] = {}
|
|
139
|
+
_forward_optional(document, kwargs, ("fields",))
|
|
140
|
+
with open_service(invocation) as (service, auth):
|
|
141
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
142
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def workspace_check_expression(invocation: Invocation) -> HandlerResult:
|
|
146
|
+
"""Ask the AI assistant to check an expression. Payload comes from ``--input``."""
|
|
147
|
+
document = invocation.load_input()
|
|
148
|
+
body = _require_field(document, "body")
|
|
149
|
+
with open_service(invocation) as (service, auth):
|
|
150
|
+
data = service.call(_symbol(invocation), body=body)
|
|
151
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def workspace_create(invocation: Invocation) -> HandlerResult:
|
|
155
|
+
"""Create a new workspace. Payload comes from the ``body`` input field."""
|
|
156
|
+
document = invocation.load_input()
|
|
157
|
+
body = _require_field(document, "body")
|
|
158
|
+
with open_service(invocation) as (service, auth):
|
|
159
|
+
data = service.call(_symbol(invocation), body=body)
|
|
160
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def workspace_delete(invocation: Invocation) -> HandlerResult:
|
|
164
|
+
"""Delete a workspace. High-impact: ``--yes --confirm WORKSPACE_ID``."""
|
|
165
|
+
explicit = _int_positional(invocation, "workspace id")
|
|
166
|
+
with open_service(invocation) as (service, auth):
|
|
167
|
+
target_id = explicit if explicit is not None else auth.workspace_id
|
|
168
|
+
enforce_confirmation(
|
|
169
|
+
invocation,
|
|
170
|
+
policy=POLICY_CONFIRM_TARGET,
|
|
171
|
+
action=f"delete workspace {target_id}",
|
|
172
|
+
target=str(target_id),
|
|
173
|
+
)
|
|
174
|
+
kwargs: dict[str, Any] = {}
|
|
175
|
+
if explicit is not None:
|
|
176
|
+
kwargs["workspace_id"] = explicit
|
|
177
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
178
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def workspace_get(invocation: Invocation) -> HandlerResult:
|
|
182
|
+
"""Get one workspace by id (positional), or the client's own workspace."""
|
|
183
|
+
explicit = _int_positional(invocation, "workspace id")
|
|
184
|
+
kwargs: dict[str, Any] = {}
|
|
185
|
+
if explicit is not None:
|
|
186
|
+
kwargs["workspace_id"] = explicit
|
|
187
|
+
with open_service(invocation) as (service, auth):
|
|
188
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
189
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def workspace_list(invocation: Invocation) -> HandlerResult:
|
|
193
|
+
"""List accessible workspaces."""
|
|
194
|
+
document = invocation.load_input() or {}
|
|
195
|
+
limit = int(document.get("limit", 100))
|
|
196
|
+
with open_service(invocation) as (service, auth):
|
|
197
|
+
data = service.call(_symbol(invocation), limit=limit)
|
|
198
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def workspace_llm_task(invocation: Invocation) -> HandlerResult:
|
|
202
|
+
"""Submit a unified LLM task. ``task_type`` and ``params`` come from ``--input``."""
|
|
203
|
+
document = invocation.load_input()
|
|
204
|
+
task_type = _require_field(document, "task_type")
|
|
205
|
+
params = _require_field(document, "params")
|
|
206
|
+
with open_service(invocation) as (service, auth):
|
|
207
|
+
data = service.call(_symbol(invocation), task_type=task_type, params=params)
|
|
208
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def workspace_reactivate(invocation: Invocation) -> HandlerResult:
|
|
212
|
+
"""Reactivate a deactivated workspace. High-impact: ``--yes --confirm WORKSPACE_ID``."""
|
|
213
|
+
explicit = _int_positional(invocation, "workspace id")
|
|
214
|
+
with open_service(invocation) as (service, auth):
|
|
215
|
+
target_id = explicit if explicit is not None else auth.workspace_id
|
|
216
|
+
enforce_confirmation(
|
|
217
|
+
invocation,
|
|
218
|
+
policy=POLICY_CONFIRM_TARGET,
|
|
219
|
+
action=f"reactivate workspace {target_id}",
|
|
220
|
+
target=str(target_id),
|
|
221
|
+
)
|
|
222
|
+
kwargs: dict[str, Any] = {}
|
|
223
|
+
if explicit is not None:
|
|
224
|
+
kwargs["workspace_id"] = explicit
|
|
225
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
226
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def workspace_segment_list(invocation: Invocation) -> HandlerResult:
|
|
230
|
+
"""List split-test segments for the workspace."""
|
|
231
|
+
with open_service(invocation) as (service, auth):
|
|
232
|
+
data = service.call(_symbol(invocation))
|
|
233
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def workspace_segment_update(invocation: Invocation) -> HandlerResult:
|
|
237
|
+
"""Update split-test segments. ``patch`` comes from ``--input``."""
|
|
238
|
+
document = invocation.load_input()
|
|
239
|
+
patch = _require_field(document, "patch")
|
|
240
|
+
with open_service(invocation) as (service, auth):
|
|
241
|
+
data = service.call(_symbol(invocation), patch=patch)
|
|
242
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def workspace_storage_breakdown(invocation: Invocation) -> HandlerResult:
|
|
246
|
+
"""Report a breakdown of storage usage for the workspace."""
|
|
247
|
+
document = invocation.load_input() or {}
|
|
248
|
+
kwargs: dict[str, Any] = {}
|
|
249
|
+
_forward_optional(document, kwargs, ("limit", "offset"))
|
|
250
|
+
with open_service(invocation) as (service, auth):
|
|
251
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
252
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def workspace_update(invocation: Invocation) -> HandlerResult:
|
|
256
|
+
"""Patch workspace settings. High-impact: ``--yes --confirm WORKSPACE_ID``."""
|
|
257
|
+
explicit = _int_positional(invocation, "workspace id")
|
|
258
|
+
document = invocation.load_input()
|
|
259
|
+
patches = _require_field(document, "patches")
|
|
260
|
+
with open_service(invocation) as (service, auth):
|
|
261
|
+
target_id = explicit if explicit is not None else auth.workspace_id
|
|
262
|
+
enforce_confirmation(
|
|
263
|
+
invocation,
|
|
264
|
+
policy=POLICY_CONFIRM_TARGET,
|
|
265
|
+
action=f"update workspace {target_id}",
|
|
266
|
+
target=str(target_id),
|
|
267
|
+
)
|
|
268
|
+
kwargs: dict[str, Any] = {"patches": patches}
|
|
269
|
+
if explicit is not None:
|
|
270
|
+
kwargs["workspace_id"] = explicit
|
|
271
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
272
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def workspace_user_add(invocation: Invocation) -> HandlerResult:
|
|
276
|
+
"""Invite one or more users to the workspace."""
|
|
277
|
+
document = invocation.load_input()
|
|
278
|
+
email_ids = _require_field(document, "email_ids")
|
|
279
|
+
kwargs: dict[str, Any] = {"email_ids": email_ids}
|
|
280
|
+
assert document is not None
|
|
281
|
+
_forward_optional(document, kwargs, ("projects",))
|
|
282
|
+
with open_service(invocation) as (service, auth):
|
|
283
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
284
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def workspace_user_get(invocation: Invocation) -> HandlerResult:
|
|
288
|
+
"""Get one workspace user by id (positional), in the client's own workspace."""
|
|
289
|
+
user_id = _require_string_positional(invocation, "user id")
|
|
290
|
+
with open_service(invocation) as (service, auth):
|
|
291
|
+
data = service.call(_symbol(invocation), user_id=user_id)
|
|
292
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def workspace_user_list(invocation: Invocation) -> HandlerResult:
|
|
296
|
+
"""List users in the client's own workspace."""
|
|
297
|
+
with open_service(invocation) as (service, auth):
|
|
298
|
+
data = service.call(_symbol(invocation))
|
|
299
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def workspace_user_remove(invocation: Invocation) -> HandlerResult:
|
|
303
|
+
"""Remove a single user from the workspace. Prompt or ``--yes`` required."""
|
|
304
|
+
user_id = _require_int_positional(invocation, "user id")
|
|
305
|
+
enforce_confirmation(
|
|
306
|
+
invocation,
|
|
307
|
+
policy=POLICY_PROMPT_OR_YES,
|
|
308
|
+
action=f"remove user {user_id} from the workspace",
|
|
309
|
+
)
|
|
310
|
+
with open_service(invocation) as (service, auth):
|
|
311
|
+
data = service.call(_symbol(invocation), user_id=user_id)
|
|
312
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def workspace_user_remove_batch(invocation: Invocation) -> HandlerResult:
|
|
316
|
+
"""Remove several users and/or pending invites. Prompt or ``--yes`` required."""
|
|
317
|
+
document = invocation.load_input() or {}
|
|
318
|
+
kwargs: dict[str, Any] = {}
|
|
319
|
+
_forward_optional(document, kwargs, ("ids", "invite_ids"))
|
|
320
|
+
if not kwargs:
|
|
321
|
+
raise CliError(
|
|
322
|
+
code=CODE_MISSING_FIELD,
|
|
323
|
+
message="Provide at least one of 'ids' or 'invite_ids' to remove.",
|
|
324
|
+
exit_status=EXIT_USAGE,
|
|
325
|
+
hint="Pass fields via --input.",
|
|
326
|
+
)
|
|
327
|
+
enforce_confirmation(
|
|
328
|
+
invocation,
|
|
329
|
+
policy=POLICY_PROMPT_OR_YES,
|
|
330
|
+
action="remove users or invites from the workspace",
|
|
331
|
+
)
|
|
332
|
+
with open_service(invocation) as (service, auth):
|
|
333
|
+
data = service.call(_symbol(invocation), **kwargs)
|
|
334
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def workspace_user_update(invocation: Invocation) -> HandlerResult:
|
|
338
|
+
"""Update a user's role. High-impact: ``--yes --confirm USER_ID``."""
|
|
339
|
+
user_id = _require_string_positional(invocation, "user id")
|
|
340
|
+
document = invocation.load_input()
|
|
341
|
+
patches = _require_field(document, "patches")
|
|
342
|
+
enforce_confirmation(
|
|
343
|
+
invocation,
|
|
344
|
+
policy=POLICY_CONFIRM_TARGET,
|
|
345
|
+
action=f"update user {user_id}",
|
|
346
|
+
target=user_id,
|
|
347
|
+
)
|
|
348
|
+
with open_service(invocation) as (service, auth):
|
|
349
|
+
data = service.call(_symbol(invocation), user_id=user_id, patches=patches)
|
|
350
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def workspace_user_update_batch(invocation: Invocation) -> HandlerResult:
|
|
354
|
+
"""Update workspace users via JSON-patch operations. ``patches`` from ``--input``."""
|
|
355
|
+
document = invocation.load_input()
|
|
356
|
+
patches = _require_field(document, "patches")
|
|
357
|
+
with open_service(invocation) as (service, auth):
|
|
358
|
+
data = service.call(_symbol(invocation), patches=patches)
|
|
359
|
+
return data, _meta(invocation, auth.workspace_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""context layer for the Mammoth CLI."""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Secret credential storage: OS keyring first, permission-checked file second.
|
|
2
|
+
|
|
3
|
+
Stores a JSON blob ``{"api_key": ..., "api_secret": ...}`` under keyring
|
|
4
|
+
service ``mammoth-cli`` with the profile name as username. When no keyring
|
|
5
|
+
backend is available, an explicit or interactively-approved fallback stores
|
|
6
|
+
the same blob in ``credentials.toml`` beside ``profiles.toml``, with
|
|
7
|
+
directory mode ``0700`` and file mode ``0600`` on POSIX. A secret value is
|
|
8
|
+
never logged or included in any rendered output.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
import keyring
|
|
21
|
+
import keyring.errors
|
|
22
|
+
import tomlkit
|
|
23
|
+
from tomlkit import TOMLDocument
|
|
24
|
+
|
|
25
|
+
from mammoth_cli.context.profiles import config_dir
|
|
26
|
+
from mammoth_cli.errors.envelope import EXIT_USAGE, CliError
|
|
27
|
+
|
|
28
|
+
KEYRING_SERVICE = "mammoth-cli"
|
|
29
|
+
CREDENTIALS_FILENAME = "credentials.toml"
|
|
30
|
+
|
|
31
|
+
StorageMode = Literal["auto", "keyring", "file"]
|
|
32
|
+
|
|
33
|
+
_FAIL_BACKEND_MODULE = "keyring.backends.fail"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def keyring_unavailable_error() -> CliError:
|
|
37
|
+
"""Build the stable error for a missing or unusable OS keyring backend."""
|
|
38
|
+
return CliError(
|
|
39
|
+
code="keyring_unavailable",
|
|
40
|
+
message="No OS keyring is available to store the credential.",
|
|
41
|
+
exit_status=EXIT_USAGE,
|
|
42
|
+
hint="Store the credential in a permission-checked file instead.",
|
|
43
|
+
recovery_commands=["mammoth auth login --storage file"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _keyring_available() -> bool:
|
|
48
|
+
try:
|
|
49
|
+
backend = keyring.get_keyring()
|
|
50
|
+
except keyring.errors.NoKeyringError:
|
|
51
|
+
return False
|
|
52
|
+
return type(backend).__module__ != _FAIL_BACKEND_MODULE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def credentials_path() -> Path:
|
|
56
|
+
"""Return the path to the file-fallback credential store."""
|
|
57
|
+
return config_dir() / CREDENTIALS_FILENAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _store_keyring(profile: str, api_key: str, api_secret: str) -> None:
|
|
61
|
+
payload = json.dumps({"api_key": api_key, "api_secret": api_secret})
|
|
62
|
+
keyring.set_password(KEYRING_SERVICE, profile, payload)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_keyring(profile: str) -> tuple[str, str] | None:
|
|
66
|
+
try:
|
|
67
|
+
raw = keyring.get_password(KEYRING_SERVICE, profile)
|
|
68
|
+
except keyring.errors.KeyringError:
|
|
69
|
+
return None
|
|
70
|
+
if raw is None:
|
|
71
|
+
return None
|
|
72
|
+
data = json.loads(raw)
|
|
73
|
+
return str(data["api_key"]), str(data["api_secret"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _delete_keyring(profile: str) -> bool:
|
|
77
|
+
try:
|
|
78
|
+
keyring.delete_password(KEYRING_SERVICE, profile)
|
|
79
|
+
return True
|
|
80
|
+
except keyring.errors.KeyringError:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_file_document() -> TOMLDocument:
|
|
85
|
+
path = credentials_path()
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return tomlkit.document()
|
|
88
|
+
return tomlkit.parse(path.read_text(encoding="utf-8"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_file_document(document: TOMLDocument) -> None:
|
|
92
|
+
directory = config_dir()
|
|
93
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
if os.name == "posix":
|
|
95
|
+
os.chmod(directory, stat.S_IRWXU)
|
|
96
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(directory), prefix=".credentials-", suffix=".tmp")
|
|
97
|
+
try:
|
|
98
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
99
|
+
handle.write(tomlkit.dumps(document))
|
|
100
|
+
if os.name == "posix":
|
|
101
|
+
os.chmod(tmp_name, stat.S_IRUSR | stat.S_IWUSR)
|
|
102
|
+
os.replace(tmp_name, credentials_path())
|
|
103
|
+
except BaseException:
|
|
104
|
+
if os.path.exists(tmp_name):
|
|
105
|
+
os.remove(tmp_name)
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _store_file(profile: str, api_key: str, api_secret: str) -> None:
|
|
110
|
+
document = _load_file_document()
|
|
111
|
+
table = document.get("profiles")
|
|
112
|
+
if table is None:
|
|
113
|
+
table = tomlkit.table()
|
|
114
|
+
document["profiles"] = table
|
|
115
|
+
entry = tomlkit.table()
|
|
116
|
+
entry["api_key"] = api_key
|
|
117
|
+
entry["api_secret"] = api_secret
|
|
118
|
+
table[profile] = entry
|
|
119
|
+
_write_file_document(document)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_file(profile: str) -> tuple[str, str] | None:
|
|
123
|
+
document = _load_file_document()
|
|
124
|
+
table = document.get("profiles")
|
|
125
|
+
if not table or profile not in table:
|
|
126
|
+
return None
|
|
127
|
+
entry = table[profile]
|
|
128
|
+
return str(entry["api_key"]), str(entry["api_secret"])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _delete_file(profile: str) -> bool:
|
|
132
|
+
document = _load_file_document()
|
|
133
|
+
table = document.get("profiles")
|
|
134
|
+
if not table or profile not in table:
|
|
135
|
+
return False
|
|
136
|
+
del table[profile]
|
|
137
|
+
_write_file_document(document)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def store_credentials(
|
|
142
|
+
profile: str,
|
|
143
|
+
api_key: str,
|
|
144
|
+
api_secret: str,
|
|
145
|
+
storage: StorageMode = "auto",
|
|
146
|
+
*,
|
|
147
|
+
interactive: bool = False,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Store one profile's secret credential.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
profile: The (already-validated) profile name.
|
|
153
|
+
api_key: The Mammoth API key.
|
|
154
|
+
api_secret: The Mammoth API secret.
|
|
155
|
+
storage: ``"auto"`` prefers the OS keyring and falls back to the file
|
|
156
|
+
store only when interactive; ``"keyring"`` requires the keyring;
|
|
157
|
+
``"file"`` uses the permission-checked fallback file explicitly.
|
|
158
|
+
interactive: Whether the current process has an interactive TTY.
|
|
159
|
+
Only consulted by ``"auto"`` when no keyring backend exists.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The storage backend actually used: ``"keyring"`` or ``"file"``.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
CliError: ``keyring_unavailable`` when keyring storage is required (or
|
|
166
|
+
selected by ``"auto"`` noninteractively) but no backend exists.
|
|
167
|
+
"""
|
|
168
|
+
if storage == "file":
|
|
169
|
+
_store_file(profile, api_key, api_secret)
|
|
170
|
+
return "file"
|
|
171
|
+
if storage == "keyring":
|
|
172
|
+
if not _keyring_available():
|
|
173
|
+
raise keyring_unavailable_error()
|
|
174
|
+
_store_keyring(profile, api_key, api_secret)
|
|
175
|
+
return "keyring"
|
|
176
|
+
if _keyring_available():
|
|
177
|
+
_store_keyring(profile, api_key, api_secret)
|
|
178
|
+
return "keyring"
|
|
179
|
+
if interactive:
|
|
180
|
+
_store_file(profile, api_key, api_secret)
|
|
181
|
+
return "file"
|
|
182
|
+
raise keyring_unavailable_error()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def load_credentials(profile: str) -> tuple[str, str] | None:
|
|
186
|
+
"""Load one profile's secret credential.
|
|
187
|
+
|
|
188
|
+
Tries the OS keyring first, then the file fallback.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
profile: The profile name.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
``(api_key, api_secret)`` if a credential is stored, else None.
|
|
195
|
+
"""
|
|
196
|
+
if _keyring_available():
|
|
197
|
+
found = _load_keyring(profile)
|
|
198
|
+
if found is not None:
|
|
199
|
+
return found
|
|
200
|
+
return _load_file(profile)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def has_credentials(profile: str) -> bool:
|
|
204
|
+
"""Return True if a secret credential is stored for ``profile``.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
profile: The profile name.
|
|
208
|
+
"""
|
|
209
|
+
return load_credentials(profile) is not None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def delete_credentials(profile: str) -> bool:
|
|
213
|
+
"""Delete one profile's secret credential from both backends.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
profile: The profile name.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if a credential was removed from either backend.
|
|
220
|
+
"""
|
|
221
|
+
removed_keyring = _delete_keyring(profile) if _keyring_available() else False
|
|
222
|
+
removed_file = _delete_file(profile)
|
|
223
|
+
return removed_keyring or removed_file
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Resolve the Mammoth API base url from a server prefix.
|
|
2
|
+
|
|
3
|
+
A server prefix is the only endpoint input the CLI exposes; it maps to
|
|
4
|
+
``https://PREFIX.mammoth.io/api/v2`` with default ``app-eu``. There is no
|
|
5
|
+
public base-url override: the supported configuration surface is exactly the
|
|
6
|
+
API key, API secret, workspace id, and this optional one-label server prefix.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from mammoth_cli.errors.envelope import EXIT_USAGE, CliError
|
|
14
|
+
|
|
15
|
+
DEFAULT_SERVER_PREFIX = "app-eu"
|
|
16
|
+
|
|
17
|
+
# One DNS label: 1-63 characters, alphanumeric, internal hyphens only. This
|
|
18
|
+
# rejects schemes, dots, slashes, query strings, fragments, ports, and
|
|
19
|
+
# whitespace by construction.
|
|
20
|
+
_DNS_LABEL_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def invalid_server_prefix_error(prefix: str) -> CliError:
|
|
24
|
+
"""Build the stable error for a malformed server prefix.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
prefix: The rejected candidate prefix.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A ``invalid_server_prefix`` :class:`CliError` (exit status 2).
|
|
31
|
+
"""
|
|
32
|
+
return CliError(
|
|
33
|
+
code="invalid_server_prefix",
|
|
34
|
+
message=f"'{prefix}' is not a valid server prefix.",
|
|
35
|
+
exit_status=EXIT_USAGE,
|
|
36
|
+
hint="Use one DNS label, for example 'app-eu'.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_base_url(server_prefix: str | None) -> str:
|
|
41
|
+
"""Resolve the API base url from a server prefix.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
server_prefix: A one-label server prefix (for example ``"app-eu"``),
|
|
45
|
+
or None to use the default ``app-eu``.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The resolved API base url.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
CliError: ``invalid_server_prefix`` when the prefix is not one valid
|
|
52
|
+
DNS label.
|
|
53
|
+
"""
|
|
54
|
+
prefix = server_prefix if server_prefix is not None else DEFAULT_SERVER_PREFIX
|
|
55
|
+
if not _DNS_LABEL_RE.match(prefix):
|
|
56
|
+
raise invalid_server_prefix_error(prefix)
|
|
57
|
+
return f"https://{prefix}.mammoth.io/api/v2"
|