astraagent 2.25.6 → 2.26.0

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.
package/astra/cli.py CHANGED
@@ -2,16 +2,16 @@
2
2
  AstraAgent CLI - Command Line Interface for the Autonomous Agent.
3
3
  """
4
4
 
5
- import asyncio
6
- import argparse
7
- import sys
8
- import os
9
- import json
10
- import shutil
11
- import fnmatch
12
- from pathlib import Path
13
- from datetime import datetime, timedelta
14
- from typing import Optional, List, Dict, Any, Tuple
5
+ import asyncio
6
+ import argparse
7
+ import sys
8
+ import os
9
+ import json
10
+ import shutil
11
+ import fnmatch
12
+ from pathlib import Path
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional, List, Dict, Any, Tuple
15
15
 
16
16
  from astra.core.agent import AstraAgent
17
17
  from astra.core.config import AgentConfig, ExecutionMode, LogLevel
@@ -54,21 +54,21 @@ def create_parser() -> argparse.ArgumentParser:
54
54
  default="autonomous", help="Execution mode")
55
55
  run_parser.add_argument("--model", default=None, help="LLM model to use")
56
56
  run_parser.add_argument("--provider", default="openai", help="LLM provider")
57
- run_parser.add_argument("--max-iterations", type=int, default=50, help="Max iterations")
58
- run_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
59
- run_parser.add_argument("-w", "--workspace", default="./", help="Workspace directory")
60
-
61
- # Start command (friendly alias for run)
62
- start_parser = subparsers.add_parser("start", help="Start the agent quickly")
63
- start_parser.add_argument("goal", nargs="?", help="Optional goal to run")
64
- start_parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode")
65
- start_parser.add_argument("-m", "--mode", choices=["autonomous", "supervised", "dry_run"],
66
- default="autonomous", help="Execution mode")
67
- start_parser.add_argument("--model", default=None, help="LLM model to use")
68
- start_parser.add_argument("--provider", default="openai", help="LLM provider")
69
- start_parser.add_argument("--max-iterations", type=int, default=50, help="Max iterations")
70
- start_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
71
- start_parser.add_argument("-w", "--workspace", default="./", help="Workspace directory")
57
+ run_parser.add_argument("--max-iterations", type=int, default=50, help="Max iterations")
58
+ run_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
59
+ run_parser.add_argument("-w", "--workspace", default="./", help="Workspace directory")
60
+
61
+ # Start command (friendly alias for run)
62
+ start_parser = subparsers.add_parser("start", help="Start the agent quickly")
63
+ start_parser.add_argument("goal", nargs="?", help="Optional goal to run")
64
+ start_parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode")
65
+ start_parser.add_argument("-m", "--mode", choices=["autonomous", "supervised", "dry_run"],
66
+ default="autonomous", help="Execution mode")
67
+ start_parser.add_argument("--model", default=None, help="LLM model to use")
68
+ start_parser.add_argument("--provider", default="openai", help="LLM provider")
69
+ start_parser.add_argument("--max-iterations", type=int, default=50, help="Max iterations")
70
+ start_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
71
+ start_parser.add_argument("-w", "--workspace", default="./", help="Workspace directory")
72
72
 
73
73
  # Config command
74
74
  config_parser = subparsers.add_parser("config", help="Manage configuration")
@@ -116,37 +116,37 @@ def create_parser() -> argparse.ArgumentParser:
116
116
  models_parser.add_argument("provider", help="Provider name (openai, gemini, etc.)")
117
117
  models_parser.add_argument("--info", action="store_true", help="Show provider info")
118
118
 
119
- # Search command - device-wide search
120
- search_parser = subparsers.add_parser("search", help="Search files across the device")
121
- search_parser.add_argument("query", nargs="?", help="Search query or filename (supports wildcards)")
122
- search_parser.add_argument("--root", help="Root directory to search (default: all device roots)")
123
- search_parser.add_argument("--ext", help="Search by extension (e.g. .py)")
124
- search_parser.add_argument("--type", help="Search by type (code, image, document)")
125
- search_parser.add_argument("--recent", action="store_true", help="Search recently modified files")
126
- search_parser.add_argument("--days", type=int, default=7, help="Recent window in days (with --recent)")
127
- search_parser.add_argument("--content", help="Search file contents for text")
128
- search_parser.add_argument("--limit", type=int, default=100, help="Maximum results to show")
129
- search_parser.add_argument("--max-depth", type=int, default=-1, help="Maximum directory depth (-1 for unlimited)")
130
- search_parser.add_argument("--show-hidden", action="store_true", help="Include hidden files/directories")
131
-
132
- # Tasks command - perform file operations
133
- tasks_parser = subparsers.add_parser("tasks", help="Perform file/folder tasks from CLI (single or batch)")
134
- tasks_parser.add_argument("action", nargs="?", help="Action (copy, move, delete, info, mkdir, list, rename)")
135
- tasks_parser.add_argument("src", nargs="?", help="Source path")
136
- tasks_parser.add_argument("dest", nargs="?", help="Destination path")
137
- tasks_parser.add_argument("--overwrite", action="store_true", help="Overwrite destination when supported")
138
- tasks_parser.add_argument("--recursive", action="store_true", help="Recursive operation for delete/list")
139
- tasks_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
140
- tasks_parser.add_argument("--batch", help="JSON array of tasks, e.g. '[{\"action\":\"mkdir\",\"src\":\"C:/tmp\"}]'")
141
- tasks_parser.add_argument("--batch-file", help="Path to JSON file containing task array")
119
+ # Search command - device-wide search
120
+ search_parser = subparsers.add_parser("search", help="Search files across the device")
121
+ search_parser.add_argument("query", nargs="?", help="Search query or filename (supports wildcards)")
122
+ search_parser.add_argument("--root", help="Root directory to search (default: all device roots)")
123
+ search_parser.add_argument("--ext", help="Search by extension (e.g. .py)")
124
+ search_parser.add_argument("--type", help="Search by type (code, image, document)")
125
+ search_parser.add_argument("--recent", action="store_true", help="Search recently modified files")
126
+ search_parser.add_argument("--days", type=int, default=7, help="Recent window in days (with --recent)")
127
+ search_parser.add_argument("--content", help="Search file contents for text")
128
+ search_parser.add_argument("--limit", type=int, default=100, help="Maximum results to show")
129
+ search_parser.add_argument("--max-depth", type=int, default=-1, help="Maximum directory depth (-1 for unlimited)")
130
+ search_parser.add_argument("--show-hidden", action="store_true", help="Include hidden files/directories")
131
+
132
+ # Tasks command - perform file operations
133
+ tasks_parser = subparsers.add_parser("tasks", help="Perform file/folder tasks from CLI (single or batch)")
134
+ tasks_parser.add_argument("action", nargs="?", help="Action (copy, move, delete, info, mkdir, list, rename)")
135
+ tasks_parser.add_argument("src", nargs="?", help="Source path")
136
+ tasks_parser.add_argument("dest", nargs="?", help="Destination path")
137
+ tasks_parser.add_argument("--overwrite", action="store_true", help="Overwrite destination when supported")
138
+ tasks_parser.add_argument("--recursive", action="store_true", help="Recursive operation for delete/list")
139
+ tasks_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
140
+ tasks_parser.add_argument("--batch", help="JSON array of tasks, e.g. '[{\"action\":\"mkdir\",\"src\":\"C:/tmp\"}]'")
141
+ tasks_parser.add_argument("--batch-file", help="Path to JSON file containing task array")
142
142
 
143
143
  # Updater command
144
144
  updater_parser = subparsers.add_parser("update", help="Check and apply updates")
145
- updater_parser.add_argument("--check", action="store_true", help="Check for updates")
146
- updater_parser.add_argument("--npm", action="store_true", help="Update via npm")
147
- updater_parser.add_argument("--pip", action="store_true", help="Update via pip")
148
- updater_parser.add_argument("--github", action="store_true", help="Download from GitHub releases")
149
- updater_parser.add_argument("--auto", action="store_true", help="Check and automatically install latest (npm preferred)")
145
+ updater_parser.add_argument("--check", action="store_true", help="Check for updates")
146
+ updater_parser.add_argument("--npm", action="store_true", help="Update via npm")
147
+ updater_parser.add_argument("--pip", action="store_true", help="Update via pip")
148
+ updater_parser.add_argument("--github", action="store_true", help="Download from GitHub releases")
149
+ updater_parser.add_argument("--auto", action="store_true", help="Check and automatically install latest (npm preferred)")
150
150
 
151
151
  # Version
152
152
  parser.add_argument("--version", action="version", version="AstraAgent 1.0.0")
@@ -196,21 +196,182 @@ def build_config(args) -> AgentConfig:
196
196
  config.max_iterations = args.max_iterations
197
197
 
198
198
  # Default to ERROR level for clean output, DEBUG if verbose
199
- if hasattr(args, 'verbose') and args.verbose:
200
- config.logging.level = LogLevel.DEBUG
201
- elif hasattr(args, 'command') and args.command in ('run', 'start'):
202
- # For run command, only show errors by default
203
- config.logging.level = LogLevel.ERROR
199
+ if hasattr(args, 'verbose') and args.verbose:
200
+ config.logging.level = LogLevel.DEBUG
201
+ elif hasattr(args, 'command') and args.command in ('run', 'start'):
202
+ # For run command, only show errors by default
203
+ config.logging.level = LogLevel.ERROR
204
204
 
205
205
  return config
206
206
 
207
207
 
208
+ def show_interactive_help():
209
+ """Show help for interactive CLI mode."""
210
+ print("\n" + "="*50)
211
+ print("⌨️ ASTRA INTERACTIVE COMMANDS")
212
+ print("="*50)
213
+ print("""
214
+ GENERAL COMMANDS:
215
+ help or /h Show this help
216
+ settings or /s Open interactive settings
217
+ status Show current agent status
218
+ tools List enabled tools
219
+ exit or q Exit the agent
220
+
221
+ SLASH COMMANDS (Direct execution):
222
+ /open <path> Open file/app
223
+ /folder <path> Open folder in explorer
224
+ /ls [path] List directory contents
225
+ /delete <path> Delete file
226
+ /mkdir <path> Create folder
227
+ /info <path> Get file/folder info
228
+
229
+ Any other text will be handled by the AI agent.
230
+ """)
231
+ print("="*50 + "\n")
232
+
233
+
234
+
235
+ def handle_interactive_settings(agent, config):
236
+ """Handle interactive settings menu."""
237
+ from astra.chat import APIKeyManager
238
+ from astra.llm.providers import PROVIDERS
239
+
240
+ while True:
241
+ print("\n[⚙️] AstraAgent Settings & Configuration")
242
+ print("-" * 60)
243
+ print(f"Current Provider: {config.llm.provider}")
244
+ print(f"Current Model: {config.llm.model}")
245
+ print("-" * 60)
246
+
247
+ # List all providers and their status
248
+ print(f"{' Provider':<20} {'Status':<15} {'API KEY'}")
249
+ all_keys = APIKeyManager.get_all_keys()
250
+
251
+ for p_key, p_data in PROVIDERS.items():
252
+ is_current = "--> " if p_key == config.llm.provider else " "
253
+ key_status = all_keys.get(p_key, "NOT SET")
254
+ status_icon = "✅ Ready" if key_status != "NOT SET" or p_data.get("free") else "❌ No Key"
255
+
256
+ print(f"{is_current}{p_key:<16} {status_icon:<15} {key_status}")
257
+
258
+ print("-" * 60)
259
+ print("Commands:")
260
+ print(" use <provider> Switch provider (e.g., 'use openai', 'use gemini')")
261
+ print(" model <name> Set model for current provider")
262
+ print(" key <provider> <key> Set API key (e.g., 'key openai sk-...')")
263
+ print(" view-env View .env file")
264
+ print(" back Return to main menu")
265
+ print("-" * 60)
266
+
267
+ try:
268
+ cmd_input = input("Settings > ").strip()
269
+ except (EOFError, KeyboardInterrupt):
270
+ break
271
+
272
+ if not cmd_input or cmd_input.lower() in ['back', 'exit', 'q']:
273
+ break
274
+
275
+ parts = cmd_input.split(' ')
276
+ cmd = parts[0].lower()
277
+ args = parts[1:]
278
+
279
+ if cmd == 'use':
280
+ if not args:
281
+ print("❌ Usage: use <provider>")
282
+ continue
283
+ new_provider = args[0].lower()
284
+ if new_provider == 'google':
285
+ new_provider = 'gemini'
286
+
287
+ if new_provider not in PROVIDERS:
288
+ print(f"❌ Unknown provider: {new_provider}")
289
+ continue
290
+
291
+ config.llm.provider = new_provider
292
+ # Set default model for this provider if available
293
+ defaults = PROVIDERS[new_provider].get("models", [])
294
+ if defaults:
295
+ config.llm.model = defaults[0]
296
+
297
+ if agent:
298
+ agent.config.llm.provider = new_provider
299
+ agent.config.llm.model = config.llm.model
300
+
301
+ # Update API Key for the new provider
302
+ env_keys = PROVIDERS[new_provider].get("env_keys", [])
303
+ if env_keys:
304
+ env_var = env_keys[0]
305
+ new_key = os.getenv(env_var)
306
+ if new_key:
307
+ agent.config.llm.api_key = new_key
308
+ config.llm.api_key = new_key
309
+ else:
310
+ print(f"⚠️ No API key found for {new_provider} (Expected {env_var})")
311
+ agent.config.llm.api_key = ""
312
+ config.llm.api_key = ""
313
+
314
+ agent.llm = None # Force re-init with new config
315
+
316
+ print(f"✅ Switched to {new_provider} (Model: {config.llm.model})")
317
+ continue
318
+
319
+ if cmd == 'key':
320
+ if len(args) < 2:
321
+ print("❌ Usage: key <provider> <your-api-key>")
322
+ continue
323
+ p_key = args[0].lower()
324
+ if p_key == 'google':
325
+ p_key = 'gemini'
326
+
327
+ if p_key not in PROVIDERS:
328
+ print(f"❌ Unknown provider: {p_key}")
329
+ continue
330
+
331
+ try:
332
+ if APIKeyManager.set_key(p_key, api_key):
333
+ # If we just set the key for the current or new provider
334
+ if p_key == config.llm.provider:
335
+ config.llm.api_key = api_key
336
+ if agent:
337
+ agent.config.llm.api_key = api_key
338
+ agent.llm = None
339
+ print(f"✅ Key updated for {p_key}")
340
+ except Exception as e:
341
+ print(f"❌ Error setting key: {e}")
342
+ continue
343
+
344
+ if cmd == 'model':
345
+ if not args:
346
+ print("❌ Usage: model <model_name>")
347
+ continue
348
+ new_model = args[0]
349
+ config.llm.model = new_model
350
+ if agent:
351
+ agent.config.llm.model = new_model
352
+ agent.llm = None
353
+ print(f"✅ Model set to {new_model}")
354
+ continue
355
+
356
+ if cmd == 'view-env':
357
+ env_file = os.path.join(os.getcwd(), ".env")
358
+ if os.path.exists(env_file):
359
+ print("\n📄 .env file contents:\n")
360
+ with open(env_file, 'r') as ef:
361
+ print(ef.read())
362
+ else:
363
+ print("❌ .env file not found")
364
+ continue
365
+
366
+ print("Unknown settings command. Type 'back' to exit settings.")
367
+
368
+
208
369
  def cli_interactive_session(config: AgentConfig):
209
370
  """Interactive CLI session - text-based."""
210
371
  import asyncio as aio
211
372
 
212
373
  print("\n[*] AstraAgent Interactive Mode")
213
- print("Type your goals/commands. Type 'exit' to quit.\n")
374
+ print("Type your goals/commands. Type 'exit' to quit. Type 'help' for more.\n")
214
375
 
215
376
  agent = AstraAgent(config)
216
377
 
@@ -227,18 +388,40 @@ def cli_interactive_session(config: AgentConfig):
227
388
  if not user_input:
228
389
  continue
229
390
 
230
- if user_input.lower() in ['exit', 'quit', 'q']:
391
+ # Handle shortcuts and commands
392
+ lowered = user_input.lower()
393
+
394
+ if lowered in ['exit', 'quit', 'q']:
231
395
  print("Goodbye!")
232
396
  break
233
397
 
234
- if user_input.lower() == 'status':
398
+ if lowered in ['help', 'h', '?', '/help']:
399
+ show_interactive_help()
400
+ continue
401
+
402
+ if lowered in ['settings', 's', '/settings', '/s', 'config']:
403
+ handle_interactive_settings(agent, config)
404
+ continue
405
+
406
+ if lowered == 'status':
235
407
  try:
236
408
  print(agent.state.get_summary())
237
409
  except:
238
410
  print("No status available")
239
411
  continue
240
412
 
241
- if user_input.lower() == 'tools':
413
+ if lowered in ['/engineer', 'engineer', 'dev', 'continuous']:
414
+ config.max_iterations = 1000
415
+ config.mode = ExecutionMode.AUTONOMOUS
416
+ config.prompt_mode = "engineer"
417
+ # Modifying agent's config in place:
418
+ agent.config.max_iterations = 1000
419
+ agent.config.prompt_mode = "engineer"
420
+ print("\n👷 Engineer Mode ACTIVATED (24/7 autonomous loop, max 1000 steps)")
421
+ print("Type your high-level goal (e.g., 'Refactor the entire backend').")
422
+ continue
423
+
424
+ if lowered == 'tools':
242
425
  try:
243
426
  tools = agent.tools.list_enabled()
244
427
  print(f"Available tools: {', '.join(tools)}")
@@ -246,14 +429,102 @@ def cli_interactive_session(config: AgentConfig):
246
429
  print("Could not list tools")
247
430
  continue
248
431
 
249
- print("\n[...] Working on your request...\n")
432
+ # Handle slash commands (direct filesystem/task operations)
433
+ if user_input.startswith('/'):
434
+ try:
435
+ from astra.tasks import TaskExecutor, format_task_result
436
+ parts = user_input[1:].split(' ', 1)
437
+ cmd = parts[0].lower()
438
+ arg = parts[1].strip() if len(parts) > 1 else ""
439
+
440
+ if cmd == 'open':
441
+ print(format_task_result(TaskExecutor.open_file(arg)))
442
+ continue
443
+ elif cmd == 'folder':
444
+ print(format_task_result(TaskExecutor.open_folder(arg)))
445
+ continue
446
+ elif cmd in ['delete', 'rm']:
447
+ if not arg:
448
+ print("❌ Usage: /delete <path>")
449
+ else:
450
+ confirm = input(f"⚠️ Delete '{arg}'? (y/N): ").strip().lower()
451
+ if confirm == 'y':
452
+ print(format_task_result(TaskExecutor.delete_file(arg)))
453
+ continue
454
+ elif cmd == 'mkdir':
455
+ print(format_task_result(TaskExecutor.create_folder(arg)))
456
+ continue
457
+ elif cmd in ['ls', 'dir', 'list']:
458
+ print(format_task_result(TaskExecutor.list_directory(arg or ".")))
459
+ continue
460
+ elif cmd == 'info':
461
+ print(format_task_result(TaskExecutor.get_file_info(arg)))
462
+ continue
463
+ except Exception as e:
464
+ print(f"❌ Error executing slash command: {e}")
465
+ continue
466
+
467
+ # Warn about missing API key for non-slash commands
468
+ if not config.llm.api_key:
469
+ print("\n⚠️ LOCAL_API_KEY is not set. Autonomous mode will fail.")
470
+ print("Type 'settings' to configure your API key or use slash commands (e.g., /open).\n")
471
+ continue
472
+
473
+ # Enhanced Thinking Animation
474
+ from astra.chat import ThinkingIndicator
475
+
476
+ # Run the agent with animation
250
477
  try:
251
- result = aio.run(agent.run(user_input))
478
+ # We need to run the async generator or function in a loop
479
+ # Since agent.run returns a string summary, we wrap it
480
+ async def run_with_animation():
481
+ async with ThinkingIndicator(f"Working on '{user_input[:20]}...'"):
482
+ return await agent.run(user_input)
483
+
484
+ result = aio.run(run_with_animation())
485
+
252
486
  print(f"\n{result}\n")
487
+
488
+ # Save session to user logs
489
+ from pathlib import Path
490
+ from datetime import datetime
491
+ import json
492
+
493
+ log_dir = Path.home() / ".astra" / "logs" / "agent_sessions"
494
+ log_dir.mkdir(parents=True, exist_ok=True)
495
+
496
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
497
+ log_file = log_dir / f"session_{timestamp}.json"
498
+
499
+ session_data = {
500
+ "goal": user_input,
501
+ "result": result,
502
+ "provider": config.llm.provider,
503
+ "model": config.llm.model,
504
+ "summary": agent.state.get_summary()
505
+ }
506
+
507
+ with open(log_file, "w", encoding="utf-8") as f:
508
+ json.dump(session_data, f, indent=2)
509
+
510
+ except KeyboardInterrupt:
511
+ print("\n🛑 Action stopped by user.")
512
+ # Reset state on manual stop
513
+ agent.state = agent.state.__class__()
514
+ agent.messages = []
515
+ continue
516
+
253
517
  except ValueError as e:
254
518
  print(f"❌ Configuration Error: {e}\n")
519
+ except RuntimeError as e:
520
+ # Handle rate limits specifically if propagated
521
+ err_msg = str(e)
522
+ if "429" in err_msg or "rate limit" in err_msg.lower():
523
+ print(f"\n⚠️ Rate limit hit. Please try again in a moment.\n")
524
+ else:
525
+ print(f"\n❌ Runtime Error: {e}\n")
255
526
  except Exception as e:
256
- print(f"❌ Error: {e}\n")
527
+ print(f"\n❌ Error: {e}\n")
257
528
 
258
529
  # Reset for next goal
259
530
  agent.state = agent.state.__class__()
@@ -301,7 +572,7 @@ async def run_agent(goal: str, config: AgentConfig) -> str:
301
572
  agent.shutdown()
302
573
 
303
574
 
304
- def show_tools(tool_name: Optional[str] = None):
575
+ def show_tools(tool_name: Optional[str] = None):
305
576
  """Show available tools."""
306
577
  from astra.tools.base import create_default_registry
307
578
 
@@ -323,404 +594,404 @@ def show_tools(tool_name: Optional[str] = None):
323
594
  print(f"Tool not found: {tool_name}")
324
595
  else:
325
596
  print("\nAvailable Tools:")
326
- for name in registry.list_enabled():
327
- tool = registry.get(name)
328
- print(f" - {name}: {tool.description[:60]}...")
329
-
330
-
331
- FILE_TYPE_EXTENSIONS: Dict[str, set] = {
332
- "code": {".py", ".js", ".ts", ".java", ".cpp", ".c", ".cs", ".go", ".rs", ".php", ".rb", ".swift", ".kt"},
333
- "image": {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico"},
334
- "document": {".txt", ".md", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".rtf", ".csv"},
335
- "archive": {".zip", ".rar", ".7z", ".tar", ".gz"},
336
- "media": {".mp3", ".wav", ".mp4", ".mkv", ".avi", ".mov"},
337
- }
338
-
339
-
340
- def _format_size(size_bytes: int) -> str:
341
- """Format file size in a human-readable form."""
342
- size = float(size_bytes)
343
- for unit in ["B", "KB", "MB", "GB", "TB"]:
344
- if size < 1024:
345
- return f"{size:.1f}{unit}"
346
- size /= 1024
347
- return f"{size:.1f}PB"
348
-
349
-
350
- def _resolve_search_roots(root: Optional[str]) -> List[Path]:
351
- """Resolve search roots. Defaults to full device roots."""
352
- if root:
353
- root_path = Path(root).resolve()
354
- return [root_path] if root_path.exists() else []
355
-
356
- if os.name == "nt":
357
- roots: List[Path] = []
358
- for drive_letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
359
- drive = Path(f"{drive_letter}:\\")
360
- if drive.exists():
361
- roots.append(drive)
362
- return roots
363
-
364
- return [Path("/")]
365
-
366
-
367
- def _is_hidden(path: Path) -> bool:
368
- """Best-effort hidden file detection."""
369
- if path.name.startswith("."):
370
- return True
371
- if os.name != "nt":
372
- return False
373
- try:
374
- import stat
375
- attrs = path.stat().st_file_attributes
376
- return bool(attrs & stat.FILE_ATTRIBUTE_HIDDEN)
377
- except Exception:
378
- return False
379
-
380
-
381
- def _iter_files(root: Path, max_depth: int = -1, show_hidden: bool = False):
382
- """Iterate files with optional depth limit."""
383
- stack: List[Tuple[Path, int]] = [(root, 0)]
384
-
385
- while stack:
386
- current, depth = stack.pop()
387
- if not current.exists():
388
- continue
389
-
390
- try:
391
- entries = list(current.iterdir())
392
- except (PermissionError, OSError):
393
- continue
394
-
395
- for entry in entries:
396
- if not show_hidden and _is_hidden(entry):
397
- continue
398
-
399
- if entry.is_dir():
400
- if max_depth < 0 or depth < max_depth:
401
- stack.append((entry, depth + 1))
402
- elif entry.is_file():
403
- yield entry
404
-
405
-
406
- def _query_matches(path: Path, query: Optional[str]) -> bool:
407
- """Match filename against substring or wildcard query."""
408
- if not query:
409
- return True
410
- name = path.name
411
- if any(ch in query for ch in "*?[]"):
412
- return fnmatch.fnmatch(name.lower(), query.lower())
413
- return query.lower() in name.lower()
414
-
415
-
416
- def _content_matches(path: Path, content_query: Optional[str]) -> bool:
417
- """Search content in text-like files with safe limits."""
418
- if not content_query:
419
- return True
420
- try:
421
- with open(path, "r", encoding="utf-8", errors="ignore") as f:
422
- chunk = f.read(1024 * 1024)
423
- return content_query.lower() in chunk.lower()
424
- except Exception:
425
- return False
426
-
427
-
428
- def run_device_search(args) -> int:
429
- """Run filesystem-wide search from CLI command arguments."""
430
- if not args.query and not args.ext and not args.type and not args.content and not args.recent:
431
- print("Provide at least one filter: query, --ext, --type, --content, or --recent")
432
- return 1
433
-
434
- roots = _resolve_search_roots(args.root)
435
- if not roots:
436
- print(f"No valid search root found for: {args.root}")
437
- return 1
438
-
439
- ext_filter = args.ext.lower() if args.ext else None
440
- if ext_filter and not ext_filter.startswith("."):
441
- ext_filter = f".{ext_filter}"
442
-
443
- type_filter = args.type.lower() if args.type else None
444
- if type_filter and type_filter not in FILE_TYPE_EXTENSIONS:
445
- valid = ", ".join(sorted(FILE_TYPE_EXTENSIONS.keys()))
446
- print(f"Invalid --type '{args.type}'. Valid types: {valid}")
447
- return 1
448
-
449
- recent_cutoff = None
450
- if args.recent:
451
- recent_cutoff = datetime.now() - timedelta(days=max(1, args.days))
452
-
453
- results: List[Path] = []
454
- scanned_files = 0
455
-
456
- for root in roots:
457
- for file_path in _iter_files(root, max_depth=args.max_depth, show_hidden=args.show_hidden):
458
- scanned_files += 1
459
-
460
- if not _query_matches(file_path, args.query):
461
- continue
462
- if ext_filter and file_path.suffix.lower() != ext_filter:
463
- continue
464
- if type_filter and file_path.suffix.lower() not in FILE_TYPE_EXTENSIONS[type_filter]:
465
- continue
466
- if recent_cutoff:
467
- try:
468
- if datetime.fromtimestamp(file_path.stat().st_mtime) < recent_cutoff:
469
- continue
470
- except (PermissionError, OSError):
471
- continue
472
- if not _content_matches(file_path, args.content):
473
- continue
474
-
475
- results.append(file_path)
476
- if len(results) >= args.limit:
477
- break
478
-
479
- if len(results) >= args.limit:
480
- break
481
-
482
- if not results:
483
- print(f"No matching files found. Scanned {scanned_files} files.")
484
- return 0
485
-
486
- print(f"\nFound {len(results)} file(s) (scanned {scanned_files} files):\n")
487
- for idx, file_path in enumerate(results, start=1):
488
- try:
489
- stat = file_path.stat()
490
- modified = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
491
- size = _format_size(stat.st_size)
492
- print(f"{idx:3}. {file_path} [{size}, {modified}]")
493
- except (PermissionError, OSError):
494
- print(f"{idx:3}. {file_path}")
495
-
496
- if len(results) == args.limit:
497
- print(f"\nResult limit reached ({args.limit}). Increase with --limit.")
498
-
499
- return 0
500
-
501
-
502
- def _execute_task(action: str, src: Optional[str], dest: Optional[str], overwrite: bool,
503
- recursive: bool, assume_yes: bool) -> Tuple[bool, str]:
504
- """Execute one filesystem task."""
505
- action = action.lower().strip()
506
-
507
- try:
508
- if action == "mkdir":
509
- if not src:
510
- return False, "mkdir requires src path"
511
- Path(src).mkdir(parents=True, exist_ok=True)
512
- return True, f"Created directory: {Path(src).resolve()}"
513
-
514
- if action == "info":
515
- if not src:
516
- return False, "info requires src path"
517
- target = Path(src).resolve()
518
- if not target.exists():
519
- return False, f"Path not found: {target}"
520
- stat = target.stat()
521
- kind = "directory" if target.is_dir() else "file"
522
- details = (
523
- f"Path: {target}\n"
524
- f"Type: {kind}\n"
525
- f"Size: {_format_size(stat.st_size)}\n"
526
- f"Modified: {datetime.fromtimestamp(stat.st_mtime)}"
527
- )
528
- return True, details
529
-
530
- if action == "list":
531
- if not src:
532
- return False, "list requires src directory"
533
- target = Path(src).resolve()
534
- if not target.is_dir():
535
- return False, f"Not a directory: {target}"
536
- entries = sorted(target.rglob("*") if recursive else target.iterdir(), key=lambda p: p.name.lower())
537
- entries = [p for p in entries if p != target]
538
- lines = [f"Listing: {target}", f"Items: {len(entries)}"]
539
- for p in entries[:500]:
540
- suffix = "/" if p.is_dir() else ""
541
- lines.append(f"- {p}{suffix}")
542
- if len(entries) > 500:
543
- lines.append(f"... and {len(entries) - 500} more")
544
- return True, "\n".join(lines)
545
-
546
- if action == "copy":
547
- if not src or not dest:
548
- return False, "copy requires src and dest"
549
- src_path = Path(src).resolve()
550
- dest_path = Path(dest).resolve()
551
- if not src_path.exists():
552
- return False, f"Source not found: {src_path}"
553
- if dest_path.exists() and not overwrite:
554
- return False, f"Destination exists: {dest_path} (use --overwrite)"
555
- dest_path.parent.mkdir(parents=True, exist_ok=True)
556
- if src_path.is_dir():
557
- if dest_path.exists():
558
- shutil.rmtree(dest_path)
559
- shutil.copytree(src_path, dest_path)
560
- else:
561
- shutil.copy2(src_path, dest_path)
562
- return True, f"Copied {src_path} -> {dest_path}"
563
-
564
- if action == "move":
565
- if not src or not dest:
566
- return False, "move requires src and dest"
567
- src_path = Path(src).resolve()
568
- dest_path = Path(dest).resolve()
569
- if not src_path.exists():
570
- return False, f"Source not found: {src_path}"
571
- if dest_path.exists() and not overwrite:
572
- return False, f"Destination exists: {dest_path} (use --overwrite)"
573
- dest_path.parent.mkdir(parents=True, exist_ok=True)
574
- shutil.move(str(src_path), str(dest_path))
575
- return True, f"Moved {src_path} -> {dest_path}"
576
-
577
- if action == "rename":
578
- if not src or not dest:
579
- return False, "rename requires src and dest"
580
- src_path = Path(src).resolve()
581
- dest_path = src_path.with_name(dest)
582
- if not src_path.exists():
583
- return False, f"Source not found: {src_path}"
584
- if dest_path.exists() and not overwrite:
585
- return False, f"Destination exists: {dest_path} (use --overwrite)"
586
- src_path.rename(dest_path)
587
- return True, f"Renamed {src_path.name} -> {dest_path.name}"
588
-
589
- if action == "delete":
590
- if not src:
591
- return False, "delete requires src path"
592
- target = Path(src).resolve()
593
- if not target.exists():
594
- return False, f"Path not found: {target}"
595
- if not assume_yes:
596
- confirm = input(f"Delete '{target}'? (y/N): ").strip().lower()
597
- if confirm != "y":
598
- return False, "Delete cancelled"
599
- if target.is_dir():
600
- if recursive:
601
- shutil.rmtree(target)
602
- else:
603
- target.rmdir()
604
- else:
605
- target.unlink()
606
- return True, f"Deleted: {target}"
607
-
608
- return False, f"Unknown action: {action}"
609
-
610
- except Exception as e:
611
- return False, str(e)
612
-
613
-
614
- def _load_batch_tasks(batch_json: Optional[str], batch_file: Optional[str]) -> List[Dict[str, Any]]:
615
- """Load task list from JSON string or file."""
616
- if batch_json:
617
- payload = json.loads(batch_json)
618
- elif batch_file:
619
- payload = json.loads(Path(batch_file).read_text(encoding="utf-8-sig"))
620
- else:
621
- return []
622
-
623
- if not isinstance(payload, list):
624
- raise ValueError("Batch payload must be a JSON array")
625
- return payload
626
-
627
-
628
- def run_tasks_command(args) -> int:
629
- """Run single or batch file tasks."""
630
- tasks: List[Dict[str, Any]] = []
631
-
632
- if args.batch or args.batch_file:
633
- try:
634
- tasks = _load_batch_tasks(args.batch, args.batch_file)
635
- except Exception as e:
636
- print(f"Failed to load batch tasks: {e}")
637
- return 1
638
- else:
639
- if not args.action:
640
- print("tasks requires action, or use --batch/--batch-file")
641
- return 1
642
- tasks = [{
643
- "action": args.action,
644
- "src": args.src,
645
- "dest": args.dest,
646
- "overwrite": args.overwrite,
647
- "recursive": args.recursive,
648
- "yes": args.yes,
649
- }]
650
-
651
- success_count = 0
652
- for idx, task in enumerate(tasks, start=1):
653
- action = str(task.get("action", "")).strip()
654
- src = task.get("src")
655
- dest = task.get("dest")
656
- overwrite = bool(task.get("overwrite", args.overwrite))
657
- recursive = bool(task.get("recursive", args.recursive))
658
- assume_yes = bool(task.get("yes", args.yes))
659
-
660
- ok, message = _execute_task(action, src, dest, overwrite, recursive, assume_yes)
661
- status = "OK" if ok else "FAIL"
662
- print(f"[{idx}/{len(tasks)}] {status} {action}: {message}")
663
- if ok:
664
- success_count += 1
665
-
666
- print(f"\nTask summary: {success_count}/{len(tasks)} succeeded")
667
- return 0 if success_count == len(tasks) else 1
668
-
669
-
670
- def run_update_command(args) -> int:
671
- """Check and/or install updates using updater module."""
672
- def _safe_print(text: str):
673
- try:
674
- print(text)
675
- except UnicodeEncodeError:
676
- print(text.encode("ascii", "replace").decode("ascii"))
677
-
678
- try:
679
- from astra.updater import UpdateManager, format_update_info
680
- except Exception as e:
681
- print(f"Update module unavailable: {e}")
682
- return 1
683
-
684
- manager = UpdateManager()
685
-
686
- # Default behavior: check for updates if no install option chosen.
687
- if args.auto or args.check or not any([args.npm, args.pip, args.github, args.auto]):
688
- info = manager.check_for_updates(force=True)
689
- _safe_print(format_update_info(info))
690
-
691
- if not args.auto and not any([args.npm, args.pip, args.github]):
692
- return 0
693
-
694
- # Auto mode only proceeds when update exists
695
- if args.auto and not info.get("update_available"):
696
- return 0
697
-
698
- source = None
699
- if args.npm:
700
- source = "npm"
701
- elif args.pip:
702
- source = "pip"
703
- elif args.github:
704
- source = "github"
705
- elif args.auto:
706
- # Prefer npm when available, fallback to pip.
707
- source = "npm" if manager.get_version_info().get("npm_available") else "pip"
708
-
709
- if not source:
710
- return 0
711
-
712
- result = manager.download_update(source=source)
713
- if result.get("success"):
714
- _safe_print(result.get("message", "Update completed"))
715
- return 0
716
-
717
- _safe_print(result.get("message", "Update failed"))
718
- if result.get("error"):
719
- _safe_print(result["error"])
720
- return 1
721
-
722
-
723
- def main():
597
+ for name in registry.list_enabled():
598
+ tool = registry.get(name)
599
+ print(f" - {name}: {tool.description[:60]}...")
600
+
601
+
602
+ FILE_TYPE_EXTENSIONS: Dict[str, set] = {
603
+ "code": {".py", ".js", ".ts", ".java", ".cpp", ".c", ".cs", ".go", ".rs", ".php", ".rb", ".swift", ".kt"},
604
+ "image": {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico"},
605
+ "document": {".txt", ".md", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".rtf", ".csv"},
606
+ "archive": {".zip", ".rar", ".7z", ".tar", ".gz"},
607
+ "media": {".mp3", ".wav", ".mp4", ".mkv", ".avi", ".mov"},
608
+ }
609
+
610
+
611
+ def _format_size(size_bytes: int) -> str:
612
+ """Format file size in a human-readable form."""
613
+ size = float(size_bytes)
614
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
615
+ if size < 1024:
616
+ return f"{size:.1f}{unit}"
617
+ size /= 1024
618
+ return f"{size:.1f}PB"
619
+
620
+
621
+ def _resolve_search_roots(root: Optional[str]) -> List[Path]:
622
+ """Resolve search roots. Defaults to full device roots."""
623
+ if root:
624
+ root_path = Path(root).resolve()
625
+ return [root_path] if root_path.exists() else []
626
+
627
+ if os.name == "nt":
628
+ roots: List[Path] = []
629
+ for drive_letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
630
+ drive = Path(f"{drive_letter}:\\")
631
+ if drive.exists():
632
+ roots.append(drive)
633
+ return roots
634
+
635
+ return [Path("/")]
636
+
637
+
638
+ def _is_hidden(path: Path) -> bool:
639
+ """Best-effort hidden file detection."""
640
+ if path.name.startswith("."):
641
+ return True
642
+ if os.name != "nt":
643
+ return False
644
+ try:
645
+ import stat
646
+ attrs = path.stat().st_file_attributes
647
+ return bool(attrs & stat.FILE_ATTRIBUTE_HIDDEN)
648
+ except Exception:
649
+ return False
650
+
651
+
652
+ def _iter_files(root: Path, max_depth: int = -1, show_hidden: bool = False):
653
+ """Iterate files with optional depth limit."""
654
+ stack: List[Tuple[Path, int]] = [(root, 0)]
655
+
656
+ while stack:
657
+ current, depth = stack.pop()
658
+ if not current.exists():
659
+ continue
660
+
661
+ try:
662
+ entries = list(current.iterdir())
663
+ except (PermissionError, OSError):
664
+ continue
665
+
666
+ for entry in entries:
667
+ if not show_hidden and _is_hidden(entry):
668
+ continue
669
+
670
+ if entry.is_dir():
671
+ if max_depth < 0 or depth < max_depth:
672
+ stack.append((entry, depth + 1))
673
+ elif entry.is_file():
674
+ yield entry
675
+
676
+
677
+ def _query_matches(path: Path, query: Optional[str]) -> bool:
678
+ """Match filename against substring or wildcard query."""
679
+ if not query:
680
+ return True
681
+ name = path.name
682
+ if any(ch in query for ch in "*?[]"):
683
+ return fnmatch.fnmatch(name.lower(), query.lower())
684
+ return query.lower() in name.lower()
685
+
686
+
687
+ def _content_matches(path: Path, content_query: Optional[str]) -> bool:
688
+ """Search content in text-like files with safe limits."""
689
+ if not content_query:
690
+ return True
691
+ try:
692
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
693
+ chunk = f.read(1024 * 1024)
694
+ return content_query.lower() in chunk.lower()
695
+ except Exception:
696
+ return False
697
+
698
+
699
+ def run_device_search(args) -> int:
700
+ """Run filesystem-wide search from CLI command arguments."""
701
+ if not args.query and not args.ext and not args.type and not args.content and not args.recent:
702
+ print("Provide at least one filter: query, --ext, --type, --content, or --recent")
703
+ return 1
704
+
705
+ roots = _resolve_search_roots(args.root)
706
+ if not roots:
707
+ print(f"No valid search root found for: {args.root}")
708
+ return 1
709
+
710
+ ext_filter = args.ext.lower() if args.ext else None
711
+ if ext_filter and not ext_filter.startswith("."):
712
+ ext_filter = f".{ext_filter}"
713
+
714
+ type_filter = args.type.lower() if args.type else None
715
+ if type_filter and type_filter not in FILE_TYPE_EXTENSIONS:
716
+ valid = ", ".join(sorted(FILE_TYPE_EXTENSIONS.keys()))
717
+ print(f"Invalid --type '{args.type}'. Valid types: {valid}")
718
+ return 1
719
+
720
+ recent_cutoff = None
721
+ if args.recent:
722
+ recent_cutoff = datetime.now() - timedelta(days=max(1, args.days))
723
+
724
+ results: List[Path] = []
725
+ scanned_files = 0
726
+
727
+ for root in roots:
728
+ for file_path in _iter_files(root, max_depth=args.max_depth, show_hidden=args.show_hidden):
729
+ scanned_files += 1
730
+
731
+ if not _query_matches(file_path, args.query):
732
+ continue
733
+ if ext_filter and file_path.suffix.lower() != ext_filter:
734
+ continue
735
+ if type_filter and file_path.suffix.lower() not in FILE_TYPE_EXTENSIONS[type_filter]:
736
+ continue
737
+ if recent_cutoff:
738
+ try:
739
+ if datetime.fromtimestamp(file_path.stat().st_mtime) < recent_cutoff:
740
+ continue
741
+ except (PermissionError, OSError):
742
+ continue
743
+ if not _content_matches(file_path, args.content):
744
+ continue
745
+
746
+ results.append(file_path)
747
+ if len(results) >= args.limit:
748
+ break
749
+
750
+ if len(results) >= args.limit:
751
+ break
752
+
753
+ if not results:
754
+ print(f"No matching files found. Scanned {scanned_files} files.")
755
+ return 0
756
+
757
+ print(f"\nFound {len(results)} file(s) (scanned {scanned_files} files):\n")
758
+ for idx, file_path in enumerate(results, start=1):
759
+ try:
760
+ stat = file_path.stat()
761
+ modified = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
762
+ size = _format_size(stat.st_size)
763
+ print(f"{idx:3}. {file_path} [{size}, {modified}]")
764
+ except (PermissionError, OSError):
765
+ print(f"{idx:3}. {file_path}")
766
+
767
+ if len(results) == args.limit:
768
+ print(f"\nResult limit reached ({args.limit}). Increase with --limit.")
769
+
770
+ return 0
771
+
772
+
773
+ def _execute_task(action: str, src: Optional[str], dest: Optional[str], overwrite: bool,
774
+ recursive: bool, assume_yes: bool) -> Tuple[bool, str]:
775
+ """Execute one filesystem task."""
776
+ action = action.lower().strip()
777
+
778
+ try:
779
+ if action == "mkdir":
780
+ if not src:
781
+ return False, "mkdir requires src path"
782
+ Path(src).mkdir(parents=True, exist_ok=True)
783
+ return True, f"Created directory: {Path(src).resolve()}"
784
+
785
+ if action == "info":
786
+ if not src:
787
+ return False, "info requires src path"
788
+ target = Path(src).resolve()
789
+ if not target.exists():
790
+ return False, f"Path not found: {target}"
791
+ stat = target.stat()
792
+ kind = "directory" if target.is_dir() else "file"
793
+ details = (
794
+ f"Path: {target}\n"
795
+ f"Type: {kind}\n"
796
+ f"Size: {_format_size(stat.st_size)}\n"
797
+ f"Modified: {datetime.fromtimestamp(stat.st_mtime)}"
798
+ )
799
+ return True, details
800
+
801
+ if action == "list":
802
+ if not src:
803
+ return False, "list requires src directory"
804
+ target = Path(src).resolve()
805
+ if not target.is_dir():
806
+ return False, f"Not a directory: {target}"
807
+ entries = sorted(target.rglob("*") if recursive else target.iterdir(), key=lambda p: p.name.lower())
808
+ entries = [p for p in entries if p != target]
809
+ lines = [f"Listing: {target}", f"Items: {len(entries)}"]
810
+ for p in entries[:500]:
811
+ suffix = "/" if p.is_dir() else ""
812
+ lines.append(f"- {p}{suffix}")
813
+ if len(entries) > 500:
814
+ lines.append(f"... and {len(entries) - 500} more")
815
+ return True, "\n".join(lines)
816
+
817
+ if action == "copy":
818
+ if not src or not dest:
819
+ return False, "copy requires src and dest"
820
+ src_path = Path(src).resolve()
821
+ dest_path = Path(dest).resolve()
822
+ if not src_path.exists():
823
+ return False, f"Source not found: {src_path}"
824
+ if dest_path.exists() and not overwrite:
825
+ return False, f"Destination exists: {dest_path} (use --overwrite)"
826
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
827
+ if src_path.is_dir():
828
+ if dest_path.exists():
829
+ shutil.rmtree(dest_path)
830
+ shutil.copytree(src_path, dest_path)
831
+ else:
832
+ shutil.copy2(src_path, dest_path)
833
+ return True, f"Copied {src_path} -> {dest_path}"
834
+
835
+ if action == "move":
836
+ if not src or not dest:
837
+ return False, "move requires src and dest"
838
+ src_path = Path(src).resolve()
839
+ dest_path = Path(dest).resolve()
840
+ if not src_path.exists():
841
+ return False, f"Source not found: {src_path}"
842
+ if dest_path.exists() and not overwrite:
843
+ return False, f"Destination exists: {dest_path} (use --overwrite)"
844
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
845
+ shutil.move(str(src_path), str(dest_path))
846
+ return True, f"Moved {src_path} -> {dest_path}"
847
+
848
+ if action == "rename":
849
+ if not src or not dest:
850
+ return False, "rename requires src and dest"
851
+ src_path = Path(src).resolve()
852
+ dest_path = src_path.with_name(dest)
853
+ if not src_path.exists():
854
+ return False, f"Source not found: {src_path}"
855
+ if dest_path.exists() and not overwrite:
856
+ return False, f"Destination exists: {dest_path} (use --overwrite)"
857
+ src_path.rename(dest_path)
858
+ return True, f"Renamed {src_path.name} -> {dest_path.name}"
859
+
860
+ if action == "delete":
861
+ if not src:
862
+ return False, "delete requires src path"
863
+ target = Path(src).resolve()
864
+ if not target.exists():
865
+ return False, f"Path not found: {target}"
866
+ if not assume_yes:
867
+ confirm = input(f"Delete '{target}'? (y/N): ").strip().lower()
868
+ if confirm != "y":
869
+ return False, "Delete cancelled"
870
+ if target.is_dir():
871
+ if recursive:
872
+ shutil.rmtree(target)
873
+ else:
874
+ target.rmdir()
875
+ else:
876
+ target.unlink()
877
+ return True, f"Deleted: {target}"
878
+
879
+ return False, f"Unknown action: {action}"
880
+
881
+ except Exception as e:
882
+ return False, str(e)
883
+
884
+
885
+ def _load_batch_tasks(batch_json: Optional[str], batch_file: Optional[str]) -> List[Dict[str, Any]]:
886
+ """Load task list from JSON string or file."""
887
+ if batch_json:
888
+ payload = json.loads(batch_json)
889
+ elif batch_file:
890
+ payload = json.loads(Path(batch_file).read_text(encoding="utf-8-sig"))
891
+ else:
892
+ return []
893
+
894
+ if not isinstance(payload, list):
895
+ raise ValueError("Batch payload must be a JSON array")
896
+ return payload
897
+
898
+
899
+ def run_tasks_command(args) -> int:
900
+ """Run single or batch file tasks."""
901
+ tasks: List[Dict[str, Any]] = []
902
+
903
+ if args.batch or args.batch_file:
904
+ try:
905
+ tasks = _load_batch_tasks(args.batch, args.batch_file)
906
+ except Exception as e:
907
+ print(f"Failed to load batch tasks: {e}")
908
+ return 1
909
+ else:
910
+ if not args.action:
911
+ print("tasks requires action, or use --batch/--batch-file")
912
+ return 1
913
+ tasks = [{
914
+ "action": args.action,
915
+ "src": args.src,
916
+ "dest": args.dest,
917
+ "overwrite": args.overwrite,
918
+ "recursive": args.recursive,
919
+ "yes": args.yes,
920
+ }]
921
+
922
+ success_count = 0
923
+ for idx, task in enumerate(tasks, start=1):
924
+ action = str(task.get("action", "")).strip()
925
+ src = task.get("src")
926
+ dest = task.get("dest")
927
+ overwrite = bool(task.get("overwrite", args.overwrite))
928
+ recursive = bool(task.get("recursive", args.recursive))
929
+ assume_yes = bool(task.get("yes", args.yes))
930
+
931
+ ok, message = _execute_task(action, src, dest, overwrite, recursive, assume_yes)
932
+ status = "OK" if ok else "FAIL"
933
+ print(f"[{idx}/{len(tasks)}] {status} {action}: {message}")
934
+ if ok:
935
+ success_count += 1
936
+
937
+ print(f"\nTask summary: {success_count}/{len(tasks)} succeeded")
938
+ return 0 if success_count == len(tasks) else 1
939
+
940
+
941
+ def run_update_command(args) -> int:
942
+ """Check and/or install updates using updater module."""
943
+ def _safe_print(text: str):
944
+ try:
945
+ print(text)
946
+ except UnicodeEncodeError:
947
+ print(text.encode("ascii", "replace").decode("ascii"))
948
+
949
+ try:
950
+ from astra.updater import UpdateManager, format_update_info
951
+ except Exception as e:
952
+ print(f"Update module unavailable: {e}")
953
+ return 1
954
+
955
+ manager = UpdateManager()
956
+
957
+ # Default behavior: check for updates if no install option chosen.
958
+ if args.auto or args.check or not any([args.npm, args.pip, args.github, args.auto]):
959
+ info = manager.check_for_updates(force=True)
960
+ _safe_print(format_update_info(info))
961
+
962
+ if not args.auto and not any([args.npm, args.pip, args.github]):
963
+ return 0
964
+
965
+ # Auto mode only proceeds when update exists
966
+ if args.auto and not info.get("update_available"):
967
+ return 0
968
+
969
+ source = None
970
+ if args.npm:
971
+ source = "npm"
972
+ elif args.pip:
973
+ source = "pip"
974
+ elif args.github:
975
+ source = "github"
976
+ elif args.auto:
977
+ # Prefer npm when available, fallback to pip.
978
+ source = "npm" if manager.get_version_info().get("npm_available") else "pip"
979
+
980
+ if not source:
981
+ return 0
982
+
983
+ result = manager.download_update(source=source)
984
+ if result.get("success"):
985
+ _safe_print(result.get("message", "Update completed"))
986
+ return 0
987
+
988
+ _safe_print(result.get("message", "Update failed"))
989
+ if result.get("error"):
990
+ _safe_print(result["error"])
991
+ return 1
992
+
993
+
994
+ def main():
724
995
  """Main entry point."""
725
996
  parser = create_parser()
726
997
  args = parser.parse_args()
@@ -730,18 +1001,18 @@ def main():
730
1001
  parser.print_help()
731
1002
  return
732
1003
 
733
- if args.command in ("run", "start"):
734
- print_banner()
735
-
736
- if args.interactive or not getattr(args, "goal", None):
737
- config = build_config(args)
738
- cli_interactive_session(config)
739
- else:
740
- config = build_config(args)
741
- result = asyncio.run(run_agent(args.goal, config))
742
- # Only show result if it's not empty and not a verbose success message
743
- if result and not result.startswith("==="):
744
- print(result)
1004
+ if args.command in ("run", "start"):
1005
+ print_banner()
1006
+
1007
+ if args.interactive or not getattr(args, "goal", None):
1008
+ config = build_config(args)
1009
+ cli_interactive_session(config)
1010
+ else:
1011
+ config = build_config(args)
1012
+ result = asyncio.run(run_agent(args.goal, config))
1013
+ # Only show result if it's not empty and not a verbose success message
1014
+ if result and not result.startswith("==="):
1015
+ print(result)
745
1016
 
746
1017
  elif args.command == "tools":
747
1018
  show_tools(args.info if hasattr(args, 'info') else None)
@@ -758,44 +1029,44 @@ def main():
758
1029
  config = AgentConfig.load(args.load)
759
1030
  print(f"Config loaded from {args.load}")
760
1031
 
761
- elif args.command == "memory":
762
- from astra.core.memory import MemoryManager
763
- memory = MemoryManager(persistence_path="./.astra_memory")
764
-
765
- if args.list:
766
- if hasattr(memory, "get_recent"):
767
- recent = memory.get_recent(10)
768
- else:
769
- recent = memory.get_recent_conversations(10)
770
- if recent:
771
- for item in recent:
772
- print(f"[{item.memory_type}] {item.content[:100]}...")
773
- else:
774
- print("No memories found")
775
- elif args.search:
776
- results = memory.search(query=args.search)
777
- for item in results:
778
- print(f"[{item.id}] {item.content[:100]}...")
779
-
780
- elif args.command == "search":
781
- exit_code = run_device_search(args)
782
- if exit_code != 0:
783
- sys.exit(exit_code)
784
-
785
- elif args.command == "tasks":
786
- exit_code = run_tasks_command(args)
787
- if exit_code != 0:
788
- sys.exit(exit_code)
789
-
790
- elif args.command == "update":
791
- exit_code = run_update_command(args)
792
- if exit_code != 0:
793
- sys.exit(exit_code)
794
-
795
- elif args.command == "chat":
796
- # Interactive chat mode with multiple providers
797
- from astra.chat import start_chat_mode
798
- from astra.llm import PROVIDERS
1032
+ elif args.command == "memory":
1033
+ from astra.core.memory import MemoryManager
1034
+ memory = MemoryManager(persistence_path="./.astra_memory")
1035
+
1036
+ if args.list:
1037
+ if hasattr(memory, "get_recent"):
1038
+ recent = memory.get_recent(10)
1039
+ else:
1040
+ recent = memory.get_recent_conversations(10)
1041
+ if recent:
1042
+ for item in recent:
1043
+ print(f"[{item.memory_type}] {item.content[:100]}...")
1044
+ else:
1045
+ print("No memories found")
1046
+ elif args.search:
1047
+ results = memory.search(query=args.search)
1048
+ for item in results:
1049
+ print(f"[{item.id}] {item.content[:100]}...")
1050
+
1051
+ elif args.command == "search":
1052
+ exit_code = run_device_search(args)
1053
+ if exit_code != 0:
1054
+ sys.exit(exit_code)
1055
+
1056
+ elif args.command == "tasks":
1057
+ exit_code = run_tasks_command(args)
1058
+ if exit_code != 0:
1059
+ sys.exit(exit_code)
1060
+
1061
+ elif args.command == "update":
1062
+ exit_code = run_update_command(args)
1063
+ if exit_code != 0:
1064
+ sys.exit(exit_code)
1065
+
1066
+ elif args.command == "chat":
1067
+ # Interactive chat mode with multiple providers
1068
+ from astra.chat import start_chat_mode
1069
+ from astra.llm import PROVIDERS
799
1070
 
800
1071
  # Show available providers if requested
801
1072
  if args.list_providers: