manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Reader and writer for the one file a runner home holds.
|
|
2
|
+
|
|
3
|
+
The homes live under one root, `<config dir>/runners` unless
|
|
4
|
+
`MANIFOLD_RUNNERS_DIR` moves them, one directory per runner id. A box with one
|
|
5
|
+
runner has one home and needs no `--runner`; a box with several is asked which.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from manifold_cli.clients.credentials import write_private_file
|
|
16
|
+
from manifold_cli.models.errors import AmbiguousRunnerHomeError, NoRunnerHomeError
|
|
17
|
+
from manifold_cli.models.runner_home import HOME_FILE_NAME, RunnerHome
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunnerHomes:
|
|
23
|
+
"""The root the runner homes sit under, one directory per runner id."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, root: Path):
|
|
26
|
+
self._root = root
|
|
27
|
+
|
|
28
|
+
def resolve(self, handle: str | None) -> "RunnerHomeClient":
|
|
29
|
+
"""The home a command acts on, which has to exist.
|
|
30
|
+
|
|
31
|
+
Falls back to the only home when the caller passes no handle, so a
|
|
32
|
+
command on a single-runner box can omit it.
|
|
33
|
+
|
|
34
|
+
Raises NoRunnerHomeError when no home answers to the handle or the box
|
|
35
|
+
has none, and AmbiguousRunnerHomeError when several do.
|
|
36
|
+
"""
|
|
37
|
+
homes = self.all()
|
|
38
|
+
if handle is not None:
|
|
39
|
+
homes = [home for home in homes if home.answers_to(handle)]
|
|
40
|
+
if len(homes) == 0:
|
|
41
|
+
raise NoRunnerHomeError(f"No runner home here answers to '{handle}'.")
|
|
42
|
+
if len(homes) == 0:
|
|
43
|
+
raise NoRunnerHomeError
|
|
44
|
+
if len(homes) > 1:
|
|
45
|
+
raise AmbiguousRunnerHomeError(sorted(home.handle() for home in homes))
|
|
46
|
+
return homes[0]
|
|
47
|
+
|
|
48
|
+
def named(self, name: str) -> "RegisteredRunner | None":
|
|
49
|
+
"""The home holding this runner name, with what it holds.
|
|
50
|
+
|
|
51
|
+
A home whose file cannot be read carries a name nothing can match, so
|
|
52
|
+
it is absent here and the server's name uniqueness is what refuses the
|
|
53
|
+
second registration.
|
|
54
|
+
"""
|
|
55
|
+
for home in self.all():
|
|
56
|
+
stored = home.load()
|
|
57
|
+
if stored is not None and stored.name == name:
|
|
58
|
+
return RegisteredRunner(home, stored)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def for_runner(self, runner_id: UUID) -> "RunnerHomeClient":
|
|
62
|
+
"""The home for this runner id, whether or not it exists yet."""
|
|
63
|
+
return RunnerHomeClient(self._root / str(runner_id) / HOME_FILE_NAME)
|
|
64
|
+
|
|
65
|
+
def all(self) -> list["RunnerHomeClient"]:
|
|
66
|
+
"""Every home on this box, by id.
|
|
67
|
+
|
|
68
|
+
A directory under the root without the file is not a home: an
|
|
69
|
+
interrupted registration leaves one, and it holds no credential.
|
|
70
|
+
"""
|
|
71
|
+
if not self._root.is_dir():
|
|
72
|
+
return []
|
|
73
|
+
return [
|
|
74
|
+
RunnerHomeClient(entry / HOME_FILE_NAME)
|
|
75
|
+
for entry in sorted(self._root.iterdir())
|
|
76
|
+
if (entry / HOME_FILE_NAME).is_file()
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def handles(self) -> list[str]:
|
|
80
|
+
"""What the homes on this box answer to, alphabetically."""
|
|
81
|
+
return sorted(home.handle() for home in self.all())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RunnerHomeClient:
|
|
85
|
+
"""One runner home, read and written as a whole.
|
|
86
|
+
|
|
87
|
+
The file carries the minted token, so it is written owner-readable and
|
|
88
|
+
replaced rather than edited in place. Registration is the only writer.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, path: Path):
|
|
92
|
+
self._path = path
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def path(self) -> Path:
|
|
96
|
+
return self._path
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def runner_id(self) -> str:
|
|
100
|
+
"""The id keying this home, which is the directory it sits in."""
|
|
101
|
+
return self._path.parent.name
|
|
102
|
+
|
|
103
|
+
def exists(self) -> bool:
|
|
104
|
+
return self._path.is_file()
|
|
105
|
+
|
|
106
|
+
def answers_to(self, handle: str) -> bool:
|
|
107
|
+
"""Whether `--runner <handle>` picks this home.
|
|
108
|
+
|
|
109
|
+
The id matches on a leading fragment, so an operator types enough of it
|
|
110
|
+
to be unambiguous rather than the whole of it. A name the server has
|
|
111
|
+
since changed no longer matches, which is what the id is here for.
|
|
112
|
+
"""
|
|
113
|
+
if len(handle) == 0:
|
|
114
|
+
return False
|
|
115
|
+
if self.runner_id.startswith(handle.lower()):
|
|
116
|
+
return True
|
|
117
|
+
stored = self.load()
|
|
118
|
+
return stored is not None and stored.name == handle
|
|
119
|
+
|
|
120
|
+
def handle(self) -> str:
|
|
121
|
+
"""What this home answers to, for a message that lists several.
|
|
122
|
+
|
|
123
|
+
A home whose file cannot be read has no name to give, so it gives the
|
|
124
|
+
id, which is the only handle left that reaches it.
|
|
125
|
+
"""
|
|
126
|
+
stored = self.load()
|
|
127
|
+
if stored is None:
|
|
128
|
+
return self.runner_id
|
|
129
|
+
return stored.name
|
|
130
|
+
|
|
131
|
+
def load(self) -> RunnerHome | None:
|
|
132
|
+
try:
|
|
133
|
+
raw = self._path.read_text()
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return RunnerHome.model_validate_json(raw)
|
|
138
|
+
except ValidationError:
|
|
139
|
+
logger.warning("runner home at %s is malformed; ignoring", self._path)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def save(self, home: RunnerHome) -> None:
|
|
143
|
+
# Owner-only: a homes root outside a user's own tree is otherwise
|
|
144
|
+
# listable by everyone on the box, and the token sits in here.
|
|
145
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
146
|
+
write_private_file(self._path, home.model_dump_json())
|
|
147
|
+
|
|
148
|
+
def clear(self) -> None:
|
|
149
|
+
"""Remove the home, file and directory both.
|
|
150
|
+
|
|
151
|
+
A home is its file, so blanking the token would leave a directory no
|
|
152
|
+
command can complete: nothing installs a token into an existing home,
|
|
153
|
+
and registering over it writes a new one under the new id anyway.
|
|
154
|
+
"""
|
|
155
|
+
self._path.unlink(missing_ok=True)
|
|
156
|
+
parent = self._path.parent
|
|
157
|
+
if parent.is_dir() and len(list(parent.iterdir())) == 0:
|
|
158
|
+
parent.rmdir()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class RegisteredRunner(NamedTuple):
|
|
162
|
+
"""A home on this box and the document it holds."""
|
|
163
|
+
|
|
164
|
+
home: RunnerHomeClient
|
|
165
|
+
stored: RunnerHome
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""WorkOS CLI Auth (OAuth 2.0 Device Authorization Grant, RFC 8628).
|
|
2
|
+
|
|
3
|
+
Docs: https://workos.com/docs/authkit/cli-auth
|
|
4
|
+
RFC 8628: https://datatracker.ietf.org/doc/html/rfc8628
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from manifold_cli.models.auth import (
|
|
14
|
+
CliConfig,
|
|
15
|
+
DeviceAuthorization,
|
|
16
|
+
PollPending,
|
|
17
|
+
TokenResponse,
|
|
18
|
+
)
|
|
19
|
+
from manifold_cli.models.errors import WorkOSAuthError
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
24
|
+
REFRESH_TOKEN_GRANT = "refresh_token"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkOSDeviceClient:
|
|
28
|
+
def __init__(self, config_provider: Callable[[], CliConfig]):
|
|
29
|
+
self._config_provider = config_provider
|
|
30
|
+
self._cached_config: CliConfig | None = None
|
|
31
|
+
self._client = httpx.Client()
|
|
32
|
+
|
|
33
|
+
def _config(self) -> CliConfig:
|
|
34
|
+
if self._cached_config is None:
|
|
35
|
+
self._cached_config = self._config_provider()
|
|
36
|
+
return self._cached_config
|
|
37
|
+
|
|
38
|
+
def start_device_authorization(self) -> DeviceAuthorization:
|
|
39
|
+
config = self._config()
|
|
40
|
+
response = self._client.post(
|
|
41
|
+
config.authorization_endpoint,
|
|
42
|
+
data={"client_id": config.workos_client_id},
|
|
43
|
+
)
|
|
44
|
+
if response.is_error:
|
|
45
|
+
body = _safe_json(response)
|
|
46
|
+
raise WorkOSAuthError(
|
|
47
|
+
error=body.get("error", "authorization_request_failed"),
|
|
48
|
+
description=body.get("error_description") or response.text,
|
|
49
|
+
)
|
|
50
|
+
return DeviceAuthorization.model_validate(response.json())
|
|
51
|
+
|
|
52
|
+
def poll_token(self, device_code: str) -> TokenResponse | PollPending:
|
|
53
|
+
config = self._config()
|
|
54
|
+
response = self._client.post(
|
|
55
|
+
config.token_endpoint,
|
|
56
|
+
data={
|
|
57
|
+
"grant_type": DEVICE_CODE_GRANT,
|
|
58
|
+
"device_code": device_code,
|
|
59
|
+
"client_id": config.workos_client_id,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
if response.is_success:
|
|
63
|
+
return TokenResponse.model_validate(response.json())
|
|
64
|
+
body = _safe_json(response)
|
|
65
|
+
error = body.get("error", "")
|
|
66
|
+
if error == PollPending.PENDING.value:
|
|
67
|
+
return PollPending.PENDING
|
|
68
|
+
if error == PollPending.SLOW_DOWN.value:
|
|
69
|
+
return PollPending.SLOW_DOWN
|
|
70
|
+
raise WorkOSAuthError(
|
|
71
|
+
error=error or "token_request_failed",
|
|
72
|
+
description=body.get("error_description"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def refresh(self, refresh_token: str) -> TokenResponse:
|
|
76
|
+
config = self._config()
|
|
77
|
+
response = self._client.post(
|
|
78
|
+
config.token_endpoint,
|
|
79
|
+
data={
|
|
80
|
+
"grant_type": REFRESH_TOKEN_GRANT,
|
|
81
|
+
"refresh_token": refresh_token,
|
|
82
|
+
"client_id": config.workos_client_id,
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
if response.is_success:
|
|
86
|
+
return TokenResponse.model_validate(response.json())
|
|
87
|
+
body = _safe_json(response)
|
|
88
|
+
raise WorkOSAuthError(
|
|
89
|
+
error=body.get("error", "refresh_failed"),
|
|
90
|
+
description=body.get("error_description"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _safe_json(response: httpx.Response) -> dict[str, Any]:
|
|
95
|
+
try:
|
|
96
|
+
data = response.json()
|
|
97
|
+
except ValueError:
|
|
98
|
+
return {}
|
|
99
|
+
return data if isinstance(data, dict) else {}
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.markup import escape
|
|
4
|
+
|
|
5
|
+
from manifold_cli.commands.organization import (
|
|
6
|
+
NO_ORGANIZATION_MESSAGE,
|
|
7
|
+
ORGANIZATION_CALL_ERRORS,
|
|
8
|
+
exit_for_call_error,
|
|
9
|
+
)
|
|
10
|
+
from manifold_cli.dependencies import Dependencies
|
|
11
|
+
from manifold_cli.models.auth import AuthState, DeviceAuthorization
|
|
12
|
+
from manifold_cli.models.errors import ManifoldApiError, WorkOSAuthError
|
|
13
|
+
|
|
14
|
+
SKILLS_HINT = (
|
|
15
|
+
"Run [bold]npx skills add bifrostai/manifold-skills --all[/bold] "
|
|
16
|
+
"to install agent skills."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
21
|
+
console = Console()
|
|
22
|
+
auth_app = typer.Typer(
|
|
23
|
+
name="auth",
|
|
24
|
+
help="Manage Manifold authentication.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
app.add_typer(auth_app)
|
|
28
|
+
|
|
29
|
+
@auth_app.command()
|
|
30
|
+
def login() -> None:
|
|
31
|
+
"""Log in to Manifold via WorkOS device authorization."""
|
|
32
|
+
|
|
33
|
+
def show(device_auth: DeviceAuthorization) -> None:
|
|
34
|
+
console.print(
|
|
35
|
+
f"Visit [bold]{escape(device_auth.verification_uri_complete)}[/bold]\n"
|
|
36
|
+
f"and confirm code [bold]{escape(device_auth.user_code)}[/bold]."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
deps.auth_service.login(on_device_code=show)
|
|
41
|
+
except (ManifoldApiError, WorkOSAuthError) as exc:
|
|
42
|
+
console.print(f"[red]Login failed:[/red] {escape(str(exc))}")
|
|
43
|
+
raise typer.Exit(1) from exc
|
|
44
|
+
|
|
45
|
+
console.print("[green]Login successful.[/green]")
|
|
46
|
+
if _report_organizations(console, deps):
|
|
47
|
+
console.print(SKILLS_HINT)
|
|
48
|
+
|
|
49
|
+
@auth_app.command()
|
|
50
|
+
def logout() -> None:
|
|
51
|
+
"""Forget local credentials."""
|
|
52
|
+
deps.auth_service.logout()
|
|
53
|
+
console.print("Logged out.")
|
|
54
|
+
|
|
55
|
+
@auth_app.command()
|
|
56
|
+
def status() -> None:
|
|
57
|
+
"""Show whether you are logged in to Manifold."""
|
|
58
|
+
try:
|
|
59
|
+
result = deps.auth_service.status()
|
|
60
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
61
|
+
raise exit_for_call_error(console, exc) from exc
|
|
62
|
+
|
|
63
|
+
match result.state:
|
|
64
|
+
case AuthState.LOGGED_OUT:
|
|
65
|
+
console.print(
|
|
66
|
+
"Not logged in. Run `manifold auth login` to authenticate."
|
|
67
|
+
)
|
|
68
|
+
case AuthState.SESSION_EXPIRED:
|
|
69
|
+
console.print(
|
|
70
|
+
"[yellow]Session expired.[/yellow] "
|
|
71
|
+
"Run `manifold auth login` to re-authenticate."
|
|
72
|
+
)
|
|
73
|
+
case AuthState.LOGGED_IN:
|
|
74
|
+
assert result.user is not None
|
|
75
|
+
console.print(
|
|
76
|
+
f"[green]Logged in as[/green] "
|
|
77
|
+
f"[bold]{escape(result.user.email)}[/bold]"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _report_organizations(console: Console, deps: Dependencies) -> bool:
|
|
82
|
+
"""Print the caller's organizations and report whether one was found.
|
|
83
|
+
|
|
84
|
+
Best-effort login epilogue: expected network, API, auth, and
|
|
85
|
+
response-shape failures are downgraded to a warning so they never fail the
|
|
86
|
+
login. A bug in this function itself still surfaces.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
organizations = deps.organization_service.list()
|
|
90
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[yellow]Could not check your organizations:[/yellow] {escape(str(exc))}"
|
|
93
|
+
)
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if not organizations:
|
|
97
|
+
console.print(NO_ORGANIZATION_MESSAGE)
|
|
98
|
+
return False
|
|
99
|
+
slugs = ", ".join(organization.slug for organization in organizations)
|
|
100
|
+
console.print(f"Organization: [bold]{slugs}[/bold]")
|
|
101
|
+
return True
|