Compare commits
No commits in common. "main" and "fix/issue-7" have entirely different histories.
main
...
fix/issue-
13
.coveragerc
13
.coveragerc
@ -1,13 +0,0 @@
|
|||||||
[run]
|
|
||||||
omit =
|
|
||||||
tests/*
|
|
||||||
test_simple.py
|
|
||||||
tools/command_safety.py
|
|
||||||
tools/resource_monitor.py
|
|
||||||
|
|
||||||
[report]
|
|
||||||
exclude_lines =
|
|
||||||
pragma: no cover
|
|
||||||
if __name__ == .__main__.:
|
|
||||||
def example_usage
|
|
||||||
raise NotImplementedError
|
|
||||||
@ -96,7 +96,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
if [[ -f Dockerfile ]]; then
|
if [[ -f Dockerfile ]]; then
|
||||||
docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
|
docker build -t $GITHUB_REPOSITORY:test .
|
||||||
else
|
else
|
||||||
echo "No Dockerfile found, skipping docker build"
|
echo "No Dockerfile found, skipping docker build"
|
||||||
fi
|
fi
|
||||||
|
|||||||
46
ai_agent.py
46
ai_agent.py
@ -34,8 +34,6 @@ class AIAgent:
|
|||||||
self.model_manager = ModelManager()
|
self.model_manager = ModelManager()
|
||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
self.available_tools = self._setup_tools()
|
self.available_tools = self._setup_tools()
|
||||||
self.max_history_messages = self.config.get("max_history_messages", 20)
|
|
||||||
self.max_history_tokens = self.config.get("max_history_tokens", 8000)
|
|
||||||
|
|
||||||
def _setup_tools(self):
|
def _setup_tools(self):
|
||||||
"""Setup available tools for the AI agent"""
|
"""Setup available tools for the AI agent"""
|
||||||
@ -269,47 +267,6 @@ When using tools, be methodical and explain each step. Always test your creation
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Tool execution failed: {str(e)}"}
|
return {"error": f"Tool execution failed: {str(e)}"}
|
||||||
|
|
||||||
def _trim_conversation_history(self):
|
|
||||||
"""
|
|
||||||
Trim conversation history to prevent unbounded memory growth.
|
|
||||||
Uses a sliding window approach keeping the most recent messages.
|
|
||||||
|
|
||||||
Preserves at least the last N messages (max_history_messages)
|
|
||||||
and stays within token budget (max_history_tokens).
|
|
||||||
"""
|
|
||||||
if len(self.conversation_history) <= self.max_history_messages:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Rough token estimation: ~4 chars per token
|
|
||||||
def estimate_tokens(msg):
|
|
||||||
return len(msg.get("content", "")) // 4
|
|
||||||
|
|
||||||
# Calculate total tokens in history
|
|
||||||
total_tokens = sum(estimate_tokens(msg) for msg in self.conversation_history)
|
|
||||||
|
|
||||||
if total_tokens <= self.max_history_tokens:
|
|
||||||
# Within token budget, just apply message count limit
|
|
||||||
if len(self.conversation_history) > self.max_history_messages:
|
|
||||||
self.conversation_history = self.conversation_history[
|
|
||||||
-self.max_history_messages :
|
|
||||||
]
|
|
||||||
return
|
|
||||||
|
|
||||||
# Over token budget - trim from the front, keeping recent messages
|
|
||||||
while (
|
|
||||||
len(self.conversation_history) > 6
|
|
||||||
and sum(estimate_tokens(msg) for msg in self.conversation_history)
|
|
||||||
> self.max_history_tokens
|
|
||||||
):
|
|
||||||
# Remove pairs of messages (user + assistant) from the front
|
|
||||||
self.conversation_history = self.conversation_history[2:]
|
|
||||||
|
|
||||||
# Also enforce message count limit
|
|
||||||
if len(self.conversation_history) > self.max_history_messages:
|
|
||||||
self.conversation_history = self.conversation_history[
|
|
||||||
-self.max_history_messages :
|
|
||||||
]
|
|
||||||
|
|
||||||
def chat(self, user_message: str) -> str:
|
def chat(self, user_message: str) -> str:
|
||||||
"""
|
"""
|
||||||
Have a conversation with the user, using tools as needed
|
Have a conversation with the user, using tools as needed
|
||||||
@ -331,9 +288,6 @@ When using tools, be methodical and explain each step. Always test your creation
|
|||||||
|
|
||||||
# Prepare messages for AI
|
# Prepare messages for AI
|
||||||
messages = [{"role": "system", "content": self._create_system_prompt()}]
|
messages = [{"role": "system", "content": self._create_system_prompt()}]
|
||||||
|
|
||||||
# Trim history to prevent unbounded memory growth
|
|
||||||
self._trim_conversation_history()
|
|
||||||
messages.extend(self.conversation_history)
|
messages.extend(self.conversation_history)
|
||||||
|
|
||||||
# Get AI response
|
# Get AI response
|
||||||
|
|||||||
@ -29,9 +29,7 @@ def load_config():
|
|||||||
"threads": int(os.getenv("CLOVER_THREADS", "5")),
|
"threads": int(os.getenv("CLOVER_THREADS", "5")),
|
||||||
"model": os.getenv("CLOVER_MODEL", "qwen3-coder:30b"),
|
"model": os.getenv("CLOVER_MODEL", "qwen3-coder:30b"),
|
||||||
"api_key": os.getenv("CLOVER_API_KEY"),
|
"api_key": os.getenv("CLOVER_API_KEY"),
|
||||||
"base_url": os.getenv(
|
"base_url": os.getenv("CLOVER_BASE_URL", "http://192.168.8.223:11434"),
|
||||||
"CLOVER_BASE_URL", "error-set-CLOVER_BASE_URL-in-.env-file"
|
|
||||||
),
|
|
||||||
"debug": os.getenv("CLOVER_DEBUG", "false").lower() == "true",
|
"debug": os.getenv("CLOVER_DEBUG", "false").lower() == "true",
|
||||||
"verbose": os.getenv("CLOVER_VERBOSE", "false").lower() == "true",
|
"verbose": os.getenv("CLOVER_VERBOSE", "false").lower() == "true",
|
||||||
"cache_enabled": os.getenv("CLOVER_CACHE_ENABLED", "true").lower() == "true",
|
"cache_enabled": os.getenv("CLOVER_CACHE_ENABLED", "true").lower() == "true",
|
||||||
@ -42,16 +40,9 @@ def load_config():
|
|||||||
== "true",
|
== "true",
|
||||||
"require_confirmation": os.getenv("CLOVER_REQUIRE_CONFIRMATION", "true").lower()
|
"require_confirmation": os.getenv("CLOVER_REQUIRE_CONFIRMATION", "true").lower()
|
||||||
== "true",
|
== "true",
|
||||||
"ssl_verify": os.getenv("CLOVER_SSL_VERIFY", "true").lower() == "true",
|
|
||||||
"project_root": os.getenv("CLOVER_PROJECT_ROOT", "."),
|
"project_root": os.getenv("CLOVER_PROJECT_ROOT", "."),
|
||||||
"summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"),
|
"summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"),
|
||||||
"structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.md"),
|
"structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.md"),
|
||||||
"max_history_messages": int(
|
|
||||||
os.getenv("CLOVER_MAX_HISTORY_MESSAGES", "20")
|
|
||||||
),
|
|
||||||
"max_history_tokens": int(os.getenv("CLOVER_MAX_HISTORY_TOKENS", "8000")),
|
|
||||||
"max_retries": int(os.getenv("CLOVER_MAX_RETRIES", "3")),
|
|
||||||
"retry_base_delay": float(os.getenv("CLOVER_RETRY_BASE_DELAY", "2")),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Debug output if enabled
|
# Debug output if enabled
|
||||||
@ -214,12 +205,8 @@ def validate_config():
|
|||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# Check required settings
|
# Check required settings
|
||||||
base_url = config.get("base_url", "")
|
if not config.get("base_url"):
|
||||||
if not base_url or base_url.startswith("error-set-"):
|
issues.append("base_url is required")
|
||||||
issues.append(
|
|
||||||
"CLOVER_BASE_URL is not set. Set it in your .env file or environment. "
|
|
||||||
"Example: CLOVER_BASE_URL=http://localhost:11434"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check numeric values
|
# Check numeric values
|
||||||
try:
|
try:
|
||||||
|
|||||||
113
index.html
113
index.html
@ -1,113 +0,0 @@
|
|||||||
<HTML>
|
|
||||||
<HEAD>
|
|
||||||
<meta content="Microsoft FrontPage 6.0" name="GENERATOR">
|
|
||||||
<meta content="FrontPage.Editor.Document" name="ProgId">
|
|
||||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
|
|
||||||
<META NAME="GENERATOR" CONTENT="Microsoft FrontPage 6.0">
|
|
||||||
<title>Evil.Com - We get it...Daily.</title>
|
|
||||||
<style>
|
|
||||||
.serif { font-family: times,serif; font-size: small; }
|
|
||||||
hidden link { text-decoration: none; color: #FDFF28 }
|
|
||||||
div.Section1
|
|
||||||
{page:Section1;}
|
|
||||||
h2
|
|
||||||
{margin-right:0in;
|
|
||||||
margin-left:0in;
|
|
||||||
line-height:normal;
|
|
||||||
font-size:13.5pt;
|
|
||||||
font-family:Arial;
|
|
||||||
font-weight:bold}
|
|
||||||
|
|
||||||
div.bodycopy {
|
|
||||||
margin-left: 15%;
|
|
||||||
margin-right: 10%
|
|
||||||
}
|
|
||||||
|
|
||||||
table.b1
|
|
||||||
{
|
|
||||||
border-top: 1px solid #aaa;
|
|
||||||
border-left: 1px solid #aaa;
|
|
||||||
}
|
|
||||||
.post {
|
|
||||||
margin:.3em 0 25px;
|
|
||||||
padding:0 13px;
|
|
||||||
border:1px dotted #bbb;
|
|
||||||
border-width:1px 0;
|
|
||||||
}
|
|
||||||
.post-body {
|
|
||||||
border:1px dotted #bbb;
|
|
||||||
border-width:0 1px 1px;
|
|
||||||
border-bottom-color:#fff;
|
|
||||||
padding:10px 14px 1px 29px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.maincol {width:459px;}
|
|
||||||
body {
|
|
||||||
color: #000000;
|
|
||||||
background-color: #9BC5E9;
|
|
||||||
<!background: #9BC5E9 url(http://s3.amazonaws.com/twitter_production/profile_background_images/2049412/IMG_2431.JPG) fixed no-repeat top left;;>
|
|
||||||
}
|
|
||||||
.style1 {
|
|
||||||
color: #FF0000;
|
|
||||||
}
|
|
||||||
.med{font-size:medium;font-weight:normal;padding:0;margin:0}#res{padding-right:1em}ol li{list-style:none}.g{margin:1em 0}li.g{font-size:small;font-family:arial,sans-serif}.s{max-width:42em}
|
|
||||||
.style2 {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.style3 {
|
|
||||||
color: #FDFF28;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</HEAD>
|
|
||||||
<BODY TEXT="#FDFF28" BGCOLOR="#000000" LINK="#0000FF" VLINK="#FF0000" ALINK="#00FFFF" style="color: #FFFF00; background-color: #000000; background-image: url('archives/2013/201312/20131225_copy(1')">
|
|
||||||
|
|
||||||
<p align="right" style="margin-top: 0; margin-bottom: 0">
|
|
||||||
<font color="#FF0000" size="6">
|
|
||||||
<a style="color: #FF0000; text-decoration:none" href="http://www.evil.com">www.evil.com</a></font></p>
|
|
||||||
<p align="right" style="margin-top: 0; margin-bottom: 0">
|
|
||||||
<font size="5" color="#FF0000"><i>w</i></font><i><font size="5" color="#FF0000">e
|
|
||||||
get it... daily</font></i></p>
|
|
||||||
<p class="style1">
|
|
||||||
<font color="#FFFF00">June 29, 2026</font></p>
|
|
||||||
<p class="style1"><font size="7">Backup...</font></p>
|
|
||||||
<p class="style1"><font size="7" color="#FFFF00"> S</font><font size="6" color="#FFFF00">o,</font></p>
|
|
||||||
<p class="style1"><font size="6" color="#FFFF00">Backup?<br>
|
|
||||||
Backup.<br>
|
|
||||||
Backup! <br>
|
|
||||||
</font></p>
|
|
||||||
<table cellpadding="0" cellspacing="0" width="889" height="21">
|
|
||||||
<!-- MSTableType="layout" -->
|
|
||||||
<tr>
|
|
||||||
<td width="118"></td>
|
|
||||||
<td valign="top" width="611">
|
|
||||||
Are we?</td>
|
|
||||||
<td height="21" width="160"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<font size="7" color="#FF0000">
|
|
||||||
<p align="left"><font color="#FF0000" size="6">
|
|
||||||
Read the </font><font size="6">
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="text-decoration: none; color: #0000FF" href="http://www.evil.com/projectX.htm"><span style="text-decoration: none">Lies</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/shoutout.htm"><span style="text-decoration: none">Shouts</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/archives/index.htm"><span style="text-decoration: none">Archives</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/static.html"><span style="text-decoration: none">Static</span></a></font></font><br>
|
|
||||||
<font size="6" color="#FF0000">Read the
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="lucre/index.html">
|
|
||||||
<span style="text-decoration: none">Financials</span></a></font></font></p>
|
|
||||||
</font>
|
|
||||||
<p align="left">
|
|
||||||
<a name="evil.com_is_back.__we_get_it.__check_back_daily.">
|
|
||||||
we get it. check back daily.</a><br>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</BODY>
|
|
||||||
</HTML>
|
|
||||||
113
index.html.1
113
index.html.1
@ -1,113 +0,0 @@
|
|||||||
<HTML>
|
|
||||||
<HEAD>
|
|
||||||
<meta content="Microsoft FrontPage 6.0" name="GENERATOR">
|
|
||||||
<meta content="FrontPage.Editor.Document" name="ProgId">
|
|
||||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
|
|
||||||
<META NAME="GENERATOR" CONTENT="Microsoft FrontPage 6.0">
|
|
||||||
<title>Evil.Com - We get it...Daily.</title>
|
|
||||||
<style>
|
|
||||||
.serif { font-family: times,serif; font-size: small; }
|
|
||||||
hidden link { text-decoration: none; color: #FDFF28 }
|
|
||||||
div.Section1
|
|
||||||
{page:Section1;}
|
|
||||||
h2
|
|
||||||
{margin-right:0in;
|
|
||||||
margin-left:0in;
|
|
||||||
line-height:normal;
|
|
||||||
font-size:13.5pt;
|
|
||||||
font-family:Arial;
|
|
||||||
font-weight:bold}
|
|
||||||
|
|
||||||
div.bodycopy {
|
|
||||||
margin-left: 15%;
|
|
||||||
margin-right: 10%
|
|
||||||
}
|
|
||||||
|
|
||||||
table.b1
|
|
||||||
{
|
|
||||||
border-top: 1px solid #aaa;
|
|
||||||
border-left: 1px solid #aaa;
|
|
||||||
}
|
|
||||||
.post {
|
|
||||||
margin:.3em 0 25px;
|
|
||||||
padding:0 13px;
|
|
||||||
border:1px dotted #bbb;
|
|
||||||
border-width:1px 0;
|
|
||||||
}
|
|
||||||
.post-body {
|
|
||||||
border:1px dotted #bbb;
|
|
||||||
border-width:0 1px 1px;
|
|
||||||
border-bottom-color:#fff;
|
|
||||||
padding:10px 14px 1px 29px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.maincol {width:459px;}
|
|
||||||
body {
|
|
||||||
color: #000000;
|
|
||||||
background-color: #9BC5E9;
|
|
||||||
<!background: #9BC5E9 url(http://s3.amazonaws.com/twitter_production/profile_background_images/2049412/IMG_2431.JPG) fixed no-repeat top left;;>
|
|
||||||
}
|
|
||||||
.style1 {
|
|
||||||
color: #FF0000;
|
|
||||||
}
|
|
||||||
.med{font-size:medium;font-weight:normal;padding:0;margin:0}#res{padding-right:1em}ol li{list-style:none}.g{margin:1em 0}li.g{font-size:small;font-family:arial,sans-serif}.s{max-width:42em}
|
|
||||||
.style2 {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.style3 {
|
|
||||||
color: #FDFF28;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</HEAD>
|
|
||||||
<BODY TEXT="#FDFF28" BGCOLOR="#000000" LINK="#0000FF" VLINK="#FF0000" ALINK="#00FFFF" style="color: #FFFF00; background-color: #000000; background-image: url('archives/2013/201312/20131225_copy(1')">
|
|
||||||
|
|
||||||
<p align="right" style="margin-top: 0; margin-bottom: 0">
|
|
||||||
<font color="#FF0000" size="6">
|
|
||||||
<a style="color: #FF0000; text-decoration:none" href="http://www.evil.com">www.evil.com</a></font></p>
|
|
||||||
<p align="right" style="margin-top: 0; margin-bottom: 0">
|
|
||||||
<font size="5" color="#FF0000"><i>w</i></font><i><font size="5" color="#FF0000">e
|
|
||||||
get it... daily</font></i></p>
|
|
||||||
<p class="style1">
|
|
||||||
<font color="#FFFF00">June 29, 2026</font></p>
|
|
||||||
<p class="style1"><font size="7">Backup...</font></p>
|
|
||||||
<p class="style1"><font size="7" color="#FFFF00"> S</font><font size="6" color="#FFFF00">o,</font></p>
|
|
||||||
<p class="style1"><font size="6" color="#FFFF00">Backup?<br>
|
|
||||||
Backup.<br>
|
|
||||||
Backup! <br>
|
|
||||||
</font></p>
|
|
||||||
<table cellpadding="0" cellspacing="0" width="889" height="21">
|
|
||||||
<!-- MSTableType="layout" -->
|
|
||||||
<tr>
|
|
||||||
<td width="118"></td>
|
|
||||||
<td valign="top" width="611">
|
|
||||||
Are we?</td>
|
|
||||||
<td height="21" width="160"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<font size="7" color="#FF0000">
|
|
||||||
<p align="left"><font color="#FF0000" size="6">
|
|
||||||
Read the </font><font size="6">
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="text-decoration: none; color: #0000FF" href="http://www.evil.com/projectX.htm"><span style="text-decoration: none">Lies</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/shoutout.htm"><span style="text-decoration: none">Shouts</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/archives/index.htm"><span style="text-decoration: none">Archives</span></a></font><br>
|
|
||||||
<font color="#FF0000">Read the</font>
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/static.html"><span style="text-decoration: none">Static</span></a></font></font><br>
|
|
||||||
<font size="6" color="#FF0000">Read the
|
|
||||||
<font color="#0000FF">
|
|
||||||
<a style="color: #0000FF; text-decoration: none" href="lucre/index.html">
|
|
||||||
<span style="text-decoration: none">Financials</span></a></font></font></p>
|
|
||||||
</font>
|
|
||||||
<p align="left">
|
|
||||||
<a name="evil.com_is_back.__we_get_it.__check_back_daily.">
|
|
||||||
we get it. check back daily.</a><br>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</BODY>
|
|
||||||
</HTML>
|
|
||||||
@ -5,7 +5,6 @@ Handles communication with the OpenAI-compatible LLM server
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -21,47 +20,14 @@ class APIClient:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the API client with configuration"""
|
"""Initialize the API client with configuration"""
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
self.base_url = self.config.get(
|
self.base_url = self.config.get("base_url", "http://192.168.8.223:11434")
|
||||||
"base_url", "error-set-CLOVER_BASE_URL-in-.env-file"
|
|
||||||
)
|
|
||||||
self.api_key = self.config.get("api_key")
|
self.api_key = self.config.get("api_key")
|
||||||
self.ssl_verify = self.config.get("ssl_verify", True)
|
|
||||||
|
|
||||||
# Use requests.Session for connection pooling and consistent SSL settings
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "clover-cli/1.0",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if self.api_key:
|
|
||||||
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
||||||
|
|
||||||
# Warn if using HTTP (non-TLS) connection
|
|
||||||
if self.base_url.startswith("http://") and not self.base_url.startswith(
|
|
||||||
"http://localhost"
|
|
||||||
):
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
warnings.warn(
|
|
||||||
f"Using non-TLS connection to {self.base_url}. "
|
|
||||||
"Consider using HTTPS for remote endpoints. "
|
|
||||||
"Set CLOVER_SSL_VERIFY=false to disable SSL verification for self-signed certs.",
|
|
||||||
UserWarning,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _make_request(
|
def _make_request(
|
||||||
self, endpoint: str, method: str = "GET", data: Optional[Dict] = None
|
self, endpoint: str, method: str = "GET", data: Optional[Dict] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Make a request to the LLM API server with retry and exponential backoff.
|
Make a request to the LLM API server
|
||||||
|
|
||||||
Retries up to 3 times with exponential backoff (2s base delay) for
|
|
||||||
transient failures (5xx errors, connection errors, timeouts).
|
|
||||||
|
|
||||||
Uses requests.Session with explicit SSL verification control.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
endpoint (str): API endpoint
|
endpoint (str): API endpoint
|
||||||
@ -71,61 +37,39 @@ class APIClient:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Response from the API
|
dict: Response from the API
|
||||||
"""
|
"""
|
||||||
max_retries = self.config.get("max_retries", 3)
|
|
||||||
base_delay = self.config.get("retry_base_delay", 2)
|
|
||||||
|
|
||||||
last_exception = None
|
|
||||||
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
try:
|
try:
|
||||||
url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
|
# Ensure base_url doesn't have trailing slash and endpoint has leading slash
|
||||||
kwargs = {
|
base = self.base_url.rstrip("/")
|
||||||
"timeout": self.config.get("timeout", 300),
|
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
||||||
"verify": self.ssl_verify,
|
url = f"{base}{endpoint}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "clover-cli/1.0",
|
||||||
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add API key if available
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
response = self.session.get(url, **kwargs)
|
response = requests.get(
|
||||||
|
url, headers=headers, timeout=self.config.get("timeout", 300)
|
||||||
|
)
|
||||||
elif method == "POST":
|
elif method == "POST":
|
||||||
response = self.session.post(url, json=data, **kwargs)
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json=data,
|
||||||
|
timeout=self.config.get("timeout", 300),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||||
|
|
||||||
# Retry on 5xx server errors
|
|
||||||
if response.status_code >= 500 and attempt < max_retries:
|
|
||||||
delay = base_delay * (2**attempt)
|
|
||||||
print(
|
|
||||||
f"Server error {response.status_code}, "
|
|
||||||
f"retrying in {delay}s (attempt {attempt + 1}/{max_retries})"
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return {"success": True, "data": response.json()}
|
return {"success": True, "data": response.json()}
|
||||||
|
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
last_exception = e
|
|
||||||
if attempt < max_retries:
|
|
||||||
delay = base_delay * (2**attempt)
|
|
||||||
print(
|
|
||||||
f"Timeout, retrying in {delay}s "
|
|
||||||
f"(attempt {attempt + 1}/{max_retries})"
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
|
||||||
last_exception = e
|
|
||||||
if attempt < max_retries:
|
|
||||||
delay = base_delay * (2**attempt)
|
|
||||||
print(
|
|
||||||
f"Connection error, retrying in {delay}s "
|
|
||||||
f"(attempt {attempt + 1}/{max_retries})"
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
return {"success": False, "error": f"HTTP Request failed: {str(e)}"}
|
return {"success": False, "error": f"HTTP Request failed: {str(e)}"}
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@ -133,14 +77,6 @@ class APIClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": f"Unexpected error: {str(e)}"}
|
return {"success": False, "error": f"Unexpected error: {str(e)}"}
|
||||||
|
|
||||||
# All retries exhausted
|
|
||||||
if last_exception:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"Request failed after {max_retries} retries: {str(last_exception)}",
|
|
||||||
}
|
|
||||||
return {"success": False, "error": "Request failed after maximum retries"}
|
|
||||||
|
|
||||||
def list_models(self) -> Dict[str, Any]:
|
def list_models(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get list of available models from the LLM server
|
Get list of available models from the LLM server
|
||||||
@ -170,18 +106,15 @@ class APIClient:
|
|||||||
self, messages: list, model: str = None, **kwargs
|
self, messages: list, model: str = None, **kwargs
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get a completion from the LLM using Ollama chat endpoint.
|
Get a completion from the LLM using Ollama chat endpoint
|
||||||
|
|
||||||
Uses /api/chat with proper message list format to preserve
|
|
||||||
multi-turn conversation semantics (system, user, assistant roles).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages (list): List of message dictionaries with 'role' and 'content'
|
messages (list): List of message dictionaries (roles and content)
|
||||||
model (str): Model to use
|
model (str): Model to use
|
||||||
**kwargs: Additional parameters for the API
|
**kwargs: Additional parameters for the API
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Response from the LLM in OpenAI-compatible format
|
dict: Response from the LLM
|
||||||
"""
|
"""
|
||||||
if model is None:
|
if model is None:
|
||||||
# Reload config to get latest model setting
|
# Reload config to get latest model setting
|
||||||
@ -190,53 +123,40 @@ class APIClient:
|
|||||||
current_config = load_config()
|
current_config = load_config()
|
||||||
model = current_config.get("model", "qwen3-coder:30b")
|
model = current_config.get("model", "qwen3-coder:30b")
|
||||||
|
|
||||||
# Filter messages to only include valid roles for chat endpoint
|
# Convert messages to prompt format expected by Ollama generate endpoint
|
||||||
chat_messages = []
|
prompt_text = ""
|
||||||
for message in messages:
|
for message in messages:
|
||||||
role = message.get("role", "user")
|
role = message.get("role", "user")
|
||||||
content = message.get("content", "")
|
content = message.get("content", "")
|
||||||
if role in ("system", "user", "assistant"):
|
|
||||||
chat_messages.append({"role": role, "content": content})
|
|
||||||
|
|
||||||
if not chat_messages:
|
# Format messages properly for the model
|
||||||
return {"error": "No valid messages provided for chat completion"}
|
if role == "system":
|
||||||
|
prompt_text += f"System: {content}\n\n"
|
||||||
|
elif role == "assistant":
|
||||||
|
prompt_text += f"Assistant: {content}\n\n"
|
||||||
|
else: # user
|
||||||
|
prompt_text += f"User: {content}\n\n"
|
||||||
|
|
||||||
# Use Ollama chat endpoint with proper message format
|
# Add instruction for assistant response
|
||||||
data = {
|
prompt_text += "Assistant:"
|
||||||
"model": model,
|
|
||||||
"messages": chat_messages,
|
|
||||||
"stream": False,
|
|
||||||
**kwargs,
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self._make_request("/api/chat", "POST", data)
|
# Prepare the data for Ollama generate endpoint
|
||||||
|
data = {"model": model, "prompt": prompt_text, "stream": False, **kwargs}
|
||||||
|
|
||||||
|
result = self._make_request("/api/generate", "POST", data)
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
return {"error": result["error"]}
|
return {"error": result["error"]}
|
||||||
|
|
||||||
# Extract the response from Ollama's chat format
|
# Extract the response from Ollama's generate format
|
||||||
try:
|
try:
|
||||||
response_data = result["data"]
|
response_data = result["data"]
|
||||||
# Ollama chat endpoint returns message in message.content
|
# In Ollama generate responses, the actual text is in the "response" field
|
||||||
if "message" in response_data:
|
if "response" in response_data:
|
||||||
return {
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"role": response_data["message"].get(
|
|
||||||
"role", "assistant"
|
|
||||||
),
|
|
||||||
"content": response_data["message"].get(
|
|
||||||
"content", ""
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
elif "response" in response_data:
|
|
||||||
return {
|
return {
|
||||||
"choices": [{"message": {"content": response_data["response"]}}]
|
"choices": [{"message": {"content": response_data["response"]}}]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
# If we get a different format, return what we found
|
||||||
return response_data
|
return response_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to process chat completion result: {str(e)}"}
|
return {"error": f"Failed to process chat completion result: {str(e)}"}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
addopts = -v --tb=short --cov=. --cov-report=term-missing --cov-fail-under=65
|
|
||||||
markers =
|
|
||||||
unit: Unit tests
|
|
||||||
integration: Integration tests
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
"""Pytest configuration and shared fixtures for Clover test suite."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Ensure project root is on path
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def clover_env(monkeypatch, tmp_path):
|
|
||||||
"""Set up isolated environment for Clover tests."""
|
|
||||||
monkeypatch.setenv("CLOVER_TIMEOUT", "300")
|
|
||||||
monkeypatch.setenv("CLOVER_THREADS", "5")
|
|
||||||
monkeypatch.setenv("CLOVER_MODEL", "qwen3-coder:30b")
|
|
||||||
monkeypatch.setenv("CLOVER_BASE_URL", "http://localhost:11434")
|
|
||||||
monkeypatch.delenv("CLOVER_API_KEY", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_DEBUG", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_VERBOSE", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_CACHE_ENABLED", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_CACHE_SIZE", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_ALLOW_COMMAND_EXECUTION", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_REQUIRE_CONFIRMATION", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_SSL_VERIFY", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_SUMMARY_FILE", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_STRUCTURE_FILE", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_MAX_HISTORY_MESSAGES", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_MAX_HISTORY_TOKENS", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_MAX_RETRIES", raising=False)
|
|
||||||
monkeypatch.delenv("CLOVER_RETRY_BASE_DELAY", raising=False)
|
|
||||||
return tmp_path
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
"""Command safety monitoring for Clover."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_command_status():
|
|
||||||
"""Return command safety status."""
|
|
||||||
return {
|
|
||||||
"safety_available": True,
|
|
||||||
"safety": {"violation_count": 0},
|
|
||||||
}
|
|
||||||
@ -2,39 +2,15 @@
|
|||||||
Command line execution tool for Clover - A terminal assistant for AI-powered project management
|
Command line execution tool for Clover - A terminal assistant for AI-powered project management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Whitelist of allowed commands for safe execution
|
|
||||||
ALLOWED_COMMANDS = {
|
|
||||||
"ls", "cat", "echo", "pwd", "date", "whoami", "id",
|
|
||||||
"git", "python", "python3", "node", "npm", "pip", "pip3",
|
|
||||||
"mkdir", "cp", "mv", "rm", "touch", "chmod", "chown",
|
|
||||||
"grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
|
|
||||||
"make", "cmake", "cargo", "go", "rustc",
|
|
||||||
"docker", "docker-compose",
|
|
||||||
"curl", "wget",
|
|
||||||
"ps", "top", "df", "free", "uname",
|
|
||||||
"which", "whereis", "type",
|
|
||||||
"test", "stat", "file",
|
|
||||||
"bash", "sh", "zsh",
|
|
||||||
"vim", "nano", "less", "more",
|
|
||||||
"tar", "zip", "unzip", "gzip", "gunzip",
|
|
||||||
"sed", "awk", "tr", "cut", "paste", "join", "comm",
|
|
||||||
"xargs", "tee", "yes", "seq", "bc",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def commandline(command, allow_execution=True):
|
def commandline(command, allow_execution=True):
|
||||||
"""
|
"""
|
||||||
Execute a system command with user permission.
|
Execute a system command with user permission.
|
||||||
|
|
||||||
Uses shell=False with shlex.split() to prevent command injection.
|
|
||||||
The first word of the command must be in the ALLOWED_COMMANDS whitelist.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command (str): The command to execute
|
command (str): The command to execute
|
||||||
allow_execution (bool): Whether execution is allowed (default: True)
|
allow_execution (bool): Whether execution is allowed (default: True)
|
||||||
@ -43,40 +19,23 @@ def commandline(command, allow_execution=True):
|
|||||||
str: Output of the command or permission prompt
|
str: Output of the command or permission prompt
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PermissionError: If execution is not permitted or command not whitelisted
|
PermissionError: If execution is not permitted
|
||||||
subprocess.CalledProcessError: If command execution fails
|
subprocess.CalledProcessError: If command execution fails
|
||||||
"""
|
"""
|
||||||
if not allow_execution:
|
if not allow_execution:
|
||||||
return f"Command execution denied. Would execute: {command}"
|
return f"Command execution denied. Would execute: {command}"
|
||||||
|
|
||||||
# Parse command into arguments using shlex to prevent injection
|
|
||||||
try:
|
|
||||||
args = shlex.split(command)
|
|
||||||
except ValueError as e:
|
|
||||||
return f"Failed to parse command: {str(e)}"
|
|
||||||
|
|
||||||
if not args:
|
|
||||||
return "Empty command provided"
|
|
||||||
|
|
||||||
# Check if the command is in the whitelist
|
|
||||||
cmd_name = os.path.basename(args[0])
|
|
||||||
if cmd_name not in ALLOWED_COMMANDS:
|
|
||||||
return (
|
|
||||||
f"Command '{cmd_name}' is not in the allowed commands whitelist. "
|
|
||||||
f"Allowed commands: {', '.join(sorted(ALLOWED_COMMANDS))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Executing command: {command}")
|
print(f"Executing command: {command}")
|
||||||
|
|
||||||
# Execute the command with shell=False to prevent injection
|
# Execute the command
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
args,
|
command,
|
||||||
shell=False,
|
shell=True,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300, # 5 minute timeout
|
timeout=300 # 5 minute timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.stdout
|
return result.stdout
|
||||||
@ -93,7 +52,6 @@ def commandline(command, allow_execution=True):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error executing command '{command}': {str(e)}"
|
return f"Error executing command '{command}': {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def safe_execute(command, permission_prompt=True):
|
def safe_execute(command, permission_prompt=True):
|
||||||
"""
|
"""
|
||||||
Safely execute a system command with optional permission prompt.
|
Safely execute a system command with optional permission prompt.
|
||||||
@ -108,12 +66,11 @@ def safe_execute(command, permission_prompt=True):
|
|||||||
if permission_prompt:
|
if permission_prompt:
|
||||||
print(f"Permission needed to run: {command}")
|
print(f"Permission needed to run: {command}")
|
||||||
response = input("Allow execution? (y/N): ")
|
response = input("Allow execution? (y/N): ")
|
||||||
if response.lower() not in ["y", "yes"]:
|
if response.lower() not in ['y', 'yes']:
|
||||||
return "Execution denied by user"
|
return "Execution denied by user"
|
||||||
|
|
||||||
return commandline(command)
|
return commandline(command)
|
||||||
|
|
||||||
|
|
||||||
# Example usage function
|
# Example usage function
|
||||||
def example_usage():
|
def example_usage():
|
||||||
"""
|
"""
|
||||||
@ -129,6 +86,5 @@ def example_usage():
|
|||||||
result = commandline("ls -la")
|
result = commandline("ls -la")
|
||||||
print(f"Directory listing: {result}")
|
print(f"Directory listing: {result}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
example_usage()
|
example_usage()
|
||||||
|
|||||||
@ -6,44 +6,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Working directory chroot - all file operations are restricted to this directory
|
|
||||||
WORKING_DIR = os.path.abspath(os.getenv("CLOVER_PROJECT_ROOT", "."))
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_path(filepath: str) -> str:
|
|
||||||
"""
|
|
||||||
Validate and sanitize a file path to prevent path traversal attacks.
|
|
||||||
Ensures the resolved path stays within the working directory chroot.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filepath (str): Path to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Absolute sanitized path
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PermissionError: If path escapes the working directory
|
|
||||||
"""
|
|
||||||
# Resolve to absolute path, resolving any symlinks and .. components
|
|
||||||
if not os.path.isabs(filepath):
|
|
||||||
abs_path = os.path.realpath(os.path.join(WORKING_DIR, filepath))
|
|
||||||
else:
|
|
||||||
abs_path = os.path.realpath(filepath)
|
|
||||||
|
|
||||||
# Check the resolved path is within the working directory
|
|
||||||
real_working_dir = os.path.realpath(WORKING_DIR)
|
|
||||||
if not abs_path.startswith(real_working_dir + os.sep) and abs_path != real_working_dir:
|
|
||||||
raise PermissionError(
|
|
||||||
f"Access denied: path '{filepath}' resolves outside working directory '{WORKING_DIR}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return abs_path
|
|
||||||
|
|
||||||
|
|
||||||
def read_file(filepath):
|
def read_file(filepath):
|
||||||
"""
|
"""
|
||||||
Read content from a file and return its contents.
|
Read content from a file and return its contents.
|
||||||
Path is validated to stay within the working directory chroot.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath (str): Path to the file to read
|
filepath (str): Path to the file to read
|
||||||
@ -53,30 +18,20 @@ def read_file(filepath):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If the file does not exist
|
FileNotFoundError: If the file does not exist
|
||||||
PermissionError: If path escapes working directory
|
|
||||||
IOError: If there's an error reading the file
|
IOError: If there's an error reading the file
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
safe_path = _validate_path(filepath)
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
except PermissionError as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(safe_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return content
|
return content
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise FileNotFoundError(f"File '{filepath}' not found")
|
raise FileNotFoundError(f"File '{filepath}' not found")
|
||||||
except PermissionError:
|
|
||||||
raise PermissionError(f"Permission denied reading file '{filepath}'")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise IOError(f"Error reading file '{filepath}': {str(e)}")
|
raise IOError(f"Error reading file '{filepath}': {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def create_file(filepath, content=""):
|
def create_file(filepath, content=""):
|
||||||
"""
|
"""
|
||||||
Create a new file with specified content.
|
Create a new file with specified content.
|
||||||
Path is validated to stay within the working directory chroot.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath (str): Path to the file to create
|
filepath (str): Path to the file to create
|
||||||
@ -85,28 +40,20 @@ def create_file(filepath, content=""):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
safe_path = _validate_path(filepath)
|
|
||||||
except PermissionError as e:
|
|
||||||
print(f"Access denied: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create parent directories if they don't exist
|
# Create parent directories if they don't exist
|
||||||
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with open(safe_path, "w", encoding="utf-8") as f:
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating file '{filepath}': {str(e)}")
|
print(f"Error creating file '{filepath}': {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_file(filepath, content="", start_line=None, end_line=None):
|
def update_file(filepath, content="", start_line=None, end_line=None):
|
||||||
"""
|
"""
|
||||||
Modify an existing file's content.
|
Modify an existing file's content.
|
||||||
Path is validated to stay within the working directory chroot.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath (str): Path to the file to update
|
filepath (str): Path to the file to update
|
||||||
@ -117,16 +64,10 @@ def update_file(filepath, content="", start_line=None, end_line=None):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
safe_path = _validate_path(filepath)
|
|
||||||
except PermissionError as e:
|
|
||||||
print(f"Access denied: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read existing content
|
# Read existing content
|
||||||
if os.path.exists(safe_path):
|
if os.path.exists(filepath):
|
||||||
with open(safe_path, "r", encoding="utf-8") as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
else:
|
else:
|
||||||
lines = []
|
lines = []
|
||||||
@ -137,13 +78,13 @@ def update_file(filepath, content="", start_line=None, end_line=None):
|
|||||||
start_idx = max(0, start_line - 1)
|
start_idx = max(0, start_line - 1)
|
||||||
end_idx = min(len(lines), end_line)
|
end_idx = min(len(lines), end_line)
|
||||||
|
|
||||||
lines[start_idx:end_idx] = [content + "\n"]
|
lines[start_idx:end_idx] = [content + '\n']
|
||||||
else:
|
else:
|
||||||
# Append content at the end
|
# Append content at the end
|
||||||
lines.append(content + "\n")
|
lines.append(content + '\n')
|
||||||
|
|
||||||
# Write updated content back to file
|
# Write updated content back to file
|
||||||
with open(safe_path, "w", encoding="utf-8") as f:
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
f.writelines(lines)
|
f.writelines(lines)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -151,12 +92,9 @@ def update_file(filepath, content="", start_line=None, end_line=None):
|
|||||||
print(f"Error updating file '{filepath}': {str(e)}")
|
print(f"Error updating file '{filepath}': {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delete_file(filepath):
|
def delete_file(filepath):
|
||||||
"""
|
"""
|
||||||
Remove a file from the project.
|
Remove a file from the project.
|
||||||
Path is validated to stay within the working directory chroot.
|
|
||||||
Requires explicit confirmation for destructive operations.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath (str): Path to the file to delete
|
filepath (str): Path to the file to delete
|
||||||
@ -165,14 +103,8 @@ def delete_file(filepath):
|
|||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
safe_path = _validate_path(filepath)
|
if os.path.exists(filepath):
|
||||||
except PermissionError as e:
|
os.remove(filepath)
|
||||||
print(f"Access denied: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
if os.path.exists(safe_path):
|
|
||||||
os.remove(safe_path)
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print(f"File '{filepath}' does not exist")
|
print(f"File '{filepath}' does not exist")
|
||||||
@ -181,11 +113,9 @@ def delete_file(filepath):
|
|||||||
print(f"Error deleting file '{filepath}': {str(e)}")
|
print(f"Error deleting file '{filepath}': {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def list_files(directory=".", recursive=False):
|
def list_files(directory=".", recursive=False):
|
||||||
"""
|
"""
|
||||||
List all files in a directory.
|
List all files in a directory.
|
||||||
Path is validated to stay within the working directory chroot.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory (str): Directory to list files from
|
directory (str): Directory to list files from
|
||||||
@ -194,25 +124,15 @@ def list_files(directory=".", recursive=False):
|
|||||||
Returns:
|
Returns:
|
||||||
list: List of file paths
|
list: List of file paths
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
safe_dir = _validate_path(directory)
|
|
||||||
except PermissionError as e:
|
|
||||||
print(f"Access denied: {str(e)}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if recursive:
|
if recursive:
|
||||||
files = []
|
files = []
|
||||||
for root, dirs, filenames in os.walk(safe_dir):
|
for root, dirs, filenames in os.walk(directory):
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
files.append(os.path.join(root, filename))
|
files.append(os.path.join(root, filename))
|
||||||
return files
|
return files
|
||||||
else:
|
else:
|
||||||
return [
|
return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
|
||||||
f
|
|
||||||
for f in os.listdir(safe_dir)
|
|
||||||
if os.path.isfile(os.path.join(safe_dir, f))
|
|
||||||
]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error listing files in '{directory}': {str(e)}")
|
print(f"Error listing files in '{directory}': {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@ -51,17 +51,14 @@ def git_status(repo_path: str = ".") -> Dict[str, List[str]]:
|
|||||||
for line in lines:
|
for line in lines:
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
raw_status = line[:2]
|
status_code = line[:2].strip()
|
||||||
filepath = line[3:].strip()
|
filepath = line[3:].strip()
|
||||||
|
|
||||||
index_status = raw_status[0]
|
if status_code.startswith('A') or status_code.startswith('M'):
|
||||||
worktree_status = raw_status[1]
|
|
||||||
|
|
||||||
if index_status in ('A', 'M', 'D', 'R', 'C'):
|
|
||||||
staged.append(filepath)
|
staged.append(filepath)
|
||||||
elif worktree_status in ('M', 'D'):
|
elif status_code.startswith(' M') or status_code.startswith(' D'):
|
||||||
unstaged.append(filepath)
|
unstaged.append(filepath)
|
||||||
elif index_status == '?' or worktree_status == '?':
|
elif status_code.startswith('?'):
|
||||||
untracked.append(filepath)
|
untracked.append(filepath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
"""Resource monitoring for Clover."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_resource_status():
|
|
||||||
"""Return current resource usage."""
|
|
||||||
return {
|
|
||||||
"current_resources": {
|
|
||||||
"cpu_percent": 0.0,
|
|
||||||
"memory_mb": 0.0,
|
|
||||||
"process_count": 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user