"""Main entry point for agenix."""
import argparse
import asyncio
import os
import sys
from pathlib import Path
from .core.agent import Agent, AgentConfig
from .core.llm import get_provider
from .core.session import SessionManager
from .tools.bash import BashTool
from .tools.edit import EditTool
from .tools.grep import GrepTool
from .tools.read import ReadTool
from .tools.write import WriteTool
from .ui.cli import CLI, CLIRenderer
[docs]
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Agenix - Lightweight AI coding agent"
)
parser.add_argument(
"--model",
type=str,
help="Model to use (e.g., gpt-4o, gpt-4, claude-3-5-sonnet-20241022)"
)
parser.add_argument(
"--api-key",
type=str,
help="API key (or set OPENAI_API_KEY env var)"
)
parser.add_argument(
"--base-url",
type=str,
help="API base URL (optional, for OpenAI-compatible APIs)"
)
parser.add_argument(
"--working-dir",
type=str,
default=".",
help="Working directory for file operations (default: current directory)"
)
parser.add_argument(
"--system-prompt",
type=str,
help="Custom system prompt"
)
parser.add_argument(
"--session",
type=str,
help="Session ID to load"
)
parser.add_argument(
"--max-turns",
type=int,
default=100,
help="Maximum conversation turns per prompt (default: 100)"
)
parser.add_argument(
"--max-tokens",
type=int,
default=16384,
help="Maximum tokens for LLM output (default: 16384). Common values: 4096, 8192, 16384, 32768"
)
parser.add_argument(
"message",
nargs="*",
help="Direct message to process (non-interactive mode)"
)
return parser.parse_args()
[docs]
def get_default_model() -> str:
"""Get default model."""
return "gpt-4o"
[docs]
def get_default_system_prompt(tools: list) -> str:
"""Get default system prompt with dynamic guidelines based on available tools.
Args:
tools: List of available tool instances
"""
import datetime
# Get tool names
tool_names = {tool.name for tool in tools}
# Tool descriptions (keep it short - one line per tool)
tool_descriptions = {
"read": "Read file contents",
"write": "Create or overwrite files",
"edit": "Make surgical edits to files (find exact text and replace)",
"bash": "Execute bash commands (ls, grep, find, etc.)",
"grep": "Search file contents for patterns",
}
# Build tools list
tools_list = "\n".join([
f"- {name}: {tool_descriptions.get(name, 'Tool')}"
for name in sorted(tool_names)
if name in tool_descriptions
])
# Build guidelines dynamically based on available tools
guidelines = []
has_read = "read" in tool_names
has_edit = "edit" in tool_names
has_write = "write" in tool_names
has_bash = "bash" in tool_names
# Read before edit guideline
if has_read and has_edit:
guidelines.append("Use read to examine files before editing")
# Edit guideline
if has_edit:
guidelines.append("Use edit for precise changes (old text must match exactly)")
# Write guideline
if has_write:
guidelines.append("Use write only for new files or complete rewrites")
# Output guideline
if has_edit or has_write:
guidelines.append("When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did")
# Always include these
guidelines.append("Be concise in your responses")
guidelines.append("Show file paths clearly when working with files")
guidelines_text = "\n".join([f"- {g}" for g in guidelines])
# Get current date/time
now = datetime.datetime.now()
date_time = now.strftime("%A, %B %d, %Y at %I:%M:%S %p %Z")
# Get working directory
cwd = os.getcwd()
return f"""You are an expert coding assistant operating inside Agenix, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
{tools_list}
Guidelines:
{guidelines_text}
Current date and time: {date_time}
Current working directory: {cwd}"""
[docs]
def validate_config(args, renderer: CLIRenderer = None) -> tuple:
"""Validate configuration and return api_key, base_url, model.
Args:
args: Command-line arguments
renderer: CLI renderer for interactive input (optional)
Returns:
tuple: (api_key, base_url, model)
Raises:
ValueError: If configuration is invalid and no renderer provided
"""
# Get API key
api_key = args.api_key or os.getenv("OPENAI_API_KEY")
# If no API key and renderer available, prompt for it
if not api_key and renderer:
api_key = renderer.prompt_config_input(
"API Key",
"OpenAI-compatible API key required",
is_secret=True
)
elif not api_key:
raise ValueError(
"API key not found. Please set OPENAI_API_KEY environment variable "
"or use --api-key parameter.\n\n"
"Example:\n"
" export OPENAI_API_KEY='sk-...'\n"
" agenix\n\n"
"Or:\n"
" agenix --api-key 'sk-...'"
)
# Get base URL (optional)
base_url = args.base_url or os.getenv("OPENAI_BASE_URL")
# If no base URL and renderer available, ask if user wants to provide one
if not base_url and renderer:
from prompt_toolkit import prompt
from prompt_toolkit.validation import Validator
try:
response = prompt(
"Use custom API base URL? (leave empty for default): ",
default=""
).strip()
if response:
base_url = response
except (EOFError, KeyboardInterrupt):
pass # Use default
# Get model
model = args.model or get_default_model()
return api_key, base_url, model
[docs]
def main():
"""Main entry point."""
args = parse_args()
# Setup working directory
working_dir = os.path.abspath(args.working_dir)
if not os.path.exists(working_dir):
print(f"Error: Working directory does not exist: {working_dir}")
sys.exit(1)
# Check if we have a direct message (non-interactive)
is_interactive = not args.message
# Initialize CLI renderer for interactive mode
cli = CLI() if is_interactive else None
# Setup tools early so we can show them in banner
tools = [
ReadTool(working_dir=working_dir),
WriteTool(working_dir=working_dir),
EditTool(working_dir=working_dir),
BashTool(working_dir=working_dir),
GrepTool(working_dir=working_dir),
]
# Setup skill directories (in priority order: default -> user -> project)
skill_dirs = []
# 1. Default skills (bundled with package, lowest priority)
default_skills_dir = os.path.join(os.path.dirname(__file__), "default-skills")
if os.path.exists(default_skills_dir):
skill_dirs.append(default_skills_dir)
# 2. User global skills (can override defaults)
user_skills_dir = os.path.expanduser("~/.agenix/skills")
if os.path.exists(user_skills_dir):
skill_dirs.append(user_skills_dir)
# 3. Project local skills (highest priority, can override both)
project_skills_dir = os.path.join(working_dir, ".agenix/skills")
if os.path.exists(project_skills_dir):
skill_dirs.append(project_skills_dir)
# In interactive mode, show banner first, then ask for config if needed
if is_interactive and cli:
# Try to get config without prompting
api_key = args.api_key or os.getenv("OPENAI_API_KEY")
base_url = args.base_url or os.getenv("OPENAI_BASE_URL")
model = args.model or get_default_model()
# Initialize agent to get skills for banner
try:
# Temporarily create agent to load skills
config = AgentConfig(
model=model,
api_key=api_key or "temp", # Temp key to load skills
base_url=base_url,
system_prompt=get_default_system_prompt(tools),
max_turns=args.max_turns,
max_tokens=args.max_tokens,
skill_dirs=skill_dirs if skill_dirs else None,
)
temp_agent = Agent(config=config, tools=tools)
skills = temp_agent.skill_manager.list_skills() if temp_agent.skill_manager else []
except:
skills = []
# Show banner
cli.renderer.render_welcome(model=model, tools=tools, skills=skills)
# Now ask for config if needed
if not api_key:
api_key = cli.renderer.prompt_config_input(
"API Key",
"OpenAI-compatible API key required",
is_secret=True
)
if not base_url:
from prompt_toolkit import prompt
try:
response = prompt(
"Use custom API base URL? (leave empty for default): ",
default=""
).strip()
if response:
base_url = response
except (EOFError, KeyboardInterrupt):
pass # Use default
else:
# Non-interactive mode
try:
api_key, base_url, model = validate_config(args)
skills = [] # Will be loaded later
except ValueError as e:
print(f"Configuration Error: {e}")
sys.exit(1)
# Setup agent
try:
config = AgentConfig(
model=model,
api_key=api_key,
base_url=base_url,
system_prompt=args.system_prompt or get_default_system_prompt(tools),
max_turns=args.max_turns,
max_tokens=args.max_tokens,
skill_dirs=skill_dirs if skill_dirs else None,
)
agent = Agent(config=config, tools=tools)
# Get skills list for UI (if not already loaded)
if is_interactive:
skills = agent.skill_manager.list_skills() if agent.skill_manager else []
except Exception as e:
if is_interactive and cli:
cli.renderer.render_error(f"Error initializing agent: {e}")
sys.exit(1)
else:
print(f"Error initializing agent: {e}")
sys.exit(1)
# Setup session management
session_manager = SessionManager()
# Load session if specified
if args.session:
try:
messages = session_manager.load_session(args.session)
agent.messages = messages
print(f"Loaded session: {args.session} ({len(messages)} messages)")
except Exception as e:
print(f"Error loading session: {e}")
sys.exit(1)
# Subscribe to agent events for session persistence
current_session_id = args.session or session_manager.create_session()
def on_message_end(event):
"""Save messages to session."""
from agenix.core.messages import MessageEndEvent
if isinstance(event, MessageEndEvent) and event.message:
session_manager.save_message(current_session_id, event.message)
agent.subscribe(on_message_end)
# Run CLI
if is_interactive:
# Interactive mode (banner already shown in main())
cli.run_interactive(agent, tools=tools, model=model, skills=skills, show_welcome=False)
else:
# Non-interactive mode
message = " ".join(args.message)
renderer = CLIRenderer()
asyncio.run(process_single_message(agent, message, renderer))
[docs]
async def process_single_message(agent, message: str, renderer: CLIRenderer):
"""Process a single message in non-interactive mode."""
try:
renderer.render_message("user", message)
async for event in agent.prompt(message):
renderer.render_event(event)
except Exception as e:
renderer.render_error(str(e))
sys.exit(1)
if __name__ == "__main__":
main()