deleetify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deleetify/__init__.py +0 -0
- deleetify/__main__.py +8 -0
- deleetify/acp_client/client.py +161 -0
- deleetify/acp_client/client_handler.py +90 -0
- deleetify/acp_client/exceptions.py +5 -0
- deleetify/cli/exceptions.py +9 -0
- deleetify/cli/lang_model.py +10 -0
- deleetify/cli/model.py +19 -0
- deleetify/cli/parser.py +14 -0
- deleetify/file_name/exceptions.py +4 -0
- deleetify/file_name/parser.py +27 -0
- deleetify/main.py +63 -0
- deleetify/processor/config/exceptions.py +4 -0
- deleetify/processor/config/model.py +8 -0
- deleetify/processor/config/parser.py +107 -0
- deleetify/processor/leetcode/exceptions.py +9 -0
- deleetify/processor/leetcode/model.py +70 -0
- deleetify/processor/leetcode/parser.py +39 -0
- deleetify/processor/leetcode/request.py +37 -0
- deleetify/prompt/agent_prompt.py +25 -0
- deleetify-0.1.0.dist-info/METADATA +120 -0
- deleetify-0.1.0.dist-info/RECORD +24 -0
- deleetify-0.1.0.dist-info/WHEEL +4 -0
- deleetify-0.1.0.dist-info/entry_points.txt +2 -0
deleetify/__init__.py
ADDED
|
File without changes
|
deleetify/__main__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from typing import Any, override
|
|
2
|
+
|
|
3
|
+
from acp import (
|
|
4
|
+
Agent,
|
|
5
|
+
Client,
|
|
6
|
+
RequestError,
|
|
7
|
+
)
|
|
8
|
+
from acp.schema import (
|
|
9
|
+
AgentMessageChunk,
|
|
10
|
+
AgentPlanUpdate,
|
|
11
|
+
AgentThoughtChunk,
|
|
12
|
+
AudioContentBlock,
|
|
13
|
+
AvailableCommandsUpdate,
|
|
14
|
+
ConfigOptionUpdate,
|
|
15
|
+
CreateTerminalResponse,
|
|
16
|
+
CurrentModeUpdate,
|
|
17
|
+
EmbeddedResourceContentBlock,
|
|
18
|
+
EnvVariable,
|
|
19
|
+
ImageContentBlock,
|
|
20
|
+
KillTerminalResponse,
|
|
21
|
+
PermissionOption,
|
|
22
|
+
ReadTextFileResponse,
|
|
23
|
+
ReleaseTerminalResponse,
|
|
24
|
+
RequestPermissionResponse,
|
|
25
|
+
ResourceContentBlock,
|
|
26
|
+
SessionInfoUpdate,
|
|
27
|
+
TerminalOutputResponse,
|
|
28
|
+
TextContentBlock,
|
|
29
|
+
ToolCallProgress,
|
|
30
|
+
ToolCallStart,
|
|
31
|
+
ToolCallUpdate,
|
|
32
|
+
UsageUpdate,
|
|
33
|
+
UserMessageChunk,
|
|
34
|
+
WaitForTerminalExitResponse,
|
|
35
|
+
WriteTextFileResponse,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeleetifyClient(Client):
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
self.response: str = ""
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
async def request_permission(
|
|
48
|
+
self,
|
|
49
|
+
options: list[PermissionOption],
|
|
50
|
+
session_id: str,
|
|
51
|
+
tool_call: ToolCallUpdate,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> RequestPermissionResponse:
|
|
54
|
+
raise RequestError.method_not_found("session/request_permission")
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
async def write_text_file(
|
|
58
|
+
self, content: str, path: str, session_id: str, **kwargs: Any
|
|
59
|
+
) -> WriteTextFileResponse | None:
|
|
60
|
+
raise RequestError.method_not_found("fs/write_text_file")
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
async def read_text_file(
|
|
64
|
+
self,
|
|
65
|
+
path: str,
|
|
66
|
+
session_id: str,
|
|
67
|
+
limit: int | None = None,
|
|
68
|
+
line: int | None = None,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> ReadTextFileResponse:
|
|
71
|
+
raise RequestError.method_not_found("fs/read_text_file")
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
async def create_terminal(
|
|
75
|
+
self,
|
|
76
|
+
command: str,
|
|
77
|
+
session_id: str,
|
|
78
|
+
args: list[str] | None = None,
|
|
79
|
+
cwd: str | None = None,
|
|
80
|
+
env: list[EnvVariable] | None = None,
|
|
81
|
+
output_byte_limit: int | None = None,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> CreateTerminalResponse:
|
|
84
|
+
raise RequestError.method_not_found("terminal/create")
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
async def terminal_output(
|
|
88
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
89
|
+
) -> TerminalOutputResponse:
|
|
90
|
+
raise RequestError.method_not_found("terminal/output")
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
async def release_terminal(
|
|
94
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
95
|
+
) -> ReleaseTerminalResponse | None:
|
|
96
|
+
raise RequestError.method_not_found("terminal/release")
|
|
97
|
+
|
|
98
|
+
@override
|
|
99
|
+
async def wait_for_terminal_exit(
|
|
100
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
101
|
+
) -> WaitForTerminalExitResponse:
|
|
102
|
+
raise RequestError.method_not_found("terminal/wait_for_exit")
|
|
103
|
+
|
|
104
|
+
@override
|
|
105
|
+
async def kill_terminal(
|
|
106
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
107
|
+
) -> KillTerminalResponse | None:
|
|
108
|
+
raise RequestError.method_not_found("terminal/kill")
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
async def session_update(
|
|
112
|
+
self,
|
|
113
|
+
session_id: str,
|
|
114
|
+
update: (
|
|
115
|
+
UserMessageChunk
|
|
116
|
+
| AgentMessageChunk
|
|
117
|
+
| AgentThoughtChunk
|
|
118
|
+
| ToolCallStart
|
|
119
|
+
| ToolCallProgress
|
|
120
|
+
| AgentPlanUpdate
|
|
121
|
+
| AvailableCommandsUpdate
|
|
122
|
+
| CurrentModeUpdate
|
|
123
|
+
| ConfigOptionUpdate
|
|
124
|
+
| SessionInfoUpdate
|
|
125
|
+
| UsageUpdate
|
|
126
|
+
),
|
|
127
|
+
**kwargs: Any,
|
|
128
|
+
) -> None:
|
|
129
|
+
if not isinstance(update, AgentMessageChunk):
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
content = update.content
|
|
133
|
+
text: str = ""
|
|
134
|
+
|
|
135
|
+
if isinstance(content, TextContentBlock):
|
|
136
|
+
text = content.text
|
|
137
|
+
elif isinstance(content, ImageContentBlock):
|
|
138
|
+
text = "<image>"
|
|
139
|
+
elif isinstance(content, AudioContentBlock):
|
|
140
|
+
text = "<audio>"
|
|
141
|
+
elif isinstance(content, ResourceContentBlock):
|
|
142
|
+
text = content.uri or "<resource>"
|
|
143
|
+
elif isinstance(content, EmbeddedResourceContentBlock):
|
|
144
|
+
text = "<resource>"
|
|
145
|
+
else:
|
|
146
|
+
text = "<content>"
|
|
147
|
+
|
|
148
|
+
self.response += text
|
|
149
|
+
|
|
150
|
+
@override
|
|
151
|
+
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
raise RequestError.method_not_found(method)
|
|
153
|
+
|
|
154
|
+
@override
|
|
155
|
+
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
156
|
+
raise RequestError.method_not_found(method)
|
|
157
|
+
|
|
158
|
+
@override
|
|
159
|
+
def on_connect(self, conn: Agent) -> None: ...
|
|
160
|
+
|
|
161
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio.subprocess as aio_subprocess
|
|
3
|
+
import os
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from acp import (
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
NewSessionResponse,
|
|
9
|
+
connect_to_agent,
|
|
10
|
+
text_block,
|
|
11
|
+
)
|
|
12
|
+
from acp.core import ClientSideConnection
|
|
13
|
+
from acp.schema import (
|
|
14
|
+
ClientCapabilities,
|
|
15
|
+
Implementation,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import asyncio.subprocess as aio_subprocess
|
|
20
|
+
from deleetify.acp_client.client import DeleetifyClient
|
|
21
|
+
from deleetify.acp_client.exceptions import AgentProcessError
|
|
22
|
+
from deleetify.processor.config.model import Config
|
|
23
|
+
import asyncio.subprocess as aio_subprocess
|
|
24
|
+
|
|
25
|
+
async def create_agent_process(config: Config) -> aio_subprocess.Process:
|
|
26
|
+
program = config.command
|
|
27
|
+
spawn_program = program
|
|
28
|
+
spawn_args = config.args
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
proc = await asyncio.create_subprocess_exec(
|
|
32
|
+
spawn_program,
|
|
33
|
+
*spawn_args,
|
|
34
|
+
env=config.envs,
|
|
35
|
+
stdin=aio_subprocess.PIPE,
|
|
36
|
+
stdout=aio_subprocess.PIPE,
|
|
37
|
+
)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
raise AgentProcessError(
|
|
40
|
+
f"No agent {config.command} fount in system"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if proc.stdin is None or proc.stdout is None:
|
|
44
|
+
raise AgentProcessError(
|
|
45
|
+
f"Agent {config.command} process does not expose stdio pipes"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return proc
|
|
49
|
+
|
|
50
|
+
class AcpHandler:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
client: DeleetifyClient,
|
|
54
|
+
proc: aio_subprocess.Process,
|
|
55
|
+
conn: ClientSideConnection
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Keep __init__ synchronous. Only assign pre-resolved variables here."""
|
|
58
|
+
self.client: DeleetifyClient = client
|
|
59
|
+
self.proc: aio_subprocess.Process = proc
|
|
60
|
+
self.conn: ClientSideConnection = conn
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def create(
|
|
64
|
+
cls, client: DeleetifyClient, proc: aio_subprocess.Process
|
|
65
|
+
) -> AcpHandler:
|
|
66
|
+
conn: ClientSideConnection = connect_to_agent(
|
|
67
|
+
client, proc.stdin, proc.stdout
|
|
68
|
+
)
|
|
69
|
+
_ = await conn.initialize(
|
|
70
|
+
protocol_version=PROTOCOL_VERSION,
|
|
71
|
+
client_capabilities=ClientCapabilities(),
|
|
72
|
+
client_info=Implementation(
|
|
73
|
+
name="deleetify", title="Deleetify Client", version="0.1.0"
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return cls(client, proc, conn)
|
|
78
|
+
|
|
79
|
+
async def createSession(self) -> NewSessionResponse:
|
|
80
|
+
session = await self.conn.new_session(mcp_servers=[], cwd=os.getcwd())
|
|
81
|
+
return session
|
|
82
|
+
|
|
83
|
+
async def prompt(self, session_id: str, prompt: str) -> str:
|
|
84
|
+
response = await self.conn.prompt(
|
|
85
|
+
session_id=session_id,
|
|
86
|
+
prompt=[text_block(prompt)],
|
|
87
|
+
message_id=str(uuid4()),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return self.client.response
|
deleetify/cli/model.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import clap
|
|
3
|
+
from clap import arg
|
|
4
|
+
|
|
5
|
+
from deleetify.cli.lang_model import Lang
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@clap.command()
|
|
9
|
+
class Cli(clap.Parser):
|
|
10
|
+
"""
|
|
11
|
+
Makes local leetcode setup less painfull
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
"""Name of the leetcode problem [Example: `208. Implement Trie (Prefix Tree)`]"""
|
|
16
|
+
|
|
17
|
+
lang: Lang = arg(short=True, long=True, default_value=Lang.PYTHON3)
|
|
18
|
+
|
|
19
|
+
config: Path = arg(short=True, long=True, default_value=Path("deleetify.toml"))
|
deleetify/cli/parser.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .exceptions import ConfigNotFound, NotATomlFile
|
|
2
|
+
from .model import Cli
|
|
3
|
+
|
|
4
|
+
def parse_cli_values() -> Cli:
|
|
5
|
+
parsed = Cli.parse()
|
|
6
|
+
config = parsed.config
|
|
7
|
+
|
|
8
|
+
if not config.is_file():
|
|
9
|
+
raise ConfigNotFound(f"{config.name} not exist")
|
|
10
|
+
|
|
11
|
+
if not config.name.endswith('.toml'):
|
|
12
|
+
raise NotATomlFile(f"It is not a toml file: {config.name}")
|
|
13
|
+
|
|
14
|
+
return parsed
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from deleetify.file_name.exceptions import InvalidUserInput
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileNameProcessor:
|
|
9
|
+
file_name: str
|
|
10
|
+
|
|
11
|
+
def __init__(self, file_name: str) -> None:
|
|
12
|
+
self.file_name = file_name
|
|
13
|
+
|
|
14
|
+
def convert_to_kebab_case(self) -> Self:
|
|
15
|
+
s = re.sub(r'(?<!^)(?=[A-Z])', ' ', self.file_name)
|
|
16
|
+
s = s.lower()
|
|
17
|
+
s = re.sub(r'[^a-z0-9]+', '-', s)
|
|
18
|
+
self.file_name = s.strip('-')
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def split_number_and_name(self) -> tuple[str, str]:
|
|
22
|
+
matches = re.match(r'^(\d+)-([a-z0-9-]+)', self.file_name)
|
|
23
|
+
if matches is None:
|
|
24
|
+
raise InvalidUserInput(f"Leetcode name is not valid")
|
|
25
|
+
code = matches.group(1)
|
|
26
|
+
name = matches.group(2)
|
|
27
|
+
return (code, name)
|
deleetify/main.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Coroutine
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from deleetify.acp_client.client import DeleetifyClient
|
|
8
|
+
from deleetify.acp_client.client_handler import AcpHandler, create_agent_process
|
|
9
|
+
from .cli.parser import parse_cli_values
|
|
10
|
+
from deleetify.file_name.parser import FileNameProcessor
|
|
11
|
+
from deleetify.processor.config.parser import process_config_toml
|
|
12
|
+
from deleetify.processor.leetcode.parser import parse_leetcode
|
|
13
|
+
from deleetify.processor.leetcode.request import get_leetcode_response
|
|
14
|
+
from deleetify.prompt.agent_prompt import get_prompt
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def exception_then_log_exit[T](
|
|
20
|
+
callback: Callable[..., Coroutine[Any, Any, T]],
|
|
21
|
+
) -> Callable[..., Coroutine[Any, Any, T]]:
|
|
22
|
+
|
|
23
|
+
async def wrapper(*args, **kwargs) -> T:
|
|
24
|
+
try:
|
|
25
|
+
return await callback(*args, **kwargs)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logging.error(e)
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
return wrapper
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@exception_then_log_exit
|
|
34
|
+
async def main():
|
|
35
|
+
cli = parse_cli_values()
|
|
36
|
+
config = process_config_toml(cli.config)
|
|
37
|
+
|
|
38
|
+
code, leetcode_name = (
|
|
39
|
+
FileNameProcessor(cli.name).convert_to_kebab_case().split_number_and_name()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
leetcode_response = get_leetcode_response(leetcode_name)
|
|
43
|
+
leetcode_response = parse_leetcode(leetcode_response, cli.lang)
|
|
44
|
+
|
|
45
|
+
proc = await create_agent_process(config)
|
|
46
|
+
client_impl = DeleetifyClient()
|
|
47
|
+
|
|
48
|
+
client_handler = await AcpHandler.create(client_impl, proc)
|
|
49
|
+
|
|
50
|
+
session = await client_handler.createSession()
|
|
51
|
+
|
|
52
|
+
# Let the agent create file if needed
|
|
53
|
+
file_name = f"{code}-{leetcode_name}.{cli.lang.externsion()}"
|
|
54
|
+
|
|
55
|
+
prompt = get_prompt(file_name, leetcode_response)
|
|
56
|
+
agent_response = await client_handler.prompt(session.session_id, prompt)
|
|
57
|
+
print(f"{agent_response}")
|
|
58
|
+
|
|
59
|
+
proc.terminate()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import tomllib
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from deleetify.processor.config.exceptions import ConfigParseError
|
|
6
|
+
from deleetify.processor.config.model import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_toml(file: Path) -> dict[str, Any]:
|
|
10
|
+
with open(file.name, "rb") as f:
|
|
11
|
+
return tomllib.load(f)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def select_provider_name(
|
|
15
|
+
providers: dict[str, Any], content: dict[str, Any], path: Path
|
|
16
|
+
) -> str:
|
|
17
|
+
provider_keys = providers.keys()
|
|
18
|
+
if len(provider_keys) > 1:
|
|
19
|
+
if "default" not in content.keys():
|
|
20
|
+
raise ConfigParseError(f"multiple provider found specify `default` key")
|
|
21
|
+
|
|
22
|
+
default_key = content["default"]
|
|
23
|
+
if not isinstance(default_key, dict):
|
|
24
|
+
raise ConfigParseError(
|
|
25
|
+
f"default key must contain key-value pair in toml: {path.name}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if "provider" not in default_key:
|
|
29
|
+
raise ConfigParseError(f"No provider key in default")
|
|
30
|
+
default_provider = default_key["provider"]
|
|
31
|
+
|
|
32
|
+
if not isinstance(default_provider, str):
|
|
33
|
+
raise ConfigParseError(
|
|
34
|
+
f"provider inside default must contain string but {default_provider}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if default_provider not in providers:
|
|
38
|
+
raise ConfigParseError(f"default provider must be configured")
|
|
39
|
+
|
|
40
|
+
return default_provider
|
|
41
|
+
|
|
42
|
+
if len(provider_keys) == 0:
|
|
43
|
+
raise ConfigParseError(f"No providers defined")
|
|
44
|
+
|
|
45
|
+
selected_provider_name = ""
|
|
46
|
+
for provider_name in provider_keys:
|
|
47
|
+
selected_provider_name = provider_name
|
|
48
|
+
|
|
49
|
+
return selected_provider_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_provider(provider_name: str, providers: dict[str, Any]) -> Config:
|
|
53
|
+
provider = providers[provider_name]
|
|
54
|
+
if not isinstance(provider, dict):
|
|
55
|
+
raise ConfigParseError(f"Provider {provider_name} config is not valid")
|
|
56
|
+
|
|
57
|
+
provider_keys = provider.keys()
|
|
58
|
+
if "command" not in provider_keys:
|
|
59
|
+
raise ConfigParseError(f"command key in {provider_name} is missing")
|
|
60
|
+
|
|
61
|
+
if not isinstance(provider["command"], str):
|
|
62
|
+
raise ConfigParseError(f"command key is not valid in provider {provider_name}")
|
|
63
|
+
|
|
64
|
+
args: list[str] = []
|
|
65
|
+
if "args" in provider_keys:
|
|
66
|
+
if not isinstance(provider["args"], list):
|
|
67
|
+
raise ConfigParseError(f"args key is not valid in provider {provider_name}")
|
|
68
|
+
for value in provider["args"]:
|
|
69
|
+
if not isinstance(value, str):
|
|
70
|
+
raise ConfigParseError(f"args item `{value}` is not valid in provider {provider_name}")
|
|
71
|
+
|
|
72
|
+
args = provider["args"]
|
|
73
|
+
|
|
74
|
+
envs: dict[str, str] = {}
|
|
75
|
+
if "envs" in provider_keys:
|
|
76
|
+
if not isinstance(provider["envs"], dict):
|
|
77
|
+
raise ConfigParseError(f"envs key is not valid in provider {provider_name}")
|
|
78
|
+
for key, value in provider["envs"].items():
|
|
79
|
+
if not isinstance(key, str):
|
|
80
|
+
raise ConfigParseError(
|
|
81
|
+
f"envs key `{key}` is not valid in provider {provider_name}"
|
|
82
|
+
)
|
|
83
|
+
if not isinstance(value, str):
|
|
84
|
+
raise ConfigParseError(
|
|
85
|
+
f"envs value `{value}` for `{key}` is not valid in provider {provider_name}"
|
|
86
|
+
)
|
|
87
|
+
envs[key] = value
|
|
88
|
+
|
|
89
|
+
config = Config(provider["command"], args, envs)
|
|
90
|
+
return config
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def process_config_toml(path: Path) -> Config:
|
|
94
|
+
content = read_toml(path)
|
|
95
|
+
|
|
96
|
+
if "provider" not in content.keys():
|
|
97
|
+
raise ConfigParseError(f"provider key not found in toml: {path.name}")
|
|
98
|
+
|
|
99
|
+
providers = content["provider"]
|
|
100
|
+
if not isinstance(providers, dict):
|
|
101
|
+
raise ConfigParseError(
|
|
102
|
+
f"provider key must contain key-value pair in toml: {path.name}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
provider_name = select_provider_name(providers, content, path)
|
|
106
|
+
config = parse_provider(provider_name, providers)
|
|
107
|
+
return config
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TopicTag(BaseModel):
|
|
6
|
+
name: str
|
|
7
|
+
slug: str
|
|
8
|
+
translated_name: Any = Field(..., alias='translatedName')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodeSnippet(BaseModel):
|
|
12
|
+
lang: str
|
|
13
|
+
lang_slug: str = Field(..., alias='langSlug')
|
|
14
|
+
code: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Solution(BaseModel):
|
|
18
|
+
id: str
|
|
19
|
+
can_see_detail: bool = Field(..., alias='canSeeDetail')
|
|
20
|
+
paid_only: bool = Field(..., alias='paidOnly')
|
|
21
|
+
has_video_solution: bool = Field(..., alias='hasVideoSolution')
|
|
22
|
+
paid_only_video: bool = Field(..., alias='paidOnlyVideo')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Question(BaseModel):
|
|
26
|
+
question_id: str = Field(..., alias='questionId')
|
|
27
|
+
question_frontend_id: str = Field(..., alias='questionFrontendId')
|
|
28
|
+
bound_topic_id: Any = Field(..., alias='boundTopicId')
|
|
29
|
+
title: str
|
|
30
|
+
title_slug: str = Field(..., alias='titleSlug')
|
|
31
|
+
content: str
|
|
32
|
+
translated_title: Any = Field(..., alias='translatedTitle')
|
|
33
|
+
translated_content: Any = Field(..., alias='translatedContent')
|
|
34
|
+
is_paid_only: bool = Field(..., alias='isPaidOnly')
|
|
35
|
+
difficulty: str
|
|
36
|
+
likes: int
|
|
37
|
+
dislikes: int
|
|
38
|
+
is_liked: Any = Field(..., alias='isLiked')
|
|
39
|
+
similar_questions: str = Field(..., alias='similarQuestions')
|
|
40
|
+
example_testcases: str = Field(..., alias='exampleTestcases')
|
|
41
|
+
contributors: list[Any]
|
|
42
|
+
topic_tags: list[TopicTag] = Field(..., alias='topicTags')
|
|
43
|
+
company_tag_stats: Any = Field(..., alias='companyTagStats')
|
|
44
|
+
code_snippets: list[CodeSnippet] = Field(..., alias='codeSnippets')
|
|
45
|
+
stats: str
|
|
46
|
+
hints: list[str]
|
|
47
|
+
solution: Solution
|
|
48
|
+
status: Any
|
|
49
|
+
sample_test_case: str = Field(..., alias='sampleTestCase')
|
|
50
|
+
meta_data: str = Field(..., alias='metaData')
|
|
51
|
+
judger_available: bool = Field(..., alias='judgerAvailable')
|
|
52
|
+
judge_type: str = Field(..., alias='judgeType')
|
|
53
|
+
mysql_schemas: list[Any] = Field(..., alias='mysqlSchemas')
|
|
54
|
+
enable_run_code: bool = Field(..., alias='enableRunCode')
|
|
55
|
+
enable_test_mode: bool = Field(..., alias='enableTestMode')
|
|
56
|
+
enable_debugger: bool = Field(..., alias='enableDebugger')
|
|
57
|
+
env_info: str = Field(..., alias='envInfo')
|
|
58
|
+
library_url: Any = Field(..., alias='libraryUrl')
|
|
59
|
+
admin_url: Any = Field(..., alias='adminUrl')
|
|
60
|
+
challenge_question: Any = Field(..., alias='challengeQuestion')
|
|
61
|
+
note: Any
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LeetCodeResponse(BaseModel):
|
|
65
|
+
question: Question
|
|
66
|
+
|
|
67
|
+
class LeetCode(BaseModel):
|
|
68
|
+
description: str
|
|
69
|
+
examples: list[str]
|
|
70
|
+
starter_code: str
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from deleetify.cli.lang_model import Lang
|
|
2
|
+
from deleetify.processor.leetcode.exceptions import LeetCodeRequestParseError
|
|
3
|
+
from deleetify.processor.leetcode.model import LeetCode, LeetCodeResponse
|
|
4
|
+
from bs4 import BeautifulSoup # type: ignore[import]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_leetcode(leetcode_response: LeetCodeResponse, lang: Lang) -> LeetCode:
|
|
8
|
+
html_content = leetcode_response.question.content
|
|
9
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
10
|
+
|
|
11
|
+
desciption = ""
|
|
12
|
+
for p in soup.find_all("p"):
|
|
13
|
+
# Skip empty paragraphs, "Constraints", and "Example X:" placeholders
|
|
14
|
+
text = p.get_text(strip=True)
|
|
15
|
+
if not text or "Constraints:" in text or "Example" in text:
|
|
16
|
+
continue
|
|
17
|
+
# Print the clean text of the description
|
|
18
|
+
desciption = p.get_text().strip()
|
|
19
|
+
|
|
20
|
+
examples: list[str] = []
|
|
21
|
+
for pre in soup.find_all("pre"):
|
|
22
|
+
# .get_text() extracts text inside <pre>, <strong>, etc., cleanly
|
|
23
|
+
# we split by lines and remove unnecessary leading/trailing spaces
|
|
24
|
+
lines = [line.strip() for line in pre.get_text().split("\\n") if line.strip()]
|
|
25
|
+
|
|
26
|
+
examples.append("\n".join(lines))
|
|
27
|
+
|
|
28
|
+
code_snp_list = list(
|
|
29
|
+
filter(lambda x: x.lang_slug == str(lang), leetcode_response.question.code_snippets)
|
|
30
|
+
)
|
|
31
|
+
if len(code_snp_list) == 0:
|
|
32
|
+
raise LeetCodeRequestParseError(f"Leetcode dont support {lang}")
|
|
33
|
+
|
|
34
|
+
code_snp = code_snp_list[0]
|
|
35
|
+
starter_code = code_snp.code
|
|
36
|
+
|
|
37
|
+
return LeetCode(
|
|
38
|
+
description=desciption, examples=examples, starter_code=starter_code
|
|
39
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import httpx # type: ignore[import]
|
|
3
|
+
from pydantic import ValidationError # type: ignore[import]
|
|
4
|
+
from deleetify.processor.leetcode.exceptions import (
|
|
5
|
+
LeetCodeRequestError,
|
|
6
|
+
LeetCodeRequestParseError,
|
|
7
|
+
)
|
|
8
|
+
from deleetify.processor.leetcode.model import LeetCodeResponse
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://alfa-leetcode-api.onrender.com/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_question_url(name: str):
|
|
16
|
+
return BASE_URL + f"select/raw?titleSlug={name}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_leetcode_response(name: str) -> LeetCodeResponse:
|
|
20
|
+
logging.info(f"Getting problem {name} from leetcode")
|
|
21
|
+
try:
|
|
22
|
+
response = httpx.get(get_question_url(name))
|
|
23
|
+
_ = response.raise_for_status()
|
|
24
|
+
except httpx.HTTPError as e:
|
|
25
|
+
logging.error(f"{e}")
|
|
26
|
+
raise LeetCodeRequestError(
|
|
27
|
+
"Something went wrong while fetching leetcode problem"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
response_json = response.json()
|
|
32
|
+
validated_resp = LeetCodeResponse.model_validate(response_json)
|
|
33
|
+
except ValidationError:
|
|
34
|
+
raise LeetCodeRequestParseError(
|
|
35
|
+
"Something went wrong in parsing leetcode response"
|
|
36
|
+
)
|
|
37
|
+
return validated_resp
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from deleetify.processor.leetcode.model import LeetCode
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_prompt(file_path: str, leetcode: LeetCode) -> str:
|
|
5
|
+
prompt = f"""
|
|
6
|
+
Act as a raw text generator for an automated file-writing agent. Generate a clean, modern boilerplate script for the LeetCode problem description provided below, written in the target language of the starter code.
|
|
7
|
+
|
|
8
|
+
Follow these structural requirements strictly:
|
|
9
|
+
1. Include all necessary standard library imports and use modern idiomatic type definitions for the target language (avoid deprecated types or legacy wrappers like in python use `list`, `dict`, and `type | None` instead of 'List', 'Dict', 'Optional' respectively).
|
|
10
|
+
2. Inside the primary class/module, implement a static method named bootstrap (or equivalent global runner function if the language is non-OOP). This method must accept parameters representing a single test case, instantiate the solution class, call the target function with those inputs, and clean-print both the inputs and the returned results to standard output.
|
|
11
|
+
3. Keep the target solution method completely unimplemented (stubbed with a basic return or empty block), matching the signature of the original LeetCode starter code.
|
|
12
|
+
4. Call the bootstrap method individually for each separate example test case at the entry point of the script (e.g., if there are 3 example test cases, call the bootstrap runner 3 distinct times with the respective arguments).
|
|
13
|
+
|
|
14
|
+
Write this to @{file_path}
|
|
15
|
+
|
|
16
|
+
Here is the problem details:
|
|
17
|
+
{leetcode.description}
|
|
18
|
+
|
|
19
|
+
{leetcode.examples}
|
|
20
|
+
|
|
21
|
+
Starter code:
|
|
22
|
+
{leetcode.starter_code}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
return prompt
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deleetify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Makes local leetcode setup less painfull
|
|
5
|
+
Author-email: "Dharshan.S" <me.dharshan.1@gmail.com>
|
|
6
|
+
Maintainer-email: "Dharshan.S" <me.dharshan.1@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Requires-Dist: agent-client-protocol>=0.10.1
|
|
15
|
+
Requires-Dist: beautifulsoup4>=4.15.0
|
|
16
|
+
Requires-Dist: httpx>=0.28.1
|
|
17
|
+
Requires-Dist: typed-clap>=0.11.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Deleetify
|
|
21
|
+
|
|
22
|
+
> Makes local LeetCode setup less painful.
|
|
23
|
+
|
|
24
|
+
A CLI tool that fetches any LeetCode problem and uses an AI agent to generate a ready-to-run boilerplate solution file — so you can jump straight into solving, not scaffolding.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Fetch any LeetCode problem by name
|
|
29
|
+
- AI-generated boilerplate via any ACP-compatible agent (e.g., opencode, claude-code)
|
|
30
|
+
- Supports Python and JavaScript starter code
|
|
31
|
+
- Configurable AI provider via TOML
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install deleetify
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv tool install deleetify
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Basic usage — generates Python boilerplate
|
|
49
|
+
deleetify "208. Implement Trie (Prefix Tree)"
|
|
50
|
+
|
|
51
|
+
# Specify language
|
|
52
|
+
deleetify "1. Two Sum" --lang javascript
|
|
53
|
+
|
|
54
|
+
# Custom config path
|
|
55
|
+
deleetify "15. 3Sum" --config ./my-agent.toml
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Deleetify uses a `deleetify.toml` to configure the AI provider. By default it looks for the file in the current directory.
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[provider.opencode]
|
|
64
|
+
command = "opencode"
|
|
65
|
+
args = ["acp"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you have multiple providers, set a default:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[default]
|
|
72
|
+
provider = "opencode"
|
|
73
|
+
|
|
74
|
+
[provider.opencode]
|
|
75
|
+
command = "opencode"
|
|
76
|
+
args = ["acp"]
|
|
77
|
+
|
|
78
|
+
[provider.gemini]
|
|
79
|
+
command = "gemini"
|
|
80
|
+
args = ["--acp"]
|
|
81
|
+
# envs = {SOME_ENV = "value"}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
1. You pass a LeetCode problem name (e.g., `"208. Implement Trie (Prefix Tree)"`)
|
|
87
|
+
2. Deleetify parses the name and fetches the problem from the LeetCode API
|
|
88
|
+
3. It scrapes the description, examples, and starter code from the HTML response
|
|
89
|
+
4. It spawns an AI agent subprocess over the Agent Communication Protocol (ACP)
|
|
90
|
+
5. The agent receives a prompt with the problem details and generates boilerplate code with built-in test cases
|
|
91
|
+
6. The generated code is printed to stdout — pipe it to a file or your editor
|
|
92
|
+
|
|
93
|
+
## Supported languages
|
|
94
|
+
|
|
95
|
+
| CLI flag | Language |
|
|
96
|
+
|--------------|------------|
|
|
97
|
+
| `python3` | Python 3 |
|
|
98
|
+
| `javascript` | JavaScript |
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- Python 3.13+
|
|
103
|
+
- An ACP-compatible agent (e.g., [opencode](https://opencode.ai))
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
git clone https://github.com/<user>/deleetify
|
|
109
|
+
cd deleetify
|
|
110
|
+
|
|
111
|
+
uv venv
|
|
112
|
+
|
|
113
|
+
uv sync
|
|
114
|
+
|
|
115
|
+
uv run deleetify "217. Contains Duplicate"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
deleetify/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
deleetify/__main__.py,sha256=mao8uxciL-kURY_g9RljZZbidiwxcQKq5LkA6l5J8aY,129
|
|
3
|
+
deleetify/main.py,sha256=0MRY_4d2WfppyJunDQQ02jlZUYaTPcIxZEQonu-TVc4,1899
|
|
4
|
+
deleetify/acp_client/client.py,sha256=wBZx3t-daQvoiKUzgb6aG1dcLMxJh7uzmh_Dix1M7Qw,4473
|
|
5
|
+
deleetify/acp_client/client_handler.py,sha256=U7aJLxYoj29poXznchKaW91mcdv-Ek5Ap3YOjV_JgsM,2739
|
|
6
|
+
deleetify/acp_client/exceptions.py,sha256=yd2o-DcKWmv1XBrBFp4Y78QoyJPFiUDNOvfwp5jvBE8,118
|
|
7
|
+
deleetify/cli/exceptions.py,sha256=LOrLU7zN7jw_5JokYKaP52UgmQs2pAA5Ww49O6wbURk,227
|
|
8
|
+
deleetify/cli/lang_model.py,sha256=vCQA15aSkZy0lK1ZRi-3oE00WmTKroHub-NG5oE3n-I,241
|
|
9
|
+
deleetify/cli/model.py,sha256=n4cMlCl9SzLyB9lcAFeE80Rp2wg0JocSnoYgaWoq2Kw,463
|
|
10
|
+
deleetify/cli/parser.py,sha256=E6ZOka5Zn0xojWFSip28qIeTf-OjZzDeKhmTG34qrM0,377
|
|
11
|
+
deleetify/file_name/exceptions.py,sha256=XrUkKBM4MWXBJyLcDD39D-1QPvlpczZMOwIHzWYYjCM,116
|
|
12
|
+
deleetify/file_name/parser.py,sha256=kgUt9drm2_fxDNqMqry5plU281f-zmRcA87yQa-t2Ig,764
|
|
13
|
+
deleetify/processor/config/exceptions.py,sha256=39-Zz3e1bXf9j80lXISkb0bnXFPQwuVEkbM-DSt9j3g,116
|
|
14
|
+
deleetify/processor/config/model.py,sha256=TeVUIGQNYyi1Mto37buphQrCiNmcNaPBGtFzIJQuBoI,123
|
|
15
|
+
deleetify/processor/config/parser.py,sha256=eK0haKcBUh4CvyeooLgU9LEjhPXB1QnYPGoiuWb8K9w,3781
|
|
16
|
+
deleetify/processor/leetcode/exceptions.py,sha256=Y0oGxScckK35k6w5wHsU440fA1dpYEoDEx8MNEVmakY,246
|
|
17
|
+
deleetify/processor/leetcode/model.py,sha256=XKY7TdLXnmkSch-Gsnbo3B4Ys2KuS4xiPEdMCEjAcGM,2465
|
|
18
|
+
deleetify/processor/leetcode/parser.py,sha256=tCZBB6VMPXhOsYhJfTf4Gm-7EnvN2z0NYDJ8G-qmcUA,1537
|
|
19
|
+
deleetify/processor/leetcode/request.py,sha256=Lm-rtwpxr0_NdJnDFIcvlodfsW5x7QA-cPkfPmgZGC8,1155
|
|
20
|
+
deleetify/prompt/agent_prompt.py,sha256=r0PlMgFLKzs_RXBKtPoO1Iz0LzXkB1oDcARXk265AC4,1592
|
|
21
|
+
deleetify-0.1.0.dist-info/METADATA,sha256=JTyGv_xp6vUnm9r2wgnnrBN6ys69mHGPUW0msxDRJEA,2891
|
|
22
|
+
deleetify-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
23
|
+
deleetify-0.1.0.dist-info/entry_points.txt,sha256=7N8wmMTXvJxKa44Ws1y7ry2nmndVe5_uoovJ_9BSKcM,54
|
|
24
|
+
deleetify-0.1.0.dist-info/RECORD,,
|