kick-agent 0.1.0__tar.gz
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.
- kick_agent-0.1.0/PKG-INFO +63 -0
- kick_agent-0.1.0/README.md +52 -0
- kick_agent-0.1.0/kick/__init__.py +1 -0
- kick_agent-0.1.0/kick/agent.py +28 -0
- kick_agent-0.1.0/kick/config.py +45 -0
- kick_agent-0.1.0/kick/examples/calculator/main.py +27 -0
- kick_agent-0.1.0/kick/examples/calculator/pkg/calculator.py +59 -0
- kick_agent-0.1.0/kick/examples/calculator/pkg/render.py +14 -0
- kick_agent-0.1.0/kick/examples/calculator/tests.py +47 -0
- kick_agent-0.1.0/kick/examples/welcomepage/serve.py +19 -0
- kick_agent-0.1.0/kick/functions/bash.py +51 -0
- kick_agent-0.1.0/kick/functions/config.py +2 -0
- kick_agent-0.1.0/kick/functions/edit.py +68 -0
- kick_agent-0.1.0/kick/functions/ls.py +41 -0
- kick_agent-0.1.0/kick/functions/read.py +49 -0
- kick_agent-0.1.0/kick/functions/test_edit.py +3 -0
- kick_agent-0.1.0/kick/functions/test_ls.py +7 -0
- kick_agent-0.1.0/kick/functions/test_read.py +7 -0
- kick_agent-0.1.0/kick/functions/test_write.py +16 -0
- kick_agent-0.1.0/kick/functions/write.py +41 -0
- kick_agent-0.1.0/kick/main.py +10 -0
- kick_agent-0.1.0/kick/prompts.py +57 -0
- kick_agent-0.1.0/kick/ui/app.py +155 -0
- kick_agent-0.1.0/kick/ui/providers.py +19 -0
- kick_agent-0.1.0/kick/ui/screens.py +45 -0
- kick_agent-0.1.0/kick/ui/utils.py +15 -0
- kick_agent-0.1.0/kick/ui/widgets.py +41 -0
- kick_agent-0.1.0/kick_agent.egg-info/PKG-INFO +63 -0
- kick_agent-0.1.0/kick_agent.egg-info/SOURCES.txt +33 -0
- kick_agent-0.1.0/kick_agent.egg-info/dependency_links.txt +1 -0
- kick_agent-0.1.0/kick_agent.egg-info/entry_points.txt +2 -0
- kick_agent-0.1.0/kick_agent.egg-info/requires.txt +4 -0
- kick_agent-0.1.0/kick_agent.egg-info/top_level.txt +1 -0
- kick_agent-0.1.0/pyproject.toml +15 -0
- kick_agent-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kick-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple CLI coding agent
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic-ai>=1.105.0
|
|
8
|
+
Requires-Dist: textual-dev>=1.8.0
|
|
9
|
+
Requires-Dist: textual[syntax]>=8.2.7
|
|
10
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
11
|
+
|
|
12
|
+
# KICK - Coding Agent
|
|
13
|
+
|
|
14
|
+
Kick is an simple coding agent that uses pydantic ai. It can read, write, and edit code.
|
|
15
|
+
<img width="400" height="225" alt="Screen Recording 2026-05-18 184310" src="https://github.com/user-attachments/assets/11fa0c41-5fda-4f71-8c71-7724b3e7e604" />
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **File Operations**: Read, write, edit and list files in the workspace
|
|
20
|
+
- supports multiple providers
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
1. Clone the repository
|
|
25
|
+
2. Install dependencies: `uv sync`
|
|
26
|
+
3. Set your api key:
|
|
27
|
+
|
|
28
|
+
example:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
GEMINI_API_KEY=your_api_key_here
|
|
32
|
+
```
|
|
33
|
+
4. setup a model:
|
|
34
|
+
|
|
35
|
+
example:
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
MODEL=groq:openai/gpt-oss-120b
|
|
39
|
+
```
|
|
40
|
+
```
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Run the agent with a prompt:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run main.py "Your prompt here"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run the agent in multiturn conversation:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv run main.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How It Works
|
|
56
|
+
|
|
57
|
+
The agent uses ai models to call several functions which will help the llm to interact with the file system.
|
|
58
|
+
- `read`: Read file contents
|
|
59
|
+
- `ls`: List files and directories
|
|
60
|
+
- `write`: Create or modify files
|
|
61
|
+
- `edit`: edit a file
|
|
62
|
+
|
|
63
|
+
> Note: This is a learning project with a simple implementation, Not recommended to use for serious stuff.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# KICK - Coding Agent
|
|
2
|
+
|
|
3
|
+
Kick is an simple coding agent that uses pydantic ai. It can read, write, and edit code.
|
|
4
|
+
<img width="400" height="225" alt="Screen Recording 2026-05-18 184310" src="https://github.com/user-attachments/assets/11fa0c41-5fda-4f71-8c71-7724b3e7e604" />
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **File Operations**: Read, write, edit and list files in the workspace
|
|
9
|
+
- supports multiple providers
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
1. Clone the repository
|
|
14
|
+
2. Install dependencies: `uv sync`
|
|
15
|
+
3. Set your api key:
|
|
16
|
+
|
|
17
|
+
example:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
GEMINI_API_KEY=your_api_key_here
|
|
21
|
+
```
|
|
22
|
+
4. setup a model:
|
|
23
|
+
|
|
24
|
+
example:
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
MODEL=groq:openai/gpt-oss-120b
|
|
28
|
+
```
|
|
29
|
+
```
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Run the agent with a prompt:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run main.py "Your prompt here"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run the agent in multiturn conversation:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run main.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
The agent uses ai models to call several functions which will help the llm to interact with the file system.
|
|
47
|
+
- `read`: Read file contents
|
|
48
|
+
- `ls`: List files and directories
|
|
49
|
+
- `write`: Create or modify files
|
|
50
|
+
- `edit`: edit a file
|
|
51
|
+
|
|
52
|
+
> Note: This is a learning project with a simple implementation, Not recommended to use for serious stuff.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic_ai import Agent, Tool
|
|
2
|
+
from kick.prompts import SYSTEM_PROMPT
|
|
3
|
+
from kick.functions.bash import bash
|
|
4
|
+
from kick.functions.edit import edit
|
|
5
|
+
from kick.functions.ls import ls
|
|
6
|
+
from kick.functions.read import read
|
|
7
|
+
from kick.functions.write import write
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_agent(cf):
|
|
11
|
+
MODEL = cf.agent.model
|
|
12
|
+
tools = [bash, edit, ls, write, read]
|
|
13
|
+
|
|
14
|
+
if not MODEL:
|
|
15
|
+
raise Exception("Setup a proper Model")
|
|
16
|
+
agent = Agent(
|
|
17
|
+
model=MODEL,
|
|
18
|
+
system_prompt=SYSTEM_PROMPT,
|
|
19
|
+
retries=3,
|
|
20
|
+
tools=[
|
|
21
|
+
Tool(ls, takes_ctx=True),
|
|
22
|
+
Tool(read, takes_ctx=True),
|
|
23
|
+
Tool(write, takes_ctx=True),
|
|
24
|
+
Tool(edit, takes_ctx=True),
|
|
25
|
+
Tool(bash, takes_ctx=True),
|
|
26
|
+
],
|
|
27
|
+
)
|
|
28
|
+
return agent
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
import tomli_w
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dataclasses import dataclass, asdict, field
|
|
5
|
+
|
|
6
|
+
CONFIG_PATH = Path.home() / ".kick" / "config.toml"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class UIConfig:
|
|
11
|
+
theme: str = "rose-pine"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AgentConfig:
|
|
16
|
+
model: str = "groq:openai/gpt-oss-120b"
|
|
17
|
+
max_steps: int = 20
|
|
18
|
+
recent_models: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Config:
|
|
23
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
24
|
+
agent: AgentConfig = field(default_factory=AgentConfig)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config():
|
|
28
|
+
if not CONFIG_PATH.exists():
|
|
29
|
+
write_config(Config())
|
|
30
|
+
with open(CONFIG_PATH, "rb") as file:
|
|
31
|
+
data = tomllib.load(file)
|
|
32
|
+
return Config(
|
|
33
|
+
ui=UIConfig(**data.get("ui", {})),
|
|
34
|
+
agent=AgentConfig(**data.get("agent", {})),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_config(config: Config):
|
|
39
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with open(CONFIG_PATH, mode="wb") as file:
|
|
41
|
+
tomli_w.dump(asdict(config), file)
|
|
42
|
+
return config
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
config = load_config()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pkg.calculator import Calculator
|
|
3
|
+
from pkg.render import format_json_output
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
calculator = Calculator()
|
|
8
|
+
if len(sys.argv) <= 1:
|
|
9
|
+
print("Calculator App")
|
|
10
|
+
print('Usage: python main.py "<expression>"')
|
|
11
|
+
print('Example: python main.py "3 + 5"')
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
expression = " ".join(sys.argv[1:])
|
|
15
|
+
try:
|
|
16
|
+
result = calculator.evaluate(expression)
|
|
17
|
+
if result is not None:
|
|
18
|
+
to_print = format_json_output(expression, result)
|
|
19
|
+
print(to_print)
|
|
20
|
+
else:
|
|
21
|
+
print("Error: Expression is empty or contains only whitespace.")
|
|
22
|
+
except Exception as e:
|
|
23
|
+
print(f"Error: {e}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
class Calculator:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self.operators = {
|
|
4
|
+
"+": lambda a, b: a + b,
|
|
5
|
+
"-": lambda a, b: a - b,
|
|
6
|
+
"*": lambda a, b: a * b,
|
|
7
|
+
"/": lambda a, b: a / b,
|
|
8
|
+
}
|
|
9
|
+
self.precedence = {
|
|
10
|
+
"+": 1,
|
|
11
|
+
"-": 1,
|
|
12
|
+
"*": 2,
|
|
13
|
+
"/": 2,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def evaluate(self, expression):
|
|
17
|
+
if not expression or expression.isspace():
|
|
18
|
+
return None
|
|
19
|
+
tokens = expression.strip().split()
|
|
20
|
+
return self._evaluate_infix(tokens)
|
|
21
|
+
|
|
22
|
+
def _evaluate_infix(self, tokens):
|
|
23
|
+
values = []
|
|
24
|
+
operators = []
|
|
25
|
+
|
|
26
|
+
for token in tokens:
|
|
27
|
+
if token in self.operators:
|
|
28
|
+
while (
|
|
29
|
+
operators
|
|
30
|
+
and operators[-1] in self.operators
|
|
31
|
+
and self.precedence[operators[-1]] >= self.precedence[token]
|
|
32
|
+
):
|
|
33
|
+
self._apply_operator(operators, values)
|
|
34
|
+
operators.append(token)
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
values.append(float(token))
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise ValueError(f"invalid token: {token}")
|
|
40
|
+
|
|
41
|
+
while operators:
|
|
42
|
+
self._apply_operator(operators, values)
|
|
43
|
+
|
|
44
|
+
if len(values) != 1:
|
|
45
|
+
raise ValueError("invalid expression")
|
|
46
|
+
|
|
47
|
+
return values[0]
|
|
48
|
+
|
|
49
|
+
def _apply_operator(self, operators, values):
|
|
50
|
+
if not operators:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
operator = operators.pop()
|
|
54
|
+
if len(values) < 2:
|
|
55
|
+
raise ValueError(f"not enough operands for operator {operator}")
|
|
56
|
+
|
|
57
|
+
b = values.pop()
|
|
58
|
+
a = values.pop()
|
|
59
|
+
values.append(self.operators[operator](a, b))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_json_output(expression: str, result: float, indent: int = 2) -> str:
|
|
5
|
+
if isinstance(result, float) and result.is_integer():
|
|
6
|
+
result_to_dump = int(result)
|
|
7
|
+
else:
|
|
8
|
+
result_to_dump = result
|
|
9
|
+
|
|
10
|
+
output_data = {
|
|
11
|
+
"expression": expression,
|
|
12
|
+
"result": result_to_dump,
|
|
13
|
+
}
|
|
14
|
+
return json.dumps(output_data, indent=indent)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from pkg.calculator import Calculator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestCalculator(unittest.TestCase):
|
|
6
|
+
def setUp(self):
|
|
7
|
+
self.calculator = Calculator()
|
|
8
|
+
|
|
9
|
+
def test_addition(self):
|
|
10
|
+
result = self.calculator.evaluate("3 + 5")
|
|
11
|
+
self.assertEqual(result, 8)
|
|
12
|
+
|
|
13
|
+
def test_subtraction(self):
|
|
14
|
+
result = self.calculator.evaluate("10 - 4")
|
|
15
|
+
self.assertEqual(result, 6)
|
|
16
|
+
|
|
17
|
+
def test_multiplication(self):
|
|
18
|
+
result = self.calculator.evaluate("3 * 4")
|
|
19
|
+
self.assertEqual(result, 12)
|
|
20
|
+
|
|
21
|
+
def test_division(self):
|
|
22
|
+
result = self.calculator.evaluate("10 / 2")
|
|
23
|
+
self.assertEqual(result, 5)
|
|
24
|
+
|
|
25
|
+
def test_nested_expression(self):
|
|
26
|
+
result = self.calculator.evaluate("3 * 4 + 5")
|
|
27
|
+
self.assertEqual(result, 17)
|
|
28
|
+
|
|
29
|
+
def test_complex_expression(self):
|
|
30
|
+
result = self.calculator.evaluate("2 * 3 - 8 / 2 + 5")
|
|
31
|
+
self.assertEqual(result, 7)
|
|
32
|
+
|
|
33
|
+
def test_empty_expression(self):
|
|
34
|
+
result = self.calculator.evaluate("")
|
|
35
|
+
self.assertIsNone(result)
|
|
36
|
+
|
|
37
|
+
def test_invalid_operator(self):
|
|
38
|
+
with self.assertRaises(ValueError):
|
|
39
|
+
self.calculator.evaluate("$ 3 5")
|
|
40
|
+
|
|
41
|
+
def test_not_enough_operands(self):
|
|
42
|
+
with self.assertRaises(ValueError):
|
|
43
|
+
self.calculator.evaluate("+ 3")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
unittest.main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import socketserver
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
PORT = 8000
|
|
6
|
+
|
|
7
|
+
# Change directory to the folder containing the site files (assumes this script is in the project root)
|
|
8
|
+
web_dir = os.path.abspath('.')
|
|
9
|
+
os.chdir(web_dir)
|
|
10
|
+
|
|
11
|
+
Handler = http.server.SimpleHTTPRequestHandler
|
|
12
|
+
|
|
13
|
+
with socketserver.TCPServer(("", PORT), Handler) as httpd:
|
|
14
|
+
print(f"Serving at http://localhost:{PORT}")
|
|
15
|
+
try:
|
|
16
|
+
httpd.serve_forever()
|
|
17
|
+
except KeyboardInterrupt:
|
|
18
|
+
print("\nServer stopped.")
|
|
19
|
+
httpd.server_close()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pydantic_ai import RunContext
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def bash(ctx: RunContext[str], command: str, timeout: int = 30) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Execute a shell command in the workspace and return its output.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
command: The shell command to execute.
|
|
11
|
+
timeout: Maximum execution time in seconds(Optional).
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The command's exit code, standard output, standard error, or an error message if execution fails.
|
|
15
|
+
"""
|
|
16
|
+
print(f"-- calling bash({ctx.deps}, {command}, timeout = {timeout})")
|
|
17
|
+
|
|
18
|
+
process = await asyncio.create_subprocess_shell(
|
|
19
|
+
command,
|
|
20
|
+
cwd=ctx.deps,
|
|
21
|
+
stdout=asyncio.subprocess.PIPE,
|
|
22
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
output = []
|
|
26
|
+
|
|
27
|
+
async def readoutput():
|
|
28
|
+
while True:
|
|
29
|
+
line = await process.stdout.readline()
|
|
30
|
+
if not line:
|
|
31
|
+
break
|
|
32
|
+
print(line.decode(), end="", flush=True)
|
|
33
|
+
output.append(line.decode())
|
|
34
|
+
|
|
35
|
+
reader = asyncio.create_task(readoutput())
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
39
|
+
except asyncio.TimeoutError:
|
|
40
|
+
process.kill()
|
|
41
|
+
await process.wait()
|
|
42
|
+
await reader
|
|
43
|
+
return f"Command timed out after : {timeout} seconds"
|
|
44
|
+
except asyncio.CancelledError:
|
|
45
|
+
process.kill()
|
|
46
|
+
await process.wait()
|
|
47
|
+
raise
|
|
48
|
+
await reader
|
|
49
|
+
if len(output) > 100:
|
|
50
|
+
output = output[-100:]
|
|
51
|
+
return f"Exit Code:\n{process.returncode}\n" + "STDOUT: \n".join(output)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import RunContext
|
|
4
|
+
import difflib
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def edit(ctx: RunContext[str], file_path: str, old_string: str, new_string: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Replace an exact text block in a file.
|
|
10
|
+
|
|
11
|
+
The file must be inside the working directory. The tool
|
|
12
|
+
finds `old_string` and replaces it with `new_string`.
|
|
13
|
+
For safety, `old_string` must appear exactly once in
|
|
14
|
+
the file.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
file_path: Path to the file to edit.
|
|
18
|
+
old_string: Text to replace.
|
|
19
|
+
new_string: Replacement text.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A success message or an error message if the edit
|
|
23
|
+
could not be applied.
|
|
24
|
+
"""
|
|
25
|
+
working_dir = ctx.deps if type(ctx) is not str else ctx
|
|
26
|
+
print(f"--calling edit({file_path}, {old_string}, {new_string})")
|
|
27
|
+
if old_string == new_string:
|
|
28
|
+
return "Error: old_string and new_string cannot be same"
|
|
29
|
+
abs_path: str = os.path.abspath(working_dir)
|
|
30
|
+
full_path: str = os.path.join(abs_path, file_path)
|
|
31
|
+
target_path: str = os.path.normpath(full_path)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
valid_target: bool = os.path.commonpath([target_path, abs_path]) == abs_path
|
|
35
|
+
except:
|
|
36
|
+
valid_target = False
|
|
37
|
+
|
|
38
|
+
if not valid_target:
|
|
39
|
+
return f'Error: Cannot edit "{file_path}" as it is outside the permitted working directory'
|
|
40
|
+
if os.path.isdir(target_path):
|
|
41
|
+
return f'Error: Cannot edit "{file_path}" as it is a directory'
|
|
42
|
+
content = ""
|
|
43
|
+
new_content = ""
|
|
44
|
+
with open(target_path, "r", encoding="utf-8") as file:
|
|
45
|
+
content = file.read()
|
|
46
|
+
start = content.find(old_string)
|
|
47
|
+
if content.count(old_string) > 1:
|
|
48
|
+
return f"Error: The edit cannot be performed as more than one match of the {old_string} is found."
|
|
49
|
+
if start == -1:
|
|
50
|
+
return f"Error: The edit cannot be applied as the old_string: {old_string} is not found in {file_path}"
|
|
51
|
+
end = start + len(old_string)
|
|
52
|
+
|
|
53
|
+
new_content = content[:start] + new_string + content[end:]
|
|
54
|
+
with open(target_path, "w", encoding="utf-8") as file:
|
|
55
|
+
file.write(new_content)
|
|
56
|
+
diff = "\n".join(
|
|
57
|
+
list(
|
|
58
|
+
difflib.unified_diff(
|
|
59
|
+
content.splitlines(),
|
|
60
|
+
new_content.splitlines(),
|
|
61
|
+
fromfile="before",
|
|
62
|
+
tofile="after",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
output = "EDIT Successfully applied\n" + diff
|
|
67
|
+
print(output)
|
|
68
|
+
return output
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pydantic_ai import RunContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ls(ctx: RunContext[str], dir="."):
|
|
6
|
+
"""
|
|
7
|
+
List files and directories in a workspace directory.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
working_dir: Workspace root directory.
|
|
11
|
+
dir: Directory to inspect, relative to the workspace root.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Names, sizes, and directory status of items in the directory,
|
|
15
|
+
or an error message if the directory is invalid or outside the workspace.
|
|
16
|
+
"""
|
|
17
|
+
working_dir = ctx.deps
|
|
18
|
+
print(f"--calling get_files_info({working_dir}, {dir})")
|
|
19
|
+
abs_path = os.path.abspath(working_dir)
|
|
20
|
+
full_path = os.path.join(abs_path, dir)
|
|
21
|
+
target_dir = os.path.normpath(full_path)
|
|
22
|
+
try:
|
|
23
|
+
valid_target_dir = os.path.commonpath([target_dir, abs_path]) == abs_path
|
|
24
|
+
except ValueError:
|
|
25
|
+
valid_target_dir = False
|
|
26
|
+
if not valid_target_dir:
|
|
27
|
+
return f'Error: Cannot list "{dir}" as it is outside the permitted working directory'
|
|
28
|
+
if not os.path.isdir(target_dir):
|
|
29
|
+
return f'Error: directory "{dir}" is not a directory'
|
|
30
|
+
|
|
31
|
+
file_info = [f"Result for {dir if dir != '.' else 'current'} directory:"]
|
|
32
|
+
|
|
33
|
+
contents = os.listdir(target_dir)
|
|
34
|
+
for content in contents:
|
|
35
|
+
content_path = os.path.join(target_dir, content)
|
|
36
|
+
file_info.append(
|
|
37
|
+
f"- {content}: file_size = {
|
|
38
|
+
os.path.getsize(content_path)
|
|
39
|
+
} bytes , is_dir = {os.path.isdir(content_path)}"
|
|
40
|
+
)
|
|
41
|
+
return "\n".join(file_info)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pydantic_ai import RunContext
|
|
3
|
+
|
|
4
|
+
MAX_CHARS = 10000
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read(ctx: RunContext[str], file_path: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Read the contents of a file in the project workspace.
|
|
10
|
+
|
|
11
|
+
Use this tool when you need to inspect source code, configuration files,
|
|
12
|
+
logs, or other project files. The file path must be relative to the
|
|
13
|
+
workspace root. Files outside the workspace cannot be accessed.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
working_dir: Absolute path to the workspace root.
|
|
17
|
+
file_path: Relative path of the file to read.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The file contents as text. Large files may be truncated. Returns an
|
|
21
|
+
error message if the file does not exist, is not a regular file, is
|
|
22
|
+
outside the workspace, or cannot be read.
|
|
23
|
+
"""
|
|
24
|
+
working_dir = ctx.deps
|
|
25
|
+
|
|
26
|
+
print(f"--calling get_file_content({working_dir}, {file_path})")
|
|
27
|
+
try:
|
|
28
|
+
abs_path = os.path.abspath(working_dir)
|
|
29
|
+
full_path = os.path.join(abs_path, file_path)
|
|
30
|
+
target_file = os.path.normpath(full_path)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
valid_target_file = os.path.commonpath([target_file, abs_path]) == abs_path
|
|
34
|
+
except ValueError:
|
|
35
|
+
valid_target_file = False
|
|
36
|
+
if not valid_target_file:
|
|
37
|
+
return f'Error: Cannot read "{file_path}" as it is outside the permitted working directory'
|
|
38
|
+
if not os.path.isfile(target_file):
|
|
39
|
+
return f'Error: file "{file_path}" is not a file'
|
|
40
|
+
content = ""
|
|
41
|
+
with open(target_file, "r") as f:
|
|
42
|
+
content = f.read(MAX_CHARS)
|
|
43
|
+
if f.read(1):
|
|
44
|
+
content += (
|
|
45
|
+
f'\n[...File "{file_path}" truncated at {MAX_CHARS} characters]'
|
|
46
|
+
)
|
|
47
|
+
return content
|
|
48
|
+
except Exception:
|
|
49
|
+
return "Error: Cannot open or read the file"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from get_files_info import get_files_info
|
|
2
|
+
|
|
3
|
+
print(get_files_info("../examples/calculator", "."))
|
|
4
|
+
print(get_files_info("../examples/calculator", "/bin"))
|
|
5
|
+
print(get_files_info("../examples/calculator", "../"))
|
|
6
|
+
print(get_files_info("../examples/calculator", "main.py"))
|
|
7
|
+
print(get_files_info("../examples/calculator", "pkg"))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from read import read
|
|
2
|
+
|
|
3
|
+
print(read("../examples/calculator", "lorem.txt"))
|
|
4
|
+
print(read("../examples/calculator", "main.py"))
|
|
5
|
+
print(read("../examples/calculator", "pkg/calculator.py"))
|
|
6
|
+
print(read("../examples/calculator", "/bin/cat"))
|
|
7
|
+
print(read("../examples/calculator", "pkg/not_found.py"))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from write_file import write_file
|
|
2
|
+
|
|
3
|
+
print(write_file("../examples/calculator", "lorem.txt", "wait, this isn't lorem ipsum"))
|
|
4
|
+
print(
|
|
5
|
+
write_file(
|
|
6
|
+
"../examples/calculator", "pkg/morelorem.txt", "lorem ipsum dolor sit amet"
|
|
7
|
+
)
|
|
8
|
+
)
|
|
9
|
+
print(
|
|
10
|
+
write_file(
|
|
11
|
+
"../examples/calculator", "pkg/test/morelorem.txt", "lorem ipsum dolor sit amet"
|
|
12
|
+
)
|
|
13
|
+
)
|
|
14
|
+
print(
|
|
15
|
+
write_file("../examples/calculator", "/tmp/temp.txt", "this should not be allowed")
|
|
16
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pydantic_ai import RunContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def write(ctx: RunContext[str], file_path, content):
|
|
6
|
+
"""
|
|
7
|
+
Write content to a file in the workspace, creating parent directories if needed.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
file_path: Path to the file relative to the workspace.
|
|
11
|
+
content: Text to write to the file.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
A success message or an error message.
|
|
15
|
+
"""
|
|
16
|
+
working_dir = ctx.deps
|
|
17
|
+
print(f"--calling write({working_dir}, {file_path}, {content})")
|
|
18
|
+
try:
|
|
19
|
+
abs_path = os.path.abspath(working_dir)
|
|
20
|
+
full_path = os.path.join(abs_path, file_path)
|
|
21
|
+
target_path = os.path.normpath(full_path)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
valid_target = os.path.commonpath([target_path, abs_path]) == abs_path
|
|
25
|
+
except:
|
|
26
|
+
valid_target = False
|
|
27
|
+
|
|
28
|
+
if not valid_target:
|
|
29
|
+
return f'Error: Cannot write "{file_path}" as it is outside the permitted working directory'
|
|
30
|
+
if os.path.isdir(target_path):
|
|
31
|
+
return f'Error: Cannot write to "{file_path}" as it is a directory'
|
|
32
|
+
parent_dir = os.path.dirname(target_path)
|
|
33
|
+
|
|
34
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
35
|
+
with open(target_path, "w") as f:
|
|
36
|
+
f.write(content)
|
|
37
|
+
return (
|
|
38
|
+
f'Successfully wrote to "{file_path}" ({len(content)} characters written)'
|
|
39
|
+
)
|
|
40
|
+
except:
|
|
41
|
+
return f'Error: Cannot write to "{file_path}"'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
SYSTEM_PROMPT = """
|
|
2
|
+
You are Kick, an autonomous AI coding agent.
|
|
3
|
+
|
|
4
|
+
Your job is to help users understand, modify, debug, and build software projects.
|
|
5
|
+
|
|
6
|
+
You have access to tools that allow you to:
|
|
7
|
+
|
|
8
|
+
- List files and directories
|
|
9
|
+
- Read file contents
|
|
10
|
+
- Write and overwrite files
|
|
11
|
+
- Edit file content
|
|
12
|
+
- Execute commands
|
|
13
|
+
General behavior rules:
|
|
14
|
+
|
|
15
|
+
1. Always analyze the user's request carefully before making tool calls.
|
|
16
|
+
|
|
17
|
+
2. Prefer inspecting relevant files before modifying them.
|
|
18
|
+
|
|
19
|
+
3. When debugging:
|
|
20
|
+
- Identify the root cause
|
|
21
|
+
- Explain the issue briefly
|
|
22
|
+
- Apply the smallest reasonable fix
|
|
23
|
+
- Avoid unnecessary rewrites
|
|
24
|
+
|
|
25
|
+
4. When writing code:
|
|
26
|
+
- Produce clean, readable, maintainable code
|
|
27
|
+
- Follow existing project style and structure
|
|
28
|
+
- Add comments only when they improve clarity
|
|
29
|
+
- Avoid overengineering
|
|
30
|
+
|
|
31
|
+
5. Before executing code:
|
|
32
|
+
- Ensure required files exist
|
|
33
|
+
- Avoid destructive operations unless explicitly requested
|
|
34
|
+
|
|
35
|
+
6. When editing files:
|
|
36
|
+
- Preserve unrelated code
|
|
37
|
+
- Modify only what is necessary
|
|
38
|
+
|
|
39
|
+
7. If the task is ambiguous:
|
|
40
|
+
- Inspect the repository structure
|
|
41
|
+
- Infer intent from existing code before asking questions
|
|
42
|
+
|
|
43
|
+
8. Always think step-by-step and make a short execution plan before tool usage.
|
|
44
|
+
|
|
45
|
+
9. After completing a task:
|
|
46
|
+
- Summarize what was changed
|
|
47
|
+
- Mention important files modified
|
|
48
|
+
- Mention any remaining issues if applicable
|
|
49
|
+
|
|
50
|
+
Path rules:
|
|
51
|
+
- All paths must be relative to the working directory
|
|
52
|
+
- Never use absolute paths
|
|
53
|
+
- The working directory is automatically provided to tools
|
|
54
|
+
|
|
55
|
+
You are an engineering assistant, not just a chatbot.
|
|
56
|
+
Act carefully, precisely, and systematically.
|
|
57
|
+
"""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.containers import Vertical, VerticalScroll, Center, Middle
|
|
3
|
+
from textual.widgets import (
|
|
4
|
+
Header,
|
|
5
|
+
Input,
|
|
6
|
+
Markdown,
|
|
7
|
+
)
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from kick.config import config, write_config
|
|
11
|
+
from kick.ui.utils import switch_model
|
|
12
|
+
from kick.ui.screens import ModelSelectionModal
|
|
13
|
+
from kick.ui.widgets import Spinner, Welcome
|
|
14
|
+
from kick.ui.providers import CommandProvider
|
|
15
|
+
from kick.agent import create_agent
|
|
16
|
+
|
|
17
|
+
agent = create_agent(config)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Kick(App):
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
("escape", "cancel_stream", "stop agent"),
|
|
23
|
+
]
|
|
24
|
+
CSS = """
|
|
25
|
+
#welcome_container {
|
|
26
|
+
width: 100%;
|
|
27
|
+
align: center middle;
|
|
28
|
+
}
|
|
29
|
+
#welcome {
|
|
30
|
+
text-align: center;
|
|
31
|
+
width: 100%;
|
|
32
|
+
}
|
|
33
|
+
"""
|
|
34
|
+
COMMANDS = App.COMMANDS | {CommandProvider}
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.message_history = []
|
|
39
|
+
self.current_stream = None
|
|
40
|
+
self.current_spinner = None
|
|
41
|
+
self.is_response_loading = False
|
|
42
|
+
self.agent = agent
|
|
43
|
+
self.config = config
|
|
44
|
+
|
|
45
|
+
async def run_agent(self, prompt: str, message_widget: Spinner):
|
|
46
|
+
response = ""
|
|
47
|
+
first_chunk = True
|
|
48
|
+
try:
|
|
49
|
+
async with self.agent.run_stream(
|
|
50
|
+
prompt,
|
|
51
|
+
deps=os.getcwd(),
|
|
52
|
+
message_history=self.message_history,
|
|
53
|
+
) as stream:
|
|
54
|
+
self.current_stream = stream
|
|
55
|
+
async for chunk in stream.stream_text(delta=True):
|
|
56
|
+
if first_chunk:
|
|
57
|
+
message_widget.stop()
|
|
58
|
+
first_chunk = False
|
|
59
|
+
response += chunk
|
|
60
|
+
message_widget.update(f"### Kick\n\n{response}")
|
|
61
|
+
self.message_history = stream.all_messages()
|
|
62
|
+
except:
|
|
63
|
+
message_widget.stop()
|
|
64
|
+
message_widget.update("> Encountered an error during generation ❌")
|
|
65
|
+
|
|
66
|
+
async def on_input_submitted(self, event: Input.Submitted):
|
|
67
|
+
if event.input.id != "prompt":
|
|
68
|
+
return
|
|
69
|
+
prompt = event.value
|
|
70
|
+
event.input.value = ""
|
|
71
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
72
|
+
user_message = Markdown(f"> ### {prompt}")
|
|
73
|
+
assistant_message = Spinner()
|
|
74
|
+
self.current_spinner = assistant_message
|
|
75
|
+
welcome = self.query("#welcome")
|
|
76
|
+
if welcome:
|
|
77
|
+
await welcome.first().remove()
|
|
78
|
+
await messages.mount(user_message)
|
|
79
|
+
await messages.mount(assistant_message)
|
|
80
|
+
self.run_worker(self.run_agent(prompt, assistant_message))
|
|
81
|
+
|
|
82
|
+
messages.scroll_end(animate=True)
|
|
83
|
+
if prompt.strip() == "/exit":
|
|
84
|
+
self.exit()
|
|
85
|
+
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
"""Create child widgets for the app."""
|
|
88
|
+
with Vertical():
|
|
89
|
+
yield Header()
|
|
90
|
+
with VerticalScroll(id="messages"):
|
|
91
|
+
with Middle():
|
|
92
|
+
with Center():
|
|
93
|
+
with Vertical(id="welcome_container"):
|
|
94
|
+
yield Welcome(
|
|
95
|
+
"""
|
|
96
|
+
[yellow]Kick[/]
|
|
97
|
+
|
|
98
|
+
░[#FE0000]▒▒▒▒▒[/]
|
|
99
|
+
█[#FE0000]▒▒▒▒[/]█████░
|
|
100
|
+
░█[#FE0000]▓▒▒▒[/]████████
|
|
101
|
+
██[#FE0000]▒▒▒▒[/]█████████
|
|
102
|
+
░██[#FE0000]▒▒▒▒[/]█████████
|
|
103
|
+
[#FE0000]░[/]█████▒
|
|
104
|
+
█████
|
|
105
|
+
░░
|
|
106
|
+
|
|
107
|
+
Autonomous Coding Agent
|
|
108
|
+
|
|
109
|
+
• Analyze repositories
|
|
110
|
+
• Edit files
|
|
111
|
+
• Execute commands
|
|
112
|
+
• Run tests
|
|
113
|
+
|
|
114
|
+
[red] /exit [/] - [yellow] exit the agent [/]
|
|
115
|
+
[red] esc [/] - [yellow] stop the agent execution [/]
|
|
116
|
+
[red] ctrl + p [/] - [yellow] open command pallete [/]
|
|
117
|
+
|
|
118
|
+
Type a prompt below to begin.
|
|
119
|
+
""",
|
|
120
|
+
id="welcome",
|
|
121
|
+
)
|
|
122
|
+
yield Input(placeholder="Build anything cool ..", id="prompt")
|
|
123
|
+
|
|
124
|
+
async def on_mount(self):
|
|
125
|
+
self.theme = config.ui.theme
|
|
126
|
+
ip = self.query_one("#prompt", Input)
|
|
127
|
+
ip.focus()
|
|
128
|
+
|
|
129
|
+
async def watch_theme(self, theme: str):
|
|
130
|
+
config.ui.theme = theme
|
|
131
|
+
write_config(config)
|
|
132
|
+
|
|
133
|
+
def show_model_selector(self):
|
|
134
|
+
self.push_screen(
|
|
135
|
+
ModelSelectionModal(
|
|
136
|
+
self.config.agent.recent_models, self.config.agent.model
|
|
137
|
+
),
|
|
138
|
+
self.on_model_selected,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def on_model_selected(self, model):
|
|
142
|
+
switch_model(self, model)
|
|
143
|
+
|
|
144
|
+
async def action_cancel_stream(self) -> None:
|
|
145
|
+
if self.current_stream:
|
|
146
|
+
await self.current_stream.cancel()
|
|
147
|
+
messages = self.query_one("#messages")
|
|
148
|
+
if self.current_spinner:
|
|
149
|
+
self.current_spinner.stop()
|
|
150
|
+
messages.mount(Markdown("> Agent Cancelled"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
app = Kick()
|
|
155
|
+
app.run()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from textual.command import Provider, Hit, Hits
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommandProvider(Provider):
|
|
5
|
+
async def search(self, query: str) -> Hits:
|
|
6
|
+
|
|
7
|
+
matcher = self.matcher(query)
|
|
8
|
+
|
|
9
|
+
app = self.app
|
|
10
|
+
current_model = app.config.agent.model
|
|
11
|
+
command = f"Model: {current_model}"
|
|
12
|
+
score = matcher.match(command)
|
|
13
|
+
if score > 0:
|
|
14
|
+
yield Hit(
|
|
15
|
+
score,
|
|
16
|
+
matcher.highlight(command),
|
|
17
|
+
app.show_model_selector,
|
|
18
|
+
help="Change the current model",
|
|
19
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from textual.screen import ModalScreen
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
from textual.widgets import ListItem, ListView, Label, Input
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelSelectionModal(ModalScreen[str]):
|
|
8
|
+
def __init__(self, recent_models: list[str], current_model: str):
|
|
9
|
+
super().__init__()
|
|
10
|
+
self.current_model = current_model
|
|
11
|
+
self.recent_models = recent_models
|
|
12
|
+
if not self.recent_models:
|
|
13
|
+
self.recent_models.append(self.current_model)
|
|
14
|
+
|
|
15
|
+
def compose(self) -> ComposeResult:
|
|
16
|
+
with Vertical():
|
|
17
|
+
yield Label("Select a Model:")
|
|
18
|
+
yield Input(value=self.current_model, id="model_input")
|
|
19
|
+
yield ListView(
|
|
20
|
+
*[ListItem(Label(model)) for model in self.recent_models],
|
|
21
|
+
id="recent_models",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
25
|
+
event.stop()
|
|
26
|
+
self.dismiss(event.value)
|
|
27
|
+
|
|
28
|
+
async def on_input_changed(self, event: Input.Changed):
|
|
29
|
+
query = event.value.lower()
|
|
30
|
+
list_view = self.query_one("#recent_models", ListView)
|
|
31
|
+
filtered_models = [
|
|
32
|
+
model for model in self.recent_models if query in model.lower()
|
|
33
|
+
]
|
|
34
|
+
list_view.clear()
|
|
35
|
+
|
|
36
|
+
for model in filtered_models:
|
|
37
|
+
await list_view.append(ListItem(Label(model)))
|
|
38
|
+
|
|
39
|
+
def on_list_view_selected(
|
|
40
|
+
self,
|
|
41
|
+
event: ListView.Selected,
|
|
42
|
+
):
|
|
43
|
+
label = event.item.query_one(Label)
|
|
44
|
+
|
|
45
|
+
self.dismiss(str(label.content))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from kick.config import write_config
|
|
2
|
+
from kick.agent import create_agent
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def switch_model(app, model):
|
|
6
|
+
if not model:
|
|
7
|
+
return
|
|
8
|
+
recents = app.config.agent.recent_models
|
|
9
|
+
if model in recents:
|
|
10
|
+
recents.remove(model)
|
|
11
|
+
recents.insert(0, model)
|
|
12
|
+
app.config.agent.model = model
|
|
13
|
+
write_config(app.config)
|
|
14
|
+
app.agent = create_agent(app.config)
|
|
15
|
+
app.notify(f"Model changed to {app.config.agent.model}")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from textual.widgets import Static, Markdown
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Welcome(Static):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Spinner(Markdown):
|
|
10
|
+
FRAMES = ["{/////}", "{~////}", "{/~///}", "{//~//}", "{////~}"]
|
|
11
|
+
MESSAGES = [
|
|
12
|
+
"Thinking ...",
|
|
13
|
+
"Building a ramp...",
|
|
14
|
+
"Launching stunt...",
|
|
15
|
+
"Defying physics...",
|
|
16
|
+
"Revving engines...",
|
|
17
|
+
"Helmet on...",
|
|
18
|
+
"Daredevil mode...",
|
|
19
|
+
"Aiming for awesome...",
|
|
20
|
+
"Full send...",
|
|
21
|
+
"What could possibly go wrong?",
|
|
22
|
+
]
|
|
23
|
+
FRAME_INTERVAL = 0.2
|
|
24
|
+
MESSAGE_TICK_RATE = 50
|
|
25
|
+
|
|
26
|
+
def on_mount(self):
|
|
27
|
+
self.frame = -1
|
|
28
|
+
self.tick = 0
|
|
29
|
+
self.current_message = random.choice(self.MESSAGES)
|
|
30
|
+
self.update(f"{self.FRAMES[self.frame]} {self.current_message}")
|
|
31
|
+
self.timer = self.set_interval(self.FRAME_INTERVAL, self.animate)
|
|
32
|
+
|
|
33
|
+
def animate(self):
|
|
34
|
+
self.frame = (self.frame + 1) % len(self.FRAMES)
|
|
35
|
+
if self.tick % self.MESSAGE_TICK_RATE == 0:
|
|
36
|
+
self.current_message = random.choice(self.MESSAGES)
|
|
37
|
+
self.update(f"{self.FRAMES[self.frame]} {self.current_message}")
|
|
38
|
+
self.tick += 1
|
|
39
|
+
|
|
40
|
+
def stop(self):
|
|
41
|
+
self.timer.stop()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kick-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple CLI coding agent
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic-ai>=1.105.0
|
|
8
|
+
Requires-Dist: textual-dev>=1.8.0
|
|
9
|
+
Requires-Dist: textual[syntax]>=8.2.7
|
|
10
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
11
|
+
|
|
12
|
+
# KICK - Coding Agent
|
|
13
|
+
|
|
14
|
+
Kick is an simple coding agent that uses pydantic ai. It can read, write, and edit code.
|
|
15
|
+
<img width="400" height="225" alt="Screen Recording 2026-05-18 184310" src="https://github.com/user-attachments/assets/11fa0c41-5fda-4f71-8c71-7724b3e7e604" />
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **File Operations**: Read, write, edit and list files in the workspace
|
|
20
|
+
- supports multiple providers
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
1. Clone the repository
|
|
25
|
+
2. Install dependencies: `uv sync`
|
|
26
|
+
3. Set your api key:
|
|
27
|
+
|
|
28
|
+
example:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
GEMINI_API_KEY=your_api_key_here
|
|
32
|
+
```
|
|
33
|
+
4. setup a model:
|
|
34
|
+
|
|
35
|
+
example:
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
MODEL=groq:openai/gpt-oss-120b
|
|
39
|
+
```
|
|
40
|
+
```
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Run the agent with a prompt:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run main.py "Your prompt here"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run the agent in multiturn conversation:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv run main.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How It Works
|
|
56
|
+
|
|
57
|
+
The agent uses ai models to call several functions which will help the llm to interact with the file system.
|
|
58
|
+
- `read`: Read file contents
|
|
59
|
+
- `ls`: List files and directories
|
|
60
|
+
- `write`: Create or modify files
|
|
61
|
+
- `edit`: edit a file
|
|
62
|
+
|
|
63
|
+
> Note: This is a learning project with a simple implementation, Not recommended to use for serious stuff.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
kick/__init__.py
|
|
4
|
+
kick/agent.py
|
|
5
|
+
kick/config.py
|
|
6
|
+
kick/main.py
|
|
7
|
+
kick/prompts.py
|
|
8
|
+
kick/examples/calculator/main.py
|
|
9
|
+
kick/examples/calculator/tests.py
|
|
10
|
+
kick/examples/calculator/pkg/calculator.py
|
|
11
|
+
kick/examples/calculator/pkg/render.py
|
|
12
|
+
kick/examples/welcomepage/serve.py
|
|
13
|
+
kick/functions/bash.py
|
|
14
|
+
kick/functions/config.py
|
|
15
|
+
kick/functions/edit.py
|
|
16
|
+
kick/functions/ls.py
|
|
17
|
+
kick/functions/read.py
|
|
18
|
+
kick/functions/test_edit.py
|
|
19
|
+
kick/functions/test_ls.py
|
|
20
|
+
kick/functions/test_read.py
|
|
21
|
+
kick/functions/test_write.py
|
|
22
|
+
kick/functions/write.py
|
|
23
|
+
kick/ui/app.py
|
|
24
|
+
kick/ui/providers.py
|
|
25
|
+
kick/ui/screens.py
|
|
26
|
+
kick/ui/utils.py
|
|
27
|
+
kick/ui/widgets.py
|
|
28
|
+
kick_agent.egg-info/PKG-INFO
|
|
29
|
+
kick_agent.egg-info/SOURCES.txt
|
|
30
|
+
kick_agent.egg-info/dependency_links.txt
|
|
31
|
+
kick_agent.egg-info/entry_points.txt
|
|
32
|
+
kick_agent.egg-info/requires.txt
|
|
33
|
+
kick_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kick
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kick-agent"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple CLI coding agent"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic-ai>=1.105.0",
|
|
9
|
+
"textual-dev>=1.8.0",
|
|
10
|
+
"textual[syntax]>=8.2.7",
|
|
11
|
+
"tomli-w>=1.2.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
kick = "kick.main:main"
|