Files
tai/tai_pkg/skills.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged.

Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals.

Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
2026-10-10 19:25:02 +02:00

167 lines
7.3 KiB
Python

"""SKILL.md discovery, install metadata, and creation prompts."""
import json
import os
from tai_pkg.consts import Consts
__all__ = ["Skills"]
class Skills:
"""Skills."""
@staticmethod
def parse_skill_front(lines):
front = {}
pos = 0
while pos < len(lines):
line = lines[pos]
if ":" in line and not line.startswith((" ", "\t")):
key, value = line.split(":", 1)
value = value.strip()
if value in (">", "|"):
joined = []
pos += 1
while pos < len(lines) and lines[pos].startswith((" ", "\t")):
joined.append(lines[pos].strip())
pos += 1
front[key.strip()] = (" " if value == ">" else "\n").join(joined)
continue
front[key.strip()] = value.strip("'\"")
pos += 1
return front
@staticmethod
def parse_skill_file(path):
with open(path, "r", encoding="utf-8") as handle:
raw = handle.read()
if not raw.startswith("---"):
return None
end = raw.find("\n---", 3)
if end < 0:
return None
front = Skills.parse_skill_front(raw[3:end].strip().splitlines())
name = front.get("name", "")
description = front.get("description", "")
if not name or not description or not Consts.SKILL_NAME_RE.match(name):
return None
return {"name": name, "description": description, "body": raw[end + 4:].lstrip("\n")}
@staticmethod
def skill_scope_dir(scope, home_dir, project_dir, name=""):
"""Shared by create_skill and install_skill so both land skills in the same place."""
base = os.path.join(project_dir, ".tai", "skills") if scope == "project" else os.path.join(home_dir, "skills")
return os.path.join(base, name) if name else base
@staticmethod
def default_skill_scope(project_dir):
for marker in (".git", ".tai") + Consts.EXTERNAL_SKILL_DIRNAMES:
if os.path.exists(os.path.join(project_dir, marker)):
return "project"
return "home"
@staticmethod
def skill_extra_files(root):
extras = []
for sub in ("scripts", "references", "assets"):
folder = os.path.join(root, sub)
if os.path.isdir(folder):
for entry in sorted(os.listdir(folder)):
extras.append(os.path.join(root, sub, entry))
return extras
@staticmethod
def skill_install_meta_path(scope_dir, entry):
return os.path.join(scope_dir, Consts.SKILL_INSTALL_META_DIRNAME, entry + ".json")
@staticmethod
def load_skill_install_meta(scope_dir, entry):
try:
with open(Skills.skill_install_meta_path(scope_dir, entry), "r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return None
@staticmethod
def save_skill_install_meta(scope_dir, entry, meta):
path = Skills.skill_install_meta_path(scope_dir, entry)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
json.dump(meta, handle)
@staticmethod
def is_external_skill_root(path):
return os.path.dirname(os.path.normpath(path)).endswith(Consts.EXTERNAL_SKILL_DIRNAMES)
@staticmethod
def skill_provenance_note(skill):
install = skill.get("install")
if install:
return "[installed %s, %s scope, from %s]" % (install.get("mode", "copy"), install.get("scope", "?"), install.get("source", "?"))
if not skill.get("managed", True):
return "[external, not managed by tai: %s]" % skill["root"]
return "[native]"
@staticmethod
def discover_skills(home_dir, project_dir):
found = {}
tai_roots = (os.path.join(home_dir, "skills"), os.path.join(project_dir, ".tai", "skills"))
external_roots = (os.path.join(os.path.expanduser("~"), ".claude", "skills"),) + tuple(os.path.join(project_dir, dirname) for dirname in Consts.EXTERNAL_SKILL_DIRNAMES)
# External roots scan first so tai's own dirs always win on a name collision (dict assignment below overwrites).
for base in external_roots + tai_roots:
if not os.path.isdir(base):
continue
managed = base in tai_roots
for entry in sorted(os.listdir(base)):
path = os.path.join(base, entry, "SKILL.md")
if not os.path.isfile(path):
continue
try:
skill = Skills.parse_skill_file(path)
except OSError:
continue
if skill is None:
continue
skill["root"] = os.path.join(base, entry)
skill["managed"] = managed
skill["install"] = Skills.load_skill_install_meta(base, entry) if managed else None
found[skill["name"]] = skill
return found
@staticmethod
def skill_catalog(skills):
lines = []
if skills:
lines += ["", "", "## Available skills"]
for name in sorted(skills):
lines.append("- %s: %s" % (name, skills[name]["description"][:300]))
wanted = sorted(name for name in Consts.SKILL_BLUEPRINTS if name not in (skills or {}))
if wanted:
lines += ["", "", "## Buildable skill blueprints (load_skill builds one on demand)"]
for name in wanted:
lines.append("- %s: %s" % (name, Consts.SKILL_BLUEPRINTS[name]["hint"]))
return "\n".join(lines)
lines.append("Load full instructions with load_skill before using one.")
return "\n".join(lines)
@staticmethod
def create_skill_prompt(name, brief, skill_dir):
return (
"Create a new agent skill named '%s' in %s.\n"
"Brief: %s\n\n"
"Follow this process exactly:\n"
"1. Call sysinfo first so the skill matches this machine (os, installed tools, root, container, venv).\n"
"2. Research the subject deeply with web_search, web_fetch, and shell. Keep researching until you literally "
"have enough verified information to write complete instructions. Verify factual claims against at least two "
"independent sources. Never invent commands, paths, versions, or API details.\n"
"3. Write SKILL.md in the skill directory with this exact frontmatter:\n"
"---\nname: %s\ndescription: <one sentence saying what the skill does and when to use it>\n---\n"
"followed by clear markdown instructions grounded in your research.\n"
"4. Add scripts/, references/, or assets/ files only when they carry real weight; keep every file focused.\n"
"5. Read back every file you wrote, fix mistakes, and confirm the frontmatter holds a name plus a description.\n"
"6. Reply with a short summary: what the skill covers, which sources verified it, and which files you wrote.\n"
"Do not stop early: an incomplete or unverified skill is a failure. Do not ask questions; decide and record assumptions in the skill body."
% (name, skill_dir, brief, name)
)