astraagent 2.25.6

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 (63) hide show
  1. package/.env.template +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +333 -0
  4. package/astra/__init__.py +15 -0
  5. package/astra/__pycache__/__init__.cpython-314.pyc +0 -0
  6. package/astra/__pycache__/chat.cpython-314.pyc +0 -0
  7. package/astra/__pycache__/cli.cpython-314.pyc +0 -0
  8. package/astra/__pycache__/prompts.cpython-314.pyc +0 -0
  9. package/astra/__pycache__/updater.cpython-314.pyc +0 -0
  10. package/astra/chat.py +763 -0
  11. package/astra/cli.py +913 -0
  12. package/astra/core/__init__.py +8 -0
  13. package/astra/core/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/astra/core/__pycache__/agent.cpython-314.pyc +0 -0
  15. package/astra/core/__pycache__/config.cpython-314.pyc +0 -0
  16. package/astra/core/__pycache__/memory.cpython-314.pyc +0 -0
  17. package/astra/core/__pycache__/reasoning.cpython-314.pyc +0 -0
  18. package/astra/core/__pycache__/state.cpython-314.pyc +0 -0
  19. package/astra/core/agent.py +515 -0
  20. package/astra/core/config.py +247 -0
  21. package/astra/core/memory.py +782 -0
  22. package/astra/core/reasoning.py +423 -0
  23. package/astra/core/state.py +366 -0
  24. package/astra/core/voice.py +144 -0
  25. package/astra/llm/__init__.py +32 -0
  26. package/astra/llm/__pycache__/__init__.cpython-314.pyc +0 -0
  27. package/astra/llm/__pycache__/providers.cpython-314.pyc +0 -0
  28. package/astra/llm/providers.py +530 -0
  29. package/astra/planning/__init__.py +117 -0
  30. package/astra/prompts.py +289 -0
  31. package/astra/reflection/__init__.py +181 -0
  32. package/astra/search.py +469 -0
  33. package/astra/tasks.py +466 -0
  34. package/astra/tools/__init__.py +17 -0
  35. package/astra/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  36. package/astra/tools/__pycache__/advanced.cpython-314.pyc +0 -0
  37. package/astra/tools/__pycache__/base.cpython-314.pyc +0 -0
  38. package/astra/tools/__pycache__/browser.cpython-314.pyc +0 -0
  39. package/astra/tools/__pycache__/file.cpython-314.pyc +0 -0
  40. package/astra/tools/__pycache__/git.cpython-314.pyc +0 -0
  41. package/astra/tools/__pycache__/memory_tool.cpython-314.pyc +0 -0
  42. package/astra/tools/__pycache__/python.cpython-314.pyc +0 -0
  43. package/astra/tools/__pycache__/shell.cpython-314.pyc +0 -0
  44. package/astra/tools/__pycache__/web.cpython-314.pyc +0 -0
  45. package/astra/tools/__pycache__/windows.cpython-314.pyc +0 -0
  46. package/astra/tools/advanced.py +251 -0
  47. package/astra/tools/base.py +344 -0
  48. package/astra/tools/browser.py +93 -0
  49. package/astra/tools/file.py +476 -0
  50. package/astra/tools/git.py +74 -0
  51. package/astra/tools/memory_tool.py +89 -0
  52. package/astra/tools/python.py +238 -0
  53. package/astra/tools/shell.py +183 -0
  54. package/astra/tools/web.py +804 -0
  55. package/astra/tools/windows.py +542 -0
  56. package/astra/updater.py +450 -0
  57. package/astra/utils/__init__.py +230 -0
  58. package/bin/astraagent.js +73 -0
  59. package/bin/postinstall.js +25 -0
  60. package/config.json.template +52 -0
  61. package/main.py +16 -0
  62. package/package.json +51 -0
  63. package/pyproject.toml +72 -0
