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/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"