ptn 0.2.5__py3-none-any.whl → 0.4.2__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 (39) hide show
  1. porterminal/__init__.py +63 -11
  2. porterminal/_version.py +2 -2
  3. porterminal/app.py +25 -1
  4. porterminal/application/ports/__init__.py +2 -0
  5. porterminal/application/ports/connection_registry_port.py +46 -0
  6. porterminal/application/services/management_service.py +30 -55
  7. porterminal/application/services/session_service.py +3 -11
  8. porterminal/application/services/terminal_service.py +97 -56
  9. porterminal/cli/args.py +91 -30
  10. porterminal/cli/display.py +18 -16
  11. porterminal/cli/script_discovery.py +112 -0
  12. porterminal/composition.py +8 -7
  13. porterminal/config.py +12 -2
  14. porterminal/container.py +4 -0
  15. porterminal/domain/__init__.py +0 -9
  16. porterminal/domain/entities/output_buffer.py +56 -1
  17. porterminal/domain/entities/tab.py +11 -10
  18. porterminal/domain/services/__init__.py +0 -2
  19. porterminal/domain/values/__init__.py +0 -4
  20. porterminal/domain/values/environment_rules.py +3 -0
  21. porterminal/infrastructure/auth.py +131 -0
  22. porterminal/infrastructure/cloudflared.py +18 -12
  23. porterminal/infrastructure/config/shell_detector.py +407 -1
  24. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  25. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  26. porterminal/infrastructure/server.py +28 -3
  27. porterminal/pty/env.py +16 -78
  28. porterminal/pty/manager.py +6 -4
  29. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  30. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  31. porterminal/static/index.html +14 -2
  32. porterminal/updater.py +13 -5
  33. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
  34. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
  35. porterminal/static/assets/app-By4EXMHC.js +0 -72
  36. porterminal/static/assets/app-DQePboVd.css +0 -32
  37. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
  38. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
  39. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,9 @@
3
3
  import asyncio
4
4
  import logging
5
5
  import re
6
+ import time
6
7
  from contextlib import suppress
8
+ from dataclasses import dataclass
7
9
  from datetime import UTC, datetime
8
10
  from typing import Any
9
11
 
@@ -19,6 +21,20 @@ from ..ports.connection_port import ConnectionPort
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
24
+
25
+ @dataclass
26
+ class ConnectionFlowState:
27
+ """Per-connection flow control state.
28
+
29
+ Implements xterm.js recommended watermark-based flow control.
30
+ When client sends 'pause', we stop sending to that connection.
31
+ When client sends 'ack', we resume sending.
32
+ """
33
+
34
+ paused: bool = False
35
+ pause_time: float | None = None
36
+
37
+
22
38
  # Terminal response sequences that should NOT be written to PTY.
23
39
  # These are responses from the terminal emulator to queries from applications.
24
40
  # If written to PTY, they get echoed back and displayed as garbage.
