eeroctl 1.7.1__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.
Files changed (45) hide show
  1. eeroctl/__init__.py +19 -0
  2. eeroctl/commands/__init__.py +32 -0
  3. eeroctl/commands/activity.py +237 -0
  4. eeroctl/commands/auth.py +471 -0
  5. eeroctl/commands/completion.py +142 -0
  6. eeroctl/commands/device.py +492 -0
  7. eeroctl/commands/eero/__init__.py +12 -0
  8. eeroctl/commands/eero/base.py +224 -0
  9. eeroctl/commands/eero/led.py +154 -0
  10. eeroctl/commands/eero/nightlight.py +235 -0
  11. eeroctl/commands/eero/updates.py +82 -0
  12. eeroctl/commands/network/__init__.py +18 -0
  13. eeroctl/commands/network/advanced.py +191 -0
  14. eeroctl/commands/network/backup.py +162 -0
  15. eeroctl/commands/network/base.py +331 -0
  16. eeroctl/commands/network/dhcp.py +118 -0
  17. eeroctl/commands/network/dns.py +197 -0
  18. eeroctl/commands/network/forwards.py +115 -0
  19. eeroctl/commands/network/guest.py +162 -0
  20. eeroctl/commands/network/security.py +162 -0
  21. eeroctl/commands/network/speedtest.py +99 -0
  22. eeroctl/commands/network/sqm.py +194 -0
  23. eeroctl/commands/profile.py +671 -0
  24. eeroctl/commands/troubleshoot.py +317 -0
  25. eeroctl/context.py +254 -0
  26. eeroctl/errors.py +156 -0
  27. eeroctl/exit_codes.py +68 -0
  28. eeroctl/formatting/__init__.py +90 -0
  29. eeroctl/formatting/base.py +181 -0
  30. eeroctl/formatting/device.py +430 -0
  31. eeroctl/formatting/eero.py +591 -0
  32. eeroctl/formatting/misc.py +87 -0
  33. eeroctl/formatting/network.py +659 -0
  34. eeroctl/formatting/profile.py +443 -0
  35. eeroctl/main.py +161 -0
  36. eeroctl/options.py +429 -0
  37. eeroctl/output.py +739 -0
  38. eeroctl/safety.py +259 -0
  39. eeroctl/utils.py +181 -0
  40. eeroctl-1.7.1.dist-info/METADATA +115 -0
  41. eeroctl-1.7.1.dist-info/RECORD +45 -0
  42. eeroctl-1.7.1.dist-info/WHEEL +5 -0
  43. eeroctl-1.7.1.dist-info/entry_points.txt +3 -0
  44. eeroctl-1.7.1.dist-info/licenses/LICENSE +21 -0
  45. eeroctl-1.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,471 @@
