Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ nuitka-crash-report.xml
*.backup
main.bin

# Bundled skills removed — skills are now fetched from remote catalog
code_puppy/bundled_skills/

2 changes: 0 additions & 2 deletions code_puppy/agents/base_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1891,8 +1891,6 @@ def collect_cancelled_exceptions(exc):
except Exception:
pass # Don't fail agent run if hook fails

# Import shell process status helper

loop = asyncio.get_running_loop()

def schedule_agent_cancel() -> None:
Expand Down
3 changes: 2 additions & 1 deletion code_puppy/api/routers/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Configuration management API endpoints."""

from typing import Any, Dict, List

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Any, Dict, List

router = APIRouter()

Expand Down
20 changes: 15 additions & 5 deletions code_puppy/command_line/core_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,20 +856,22 @@ def handle_wiggum_stop_command(command: str) -> bool:
@register_command(
name="skills",
description="Manage agent skills - browse, enable, disable skills",
usage="/skills [list|enable|disable]",
usage="/skills [list|install|enable|disable]",
category="core",
detailed_help="""Launch the skills TUI menu or manage skills with subcommands:
/skills - Launch interactive TUI menu
/skills list - Quick text list of all skills
/skills enable - Enable skills integration globally
/skills disable - Disable skills integration globally""",
/skills list - Quick text list of all skills
/skills install - Browse & install skills from remote catalog
/skills enable - Enable skills integration globally
/skills disable - Disable skills integration globally""",
)
def handle_skills_command(command: str) -> bool:
"""Handle the /skills command.

Subcommands:
/skills - Launch interactive TUI menu
/skills list - Quick text list of all skills (no TUI)
/skills install - Browse & install skills from remote catalog
/skills enable - Enable skills globally
/skills disable - Disable skills globally
"""
Expand Down Expand Up @@ -930,6 +932,14 @@ def handle_skills_command(command: str) -> bool:

return True

elif subcommand == "install":
from code_puppy.command_line.skills_install_menu import (
run_skills_install_menu,
)

run_skills_install_menu()
return True

elif subcommand == "enable":
set_skills_enabled(True)
emit_success("✅ Skills integration enabled globally")
Expand All @@ -942,7 +952,7 @@ def handle_skills_command(command: str) -> bool:

else:
emit_error(f"Unknown subcommand: {subcommand}")
emit_info("Usage: /skills [list|enable|disable]")
emit_info("Usage: /skills [list|install|enable|disable]")
return True

# No subcommand - launch TUI menu
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/command_line/prompt_toolkit_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
get_active_model,
)
from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter
from code_puppy.command_line.skills_completion import SkillsCompleter
from code_puppy.command_line.utils import list_directory
from code_puppy.config import (
COMMAND_HISTORY_FILE,
Expand Down Expand Up @@ -574,6 +575,7 @@ async def get_input_with_combined_completion(
AgentCompleter(trigger="/agent"),
AgentCompleter(trigger="/a"),
MCPCompleter(trigger="/mcp"),
SkillsCompleter(trigger="/skills"),
SlashCompleter(),
]
)
Expand Down
160 changes: 160 additions & 0 deletions code_puppy/command_line/skills_completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Prompt-toolkit completion for `/skills`.

Mirrors MCPCompleter but simpler:
- Completes subcommands for `/skills ...`
- For `/skills install ...`, completes skill ids from the remote catalog

This module is intentionally defensive: if the remote catalog isn't available,
completion simply returns no skill ids.
"""

from __future__ import annotations

import logging
import time
from typing import Iterable, List

from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document

logger = logging.getLogger(__name__)


def load_catalog_skill_ids() -> List[str]:
"""Load skill ids from the remote catalog (lazy, cached)."""

try:
from code_puppy.plugins.agent_skills.skill_catalog import catalog

return [entry.id for entry in catalog.get_all()]
except Exception as e:
logger.debug(f"Could not load skill ids: {e}")
return []


class SkillsCompleter(Completer):
"""Completer for /skills subcommands."""

def __init__(self, trigger: str = "/skills"):
"""Initialize the skills completer.