@@ -31,11 +47,20 @@ TERMINAL_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]*c|\x1b\[[\d;]*R")
31
47
  # Constants
32
48
  HEARTBEAT_INTERVAL = 30 # seconds
33
49
  HEARTBEAT_TIMEOUT = 300 # 5 minutes
34
- PTY_READ_INTERVAL = 0.008 # ~120Hz polling
35
- OUTPUT_BATCH_INTERVAL = 0.016 # ~60Hz output (batch writes for smoother rendering)
50
+
51
+ # Adaptive PTY read interval: fast when data flowing, slow when idle
52
+ PTY_READ_INTERVAL_MIN = 0.001 # 1ms when data is flowing (high throughput)
53
+ PTY_READ_INTERVAL_MAX = 0.008 # 8ms when idle (save CPU)
54
+ PTY_READ_BURST_THRESHOLD = 5 # Consecutive reads with data before going fast
55
+
56
+ # Tiered batch intervals: faster for interactive, slower for bulk
57
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE = 0.004 # 4ms for small data (<256 bytes)
58
+ OUTPUT_BATCH_INTERVAL_BULK = 0.016 # 16ms for larger data
59
+ OUTPUT_BATCH_SIZE_THRESHOLD = 256 # Bytes - threshold for interactive vs bulk
36
60
  OUTPUT_BATCH_MAX_SIZE = 16384 # Flush if batch exceeds 16KB
37
- INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for small interactive data
61
+ INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for very small data
38
62
  MAX_INPUT_SIZE = 4096
63
+ FLOW_PAUSE_TIMEOUT = 5.0 # seconds - auto-resume if client stops sending ACKs (was 15s)
39
64
 
40
65
 
41
66
  class AsyncioClock:
@@ -65,6 +90,8 @@ class TerminalService:
65
90
  self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
66
91
  # Per-session locks to prevent race between buffer replay and broadcast
67
92
  self._session_locks: dict[str, asyncio.Lock] = {}
93
+ # Per-connection flow control state (watermark-based backpressure)
94
+ self._flow_state: dict[ConnectionPort, ConnectionFlowState] = {}
68
95
 
69
96
  # -------------------------------------------------------------------------
70
97
  # Multi-client connection tracking
@@ -72,9 +99,7 @@ class TerminalService:
72
99
 
73
100
  def _get_session_lock(self, session_id: str) -> asyncio.Lock:
74
101
  """Get or create a lock for a session."""
75
- if session_id not in self._session_locks:
76
- self._session_locks[session_id] = asyncio.Lock()
77
- return self._session_locks[session_id]
102
+ return self._session_locks.setdefault(session_id, asyncio.Lock())
78
103
 
79
104
  def _cleanup_session_lock(self, session_id: str) -> None:
80
105
  """Remove session lock when no longer needed."""
@@ -82,13 +107,17 @@ class TerminalService:
82
107
 
83
108
  def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
84
109
  """Register a connection for a session. Returns connection count."""
85
- if session_id not in self._session_connections:
86
- self._session_connections[session_id] = set()
87
- self._session_connections[session_id].add(connection)
88
- return len(self._session_connections[session_id])
110
+ connections = self._session_connections.setdefault(session_id, set())
111
+ connections.add(connection)
112
+ # Initialize flow control state for this connection
113
+ self._flow_state[connection] = ConnectionFlowState()
114
+ return len(connections)
89
115
 
90
116
  def _unregister_connection(self, session_id: str, connection: ConnectionPort) -> int:
91
117
  """Unregister a connection. Returns remaining count."""
118
+ # Clean up flow control state
119
+ self._flow_state.pop(connection, None)
120
+
92
121
  if session_id not in self._session_connections:
93
122
  return 0
94
123
  self._session_connections[session_id].discard(connection)
@@ -98,12 +127,27 @@ class TerminalService:
98
127
  return count
99
128
 
100
129
  async def _send_to_connections(self, connections: list[ConnectionPort], data: bytes) -> None:
101
- """Send data to a list of connections (used with pre-snapshotted list)."""
130
+ """Send data to connections, respecting flow control.
131
+
132
+ Skips paused connections (client overwhelmed) but auto-resumes
133
+ after FLOW_PAUSE_TIMEOUT to prevent permanent pause from dead clients.
134
+ """
135
+ current_time = time.time()
102
136
  for conn in connections:
137
+ flow = self._flow_state.get(conn)
138
+ if flow and flow.paused:
139
+ # Check timeout - auto-resume if client stopped responding
140
+ if flow.pause_time and (current_time - flow.pause_time) > FLOW_PAUSE_TIMEOUT:
141
+ flow.paused = False
142
+ flow.pause_time = None
143
+ logger.debug("Auto-resumed paused connection after timeout")
144
+ else:
145
+ continue # Skip paused connection
146
+
103
147
  try:
104
148
  await conn.send_output(data)
105
- except Exception:
106
- pass # Connection cleanup handled elsewhere
149
+ except Exception as e:
150
+ logger.debug("Failed to send output to connection: %s", e)
107
151
 
108
152
  async def _broadcast_output(self, session_id: str, data: bytes) -> None:
109
153
  """Broadcast PTY output to all connections for a session.
@@ -264,6 +308,7 @@ class TerminalService:
264
308
  batch_buffer: list[bytes] = []
265
309
  batch_size = 0
266
310
  last_flush_time = asyncio.get_running_loop().time()
311
+ consecutive_data_reads = 0 # Track consecutive reads with data for adaptive sleep
267
312
 
268
313
  async def flush_batch() -> None:
269
314
  """Flush batched data with lock protection."""
@@ -295,6 +340,10 @@ class TerminalService:
295
340
  data = session.pty_handle.read(4096)
296
341
  if data:
297
342
  session.touch(datetime.now(UTC))
343
+ # Track consecutive reads with data for adaptive sleep
344
+ consecutive_data_reads = min(
345
+ consecutive_data_reads + 1, PTY_READ_BURST_THRESHOLD
346
+ )
298
347
 
299
348
  # Small data (interactive): flush immediately for responsiveness
300
349
  if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
@@ -312,6 +361,9 @@ class TerminalService:
312
361
  # Flush if batch is large enough
313
362
  if batch_size >= OUTPUT_BATCH_MAX_SIZE:
314
363
  await flush_batch()
364
+ else:
365
+ # No data - reset burst counter
366
+ consecutive_data_reads = 0
315
367
 
316
368
  except Exception as e:
317
369
  logger.error("PTY read error session_id=%s: %s", session.id, e)
@@ -319,12 +371,25 @@ class TerminalService:
319
371
  await self._broadcast_output(session_id, f"\r\n[PTY error: {e}]\r\n".encode())
320
372
  break
321
373
 
374
+ # Tiered batch interval: faster for small batches, slower for large
375
+ batch_interval = (
376
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE
377
+ if batch_size < OUTPUT_BATCH_SIZE_THRESHOLD
378
+ else OUTPUT_BATCH_INTERVAL_BULK
379
+ )
380
+
322
381
  # Check if we should flush based on time
323
382
  current_time = asyncio.get_running_loop().time()
324
- if batch_buffer and (current_time - last_flush_time) >= OUTPUT_BATCH_INTERVAL:
383
+ if batch_buffer and (current_time - last_flush_time) >= batch_interval:
325
384
  await flush_batch()
326
385
 
327
- await asyncio.sleep(PTY_READ_INTERVAL)
386
+ # Adaptive sleep: fast when data flowing, slow when idle
387
+ sleep_time = (
388
+ PTY_READ_INTERVAL_MIN
389
+ if consecutive_data_reads >= PTY_READ_BURST_THRESHOLD
390
+ else PTY_READ_INTERVAL_MAX
391
+ )
392
+ await asyncio.sleep(sleep_time)
328
393
 
329
394
  # Flush any remaining data
330
395
  await flush_batch()
@@ -358,7 +423,7 @@ class TerminalService:
358
423
  if isinstance(message, bytes):
359
424
  await self._handle_binary_input(session, message, rate_limiter, connection)
360
425
  elif isinstance(message, dict):
361
- await self._handle_json_message(session, message, rate_limiter, connection)
426
+ await self._handle_json_message(session, message, connection)
362
427
 
363
428
  async def _handle_binary_input(
364
429
  self,
@@ -400,7 +465,6 @@ class TerminalService:
400
465
  self,
401
466
  session: Session[PTYPort],
402
467
  message: dict[str, Any],
403
- rate_limiter: TokenBucketRateLimiter,
404
468
  connection: ConnectionPort,
405
469
  ) -> None:
406
470
  """Handle JSON control message."""
@@ -408,13 +472,27 @@ class TerminalService:
408
472
 
409
473
  if msg_type == "resize":
410
474
  await self._handle_resize(session, message, connection)
411
- elif msg_type == "input":
412
- await self._handle_json_input(session, message, rate_limiter, connection)
413
475
  elif msg_type == "ping":
414
476
  await connection.send_message({"type": "pong"})
415
477
  session.touch(datetime.now(UTC))
416
478
  elif msg_type == "pong":
417
479
  session.touch(datetime.now(UTC))
480
+ elif msg_type == "pause":
481
+ # Client is overwhelmed - stop sending data to this connection
482
+ flow = self._flow_state.get(connection)
483
+ if flow:
484
+ flow.paused = True
485
+ flow.pause_time = time.time()
486
+ # Send confirmation so client knows pause was received
487
+ await connection.send_message({"type": "pause_ack"})
488
+ logger.debug("Connection paused (client overwhelmed) session_id=%s", session.id)
489
+ elif msg_type == "ack":
490
+ # Client caught up - resume sending data
491
+ flow = self._flow_state.get(connection)
492
+ if flow and flow.paused:
493
+ flow.paused = False
494
+ flow.pause_time = None
495
+ logger.debug("Connection resumed (client caught up) session_id=%s", session.id)
418
496
  else:
419
497
  logger.warning("Unknown message type session_id=%s type=%s", session.id, msg_type)
420
498
 
@@ -475,40 +553,3 @@ class TerminalService:
475
553
  new_dims.cols,
476
554
  new_dims.rows,
477
555
  )
478
-
479
- async def _handle_json_input(
480
- self,
481
- session: Session[PTYPort],
482
- message: dict[str, Any],
483
- rate_limiter: TokenBucketRateLimiter,
484
- connection: ConnectionPort,
485
- ) -> None:
486
- """Handle JSON-encoded terminal input."""
487
- data = message.get("data", "")
488
-
489
- if len(data) > self._max_input_size:
490
- await connection.send_message(
491
- {
492
- "type": "error",
493
- "message": "Input too large",
494
- }
495
- )
496
- return
497
-
498
- if data:
499
- input_bytes = data.encode("utf-8")
500
- # Filter terminal response sequences
501
- filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", input_bytes)
502
- if not filtered:
503
- return
504
-
505
- if rate_limiter.try_acquire(len(filtered)):
506
- session.pty_handle.write(filtered)
507
- session.touch(datetime.now(UTC))
508
- else:
509
- await connection.send_message(
510
- {
511
- "type": "error",
512
- "message": "Rate limit exceeded",
513
- }
514
- )
porterminal/cli/args.py CHANGED
@@ -22,7 +22,6 @@ def parse_args() -> argparse.Namespace:
22
22
  formatter_class=argparse.RawDescriptionHelpFormatter,
23
23
  )
24
24
  parser.add_argument(
25
- "-V",
26
25
  "--version",
27
26
  action="version",
28
27
  version=f"%(prog)s {__version__}",
@@ -34,6 +33,7 @@ def parse_args() -> argparse.Namespace:
34
33
  help="Starting directory for the shell (default: current directory)",
35
34
  )
36
35
  parser.add_argument(
36
+ "-n",
37
37
  "--no-tunnel",
38
38
  action="store_true",
39
39
  help="Start server only, without Cloudflare tunnel",
@@ -45,12 +45,13 @@ def parse_args() -> argparse.Namespace:
45
45
  help="Show detailed startup logs",
46
46
  )
47
47
  parser.add_argument(
48
- "-U",
48
+ "-u",
49
49
  "--update",
50
50
  action="store_true",
51
51
  help="Update to the latest version",
52
52
  )
53
53
  parser.add_argument(
54
+ "-c",
54
55
  "--check-update",
55
56
  action="store_true",
56
57
  help="Check if a newer version is available",
@@ -62,10 +63,23 @@ def parse_args() -> argparse.Namespace:
62
63
  help="Run in background and return immediately",
63
64
  )
64
65
  parser.add_argument(
66
+ "-i",
65
67
  "--init",
66
68
  action="store_true",
67
69
  help="Create .ptn/ptn.yaml config file in current directory",
68
70
  )
71
+ parser.add_argument(
72
+ "-p",
73
+ "--password",
74
+ action="store_true",
75
+ help="Prompt for password to protect terminal access",
76
+ )
77
+ parser.add_argument(
78
+ "-dp",
79
+ "--default-password",
80
+ action="store_true",
81
+ help="Toggle password requirement in config (on/off)",
82
+ )
69
83
  # Internal argument for background mode communication
70
84
  parser.add_argument(
71
85
  "--_url-file",
@@ -95,47 +109,94 @@ def parse_args() -> argparse.Namespace:
95
109
 
96
110
  if args.init:
97
111
  _init_config()
112
+ # Continue to launch ptn after creating config
113
+
114
+ if args.default_password:
115
+ _toggle_password_requirement()
98
116
  sys.exit(0)
99
117
 
100
118
  return args
101
119
 
102
120
 
103
- DEFAULT_CONFIG = """\
104
- # ptn configuration file
105
- # Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
106
-
107
- # Custom buttons (appear in third toolbar row)
108
- buttons:
109
- - label: "git"
110
- send: "git status\\r"
111
- - label: "build"
112
- send: "npm run build\\r"
113
- # Multi-step button with delays (ms):
114
- # - label: "deploy"
115
- # send:
116
- # - "npm run build"
117
- # - 100
118
- # - "\\r"
119
-
120
- # Terminal settings (optional)
121
- # terminal:
122
- # default_shell: bash
123
- # cols: 120
124
- # rows: 30
125
- """
126
-
127
-
128
121
  def _init_config() -> None:
129
- """Create .ptn/ptn.yaml in current directory."""
122
+ """Create .ptn/ptn.yaml in current directory with auto-discovered scripts."""
130
123
  from pathlib import Path
131
124
 
132
- config_dir = Path.cwd() / ".ptn"
125
+ import yaml
126
+
127
+ from porterminal.cli.script_discovery import discover_scripts
128
+
129
+ cwd = Path.cwd()
130
+ config_dir = cwd / ".ptn"
133
131
  config_file = config_dir / "ptn.yaml"
134
132
 
135
133
  if config_file.exists():
136
134
  print(f"Config already exists: {config_file}")
137
135
  return
138
136
 
137
+ # Build config with default buttons (row 1: AI coding tools)
138
+ config: dict = {
139
+ "buttons": [
140
+ {"label": "new", "send": ["/new", 100, "\r"]},
141
+ {"label": "init", "send": ["/init", 100, "\r"]},
142
+ {"label": "resume", "send": ["/resume", 100, "\r"]},
143
+ {"label": "compact", "send": ["/compact", 100, "\r"]},
144
+ {"label": "claude", "send": ["claude", 100, "\r"]},
145
+ {"label": "codex", "send": ["codex", 100, "\r"]},
146
+ ]
147
+ }
148
+
149
+ # Auto-discover project scripts and add to row 2
150
+ discovered = discover_scripts(cwd)
151
+ if discovered:
152
+ config["buttons"].extend(discovered)
153
+
139
154
  config_dir.mkdir(exist_ok=True)
140
- config_file.write_text(DEFAULT_CONFIG)
155
+
156
+ # Write YAML with comment header
157
+ header = "# ptn configuration file\n# Docs: https://github.com/lyehe/porterminal\n\n"
158
+ yaml_content = yaml.safe_dump(config, default_flow_style=False, sort_keys=False)
159
+ config_file.write_text(header + yaml_content)
160
+
141
161
  print(f"Created: {config_file}")
162
+ if discovered:
163
+ print(
164
+ f"Discovered {len(discovered)} project script(s): {', '.join(b['label'] for b in discovered)}"
165
+ )
166
+
167
+
168
+ def _toggle_password_requirement() -> None:
169
+ """Toggle security.require_password in config file."""
170
+ from pathlib import Path
171
+
172
+ import yaml
173
+
174
+ from porterminal.config import find_config_file
175
+
176
+ # Find existing config or use default location
177
+ config_path = find_config_file()
178
+ if config_path is None:
179
+ config_dir = Path.cwd() / ".ptn"
180
+ config_path = config_dir / "ptn.yaml"
181
+ config_dir.mkdir(exist_ok=True)
182
+
183
+ # Read existing config or create empty
184
+ if config_path.exists():
185
+ with open(config_path, encoding="utf-8") as f:
186
+ data = yaml.safe_load(f) or {}
187
+ else:
188
+ data = {}
189
+
190
+ # Toggle the value
191
+ if "security" not in data:
192
+ data["security"] = {}
193
+ current = data["security"].get("require_password", False)
194
+ data["security"]["require_password"] = not current
195
+
196
+ # Write back
197
+ with open(config_path, "w", encoding="utf-8") as f:
198
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
199
+
200
+ new_value = data["security"]["require_password"]
201
+ status = "enabled" if new_value else "disabled"
202
+ print(f"Password requirement {status} in {config_path}")
@@ -58,6 +58,14 @@ def get_caution() -> str:
58
58
  return CAUTION_DEFAULT
59
59
 
60
60
 
61
+ def _apply_gradient(lines: list[str], colors: list[str]) -> list[str]:
62
+ """Apply color gradient to text lines."""
63
+ return [
64
+ f"[{colors[min(i, len(colors) - 1)]}]{line}[/{colors[min(i, len(colors) - 1)]}]"
65
+ for i, line in enumerate(lines)
66
+ ]
67
+
68
+
61
69
  def get_qr_code(url: str) -> str:
62
70
  """Generate QR code as ASCII string.
63
71
 
@@ -112,21 +120,15 @@ def display_startup_screen(
112
120
  else:
113
121
  status = "[yellow]●[/yellow] LOCAL MODE"
114
122
 
115
- # Build logo with gradient
116
- logo_lines = LOGO.strip().split("\n")
117
- colors = ["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"]
118
- logo_colored = []
119
- for i, line in enumerate(logo_lines):
120
- color = colors[i] if i < len(colors) else colors[-1]
121
- logo_colored.append(f"[{color}]{line}[/{color}]")
122
-
123
- # Build tagline with gradient
124
- tagline_lines = TAGLINE.split("\n")
125
- tagline_colors = ["bright_magenta", "magenta"]
126
- tagline_colored = []
127
- for i, line in enumerate(tagline_lines):
128
- color = tagline_colors[i] if i < len(tagline_colors) else tagline_colors[-1]
129
- tagline_colored.append(f"[{color}]{line}[/{color}]")
123
+ # Build logo and tagline with gradients
124
+ logo_colored = _apply_gradient(
125
+ LOGO.strip().split("\n"),
126
+ ["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"],
127
+ )
128
+ tagline_colored = _apply_gradient(
129
+ TAGLINE.split("\n"),
130
+ ["bright_magenta", "magenta"],
131
+ )
130
132
 
131
133
  # Left side content
132
134
  left_lines = [
@@ -136,7 +138,7 @@ def display_startup_screen(
136
138
  *tagline_colored,
137
139
  "",
138
140
  f"[bold yellow]{get_caution()}[/bold yellow]",
139
- "[bright_red]The URL is the only security. Use at your own risk.[/bright_red]",
141
+ "[bright_red]Use -p for password protection if your screen is exposed[/bright_red]",
140
142
  status,
141
143
  f"[bold cyan]{url}[/bold cyan]",
142
144
  ]
@@ -0,0 +1,112 @@
1
+ """Auto-discover project scripts for config initialization."""
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ # Pattern for safe script names (alphanumeric, hyphens, underscores only)
9
+ _SAFE_NAME = re.compile(r"^[a-zA-Z0-9_-]+$")
10
+
11
+
12
+ def _is_safe_name(name: str) -> bool:
13
+ """Check if script name contains only safe characters."""
14
+ return bool(_SAFE_NAME.match(name)) and len(name) <= 50
15
+
16
+
17
+ def discover_scripts(cwd: Path | None = None) -> list[dict]:
18
+ """Discover project scripts in current directory.
19
+
20
+ Returns list of button configs: [{"label": "build", "send": "npm run build\\r", "row": 2}]
21
+ Only includes scripts explicitly defined in project files.
22
+ """
23
+ base = cwd or Path.cwd()
24
+ buttons = []
25
+
26
+ # Check each project type (only those with explicit scripts)
27
+ buttons.extend(_discover_npm_scripts(base))
28
+ buttons.extend(_discover_python_scripts(base))
29
+ buttons.extend(_discover_makefile_targets(base))
30
+
31
+ # Dedupe by label, keep first occurrence
32
+ unique: dict[str, dict] = {}
33
+ for btn in buttons:
34
+ unique.setdefault(btn["label"], btn)
35
+ return list(unique.values())
36
+
37
+
38
+ def _discover_npm_scripts(base: Path) -> list[dict]:
39
+ """Extract scripts from package.json."""
40
+ pkg_file = base / "package.json"
41
+ if not pkg_file.exists():
42
+ return []
43
+
44
+ try:
45
+ data = json.loads(pkg_file.read_text(encoding="utf-8"))
46
+ scripts = data.get("scripts", {})
47
+
48
+ # Common useful scripts to include (if defined)
49
+ priority = ["build", "dev", "start", "test", "lint", "format", "watch"]
50
+
51
+ buttons = []
52
+ for name in priority:
53
+ if name in scripts:
54
+ buttons.append({"label": name, "send": f"npm run {name}\r", "row": 2})
55
+
56
+ return buttons[:6] # Limit to 6 buttons
57
+ except Exception:
58
+ return []
59
+
60
+
61
+ def _discover_python_scripts(base: Path) -> list[dict]:
62
+ """Extract scripts from pyproject.toml."""
63
+ toml_file = base / "pyproject.toml"
64
+ if not toml_file.exists():
65
+ return []
66
+
67
+ try:
68
+ data = tomllib.loads(toml_file.read_text(encoding="utf-8"))
69
+ buttons = []
70
+
71
+ # Check [project.scripts] (PEP 621)
72
+ project_scripts = data.get("project", {}).get("scripts", {})
73
+ for name in list(project_scripts.keys())[:4]:
74
+ if _is_safe_name(name):
75
+ buttons.append({"label": name, "send": f"{name}\r", "row": 2})
76
+
77
+ # Check [tool.poetry.scripts]
78
+ poetry_scripts = data.get("tool", {}).get("poetry", {}).get("scripts", {})
79
+ for name in list(poetry_scripts.keys())[:4]:
80
+ if _is_safe_name(name) and not any(b["label"] == name for b in buttons):
81
+ buttons.append({"label": name, "send": f"{name}\r", "row": 2})
82
+
83
+ return buttons[:6]
84
+ except Exception:
85
+ return []
86
+
87
+
88
+ def _discover_makefile_targets(base: Path) -> list[dict]:
89
+ """Extract targets from Makefile."""
90
+ makefile = base / "Makefile"
91
+ if not makefile.exists():
92
+ return []
93
+
94
+ try:
95
+ content = makefile.read_text(encoding="utf-8")
96
+ # Match target definitions: "target:" at start of line
97
+ # Regex excludes targets starting with . (internal targets like .PHONY)
98
+ pattern = r"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:"
99
+ targets = re.findall(pattern, content, re.MULTILINE)
100
+
101
+ # Priority order for common targets (use set for O(1) lookup)
102
+ priority = ["build", "test", "run", "clean", "install", "dev", "lint", "all"]
103
+ priority_set = set(priority)
104
+ target_set = set(targets)
105
+
106
+ # Priority targets first, then remaining targets
107
+ ordered = [t for t in priority if t in target_set]
108
+ ordered.extend(t for t in targets if t not in priority_set)
109
+
110
+ return [{"label": name, "send": f"make {name}\r", "row": 2} for name in ordered[:6]]
111
+ except Exception:
112
+ return []