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.
Files changed (35) hide show
  1. kick_agent-0.1.0/PKG-INFO +63 -0
  2. kick_agent-0.1.0/README.md +52 -0
  3. kick_agent-0.1.0/kick/__init__.py +1 -0
  4. kick_agent-0.1.0/kick/agent.py +28 -0
  5. kick_agent-0.1.0/kick/config.py +45 -0
  6. kick_agent-0.1.0/kick/examples/calculator/main.py +27 -0
  7. kick_agent-0.1.0/kick/examples/calculator/pkg/calculator.py +59 -0
  8. kick_agent-0.1.0/kick/examples/calculator/pkg/render.py +14 -0
  9. kick_agent-0.1.0/kick/examples/calculator/tests.py +47 -0
  10. kick_agent-0.1.0/kick/examples/welcomepage/serve.py +19 -0
  11. kick_agent-0.1.0/kick/functions/bash.py +51 -0
  12. kick_agent-0.1.0/kick/functions/config.py +2 -0
  13. kick_agent-0.1.0/kick/functions/edit.py +68 -0
  14. kick_agent-0.1.0/kick/functions/ls.py +41 -0
  15. kick_agent-0.1.0/kick/functions/read.py +49 -0
  16. kick_agent-0.1.0/kick/functions/test_edit.py +3 -0
  17. kick_agent-0.1.0/kick/functions/test_ls.py +7 -0
  18. kick_agent-0.1.0/kick/functions/test_read.py +7 -0
  19. kick_agent-0.1.0/kick/functions/test_write.py +16 -0
  20. kick_agent-0.1.0/kick/functions/write.py +41 -0
  21. kick_agent-0.1.0/kick/main.py +10 -0
  22. kick_agent-0.1.0/kick/prompts.py +57 -0
  23. kick_agent-0.1.0/kick/ui/app.py +155 -0
  24. kick_agent-0.1.0/kick/ui/providers.py +19 -0
  25. kick_agent-0.1.0/kick/ui/screens.py +45 -0
  26. kick_agent-0.1.0/kick/ui/utils.py +15 -0
  27. kick_agent-0.1.0/kick/ui/widgets.py +41 -0
  28. kick_agent-0.1.0/kick_agent.egg-info/PKG-INFO +63 -0
  29. kick_agent-0.1.0/kick_agent.egg-info/SOURCES.txt +33 -0
  30. kick_agent-0.1.0/kick_agent.egg-info/dependency_links.txt +1 -0
  31. kick_agent-0.1.0/kick_agent.egg-info/entry_points.txt +2 -0
  32. kick_agent-0.1.0/kick_agent.egg-info/requires.txt +4 -0
  33. kick_agent-0.1.0/kick_agent.egg-info/top_level.txt +1 -0
  34. kick_agent-0.1.0/pyproject.toml +15 -0
  35. 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,2 @@
1
+ class Config:
2
+ MAX_CHARS: int = 10000
@@ -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,3 @@
1
+ from edit import edit
2
+
3
+ print(edit("../examples/calculator/", "./lorem.txt", "wait", "hello"))
@@ -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,10 @@
1
+ from kick.ui.app import Kick
2
+
3
+
4
+ def main():
5
+ app = Kick()
6
+ app.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ kick = kick.main:main
@@ -0,0 +1,4 @@
1
+ pydantic-ai>=1.105.0
2
+ textual-dev>=1.8.0
3
+ textual[syntax]>=8.2.7
4
+ tomli-w>=1.2.0
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+