Args:
trigger: The slash command prefix to trigger completion.
"""

self.trigger = trigger
self.subcommands = {
"list": "List all installed skills",
"install": "Browse & install from catalog",
"enable": "Enable skills integration globally",
"disable": "Disable skills integration globally",
"toggle": "Toggle skills system on/off",
"refresh": "Refresh skill cache",
"help": "Show skills help",
}

self._skill_ids_cache: List[str] | None = None
self._cache_timestamp: float | None = None

def _get_skill_ids(self) -> List[str]:
"""Get skill ids with 30-second cache."""

current_time = time.time()
if (
self._skill_ids_cache is None
or self._cache_timestamp is None
or current_time - self._cache_timestamp > 30
):
self._skill_ids_cache = load_catalog_skill_ids()
self._cache_timestamp = current_time

return self._skill_ids_cache or []

def get_completions(
self, document: Document, complete_event
) -> Iterable[Completion]:
"""Yield completions for /skills subcommands and skill ids."""

text = document.text
cursor_position = document.cursor_position
text_before_cursor = text[:cursor_position]

# Only trigger if /skills is at the very beginning of the line
stripped_text = text_before_cursor.lstrip()
if not stripped_text.startswith(self.trigger):
return

# Find where /skills starts (after any leading whitespace)
skills_pos = text_before_cursor.find(self.trigger)
skills_end = skills_pos + len(self.trigger)

# Require a space after /skills before showing completions
if (
skills_end >= len(text_before_cursor)
or text_before_cursor[skills_end] != " "
):
return

# Everything after /skills (after the space)
after_skills = text_before_cursor[skills_end + 1 :].strip()

# If nothing after /skills, show all subcommands
if not after_skills:
for subcommand, description in sorted(self.subcommands.items()):
yield Completion(
subcommand,
start_position=0,
display=subcommand,
display_meta=description,
)
return

parts = after_skills.split()

# Special-case: /skills install <skill-id>
if len(parts) >= 1:
subcommand = parts[0].lower()

if subcommand == "install":
# Case 1: exactly `install ` -> show all ids
if len(parts) == 1 and text.endswith(" "):
for skill_id in sorted(self._get_skill_ids()):
yield Completion(
skill_id,
start_position=0,
display=skill_id,
display_meta="Skill",
)
return
Comment on lines +118 to +128
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

text.endswith(" ") uses full document text, not text before cursor.

On line 110, text is document.text which includes characters after the cursor. If the cursor is positioned in the middle of the line (e.g. editing an earlier part), text.endswith(" ") may produce incorrect results. Use text_before_cursor instead, which was already computed on line 70.

🔧 Proposed fix
-                if len(parts) == 1 and text.endswith(" "):
+                if len(parts) == 1 and text_before_cursor.endswith(" "):

The same issue applies to line 137:

-        if len(parts) == 1 and not text.endswith(" "):
+        if len(parts) == 1 and not text_before_cursor.endswith(" "):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if subcommand == "install":
# Case 1: exactly `install ` -> show all ids
if len(parts) == 1 and text.endswith(" "):
for skill_id in sorted(self._get_skill_ids()):
yield Completion(
skill_id,
start_position=0,
display=skill_id,
display_meta="Skill",
)
return
if subcommand == "install":
# Case 1: exactly `install ` -> show all ids
if len(parts) == 1 and text_before_cursor.endswith(" "):
for skill_id in sorted(self._get_skill_ids()):
yield Completion(
skill_id,
start_position=0,
display=skill_id,
display_meta="Skill",
)
return
🤖 Prompt for AI Agents
In `@code_puppy/command_line/skills_completion.py` around lines 108 - 118, The
check uses document.text (variable `text`) which includes characters after the
cursor; replace `text.endswith(" ")` with the already-computed
`text_before_cursor.endswith(" ")` in this completion generator (the block
handling `subcommand == "install"`, where `parts` is inspected and
`self._get_skill_ids()` is used) and make the identical change for the other
occurrence mentioned (the later branch around the second check near the
`Completion` yields) so the logic correctly inspects only text before the
cursor.


# Case 2: `install <partial>` -> filter ids
if len(parts) == 2 and cursor_position > (
skills_end + 1 + len(subcommand) + 1
):
partial = parts[1]
start_position = -len(partial)
for skill_id in sorted(self._get_skill_ids()):
if skill_id.lower().startswith(partial.lower()):
yield Completion(
skill_id,
start_position=start_position,
display=skill_id,
display_meta="Skill",
)
return

# If we only have one part and no trailing space, complete subcommands
if len(parts) == 1 and not text.endswith(" "):
partial = parts[0]
for subcommand, description in sorted(self.subcommands.items()):
if subcommand.startswith(partial):
yield Completion(
subcommand,
start_position=-(len(partial)),
display=subcommand,
display_meta=description,
)
return

# Otherwise, no further completion.
return
Loading
Loading