mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
cli/skills: expose skills to the session
ReplState builds the store from [skills] against the current workspace and publishes its roots as static read-only roots, so read_file/grep_files see a skill directory while writing stays denied; the roots are rebuilt when the workspace changes, since the project-scope directories hang off it. Discovered skills are folded into the system prompt the way MCP instructions are, with the catalog cap and its 'use skill_search' hint, and only when tools are on and injection is not switched off; [skills].enabled = false drops the tools from the registry entirely. The write policy is installed with the CLI confirm: once, session, or no, timed with a default of no and independent of YOLO, because a skill directory is not a grant. A library host that installs no hook keeps the deny default. /skills lists, searches, reads, and reloads; /status counts what was found.
This commit is contained in:
@@ -387,6 +387,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
if isinstance(state_obj, ReplState):
|
||||
state_obj.instance_state.workspace.policy_confirm_hook = None
|
||||
state_obj.instance_state.workspace.policy_allowed_commands.clear()
|
||||
from plyngent.tools.skills import set_skill_write_policy
|
||||
|
||||
set_skill_write_policy(None, instance=state_obj.instance_state)
|
||||
from plyngent.tools.net import (
|
||||
clear_private_grants,
|
||||
clear_ssrf_assume_public_cidrs,
|
||||
|
||||
@@ -21,6 +21,7 @@ from plyngent.prompting import (
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.skills import SkillWriteDecision
|
||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -347,6 +348,54 @@ def prompt_policy_fetch_confirm(
|
||||
return False
|
||||
|
||||
|
||||
def prompt_skill_write_confirm(
|
||||
skill_dir: Path,
|
||||
action: str,
|
||||
detail: str,
|
||||
timeout_seconds: float,
|
||||
) -> SkillWriteDecision:
|
||||
"""Ask whether to write into *skill_dir* (timed; default deny).
|
||||
|
||||
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
|
||||
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
|
||||
denies. Independent of YOLO: a skill directory is not a path grant, so the
|
||||
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
|
||||
"""
|
||||
lines = [
|
||||
f"action: {action} skill {skill_dir.name!r}",
|
||||
f"path: {skill_dir}",
|
||||
f"change: {detail}",
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
||||
]
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return SkillWriteDecision(allow=False)
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
|
||||
backend.echo()
|
||||
prompt = (
|
||||
f"[skills] allow {action} in {skill_dir.name!r}? [o]nce/[s]ession/[n]o (timeout {timeout_seconds:g}s): "
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
token = raw.strip().lower()
|
||||
if token in {"s", "session"}:
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
if token in {"o", "once", "y", "yes"}:
|
||||
return SkillWriteDecision(allow=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return SkillWriteDecision(allow=False)
|
||||
|
||||
|
||||
def prompt_directory_access_confirm(
|
||||
path: Path,
|
||||
mode: AccessMode,
|
||||
|
||||
@@ -590,6 +590,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"rounds={last_rounds}\n"
|
||||
f"usage_session={session_u.format_line(billed=True)}\n"
|
||||
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
|
||||
f"skills={len(state.skills.skills) if state.skills is not None else 'off'} "
|
||||
f"workspace={state.workspace}"
|
||||
)
|
||||
|
||||
@@ -1250,6 +1251,54 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||
_print_mcp_status(state)
|
||||
|
||||
|
||||
@slash.command("skills")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
|
||||
@click.argument("target", required=False)
|
||||
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
|
||||
@click.pass_obj
|
||||
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
|
||||
"""List skills, search or read them, or rescan the roots.
|
||||
|
||||
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
||||
``/skills search PATTERN`` — regex over every skill's contents.
|
||||
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
|
||||
``/skills reload`` — rescan the roots after an external edit.
|
||||
"""
|
||||
from plyngent.tools.context import bind_tool_context
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
|
||||
|
||||
if state.skills is None:
|
||||
click.echo("skills: disabled (set [skills].enabled = true)")
|
||||
return
|
||||
act = (action or "list").lower()
|
||||
if act == "reload":
|
||||
state.reload_skills()
|
||||
_slash_rebuild_tools_if_on(state)
|
||||
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
|
||||
return
|
||||
# The tool handlers read the bound instance (the registry binds it for a
|
||||
# model call; a slash command has to do it itself).
|
||||
with bind_tool_context(instance=state.instance_state, session=state.session_state):
|
||||
if act == "search":
|
||||
if not target:
|
||||
msg = "/skills search requires a pattern"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_search.handler(target)))
|
||||
return
|
||||
if act == "read":
|
||||
if not target:
|
||||
msg = "/skills read requires a skill name"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
|
||||
return
|
||||
click.echo(format_skill_table(state.skills))
|
||||
if not state.tools_enabled:
|
||||
click.secho(
|
||||
f"note: tools are off, so {', '.join(sorted(SKILL_TOOL_NAMES))} are not offered to the model",
|
||||
fg="bright_black",
|
||||
)
|
||||
|
||||
|
||||
@slash.command("grants")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
||||
@click.argument("target", required=False)
|
||||
|
||||
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
from plyngent.runtime.mcp_client import McpManager
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
@@ -88,6 +89,8 @@ class ReplState:
|
||||
session_state: SessionState = field(default_factory=SessionState)
|
||||
# MCP connections started by the host app before this state was built.
|
||||
mcp_manager: McpManager | None = None
|
||||
# Discovered skills (None when ``[skills].enabled`` is false).
|
||||
skills: SkillStore | None = field(default=None, init=False, repr=False)
|
||||
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
||||
@@ -103,6 +106,7 @@ class ReplState:
|
||||
self.instance_state.workspace_root = self.workspace
|
||||
self.instance_state.workspace.root = self.workspace
|
||||
self._install_config_access()
|
||||
self._install_skills()
|
||||
from plyngent.cli.limits import set_auto_continue_default
|
||||
|
||||
set_auto_continue_default(enabled=self.auto_continue_limits)
|
||||
@@ -302,6 +306,71 @@ class ReplState:
|
||||
err=True,
|
||||
)
|
||||
|
||||
def _install_skills(self) -> None:
|
||||
"""Build the skill store and expose its roots read-only to path tools.
|
||||
|
||||
Roots follow ``[skills]`` (explicit paths first, then our own user
|
||||
directory and other harnesses' directories, with the project-scope ones
|
||||
tied to the current workspace). Writing is *not* part of this: the roots
|
||||
are read-only, and skill writes go through the write policy installed
|
||||
below, so a skill directory never becomes a general write grant.
|
||||
"""
|
||||
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||
from plyngent.config.path import get_default_path
|
||||
from plyngent.skills import SkillStore, build_roots
|
||||
from plyngent.tools.skills import SkillWritePolicy, set_skill_write_policy
|
||||
from plyngent.tools.workspace import set_static_read_roots
|
||||
|
||||
cfg = self.config.skills_config
|
||||
set_skill_write_policy(
|
||||
SkillWritePolicy(allow_all=cfg.allow_write, confirm=prompt_skill_write_confirm),
|
||||
instance=self.instance_state,
|
||||
)
|
||||
if not cfg.enabled:
|
||||
self.skills = None
|
||||
self.instance_state.skills = None
|
||||
_ = set_static_read_roots([], instance=self.instance_state)
|
||||
return
|
||||
roots = build_roots(
|
||||
cfg,
|
||||
workspace=self.workspace,
|
||||
user_config_dir=get_default_path().parent,
|
||||
home=Path.home(),
|
||||
)
|
||||
self.skills = SkillStore(roots)
|
||||
self.instance_state.skills = self.skills
|
||||
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
||||
|
||||
def reload_skills(self) -> None:
|
||||
"""Rescan the roots (after an external edit or a config change)."""
|
||||
if self.skills is not None:
|
||||
self.skills.reload()
|
||||
|
||||
def _skills_text(self) -> str:
|
||||
"""Fold the discovered skills into the system prompt (like MCP instructions).
|
||||
|
||||
Only names, sources, and descriptions: the bodies stay on disk so a turn
|
||||
pays for the skill it actually reads. Notes and shadowed copies are left
|
||||
to ``skill_list``, which the model can call when something looks off.
|
||||
"""
|
||||
cfg = self.config.skills_config
|
||||
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
|
||||
return ""
|
||||
skills = self.skills.skills
|
||||
if not skills:
|
||||
return ""
|
||||
limit = max(0, cfg.max_catalog_skills)
|
||||
shown = skills[:limit]
|
||||
lines = [
|
||||
"### Skills",
|
||||
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
|
||||
]
|
||||
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
|
||||
if len(skills) > len(shown):
|
||||
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
|
||||
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
@@ -309,6 +378,7 @@ class ReplState:
|
||||
from plyngent.tools.catalog import register_builtin_tools
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.plugins import load_plugin_tools
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES
|
||||
|
||||
plugins_cfg = self.config.plugins_config
|
||||
catalog = register_builtin_tools()
|
||||
@@ -323,7 +393,9 @@ class ReplState:
|
||||
# Connections were started by the host; registration is sync-only.
|
||||
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
||||
tools = catalog.select(surface="local")
|
||||
# ``[skills].enabled = false`` drops the skill tools from the surface.
|
||||
excluded = None if self.config.skills_config.enabled else set(SKILL_TOOL_NAMES)
|
||||
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||
yolo = self.effective_yolo() != "off"
|
||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||
return ToolRegistry(
|
||||
@@ -357,6 +429,7 @@ class ReplState:
|
||||
system_prompt = compose_agent_system_content(
|
||||
agent_cfg.system_prompt,
|
||||
agent_cfg.tool_directives,
|
||||
self._skills_text(),
|
||||
mcp_text,
|
||||
)
|
||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||
@@ -539,6 +612,8 @@ class ReplState:
|
||||
|
||||
self.config.reload()
|
||||
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
||||
# Roots and their switches came from the file that was just re-read.
|
||||
self._install_skills()
|
||||
|
||||
selectable = self.config.selectable_providers()
|
||||
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
||||
@@ -576,6 +651,8 @@ class ReplState:
|
||||
self.workspace = resolved
|
||||
self.instance_state.workspace_root = resolved
|
||||
self.instance_state.workspace.root = resolved
|
||||
# Project-scope skill roots follow the workspace.
|
||||
self._install_skills()
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user