1
+ """Authentication commands for the Eero CLI.
2
+
3
+ Commands:
4
+ - eero auth login: Start authentication flow
5
+ - eero auth logout: End current session
6
+ - eero auth clear: Clear all stored credentials
7
+ - eero auth status: Show authentication status
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import sys
15
+ from typing import TypedDict
16
+
17
+ import click
18
+ from eero import EeroClient
19
+ from eero.exceptions import EeroAuthenticationException, EeroException
20
+ from rich.panel import Panel
21
+ from rich.prompt import Confirm, Prompt
22
+ from rich.table import Table
23
+
24
+ from ..context import EeroCliContext, ensure_cli_context, get_cli_context
25
+ from ..exit_codes import ExitCode
26
+ from ..output import OutputFormat
27
+ from ..utils import get_cookie_file
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class _UserData(TypedDict):
33
+ """Type definition for user data in account info."""
34
+
35
+ id: str | None
36
+ name: str | None
37
+ email: str | None
38
+ phone: str | None
39
+ role: str | None
40
+ created_at: str | None
41
+
42
+
43
+ class _AccountData(TypedDict):
44
+ """Type definition for account data."""
45
+
46
+ id: str | None
47
+ name: str | None
48
+ premium_status: str | None
49
+ premium_expiry: str | None
50
+ created_at: str | None
51
+ users: list[_UserData]
52
+
53
+
54
+ @click.group(name="auth")
55
+ @click.pass_context
56
+ def auth_group(ctx: click.Context) -> None:
57
+ """Manage authentication.
58
+
59
+ \b
60
+ Commands:
61
+ login - Authenticate with your Eero account
62
+ logout - End current session
63
+ clear - Clear all stored credentials
64
+ status - Show authentication status
65
+
66
+ \b
67
+ Examples:
68
+ eero auth login # Start login flow
69
+ eero auth status # Check if authenticated
70
+ eero auth logout # End session
71
+ """
72
+ ensure_cli_context(ctx)
73
+
74
+
75
+ @auth_group.command(name="login")
76
+ @click.option("--force", is_flag=True, help="Force new login even if already authenticated")
77
+ @click.option("--no-keyring", is_flag=True, help="Don't use keyring for secure token storage")
78
+ @click.pass_context
79
+ def auth_login(ctx: click.Context, force: bool, no_keyring: bool) -> None:
80
+ """Login to your Eero account.
81
+
82
+ Starts an interactive authentication flow. A verification code
83
+ will be sent to your email or phone number.
84
+
85
+ \b
86
+ Examples:
87
+ eero auth login # Start login flow
88
+ eero auth login --force # Force new login
89
+ """
90
+ cli_ctx = get_cli_context(ctx)
91
+ console = cli_ctx.console
92
+
93
+ async def run() -> None:
94
+ async with EeroClient(
95
+ cookie_file=str(get_cookie_file()),
96
+ use_keyring=not no_keyring,
97
+ ) as client:
98
+ if client.is_authenticated and not force:
99
+ # Validate session is actually working, not just locally present
100
+ try:
101
+ await client.get_networks()
102
+ console.print(
103
+ "[bold yellow]Already authenticated.[/bold yellow] "
104
+ "Use --force to login again."
105
+ )
106
+ return
107
+ except EeroAuthenticationException:
108
+ # Session expired, continue with login flow
109
+ console.print("[yellow]Session expired. Starting new login...[/yellow]")
110
+
111
+ await _interactive_login(client, force, console, cli_ctx)
112
+
113
+ try:
114
+ asyncio.run(run())
115
+ except EeroAuthenticationException as e:
116
+ cli_ctx.renderer.render_error(str(e))
117
+ sys.exit(ExitCode.AUTH_REQUIRED)
118
+ except EeroException as e:
119
+ cli_ctx.renderer.render_error(str(e))
120
+ sys.exit(ExitCode.GENERIC_ERROR)
121
+
122
+
123
+ async def _interactive_login(
124
+ client: EeroClient, force: bool, console, cli_ctx: EeroCliContext
125
+ ) -> bool:
126
+ """Interactive login process."""
127
+ cookie_file = get_cookie_file()
128
+
129
+ # Check for existing session
130
+ if os.path.exists(cookie_file) and not force:
131
+ try:
132
+ with open(cookie_file, "r") as f:
133
+ cookies = json.load(f)
134
+ if cookies.get("user_token") and cookies.get("session_id"):
135
+ console.print(
136
+ Panel.fit(
137
+ "An existing authentication session was found.",
138
+ title="Eero Login",
139
+ border_style="blue",
140
+ )
141
+ )
142
+ reuse = Confirm.ask("Do you want to reuse the existing session?")
143
+
144
+ if reuse:
145
+ with cli_ctx.status("Testing existing session..."):
146
+ try:
147
+ networks = await client.get_networks()
148
+ console.print(
149
+ f"[bold green]Session valid! "
150
+ f"Found {len(networks)} network(s).[/bold green]"
151
+ )
152
+ return True
153
+ except Exception as ex:
154
+ logger.debug("Session validation failed: %s", ex)
155
+ console.print("[yellow]Existing session invalid.[/yellow]")
156
+ except Exception as ex:
157
+ logger.debug("Failed to check existing session: %s", ex)
158
+
159
+ # Clear existing auth data
160
+ await client._api.auth.clear_auth_data()
161
+
162
+ # Start fresh login
163
+ console.print(
164
+ Panel.fit(
165
+ "Please login to your Eero account.\nA verification code will be sent to you.",
166
+ title="Eero Login",
167
+ border_style="blue",
168
+ )
169
+ )
170
+
171
+ user_identifier = Prompt.ask("Email or phone number")
172
+
173
+ with cli_ctx.status("Requesting verification code..."):
174
+ try:
175
+ result = await client.login(user_identifier)
176
+ if not result:
177
+ console.print("[bold red]Failed to request verification code[/bold red]")
178
+ return False
179
+ console.print("[bold green]Verification code sent![/bold green]")
180
+ except EeroException as ex:
181
+ console.print(f"[bold red]Error:[/bold red] {ex}")
182
+ return False
183
+
184
+ # Verification loop
185
+ max_attempts = 3
186
+ for attempt in range(max_attempts):
187
+ verification_code = Prompt.ask("Verification code (check your email/phone)")
188
+
189
+ with cli_ctx.status("Verifying..."):
190
+ try:
191
+ result = await client.verify(verification_code)
192
+ if result:
193
+ console.print("[bold green]Login successful![/bold green]")
194
+ return True
195
+ except EeroException as ex:
196
+ console.print(f"[bold red]Error:[/bold red] {ex}")
197
+
198
+ if attempt < max_attempts - 1:
199
+ resend = Confirm.ask("Resend verification code?")
200
+ if resend:
201
+ with cli_ctx.status("Resending..."):
202
+ await client._api.auth.resend_verification_code()
203
+ console.print("[green]Code resent![/green]")
204
+
205
+ console.print("[bold red]Too many failed attempts[/bold red]")
206
+ return False
207
+
208
+
209
+ @auth_group.command(name="logout")
210
+ @click.pass_context
211
+ def auth_logout(ctx: click.Context) -> None:
212
+ """Logout from your Eero account.
213
+
214
+ Ends the current session and clears the session token.
215
+ Credentials are preserved for easy re-authentication.
216
+ """
217
+ cli_ctx = get_cli_context(ctx)
218
+ console = cli_ctx.console
219
+
220
+ async def run() -> None:
221
+ async with EeroClient(cookie_file=str(get_cookie_file())) as client:
222
+ if not client.is_authenticated:
223
+ console.print("[yellow]Not logged in[/yellow]")
224
+ return
225
+
226
+ with cli_ctx.status("Logging out..."):
227
+ try:
228
+ result = await client.logout()
229
+ if result:
230
+ console.print("[bold green]Logged out successfully[/bold green]")
231
+ else:
232
+ console.print("[bold red]Failed to logout[/bold red]")
233
+ except EeroException as ex:
234
+ console.print(f"[bold red]Error:[/bold red] {ex}")
235
+ sys.exit(ExitCode.GENERIC_ERROR)
236
+
237
+ asyncio.run(run())
238
+
239
+
240
+ @auth_group.command(name="clear")
241
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
242
+ @click.pass_context
243
+ def auth_clear(ctx: click.Context, force: bool) -> None:
244
+ """Clear all stored authentication data.
245
+
246
+ Removes all stored credentials including tokens and session data.
247
+ You will need to login again after this.
248
+ """
249
+ cli_ctx = get_cli_context(ctx)
250
+ console = cli_ctx.console
251
+
252
+ if not force and not cli_ctx.non_interactive:
253
+ confirmed = Confirm.ask(
254
+ "This will clear all authentication data. Continue?",
255
+ default=False,
256
+ )
257
+ if not confirmed:
258
+ console.print("[yellow]Cancelled[/yellow]")
259
+ return
260
+ elif cli_ctx.non_interactive and not force:
261
+ cli_ctx.renderer.render_error(
262
+ "Clearing auth data requires confirmation. Use --force in non-interactive mode."
263
+ )
264
+ sys.exit(ExitCode.SAFETY_RAIL)
265
+
266
+ async def run() -> None:
267
+ async with EeroClient(cookie_file=str(get_cookie_file())) as client:
268
+ await client._api.auth.clear_auth_data()
269
+ console.print("[bold green]Authentication data cleared[/bold green]")
270
+
271
+ asyncio.run(run())
272
+
273
+
274
+ def _get_session_info() -> dict:
275
+ """Read session info from cookie file."""
276
+ from datetime import datetime
277
+
278
+ cookie_file = get_cookie_file()
279
+ session_info = {
280
+ "cookie_file": str(cookie_file),
281
+ "cookie_exists": cookie_file.exists(),
282
+ "session_expiry": None,
283
+ "session_expired": True, # Default to expired
284
+ "has_token": False,
285
+ "preferred_network_id": None,
286
+ }
287
+
288
+ if cookie_file.exists():
289
+ try:
290
+ with open(cookie_file, "r") as f:
291
+ data = json.load(f)
292
+ session_info["session_expiry"] = data.get("session_expiry")
293
+ session_info["preferred_network_id"] = data.get("preferred_network_id")
294
+ session_info["has_token"] = bool(data.get("user_token"))
295
+
296
+ # Check if session is expired based on expiry date
297
+ expiry_str = data.get("session_expiry")
298
+ if expiry_str:
299
+ try:
300
+ expiry = datetime.fromisoformat(expiry_str)
301
+ session_info["session_expired"] = datetime.now() > expiry
302
+ except ValueError:
303
+ logger.debug("Failed to parse session expiry date: %s", expiry_str)
304
+ except Exception as ex:
305
+ logger.debug("Failed to read cookie file: %s", ex)
306
+
307
+ return session_info
308
+
309
+
310
+ def _check_keyring_available() -> bool:
311
+ """Check if keyring is available and has eero credentials."""
312
+ try:
313
+ import keyring
314
+
315
+ token = keyring.get_password("eero", "user_token")
316
+ return token is not None
317
+ except Exception:
318
+ return False
319
+
320
+
321
+ @auth_group.command(name="status")
322
+ @click.pass_context
323
+ def auth_status(ctx: click.Context) -> None:
324
+ """Show current authentication status.
325
+
326
+ Displays session info, authentication method, and account details.
327
+ """
328
+ cli_ctx = get_cli_context(ctx)
329
+ console = cli_ctx.console
330
+
331
+ async def run() -> None:
332
+ cookie_file = get_cookie_file()
333
+ session_info = _get_session_info()
334
+ keyring_available = _check_keyring_available()
335
+
336
+ async with EeroClient(cookie_file=str(cookie_file)) as client:
337
+ is_auth = client.is_authenticated
338
+ account_data: _AccountData | None = None
339
+
340
+ # Determine session validity based on expiry date, not API call
341
+ # (API call may fail due to network issues, not expired session)
342
+ session_valid = (
343
+ is_auth and session_info["has_token"] and not session_info["session_expired"]
344
+ )
345
+
346
+ # Try to get account info if we have a valid session
347
+ if session_valid:
348
+ try:
349
+ with cli_ctx.status("Getting account info..."):
350
+ account = await client.get_account()
351
+ users_list: list[_UserData] = [
352
+ _UserData(
353
+ id=u.id,
354
+ name=u.name,
355
+ email=u.email,
356
+ phone=u.phone,
357
+ role=u.role,
358
+ created_at=str(u.created_at) if u.created_at else None,
359
+ )
360
+ for u in (account.users or [])
361
+ ]
362
+ account_data = _AccountData(
363
+ id=account.id,
364
+ name=account.name,
365
+ premium_status=account.premium_status,
366
+ premium_expiry=(
367
+ str(account.premium_expiry) if account.premium_expiry else None
368
+ ),
369
+ created_at=str(account.created_at) if account.created_at else None,
370
+ users=users_list,
371
+ )
372
+ except Exception as ex:
373
+ # API call failed but session may still be valid per expiry date
374
+ logger.debug("Session verification API call failed: %s", ex)
375
+
376
+ # Determine auth method
377
+ auth_method = "keyring" if keyring_available else "cookie"
378
+
379
+ if cli_ctx.is_structured_output():
380
+ data = {
381
+ "authenticated": is_auth,
382
+ "session_valid": session_valid,
383
+ "auth_method": auth_method,
384
+ "session": {
385
+ "cookie_file": session_info["cookie_file"],
386
+ "expiry": session_info["session_expiry"],
387
+ "preferred_network_id": session_info["preferred_network_id"],
388
+ },
389
+ "keyring_available": keyring_available,
390
+ "account": account_data,
391
+ }
392
+ cli_ctx.render_structured(data, "eero.auth.status/v1")
393
+
394
+ elif cli_ctx.output_format == OutputFormat.LIST:
395
+ # List format - parseable key-value rows
396
+ status = (
397
+ "valid" if session_valid else ("expired" if is_auth else "not_authenticated")
398
+ )
399
+ print(f"status {status}")
400
+ print(f"auth_method {auth_method}")
401
+ print(f"cookie_file {session_info['cookie_file']}")
402
+ print(f"session_expiry {session_info['session_expiry'] or 'N/A'}")
403
+ print(f"keyring_available {keyring_available}")
404
+ if account_data:
405
+ print(f"account_id {account_data['id']}")
406
+ print(f"account_name {account_data['name'] or 'N/A'}")
407
+ print(f"premium_status {account_data['premium_status'] or 'N/A'}")
408
+ print(f"premium_expiry {account_data['premium_expiry'] or 'N/A'}")
409
+ for u in account_data.get("users", []):
410
+ print(f"user {u['email']} {u['role']} {u['name'] or ''}")
411
+
412
+ else:
413
+ # Table format - Rich tables
414
+ # Session info table
415
+ session_table = Table(title="Session Information")
416
+ session_table.add_column("Property", style="cyan")
417
+ session_table.add_column("Value")
418
+
419
+ if session_valid:
420
+ status_display = "[green]Valid[/green]"
421
+ elif is_auth and session_info["session_expired"]:
422
+ status_display = "[yellow]Expired[/yellow]"
423
+ else:
424
+ status_display = "[red]Not Authenticated[/red]"
425
+
426
+ session_table.add_row("Status", status_display)
427
+ session_table.add_row("Auth Method", f"[blue]{auth_method}[/blue]")
428
+ session_table.add_row("Cookie File", session_info["cookie_file"])
429
+ session_table.add_row("Session Expiry", session_info["session_expiry"] or "N/A")
430
+ session_table.add_row(
431
+ "Keyring Available",
432
+ "[green]Yes[/green]" if keyring_available else "[dim]No[/dim]",
433
+ )
434
+
435
+ console.print(session_table)
436
+
437
+ # Account info table (only if we got account data)
438
+ if account_data:
439
+ console.print()
440
+ account_table = Table(title="Account Information")
441
+ account_table.add_column("Property", style="cyan")
442
+ account_table.add_column("Value")
443
+
444
+ account_table.add_row("Account ID", account_data["id"])
445
+ account_table.add_row("Account Name", account_data["name"] or "N/A")
446
+ premium = account_data["premium_status"] or "N/A"
447
+ if premium and "active" in premium.lower():
448
+ premium = f"[green]{premium}[/green]"
449
+ account_table.add_row("Premium Status", premium)
450
+ account_table.add_row("Premium Expiry", account_data["premium_expiry"] or "N/A")
451
+ account_table.add_row("Created", account_data["created_at"] or "N/A")
452
+
453
+ console.print(account_table)
454
+
455
+ # Users table
456
+ if account_data.get("users"):
457
+ console.print()
458
+ users_table = Table(title="Account Users")
459
+ users_table.add_column("Email", style="cyan")
460
+ users_table.add_column("Name")
461
+ users_table.add_column("Role", style="magenta")
462
+
463
+ for u in account_data["users"]:
464
+ users_table.add_row(u["email"], u["name"] or "", u["role"])
465
+
466
+ console.print(users_table)
467
+ elif not session_valid:
468
+ console.print()
469
+ console.print("[yellow]Run `eero auth login` to authenticate.[/yellow]")
470
+
471
+ asyncio.run(run())
@@ -0,0 +1,142 @@
1
+ """Shell completion commands for the Eero CLI.
2
+
3
+ Commands:
4
+ - eero completion bash: Generate bash completion
5
+ - eero completion zsh: Generate zsh completion
6
+ - eero completion fish: Generate fish completion
7
+ """
8
+
9
+ import click
10
+
11
+ from ..context import ensure_cli_context
12
+
13
+
14
+ @click.group(name="completion")
15
+ @click.pass_context
16
+ def completion_group(ctx: click.Context) -> None:
17
+ """Generate shell completion scripts.
18
+
19
+ \b
20
+ Supported shells:
21
+ bash - Bash completion
22
+ zsh - Zsh completion
23
+ fish - Fish completion
24
+
25
+ \b
26
+ Installation:
27
+ # Bash (add to ~/.bashrc)
28
+ eval "$(eero completion bash)"
29
+
30
+ # Zsh (add to ~/.zshrc)
31
+ eval "$(eero completion zsh)"
32
+
33
+ # Fish (add to ~/.config/fish/completions/eero.fish)
34
+ eero completion fish > ~/.config/fish/completions/eero.fish
35
+ """
36
+ ensure_cli_context(ctx)
37
+
38
+
39
+ @completion_group.command(name="bash")
40
+ @click.pass_context
41
+ def completion_bash(ctx: click.Context) -> None:
42
+ """Generate bash completion script.
43
+
44
+ \b
45
+ Usage:
46
+ # Add to ~/.bashrc:
47
+ eval "$(eero completion bash)"
48
+
49
+ # Or source from file:
50
+ eero completion bash > /etc/bash_completion.d/eero
51
+ """
52
+ # Click provides shell completion via _EERO_COMPLETE
53
+ script = """
54
+ _eero_completion() {
55
+ local IFS=$'\\n'
56
+ COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\
57
+ COMP_CWORD=$COMP_CWORD \\
58
+ _EERO_COMPLETE=complete_bash $1 ) )
59
+ return 0
60
+ }
61
+
62
+ complete -F _eero_completion -o default eero
63
+ """
64
+ click.echo(script)
65
+
66
+
67
+ @completion_group.command(name="zsh")
68
+ @click.pass_context
69
+ def completion_zsh(ctx: click.Context) -> None:
70
+ """Generate zsh completion script.
71
+
72
+ \b
73
+ Usage:
74
+ # Add to ~/.zshrc:
75
+ eval "$(eero completion zsh)"
76
+
77
+ # Or add to fpath:
78
+ eero completion zsh > ~/.zsh/completions/_eero
79
+ """
80
+ script = """
81
+ #compdef eero
82
+
83
+ _eero() {
84
+ local -a completions
85
+ local -a completions_with_descriptions
86
+ local -a response
87
+ (( ! $+commands[eero] )) && return 1
88
+
89
+ response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _EERO_COMPLETE=complete_zsh eero)}")
90
+
91
+ for key descr in ${(kv)response}; do
92
+ if [[ "$descr" == "_" ]]; then
93
+ completions+=("$key")
94
+ else
95
+ completions_with_descriptions+=("$key":"$descr")
96
+ fi
97
+ done
98
+
99
+ if [ -n "$completions_with_descriptions" ]; then
100
+ _describe -V unsorted completions_with_descriptions -U
101
+ fi
102
+
103
+ if [ -n "$completions" ]; then
104
+ compadd -U -V unsorted -a completions
105
+ fi
106
+ }
107
+
108
+ compdef _eero eero
109
+ """
110
+ click.echo(script)
111
+
112
+
113
+ @completion_group.command(name="fish")
114
+ @click.pass_context
115
+ def completion_fish(ctx: click.Context) -> None:
116
+ """Generate fish completion script.
117
+
118
+ \b
119
+ Usage:
120
+ # Save to fish completions directory:
121
+ eero completion fish > ~/.config/fish/completions/eero.fish
122
+ """
123
+ script = """
124
+ function _eero_completion
125
+ set -l response (env _EERO_COMPLETE=complete_fish COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) eero)
126
+
127
+ for completion in $response
128
+ set -l metadata (string split "," -- $completion)
129
+
130
+ if [ $metadata[1] = "plain" ]
131
+ echo $metadata[2]
132
+ else if [ $metadata[1] = "dir" ]
133
+ __fish_complete_directories $metadata[2]
134
+ else if [ $metadata[1] = "file" ]
135
+ __fish_complete_path $metadata[2]
136
+ end
137
+ end
138
+ end
139
+
140
+ complete --no-files --command eero --arguments "(_eero_completion)"
141
+ """
142
+ click.echo(script)