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 ADDED
@@ -0,0 +1,3 @@
1
+ """Free.ai Coder CLI — Free AI coding assistant."""
2
+
3
+ __version__ = "0.1.0"
free_code/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m free_code`."""
2
+
3
+ from free_code.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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)