tabsdata-cli 2.0.0__cp312-abi3-win_amd64.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.
Files changed (39) hide show
  1. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/__init__.py +5 -0
  2. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/_registry.py +78 -0
  3. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/BANNER +5 -0
  4. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  5. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/README-PyPi.md +8 -0
  6. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/RELEASE-NOTES +1 -0
  7. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/THIRD-PARTY +3 -0
  8. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/VERSION +1 -0
  9. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/cli_utils.py +993 -0
  10. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/shell.py +390 -0
  11. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/__init__.py +3 -0
  12. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/auth_group.py +197 -0
  13. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/cli.py +508 -0
  14. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/collection_group.py +458 -0
  15. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/config_utils.py +93 -0
  16. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/connection_group.py +159 -0
  17. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/examples_group.py +206 -0
  18. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/execution_utils.py +429 -0
  19. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fn_group.py +1109 -0
  20. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fnrun_group.py +285 -0
  21. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/group_group.py +184 -0
  22. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/metrics_group.py +323 -0
  23. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/permission_group.py +259 -0
  24. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/plan_group.py +324 -0
  25. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/project_group.py +170 -0
  26. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/role_group.py +630 -0
  27. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/settings_group.py +344 -0
  28. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/table_group.py +600 -0
  29. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trigger_group.py +338 -0
  30. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trx_group.py +329 -0
  31. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/user_group.py +200 -0
  32. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/worker_group.py +185 -0
  33. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/template.py +781 -0
  34. tabsdata_cli-2.0.0.data/purelib/tabsdatak/py.typed +0 -0
  35. tabsdata_cli-2.0.0.dist-info/METADATA +39 -0
  36. tabsdata_cli-2.0.0.dist-info/RECORD +39 -0
  37. tabsdata_cli-2.0.0.dist-info/WHEEL +5 -0
  38. tabsdata_cli-2.0.0.dist-info/licenses/src/main/python/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  39. tabsdata_cli-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,508 @@
