From bbda658eabae3cfb2c77102a2dac79049ef900be Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 22 Sep 2026 22:26:50 +0800 Subject: [PATCH] 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]. --- src/plyngent/config/__init__.py | 2 + src/plyngent/config/models.py | 32 +++++++++++ src/plyngent/config/store.py | 18 +++++++ tests/test_config/test_skills_section.py | 69 ++++++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 tests/test_config/test_skills_section.py diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index 3ed97f8..9df1dbd 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -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 diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index e98f037..4ca6dd5 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -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 + (``/skills``), ``claude`` = Claude Code's skill dirs + (``~/.claude/skills``; plus ``/.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. diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 26c6224..7217878 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -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()] diff --git a/tests/test_config/test_skills_section.py b/tests/test_config/test_skills_section.py new file mode 100644 index 0000000..83cebb0 --- /dev/null +++ b/tests/test_config/test_skills_section.py @@ -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