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:
2026-09-22 22:26:50 +08:00
parent ec228e9219
commit bbda658eab
4 changed files with 121 additions and 0 deletions
+2
View File
@@ -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
+32
View File
@@ -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.
+18
View File
@@ -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()]
+69
View File
@@ -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