freeai-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- free_code/__init__.py +3 -0
- free_code/__main__.py +6 -0
- free_code/agent.py +262 -0
- free_code/auth.py +74 -0
- free_code/cli.py +305 -0
- free_code/client.py +308 -0
- free_code/config.py +125 -0
- free_code/context/__init__.py +1 -0
- free_code/context/discovery.py +140 -0
- free_code/context/repo_map.py +86 -0
- free_code/context/window.py +169 -0
- free_code/models.py +56 -0
- free_code/streaming.py +81 -0
- free_code/tools/__init__.py +158 -0
- free_code/tools/file_ops.py +112 -0
- free_code/tools/git_ops.py +102 -0
- free_code/tools/list_files.py +107 -0
- free_code/tools/search.py +94 -0
- free_code/tools/shell.py +95 -0
- free_code/tools/test_runner.py +131 -0
- free_code/ui/__init__.py +1 -0
- free_code/ui/diff_view.py +55 -0
- free_code/ui/prompt.py +85 -0
- free_code/ui/terminal.py +118 -0
- freeai_code-0.1.0.dist-info/METADATA +109 -0
- freeai_code-0.1.0.dist-info/RECORD +30 -0
- freeai_code-0.1.0.dist-info/WHEEL +5 -0
- freeai_code-0.1.0.dist-info/entry_points.txt +2 -0
- freeai_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- freeai_code-0.1.0.dist-info/top_level.txt +1 -0
free_code/__init__.py
ADDED
free_code/__main__.py
ADDED
free_code/agent.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Local agent loop — plan, execute, observe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.spinner import Spinner
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from free_code.client import CoderClient
|
|
16
|
+
from free_code.config import load_config
|
|
17
|
+
from free_code.context.repo_map import generate_repo_map
|
|
18
|
+
from free_code.context.window import build_context
|
|
19
|
+
from free_code.tools import TOOL_DEFINITIONS, TOOL_REGISTRY
|
|
20
|
+
from free_code.tools.shell import is_dangerous
|
|
21
|
+
from free_code.ui.terminal import (
|
|
22
|
+
confirm,
|
|
23
|
+
print_error,
|
|
24
|
+
print_markdown,
|
|
25
|
+
print_tool_call,
|
|
26
|
+
print_tool_result,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
SYSTEM_PROMPT = """\
|
|
32
|
+
You are Free.ai Coder, an expert AI coding assistant running in the user's terminal.
|
|
33
|
+
You have access to their local filesystem and can read, write, and edit files.
|
|
34
|
+
|
|
35
|
+
## Tools Available
|
|
36
|
+
You can use these tools to help the user:
|
|
37
|
+
- file_read: Read file contents
|
|
38
|
+
- file_write: Write/create files
|
|
39
|
+
- apply_patch: Search/replace edit in a file
|
|
40
|
+
- shell_command: Execute shell commands
|
|
41
|
+
- grep_search: Search for patterns in code
|
|
42
|
+
- git_status, git_diff, git_commit, git_log: Git operations
|
|
43
|
+
- run_tests: Run the project's test suite
|
|
44
|
+
- list_files: List project files
|
|
45
|
+
|
|
46
|
+
## Guidelines
|
|
47
|
+
- Read files before editing them to understand the full context
|
|
48
|
+
- Make minimal, focused changes
|
|
49
|
+
- Show diffs for significant edits
|
|
50
|
+
- Run tests after making changes when appropriate
|
|
51
|
+
- Use grep_search to find relevant code before making changes
|
|
52
|
+
- Prefer apply_patch over file_write for editing existing files
|
|
53
|
+
- Ask for confirmation before destructive operations
|
|
54
|
+
- Be concise in explanations, verbose in code
|
|
55
|
+
|
|
56
|
+
When you need to use a tool, respond with a JSON tool call in this format:
|
|
57
|
+
{"tool": "tool_name", "args": {"arg1": "value1"}}
|
|
58
|
+
|
|
59
|
+
After each tool result, analyze the output and decide the next step.
|
|
60
|
+
When you're done, provide a final summary of what was accomplished.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
MAX_AGENT_STEPS = 30
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Agent:
|
|
67
|
+
"""The coding agent — runs a plan/execute/observe loop."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
project_root: Path,
|
|
72
|
+
config: Optional[Dict[str, Any]] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.project_root = project_root.resolve()
|
|
75
|
+
self.config = config or load_config()
|
|
76
|
+
self.client = CoderClient(self.config)
|
|
77
|
+
self.messages: List[Dict[str, str]] = []
|
|
78
|
+
self.safe_mode = self.config.get("safe_mode", True)
|
|
79
|
+
|
|
80
|
+
async def chat(self, user_message: str) -> None:
|
|
81
|
+
"""Process a user message through the agent loop."""
|
|
82
|
+
# Build context on first message
|
|
83
|
+
if not self.messages:
|
|
84
|
+
context, included = build_context(
|
|
85
|
+
self.project_root,
|
|
86
|
+
user_message,
|
|
87
|
+
max_tokens=self.config.get("max_context_tokens", 32000),
|
|
88
|
+
)
|
|
89
|
+
system = SYSTEM_PROMPT + f"\n\n## Project Context\n\n{context}"
|
|
90
|
+
else:
|
|
91
|
+
system = None
|
|
92
|
+
|
|
93
|
+
self.messages.append({"role": "user", "content": user_message})
|
|
94
|
+
|
|
95
|
+
for step in range(MAX_AGENT_STEPS):
|
|
96
|
+
# Get LLM response
|
|
97
|
+
response_text = await self._get_response(system)
|
|
98
|
+
system = None # Only send system on first turn
|
|
99
|
+
|
|
100
|
+
if not response_text:
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
# Check for tool calls in the response
|
|
104
|
+
tool_call = self._extract_tool_call(response_text)
|
|
105
|
+
|
|
106
|
+
if tool_call:
|
|
107
|
+
tool_name = tool_call["tool"]
|
|
108
|
+
tool_args = tool_call.get("args", {})
|
|
109
|
+
|
|
110
|
+
# Print any text before the tool call
|
|
111
|
+
pre_text = response_text[:response_text.find('{"tool"')].strip()
|
|
112
|
+
if pre_text:
|
|
113
|
+
print_markdown(pre_text)
|
|
114
|
+
|
|
115
|
+
# Execute the tool
|
|
116
|
+
result = await self._execute_tool(tool_name, tool_args)
|
|
117
|
+
|
|
118
|
+
if result is None:
|
|
119
|
+
# Tool was cancelled by user
|
|
120
|
+
self.messages.append({
|
|
121
|
+
"role": "assistant",
|
|
122
|
+
"content": response_text,
|
|
123
|
+
})
|
|
124
|
+
self.messages.append({
|
|
125
|
+
"role": "user",
|
|
126
|
+
"content": "(Tool execution cancelled by user)",
|
|
127
|
+
})
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Add assistant message and tool result
|
|
131
|
+
self.messages.append({
|
|
132
|
+
"role": "assistant",
|
|
133
|
+
"content": response_text,
|
|
134
|
+
})
|
|
135
|
+
self.messages.append({
|
|
136
|
+
"role": "user",
|
|
137
|
+
"content": f"[Tool result for {tool_name}]:\n{result}",
|
|
138
|
+
})
|
|
139
|
+
else:
|
|
140
|
+
# No tool call — this is the final response
|
|
141
|
+
print_markdown(response_text)
|
|
142
|
+
self.messages.append({
|
|
143
|
+
"role": "assistant",
|
|
144
|
+
"content": response_text,
|
|
145
|
+
})
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
async def _get_response(self, system: Optional[str] = None) -> str:
|
|
149
|
+
"""Get a streaming response from the LLM."""
|
|
150
|
+
chunks: List[str] = []
|
|
151
|
+
spinner = Spinner("dots", text="Thinking...")
|
|
152
|
+
|
|
153
|
+
with Live(spinner, console=console, refresh_per_second=10, transient=True):
|
|
154
|
+
first_chunk = True
|
|
155
|
+
async for event in self.client.chat_stream(self.messages, system=system):
|
|
156
|
+
event_type = event.get("type", "")
|
|
157
|
+
|
|
158
|
+
if event_type == "text":
|
|
159
|
+
content = event.get("content", "")
|
|
160
|
+
if first_chunk:
|
|
161
|
+
first_chunk = False
|
|
162
|
+
chunks.append(content)
|
|
163
|
+
|
|
164
|
+
elif event_type == "error":
|
|
165
|
+
print_error(event.get("content", "Unknown error"))
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
elif event_type == "done":
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
return "".join(chunks)
|
|
172
|
+
|
|
173
|
+
def _extract_tool_call(self, text: str) -> Optional[Dict[str, Any]]:
|
|
174
|
+
"""Extract a tool call JSON from the response text."""
|
|
175
|
+
# Look for {"tool": "..."} pattern
|
|
176
|
+
start = text.find('{"tool"')
|
|
177
|
+
if start == -1:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# Find the matching closing brace
|
|
181
|
+
depth = 0
|
|
182
|
+
for i in range(start, len(text)):
|
|
183
|
+
if text[i] == "{":
|
|
184
|
+
depth += 1
|
|
185
|
+
elif text[i] == "}":
|
|
186
|
+
depth -= 1
|
|
187
|
+
if depth == 0:
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(text[start:i + 1])
|
|
190
|
+
if "tool" in data:
|
|
191
|
+
return data
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
pass
|
|
194
|
+
break
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
async def _execute_tool(
|
|
198
|
+
self,
|
|
199
|
+
tool_name: str,
|
|
200
|
+
tool_args: Dict[str, Any],
|
|
201
|
+
) -> Optional[str]:
|
|
202
|
+
"""Execute a tool and return the result. Returns None if cancelled."""
|
|
203
|
+
if tool_name not in TOOL_REGISTRY:
|
|
204
|
+
return f"Error: Unknown tool: {tool_name}"
|
|
205
|
+
|
|
206
|
+
# Safety checks
|
|
207
|
+
if self.safe_mode:
|
|
208
|
+
if tool_name in ("file_write", "apply_patch", "git_commit"):
|
|
209
|
+
print_tool_call(tool_name, tool_args)
|
|
210
|
+
if not confirm("Apply this change?"):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
if tool_name == "shell_command":
|
|
214
|
+
cmd = tool_args.get("command", "")
|
|
215
|
+
print_tool_call(tool_name, tool_args)
|
|
216
|
+
if is_dangerous(cmd):
|
|
217
|
+
console.print("[warning]This command could be dangerous.[/warning]")
|
|
218
|
+
if not confirm(f"Run: {cmd}"):
|
|
219
|
+
return None
|
|
220
|
+
else:
|
|
221
|
+
# Even in non-safe mode, confirm dangerous shell commands
|
|
222
|
+
if tool_name == "shell_command" and is_dangerous(tool_args.get("command", "")):
|
|
223
|
+
print_tool_call(tool_name, tool_args)
|
|
224
|
+
if not confirm("This command could be dangerous. Run anyway?"):
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
if tool_name not in ("file_write", "apply_patch", "shell_command", "git_commit") or not self.safe_mode:
|
|
228
|
+
print_tool_call(tool_name, tool_args)
|
|
229
|
+
|
|
230
|
+
# Inject project root
|
|
231
|
+
tool_args["_project_root"] = str(self.project_root)
|
|
232
|
+
|
|
233
|
+
# Execute
|
|
234
|
+
func = TOOL_REGISTRY[tool_name]
|
|
235
|
+
try:
|
|
236
|
+
result = func(**tool_args)
|
|
237
|
+
except TypeError as e:
|
|
238
|
+
# Handle unexpected keyword arguments
|
|
239
|
+
tool_args_clean = {k: v for k, v in tool_args.items() if not k.startswith("_")}
|
|
240
|
+
tool_args_clean["_project_root"] = str(self.project_root)
|
|
241
|
+
try:
|
|
242
|
+
result = func(**tool_args_clean)
|
|
243
|
+
except Exception as e2:
|
|
244
|
+
result = f"Error executing {tool_name}: {e2}"
|
|
245
|
+
except Exception as e:
|
|
246
|
+
result = f"Error executing {tool_name}: {e}"
|
|
247
|
+
|
|
248
|
+
print_tool_result(result, collapsed=len(str(result)) > 1000)
|
|
249
|
+
return str(result)
|
|
250
|
+
|
|
251
|
+
def clear_history(self) -> None:
|
|
252
|
+
"""Clear conversation history."""
|
|
253
|
+
self.messages.clear()
|
|
254
|
+
|
|
255
|
+
def compact_history(self) -> None:
|
|
256
|
+
"""Summarize conversation to save context."""
|
|
257
|
+
if len(self.messages) <= 2:
|
|
258
|
+
return
|
|
259
|
+
# Keep first and last 4 messages, summarize the middle
|
|
260
|
+
kept = self.messages[:1] + self.messages[-4:]
|
|
261
|
+
summary = f"(Previous conversation with {len(self.messages)} messages was compacted)"
|
|
262
|
+
self.messages = [{"role": "user", "content": summary}] + kept
|
free_code/auth.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Authentication flow for Free.ai and BYOK providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.prompt import Prompt
|
|
10
|
+
|
|
11
|
+
from free_code.config import load_config, save_config, set_config_value
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_auth(config: Optional[dict] = None) -> bool:
|
|
17
|
+
"""Check if the user has valid authentication configured."""
|
|
18
|
+
if config is None:
|
|
19
|
+
config = load_config()
|
|
20
|
+
provider = config.get("provider", "free.ai")
|
|
21
|
+
if provider == "free.ai":
|
|
22
|
+
# Free.ai allows anonymous usage with daily limits
|
|
23
|
+
return True
|
|
24
|
+
# BYOK providers need an API key
|
|
25
|
+
return bool(config.get("api_key"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def login_flow() -> None:
|
|
29
|
+
"""Interactive login flow."""
|
|
30
|
+
config = load_config()
|
|
31
|
+
console.print()
|
|
32
|
+
console.print("[bold]Free.ai Coder — Login[/bold]")
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
provider = Prompt.ask(
|
|
36
|
+
"Provider",
|
|
37
|
+
choices=["free.ai", "openai", "anthropic", "google", "openrouter"],
|
|
38
|
+
default="free.ai",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if provider == "free.ai":
|
|
42
|
+
console.print()
|
|
43
|
+
console.print("Free.ai offers free daily limits with no account required.")
|
|
44
|
+
console.print("For higher limits, get a token at [link=https://free.ai/pricing]https://free.ai/pricing[/link]")
|
|
45
|
+
console.print()
|
|
46
|
+
token = Prompt.ask("Free.ai token (press Enter to skip)", default="")
|
|
47
|
+
if token:
|
|
48
|
+
set_config_value("token", token)
|
|
49
|
+
console.print("[green]Token saved.[/green]")
|
|
50
|
+
else:
|
|
51
|
+
console.print("[dim]Using free anonymous tier.[/dim]")
|
|
52
|
+
set_config_value("provider", "free.ai")
|
|
53
|
+
else:
|
|
54
|
+
console.print()
|
|
55
|
+
key_name = "API key"
|
|
56
|
+
if provider == "openrouter":
|
|
57
|
+
console.print("Get an API key at [link=https://openrouter.ai/keys]https://openrouter.ai/keys[/link]")
|
|
58
|
+
elif provider == "openai":
|
|
59
|
+
console.print("Get an API key at [link=https://platform.openai.com/api-keys]https://platform.openai.com/api-keys[/link]")
|
|
60
|
+
elif provider == "anthropic":
|
|
61
|
+
console.print("Get an API key at [link=https://console.anthropic.com/settings/keys]https://console.anthropic.com/settings/keys[/link]")
|
|
62
|
+
elif provider == "google":
|
|
63
|
+
console.print("Get an API key at [link=https://aistudio.google.com/apikey]https://aistudio.google.com/apikey[/link]")
|
|
64
|
+
|
|
65
|
+
api_key = Prompt.ask(f"\n{key_name}")
|
|
66
|
+
if not api_key:
|
|
67
|
+
console.print("[red]API key is required for {provider}.[/red]")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
set_config_value("provider", provider)
|
|
71
|
+
set_config_value("api_key", api_key)
|
|
72
|
+
console.print(f"[green]Configured {provider} provider.[/green]")
|
|
73
|
+
|
|
74
|
+
console.print("[green]Login complete. Run [bold]free-code[/bold] to start coding.[/green]")
|
free_code/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""CLI entry point — Click commands for free-code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from free_code import __version__
|
|
16
|
+
from free_code.config import (
|
|
17
|
+
CONFIG_FILE,
|
|
18
|
+
get_config_value,
|
|
19
|
+
load_config,
|
|
20
|
+
save_config,
|
|
21
|
+
set_config_value,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_project_root() -> Path:
|
|
28
|
+
"""Determine the project root directory."""
|
|
29
|
+
from free_code.tools.git_ops import git_root
|
|
30
|
+
|
|
31
|
+
cwd = Path.cwd()
|
|
32
|
+
root = git_root(str(cwd))
|
|
33
|
+
if root:
|
|
34
|
+
return Path(root)
|
|
35
|
+
return cwd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(invoke_without_command=True)
|
|
39
|
+
@click.version_option(version=__version__, prog_name="free-code")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def main(ctx: click.Context) -> None:
|
|
42
|
+
"""Free.ai Coder -- AI coding assistant for your terminal."""
|
|
43
|
+
if ctx.invoked_subcommand is None:
|
|
44
|
+
# Default: interactive chat
|
|
45
|
+
ctx.invoke(chat)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@main.command()
|
|
49
|
+
def chat() -> None:
|
|
50
|
+
"""Start an interactive coding session."""
|
|
51
|
+
from free_code.agent import Agent
|
|
52
|
+
from free_code.context.repo_map import generate_repo_map
|
|
53
|
+
from free_code.models import get_model
|
|
54
|
+
from free_code.ui.prompt import get_input, setup_history, show_help
|
|
55
|
+
from free_code.ui.terminal import (
|
|
56
|
+
print_error,
|
|
57
|
+
print_info,
|
|
58
|
+
print_markdown,
|
|
59
|
+
print_model_info,
|
|
60
|
+
print_success,
|
|
61
|
+
print_welcome,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
config = load_config()
|
|
65
|
+
project_root = get_project_root()
|
|
66
|
+
agent = Agent(project_root, config)
|
|
67
|
+
|
|
68
|
+
print_welcome()
|
|
69
|
+
print_model_info(config.get("provider", "free.ai"), get_model(config))
|
|
70
|
+
console.print(f" [dim]Project:[/dim] {project_root}")
|
|
71
|
+
console.print()
|
|
72
|
+
|
|
73
|
+
setup_history()
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
user_input = get_input("you> ")
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
if user_input is None:
|
|
83
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
if not user_input:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Handle slash commands
|
|
90
|
+
if user_input.startswith("/"):
|
|
91
|
+
cmd = user_input.split()[0].lower()
|
|
92
|
+
|
|
93
|
+
if cmd in ("/quit", "/exit", "/q"):
|
|
94
|
+
console.print("[dim]Goodbye![/dim]")
|
|
95
|
+
break
|
|
96
|
+
elif cmd == "/help":
|
|
97
|
+
show_help()
|
|
98
|
+
continue
|
|
99
|
+
elif cmd == "/clear":
|
|
100
|
+
agent.clear_history()
|
|
101
|
+
print_success("Conversation cleared.")
|
|
102
|
+
continue
|
|
103
|
+
elif cmd == "/compact":
|
|
104
|
+
agent.compact_history()
|
|
105
|
+
print_success("Conversation compacted.")
|
|
106
|
+
continue
|
|
107
|
+
elif cmd == "/model":
|
|
108
|
+
parts = user_input.split(maxsplit=1)
|
|
109
|
+
if len(parts) > 1:
|
|
110
|
+
set_config_value("model", parts[1])
|
|
111
|
+
config = load_config()
|
|
112
|
+
agent.client = __import__(
|
|
113
|
+
"free_code.client", fromlist=["CoderClient"]
|
|
114
|
+
).CoderClient(config)
|
|
115
|
+
print_success(f"Model set to: {parts[1]}")
|
|
116
|
+
else:
|
|
117
|
+
print_info(f"Current model: {get_model(config)}")
|
|
118
|
+
continue
|
|
119
|
+
elif cmd == "/config":
|
|
120
|
+
_show_config()
|
|
121
|
+
continue
|
|
122
|
+
elif cmd == "/files":
|
|
123
|
+
from free_code.tools.list_files import list_files
|
|
124
|
+
result = list_files(_project_root=str(project_root), max_depth=2)
|
|
125
|
+
console.print(f"[dim]{result}[/dim]")
|
|
126
|
+
continue
|
|
127
|
+
elif cmd == "/repo":
|
|
128
|
+
repo_map = generate_repo_map(project_root)
|
|
129
|
+
console.print(f"[dim]{repo_map}[/dim]")
|
|
130
|
+
continue
|
|
131
|
+
elif cmd == "/diff":
|
|
132
|
+
from free_code.tools.git_ops import git_diff
|
|
133
|
+
result = git_diff(_project_root=str(project_root))
|
|
134
|
+
console.print(f"[dim]{result}[/dim]")
|
|
135
|
+
continue
|
|
136
|
+
elif cmd == "/status":
|
|
137
|
+
from free_code.tools.git_ops import git_status
|
|
138
|
+
result = git_status(_project_root=str(project_root))
|
|
139
|
+
console.print(f"[dim]{result}[/dim]")
|
|
140
|
+
continue
|
|
141
|
+
elif cmd == "/test":
|
|
142
|
+
from free_code.tools.test_runner import run_tests
|
|
143
|
+
parts = user_input.split(maxsplit=1)
|
|
144
|
+
path = parts[1] if len(parts) > 1 else None
|
|
145
|
+
with console.status("Running tests..."):
|
|
146
|
+
result = run_tests(path=path, _project_root=str(project_root))
|
|
147
|
+
console.print(result)
|
|
148
|
+
continue
|
|
149
|
+
else:
|
|
150
|
+
print_error(f"Unknown command: {cmd}. Type /help for available commands.")
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# Send to agent
|
|
154
|
+
try:
|
|
155
|
+
asyncio.run(agent.chat(user_input))
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
console.print("\n[dim]Interrupted.[/dim]")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
print_error(f"Agent error: {e}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@main.command()
|
|
163
|
+
@click.argument("question", nargs=-1, required=True)
|
|
164
|
+
def ask(question: tuple) -> None:
|
|
165
|
+
"""Ask a one-shot question about your codebase."""
|
|
166
|
+
from free_code.agent import Agent
|
|
167
|
+
from free_code.ui.terminal import print_error
|
|
168
|
+
|
|
169
|
+
question_str = " ".join(question)
|
|
170
|
+
config = load_config()
|
|
171
|
+
project_root = get_project_root()
|
|
172
|
+
agent = Agent(project_root, config)
|
|
173
|
+
|
|
174
|
+
# In ask mode, disable safe_mode for reads (it's one-shot)
|
|
175
|
+
agent.safe_mode = config.get("safe_mode", True)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
asyncio.run(agent.chat(question_str))
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
console.print("\n[dim]Interrupted.[/dim]")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
print_error(str(e))
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@main.command()
|
|
187
|
+
@click.argument("task", nargs=-1, required=True)
|
|
188
|
+
def run(task: tuple) -> None:
|
|
189
|
+
"""Execute a coding task (e.g., 'add unit tests for auth module')."""
|
|
190
|
+
from free_code.agent import Agent
|
|
191
|
+
from free_code.ui.terminal import print_error
|
|
192
|
+
|
|
193
|
+
task_str = " ".join(task)
|
|
194
|
+
config = load_config()
|
|
195
|
+
project_root = get_project_root()
|
|
196
|
+
agent = Agent(project_root, config)
|
|
197
|
+
|
|
198
|
+
console.print(f"\n[bold]Task:[/bold] {task_str}")
|
|
199
|
+
console.print(f"[dim]Project: {project_root}[/dim]\n")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
asyncio.run(agent.chat(
|
|
203
|
+
f"Execute this task: {task_str}\n\n"
|
|
204
|
+
"Work through it step by step. Read relevant files first, "
|
|
205
|
+
"make the changes, then verify they work."
|
|
206
|
+
))
|
|
207
|
+
except KeyboardInterrupt:
|
|
208
|
+
console.print("\n[dim]Interrupted.[/dim]")
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print_error(str(e))
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@main.command()
|
|
215
|
+
def init() -> None:
|
|
216
|
+
"""Initialize free-code in the current project."""
|
|
217
|
+
from free_code.context.discovery import discover_files
|
|
218
|
+
from free_code.context.repo_map import generate_repo_map
|
|
219
|
+
from free_code.tools.git_ops import is_git_repo
|
|
220
|
+
from free_code.tools.test_runner import detect_test_framework
|
|
221
|
+
from free_code.ui.terminal import print_info, print_success, print_warning
|
|
222
|
+
|
|
223
|
+
project_root = get_project_root()
|
|
224
|
+
console.print(f"\n[bold]Initializing free-code[/bold] in {project_root}\n")
|
|
225
|
+
|
|
226
|
+
# Check git
|
|
227
|
+
if is_git_repo(str(project_root)):
|
|
228
|
+
print_success("Git repository detected")
|
|
229
|
+
else:
|
|
230
|
+
print_warning("Not a git repository")
|
|
231
|
+
|
|
232
|
+
# Discover files
|
|
233
|
+
with console.status("Scanning files..."):
|
|
234
|
+
files = discover_files(project_root)
|
|
235
|
+
print_info(f"Found {len(files)} code files")
|
|
236
|
+
|
|
237
|
+
# Detect test framework
|
|
238
|
+
framework = detect_test_framework(str(project_root))
|
|
239
|
+
if framework:
|
|
240
|
+
print_info(f"Test framework: {framework}")
|
|
241
|
+
else:
|
|
242
|
+
print_info("No test framework detected")
|
|
243
|
+
|
|
244
|
+
# Generate repo map
|
|
245
|
+
with console.status("Generating repository map..."):
|
|
246
|
+
repo_map = generate_repo_map(project_root)
|
|
247
|
+
|
|
248
|
+
console.print(f"\n[dim]{repo_map}[/dim]")
|
|
249
|
+
console.print(f"\n[success]Initialization complete. Run [bold]free-code[/bold] to start coding.[/success]\n")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@main.group(invoke_without_command=True)
|
|
253
|
+
@click.pass_context
|
|
254
|
+
def config(ctx: click.Context) -> None:
|
|
255
|
+
"""View or edit configuration."""
|
|
256
|
+
if ctx.invoked_subcommand is None:
|
|
257
|
+
_show_config()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@config.command("set")
|
|
261
|
+
@click.argument("key")
|
|
262
|
+
@click.argument("value")
|
|
263
|
+
def config_set(key: str, value: str) -> None:
|
|
264
|
+
"""Set a configuration value."""
|
|
265
|
+
from free_code.ui.terminal import print_success
|
|
266
|
+
|
|
267
|
+
set_config_value(key, value)
|
|
268
|
+
print_success(f"Set {key} = {value}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@config.command("get")
|
|
272
|
+
@click.argument("key")
|
|
273
|
+
def config_get(key: str) -> None:
|
|
274
|
+
"""Get a configuration value."""
|
|
275
|
+
value = get_config_value(key)
|
|
276
|
+
if value is not None:
|
|
277
|
+
console.print(f"{key} = {value}")
|
|
278
|
+
else:
|
|
279
|
+
console.print(f"[dim]{key} is not set[/dim]")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@main.command()
|
|
283
|
+
def login() -> None:
|
|
284
|
+
"""Authenticate with Free.ai or configure a BYOK provider."""
|
|
285
|
+
from free_code.auth import login_flow
|
|
286
|
+
|
|
287
|
+
login_flow()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _show_config() -> None:
|
|
291
|
+
"""Display current configuration as a table."""
|
|
292
|
+
cfg = load_config()
|
|
293
|
+
table = Table(title="Configuration", show_header=True, header_style="bold")
|
|
294
|
+
table.add_column("Key", style="cyan")
|
|
295
|
+
table.add_column("Value")
|
|
296
|
+
|
|
297
|
+
for key, value in sorted(cfg.items()):
|
|
298
|
+
display = str(value)
|
|
299
|
+
# Mask sensitive values
|
|
300
|
+
if key in ("token", "api_key") and value:
|
|
301
|
+
display = display[:8] + "..." if len(display) > 8 else "***"
|
|
302
|
+
table.add_row(key, display)
|
|
303
|
+
|
|
304
|
+
table.add_row("[dim]config file[/dim]", f"[dim]{CONFIG_FILE}[/dim]")
|
|
305
|
+
console.print(table)
|