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.
167 lines
7.3 KiB
Python
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)
|
|
)
|