1
+ #
2
+ # Copyright 2026 Tabsdata Inc.
3
+ #
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import textwrap
9
+ from collections.abc import Mapping
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ import rich_click as click
13
+
14
+ try:
15
+ __version__ = version("tabsdata-cli")
16
+ except PackageNotFoundError:
17
+ __version__ = "0.0.0"
18
+
19
+ from tabsdatak.cli._registry import collect_cli_commands
20
+ from tabsdatak.cli.cli_utils import (
21
+ DEFAULT_TABSDATA_DIRECTORY,
22
+ OPTION_PROMPT,
23
+ RENDER_WIDTH,
24
+ SERVER_URL_DEFAULTS_HELP,
25
+ clear_pins,
26
+ cli_option_enabled,
27
+ configured_server,
28
+ get_credentials_file_path,
29
+ hint_common_solutions,
30
+ initialise_tabsdata_server_connection,
31
+ load_cli_options,
32
+ logical_prompt,
33
+ make_console,
34
+ store_cli_options,
35
+ utils_login,
36
+ verify_login_or_prompt,
37
+ )
38
+
39
+ # Built-in command groups, imported for their @cli_command side effects: each
40
+ # module registers its top-level group, attached below via collect_cli_commands.
41
+ # Only registration runs at import time -- every command's execution-time imports
42
+ # (the server SDK, connection templates, ...) are deferred into the command body.
43
+ # TODO: role and stage management are temporarily removed; reintroduce their
44
+ # modules here (they self-register via @cli_command) in a later version.
45
+ from tabsdatak.cli.td import ( # noqa: F401
46
+ auth_group,
47
+ collection_group,
48
+ connection_group,
49
+ examples_group,
50
+ fn_group,
51
+ fnrun_group,
52
+ group_group,
53
+ metrics_group,
54
+ permission_group,
55
+ plan_group,
56
+ project_group,
57
+ settings_group,
58
+ table_group,
59
+ trigger_group,
60
+ trx_group,
61
+ user_group,
62
+ worker_group,
63
+ )
64
+ from tabsdatak.common._information.about import tdabout
65
+
66
+ CYAN = "[cyan]"
67
+ NO_CYAN = "[/cyan]"
68
+
69
+ BOLD = "[bold]"
70
+ NO_BOLD = "[/bold]"
71
+
72
+ UNDERLINE = "[underline]"
73
+ NO_UNDERLINE = "[underline]"
74
+
75
+ _render_width = None if sys.stdout.isatty() else RENDER_WIDTH
76
+ click.rich_click.WIDTH = _render_width
77
+ click.rich_click.MAX_WIDTH = _render_width
78
+ if _render_width is not None:
79
+ os.environ.setdefault("TERMINAL_WIDTH", str(_render_width))
80
+
81
+ console = make_console()
82
+
83
+ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
84
+
85
+ # Env vars each supported AI agent always sets when it runs a shell; tdk refuses
86
+ # to run inside them (agents must use the Tabsdata MCP server instead).
87
+ _AI_AGENT_ENV_VARS = ("GEMINI_CLI", "CLAUDECODE", "CODEX_THREAD_ID")
88
+ # Escape hatch: set to 1/true/yes to bypass the guard (warns instead of failing).
89
+ _AI_AGENT_BYPASS_ENV_VAR = "TDK_IN_AGENT"
90
+ _AI_AGENT_ERROR_MESSAGE = (
91
+ "Tabsdata tdk command does not support running within an AI agent, "
92
+ "use Tabsdata MCP instead"
93
+ )
94
+
95
+
96
+ def _env_is_truthy(value: str | None) -> bool:
97
+ return (value or "").strip().lower() in {"1", "true", "yes"}
98
+
99
+
100
+ def detect_ai_agent(env: Mapping[str, str] | None = None) -> str | None:
101
+ """Return the name of the first AI-agent marker env var that is set (and
102
+ non-empty), or ``None`` when tdk is not running inside a supported agent."""
103
+ env = os.environ if env is None else env
104
+ for var in _AI_AGENT_ENV_VARS:
105
+ value = env.get(var)
106
+ if value is not None and value.strip() != "":
107
+ return var
108
+ return None
109
+
110
+
111
+ def enforce_not_in_ai_agent(env: Mapping[str, str] | None = None) -> None:
112
+ """Exit if tdk is being run inside a supported AI agent, unless the
113
+ ``_TDK_IN_AGENT`` escape hatch is set truthy (in which case warn and continue)."""
114
+ env = os.environ if env is None else env
115
+ if detect_ai_agent(env) is None:
116
+ return
117
+ if _env_is_truthy(env.get(_AI_AGENT_BYPASS_ENV_VAR)):
118
+ click.echo(
119
+ f"WARNING: {_AI_AGENT_BYPASS_ENV_VAR} is set -- bypassing the AI-agent "
120
+ "guard; tdk is not supported within an AI agent, use Tabsdata MCP instead.",
121
+ err=True,
122
+ )
123
+ return
124
+ click.echo(_AI_AGENT_ERROR_MESSAGE, err=True)
125
+ raise SystemExit(1)
126
+
127
+
128
+ def _full_help_options(command) -> list:
129
+ options = []
130
+ for param in getattr(command, "params", []):
131
+ if isinstance(param, click.Option):
132
+ options.append({
133
+ "opts": list(param.opts) + list(param.secondary_opts),
134
+ "help": param.help or "",
135
+ "is_flag": bool(param.is_flag),
136
+ "required": bool(param.required),
137
+ "default": (
138
+ param.default
139
+ if (param.default is not None and not param.is_flag)
140
+ else None
141
+ ),
142
+ "type": getattr(param.type, "name", None),
143
+ })
144
+ return options
145
+
146
+
147
+ def _full_help_node(name: str, command, path: str) -> dict:
148
+ node = {
149
+ "name": name,
150
+ "path": path,
151
+ "help": (command.help or command.short_help or "").strip(),
152
+ "options": _full_help_options(command),
153
+ "arguments": [
154
+ {"name": param.name, "required": bool(param.required)}
155
+ for param in getattr(command, "params", [])
156
+ if isinstance(param, click.Argument)
157
+ ],
158
+ }
159
+ if isinstance(command, click.Group):
160
+ node["commands"] = [
161
+ _full_help_node(sub, command.commands[sub], f"{path} {sub}")
162
+ for sub in sorted(command.commands)
163
+ ]
164
+ return node
165
+
166
+
167
+ def _full_help_text(node: dict, lines: list | None = None) -> str:
168
+ lines = [] if lines is None else lines
169
+ lines.append(node["path"])
170
+ if node["help"]:
171
+ for line in node["help"].splitlines():
172
+ lines.append(f" {line}".rstrip())
173
+ if node["arguments"]:
174
+ lines.append(" Arguments:")
175
+ for arg in node["arguments"]:
176
+ tag = "required" if arg["required"] else "optional"
177
+ lines.append(f" {arg['name']} ({tag})")
178
+ if node["options"]:
179
+ lines.append(" Options:")
180
+ for opt in node["options"]:
181
+ flags = ", ".join(opt["opts"])
182
+ tag = "required" if opt["required"] else "optional"
183
+ suffix = f" {opt['help']}" if opt["help"] else ""
184
+ lines.append(f" {flags} ({tag}){suffix}".rstrip())
185
+ lines.append("")
186
+ for sub in node.get("commands", []):
187
+ _full_help_text(sub, lines)
188
+ return "\n".join(lines)
189
+
190
+
191
+ def build_full_help(root, output: str = "text", prog_name: str = "tdk") -> str:
192
+ """Render the help for ``root`` and every command/subcommand under it.
193
+
194
+ Each option and argument is tagged ``(required)`` or ``(optional)`` in the
195
+ text form, and carries a ``required`` boolean in the json form.
196
+
197
+ ``output`` is ``"text"`` (a flat, indented listing) or ``"json"`` (a nested
198
+ tree of ``{name, path, help, options, arguments, commands}`` objects)."""
199
+ tree = _full_help_node(prog_name, root, prog_name)
200
+ if output == "json":
201
+ # ``default=str`` coerces any non-JSON option default (e.g. a Choice or
202
+ # Path sentinel) to its string form rather than failing the dump.
203
+ return json.dumps(tree, indent=2, default=str)
204
+ return _full_help_text(tree)
205
+
206
+
207
+ def _incoming_argv(args, kwargs) -> list:
208
+ if kwargs.get("args") is not None:
209
+ return list(kwargs["args"])
210
+ if args and args[0] is not None:
211
+ return list(args[0])
212
+ return list(sys.argv[1:])
213
+
214
+
215
+ def _full_output_format(argv: list) -> str:
216
+ fmt = "text"
217
+ for flag in ("--format", "-o"):
218
+ if flag in argv:
219
+ index = argv.index(flag)
220
+ if index + 1 < len(argv):
221
+ fmt = argv[index + 1]
222
+ if fmt not in ("text", "json"):
223
+ click.echo(f"Invalid --format '{fmt}'; expected 'text' or 'json'.", err=True)
224
+ raise SystemExit(2)
225
+ return fmt
226
+
227
+
228
+ class _AiAgentGuardedGroup(click.RichGroup):
229
+ """Top-level group that refuses to run inside a supported AI agent. The check
230
+ runs in ``main`` -- before any argument parsing -- so even ``--help`` and
231
+ ``--version`` are gated, not just subcommands.
232
+
233
+ ``main`` also intercepts ``--help --full`` (before Click's own ``--help``
234
+ handling) to print the recursive full help for every command and
235
+ subcommand. ``--full`` is only honored together with ``--help``, and
236
+ ``--format`` only together with ``--full``:
237
+ ``tdk --help [--full [--format FORMAT]]``."""
238
+
239
+ def main(self, *args, **kwargs):
240
+ enforce_not_in_ai_agent()
241
+ argv = _incoming_argv(args, kwargs)
242
+ wants_help = "--help" in argv or "-h" in argv
243
+ if wants_help and "--full" in argv:
244
+ click.echo(build_full_help(self, _full_output_format(argv)))
245
+ raise SystemExit(0)
246
+ return super().main(*args, **kwargs)
247
+
248
+
249
+ @click.group(cls=_AiAgentGuardedGroup, context_settings=CONTEXT_SETTINGS)
250
+ @click.version_option(version=__version__, message="Tabsdata Client %(version)s")
251
+ @click.option(
252
+ "--no-prompt",
253
+ is_flag=True,
254
+ help=(
255
+ "Disable all prompts for this invocation. If a prompt is required for "
256
+ "proper execution, the command will fail. Turn prompting off for good "
257
+ "with 'tdk set --prompt false'."
258
+ ),
259
+ )
260
+ @click.option(
261
+ "--full",
262
+ is_flag=True,
263
+ expose_value=False,
264
+ help=(
265
+ "With --help, print the full help for every command and subcommand, "
266
+ "then exit. Optionally add '--format text|json' (default: text): "
267
+ "'tdk --help [--full [--format FORMAT]]'."
268
+ ),
269
+ )
270
+ @click.pass_context
271
+ def cli(ctx: click.Context, no_prompt: bool):
272
+ """Main CLI for the Tabsdata system."""
273
+ try:
274
+ os.makedirs(DEFAULT_TABSDATA_DIRECTORY, exist_ok=True)
275
+ ctx.obj = {
276
+ "tabsdata_directory": DEFAULT_TABSDATA_DIRECTORY,
277
+ "no_prompt": no_prompt,
278
+ }
279
+ # The server connection is set up lazily: the pinned project is applied
280
+ # when the connection is first built (on first use), so commands that
281
+ # never talk to the server don't construct it or import the server SDK.
282
+ initialise_tabsdata_server_connection(ctx)
283
+ # Every persisted setting -- hints, prompt, server, pins -- rides in
284
+ # the one options store.
285
+ load_cli_options(ctx)
286
+ # 'tdk set --prompt false' is a standing '--no-prompt'; the flag turns
287
+ # prompting off for a single invocation.
288
+ ctx.obj["prompt_setting"] = cli_option_enabled(ctx, OPTION_PROMPT)
289
+ ctx.obj["no_prompt"] = no_prompt or not ctx.obj["prompt_setting"]
290
+ except Exception as e:
291
+ hint_common_solutions(ctx, e)
292
+ raise click.ClickException(f"Failed to initialize CLI: {e}")
293
+
294
+
295
+ # Attach every @cli_command-annotated group: the built-ins imported above, plus
296
+ # any enterprise-tier variant commands that register themselves when their
297
+ # package is installed. No manual add_command list to keep
298
+ # in sync -- a new command only needs the @cli_command annotation.
299
+ for _command in collect_cli_commands():
300
+ cli.add_command(_command)
301
+
302
+
303
+ @cli.command()
304
+ @click.pass_context
305
+ def shell(ctx: click.Context):
306
+ """Start an interactive shell session"""
307
+ from tabsdatak.cli.shell import run_shell
308
+
309
+ run_shell(
310
+ ctx,
311
+ cli,
312
+ __version__,
313
+ banner_title="Tabsdata Interactive Shell",
314
+ tool_name="tdk",
315
+ )
316
+
317
+
318
+ @cli.command()
319
+ def about():
320
+ """Show build metadata and system information."""
321
+ tdabout()
322
+
323
+
324
+ MANIFEST_COLUMN_WIDTH = 80
325
+
326
+ MANIFEST_RULE_CHARACTERS = "═─"
327
+
328
+ MANIFEST_ROW_SEPARATORS = {
329
+ "╒": "╤",
330
+ "╞": "╪",
331
+ "├": "┼",
332
+ "╘": "╧",
333
+ }
334
+
335
+
336
+ def _narrow_manifest(content: str, width: int = MANIFEST_COLUMN_WIDTH) -> str:
337
+ narrowed = []
338
+ widths = None
339
+ for line in content.split("\n"):
340
+ if line.startswith("╒"):
341
+ widths = [len(segment) for segment in line[1:-1].split("╤")]
342
+ if widths is None or widths[-1] <= width:
343
+ narrowed.append(line)
344
+ continue
345
+ narrowed.extend(_narrow_manifest_line(line, widths, width))
346
+ return "\n".join(narrowed)
347
+
348
+
349
+ def _narrow_manifest_line(line: str, widths: list[int], width: int) -> list[str]:
350
+ capped = widths[:-1] + [width]
351
+ separator = MANIFEST_ROW_SEPARATORS.get(line[:1])
352
+ if separator:
353
+ rule = line[1] if line[1] in MANIFEST_RULE_CHARACTERS else "─"
354
+ return [line[0] + separator.join(rule * size for size in capped) + line[-1]]
355
+ if not line.startswith("│"):
356
+ return [line]
357
+ cells = line[1:-1].split("│")
358
+ if len(cells) != len(widths):
359
+ return [line]
360
+ wrapped = [
361
+ textwrap.wrap(cells[-1].strip(), width - 2, break_long_words=False) or [""]
362
+ ]
363
+ rows = []
364
+ for index, piece in enumerate(wrapped[0]):
365
+ head = cells[:-1] if index == 0 else [" " * size for size in widths[:-1]]
366
+ rows.append("│" + "│".join(head) + f"│ {piece.ljust(width - 2)} │")
367
+ return rows
368
+
369
+
370
+ def _show_manifest(resource: str, title: str, dedent: bool = False, wide: bool = False):
371
+ from tabsdatak.common._resources.reader import td_resource
372
+
373
+ path = td_resource(f"assets/manifest/{resource}")
374
+ with open(path, "r", encoding="utf-8") as f:
375
+ content = f.read()
376
+ if dedent:
377
+ content = textwrap.dedent(content).strip()
378
+ if not wide:
379
+ content = _narrow_manifest(content)
380
+ console.print()
381
+ console.print(f"{UNDERLINE}{title}{NO_UNDERLINE}")
382
+ console.print()
383
+ console.print(f"{CYAN}{content}{NO_CYAN}")
384
+ console.print()
385
+
386
+
387
+ @cli.group(no_args_is_help=True)
388
+ def info():
389
+ """
390
+ Show the license, third-party dependencies and release notes of tabsdata.
391
+ """
392
+
393
+
394
+ @info.command(name="third-party")
395
+ @click.option(
396
+ "--wide",
397
+ is_flag=True,
398
+ default=False,
399
+ help="Show the license column at its full width, instead of wrapping it.",
400
+ )
401
+ def third_party(wide: bool):
402
+ """Show the third-party dependencies of tabsdata."""
403
+ _show_manifest("THIRD-PARTY", "Third Party Dependencies", wide=wide)
404
+
405
+
406
+ @info.command(name="license")
407
+ def license_():
408
+ """Show the license of tabsdata."""
409
+ _show_manifest("LICENSE", "License", dedent=True)
410
+
411
+
412
+ @info.command(name="release-notes")
413
+ def release_notes():
414
+ """Show the release notes of the current version of tabsdata."""
415
+ _show_manifest("RELEASE-NOTES", "Release Notes")
416
+
417
+
418
+ @cli.command()
419
+ @click.option(
420
+ "--server",
421
+ "-s",
422
+ help=(
423
+ "URL of the Tabsdata Server. Defaults to the server set with 'tdk set "
424
+ "--server'; will be prompted for it if neither is provided. "
425
+ + SERVER_URL_DEFAULTS_HELP
426
+ ),
427
+ )
428
+ @click.option(
429
+ "--user",
430
+ "-u",
431
+ help="Username for the Tabsdata Server. Will be prompted for it if not provided.",
432
+ )
433
+ @click.option(
434
+ "--password",
435
+ "-p",
436
+ help=(
437
+ "Password for the Tabsdata Server. It is discouraged to send the password as a "
438
+ "plain argument, it should be either sent as the value of an environment "
439
+ "variable or written through the prompt. Will be prompted for it if not "
440
+ "provided."
441
+ ),
442
+ )
443
+ @click.pass_context
444
+ def login(ctx: click.Context, server: str, user: str, password: str):
445
+ """Login to the Tabsdata Server"""
446
+ server = (
447
+ server or configured_server(ctx) or logical_prompt(ctx, "Tabsdata Server URL")
448
+ )
449
+ user = user or logical_prompt(ctx, "Username for the Tabsdata Server")
450
+ password = password or logical_prompt(
451
+ ctx,
452
+ "Password",
453
+ hide_input=True,
454
+ )
455
+ utils_login(ctx, server, user, password)
456
+ from tabsdatak.cli.td.config_utils import execute_actions_for_command
457
+
458
+ execute_actions_for_command("login")
459
+
460
+
461
+ @cli.command()
462
+ @click.pass_context
463
+ def logout(ctx: click.Context):
464
+ """Logout from the Tabsdata Server"""
465
+ try:
466
+ ctx.obj["tabsdataserver"].logout()
467
+ click.echo("Logout successful.")
468
+ except Exception as e:
469
+ hint_common_solutions(ctx, e)
470
+ raise click.ClickException(f"Failed to logout: {e}")
471
+ finally:
472
+ try:
473
+ os.remove(get_credentials_file_path(ctx))
474
+ except Exception:
475
+ pass
476
+ try:
477
+ clear_pins(ctx)
478
+ store_cli_options(ctx)
479
+ except Exception:
480
+ pass
481
+
482
+
483
+ @cli.command()
484
+ @click.pass_context
485
+ def status(ctx: click.Context):
486
+ """Check the status of the server"""
487
+ verify_login_or_prompt(ctx)
488
+ click.echo("Obtaining server status")
489
+ click.echo("-" * 10)
490
+ try:
491
+ current_status = ctx.obj["tabsdataserver"].status
492
+ click.echo(str(current_status))
493
+ except Exception as e:
494
+ hint_common_solutions(ctx, e)
495
+ raise click.ClickException(f"Failed to get status: {e}")
496
+
497
+
498
+ if __name__ == "__main__":
499
+ from tabsdatak.common._compatibility import (
500
+ PackageVersionError,
501
+ check_sticky_version_packages,
502
+ )
503
+
504
+ try:
505
+ check_sticky_version_packages()
506
+ except PackageVersionError as error:
507
+ print(error.messages, file=sys.stderr)
508
+ cli()