opalacoder 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.
- opalacoder/__init__.py +2 -0
- opalacoder/agents.py +233 -0
- opalacoder/agents.yaml +78 -0
- opalacoder/api_keys.py +75 -0
- opalacoder/cli.py +339 -0
- opalacoder/cli_commands.py +277 -0
- opalacoder/config.py +215 -0
- opalacoder/embeddings.py +85 -0
- opalacoder/i18n.py +249 -0
- opalacoder/orchestrator.py +381 -0
- opalacoder/planner.py +206 -0
- opalacoder/project.py +196 -0
- opalacoder/session.py +4 -0
- opalacoder/skills/generaldeveloper.md +52 -0
- opalacoder/skills/html_css_js.md +51 -0
- opalacoder/skills/opalacoder.md +37 -0
- opalacoder/skills/python_subprocess.md +11 -0
- opalacoder/skills/react_vite.md +6 -0
- opalacoder/skills.py +184 -0
- opalacoder/structured.py +113 -0
- opalacoder/terminal.py +186 -0
- opalacoder/tools.py +351 -0
- opalacoder/vcs.py +254 -0
- opalacoder-0.1.0.dist-info/METADATA +230 -0
- opalacoder-0.1.0.dist-info/RECORD +27 -0
- opalacoder-0.1.0.dist-info/WHEEL +4 -0
- opalacoder-0.1.0.dist-info/entry_points.txt +2 -0
opalacoder/cli.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""OpalaCoder CLI – entry point."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .config import DEFAULT_MODEL, ALTERNATIVE_MODEL, DEFAULT_MAX_RETRIES, DEFAULT_MODE, DEFAULT_DB_PATH, DEFAULT_LANG
|
|
10
|
+
from .project import ProjectStore, ProjectData
|
|
11
|
+
from .agents import make_chat_memgpt_agent, make_intent_classifier, make_complexity_evaluator
|
|
12
|
+
from .api_keys import ensure_api_key
|
|
13
|
+
from . import terminal as T
|
|
14
|
+
from agenticblocks.blocks.llm.agent import AgentInput
|
|
15
|
+
from .i18n import _, set_lang
|
|
16
|
+
from rich.markup import escape as _escape
|
|
17
|
+
from .cli_commands import REPLState, _registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _inject_project(project: ProjectData, prompt: str) -> str:
|
|
21
|
+
"""Prepend project context to every prompt sent to agents."""
|
|
22
|
+
return project.context_header() + prompt
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ─── Project startup menu ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
async def startup_menu(store: ProjectStore, args) -> ProjectData:
|
|
28
|
+
"""Show the project selection/creation menu and return a ready ProjectData."""
|
|
29
|
+
projects = store.list_projects()
|
|
30
|
+
|
|
31
|
+
if projects:
|
|
32
|
+
options = ["Create new project"] + [
|
|
33
|
+
f"{p['project_name'] or p['name']} [{p['project_path']}]"
|
|
34
|
+
for p in projects
|
|
35
|
+
]
|
|
36
|
+
choice = T.choose("What would you like to do?", options[:4] if len(options) > 4 else options)
|
|
37
|
+
if choice == "Create new project":
|
|
38
|
+
return await _create_project(store, args)
|
|
39
|
+
else:
|
|
40
|
+
idx = options.index(choice) - 1
|
|
41
|
+
name = projects[idx]["name"]
|
|
42
|
+
project = store.load(name)
|
|
43
|
+
project.mode = args.mode
|
|
44
|
+
project.model = args.model
|
|
45
|
+
store.save(project)
|
|
46
|
+
T.success(f"Project '{project.project_name or project.name}' loaded.")
|
|
47
|
+
return project
|
|
48
|
+
else:
|
|
49
|
+
T.info("No projects found. Let's create your first one.")
|
|
50
|
+
return await _create_project(store, args)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _create_project(store: ProjectStore, args) -> ProjectData:
|
|
54
|
+
"""Interactively create a new project and select its skills via LLM."""
|
|
55
|
+
from .skills import select_skills_for_project, load_skills
|
|
56
|
+
|
|
57
|
+
project_name = T.ask("Project name").strip() or "default"
|
|
58
|
+
cwd = os.getcwd()
|
|
59
|
+
entered_path = T.ask(f"Project path [{cwd}]").strip()
|
|
60
|
+
project_path = os.path.abspath(entered_path if entered_path else cwd)
|
|
61
|
+
|
|
62
|
+
if not os.path.exists(project_path):
|
|
63
|
+
os.makedirs(project_path, exist_ok=True)
|
|
64
|
+
T.success(f"Directory created: {project_path}")
|
|
65
|
+
|
|
66
|
+
description = T.ask("Brief project description (used to select skills)").strip()
|
|
67
|
+
|
|
68
|
+
all_skills = load_skills(project_path)
|
|
69
|
+
available = [s["name"] for s in all_skills if s["name"] != "opalacoder"]
|
|
70
|
+
T.info(f"Available skills: opalacoder (default), {', '.join(available) if available else '(none)'}")
|
|
71
|
+
|
|
72
|
+
if description and available:
|
|
73
|
+
with T.spinner(_("selecting_skills")):
|
|
74
|
+
chosen_skills = await select_skills_for_project(args.model, description, project_path)
|
|
75
|
+
else:
|
|
76
|
+
chosen_skills = ["opalacoder"]
|
|
77
|
+
|
|
78
|
+
T.success(f"Skills selected: {', '.join(chosen_skills)}")
|
|
79
|
+
|
|
80
|
+
db_key = project_name.replace(" ", "_").lower()
|
|
81
|
+
if store.exists(db_key):
|
|
82
|
+
db_key = db_key + "_1"
|
|
83
|
+
|
|
84
|
+
project = store.create(
|
|
85
|
+
name=db_key,
|
|
86
|
+
mode=args.mode,
|
|
87
|
+
model=args.model,
|
|
88
|
+
project_name=project_name,
|
|
89
|
+
project_path=project_path,
|
|
90
|
+
skills=chosen_skills,
|
|
91
|
+
description=description,
|
|
92
|
+
)
|
|
93
|
+
T.success(f"Project '{project_name}' created.")
|
|
94
|
+
return project
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─── REPL Loop ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async def repl_loop(project: ProjectData, store: ProjectStore, max_retries: int) -> None:
|
|
100
|
+
from .tools import set_project_path
|
|
101
|
+
from .skills import load_project_skills
|
|
102
|
+
|
|
103
|
+
set_project_path(project.project_path)
|
|
104
|
+
project_skills = load_project_skills(project.project_path, project.skills)
|
|
105
|
+
|
|
106
|
+
T.section(f"Active Project: {_escape(project.project_name or project.name)}")
|
|
107
|
+
T.console.print(f" [dim]Path: {_escape(project.project_path)}[/dim]")
|
|
108
|
+
T.console.print(f" [dim]Skills: {', '.join(project.skills)}[/dim]")
|
|
109
|
+
|
|
110
|
+
chat_agent = make_chat_memgpt_agent(project.model)
|
|
111
|
+
state = REPLState(project, store, project_skills, chat_agent)
|
|
112
|
+
|
|
113
|
+
if state.project.request and state.project.plan_text and not state.project.results:
|
|
114
|
+
T.warning(_("pending_demand", request=state.project.request[:50]))
|
|
115
|
+
choice = T.choose(_("resume_or_clear"), [_("resume"), _("clear")])
|
|
116
|
+
if choice == _("resume"):
|
|
117
|
+
await run_pipeline(state.project, store, max_retries, project_skills=state.project_skills)
|
|
118
|
+
else:
|
|
119
|
+
state.project.clear_state()
|
|
120
|
+
store.save(state.project)
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
user_input = T.ask(f"OpalaCoder ({state.display_name})")
|
|
125
|
+
if not user_input:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if user_input.startswith("/"):
|
|
129
|
+
cmd, *args = user_input.split(maxsplit=1)
|
|
130
|
+
if cmd not in _registry:
|
|
131
|
+
T.error(_("unknown_command", cmd=cmd))
|
|
132
|
+
continue
|
|
133
|
+
result = await _registry.dispatch(state, cmd, args)
|
|
134
|
+
if result == "break":
|
|
135
|
+
break
|
|
136
|
+
elif result == "continue":
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
_VALID_INTENTS = {"greetings", "question", "plan", "chat", "command_hint"}
|
|
141
|
+
classifier = make_intent_classifier(state.project.model)
|
|
142
|
+
with T.spinner(_("agent_thinking")):
|
|
143
|
+
intent_res = await classifier.run(AgentInput(prompt=user_input))
|
|
144
|
+
_raw = intent_res.response.strip().lower()
|
|
145
|
+
intent = _raw.split()[0].rstrip(".,!?") if _raw else ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if not intent or intent not in _VALID_INTENTS:
|
|
149
|
+
T.console.print(f"[yellow]{_('intent_unclear')}[/yellow]")
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if intent == "command_hint":
|
|
153
|
+
cmd_word = user_input.strip().split()[0].lower()
|
|
154
|
+
T.console.print(f"[yellow]{_('command_hint_suggestion', cmd=cmd_word)}[/yellow]")
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if intent == "plan":
|
|
158
|
+
if state.project.results or state.project.request:
|
|
159
|
+
state.project.clear_state()
|
|
160
|
+
store.save(state.project)
|
|
161
|
+
|
|
162
|
+
if state.project.model.startswith("ollama/"):
|
|
163
|
+
complexity = "alternative" if len(user_input.split()) > 200 else "default"
|
|
164
|
+
else:
|
|
165
|
+
complexity_evaluator = make_complexity_evaluator(state.project.model)
|
|
166
|
+
with T.spinner(_("evaluating_complexity")):
|
|
167
|
+
comp_res = await complexity_evaluator.run(AgentInput(prompt=user_input))
|
|
168
|
+
raw_comp = comp_res.response.strip().lower()
|
|
169
|
+
if "alternative" in raw_comp:
|
|
170
|
+
complexity = "alternative"
|
|
171
|
+
else:
|
|
172
|
+
complexity = "default"
|
|
173
|
+
|
|
174
|
+
if complexity == "alternative":
|
|
175
|
+
if ensure_api_key(ALTERNATIVE_MODEL):
|
|
176
|
+
T.info(_("routing_complex_task", model=ALTERNATIVE_MODEL))
|
|
177
|
+
active_model = ALTERNATIVE_MODEL
|
|
178
|
+
else:
|
|
179
|
+
T.warning(_("api_key_missing_fallback", model=state.project.model))
|
|
180
|
+
active_model = state.project.model
|
|
181
|
+
else:
|
|
182
|
+
active_model = state.project.model
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
await run_pipeline(state.project, store, max_retries, request=user_input, active_model=active_model, project_skills=state.project_skills)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
if active_model != state.project.model:
|
|
188
|
+
T.error(_("alt_model_error", model=active_model, err=e))
|
|
189
|
+
T.info(_("fallback_to_model", model=state.project.model))
|
|
190
|
+
if state.project.results or state.project.request:
|
|
191
|
+
state.project.clear_state()
|
|
192
|
+
store.save(state.project)
|
|
193
|
+
await run_pipeline(state.project, store, max_retries, request=user_input, active_model=state.project.model, project_skills=state.project_skills)
|
|
194
|
+
else:
|
|
195
|
+
raise
|
|
196
|
+
else:
|
|
197
|
+
with T.spinner(_("agent_thinking")):
|
|
198
|
+
response = await state.chat_agent.run(AgentInput(prompt=_inject_project(state.project, user_input)))
|
|
199
|
+
answer = response.response.strip() if response.response else "(no response)"
|
|
200
|
+
T.console.print(f"\n[bold green]OpalaCoder:[/bold green] {answer}\n")
|
|
201
|
+
store.append_message(state.project, "user", user_input)
|
|
202
|
+
store.append_message(state.project, "assistant", answer)
|
|
203
|
+
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
T.info(_("repl_interrupted"))
|
|
206
|
+
break
|
|
207
|
+
except EOFError:
|
|
208
|
+
T.info(_("exiting"))
|
|
209
|
+
break
|
|
210
|
+
except T.UserCancelled:
|
|
211
|
+
T.info(_("repl_cancelled"))
|
|
212
|
+
state.project.clear_state()
|
|
213
|
+
store.save(state.project)
|
|
214
|
+
except T.AppExit:
|
|
215
|
+
T.info(_("exiting"))
|
|
216
|
+
break
|
|
217
|
+
except Exception as e:
|
|
218
|
+
T.error(_("unexpected_error", err=e))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def run_pipeline(
|
|
222
|
+
project: ProjectData,
|
|
223
|
+
store: ProjectStore,
|
|
224
|
+
max_retries: int,
|
|
225
|
+
request: str = None,
|
|
226
|
+
active_model: str = None,
|
|
227
|
+
project_skills: list = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
model = active_model or project.model
|
|
230
|
+
T.info(_("using_model", model=model))
|
|
231
|
+
|
|
232
|
+
if not request:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
T.section(_("new_demand"))
|
|
236
|
+
store.append_message(project, "user", request)
|
|
237
|
+
project.request = request
|
|
238
|
+
store.save(project)
|
|
239
|
+
|
|
240
|
+
hist_text = ""
|
|
241
|
+
for msg in project.history[-10:-1]:
|
|
242
|
+
role = "Assistant" if msg["role"] == "assistant" else "User"
|
|
243
|
+
hist_text += f"{role}: {msg['content']}\n"
|
|
244
|
+
|
|
245
|
+
from .orchestrator import AutonomousOrchestratorStrategy
|
|
246
|
+
from .skills import get_relevant_skills_llm, SCOPE_ORCHESTRATOR
|
|
247
|
+
|
|
248
|
+
orchestrator_skills = await get_relevant_skills_llm(
|
|
249
|
+
model, request, scope=SCOPE_ORCHESTRATOR, project_skills=project_skills
|
|
250
|
+
)
|
|
251
|
+
enriched_request = request
|
|
252
|
+
if orchestrator_skills:
|
|
253
|
+
enriched_request = (
|
|
254
|
+
f"[SKILLS CONTEXT]:\n{orchestrator_skills}\n[END SKILLS CONTEXT]\n\n"
|
|
255
|
+
f"[USER REQUEST]:\n{request}\n[END USER REQUEST]"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
orchestrator = AutonomousOrchestratorStrategy(model=model)
|
|
259
|
+
|
|
260
|
+
final_response = await orchestrator.run(
|
|
261
|
+
user_request=enriched_request,
|
|
262
|
+
history=hist_text,
|
|
263
|
+
session=project,
|
|
264
|
+
store=store,
|
|
265
|
+
max_retries=max_retries,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
T.section(_("phase5"))
|
|
269
|
+
|
|
270
|
+
store.append_message(project, "assistant", final_response)
|
|
271
|
+
T.show_result(final_response)
|
|
272
|
+
project.clear_state()
|
|
273
|
+
store.save(project)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ─── CLI entrypoint ───────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
279
|
+
parser = argparse.ArgumentParser(
|
|
280
|
+
prog="opalacoder",
|
|
281
|
+
description="OpalaCoder – project-centric coding agent",
|
|
282
|
+
)
|
|
283
|
+
parser.add_argument("--version", action="version", version=f"OpalaCoder {__version__}")
|
|
284
|
+
parser.add_argument("--mode", choices=["auto", "plan", "edit"], default=DEFAULT_MODE)
|
|
285
|
+
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"LLM model (default: {DEFAULT_MODEL})")
|
|
286
|
+
parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES)
|
|
287
|
+
parser.add_argument("--db", default=DEFAULT_DB_PATH)
|
|
288
|
+
parser.add_argument("--lang", choices=["en", "pt"], default=DEFAULT_LANG)
|
|
289
|
+
parser.add_argument("--delete", metavar="PROJECT_NAME", help="Delete a project and exit")
|
|
290
|
+
parser.add_argument("--list-projects", action="store_true", help="List all projects and exit")
|
|
291
|
+
parser.add_argument("--debug", action="store_true")
|
|
292
|
+
return parser
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main() -> None:
|
|
296
|
+
parser = build_parser()
|
|
297
|
+
args = parser.parse_args()
|
|
298
|
+
|
|
299
|
+
if args.debug:
|
|
300
|
+
from opalacoder.config import setup_litellm_debug
|
|
301
|
+
setup_litellm_debug()
|
|
302
|
+
|
|
303
|
+
set_lang(args.lang)
|
|
304
|
+
T.print_banner(version=__version__, mode=args.mode)
|
|
305
|
+
|
|
306
|
+
store = ProjectStore(db_path=args.db)
|
|
307
|
+
|
|
308
|
+
if args.list_projects:
|
|
309
|
+
projects = store.list_projects()
|
|
310
|
+
if not projects:
|
|
311
|
+
T.info("No projects found.")
|
|
312
|
+
else:
|
|
313
|
+
T.section("Existing Projects")
|
|
314
|
+
for p in projects:
|
|
315
|
+
pname = p["project_name"] or p["name"]
|
|
316
|
+
T.console.print(
|
|
317
|
+
f" [cyan]{_escape(p['name'])}[/cyan] [bold]{_escape(pname)}[/bold] "
|
|
318
|
+
f"[dim]{_escape(p['project_path'])} {p['updated_at'][:10]}[/dim]"
|
|
319
|
+
)
|
|
320
|
+
sys.exit(0)
|
|
321
|
+
|
|
322
|
+
if args.delete:
|
|
323
|
+
if store.exists(args.delete):
|
|
324
|
+
store.delete(args.delete)
|
|
325
|
+
T.success(f"Project '{args.delete}' deleted.")
|
|
326
|
+
else:
|
|
327
|
+
T.error(f"Project '{args.delete}' not found.")
|
|
328
|
+
sys.exit(0)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
project = asyncio.run(startup_menu(store, args))
|
|
332
|
+
asyncio.run(repl_loop(project, store, max_retries=args.max_retries))
|
|
333
|
+
except KeyboardInterrupt:
|
|
334
|
+
T.warning(_("repl_interrupted"))
|
|
335
|
+
sys.exit(0)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
if __name__ == "__main__":
|
|
339
|
+
main()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""REPL command registry and handlers for OpalaCoder CLI."""
|
|
2
|
+
|
|
3
|
+
from .project import ProjectStore, ProjectData
|
|
4
|
+
from .agents import make_chat_memgpt_agent
|
|
5
|
+
from . import terminal as T
|
|
6
|
+
from .i18n import _
|
|
7
|
+
from rich.markup import escape as _escape
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ─── REPL state container ─────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
class REPLState:
|
|
13
|
+
def __init__(self, project: ProjectData, store: ProjectStore, project_skills: list, chat_agent):
|
|
14
|
+
self.project = project
|
|
15
|
+
self.store = store
|
|
16
|
+
self.project_skills = project_skills
|
|
17
|
+
self.chat_agent = chat_agent
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def display_name(self) -> str:
|
|
21
|
+
return self.project.project_name or self.project.name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ─── Command registry ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class CommandRegistry:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self._cmds: dict[str, tuple] = {}
|
|
29
|
+
|
|
30
|
+
def register(self, *names: str, usage: str = "", description: str = ""):
|
|
31
|
+
def decorator(fn):
|
|
32
|
+
for name in names:
|
|
33
|
+
self._cmds[name] = (fn, usage, description)
|
|
34
|
+
return fn
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
def __contains__(self, cmd: str) -> bool:
|
|
38
|
+
return cmd in self._cmds
|
|
39
|
+
|
|
40
|
+
def help_lines(self) -> list[tuple[str, str]]:
|
|
41
|
+
seen, result = set(), []
|
|
42
|
+
for name, (fn, usage, desc) in self._cmds.items():
|
|
43
|
+
if fn not in seen:
|
|
44
|
+
seen.add(fn)
|
|
45
|
+
result.append((f"{name} {usage}".strip(), desc))
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
async def dispatch(self, state: REPLState, cmd: str, args: list[str]) -> str | None:
|
|
49
|
+
fn, _, _ = self._cmds[cmd]
|
|
50
|
+
return await fn(state, args)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_registry = CommandRegistry()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─── Command handlers ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
@_registry.register("/help", "/h", description="Show this help message")
|
|
59
|
+
async def cmd_help(_state: REPLState, _args: list[str]) -> None:
|
|
60
|
+
T.console.print(f"\n[cyan]{_('available_commands')}[/cyan]")
|
|
61
|
+
for display, desc in _registry.help_lines():
|
|
62
|
+
T.console.print(f" [green]{display:<28}[/green] {desc}")
|
|
63
|
+
T.console.print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@_registry.register("/clear", description="Clear project memory and history")
|
|
67
|
+
async def cmd_clear(state: REPLState, _args: list[str]) -> None:
|
|
68
|
+
from .skills import load_project_skills
|
|
69
|
+
if T.confirm("Are you sure you want to clear this project's memory?"):
|
|
70
|
+
state.project = state.store.overwrite(
|
|
71
|
+
state.project.name, state.project.mode, state.project.model,
|
|
72
|
+
state.project.project_name, state.project.project_path,
|
|
73
|
+
state.project.skills, state.project.description,
|
|
74
|
+
)
|
|
75
|
+
state.project_skills = load_project_skills(state.project.project_path, state.project.skills)
|
|
76
|
+
state.chat_agent = make_chat_memgpt_agent(state.project.model)
|
|
77
|
+
T.success("Project memory cleared.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@_registry.register("/rename", usage="<new_name>", description="Rename the current project")
|
|
81
|
+
async def cmd_rename(state: REPLState, args: list[str]) -> str | None:
|
|
82
|
+
if not args:
|
|
83
|
+
T.error("Usage: /rename <new_name>")
|
|
84
|
+
return "continue"
|
|
85
|
+
new_name = args[0].strip('"\'')
|
|
86
|
+
if state.store.rename(state.project.name, new_name):
|
|
87
|
+
state.project.name = new_name
|
|
88
|
+
state.store.save(state.project)
|
|
89
|
+
T.success(f"Project renamed to '{new_name}'.")
|
|
90
|
+
else:
|
|
91
|
+
T.error(f"A project named '{new_name}' already exists.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@_registry.register("/list", description="List all projects")
|
|
95
|
+
async def cmd_list(state: REPLState, _args: list[str]) -> None:
|
|
96
|
+
projects = state.store.list_projects()
|
|
97
|
+
if not projects:
|
|
98
|
+
T.info("No projects found.")
|
|
99
|
+
else:
|
|
100
|
+
T.console.print(f"\n[dim]Existing projects:[/dim]")
|
|
101
|
+
for p in projects:
|
|
102
|
+
pname = p["project_name"] or p["name"]
|
|
103
|
+
T.console.print(
|
|
104
|
+
f" [cyan]{_escape(p['name'])}[/cyan] "
|
|
105
|
+
f"[bold]{_escape(pname)}[/bold] "
|
|
106
|
+
f"[dim]{_escape(p['project_path'])} {p['updated_at'][:10]} mode={p['mode']}[/dim]"
|
|
107
|
+
)
|
|
108
|
+
T.console.print()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@_registry.register("/load", usage="<name>", description="Load another project")
|
|
112
|
+
async def cmd_load(state: REPLState, args: list[str]) -> str | None:
|
|
113
|
+
from .tools import set_project_path
|
|
114
|
+
from .skills import load_project_skills
|
|
115
|
+
if not args:
|
|
116
|
+
T.error("Usage: /load <name>")
|
|
117
|
+
return "continue"
|
|
118
|
+
name = args[0].strip('"\'')
|
|
119
|
+
if not state.store.exists(name):
|
|
120
|
+
T.error(f"Project '{name}' not found.")
|
|
121
|
+
return "continue"
|
|
122
|
+
loaded = state.store.load(name)
|
|
123
|
+
if loaded:
|
|
124
|
+
state.project = loaded
|
|
125
|
+
set_project_path(state.project.project_path)
|
|
126
|
+
state.project_skills = load_project_skills(state.project.project_path, state.project.skills)
|
|
127
|
+
state.chat_agent = make_chat_memgpt_agent(state.project.model)
|
|
128
|
+
T.success(f"Project '{name}' loaded.")
|
|
129
|
+
T.console.print(f" [dim]Skills: {', '.join(state.project.skills)}[/dim]")
|
|
130
|
+
if state.project.request and state.project.plan_text and not state.project.results:
|
|
131
|
+
T.warning(_("pending_demand", request=state.project.request[:50]))
|
|
132
|
+
else:
|
|
133
|
+
T.error(f"Project '{name}' not found.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@_registry.register("/delete", usage="<name>", description="Delete a project")
|
|
137
|
+
async def cmd_delete(state: REPLState, args: list[str]) -> str | None:
|
|
138
|
+
if not args:
|
|
139
|
+
T.error("Usage: /delete <name>")
|
|
140
|
+
return "continue"
|
|
141
|
+
name = args[0].strip('"\'')
|
|
142
|
+
if not state.store.exists(name):
|
|
143
|
+
T.error(f"Project '{name}' not found.")
|
|
144
|
+
return "continue"
|
|
145
|
+
|
|
146
|
+
project_to_delete = state.store.load(name)
|
|
147
|
+
state.store.delete(name)
|
|
148
|
+
|
|
149
|
+
import os
|
|
150
|
+
import shutil
|
|
151
|
+
if project_to_delete and project_to_delete.project_path and os.path.exists(project_to_delete.project_path):
|
|
152
|
+
if T.confirm(_("delete_dir_confirm", path=project_to_delete.project_path), default=False):
|
|
153
|
+
try:
|
|
154
|
+
shutil.rmtree(project_to_delete.project_path)
|
|
155
|
+
T.success(_("dir_deleted", path=project_to_delete.project_path))
|
|
156
|
+
except Exception as e:
|
|
157
|
+
T.error(_("dir_delete_failed", err=str(e)))
|
|
158
|
+
else:
|
|
159
|
+
opalacoder_dir = os.path.join(project_to_delete.project_path, ".opalacoder")
|
|
160
|
+
if os.path.exists(opalacoder_dir):
|
|
161
|
+
try:
|
|
162
|
+
shutil.rmtree(opalacoder_dir)
|
|
163
|
+
T.success(_("vcs_deleted"))
|
|
164
|
+
except Exception as e:
|
|
165
|
+
T.error(_("vcs_delete_failed", err=str(e)))
|
|
166
|
+
|
|
167
|
+
T.success(f"Project '{name}' deleted.")
|
|
168
|
+
if state.project.name == name:
|
|
169
|
+
T.info("Current project was deleted. Please restart OpalaCoder.")
|
|
170
|
+
return "break"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@_registry.register("/lsskills", description="List active skills for this project")
|
|
174
|
+
async def cmd_lsskills(state: REPLState, _args: list[str]) -> None:
|
|
175
|
+
T.console.print(f"\n[dim]Active skills for this project:[/dim]")
|
|
176
|
+
for s in state.project_skills:
|
|
177
|
+
T.console.print(f" [cyan]{s['name']}[/cyan] [dim]{s['description']}[/dim]")
|
|
178
|
+
T.console.print()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@_registry.register("/skills", description="List all available skills (active marked with *)")
|
|
182
|
+
async def cmd_skills(state: REPLState, _args: list[str]) -> None:
|
|
183
|
+
from .skills import _skill_search_dirs, _parse_skill_file
|
|
184
|
+
import os as _os
|
|
185
|
+
search_dirs = _skill_search_dirs(state.project.project_path)
|
|
186
|
+
found_any = False
|
|
187
|
+
for s_dir in search_dirs:
|
|
188
|
+
if not _os.path.isdir(s_dir):
|
|
189
|
+
continue
|
|
190
|
+
files = sorted(f for f in _os.listdir(s_dir) if f.endswith(".md"))
|
|
191
|
+
if not files:
|
|
192
|
+
continue
|
|
193
|
+
T.console.print(f"\n[dim]Skills in [bold]{_escape(s_dir)}[/bold]:[/dim]")
|
|
194
|
+
for filename in files:
|
|
195
|
+
skill = _parse_skill_file(_os.path.join(s_dir, filename))
|
|
196
|
+
if skill:
|
|
197
|
+
active = "[green]*[/green] " if skill["name"] in state.project.skills else " "
|
|
198
|
+
T.console.print(f" {active}[cyan]{skill['name']}[/cyan] [dim]{skill['description']}[/dim]")
|
|
199
|
+
found_any = True
|
|
200
|
+
if not found_any:
|
|
201
|
+
T.info("No skill files found.")
|
|
202
|
+
T.console.print(f"\n[dim]([green]*[/green] = active in this project)[/dim]\n")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@_registry.register("/addskill", usage="<name>", description="Add a skill to this project")
|
|
206
|
+
async def cmd_addskill(state: REPLState, args: list[str]) -> str | None:
|
|
207
|
+
from .skills import find_skill_file, load_project_skills
|
|
208
|
+
if not args:
|
|
209
|
+
T.error("Usage: /addskill <skill_name>")
|
|
210
|
+
return "continue"
|
|
211
|
+
skill_name = args[0].strip().lower()
|
|
212
|
+
if skill_name in state.project.skills:
|
|
213
|
+
T.info(f"Skill '{skill_name}' is already active.")
|
|
214
|
+
return "continue"
|
|
215
|
+
found = find_skill_file(skill_name, state.project.project_path)
|
|
216
|
+
if not found:
|
|
217
|
+
T.error(f"Skill '{skill_name}.md' not found in any skills directory.")
|
|
218
|
+
else:
|
|
219
|
+
state.project.skills.append(skill_name)
|
|
220
|
+
state.store.save(state.project)
|
|
221
|
+
state.project_skills = load_project_skills(state.project.project_path, state.project.skills)
|
|
222
|
+
T.success(f"Skill '{skill_name}' added to project.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@_registry.register("/rmskill", usage="<name>", description="Remove a skill from this project")
|
|
226
|
+
async def cmd_rmskill(state: REPLState, args: list[str]) -> str | None:
|
|
227
|
+
from .skills import load_project_skills
|
|
228
|
+
if not args:
|
|
229
|
+
T.error("Usage: /rmskill <skill_name>")
|
|
230
|
+
return "continue"
|
|
231
|
+
skill_name = args[0].strip().lower()
|
|
232
|
+
if skill_name == "opalacoder":
|
|
233
|
+
T.error("Skill 'opalacoder' is required and cannot be removed.")
|
|
234
|
+
return "continue"
|
|
235
|
+
if skill_name not in state.project.skills:
|
|
236
|
+
T.info(f"Skill '{skill_name}' is not active in this project.")
|
|
237
|
+
return "continue"
|
|
238
|
+
state.project.skills.remove(skill_name)
|
|
239
|
+
state.store.save(state.project)
|
|
240
|
+
state.project_skills = load_project_skills(state.project.project_path, state.project.skills)
|
|
241
|
+
T.success(f"Skill '{skill_name}' removed from project.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@_registry.register("/undo", description=_("undo_desc"))
|
|
245
|
+
async def cmd_undo(state: REPLState, _args: list[str]) -> str | None:
|
|
246
|
+
from .vcs import get_vcs_strategy
|
|
247
|
+
from .config import get_git_strategy
|
|
248
|
+
vcs = get_vcs_strategy(get_git_strategy(), state.project.project_path)
|
|
249
|
+
success, msg = vcs.undo_last()
|
|
250
|
+
if success:
|
|
251
|
+
T.success(_("undo_success"))
|
|
252
|
+
else:
|
|
253
|
+
T.error(_("undo_fail") + f" ({msg})")
|
|
254
|
+
return "continue"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@_registry.register("/commit", usage="<message>", description=_("commit_desc"))
|
|
258
|
+
async def cmd_commit(state: REPLState, args: list[str]) -> str | None:
|
|
259
|
+
if not args:
|
|
260
|
+
T.error("Usage: /commit <message>")
|
|
261
|
+
return "continue"
|
|
262
|
+
message = " ".join(args).strip('"\'')
|
|
263
|
+
from .vcs import get_vcs_strategy
|
|
264
|
+
from .config import get_git_strategy
|
|
265
|
+
vcs = get_vcs_strategy(get_git_strategy(), state.project.project_path)
|
|
266
|
+
success, msg = vcs.manual_commit(message)
|
|
267
|
+
if success:
|
|
268
|
+
T.success(_("commit_success"))
|
|
269
|
+
else:
|
|
270
|
+
T.error(_("commit_fail", err=msg))
|
|
271
|
+
return "continue"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@_registry.register("/exit", "/quit", description=_("exit_desc"))
|
|
275
|
+
async def cmd_exit(_state: REPLState, _args: list[str]) -> str:
|
|
276
|
+
T.info(_("exiting"))
|
|
277
|
+
return "break"
|