Skip to content

Commit 63c34fc

Browse files
AVADSA25Mikarina13claude
authored
Load the user skills folder and write approved skills there (#442)
* User skills folder: design note (approved skills to ~/.codec/skills, loaded) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Load the user skills folder and write approved skills there Every skill registry reads the built-in folder (SKILLS_DIR, the repo's skills/ on this Mac) and then the user folder (USER_SKILLS_DIR, ~/.codec/skills): a built-in name always wins, user files are never trusted by the manifest so every load runs the AST gate, and _ files and sub-folders are skipped. /api/skill/approve (review-and-approve, Settings > Learning) writes to the user folder (0700 folder, 0600 file, atomic) instead of the repo's built-in folder; the built-in-name refusal and the AST gate are unchanged. Learning's installed check and self-improve's name scan see user skills. The suite points CODEC_USER_SKILLS_DIR at a temp folder and no longer imports the owner's user skills. docs/USER-SKILLS-DIR-DESIGN.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: approved-skills folder known issue fixed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4b4a3c3 commit 63c34fc

23 files changed

Lines changed: 312 additions & 36 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ Required: `SKILL_NAME`, `SKILL_TRIGGERS`, `SKILL_DESCRIPTION`. Optional: `SKILL_
299299
### Adding a new skill
300300
Drop a `.py` file in `~/.codec/skills/` (user) or `skills/` (built-in). Restart `codec-dashboard` and the main `open-codec` process. Hot-reload is not currently supported.
301301

302+
**Two folders (2026-10-07, docs/USER-SKILLS-DIR-DESIGN.md).** Every registry reads the built-in folder (`codec_config.SKILLS_DIR`, the repo's `skills/` unless `config.json:skills_dir` says otherwise) and then the user folder (`codec_config.USER_SKILLS_DIR`: `config.json:user_skills_dir`, default `~/.codec/skills`). A built-in name always wins; a user file is never trusted by the manifest, so every load runs the AST gate; files starting with `_` and sub-folders are skipped. `/api/skill/approve` (review-and-approve, Settings > Learning) writes approved skills to the user folder (0700 folder, 0600 file), never the repo. The test suite points `CODEC_USER_SKILLS_DIR` at a temp folder (tests/conftest.py).
303+
302304
## 5. Memory contract
303305

304306
Single SQLite database at `~/.codec/memory.db`. Wrapper: `codec_memory.CodecMemory`.

‎codec_agents.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@
3939
# ── CONFIG ──
4040
CONFIG_PATH = os.path.expanduser("~/.codec/config.json")
4141
try:
42-
from codec_config import SKILLS_DIR
42+
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
4343
except ImportError:
4444
SKILLS_DIR = os.path.expanduser("~/.codec/skills")
45+
USER_SKILLS_DIR = None
4546
DB_PATH = os.path.expanduser("~/.codec/memory.db")
4647

4748
def _cfg():
@@ -408,7 +409,7 @@ def _shell_execute(cmd: str) -> str:
408409

409410
from codec_skill_registry import SkillRegistry
410411

411-
_agents_registry = SkillRegistry(SKILLS_DIR)
412+
_agents_registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
412413

413414

414415
def _make_lazy_fn(registry: "SkillRegistry", skill_name: str):

‎codec_autopilot.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
sys.path.insert(0, str(_REPO / "skills"))
4040

4141
from codec_skill_registry import SkillRegistry
42-
from codec_config import SKILLS_DIR
42+
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
4343
from codec_audit import audit
4444

4545
log = logging.getLogger("codec-autopilot")
@@ -116,7 +116,7 @@ def _day_matches(days_spec: str, weekday: int) -> bool:
116116
def _speak(text: str):
117117
"""Best-effort TTS via tts_say skill; falls back to macOS `say`."""
118118
try:
119-
registry = SkillRegistry(SKILLS_DIR)
119+
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
120120
registry.scan()
121121
mod = registry.load("tts_say")
122122
if mod and hasattr(mod, "run"):
@@ -225,7 +225,7 @@ def main():
225225
codec_lifecycle.install_handlers(
226226
lambda: log.info("CODEC Autopilot graceful shutdown"),
227227
name="codec-autopilot")
228-
registry = SkillRegistry(SKILLS_DIR)
228+
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
229229
registry.scan()
230230
while True:
231231
try:

‎codec_config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,10 @@ def get_telegram_bot_token() -> str:
361361
# Skills load from the repo directly — single source of truth
362362
_REPO_DIR = os.path.dirname(os.path.abspath(__file__))
363363
SKILLS_DIR = cfg.get("skills_dir", os.path.join(_REPO_DIR, "skills"))
364+
# The owner's skills (approved reviews, marketplace installs): read after the built-ins,
365+
# never trusted by the manifest. CODEC_USER_SKILLS_DIR lets the test suite point it away.
366+
USER_SKILLS_DIR = os.path.expanduser(os.environ.get("CODEC_USER_SKILLS_DIR")
367+
or cfg.get("user_skills_dir") or "~/.codec/skills")
364368
AUDIT_LOG = os.path.expanduser("~/.codec/audit.log")
365369

366370
# Features

‎codec_dashboard.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,7 +1032,7 @@ <h2 class="mcp-intro-title" id="connPermsH">macOS permissions</h2>
10321032
<div class="settings-section" id="learnProposalsSection">
10331033
<div class="settings-title">Proposed skills</div>
10341034
<div class="settings-card"><div class="push-body">
1035-
<p class="push-intro">Drafted by the self-improvement run when CODEC lacked a skill. Review shows the code; Approve and install writes it to your skills folder after the safety check, and CODEC loads it after the next restart.</p>
1035+
<p class="push-intro">Drafted by the self-improvement run when CODEC lacked a skill. Review shows the code; Approve and install writes it to your skills folder (~/.codec/skills) after the safety check, and CODEC loads it after the next restart.</p>
10361036
<div id="learnProposals" class="mem-facts"><div class="refreshing">Loading...</div></div>
10371037
<div class="push-msg" id="learnMsg" role="status" aria-live="polite"></div>
10381038
</div></div>
@@ -3002,7 +3002,7 @@ <h2 class="mcp-intro-title" id="connPermsH">macOS permissions</h2>
30023002
LRN.review = rid; LRN.name = p.name;
30033003
_learnDlg();
30043004
document.getElementById('learnTitle').textContent = 'Review ' + p.name;
3005-
document.getElementById('learnWhat').textContent = 'Approve and install writes ' + fname + ' to your skills folder after the safety check; CODEC loads it after the next restart.' +
3005+
document.getElementById('learnWhat').textContent = 'Approve and install writes ' + fname + ' to your skills folder (~/.codec/skills) after the safety check; CODEC loads it after the next restart.' +
30063006
(p.truncated ? ' The code is longer than shown here.' : '');
30073007
document.getElementById('learnCode').textContent = p.code;
30083008
document.getElementById('learnErr').textContent = '';

‎codec_dispatch.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
# Audit emits route through the unified log_event adapter (real, not no-op)
1212
# per docs/PHASE1-STEP1-DESIGN.md.
1313
from codec_audit import log_event
14-
from codec_config import SKILLS_DIR
14+
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
1515
from codec_hooks import HookVeto, run_with_hooks
1616
from codec_skill_registry import SkillRegistry
1717
from codec_skill_switches import is_off as _skill_off, off_message as _off_message
1818

1919
log = logging.getLogger('codec')
2020

2121
# Global registry instance shared across codec.py
22-
registry = SkillRegistry(SKILLS_DIR)
22+
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
2323

2424

2525
def load_skills():

‎codec_mcp.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _validate_mcp_input(tool_name: str, task: str, context: str = "") -> str | N
6161
if _REPO_DIR not in sys.path:
6262
sys.path.insert(0, _REPO_DIR)
6363

64-
from codec_config import MCP_DEFAULT_ALLOW, MCP_ALLOWED_TOOLS, MCP_BLOCKED_TOOLS, SKILLS_DIR
64+
from codec_config import MCP_DEFAULT_ALLOW, MCP_ALLOWED_TOOLS, MCP_BLOCKED_TOOLS, SKILLS_DIR, USER_SKILLS_DIR
6565
if SKILLS_DIR not in sys.path:
6666
sys.path.insert(0, SKILLS_DIR)
6767
from codec_skill_registry import SkillRegistry
@@ -78,7 +78,7 @@ def __iter__(self):
7878

7979

8080
# Global registry for MCP skill tools
81-
_mcp_registry = SkillRegistry(SKILLS_DIR)
81+
_mcp_registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
8282

8383

8484
def build_mcp(auth=None):

‎codec_self_improve.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
_REPO = Path(__file__).resolve().parent
8282
sys.path.insert(0, str(_REPO))
8383

84-
from codec_config import QWEN_BASE_URL, QWEN_MODEL, SKILLS_DIR, is_dangerous_skill_code
84+
from codec_config import QWEN_BASE_URL, QWEN_MODEL, SKILLS_DIR, USER_SKILLS_DIR, is_dangerous_skill_code
8585

8686
try:
8787
from codec_audit import log_event as _si_log_event
@@ -106,7 +106,8 @@ def _si_log_event(*a, **kw): # type: ignore[no-redef]
106106

107107
def _existing_skill_names() -> set[str]:
108108
out = set()
109-
for f in Path(SKILLS_DIR).glob("*.py"):
109+
# Built-ins and the owner's folder (approved skills): never propose what is already there.
110+
for f in [*Path(SKILLS_DIR).glob("*.py"), *Path(USER_SKILLS_DIR).glob("*.py")]:
110111
if f.name.startswith("_") or f.name == "codec.py":
111112
continue
112113
out.add(f.stem)

‎codec_skill_registry.py‎

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,15 @@ class SkillRegistry:
105105
mod = registry.load("weather") # first call imports; subsequent calls return cache
106106
"""
107107

108-
def __init__(self, skills_dir: str):
108+
def __init__(self, skills_dir: str, user_dir: Optional[str] = None):
109109
self.skills_dir = skills_dir
110+
# The owner's folder (~/.codec/skills): read after the built-ins, a built-in
111+
# name always wins, and its files are never trusted by the manifest (every
112+
# load runs the AST gate). None, or the built-in folder itself, means off.
113+
same = user_dir and os.path.realpath(user_dir) == os.path.realpath(skills_dir)
114+
self.user_dir = None if (not user_dir or same) else user_dir
115+
# names whose file is in user_dir (set at scan() time)
116+
self._user_names: Set[str] = set()
110117
# name -> metadata dict (always populated after scan)
111118
self._meta: Dict[str, Dict[str, Any]] = {}
112119
# name -> file path
@@ -129,9 +136,11 @@ def scan(self) -> int:
129136
Returns the number of skills discovered."""
130137
self._meta.clear()
131138
self._paths.clear()
139+
self._user_names.clear()
132140
# Do NOT clear _modules — keep any already-loaded modules cached
133141

134-
if not os.path.isdir(self.skills_dir):
142+
has_user = bool(self.user_dir) and os.path.isdir(self.user_dir)
143+
if not os.path.isdir(self.skills_dir) and not has_user:
135144
return 0
136145

137146
# Reload the trusted-skill manifest on every scan so manifest edits
@@ -140,21 +149,42 @@ def scan(self) -> int:
140149
# Reload user trigger overrides too (A-4) — same refresh-on-scan policy.
141150
self._custom_triggers = self._load_custom_triggers()
142151

143-
for fname in sorted(os.listdir(self.skills_dir)):
144-
if not fname.endswith(".py") or fname.startswith("_"):
145-
continue
146-
filepath = os.path.join(self.skills_dir, fname)
147-
meta = _extract_metadata(filepath)
148-
if meta is None:
149-
continue
150-
name = meta.get("SKILL_NAME", fname[:-3])
151-
self._meta[name] = meta
152-
self._paths[name] = filepath
152+
if os.path.isdir(self.skills_dir):
153+
for fname in sorted(os.listdir(self.skills_dir)):
154+
if not fname.endswith(".py") or fname.startswith("_"):
155+
continue
156+
filepath = os.path.join(self.skills_dir, fname)
157+
meta = _extract_metadata(filepath)
158+
if meta is None:
159+
continue
160+
name = meta.get("SKILL_NAME", fname[:-3])
161+
self._meta[name] = meta
162+
self._paths[name] = filepath
163+
164+
if has_user:
165+
for fname in sorted(os.listdir(self.user_dir)):
166+
if not fname.endswith(".py") or fname.startswith("_"):
167+
continue
168+
filepath = os.path.join(self.user_dir, fname)
169+
if not os.path.isfile(filepath):
170+
continue
171+
meta = _extract_metadata(filepath)
172+
if meta is None:
173+
continue
174+
name = meta.get("SKILL_NAME", fname[:-3])
175+
if name in self._meta and name not in self._user_names:
176+
log.warning("User skill %s skipped: the built-in skill %r has that name", fname, name)
177+
continue
178+
self._meta[name] = meta
179+
self._paths[name] = filepath
180+
self._user_names.add(name)
153181

154182
log.info(
155183
"Skill registry: %d skills discovered (metadata only), %d in trusted manifest",
156184
len(self._meta), len(self._trusted_hashes),
157185
)
186+
if self._user_names:
187+
log.info("Skill registry: %d of them from the user folder %s", len(self._user_names), self.user_dir)
158188
return len(self._meta)
159189

160190
def _load_trusted_manifest(self) -> Set[str]:
@@ -283,7 +313,9 @@ def load(self, name: str) -> Optional[Any]:
283313
return None
284314

285315
file_hash = hashlib.sha256(raw).hexdigest()
286-
trusted = file_hash in self._trusted_hashes
316+
# A file in the user folder is never trusted by the manifest, even if its
317+
# bytes equal a pinned built-in's: it always goes through the AST gate.
318+
trusted = file_hash in self._trusted_hashes and name not in self._user_names
287319

288320
if not trusted:
289321
# Decode for AST check; fail-safe on UnicodeDecodeError.

‎codec_slash_commands.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,11 @@ def _cmd_skills(args: list[str]) -> str:
162162
# Lazy import to avoid pulling skill registry at module load
163163
try:
164164
from codec_skill_registry import SkillRegistry
165-
from codec_config import SKILLS_DIR
165+
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
166166
except Exception as e:
167167
return f"Error: skill registry unavailable: {e}"
168168

169-
reg = SkillRegistry(SKILLS_DIR)
169+
reg = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
170170
reg.scan()
171171
# P3.6: the deny list the Skills page writes and every path enforces (codec_skill_switches).
172172
import codec_skill_switches as switches

0 commit comments

Comments
 (0)