tsugite-cli 0.3.3__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 (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
@@ -0,0 +1,430 @@
1
+ """Interactive user input tools for Tsugite agents."""
2
+
3
+ import re
4
+ import sys
5
+ import termios
6
+ import time
7
+ from contextlib import contextmanager
8
+ from typing import List, Optional
9
+
10
+ import nest_asyncio
11
+ import questionary
12
+ from questionary import Style
13
+ from rich.console import Console
14
+ from rich.prompt import Confirm, Prompt
15
+
16
+ from ..tools import tool
17
+ from ..ui_context import paused_progress
18
+ from ..utils import is_interactive, validation_error
19
+
20
+ # Allow nested event loops (needed for questionary in async contexts)
21
+ nest_asyncio.apply()
22
+
23
+ # Custom style for questionary to match Rich theme
24
+ QUESTIONARY_STYLE = Style(
25
+ [
26
+ ("qmark", "fg:cyan bold"), # Question mark
27
+ ("question", "fg:cyan bold"), # Question text
28
+ ("answer", "fg:yellow bold"), # Selected answer
29
+ ("pointer", "fg:yellow bold"), # Selection pointer
30
+ ("highlighted", "fg:yellow bold"), # Highlighted option
31
+ ("selected", "fg:green"), # Already selected (for checkbox)
32
+ ("instruction", "fg:white"), # Instructions - white/default for better readability
33
+ ]
34
+ )
35
+
36
+
37
+ def _flush_input_buffer() -> None:
38
+ """Flush any pending input from stdin to prevent accidental key presses.
39
+
40
+ This prevents issues where a user accidentally hits Enter twice and
41
+ unintentionally confirms a pre-selected option.
42
+ """
43
+ if not is_interactive():
44
+ return
45
+
46
+ try:
47
+ # Save current terminal settings
48
+ fd = sys.stdin.fileno()
49
+ old_settings = termios.tcgetattr(fd)
50
+
51
+ # Flush input buffer
52
+ termios.tcflush(fd, termios.TCIFLUSH)
53
+
54
+ # Restore settings
55
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
56
+ except (termios.error, OSError):
57
+ # If we can't flush (e.g., not a real TTY), just continue
58
+ pass
59
+
60
+
61
+ def _generate_id_from_question(question: str) -> str:
62
+ """Generate a valid ID from question text.
63
+
64
+ Args:
65
+ question: The question text
66
+
67
+ Returns:
68
+ A lowercase, underscored ID based on the question
69
+ """
70
+ # Remove punctuation and convert to lowercase
71
+ clean = re.sub(r"[^\w\s]", "", question.lower())
72
+ # Replace spaces with underscores
73
+ id_str = re.sub(r"\s+", "_", clean.strip())
74
+ # Truncate if too long
75
+ if len(id_str) > 50:
76
+ id_str = id_str[:50]
77
+ # Remove trailing underscores
78
+ id_str = id_str.rstrip("_")
79
+ return id_str or "question"
80
+
81
+
82
+ @tool
83
+ def ask_user(question: str, question_type: str = "text", options: Optional[List[str]] = None) -> str:
84
+ """Ask the user a question interactively.
85
+
86
+ This tool allows the LLM to ask the user for input during agent execution.
87
+ It supports three types of questions:
88
+ - text: Freeform text input
89
+ - yes_no: Binary yes/no question (returns "yes" or "no")
90
+ - choice: Multiple choice from a list of options
91
+
92
+ Args:
93
+ question: The question to ask the user
94
+ question_type: Type of question - "text", "yes_no", or "choice"
95
+ options: List of options for "choice" type questions (required for choice type)
96
+
97
+ Returns:
98
+ User's response as a string
99
+
100
+ Raises:
101
+ ValueError: If not in interactive mode or invalid parameters
102
+ RuntimeError: If user interaction fails
103
+ """
104
+ if not is_interactive():
105
+ raise RuntimeError(
106
+ "Cannot use ask_user tool in non-interactive mode. "
107
+ "This tool requires a terminal with user input capability."
108
+ )
109
+
110
+ # Validate question type
111
+ valid_types = ["text", "yes_no", "choice"]
112
+ if question_type not in valid_types:
113
+ raise validation_error(
114
+ "question_type",
115
+ question_type,
116
+ f"must be one of {', '.join(valid_types)}",
117
+ )
118
+
119
+ # Validate options for choice type
120
+ if question_type == "choice":
121
+ if not options or len(options) < 2:
122
+ raise validation_error(
123
+ "options",
124
+ str(options),
125
+ "must provide at least 2 options for choice type questions",
126
+ )
127
+
128
+ try:
129
+ with terminal_context() as console:
130
+ return handle_question_by_type(question_type, question, options, console, _flush_input_buffer)
131
+ except KeyboardInterrupt:
132
+ raise RuntimeError("User input interrupted by keyboard interrupt")
133
+ except Exception as e:
134
+ raise RuntimeError(f"Failed to get user input: {e}")
135
+
136
+
137
+ @tool
138
+ def ask_user_batch(questions: List[dict]) -> dict:
139
+ """Ask the user multiple questions at once and collect all responses.
140
+
141
+ This tool allows the LLM to ask multiple questions in a batch, showing all questions
142
+ upfront and collecting all answers before returning to the agent. This provides a
143
+ better user experience for multi-field forms or related questions.
144
+
145
+ Args:
146
+ questions: List of question dictionaries. REQUIRED fields per question:
147
+ - question (str, REQUIRED): The question text to display
148
+ - type (str, REQUIRED): Question type - "text", "yes_no", or "choice"
149
+ (can also use "question_type" for consistency with ask_user)
150
+ - options (List[str]): Options for choice type (REQUIRED when type="choice")
151
+ - id (str, OPTIONAL): Unique identifier for response dict key.
152
+ If not provided, auto-generated from question text.
153
+
154
+ Returns:
155
+ Dictionary mapping question IDs to user responses
156
+
157
+ Raises:
158
+ ValueError: If not in interactive mode or invalid question structure
159
+ RuntimeError: If user interaction fails
160
+
161
+ Example:
162
+ # With explicit IDs
163
+ responses = ask_user_batch(questions=[
164
+ {"id": "email", "question": "What is your email?", "type": "text"},
165
+ {"id": "save", "question": "Save to file?", "type": "yes_no"},
166
+ {"id": "format", "question": "Choose format:", "type": "choice", "options": ["json", "txt"]}
167
+ ])
168
+ # Returns: {"email": "user@example.com", "save": "yes", "format": "json"}
169
+
170
+ # Without IDs (auto-generated from questions)
171
+ responses = ask_user_batch(questions=[
172
+ {"question": "What is your name?", "type": "text"},
173
+ {"question": "Save to file?", "type": "yes_no"},
174
+ {"question": "Choose format:", "type": "choice", "options": ["json", "txt", "md"]}
175
+ ])
176
+ # Returns: {"what_is_your_name": "Alice", "save_to_file": "yes", "choose_format": "json"}
177
+ """
178
+ if not is_interactive():
179
+ raise RuntimeError(
180
+ "Cannot use ask_user_batch tool in non-interactive mode. "
181
+ "This tool requires a terminal with user input capability."
182
+ )
183
+
184
+ # Validate questions list
185
+ if not questions or not isinstance(questions, list):
186
+ raise validation_error(
187
+ "questions",
188
+ str(questions),
189
+ "must be a non-empty list of question dictionaries",
190
+ )
191
+
192
+ # Validate each question structure and auto-generate IDs if needed
193
+ valid_types = ["text", "yes_no", "choice"]
194
+ validate_batch_questions(questions, valid_types)
195
+
196
+ # Collect responses
197
+ responses = {}
198
+
199
+ try:
200
+ with terminal_context() as console:
201
+ console.print("\n[bold cyan]Please answer the following questions:[/bold cyan]\n")
202
+
203
+ for i, q in enumerate(questions, 1):
204
+ q_id = q["id"]
205
+ q_text = q["question"]
206
+ q_type = q["type"]
207
+ options = q.get("options")
208
+
209
+ # Show question number
210
+ console.print(f"[dim]Question {i}/{len(questions)}[/dim]")
211
+
212
+ # Handle question based on type
213
+ answer = handle_question_by_type(q_type, q_text, options, console, _flush_input_buffer)
214
+ responses[q_id] = answer
215
+
216
+ # Add spacing between questions (except after last one)
217
+ if i < len(questions):
218
+ console.print()
219
+
220
+ console.print("\n[green]✓ All questions answered[/green]\n")
221
+
222
+ # Write summary of answers to captured stdout so it appears in observation for LLM
223
+ print("\nUser responses:")
224
+ for q_id, answer in responses.items():
225
+ print(f" {q_id}: {answer}")
226
+
227
+ except KeyboardInterrupt:
228
+ raise RuntimeError("User input interrupted by keyboard interrupt")
229
+ except Exception as e:
230
+ raise RuntimeError(f"Failed to get user input: {e}")
231
+
232
+ return responses
233
+
234
+
235
+ @contextmanager
236
+ def terminal_context():
237
+ """Context manager for terminal I/O with proper stdin/stdout handling.
238
+
239
+ Yields:
240
+ Console: A terminal console that writes directly to real terminal
241
+ """
242
+ terminal_console = Console(file=sys.__stdout__, force_terminal=True)
243
+ old_stdin = sys.stdin
244
+
245
+ try:
246
+ # Restore real terminal stdin for user input
247
+ sys.stdin = sys.__stdin__
248
+
249
+ # Pause progress spinner while showing prompts
250
+ with paused_progress():
251
+ yield terminal_console
252
+ finally:
253
+ # Restore stdin for executor
254
+ sys.stdin = old_stdin
255
+
256
+
257
+ def ask_text_question(question: str, console: Console, flush_fn) -> str:
258
+ """Ask a freeform text question.
259
+
260
+ Args:
261
+ question: Question text
262
+ console: Rich console for output
263
+ flush_fn: Function to flush input buffer
264
+
265
+ Returns:
266
+ User's text response
267
+ """
268
+ console.print(f"\n[cyan]Question:[/cyan] {question}")
269
+ flush_fn()
270
+ response = Prompt.ask("[yellow]Your answer[/yellow]", console=console)
271
+ print(f"User answered: {response}")
272
+ return response
273
+
274
+
275
+ def ask_yes_no_question(question: str, console: Console, flush_fn) -> str:
276
+ """Ask a yes/no question.
277
+
278
+ Args:
279
+ question: Question text
280
+ console: Rich console for output
281
+ flush_fn: Function to flush input buffer
282
+
283
+ Returns:
284
+ "yes" or "no"
285
+ """
286
+ console.print(f"\n[cyan]Question:[/cyan] {question}")
287
+ flush_fn()
288
+ result = Confirm.ask("[yellow]Your answer[/yellow]", console=console)
289
+ answer = "yes" if result else "no"
290
+ print(f"User answered: {answer}")
291
+ return answer
292
+
293
+
294
+ def ask_choice_question(question: str, options: List[str], console: Console, flush_fn) -> str:
295
+ """Ask a multiple choice question with arrow key navigation.
296
+
297
+ Args:
298
+ question: Question text
299
+ options: List of choice options
300
+ console: Rich console for output
301
+ flush_fn: Function to flush input buffer
302
+
303
+ Returns:
304
+ Selected option
305
+
306
+ Raises:
307
+ KeyboardInterrupt: If user cancels with Ctrl+C
308
+ """
309
+ console.print() # Add blank line for spacing
310
+ flush_fn()
311
+
312
+ # Small delay to prevent rapid double-press issues
313
+ time.sleep(0.15)
314
+
315
+ # Questionary needs real stdout for its TUI - temporarily restore it
316
+ old_stdout = sys.stdout
317
+ sys.stdout = sys.__stdout__
318
+
319
+ try:
320
+ answer = questionary.select(
321
+ question,
322
+ choices=options,
323
+ style=QUESTIONARY_STYLE,
324
+ use_arrow_keys=True,
325
+ use_shortcuts=True,
326
+ use_jk_keys=True,
327
+ instruction="(Use arrow keys or j/k, Enter to select)",
328
+ ).ask()
329
+
330
+ # questionary returns None on Ctrl+C
331
+ if answer is None:
332
+ raise KeyboardInterrupt()
333
+
334
+ print(f"User answered: {answer}")
335
+ return answer
336
+ finally:
337
+ # Restore captured stdout for executor
338
+ sys.stdout = old_stdout
339
+
340
+
341
+ def validate_batch_questions(questions: List[dict], valid_types: List[str]) -> None:
342
+ """Validate batch questions structure and auto-generate IDs.
343
+
344
+ Modifies questions in-place to add auto-generated IDs where missing.
345
+
346
+ Args:
347
+ questions: List of question dictionaries to validate
348
+ valid_types: List of valid question types
349
+
350
+ Raises:
351
+ ValueError: If validation fails
352
+ """
353
+ seen_ids = set()
354
+
355
+ for i, q in enumerate(questions):
356
+ if not isinstance(q, dict):
357
+ raise validation_error(f"questions[{i}]", str(q), "must be a dictionary")
358
+
359
+ # Check required fields
360
+ if "question" not in q:
361
+ raise validation_error(f"questions[{i}]", str(q), "missing required field 'question'")
362
+
363
+ # Accept both 'type' and 'question_type' for compatibility
364
+ if "type" not in q and "question_type" not in q:
365
+ raise validation_error(f"questions[{i}]", str(q), "missing required field 'type' or 'question_type'")
366
+
367
+ # Normalize to 'type' if 'question_type' was provided
368
+ if "question_type" in q and "type" not in q:
369
+ q["type"] = q["question_type"]
370
+
371
+ # Auto-generate ID if not provided
372
+ if "id" not in q:
373
+ base_id = _generate_id_from_question(q["question"])
374
+ q_id = base_id
375
+ counter = 1
376
+ while q_id in seen_ids:
377
+ q_id = f"{base_id}_{counter}"
378
+ counter += 1
379
+ q["id"] = q_id
380
+ else:
381
+ q_id = q["id"]
382
+ # Check for duplicate explicit IDs
383
+ if q_id in seen_ids:
384
+ raise validation_error(
385
+ f"questions[{i}].id", q_id, "duplicate question ID - all question IDs must be unique"
386
+ )
387
+
388
+ seen_ids.add(q_id)
389
+
390
+ # Validate type
391
+ q_type = q["type"]
392
+ if q_type not in valid_types:
393
+ raise validation_error(f"questions[{i}].type", q_type, f"must be one of {', '.join(valid_types)}")
394
+
395
+ # Validate options for choice type
396
+ if q_type == "choice":
397
+ if "options" not in q or not q["options"] or len(q["options"]) < 2:
398
+ raise validation_error(
399
+ f"questions[{i}].options",
400
+ str(q.get("options")),
401
+ "must provide at least 2 options for choice type questions",
402
+ )
403
+
404
+
405
+ def handle_question_by_type(q_type: str, q_text: str, options: Optional[List[str]], console: Console, flush_fn) -> str:
406
+ """Handle a question based on its type.
407
+
408
+ Args:
409
+ q_type: Question type ("text", "yes_no", or "choice")
410
+ q_text: Question text
411
+ options: Options for choice questions (required if q_type == "choice")
412
+ console: Rich console for output
413
+ flush_fn: Function to flush input buffer
414
+
415
+ Returns:
416
+ User's response
417
+
418
+ Raises:
419
+ ValueError: If invalid question type or missing options
420
+ """
421
+ if q_type == "text":
422
+ return ask_text_question(q_text, console, flush_fn)
423
+ elif q_type == "yes_no":
424
+ return ask_yes_no_question(q_text, console, flush_fn)
425
+ elif q_type == "choice":
426
+ if not options:
427
+ raise ValueError("Options required for choice type questions")
428
+ return ask_choice_question(q_text, options, console, flush_fn)
429
+ else:
430
+ raise ValueError(f"Invalid question type: {q_type}")
tsugite/tools/shell.py ADDED
@@ -0,0 +1,129 @@
1
+ """Shell command execution tools for Tsugite agents."""
2
+
3
+ import shlex
4
+ import subprocess
5
+
6
+ from tsugite.tools import tool
7
+ from tsugite.utils import execute_shell_command
8
+
9
+ DANGEROUS_SHELL_SUBSTRINGS = (
10
+ "rm -rf /",
11
+ "sudo rm",
12
+ "dd if=",
13
+ "mkfs",
14
+ "format",
15
+ "> /dev/",
16
+ )
17
+
18
+
19
+ BLOCKED_SAFE_MODE_COMMANDS = {
20
+ "rm",
21
+ "rmdir",
22
+ "del",
23
+ "format",
24
+ "fdisk",
25
+ "mkfs",
26
+ "sudo",
27
+ "su",
28
+ "chmod",
29
+ "chown",
30
+ "passwd",
31
+ "wget",
32
+ "curl",
33
+ "nc",
34
+ "netcat",
35
+ "ssh",
36
+ "scp",
37
+ "python",
38
+ "perl",
39
+ "ruby",
40
+ "node",
41
+ "bash",
42
+ "sh",
43
+ }
44
+
45
+
46
+ @tool
47
+ def run(command: str, timeout: int = 30, shell: bool = True) -> str:
48
+ """Execute a shell command and return its output.
49
+
50
+ Args:
51
+ command: Shell command to execute
52
+ timeout: Maximum execution time in seconds (default: 30)
53
+ shell: Whether to use shell execution (default: True)
54
+ """
55
+ # Basic safety check for dangerous patterns
56
+ for pattern in DANGEROUS_SHELL_SUBSTRINGS:
57
+ if pattern in command.lower():
58
+ raise ValueError(f"Dangerous command pattern detected: {pattern}")
59
+
60
+ return execute_shell_command(command, timeout=timeout, shell=shell)
61
+
62
+
63
+ @tool
64
+ def run_safe(command: str, timeout: int = 30) -> str:
65
+ """Execute a shell command with additional safety checks.
66
+
67
+ This version disables shell=True for safer execution and has stricter
68
+ command validation.
69
+
70
+ Args:
71
+ command: Command to execute (will be parsed safely)
72
+ timeout: Maximum execution time in seconds (default: 30)
73
+
74
+ Returns:
75
+ Command output
76
+
77
+ Raises:
78
+ RuntimeError: If command execution fails or is deemed unsafe
79
+ """
80
+ try:
81
+ cmd_parts = shlex.split(command)
82
+ if not cmd_parts:
83
+ raise ValueError("Empty command")
84
+
85
+ command_name = cmd_parts[0].lower()
86
+
87
+ if command_name in BLOCKED_SAFE_MODE_COMMANDS:
88
+ raise ValueError(f"Command '{command_name}' is not allowed in safe mode")
89
+
90
+ return run(command, timeout=timeout, shell=False)
91
+
92
+ except Exception as e:
93
+ raise RuntimeError(f"Safe command execution failed: {e}") from e
94
+
95
+
96
+ @tool
97
+ def get_system_info() -> str:
98
+ """Get basic system information.
99
+
100
+ Returns:
101
+ System information including OS, hostname, and current directory
102
+ """
103
+ try:
104
+ info_commands = [
105
+ ("OS", "uname -s"),
106
+ ("Hostname", "hostname"),
107
+ ("Current Directory", "pwd"),
108
+ ("Date", "date"),
109
+ ("Uptime", "uptime"),
110
+ ]
111
+
112
+ results = []
113
+ for label, cmd in info_commands:
114
+ try:
115
+ result = subprocess.run(
116
+ cmd.split(),
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=5,
120
+ check=True,
121
+ )
122
+ results.append(f"{label}: {result.stdout.strip()}")
123
+ except Exception:
124
+ results.append(f"{label}: [unavailable]")
125
+
126
+ return "\n".join(results)
127
+
128
+ except Exception as e:
129
+ return f"Failed to get system info: {e}"