core/config: add the [trash] section

`[trash]` owns the one policy `delete_path` needs from the human: whether a
delete is a move to the desktop's trash (`enabled = true`, the default) or a
removal that cannot be undone (`false`, which is what the tool did before).

Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot
silently switch a destructive policy on. `reload()` re-reads the section.
This commit is contained in:
2026-10-21 20:19:54 +08:00
parent 7b7c90b8ee
commit c4a287c464
4 changed files with 85 additions and 0 deletions
+1
View File
@@ -25,6 +25,7 @@ from .models import ProviderConfig as ProviderConfig
from .models import ReasoningEffortConfig as ReasoningEffortConfig
from .models import SkillDiscoverySource as SkillDiscoverySource
from .models import SkillsConfig as SkillsConfig
from .models import TrashConfig as TrashConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .reasoning import ReasoningConfig as ReasoningConfig
+15
View File
@@ -304,6 +304,21 @@ class NotifyConfig(Struct, omit_defaults=True):
protocol: str = ""
class TrashConfig(Struct, omit_defaults=True):
"""Whether a delete lands in the system trash or is final.
``enabled`` (the default) sends every path ``delete_path`` removes to the
desktop's own trash — the freedesktop trash on Linux, the Finder trash on
macOS, the Recycle Bin on Windows — so a deletion can be undone there.
``false`` restores the old behaviour of removing the path outright.
Scratch paths under the OS temporary directory, and a call that passes
``permanent=true``, are deleted for good either way.
"""
enabled: bool = True
class ModelConfig(Struct, omit_defaults=True):
"""Capability flags and optional routing overrides for a model.
+27
View File
@@ -16,6 +16,7 @@ from .models import (
PluginsConfig,
Provider,
SkillsConfig,
TrashConfig,
)
if TYPE_CHECKING:
@@ -83,6 +84,22 @@ def _parse_notify(raw: dict[str, object]) -> NotifyConfig:
return config
def _parse_trash(raw: dict[str, object]) -> TrashConfig:
"""Parse the ``[trash]`` section, falling back to defaults.
Unknown keys are rejected like ``[mcp]`` does: a typo leaves deletes going
to the trash instead of half-applying a policy about destructive ones.
"""
try:
config = msgspec.convert(raw, TrashConfig)
except msgspec.ValidationError:
return TrashConfig()
known = set(TrashConfig.__struct_fields__)
if set(raw) - known:
return TrashConfig()
return config
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
"""Parse the ``[mcp]`` section, falling back to defaults.
@@ -178,6 +195,7 @@ class ConfigStore:
_mcp: McpConfig
_skills: SkillsConfig
_notify: NotifyConfig
_trash: TrashConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -193,6 +211,7 @@ class ConfigStore:
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -264,6 +283,13 @@ class ConfigStore:
"""Typed notifications section (grace, channels, actions)."""
return self._notify
# -- trash (read-only) --
@property
def trash_config(self) -> TrashConfig:
"""Typed trash section (whether a delete can be undone)."""
return self._trash
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()]
@@ -469,6 +495,7 @@ class ConfigStore:
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.config import load
if TYPE_CHECKING:
from pathlib import Path
def test_trash_section_defaults(tmp_path: Path) -> None:
"""Deletes go to the system trash unless the section says otherwise."""
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
assert load(path).trash_config.enabled is True
def test_trash_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
assert load(path).trash_config.enabled is False
def test_trash_section_typo_falls_back(tmp_path: Path) -> None:
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenable = false\n", encoding="utf-8")
assert load(path).trash_config.enabled is True
path2 = tmp_path / "d.toml"
_ = path2.write_text('[trash]\nenabled = "no"\n', encoding="utf-8")
assert load(path2).trash_config.enabled is True
def test_trash_section_survives_reload(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
store = load(path)
assert store.trash_config.enabled is False
_ = path.write_text("[trash]\nenabled = true\n", encoding="utf-8")
store.reload()
assert store.trash_config.enabled is True