package/astra/cli.py ADDED
@@ -0,0 +1,913 @@
1
+ """
2
+ AstraAgent CLI - Command Line Interface for the Autonomous Agent.
3
+ """
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
15
+
16
+ from astra.core.agent import AstraAgent
17
+ from astra.core.config import AgentConfig, ExecutionMode, LogLevel
18
+
19
+
20
+ BANNER = r"""
21
+ ___ __ ___ __
22
+ / | _____ / /__________ _ / | ____ ____ ____ / /_
23
+ / /| | / ___/ / __/ ___/ __ `// /| | / __ `/ _ \/ __ \/ __/
24
+ / ___ |(__ ) / /_/ / / /_/ // ___ |/ /_/ / __/ / / / /_
25
+ /_/ |_/____/ \__/_/ \__,_//_/ |_|\__, /\___/_/ /_/\__/
26
+ /____/
27
+
28
+ [*] Autonomous AI Agent v2.25.6
29
+ ============================================
30
+ """
31
+
32
+
33
+ def print_banner():
34
+ """Print the ASCII banner."""
35
+ print(BANNER)
36
+
37
+
38
+ def create_parser() -> argparse.ArgumentParser:
39
+ """Create argument parser."""
40
+ parser = argparse.ArgumentParser(
41
+ prog="astra",
42
+ description="AstraAgent - Autonomous AI Agent",
43
+ formatter_class=argparse.RawDescriptionHelpFormatter
44
+ )
45
+
46
+ # Main commands
47
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
48
+
49
+ # Run command
50
+ run_parser = subparsers.add_parser("run", help="Run agent with a goal")
51
+ run_parser.add_argument("goal", nargs="?", help="Goal to accomplish")
52
+ run_parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode")
53
+ run_parser.add_argument("-m", "--mode", choices=["autonomous", "supervised", "dry_run"],
54
+ default="autonomous", help="Execution mode")
55
+ run_parser.add_argument("--model", default=None, help="LLM model to use")
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")
72
+
73
+ # Config command
74
+ config_parser = subparsers.add_parser("config", help="Manage configuration")
75
+ config_parser.add_argument("--show", action="store_true", help="Show current config")
76
+ config_parser.add_argument("--save", help="Save config to file")
77
+ config_parser.add_argument("--load", help="Load config from file")
78
+ config_parser.add_argument("--set-key", help="Set API key")
79
+
80
+ # Tools command
81
+ tools_parser = subparsers.add_parser("tools", help="List available tools")
82
+ tools_parser.add_argument("--info", help="Show info about a specific tool")
83
+
84
+ # Memory command
85
+ memory_parser = subparsers.add_parser("memory", help="Manage agent memory")
86
+ memory_parser.add_argument("--list", action="store_true", help="List recent memories")
87
+ memory_parser.add_argument("--search", help="Search memories")
88
+ memory_parser.add_argument("--clear", action="store_true", help="Clear all memories")
89
+
90
+ # Chat command (multi-provider interactive chat)
91
+ chat_parser = subparsers.add_parser("chat", help="Interactive chat with LLM providers")
92
+ chat_parser.add_argument("-p", "--provider", help="LLM provider (openai, gemini, anthropic, groq, openrouter, local)")
93
+ chat_parser.add_argument("-m", "--model", help="Model to use")
94
+ chat_parser.add_argument("-k", "--api-key", help="API key for provider")
95
+ chat_parser.add_argument("--list-providers", action="store_true", help="List available providers")
96
+
97
+ # Settings command
98
+ settings_parser = subparsers.add_parser("settings", help="Manage chat settings and API keys")
99
+ settings_parser.add_argument("--list-keys", action="store_true", help="View all API keys status")
100
+ settings_parser.add_argument("--set-key", metavar="PROVIDER", help="Set API key for provider")
101
+ settings_parser.add_argument("--view-env", action="store_true", help="View .env file contents")
102
+ settings_parser.add_argument("--reset-all", action="store_true", help="Reset all API keys")
103
+
104
+ # Keys command (shortcut to view keys)
105
+ keys_parser = subparsers.add_parser("keys", help="View and manage API keys")
106
+ keys_parser.add_argument("--list", action="store_true", help="List all API keys")
107
+ keys_parser.add_argument("--set", metavar="PROVIDER", help="Set API key for provider")
108
+ keys_parser.add_argument("--get", metavar="PROVIDER", help="Get masked status of API key")
109
+
110
+ # Providers command
111
+ providers_parser = subparsers.add_parser("providers", help="List available LLM providers")
112
+ providers_parser.add_argument("--info", metavar="PROVIDER", help="Show info about specific provider")
113
+
114
+ # List models for a provider
115
+ models_parser = subparsers.add_parser("models", help="List models for a provider")
116
+ models_parser.add_argument("provider", help="Provider name (openai, gemini, etc.)")
117
+ models_parser.add_argument("--info", action="store_true", help="Show provider info")
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")
142
+
143
+ # Updater command
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)")
150
+
151
+ # Version
152
+ parser.add_argument("--version", action="version", version="AstraAgent 1.0.0")
153
+
154
+ return parser
155
+
156
+
157
+ def build_config(args) -> AgentConfig:
158
+ """Build configuration from arguments and config file."""
159
+ # First try to load from config.json if it exists
160
+ config_file = Path("config.json")
161
+ if config_file.exists():
162
+ try:
163
+ config = AgentConfig.load(str(config_file))
164
+ except Exception:
165
+ config = AgentConfig()
166
+ else:
167
+ config = AgentConfig()
168
+
169
+ # Then apply environment variables (they override config file)
170
+ env_config = AgentConfig.from_env()
171
+ if env_config.llm.api_key:
172
+ config.llm.api_key = env_config.llm.api_key
173
+ config.llm.provider = env_config.llm.provider
174
+ config.llm.model = env_config.llm.model
175
+
176
+ # Apply CLI arguments (only if explicitly provided)
177
+ if hasattr(args, 'workspace') and args.workspace:
178
+ config.workspace_path = args.workspace
179
+
180
+ if hasattr(args, 'mode') and args.mode:
181
+ mode_map = {
182
+ "autonomous": ExecutionMode.AUTONOMOUS,
183
+ "supervised": ExecutionMode.SUPERVISED,
184
+ "dry_run": ExecutionMode.DRY_RUN
185
+ }
186
+ config.mode = mode_map.get(args.mode, ExecutionMode.AUTONOMOUS)
187
+
188
+ # Only override provider if explicitly passed (not default)
189
+ if hasattr(args, 'provider') and args.provider and args.provider != "openai":
190
+ config.llm.provider = args.provider
191
+
192
+ if hasattr(args, 'model') and args.model:
193
+ config.llm.model = args.model
194
+
195
+ if hasattr(args, 'max_iterations'):
196
+ config.max_iterations = args.max_iterations
197
+
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
204
+
205
+ return config
206
+
207
+
208
+ def cli_interactive_session(config: AgentConfig):
209
+ """Interactive CLI session - text-based."""
210
+ import asyncio as aio
211
+
212
+ print("\n[*] AstraAgent Interactive Mode")
213
+ print("Type your goals/commands. Type 'exit' to quit.\n")
214
+
215
+ agent = AstraAgent(config)
216
+
217
+ try:
218
+ while True:
219
+ try:
220
+ user_input = input("You > ").strip()
221
+ except EOFError:
222
+ break
223
+ except KeyboardInterrupt:
224
+ print("\n[*] Exiting...")
225
+ break
226
+
227
+ if not user_input:
228
+ continue
229
+
230
+ if user_input.lower() in ['exit', 'quit', 'q']:
231
+ print("Goodbye!")
232
+ break
233
+
234
+ if user_input.lower() == 'status':
235
+ try:
236
+ print(agent.state.get_summary())
237
+ except:
238
+ print("No status available")
239
+ continue
240
+
241
+ if user_input.lower() == 'tools':
242
+ try:
243
+ tools = agent.tools.list_enabled()
244
+ print(f"Available tools: {', '.join(tools)}")
245
+ except:
246
+ print("Could not list tools")
247
+ continue
248
+
249
+ print("\n[...] Working on your request...\n")
250
+ try:
251
+ result = aio.run(agent.run(user_input))
252
+ print(f"\n{result}\n")
253
+ except ValueError as e:
254
+ print(f"āŒ Configuration Error: {e}\n")
255
+ except Exception as e:
256
+ print(f"āŒ Error: {e}\n")
257
+
258
+ # Reset for next goal
259
+ agent.state = agent.state.__class__()
260
+ agent.messages = []
261
+
262
+ finally:
263
+ agent.shutdown()
264
+
265
+
266
+ async def run_agent(goal: str, config: AgentConfig) -> str:
267
+ """Run the agent with a goal."""
268
+ agent = AstraAgent(config)
269
+ try:
270
+ result = await agent.run(goal)
271
+
272
+ # Check if agent encountered an error
273
+ if agent.state.last_error:
274
+ error_msg = agent.state.last_error
275
+ if "Cannot connect" in error_msg or "timeout" in error_msg.lower():
276
+ return f"āŒ Connection Error: Cannot reach LLM server.\n\nRun: ollama serve (or use LM Studio)\nThen try again or see SETUP.md"
277
+ else:
278
+ return f"āŒ Error: {error_msg}"
279
+
280
+ # Success - return summary only if non-empty
281
+ if result and not result.startswith("==="):
282
+ return result
283
+ elif result.startswith("==="):
284
+ # Extract key info from summary
285
+ return f"āœ… Goal '{goal}' processed.\nCheck logs for details."
286
+
287
+ return "āœ… Done"
288
+
289
+ except ValueError as e:
290
+ # Configuration errors
291
+ return f"āŒ Configuration Error: {str(e)}\n\nSee SETUP.md for instructions."
292
+ except RuntimeError as e:
293
+ # Runtime errors (connection, API errors)
294
+ error_msg = str(e)
295
+ if "Cannot connect" in error_msg or "timeout" in error_msg.lower():
296
+ return f"āŒ Connection Error: Cannot reach LLM server.\n\nRun: ollama serve (or use LM Studio)\nThen try again or see SETUP.md"
297
+ return f"āŒ Error: {error_msg}"
298
+ except Exception as e:
299
+ return f"āŒ Unexpected Error: {str(e)}"
300
+ finally:
301
+ agent.shutdown()
302
+
303
+
304
+ def show_tools(tool_name: Optional[str] = None):
305
+ """Show available tools."""
306
+ from astra.tools.base import create_default_registry
307
+
308
+ registry = create_default_registry()
309
+
310
+ if tool_name:
311
+ tool = registry.get(tool_name)
312
+ if tool:
313
+ info = tool.get_info()
314
+ print(f"\nTool: {info['name']}")
315
+ print(f"Description: {info['description']}")
316
+ print(f"Category: {info['category']}")
317
+ print(f"Risk Level: {info['risk_level']}")
318
+ print("\nParameters:")
319
+ for param in tool.parameters:
320
+ req = "required" if param.required else "optional"
321
+ print(f" - {param.name} ({param.type}, {req}): {param.description}")
322
+ else:
323
+ print(f"Tool not found: {tool_name}")
324
+ else:
325
+ 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():
724
+ """Main entry point."""
725
+ parser = create_parser()
726
+ args = parser.parse_args()
727
+
728
+ if args.command is None:
729
+ print_banner()
730
+ parser.print_help()
731
+ return
732
+
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)
745
+
746
+ elif args.command == "tools":
747
+ show_tools(args.info if hasattr(args, 'info') else None)
748
+
749
+ elif args.command == "config":
750
+ config = AgentConfig.from_env()
751
+ if args.show:
752
+ import json
753
+ print(json.dumps(config.to_dict(), indent=2, default=str))
754
+ elif args.save:
755
+ config.save(args.save)
756
+ print(f"Config saved to {args.save}")
757
+ elif args.load:
758
+ config = AgentConfig.load(args.load)
759
+ print(f"Config loaded from {args.load}")
760
+
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
799
+
800
+ # Show available providers if requested
801
+ if args.list_providers:
802
+ print("\nšŸ“¦ Available LLM Providers:\n")
803
+ for key, info in PROVIDERS.items():
804
+ free_tag = "šŸ’° Free" if info.get("free") else "šŸ’³ Paid"
805
+ print(f" • {key:12} - {info['name']:20} [{free_tag}]")
806
+ print("\nRun: python main.py chat")
807
+ return
808
+
809
+ # Start interactive chat
810
+ asyncio.run(start_chat_mode())
811
+
812
+ elif args.command == "settings":
813
+ # Settings management
814
+ from astra.chat import APIKeyManager
815
+
816
+ if args.list_keys:
817
+ APIKeyManager.view_keys()
818
+ elif args.set_key:
819
+ print(f"\nSetting API key for: {args.set_key}")
820
+ api_key = input("Enter API key: ").strip()
821
+ if api_key:
822
+ APIKeyManager.set_key(args.set_key, api_key)
823
+ elif args.view_env:
824
+ env_file = Path(".env")
825
+ if env_file.exists():
826
+ print("\nšŸ“„ .env file contents:\n")
827
+ print(env_file.read_text())
828
+ else:
829
+ print("āŒ .env file not found")
830
+ elif args.reset_all:
831
+ confirm = input("āš ļø Clear ALL API keys from .env? (y/N): ").strip().lower()
832
+ if confirm == 'y':
833
+ env_file = Path(".env")
834
+ if env_file.exists():
835
+ env_file.unlink()
836
+ print("āœ… All keys cleared")
837
+ else:
838
+ # Default: show keys
839
+ APIKeyManager.view_keys()
840
+
841
+ elif args.command == "keys":
842
+ # API key management shortcut
843
+ from astra.chat import APIKeyManager
844
+
845
+ if args.list or not any([args.set, args.get]):
846
+ APIKeyManager.view_keys()
847
+ elif args.set:
848
+ print(f"\nSetting API key for: {args.set}")
849
+ api_key = input("Enter API key: ").strip()
850
+ if api_key:
851
+ APIKeyManager.set_key(args.set, api_key)
852
+ elif args.get:
853
+ from astra.llm import PROVIDERS
854
+ if args.get not in PROVIDERS:
855
+ print(f"āŒ Unknown provider: {args.get}")
856
+ return
857
+ keys = APIKeyManager.get_all_keys()
858
+ status = keys.get(args.get, "NOT SET")
859
+ print(f"\n{args.get}: {status}")
860
+
861
+ elif args.command == "providers":
862
+ # Show provider information
863
+ from astra.llm import PROVIDERS
864
+
865
+ if args.info:
866
+ provider = PROVIDERS.get(args.info)
867
+ if not provider:
868
+ print(f"āŒ Unknown provider: {args.info}")
869
+ return
870
+
871
+ print(f"\nšŸ“¦ Provider: {provider['name']}")
872
+ print(f" Key: {args.info}")
873
+ print(f" Type: {'šŸ’° Free' if provider.get('free') else 'šŸ’³ Paid'}")
874
+ print(f" Website: {provider.get('website', 'N/A')}")
875
+ print(f"\n Models: {len(provider['models'])} available")
876
+ for model in provider['models'][:10]:
877
+ print(f" • {model}")
878
+ if len(provider['models']) > 10:
879
+ print(f" ... and {len(provider['models']) - 10} more")
880
+ else:
881
+ # List all providers
882
+ print("\nšŸ“¦ Available LLM Providers:\n")
883
+ for key, info in PROVIDERS.items():
884
+ free_tag = "šŸ’° Free" if info.get("free") else "šŸ’³ Paid"
885
+ print(f" {key:12} - {info['name']:20} [{free_tag}] ({len(info['models'])} models)")
886
+ print("\nRun: python main.py providers --info <provider>")
887
+
888
+ elif args.command == "models":
889
+ # Show models for a provider
890
+ from astra.llm import PROVIDERS
891
+
892
+ provider = PROVIDERS.get(args.provider)
893
+ if not provider:
894
+ print(f"āŒ Unknown provider: {args.provider}")
895
+ return
896
+
897
+ if args.info:
898
+ print(f"\nšŸ“¦ Provider: {provider['name']}")
899
+ print(f" Website: {provider.get('website', 'N/A')}")
900
+ print(f" Type: {'šŸ’° Free' if provider.get('free') else 'šŸ’³ Paid'}")
901
+
902
+ print(f"\nšŸ¤– Available models for {provider['name']}:\n")
903
+ for i, model in enumerate(provider['models'], 1):
904
+ print(f" {i:2}. {model}")
905
+
906
+ print(f"\nTotal: {len(provider['models'])} models")
907
+
908
+ else:
909
+ parser.print_help()
910
+
911
+
912
+ if __name__ == "__main__":
913
+ main()