mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
skills/config: add the [skills] section
Skills are discovered from user-level directories by default: an explicit ``paths`` list wins, then the built-in sources (our own user config dir, and other harnesses' skill dirs) with the project-scope dirs behind their own switch. A name found twice shadows the lower-priority copy without hiding it. ``inject_catalog`` / ``max_catalog_skills`` bound what reaches the system prompt, ``enabled = false`` registers nothing, and ``allow_write`` is the standing grant for editing skills (default: ask every time). A malformed section degrades to the defaults, like [agent] and [mcp].
This commit is contained in:
@@ -21,6 +21,8 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
||||
from .models import OpenAIProvider as OpenAIProvider
|
||||
from .models import PluginsConfig as PluginsConfig
|
||||
from .models import ProviderConfig as ProviderConfig
|
||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||
from .models import SkillsConfig as SkillsConfig
|
||||
from .models import compose_agent_system_content as compose_agent_system_content
|
||||
from .path import get_default_path as _get_default_path
|
||||
from .routing import EffectiveProvider as EffectiveProvider
|
||||
|
||||
@@ -13,6 +13,10 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||
type AllowPathMode = Literal["read", "write", "exec"]
|
||||
|
||||
# Built-in skill discovery sources (``[skills].discover``): our own user config
|
||||
# directory, and the skill directories other harnesses install into.
|
||||
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
||||
|
||||
# Built-in persona when ``[agent].system_prompt`` is omitted.
|
||||
# Set ``system_prompt = ""`` to omit the persona block only.
|
||||
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
|
||||
@@ -206,6 +210,34 @@ class NetworkingConfig(Struct, omit_defaults=True):
|
||||
ssrf_assume_public_cidrs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillsConfig(Struct, omit_defaults=True):
|
||||
"""Skill directories the agent may discover, read, and (with a grant) edit.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md`` (YAML frontmatter with at least
|
||||
``name`` / ``description``, then a Markdown body) plus optional bundled files
|
||||
(scripts, references, templates). Roots are scanned in this order:
|
||||
|
||||
1. ``paths`` — explicit roots, highest priority
|
||||
2. ``discover`` sources in order: ``plyngent`` = our user config dir
|
||||
(``<user config>/skills``), ``claude`` = Claude Code's skill dirs
|
||||
(``~/.claude/skills``; plus ``<workspace>/.claude/skills`` when
|
||||
``include_project_roots``)
|
||||
|
||||
A skill found in several roots shadows the lower-priority copies: they stay
|
||||
listed, never hidden. ``enabled = false`` registers no skill tools at all and
|
||||
injects no catalog. Writing needs an explicit grant (a confirm per call, or
|
||||
``allow_write`` for a standing one); reads need none.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
paths: list[str] = field(default_factory=list)
|
||||
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
|
||||
include_project_roots: bool = True
|
||||
inject_catalog: bool = True
|
||||
max_catalog_skills: int = 50
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
class ModelConfig(Struct, omit_defaults=True):
|
||||
"""Capability flags and optional routing overrides for a model.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from .models import (
|
||||
NetworkingConfig,
|
||||
PluginsConfig,
|
||||
Provider,
|
||||
SkillsConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -57,6 +58,14 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
|
||||
return NetworkingConfig()
|
||||
|
||||
|
||||
def _parse_skills(raw: dict[str, object]) -> SkillsConfig:
|
||||
"""Parse the ``[skills]`` section, falling back to defaults."""
|
||||
try:
|
||||
return msgspec.convert(raw, SkillsConfig)
|
||||
except msgspec.ValidationError:
|
||||
return SkillsConfig()
|
||||
|
||||
|
||||
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||
|
||||
@@ -150,6 +159,7 @@ class ConfigStore:
|
||||
_plugins: PluginsConfig
|
||||
_networking: NetworkingConfig
|
||||
_mcp: McpConfig
|
||||
_skills: SkillsConfig
|
||||
_providers: dict[str, Provider]
|
||||
_bad_providers: dict[str, object]
|
||||
_recoverable_providers: dict[str, Provider]
|
||||
@@ -163,6 +173,7 @@ class ConfigStore:
|
||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||
|
||||
@property
|
||||
@@ -220,6 +231,13 @@ class ConfigStore:
|
||||
"""Typed MCP section (server definitions + disable list)."""
|
||||
return self._mcp
|
||||
|
||||
# -- skills (read-only) --
|
||||
|
||||
@property
|
||||
def skills_config(self) -> SkillsConfig:
|
||||
"""Typed skills section (roots, discovery sources, catalog limits)."""
|
||||
return self._skills
|
||||
|
||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||
cleaned = [name.strip() for name in names if name.strip()]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_skills_section_defaults(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("", encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.skills_config.enabled is True
|
||||
assert store.skills_config.paths == []
|
||||
assert store.skills_config.discover == ["plyngent", "claude"]
|
||||
assert store.skills_config.include_project_roots is True
|
||||
assert store.skills_config.inject_catalog is True
|
||||
assert store.skills_config.max_catalog_skills == 50
|
||||
assert store.skills_config.allow_write is False
|
||||
|
||||
|
||||
def test_skills_section_parse(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = false
|
||||
paths = ["~/team-skills", "/srv/skills"]
|
||||
discover = ["plyngent"]
|
||||
include_project_roots = false
|
||||
inject_catalog = false
|
||||
max_catalog_skills = 5
|
||||
allow_write = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load(path).skills_config
|
||||
assert cfg.enabled is False
|
||||
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
||||
assert cfg.discover == ["plyngent"]
|
||||
assert cfg.include_project_roots is False
|
||||
assert cfg.inject_catalog is False
|
||||
assert cfg.max_catalog_skills == 5
|
||||
assert cfg.allow_write is True
|
||||
|
||||
|
||||
def test_skills_section_invalid_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown discovery source or a bad type degrades to the defaults."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
discover = ["codex"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path).skills_config.discover == ["plyngent", "claude"]
|
||||
|
||||
path2 = tmp_path / "d.toml"
|
||||
_ = path2.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = "yes"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path2).skills_config.enabled is True
|
||||
Reference in New Issue
Block a user